Dev

Sync/Async와 Blocking/NonBlocking 성능 비교

칼퇴시켜주세요 2024. 5. 31. 15:16
728x90

개요

Common 모듈을 구현하면서 FileProcess에 대한 정의와 어떻게 I/O처리 과정에서 Sync/Async와 Blocking/NonBlocking이 등장하게 되었는지 배경을 소개한다.

기존 프로젝트에서 파일 업로드 및 파싱을 하여 변환하는 프로세스가 존재했다. 처음 구현한 방식은 MultiPartFile을 받고, LocalStorage에 파일을 쓰고, 쓴 파일을 다시 읽어 파싱한 후 파싱 결과를 DB에 저장하도록 되어있었다.

동기화 문제

기존 방식은 MultiPartFile → Local Save → Local Load → Parsing순서로 하나의 thread에서 요청이 들어온 순서대로 처리를 진행하였다.

만약 여러 사람이 동시에 요청을 보낸다면?

즉 thread 여러 개가 동시에 요청을 보낼 때 공유 자원이 thread-safe하지 못하는 경우가 발생한다. 이 문제를 해결하기 위해 다양한 Lock 기법을 활용하여 공유 자원 동기화 방법 문서를 아래 정리해 놓았다.

https://devconf.tistory.com/92

 

Multi-Thread 동시성 제어 방법

시작하기 앞서 ‘본 문서는 동시성과 관련된 용어들을 정리해 보고, 프로젝트를 진행하면서 발생했던 동시성 문제를 다양한 동시성 처리 기법들을 사용하여 해결하여 결과 비교 분석’을 위해

devconf.tistory.com

 

 

스레드 Block 문제

어플리케이션을 개발하다 보면 다양한 Block 문제를 만나게 된다. 이때 가장 많이 보이는 용어가 (Sync/Async & Blocking/NonBlocking IO)이다.

보통 I/O를 담당하는 스레드(File, Socket)에서 Block이 발생하는데, 누군 가로부터 받은 (Byte)데이터가 중간에 유실되거나 변형되어 전달되지 않기 위해 I/O하는 과정 중간에 스레드를 멈추거나 가로채면 안된다. 이 과정을 Blocking IO라고 한다.

만약 I/O 요청이 많거나 대용량 데이터 I/O가 필요하다면?

기존 Blocking I/O 방식으로 다량의 요청이나 대용량 데이터를 처리할 수도 있다. 하지만 이 방법으로 최고의 성능을 내기는 힘들다.

Sync/Async와 Blocking/NonBlocking

아래 그림은 Sync/Async와 Blocking/NonBlocking사이 관계를 그림으로 설명한 것이다.

blocking + Synchronous, non-blocking + Asynchronous

blocking + Synchronous

결과가 처리되어 나올때까지 기다렸다가 return 값으로 결과를 전달한다.

non-blocking + Asynchronous

작업 요청을 받아서 별도의 프로세서에서 진행하게 하고 바로 return(작업 끝)한다.
결과는 별도의 작업 후 간접적으로 전달(callback)한다.

non-blocking + Synchronous

non-blocking + Synchronous

결과가 없다면 바로 return한다.
결과가 있으면 바로 결과를 return 한다.
(결과가 생길때까지 계속 완료 되었는지 확인)

blocking + Asynchronous

blocking + Asynchronous

호출되는 함수가 바로 return하지 않고, 호출하는 함수는 작업 완료 여부를 신경쓰지 않는다.
(이 조합은 사실 이점이 없어서 일부러 이 방식을 사용하진 않는다고 한다.)

의도하지 않게 blocking+Async로 동작하는 경우가 있는데 , non-blocking+Async를 추구하다 의도가 변질되어버리는 경우이다. 예를들어 Future의 get()을 호출하는 경우가 있는데 get의 경우 future작업이 완료될때 까지 block상태로 대기 하고 있기 때문이다.

성능 비교

큰 범위로 read, write관련해서 blocking + Synchronous/non-blocking + Asynchronous 두가지 코드를 예시로 보여주고 bigSize, smallSize 파일을 가지고 성능 비교를 해보았다.

read(blocking + Synchronous)

public Stream<String> read(Path path) {
    return CompletableFuture.supplyAsync(()->{
      System.err.println("FileChannel.read() 호출");
      long startTime = System.currentTimeMillis();
      Stream<String> resultStream = null;
      try {
        resultStream = Files.lines(path);
      } catch (IOException e) {
        throw new RuntimeException(e);
      }
      long endTime = System.currentTimeMillis();

      System.err.println("FileChannel.read() 완료 : " + (endTime - startTime) + " ms elapsed.");
      System.err.println("FileChannel I/O 완료 후 다른 작업 수행");
      return  resultStream;
    },  processManager.getExecutorService("FileProcess")).join();
  }
  • test code
