[Concurrency] 경량 스레드
Go의 goroutine, JVM의 virtual Thread를 비롯해서 가벼운 스레드라는 것이 부상한 지도 좀 됐다.
이게 뭐고, 왜 좋다는거고, 어째서 그냥 스레드보다 낫다고 하는 걸까?
경량스레드가 기존 스레드와 비교해서 뭐가 다른 것인지, 어떤 구현체들이 있는지를 대강 다뤄본다.
OS 스레드
통상적으로, 프로그래밍 언어에서 스레드라고 하면 OS 스레드를 말한다.
OS에서 제공하는 스레드를 사용하면 OS의 스케줄링에 따라서 멀티코어를 적당히 활용하게 된다.
코어가 부족하다면 시분할로 코어를 나눠쓴다.

문제는, OS 스레드가 꽤 무거운 자원이라는 것이다.
메모리 사용량
OS 스케줄러는 공정함을 위해 항상 OS 스레드를 멈췄다가 재실행할 수 있다. 이 때문에 각각의 OS 스레드에는 상태 저장을 위한 독립적인 스택 공간이 할당된다.
각 스레드의 스택 크기는 OS나 환경마다 다르나, 보통 1MB~8MB 사이 정도를 차지한다.
그래서 이것도 스레드가 쌓이다보면 통제가 어려울 정도로 메모리가 누적될 수 있다.
**컨텍스트 스위칭 **
OS 스케줄러에 의해서 OS 스레드가 교체되는 것, 현재 실행중인 OS 스레드를 멈추고 다른 스레드를 실행하는 것을 컨텍스트 스위칭이라고 한다. 이 스위칭 비용은 상당히 비싼 편이다.
단순히 무겁고 느린게 문제가 아니라, 스위칭될때마다 캐시를 날려버리기 때문이다.
스위칭 동작 자체는 대개 마이크로초 수준으로 완료된다.
syscall 비용
또한 OS 스레드도 OS의 기능인지라 스레드 관련 제어를 할때마다 system call을 하게 된다.
OS systemcall은 간단한 API 호출이 아니라서, user mode <> 커널 모드의 교체가 한번씩 이루어지는데, 이 비용도 쌓이다보면 커진다.
이러한 요소들로 인해서 OS 스레드가 무겁고 비효율적이라고 하는 것이다.
API 서버를 예로 들면 요청마다 Task 단위로 처리하게 된다. Task 하나마다 OS 스레드를 띄우게 만든다면 요청이 10000개만 들어와도 OS 스레드가 10000개 뜨고, 메모리만 80GB를 먹는 것이다.
OS 스레드로 이런 동시성 프로그램을 관리하기에는 지나치게 메모리를 많이 먹고, 실행성능도 깎아먹는다.
그래서 나온 것이 경량스레드다.
경량 스레드
앞서 언급했듯 OS 스레드는 매우 무겁고 비용이 많이 드는 동작이다.
그래서 나온 것이, OS 스레드를 덜 쓰고 효율적으로 스레드를 활용할 수 있는 스레드 레이어를 추가하는 것이다.
OS 스레드를 쓰긴 하나, 직접적으로 쓰지 않고 또 한 단계를 거쳐서 사용하는 것이다.
CPU 코어를 여러개의 OS 스레드가 나눠서 썼듯이, 하나의 OS 스레드를 여러개의 경량스레드가 나눠써서 자린고비를 실천하는 것이라 보면 되겠다.
대신 그 분배-스케줄링을 OS가 아닌 그 언어의 런타임에서 처리할 뿐이다. 하지만 스위칭으로 인한 비용이나, 스레드별 스택 크기가 KB 수준으로 훨씬 적기 때문에 결과적으로 더 나은 효율성을 보여준다.
기본적인 아이디어는 하나의 OS 스레드로 충분하다면 하나의 OS 스레드를 재사용하다가, 안되면 OS 스레드를 추가해서 처리량을 확장한다는 것이다.
실제로 프로그램을 짜다보면 병목이 생기는 부분은 I/O로 인한 대기가 대부분이기 때문에, 이 정도의 낙관적인 동시성 처리로도 효과가 있는 편이다.
하지만 연산이 CPU 집약적인 경우에는 최적의 성능을 내기 어려울 수도 있다.
엄청 느려지거나 하는건 아니지만, 선점 스케줄링을 사용하는 경우에는 작업이 지속된다는 보장이 없어서 순진한 병렬화에 제동이 좀 가해지기 때문이다.
경량스레드의 대표적인 구현체로는 Go의 goroutine, Java의 Virtual Thread, Rust의 tokio 정도가 있다.
하지만 구현 방식이 각자 다른 부분이 다 있어서, 퉁쳐서 설명하기에는 한계가 있다.
스케줄링과 선점, 비선점
경량스레드 런타임의 스케줄러도 개념적으로는 OS 스케줄링과 비슷하다.
그래서 OS 스케줄링과 비슷하게 선점/비선점으로 분배-스케줄링 방식이 갈라진다. 용어도 같다.
비선점
비선점(non-preemptive)은 Task가 종료되거나, Task가 자발적으로 양보(yield)를 할 때만 그 코어를 다른 Task가 사용할 수 있다는 것이다. 협조적 선점(cooperative preemptive)이라고도 부른다.
구현이 비교적 단순하고, CPU 집약적인 경우에는 선점보다 효율적인 경향이 있다.
다만 비교적 공평하게 분배가 되기 힘들다는 단점이 있다.
선점
선점(preemptive)은 우선순위가 높은 Task가 우선순위가 낮은 Task를 중단시키고 코어를 강탈해올 수 있다는 것이다. 비협조적 선점(non-cooperative preemptive)이라고도 부른다.
Task를 강제로 멈추고 사용할 수 있으니 더 공평하게 작업을 분배할 수 있다는 장점이 있으나, CPU 집약적인 경우에는 비선점보다 효율성이 떨어진다는 단점도 있다.
Go의 Goroutine
고루틴은 경량스레드라는 유행을 가져온 선도자라고 할 수 있다. 그 자체로 Go를 상징하기도 한다.
Go의 경량스레드는 개념적으로는 stackful coroutine이며, 스케줄링 방식은 비협조적 선점(non-cooperative preemptive)이다.
stackful coroutine
stackful이란 것은 각각의 태스크에 고유한 스택이 할당된다는 의미다.
OS 스레드보다는 작지만 아무튼 상태를 저장하고 스위칭하기 위한 값을 둬야 하는 것이다.
Go의 경우 스택 크기가 보통 2kb다. 이건 하드코딩된 값이다.
다시 말해 고루틴 하나 띄울때마다 2kb는 기본으로 먹는다는 것이다. 그리고 상황에 따라서는 4kb, 8kb로 더 커질 수도 있다.
새 OS 스레드의 실행
위에서 몇번이나 OS 스레드는 비싼 자원이라고 강조했다. 그래서 고루틴을 띄운다고 해서 꼭 새 OS 스레드를 할당하지 않는다.
그러면 고루틴은 추가 스레드가 필요한 상황인지 아닌지를 어떻게 판단할까?
일반적으로는, 현재 스레드에서 Blocking I/O를 사용함으로 인해서 현재 스레드가 블럭된다면 새 스레드를 할당한다.
non-cooperative preemptive
고루틴의 스케줄링 방식은 비협력적 선점(non-cooperative preemptive)에 속한다.
원래 1.0부터는 협력적 선점을 사용했으나, 1.14부터 비협력적 선점을 사용하게 되었다.
아무튼 Go의 현재 스케줄링 매커니즘은 선점형이고, 다시 말해서 고루틴은 다른 고루틴을 중단시키고 코어를 강탈해올 수 있는 구조다.
그럼 어떤 기준으로 고루틴을 멈추고 강탈할까?
필요하다고 해서 무작정 빼앗는 것은 구현상의 문제가 많을 것이다.
Go 스케줄러는 Time Slice라는 단위를 둬서, 일단 그 기간을 단위로 선점을 처리하는 방식을 취한다.
https://www.youtube.com/watch?v=wQpC99Xu1U4
Time Slice 기간이 지나면, 우선순위를 판단해서 더 높은 놈이 있으면 현재 Task를 멈추고 우선순위가 높은 Task를 실행시키는 것이다. 우선순위가 더 높은게 없다면 그대로 실행하고.
Time Slice의 현재 기본값은 10ms로 하드코딩되어있다. 그러니까 고루틴은 경쟁이 심한 경우엔 최대 10ms까지만 실행되다가 멈출 수 있는 것이다.
Java의 Virtual Thread
Virtual Thread는 Goroutine을 벤치마킹해서 만든 경량 스레드다.
마찬가지로 stackful coroutine이나, 스케줄링 방식은 협력적 선점(cooperative preemptive)을 사용한다.
https://techblog.woowahan.com/15398/
stackful coroutine
virtual thread도 goroutine과 마찬가지로, 런타임에 의해서 실시간으로 Task들을 스케줄링한다.
그래서 Task마다 스위칭을 위한 스택을 할당한다.
다만 Go보다는 스택 크기가 좀 큰 편이다. 각 가상스레드는 약 10kb 정도의 스택을 가진다.
cooperative preemptive
Virtual Thread는 협력적 선점, 혹은 비선점 스케줄링으로 Task를 관리한다.
그러니까 Task가 다른 Task를 강제로 중단시키지는 않는다는 것이다.
물론 모든 상황에서 계속 실행되는건 아니고, Thread.sleep이나 I/O 같은 blocking 작업이 일어날 경우에는 암시적으로 yield되어서 양보할 수도 있다.
Rust의 tokio
tokio를 비롯한 Rust async는 꽤 색다르고 실험적인 방식으로 경량스레드를 구현한다. 분기가 많이 갈라지긴 했지만 이것도 goroutine을 참고하기는 했다.
tokio는 stackless coroutine이며, 협력적 선점(cooperative preemptive)을 사용한다.
경량 task를 실제 OS 스레드에 n:m으로 매핑한다는 아이디어는 같으나, 세부적으로는 크게 다르다.

