Application/JAVA & Kotlin

자바 병렬성과 동시성 정리

seungh0 2023. 3. 13. 00:15
반응형
  • 병렬성: 한 테스크를 여러 하위 테스크로 나눠서 CPU의 다른 코어 또는 다른 머신에서 이들 하위 테스크를 병렬로 실행한다.
  • 동시성: 조금씩 연관된 작업을 같은 CPU에서 동작하는 것 또는 애플리케이션의 생산성을 극대화할 수 있도록 코어를 바쁘게 유지하는 것이 목표라면, 원격 서비스나 데이터베이스 결과를 기다리는 스레드를 블록함으로써 연산 자원을 낭비하는 일을 피해야 한다.

자바는 이런 환경에서 사용할 수 있는 두 가지 주요 도구를 제공한다.

  • Future 인터페이스로 자바 8의 CompletableFuture 구현은 간단하고 효율적인 문제 해결사.
  • 자바9에 추가된 발행 구독 프로토콜에 기반한 리액티브 프로그래밍 개념을 따르는 Flow API는 조금 더 정교한 프로그래밍 접근 방법을 제공한다.

동시성은 단일 코어 머신에서 발생할 수 있는 프로그래밍 속성으로 실행이 서로 곂칠 수 있는 반면, 병렬성은 병렬 실행을 하드웨어 수준에서 지원.

동시성을 구현하는 자바 지원의 진화

  • 처음에는 자바는 Runnable와 Thread를 동기화된 클래스와 메소드를 이용해 잠갔다.
  • 자바 5는 좀 더 표현력있는 동시성을 지원하는 특히 스레드 실행과 테스크 제출을 분리하는 ExecutorService 인터페이스, 높은 수준의 결과, 즉 Runnable, Thread의 변형을 반환하는 Callable<T> and Future<T> 제네릭 등을 지원했다. ExecutorService는 Runnable와 Callable 둘 다 실행할 수 있다.
  • 자바 7에서는 분할 그리고 정복 알고리즘의 포크/조인 구현을 지원하는 java.util.concurrent.RecursiveTask가 추가되었다.
  • 자바 8에서는 스트림과 새로 추가된 람다 지원에 기반한 병렬 프로세싱이 추가되었다. 자바는 Future를 조합하는 기능을 추가하면서 동시성을 강화(Future 구현인 CompletableFuture)했다.
  • 자바 9에서는 분산 비동기 프로그래밍을 명시적으로 지원한다. → 리액티브 프로그래밍이라 부르며 자바 9에서는 발행-구독 프로토콜 (java.util.concurrent.Flow) 인터페이스 추가로 이를 지원한다.
  • CompletableFuture와 Flow의 궁극적인 목표는 가능한한 동시에 실행할 수 있는 독립적인 테스크를 가능하게 만들면서, 멀티 코어 또는 여러 기기를ㄷ통해 제공되는 병렬성을 쉽게 이용하는 것.

스레드와 높은 수준의 추상화

  • 단일 CPU 컴퓨터도 여러 사용자를 지원할 수 있는데, 이는 운영체제가 각 사용자에게 프로세스 하나를 할당하기 때문이다.
  • 운영체제는 두 사용자가 각각 자신만의 공간에 있다고 생각할 수 있도록 가상 주소 공간을 각각 프로세스에 제공한다.
  • 운영체제는 주기적으로 번갈아가면서 각 프로세스에 CPU를 할당함으로써 프로세스는 다시 운영체제에 한 개이상의 스레드, 즉 본인이 가진 프로세스와 같은 주소 공간을 공유하는 프로세스를 요청함으로써 태스크를 동시에 또는 협력적으로 실행할 수 있다.

Executor와 스레드 풀

  • 자바 5는 Executor 프레임워크와 스레드 풀을 통해 스레드의 힘을 높은 수준으로 끌어올리는 즉 자바 프로그래머가 태스크 제출과 실행을 분리할 수 있는 기능을 제공했다.

스레드 문제

  • 자바 스레드는 직접 OS 스레드에 접근한다. OS 스레드를 만들고 종료하려면 비싼 비용 (페이지 테이블과 가관련된 상호작용)을 치러야 하며 더욱이 운영체제 스레드의 숫자는 제한되어 있는 것이 문제다.
  • 운영체제가 지원하는 스레드 수를 초과해 사용하면 자바 애플리케이션이 예상치 못한 방식으로 크래시될 수 있으므로 기존 스레드가 실행되는 상태에서 계속 새로운 스레드를 만드는 상황이 일어나지 않도록 주의해야한다.

