5 minute read

대부분의 개발자들은 Block & Synchronous 케이스, 그리고 Non-Block & Asynchronous 케이스를 많이 접해, 실무 이전에 이미 학습 단계 부터 전형적인 케이스로서 두 메서드를 서로 비교하면서 까지 배워 낮설지는 않을것이다. 하지만 굉장히 중요한 내용이기에 한번더 짚어보고 넘어가보고자 한다.

13년전만 해도 위 도표가 발표되던 그떄만 해도 one of new standard features 였던 AIO(Asynchronous I/O) 가 지금은 우리에겐 너무 익숙해 져버렸고 항상 쓰는 개념이 되어 있어 오히려 반대로 기존의 다른 메소드를 알지 못하는 현상이 발생하고 있다.

목차

  1. Blocking, Non-Blocking
  2. Async, Sync
  3. 동기/비동기, blocking/non-blocking의 차이는?
  4. 자바의 비동기 기술
  5. 스프링의 비동기 기술
  6. 참고

1. Blocking, Non-Blocking

동기와 비동기는 프로그래밍을 하면서 굉장히 많이 나오는 주제이다. 하지만 Blocking과 Non-Blocking 과 굉장히 헷갈리고, 많이 혼용하여 사용하는 용어이다. 그렇다면, 이걸 어떻게 자세히 구분할 수 있을까? 먼저 Blocking과 Non-Blocking에 대해 알아보도록 한다.

Blocking과 Non-Blocking은 주로 IO의 읽기, 쓰기 에서 주로 사용된다.

1-1. Blocking

호출된 함수__가 자신이 할 일을 모두 마칠 때까지 제어권을 계속 가지고 __호출한 함수 에게 바로 돌려주지 않는것을 Blocking 이라 한다.

  • 요청한 작업을 마칠때까지 계속 __대기__한다.
  • 즉시 리턴하지 않는다. (일을 못하게 막는다)
  • return 값을 받아야 끝이 난다.
  • Thread 관점으로 본다면, 요청한 작업을 마칠 때까지 계속 대기하며, return 값을 받을 때까지 한 Thread를 계속 사용/대기 한다.

1-2 Non-Blocking

__호출된 함수__가 자신이 할 일을 채 마치치 않았더라도 바로 제어권을 건네주어 (return) __호출한 함수__가 다른 일을 진행할 수 있도록 해주면 Non-Blocking

  • 요청한 작업을 즉시 마칠수 없다면 즉시 return 한다
  • 즉시 리턴한다.
  • Tread 관점으로 본다면, 하나의 쓰레드가 여러개의 IO를 처리 가능하다.

2. Synchronous / Asynchronous

네트워크 코드를 작성하면서 동기/비동기에 대해서 많이 접했을것이다.

동시에 발생하는 것들 (always plural, can never be singular). 동시라는 것은 즉, 시(time)이라는 단일계 에서 같이, 함께 무언가가 이루어지는 두개 이상의 개체 혹은 이벤트를 의미한다고 볼수 있다.

2-1 Synchronous

  • Thread1이 작업을 시작시키고, Task1이 끝날때까지 기다렸다 Task2를 시작한다.
  • 작업 요청을 했을 때 요청의 결과값(return)을 직접 받는다.
  • 요청의 결과값이 return값과 동일하다.
  • 호출한 함수가 작업 완료를 신경쓴다.

2-2 비동기

  • Thread1이 작업을 시작시키고, 완료를 기다리지 않고, Thread1은 다른일을 처리한다.
  • 작업 요청을 했을 때 요청의 결과값(return) 을 간접적으로 받는것)
  • 요청의 결과 값이 return 값과 다를수 있다.
  • 해당 요청 작업은 별도의 스레드에서 실행하게 된다.
  • __콜백__을 통한 처리가 비동기 처리라고 할 수 있다.
  • 호출된 함수 (callback 함수)가 작업 완료를 신경 쓴다.

