동기/비동기 그리고 블로킹/넌블로킹의 개념을 정리하고 Java로 예제 코드를 정리합니다.

동기와 비동기의 주요 차이점은 작업 완료 여부를 신경쓰는가에 있습니다.
작업을 요청한 후, 그 결과가 나올 때까지 기다리고 나머지 작업을 이어서 처리합니다. 따라서 작업의 순서가 보장됩니다.
작업을 요청한 후 결과를 기다리지 않고 본인의 작업을 이어서 진행할 수 있습니다.

즉 처리가 오래 걸리는 작업이 동기로 실행되면 작업 시간이 오래 걸리는 문제가 있지만, 비동기 처리를 할 경우 동시에 여러 작업을 진행할 수 있는 특징이 있습니다.

블로킹과 넌블로킹의 주요 차이점은 제어권에 있습니다.
호출된 함수가 자신의 작업을 모두 마칠 때까지 제어권을 가지고 있습니다. 따라서 호출한 함수는 호출된 함수가 작업을 마칠 때까지 대기합니다.
호출된 함수가 즉시 제어권을 반환하여, 호출한 함수는 바로 다음 작업을 수행할 수 있습니다.
비동기와 넌블로킹은 비슷해 보이지만 다음과 같은 차이점이 있습니다.
실제 프로그래밍에서는 총 4가지 조합이 가능합니다.

