2022년 7월 16일 토요일

Tuning the Size of Your Thread Pool

스레드 풀은 얼마나 커야 할까요?

얼마 전에 친구가 Skype에서 저에게 핑을 보내서 하루에 몇 번씩 30,000개의 스레드를 실행하는 64-way 상자에서 실행되는 JVM 클러스터에 대해 질문했습니다. 300,000개 이상의 스레드가 실행되면서 커널이 스레드를 관리하는 데 너무 많은 시간을 소비하여 애플리케이션이 완전히 불안정해졌습니다. 이 응용 프로그램에 스레드 풀이 필요하여 클라이언트가 제한을 받는 대신 클라이언트를 제한할 수 있다는 것은 매우 분명했습니다.

예제는 극단적인 것이지만 스레드 풀을 사용하는 이유를 강조합니다. 그러나 스레드 풀이 있는 경우에도 데이터 손실이나 트랜잭션 실패로 인해 사용자에게 여전히 고통을 줄 수 있습니다. 또는 풀이 너무 크거나 작으면 애플리케이션이 완전히 불안정해질 수 있습니다. 적절한 크기의 스레드 풀은 하드웨어와 응용 프로그램이 편안하게 지원할 수 있는 만큼 많은 요청을 실행할 수 있어야 합니다. 다시 말해, 처리 능력이 있는 경우 대기열에서 요청이 대기하는 것을 원하지 않지만 관리할 수 있는 능력보다 더 많은 요청을 시작하는 것도 원하지 않습니다. 그렇다면 스레드 풀은 얼마나 커야 할까요?

"추측하지 마십시오"라는 주문을 따르려면 먼저 문제의 기술을 살펴보고 어떤 측정이 의미가 있으며 이를 얻기 위해 시스템을 어떻게 계측할 수 있는지 질문해야 합니다. 우리는 또한 테이블에 약간의 수학을 가져와야 합니다. 스레드 풀이 큐와 결합된 하나 이상의 서비스 공급자라고 생각하면 이것이 리틀의 법칙을 사용하여 모델링할 수 있는 시스템임을 알 수 있습니다. 방법을 알아보기 위해 더 깊이 파헤쳐 보겠습니다.

리틀의 법칙

리틀의 법칙에 따르면 시스템의 요청 수는 요청이 도착하는 속도에 개별 요청을 처리하는 데 걸리는 평균 시간을 곱한 것과 같습니다. 이 법칙은 우리의 일상 생활에서 너무나 흔해서 1950년대까지 제안되지 않았고 1960년대에야 입증되었다는 것이 놀랍습니다. 다음은 작동 중인 리틀의 법칙의 한 형태의 예입니다. 줄을 서서 얼마나 오래 서 있을 것인지 계산하려고 시도한 적이 있습니까? 줄을 서 있는 사람의 수를 고려한 다음 대기열 맨 앞에 있는 사람에게 서비스를 제공하는 데 시간이 얼마나 걸렸는지 빠르게 확인할 수 있습니다. 그런 다음 이 두 값을 곱하여 줄을 얼마나 오래 서 있는지 추정합니다. 선의 길이를 보는 것보다

리틀의 법칙으로 할 수 있는 유사한 게임이 많이 있습니다. 이 게임은 "평균적으로 사람이 서빙을 받기 위해 줄을 서서 기다리는 시간은 얼마나 됩니까?"와 같은 다른 질문에 답할 것입니다. 등등.

그림 1. 리틀의 법칙

비슷한 맥락에서 우리는 스레드 풀 크기를 결정하기 위해 Little의 법칙을 사용할 수 있습니다. 우리가 해야 할 일은 요청이 도착하는 속도와 서비스를 제공하는 평균 시간을 측정하는 것입니다. 그런 다음 이러한 측정값을 Little의 법칙에 연결하여 시스템의 평균 요청 수를 계산할 수 있습니다. 해당 숫자가 스레드 풀의 크기보다 작으면 그에 따라 풀의 크기를 줄일 수 있습니다. 반면에 숫자가 스레드 풀의 크기보다 크면 상황이 조금 더 복잡해집니다.

실행되는 것보다 대기 중인 요청이 더 많은 경우 가장 먼저 확인해야 할 것은 시스템에 더 큰 스레드 풀을 지원하기에 충분한 용량이 있는지 여부입니다. 그렇게 하려면 응용 프로그램의 확장 기능을 제한할 가능성이 가장 큰 리소스를 결정해야 합니다. 이 기사에서는 CPU라고 가정하지만 실제로는 다른 CPU일 가능성이 매우 높다는 점에 유의하십시오. 가장 쉬운 상황은 스레드 풀의 크기를 늘릴 수 있는 충분한 용량이 있는 것입니다. 그렇지 않다면 애플리케이션을 조정하거나 하드웨어를 추가하거나 이 두 가지를 조합하는 다른 옵션을 고려해야 합니다.