호출된 함수__의 수행 결과 및 종료를 __호출한 함수__가 (__호출된 함수 뿐만 아니라 __호출한 함수__도 함께 신경 쓰면 Syncrhonous)

3. 동기/비동기, blocking/non-blocking의 차이

위 같이만 봤을때, 동기와 blocking 이 거이 유사하고, 비동기와 non-blocking이 다를것 없어보인다. 하지만 두 그룹의 관심사 가 다른것은 명백하다.

3-1. blocking/non-blocking

이 그룹은 호출되는 함수가 바로!!바로!! return을 하냐 마느냐에 초점이 맞춰져 있다.

호출된 함수가 바로 return해서 함수에게 제어권을 넘겨주고 호출한 함수가 다른 일을 할 수 있는 기회를 줄수 있으면 non-blocking이다.

호출된 함수가 자신의 작업을 모두 마칠때까지 호출한 함수에게 제어권을 넘겨주지 않고 대기하면 blocking 이라 한다.

3-2. 동기/비동기

이 그룹의 관심사는 함수의 작업 완료 여부를 누가 신경쓰냐에 맞춰져 있다.

호출되는 함수에게 callback을 전달하여 호출되는 함수의 작업이 완료되면 호출되는 함수가 전달받은 callback을 실행하고, 호출한 함수는 작업 여부를 신경쓰지 않는다면 비동기 이다.

호출하는 함수가 호출되는 함수의 작업 완료후 return을 기다리거나 호출되는 함수로부터 바로 return을 받더라도 작업 완료 여부를 호출한 함수 스스로 확인하면 신경 쓴다면 동기이다.

4. 자바의 비동기 기술

4-1. 쓰레드풀

  • 쓰레드를 생성하고 없애는데에는 CPU 사용을 많이 한다.
  • 이를 없애지 않고, 반납한 뒤 재활요 하여 비용이 많이 들어가는것을 최소화 한다.

4-2 ExecutorService

ExecutorService를 통해 쓰레드 풀을 생성하고 병렬처리를 해보자.

ExecutorService 생성

Executors는 ExecutorService 객체를 생성하며, 다음 메소드를 제공하여 쓰레드 풀을 개수 및 종류를 정할 수 있다.

  • newFixedThreadPool(int): 인자 개수만큼 고정된 쓰레드풀을 생성한다.
  • newCachedThreadPool(): 필요할 때, 필요한 만큼 쓰레드풀을 생성한다. 이미 생성된 쓰레드를 재활용 할 수 있기 때문에 성능상 이점이 크다.
  • newScheduledThreadPool(int): 일정 시간 뒤에 실행되는 작업이나, 주기적으로 수행 되는 작업이 있다면 ScheduledThreadPool을 고려해볼 수 있다.
  • newSingleThreadExecutor(): 쓰레드 1개인 ExecutorService를 리턴한다. 싱글 쓰레드에서 동작해야 하는 작업을 처리할 때 사용한다.
Runtime.getRuntime().availableProcessors()는 현재 사용가능한 core 개수를 리턴해 줍니다.

ExecutorService에 작업 추가하기

Executor로 ExecutorService를 생성하였다면, ExecutorService는 작업을 처리할 수 있다. ExecutorService.submit() 메소드로 작업을 추가하면 된다. 그후 submit(() -> {}) 로 멀티쓰레드로 처리할 작업을 예약한다. 인자로 람다실을 전달 가능하다.

ExecutorServiceTest

public class ExecutorServiceTest {

    public static void main(String args[]) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(4);

        executor.submit(() -> {
            String threadName = Thread.currentThread().getName();
            System.out.println("Job1 " + threadName);
        });
        executor.submit(() -> {
            String threadName = Thread.currentThread().getName();
            System.out.println("Job2 " + threadName);
        });
        executor.submit(() -> {
            String threadName = Thread.currentThread().getName();
            System.out.println("Job3 " + threadName);
        });
        executor.submit(() -> {
         //			try {
         //				Thread.sleep(2000);
         //			} catch (InterruptedException e) {
         //				e.printStackTrace();
         //			}
            String threadName = Thread.currentThread().getName();
            System.out.println("Job4 " + threadName);
        });

