2024년 1백만 동시 작업 실행에 필요한 메모리 용량
(hez2010.github.io)- 2024년 말 최신 언어·런타임 기준으로 1개부터 100만 개까지 동시 작업의 메모리 사용량을 비교한 벤치마크이며, 최신 결과는 별도 Take 2 페이지를 참고하라고 안내함
- 모든 테스트는 각 작업이 10초 대기한 뒤 전체 완료를 기다리는 동일한 구조로 맞췄고, 여러 스레드보다 코루틴·비동기 작업·고루틴·가상 스레드의 메모리 특성을 비교함
- 비교 대상은 Rust
tokio·async_std, C#과 NativeAOT, NodeJS, Pythonasyncio, Go goroutine, Java virtual thread, Java GraalVM native image이며 전체 코드는 GitHub에 공개됨 - 작업 수가 늘수록 런타임별 메모리 증가 폭이 크게 갈렸고, 100만 작업에서는 C#이 가장 낮은 메모리 사용량을 보였으며 Rust도 효율적인 결과를 유지함
- 최신.NET은 큰 개선을 보였고 NativeAOT가 Rust와 경쟁했지만, Go goroutine은 100만 작업에서 우승 결과보다 13배 이상, Java보다 2배 이상 많은 메모리를 사용함
벤치마크 방식과 공개 자료
- 2024년 말 기준 최신 언어 버전으로 2023년 비동기 프로그래밍 메모리 소비 비교를 다시 수행한 결과임
- 상단에는 최신 결과를 확인하려면 How Much Memory Do You Need in 2024 to Run 1 Million Concurrent Tasks? - Take 2를 보라는 안내가 있음
- 테스트 프로그램은 명령행 인자로 받은
N개의 동시 작업을 만들고, 각 작업이 10초 동안 대기한 뒤 모든 작업이 끝나면 종료함 - 비교 초점은 여러 스레드가 아니라 코루틴 계열 동시성 모델에 있음
- 전체 벤치마크 코드는 async-runtimes-benchmarks-2024에 공개됨
비교 대상 언어와 런타임
- Rust는
tokio와async_std두 가지 비동기 런타임으로 비교함- 둘 다 Rust에서 널리 쓰이는 비동기 런타임임
- C#은
async/await를 직접 지원하며,Task.Delay와Task.WhenAll로 작업을 실행함- .NET 7부터 제공되는 NativeAOT도 함께 비교함
- NativeAOT는 관리 코드를 VM 없이 실행할 수 있도록 최종 바이너리로 직접 컴파일함
- NodeJS는
setTimeout을util.promisify로 감싼 뒤Promise.all로 대기함 - Python은
asyncio.sleep과asyncio.gather를 사용함 - Go는 동시성 구성 요소로 goroutine을 사용하며, 개별 await 대신
WaitGroup으로 모든 작업 완료를 기다림 - Java는 JDK 21부터 제공되는 virtual thread를 사용함
- GraalVM의 native image도 함께 비교함
- GraalVM native image는 .NET NativeAOT와 유사한 개념으로 포함됨
테스트 환경
- 하드웨어: 13th Gen Intel Core i7-13700K
- 운영체제: Debian GNU/Linux 12(bookworm)
- Rust: 1.82.0
- .NET: 9.0.100
- Go: 1.23.3
- Java: openjdk 23.0.1 build 23.0.1+11-39
- Java(GraalVM): java 23.0.1 build 23.0.1+11-jvmci-b01
- NodeJS: v23.2.0
- Python: 3.13.0
- 가능한 경우 모든 프로그램은 release mode로 실행됨
- 테스트 환경에
libicu가 없어 국제화와 전역화 지원은 비활성화됨
작업 수가 늘어날 때의 메모리 변화
-
최소 풋프린트: 1개 작업
- 런타임 자체가 요구하는 메모리를 보기 위해 먼저 작업 1개만 실행함
- Rust, C# NativeAOT, Go는 정적으로 네이티브 바이너리로 컴파일되어 매우 적은 메모리를 사용했고 서로 비슷한 결과를 보임
- Java GraalVM native image도 좋은 결과를 냈지만, 다른 정적 컴파일 대상보다 메모리를 조금 더 사용함
- 관리 플랫폼이나 인터프리터 위에서 실행되는 프로그램은 더 많은 메모리를 소비함
- 이 구간에서는 Go가 가장 작은 풋프린트를 보임
- Java GraalVM은 OpenJDK Java보다 훨씬 많은 메모리를 사용했으며, 설정으로 조정 가능할 수 있음
-
1만 개 작업
- Rust의 두 벤치마크는 1만 개 작업에서도 최소 풋프린트 대비 메모리 사용량이 크게 늘지 않았고, 매우 적은 메모리를 유지함
- C# NativeAOT도 약 10MB의 메모리만 사용하며 Rust 뒤를 가까이 따름
- Go의 메모리 사용량은 이 구간에서 크게 증가함
- Java GraalVM native image의 virtual thread는 Go goroutine보다 더 가벼워 보임
- Go와 Java GraalVM native image는 정적으로 네이티브 바이너리로 컴파일됐지만, VM 위에서 실행되는 C#보다 더 많은 RAM을 사용함
-
10만 개 작업
- 작업 수가 10만 개로 늘어나자 모든 언어의 메모리 소비가 크게 증가하기 시작함
- Rust와 C#은 이 구간에서도 좋은 결과를 냄
- C# NativeAOT는 Rust보다 더 적은 RAM을 사용하며 모든 언어를 앞섬
- Go 프로그램은 이 시점에서 Rust뿐 아니라 Java, C#, NodeJS에도 뒤처짐
- 예외적으로 GraalVM에서 실행한 Java는 Go를 이긴 대상에서 제외됨
-
100만 개 작업
- 100만 개 작업에서는 C#이 다른 모든 언어를 확실히 앞섬
- Rust는 예상대로 메모리 효율 측면에서 좋은 결과를 이어감
- Go와 다른 런타임의 격차는 더 커짐
- Go는 우승 결과보다 13배 이상 많은 메모리를 사용함
- Java와 비교해도 Go는 2배 이상 많은 메모리를 사용해, JVM은 메모리를 많이 쓰고 Go는 가볍다는 일반적 인식과 다른 결과를 보임
최종 관찰
- 동시 작업 수가 매우 많으면 각 작업이 복잡한 연산을 하지 않아도 상당한 메모리를 사용할 수 있음
- 언어 런타임마다 트레이드오프가 다르게 나타남
- 적은 작업 수에서는 가볍고 효율적일 수 있음
- 수십만 개 작업으로 확장하면 메모리 증가 폭이 커질 수 있음
- 최신 컴파일러와 런타임 기준으로 .NET은 큰 개선을 보임
- .NET NativeAOT는 Rust와 경쟁력 있는 결과를 냄
- Java의 GraalVM native image도 메모리 효율에서 좋은 결과를 냄
- Go goroutine은 리소스 소비 측면에서 계속 비효율적인 결과를 보임
댓글과 토론
Hacker News 의견들
-
이 벤치마크는 경우에 따라 서로 다른 대상을 비교하는 느낌임
예를 들어 Node.js에서는 백만 개의 Promise를 런타임 이벤트 루프에 넣고Promise.all로 기다리지만, Go 버전은 백만 개의 고루틴을 만들고waitgroup.Done을defer로 호출함
각 언어의 관용적인 동시성 방식일 수는 있어도, 고루틴과 Promise의 근본적 차이와 런타임 실행 모델 차이를 반영하지 못함
JS는 단일 이벤트 루프 기반이고, Go는 기본적으로 물리 스레드 수만큼 OS 스레드를 만들고 사용자 공간 스케줄러로 고루틴을 배분함
파일 읽기나 네트워크 호출 같은 비동기 I/O는 Promise류가 잘 맞지만, CPU 중심 작업은 고루틴이나 Node.js의worker_threads가 더 적합하므로, 작업 성격별 메모리 사용량 비교가 더 흥미로울 듯함- 오히려 이 벤치마크는 더 많은 벤치마크가 해야 할 일을 했다고 봄
컴파일러 간 차이보다, 전문 Go 개발자나 Node.js 개발자에게 같은 작업을 풀게 했을 때 실제로 어떤 코드가 나오는지가 더 궁금함
다만 HTTP 요청 처리처럼 실제로 유용한 작업을 대상으로 했다면 더 좋았을 것임
Go는 특정 프로그래밍 방식을 강하게 유도하고, JavaScript는 다른 방식을 유도하며, 글은 그 결과를 잘 보여줌 - Go에는 Promise 같은 비동기 방식을 그대로 쓰는 방법이 없는 것으로 알고 있고, 동시 비동기 작업마다 고루틴을 만들어야 한다면 제출된 비교는 타당하다고 봄
다만 논블로킹 작업을 하고 반환값을 받기 위해 고루틴을 하나 띄우는 건 다소 낭비처럼 느껴짐 - 요구사항은 백만 개의 동시 작업을 실행하는 것임
당연히 언어마다 이를 달성하는 방식이 다르고, 각 방식마다 장단점이 있음
애초에 서로 다른 언어들이 존재하는 이유도 그런 차이 때문임 - Java의 가상 스레드는 Java 21 전후에 들어온 매우 새로운 기능이고, OS 스레드는 수십 년 된 방식임
JVM을 많이 쓰는 입장에서는 Java 결과도 가상 스레드와 OS 스레드를 따로 나눠 비교했으면 좋았을 것임
- 오히려 이 벤치마크는 더 많은 벤치마크가 해야 할 일을 했다고 봄
-
“10초 동안 기다리는 작업을 실행”하는 것과 “10초 뒤 깨우기를 예약”하는 것은 다름
메모리 사용량이 낮게 나온 몇몇 언어의 코드는 두 번째를 하고, 높게 나온 쪽은 첫 번째를 함
예를 들어 내 머신에서 글의 Go 코드는 2.5GB를 쓰지만,time.AfterFunc(10*time.Second, wg.Done)으로 깨우기만 예약하는 식의 코드는 124MB만 사용함
이 차이는 Rust 결과와 비슷한 수준임- 동의함
sleep(1 second)를 10번 반복하는 단순한 의사코드만 넣어도 결과가 꽤 달라짐
어떤 이유에서인지 Java는 메모리를 훨씬 많이 쓰고 더 오래 걸리며 약 20초가 걸렸고, C#은 1GB 넘게 썼고, Python은 작업 예약 자체도 버거워 1분 넘게 걸리면서 메모리도 더 사용함
Node.js는 이 변화에 거의 흔들리지 않는 듯해서, 이런 쪽이 더 합리적인 벤치마크라고 봄 - .NET에서 250ms마다 깨어나 네트워크 요청을 보내는 식의
Task를 띄워도, 비동기 오버헤드 자체의 메모리 사용량은 비슷하게 유지될 것임
10만 작업만 되어도 병목은 동시성 원시 요소가 아니라 네트워크 스택이 되고, 초당 40만 건의 외부 요청 전송은SocketAsyncEngine을 써도 CPU와 시스템 호출 비용이 큼
Go에서는 고루틴을 띄우거나, 직접 스케줄링하거나, 채널 리더들을 집계하는 형태가 필요함
반면 .NET의Task는 이런 패턴을 즉시 제공하고, 여러 작업을 빠르게 섞어 실행하려 할수록 동시성 원시 요소의 오버헤드가 중요해짐
.NET에서는 결과가 필요해질 때까지 호출 지점에서await하지 않으면 되며, 이 글은 그 오버헤드가 낮다는 점을 보여줌
- 동의함
-
이 목록의 모든 언어에 공정한 방식이 무엇인지는 모르겠지만, Go와 Node.js만 놓고 보면 Go에서는 타이머를 예약하는 고루틴 하나와 타이머가 울렸을 때 회수하는 고루틴 하나를 쓰는 방식이 더 공정함
그래야 거대한 스택을 만들지 않고, Node.js에서 실제로 하는 일과 더 비슷해짐
또한 Node.js를 넣으면서 Bun과 Deno를 빼는 것도 이상함
다른 언어에도 여러 런타임이 있을 수 있으니, 결국 이 벤치마크는 서로 다른 것을 비교하고 있어 별로 쓸모 있어 보이지 않음 -
“동시 작업 수가 많으면 메모리를 상당히 소비할 수 있다”지만 절대값을 봐야 함
최악의 경우에도 백만 작업이 2.7GB RAM을 썼고, 작업당 오버헤드는 약 2700바이트임
이 정도는 가장 저렴한 서버에도 여유 있게 들어감
결론은 오히려 반대임: 작업별 데이터가 몇 KB 이상이라면 작업 스케줄러 메모리 오버헤드는 무시해도 될 정도임- 그 이상이기도 함
Go와 Java는 각 가상 스레드마다 스택을 유지함
영리하게 처리하긴 하지만, 단순sleep보다 조금이라도 더 많은 일을 했다면 두 시스템 모두 메모리가 크게 터졌을 가능성이 충분함
- 그 이상이기도 함
-
“동시 작업”을 어떻게 정의하느냐에 크게 달렸지만, 글은 정의를 제시함: N개의 동시 작업을 시작하고, 각 작업은 10초 기다린 뒤 모두 끝나면 프로그램이 종료됨
부작용이 없으니 컴파일러가 죽은 코드로 제거할 수 있다는 식의 의미론은 제쳐두면, 사실 필요한 것은 각 “작업”마다 타이머와 이어 실행할 함수뿐이고, 대부분 플랫폼에서 24바이트 정도면 됨
할당 오버헤드와 타이머 관리 자료구조를 감안해도 두 배 정도면 충분하고, 함수 포인터 압축 같은 기법을 쓰면 절반까지도 줄일 수 있음
그래프를 대충 보면 우승자가 백만 동시 작업에 약 200MB를 쓰므로, 꽤 효율적인 구현보다도 약 4배 나쁨
Go가 작업당 2500바이트를 쓰는 이유는 모르겠음- 글의 Go 버전은 각 작업마다 고루틴을 만들고
WaitGroup으로 동기화함
기억하기로 고루틴 기본 스택이 2KB라서, 그 정도 수치가 맞음
공정하지 않으니 더 가벼운 타이머를 써야 한다고 볼 수도 있음
다만 이를 효율적으로 기다리는 직접적인 방법은 없고, 본질적으로 부록의 Rust 프로그램과 비슷해짐 - https://tpaschalis.me/goroutines-size/
https://github.com/golang/go/blob/master/src/runtime/stack.g...
- 글의 Go 버전은 각 작업마다 고루틴을 만들고
-
“Go가 우승자보다 13배 넘게 지고 Java보다도 2배 넘게 져서, JVM은 메모리 돼지이고 Go는 가볍다는 일반 인식과 모순된다”는 식의 결론은 전형적인 인공적 hello world식 벤치마크를 실제 프로그램 대표처럼 믿는 태도임
- 맞지만, Java와 C#도 지난 10년 동안 크게 따라잡아서 지금은 매우 매끄럽게 동작함
느리다는 인식 대부분은 오래전에 접한 레거시 기술에서 온 것이거나, 아직도 과소 사양 IIS 서버에서 돌아가는 끔찍한 .NET Framework 코드를 본 경험에서 비롯된 경우가 많음 - 물론 Java와 C#은 이런 종류의 작업에 비동기보다 스레드를 압도적으로 많이 씀
- 맞지만, Java와 C#도 지난 10년 동안 크게 따라잡아서 지금은 매우 매끄럽게 동작함
-
단순하고 관용적인 코드로 언어를 비교하는 건 좋지만, 완전히 비어 있는 함수 본문과 변수 하나만 강조한 막대그래프로 개발자에게 성능을 보여주는 건 불공정함
그래프만 보면 막대가 작은 언어 X를 안전하게 고르면 된다는 인상을 줌
이런 그래프로 의사결정을 하려는 사람은 직접 벤치마크를 돌리고 두 가지를 추가해야 함: 함수 본문에 최소한의 현실적 작업을 넣어 언어별 메모리 사용 방식을 보고, 메모리뿐 아니라 실행 시간도 측정해 스케줄링 차이를 확인해야 함- 이런 주의는 통계만큼이나 오래된 요구임
그래도 글을 읽은 대부분은 이 결과를 그 자체의 범위 안에서 쓸 준비가 되어 있다고 봄
- 이런 주의는 통계만큼이나 오래된 요구임
-
이런 대개 쓸모없는 벤치마크를 자발적으로 올릴 만큼 대담한 사람이 있다는 게 아직도 이해되지 않음
필연적으로 오류투성이가 될 텐데, 무엇이 그들을 밀어붙이는지 모르겠음
결국 우스꽝스럽게 보이는 경우가 더 많음- 인터넷에 틀린 걸 올리는 게 진실을 배우는 가장 빠른 방법이라는 말이 있음
- 호기심으로 가볍게 시도해 보는 건 해롭지 않음
이런 블로그 글이 근본적 결론을 뽑아낼 엄밀한 과학이 아니라는 건 사람들이 이해한다고 봄
그리고 오류는 기능이기도 함. 정오표에서 가장 많이 배움
-
Rust 비동기를 정기적으로 쓰는데, 부록의 버전이 왜 10 × 1,000,000초가 걸리지 않는지 이해가 안 됐음
즉 동시성이 전혀 일어나지 않을 거라고 예상했음
아래 답변들을 보니 “동시성이 일어나지 않는다”는 점은 맞았지만, 걸리는 시간에 대해서는 틀렸음
tokio::time::sleep()이 future가 처음.await되는 시점이 아니라, future가 만들어진 시점, 즉sleep()이 호출된 시점을 기준으로 시간을 추적하기 때문임sleep구현은 future가 폴링되는 시점이 아니라sleep이 호출된 시점을 기준으로 깨울 시간을 정함
그래서 첫 작업은 1초를 기다리고, 나머지 작업들은 이미 깨울 시간이 지났다고 보고 즉시 반환함
https://docs.rs/tokio/latest/tokio/time/fn.sleep.html- Rust 프로그래머는 아니지만, 업데이트된 설명은 잘못됐을 가능성이 크다고 봄
아마 작업 실행이 시작될 때 시작 시간이 기록되고, 작업이 즉시 비동기 루프에 제어권을 돌려주는 구조에 더 가까울 것임
비동기 루프가 다음 작업을 시작하는 식으로 진행되고, 처음 작업 실행이 시작된 뒤 1초가 지나기 전에는 잠든 작업에 제어권을 돌려주지 않는 것뿐일 가능성이 큼
sleep()호출 시점과 직접 관련 있다고 하면 놀랄 것 같음 - 틀린 것 같음
전체는 약 10초만 걸려야 함
tokio::time::sleep은 future를 반환하기 전에 호출 시각을 기록하므로, 백만 작업 모두 거의 같은 시각, 몇 밀리초 차이 안에서 찍힐 것임
https://docs.rs/tokio/1.41.1/src/tokio/time/sleep.rs.html#12... Tokio::sleep은 비동기임
-
Java의 경우 벤치마크가 적어도 조금은 깨졌고, 다른 것을 테스트하고 있다고 꽤 확신함
ArrayList의 초기 크기를 지정하지 않으면 크기 10짜리 리스트에서 시작하고,add()호출 때 계속 재할당되면서 가비지 컬렉션이 필요한 미사용 객체가 많이 생김- 적절한 크기의 저장 공간을 만드는 편이 확실히 더 나음
다만 내부 배열이add때마다 매번 재할당되지는 않을 것임
백만 번 추가해도 재할당은 30번 미만일 가능성이 높고, 용량은 초기값 10에서 1.5배씩 기하급수적으로 증가함 - 문서에는 “add 연산은 상각 상수 시간으로 실행되며, n개 원소 추가에는 O(n) 시간이 필요하다”고 되어 있음
선형 시간 복잡도라면 스레드 포인터를 리스트에 추가하는 일이 스레드 생성 시간에 크게 기여하지 않는다는 점은 명확해 보임
이런 연산을 상각 선형 시간으로 구현하는 방식은 Python, Crealloc등에서도 쓰이며 https://en.wikipedia.org/wiki/Dynamic_array#Geometric_expans...에 설명되어 있음 - 그건 초보적인 실수임
ArrayList크기를 미리 잡았어야 하고, 더 좋게는 크기를 미리 아는 상황이니 배열을 썼어야 함
배열이 더 메모리 효율적이고, 괜찮은 개발자라면 그렇게 했을 것이라고 봄
글에서는 Rust(tokio)가join_all대신Vec을 순회하는 루프를 써서 리스트 재할당을 피할 수 있다고 했지만, 2년 전 이전 블로그 글에서도 Java에서는 배열을 쓰라는 지적이 있었고 반영하지 않았음
Elixir도 이전 벤치마크에서Task를 쓰면 안 된다는 지적이 있었고, 그 지적을 한 사람이 Elixir 창시자였음: https://github.com/pkolaczk/async-runtimes-benchmarks/pull/7
- 적절한 크기의 저장 공간을 만드는 편이 확실히 더 나음