카테고리 없음

블로킹과 논블로킹, 동기와 비동기 그리고 I/O 모델 이해하기

tally 2024. 7. 10. 00:11

개요

블로킹(blocking), 논블로킹(non-blocking), 동기(synchronous), 비동기(asynchronous)와 같은 용어는 I/O 작업과 태스크 실행 맥락에서 자주 사용되지만, 이들을 정확히 이해하고 구분하는 것은 쉽지 않습니다.
 
이러한 개념을 이해하는 것은 애플리케이션 성능과 응답성을 최적화하는 데 매우 중요한데요,
이번 포스팅에서는 이러한 주요 개념들을 코드와 함께 공유해보고자 합니다.
 
 
예시에 사용된 코드 예시들은 해당 리포지토리에서 확인 가능합니다.
https://github.com/own-playground/io-model

GitHub - own-playground/io-model: [practice] Implement the most prevalent I/O models

[practice] Implement the most prevalent I/O models - own-playground/io-model

github.com

 
 
 

블록킹과 논블록킹

블록킹과 논블록킹은 작업의 수행 여부과 관련이 있습니다. 풀어서 설명하자면, I/O를 담당하는 함수를 호출했을 때 호출한 함수가 자신의 작업을 할 수 있는지 없는지에 따라 구분된다고 볼 수 있습니다.

블록킹과 논블록킹을 설명할 때 '제어권'이라는 용어가 나오게 되는데요,
제어권은 자신(함수)의 코드를 실행할 권리. 즉, 어떠한 함수를 호출해도 내 작업을 실행할 수 있는가를 나타냅니다.

예시로, 제어권이 없다면 호출된 함수가 자신의 작업을 마칠때까지 호출한 함수에게 제어권을 반환하지 않는다면 호출한 함수는 다른 작업을 수행할 수 없음을 의미합니다.

 
 

블록킹

블로킹은 한 작업이 다른 작업의 완료를 기다리는 동안 아무것도 하지 않고 대기하는 상태를 의미합니다. 즉, 메인 플로우의 제어권이 호출된 함수로 넘어가면, 해당 함수가 작업을 마칠 때까지 메인 플로우는 자신의 작업을 중단한 채 기다려야 함을 의미합니다.
 
블록킹 형태로 동작되는 코드의 실행 결과라고 가정해보겠습니다.

biz logic start...               (A 함수)
데이터베이스에서 데이터를 가져오는 중...         (B 함수)
[블록킹 작업] 완료. 결과: 김철수              (B 함수)
[메인 스레드] 다른 작업 수행 중... 1    (A 함수)
[메인 스레드] 다른 작업 수행 중... 2    (A 함수)
[메인 스레드] 다른 작업 수행 중... 3    (A 함수)
[메인 스레드] 다른 작업 수행 중... 4    (A 함수)
[메인 스레드] 다른 작업 수행 중... 5    (A 함수)
biz logic end...                 (A 함수)

 
실행 결과의 내용처럼 B 함수가 완료될 때까지 A 함수는 아무 작업도 수행하지 않고 대기하게 됩니다.
이를 시퀀스 다이어그램으로 나타내면 다음과 같습니다.

 
 
 

논블록킹

논블로킹은 한 작업이 다른 작업의 완료를 기다리는 동안에도 자신의 작업을 계속할 수 있는 상태를 의미합니다. 즉, 메인 플로우는 호출된 함수로 제어권이 넘어가자마자 즉시 반환받아 자신의 작업을 계속 수행할 수 있습니다.
( 이것이 가능한 건 콜백, 프로미스, 이벤트 루프와 같은 메커니즘을 통해 이루어지게 됩니다. )
 
마찬가지로, 논블록킹 형태로 동작되는 코드의 실행 결과라고 가정해보겠습니다.