각각의 상황을 예제 코드로 살펴보겠습니다.
public class SyncBlocking {
public static void main(String[] args) throws InterruptedException {
log("동기-블로킹 작업 시작");
doSomething();
log("동기-블로킹 작업 종료");
}
private static void doSomething() {
log("다른 작업 시작");
try {
Thread.sleep(2000); // 2초가 소요되는 작업
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
doSomething() 메서드가 실행되는 동안 스레드가 BLOCKING 상태로 변경되며, 메서드가 완료될 때까지 다음 작업을 수행할 수 없습니다.
21:06:27.942 [ main] 동기-블로킹 작업 시작
21:06:27.943 [ main] 다른 작업 시작
21:06:29.945 [ main] 동기-블로킹 작업 종료
public class SyncNonBlocking {
public static void main(String[] args) {
log("동기-넌블로킹 작업 시작");
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
log("다른 작업 시작");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return "넌블로킹 작업물";
});
log("다른 작업 이어서 진행");
try {
String result = future.get();
log("최종 결과: " + result);
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
log("동기-넌블로킹 작업 종료");
}
}
Java의 CompletableFuture를 사용하면 비동기 + 넌블로킹 작업을 구현할 수 있습니다. supplyAsync() 매개변수로 콜백 함수를 전달하면 별도의 스레드에서 해당 작업을 수행합니다. 그리고 get() 함수를 호출하면 main 스레드는 콜백함수의 작업 결과물을 받을 때까지 대기합니다. Future 관련 내용은 다른 포스팅에서 다루겠습니다.
21:07:08.011 [ main] 동기-넌블로킹 작업 시작
21:07:08.013 [ main] 다른 작업 이어서 진행
21:07:08.013 [ForkJoinPool.commonPool-worker-1] 다른 작업 시작
21:07:10.019 [ main] 최종 결과: 넌블로킹 작업물
21:07:10.019 [ main] 동기-넌블로킹 작업 종료
public class SyncNonBlocking {
public static void main(String[] args) throws InterruptedException, ExecutionException {
log("동기-넌블로킹 작업 시작");
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
log("다른 작업 시작");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return "Hello World";
});
log("이어서 다른 작업을 수행");
while (!future.isDone()) {
Thread.sleep(200);
log("작업 끝났는지 확인 중");
}
log(future.get() + " 획득");
log("동기-넌블로킹 작업 종료");
}
}
워커 스레드에서 다른 작업을 수행함과 동시에 메인 스레드는 본인의 작업을 동시에 실행됩니다. 따라서 넌블로킹이지만, 메인 스레드는 워커 스레드의 작업물을 얻기 위해 바쁜 대기(busy-waiting)을 할 수밖에 없습니다. 즉 워커 스레드의 결과물을 얻기 전까지 다른 작업을 할 수 있긴 하므로 SyncBlocking과 다르다고 볼 수 있습니다.
21:58:43.256 [ main] 동기-넌블로킹 작업 시작
21:58:43.258 [ main] 이어서 다른 작업을 수행
21:58:43.258 [ForkJoinPool.commonPool-worker-1] 다른 작업 시작
21:58:43.463 [ main] 작업 끝났는지 확인 중
21:58:43.668 [ main] 작업 끝났는지 확인 중
21:58:43.872 [ main] 작업 끝났는지 확인 중
21:58:44.079 [ main] 작업 끝났는지 확인 중
21:58:44.281 [ main] 작업 끝났는지 확인 중
21:58:44.283 [ main] Hello World 획득
21:58:44.283 [ main] 동기-넌블로킹 작업 종료
public class AsyncBlocking {
public static void main(String[] args) {
log("비동기-블로킹 작업 시작");
Random random = new Random();
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
log("작업 진행중");
try {
Thread.sleep(1700 + random.nextInt(400));
return 10;
} catch (InterruptedException e) {
throw new RuntimeException(e.getMessage());
}
})
.thenApply(result -> result * 2);
int value = 0;
try {
value = future.get(2000, TimeUnit.MILLISECONDS);
} catch (ExecutionException | InterruptedException | TimeoutException e) {
throw new RuntimeException(e.getMessage(), e);
} finally {
log("최종 결과: " + value);
log("비동기-블로킹 작업 종료");
}
}
}
비동기 블로킹 방식은 큰 의미가 없습니다. 왜냐하면 워커 스레드가 작업하는 동안 메인 스레드는 다른 작업을 수행할 수 없고, 이는 동기 블로킹 방식과 차이가 거의 없다고 볼 수 있습니다.
22:10:04.921 [ main] 비동기-블로킹 작업 시작
22:10:04.923 [ForkJoinPool.commonPool-worker-1] 작업 진행중
22:10:06.708 [ main] 최종 결과: 20
22:10:06.709 [ main] 비동기-블로킹 작업 종료
completableFuture.get() 함수를 호출하여 워커 스레드의 작업 결과가 계산될 때까지 대기합니다. 결국 동기 블로킹 방식과 거의 유사합니다. 오히려 비동기 작업을 위해 스레드를 분리하면서 오히려 오버헤드가 발생할 가능성이 있습니다.
public class AsyncNonBlocking {
public static void main(String[] args) throws ExecutionException, InterruptedException {
log("비동기-넌블로킹 작업 시작");
CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(1000);
log("다른 작업 시작");
return 10;
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).thenApplyAsync(value -> {
log("중간 처리 중");
return value * 2;
}).thenAcceptAsync(value -> {
log("최종 결과: " + value);
});
log("이어서 작업 진행");
Thread.sleep(3000);
log("비동기-넌블로킹 작업 종료");
}
}
메인 스레드는 워커 스레드에 새로운 작업을 요청하고 이어서 본인의 작업을 진행합니다. 메인 스레드는 워커 스레드의 결과물을 기다리지 않고 종료됩니다. 따라서 메인 스레드 종료 전 3초 대기하도록 구현했습니다.
22:20:59.545 [ main] 비동기-넌블로킹 작업 시작
22:20:59.548 [ main] 이어서 작업 진행
22:21:00.553 [ForkJoinPool.commonPool-worker-1] 다른 작업 시작
22:21:00.554 [ForkJoinPool.commonPool-worker-1] 중간 처리 중
22:21:00.555 [ForkJoinPool.commonPool-worker-1] 최종 결과: 20
22:21:02.553 [ main] 비동기-넌블로킹 작업 종료
참고