과정을 즐기자

Java Virtual Thread 에 대해 알아보자 본문

Java

Java Virtual Thread 에 대해 알아보자

320Hwany 2024. 8. 28. 12:56

Java 21 LTS 버전부터 정식으로 Virtual Thread가 포함되었습니다.

Virtual Thread 란 무엇일까요?

 

오라클 공식 문서에 있는 표현은 다음과 같습니다.

"Virtual threads are lightweight threads that reduce the effort of writing, maintaining, and debugging

high-throughput concurrent applications."

 

간단하게 해석해보면 처리량이 높은 애플리케이션을 만들 때 유지 관리 및 디버깅의 노력을 줄여주는 경량 쓰레드입니다.

경량 쓰레드라는 말을 처음 보았을 때 CPU 코어를 더 잘 활용하기 위해 쓰레드 개념이 나온 것처럼 경량 쓰레드가 나온 이유도

뭔가 비슷한 이유가 아닐까 라는 생각만 들었습니다.

 

이번 글에서는 다음과 같은 순서로 다루겠습니다.

 

1. Virtual Thread의 특징 - 생성/실행 속도 빠름, 스케줄링 방식, Non-Blocking I/O

2. 기존 쓰레드 풀 방식의 병목 지점과 이를 개선한 방식

3. 성능 테스트

4. Virtual Thread의 단점

 

📕 Virtual Thread는 생성/실행 속도가 빠름

 

일반 쓰레드를 10만 개 생성할 때는 4005ms 시간이 걸렸습니다.

 

 

가상 쓰레드를 10만 개 생성할 때는 166ms 시간이 걸렸습니다.

생성/실행 속도에서 약 20배 이상의 큰 차이를 보입니다.

 

왜 이렇게 차이가 나는 것일까요? 

Thread의 start() 메소드와 관련이 있습니다.

일반 쓰레드는 실행할 때 start0() JNI 메소드를 호출하게 됩니다. 이 부분은 자바 코드가 아니라 C나 C++로 되어 있는 코드로

시스템 콜을 호출할 때 사용됩니다. 이 과정에서 커널 영역에 접근하여 운영체제 수준에서 관리되는 것입니다.

바로 이 부분에서 많은 시간이 소요되는 것입니다.

 

Virtual Thread의 start() 메소드는 어떨까요? (Virtual Thread의 상속을 따라가보면 Thread 클래스가 존재)

 

뭔가 보이는 코드는 더 복잡해진 것 같지만 JNI를 호출하는 코드는 없습니다.

대신에 submitRunContinuation() 이라는 메소드를 볼 수 있는데 이 메소드를 살펴보면

어떤 스케줄러에 continuation을 전달해줍니다.

(continuation은 현재 실행 상태를 저장하고 이후에 그 지점에서 다시 실행할 수 있도록 해주는 프로그래밍 개념)

여기서 알 수 있는 점은 Virtual Thread는 생성/실행에 OS가 개입하지 않고 JVM 수준에서 관리된다는 것을 알 수 있습니다.

그렇기 때문에 생성/실행 속도에서 큰 차이를 보였습니다.

또한 생성/실행 속도 뿐만 아니라 차지하는 메모리 크기도 약 100배 정도 차이난다고 합니다.

 

📘 Virtual Thread의 컨텍스트 스위칭은 OS 수준에서 발생하지 않음

 

1번에 이어서 스케줄러에 대해 알아보겠습니다.

 

필드가 되게 많은데 스케줄러와 관련된 scheduler 필드와 DEFAULT_SCHEDULER를 살펴보면

Executor 인터페이스의 구현체인 ForkJoinPool로 이루어져 있음을 알 수 있고 또한 static 변수라는 점에서

모든 Virtual Thread가 하나의 스케줄러를 사용한다는 것을 알 수 있습니다.

ForkJoinPool은 플랫폼 쓰레드(우리가 알고 있는 쓰레드)를 관리하고 Virtual 쓰레드에게 작업을 분배하는 것입니다.

 

우아한 기술 블로그 - Java의 미래, Virtual Thread

 