        // 더이상 ExecutorService에 Task를 추가할 수 없습니다.
        // 작업이 모두 완료되면 쓰레드풀을 종료시킵니다.
        executor.shutdown();

        // shutdown() 호출 전에 등록된 Task 중에 아직 완료되지 않은 Task가 있을 수 있습니다.
        // Timeout을 20초 설정하고 완료되기를 기다립니다.
        // 20초 전에 완료되면 true를 리턴하며, 20초가 지나도 완료되지 않으면 false를 리턴합니다.
        if (executor.awaitTermination(20, TimeUnit.SECONDS)) {
            System.out.println(LocalTime.now() + " All jobs are terminated");
        } else {
            System.out.println(LocalTime.now() + " some jobs are not terminated");

            // 모든 Task를 강제 종료합니다.
            executor.shutdownNow();
        }

        System.out.println("end");
    }
}

  • shutdown()은 더이상 쓰레드풀에 작업을 추가하지 못하게 하는 역할을 한다. 그리고 처리중인 Task가 모두 완료되면 쓰레드 풀을 종료시킨다.
  • awitTermination()은 이미 수행중인 Task가 지정된 시간동안 끝나기를 기다리고, 지정된 시간 내에 끝나지 않으면 False를 리턴하며, 이때 shutdownNow()를 호출하면 실행중인 Task를 모두 강제로 종료할 수 있다.

4-3. Future

  • 비동기 적인 연산에 대한 결과를 가지고 있는것
  • 다른 쓰레드에서 사용한 결과를 가져오는 가장 기본이 되는 인터페이스이다.

Future를 이요하면 예약된 작업에 대한 결과를 알 수 있다. executor.submit()은 Future객체를 리턴한다. 모든 작업을 예약할 떄, Future를 따로 저장을 해 두면 메인 쓰레드에서 쓰레드풀에서 처리한 결과를 알수 있다.

이전 코드를 보면 작업을 추가하고 처리에 대한 결과는 따로 확인하지 않았는데, 여기서는 future.get() 을 통해 작업이 종료될때까지 기다린다.

public class ExecutorServiceTest3 {

    public static void main(String args[]) {
        final int maxCore = Runtime.getRuntime().availableProcessors();
        final ExecutorService executor = Executors.newFixedThreadPool(maxCore);
        final List<Future<String>> futures = new ArrayList<>();

        for (int i = 1; i < 5; i++) {
            final int index = i;
            futures.add(executor.submit(() -> {
                System.out.println("finished job" + index);
                return "job" + index + " " + Thread.currentThread().getName();
            }));
        }

        for (Future<String> future : futures) {
            String result = null;
            try {
                result = future.get();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
            System.out.println(result);
        }

        executor.shutdownNow();
        System.out.println("end");
    }
}

위 코드를 보면 작업이 순서대로 처리되지는 않을수 있지만 Future에 대한 로그는 순차적으로 출력이 된다.

List에 futures에 future를 추가하고, 그 밑의 for문에서 작업 1~4 작업을 순서대로 기다린다. 그래서 로그를 출력해보면 순서대로 출력이 되는것이다. Future에 대한 for문이 끝나면 ExecutorService는 필요가 없기 때문에 바로 종료할 수 있다.

참고

https://velog.io/@wnwl1216/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%A6%AC%EC%95%A1%ED%8B%B0%EB%B8%8C-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D4%ED%8E%B8-%EC%9E%90%EB%B0%94%EC%99%80-%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%98-%EB%B9%84%EB%8F%99%EA%B8%B0-%EA%B8%B0%EC%88%A0 https://jongmin92.github.io/tags/ListenableFuture/ https://robin00q.tistory.com/71 https://jh-7.tistory.com/25 https://www.youtube.com/watch?v=aSTuQiPB4Ns

Categories:

Updated: