주니어 백엔드 개발자가 반드시 알아야 할 실무 지식 ‐ IO 병목, 어떻게 해결하지 - woojin-playground/Backend-PlayGround GitHub Wiki
네트워크 IO와 자원 효율
- 데이터를 보내고 받는 동안 코들르 실행하는 쓰레드는 다음과 같은 흐름을 가져가게 된다.
- 데이터 입출력이 완료될 때까지 쓰레드는 아무 작업도 하지 않고 입출력이 끝나기를 기다린다.
- 즉, 입출력이 끝날 때까지 쓰레드는 블로킹된다. 보통 입출력에 소요되는 시간은 코드를 실행하는 시간보다 훨씬 길다.
- 서버처럼 네트워크 연동이 많은 프로그램은 전체 실행 시간의 90% 이상을 입출력 대기에 사용하는 경우도 있다.
- 쓰레드가 대기하는 데 시간을 소요한다는 것은 그 쓰레드를 실행하는 CPU도 아무것도 하지 않는 시간이 생긴다는 의미이다.
- CPU 사용률을 높이려면 CPU가 실행할 쓰레드를 많이 만들면 된다.
- 하지만 쓰레드를 생성하는 데에는 한계가 있다. 쓰레드는 수백 KB에서 수 MB 메모리를 사용한다.
- 메모리를 늘려 쓰레드를 많이 만들 수 있다고 하더라도 여전히 문제가 되는데 바로 컨텍스트 스위칭이다.
- 동시에 실행되는 쓰레드 수가 증가하면 컨텍스트 스위칭에 사용되는 시간도 비례해 증가한다.
Blocking
- 작업이 완료될 때까지 쓰레드가 대기하는 것을 블로킹이라고 한다.
- 주로 데이터 입출력 과정에서 블로킹이 발생한다.
- 입출력 과정에서 블로킹이 발생하기 때문에 이런 방식을 블로킹 IO라고 한다.
컨텍스트 스위칭(Context Switching)
- 운영체제는 여러 쓰레드를 번갈아가면서 CPU에 할당한다.
- 한 쓰레드를 짧은 시간 동안 실행하고 다음 쓰레드를 짧은 시간 실행하는 식이다.
- CPU가 쓰레드를 전환하려면 현재 실행 중인 쓰레드 상태를 기록하고 다음에 실행할 쓰레드 상태 정보를 불러와야 한다.
- 이렇게 상태 정보를 변경하고 쓰레드를 전환하는 과정을 컨텍스트 스위칭이라고 한다.
- 컨텍스트 스위칭은 MS 단위로 실행되지만 컨텍스트 스위칭을 하는 동안 CPU는 실질적인 작업을 하지 않는다.
- 그래서 동시에 실행되는 프로세스와 쓰레드 수가 많으면 컨텍스트 스위칭에 소요되는 시간도 무시하기 힘들만큼 커질 수 있다.
정리하자면 트래픽이 증가하면 다음 2가지 이유로 자원 효율이 떨어지게 된다.
- IO 대기와 컨텍스트 스위칭에 따른 CPU 낭비
- 요청마다 쓰레드를 할당함으로써 메모리 사용량이 높아짐
가상 쓰레드로 자원 효율 높이기
Thread[] threads = new Thread[100_000];
long start = System.currentTimeMillis();
for (int i = 0; i < threads.length; i++) {
// 가상 쓰레드는 Thread.ofVirtual()로 생성한다.
Thread thread = Thread.ofPlatform().start(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
threads[i] = thread;
}
long end = System.currentTimeMillis();
- 가상 쓰레드는 플랫폼 쓰레드에 비해 훨씬 적은 비용(자원, 시간)이 들기 때문에 한 장비에서 수십만에서 백만 개에 이르는 가상 쓰레드를 생성할 수 있다.
Java의 가상 쓰레드란?
- 가상 쓰레드와 고루틴은 경량 쓰레드(
LightWeight)라는 공통점을 가진다. - 경량 쓰레드는 OS가 관리하는 쓰레드가 아니라 JVM같은 언어의 런타임이 관리하는 쓰레드이다.
- 쓰레드는 기본적으로 최대 2MB의 스택 메모리 사이즈를 가지기 때문에, 컨텍스트 스위칭 시 메모리 이동량이 크다.
- 또한 생성을 위해서 커널과 통신하여 스케줄링해야 하므로, 시스템 콜을 이용하기 때문에 생성 비용도 적지 않다.
- 하지만 Virtual Thread는 JVM에 의해 생성되기 때문에 시스템 콜과 같은 커널 영역의 호출이 적고, 메모리 크기가 일반 쓰레드의 1%에 불과하다.
- 따라서 Thread에 비해 컨텍스트 스위칭 비용이 적다.
Java의 가상 쓰레드 동작 원리
- 실행될 Virtual Thread의 작업인
runContinuation을carrier thread의Work Queue에 push한다. Work Queue에 있는runContinuation들은ForkJoinPool에 의해 작업 훔치기 방식으로carrier thread에 의해 처리된다.- 처리되던
runContinuation들은 I/O, sleep으로 인한 Interrupt나 작업 완료 시,Work Queue에서 pop되어 park 과정에 의해 다시 힙 메모리로 되돌아간다.
네트워크 IO와 가상 쓰레드
- 가상 쓰레드는 실행하는 과정에서 블로킹되면 플랫폼 쓰레드와 언마운트되고 실행을 멈춘다.
- 이 때, 언마운트된 플랫폼 쓰레드는 실행 대기 중인 다른 가상 쓰레드와 연결된 뒤 실행을 재개한다.
블로킹 연산과 synchronized 키워드
- 블로킹 연산에는 IO 기능, ReentrantLock, Thread.sleep() 등이 포함된다.
- 이들 연산을 사용해서 가상 쓰레드가 블로킹되면 플랫폼 쓰레드는 대기 중인 다른 가상 쓰레드를 실행한다.
- 반면에 자바 23 또는 이전 버전에서 synchronized로 인해 블로킹되면 가상 쓰레드는 플랫폼 쓰레드로부터 언마운트 되지 않는다.
- 즉, 플랫폼 쓰레드도 같이 블로킹된다. 이렇게 가상 쓰레드가 플랫폼 쓰레드까지 블로킹할 때, 이를 가상 쓰레드가 플랫폼 쓰레드에 고정되었다고 말한다.
- 자바 21 기준으로 synchronized 외에도 JNI 호출 등 가상 쓰레드가 플랫폼 쓰레드에 고정되는 경우가 있는데, 가상 쓰레드가 고정되면 CPU 효율을 높일 수 없다.
가상 쓰레드와 성능
- IO 중심 작업과 CPU 중심 작업으로 나눌 수 있다.
- 네트워크 프로그래밍처럼 입출력이 주를 이루는 작업은 IO 중심 작업에 해당하고 반대로 정렬처럼 계산이 주를 이루는 작업은 CPU 중심 작업에 해당한다.
- 가상 쓰레드는 IO 중심 작업일 때 효과가 있다.
- IO는 가상 쓰레드가 지원하는 블로킹 연산이므로 IO 중심 작업일 때, 플랫폼 쓰레드가 CPU 낭비 없이 효율적으로 여러 가상 쓰레드를 실행할 수 있다.
- CPU 중심 작업에 가상 쓰레드를 사용하면 성능 개선 효과를 얻을 수 없다. 오히려 성능이 나빠질 수도 있다.
가상 쓰레드와 쓰레드 풀
- 요청별 쓰레드 방식을 사용하는 서버는 쓰레드 풀을 사용할 때가 많다.
- 미리 쓰레드를 생성해서 요청이 들어왔을 때 쓰레드 생성 부하를 줄이기 위함이다.
- 또한 쓰레드 풀 크기에 최대치를 설정해서 요청이 급격히 늘어나도 쓰레드가 무한정 생성되는 것을 막는다.
- CPU와 메모리 같은 자원을 일정 수준으로 제한해서 서버 자원이 포화되는 것을 방지하려는 목적이다.
- 가상 쓰레드는 플랫폼 쓰레드보다 생성 비용이 적기 때문에 쓰레드 풀을 미리 구성할 필요가 없다. 필요한 시점에 가상 쓰레드를 생성하고 필요 없으면 제거하면 된다.
가상 쓰레드의 중요한 장점
- 가상 쓰레드의 중요한 장점은 코드를 크게 수정할 필요가 없다는 것이다.
- 스프링 프레임워크나 MySQL JDBC 드라이버같이 많이 사용하는 프레임워크와 라이브러리도 이미 가상 쓰레드를 지원하고 있다.
논블로킹 IO로 성능 높이기
- 가상 쓰레드와 고루틴같은 경량 쓰레드를 사용하면 IO 중심 작업을 하는 서버의 처리량을 높일 수 있다.
- 하지만 경량 쓰레드 자체도 메모리를 사용하고 스케줄링이 필요하다.
- 경량 쓰레드가 많아질수록 더 많은 메모리를 사용하고 스케줄링에 더 많은 시간을 사용하게 된다.
- 사용자가 폭발적으로 증가하면 어느 순간 경량 쓰레드로도 한계가 온다. 이 때는 서버의 IO 구현 방식을 구조적으로 변경해야하는데, 바로 논블로킹 IO를 사용하는 것이다.
논블로킹 IO 동작 개요
- 논블로킹 IO는 입출력이 끝날 때까지 쓰레드가 대기하지 않는다.
channel.read()코드는 데이터를 읽을 때까지 대기하지 않는다.channel.read()코드는 읽을 데이터가 없으면 바로 0을 리턴한다. 이는 데이터를 읽을 때까지 대기하는 블로킹 IO와는 동작 방식이 다르다.