기록하기

동기와 비동기, Blocking 과 Non-Blocking 차이 정리 본문

Server/Spring Boot

동기와 비동기, Blocking 과 Non-Blocking 차이 정리

jjungdev 2023. 8. 1. 22:58

Spring Webflux 를 공부하면서 Reactive Programming 에 대해 학습을 진행하였다.

그 중에서 가장 먼저, 항상 헷갈리는 개념인 동기 vs 비동기, Blocking vs Non-Blocking 에 대해 내용을 정리해보고자 한다.

 

내용 정리에 앞서 결론적으로 먼저 정리를 해보자면,

더보기
  • Caller : 호출하는 함수
  • Callee : 호출 당하는 함수
  • 동기와 비동기의 차이는 Caller 가 Callee 의 결과에 관심을 가지는지 여부
  • Blocking 과 Non-Blocking 의 차이는 제어권을 Caller 와 Callee 중 누가 가지고 있는지

이렇게 정리할 수 있으며 위 기준에 따라 구분할 수 있다.

이제 이 내용을 바탕으로 함수 호출 관점에서, I/O 관점에서 내용을 정리해보고자 한다.

 

함수 호출 관점에서 동기와 비동기

간단한 예제를 작성해보자

 

Caller 인 main 함수에서 Callee 즉 getResult 함수를 호출하는 구조인데, 내부적으로는 숫자 0에서 +5를 하여 5가 되면 종료되는 로직만 수행하며 해당 로직을 2가지 케이스로 구현해볼 수 있다.

 

TestA 클래스

@Slf4j
public class TestA {

    public static void main(String[] args) {
        log.info("함수 호출 관점 A start");
        var result = getResult();
        var nextValue = result + 5;
        assert nextValue == 5;
        log.info("함수 호출 관점 A finish");
    }

    public static int getResult() {
        log.info("getResult start");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        var result = 0;
        try {
            return result;
        } finally {
            log.info("getResult finish");
        }
    }
}

 

TestB 클래스

@Slf4j
public class TestB {

    public static void main(String[] args) {
        log.info("함수 호출 관점 B start");
        getResult(num -> {
            var nextValue = num + 5;
            assert nextValue == 5;
        });
        log.info("함수 호출 관점 B finish");
    }

    public static void getResult(Consumer<Integer> consumer) {
        log.info("getResult start");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        var result = 0;
        consumer.accept(result);
        log.info("getResult finish");
    }
}

 

이 2가지 클래스의 차이는 무엇일까?

 

먼저, TestA 클래스의 수행 순서는 다음과 같다.

  1. main -> getResult 호출 : Caller 가 Callee 호출
  2. getResult 가 return result
  3. main 에서 로직 수행

반면, TestB 의 경우에는 getResult 에 액션을 위임하게 되는데 Callee 가 함수형 인터페이스를 실행하는 것을 확인할 수 있다. 그래서 다음과 같은 수행 순서를 가지게 된다.

  1. main -> getResult 호출 : Caller 가 Callee 호출
  2. getResult 에서 함수형 인터페이스를(로직을) 실행
  3. main 에게 return result

 

즉, 굵게 작성한 부분이 다른데, TestA 클래스의 경우 main 이 getResult 결과를 기다렸다가 그 결과를 바탕으로 다음 코드를 실행하는 반면, TestB 클래스의 경우 main 이 실행하는 것이 아니라 getResult 에서 실행 후 결과를 반환하는 차이가 있다.

 

TestA 클래스가 동기 / TestB 클래스가 비동기인데 정리를 해보면 이런 차이가 있음을 알 수 있다.

동기 비동기
Caller 는 Callee 결과에 관심 유 Caller 는 Callee 결과에 관심 무
Callee 가 전해주는 결과에 따라 다음 action 수행 Callee 는 결과를 이용해서 callback 수행

 

함수 호출 관점에서 Blocking 과 Non-Blocking

그러면 TestA 와 TestB 의 공통점은 무엇일까?