기존 쓰레드 방식에서 OS 쓰레드는 플랫폼쓰레드와 1 : 1로 대응되며 OS 쓰레드는 OS 레벨에서 스케줄링됩니다.

Virtual 쓰레드 방식은 OS 쓰레드는 플랫폼쓰레드와 1 : 1로 대응되지만 플랫폼쓰레드와 Virtual 쓰레드는

1 : N 으로 대응되며 이러한 스케줄링은 OS 레벨이 아닌 JVM 레벨에서 ForkJoinPool로 구현된 스케줄러가 해줍니다.

따라서 컨텍스트 스위칭 속도도 약 10배 정도 차이납니다.

 

조금 더 들어가 Virtual Thread 클래스에서 DEFAULT_SCHEDULER를 생성하는 메소드는 다음과 같습니다.

간단하게 요약하면 property로 설정한 설정값이 있다면 사용하고 없으면 새롭게 설정해주는 코드입니다.

여기서 또 주목해볼 점이 parallelism는 일반적으로 동시에 병렬로 실행될 수 있는 작업 수를 의미합니다.

Virtual Thread 방식은 CPU 코어의 개수와 근접하게 플랫폼 쓰레드를 만든다고 볼 수 있습니다.

📗 Virtual Thread는 Non-Blocking I/O 방식으로 동작한다.

Virtual Thread는 Non-Blocking I/O 방식으로 동작합니다.

우선 코드를 통해 확인해본 후 어떻게 동작하는 지를 알아보겠습니다.

 

1초 대기하는 간단한 API 입니다.

 

 

1. Blocking I/O 로 동작하는 기존 쓰레드 방식

 

쓰레드 풀에 10개의 쓰레드를 만들어 놓고, 1초에 동시에 100개의 요청을 전송해보겠습니다.

 

성능 테스트 툴로는 Artilley를 활용했습니다.

 

10개의 쓰레드로 100개의 요청을 처리해야 합니다.

가장 빠르게 처리된 쓰레드는 1006ms 였지만 가장 오래 걸린 쓰레드는 9250ms 이고 중간값은 5123.5ms로 나왔습니다.

여기서 알 수 있는 점은 기존 쓰레드 방식은 Blocking I/O 방식으로 동작한다는 것입니다.

 

2. Non-Blocking I/O 로 동작하는 Virtual 쓰레드 방식

 

Virtual Thread 활용하기 위한 설정을 추가하고 적용되지 않는 max, min-spare 설정은 지웠습니다.

 

마찬가지로 10개의 쓰레드로 100개의 요청을 처리해야 합니다.

가장 빠르게 처리된 쓰레드는 1001ms 였고 가장 오래 걸린 쓰레드는 1096ms 이고 중간값은 1005ms로 나왔습니다.

여기서 알 수 있는 점은 Virtual Thread 방식은 Non-Blocking I/O 방식으로 동작한다는 것입니다.

 

📒 기존 쓰레드 풀 방식의 병목 지점

  • Acceptor: 클라이언트의 소켓 연결을 받아들임.
  • PollerEventQueue: Acceptor가 받아들인 소켓 연결을 큐에 저장.
  • Poller: 큐에서 소켓을 가져와, 요청을 처리할 준비가 되었는지 확인.
  • TaskQueue: 요청을 처리하기 위한 작업을 대기열에 저장.
  • Thread Pool: 대기 중인 작업을 처리하여 클라이언트의 요청에 응답.

그림의 톰캣 8.5 이후 버전부터는 BIO Connector가 아닌 NIO Connector 입니다.

Acceptor와 Poller 쓰레드는 Non-Blocking 방식으로 동작하여 Connection per Request가 아닌 Thread per Request 방식으로

동작할 수 있게 하였습니다.

 

그림에서 초록색 부분은 성능에 크게 문제되는 부분이 없습니다. 병목 지점은 빨간색 부분입니다. (사실 이것도 크게 문제는 되지 않음)

플랫폼 쓰레드와 OS 쓰레드가 1 : 1로 대응되는데 OS 쓰레드는 CPU를 할당 받아 실행됩니다.

