1백만개의 동시 작업을 실행하는데 메모리가 얼마나 필요할까?
(pkolaczk.github.io)- 비동기 와 멀티쓰레드 간의 메모리 사용량을 Rust, Go, Java, C#, Python, Node.js, Elixir 언어로 비교
- 10초간 기다리는 태스크를 N개 실행하는 프로그램을 각 언어로 작성(ChatGPT의 도움을 받아)
- Xeon E3 + Ubuntu 22.04 에서 비교
결과
- 최소 풋프린트 (1개의 태스크만 실험): Go 와 Rust는 3MB 이하만 요구, Python은 17MB, Java/Node.js는 약 40MB, C#은 131MB
- 1만개 태스크 : Rust Tokio 4.6MB, Rust async-std 8MB, Go 28.6MB, Python 40MB, Rust Threads 48MB, Node.js 48MB, Java Virtual Thread 78MB, Elixir 99MB, C# 131MB, Java Threads 244MB
- 10만개 태스크(쓰레드는 제외): Rust tokio 23MB, Rust Async-std 54MB, Node.js 112MB, C# 130MB, Java virtual threads 223 MB, Python 240MB, Go 269MB, Elixir 445MB
- 100만개 태스크: Rust Tokio 213MB, C# 461MB, Node.js 494MB, Rust async-std 527MB, Java virtual thread 1154MB, Python 2232MB, Go 2658MB, Elixir 4009MB
결론
- Rust tokio는 타의 추종을 불허함
- C# 이 풋프린트는 크지만, 매우 경쟁력 있음(Rust를 이기기도)
- Go 는 1백만개로 가면서 자바 가상쓰레드와 격차가 벌어짐(Go가 JVM에 비해 가볍다는 일반적인 생각을 뒤엎음)
- 메모리 사용량만 살펴본거라 다른 요소들은 고려되지 않음
- 1백만개 태스크에서는 작업을 시작하는데 오버헤드가 많아지고, 대부분의 코드가 완료하는데 12초 이상 걸림
- 다른 벤치마크도 실행할 예정
Go를 쓰고 Rust를 계속 기웃거리면서, 과연 이 빡빡한 문법에 적응할 필요가 있을 것인가 고민하는 경우에 꽤나 유의미한 벤치마크네요. Go가 OOM으로 죽을 상황에서도 Rust라면 잘 버틴다면.... 투자할 가치가 충분하겠네요.
물론 Rust 개발자 구하기가 훨씬 어려운게 여전히 문제긴 하겠습니다만...
Go 는 개별 고루틴마다 스택 (2KB) 이 하나씩 할당되면서 O(n) 만큼 사용량이 늘어나는 구조라 스레드 개수가 늘어날수록 불리해지는건 사실인데....
사소하게 궁금해지는것은 1만개 스레드를 넘기는 상황이 얼마나 자주있을려나요. 실제 코드 돌아가는것보다 컨텍스트스위칭이 더 자주 발생할꺼 같은....
Erlang 공식 문서를 찾아보니, Erlang 프로세스 하나를 스폰하는 데는 338워드가 필요하다고 합니다. 그리고 64비트 시스템에서 1워드는 8바이트라고 하니, Erlang 프로세스 하나는 약 2.7KB(338 × 8 = 2,704)의 메모리를 차지하겠군요. Go 언어에서 goroutine 스택 하나 크기가 약 2.0KB라고 하니, Erlang 쪽이 메모리를 더 먹는다고 봐야 할 것 같습니다.
그렇다면 단순 계산으로 1백만 개의 Erlang 프로세스는 2.7GB의 메모리를 차지해야 하는데, 위에서 소개된 Elixir 벤치마크에서는 약 4.0GB의 최대 메모리 사용량이 관찰되었으니 1.3GB의 메모리가 더 사용된 셈입니다. 단순히 계산하면 이 시나리오에서 Erlang 프로세스 하나당 1.3KB의 메모리가 더 쓰였다는 의미인데, 잘은 모르겠지만 Erlang 프로세스 수가 일정 한도 이상으로 늘어나면 런타임에서 뭔가 추가적인 메모리 공간 사용이 필요한 건가 싶기도 합니다.