Конкурентность в Go: горутины, каналы и context без подводных камней

Как в Go на самом деле устроена конкурентность: дешёвые горутины и M:N-планировщик, каналы против мьютексов, context для отмены, рабочие паттерны (worker pool, fan-in/out, pipeline) и типичные подводные камни — гонки, утечки горутин и дедлоки

Конкурентность — то, ради чего многие берут Go. Горутина запускается одним словом, стоит копейки, а каналы дают способ связывать их без ручной возни с блокировками. Но именно эта лёгкость и подводит: дешёвая горутина легко утекает, канал легко превращается в дедлок, а «share memory by communicating» не отменяет гонок данных, если всё-таки шарить память. Эта статья — про то, как устроена конкурентность в Go и как обойти типичные ловушки.

Это первая, вводная статья серии «Конкурентность в Go»: она задаёт основу — горутины, каналы, context и обзор подводных камней, — а дальше серия углубляется в примитивы sync, модель памяти, продвинутые паттерны и отладку. Более широкий разбор моделей конкурентности в разных языках — в отдельной статье «Горутины, корутины, потоки»; здесь — практика именно по Go.

Ретрофутуристская мастерская: диспетчер-gopher за пультом «go func() SPAWN» спаунит рой воркеров-горутин; каналы-трубы chan несут посылки-сообщения; планировщик раскладывает много горутин на несколько движков P0–P3 (M:N); спящий gopher ждёт у небуферизованного канала

В статье

Горутины: дешёвая конкурентность

Горутина — это функция, запущенная на исполнение конкурентно с вызывающим кодом. Синтаксически — одно слово go перед вызовом:

package main

import (
	"fmt"
	"time"
)

func main() {
	go say("привет из горутины")
	say("привет из main")
	time.Sleep(100 * time.Millisecond) // грубое ожидание — так делать не надо, см. ниже
}

func say(s string) {
	fmt.Println(s)
}

Ключевое отличие горутины от потока ОС — цена. Поток ОС резервирует стек фиксированного (и крупного) размера — обычно порядка мегабайта, — а его создание и переключение контекста проходят через ядро. Горутина же:

  • начинается с маленького растущего стека (несколько килобайт), который увеличивается и уменьшается по необходимости — рантайм копирует стек в больший сегмент, когда места не хватает;
  • живёт в пользовательском пространстве — создание, завершение и переключение между горутинами делает планировщик рантайма Go, без системного вызова на каждый чих.

Поэтому запустить десятки и сотни тысяч горутин — норма, а не экзотика. Но «дешёвая» не значит «бесплатная»: каждая горутина держит стек и, что важнее, часто держит ссылку на канал или ресурс. Забытая горутина, зависшая навсегда, — это утечка, и её цена платится не разово, а постоянно.

Ещё одно важное свойство: когда main возвращается, программа завершается — рантайм не ждёт остальные горутины. time.Sleep в примере выше — костыль, чтобы горутина успела отработать до выхода. В реальном коде для ожидания используют sync.WaitGroup, каналы или errgroup (см. паттерны), а не сон на глазок.

Планировщик: M:N, GOMAXPROCS и вытеснение

Горутины не мапятся на потоки ОС один-к-одному. Планировщик Go — M:N: множество горутин (M штук в математическом смысле) мультиплексируются на меньший пул потоков ОС (N штук). Внутри рантайма это описывается тремя сущностями:

  • G (goroutine) — сама горутина: её стек и состояние;
  • M (machine) — поток ОС, который реально исполняет код;
  • P (processor) — логический процессор, контекст планирования; держит локальную очередь готовых горутин. Чтобы M исполнял Go-код, ему нужен P.

Число P задаётся переменной GOMAXPROCS — это максимальное количество горутин, исполняющих Go-код одновременно. По умолчанию оно равно числу доступных CPU (runtime.NumCPU()).

import "runtime"

// прочитать текущее значение (передав отрицательное число):
n := runtime.GOMAXPROCS(-1)

// изменить (обычно не нужно — трогайте только осознанно):
// runtime.GOMAXPROCS(4)

