各個語言執行100萬個並行任務需要多少記憶體?

2023-06-01 12:01:00

譯者注:

原文連結:https://pkolaczk.github.io/memory-consumption-of-async/
Github專案地址:https://github.com/pkolaczk/async-runtimes-benchmarks

正文

在這篇部落格文章中,我深入探討了非同步和多執行緒程式設計在記憶體消耗方面的比較,跨足瞭如Rust、Go、Java、C#、Python、Node.js 和 Elixir等流行語言。

不久前,我不得不對幾個計算機程式進行效能比較,這些程式旨在處理大量的網路連線。我發現那些程式在記憶體消耗方面有巨大的差異,甚至超過20倍。有些程式在10000個連線中僅消耗了略高於100MB的記憶體,但另一些程式卻達到了接近3GB。不幸的是,這些程式相當複雜,功能也不盡相同,因此很難直接進行比較並得出有意義的結論,因為這不是一個典型的蘋果到蘋果的比較。這促使我想出了建立一個綜合性基準測試的想法。

基準測試

我使用各種程式語言建立了以下程式:

啟動N個並行任務,每個任務等待10秒鐘,然後在所有任務完成後程式就退出。任務的數量由命令列引數控制。

在ChatGPT的小小幫助下,我可以在幾分鐘內用各種程式語言編寫出這樣的程式,甚至包括那些我不是每天都在用的程式語言。為了方便起見,所有基準測試程式碼都可以在我的GitHub上找到。

Rust

我用Rust編寫了3個程式。第一個程式使用了傳統的執行緒。以下是它的核心部分:

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();
}

另外兩個版本使用了async,一個使用tokio,另一個使用async-std。以下是使用tokio的版本的核心部分:

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();
}

async-std版本與此非常相似,因此我在這裡就不再參照了。

Go

在Go語言中,goroutine是實現並行的基本構建塊。我們不需要分開等待它們,而是使用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提供了虛擬執行緒的預覽,這是一個類似於goroutine的概念。因此,我建立了兩個版本的基準測試。我也很好奇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);

Node.JS

下面是 Node.JS:

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

Elixir 也因其非同步功能而聞名:

tasks =
    for _ <- 1..num_tasks do
        Task.async(fn ->
            :timer.sleep(10000)
        end)
    end

Task.await_many(tasks, :infinity)

測試環境

  • 硬體: Intel(R) Xeon(R) CPU E3-1505M v6 @ 3.00GHz
  • 作業系統: 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
  • Node.JS: v12.22.9
  • Python: 3.10.6
  • Elixir: Erlang/OTP 24 erts-12.2.1, Elixir 1.12.2

所有程式在可用的情況下都使用釋出模式(release mode)進行執行。其他選項保持為預設設定。

結果

最小記憶體佔用

讓我們從一些小的任務開始。因為某些執行時需要為自己分配一些記憶體,所以我們首先只啟動一個任務。

圖1:啟動一個任務所需的峰值記憶體

我們可以看到,這些程式確實分為兩組。Go和Rust程式,靜態編譯為本地可執行檔案,需要很少的記憶體。其他在託管平臺上執行或通過直譯器消耗更多記憶體的程式,儘管在這種情況下Python表現得相當好。這兩組之間的記憶體消耗差距大約有一個數量級。

讓我感到驚訝的是,.NET某種程度上具有最差的記憶體佔用,但我猜這可以通過某些設定進行調整。如果有任何技巧,請在評論中告訴我。在偵錯模式和釋出模式之間,我沒有看到太大的區別。

10k 任務

圖2:啟動10,000個任務所需的峰值記憶體

這裡有一些意外發現!大家可能都預計執行緒將成為這個基準測試的大輸家。這對於Java執行緒確實如此,實際上它們消耗了將近250MB的記憶體。但是從Rust中使用的原生Linux執行緒似乎足夠輕量級,在10000個執行緒時,記憶體消耗仍然低於許多其他執行時的空閒記憶體消耗。非同步任務或虛擬(綠色)執行緒可能比原生執行緒更輕,但我們在只有10000個任務時看不到這種優勢。我們需要更多的任務。

另一個意外之處是Go。Goroutines應該非常輕量,但實際上,它們消耗的記憶體超過了Rust執行緒所需的50%。坦率地說,我本以為Go的優勢會更大。因此,我認為在10000個並行任務中,執行緒仍然是相當有競爭力的替代方案。Linux核心在這方面肯定做得很好。

Go也失去了它在上一個基準測試中相對於Rust非同步所佔據的微小優勢,現在它比最好的Rust程式消耗的記憶體多出6倍以上。它還被Python超越。

最後一個意外之處是,在10000個任務時,.NET的記憶體消耗並沒有從空閒記憶體使用中顯著增加。可能它只是使用了預分配的記憶體。或者它的空閒記憶體使用如此高,10000個任務太少以至於不重要。

100k 任務

我無法在我的系統上啟動100,000個執行緒,所以執行緒基準測試必須被排除。可能這可以通過某種方式調整系統設定來實現,但嘗試了一個小時後,我放棄了。所以在100,000個任務時,你可能不想使用執行緒。

在這一點上,Go程式不僅被Rust擊敗,還被Java、C#和Node.JS擊敗。

而Linux .NET可能有作弊,因為它的記憶體使用仍然沒有增加。