Оцените модель выполнения программы: создаст ли каждая из 100 000 горутин отдельный поток ОС?
package main
func main() {
ch := make(chan struct{})
for i := 0; i < 100000; i++ {
go func() { <-ch }()
}
select {}
}
Нет. Горутина не соответствует потоку ОС один к одному: заблокированные на чтении из канала горутины переводятся планировщиком Go в состояние ожидания, а потоки ОС могут выполнять другие готовые горутины. Однако 100 000 горутин всё равно занимают память и служебные структуры, поэтому их массовое создание может привести к существенному потреблению ресурсов или нехватке памяти.
Подход с легковесными горутинами нужен для удобного запуска большого числа конкурентных задач без создания отдельного потока ОС для каждой задачи. Планировщик Go мультиплексирует горутины на потоки ОС, скрывая большую часть низкоуровневого управления потоками от разработчика.
Это решает проблему высокой стоимости модели «одна задача — один поток», но не делает горутины бесплатными. Каждая горутина требует памяти для стека и хранит состояние, необходимое планировщику.
В примере создаются 100 000 горутин, каждая из которых выполняет чтение из небуферизованного канала. Отправки в канал и его закрытия нет, поэтому все горутины навсегда переходят в состояние ожидания, а main блокируется в select {}.
Ошибочно считать, что для этого обязательно потребуются 100 000 потоков ОС. Не менее ошибочно считать, что заблокированные горутины не требуют ресурсов: они остаются зарегистрированными в рантайме и сохраняют свои стеки.
Упрощённо планировщик Go оперирует тремя сущностями: G — горутина, M — поток ОС и P — логический процессор, необходимый для выполнения Go-кода. Несколько готовых горутин могут последовательно выполняться на одном потоке ОС.
Когда горутина выполняет <-ch и канал не готов к передаче, рантайм паркует эту горутину: она исключается из очереди runnable и не занимает поток активным ожиданием. Когда в канал поступает значение или канал закрывается, ожидающая горутина может снова стать готовой к выполнению.
Количество потоков ОС не обязано совпадать с количеством горутин и даже не обязано точно совпадать с числом P. Рантайм создаёт и паркует потоки по необходимости; точное поведение зависит от версии Go, числа процессоров, системных вызовов, сборки мусора и других факторов.
Следовательно, блокировка на канале обычно не приводит к модели «одна заблокированная горутина — один занятый поток». Но большое число ожидающих горутин может потреблять значительный объём памяти. Если горутины никогда не будут разблокированы, это также создаёт логическую утечку: они и связанные с ними данные остаются достижимыми до завершения процесса.
Нельзя путать ожидание на канале с блокирующим системным вызовом или вызовом C-кода через cgo. Для таких операций рантайму может потребоваться отдельное управление потоками, хотя это всё равно не означает автоматического создания потока для каждой горутины.
Сервис запускал горутину на каждый входящий запрос. При остановке сервиса запросы отменялись, но рабочие горутины ожидали чтения из канала результатов, который никогда не закрывался. Число горутин росло, хотя число потоков ОС оставалось относительно небольшим; процесс постепенно расходовал память.
Вариант с созданием отдельного потока ОС для каждой задачи был бы ещё дороже и не является моделью выполнения обычных горутин. Вариант с бесконечным ожиданием на канале экономит процессор, но не устраняет утечку жизненного цикла.
Выбранное решение — связать ожидание с контекстом отмены и гарантировать завершение канала или другой сигнал остановки:
Такой подход позволяет горутине завершиться при отмене запроса. Важно также ограничивать число одновременно запускаемых задач и проверять, что потребитель результатов не прекращает работу раньше производителя без предусмотренного сигнала отмены.
Нет. Блокировка освобождает горутину от активного использования процессора, но сама горутина не уничтожается. Рантайм сохраняет её состояние, стек и необходимые ссылки, чтобы возобновить выполнение после готовности канала. Память будет освобождена сборщиком мусора только после завершения горутины и исчезновения ссылок на связанные объекты.
Да, закрытие канала делает ожидающие операции чтения готовыми к завершению. Чтение из закрытого канала возвращает оставшиеся буферизованные значения, а после их исчерпания — нулевое значение типа и ok == false. В примере канал не закрывается, поэтому горутины остаются заблокированными навсегда.
Нет. Горутины задают конкурентность, но фактический параллелизм ограничен доступными логическими процессорами и ресурсами системы. Число одновременно исполняемых Go-кодов обычно определяется GOMAXPROCS, тогда как остальные готовые горутины ждут своей очереди; блокирующие операции и системные вызовы могут влиять на число используемых потоков ОС.