만약에 I/O 작업이 발생하면 해당 OS 쓰레드는 CPU를 할당 받을 수 없게 되고 다른 OS 쓰레드에게 CPU 소유권이 넘어가게 됩니다.

이 시점에 플랫폼 쓰레드, OS 쓰레드가 같이 Blocking 되는 것입니다. 

즉, 쓰레드가 작업을 실행하는 DB I/O나 네트워크 I/O 가 발생하게 되면 쓰레드는 Blocking 됩니다.

 

📚 Virtual Thread 가 개선한 병목 지점

 

Virtual Thread 방식은 이러한 I/O 작업을 진행할 때 플랫폼 쓰레드가 Blocking 되지 않게 해줍니다.

그림처럼 Virtual Thread 1, Virtual Thread 2가 플랫폼 Thread 1과 대응되고 있다고 하겠습니다.

Virtual Thread 1이 작업을 수행하다가 DB I/O나 네트워크 I/O가 발생하면 Virtual Thread 1는 work steal queue에서

pop 된 후 park() 메소드에 의해 힙 메모리로 돌아갑니다.

I/O 작업이 끝나면 다시 unpark() 메소드를 호출하여 work steal queue에 들어간 후 플랫폼 쓰레드에 할당되어 사용될 수 있습니다.

또 park() 메소드를 호출한 후 힙 메모리에서 더 이상 사용되지 않으면 GC에 의해 삭제됩니다.

 

이렇게 Virtual Thread가 I/O 작업을 수행할 때 플랫폼 쓰레드와 OS 쓰레드는 Blocking 되지 않고 다른 Virtual Thread의

작업을 처리할 수 있게 됩니다. 즉 Non-Blocking I/O 방식으로 작업을 처리할 수 있게 되는 것입니다.

 

🚗  성능 테스트 해보자

 

테스트 해볼 API는 단순합니다.

쓰레드 정보를 출력하고 0.3초 sleep 하도록 하여 각각의 방식이 얼마만큼 처리할 수 있는지 확인해 보겠습니다.

 

해당 API를 1초에 1000번씩 10초동안 총 10000번을 호출해보겠습니다.

 

1. Virtual Thread 방식

 

10000개의 요청 모두 성공했고 p99가 461ms 정도 나옵니다.

또 쓰레드 정보를 출력해보았을 때 Virtual Thread는 매 요청마다 새로 생성되는 것으로 보이지만 플랫폼 쓰레드는 총 8개만

존재하였습니다. 아마도 저의 M1 맥북의 CPU 코어가 8개여서 그런 것 같다고 유추하고 있습니다.

 

2. 기존 쓰레드 풀 방식

 

마찬가지로 해당 API를 1초에 1000번씩 10초동안 총 10000번을 호출해보겠습니다.

그런데 이번에는 기존 쓰레드 풀 방식을 사용하는데 Virtual Thread가 사용한 만큼의 플랫폼 쓰레드를 사용하기 위해

8개로 설정해주었습니다.

 

처리 시간이 오래 걸릴 뿐만 아니라 10000개 요청 중에서 264개만 성공하였습니다. accept-count의 수가 문제일 수도 있어서

늘려보았지만 비슷한 결과가 나왔습니다.

내장 톰캣에 HTTP 연결 타임아웃은 설정하였지만 운영체제, 네트워크 스택에서 설정하는 TCP 소켓 커넥션 타임 아웃에 걸려서

나머지 요청들이 실패되는 것으로 유추하였습니다. 같은 수의 플랫폼 쓰레드를 사용한다면 차이가 많이 나는군요...

 

쓰레드 개수를 100개로 늘렸지만 여전히 10000개의 요청 중에서 4800개만 성공하였습니다.

 

쓰레드 개수를 200개로 늘렸을 때는 10000개의 요청이 모두 성공하였고 p99는 5600ms가 나왔습니다.

 

 

쓰레드 개수를 300개로 늘렸을 때는 10000개 요청이 모두 성공하였고 p99가 821ms가 나왔습니다.

쓰레드가 300개여도 Virtual Thread가 2배정도 빠른 성능을 보였습니다.

 