// 논블로킹 예제
biz logic start...                         (A 함수)
[논블록킹 작업] 데이터베이스에서 데이터를 가져오는 중...      (B 함수)
[메인 스레드] 다른 작업 수행 중... 1              (A 함수)
[메인 스레드] 다른 작업 수행 중... 2              (A 함수)
[메인 스레드] 다른 작업 수행 중... 3              (A 함수)
[메인 스레드] 다른 작업 수행 중... 4              (A 함수)
[메인 스레드] 다른 작업 수행 중... 5              (A 함수)
biz logic end...                           (A 함수)

 
블록킹과는 다르게 B 함수가 시작됐음에도 불구하고 A 함수의 다음 작업이 진행된 것을 볼 수 있습니다. 따라서 B 함수의 작업 수행 완료되기를 기다리지 않고 A 함수의 작업이 되었음을 알 수 있습니다.
여기서 중요한 것은 B 함수의 결과를 받지 않았다가 아닌 B 함수를 호출한 후 즉시 A 함수의 다음 작업으로 넘어갔다는 것입니다.
 
시퀀스 다이어그램으로 나타내면 다음과 같습니다.
 
 

 
 
 

동기와 비동기

동기와 비동기는 작업의 완료 시점과 결과 처리 방식에 관한 개념입니다. 즉, 결과를 돌려주었을 때 결과에 관심이 있는지 없는지에 대한 여부를 나타냅니다.
 
동기와 비동기를 구분할 때 중점을 두어야 하는 것은 제어권의 반환과 결과 전달의 시간이 일치하는가 아닌가가 중요합니다.
 
 

동기

동기 방식에서는 작업의 완료와 결과 반환이 동시에 일어납니다. 이는 작업 간의 예측 가능한 순서를 유지한다고 볼 수 있습니다.
 

 
동기 방식은 두 가지 경우가 있을 수 있습니다.

  • 방법A: A 작업이 혼자 시작해서 혼자 끝나고, B 작업도 혼자 시작해서 혼자 끝남
  • 방법B: A 작업과 B 작업이 같이 시작해 같이 끝남

 
각각을 코드로 알아보겠습니다.
 
방법A. A 작업이 혼자 시작해서 혼자 끝나고, B 작업도 혼자 시작해서 혼자 끝남

final long start = System.currentTimeMillis();  
instance.taskA(); // sleep(2000). A 함수 호출  
instance.taskB(); // sleep(3000). A 함수가 종료된 후 B 함수 호출
final long end = System.currentTimeMillis();  
  
System.out.println("All tasks finished execute time = " + (end - start));
Task A started
Task A finished
Task B started
Task B finished
All tasks finished execute time = 5008

 
 
방법B. A 작업과 B 작업이 같이 시작해 같이 끝남

Thread taskA = new Thread(instance::taskA);  // sleep(2000)
Thread taskB = new Thread(instance::taskB);  // sleep(3000)
  
final long start = System.currentTimeMillis();  
taskA.start();
taskB.start(); 
taskA.join();  // -> taskA 스레드가 완료될 때까지 메인 스레드를 대기
taskB.join();  // -> taskB 스레드가 완료될 때까지 메인 스레드를 대기
final long end = System.currentTimeMillis();  
  
System.out.println("All tasks finished execute time = " + (end - start));
Task B started
Task A started
Task A finished
Task B finished
All tasks finished execute time = 3005

 
 
 

비동기

비동기 방식에서는 작업의 완료와 결과 반환이 별도로 이루어집니다. 다시 말해, 시작, 종료가 일치하지 않으며 작업이 완료되더라도 곧바로 시작하지 않음을 의미합니다.
 

 
마찬가지로, 비동기 방식은 두 가지 경우가 있을 수 있습니다.

  • 방법 A: A 함수의 작업이 진행중에 B 함수의 작업이 시작되고, A 함수가 끝나고 B 함수가 끝남
  • 방법 B: A 함수의 작업이 진행중에 B 함수의 작업이 시작되지만, B 함수가 먼저 끝나고 A 함수가 끝남

 
각각에 대해서 알아보겠습니다.
 
방법 A: A 함수의 작업이 진행중에 B 함수의 작업이 시작되고, A 함수가 끝나고 B 함수가 끝남

CompletableFuture<Void> futureA = CompletableFuture.runAsync(this::taskA);  // taskA: sleep(2000)
CompletableFuture<Void> futureB = CompletableFuture.runAsync(() -> {        // taskB: sleep(3000)
    sleep(500); // TaskA 시작 후 0.5초 뒤에 TaskB 시작  
    taskB();  
});  
  
