Запускаем 100К горутин в Go

Допустим, у вас есть сервер с 1Гб памяти. Вы выполняете на нем какие-то задачки, и для каждой задачки создаете горутину. Внутри горутина очень простая, без рекурсий и жирных объектов в памяти:

func work() int {
    // какая-то несложная работа
}

Сколько одновременно живущих горутин можно создать без проблем для работы сервера?

Результаты опроса на канале:

Сколько можно создать одновременно живущих горутин?

 2% 10–100
    ■■

 6% 100–1000
    ■■■■

11% 1000–10000
    ■■■■■■

14% 10000–100000
    ■■■■■■■

67% 100000+
    ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■

Так ли это? Давайте разбираться.

Запускаем 100К горутин

Горутина — это структура минимального размера около 2Кб, так что формально в 1Гб можно уместить 1024*1024/2 ≈ 500K горутин. Понятно, что часть памяти уже занята, но 100 тысяч уж всяко уместятся, так что это ответ 100000+

Но есть нюанс (и даже не один).

Допустим, мы взяли и запустили все горутины, вот так:

func work() {
    // пока забудем, что именно
    // тут происходит
}

const n = 100_000
for range n {
    go work()
}

В память-то они поместятся, а вот борьба за CPU будет нешуточная. Планировщику горутин придется постоянно переключаться между горутинами, чтобы выполнить их на (небольшом) количестве ядер сервера, так что потери на переключение контекста могут быть заметными.

Создаем 100К горутин, выполняем N

Итак, мы создаем 100 тысяч горутин.

Разумно при этом ограничить количество одновременно выполняющихся горутин, чтобы не пытаться впихнуть их на CPU все разом:

nConc = runtime.NumCPU()*2
sema := make(chan struct{}, nConc)

const n = 100_000
for range n {
    go func() {
        sema <- struct{}{}
        work()
        <-sema
    }()
}

Здесь мы используем буферизованный канал в качестве семафора. Семафор — это такая штука с ограниченным количеством мест, которые можно занимать и освобождать.

Мы по-прежнему создаем 100К горутин, но теперь каждая из них пытается занять семафор перед выполнением работы. Количество мест в семафоре ограничено (16 мест на 8-ядерном сервере), так что в каждый момент времени только 16 горутин будут выполняться, а остальные будут ждать на семафоре.

Общее время выполнения не увеличится (скорее даже уменьшится), а ресурсы сервера будут использоваться более разумно. Если же мы не хотим занимать CPU на 100% и готовы пожертвовать общим временем, то можно уменьшить nConc.

А если вынести sema <- struct{}{} из горутины в общий цикл, то и создаваться горутины будут не все разом, а постепенно.

Всегда ли оптимален такой подход? Нет.

Количество одновременных горутин

Вот конструкция, к которой мы пришли:

func work() {
    // работает работу на работе
}

nConc = runtime.NumCPU()*2
sema := make(chan struct{}, nConc)

const n = 100_000
for range n {
    sema <- struct{}{}
    go func() {
        work()
        <-sema
    }()
}

Количество одновременных горутин равно количеству ядер × 2. Это хороший подход, если работа внутри work в основном использует CPU (CPU-bound).

Но что делать, если work делает HTTP-запросы? Тогда, вероятно, большую часть времени она ожидает ответа от удаленного сервера, и CPU при этом не используется.

Если большую часть времени горутины чего-то ждут (диска, сети) — их можно одновременно выполнять в больших количествах (десятки, сотни, иногда тысячи).

Конкретное количество зависит от длительности ожидания (например, как быстро отвечает удаленный сервер), использования памяти (например, насколько большой приходит ответ) и ограничений по ресурсам (например, количество файловых дескрипторов операционной системы).

Поэтому часто параметр nConc делают настраиваемым, чтобы можно было менять его в продакшене без перекомпиляции приложения.

Итого

  1. Ограничивайте количество одновременных горутин (семафором или другими средствами).
  2. Для CPU-bound задач степень параллелизма определяется количеством ядер.
  3. Для I/O-bound задач степень параллелизма определяется спецификой работы и доступными ресурсами.

Ну и если вся эта многозадачная хурма вам интересна — проходите мой курс (осторожно, он сложный).

★ Подписывайтесь на новые заметки.