Открыть сервис

Горутины

Горутина — это легковесный поток выполнения (англ. lightweight thread), управляемый на уровне языка программирования, а не операционной системы. В языке Go (Golang) горутины являются фундаментальной единицей конкурентного выполнения. Они позволяют выполнять функции параллельно или псевдопараллельно, используя меньше ресурсов, чем системные потоки (threads), и управляются встроенным планировщиком Go runtime. Горутины стартуют с начальным стеком размером всего несколько килобайт, который может динамически расти и сокращаться по мере необходимости, что позволяет запускать тысячи или даже миллионы горутин в одной программе.

История

Концепция горутин восходит к более ранним идеям в области языков программирования, в частности к корутинам (сопрограммам), предложенным Мелвином Конвеем в 1963 году, и к волокнам (fibers), используемым в некоторых операционных системах. Однако непосредственным предшественником горутин считается механизм «процессов» (processes) в языке Newsqueak, разработанном Робертом Пайком в 1980-х годах. Пайк также участвовал в создании языка Limbo, где использовались аналогичные конструкции.

Язык Go был разработан в Google Робертом Пайком, Кеном Томпсоном и Робертом Гриземером и официально анонсирован в 2009 году. Горутины стали ключевой особенностью Go, отличающей его от многих современных языков. В отличие от традиционных потоков ОС, горутины не имеют фиксированного идентификатора (ID) и не могут быть принудительно остановлены извне — они завершаются только при возврате из функции или при завершении всей программы. Это упрощает модель конкурентности и снижает риск ошибок, связанных с синхронизацией.

Устройство и работа

Планировщик Go

Горутины выполняются под управлением планировщика Go runtime, который реализует модель M:N — отображение M горутин на N системных потоков (OS threads). Планировщик использует механизм вытесняющей многозадачности (preemptive scheduling): горутина может быть приостановлена в любой точке выполнения, если она выполняет определённые операции (например, системный вызов, блокировку канала, ожидание ввода-вывода). Это предотвращает «захват» процессора одной горутиной надолго.

Планировщик включает три основных компонента:

  • M (machine) — системный поток ОС, который выполняет код.
  • P (processor) — логический процессор, который выделяет ресурсы для выполнения горутин. Количество P обычно равно количеству ядер CPU (настраивается через GOMAXPROCS).
  • G (goroutine) — сама горутина.

Каждый P имеет локальную очередь горутин, готовых к выполнению. Если очередь P пуста, он может «украсть» горутину из очереди другого P (механизм work-stealing). Это обеспечивает равномерную загрузку всех ядер.

Стек горутины

В отличие от системных потоков, которые имеют фиксированный стек (обычно 1–8 МБ), горутины стартуют с очень маленьким стеком (например, 2–4 КБ в Go 1.x). При необходимости стек может динамически увеличиваться (до 1 ГБ в 64-битных системах) и уменьшаться. Это достигается за счёт использования сегментированных стеков (segmented stacks) или копирования стека (stack copying). В современных версиях Go применяется копирование стека: когда стек переполняется, runtime выделяет новый, больший блок памяти, копирует в него содержимое старого стека и обновляет все указатели. Это позволяет запускать миллионы горутин без исчерпания памяти.

Создание и завершение

Горутина создаётся с помощью ключевого слова go перед вызовом функции:

``go go myFunction() ``

Функция может быть именованной, анонимной или замыканием. Горутина завершается, когда функция возвращает управление. Если основная функция main() завершается, все горутины принудительно завершаются вместе с программой. Для синхронизации завершения часто используются каналы (channels) или sync.WaitGroup.

Каналы и синхронизация

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

Пример использования канала:

``go ch := make(chan int) go func() { ch <- 42 }() value := <-ch ``

