2022년 7월 16일 토요일

The importance of tuning your thread pools

https://blog.bramp.net/post/2015/12/17/the-importance-of-tuning-your-thread-pools/

의 번역글 


알고 있든 모르든 Java 웹 애플리케이션은 들어오는 요청을 처리하기 위해 스레드 풀을 사용하고 있을 가능성이 큽니다. 이것은 많은 사람들이 간과하는 구현 세부 사항이지만 조만간 풀이 사용되는 방식과 애플리케이션에 맞게 풀을 올바르게 조정하는 방법을 이해해야 합니다. 이 기사는 스레드 모델, 스레드 풀이 무엇인지, 올바르게 구성하기 위해 무엇을 해야 하는지 설명하는 것을 목표로 합니다.



단일 스레드

몇 가지 기본 사항부터 시작하여 스레드 모델의 진화를 진행해 보겠습니다. 어떤 애플리케이션 서버나 프레임워크를 사용 하든 Tomcat , Dropwizard , Jetty 는 모두 동일한 기본 접근 방식을 사용합니다. 웹 서버 깊숙한 곳에는 소켓이 있습니다. 이 소켓은 들어오는 TCP 연결을 수신 대기 중이며 수락합니다. 일단 수락되면 새로 설정된 TCP 연결에서 데이터를 읽고 구문 분석하여 HTTP 요청으로 전환할 수 있습니다. 그런 다음 이 요청은 원하는 작업을 수행하기 위해 웹 응용 프로그램으로 전달됩니다.

스레드의 역할에 대한 이해를 돕기 위해 애플리케이션 서버를 사용하지 않고 처음부터 간단한 서버를 구축합니다. 이 서버는 대부분의 애플리케이션 서버가 내부에서 수행하는 작업을 미러링합니다. 우선 단일 스레드 웹 서버는 다음과 같이 보일 수 있습니다.