여기서 이 두 클래스의 공통점은, Caller 가 Callee 가 하는 일이 완료될 때까지 대기한다는 점이다. 바로 제어권을 Callee 가 가지고 있다는 점인데 이런 점에서 이 두 클래스는 Blocking 상태이다.

 

그렇다면 TestC 클래스를 한 번 살펴보자

TestC 클래스

@Slf4j
public class TestC {

    public static void main(String[] args) {
        log.info("함수 호출 관점 C start");
        var count = 1;
        Future<Integer> result = getResult();
        while (!result.isDone()) {
            log.info("Caller 가 자신의 일을 한다. count : {}", count++);
        }
        log.info("함수 호출 관점 C finish");
    }

    public static Future<Integer> getResult() {
        var executorService = Executors.newSingleThreadExecutor();

        try {
            return executorService.submit(new Callable<Integer>() {
                @Override
                public Integer call() throws Exception {
                    log.info("getResult start");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        log.error("error : {}", e);
                    }

                    var result = 0;
                    try {
                        return result;
                    } finally {
                        log.info("getResult finish");
                    }
                }
            });
        } finally {
            executorService.shutdown();
        }
    }
}

 

이 코드를 실행해보면 다음과 같이 log 가 나온다.

더보기

[main] -- 함수 호출 관점 C start

[pool-1-thread-1] -- getResult start

[main] -- Caller 가 자신의 일을 한다. count : 1
[main] -- Caller 가 자신의 일을 한다. count : 2
[main] -- Caller 가 자신의 일을 한다. count : 3
[main] -- Caller 가 자신의 일을 한다. count : 4

...

[pool-1-thread-1] -- getResult finish
[main] -- 함수 호출 관점 C finish

즉, Callee 가 아직 일을 끝내지 않은 상태이지만 Caller 는 log 를 찍는 일을 계속 수행을 하고 있음을 알 수 있다.

앞서 살펴본 TestA, TestB 는 Caller 가 본인 일을 하지 못했지만 TestC 는 본인의 일을 할 수 있다는 점이 차이점이다. 여기서 TestC 가 Non-Blocking 상태이다.

 

이를 바탕으로 Blocking 과 Non-Blocking 의 차이를 정리해볼 수 있다.

Blocking Non-Blocking
Caller 는 Callee 가 완료될때까지 대기 Caller 는 Callee 를 기다리지 않고 본인의 일 수행
제어권은 Callee 에게 있다. 제어권은 Caller 에게 있다.
Caller, Callee 별도의 thread 가 필요하지 않다. Calleer, Callee 별도의 thread 가 필요하다.

 

하지만 TestC 는 지속적으로 getResult 의 결과를 확인할 필요가 있다는 점에서 동기 모델로 볼 수 있는데, 비동기 Non-Blocking 모델인 TestD 클래스까지 작성을 해보고, 동기 Blocking / 비동기 Blocking / 동기 Non-Blocking / 비동기 Non-Blocking 을 정리하려고 한다.

 

TestD 클래스

@Slf4j
public class TestD {

    public static void main(String[] args) {
        log.info("함수 호출 관점 D start");
        getResult(new Consumer<Integer>() {
            @Override
            public void accept(Integer integer) {
                var nextValue = integer + 5;
                assert nextValue == 5;
            }
        });
        log.info("함수 호출 관점 D finish");
    }

    public static void getResult(Consumer<Integer> callback) {
        var executorService = Executors.newSingleThreadExecutor();

        try {
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    log.info("getResult start");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        log.error("error : {}", e);
                    }

                    var result = 0;
                    try {
                        callback.accept(result);
                    } finally {
                        log.info("getResult finish");
                    }
                }
            });
        } finally {
            executorService.shutdown();
        }
    }
}

 

TestD 클래스를 실행해보면 다음과 같이 log 가 찍힌다.

더보기

[main] -- 함수 호출 관점 D start
[pool-1-thread-1] -- getResult start
[main] -- 함수 호출 관점 D finish
[pool-1-thread-1] -- getResult finish