Планировщик использует work stealing: если у одного P опустела локальная очередь, он «крадёт» готовые горутины из очереди соседнего P или из глобальной очереди — так нагрузка выравнивается между потоками.

Когда горутина делает блокирующий системный вызов (например, чтение из сети или файла), рантайм умеет отвязать P от заблокированного M и отдать P другому потоку, чтобы остальные горутины продолжали работать. Поэтому «блокирующий» с точки зрения кода сетевой вызов не блокирует весь пул — под капотом сетевой ввод-вывод работает через неблокирующий poller.

Про контейнеры и GOMAXPROCS. Начиная с Go 1.25 значение по умолчанию учитывает CPU-лимиты cgroup: в контейнере с ограничением в 2 CPU рантайм по умолчанию не выставит GOMAXPROCS по числу ядер всей ноды. До этого в контейнерах приходилось выставлять значение вручную (или библиотекой), иначе планировщик считал доступными все ядра хоста. Проверьте поведение на своей версии.

Вытеснение: почему длинный цикл больше не вешает планировщик

До Go 1.14 планировщик был кооперативным: горутина уступала управление только в «точках безопасности» — на вызовах функций, аллокациях, операциях с каналами. Плотный цикл без единого вызова функции мог захватить P и не отдавать его — вплоть до подвисания сборки мусора, которой нужно остановить все горутины.

// До Go 1.14 такой цикл мог не дать себя вытеснить:
func spin() {
	for {
		// нет вызовов функций, нет точек безопасности —
		// кооперативный планировщик не мог сюда «влезть»
	}
}

С Go 1.14 появилось асинхронное вытеснение (async preemption) на основе сигналов: рантайм может прервать даже такой цикл и переключить горутину. Практический вывод — длинные вычислительные циклы больше не «вешают» планировщик и не мешают GC. Полагаться на это как на инструмент проектирования не стоит, но понимать, почему CPU-bound код теперь ведёт себя корректно, полезно.

Конкурентность ≠ параллелизм

Это разные вещи, и Go-сообщество на этом настаивает (канонично — доклад Роба Пайка «Concurrency is not parallelism»):

  • Конкурентность — это про структуру: как программа разбита на независимо продвигающиеся задачи. Свойство кода.
  • Параллелизм — это про исполнение: сколько задач реально работают в один и тот же момент на разных ядрах. Свойство железа и рантайма.

При GOMAXPROCS=1 программа с сотней горутин полностью конкурентна, но не параллельна: в любой момент исполняется ровно одна горутина, планировщик их чередует. Поднимите GOMAXPROCS — и та же конкурентная структура начнёт исполняться параллельно, без изменений в коде.

Отсюда практический вывод: горутины не делают программу быстрее автоматически. Если задача чисто вычислительная (CPU-bound), параллелизм ограничен числом ядер, и лишние горутины лишь добавят накладных расходов на планирование. Выигрыш конкурентности особенно заметен на I/O-bound нагрузке, где горутины большую часть времени ждут (сеть, диск, БД), а не считают.

Каналы: рандеву и буферы

Канал — типизированный конвейер, по которому горутины передают значения. Девиз Go: «Не общайтесь через разделяемую память — разделяйте память, общаясь» (share memory by communicating). Вместо того чтобы защищать общие данные мьютексом, канал передаёт владение данными от одной горутины к другой.

ch := make(chan int)      // небуферизованный канал int
bufCh := make(chan int, 8) // буферизованный, ёмкость 8

Небуферизованный: рандеву

У небуферизованного канала нет места «внутри». Отправка блокируется до тех пор, пока другая горутина не будет готова принять, и наоборот — это рандеву (встреча). Момент передачи синхронизирует обе горутины:

func main() {
	done := make(chan struct{})

	go func() {
		fmt.Println("работаю…")
		close(done) // сигнал: закончил
	}()

	<-done // блокируемся, пока горутина не закроет канал
	fmt.Println("готово")
}

chan struct{} — идиома для канала-сигнала: значение не важно, важен сам факт передачи/закрытия, а struct{} не занимает памяти.

