glenn-syj / more-effective-java

이펙티브 자바를 읽으며 자바를 더 효율적으로 공부합니다
4 stars 5 forks source link

[MEJ-011] Thread의 생성과 실행 그리고 ForkJoinPool #197

Open glenn-syj opened 1 month ago

glenn-syj commented 1 month ago

based on: #195 by @undeadtimo

들어가며

parallel 메서드와 parrallelStream 메서드에 대해 조사한 글을 읽으며, 제가 스레드에 대한 이해가 더욱 필요할 것 같아 추가적으로 자바에서 Thread가 생성되고 실행되는지를 실험해보고자 했습니다. 특히 #195 에서 Thread.currentThread().getName()의 결과에서 개별 스레드의 이름이 ForkJoinPool.commonPool-worker-1와 같은 식으로 나타나는 이유가 궁금했습니다.

코드로 스레드 이해하기

스레드의 간단한 이해 스레드는 프로세스 내에서 작업을 실행하는 작은 단위라고 볼 수 있습니다. 그렇다면 하나의 프로세스에서 같은 자원을 공유하는 여러 스레드가 실행된다면, 이를 멀티 스레딩(multi-threading)이라 부를 수 있겠고요. 자바에서는 Thread 클래스와 Runnable 인터페이스를 통해 멀티 스레딩을 지원하고 있습니다.

스레드의 생성과 실행 스레드를 생성하는 방법에는 (1) Thread 클래스를 상속받거나 (2) Runnable 인터페이스를 구현하는 방식이 있습니다. 이번 코드에서는 람다식을 이용해 (2) Runnable 인터페이스를 구현해보겠습니다. 특히, Runnable이 run이라는 추상 메소드 하나만 가지는 함수형 인터페이스라는 점도 알아두면 좋겠습니다.

package java_test;

/*
* ChatGPT 코드를 활용했으나, 실험에 맞게 수정했습니다.
*/
public class ThreadTest {
    public static void main(String[] args) {

        Runnable task = () -> {
            String threadName = Thread.currentThread().getName();
            System.out.println("Current thread name: " + threadName);
        };

        Runnable task2 = () -> {
            String threadName = Thread.currentThread().getName();
            System.out.println("Current thread name: " + threadName);
        };

        Runnable task3 = () -> {
            String threadName = Thread.currentThread().getName();
            System.out.println("Current thread name: " + threadName);
        };

        System.out.println("Main thread name: " + Thread.currentThread().getName());

        Thread thread = new Thread(task);
        Thread thread2 = new Thread(task2);
        Thread thread3 = new Thread(task3, "custom-thread-name");

        thread.start();
        thread2.start();
        thread3.start();

    }
}

위 코드를 차근차근 살펴보고 생각해봅시다. 총 몇 개의 행이 출력될까요?

4개의 행이 출력됩니다. main 스레드 내부에서 바로 출력되는 행이 하나, 스레드를 생성하고 실행하면서 출력되는 행이 셋입니다. 위 코드에서는 Thread thread = new Thread(task) 와 같이, Runnable 구현체를 Thread 생성자에 전달하여 스레드를 셋 만들었습니다. 여기에서 각 스레드가 실행된다면, 전달 받은 task가 진행됩니다. 그리고 각 task는 현재 자신이 올려진 스레드의 이름을 출력하도록 합니다. 결과는 어떨까요?

Main thread name: main
Current thread name: Thread-1
Current thread name: custom-thread-name
Current thread name: Thread-0

여기서 주목해야 할 점은 두 가지 입니다. 하나는 custom-thread-name 또는 Thread-X라는 이름의 스레드명이고, 다른 하나는 스레드를 시작한 순서와 출력되는 순서가 다르다는 점입니다.

사실 자바에서 스레드명을 따로 지정해주지 않는다면 기본값은 Thread-번호와 같은 형식으로 할당됩니다. 위 코드에서, Thread thread3 = new Thread(task3, "custom-thread-name");와 같은 방식으로 스레드명을 지정해주는 경우는 달라지지만요.