스레드 풀 그리고 스레드 풀이 더 좋은 이유

  • ExecutorServic의 newFixedThreadPool 같은 팩토리 메소드 중 하나를 이용해 스레드 풀을 만들어 사용할 수 있다.
  • 스레드 풀에서 사용하지 않은 스레드로 제출된 테스크를 먼저 온 순서대로 실행한다. 이들 태스크 실행이 종료되면 이들 스레드를 풀로 반환한다.
  • 이 방식의 장점은 하드웨어에 맞는 수의 태스크를 유지함과 동시에 수 천개의 태스크를 스레드 풀에 아무 오버헤드 없이 제출할 수 있다는 점이다.
  • 큐의 크기 조정, 거부 정책, 태스크 종류에 따른 우선순위 등 다양한 설정을 할 수 있다.
  • 프로그래머는 태스크(Runnable or Callable)을 제공하면 스레드가 이를 실행한다.

스레드 풀 그리고 스레드 풀이 나쁜 이유

  • 거의 모든 관점에서 스레드를 직접 사용하는 것보다 스레드 풀을 이용하는 것이 바람직하지만 두 가지 사항을 주의해야 한다.
    • k 스레드를 가진 스레드 풀은 오직 k만큼의 스레드를 동시에 실행할 수 있다. 초과로 제출된 태스크는 큐에 저장되며 이전에 태스크 중 하나가 종료되기 전까지는 스레드에 할당하지 않는다.
      • 불필요하게 많은 스레드를 만드는 일을 피할 수 있으므로 보통 이 상황은 아무 문제가 되지 않지만 잠을 자거나 I/O를 기다리는 블록 상황에서 이들 태스크가 워커 스레드에 할당된 상태를 유지하지만 아무 작업도 하지 않게 된다.
      • 핵심은 블록할 수 있는 태스크는 스레드 풀에 제출하지 말아야 한다는 것이지만 항상 이를 지킬 수 있는 것은 아니다.
    • 중요한 코드를 실행하는 스레드가 죽는 일이 발생하지 않도록 보통 자바 프로그램은 main이 반환되기 전에 모든 스레드의 작업이 끝나길 기다린다. 따라서 프로그램을 종료하기전에 모든 스레들 풀을 종료하는 습관을 갖는 것이 중요하다.

스레드의 다른 추상화: 중첩되지 않은 메소드 호출

  • 비동기 메소드: 메소드 호출자에 기능을 제공하도록 메소드가 반환된 후에도 만들어진 태스크가 실행되는 메소드
    • 이들 메소드를 사용할 때 어떤 위험성이 따르는가?
      • 스레드 실행은 메소드를 호출한 다음에 코드와 동시에 실행되므로 데이터 경쟁 문제를 일으키지 않도록 주의해야 한다.
      • 기존 실행 중이던 스레드가 종료되지 않은 상황에서 자바의 main() 메소드가 반환하면 어떻게 될까? 두 가지 방벙비 있는데 어느 방법도 안전하지 못하다.
        • 애플리케이션을 종료하지 못하고 모든 스레드가 실행을 끝낼 때 까지 기다린다.
        • 애플리케이션 종료를 방해하는 스레드를 강제종료시키고 애플리케이션을 종료한다.
      • 첫 번째 방법에서는 잊고서 종료하지 못한 스레드에 의해 애플리케이션이 크래시될 수 있다. 또 다른 문제로 디스크에 쓰기 I/O 작업을 시도하는 일련의 작업을 중단했을 때 이로 인해 외부 데이터의 일관성이 파괴될 수 있다. 이들 문제를 피하려면 애플리케이션에서 만든 모든 스레드를 추적하고 애플리케이션이 종료하기 전에 스레드 풀에 모든 스레드를 종료하는 것이 좋다.
      • 자바 스레드는 setDaemon() 메소드를 이용해 데모 또는 비데몬으로 구분시킬 수 있다.
        • 데몬 스레드는 애플리케이션이 종료될 때 강제 종료되므로 디스크의 데이터 일관성을 파괴하지 않는 동작을 수행할 때 유용하게 활용될 수 있는 반면, main() 메소드는 모든 비데몬 스레드가 종료될 때 까지 프로그램을 종료하지 않고 기다린다.
반응형