ServerSocket listener = new ServerSocket(8080);
try {
	while (true) {
		Socket socket = listener.accept();
		try {
			handleRequest(socket);
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
} finally {
	listener.close();
}

이 코드 는 포트 8080에 ServerSocket 을 생성한 다음 엄격한 루프에서 ServerSocket이 수락할 새 연결을 확인합니다. 수락되면 소켓이 handleRequest 메소드로 전달됩니다. 이 메서드는 일반적으로 HTTP 요청을 읽고 필요한 모든 프로세스를 수행하고 응답을 작성합니다. 이 간단한 예에서 handleRequest는 한 줄을 읽고 짧은 HTTP 응답을 반환합니다. handleRequest가 데이터베이스에서 읽거나 다른 종류의 IO를 수행하는 것과 같이 더 복잡한 작업을 수행하는 것은 정상입니다.

final static String response =
	"HTTP/1.0 200 OK\r\n" +
	"Content-type: text/plain\r\n" +
	"\r\n" +
	"Hello World\r\n";

public static void handleRequest(Socket socket) throws IOException {
	// Read the input stream, and return "200 OK"
	try {
		BufferedReader in = new BufferedReader(
			new InputStreamReader(socket.getInputStream()));
		
		log.info(in.readLine());

		OutputStream out = socket.getOutputStream();
		out.write(response.getBytes(StandardCharsets.UTF_8));
	} finally {
		socket.close();
	}
}

수락된 모든 소켓을 처리하는 단일 스레드만 있으므로 다음 요청을 수락하기 전에 각 요청을 완전히 처리해야 합니다. 실제 응용 프로그램에서는 동등한 handleRequest 메서드가 반환되는 데 100밀리초 정도 소요되는 것이 정상일 수 있습니다. 이 경우 서버는 초당 10개의 요청만 처리하도록 제한됩니다.

다중 스레드

IO에서 handleRequest가 차단될 수 있지만 CPU는 더 많은 요청을 처리할 수 있습니다. 단일 스레드 접근 방식에서는 이것이 불가능합니다. 따라서 이 서버는 여러 스레드를 생성하여 동시 작업을 허용하도록 개선할 수 있습니다.

public static class HandleRequestRunnable implements Runnable {
	final Socket socket;

	public HandleRequestRunnable(Socket socket) {
		this.socket = socket;
	}

	public void run() {
		try {
			handleRequest(socket);
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}

// Main loop here
ServerSocket listener = new ServerSocket(8080);
try {
	while (true) {
		Socket socket = listener.accept();
		new Thread( new HandleRequestRunnable(socket) ).start();
	}
} finally {
	listener.close();
}

여기에서 accept()는 여전히 단일 스레드 내에서 긴밀한 루프로 호출되지만 TCP 연결이 수락되고 소켓이 사용 가능하면 새 스레드가 생성됩니다. 이 생성된 스레드는 위에서 동일한 handleRequest 메서드를 단순히 호출하는 HandleRequestRunnable을 실행합니다.

새 스레드를 생성하면 이제 원래 accept() 스레드를 해제하여 더 많은 TCP 연결을 처리하고 애플리케이션이 요청을 동시에 처리할 수 있습니다. 이 기술을 "요청당 스레드"라고 하며 가장 널리 사용되는 접근 방식입니다. 이벤트 기반 비동기 모델 NGINX 및 Node.js 배포 와 같은 다른 접근 방식이 있지만 스레드 풀을 사용하지 않으므로 이 기사의 범위를 벗어납니다.

요청당 스레드 접근 방식에서는 JVM과 OS 모두 리소스를 할당해야 하므로 새 스레드를 생성하고 나중에 삭제하는 데 비용이 많이 들 수 있습니다. 또한 위의 구현에서 생성되는 스레드의 수는 무제한입니다. 무제한은 리소스 고갈로 빠르게 이어질 수 있으므로 매우 문제가 됩니다.

자원 고갈

각 스레드에는 스택에 대해 특정 양의 메모리가 필요합니다. 최근 64비트 JVM에서 기본 스택 크기 는 1024KB입니다. 서버가 요청을 많이 받거나 handleRequest 메소드가 느려지면 서버는 엄청난 수의 동시 스레드로 끝날 수 있습니다. 따라서 1000개의 동시 요청을 관리하기 위해 1000개의 스레드는 스레드의 스택에 대해서만 1GB의 JVM RAM을 소비합니다. 또한 각 스레드에서 실행되는 코드는 요청을 처리하는 데 필요한 힙에 개체를 생성합니다. 이것은 매우 빠르게 합산되고 JVM에 할당된 힙 공간을 초과할 수 있어 가비지 수집기에 압력을 가하여 스래싱을 ​​일으키고 결국 OutOfMemoryErrors 로 이어 집니다.

스레드는 RAM을 소비할 뿐만 아니라 파일 핸들이나 데이터베이스 연결과 같은 다른 유한 리소스를 사용할 수 있습니다. 이를 초과하면 다른 유형의 오류 또는 충돌이 발생할 수 있습니다. 따라서 리소스 소진을 방지하려면 무제한 데이터 구조를 피하는 것이 중요합니다.

만병 통치약은 아니지만 스택 크기 문제는 -Xss 플래그로 스택 크기를 조정하여 어느 정도 완화할 수 있습니다. 스택이 작을수록 스레드당 오버헤드가 줄어들지만 잠재적으로 StackOverflowErrors 가 발생할 수 있습니다. 마일리지는 다양하지만 많은 응용 프로그램의 경우 기본 1024KB가 과도하며 더 작은 256KB 또는 512KB 값이 더 적절할 수 있습니다. Java가 허용하는 가장 작은 값은 160KB입니다.

스레드 풀

계속해서 새 스레드를 생성하지 않고 최대 수를 제한하기 위해 간단한 스레드 풀을 사용할 수 있습니다. 간단히 말해서 풀은 모든 스레드를 추적하여 상한선까지 필요할 때 새 스레드를 만들고 가능한 경우 유휴 스레드를 재사용합니다.

ServerSocket listener = new ServerSocket(8080);
ExecutorService executor = Executors.newFixedThreadPool(4);
try {
	while (true) {
		Socket socket = listener.accept();
		executor.submit( new HandleRequestRunnable(socket) );
	}
} finally {
	listener.close();
}

이제 이 코드는 스레드를 직접 생성하는 대신 스레드 풀 전체에서 실행할 작업(Runnable의 관점에서)을 제출하는 ExecutorService를 사용합니다. 이 예에서 4개의 스레드로 구성된 고정 스레드 풀은 들어오는 모든 요청을 처리하는 데 사용됩니다. 이것은 "진행 중인" 요청의 수를 제한하므로 리소스 사용량에 제한이 있습니다.

newFixedThreadPool 외에도 Executors 유틸리티 클래스는 newCachedThreadPool 메서드도 제공합니다. 이것은 이전의 무제한 스레드 수로 인해 어려움을 겪지만 가능할 때마다 이전에 생성되었지만 지금은 유휴 스레드를 사용합니다. 일반적으로 이 유형의 풀은 외부 리소스를 차단하지 않는 단기 요청에 유용합니다.

ThreadPoolExecutors 는 직접 구성할 수 있으므로 동작을 사용자 지정할 수 있습니다. 예를 들어 풀 내 스레드의 최소 및 최대 수와 스레드가 생성 및 소멸되는 시점에 대한 정책을 정의할 수 있습니다. 이에 대한 예가 곧 표시됩니다.

작업 대기열

고정 스레드 풀의 경우 관찰자는 모든 스레드가 사용 중이고 새 요청이 들어오는 경우 어떤 일이 발생하는지 궁금해할 수 있습니다. 글쎄, ThreadPoolExecutor는 스레드를 사용할 수 있게 되기 전에 보류 중인 요청을 보유하기 위해 큐를 사용할 수 있습니다. Executors.newFixedThreadPool은 기본적으로 무제한 LinkedList를 사용합니다. 이렇게 하면 리소스 고갈 문제가 발생하지만 대기열에 있는 각 요청이 전체 스레드보다 작고 일반적으로 리소스를 많이 사용하지 않기 때문에 속도가 훨씬 느립니다. 그러나 이 예에서 대기열에 있는 각 요청은 (OS에 따라) 파일 핸들을 사용하는 소켓을 보유하고 있습니다. 이것은 운영 체제가 제한하는 종류의 리소스이므로 필요한 경우가 아니면 보유하는 것이 가장 좋지 않을 수 있습니다. 따라서 작업 대기열의 크기를 제한하는 것도 의미가 있습니다.

public static ExecutorService newBoundedFixedThreadPool(int nThreads, int capacity) {
	return new ThreadPoolExecutor(nThreads, nThreads,
		0L, TimeUnit.MILLISECONDS,
		new LinkedBlockingQueue<Runnable>(capacity),
		new ThreadPoolExecutor.DiscardPolicy());
}

public static void boundedThreadPoolServerSocket() throws IOException {
	ServerSocket listener = new ServerSocket(8080);
	ExecutorService executor = newBoundedFixedThreadPool(4, 16);
	try {
		while (true) {
			Socket socket = listener.accept();
			executor.submit( new HandleRequestRunnable(socket) );
		}
	} finally {
		listener.close();
	}
}

다시 스레드 풀을 생성하지만 Executors.newFixedThreadPool 도우미 메서드를 사용하는 대신 ThreadPoolExecutor를 직접 생성하여 16개 요소로 제한되는 제한된 LinkedBlockingQueue 를 전달합니다. 또는 제한된 버퍼의 구현인 ArrayBlockingQueue 를 사용할 수 있습니다.

모든 스레드가 사용 중이고 큐가 가득 차면 ThreadPoolExecutor에 대한 마지막 인수에 의해 다음에 발생하는 작업이 정의됩니다. 이 예에서 DiscardPolicy 가 사용되어 대기열을 오버플로하는 모든 작업을 단순히 버립니다. 예외를 발생시키는 AbortPolicy 또는 호출자의 스레드에서 작업을 실행하는 CallerRunsPolicy 와 같은 다른 정책이 있습니다 . 이 CallerRunsPolicy는 작업을 추가할 수 있는 속도를 자체 제한하는 간단한 방법을 제공하지만 차단되지 않은 상태로 유지되어야 하는 스레드를 차단하여 해로울 수 있습니다.

좋은 기본 정책은 작업을 삭제하는 Discard 또는 Abort입니다. 이러한 경우 HTTP 503 "서비스를 사용할 수 없음" 과 같은 간단한 오류를 클라이언트에 쉽게 반환할 수 있습니다 일부는 대기열 크기를 늘리기만 하면 모든 작업이 결국 실행될 수 있다고 주장합니다. 그러나 사용자는 영원히 기다리기를 원하지 않으며, 기본적으로 작업이 들어오는 속도가 실행할 수 있는 속도를 초과하면 대기열이 무한정 늘어납니다. 대신 대기열은 요청 버스트를 부드럽게 처리하거나 처리 중 짧은 지연을 처리하는 데만 사용해야 합니다. 정상 작동 시 대기열은 비어 있어야 합니다.

스레드는 몇 개입니까?

이제 스레드 풀을 만드는 방법을 이해했습니다. 어려운 질문은 사용 가능한 스레드 수입니다. 리소스가 고갈되지 않도록 최대 수를 제한해야 한다고 결정했습니다. 여기에는 모든 유형의 리소스, 메모리(스택 및 힙), 열린 파일 핸들, 열린 TCP 연결, 원격 데이터베이스가 처리할 수 있는 연결 수 및 기타 유한 리소스가 포함됩니다. 반대로 스레드가 IO 바운드가 아닌 CPU 바운드인 경우 물리적 코어 수는 유한한 것으로 간주되어야 하며 코어당 스레드를 하나만 생성해야 합니다.

이것은 모두 응용 프로그램이 수행하는 작업에 따라 다릅니다. 사용자는 다양한 풀 크기와 현실적인 요청 조합을 사용하여 부하 테스트를 실행해야 합니다. 중단점까지 스레드 풀 크기를 늘릴 때마다. 이를 통해 리소스가 소진되었을 때 상한을 찾을 수 있습니다. 어떤 경우에는 사용 가능한 리소스의 수를 늘리는 것이 현명할 수 있습니다. 예를 들어 JVM에서 더 많은 RAM을 사용할 수 있도록 하거나 더 많은 파일 핸들을 허용하도록 OS를 조정합니다. 그러나 어느 시점에서 이론적 상한선에 도달할 것이며 주목해야 하지만 이것이 이야기의 끝이 아닙니다.

리틀의 법칙

리틀의 법칙 방정식

큐잉 이론, 특히 리틀의 법칙 은 스레드 풀의 속성을 이해하는 데 도움이 될 수 있습니다. 간단히 말해서 리틀의 법칙은 세 변수 간의 관계를 설명합니다. L은 진행 중인 요청 수, λ는 새 요청이 도착하는 속도, W는 요청을 처리하는 평균 시간입니다. 예를 들어 초당 10개의 요청이 도착하고 각 요청을 처리하는 데 1초가 걸린다면 언제든지 평균 10개의 요청이 진행 중입니다. 이 예에서는 10개의 스레드를 사용하는 것으로 매핑됩니다. 단일 요청을 처리하는 시간이 2배이면 진행 중인 평균 요청도 20개로 두 배가 되므로 20개의 스레드가 필요합니다.

실행 시간이 진행 중인 요청에 미치는 영향을 이해하는 것은 매우 중요합니다. 일부 백엔드 리소스(예: 데이터베이스)가 중단되어 요청을 처리하는 데 더 오래 걸리고 스레드 풀이 빠르게 소모되는 것이 일반적입니다. 따라서 이론적 상한은 풀 크기에 대한 적절한 제한이 아닐 수 있습니다. 대신 실행 시간에 제한을 두고 이론상 상한선과 함께 사용해야 합니다.

예를 들어 JVM이 메모리 할당을 초과하기 전에 처리할 수 있는 최대 진행 중 요청이 1000이라고 가정해 보겠습니다. 각 요청에 대해 30초를 넘지 않도록 예산을 책정하면 최악의 경우 초당 33 ⅓ 요청을 처리할 것으로 예상해야 합니다. 그러나 모든 것이 올바르게 작동하고 요청을 처리하는 데 500ms만 걸리는 경우 애플리케이션은 1000개의 스레드에서만 초당 2000개의 요청을 처리할 수 있습니다. 짧은 지연 버스트를 부드럽게 하기 위해 대기열을 사용할 수 있도록 지정하는 것도 합리적일 수 있습니다.

왜 번거롭습니까?

스레드 풀에 스레드가 너무 적으면 리소스를 충분히 활용하지 못하고 불필요하게 사용자를 외면할 위험이 있습니다. 그러나 너무 많은 스레드가 허용되면 리소스가 고갈되어 더 큰 피해를 줄 수 있습니다.

지역 자원이 고갈될 뿐만 아니라 다른 사람들에게 부정적인 영향을 미칠 수 있습니다. 예를 들어 동일한 백엔드 데이터베이스를 쿼리하는 여러 애플리케이션을 가정해 보겠습니다. 데이터베이스에는 일반적으로 동시 연결 수에 대한 엄격한 제한이 있습니다. 제한되지 않고 오작동하는 하나의 응용 프로그램이 이러한 모든 연결을 사용하면 다른 응용 프로그램이 데이터베이스에 액세스하지 못하도록 차단합니다. 광범위한 중단을 유발합니다.

설상가상으로 계단식 오류가 발생할 수 있습니다. 공통 로드 밸런서 뒤에 단일 애플리케이션의 여러 인스턴스가 있는 환경을 상상해 보십시오. 과도한 진행 중인 요청으로 인해 인스턴스 중 하나에 메모리가 부족하기 시작하면 JVM은 가비지 수집에 더 많은 시간을 소비하고 요청을 처리하는 데 더 적은 시간을 소비합니다. 속도가 느려지면 해당 인스턴스의 용량이 줄어들고 다른 인스턴스가 수신 요청의 더 많은 부분을 처리하도록 합니다. 이제 제한되지 않은 스레드 풀을 사용하여 더 많은 요청을 처리하므로 동일한 문제가 발생합니다. 메모리가 부족하고 다시 적극적으로 가비지 수집을 시작합니다. 이 악순환은 시스템 장애가 발생할 때까지 모든 경우에 걸쳐 계속됩니다.

부하 테스트가 수행되지 않고 임의로 많은 수의 스레드가 허용되는 것을 너무 자주 관찰했습니다. 일반적인 경우 응용 프로그램은 적은 수의 스레드를 사용하여 들어오는 속도로 요청을 처리할 수 있습니다. 그러나 요청 처리가 원격 서비스에 의존하고 해당 서비스가 일시적으로 느려지면 W(평균 처리 시간) 증가의 영향으로 풀이 매우 빨리 소진될 수 있습니다. 애플리케이션이 최대 수에서 로드 테스트되지 않았기 때문에 앞에서 설명한 모든 리소스 고갈 문제가 나타납니다.

스레드 풀은 몇 개입니까?

마이크로 서비스 또는 SOA( 서비스 지향 아키텍처 ) 에서는 여러 원격 백엔드 서비스에 액세스하는 것이 일반적입니다. 이 설정은 특히 실패하기 쉬우므로 문제를 정상적으로 처리할 수 있도록 고려해야 합니다. 원격 서비스의 성능이 저하되면 스레드 풀이 빠르게 한계에 도달하여 후속 요청이 삭제될 수 있습니다. 그러나 모든 요청에 ​​이 비정상적 백엔드가 필요한 것은 아니지만 스레드 풀이 가득 차서 이러한 요청이 불필요하게 삭제됩니다.

백엔드별 스레드 풀을 제공하여 각 백엔드의 장애를 격리할 수 있습니다. 이 패턴에는 여전히 단일 요청 작업자 풀이 있지만 요청이 원격 서비스를 호출해야 하는 경우 작업이 해당 백엔드의 스레드 풀로 전송됩니다. 이렇게 하면 단일 느린 백엔드로 인해 기본 요청 풀의 부담이 줄어듭니다. 그런 다음 특정 백엔드 풀이 필요한 요청만 오작동할 때 영향을 받습니다.

다중 스레드 풀의 최종 이점은 교착 상태를 방지하는 데 도움이 된다는 것입니다. 아직 처리되지 않은 요청의 결과로 사용 가능한 모든 스레드가 차단되면 교착 상태가 발생하고 스레드가 앞으로 나아갈 수 없습니다. 여러 풀을 사용하고 풀이 실행하는 작업을 잘 이해하면 이 문제를 어느 정도 완화할 수 있습니다.

마감일 및 기타 모범 사례

일반적인 모범 사례는 모든 원격 호출에 기한이 있는지 확인하는 것입니다. 즉, 원격 서비스가 합리적인 시간 내에 응답하지 않으면 요청이 포기됩니다. 스레드 풀 내 작업에도 동일한 기술을 사용할 수 있습니다. 특히 스레드가 정의된 기한보다 긴 한 요청을 처리하는 경우 종료되어야 합니다. 새로운 요청을 위한 공간을 만들고 W에 상한선을 두는 것은 낭비처럼 보일 수 있지만 사용자(일반적으로 웹 브라우저일 수 있음)가 응답을 기다리는 경우 30초 후에 브라우저는 어쨌든 사용자가 참을성이 없어 다른 곳으로 이동할 가능성이 높습니다.

빠른 실패는 백엔드용 풀을 생성할 때 취할 수 있는 또 다른 접근 방식입니다. 백엔드가 실패하면 스레드 풀은 응답하지 않는 백엔드에 연결하기 위해 대기 중인 요청으로 빠르게 채워집니다. 대신 백엔드가 비정상으로 표시될 수 있으며, 모든 후속 요청은 불필요하게 기다리는 대신 즉시 실패할 수 있습니다. 그러나 백엔드가 다시 정상 상태가 되었는지 확인하려면 메커니즘이 필요합니다.

마지막으로 요청이 여러 백엔드를 독립적으로 호출해야 하는 경우 순차적 대신 병렬로 호출할 수 있어야 합니다. 이렇게 하면 스레드가 증가하는 대신 대기 시간이 줄어듭니다.

운 좋게도 hystrix 라는 훌륭한 라이브러리가 있어 이러한 모범 사례를 패키지로 만들고 간단하고 안전한 방식으로 노출합니다.

결론

이 기사가 스레드 풀에 대한 이해를 높이는 데 도움이 되었기를 바랍니다. 응용 프로그램의 요구 사항을 이해하고 최대 스레드 수와 평균 응답 시간을 조합하여 적절한 스레드 풀을 결정할 수 있습니다. 이렇게 하면 연쇄적인 실패를 피할 수 있을 뿐만 아니라 서비스를 계획하고 프로비저닝하는 데 도움이 됩니다.

애플리케이션이 스레드 풀을 명시적으로 사용하지 않더라도 애플리케이션 서버 또는 더 높은 수준의 추상화에서 암시적으로 사용됩니다. Tomcat , JBoss , Undertow , Dropwizard 모두 스레드 풀(서블릿이 실행되는 풀)에 여러 조정 가능 항목을 제공합니다.


댓글 없음:

댓글 쓰기