驚!Go在十億次循環(huán)和百萬任務(wù)中表現(xiàn)不如Java,究竟為何?
編程語言比較的話題總是能吸引程序員的眼球!
近期外網(wǎng)的兩篇編程語言對(duì)比的文章在國內(nèi)程序員圈里引起熱議。一篇是由Ben Dicken (@BenjDicken)[1] 做的語言性能測試[2],對(duì)比了十多種主流語言在執(zhí)行10億次循環(huán)(一個(gè)雙層循環(huán):1萬 * 10 萬)的速度;另一篇?jiǎng)t是一個(gè)名為hez2010的開發(fā)者做的內(nèi)存開銷測試[3],對(duì)比了多種語言在處理百萬任務(wù)時(shí)的內(nèi)存開銷。
下面是這兩項(xiàng)測試的結(jié)果示意圖:
10億循環(huán)測試結(jié)果
百萬任務(wù)內(nèi)存開銷測試結(jié)果
我們看到:在這兩項(xiàng)測試中,Go的表現(xiàn)不僅遠(yuǎn)不及NonGC的C/Rust,甚至還落后于Java,尤其是在內(nèi)存開銷測試中,Go的內(nèi)存使用顯著高于以“吃內(nèi)存”著稱的Java。這一結(jié)果讓許多開發(fā)者感到意外,因?yàn)镚o通常被認(rèn)為是輕量級(jí)的語言,然而實(shí)際的測試結(jié)果卻揭示了其在高并發(fā)場景下的“內(nèi)存效率不足”。
那么究竟為何在這兩項(xiàng)測試中,Go的表現(xiàn)都不及預(yù)期呢?在這篇文章中,我將探討可能的原因,以供大家參考。
我們先從十億次循環(huán)測試開始。
1. 循環(huán)測試跑的慢,都因編譯器優(yōu)化還不夠
下面是作者給出的Go測試程序[4]:
// why-go-sucks/billion-loops/go/code.go
package main
import (
"fmt"
"math/rand"
"os"
"strconv"
)
func main() {
input, e := strconv.Atoi(os.Args[1]) // Get an input number from the command line
if e != nil {
panic(e)
}
u := int32(input)
r := int32(rand.Intn(10000)) // Get a random number 0 <= r < 10k
var a [10000]int32 // Array of 10k elements initialized to 0
for i := int32(0); i < 10000; i++ { // 10k outer loop iterations
for j := int32(0); j < 100000; j++ { // 100k inner loop iterations, per outer loop iteration
a[i] = a[i] + j%u // Simple sum
}
a[i] += r // Add a random value to each element in array
}
fmt.Println(a[r]) // Print out a single element from the array
}
這段代碼通過命令行參數(shù)獲取一個(gè)整數(shù),然后生成一個(gè)隨機(jī)數(shù),接著通過兩層循環(huán)對(duì)一個(gè)數(shù)組的每個(gè)元素進(jìn)行累加,最終輸出該數(shù)組中以隨機(jī)數(shù)為下標(biāo)對(duì)應(yīng)的數(shù)組元素的值。
我們?cè)賮砜匆幌?競爭對(duì)手"的測試代碼。C測試代碼如下:
// why-go-sucks/billion-loops/c/code.c
#include "stdio.h"
#include "stdlib.h"
#include "stdint.h"
int main (int argc, char** argv) {
int u = atoi(argv[1]); // Get an input number from the command line
int r = rand() % 10000; // Get a random integer 0 <= r < 10k
int32_t a[10000] = {0}; // Array of 10k elements initialized to 0
for (int i = 0; i < 10000; i++) { // 10k outer loop iterations
for (int j = 0; j < 100000; j++) { // 100k inner loop iterations, per outer loop iteration
a[i] = a[i] + j%u; // Simple sum
}
a[i] += r; // Add a random value to each element in array
}
printf("%d\n", a[r]); // Print out a single element from the array
}
下面是Java的測試代碼:
// why-go-sucks/billion-loops/java/code.java
package jvm;
import java.util.Random;
public class code {
public static void main(String[] args) {
var u = Integer.parseInt(args[0]); // Get an input number from the command line
var r = new Random().nextInt(10000); // Get a random number 0 <= r < 10k
var a = new int[10000]; // Array of 10k elements initialized to 0
for (var i = 0; i < 10000; i++) { // 10k outer loop iterations
for (var j = 0; j < 100000; j++) { // 100k inner loop iterations, per outer loop iteration
a[i] = a[i] + j % u; // Simple sum
}
a[i] += r; // Add a random value to each element in array
}
System.out.println(a[r]); // Print out a single element from the array
}
}
你可能不熟悉C或Java,但從代碼的形式上來看,C、Java與Go的代碼確實(shí)處于“同等條件”。這不僅意味著它們?cè)谙嗤挠布蛙浖h(huán)境中運(yùn)行,更包括它們采用了相同的計(jì)算邏輯和算法,以及一致的輸入?yún)?shù)處理等方面的相似性。
為了確認(rèn)一下原作者的測試結(jié)果,我在一臺(tái)阿里云ECS上(amd64,8c32g,CentOS 7.9)對(duì)上面三個(gè)程序進(jìn)行了測試(使用time命令測量計(jì)算耗時(shí)),得到一個(gè)基線結(jié)果。我的環(huán)境下,C、Java和Go的編譯器版本如下:
$go version
go version go1.23.0 linux/amd64
$java -version
openjdk version "17.0.9" 2023-10-17 LTS
OpenJDK Runtime Environment Zulu17.46+19-CA (build 17.0.9+8-LTS)
OpenJDK 64-Bit Server VM Zulu17.46+19-CA (build 17.0.9+8-LTS, mixed mode, sharing)
$gcc -v
使用內(nèi)建 specs。
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/lto-wrapper
目標(biāo):x86_64-redhat-linux
配置為:../configure --prefix=/usr --mandir=/usr/share/man --infodir=/usr/share/info --with-bugurl=http://bugzilla.redhat.com/bugzilla --enable-bootstrap --enable-shared --enable-threads=posix --enable-checking=release --with-system-zlib --enable-__cxa_atexit --disable-libunwind-exceptions --enable-gnu-unique-object --enable-linker-build-id --with-linker-hash-style=gnu --enable-languages=c,c++,objc,obj-c++,java,fortran,ada,go,lto --enable-plugin --enable-initfini-array --disable-libgcj --with-isl=/builddir/build/BUILD/gcc-4.8.5-20150702/obj-x86_64-redhat-linux/isl-install --with-cloog=/builddir/build/BUILD/gcc-4.8.5-20150702/obj-x86_64-redhat-linux/cloog-install --enable-gnu-indirect-function --with-tune=generic --with-arch_32=x86-64 --build=x86_64-redhat-linux
線程模型:posix
gcc 版本 4.8.5 20150623 (Red Hat 4.8.5-44) (GCC)
測試步驟與結(jié)果如下:
Go代碼測試:
$cd why-go-sucks/billion-loops/go
$go build -o code code.go
$time ./code 10
456953
real 0m3.766s
user 0m3.767s
sys 0m0.007s
C代碼測試:
$cd why-go-sucks/billion-loops/c
$gcc -O3 -std=c99 -o code code.c
$time ./code 10
459383
real 0m3.005s
user 0m3.005s
sys 0m0.000s
Java代碼測試:
$javac -d . code.java
$time java -cp . jvm.code 10
456181
real 0m3.105s
user 0m3.092s
sys 0m0.027s
從測試結(jié)果看到(基于real時(shí)間):采用-O3優(yōu)化的C代碼最快,Java落后一個(gè)身位,而**Go則比C慢了25%,比Java慢了21%**。
注:time命令的輸出結(jié)果通常包含三個(gè)主要部分:real、user和sys。real是從命令開始執(zhí)行到結(jié)束所經(jīng)過的實(shí)際時(shí)間(墻鐘時(shí)間),我們依次指標(biāo)為準(zhǔn)。user是程序在用戶模式下執(zhí)行所消耗的CPU時(shí)間。sys則是程序在內(nèi)核模式下執(zhí)行所消耗的CPU時(shí)間(系統(tǒng)調(diào)用)。如果總時(shí)間(real)略低于用戶時(shí)間(user),這表明程序可能在某些時(shí)刻被調(diào)度或等待,而不是持續(xù)占用CPU。這種情況可能是由于輸入輸出操作、等待資源等原因。如果real時(shí)間顯著小于user時(shí)間,這種情況通常發(fā)生在并發(fā)程序中,其中多個(gè)線程或進(jìn)程在不同的時(shí)間段執(zhí)行,導(dǎo)致總的用戶CPU時(shí)間遠(yuǎn)大于實(shí)際的墻鐘時(shí)間。sys時(shí)間保持較低,說明系統(tǒng)調(diào)用的頻率較低,程序主要是執(zhí)行計(jì)算而非進(jìn)行大量的系統(tǒng)交互。
這時(shí)作為Gopher的你可能會(huì)說:原作者編寫的Go測試代碼不夠優(yōu)化,我們能優(yōu)化到比C還快!
大家都知道原代碼是不夠優(yōu)化的,隨意改改計(jì)算邏輯就能帶來大幅提升。但我們不能忘了“同等條件”這個(gè)前提。你采用的優(yōu)化方法,其他語言(C、Java)也可以采用。
那么,在不改變“同等條件”的前提下,我們還能優(yōu)化點(diǎn)啥呢?本著能提升一點(diǎn)是一點(diǎn)的思路,我們嘗試從下面幾個(gè)點(diǎn)優(yōu)化一下,看看效果:
- 去除不必要的if判斷
- 使用更快的rand實(shí)現(xiàn)
- 關(guān)閉邊界檢查
- 避免逃逸
下面是修改之后的代碼:
// why-go-sucks/billion-loops/go/code_optimize.go
package main
import (
"fmt"
"math/rand"
"os"
"strconv"
)
func main() {
input, _ := strconv.Atoi(os.Args[1]) // Get an input number from the command line
u := int32(input)
r := int32(rand.Uint32() % 10000) // Use Uint32 for faster random number generation
var a [10000]int32 // Array of 10k elements initialized to 0
for i := int32(0); i < 10000; i++ { // 10k outer loop iterations
for j := int32(0); j < 100000; j++ { // 100k inner loop iterations, per outer loop iteration
a[i] = a[i] + j%u // Simple sum
}
a[i] += r // Add a random value to each element in array
}
z := a[r]
fmt.Println(z) // Print out a single element from the array
}
我們編譯并運(yùn)行一下測試:
$cd why-go-sucks/billion-loops/go
$go build -o code_optimize -gcflags '-B' code_optimize.go
$time ./code_optimize 10
459443
real 0m3.761s
user 0m3.759s
sys 0m0.011s
對(duì)比一下最初的測試結(jié)果,這些“所謂的優(yōu)化”沒有什么卵用,優(yōu)化前你估計(jì)也能猜測到這個(gè)結(jié)果,因?yàn)槌诉吔鐧z查,其他優(yōu)化都沒有處于循環(huán)執(zhí)行的熱路徑之上。
注:rand.Uint32() % 10000的確要比rand.Intn(10000)快,我自己的benchmark結(jié)果是快約1倍。
那Go程序究竟慢在哪里呢?在“同等條件”下,我能想到的只能是Go編譯器后端在代碼優(yōu)化方面優(yōu)化做的還不夠,相較于GCC、Java等老牌編譯器還有明顯差距。
比如說,原先的代碼中在內(nèi)層循環(huán)中頻繁訪問a[i],導(dǎo)致數(shù)組訪問的讀寫操作較多(從內(nèi)存加載a[i],更新值后寫回)。GCC和Java編譯器在后端很可能做了這樣的優(yōu)化:將數(shù)組元素累積到一個(gè)臨時(shí)變量中,并在外層循環(huán)結(jié)束后寫回?cái)?shù)組,這樣做可以減少內(nèi)層循環(huán)中的內(nèi)存讀寫操作,充分利用CPU緩存和寄存器,加速數(shù)據(jù)處理。
注:數(shù)組從內(nèi)存或緩存讀,而一個(gè)臨時(shí)變量很大可能是從寄存器讀,那讀取速度相差還是很大的。
如果我們手工在Go中實(shí)施這一優(yōu)化,看看能達(dá)到什么效果呢?我們改一下最初版本的Go代碼(code.go),新代碼如下:
// why-go-sucks/billion-loops/go/code_local_var.go
package main
import (
"fmt"
"math/rand"
"os"
"strconv"
)
func main() {
input, e := strconv.Atoi(os.Args[1]) // Get an input number from the command line
if e != nil {
panic(e)
}
u := int32(input)
r := int32(rand.Intn(10000)) // Get a random number 0 <= r < 10k
var a [10000]int32 // Array of 10k elements initialized to 0
for i := int32(0); i < 10000; i++ { // 10k outer loop iterations
temp := a[i]
for j := int32(0); j < 100000; j++ { // 100k inner loop iterations, per outer loop iteration
temp += j % u // Simple sum
}
temp += r // Add a random value to each element in array
a[i] = temp
}
fmt.Println(a[r]) // Print out a single element from the array
}
編譯并運(yùn)行測試:
$go build -o code_local_var code_local_var.go
$time ./code_local_var 10
459169
real 0m3.017s
user 0m3.017s
sys 0m0.007s
我們看到,測試結(jié)果直接就比Java略好一些了。顯然Go編譯器沒有做這種優(yōu)化,從code.go的匯編也大致可以看出來:
圖片
使用[lensm](https://github.com/loov/lensm "lensm")生成的匯編與go源碼對(duì)應(yīng)關(guān)系
而Java顯然做了這類優(yōu)化,我們?cè)谠璊ava代碼版本上按上述優(yōu)化邏輯修改了一下:
// why-go-sucks/billion-loops/java/code_local_var.java
package jvm;
import java.util.Random;
public class code {
public static void main(String[] args) {
var u = Integer.parseInt(args[0]); // 獲取命令行輸入的整數(shù)
var r = new Random().nextInt(10000); // 生成隨機(jī)數(shù) 0 <= r < 10000
var a = new int[10000]; // 定義長度為10000的數(shù)組a
for (var i = 0; i < 10000; i++) { // 10k外層循環(huán)迭代
var temp = a[i]; // 使用臨時(shí)變量存儲(chǔ) a[i] 的值
for (var j = 0; j < 100000; j++) { // 100k內(nèi)層循環(huán)迭代,每次外層循環(huán)迭代
temp += j % u; // 更新臨時(shí)變量的值
}
a[i] = temp + r; // 將臨時(shí)變量的值加上 r 并寫回?cái)?shù)組
}
System.out.println(a[r]); // 輸出 a[r] 的值
}
}
但從運(yùn)行這個(gè)“優(yōu)化”后的程序的結(jié)果來看,其對(duì)java代碼的提升幅度幾乎可以忽略不計(jì):
$time java -cp . jvm.code 10
450375
real 0m3.043s
user 0m3.028s
sys 0m0.027s
這也直接證明了即便采用的是原版java代碼,java編譯器也會(huì)生成帶有抽取局部變量這種優(yōu)化的可執(zhí)行代碼,java程序員無需手工進(jìn)行此類優(yōu)化。
像這種編譯器優(yōu)化,還有不少,比如大家比較熟悉的循環(huán)展開(Loop Unrolling)也可以提升Go程序的性能:
// why-go-sucks/billion-loops/go/code_loop_unrolling.go
package main
import (
"fmt"
"math/rand"
"os"
"strconv"
)
func main() {
input, e := strconv.Atoi(os.Args[1]) // Get an input number from the command line
if e != nil {
panic(e)
}
u := int32(input)
r := int32(rand.Intn(10000)) // Get a random number 0 <= r < 10k
var a [10000]int32 // Array of 10k elements initialized to 0
for i := int32(0); i < 10000; i++ { // 10k outer loop iterations
var sum int32
// Unroll inner loop in chunks of 4 for optimization
for j := int32(0); j < 100000; j += 4 {
sum += j % u
sum += (j + 1) % u
sum += (j + 2) % u
sum += (j + 3) % u
}
a[i] = sum + r // Add the accumulated sum and random value
}
fmt.Println(a[r]) // Print out a single element from the array
}
運(yùn)行這個(gè)Go測試程序,性能如下:
$go build -o code_loop_unrolling code_loop_unrolling.go
$time ./code_loop_unrolling 10
458908
real 0m2.937s
user 0m2.940s
sys 0m0.002s
循環(huán)展開可以增加指令級(jí)并行性,因?yàn)檎归_后的代碼塊中可以有更多的獨(dú)立指令,比如示例中的計(jì)算j % u、(j+1) % u、(j+2) % u和(j+3) % u,這些計(jì)算操作是獨(dú)立的,可以并行執(zhí)行,打破了依賴鏈,從而更好地利用處理器的并行流水線。而原版Go代碼中,每次迭代都會(huì)根據(jù)前一次迭代的結(jié)果更新a[i],形成一個(gè)依賴鏈,這種順序依賴性迫使處理器只能按順序執(zhí)行這些指令,導(dǎo)致流水線停頓。
不過其他語言也可以做同樣的手工優(yōu)化,比如我們對(duì)C代碼做同樣的優(yōu)化(why-go-sucks/billion-loops/c/code_loop_unrolling.c),c測試程序的性能可以提升至2.7s水平,這也證明了初版C程序即便在-O3的情況下編譯器也沒有自動(dòng)為其做這個(gè)優(yōu)化:
$time ./code_loop_unrolling 10
459383
real 0m2.723s
user 0m2.722s
sys 0m0.001s
到這里我們就不再針對(duì)這個(gè)10億次循環(huán)的性能問題做進(jìn)一步展開了,從上面的探索得到的初步結(jié)論就是Go編譯器優(yōu)化做的還不到位所致,期待后續(xù)Go團(tuán)隊(duì)能在編譯器優(yōu)化方面投入更多精力,爭取早日追上GCC/Clang、Java這些成熟的編譯器優(yōu)化水平。
下面我們?cè)賮砜碐o在百萬任務(wù)場景下內(nèi)存開銷大的“問題”。
2. 內(nèi)存占用高,問題出在Goroutine實(shí)現(xiàn)原理
我們先來看第二個(gè)問題的測試代碼:
package main
import (
"fmt"
"os"
"strconv"
"sync"
"time"
)
func main() {
numRoutines := 100000
if len(os.Args) > 1 {
n, err := strconv.Atoi(os.Args[1])
if err == nil {
numRoutines = n
}
}
var wg sync.WaitGroup
for i := 0; i < numRoutines; i++ {
wg.Add(1)
go func() {
time.Sleep(10 * time.Second)
wg.Done()
}()
}
wg.Wait()
}
這個(gè)代碼其實(shí)就是根據(jù)傳入的task數(shù)量啟動(dòng)等同數(shù)量的goroutine,然后每個(gè)goroutine模擬工作負(fù)載sleep 10s,這等效于百萬長連接的場景,只有連接,但沒有收發(fā)消息。
相對(duì)于上一個(gè)問題,這個(gè)問題更好解釋一些。
Go使用的groutine是一種有棧協(xié)程,文章中使用的是每個(gè)task一個(gè)goroutine的模型,且維護(hù)百萬任務(wù)一段時(shí)間,這會(huì)真實(shí)創(chuàng)建百萬個(gè)goroutine(G數(shù)據(jù)結(jié)構(gòu)),并為其分配??臻g(2k起步),這樣你可以算一算,不考慮其他結(jié)構(gòu)的占用,僅每個(gè)goroutine的??臻g所需的內(nèi)存都是極其可觀的:
mem = 1000000 * 2000 Bytes = 2000000000 Bytes = 2G Bytes
所以啟動(dòng)100w goroutine,保底就2GB內(nèi)存出去了,這與原作者測試的結(jié)果十分契合(原文是2.5GB多)。并且,內(nèi)存還會(huì)隨著goroutine數(shù)量增長而線性增加。
那么如何能減少內(nèi)存使用呢?如果采用每個(gè)task一個(gè)goroutine的模型,這個(gè)內(nèi)存占用很難省去,除非將來Go團(tuán)隊(duì)對(duì)goroutine實(shí)現(xiàn)做大修。
如果task是網(wǎng)絡(luò)通信相關(guān)的,可以使用類似gnet這樣的直接基于epoll建構(gòu)的框架,其主要的節(jié)省在于不會(huì)啟動(dòng)那么多goroutine,而是通過一個(gè)goroutine池來處理數(shù)據(jù),每個(gè)池中的goroutine負(fù)責(zé)一批網(wǎng)絡(luò)連接或網(wǎng)絡(luò)請(qǐng)求。
在一些Gopher的印象中,Goroutine一旦分配就不回收,這會(huì)使他們會(huì)誤認(rèn)為一旦分配了100w goroutine,這2.5G內(nèi)存空間將始終被占用,真實(shí)情況是這樣么?我們用一個(gè)示例程序驗(yàn)證一下就好了:
// why-go-sucks/million-tasks/million-tasks.go
package main
import (
"fmt"
"log"
"os"
"os/signal"
"runtime"
"sync"
"syscall"
"time"
)
// 打印當(dāng)前內(nèi)存使用情況和相關(guān)信息
func printMemoryUsage() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
// 獲取當(dāng)前 goroutine 數(shù)量
numGoroutines := runtime.NumGoroutine()
// 獲取當(dāng)前線程數(shù)量
numThreads := runtime.NumCPU() // Go runtime 不直接提供線程數(shù)量,但可以通過 NumCPU 獲取邏輯處理器數(shù)量
fmt.Printf("======>\n")
fmt.Printf("Alloc = %v MiB", bToMb(m.Alloc))
fmt.Printf("\tTotalAlloc = %v MiB", bToMb(m.TotalAlloc))
fmt.Printf("\tSys = %v MiB", bToMb(m.Sys))
fmt.Printf("\tNumGC = %v", m.NumGC)
fmt.Printf("\tNumGoroutines = %v", numGoroutines)
fmt.Printf("\tNumThreads = %v\n", numThreads)
fmt.Printf("<======\n\n")
}
// 將字節(jié)轉(zhuǎn)換為 MB
func bToMb(b uint64) uint64 {
return b / 1024 / 1024
}
func main() {
const signal1Goroutines = 900000
const signal2Goroutines = 90000
const signal3Goroutines = 10000
// 用于接收退出信號(hào)
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// 控制 goroutine 的退出
signal1Chan := make(chan struct{})
signal2Chan := make(chan struct{})
signal3Chan := make(chan struct{})
var wg sync.WaitGroup
ticker := time.NewTicker(5 * time.Second)
go func() {
for range ticker.C {
printMemoryUsage()
}
}()
// 等待退出信號(hào)
go func() {
count := 0
for {
<-sigChan
count++
if count == 1 {
log.Println("收到第一類goroutine退出信號(hào)")
close(signal1Chan) // 關(guān)閉 signal1Chan,通知第一類 goroutine 退出
continue
}
if count == 2 {
log.Println("收到第二類goroutine退出信號(hào)")
close(signal2Chan) // 關(guān)閉 signal2Chan,通知第二類 goroutine 退出
continue
}
log.Println("收到第三類goroutine退出信號(hào)")
close(signal3Chan) // 關(guān)閉 signal3Chan,通知第三類 goroutine 退出
return
}
}()
// 啟動(dòng)第一類 goroutine(在收到 signal1 時(shí)退出)
log.Println("開始啟動(dòng)第一類goroutine...")
for i := 0; i < signal1Goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 模擬工作
for {
select {
case <-signal1Chan:
return
default:
time.Sleep(10 * time.Second) // 模擬一些工作
}
}
}(i)
}
log.Println("啟動(dòng)第一類goroutine(900000) ok")
time.Sleep(time.Second * 5)
// 啟動(dòng)第二類 goroutine(在收到 signal2 時(shí)退出)
log.Println("開始啟動(dòng)第二類goroutine...")
for i := 0; i < signal2Goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 模擬工作
for {
select {
case <-signal2Chan:
return
default:
time.Sleep(10 * time.Second) // 模擬一些工作
}
}
}(i)
}
log.Println("啟動(dòng)第二類goroutine(90000) ok")
time.Sleep(time.Second * 5)
// 啟動(dòng)第三類goroutine(隨程序退出而退出)
log.Println("開始啟動(dòng)第三類goroutine...")
for i := 0; i < signal3Goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 模擬工作
for {
select {
case <-signal3Chan:
return
default:
time.Sleep(10 * time.Second) // 模擬一些工作
}
}
}(i)
}
log.Println("啟動(dòng)第三類goroutine(90000) ok")
// 等待所有 goroutine 完成
wg.Wait()
fmt.Println("所有 goroutine 已退出,程序結(jié)束")
}
這個(gè)程序我就不詳細(xì)解釋了。大致分三類goroutine,第一類90w個(gè),在我發(fā)送第一個(gè)ctrl+c信號(hào)后退出,第二類9w個(gè),在我發(fā)送第二個(gè)ctrl+c信號(hào)后退出,最后一類1w個(gè),隨著程序退出而退出。
在我的執(zhí)行環(huán)境下編譯和執(zhí)行一下這個(gè)程序,并結(jié)合runtime輸出以及使用top -p pid的方式查看其內(nèi)存占用:
$go build million-tasks.go
$./million-tasks
2024/12/01 22:07:03 開始啟動(dòng)第一類goroutine...
2024/12/01 22:07:05 啟動(dòng)第一類goroutine(900000) ok
======>
Alloc = 511 MiB TotalAlloc = 602 MiB Sys = 2311 MiB NumGC = 9 NumGoroutines = 900004 NumThreads = 8
<======
2024/12/01 22:07:10 開始啟動(dòng)第二類goroutine...
2024/12/01 22:07:11 啟動(dòng)第二類goroutine(90000) ok
======>
Alloc = 577 MiB TotalAlloc = 668 MiB Sys = 2553 MiB NumGC = 9 NumGoroutines = 990004 NumThreads = 8
<======
2024/12/01 22:07:16 開始啟動(dòng)第三類goroutine...
2024/12/01 22:07:16 啟動(dòng)第三類goroutine(90000) ok
======>
Alloc = 597 MiB TotalAlloc = 688 MiB Sys = 2593 MiB NumGC = 9 NumGoroutines = 1000004 NumThreads = 8
<======
======>
Alloc = 600 MiB TotalAlloc = 690 MiB Sys = 2597 MiB NumGC = 9 NumGoroutines = 1000004 NumThreads = 8
<======
... ...
======>
Alloc = 536 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 10 NumGoroutines = 1000004 NumThreads = 8
<======
100w goroutine全部創(chuàng)建ok后,我們查看一下top輸出:
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
5800 root 20 0 3875556 2.5g 988 S 54.0 8.2 0:30.92 million-tasks
我們看到RES為2.5g,和我們預(yù)期的一致!
接下來,我們停掉第一批90w個(gè)goroutine,看RES是否會(huì)下降,何時(shí)會(huì)下降!
輸入ctrl+c,停掉第一批90w goroutine:
^C2024/12/01 22:10:15 收到第一類goroutine退出信號(hào)
======>
Alloc = 536 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 10 NumGoroutines = 723198 NumThreads = 8
<======
======>
Alloc = 536 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 10 NumGoroutines = 723198 NumThreads = 8
<======
======>
Alloc = 536 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 10 NumGoroutines = 100004 NumThreads = 8
<======
======>
Alloc = 536 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 10 NumGoroutines = 100004 NumThreads = 8
<======
... ...
但同時(shí)刻的top顯示RES并沒有變化:
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
5800 root 20 0 3875812 2.5g 988 S 0.0 8.2 0:56.38 million-tasks
等待兩個(gè)GC間隔的時(shí)間后(大約4分),Goroutine的??臻g被釋放:
======>
Alloc = 465 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 12 NumGoroutines = 100004 NumThreads = 8
<======
======>
Alloc = 465 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 12 NumGoroutines = 100004 NumThreads = 8
<======
======>
Alloc = 465 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 12 NumGoroutines = 100004 NumThreads = 8
<======
======>
Alloc = 465 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 12 NumGoroutines = 100004 NumThreads = 8
<======
top顯示RES從2.5g下降為大概700多MB(RES的單位是KB):
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
5800 root 20 0 3875812 764136 992 S 0.0 2.4 1:01.87 million-tasks
接下來,我們?cè)偻5舻诙?w goroutine:
^C2024/12/01 22:16:21 收到第二類goroutine退出信號(hào)
======>
Alloc = 465 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 13 NumGoroutines = 100004 NumThreads = 8
<======
======>
Alloc = 465 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 13 NumGoroutines = 100004 NumThreads = 8
<======
======>
Alloc = 465 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 13 NumGoroutines = 10004 NumThreads = 8
<======
======>
Alloc = 465 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 13 NumGoroutines = 10004 NumThreads = 8
<======
此時(shí),top值也沒立即改變:
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
5800 root 20 0 3875812 764136 992 S 0.0 2.4 1:05.99 million-tasks
大約等待一個(gè)GC間隔(2分鐘)后,top中RES下降:
======>
Alloc = 458 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 14 NumGoroutines = 10004 NumThreads = 8
<======
======>
Alloc = 458 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 14 NumGoroutines = 10004 NumThreads = 8
<======
======>
Alloc = 458 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 14 NumGoroutines = 10004 NumThreads = 8
<======
RES變?yōu)椴坏?00M:
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
5800 root 20 0 3875812 699156 992 S 0.0 2.2 1:06.75 million-tasks
第三次按下ctrl+c,程序退出:
^C2024/12/01 22:18:46 收到第三類goroutine退出信號(hào)
======>
Alloc = 458 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 14 NumGoroutines = 10003 NumThreads = 8
<======
======>
Alloc = 458 MiB TotalAlloc = 695 MiB Sys = 2606 MiB NumGC = 14 NumGoroutines = 10003 NumThreads = 8
<======
所有 goroutine 已退出,程序結(jié)束
我們看到Go是會(huì)回收goroutine占用的內(nèi)存空間的,并且歸還給OS,只是這種歸還比較lazy。尤其是,第二次停止goroutine前,go程序剩下10w goroutine,按理論來講需占用大約200MB的空間,實(shí)際上卻是700多MB;第二次停止goroutine后,goroutine數(shù)量降為1w,理論占用應(yīng)該在20MB,但實(shí)際卻是600多MB,我們看到go運(yùn)行時(shí)這種lazy歸還OS內(nèi)存的行為可能也是“故意為之”,是為了避免反復(fù)從OS申請(qǐng)和歸還內(nèi)存。
3. 小結(jié)
本文主要探討了Go語言在十億次循環(huán)和百萬任務(wù)的測試中的表現(xiàn)令人意外地遜色于Java和C語言的原因。我認(rèn)為Go在循環(huán)執(zhí)行中的慢速表現(xiàn),主要是其編譯器優(yōu)化不足,影響了執(zhí)行效率。 而在內(nèi)存開銷方面,Go的Goroutine實(shí)現(xiàn)是使得內(nèi)存使用量大幅增加的“罪魁禍?zhǔn)住?,這是由于每個(gè)Goroutine初始都會(huì)分配固定大小的棧空間。
通過本文的探討,我的主要目的是希望大家不要以訛傳訛,而是要搞清楚背后的真正原因,并正視Go在某些方面的不足,以及其當(dāng)前在某些應(yīng)用上下文中的局限性。 同時(shí),也希望Go開發(fā)團(tuán)隊(duì)在編譯器優(yōu)化方面進(jìn)行更多投入,以提升Go在高性能計(jì)算領(lǐng)域的競爭力。
本文涉及的源碼可以在這里[5]下載。
4. 參考資料
- Billion nested loop iterations[6] - https://benjdd.com/languages/
- How Much Memory Do You Need in 2024 to Run 1 Million Concurrent Tasks?[7] - https://hez2010.github.io/async-runtimes-benchmarks-2024/
參考資料
[1] Ben Dicken (@BenjDicken): https://benjdd.com
[2] 語言性能測試: https://benjdd.com/languages/
[3] 內(nèi)存開銷測試: https://hez2010.github.io/async-runtimes-benchmarks-2024/
[4] Go測試程序: https://github.com/bddicken/languages/blob/main/loops/go/code.go
[5] 這里: https://github.com/bigwhite/experiments/tree/master/why-go-sucks
[6] Billion nested loop iterations: https://benjdd.com/languages/
[7] How Much Memory Do You Need in 2024 to Run 1 Million Concurrent Tasks?: https://hez2010.github.io/async-runtimes-benchmarks-2024/