GoSuda

Принцип работы горутин (Goroutine) в Go: переменный стек размером 2 КБ и планировщик GMP с моделью M:N

By hamori views ...

Goroutine

Часто встречающаяся статья о преимуществах golang, которую пишут, когда просят Gopher'ов рассказать о достоинствах Go, посвящена конкурентности (Concurrency). Её основой является горутина (goroutine), которую можно легко и просто использовать для обработки задач. Ниже приведено краткое описание этой темы.

Конкурентность (Concurrency) vs Параллелизм (Parallelism)

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

  • Конкурентность: Конкурентность связана с обработкой большого количества задач в одно и то же время. Это не обязательно означает, что они действительно выполняются одновременно; это структурная и логическая концепция, при которой несколько задач разбиваются на небольшие части и выполняются поочерёдно, создавая для пользователя впечатление, что несколько задач обрабатываются одновременно. Конкурентность возможна даже на одноядерном процессоре.
  • Параллелизм: Параллелизм означает «одновременную обработку нескольких задач на нескольких ядрах». Буквально это означает выполнение работы параллельно, когда другие задачи запускаются одновременно.

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

Многопоточность (Multi thread) в Java, которая широко используется, является типичным представителем концепции параллелизма.

Почему горутины хороши?

Легковесность (lightweight)

Стоимость создания значительно ниже, чем в других языках. Возникает вопрос: почему в golang их создаётся так мало? Причина в том, что место их создания управляется внутри среды выполнения Go. Это связано с тем, что они являются легковесными логическими потоками: они меньше единицы OS-потока, начальный стек требует примерно 2 КБ, а по мере необходимости стек может динамически расширяться в зависимости от реализации пользователя.

Поскольку управление осуществляется на уровне стека, создание и удаление выполняются очень быстро и дёшево, поэтому можно запускать даже миллионы горутин без существенной нагрузки. Благодаря планировщику времени выполнения горутины могут минимизировать участие ядра ОС.

Высокая производительность (performance)

Прежде всего, как уже было описано выше, горутины минимально задействуют ядро ОС, поэтому переключение контекста на уровне пользователя (User-Level) обходится дешевле, чем на уровне OS-потоков, и позволяет быстро переключать задачи.

Кроме того, используется модель M:N, при которой горутины распределяются по OS-потокам и управляются ими. Создаётся пул OS-потоков, поэтому можно обойтись небольшим количеством потоков без необходимости в большом их числе. Например, при переходе в состояние ожидания, например при системном вызове, среда выполнения Go выполняет другие горутины в том же OS-потоке, так что OS-поток не простаивает и эффективно использует CPU, что обеспечивает быструю обработку.

В результате Golang может демонстрировать высокую производительность, особенно при операциях I/O, по сравнению с другими языками.

Лаконичность (concise)

Большим преимуществом также является то, что для запуска функции в конкурентном режиме достаточно одного ключевого слова go.

Для работы с Mutex, Semaphore и другими сложными механизмами блокировки необходимо учитывать состояние взаимоблокировки (DeadLock), которое неизбежно возникает при использовании блокировок, поэтому уже на этапе проектирования требуется сложная подготовка.

Goroutine следует философии «не разделяй память для обмена данными, а обменивайся данными для разделения памяти» и рекомендует передачу данных через 채널(Channel), при этом SELECT в сочетании с каналами позволяет обрабатывать сначала те каналы, в которых уже готовы данные. Кроме того, с помощью sync.WaitGroup можно просто дождаться завершения всех горутин, что упрощает управление потоком работы. Благодаря этим инструментам предотвращаются гонки данных между потоками и конкурентная обработка становится более безопасной.

Также с помощью контекста (context) можно управлять жизненным циклом, отменой, таймаутами, дедлайнами и областью запросов на уровне пользователя (User-Level), что обеспечивает определённую степень надёжности.

Параллельная работа в Goroutine (GOMAXPROCS)

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