Буферизованный: развязка на ёмкость буфера

У буферизованного канала есть внутренняя очередь. Отправка блокируется, только когда буфер полон; приём — когда буфер пуст. Это развязывает отправителя и получателя по времени в пределах ёмкости:

ch := make(chan int, 2)
ch <- 1 // не блокирует — есть место
ch <- 2 // не блокирует — есть место
// ch <- 3 // заблокировалось бы: буфер полон, приёмника нет
fmt.Println(<-ch, <-ch) // 1 2

Буфер — не «ускоритель», а инструмент управления обратным давлением (backpressure). Слишком большой буфер маскирует то, что потребитель не поспевает за производителем, и превращает быструю ошибку в скрытое накопление. Ёмкость выбирают осознанно, а не «на всякий случай побольше».

Направления каналов

Тип канала можно сузить до только-отправки или только-приёма — это делает API самодокументируемым и ловит ошибки на этапе компиляции:

func produce(out chan<- int) { // out — только для отправки
	for i := 0; i < 3; i++ {
		out <- i
	}
	close(out)
}

func consume(in <-chan int) { // in — только для приёма
	for v := range in { // range читает, пока канал не закрыт
		fmt.Println(v)
	}
}

func main() {
	ch := make(chan int)
	go produce(ch)
	consume(ch)
}

Закрытие и чтение из закрытого канала

Закрывает канал отправитель, когда значений больше не будет. Приёмник узнаёт о закрытии по второму возвращаемому значению:

v, ok := <-ch
// ok == false и v == нулевое значение типа, если канал закрыт и опустошён

Важные правила, нарушение которых — паника:

Операция Небуф./буф. канал nil-канал Закрытый канал
Отправка ch <- x блок до готовности/места блокируется навсегда паника
Приём <-ch блок до значения блокируется навсегда сразу нулевое значение, ok == false
close(ch) закрывает паника паника (повторное закрытие)

Из таблицы следуют две частые ошибки: отправка в закрытый канал и повторное закрытие — обе роняют программу паникой. Отсюда правило «закрывает всегда отправитель, и ровно один раз». Если отправителей несколько, закрытие координируют отдельно (например, через sync.WaitGroup и одну закрывающую горутину).

nil-каналы: не баг, а инструмент

Чтение и запись в nil-канал блокируются навсегда. На первый взгляд бесполезно, но в select это способ динамически отключить ветку: присвоив каналу nil, вы исключаете его case из рассмотрения (см. следующий раздел).

select, таймауты и nil-каналы

select ждёт готовности одной из нескольких канальных операций. Если готовы несколько — выбор случайный (это защищает от голодания одной из веток):

select {
case v := <-ch1:
	fmt.Println("из ch1:", v)
case ch2 <- x:
	fmt.Println("отправили в ch2")
case <-time.After(time.Second):
	fmt.Println("таймаут")
}

default: неблокирующие операции

Ветка default срабатывает, если ни одна другая не готова прямо сейчас — так делают неблокирующую отправку/приём:

select {
case v := <-ch:
	handle(v)
default:
	// канал пуст — не ждём, идём дальше
}

Таймаут: time.After и его ловушка

time.After(d) возвращает канал, в который через d придёт время. Удобно для таймаута одной операции. Но в горячем цикле это ловушка: каждый оборот создаёт новый таймер.

// Осторожно в цикле: на каждой итерации — новый таймер.
for {
	select {
	case v := <-work:
		process(v)
	case <-time.After(time.Second): // создаётся каждый раз
		return
	}
}

Исторически такой таймер не мог быть собран сборщиком мусора, пока не сработает, — на высокой частоте это давало заметное давление на память (в Go 1.23 поведение таймеров улучшили, и неиспользуемый таймер теперь может собираться раньше). Для повторяющихся таймаутов надёжнее один переиспользуемый time.NewTimer с Stop/Reset — или, что чаще правильнее, отмена через context.

Отключение ветки через nil-канал