작업 예제

소켓에서 요청을 가져와 실행하는 일반적인 워크플로를 고려하여 이전 텍스트의 압축을 풉니다. 실행 과정에서 결과가 클라이언트에 반환될 때까지 데이터 또는 기타 리소스를 가져와야 합니다. 데모에서 서버는 CPU 집약적인 작업을 수행하는 것과 데이터베이스 또는 기타 외부 데이터 소스에서 데이터를 검색하는 것 사이에서 변동하는 균일한 작업 단위를 실행하여 이 워크로드를 시뮬레이션합니다. 데모를 위해 Pi 또는 2의 제곱근과 같은 무리수에 대해 소수 자릿수를 계산하여 CPU 집약적 활동을 시뮬레이션해 보겠습니다. Thread.sleep은 외부 데이터 소스에 대한 호출을 시뮬레이션하는 데 사용됩니다. 스레드 풀은 서버의 활성 요청 수를 조절하는 데 사용됩니다.

java.util.concurrent(juc)에서 스레드 풀링 선택 사항을 모니터링하려면 자체 계측을 추가해야 합니다. 실생활에서 우리는 aspect, ASM 또는 다른 바이트 코드 계측 기술을 사용하여 jucThreadPoolExecutor를 계측하여 이것을 추가할 수 있습니다. 이 예제의 목적을 위해 필요한 모니터링을 포함하도록 jucThreadPoolExecutor 확장을 수동으로 롤링하여 이러한 주제를 피할 것입니다.

공개 클래스 InstrumentedThreadPoolExecutor 는 ThreadPoolExecutor를 확장 합니다. {

 // 모든 요청 시간을 추적합니다.
 private final ConcurrentHashMap<Runnable, Long> timeOfRequest =
         new ConcurrentHashMap<>();
 개인 최종 ThreadLocal<Long> startTime = new ThreadLocal<Long>();
 개인 긴 lastArrivalTime;
 // 다른 변수는 AtomicLongs 및 AtomicIntegers입니다.

 @Override
 protected void beforeExecute(Thread worker, Runnable task) {
   super .beforeExecute(worker, task);
   startTime.set(System.nanoTime());
 }

 @Override
 protected void afterExecute(Runnable task, Throwable t) {
   try {
     totalServiceTime.addAndGet(System.nanoTime() - startTime.get());
     totalPoolTime.addAndGet(startTime.get() - timeOfRequest.remove(작업));
     numberOfRequestsRetired.incrementAndGet();
   } 마지막으로 {
     슈퍼 .afterExecute(작업, t);
   }
 }

 @Override
 public void execute(Runnable task) {
   long now = System.nanoTime();

   numberOfRequests.incrementAndGet();
   동기화 (이) {
     if (lastArrivalTime != 0L ) {
       aggregateInterRequestArrivalTime.addAndGet(현재 - lastArrivalTime);
     }
     lastArrivalTime = 지금;
     timeOfRequest.put(작업, 지금);
   }
   슈퍼 .execute(작업);
  }
 }

목록 1. InstrumentedThreadPoolExecutor의 필수 비트

이 목록에는 우리 서버가 ThreadPoolExecutor 대신 사용할 계측 코드의 중요하지 않은 부분이 포함되어 있습니다. 세 가지 주요 Executor 메소드(beforeExecute, execute 및 afterExecute)를 재정의하여 데이터를 수집한 다음 해당 데이터를 MXBean으로 노출합니다. 각 방법이 어떻게 작동하는지 살펴보겠습니다.

실행기의 실행 메소드는 실행기로 요청을 전달하는 데 사용됩니다. 이 메서드를 재정의하면 모든 초기 타이밍을 수집할 수 있습니다. 또한 이 기회를 사용하여 요청 사이의 간격을 추적할 수 있습니다. 계산에는 각 스레드가 lastArrivalTime을 재설정하는 여러 단계가 포함됩니다. 상태를 공유하고 있으므로 해당 활동을 동기화해야 합니다. 마지막으로 슈퍼 Executor 클래스에 실행을 위임합니다.

