Rust, Go, Java, C#, Python, Node.js, Elixir 같은 인기 언어를 사용한 비동기 및 멀티 스레드 프로그래밍 간의 메모리 소비를 비교해 보았습니다.
얼마 전, 대량의 네트워크 연결을 처리하도록 설계된 몇 가지 컴퓨터 프로그램의 성능을 비교할 필요가 있었습니다. 이러한 프로그램들의 메모리 소비에 있어서는 20배 이상의 큰 차이를 보았습니다. 일부 프로그램은 100MB 조금 넘게 사용했지만, 다른 프로그램들은 10k 연결에서 거의 3GB에 도달했습니다. 불행히도, 이 프로그램들은 상당히 복잡하고 기능에서도 차이가 있어 직접 비교하고 의미 있는 결론을 도출하기 어려웠습니다. 이는 저에게 대신 합성 벤치마크를 만들어보는 아이디어를 주었습니다.
벤치마크
다양한 프로그래밍 언어로 다음과 같은 프로그램을 만들었습니다:
N개의 동시 작업을 시작하고, 각 작업은 10초 동안 대기한 후 모든 작업이 끝나면 프로그램이 종료됩니다. 작업 수는 명령 줄 인수에 의해 제어됩니다.
Rust
1. 전통적인 스레드 사용
let mut handles = Vec::new();
for _ in 0..num_threads {
let handle = thread::spawn(|| {
thread::sleep(Duration::from_secs(10));
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
2. 하나는 tokio를 사용하고 다른 하나는 async-std를 사용
※ async-std 변형은 매우 유사하므로 여기서는 인용하지 않음
let mut tasks = Vec::new();
for _ in 0..num_tasks {
tasks.push(task::spawn(async {
time::sleep(Duration::from_secs(10)).await;
}));
}
for task in tasks {
task.await.unwrap();
}
Go
Go에서 고루틴은 동시성을 위한 구성 요소입니다. 별도로 기다리지 않고 대신 WaitGroup을 사용합니다.
var wg sync.WaitGroup
for i := 0; i < numRoutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(10 * time.Second)
}()
}
wg.Wait()
Java
Java는 전통적으로 스레드를 사용하지만 JDK 21은 고루틴과 유사한 개념인 가상 스레드의 미리 보기를 제공합니다. 따라서 벤치마크의 두 가지 변형을 만들었습니다. 또한 Java 스레드가 Rust의 스레드와 어떻게 비교되는지 궁금했습니다.
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < numTasks; i++) {
Thread thread = new Thread(() -> {
try {
Thread.sleep(Duration.ofSeconds(10));
} catch (InterruptedException e) {
}
});
thread.start();
threads.add(thread);
}
for (Thread thread : threads) {
thread.join();
}
그리고 여기에 가상 스레드가 있는 변형 (매우 비슷함)
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < numTasks; i++) {
Thread thread = Thread.startVirtualThread(() -> {
try {
Thread.sleep(Duration.ofSeconds(10));
} catch (InterruptedException e) {
}
});
threads.add(thread);
}
for (Thread thread : threads) {
thread.join();
}
C#
Rust와 유사한 C#은 async/await를 최고 수준으로 지원합니다.
List<Task> tasks = new List<Task>();
for (int i = 0; i < numTasks; i++)
{
Task task = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(10));
});
tasks.Add(task);
}
await Task.WhenAll(tasks);
NodeJS
const delay = util.promisify(setTimeout);
const tasks = [];
for (let i = 0; i < numTasks; i++) {
tasks.push(delay(10000);
}
await Promise.all(tasks);
Python
Python 3.5에는 async/await가 추가되었으므로 다음과 같이 작성할 수 있습니다.
async def perform_task():
await asyncio.sleep(10)
tasks = []
for task_id in range(num_tasks):
task = asyncio.create_task(perform_task())
tasks.append(task)
await asyncio.gather(*tasks)
Elixir
tasks =
for _ <- 1..num_tasks do
Task.async(fn ->
:timer.sleep(10000)
end)
end
Task.await_many(tasks, :infinity)
테스트 환경
Hardware | Intel(R) Xeon(R) CPU E3-1505M v6 @ 3.00GHz |
OS | Ubuntu 22.04 LTS, Linux p5520 5.15.0-72-generic |
Rust | 1.69 |
Go | 1.18.1 |
Java | OpenJDK “21-ea” build 21-ea+22-1890 |
.NET | 6.0.116 |
NodeJS | v12.22.9 |
Python | 3.10.6 |
Elixir | Erlang/OTP 24 erts-12.2.1, Elixir 1.12.2 |
결과
최소 설치 공간
Go 및 Rust 프로그램은 거의 메모리를 사용하지 않는 반면, 다른 프로그램은 더 많은 메모리를 사용하지만 Python은 그 중에서도 가장 효율적입니다. .NET은 가장 많은 메모리를 사용하지만 설정을 조정하여 최적화할 수 있습니다. 디버그 모드와 릴리스 모드 사이에는 큰 차이가 없습니다.
10k Tasks
1. Java 스레드 vs. Rust 네이티브 스레드
예상대로, Java 스레드는 많은 메모리를 소비하는 경향이 있습니다. 하지만 놀랍게도, Rust에서 사용되는 네이티브 Linux 스레드는 매우 가벼운 편으로 나타났습니다. 10,000개의 작업에서도 다른 런타임보다 낮은 메모리를 사용했습니다.
2. Go의 Goroutine
Go의 Goroutine은 일반적으로 가벼운 것으로 알려져 있지만, 이 특정 벤치마크에서는 Rust 스레드보다 많은 메모리를 사용했습니다. 이는 예상과는 다른 결과이며, 더 큰 차이를 기대했을 것입니다.
3. .NET의 메모리 소비
.NET은 10,000개의 작업에서 메모리 소비량이 크게 증가하지 않았습니다. 이는 미리 할당된 메모리를 사용하거나, 유휴 메모리 사용량이 높아서 그렇다고 추측됩니다.
이러한 결과로 보아, 스레드는 여전히 매우 경쟁력 있는 대안으로 나타났습니다. 특히, Rust의 네이티브 스레드는 가벼운 메모리 소비로 주목받았으며, Go의 Goroutine은 다른 결과를 보여줬습니다. 이러한 결과는 언어와 런타임 환경 간의 성능 및 자원 소비에 대한 이해를 높이는 데 기여할 것입니다.
100k Tasks
내 시스템에서 100,000개의 스레드를 실행할 수 없어서 스레드 벤치마크를 제외해야 했습니다. 아마도 시스템 설정을 변경하면 어떻게든 조정할 수 있을 것 같지만 한 시간 동안 시도한 후 포기했습니다. 따라서 100,000개의 작업에서는 스레드를 사용하고 싶지 않을 것입니다.
현재 상황에서는 Go 프로그램이 Rust 뿐만 아니라 Java, C#, Node.js에도 패배한 것으로 나타났습니다. 또한, Linux .NET은 메모리 사용량이 증가하지 않아 속임수를 쓰고 있을 가능성이 있으며, 실제로도 올바른 수의 작업을 시작하고 있으며 메인 루프를 차단하지 않으면서도 작업을 수행합니다. 이러한 결과는 프로그래밍 언어 및 런타임 환경 간의 성능과 자원 사용에 대한 이해를 높이는 데 기여할 것으로 보입니다.
1 Million Tasks
100만 개의 작업에서 Elixir는 **(SystemLimitError) 시스템 제한에 도달하여 포기했습니다. 편집: 일부 댓글 작성자는 프로세스 제한을 늘릴 수 있다고 지적했습니다. Elixir 호출에 --erl '+P 1000000' 매개변수를 추가한 후 정상적으로 실행되었습니다.
최근 결과에 따르면, C# 프로그램의 메모리 소비량이 증가하는 것을 확인할 수 있었지만 여전히 매우 경쟁력이 있습니다. Rust 런타임 중 하나를 조금 이겼다는 점에서 놀라운 성과를 보였습니다.
Go와 다른 프로그램 간의 격차가 더 벌어졌으며, 이제 Go는 우승자로부터 12배 이상 뒤처졌습니다. 또한, Java에 2배 이상 뒤처졌는데, 이는 JVM이 메모리를 많이 소비하는 경향이 있고, 이에 반해 Go가 가벼워야 한다는 일반적인 인식과 모순됩니다.
Rust의 tokio는 여전히 우승하지 못했으며, 100,000개의 작업에서의 성능을 확인한 후에도 이는 놀라운 일이 아니었습니다. 이러한 결과는 프로그래밍 언어 및 런타임 환경 간의 성능 및 자원 사용에 대한 지속적인 연구와 이해를 높이는 데 기여할 것입니다.
결론
이러한 관찰을 통해, 많은 수의 동시 작업은 단순한 작업일지라도 상당한 메모리를 소비할 수 있음을 알 수 있습니다. 서로 다른 언어 런타임은 다양한 트레이드오프를 가지고 있으며, 일부는 소수의 작업에 대해 가벼우나 대규모 작업을 처리하는 데 적합하지 않을 수 있습니다. 다른 런타임은 초기 오버헤드가 높지만 대규모 작업을 쉽게 처리할 수 있습니다. 모든 런타임이 기본 설정으로 매우 많은 수의 동시 작업을 처리할 수 있는 것은 아니므로 이 점을 고려해야 합니다.
또한, 이 비교는 메모리 소비에만 초점을 맞췄지만, 작업 시작 시간과 통신 속도 등 다른 요소도 중요합니다. 특히 100만 개의 작업에서 작업 시작 오버헤드가 분명히 드러났으며 대부분의 프로그램이 완료하는 데 12초 이상 소요되었습니다. 이러한 측면을 고려하여 향후 벤치마크에서 추가적인 분석을 진행할 예정입니다.
참고 링크