Приём из nil-канала блокируется навсегда, поэтому такая ветка select никогда не выберется. Это позволяет включать и выключать источник на лету:

func merge(in1, in2 <-chan int, out chan<- int) {
	for in1 != nil || in2 != nil {
		select {
		case v, ok := <-in1:
			if !ok {
				in1 = nil // источник иссяк — выключаем его ветку
				continue
			}
			out <- v
		case v, ok := <-in2:
			if !ok {
				in2 = nil
				continue
			}
			out <- v
		}
	}
	close(out)
}

Без трюка с nil закрытый канал в select выбирался бы бесконечно (он всегда «готов» отдать нулевое значение), и цикл крутился бы вхолостую.

Каналы против мьютексов: честный выбор

Расхожий совет «в Go всё делают каналами» — вредная догма. И каналы, и sync-примитивы — законные инструменты; выбор зависит от того, что вы делаете. Официальная вики Go формулирует это прямо: используйте то, что выразительнее и проще для конкретной задачи.

Канал уместен, когда:

  • передаётся владение данными — значение уходит от одной горутины к другой, и первая к нему больше не обращается;
  • нужна координация и оркестрация горутин (сигналы, завершение, конвейеры);
  • строится pipeline — стадии обработки, соединённые каналами;
  • распределяется работа между воркерами.

Мьютекс/атомик уместнее, когда:

  • защищается общее изменяемое состояние, живущее на месте: кэш, счётчик, map, конфигурация;
  • операция — простое «прочитал/обновил поле», и заворачивать это в канальный протокол значит писать больше кода без выгоды.

Мини-иллюстрация: обычный счётчик проще и быстрее защитить мьютексом (или сделать атомарным), чем гонять инкременты через канал.

type Counter struct {
	mu sync.Mutex
	n  int64
}

func (c *Counter) Inc() {
	c.mu.Lock()
	c.n++
	c.mu.Unlock()
}

func (c *Counter) Value() int64 {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}

Рядом стоят и другие примитивы из sync: sync.WaitGroup — дождаться завершения группы горутин; sync.Once — гарантированно однократная инициализация; sync.Pool — переиспользование объектов под аллокационным давлением; sync/atomic — атомарные счётчики и флаги без мьютекса. Детальный разбор — во второй статье серии про пакет sync и атомики, а гарантии видимости, которые всё это даёт, — в статье про модель памяти.

Антипаттерн в обе стороны. Канал там, где хватило бы мьютекса, — это лишний протокол, лишняя горутина-владелец и труднее отлаживаемый код. Мьютекс там, где естественнее конвейер, — это ручная возня с блокировками вместо потока данных. Ориентир простой: передаёте данные — канал; защищаете состояние — мьютекс/атомик.

context: отмена и дедлайны

Горутину нельзя «убить» извне — в Go нет принудительного завершения. Единственный корректный способ остановить работу — попросить её остановиться и дождаться, пока она сама выйдет. Стандартный механизм для этого — пакет context.

context.Context несёт сигнал отмены, дедлайн и (реже) значения запроса вниз по дереву вызовов. Конструкторы:

  • context.Background() — корневой контекст (в main, инициализации, тестах);
  • context.WithCancel(parent) — возвращает контекст и функцию cancel() для ручной отмены;
  • context.WithTimeout(parent, d) — отмена через d;
  • context.WithDeadline(parent, t) — отмена в момент времени t.

Все они возвращают производный контекст и функцию cancel, которую нужно вызвать (обычно defer cancel()), чтобы освободить связанные ресурсы, — даже если отмена уже произошла по таймауту.

func fetchAll(ctx context.Context, urls []string) error {
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel() // обязательно, иначе утечка таймера/ресурсов

	for _, u := range urls {
		if err := fetchOne(ctx, u); err != nil {
			return err
		}
	}
	return nil
}

Проброс: первым аргументом, не в структуре

Соглашение Go: context передаётся явно, первым параметром, с именем ctx. Его не сохраняют в поля структуры и не прячут в глобалы:

func fetchOne(ctx context.Context, url string) error { /* ... */ return nil }

