이 글은 언어별 스레드 환경에 대해 알았으면 해서 “Why you can have millions of Goroutines but only thousands of Java Threads”를 번역한 것입니다.
JVM 언어에 경험이 있는 엔지니어라면 다음과 같은 에러를 본 적이 있을 것입니다.
[error] (run-main-0) java.lang.OutOfMemoryError: unable to create native thread:
[error] java.lang.OutOfMemoryError: unable to create native thread:
[error] at java.base/java.lang.Thread.start0(Native Method)
[error] at java.base/java.lang.Thread.start(Thread.java:813) ...
[error] at java.base/java.lang.Thread.run(Thread.java:844)OutOfMemory… 스레드 생성 에러. Linux가 구동되는 제 노트북에서는 대략 11,500개의 스레드 개수 근처에서 이 오류가 발생합니다.
Go로 무한히 슬립하는 Goroutine을 만들면 아주 다른 결과가 됩니다. 제 노트북에서는 70,000,000개의 Goroutine을 만들 수 있었지만, 지루해서 그만 만들었습니다. 왜 Goroutine은 스레드보다 더 많이 만들 수 있을까요? 이 대답을 찾으려면 OS를 이해해야 합니다. 또한 이것은 학술적인 문제가 아니라 실제로 사용되는 소프트웨어를 설계하는 문제입니다. 저는 프로덕션 환경에서 JVM 스레드의 한계에 부딪힌 경험이 여러 번 있습니다. 문제가 있는 코드가 thread에서 메모리를 누설시킨 적도 있고, 단순히 엔지니어가 JVM thread의 한계에 대해서 인지하고 있지 않았던 적도 있습니다.
스레드란 무엇인가?
스레드라는 용어는 상황에 따라 다른 것을 의미할 수 있습니다. 이번 포스트에서는 논리적 스레드를 언급하려 합니다. 즉, 순차적으로 행해지는 일련의 처리를 thread라고 부릅니다. CPU는 코어당 하나의 논리적 스레드만 실행할 수 있습니다. 코어 수를 초과하는 스레드가 있는 경우 일부 스레드는 다른 스레드가 작업을 수행하기 위해 일시 중지하고, 순서가 오면 작업을 다시 시작하는 작업을 수행합니다. 일시 정지 및 재개 기능을 실현하기 위해, thread에는 적어도 2개의 것이 요구됩니다.
- 특정 종류의 명령어 포인터. 즉 정지 시 어디의 행을 실행하고 있었는지의 정보.
- 스택. 즉, 현재의 상태를 보존해 두기 위한 것. 스택에는 로컬 변수와 힙에 저장된 변수에 대한 포인터가 포함됩니다. 프로세스의 모든 스레드는 하나의 힙을 공유합니다.
이러한 정보를 가지고 CPU 스케줄러는 thread를 정지하기에 충분한 정보를 취득해, 다른 thread를 실행시키고, 그 후에 원래의 중단된 thread를 재개시킵니다. 이 처리는 일반적으로 스레드에서는 완전히 투명합니다. 스레드의 관점에서 보면, 자신은 연속적으로 동작하고 있다는 것입니다. 스레드가 자신의 일시 정지를 관측하기 위해서는, 계속의 처리가 행해질 때까지의 시간을 계측할 수밖에 없습니다.
원래 질문으로 돌아가서: 왜 그렇게 많은 Goroutine을 만들 수 있을까요?
JVM은 OS 스레드를 사용한다
사양으로 정해져 있는 것은 아니지만, 제가 아는 한 모든 현대적인 JVM은 스레딩을 가능한 OS의 thread에 위임합니다. 여기서부터는 “사용자 영역 스레드”라는 말로 커널/OS 대신 프로그래밍 언어가 스케줄하는 스레드를 말합니다. OS의 스레드는 생성할 수 있는 수를 제한하는 특성이 두 가지 있습니다. 프로그래밍 언어가 만드는 스레드와 OS 스레드를 1:1로 매핑하는 방법은 대규모 병렬성을 지원할 수 없습니다.
JVM: 고정 길이 스택 크기
OS 스레드를 사용하면 스레드 당 고정 길이가 큰 메모리를 소비합니다.
OS 스레드에 대한 두 번째 문제는 스택 크기가 고정 길이라는 것입니다. 스택 사이즈 자체는 설정 가능하고, 64비트 환경에서의 JVM 기본 thread당 스택 길이는 1MB입니다. 기본 스택 크기를 줄일 수 있지만 메모리 용량 측면에서 스택오버플로 위험과의 트레이드오프가 존재합니다. 재귀 호출이 많은 코드라면 스택 오버플로가 발생할 확률이 높아질 것입니다. 기본값을 그대로 두면 1k 스레드가 1GB RAM을 사용하게 됩니다! 서버에서 백만 스레드가 실행하는 환경에서는 테라바이트 단위의 RAM이 필요한데, 이를 가지고 있는 사람은 거의 없을 것입니다.
Go: 동적인 스택 크기
Go는 큰(그리고 거의 사용되지 않는) 스택 때문에 메모리 부족에 빠지는 것을 영리하게 피합니다. Go의 스택 크기는 저장된 데이터에 따라 동적으로 늘어납니다. 이것은 쉬운 일이 아니며 설계의 측면에서 여러 토론들이 있었습니다. 구체적인 구현의 부분은 아니지만, 결론적으로 새로운 Goroutine는 단지 4KB의 스택을 가질 것이라는 것입니다. 하나의 스택당 4KB의 용량이므로 25만 개의 Goroutine을 1GB의 RAM에 담을 수 있습니다. 하나의 스레드당 1MB가 필요한 Java와 비교하면 큰 차이입니다.
JVM 문제: 컨텍스트 스위치 지연
OS의 스레드는 컨텍스트 스위치 지연으로 인해 수만 건 정도로 제한됩니다.
JVM은 OS 스레드를 사용하므로 OS 커널 스케줄러에 의존합니다. OS는 실행 중인 프로세스와 스레드 목록을 가지고 있으며 각각에 “공정한” 시간을 CPU에 할당하려고 합니다. 커널이 스레드를 전환할 때(다른 스레드로) 필요한 처리량이 엄청납니다. 새로운 thread나 프로세스는, 다른 thread가 같은 CPU로 움직이고 있다고 하는 사실을 은폐하는 추상화 레이어 안에서 기동합니다. 여기에서는 기본적인 내용은 없지만, 관심이 있다면 여기를 더 읽어보기를 권장합니다. 가장 중요한 것은 컨텍스트 스위치에는 1~100 마이크로초 정도의 시간이 걸린다는 것입니다. 그렇다고 생각하지 않을 수도 있지만, 컨텍스트 스위치당 10 마이크로초가 걸리는 것은 초당 모든 스레드가 처리하려고 할 때 1코어 CPU에 100k 스레드만 실행할 수 있다는 것입니다. 게다가 스레드가 실제 일을 하는 시간은 환산되어 있지 않습니다.
Go의 차이점: 하나의 OS 스레드에서 여러 Goroutine 이동
Go는 같은 OS에서 많은 Goroutine을 실행하기 위해 자체 스케줄러를 구현합니다. 만약 Go가 커널과 같이 컨텍스트 스위치를 한다고 해도, 그 때문에 링0에 스위치할 필요성을 없앰으로써 많은 시간을 절약할 수 있습니다. 이것만이 아닙니다. 100만 개의 Goroutine을 지원하기 위해 Go는 더욱 정교해졌습니다.
만약 Java의 thread가 사용자 영역에서 움직인다고 합시다. 그래도 수백만 개의 스레드를 움직일 수는 없습니다. 조금 멈추고, 새로운 시스템에서는 스레드의 스위 치에 단지 100 나노초 걸린다고 하자. 만약 모든 시간을 컨텍스트 스위치에 나눈다고 해도, 대략적으로 초당 100만 thread에 10회씩밖에 기회가 안 주어집니다. 이것만으로도 CPU를 다 사용하게 됩니다. 진정으로 대규모 병렬 처리를 실현하려면 추가 최적화가 필요합니다. 그것은 유용한 일을 하는 스레드에게만 시간을 준다는 것입니다. 그렇게 많은 스레드가 움직이고 있어도, 의미 있는 일을 하고 있는 것은 한 줌일 것입니다. Go는 채널과 스케줄러를 통합하여 이 최적화를 실현합니다. Goroutine이 빈 채널에서 기다리면 스케줄러는 이를 감지하고 Goroutine을 실행하지 않습니다. 게다가 Go는 유휴 상태인 Goroutine을 자신의 OS 스레드에 고정시킵니다. 이를 통해 활성 Goroutine(원하는 경우 훨씬 적은 수)이 하나의 스레드에 할당되고 대다수의 유휴 Goroutine이 별도로 관리됩니다. 이렇게 하면 지연시간을 줄일 수 있습니다.
Java의 스케줄러가 환경을 관측할 수 있게 되지 않는 한, 까다로운 스케줄링 기능의 실현은 불가능할 것입니다. 그러나 “사용자 영역에서” 언제 스레드가 일을 할 수 있는지를 관리하는 런타임 스케줄러를 만드는 것은 가능합니다. 이것은 Akka와 같이 수백만 액터를 지원하는 프레임워크의 기초가 됩니다.
끝맺음말
OS의 스레드를 사용하는 모델에서 사용자 영역에서 움직이는 경량의 스레드 모델로의 이동은 지금까지도 이루어져 왔으며, 미래에서도 이 트렌드는 변하지 않을 것입니다. 대규모 병렬 처리가 필요한 경우, 다른 방법은 없습니다. 그러나 그것에 해당하는 복잡성도 같이 수반됩니다. Go가 자체 스케줄러와 동적 스택 크기를 사용하지 않고 OS 스레드를 사용하도록 선택했다면 런타임에서 수천 줄의 코드를 삭제할 수 있었을 것입니다. 종종 단순한 것이 더 좋은 모델입니다. 복잡성은 언어 및 라이브러리 작성자에 의해 추상화되며 소프트웨어 엔지니어는 대규모 병렬 프로그램을 작성할 수 있습니다.






