В языке Go одним из важнейших преимуществ является мощная поддержка многопоточности и параллелизма за счёт горутин и каналов. В этой статье подробно разберём три продвинутых шаблона работы с горутинами:
Fan-out
Fan-in
Pipelines
Эти паттерны позволяют писать эффективный, масштабируемый и читабельный многопоточный код.
0. Как работают горутины под капотом в GO lang
В примере Fan-out из статьи, распределение работы происходит следующим образом:
Общий канал (jobs) используется как единая очередь задач, куда отправляются задания.
Запускается несколько воркеров (в примере — три воркера). Каждый воркер — это отдельная горутина, которая ожидает задачи из канала jobs.
Когда в канал поступает задача, один из свободных воркеров её забирает. Каналы работают по принципу FIFO (First In, First Out), и задачи передаются воркерам в порядке их поступления. Воркеры получают задачи именно тогда, когда они свободны.
Если все воркеры заняты, очередная задача ждёт в очереди, пока какой-то воркер не освободится.
Воркеры выполняют задачи параллельно, поэтому нагрузка распределяется динамически и равномерно между всеми доступными горутинами.
Такой подход позволяет гибко распределять работу и использовать максимально эффективно доступные ресурсы (ядра CPU). Чем больше воркеров — тем больше параллелизм и выше потенциальная скорость обработки задач.
Распределение работы между воркерами происходит за счёт конструкции:
for job := range jobs {
// выполнение задачи
}
Вот как это работает детально:
Канал jobs общий для всех воркеров.
Каждый воркер выполняет эту конструкцию (range jobs) в своей отдельной горутине.
Когда в канал поступает задача, Go runtime автоматически передаёт её первому свободному воркеру, ожидающему данные из канала.
Как только один из воркеров взял задачу из канала, другие воркеры эту задачу уже не увидят — она «выбрана» из канала и передана конкретному воркеру.
Если все воркеры заняты, задача будет находиться в канале (ожидать), пока кто-то не освободится.
Таким образом, сам канал Go и конструкция range обеспечивают автоматическое и эффективное распределение задач между горутинами (воркерами).
Вот подробное объяснение, как это работает под капотом Go:
1. Каналы и их внутренняя реализация
В Go каналы (chan) — это механизм безопасного обмена данными между горутинами. Внутри они реализованы следующим образом:
Буферизация:
Канал может быть буферизованным (с фиксированным размером очереди) или небуферизованным (без очереди).
Небуферизованный канал: операция отправки (channel <- value) блокируется, пока кто-то не заберёт значение (<-channel).
Буферизованный канал: отправка блокируется только если канал полон, а получение — если пуст.
Очередь задач:
Канал представляет собой FIFO-очередь (First-In-First-Out), то есть данные, отправленные первыми, будут получены первыми.
2. Как происходит распределение задач горутинам?
Когда запускаются несколько горутин, каждая из них вызывает блокирующий оператор:
for job := range jobs {
// выполнение задачи
}
В этот момент каждая горутина пытается выполнить операцию чтения из канала.
Если канал пуст, горутина «засыпает» (переходит в состояние ожидания).
Когда в канал отправляется значение (например, jobs <- job), Go runtime пробуждает одну из ожидающих горутин, передавая ей значение.
Если задач много и горутин несколько, задачи равномерно распределяются между свободными (ожидающими) горутинами в порядке отправки.
Таким образом, канал выступает как очередь задач, а горутины — как воркеры, забирающие задачи из этой очереди.
Роль Go runtime (scheduler)
Под капотом Go использует собственный планировщик (scheduler):
Планировщик управляет горутинами и распределяет их по потокам ОС.
Горутин обычно намного больше, чем потоков ОС, благодаря чему достигается высокая эффективность.
Планировщик Go автоматически выбирает, какую горутину пробудить, когда данные становятся доступными для чтения из канала.
При пробуждении горутины она ставится в очередь выполнения планировщика и получает процессорное время, как только оно освобождается.
Процесс выглядит так:
Горутина ждёт получения из канала.
Данные поступают в канал.
Go runtime выбирает и пробуждает ожидающую горутину.
Горутина берёт данные и продолжает работу.
Это обеспечивает эффективную и прозрачную работу многопоточности без сложной ручной синхронизации.
Итоговая схема взаимодействия:
Отправитель (main goroutine)
│
▼
Канал jobs ────────┐
▲ ▲ ▲ │
│ │ │ │
goroutine goroutine goroutine
(worker1) (worker2) (worker3)
Каждый раз, когда задача (job) отправляется в канал, она поступает первому свободному воркеру.
Таким образом, Go runtime и механизм каналов сами управляют распределением задач, что позволяет разработчику не думать о низкоуровневых деталях и полностью сосредоточиться на логике программы.
Как внутри под капотом работает планировшик на go
Планировщик горутин (goroutine scheduler) в Go — это ключевая часть среды выполнения Go, которая обеспечивает параллельность и эффективное выполнение горутин.
Как устроен планировщик горутин Go?
Планировщик Go называется GMP-моделью (Goroutine, Machine, Processor):
G (Goroutine) – это лёгкая пользовательская нить (user-space thread).
M (Machine) – это поток операционной системы (OS-thread).
P (Processor) – это логический процессор, представляющий контекст исполнения горутин (локальная очередь горутин, кеш).
Архитектура планировщика Go (GMP)
G1 G2 G3 ... GN
\ | /
\ | /
P1 P2 ... PN
| |
M1 M2
| |
Ядро ОС Ядро ОС
Пояснение:
Горутин может быть тысячи или даже миллионы.
Горутин всегда больше, чем потоков ОС (M).
Логические процессоры (P) распределяют горутины между доступными потоками ОС (M).
Каждый логический процессор (P) имеет собственную локальную очередь горутин, которую он запускает по очереди.
Потоки ОС (M) выполняют горутины, взятые из очередей логических процессоров (P).
Как горутина запускается и выполняется?
Когда создаётся новая горутина (go func()), она помещается в очередь соответствующего процессора (P).
Планировщик выбирает доступный поток ОС (M), который подключён к процессору (P), и начинает выполнять горутины из его локальной очереди.
Если горутина блокируется (например, на канале, мьютексе или ожидании ввода-вывода), планировщик временно отцепляет поток ОС от процессора (P) и запускает другую горутину.
Планировщик следит за тем, чтобы постоянно были заняты все доступные ядра процессора (CPU) и эффективно использовалось время выполнения.
Work Stealing (кража работы)
Планировщик Go использует механизм «work stealing» (кража задач), чтобы эффективно балансировать нагрузку между процессорами (P):
Если локальная очередь одного процессора (P) пуста, он может «украсть» горутины из очереди другого процессора (P), чтобы не простаивать.
Это обеспечивает эффективное и равномерное распределение работы между всеми доступными ядрами процессора и потоками ОС.
Парковка и пробуждение горутин
Когда горутина блокируется (например, ожидает ввода-вывода или данных из канала):
Go runtime помечает её как неактивную («паркует») и перестаёт выделять ей ресурсы CPU.
Как только данные становятся доступны (например, в канале появляются данные), Go runtime пробуждает («разпарковывает») горутину, возвращает её в очередь на выполнение.
Планировщик Go оперативно переключает потоки ОС и горутины, минимизируя время ожидания и простаивания CPU.
Взаимодействие с ОС и ядрами CPU
Потоки ОС (M) напрямую взаимодействуют с ядрами CPU, именно они физически исполняют код.
Go runtime автоматически увеличивает или уменьшает количество потоков ОС (M), чтобы оптимально использовать ресурсы CPU.
Количество процессоров (P) по умолчанию равно количеству логических ядер CPU, но его можно регулировать через runtime.GOMAXPROCS.
Пример работы планировщика в действии:
Допустим, у нас 4 горутины и 2 логических процессора (P1 и P2):
G1 ──┐
G2 ──┼───▶ P1 ───▶ M1 ───▶ CPU1
│
G3 ──┤ (work stealing)
G4 ──┴───▶ P2 ───▶ M2 ───▶ CPU2
Если P2 закончил работу быстрее, он попытается «украсть» работу (например, G2) у P1, тем самым балансируя нагрузку.
Итоги и преимущества GMP-модели
Планировщик горутин в Go:
Автоматически балансирует нагрузку между ядрами CPU.
Использует «work stealing» для эффективного распределения задач.
Эффективно работает с блокирующими операциями (I/O, каналы, мьютексы).
Позволяет запускать тысячи и миллионы горутин с минимальными затратами памяти и производительности.
Не требует ручного управления потоками и ядрами CPU.
Таким образом, Go runtime обеспечивает прозрачный, эффективный и высокопроизводительный механизм параллельного и конкурентного выполнения программ.
1. Что такое Fan-out и Fan-in?
Fan-out
Fan-out — это шаблон, при котором задачи из одного источника распределяются между несколькими воркерами. Основная цель — распараллелить работу и ускорить выполнение задачи.
Пример Fan-out:
func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
fmt.Printf("Worker %d начал работу над задачей %d\n", id, job)
time.Sleep(time.Second) // эмуляция работы
results <- job * 2
fmt.Printf("Worker %d завершил задачу %d\n", id, job)
}
}
func main() {
jobs := make(chan int, 10)
results := make(chan int, 10)
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)
for a := 1; a <= 9; a++ {
fmt.Println("Результат:", <-results)
}
}
Fan-in
Fan-in — это обратный процесс, при котором результаты из нескольких каналов собираются в один.
Пример Fan-in:
func merge(cs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
output := func(c <-chan int) {
for n := range c {
out <- n
}
wg.Done()
}
wg.Add(len(cs))
for _, c := range cs {
go output(c)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
c1 := make(chan int)
c2 := make(chan int)
go func() {
for i := 1; i <= 5; i++ {
c1 <- i
time.Sleep(time.Millisecond * 200)
}
close(c1)
}()
go func() {
for i := 6; i <= 10; i++ {
c2 <- i
time.Sleep(time.Millisecond * 300)
}
close(c2)
}()
for n := range merge(c1, c2) {
fmt.Println("Получено:", n)
}
}
2. Pipelines (Конвейеры)
Pipeline — это последовательность этапов обработки данных, где выход одного этапа становится входом для другого.
Пример Pipeline:
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
for n := range square(square(gen(2, 3, 4))) {
fmt.Println(n)
}
}
3. Реальные примеры применения
Fan-out
Обработка запросов веб-сервера: распределение HTTP-запросов между несколькими горутинами для ускорения ответа.
Загрузка данных: одновременная загрузка файлов или ресурсов с нескольких URL.
Fan-in
Агрегация логов: сбор данных из нескольких сервисов в единый канал для обработки и сохранения.
Сбор статистики: получение метрик из нескольких источников и объединение их в единую систему мониторинга.
Pipelines
Обработка изображений: загрузка, обработка (например, изменение размера или фильтрация), сохранение.
ETL-процессы: извлечение данных из базы, трансформация, и последующая загрузка данных в другую базу или систему аналитики.
Заключение
Используя паттерны fan-out, fan-in и pipelines в Go, вы можете писать эффективные и производительные многопоточные приложения. Эти подходы обеспечивают чистоту кода, лёгкость масштабирования и гибкость для решения сложных задач.