Так контекст течёт вниз по всей цепочке вызовов, и отмена наверху доходит до самой глубокой операции.

Реакция на отмену: ctx.Done()

Долгая или блокирующая работа обязана слушать ctx.Done() — канал, который закрывается при отмене или дедлайне. ctx.Err() скажет, что случилось: context.Canceled или context.DeadlineExceeded.

func worker(ctx context.Context, jobs <-chan Job) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err() // отменили или вышел дедлайн — выходим
		case job, ok := <-jobs:
			if !ok {
				return nil // задачи кончились
			}
			if err := do(ctx, job); err != nil {
				return err
			}
		}
	}
}

Отмена распространяется по дереву: отменив родительский контекст, вы отменяете все производные от него, а значит и все горутины, которые их слушают. Это и есть способ остановить целое поддерево работы одним cancel().

Типичная ошибка: игнорировать ctx в долгой операции

Контекст бесполезен, если его никто не проверяет. Функция, которая приняла ctx, но не смотрит на ctx.Done() в длинном цикле и не передаёт ctx дальше (в HTTP-клиент, в запрос к БД, в дочерние вызовы), — отменяется только на бумаге. Стандартная библиотека уже умеет отмену: http.NewRequestWithContext, db.QueryContext и подобные прекращают работу по ctx. Ваша задача — дотянуть ctx до них и проверять его в собственных циклах.

Рабочие паттерны: обзор

Здесь — карта основных паттернов. Детальный разбор с полными реализациями — в четвёртой статье серии про паттерны конкурентности; канонический источник — доклад Роба Пайка «Go Concurrency Patterns» и статья блога Go Concurrency Patterns: Pipelines.

Worker pool: ограничение параллелизма

Фиксированное число воркеров читает задачи из общего канала. Так вы ограничиваете параллелизм (не плодя горутину на каждую задачу) и держите нагрузку под контролем:

func pool(ctx context.Context, jobs <-chan int, results chan<- int, workers int) {
	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for {
				select {
				case <-ctx.Done():
					return
				case j, ok := <-jobs:
					if !ok {
						return
					}
					results <- j * j // «обработка»
				}
			}
		}()
	}
	wg.Wait()
	close(results)
}

Тонкость этого примера: приём защищён ctx.Done(), а вот отправка results <- j * j — нет. Если потребитель results перестанет читать, а контекст отменят, воркер заблокируется на отправке и отмену уже не увидит. Здесь это опущено ради простоты вводного примера; полностью защищённый вариант — со select и на стороне отправки — в статье про паттерны.

Семафор на буферизованном канале

Когда нужно не пул воркеров, а просто «не больше N одновременно», подойдёт буферизованный канал как счётный семафор:

sem := make(chan struct{}, 4) // максимум 4 одновременно

for _, task := range tasks {
	sem <- struct{}{} // занять слот (блокирует, если все 4 заняты)
	go func(t Task) {
		defer func() { <-sem }() // освободить слот
		handle(t)
	}(task)
}

Fan-out / fan-in

Fan-out — несколько горутин читают из одного канала, распараллеливая обработку. Fan-in — несколько каналов сливаются в один. Вместе они масштабируют этап конвейера.

Pipeline: стадии на каналах

Обработка разбивается на стадии, соединённые каналами: выход одной — вход следующей. Каждая стадия — горутина, читающая из входного канала и пишущая в выходной; закрытие входного канала каскадно завершает конвейер. Для досрочной остановки используют отдельный done-канал или, чаще, context.

errgroup: группа горутин с ошибкой и отменой

golang.org/x/sync/errgroup — самый удобный способ запустить группу горутин, дождаться их всех и получить первую ошибку. errgroup.WithContext вдобавок автоматически отменяет общий контекст, как только одна из горутин вернула ошибку:

import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, urls []string) error {
	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(8) // не более 8 горутин одновременно
	results := make([]string, len(urls))

	for i, u := range urls {
		i, u := i, u // до Go 1.22 — обязательно; см. подводные камни
		g.Go(func() error {
			body, err := fetchOne(ctx, u)
			if err != nil {
				return err // первая ошибка отменит ctx для остальных
			}
			results[i] = body
			return nil
		})
	}
	return g.Wait() // ждём всех, возвращаем первую ошибку
}

