Конкурентность — горутины и каналы
- среда, 5 ноября 2025 г. в 00:00:07
Конкурентность — это ядро языка Go. Горутины, каналы и связанные примитивы — это инструменты, с которыми Go делает параллелизм простым и выразительным. В этой статье я объясню концепции, покажу ключевые конструкции, разберу типичные ошибки/подводные камни, дам практические паттерны и инструменты для отладки и профилирования. В конце — краткий практический план действий.
Что такое горутина и как она работает
Каналы: буферизированные и небуферизированные
select и управление множественными каналами
Синхронизация: sync.Mutex, RWMutex, WaitGroup, Cond, atomic
Контексты (context.Context) и отмена операций
Паттерны: pipeline, fan-out/fan-in, worker pool, pub/sub, rate limiter, debounce/throttle
Планировщик Go (GMP): как он влияет на поведение
Типичные баги и антипаттерны (race, deadlock, nil-channel, leak, double unlock)
Инструменты: -race, pprof, trace, dlv и методики тестирования
Практические рекомендации и чек-лист
Горутина — легковесный поток выполнения в Go: go f() запускает функцию f в новой горутине.
Горутины очень дешёвые: стартовый стек (2кB) — динамически растущий (до 1ГB на 64-разрядных системах), не фиксированный как у ОС-потока. Это позволяет эффективно использовать память, так как для большинства задач выделяется только небольшой объем.
Модель: тысячи/миллионы горутин — типично для Go, но нужна осторожность с синхронизацией и памятью.
Пример
go func() {
fmt.Println("Hello from goroutine")
}()
Особенности
Стек растёт/сжимается автоматически.
Ошибки в горутине (panic) по-умолчанию не убивают другие горутины — нужно оборачивать recover, если есть вероятность паники.
В sync.Mutex Go не хранит информацию о том, какая горутина захватила блокировку. Из-за этого одна и та же горутина не может вызвать Lock() повторно — она просто заблокирует сама себя навсегда (deadlock).
Каналы — основной механизм коммуникации между горутинами: "передавай значение через канал, а не делись памятью".
var ch chan int // nil-канал
ch = make(chan int) // небуферизированный
chBuf := make(chan int, 10) // буферизированный
ch <- v блокирует отправителя до тех пор, пока кто-то не прочитает v.
Хорош для синхронизации: handoff.
Отправитель блокируется только, когда буфер заполнен.
Размер буфера — инструмент для сглаживания пиков, но не замена проектирования потоков.
close(ch) сигнализирует о завершении отправки. Читатели получают нулевое значение и ok==false.
Закрывать должен отправитель, не читатель. Закрытие nil канала вызывает panic.
Пример чтения
for v := range ch {
fmt.Println(v) // завершится, когда ch закрыт
}
Небуферизированный канал
Открытый | Закрытый | Неинициализированный | |
|---|---|---|---|
Чтение | Блокировка до прихода писателя | Вернется zero value | Блокировка навсегда |
Запись | Блокировка до прихода читателя | Panic | Блокировка навсегда |
Закрытие | Канал закроется | Panic | Panic |
Буферизированный канал
Открытый и частично заполненный | Открытый и полностью заполненный | Открытый и пустой | Закрытый и частично заполненный | |
|---|---|---|---|---|
Чтение | Прочитаем значение | Прочитаем значение | Блокировка до прихода писателя | Прочитаем значение |
Запись | Запишем значение | Блокировка до прихода читателя | Запишем значение | Panic |
Канал только на чтение
Запись | Ошибка компиляции |
|---|---|
Закрытие | Ошибка компиляции |
Канал только на запись
Чтение | Ошибка компиляции |
|---|
select позволяет дождаться одного из нескольких каналов (подобие switch для каналов).
Пример
select {
case v := <-ch1:
// обработать v
case ch2 <- x:
// отпр��влено
case <-time.After(time.Second):
// таймаут
}
Особенности
Если несколько case готовы — выбирается случайный (fairness).
select с nil каналом — этот case игнорируется (удобно для динамики).
Используйте default для неблокирующей проверки.
sync.Mutex — это примитив синхронизации из стандартной библиотеки Go, реализующий концепцию взаимного исключения (mutual exclusion) для защиты общих ресурсов от одновременного доступа несколькими горутинами. Мьютекс предоставляет монопольный доступ: как только одна горутина вызывает Lock(), другие горутины, пытающиеся его заблокировать, будут приостановлены до тех пор, пока первая горутина не вызовет Unlock(). Это предотвращает гонки данны�� и обеспечивает безопасное управление состоянием общих переменных, карт, структур и других объектов.
Не рекурсивный. Lock() на уже заблокированном мьютексе из той же горутины → deadlock.
sync.RWMutex — это мьютекс "читатель-писатель" (reader/writer), который предоставляет более гранулированный контроль доступа к общим данным, чем стандартный sync.Mutex. Он позволяет нескольким горутинам одновременно читать данные, но гарантирует, что только одна горутина может вносить изменения (записывать) в один момент времени.
Основные принципы работы
Чтение: Неограниченное количество горутин может одновременно получить блокировку чтения (RLock) и читать данные. Это повышает производительность, если операции чтения происходят значительно чаще, чем операции записи.
Запись: Когда горутина запрашивает блокировку записи (Lock), она получает монопольный доступ к ресурсу. Все остальные горутины, как читатели, так и другие писатели, блокируются до тех пор, пока писатель не завершит свою работу и не освободит блокировку.
Приоритет писателя: Если горутина ждёт блокировки для записи (Lock), все новые запросы на блокировку чтения (RLock) также будут заблокированы. Это предотвращает "голодание" писателя, который мог бы бесконечно ждать, пока все читатели завершат свою работу.
Методы
Lock(): Блокирует RWMutex для записи. Вызывающая горутина будет ждать, пока не освободятся все блокировки чтения и другие блокировки записи.
Unlock(): Снимает блокировку записи. Должен быть вызван горутиной, которая ранее получила блокировку Lock().
RLock(): Блокирует RWMutex для чтения. Неограниченное количество горутин может одновременно вызвать этот метод.
RUnlock(): Снимает блокировку чтения. Должен быть вызван горутиной, которая ранее получила блокировку RLock().
RLocker(): Возвращает интерфейс sync.Locker, который вызывает RLock() и RUnlock(), что полезно для использования с другими компонентами, которые ожидают этот интерфейс
Это структура в Go для синхронизации горутин, который позволяет основной горутине дождаться завершения группы других горутин. Она работает как счетчик, который увеличивается методом Add() перед запуском горутины, а каждая завершающаяся горутина уменьшает его, вызывая Done(). Основная горутина блокируется на вызове Wait() до тех пор, пока счетчик не станет равен нулю.
это примитив синхронизации в Go, позволяющий горутинам ждать выполнения определённого условия, пока другие горутины не сообщат об этом. Он используется, когда одной или нескольким горутинам необходимо приостановиться до тех пор, пока состояние общего ресурса не достигнет нужного значения. Основные методы — Wait(), Signal() и Broadcast().
Wait(): Блокирует текущую горутину, пока не будет получено уведомление. Важно, что вызов Wait() должен происходить внутри цикла, проверяющего условие.
Signal(): Разблокирует одну горутину, которая ожидает с помощью Wait().
Broadcast(): Разблокирует все горутины, ожидающие с помощью Wait().
sync.Cond полезен для реализации таких структур, как очереди, где горутины могут ждать, пока очередь не станет пустой или пока не появятся новые данные. Это более эффективная альтернатива «спинлокам», которые потребляют много ресурсов процессора.
Важно помнить, что sync.Cond часто используется в сочетании с мьютексом (sync.Mutex), который защищает совместно используемые данные, на которые ссылается условие.
sync.Once — это тип данных в языке программирования Go, который гарантирует, что указанный код будет выполнен только один раз, даже если функция вызывается из нескольких горутин одновременно. Он используется для ленивой инициализации ресурсов, подключения к базе данных или загрузки конфигураций. sync.Once имеет метод Do, который принимает функцию и гарантирует, что эта функция будет вызвана только единожды, независимо от количества вызовов.
Как это работает:
Создается экземпляр sync.Once.
При вызове Do, если код еще не выполнялся, он выполняется.
Если Do вызывается повторно, sync.Once просто игнорирует вызов, так как код уже был выполнен.
Это достигается за счет внутреннего механизма блокировки, который гарантирует, что только одна горутина сможет выполнить код в первый раз.
sync.Pool — это потокобезопасный пул объектов в Go, который переиспользует кратковременные объекты, чтобы уменьшить нагрузку на сборщик мусора и сократить выделение памяти. Это достигается за счет извлечения объекта из пула с помощью метода Get(), его использования и последующего возвращения обратно в пул через метод Put(). Важно обнулять поля объекта перед его возвращением, чтобы избежать использования «грязных» данных при последующем извлечении.
sync/atomic — это пакет в языке Go, который предоставляет низкоуровневые операции для работы с памятью, выполняемые как единое, неделимое целое (атомарно), что позволяет избежать гонок данных в многопоточных программах без использования мьютексов. Эти примитивы включают в себя безопасные чтение (Load), запись (Store), сложение (Add) и другие операции над примитивными типами данных, такими как int32, int64 и uintptr.
Основные моменты:
Атомарность: Операции выполняются без возможности прерывания другими потоками, гарантируя, что они завершатся полностью или не начнутся вообще.
Цель: Безопасная синхронизация горутин в многопоточных программах при доступе к разделяемой памяти.
Альтернатива мьютексам: Предоставляет низкоуровневый способ синхронизации, который может быть более производительным для некоторых задач по сравнению с мьютексами, но требует большей осторожности при использовании.
Ключевые функции:
Load: Атомарное чтение значения переменной.
Store: Атомарная запись значения переменной.
Add: Атомарное сложение значения переменной.
CompareAndSwap (CAS): Сравнение значения переменной с заданным значением и, если они равны, атомарная замена на новое значение.
Важное замечание: Использование sync/atomic требует большой осторожности, и в большинстве случаев предпочтительнее использовать каналы или мьютексы из пакета sync для более высокоуровневой синхронизации.
Контекст — стандартный способ отмены, дедлайнов и передачи метаданных.
Всегда принимать ctx context.Context первым аргументом в API, который может блокироваться/долго работать.
Пример
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
Отмена: отправка cancel() должна привести к завершению всех зависимых операций (проверять ctx.Done() в циклах).
Данные проходят через стадии, каждая — горутина с входом/выходом (каналами).
Преимущества: разделение обязанностей, backpressure через буферы.
Fan-out: один источник → несколько воркеров.
Fan-in: несколько воркеров → один канал результатов.
N фиксированных воркеров читают из jobs и пишут результат в results.
Контролирует параллелизм.
Издатель пишет во множество подписчиков (map[chan]struct{}). Обязательно учитывать медленных подписчиков (drop / buffer / per-subscriber goroutines).
time.Ticker для равномерной отправки.
Token bucket даёт burst.
Использование общего context и WaitGroup. Закрывать при SIGTERM/SIGINT.
Go-планировщик основан на трёх сущностях:
G (goroutine) — легковесный поток выполнения (наш go f()), содержит стек, статус, регистры и т. п.
M (machine / OS thread) — реальный поток ОС, выполняет код. M — то, что ядро ОС видит.
P (processor / logical processor) — ресурс планировщика, представляющий набор контекстной информации, необходимых для запуска G (например, локальная очередь задач, кэш). Чтобы G выполнялся, он должен быть привязан к M, которое использует P: G выполняется на M при наличии P.
Соотношение: много G → M:N через P. Число P задаёт параллелизм (GOMAXPROCS).
GOMAXPROCS (по умолчанию — число логических CPU) определяет количество P и, следовательно, максимальное число goroutine, выполняемых одновременно на разных потоках ОС.
Установить можно:
через среду: GOMAXPROCS=4 go run .
программно: runtime.GOMAXPROCS(n)
Вывод: для CPU-bound задач обычно не нужно снижать GOMAXPROCS. Для специфичных задач (контейнер, гипер-threading) можно экспериментировать.
У каждого P есть локальная очередь runnable G (deque) для низкой задержки.
Есть также глобальная очередь для балансировки.
Когда P заканчивает работу — оно берёт следующую G из своей очереди; если пусто — пытается украсть работу у других P (work-stealing).
Это даёт хорошую балансировку и масштабируемость при большом числе G.
Когда G выполняет блокирующий системный вызов (например, чтение файла или C-код через cgo), M, исполнявший эту G, может быть превращён в "blocked M". Тогда система:
либо создаёт новый M, чтобы поддержать желаемый параллелизм (если доступен P),
либо ждёт освобождения — поведении зависит от состояния P/M.
Для сетевых операций Go использует netpoller. Неблокирующие сетевые операции интегрированы с netpoller — чтение/запись не держат M заблокированным до получения данных, что помогает масштабировать тысячи соединений.
С Go 1.14 и позже введена асинхронная вытесняющая планировка: раннее планирование было кооперативным (только на точках безопасности), теперь планировщик может прервать долгую горутину (сигналами) и переключить выполнение, поэтому долгие циклы стали безопаснее.
Но есть непрерываемые участки (C-код, syscalls, некоторые runtime-операции). Длительное выполнение в нативном C-коде или без точек безопасности может увеличить задержки.
Когда P ожидает работу, он может под-spin-ить (коротко исполняться/получать работу) прежде чем засыпать — это оптимизация для уменьшения latency при частых задачах.
Это нормальное поведение, но под этим может скрываться повышенная загрузка CPU, если много коротких задач: стоит мониторить.
У горутины динамический стек (начальный — (2кB), затем растёт при необходимости до 1ГB на 64-разрядных системах). Это делает G крайне «лёгкими», но при экстремальном росте стека происходит копирование — это дорого. Поэтому:
избегай рекурсий/очень глубоких стеков в миллионах горутин;
избегай частых аллокаций больших локальных переменных в горячих горутинах.
runtime.NumGoroutine() — число горутин.
pprof / runtime/pprof — CPU/heap профайлы.
runtime/trace — детальная трассировка планировщика (включает переключения G, блокировки, syscalls). Пример:
import "runtime/trace"
f, _ := os.Create("trace.out")
trace.Start(f); defer trace.Stop()
затем go tool trace trace.out.
GODEBUG (runtime debug): есть флаги вроде GODEBUG=schedtrace=1000 (печать статистики планировщика, осторожно — меняется с версиями). Используется аккуратно при отладке.
go tool pprof и go test -race — для гонок/узких мест.
Состояние гонки (race condition) в Go возникает, когда две или более горутины одновременно пытаются получить доступ к общим данным и изменить их, а результат выполнения зависит от непредсказуемого порядка выполнения этих операций. Это может привести к ошибкам, которые проявляются нерегулярно и не всегда очевидны, так как порядок доступа к данным зависит от планировщика горутин. Go имеет встроенный детектор гонок, который помогает выявлять такие проблемы.
Как это происходит
Общие данные: Несколько горутин работают с одними и теми же переменными или ресурсами.
Нескоординированные операции: Одна или несколько горутин пытаются одновременно прочитать и/или записать эти данные без какой-либо синхронизации (например, без использования мьютексов).
Непредсказуемый результат: Поскольку порядок выполнения о��ераций в разных горутинах не фиксирован, итоговое значение общей переменной может отличаться от ожидаемого, что приводит к некорректной работе программы.
Как бороться с состояниями гонки в Go
Используй мьютексы: Для защиты общих данных используй sync.Mutex или sync.RWMutex. Они гарантируют, что в критической секции кода в любой момент времени выполняется только одна горутина.
Встраивай синхронизацию: Используй sync.WaitGroup для синхронизации жизни горутин.
Каналы: Передавай данные между горутинами с помощью каналов. Каналы обеспечивают безопасную передачу данных и избегают прямого доступа к общим переменным.
atomic пакет: Используй функции из пакета sync/atomic для атомарных операций с примитивными типами данных, например, atomic.AddInt64.
Встроенный детектор гонок: Используй команду go run -race, чтобы запустить программу с детектором гонок, который отследит и выведет информацию о состояниях гонки.
Deadlock в Golang — это состояние, когда две или более горутины находятся в бесконечном цикле ожидания друг друга, так как каждая из них ждет ресурс, который занят другой горутиной. Это приводит к тому, что программа замирает и не может продолжать выполнение, например, когда две горутины пытаются получить один и тот же ресурс в разном порядке, или когда они ожидают события в канале, которое никогда не произойдет.
Как возникает deadlock
Взаимное блокирование ресурсов: Две горутины пытаются заблокировать несколько ресурсов, но каждая пытается захватить их в разном порядке. Например, горутина 1 сначала блокирует ресурс A, а затем пытается заблокировать ресурс B, в то время как горутина 2 сначала блокирует ресурс B, а затем пытается заблокировать ресурс A. В итоге обе горутины будут бесконечно ждать друг друга.
Неправильная работа с каналами: Deadlock может произойти при работе с каналами, если горутина пытается считать из канала, в который ничего не будет отправлено, или отправить данные в канал, который никогда не будет прочитан.
Отсутствие выхода: Если горутина находится в ожидании, но нет никакого условия или события, которое могло бы ее разблокировать, она попадет в deadlock.
Признаки deadlock
Программа перестает отвечать.
Некоторые части програ��мы перестают работать.
Программа потребляет большое количество процессорного времени, но ничего не выполняет.
Как избежать deadlock
Используй одни и те же механизмы блокировки для всех горутин при доступе к общим ресурсам.
Внедряй ясные и простые правила доступа к ресурсам.
Используй инструменты для отладки и анализа кода.
Избегай блокировки ресурсов в разном порядке.
Обеспечь, чтобы горутины могли выйти из цикла ожидания
Операции над nil каналом блокируют навсегда. Используется сознательно для динамического отключения case в select.Nil-канал в Go — это канал, который не был инициализирован с помощью make и имеет нулевое значение (nil). Любые попытки отправки или получения данных из такого канала приведут к вечной блокировке, а попытка закрыть его вызовет панику.
Основные свойства
Блокировка: Получение данных из nil-канала или отправка в него данных приводит к вечной блокировке горутины.
Отсутствие инициализации: nil-канал является результатом объявления канала без последующей инициализации конструктором make(), например, var ch chan int.
Паника при закрытии: Попытка закрыть nil-канал вызовет панику (panic) в программе.
Использование в select: В блоке select операция над nil-каналом, будь то отправка или получение, никогда не будет выбрана.
Как избежать проблем
Инициализируй каналы: Всегда инициализируй каналы с помощью make(), прежде чем использовать их.
Проверяй на nil: Перед использованием канала проверь, не равен ли он nil, чтобы избежать потенциальных проблем.
Закрытие канала дважды в Go приведет к панике (panic), то есть аварийному завершению программы, поскольку попытка закрыть уже закрытый канал является ошибкой выполнения. После первого закрытия канал нельзя использовать для отправки данных, но можно для чтения, а попытка повторно закрыть его вызовет панику.
Как происходит ошибка
Первое закрытие: Канал закрывается. После этого можно получать данные из него, но нельзя отправлять. При попытке отправки будет выдана паника.
Второе закрытие: Если попытаться вызвать close() для уже закрытого канала, Go немедленно завершит выполнение программы, так как это нарушает правила работы с каналами.
Правила закрытия каналов
Канал можно закрывать только со стороны отправителя, если этот отправитель является единственным, кто отправляет данные в канал.
Не следует закрывать канал со стороны получателя.
Не следует закрывать канал, если в него могут писать несколько горутин одновременно, так как невозможно точно определить, кто из отправителей закончил свою работу.
Чтение из закрытого канала в Go не блокирует выполнение горутины и всегда возвращает нулевое значение для типа данных канала, а также второе булево значение, указывающее на то, что канал закрыт. Попытка отправить данные в закрытый канал приведет к панике (runtime panic).
Как читать из закрытого канала
Используй многозначный оператор range или ok с оператором приема:
С помощью цикла range
Цикл range будет успешно завершен, когда все значения из канала будут прочитаны, и затем автоматически остановится.
for v := range ch {
// Обработка полученного значения v
}
С помощью ok оператора
Этот метод позволяет явно проверить, открыт канал или закрыт.
v, ok := <-ch
if ok {
// Канал открыт, значение v получено
} else {
// Канал закрыт, значение v — нулевое
}
Важные моменты
Отсутствие блокировки: Чтение из закрытого канала не блокирует выполнение.
Нулевое значение: Всегда возвращается нулевое значение для типа данных канала (например, 0 для int или "" для string).
Паника при записи: Попытка записи в закрытый канал вызывает панику программы.
Утечка горутин (goroutine leak) в Go — это ситуация, когда горутина навсегда блокируется и не завершает свою работу, потребляя ресурсы, такие как стек и память, даже после того, как она больше не нужна. Это может произойти из-за ошибок в логике, таких как ожидание сообщения по каналу, который больше никогда не будет отправлять данные, или из-за незакрытых каналов, которые блокируют другие горутины. В результате приложение может стать нестабильным, вызвать утечки памяти и снизить производительность.
Причины возникновения
Ожидание по каналам: Горутина ждет данные из канала, но ни одна другая горутина не записывает в этот канал, либо канал никогда не будет закрыт.
Неотмененные контексты: В асинхронных операциях (например, работа с фреймворками) контекст не отменяется, что приводит к утечке горутины, скрытой внутри библиотеки.
Бесконечные циклы: Горутины, запущенные в бесконечных циклах, которые не имеют механизма выхода.
Блокировки (Mutex): Горутина может заблокироваться навсегда, если не освободит мьютекс, который в свою очередь блокирует другие горутины.
Последствия
Утечка памяти: Горутина и все связанные с ней переменные занимают память, которая не может быть освобождена сборщиком мусора.
Снижение производительности: Большое количество заблокированных горутин потребляет ресурсы сервера, что замедляет работу приложения.
Непредсказуемое поведение: Приложение может работать нестабильно и непредсказуемо.
Невозможность остановки: Утечную горутину нельзя остановить, если только не остановить всё приложение целиком.
Паника: Если в утекающей горутине происходит паника, она может повлечь за собой панику и в основной горутине.
Как предотвратить и исправить
Закрывай каналы: Убедись, что каналы закрываются после того, как в них перестают записывать данные.
Используй context: Используй context.WithCancel для отмены контекстов и остановки горутин, которые их используют.
Предотвращай бесконечные циклы: Используй условия выхода и механизмы остановки для горутин.
Используй инструменты отладки: Отлаживай утечки с помощью pprof (profiling) и других инструментов.
В Golang "double lock" (двойная блокировка) и "unlock panic" (паника при разблокировке) — это не стандартные термины, но они описывают две разные проблемы: deadlock и неправильную обработку ошибок. Двойная блокировка — это вид deadlock, возникающий, когда две или более горутины блокируют друг друга, ожидая освобождения ресурсов, что приводит к остановке программы. "Panic unlock" или паника при разблокировке обычно связана с попыткой вызвать unlock на мьютексе, который уже не заблокирован (хотя в стандартной библиотеке Go это не приводит к панике, а просто игнорируется).
Двойная блокировка (Double Lock)
Описание: Ситуация, когда одна горутина удерживает один мьютекс и пытается получить другой, который уже заблокирован первой горутиной, а другая горутина удерживает второй мьютекс и ждет первый.
Пример:
Горутина А блокирует мьютекс M1.
Горутина Б блокирует мьютекс M2.
Горутина А пытается заблокировать M2 (уже заблокирован Б) и ждет.
Горутина Б пытается заблокировать M1 (уже заблокирован А) и ждет.
Обе горутины зависают, и программа останавливается.
Решение: Используй либо один мьютекс для защиты обоих ресурсов, либо установи строгий порядок получения мьютексов, чтобы избежать циклического ожидания.
«Паника при разблокировке» (Unlock Panic)
Описание: В стандартной библиотеке Go вызов sync.Mutex.Unlock() на мьютексе, который не был заблокирован текущей горутиной, не вызывает панику, как это было в более старых версиях.
Некорректное использование: Если ты думаешь, что в такой ситуации должна произойти паника, и пишешь код, исходя из этого предположения, ты можешь столкнуться с неожиданным поведением. В отличие от других языков, Go не имеет механизма, который бы явно указывал на «неправильное» освобождение мьютекса, если только ты не добавишь свою собственную проверку.
«Правильная» обработка: Вместо паники unlock просто ничего не делает. Если тебе нужна более строгая проверка, ты можешь добавить собственную логику, которая будет отслеживать, заблокирован ли мьютекс, до вызова Unlock().
Включает detector гонок. Обязательно запускать в CI для concurrent кодa.
CPU/heap/profile анализ.
Бенчмарки и профили.
На весь модуль.
Для concurrent code стараться писать детерминированные тесты: использовать controllable clocks, mocks, channels.
Для визуализации планирования и блокировок.
Пошагово, точки останова в конкретной горутине.
Принцип: разделяй ответственность — каждая горутина делает одну задачу.
Используй каналы для коммуникации; разделяй владение данными: один владелец — одна горутина, которая модифицирует состояние; остальные читают через канал.
context всегда первым аргументом для долгих операций.
Предпочитай каналы для передачи данных, мьютексы для защиты маленьких критических областей.
Используй sync/atomic для простых счётчиков.
Не делай defer wg.Done() в цикле без понимания — корректно wg.Add(1) до запуска горутины.
Никогда не полагайся на то, что внешние системы (БД, API, сеть, файл и т.д.) ответят вовремя — всегда сам контролируй время ожидания, используя context.WithTimeout или select с time.After.
Отправитель закрывает канал. Используй close(results) в goroutine, которая завершает отправку.
Для graceful shutdown объединяй context + WaitGroup + Shutdown().
Логи в горутинах: включай goroutine id (если нужно) или correlation id в контекст.
Экспонируй метрики: number of goroutines, queue sizes, rates, latencies.
Меньше синхронизации → выше throughput.
Профилируй -cpu, -mem при оптимизации.
sync.Pool для частых аллокаций (buffers).
func StartWorkers(ctx context.Context, n int, jobs <-chan Job, results chan<- Result) {
var wg sync.WaitGroup
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok { return }
results <- Process(job)
}
}
}()
}
go func() {
wg.Wait()
close(results)
}()
}
func fanIn[T any](chs ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
wg.Add(len(chs))
for _, c := range chs {
go func(c <-chan T) {
defer wg.Done()
for v := range c {
out <- v
}
}(c)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func safeGo(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("panic in goroutine: %v", r)
}
}()
fn()
}()
}
Всегда думай: кто владеет состоянием?
Предпочитай каналы там, где возможна передача сообщений.
Везде, где операция может долго блокировать — принимай context.Context.
Тестируй go test -race — так находится большинство ошибок data race.
Для long-running сервисов включи pprof на debug endpoint.
Добавь метрики: goroutines, queue length, errors.
Обрабатывай shutdown: signal.Notify + context.WithTimeout + WaitGroup.
Не закрывай каналы из нескольких мест, закрывает только отправитель.
Следи за утечками горутин (в CI/metrics) — горутины не должны расти бесконтрольно.
Вместо обыкновенных каналов, используй каналы на запись и на чтение, где это возможно.
Горутины и каналы — мощный, но тонкий инструмент.
Думай о владении, cancelation, timeouts и ограничении параллелизма.
Используй инструменты (-race, pprof, trace) на постоянной основе.
Пиши простые, тестируемые паттерны: worker pool, pipeline, fan-out/fan-in.