Каждый, кто касался Go, наверняка слышал о том, что горутина — это «легковесный поток», который управляется самим рантаймом, и именно поэтому вы можете запускать тысячи и более горутин. Нас точно не обманывают, но давайте разберёмся, почему это так и какой в этом компромисс.
Что именно будем измерять
Я планирую замерить время запуска программы, объём потребляемой оперативной памяти и загрузку процессора, а также проверить, сколько горутин действительно существует внутри программы. Будем двигаться постепенно от меньшего к большему, а то вдруг мой ноутбук-старичок споткнётся раньше.
Что я ожидаю
Я слышал, что горутина весит от 2 до 8 КБ в зависимости от ОС. Окей, возьмём по максимуму, и пусть миллион горутин займёт 8 ГБ оперативной памяти. Нагрузку на процессор, честно сказать, не представляю, как считать, поэтому накину на глаз: пусть будет 67 процентов. А время запуска программы прикинем так: пусть запуск займёт до 5 секунд.
Запускаем 1 000 000 горутин
Для эксперимента мне нужно, чтобы каждая горутина:
реально стартовала;
сообщила об этом;
не завершилась до момента измерения.
Поэтому внутри каждой горутины полезной работы специально нет:
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
А что с CPU?
Вот здесь мой прогноз в 67% оказался совсем мимо. После того как миллион горутин успел стартовать, процессор практически вернулся к обычной загрузке. На первый взгляд странно. У меня ведь существует миллион горутин. Почему CPU не горит? Причина находится здесь:
<-block
Каждая горутина доходит до чтения из канала, данных в котором нет, и блокируется. Рантайм паркует такую горутину: она продолжает существовать, её стек занимает память, рантайм хранит её состояние, но выполнять на CPU ей сейчас нечего.
Получается важное различие:
существующая горутина != выполняющаяся горутина
Поэтому миллион ожидающих горутин может занимать гигабайты памяти и при этом практически не потреблять процессорное время после того, как все они были запущены и припаркованы.
Вывод
Итак, мой ноутбук действительно пережил 1 000 000 одновременно существующих горутин. Go не взорвался.
Но эксперимент хорошо показывает, что фраза:
горутины дешёвые
нуждается в продолжении.
Они позволяют строить конкурентный код значительно дешевле, чем модель «одна задача — один системный поток», но каждая горутина всё равно требует памяти и работы runtime.
paramtamtam
вы прокрутили последовательно 1кк горутин окном в n потоков, поздравляю! прочитайте как работает планировщик, уверен - вы откроете для себя много интересного (там правда много интересного)
sux_cm Автор
"одновременно существующих» != «одновременно выполняющихся». Я нигде не писал, что миллион горутин одновременно выполнялся на миллионе системных потоков.