이를 통해 Caller 인 main 은 getResult 의 결과와 상관없이 본인의 일, 즉 log 찍는 일을 수행하고 있음을 알 수 있다.

 

그래서 최종적으로 정리를 해보면, 다음과 같다.

  동기 비동기
Blocking - Caller 는 Callee 의 일에 관심이 있고(동기),
- Callee 에게 제어권이 있으며, Caller 는 아무것도 할 수 없다.
- Caller 는 Callee 의 일에 관심이 없고(비동기),
- Callee 에게 제어권이 있으며, Caller 는 아무것도 할 수 없다.  
- 결과는 Callee 가 처리한다.
Non-Blocking - Caller 는 Callee 의 일에 관심이 있고(동기),
- Caller 에게 제어권이 있으며, Caller 는 자기 할 일을 할 수 있다. 다만 지속적으로 Callee 일 확인이 필요하다.
- Caller 는 Callee 의 일에 관심이 없고(비동기),
- Caller 에게 제어권이 있으며, Caller 는 자기 할 일을 할 수 있다.
- 결과는 Callee 가 처리한다.

 

I/O 관점에서 Blocking 과 Non-Blocking

I/O 관점에서 Blocking, Non-Blocking 을 정리해볼 수 있는데, 먼저 blocking 은 다음과 같은 의미를 가진다.

  • CPU-bound blocking : 오랜 시간 일을 한다.
  • IO-bound blocking : 오랜 시간 대기를 한다.

여기서 IO-bound blocking 은 스레드가 대부분의 시간을 대기를 하면서 보내게 되는데 IO-bound non-blocking 시에는 Caller 가 다른 일을 할 수 있게 되는 것을 의미한다.

 

여기서 Blocking 의 경우에는 전파가 되는데, 만약 어떤 함수에서 Blocking 한 함수와 Non-Blocking 한 함수 2개를 호출한다면 바로 응답을 돌려주는 Non-Blocking 이 있어도 Blocking 함수가 있어서 시간이 오래 걸리게 된다. 즉, Caller 는 Blocking 이 된다.

이는 Caller 가 Non-Blocking 이 되려면 모든 함수가 Non-Blocking 이어야 한다는 것을 의미하며, IO-bound blocking 도 발생하면 안 되는 것을 의미한다.

 

위에서 정리해본 동기, 비동기, Blocking, Non-Blocking 개념 차이를 정리해본 것을 바탕으로 I/O 관점에서 정리를 해보면 다음과 같다.

 

TestA 동기 Blocking

- Caller 는 Callee 의 결과에 관심이 있다.

- Caller 는 Callee 가 완료할때까지 기다린다.

-> 즉, thread 가 block 되어 wait queue 에서 기다린다.

 

TestB 동기 Non-Blocking

- Caller 는 Callee 의 결과에 관심이 있다.

- Caller 는 Callee 가 완료할때까지 기다리지 않고, 자신의 일을 한다.

-> wait queue 에 들어가지 않고, recvfrom 을 주기적으로 호출한다.

 

TestC 비동기 Blocking

- Caller 는 Callee 의 결과 관심 없다.

-> thread 가 주기적으로 확인한다.

 

TestD 비동기 Non-Blocking

- Caller 는 Callee 의 결과 관심 없다.

- 즉, thread1 은 작업 완료 후 thread2 에 결과 전달한다.

-> 요청은 thread1, 응답은 thread2

 

표로 정리한 내용을 첨부하며 다음 블로그에서 더 내용을 이어가보겠다.

  동기 비동기
Blocking - kernel I/O 작업을 application 이 대기
- 그 후에 이 결과를 이어 받아서 작업 수행
 
Non-Blocking - kernel I/O 작업이 완료되었는지 application 이 주기적으로 확인
- 중간중간에 확인하면서 본인 일 수행
- kernel I/O 작업 요청 보내고, application 은 자신의 일 수행
- 작업 완료 시 -> kernel 이 signal, callback 을 호출