본문 바로가기
Rust

[Rust] Rust, Go, Java, C#, Python, Node.js, Elixir 1백만 개의 동시 작업을 실행 테스트 (번역)

by 검은냥냥이 2024. 3. 28.

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

 

결과

최소 설치 공간

그림 1: 하나의 작업을 실행하는 데 필요한 최대 메모리

Go 및 Rust 프로그램은 거의 메모리를 사용하지 않는 반면, 다른 프로그램은 더 많은 메모리를 사용하지만 Python은 그 중에서도 가장 효율적입니다. .NET은 가장 많은 메모리를 사용하지만 설정을 조정하여 최적화할 수 있습니다. 디버그 모드와 릴리스 모드 사이에는 큰 차이가 없습니다.

 

10k Tasks

그림 2: 10,000개의 작업을 실행하는 데 필요한 최대 메모리

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개의 작업에서는 스레드를 사용하고 싶지 않을 것입니다.

그림 3: 100,000개의 작업을 실행하는 데 필요한 최대 메모리

현재 상황에서는 Go 프로그램이 Rust 뿐만 아니라 Java, C#, Node.js에도 패배한 것으로 나타났습니다. 또한, Linux .NET은 메모리 사용량이 증가하지 않아 속임수를 쓰고 있을 가능성이 있으며, 실제로도 올바른 수의 작업을 시작하고 있으며 메인 루프를 차단하지 않으면서도 작업을 수행합니다. 이러한 결과는 프로그래밍 언어 및 런타임 환경 간의 성능과 자원 사용에 대한 이해를 높이는 데 기여할 것으로 보입니다.

 

1 Million Tasks

100만 개의 작업에서 Elixir는 **(SystemLimitError) 시스템 제한에 도달하여 포기했습니다. 편집: 일부 댓글 작성자는 프로세스 제한을 늘릴 수 있다고 지적했습니다. Elixir 호출에 --erl '+P 1000000' 매개변수를 추가한 후 정상적으로 실행되었습니다.

그림 4: 1백만 개의 작업을 실행하는 데 필요한 최대 메모리

최근 결과에 따르면, C# 프로그램의 메모리 소비량이 증가하는 것을 확인할 수 있었지만 여전히 매우 경쟁력이 있습니다. Rust 런타임 중 하나를 조금 이겼다는 점에서 놀라운 성과를 보였습니다.

Go와 다른 프로그램 간의 격차가 더 벌어졌으며, 이제 Go는 우승자로부터 12배 이상 뒤처졌습니다. 또한, Java에 2배 이상 뒤처졌는데, 이는 JVM이 메모리를 많이 소비하는 경향이 있고, 이에 반해 Go가 가벼워야 한다는 일반적인 인식과 모순됩니다.

Rust의 tokio는 여전히 우승하지 못했으며, 100,000개의 작업에서의 성능을 확인한 후에도 이는 놀라운 일이 아니었습니다. 이러한 결과는 프로그래밍 언어 및 런타임 환경 간의 성능 및 자원 사용에 대한 지속적인 연구와 이해를 높이는 데 기여할 것입니다.

 

결론

이러한 관찰을 통해, 많은 수의 동시 작업은 단순한 작업일지라도 상당한 메모리를 소비할 수 있음을 알 수 있습니다. 서로 다른 언어 런타임은 다양한 트레이드오프를 가지고 있으며, 일부는 소수의 작업에 대해 가벼우나 대규모 작업을 처리하는 데 적합하지 않을 수 있습니다. 다른 런타임은 초기 오버헤드가 높지만 대규모 작업을 쉽게 처리할 수 있습니다. 모든 런타임이 기본 설정으로 매우 많은 수의 동시 작업을 처리할 수 있는 것은 아니므로 이 점을 고려해야 합니다.

또한, 이 비교는 메모리 소비에만 초점을 맞췄지만, 작업 시작 시간과 통신 속도 등 다른 요소도 중요합니다. 특히 100만 개의 작업에서 작업 시작 오버헤드가 분명히 드러났으며 대부분의 프로그램이 완료하는 데 12초 이상 소요되었습니다. 이러한 측면을 고려하여 향후 벤치마크에서 추가적인 분석을 진행할 예정입니다.

 

참고 링크

 

How Much Memory Do You Need to Run 1 Million Concurrent Tasks? | Piotr Kołaczkowski

May 21, 2023 In this blog post, I delve into the comparison of memory consumption between asynchronous and multi-threaded programming across popular languages like Rust, Go, Java, C#, Python, Node.js and Elixir. Some time ago I had to compare performance o

pkolaczk.github.io

 

 

728x90
사업자 정보 표시
레플라 | 홍대기 | 경기도 부천시 부일로 519 화신오피스텔 1404호 | 사업자 등록번호 : 726-04-01977 | TEL : 070-8800-6071 | Mail : support@reafla.co.kr | 통신판매신고번호 : 호 | 사이버몰의 이용약관 바로가기