futureA.thenRun(() -> System.out.println("Task A finished"));  
futureB.thenRun(() -> System.out.println("Task B finished"));
Task A started
Task B started
Task A finished
Task B finished

 
 
방법 B: A 함수의 작업이 진행중에 B 함수의 작업이 시작되지만, B 함수가 먼저 끝나고 A 함수가 끝남

CompletableFuture<Void> futureA = CompletableFuture.runAsync(this::taskA);  // taskA: sleep(2000)
CompletableFuture<Void> futureB = CompletableFuture.runAsync(() -> {        // taskB: sleep(1000)
    sleep(500); // TaskA 시작 후 0.5초 뒤에 TaskB 시작  
    taskB();  
});
futureA.thenRun(() -> System.out.println("Task A finished"));  
futureB.thenRun(() -> System.out.println("Task B finished"));

 
 
 
 

I/O 모델 (2 x 2 믹스)

이제 블로킹/논블로킹과 동기/비동기 개념을 조합하여 네 가지 I/O 모델을 살펴보겠습니다.

  • 동기 & 블로킹 (Blocking I/O)
  • 동기 & 논블로킹 (Non-blocking I/O)
  • 비동기 & 블로킹 (Multiplexing I/O)
  • 비동기 & 논블로킹 (Asynchronous I/O)
이미지 출처: https://homoefficio.github.io/2017/02/19/Blocking-NonBlocking-Synchronous-Asynchronous/

 
 
 

Synchronous & Blocking: Blocking I/O

동기와 블로킹 조합이므로, 작업의 완료를 신경쓰기 때문에 작업이 완료되면 즉시 결과를 처리하고, 호출된 함수가 작업을 완료할 때까지 제어권을 가져가므로, 호출한 함수는 대기 상태가 되는 것이 특징입니다.

https://developer.ibm.com/articles/l-async/#synchronous-blocking-i-o2

 
 
예시 동작 흐름

  1. A 함수가 B 함수를 호출
  2. B 함수가 완료될 때까지 A 함수는 대기
  3. B 함수의 작업이 완료되면 결과를 반환하고 제어권이 A 함수로 돌아와서 A 함수의 작업이 계속 실행
System.out.println("===== [Sync][Blocking] - 시작 =====");  

System.out.println("메인 스레드는 결과를 기다리는 동안 블로킹됩니다. - 시작");  
final String result = getById(2L); // 가상의 I/O 작업 시간이 2초라고 가정
System.out.println("메인 스레드는 결과를 기다리는 동안 블로킹됩니다. - 종료");  
System.out.println("complete execute name = " + result);  

System.out.println("===== [Sync][Blocking] - 종료 =====");
===== [Sync][Blocking] - 시작 =====
메인 스레드는 결과를 기다리는 동안 블로킹됩니다. - 시작
동기 작업 시작
동기 작업 종료
메인 스레드는 결과를 기다리는 동안 블로킹됩니다. - 종료
complete execute name = 김철수
===== [Sync][Blocking] - 종료 =====

 
 

Synchronous & Non-blocking: Non-blocking I/O

동기 논블록킹 모델은 논블록킹이라 메인 스레드가 다른 작업을 수행할 수 있지만, 동기이므로 결과에 관심을 가지고 있어서 작업이 완료되었는지 지속적으로 확인해야 합니다. 그러나, 결과를 받기까지 계속 상태를 체크(polling)하는 busy-wait 상태가 되고, 부적절한 폴링 주기 설정 시 빈번한 컨텍스트 스위칭 발생이 발생하게 됩니다.

https://developer.ibm.com/articles/l-async/#synchronous-non-blocking-i-o3

 
 
예시 동작 흐름

  1. A 함수가 B 함수를 호출
  2. B 함수는 즉시 제어권을 반환하고 A 함수는 자신의 작업을 진행
  3. A 함수는 주기적으로 B 함수의 작업 완료 여부를 확인 (polling)
System.out.println("===== [Sync][Non-Blocking] - 시작 =====");

final ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(() -> {
    for (int i = 1; i <= 5; i++) {
        sleep(100); // 가상의 I/O 작업 시간
        System.out.println("[비동기 작업] 작업 진행 중... " + i);
    }
    return repository.get(1L);
});