@Test
  public void read() throws InterruptedException, ExecutionException {
    FileProcess fileProcess = new FileProcess();
    ExecutorService executorService = Executors.newFixedThreadPool(8);
    List<CompletableFuture<Void>> futures = new ArrayList<>();

    for (int i = 0; i < 100000; i++) {
      CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
        return fileProcess.read(Path.of(bigSizeFile));
      }, executorService).thenApply(Function.identity()).thenAccept(stringStream -> {
        //stringStream.toList();
        //stringStream.close(); // 스트림 사용 후 닫기 (주석 해제 필요)
      });
      futures.add(future);
    }
    // 모든 future가 완료될 때까지 기다립니다.
    CompletableFuture<Void> combinedFuture = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
    combinedFuture.join();  // 모든 작업이 완료될 때까지 블로킹

    System.out.println("모든 파일 처리 완료");
    executorService.shutdown();
  }

아래 두 사진은 각각 bigSize, smallSize를 가지고 blocking + Synchronous방식의 read를 테스트한 결과이다.

  • BigSize
  • SmallSize

총 8개의 스레드에 10만개의 요청을 동시에 보내어 테스트를 진행하였고 combinedFuture.join();을 확인해여 Total Time을 확인할 수 있다. read를 이용할때 큰 파일의 경우 12초, 작은 파일의 경우 18초 정도 걸리는것을 확인하였다. 결과적으로 큰 파일을 읽는 것이 작은 파일을 읽는 것 보다 시간적 측면에서 우수하다는 것을 확인 할 수 있다.

read(non-blocking + Asynchronous)

public CompletableFuture<Stream<String>> readAsync(Path path){
    return CompletableFuture.supplyAsync(() -> {
      long startTime = System.currentTimeMillis();

      int bufferSize = 8 * 1024; //8k
      long position = 0;  // 파일에서 읽기 시작할 위치

      try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(
          path, StandardOpenOption.READ)) {

        ByteBuffer buffer = ByteBuffer.allocate(bufferSize);
        CompletableFuture<Stream<String>> resultFuture = new CompletableFuture<>();
        ByteBuffer resultDate = ByteBuffer.allocate((int)channel.size());
        readFromChannel(channel, buffer, resultFuture, resultDate, position);

        System.err.println("AsyncFileChannel I/O 진행하는 동안 다른 작업 수행");
        return resultFuture.join();
      } catch (IOException e) {
        throw new RuntimeException("Failed to open or read file", e);
      } finally {
        long endTime = System.currentTimeMillis();
        System.err.println("FileChannel.read() 완료 : " + (endTime - startTime) + " ms elapsed.");
      }
    }, processManager.getExecutorService("FileProcess"));
  }

  private void readFromChannel(AsynchronousFileChannel channel, ByteBuffer buffer,
      CompletableFuture<Stream<String>> resultFuture, ByteBuffer resultData, long position) {
    System.err.println("AsynchronousFileChannel.read() 호출");
    channel.read(buffer, position, resultData, new CompletionHandler<Integer, ByteBuffer>() {
      @Override
      public void completed(Integer bytesRead, ByteBuffer resultData) {
        if (bytesRead == -1) {
          buffer.flip();
          resultFuture.complete(Stream.of(new String(resultData.array(), 0, resultData.limit()).split("\\R")));
          closeAsyncFileChannel(channel);
          return;
        }
        buffer.flip();
        byte[] data = new byte[buffer.remaining()];
        buffer.get(data);
        resultData.put(data);
        buffer.compact();

        channel.read(buffer, resultData.position() + position, resultData, this);
      }

      @Override
      public void failed(Throwable exc, ByteBuffer attachment) {
        resultFuture.completeExceptionally(exc);
      }
    });
  }
  • test code
 @Test
  public void asyncRead() throws InterruptedException, ExecutionException {
    FileProcess fileProcess = new FileProcess();
    ExecutorService executorService = Executors.newFixedThreadPool(8);
    List<CompletableFuture<Void>> futures = new ArrayList<>();

    Future<CompletableFuture<Stream<String>>> result = null;

    for (int i = 0; i < 100000; i++) {
      CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
        return fileProcess.readAsync(Path.of(bigSizeFile));
      }, executorService).thenCompose(Function.identity()).thenAccept(stringStream -> {
        //stringStream.toList();
        //stringStream.close(); // 스트림 사용 후 닫기 (주석 해제 필요)
      });
      futures.add(future);
    }
    // 모든 future가 완료될 때까지 기다립니다.
    CompletableFuture<Void> combinedFuture = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
    combinedFuture.join();  // 모든 작업이 완료될 때까지 블로킹

    System.out.println("모든 파일 처리 완료");
    executorService.shutdown();
  }

