8장: 스레드 풀 활용

작업과 실행 정책 간 보이지 않는 연결 관계

  • 타이밍, 작업 결과, 다른 작업의 조건 등에 영향을 받지 않는 독립적인 작업은 스레드 풀에서 실행시켜도 문제가 없다.

  • 결국 스레드 풀은 동일하고 서로 독립적인 다수의 작업을 실행할 때 가장 효과적이다.

  • 다른 작업에 의존성을 갖는 작업을 스레드 풀에서 실행하려는 경우 데드락 등의 문제가 발생할 여지가 있으므로 보이지 않는 조건을 면밀히 따져 대비해야 한다.

    • 단일 스레드로 동작할 때 적합한 경우 Executor 프레임워크를 사용해도 하나의 스레드만으로 동작해야 하는 조건이 생긴다.

  • 응답 시간에 민감한 작업을 스레드 풀에 위임했는데, 다른 스레드들이 모두 오래걸리는 작업을 수행하고 있는 경우 응답 성능이 떨어질 수 있다.

  • Executor가 자동으로 유휴 스레드를 제거하거나 작업이 많으면 새로운 스레드를 추가하기 때문에 ThreadLocal을 기반으로 동작하는 작업을 스레드 풀에 위임하면 안된다.

  • Thread Starvation Deadlock

    • 스레드 풀에서 다른 작업에 의존하는 어떠한 작업을 실행할 때, 조건이 충족되지 않아 작업이 계속 대기하게 되는 상태를 의미한다.

    • 특정 작업 1개를 n개의 작업이 의존하고 있고, n개의 작업이 각각 스레드 풀의 스레드에 할당되어 있는 경우, 새로운 특정 작업 1개는 스레드 풀을 통해 진행될 수 없어 영원히 멈추게 된다.

  • 특정 작업이 오래 실행된다면 스레드 풀 전체의 응답 속도에 영향이 갈 수 있다. 오래 걸리는 작업이 생기지 않도록 가급적 시간 제한을 둘 수 있는 메서드를 사용하고, 실패한 경우 다시 작업 큐에 할당하는 것이 바람직하다.

스레드 풀 크기 조절

  • 스레드 풀에서 실행할 작업의 종류와 스레드 풀을 활용할 애플리케이션 특성에 따라 크기를 정할 수 있다.

  • 스레드 풀의 크기는 Runtime#availableProcessors 등의 메서드를 통해 동적으로 지정되도록 해야 한다.

  • 크기가 너무 크면 스레드 간에 CPU, 메모리 자원 획득을 위해 경쟁하게 되어 자원 고갈이 발생할 수 있다.

  • 스레드 풀이 너무 작으면 자원은 충분하지만 작업이 실행되지 못할 수 있다.

  • CPU bounded 작업인 경우 CPU 코어 + 1개의 스레드를 가진 스레드 풀이 최적의 성능을 발휘한다고 알려져 있다.

    • 페이징 오류 등의 원인으로 인해 스레드가 멈추는 경우가 있기 때문에 1개의 추가 스레드를 마련해두면 CPU가 쉬지 않고 일할 수 있다.

  • I/O bounded 작업 또는 블로킹 작업인 경우 모든 스레드가 대기 상태에 들어가기 쉬우므로 스레드 풀의 크기를 크게 잡아야 한다.

  • 실제 작업하는 시간 대비 대기 시간의 비율을 측정해보거나, 직접 테스트해보는 것이 좋다.

  • 다음 수식을 통해 원하는 활용도를 유지할 수 있는 스레드 풀의 크기를 구할 수 있다.

    • (CPU 개수) * (목표로 하는 CPU 활용 비율 ex. 0.5) * (1 + 작업 시간 대비 대기 시간의 비율)

  • CPU가 아닌 메모리, 소켓, 데이터베이스 연결 등을 기준으로 스레드 풀 크기를 지정하려면, 단순히 필요한 자원의 총합 / 자원의 전체 개수 로 지정하면 된다.

ThreadPoolExecutor 설정

  • ThreadPoolExecutor는 스레드 풀을 생성하는 팩토리 메서드(newFixedThreadPool, newScheduledThreadPool 등)를 제공한다.