for (int i = 1; i <= 50; i++) {
    if(future.isDone()) {
        break;
    }
    System.out.println("[polling - " + i + "] 메인 스레드는 다른 작업을 수행할 수 있음");
    sleep(100);
}
String result = future.get(); // 이미 완료되었으므로 즉시 반환

System.out.println("complete execute name = " + result);
System.out.println("===== [Sync][Non-Blocking] - 종료 =====");
===== [Sync][Non-Blocking] - 시작 =====
[polling - 1] 메인 스레드는 다른 작업을 수행할 수 있음
[비동기 작업] 작업 진행 중... 1
[polling - 2] 메인 스레드는 다른 작업을 수행할 수 있음
[polling - 3] 메인 스레드는 다른 작업을 수행할 수 있음
[비동기 작업] 작업 진행 중... 2
[polling - 4] 메인 스레드는 다른 작업을 수행할 수 있음
[비동기 작업] 작업 진행 중... 3
[polling - 5] 메인 스레드는 다른 작업을 수행할 수 있음
[비동기 작업] 작업 진행 중... 4
[비동기 작업] 작업 진행 중... 5
[polling - 6] 메인 스레드는 다른 작업을 수행할 수 있음
complete execute name = 홍길동
===== [Sync][Non-Blocking] - 종료 =====

 
 
 
 

Asynchronous & Blocking: Multiplexing I/O

비동기 블로킹 모델은 작업이 비동기로 시작되지만, 호출한 함수는 작업이 완료될 때까지 대기하게 됩니다. 이는 의도적으로 사용되는 경우는 드물지만, 특정 상황에서 발생할 수 있습니다.
( JDBC는 Blocking 방식으로 DB에 접근하게 되는데 비동기 환경에서 JDBC로 DB에 접근하게 될 때 Async & Blocking이 될 수 있음 )
 
참고로, 비동기 작업 자체는 완료 시점을 예측할 수 없지만, 블로킹 호출(future.get())로 인해 결과를 즉시 받을 수 있습니다.

https://developer.ibm.com/articles/l-async/#asynchronous-blocking-i-o4

 

예시 동작 흐름

  1. A 함수가 B 함수를 비동기로 호출
  2. A 함수는 B 함수가 작업을 완료할 때까지 대기
  3. B 함수가 작업을 완료하면 A 함수는 작업 결과를 즉시 처리
System.out.println("===== [Async][Blocking] - 시작 =====");
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    for (int i = 1; i <= 5; i++) {
        sleep(200);
        System.out.println("비동기 작업 진행 중... " + i);
    }
    return repository.get(2L);
});

System.out.println("비동기 작업 시작.");
System.out.println("메인 스레드는 결과를 기다리는 동안 블로킹됩니다.");

final String result = future.get(); // 비동기 작업이 완료될 때까지 블로킹

System.out.println("complete execute name = " + result);
System.out.println("===== [Async][Blocking] - 종료 =====");
===== [Async][Blocking] - 시작 =====
비동기 작업 시작.
메인 스레드는 결과를 기다리는 동안 블로킹됩니다.
비동기 작업 진행 중... 1
비동기 작업 진행 중... 2
비동기 작업 진행 중... 3
비동기 작업 진행 중... 4
비동기 작업 진행 중... 5
complete execute name = 김철수
===== [Async][Blocking] - 종료 =====

 
 
 
 

Asynchronous & Non-blocking: Asynchronous I/O

비동기 논블록킹 모델은 비동기이므로 작업이 완료되면 콜백 등의 방식으로 결과를 반환받고, 논블록킹이므로 메인 스레드는 다른 작업을 수행할 수 있습니다.

https://developer.ibm.com/articles/l-async/#asynchronous-non-blocking-i-o-aio-5

 
예시 동작 흐름

  1. A 함수가 B 함수를 비동기로 호출
  2. B 함수는 즉시 제어권을 반환하고 A 함수는 자신의 작업을 계속 진행
  3. B 함수가 작업을 완료하면 콜백을 통해 A 함수에 결과를 전달
System.out.println("===== [Async][Non-Blocking] - 시작 =====");

