Запустили 1 000 000 горутин на Go. Вот сколько это стоило
- четверг, 24 сентября 2026 г. в 00:00:10
Каждый, кто касался Go, наверняка слышал о том, что горутина — это «легковесный поток», который управляется самим рантаймом, и именно поэтому вы можете запускать тысячи и более горутин. Нас точно не обманывают, но давайте разберёмся, почему это так и какой в этом компромисс.
Я планирую замерить время запуска программы, объём потребляемой оперативной памяти и загрузку процессора, а также проверить, сколько горутин действительно существует внутри программы. Будем двигаться постепенно от меньшего к большему, а то вдруг мой ноутбук-старичок споткнётся раньше.
Я слышал, что горутина весит от 2 до 8 КБ в зависимости от ОС. Окей, возьмём по максимуму, и пусть миллион горутин займёт 8 ГБ оперативной памяти. Нагрузку на процессор, честно сказать, не представляю, как считать, поэтому накину на глаз: пусть будет 67 процентов. А время запуска программы прикинем так: пусть запуск займёт до 5 секунд.
Для эксперимента мне нужно, чтобы каждая горутина:
реально стартовала;
сообщила об этом;
не завершилась до момента измерения.
Поэтому внутри каждой горутины полезной работы специально нет:
go func() { wg.Done() <-block }()
wg.Done() показывает, что горутина уже начала выполняться, а чтение из block оставляет её жить и переводит в ожидание.
После запуска всех горутин вызывается:
wg.Wait()
Поэтому к моменту измерений каждая из них гарантированно успела хотя бы начать выполнение.
Полный код эксперимента:
package main import ( "flag" "fmt" "runtime" "sync" "time" ) func main() { n := flag.Int("n", 1_000_000, "number of goroutines") flag.Parse() block := make(chan struct{}) var wg sync.WaitGroup wg.Add(*n) var before runtime.MemStats runtime.ReadMemStats(&before) start := time.Now() for i := 0; i < *n; i++ { go func() { wg.Done() <-block }() } wg.Wait() elapsed := time.Since(start) var after runtime.MemStats runtime.ReadMemStats(&after) fmt.Printf("Goroutines: %d\n", runtime.NumGoroutine()) fmt.Printf("Create time: %s\n", elapsed) fmt.Printf("HeapAlloc: %.2f MB\n", float64(after.HeapAlloc)/1024/1024) fmt.Printf("HeapSys: %.2f MB\n", float64(after.HeapSys)/1024/1024) fmt.Printf("StackInuse: %.2f MB\n", float64(after.StackInuse)/1024/1024) fmt.Printf("StackSys: %.2f MB\n", float64(after.StackSys)/1024/1024) fmt.Printf("Sys: %.2f MB\n", float64(after.Sys)/1024/1024) fmt.Printf( "HeapAlloc delta: %.2f MB\n", float64(after.HeapAlloc-before.HeapAlloc)/1024/1024, ) fmt.Println("\nPress Enter to exit...") fmt.Scanln() close(block) }
Goroutine | Время создания и старта | StackInuse | Sys | Working Set | Private Memory |
|---|---|---|---|---|---|
1 000 | 6.0 ms | 7.94 MiB | 15.27 MiB | 14.33 MiB | 21.66 MiB |
10 000 | 55.5 ms | 78.38 MiB | 92.08 MiB | 90.99 MiB | 98.16 MiB |
100 000 | 628 ms | 781.50 MiB | 854.77 MiB | 849.27 MiB | 858.95 MiB |
500 000 | 3.92 s | 3906.53 MiB | 4224.45 MiB | 4217.69 MiB | 4234.79 MiB |
1 000 000 | 7.92 s | 7812.81 MiB | 8457.53 MiB | 8107.91 MiB | 8446.18 MiB |
При -n 1000000 программа показала:
Goroutines: 1000001
Почему миллион и ещё одна?
Потому что, помимо нашего миллиона, у программы есть основная горутина main.Моя оценка по памяти неожиданно оказалась очень близкой. А вот со временем уже нет:
7.92 секунды
вместо ожидаемых пяти.
Рост при этом получился довольно понятным:
1K → 6 ms 10K → 55.5 ms 100K → 628 ms 500K → 3.92 s 1M → 7.92 s
На больших значениях время выглядит близким к линейному росту относительно количества горутин.
Теперь самое интересное. Посмотрим только на StackInuse:
1 000 → 7.94 MiB 10 000 → 78.38 MiB 100 000 → 781.50 MiB 500 000 → 3906.53 MiB 1 000 000 → 7812.81 MiB
Пересчитаем последний результат:
7812.81 MiB × 1024 / 1 000 000 ≈ 8 KiB
Практически ровно столько я и заложил в ожидания. Я случайно попал? Не совсем…
Лезем в runtime/stack.go.
Минимальный стек для Go-кода задаётся как:
stackMin = 2048
То есть 2 KiB.
Но для Windows рантайм добавляет дополнительное пространство:
stackSystem = goos.IsWindows*4096 + ...
Получаем:
2048 + 4096 = 6144 байт
После этого рантайм округляет размер выделяемого стека вверх до подходящей степени двойки:
8192 байта = 8 KiB
Вот здесь мой прогноз в 67% оказался совсем мимо. После того как миллион горутин успел стартовать, процессор практически вернулся к обычной загрузке. На первый взгляд странно. У меня ведь существует миллион горутин. Почему CPU не горит? Причина находится здесь:
<-block
Каждая горутина доходит до чтения из канала, данных в котором нет, и блокируется. Рантайм паркует такую горутину: она продолжает существовать, её стек занимает память, рантайм хранит её состояние, но выполнять на CPU ей сейчас нечего.
Получается важное различие:
существующая горутина != выполняющаяся горутина
Поэтому миллион ожидающих горутин может занимать гигабайты памяти и при этом практически не потреблять процессорное время после того, как все они были запущены и припаркованы.
Итак, мой ноутбук действительно пережил 1 000 000 одновременно существующих горутин. Go не взорвался.
Но эксперимент хорошо показывает, что фраза:
горутины дешёвые
нуждается в продолжении.
Они позволяют строить конкурентный код значительно дешевле, чем модель «одна задача — один системный поток», но каждая горутина всё равно требует памяти и работы runtime.