또한, thread -> thread1 -> thread2 순서로 스레드가 시작되었던 것과 달리 출력 순서는 뒤죽박죽입니다.(일치할 수도 있습니다.) 사실 뒤죽박죽이라는 표현보다는, 순서를 예측할 수 없다는 표현이 정확합니다. 이는 JVM이 바탕하는(underlying) OS의 복잡한 스케쥴링 시스템과 함께 스레드를 관리하는 까닭입니다. 운영체제의 스케줄러는 여러 상태를 고려하고, 컨텍스트 스위칭도 일어날 것이고, 멀티코어 프로세서까지 신경쓴다면 실행 순서 예측은 불가능에 가깝습니다.

ForkJoinPool과 스레드

ForkJoinPool이란 이제 #195 로 다시 돌아와 봅시다. 순차 스트림을 병렬 스트림으로 바꾸는 parallel() 메서드와 병렬 스트림을 생성하는 parallelStream() 메서드는 모두 내부적으로 포크 조인 풀(Fork-Join Pool)을 이용합니다. 포크 조인 풀은 작업을 분할하고, 분할된 작업을 병렬로 실행하고, 관리하며 관찰하는데요. 이 과정에서 이름에 포크-조인(Fork-Join)이 쓰이는 이유가 드러납니다. 분할 정복의 개념을 떠올리면 도움이 됩니다.

포크(Fork)는 작업을 분할하는 것으로, 큰 작업을 작은 서브태스크로 재귀적 분할하는 일을 의미합니다.

조인(Join)은 작업을 병합하는 것으로, 서브태스크들의 결과를 취합해 최종 결과를 얻는 일을 의미합니다.

추가적으로, 포크-조인 풀은 워크-스틸링(Work-Stealing) 알고리즘을 이용해 유휴 상태가 되면 다른 스레드의 작업을 훔쳐서 작업을 이어나갑니다. 이는 구현된 포크-조인 풀 클래스가 일반적인 ExecutorService 클래스와 비교해 가지는 차이가 됩니다.

이러한 ForkJoinPool에서 실행되는 작업의 골조가 되는 ForkJoinTask<V> 추상 클래스는 RecursiveTask<V>RecursiveAction과 같은 하위 클래스를 가집니다. 전자는 결과를 반환하는 작업이고, 후자는 결과를 반환하지 않는 작업입니다.

ForkJoinPool의 스레드 풀 관리

ForkJoinPool.commonPool()은 자바 8부터 제공하고, 어플리케이션 전역에서 공유되는 공통 포크-조인 풀이라고 할 수 있습니다. 따로 명시하지 않는다면 모든 ForkJoinTask에서 이용되는 인스턴스입니다. 대략적인 스레드 풀은 아래와 같이 관리됩니다.

  1. 스레드 생성
  2. 작업 큐 관리
  3. 작업 분할(Fork) 및 병합(Join)
  4. 워크-스틸링

(1) 먼저, 시스템 가용 프로세서 수에 기반한 스레드가 생성됩니다. (2) 각 스레드가 가지는 작업 큐에서 작업이 추가되거나 큐에서 작업이 가져와 실행됩니다. (3) 작업은 작은 서브태스크로 분할되어 독립적으로 실행된 이후 병합되어 최종 결과가 나옵니다. (4) 유휴 상태가 된 스레드는 다른 스레드의 작업을 가져와 처리합니다.

스레드 이름 다시 보기

ForkJoinPool.commonPool-worker-1를 이제 이해할 수 있겠죠? 자바 어플리케이션에서 공통적으로 이용되는 포크-조인 풀 인스턴스 ForkJoinPool.commonPool에서 프로세스를 처리하는 worker 중 번호 1번인 스레드라고요.

나가며

이번 탐구를 진행하면서, 스레드의 기본 개념과 관리에 대한 기초적인 이해를 다질 수 있어서 좋았는데요. 나아가 포크-조인이나 워크-스틸링 등의 기본적인 용어를 마주하면, 근간이 되는 컴퓨터 과학 지식이 필요함도 느껴집니다.