쓰레드 풀에 쓰레드 개수를 무한정 늘릴 수가 없습니다. 쓰레드를 생성하는데 필요한 메모리가 증가할 뿐만 아니라

컨텍스트 스위칭 비용도 증가합니다. 요청이 많아지면 소켓 연결만 해도 CPU 리소스를 꽤 많이 먹었는데 이러한

컨텍스트 스위칭 비용도 증가하면 CPU 리소스를 더 많이 먹게됩니다.

따라서 적절한 모니터링을 통해 쓰레드 개수를 튜닝해야 할 것 같습니다.

 

Virtual Thread 방식은 어느 정도의 요청을 더 받을 수 있는 지 알아보기 위해 10초동안 20000개의 요청을 보내보았습니다.

20000개 중에 16782개를 처리하였고 p99는 611.7ms이 나왔습니다.

그리고 CPU 사용량 경고가 뜬 것으로 보아 확실히 요청이 많아지면 소켓 연결만 해도 CPU 리소스를 많이 먹는 것 같습니다.

 

📌 Virtual Thread 단점은 없을까?

지금까지의 이야기만 보면 Virtual Thread는 무조건 써야할 것 같지만 항상 좋은 것은 아닙니다.

바로 CPU bound 작업의 경우에는 적합하지 않다는 것입니다. 

 

오라클 공식 문서에는 다음과 같이 언급하고 있습니다.

"Virtual threads are not faster threads; they do not run code any faster than platform threads.

They exist to provide scale (higher throughput), not speed (lower latency)."

 

정리하면 Virtual Threads는 빠른 쓰레드가 아니라 처리량이 많은 상황에 적합한 방식입니다.

이유를 생각해보면 플랫폼 쓰레드와 Virtual 쓰레드가 연결되는 과정이 추가되기 때문에 조금 더 느려질 수 있는 것입니다.

그래서 CPU를 많이 사용하는 작업의 경우에는 Virtual 쓰레드를 사용하지 않고 그냥 플랫폼 쓰레드와 OS 쓰레드만 1 : 1로 연결하여

사용하는 것이 더 빠를 수 있는 것입니다.

 

또 Virtual Thread는 아직 한계가 있는데 synchronized와 같은 키워드를 사용할 때 Virtual Thread가 플랫폼 쓰레드, OS 쓰레드와

함께 같이 Blocking 되어 성능이 제대로 나오지 않을 수 있습니다. 위에서 진행한 성능 테스트는 해당 키워드가 없었기 때문에 높은 성능이

나왔지만 사용하는 DB의 JDBC driver가 내부적으로 synchronized을 사용하면 성능이 제대로 나오지 않게됩니다.

따라서 synchronized 키워드가 아닌 Lock을 사용할 필요가 있습니다.

 

최근에 나온 MySQL 9.xx 버전 부터는 제공하는 JDBC driver가 synchronized에서 ReentrantLock을 사용하는 방식으로 수정하여

이 부분을 개선한 것으로 알고 있습니다. 이제 Spring Boot에도 적용이 된다면 Virtual Thread를 더 많이 사용하게 될 것 같습니다.

 

마지막으로 Virtual Thread 방식은 기존 쓰레드 풀 방식과 다르게 쓰레드 생성에 한계를 두지 않습니다.

하드웨어 성능이 받쳐주는 것이 보장된다면 계속 생성됩니다. 따라서 하드웨어 성능과 관련지어 모니터링 해야 하며

DB 커넥션 수와 같이 다른 이유로 병목이 될 수도 있으며 이러한 부분도 같이 체크해야 할 것 같습니다.

 

참고한 자료

https://docs.oracle.com/en/java/javase/20/core/virtual-threads.html#GUID-DC4306FC-D6C1-4BCC-AECE-48C32C1A8DAA

https://techblog.woowahan.com/15398/

https://findstar.pe.kr/2023/04/17/java-virtual-threads-1/

https://www.youtube.com/watch?v=BZMZIM-n4C0&t=2555s

https://velog.io/@sihyung92/how-does-springboot-handle-multiple-requests

https://www.youtube.com/watch?v=srpOD6WIasM