이름에서 알 수 있듯이 executeBefore 메서드는 요청이 실행되기 직전에 실행됩니다. 이 시점까지 요청이 누적되는 모든 시간은 풀에서 기다리는 시간일 뿐입니다. 이것을 흔히 "데드 타임"이라고 합니다. 이 메서드가 종료되고 afterExecute가 호출되기 전의 시간은 Little의 법칙에 사용할 수 있는 "서비스 시간"으로 간주됩니다. 스레드 로컬에 시작 시간을 저장할 수 있습니다. afterExecute 메소드는 "풀에서의 시간", "요청을 처리하는 시간" 및 "요청이 폐기되었습니다" 등록에 대한 계산을 완료합니다.

또한 InstrumentedThreadPoolExecutor가 수집한 성능 카운터에 대해 보고하기 위해 MXBean이 필요합니다. 그것이 MXBean ExecutorServiceMonitor의 작업이 될 것입니다. (목록 2 참조).

공개 클래스 ExecutorServiceMonitor 는 ExecutorServiceMonitorMXBean
을 구현합니다 . {

 공개 이중 getRequestPerSecondRetirementRate() {
   반환(이중) getNumberOfRequestsRetired() /
fromNanoToSeconds(threadPool.getAggregateInterRequestArrivalTime());
 }

 공개 이중 getAverageServiceTime() {
   return fromNanoToSeconds(threadPool.getTotalServiceTime()) /
(이중) getNumberOfRequestsRetired();
 }

 public double getAverageTimeWaitingInPool() {
   return fromNanoToSeconds( this .threadPool.getTotalPoolTime()) /
(double) 이 .getNumberOfRequestsRetired();
 }

 public double getAverageResponseTime() {
   return this .getAverageServiceTime() +
this .getAverageTimeWaitingInPool();
 }

 공개 이중 getEstimatedAverageNumberOfActiveRequests() {
   반환 getRequestPerSecondRetirementRate() * (getAverageServiceTime() +
getAverageTimeWaitingInPool());
 }

 공개 이중 getRatioOfDeadTimeToResponseTime() {
   이중 poolTime = (이중) 이 .threadPool.getTotalPoolTime();
   반환 poolTime /
(poolTime + (더블) threadPool.getTotalServiceTime());
 }

 공개 이중 v() {
   반환 getEstimatedAverageNumberOfActiveRequests() /
(이중) Runtime.getRuntime().availableProcessors();
 }
}

목록 2. ExecutorServiceMonitor, 흥미로운 부분

목록 2에서 대기열이 어떻게 사용되는가? getEstimatedAverageNumberOfActiveRequests() 메서드는 Little의 법칙을 구현한 것입니다. 이 방법에서 폐기 비율 또는 관찰된 요청이 시스템을 떠나는 비율에 시스템의 평균 요청 수를 산출하는 요청을 처리하는 데 걸리는 평균 시간을 곱합니다. 다른 관심 메소드는 getRatioOfDeadTimeToResponseTime() 및 getRatioOfDeadTimeToResponseTime()입니다. 몇 가지 실험을 실행하고 이러한 숫자가 서로 및 CPU 사용률과 어떤 관련이 있는지 살펴보겠습니다.

실험 1

첫 번째 실험은 어떻게 작동하는지에 대한 감각을 제공하기 위해 의도적으로 사소한 것입니다. 간단한 실험을 위한 설정은 "서버 스레드 풀 크기"를 1로 설정하고 단일 클라이언트가 30초 동안 위에서 설명한 요청을 반복적으로 수행하도록 합니다.

(이미지를 클릭하시면 확대됩니다)

그림 2 클라이언트 1개, 서버 스레드 1개의 결과