특히, 탐구 내내 스레드가 프로세스 내에서 같은 자원을 공유한다는 점을 의식하게 되었는데요. 비록 글에 담지는 못했지만, 스레드 제어나 멀티 스레딩에서의 주의점, 자바 synchronized 키워드에 대해서도 추가적으로 고민해보게 되는 좋은 기회였습니다.

References

https://www.quora.com/Why-do-threads-finish-in-a-random-order-in-Java https://stackoverflow.com/questions/41759261/how-jvm-thread-scheduler-control-threads-for-multiprocessors https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ForkJoinPool.html

undeadtimo commented 1 month ago

깔끔하게 정리한 글 감사합니다. 덕분에 모르고 지나칠 뻔한 지식을 얻게 되었군요.

저는 스레드의 작업이 끝날 때 이름을 출력하도록 하였으나, 출력되는 ForkJoinPool에 대해서 관심을 가지지 않고 넘어갔습니다.

그러나 ForkJoinPool은 멀티 스레딩의 작업 처리를 분할 정복 및 work-stealing 기법을 통해 효율적으로 진행되도록 하는 중요한 클래스였군요.

glenn-syj님의 해당 이슈를 읽고 관심이 생겨 조사를 해보니 ExecutorService에도 ForkJoinPool 처럼 멀티 스레딩 방식의 병렬 처리 과정을 효율적으로 진행되게 해주는 기능이 있었습니다.


ForkJoinPool commonPool = ForkJoinPool.commonPool();
ExecutorService workStealingPool = Executors.newWorkStealingPool();

ForkJoinPool는 commonPool()을 통해 작업 스레드를 관리하고, ExecutorService는 newWorkStealingPool()를 통해 작업 스레드를 관리할 수 있습니다.

해당 newWorkStealingPool()은 JDK 1.8 버전부터 등장한 것으로 ForkJoinPool의 commonPool()과 비슷하지만 확연한 차이를 가지고 있습니다.

  1. ForkJoinPool.commonPool은 LIFO(last-in, first-out) 순서로 다른 스레드의 deque에서 work-stealing을 수행합니다. 반면 ExecutorService.newWorkStealingPool은 FIFO(first-in, first-out) 순서로 다른 스레드의 deque에서 work-stealing을 수행합니다.

https://gee.cs.oswego.edu/dl/papers/fj.pdf

연구결과에 따르면, work-stealing 수행에 있어서 LIFO 보다 FIFO 방식이 더 효율적 이라고 합니다.

그 이유는, deque의 소유자 스레드가 작업하는 곳과 반대되는 곳에서 work-stealing을 수행하기 때문에 충돌이 적으며, 거대한 수준의 작업을 분할 정복 알고리즘을 통해 처리하는 과정에서 FIFO 방식이 더 빠르기 때문이라고 합니다.

  1. ForkJoinPool.commonPool은 작업의 분할정도가 세밀하든, 세밀하지 않든 work-stealing이 일정하게 발생하지만, ExecutorService.newWorkStealingPool은 작업의 분할정도가 세밀하다면 work-stealing이 많이 일어나지만, 작업의 분할정도가 세밀하지 않다면 work-stealing이 줄어듭니다.

작업의 분할정도가 세밀하지 않을 때, work-stealing을 많이 수행한다면 불필요한 오버헤드가 발생하여 오히려 work-stealing을 적게 수행하는 것보다 작업의 효율이 떨어질 수 있습니다.

work-stealing에는 작업 큐에 접근하기 위한 동기화(ex. Compare-And-Swap) 비용, 어떤 스레드의 작업에 대해 work-stealing을 수행할지 판단하는 비용, 캐시 적중률 감소 비용과 같은 불필요한 오버헤드가 존재합니다.

따라서 work-stealing은 작업의 분할정도가 세밀하지 않을 때 같은 특수한 경우에는 오히려 많이 수행할 수록 작업 효율을 감소시킬 수 있습니다.

즉, ForkJoinPool.commonPool 보다 ExecutorService.newWorkStealingPool이 더 효율적인 작업 처리 과정을 보여줍니다.

Reference

https://www.baeldung.com/java-work-stealing