아래 두 사진은 각각 bigSize, smallSize를 가지고 non-blocking + Asynchronouss방식의 read를 테스트한 결과이다.

  • BigSize
  • SmallSize

총 8개의 스레드에 10만개의 요청을 동시에 보내어 테스트를 진행하였고 combinedFuture.join();을 확인해여 Total Time을 확인할 수 있다. 결과적으로 readAsync를 이용할때 작은 파일의 경우 약 10초 정도의 성능이 나왔고, 큰 파일의 경우 결과를 확인할 수 없었다. 이유는 아래와 같다.

큰 파일의 경우 AsynchronusFileChannel을 이용하여 비동기 io를 처리하는데 이 과정에서 파일이 큰 경우 여러개의 파일을 병렬로 읽기 때문에 위의 사진에서 확인 할 수 있듯이 파일당 약 1.2초정도 소요되며 10만개의 파일을 readAsync로 읽을 경우 약 30시간이 걸리는것을 확인 할 수 있다.

결론

이번 분석에서 Sync/Async와 Blocking/NonBlocking 성능을 측정하기 위해 read/readAsync를 사용하여 진행하였다. 성능 분석을 통해 분석한 결과로 크게 두가지 타입에 따라 적합한 메서드가 존재 했다.

큰 파일의 경우 Sync + blocking으로 수행하는 것이 가장 성능이 좋았고, 작은 파일의 경우 Async + non-blocking으로 수행하는것이 가장 성능이 좋은것을 확인 할 수 있었다.

추가적으로 이 개념을 network I/O 나 다른 event process I/O의 경우 적은 데이터가 왔다 갔다 하기 때문에 Async + non-blocking을 선택하는 것도 하나의 방법이 될 수 있다.

 

https://velog.io/@wonhee010/%EB%8F%99%EA%B8%B0vs%EB%B9%84%EB%8F%99%EA%B8%B0-feat.-blocking-vs-non-blocking

 

동기 vs 비동기 (feat. blocking vs non-blocking)

동기/비동기, blocking/non-blocking에 대해 알아봤다.

velog.io

 

https://velog.io/@mmy789/Java-NIO-6

 

[Java] 파일 비동기 채널

파일 비동기 채널

velog.io

 

https://engineering.linecorp.com/ko/blog/do-not-block-the-event-loop-part3

 

비동기 서버에서 이벤트 루프를 블록하면 안 되는 이유 3부 - Reactor 패턴과 이벤트 루프

들어가며 2부에서는 Java NIO를 소개하고 Selector를 이용한 멀티플렉싱 기반의 다중 접속 서버를 구현하는 방법을 알아보았습니다. 이번 3부에서는 Reactor 패턴과 이벤트 루프(event loop)에 대해 알아

engineering.linecorp.com

 

https://technet.tmaxsoft.com/upload/download/online/jeus/pver-20170202-000001/concurrency-utilities/chapter_managed_objects.html

 

제2장 Managed Objects

public class AppServlet extends HTTPServlet implements Servlet { // Retrieve our executor instance. @Resource(name=mtf1”) ManagedThreadFactory mtf; protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOExcepti

technet.tmaxsoft.com

 

https://homoefficio.github.io/2016/08/13/%EB%8C%80%EC%9A%A9%EB%9F%89-%ED%8C%8C%EC%9D%BC%EC%9D%84-AsynchronousFileChannel%EB%A1%9C-%EB%8B%A4%EB%A4%84%EB%B3%B4%EA%B8%B0/

 

대용량 파일을 AsynchronousFileChannel로 다뤄보기

Java 7 에는 비동기 방식의 File I/O를 지원하는 AsynchronousFileChannel이 추가되었다. 비동기 방식이므로 File I/O에 소요되는 시간 동안 다른 처리를 할 수 있다는 장점이 있다. 특히 용량이 큰 파일일 수

homoefficio.github.io

 

https://taes-k.github.io/2021/01/06/java-nio/

 

과연 java.nio는 java.io보다 항상 좋은 성능을 보일까?

java.nio는 java4 부터 java.io의 단점을 보완하기위해 추가된 패키지로 nio(new-io)라는 이름으로 등장 하였습니다. (+ java7 부터 nio2를 통해 java.io와 java.nio가 추가 개선되었습니다.) 이번 포스팅에서는 Fi

taes-k.github.io

 

https://blog.naver.com/PostView.naver?blogId=rain483&logNo=220643283347&parentCategoryNo=&categoryNo=32&viewDate=&isShowPopularPosts=false&from=postView

 

https://www.baeldung.com/java-completablefuture-non-blocking

 

반응형