그림 2의 그래픽은 VisualVM MBean 보기와 그래픽 CPU 사용률 모니터의 스크린샷을 찍어 얻은 것입니다. VisualVM( http://visualvm.java.net/ )은 성능 모니터링 및 프로파일링 도구를 수용하도록 구축된 오픈 소스 프로젝트입니다. 이 도구의 사용은 이 문서의 범위를 벗어납니다. 간단히 말해서, MBean 보기는 PlatformMBeansServer에 등록된 모든 JMX MBean에 대한 액세스를 제공하는 선택적 플러그인입니다.

실험으로 돌아가서 스레드 풀 크기는 1이었습니다. 클라이언트의 반복 동작을 감안할 때 활성 요청의 평균 수는 1이 될 것으로 예상할 수 있습니다. 그러나 클라이언트가 요청을 다시 발행하는 데 시간이 걸리므로 값은 1보다 약간 작아야 하며 0.98은 해당 예상과 일치해야 합니다.

다음 값인 RatioOfDeadTimeToResponseTime은 0.1%를 약간 넘습니다. 활성 요청이 하나만 있고 스레드 풀 크기와 일치한다는 점을 감안할 때 이 값은 0일 것으로 예상됩니다. 그러나 0이 아닌 결과는 타이머 배치 및 타이머 정밀도로 인해 발생하는 오류로 인한 것일 수 있습니다. 값은 상당히 작으므로 안전하게 무시할 수 있습니다. CPU 사용률은 1개 미만의 코어가 사용되고 있음을 알려줍니다. 이는 더 많은 요청을 처리할 수 있는 충분한 여유 공간이 있음을 의미합니다.

실험 2

두 번째 실험에서는 스레드 풀 크기를 1로 두고 동시 클라이언트 요청 수를 최대 10개로 늘렸습니다. 예상대로 한 번에 하나의 스레드만 작동하기 때문에 CPU 사용률은 변경되지 않았습니다. 그러나 활성 요청 수가 10개였기 때문에 더 많은 요청을 폐기할 수 있었습니다. 따라서 클라이언트는 클라이언트가 재발행할 때까지 기다릴 필요가 없었습니다. 이것으로부터 우리는 하나의 요청이 항상 활성이었고 대기열 길이가 9였다는 결론을 내릴 수 있습니다. 더 놀라운 것은 응답 시간에 대한 데드 타임의 비율이 90%에 가깝다는 것입니다. 이 수치는 클라이언트가 보는 총 응답 시간의 90%가 스레드를 기다리는 데 소요된 시간에 기인할 수 있음을 의미합니다. 레이턴시를 줄이고 싶다면,

(이미지를 클릭하시면 확대됩니다)

그림 3, 10개의 클라이언트 요청, 1개의 서버 스레드 결과

실험 3

코어 수가 4개이므로 스레드 풀 크기를 4로 설정하여 동일한 실험을 실행할 수 있습니다.

(이미지를 클릭하시면 확대됩니다)

그림 4. 10개의 클라이언트 요청, 4개의 서버 스레드 결과

다시 한 번 평균 요청 수가 10임을 알 수 있습니다. 이번에는 폐기된 요청 수가 2000을 약간 넘었다는 점입니다. 이에 상응하는 CPU 포화도 증가가 있지만 여전히 100%가 아닙니다. 데드 타임은 여전히 ​​전체 응답 시간의 60%에 불과하며, 이는 여전히 개선의 여지가 있음을 의미합니다. 스레드 풀 크기를 다시 늘려 추가 CPU를 흡수할 수 있습니까?

실험 4

이 마지막 실행에서 풀은 8개의 스레드로 구성되었습니다. 결과를 보면 예상대로 평균 요청 수가 10개 미만이고 데드 타임이 19% 미만으로 시작되며 폐기된 요청 수가 3621개로 다시 정상 증가했음을 알 수 있습니다. 그러나 CPU 사용률이 100%에 가까워지면 이러한 부하 조건에서 달성할 수 있는 개선 사항이 거의 끝나가고 있는 것처럼 보입니다. 또한 8이 이상적인 수영장 크기임을 시사합니다. 우리가 내릴 수 있는 또 다른 결론은 더 많은 데드 타임을 제거해야 하는 경우 이를 수행하는 유일한 방법은 더 많은 CPU를 추가하거나 애플리케이션의 CPU 효율성을 개선하는 것입니다.

(이미지를 클릭하시면 확대됩니다)

그림 5. 10개의 클라이언트 요청, 8개의 서버 스레드 결과

이론과 현실의 만남

이 실험에 대해 제기될 수 있는 다양한 비판 중 하나는 지나치게 단순화되었다는 것입니다. 대규모 응용 프로그램에는 스레드 또는 연결 풀이 하나만 있는 경우가 거의 없습니다. 실제로 많은 응용 프로그램에는 사용되는 각 통신 기술에 대해 풀이 하나 더 있습니다. 예를 들어, 애플리케이션에는 JMS의 요청과 함께 서블릿이 처리하는 HTTP 요청이 있을 수 있으며, JDBC 연결 풀도 있을 수 있습니다. 이 경우 서블릿 엔진에 대한 스레드 풀이 있습니다. JMS 및 JDBC 구성 요소에 대한 연결 풀도 있습니다. 그러한 경우에 해야 할 한 가지는 그것들을 모두 단일 스레드 풀로 처리하는 것입니다. 이것은 우리가 서비스 시간과 함께 도착율을 집계한다는 것을 의미합니다. 리틀의 법칙을 집계로 사용하여 시스템을 연구함으로써 총 스레드 수를 결정할 수 있습니다. 다음 단계는 다양한 스레드 그룹 간에 해당 번호를 분할하는 것입니다. 파티셔닝 로직은 성능 요구 사항에 따라 확실히 영향을 받는 여러 기술 중 하나를 사용할 수 있습니다. 한 가지 기술은 하드웨어에 대한 개별 수요에 따라 스레드 풀 크기의 균형을 맞추는 것입니다. 그러나 JMS를 통해 들어오는 요청보다 웹 클라이언트에 우선 순위를 부여하는 것이 중요할 수 있으므로 해당 방향으로 풀의 균형을 맞출 수 있습니다. 물론 재조정할 때 요구되는 하드웨어의 차이를 고려하여 각 풀의 크기를 조정해야 합니다. 그러나 JMS를 통해 들어오는 요청보다 웹 클라이언트에 우선 순위를 부여하는 것이 중요할 수 있으므로 해당 방향으로 풀의 균형을 맞출 수 있습니다. 물론 재조정할 때 요구되는 하드웨어의 차이를 고려하여 각 풀의 크기를 조정해야 합니다. 그러나 JMS를 통해 들어오는 요청보다 웹 클라이언트에 우선 순위를 부여하는 것이 중요할 수 있으므로 해당 방향으로 풀의 균형을 맞출 수 있습니다. 물론 재조정할 때 요구되는 하드웨어의 차이를 고려하여 각 풀의 크기를 조정해야 합니다.

또 다른 고려 사항은 이 공식이 시스템의 평균 요청 수에 초점을 맞춘다는 것입니다. 여러 가지 이유로 90번째 백분위수 대기열 길이를 알고 싶을 수 있습니다. 이 값을 사용하면 도착률의 자연스러운 변동을 처리할 수 있는 여유 공간을 더 많이 확보할 수 있습니다. 이 수치에 도달하는 것은 훨씬 더 복잡하며 평균에 20% 버퍼를 추가하는 것만큼이나 효과적일 것입니다. 이 작업을 수행할 때 많은 스레드를 처리할 수 있는 충분한 용량이 있는지 확인하십시오. 예를 들어, 우리의 예에서 8개의 스레드가 CPU를 최대화했기 때문에 더 많은 스레드를 추가하면 성능이 저하되기 시작할 수 있습니다. 마지막으로 커널은 스레드 풀보다 스레드 관리에 훨씬 더 효율적입니다. 따라서 8개 이상의 스레드를 추가하는 것이 도움이 되지 않을 수 있다고 추측했지만, 측정 결과 잠재적으로 흡수될 수 있는 시스템에 더 많은 헤드룸이 있음을 알 수 있습니다. 즉, 용량을 초과하여 실행(즉, 스트레스 테스트)하여 최종 사용자 응답 시간 및 처리량에 미치는 영향을 확인합니다.

결론

스레드 풀에 대한 적절한 구성을 얻는 것은 쉽지 않을 수 있지만 로켓 과학도 아닙니다. 문제 이면의 수학은 우리가 일상 생활에서 항상 그것들을 만난다는 점에서 잘 이해되고 상당히 직관적입니다. 부족한 것은 합리적인 선택을 하는 데 필요한 측정(jucExecutorService의 증인으로)입니다. 이것은 정확한 과학보다 버킷 화학에 가깝기 때문에 적절한 설정에 도달하는 것이 약간 까다롭지만 약간의 시간을 들여 만지작거리면 예상보다 높은 작업 부하로 인해 불안정해진 시스템을 처리해야 하는 두통을 줄일 수 있습니다. .

저자 소개

Kirk Pepperdine 은 Java 성능 조정 분야를 전문으로 하는 독립 컨설턴트로 일하고 있습니다. 또한 그는 전 세계적으로 호평을 받은 성능 튜닝 세미나를 저술했습니다. 이 세미나에서는 Java 성능 문제를 해결하는 데 있어 팀 효율성을 향상시키는 데 사용된 성능 조정 방법론을 제시합니다. 2006년 Java 챔피언으로 지명된 Kirk는 많은 출판물에서 성능에 대해 글을 썼고 Devoxx 및 JavaONE을 포함한 많은 컨퍼런스에서 성능에 대해 연설했습니다. 그는 성능 튜닝 정보 리소스로 잘 알려진 사이트인 Java Performance Tuning 을 찾는 데 도움을 주었습니다.

댓글 없음:

댓글 쓰기