stackless coroutine
위에서 goroutine과 virtual thread는 Task 관리를 위해 각 Task에 컨텍스트 관리를 위한 스택을 뒀었다.
하지만 Rust에서는 stack을 두지 않고서, 상태머신 기반으로 Task 스위칭을 처리한다.
다만 상태머신 관리를 위한 Future라는 값 자체는 존재하는데, 통상적으로 태스크당 생성되는 변수의 총합은 100바이트 미만으로 유지된다.
그러니까 경량스레드와 스케줄링을 위해서 추가 메모리를 그다지 사용하지는 않는다는 것이다.
tokio의 가장 큰 장점은 여기에서 나온다.
그리고 사실 엄밀히 말하면 Rust에는 Go나 Java에 대응되는 런타임이 없다. 비동기 코드를 스케줄링 가능한 형태로 컴파일하고, tokio 같은 라이브러리가 멀티플렉싱을 해줄 뿐이다. 그래서 tokio 외에도 대안이 존재한다.
cooperative preemptive
tokio의 스케줄링 방식은 기본적으로 비선점형, 협력적 선점이다. Task가 자발적으로 yield를 해야만 다른 Task를 실행할 수 있다는 것이다.
다만 여기에는 오해의 소지가 있는데, Java virtual Thread와 마찬가지로 blocking I/O를 사용하면 자동으로 yield가 되고 공평하게 분배가 되게끔 설계되어있다. 정확히 말하면 tokio의 i/o 함수들이 yield 기능을 내포하고 있는 셈이다.
리소스 효율성
go/java와 비교한다면 적은 메모리 사용량, 더 적은 스케줄링 비용으로 리소스 효율성이 뛰어나다는 것은 거의 확실하다.
사용사례에 따라서 다르겠지만, 같은 프로그램을 작성할 때도 CPU/memory 사용량을 절반이나 절반 이상 절약할 수 있다.
하지만 컴파일타임에 스케줄링에 대한 부분을 어느 정도 전처리를 하기 때문에, 그 복잡성이 사용자에게까지 위임되는 부분이 약간은 존재한다.
개인적으로 느끼기에는 큰 차이는 없는거같다.
참조
https://engineering.grab.com/counter-service-how-we-rewrote-it-in-rust
https://groups.google.com/g/golang-nuts/c/q0T6Z7inxTc?pli=1
https://www.youtube.com/watch?v=wQpC99Xu1U4
https://go.dev/src/runtime/HACKING
https://go.googlesource.com/proposal/+/master/design/24543-non-cooperative-preemption.md
https://stackoverflow.com/questions/73915144/why-is-go-considered-partially-preemptive#comment139464836_73932230
https://techblog.woowahan.com/15398/
https://openjdk.org/jeps/444
https://rockthejvm.com/articles/the-ultimate-guide-to-java-virtual-threads
https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html
https://tokio.rs/blog/2019-10-scheduler
https://without.boats/blog/why-async-rust/
https://kerkour.com/rust-vs-go-concurrency-models-stackfull-vs-stackless-coroutines