ПрограммированиеBackend разработчик

Как работают горутины (goroutines) и планировщик Go, и почему важно правильно управлять конкурентным выполнением задач?

Проходите собеседования с ИИ помощником Hintsage

Ответ.

Горутины — это лёгкие потоки исполнения, заложенные в архитектуру Go c самых первых версий для достижения эффективной конкурентности. Исторически идея lightweight-thread появилась как попытка обойти дороговизну системных потоков, а также из-за высокой потребности в масштабируемых серверных приложениях. Go изначально проектировался как язык для серверных и сетевых систем, где миллионы задач должны обрабатываться параллельно.

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

Решение: Горутины запускаются через ключевое слово go. Работа горутин планируется планировщиком Go, который использует модель M:N (M потоков ОС обслуживают N горутин языка Go). Для управления жизненным циклом применяют каналы, WaitGroup, context и контроль закрытия каналов.

Пример кода:

package main import ("fmt"; "time") func worker(id int) { fmt.Printf("Worker %d started ", id) time.Sleep(time.Second) fmt.Printf("Worker %d done ", id) } func main() { for i := 1; i <= 3; i++ { go worker(i) } time.Sleep(2 * time.Second) }

Ключевые особенности:

  • Мгновенное и дешёвое создание горутин (в десятки тысяч раз дешевле, чем поток ОС).
  • Прямое взаимодействие через каналы, обеспечение синхронизации и обмена данными.
  • Необходимость ручного контроля за завершением работы (какие горутины дожидаются, кто их прерывает, как производится сигнализация о остановке).

Вопросы с подвохом.

Если в main горутину не дожидаться явно, всегда ли она выполнится?

Нет, выполнение main завершается — процесс завершится независимо от состояния дочерних горутин, и не все задачи будут выполнены.

Является ли запуск go func(...) из цикла гарантией, что каждая горутина получит собственное значение переменных цикла?

Нет, возникает проблемa захвата переменной цикла, горутины могут работать с одним и тем же значением среза/переменной. Нужно использовать копирование переменной, например, передавать как аргумент:

for i := 0; i < 3; i++ { go func(n int) { fmt.Println(n) }(i) }

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

Да, если в ней запускается бесконечный или очень тяжелый цикл без точек переключения (например, без вызовов функции времени или yield), она может удерживать поток ОС — хотя это противодействует идеологии Go о "кооперативном многозадачности". Например, тяжёлая функция без блокировок:

func busy() { for { // Нет никаких ожиданий или блокирующих вызовов } }

Типовые ошибки и анти-паттерны

  • Запуск горутин без контроля их завершения
  • Захват переменных цикла без передачи их внутрь анонимных функций
  • Перегрузка системы due to "leaky goroutines" (утечки не завершающихся горутин)
  • Игнорирование ошибок синхронизации при обмене через каналы

Пример из жизни

Негативный кейс

В микросервисе запускают периодически горутину чтения из базы данных, но забывают завершать её при отмене запроса. В результате остаются "висячие" горутины, которые со временем приводят к потреблению всей оперативной памяти.

Плюсы:

  • Высокая скорость старта
  • Простота масштабирования

Минусы:

  • Утечка памяти
  • Рост времени отклика
  • Непредсказуемое завершение

Позитивный кейс

Используется context для контроля отмены задач, WaitGroup — для управления завершением всех горутин перед остановкой приложения, а каналы — для корректной передачи данных между исполнителями.

Плюсы:

  • Предсказуемый жизненный цикл
  • Управление завершением
  • Легко масштабируется

Минусы:

  • Нужно явно писать логику отмены и синхронизации
  • Чуть более сложная архитектура программы