Каждый, кто касался 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: %dn", runtime.NumGoroutine())
fmt.Printf("Create time: %sn", elapsed)
fmt.Printf("HeapAlloc: %.2f MBn", float64(after.HeapAlloc)/1024/1024)
fmt.Printf("HeapSys: %.2f MBn", float64(after.HeapSys)/1024/1024)
fmt.Printf("StackInuse: %.2f MBn", float64(after.StackInuse)/1024/1024)
fmt.Printf("StackSys: %.2f MBn", float64(after.StackSys)/1024/1024)
fmt.Printf("Sys: %.2f MBn", float64(after.Sys)/1024/1024)
fmt.Printf(
"HeapAlloc delta: %.2f MBn",
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.
Автор: sux_cm