CompletableFuture<String> futureA = CompletableFuture.supplyAsync(() -> {
    for (int i = 1; i <= 5; i++) {
        sleep(200);
        System.out.println("[비동기 작업] 작업A 진행중... " + i);
    }
    return repository.get(2L);  // 2초 후 완료
});
CompletableFuture<String> futureB = CompletableFuture.supplyAsync(() -> {
    for (int i = 1; i <= 5; i++) {
        sleep(100);
        System.out.println("[비동기 작업] 작업B 진행중... " + i);
    }
    return repository.get(3L);  // 1초 후 완료
});

CompletableFuture.allOf(futureA, futureB)
    .thenRun(() -> System.out.println("------ 모든 비동기 작업 완료 ------"))
    .thenCompose(v -> futureA).thenAccept(result -> System.out.println("complete futureA result = " + result))
    .thenCompose(v -> futureB).thenAccept(result -> System.out.println("complete futureB result = " + result));

System.out.println("메인 스레드는 비동기 작업의 완료를 기다리지 않고 계속 실행됩니다.");
for (int i = 1; i <= 5; i++) {
    sleep(300);
    System.out.println(">> [메인 스레드] 작업  진행중... " + i);
}
System.out.println("메인 스레드 작업 완료");

System.out.println("===== [Async][Non-Blocking] - 종료 =====");
===== [Async][Non-Blocking] - 시작 =====
메인 스레드는 비동기 작업의 완료를 기다리지 않고 계속 실행됩니다.
[비동기 작업] 작업B 진행중... 1
[비동기 작업] 작업A 진행중... 1
[비동기 작업] 작업B 진행중... 2
>> [메인 스레드] 작업  진행중... 1
[비동기 작업] 작업B 진행중... 3
[비동기 작업] 작업A 진행중... 2
[비동기 작업] 작업B 진행중... 4
[비동기 작업] 작업B 진행중... 5
>> [메인 스레드] 작업  진행중... 2
[비동기 작업] 작업A 진행중... 3
[비동기 작업] 작업A 진행중... 4
>> [메인 스레드] 작업  진행중... 3
[비동기 작업] 작업A 진행중... 5
------ 모든 비동기 작업 완료 ------
complete futureA result = 김철수
complete futureB result = 이영희
>> [메인 스레드] 작업  진행중... 4
>> [메인 스레드] 작업  진행중... 5
메인 스레드 작업 완료
===== [Async][Non-Blocking] - 종료 =====

 
 

정리하면서 들었던 궁금한 점

Q. 비동기의 특징인 '작업의 완료를 신경 쓰지 않음'이 되려면 CompletableFuture.allOf.thenRun 을 사용하면 안되는 건가요?
 
A. CompletableFuture.allOf와 thenRun의 사용이 비동기의 특성과 모순되는 것은 아닙니다. 이 점에 대해 설명드리겠습니다:
1. CompletableFuture.runAsync만 사용할 경우, 작업을 시작하고 즉시 제어권을 반환합니다. 이는 "작업의 완료를 신경 쓰지 않는" 가장 순수한 형태의 비동기 실행입니다.
2. CompletableFuture.allOf.thenRun을 사용하는 경우:
    - 여전히 비동기적으로 작업을 실행합니다.
    - 다만, 모든 작업이 완료된 후 추가 작업을 실행하도록 예약하는 것입니다.
    - 이 방식도 비동기적이며, 메인 스레드를 차단하지 않습니다.
3. 비동기 프로그래밍의 목적은 작업을 백그라운드에서 실행하여 메인 스레드의 차단을 방지하는 것입니다. allOf.thenRun을 사용해도 이 목적을 달성할 수 있습니다.
결론적으로, CompletableFuture.allOf.thenRun의 사용이 비동기의 특성을 해치지 않습니다. 
-- Claude 3.5 Sonnet

 
 
 

결론

블록킹 & 논블록킹, 동기 & 비동기를 여전히 헷갈리는 부분들이 있습니다. CompletableFuture 클래스를 잘 숙지하지 못한 것도 있고, 익숙하지 않은 것들도 컸던 것 같습니다. 그럼에도 불구하고, 코드로 작성해보면서 어떠한 조합이어도 대략적인 방향성으로 그려야 하는지가 정리되었던 것 같습니다. 
다소 서툰 지식들로 포스팅을 작성해보았는데, 혹여 잘못된 내용이 있다면 언제든 피드백해주세요.
 
 
 

참고