func fetchOne(ctx context.Context, url string) (string, error) { return "", nil }

Это почти всегда лучше, чем вручную сводить ошибки из горутин через каналы. SetLimit заодно закрывает потребность в отдельном семафоре.

Rate limiting через тикер

time.Ticker даёт равномерный поток тиков — простая основа для ограничения частоты (для серьёзных задач есть golang.org/x/time/rate):

lim := time.NewTicker(200 * time.Millisecond) // максимум 5 в секунду
defer lim.Stop()

for _, req := range requests {
	<-lim.C // ждём следующий тик
	go handle(req)
}

Подводные камни

Обзорно — здесь; развёрнутый разбор с инструментами и разбором конкретных случаев — в пятой статье серии про отладку гонок и утечек.

Гонки данных: компилятор их не ловит

Гонка данных — это конкурентный доступ к одной ячейке памяти из нескольких горутин без синхронизации, где хотя бы один доступ — запись. Компилятор Go не обнаруживает гонки: код спокойно скомпилируется и часто даже «работает» — до тех пор, пока планировщик не сложит операции неудачно.

// ГОНКА: две горутины пишут в один счётчик без синхронизации.
func main() {
	counter := 0
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++ // читает, инкрементирует, пишет — не атомарно
		}()
	}
	wg.Wait()
	fmt.Println(counter) // почти наверняка не 1000
}

Ловят гонки race detector — встроенный инструмент рантайма, включаемый флагом -race:

go run -race .
go test -race ./...
go build -race -o app .

Под -race рантайм инструментирует доступы к памяти и на реальной гонке печатает отчёт — примерно такого вида (адреса и номера строк здесь иллюстративные):

==================
WARNING: DATA RACE
Read at 0x00c0000b4008 by goroutine 8:
  main.main.func1()
      /app/main.go:14 +0x…

Previous write at 0x00c0000b4008 by goroutine 7:
  main.main.func1()
      /app/main.go:14 +0x…
==================
Found 1 data race(s)

Важные оговорки: детектор находит только те гонки, что реально произошли во время прогона (не доказывает их отсутствие), и заметно замедляет программу с ростом потребления памяти — поэтому -race гоняют в тестах и CI, а не в проде. Починка гонки — синхронизация: мьютекс, атомик или передача данных через канал (гарантии, которые они дают, — в статье про модель памяти).

Отдельно: конкурентная запись в обычную map — это не просто гонка, а немедленное аварийное завершение с сообщением вида fatal error: concurrent map writes, которое рантайм ловит специально. Защищайте map мьютексом или берите sync.Map под подходящий сценарий.

Утечки горутин

Горутина, навсегда заблокированная на канале, которого никто не разблокирует, никогда не завершится и не освободит ни стек, ни удерживаемые ресурсы. Классика — отправка в канал, который никто уже не читает:

// УТЕЧКА: если получатель ушёл (например, по таймауту в вызывающем коде),
// горутина навсегда повиснет на отправке в небуферизованный ch.
func leak() <-chan int {
	ch := make(chan int)
	go func() {
		ch <- compute() // повиснет, если никто не прочитает
	}()
	return ch
}

Лечится это контекстом (горутина слушает ctx.Done() и выходит), выбором ёмкости буфера или гарантией, что читатель всегда есть. Найти утечки помогает runtime.NumGoroutine() (растёт со временем — подозрительно) и профиль горутин через net/http/pprof или runtime/pprof: дамп покажет, где именно застряли горутины.

Обновление 20.08.2026. В Go 1.27 профиль goroutineleak вышел из эксперимента и доступен в runtime/pprof — это готовый инструмент для ситуаций, описанных выше. Стенд из этой статьи и goleak в его тестах (ниже, в разделе демо) остаются в силе: goleak решает другую задачу — проверяет отсутствие утечек на выходе из конкретного теста, а не строит профиль по живому процессу. Разбор нового профиля — в отдельной статье.