Помимо каналов, для синхронизации горутин используются примитивы из пакета sync: Mutex (взаимное исключение), RWMutex (блокировка чтения/записи), WaitGroup (ожидание группы горутин), Once (однократное выполнение), Cond (условная переменная). Однако в сообществе Go рекомендуется отдавать предпочтение каналам, следуя принципу: «Не общайтесь через разделяемую память; вместо этого разделяйте память через общение» (Don't communicate by sharing memory; share memory by communicating).

Преимущества и недостатки

Преимущества

  • Лёгкость: горутины потребляют значительно меньше памяти, чем системные потоки (килобайты против мегабайтов), что позволяет запускать сотни тысяч и миллионы горутин в одной программе.
  • Быстрое создание и переключение: создание горутины занимает наносекунды, а переключение между ними — единицы наносекунд, в то время как переключение системных потоков может занимать микросекунды.
  • Встроенная поддержка: конкурентность является частью языка, а не внешней библиотекой, что упрощает написание корректного кода.
  • Упрощённая модель: каналы и select (оператор выбора из нескольких каналов) делают синхронизацию интуитивно понятной по сравнению с блокировками и условными переменными.

Недостатки

  • Отсутствие идентификаторов: горутины не имеют уникальных ID, что затрудняет их внешнее управление (например, принудительное завершение). Для отмены операций используется контекст (context.Context).
  • Сложность отладки: большое количество горутин может затруднить трассировку и отладку, особенно при наличии взаимоблокировок (deadlocks) или гонок данных (data races). В Go есть встроенный детектор гонок (race detector), но он не всегда эффективен.
  • Не подходят для CPU-интенсивных задач: если горутина выполняет длительные вычисления без блокировок, она может монополизировать системный поток, что снижает эффективность планировщика. Для таких задач рекомендуется использовать отдельные системные потоки или пакет runtime.GOMAXPROCS.
  • Ограниченная поддержка в других языках: горутины являются специфической особенностью Go, и их концепция не всегда легко переносится на другие языки (хотя существуют библиотеки, реализующие подобные модели, например, gevent в Python или goroutines в Kotlin).

Применение

Горутины широко используются в серверной разработке, особенно для создания высоконагруженных сетевых сервисов, таких как веб-серверы, прокси, базы данных и системы обработки сообщений. Например, веб-сервер на Go (например, с использованием стандартной библиотеки net/http) автоматически запускает новую горутину для каждого входящего соединения, что позволяет обрабатывать тысячи одновременных запросов без значительных накладных расходов.

Также горутины применяются в:

  • Параллельной обработке данных: например, конвейерная обработка (pipeline), где каждая стадия выполняется в отдельной горутине, соединённой каналами.
  • Фоновых задачах: периодические проверки, таймеры, сбор метрик.
  • Реализации асинхронных операций ввода-вывода: горутины позволяют писать синхронный код, который работает асинхронно под капотом.

Примеры

Простой пример с каналом

```go package main

import "fmt"

func main() { ch := make(chan string) go func() { ch <- "Привет из горутины" }() msg := <-ch fmt.Println(msg) } ```

Пример с sync.WaitGroup

```go package main

import ( "fmt" "sync" )

func worker(id int, wg *sync.WaitGroup) { defer wg.Done() fmt.Printf("Горутина %d начала работу\n", id) }

func main() { var wg sync.WaitGroup for i := 1; i <= 5; i++ { wg.Add(1) go worker(i, &wg) } wg.Wait() fmt.Println("Все горутины завершены") } ```

Интересные факты

  • Термин «горутина» (goroutine) является игрой слов: «go» (название языка) + «coroutine» (сопрограмма). Роберт Пайк отмечал, что горутины не являются корутинами в классическом смысле, так как они не поддерживают явную передачу управления (yield).
  • В ранних версиях Go планировщик был кооперативным (горутины должны были явно передавать управление), но с версии Go 1.14 он стал вытесняющим, что повысило стабильность и производительность.
  • Максимальное количество горутин в одной программе ограничено только доступной памятью. В тестах удавалось запускать более 10 миллионов горутин на обычном сервере.

Критика

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

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

Источники

  • Документация языка Go: «Effective Go», «The Go Memory Model».
  • Роберт Пайк, «Concurrency is not Parallelism» (лекция, 2012).
  • The Go Programming Language Specification, раздел «Go statements».
  • Статья «Go's Work-Stealing Scheduler» (The Go Blog, 2018).
  • Книга «The Go Programming Language» (Алан Донован, Брайан Керниган, 2015).

BFOmetr — база данных и аналитика по компаниям России.

На главную BFOmetr →