설정 인자

  • 여러 설정을 통해 원하는 형태대로 스레드 풀을 생성할 수 있다.

    • corePoolSize

      • 실행할 작업이 없어도 스레드 개수를 최대한 코어 크기에 맞춘다.

      • 단, 최초에 ThreadPoolExecutor를 생성한 경우 작업이 실행되면서 코어 크기까지 스레드가 차례로 생성된다.

      • prestartAllCoreThreads 메서드 호출 시 작업을 맡기기 전에 스레드를 준비시킬 수 있다.

    • maximumPoolSize

      • 동시에 동작 가능한 스레드의 최대 개수를 지정한다.

      • 큐에 작업이 가득 찼다면 스레드를 늘려 작업을 바로 할당한다.

      • 스레드 개수가 이만큼 도달한 상태라면 작업이 들어왔을 때 집중 대응 정책에 따라 거부할 수도 있다.

    • keepAliveTime

      • 설정한 스레드 유지 시간이 지나도록 아무런 작업 없이 대기하고 있던 스레드는 제거 대상이 된다. 스레드 풀의 스레드 개수가 코어 크기를 넘어있는 경우 해당 스레드는 제거된다.

    • workQueue

      • 작업을 쌓아둘 BlockingQueue 객체를 지정할 수 있다.

      • 대부분 기본적으로 LinkedBlockingQueue를 사용하여 작업이 처리되는 속도보다 추가되는 속도가 빠르면 큐에 계속해서 작업이 쌓인다.

      • 자원 관리 측면에서는 크기가 제한된 BlockingQueue를 사용하는 것이 안정적이다. 이 경우 큐가 꽉 찬 상황에서 새로운 작업을 등록하려할 때 처리 방안을 마련해야 한다. 또한 큐의 크기와 스레드 개수를 같이 튜닝해야 한다.

      • 스레드 수가 매우 많아 작업이 쌓일 가능성이 없는 경우 SynchronousQueue를 사용해 바로 스레드에게 실행하도록 넘길 수 있다.

      • PriorityBlockingQueue를 사용하면 작업이 지정된 우선 순위에 따라 실행되도록 할 수 있다.

  • 스레드 풀에서 실행할 작업이 다른 작업에 의존성을 갖는 경우, 스레드 부족 데드락에 빠질 수 있으므로 크기가 제한되지 않은 풀을 사용하는 것이 좋다.

집중 대응 정책

  • saturation policy

  • 크기가 제한된 큐에 작업이 가득차있을 때 새로운 작업을 실행시키는 경우 어떻게 처리할 지 지정할 수 있다.

  • Abort

    • RejectedExecutionException 예외를 던져 작업을 추가할 수 없음을 알린다.

  • Discard

    • 작업을 추가하지 않고 무시한다.

  • Discard Oldest

    • 가장 오래 전에 추가된 작업을 제거하고, 새로운 작업을 추가한다.

    • 우선 순위 큐인 경우 이 방식을 사용하면 안된다.

  • Caller Runs

    • 작업을 제거하거나 예외를 던지지 않고, 스레드 풀에 작업을 할당하려 했던 스레드에서 작업을 실행시키는 방식이다.

    • 예를 들어 웹 서버의 스레드가 별도 스레드 풀에 작업을 할당하려 했지만 이를 실패하면, 웹 서버의 스레드들이 직접 작업을 수행할 것이다. 요청이 여전히 많이 들어온다면 웹 서버의 스레드들도 고갈될 것이고, 자연스럽게 TCP 계층에서 접속 요청을 자체 큐에 쌓아 대기시킨다. TCP 계층에서도 큐에 요청을 쌓을 수 없는 상태가 되면 연결이 최종적으로 거부될 것이다. 이렇게 점진적으로 성능이 떨어지도록 하는 것이 이 방식의 목적이다.

  • 작업 큐가 가득 찼을 때 작업을 추가하려던 execute 메서드를 대기시키는 정책은 따로 없다. 해당 방법을 원한다면 직접 외부에서 구현해야 한다.

  • 큐의 크기를 제한하지 않고 작업 추가 속도를 적절한 범위에서 제한하는 방법은 다음과 같다.

    • 스레드 풀의 스레드 개수 + 큐에서 대기하도록 허용하는 최대 작업 개수 값을 세마포어 크기로 설정하여 작업 추가 시 세마포어를 획득하도록 한다.

동적 변경

  • ThreadPoolExecutor 객체를 생성한 후에도 setter 메서드를 통해 설정 값을 변경할 수 있다.

  • 이를 막고자 한다면 unconfigurableExecutionService 메서드를 통해 동적 변경이 불가능한 객체를 얻을 수 있다.

스레드 팩토리

  • 스레드 풀에서 생성할 스레드에 대해 설정할 수 있다.

  • 스레드의 이름을 직접 만든 규칙에 의해 붙이거나, 스레드의 실행 우선 순위 조절, 데몬 상태 지정, UncaughtExceptionHandler 지정 등의 목적으로 사용할 수 있다.

ThreadPoolExecutor 상속

  • ThreadPoolExecutor는 상속받아 기능을 추가할 수 있도록 설계되었다.

  • beforeExecute, afterExecute 메서드를 구현하면 작업 실행 전후에 특정 작업을 수행할 수 있다.

    • beforeExecute 과정에서 예외가 발생하면 실제 execute 메서드도 수행되지 않는다.

    • afterExecute 과정은 execute 메서드 내에서 어떤 일이 발생하든 항상 수행된다.

  • 스레드 풀의 종료 절차가 모두 마무리되면 terminated 훅 메서드를 호출한다. 이 때 각종 자원을 반납하거나 로그 출력, 통계 확보 등의 작업을 수행할 수 있다.

재귀 함수 병렬화

  • 특정 작업을 여러 번 실행하는 반복문이 있을 때 각 작업들이 서로 독립적이라면 병렬화하여 성능적 이점을 얻을 수 있다.

  • 반복문의 각 단계에서 실행되는 작업이 내부에서 재귀적으로 호출했던 작업의 결과를 사용하지 않는 경우에도 작업을 병렬화할 수 있다.

  • 모든 연산이 완료되길 기다리려면 shutdown 메서드와 awaitTermination 메서드를 차례로 호출하면 된다.

  • 하나의 연산이 완료되었을 때 다른 스레드들의 작업을 중단하게 하려면, 스레드 내부에서 항상 latch를 확인한 후 작업을 실행하면 된다.

Last updated