Дедлоки

Дедлок — взаимная блокировка: горутины ждут друг друга по кругу. На каналах простейший случай — приём из канала, в который никто не отправит:

func main() {
	ch := make(chan int)
	<-ch // никто не отправит — вечная блокировка
}

Если все горутины программы оказались заблокированы, рантайм это обнаруживает и падает с сообщением вида:

fatal error: all goroutines are asleep - deadlock!

Важная оговорка: этот детектор срабатывает, только когда заблокирована вся программа. Частичный дедлок — когда часть горутин повисла, а main продолжает жить, — рантайм не заметит; это будет выглядеть как утечка. На мьютексах классическая причина дедлока — разный порядок захвата двух блокировок в разных местах кода; лечится единым порядком захвата.

Захват переменной цикла: что изменилось в Go 1.22

До Go 1.22 переменная цикла for была одна на все итерации. Горутина, замкнувшая её, видела не то значение, что было на своей итерации, а последнее — очень частый источник багов:

// До Go 1.22 — БАГ: все горутины, скорее всего, напечатают одно и то же
// (последнее) значение i, потому что замыкают общую переменную.
for i := 0; i < 3; i++ {
	go func() {
		fmt.Println(i)
	}()
}

Каноническое лечение до 1.22 — «затенить» переменную копией на каждой итерации:

for i := 0; i < 3; i++ {
	i := i // копия на итерацию
	go func() {
		fmt.Println(i)
	}()
}

В Go 1.22 семантику изменили: теперь переменная цикла создаётся заново на каждой итерации, и этот класс багов в новых горутинах исчез — строчка i := i больше не нужна. Но помнить о нём стоит: вы встретите старый код с этим приёмом, а если целитесь в модуль с go ниже 1.22 в go.mod, действует прежняя семантика. То же касается и примера с errgroup выше.

Checklist

  1. Каждая запущенная горутина имеет понятный способ завершиться — через ctx.Done(), закрытие канала или WaitGroup? Нет вечно висящих отправок/приёмов?
  2. main дожидается нужных горутин (WaitGroup/канал/errgroup), а не полагается на time.Sleep?
  3. Канал закрывает отправитель, ровно один раз; в закрытый канал никто не пишет?
  4. Ёмкость буфера выбрана осознанно (backpressure), а не «побольше на всякий случай»?
  5. Долгие и блокирующие операции слушают ctx.Done() и пробрасывают ctx вглубь (в HTTP/БД-вызовы)?
  6. Для каждого WithCancel/WithTimeout/WithDeadline вызывается cancel() (обычно defer cancel())?
  7. Разделяемое состояние защищено (мьютекс/атомик/канал), а не «кажется, и так норм»? Прогнали go test -race?
  8. Параллелизм ограничен (worker pool, семафор, errgroup.SetLimit), а не «горутина на каждую задачу» без предела?
  9. Выбор канал-vs-мьютекс сделан по существу (передаёте данные — канал; защищаете состояние — мьютекс), а не по догме?
  10. Если целитесь в go ниже 1.22 — учтён захват переменной цикла?

Демо и версии

  • Код: живой стенд digital-cookbook/go-concurrency/ — запускаемые версии паттернов из статьи (worker pool с select на приёме И отправке, fan-in/out, pipeline, errgroup), каждый с тестом на корректность и отсутствие утечек (goleak); весь модуль проходит go test -race ./.... Фрагменты в тексте — выжимки без package/импортов; полные программы — в стенде (goroutines/).
  • Версии: примеры требуют Go 1.25+ (значение GOMAXPROCS по умолчанию учитывает cgroup-лимиты начиная с Go 1.25) и golang.org/x/sync (для errgroup). Вехи, на которые опирается статья: асинхронное вытеснение — Go 1.14; область видимости переменной цикла на итерацию — Go 1.22; cgroup-aware GOMAXPROCS по умолчанию — Go 1.25. Замеры производительности (если понадобятся) снимайте у себя через go test -bench, а не берите числа из статей.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии