原文連結: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編寫了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語言中,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傳統上使用執行緒,但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();
}
與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:
const delay = util.promisify(setTimeout);
const tasks = [];
for (let i = 0; i < numTasks; i++) {
tasks.push(delay(10000);
}
await Promise.all(tasks);
還有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)
所有程式在可用的情況下都使用釋出模式(release mode)進行執行。其他選項保持為預設設定。
讓我們從一些小的任務開始。因為某些執行時需要為自己分配一些記憶體,所以我們首先只啟動一個任務。
圖1:啟動一個任務所需的峰值記憶體
我們可以看到,這些程式確實分為兩組。Go和Rust程式,靜態編譯為本地可執行檔案,需要很少的記憶體。其他在託管平臺上執行或通過直譯器消耗更多記憶體的程式,儘管在這種情況下Python表現得相當好。這兩組之間的記憶體消耗差距大約有一個數量級。
讓我感到驚訝的是,.NET某種程度上具有最差的記憶體佔用,但我猜這可以通過某些設定進行調整。如果有任何技巧,請在評論中告訴我。在偵錯模式和釋出模式之間,我沒有看到太大的區別。
圖2:啟動10,000個任務所需的峰值記憶體
這裡有一些意外發現!大家可能都預計執行緒將成為這個基準測試的大輸家。這對於Java執行緒確實如此,實際上它們消耗了將近250MB的記憶體。但是從Rust中使用的原生Linux執行緒似乎足夠輕量級,在10000個執行緒時,記憶體消耗仍然低於許多其他執行時的空閒記憶體消耗。非同步任務或虛擬(綠色)執行緒可能比原生執行緒更輕,但我們在只有10000個任務時看不到這種優勢。我們需要更多的任務。
另一個意外之處是Go。Goroutines應該非常輕量,但實際上,它們消耗的記憶體超過了Rust執行緒所需的50%。坦率地說,我本以為Go的優勢會更大。因此,我認為在10000個並行任務中,執行緒仍然是相當有競爭力的替代方案。Linux核心在這方面肯定做得很好。
Go也失去了它在上一個基準測試中相對於Rust非同步所佔據的微小優勢,現在它比最好的Rust程式消耗的記憶體多出6倍以上。它還被Python超越。
最後一個意外之處是,在10000個任務時,.NET的記憶體消耗並沒有從空閒記憶體使用中顯著增加。可能它只是使用了預分配的記憶體。或者它的空閒記憶體使用如此高,10000個任務太少以至於不重要。
我無法在我的系統上啟動100,000個執行緒,所以執行緒基準測試必須被排除。可能這可以通過某種方式調整系統設定來實現,但嘗試了一個小時後,我放棄了。所以在100,000個任務時,你可能不想使用執行緒。
在這一點上,Go程式不僅被Rust擊敗,還被Java、C#和Node.JS擊敗。
而Linux .NET可能有作弊,因為它的記憶體使用仍然沒有增加。