Goroutine , Java , Thread ,

왜 Go에서는 수백만개의 Goroutine을 만들수 있지만, Java는 수천개의 스레드만 만들 수 있을까?

by Mimul FollowJanuary 28, 2022 · 5 min read · Last Updated:
Share this

이 글은 언어별 스레드 환경에 대해 알았으면 해서 "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개의 것이 요구한다.

  1. 특정 종류의 명령어 포인터. 즉 정지시 어디의 행을 실행하고 있었는지의 정보.
  2. 스택. 즉, 현재의 상태를 보존해 두기 위한 것. 스택에는 로컬 변수와 힙에 저장된 변수에 대한 포인터가 포함된다. 프로세스의 모든 스레드는 하나의 힙을 공유한다.

이러한 정보를 가지고 CPU 스케줄러는 thread를 정지하기에 충분한 정보를 취득해, 다른 thread를 실행시키고, 그 후에 원래의 중단된 thread를 재개시킨다. 이 처리는 일반적으로 스레드에서는 완전히 투명하다. 스레드의 관점에서 보면, 자신은 연속적으로 동작하고 있다는 것이다. 스레드가 자신의 일시 정지를 관측하기 위해서는, 계속의 처리가 행해질 때까지의 시간을 계측할 밖에 없다.

원래 질문으로 돌아가 : 왜 그렇게 많은 Goroutine을 만들수 있습니까?

JVM은 OS 스레드를 사용한다

java_thread

사양으로 정해져 있는 것은 아니지만, 내가 아는 한, 모든 현대적인 JVM은 스레딩을 가능한 OS의 thread에 위임한다. 여기서부터는 "사용자 영역 스레드"라는 말로 커널/OS 대신 프로그래밍 언어가 스케줄하는 스레드를 말한다. OS의 스레드는 생성할 수 있는 수를 제한하는 특성이 두 가지 있다. 프로그래밍 언어가 만드는 스레드와 OS 스레드를 1:1로 매팽하는 방법은 대규모 병렬성을 지원할 수 없다.

JVM: 고정 길이 스택 크기

OS 스레드를 사용하면 스레드 당 고정 길이가 큰 메모리를 소비한다.

OS 스레드에 대한 두번째 문제는 스택 크기가 고정 길이라는 것이다. 스택 사이즈 자체는 설정 가능하고, 64 비트 환경에서의 JVM 기본 thread 당의 스택 길이는 1MB이다. 기본 스택 크기를 줄일수 있지만 메모리 용량 측면에서 스택오버플로 위험과의 트레이드오프가 존재한다. 재귀 호출이 많은 코드라면 스택 오버플로가 발생할 확률이 높아질 것이다. 기본값을 그대로두면 1k 스레드가 1GB RAM을 사용하게 된다! 서버에서 백만 스레드가 실행하하는 환경에서는 테라바이트 단위의 RAM이 필요한데, 이를 가지고 있는 사람은 거의 없을 것이다.

Go: 동적인 스택 크기

go_thread

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 스레드를 사용하도록 선택했다면 런타임에서 수천 줄의 코드를 삭제할 수 있었다. 종종 단순한 것이 더 좋은 모델이다. 복잡성은 언어 및 라이브러리 작성자에 의해 추상화되며 소프트웨어 엔지니어는 대규모 병렬 프로그램을 작성할 수 있다.