Однако горутины обеспечивают и параллельную работу, и это делает GOMAXPROCS.

Если GOMAXPROCS не задан, его значение по умолчанию зависит от версии.

  1. До 1.5: значение по умолчанию равно 1; для использования большего числа необходимо было задавать его явно, например, через runtime.GOMAXPOCS(runtime.NumCPU()).

  2. 1.5 ~ 1.24: значение изменено на количество всех доступных логических ядер. Начиная с этой версии, разработчику обычно не нужно задавать его, если только нет особых ограничений.

  3. 1.25: как и подобает популярному языку для контейнерных сред, проверяется cGroup в Linux и определяется ограничение CPU (CPU limit), заданное для контейнера.

    Так, если количество логических ядер равно 10, а ограничение CPU равно 5, то GOMAXPROCS устанавливается в меньшее значение, то есть 5.

Изменение в версии 1.25 имеет очень большое значение, поскольку повысилась применимость языка в контейнерных средах. Благодаря этому можно уменьшить создание ненужных потоков и переключения контекста, что позволяет предотвратить троттлинг CPU (throttling).

 1package main
 2
 3import (
 4	"fmt"
 5	"math/rand"
 6	"runtime"
 7	"time"
 8)
 9
10func exe(name int, wg *sync.WaitGroup) {
11	defer wg.Done()
12
13	fmt.Printf("Goroutine %d: 시작\n", name)
14	time.Sleep(10 * time.Millisecond) // 작업 시뮬레이션을 위한 지연
15	fmt.Printf("Goroutine %d: 시작\n", name)
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // CPU 코어 2개만 사용
20	wg := sync.WaitGroup();
21  goroutineCount := 10
22	wg.Add(goroutineCount)
23
24	for i := 0; i < goroutineCount; i++ {
25		go exe(i, &wg)
26	}
27
28	fmt.Println("모든 goroutine이 끝날 때까지 대기합니다...")
29	wg.Wait()
30	fmt.Println("모든 작업이 완료되었습니다.")
31
32}
33

Планировщик горутин (модель M:N)

Если подробнее рассмотреть часть о распределении горутин по OS-потокам по модели M:N, то существует модель GMP горутин.

  • G (Goroutine): наименьшая единица работы, выполняемая в Go
  • M (Machine): OS-поток (фактическое место выполнения работы)
  • P (Processor): логический процессор, управляемый средой выполнения Go

Вот так. P дополнительно имеет локальную очередь выполнения (Local Run Queue) и выполняет роль планировщика, назначающего G на M. Если говорить кратко, горутина

Порядок работы GMP выглядит следующим образом:

  1. Когда G (Goroutine) создаётся, она помещается в локальную очередь выполнения P (Processor).
  2. P (Processor) назначает G (Goroutine) из локальной очереди выполнения на M (Machine).
  3. M (Machine) возвращает состояние G (Goroutine): block, complete или preempted.
  4. Work-Stealing (кража работы): если локальная очередь выполнения P пуста, другой P сначала проверяет глобальную очередь. Если и там нет G (Goroutine), он крадёт работу у другого локального P (Processor), чтобы все M работали без простоя.
  5. Обработка системных вызовов (Blocking): если во время выполнения G (Goroutine) возникает блокировка, M (Machine) переходит в состояние ожидания, при этом P (Processor) отделяется от заблокированного M (Machine), соединяется с другим M (Machine) и выполняет следующую G (Goroutine). Благодаря этому даже во время ожидания операций I/O не происходит растраты CPU.
  6. Если одна G (Goroutine) занимает процессор слишком долго (preempted), другим G (Goroutine) предоставляется возможность выполнения.

Сборщик мусора (GC, Garbage Collector) в Golang также выполняется поверх горутин, что позволяет очищать память параллельно, минимально прерывая выполнение приложения (STW), и эффективно использовать системные ресурсы.

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

Спасибо.