Введение
Конкурентность — это то, за что Go любят и выбирают. Пока другие языки прикручивают асинхронность через callbacks, промисы и async/await, в Go запуск параллельной работы выглядит как go f() — два символа перед вызовом функции. За этой простотой стоит продуманная модель: лёгкие горутины вместо тяжёлых потоков ОС и каналы для безопасного общения между ними. Девиз Go звучит так: «Не общайтесь через разделяемую память; вместо этого разделяйте память, общаясь». То есть не боритесь за один кусок памяти под замками, а передавайте данные друг другу по каналам. В этой главе ты научишься запускать горутины, синхронизировать их через sync.WaitGroup, передавать данные по каналам (буферизованным и небуферизованным), задавать каналам направление, мультиплексировать их через select, правильно закрывать каналы и читать их через range. Разберёшься, что такое гонка данных и как её ловит детектор -race, когда нужен sync.Mutex, и в конце соберёшь боевой паттерн — пул воркеров. Это самая насыщенная глава курса, но именно она открывает настоящую мощь языка.
Горутина: сверхлёгкий поток
Горутина — это функция, выполняющаяся конкурентно с остальными. Запускается словом go перед вызовом. В отличие от потоков операционной системы, которые стоят мегабайты памяти, горутина стартует с нескольких килобайт стека и растёт по мере надобности. Поэтому в Go-программе спокойно живут сотни тысяч горутин — планировщик языка сам распределяет их по нескольким системным потокам.
package main
import "fmt"
func main() {
// go запускает foo() конкурентно и НЕ ждёт её завершения.
go foo()
// Программа тут же летит дальше и... завершается,
// не дав foo даже начать. main вышел — программа умерла.
fmt.Println("main закончился")
}
func foo() {
for i := 0; i < 5; i++ {
fmt.Println("foo:", i)
}
}
Этот код почти наверняка не напечатает ничего из foo. Причина фундаментальна: когда завершается функция main, программа завершается целиком, не дожидаясь горутин. Горутина — это обещание сделать работу «когда-нибудь», и если её никто не ждёт, она может вообще не успеть выполниться. Значит, нам нужен способ дождаться горутин.
sync.WaitGroup: дожидаемся горутин
sync.WaitGroup — это счётчик незавершённых горутин. Ты говоришь ему, сколько горутин запускаешь (Add), каждая по завершении отмечается (Done), а main блокируется на Wait, пока счётчик не обнулится. Это самый простой и правильный способ дождаться завершения работы.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
// Говорим: будем ждать 2 горутины. Add ВСЕГДА до go.
wg.Add(2)
go worker("A", &wg)
go worker("B", &wg)
// Блокируемся, пока обе горутины не позовут Done().
wg.Wait()
fmt.Println("все воркеры завершились")
}
// wg передаём УКАЗАТЕЛЕМ: WaitGroup нельзя копировать,
// все горутины должны отмечаться в одном и том же счётчике.
func worker(name string, wg *sync.WaitGroup) {
// defer гарантирует Done() даже при раннем выходе или панике.
defer wg.Done()
for i := 0; i < 3; i++ {
fmt.Println(name, i)
}
}
Три правила WaitGroup, которые спасут тебя от боли: Add вызывай до запуска горутины (иначе Wait может проскочить), wg передавай указателем (*sync.WaitGroup), а Done ставь через defer в самом начале горутины, чтобы он сработал при любом выходе.
Каналы: как горутины общаются
Горутины запустили, дожидаться научились. Но как передать данные из горутины наружу? Через глобальные переменные — опасно (об этом ниже, в разделе про гонки). Правильный путь — каналы. Канал (chan) — это типизированная труба, по которой одна горутина отправляет значения, а другая принимает. Канал одновременно передаёт данные и синхронизирует горутины.
// Создаём канал для int значений.
ch := make(chan int)
// Отправка в канал: стрелка указывает В канал.
ch <- 42
// Приём из канала: стрелка указывает ИЗ канала (слева от него).
v := <-ch
fmt.Println(v) // 42
Запомни направление стрелки раз и навсегда: ch <- v — «значение v втекает в канал ch» (отправка), а v := <-ch — «значение вытекает из канала в переменную v» (приём). Стрелка всегда «показывает», куда движутся данные.
Небуферизованные каналы блокируют
По умолчанию make(chan int) создаёт небуферизованный канал — без внутреннего хранилища. Его главное свойство: отправка блокируется, пока кто-то не начнёт принимать, и наоборот. Это делает канал точкой рандеву (встречи) двух горутин: они синхронизируются в момент передачи.
package main
import "fmt"
func main() {
ch := make(chan int)
// Отправляющая горутина.
go func() {
for i := 0; i < 5; i++ {
ch <- i // блокируется, пока main не примет
}
close(ch) // закрываем: больше значений не будет
}()
// Принимаем в main. range сам завершится, когда канал закроют.
for n := range ch {
fmt.Println("получено:", n)
}
}
Тут одновременно четыре важные вещи: отправка ch <- i ждёт приёмника, close(ch) сообщает «поток данных окончен», for n := range ch читает значения пока канал открыт и автоматически выходит после закрытия. Именно так каналы заменяют ручную синхронизацию: не нужен ни WaitGroup, ни счётчики.
Буферизованные каналы
Если задать второй аргумент make(chan int, 3), канал получит буфер на 3 элемента. Отправка блокируется только когда буфер полон, приём — только когда буфер пуст. Буфер сглаживает разницу в скорости между отправителем и получателем, но не бесконечен.
// Канал с буфером на 2 элемента.
ch := make(chan string, 2)
ch <- "первый" // не блокирует — в буфере есть место
ch <- "второй" // не блокирует — заняли второе место
// ch <- "третий" // ЗАБЛОКИРУЕТ: буфер полон, приёмника нет -> deadlock
fmt.Println(<-ch) // первый (FIFO — первым пришёл, первым вышел)
fmt.Println(<-ch) // второй
Небуферизованный канал — про синхронизацию (гарантированную встречу), буферизованный — про развязку по скорости. Не хватайся за буфер, чтобы «на всякий случай» — чаще всего нужен именно небуферизованный канал, а лишний буфер прячет ошибки синхронизации.
Направленные каналы
Канал можно ограничить по направлению в сигнатуре функции: только для отправки или только для приёма. Это не про производительность, а про безопасность и читаемость — компилятор не даст случайно принять из канала, который функция должна лишь наполнять.
// producer принимает канал ТОЛЬКО ДЛЯ ОТПРАВКИ: chan<- int.
// Внутри можно только слать, приём — ошибка компиляции.
func producer(out chan<- int) {
for i := 0; i < 5; i++ {
out <- i
}
close(out)
}
// consumer принимает канал ТОЛЬКО ДЛЯ ПРИЁМА: <-chan int.
// Внутри можно только принимать; отправка и close — ошибка компиляции.
func consumer(in <-chan int) {
for n := range in {
fmt.Println("обработано:", n)
}
}
func main() {
ch := make(chan int) // обычный двунаправленный канал
go producer(ch) // Go сам сузит его до chan<- int
consumer(ch) // и до <-chan int
}
Мнемоника направления: если <- стоит после слова chan (chan<- int) — данные втекают в канал, это «только отправка». Если <- стоит перед словом chan (<-chan int) — данные вытекают, это «только приём». Обычный двунаправленный канал автоматически приводится к направленному при передаче в функцию.
Закрытие канала и проверка «канал закрыт»
Закрывает канал отправитель вызовом close(ch) — это сигнал «данных больше не будет». Приём из закрытого канала возвращает нулевое значение и false во второй переменной. Никогда не закрывай канал со стороны получателя и не закрывай дважды — это паника.
ch := make(chan int, 2)
ch <- 10
close(ch)
// Форма "comma, ok" для приёма: ok == false, если канал закрыт и пуст.
v, ok := <-ch
fmt.Println(v, ok) // 10 true — значение из буфера
v, ok = <-ch
fmt.Println(v, ok) // 0 false — канал закрыт, значений нет
select: ждём сразу несколько каналов
select — это «switch для каналов». Он ждёт, пока станет готова любая из операций с каналами, и выполняет соответствующую ветку. Если готовы несколько — выбирает случайную. Это ядро сложной конкурентности: таймауты, отмена, объединение нескольких источников.
package main
import (
"fmt"
"time"
)
func main() {
c1 := make(chan string)
c2 := make(chan string)
go func() { time.Sleep(100 * time.Millisecond); c1 <- "от c1" }()
go func() { time.Sleep(200 * time.Millisecond); c2 <- "от c2" }()
// Ждём два ответа. select берёт тот канал, что готов раньше.
for i := 0; i < 2; i++ {
select {
case msg := <-c1:
fmt.Println(msg)
case msg := <-c2:
fmt.Println(msg)
}
}
}
// Частая идиома: таймаут через select и time.After.
select {
case res := <-slowChannel:
fmt.Println("успели:", res)
case <-time.After(1 * time.Second):
// time.After возвращает канal, в который через секунду придёт значение.
fmt.Println("таймаут: операция слишком долгая")
}
Без select невозможно аккуратно реализовать таймаут или ждать несколько каналов одновременно — select превращает каналы из простой трубы в полноценный инструмент координации.
Гонка данных и детектор -race
Теперь самое опасное. Если несколько горутин одновременно читают и пишут одну переменную без синхронизации — это гонка данных (data race). Результат непредсказуем: значение теряется, программа даёт разные ответы при каждом запуске, а иногда падает. Гонки коварны тем, что на маленьких данных «вроде работает», а в проде под нагрузкой ломается.
package main
import (
"fmt"
"sync"
)
var counter int // общая переменная — источник гонки
func main() {
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 — обычно меньше
}
Операция counter++ на самом деле три действия: прочитать, прибавить, записать. Две горутины могут прочитать одно и то же старое значение и обе записать «старое+1» — один инкремент потерялся. Запусти программу с флагом детектора гонок, и Go покажет точное место проблемы.
go run -race main.go
# ==================
# WARNING: DATA RACE
# Read at 0x... by goroutine 8:
# main.main.func1()
# ...
# ==================
Правило: любой конкурентный код всегда прогоняй под -race, особенно тесты (go test -race ./...). Детектор гонок — один из лучших инструментов Go, он ловит то, что глазами не увидишь.
sync.Mutex: защищаем общую память
Если разделяемую память всё же нужно использовать (не всё удобно гнать через каналы), доступ к ней защищают взаимным исключением — sync.Mutex. Мьютекс — это замок: горутина берёт Lock(), делает своё дело, отпускает Unlock(). Пока замок занят, другие ждут. Так одновременно в «критической секции» находится только одна горутина.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
counter := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock() // берём замок
counter++ // критическая секция — тут мы одни
mu.Unlock() // отпускаем замок
}()
}
wg.Wait()
fmt.Println(counter) // теперь ГАРАНТИРОВАННО 1000
}
Мьютекс vs каналы — не «или-или». Каналы хороши, когда данные передаются между горутинами. Мьютекс проще, когда несколько горутин лишь обновляют общее состояние (счётчик, кэш, карту). Выбирай по смыслу: «передача владения» — канал, «защита общего поля» — мьютекс.
Ловушка переменной цикла в горутине
Классическая ошибка, на которой спотыкались все, кто писал Go до версии 1.22. Если запускать горутины в цикле и захватывать переменную цикла замыканием, все горутины могли увидеть одно и то же — последнее — её значение, потому что переменная была общей на весь цикл.
// Опасно в старых версиях Go: все горутины могут напечатать "3 3 3".
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // i — общая на все итерации в Go < 1.22
}()
}
// Надёжный способ, работающий ВЕЗДЕ: передать i аргументом.
// Аргумент копируется в момент запуска — каждая горутина получит своё значение.
for i := 0; i < 3; i++ {
go func(n int) {
fmt.Println(n) // 0, 1, 2 в каком-то порядке
}(i)
}
В Go 1.22 семантику исправили: теперь переменная цикла своя на каждой итерации, и первый вариант работает корректно. Но привычка передавать значение аргументом (или создавать локальную копию i := i) остаётся хорошим тоном — код одинаково верен на любой версии.
Конвейер (pipeline)
Мощная идиома: несколько стадий, соединённых каналами, где выход одной стадии — вход следующей. Каждая стадия — горутина, которая читает из входного канала, обрабатывает и пишет в выходной. Данные текут по трубе, стадии работают параллельно.
// Стадия 1: генерирует числа.
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
// Стадия 2: читает числа, возводит в квадрат, отдаёт дальше.
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
// Соединяем стадии: gen -> sq -> потребитель.
for n := range sq(gen(2, 3, 4)) {
fmt.Println(n) // 4, 9, 16
}
}
Заметь паттерн, общий для всех стадий: каждая закрывает свой выходной канал, когда исчерпала вход, и каждая читает вход через range до закрытия. Именно эта дисциплина «закрыл выход — потому что закрылся вход» делает конвейеры надёжными.
Fan-out / fan-in
Если одна стадия — узкое место, её распараллеливают: несколько горутин читают из одного входного канала (fan-out), а их результаты сливаются в один выходной (fan-in). Так тяжёлую работу раскидывают по нескольким исполнителям.
// merge (fan-in) сливает несколько каналов в один.
func merge(cs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
// На каждый входной канал — горутина, перекладывающая его в out.
forward := func(c <-chan int) {
defer wg.Done()
for n := range c {
out <- n
}
}
wg.Add(len(cs))
for _, c := range cs {
go forward(c)
}
// Закрываем out, когда все перекладчики закончили.
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
in := gen(2, 3, 4, 5)
// fan-out: две горутины sq читают из одного in.
c1 := sq(in)
c2 := sq(in)
// fan-in: сливаем результаты обратно.
for n := range merge(c1, c2) {
fmt.Println(n)
}
}
Fan-out распределяет нагрузку, fan-in собирает результат. Это обобщение worker pool: воркеры — это fan-out от канала задач, а сбор результатов — fan-in. Понимая эти два движения, ты разберёшь почти любую конкурентную схему в Go.
sync.Once и sync.RWMutex
Ещё два полезных инструмента из пакета sync. sync.Once гарантирует, что кусок кода выполнится ровно один раз, сколько бы горутин его ни вызвали — идеально для ленивой инициализации (подключение к БД, загрузка конфига). sync.RWMutex — «умный» мьютекс: много читателей одновременно или один писатель, что ускоряет сценарии «часто читаем, редко пишем».
var once sync.Once
var config map[string]string
// loadConfig может вызываться из многих горутин,
// но тело once.Do выполнится РОВНО ОДИН РАЗ.
func loadConfig() {
once.Do(func() {
fmt.Println("загружаю конфиг (только раз)")
config = map[string]string{"env": "prod"}
})
}
// RWMutex: RLock/RUnlock для чтения, Lock/Unlock для записи.
var mu sync.RWMutex
var cache = map[string]int{}
func get(key string) int {
mu.RLock() // много читателей могут держать RLock одновременно
defer mu.RUnlock()
return cache[key]
}
func set(key string, v int) {
mu.Lock() // писатель — эксклюзивно, ждёт всех читателей
defer mu.Unlock()
cache[key] = v
}
Про context для отмены и таймаутов горутин мы поговорим отдельно в одной из следующих глав — это стандартный способ сказать целому дереву горутин «стоп, работу можно прекращать». А пока запомни: sync.Once — для однократной инициализации, RWMutex — когда чтений много, а записей мало.
Паттерн worker pool
Теперь соберём всё в главный боевой паттерн Go-конкурентности — пул воркеров. Идея: есть много однотипных задач и мы не хотим плодить горутину на каждую (их могут быть миллионы). Вместо этого запускаем фиксированное число воркеров, которые разбирают задачи из общего канала jobs и складывают результаты в канал results. Это ограничивает параллелизм и равномерно грузит систему.
package main
import (
"fmt"
"sync"
)
// worker читает задачи из jobs (только приём) и пишет в results (только отправка).
// Направленные каналы делают контракт функции явным.
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs { // range сам завершится, когда jobs закроют
fmt.Printf("воркер %d обрабатывает задачу %d
", id, j)
results <- j * j // «тяжёлая» работа: возводим в квадрат
}
}
func main() {
const numJobs = 9
const numWorkers = 3
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
var wg sync.WaitGroup
// Запускаем фиксированный пул воркеров.
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// Загружаем задачи и закрываем канал — сигнал «задач больше нет».
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
// Закрываем results в отдельной горутине, КОГДА все воркеры доделали.
go func() {
wg.Wait()
close(results)
}()
// Собираем результаты, пока канал не закроется.
sum := 0
for r := range results {
sum += r
}
fmt.Println("сумма квадратов:", sum) // 1+4+9+...+81 = 285
}
Разбери этот код по косточкам — в нём собрана вся глава: горутины-воркеры, направленные каналы (<-chan и chan<-), закрытие каналов как сигнал завершения, range по каналу, WaitGroup для координации закрытия results. Обрати внимание на тонкость: results закрывается в отдельной горутине после wg.Wait(), потому что закрыть его раньше, чем воркеры допишут, — паника, а не закрыть вовсе — вечная блокировка range results.
Кейс из реального проекта
Тебе нужно проверить доступность 200 URL из списка (конкурентный парсер/чекер ссылок). Делать это последовательно — минуты ожидания сети. Запускать 200 одновременных запросов — можно упереться в лимиты ОС и сервера. Идеальное решение — worker pool с ограниченным числом воркеров, каждый из которых берёт URL из канала, «ходит» по сети и кладёт результат обратно.
package main
import (
"fmt"
"sync"
)
// result — что вернул один воркер по одному URL.
type result struct {
url string
status int
err error
}
// checkURL имитирует сетевую проверку (в реальности здесь http.Get).
func checkURL(url string) result {
// ... реальный код делал бы http.Get(url) и читал resp.StatusCode ...
return result{url: url, status: 200}
}
// worker берёт URL-ы из jobs и шлёт результаты в results.
func worker(jobs <-chan string, results chan<- result, wg *sync.WaitGroup) {
defer wg.Done()
for url := range jobs {
results <- checkURL(url)
}
}
func main() {
urls := []string{
"https://go.dev",
"https://example.com",
"https://pkg.go.dev",
"https://golang.org",
}
jobs := make(chan string, len(urls))
results := make(chan result, len(urls))
var wg sync.WaitGroup
const workers = 3 // ограничиваем параллелизм тремя горутинами
for i := 0; i < workers; i++ {
wg.Add(1)
go worker(jobs, results, &wg)
}
for _, u := range urls {
jobs <- u
}
close(jobs)
// Закрываем results, когда все воркеры закончили.
go func() {
wg.Wait()
close(results)
}()
// Собираем и печатаем отчёт по мере готовности.
for r := range results {
if r.err != nil {
fmt.Printf("%s -> ОШИБКА: %v
", r.url, r.err)
continue
}
fmt.Printf("%s -> %d
", r.url, r.status)
}
}
Меняя одну константу workers, ты регулируешь нагрузку: 3 воркера — бережно, 50 — быстро, но агрессивно. Задачи и результаты ходят по каналам, никакой разделяемой памяти и мьютексов не понадобилось — это и есть тот самый «разделяйте память, общаясь». Такой скелет worker pool ты будешь переиспользовать в десятках реальных задач: рассылка писем, обработка изображений, импорт данных, опрос API.
Типичные ошибки
Забыть дождаться горутин
Запустил go f() и не дождался — main завершился, горутина не успела ничего сделать. Всегда синхронизируйся: WaitGroup для «дождаться всех» или канал, из которого читаешь результат. Горутина без ожидания — это выброшенная работа.
Deadlock из-за небуферизованного канала
Отправка в небуферизованный канал ch <- v блокируется, пока кто-то не примет. Если приёмника нет (например, забыл запустить горутину-получателя или пишешь в канал из той же единственной горутины), получишь fatal error: all goroutines are asleep - deadlock. Убедись, что на каждую отправку есть приём, а на приём — отправка или закрытие.
Закрытие канала не с той стороны или дважды
Закрывать канал должен только отправитель и только один раз. Закрытие получателем, повторное close или отправка в закрытый канал — это паника panic: close of closed channel / send on closed channel. Если отправителей несколько, координируй закрытие через WaitGroup, как в worker pool.
Гонка данных вместо синхронизации
Несколько горутин пишут в общую переменную, слайс или карту без защиты — классическая гонка. Карты в Go вообще не потокобезопасны и падают с fatal error: concurrent map writes. Защищай общее состояние sync.Mutex или передавай данные через каналы, и обязательно прогоняй код под go run -race / go test -race.
Практика
- Запусти три горутины, каждая печатает своё имя пять раз. Синхронизируй их через
sync.WaitGroupтак, чтобыmainдождался всех перед выходом. - Напиши генератор: функция возвращает
<-chan int(только для приёма), внутри горутина шлёт числа от 1 до 10 и закрывает канал. Вmainпрочитай их черезrange. - Собери конвейер из двух стадий:
genотдаёт числа в канал,squareчитает их, возводит в квадрат и пишет в новый канал. Соедини стадии и напечатай результат. - Воспроизведи гонку данных на общем счётчике из 1000 горутин, подтверди её флагом
-race, затем почини двумя способами: черезsync.Mutexи через канал-аккумулятор. Сравни. - Реализуй worker pool из 4 воркеров, который обрабатывает 20 задач (например, вычисляет факториал числа), собирает результаты в канал и печатает их сумму. Убедись, что
resultsзакрывается корректно послеwg.Wait().
Итог
Конкурентность в Go строится на двух простых кирпичах — горутинах и каналах, — но из них собираются сколь угодно сложные конструкции. Горутина запускается словом go и почти ничего не стоит, а чтобы её работа не пропала, горутины синхронизируют через sync.WaitGroup. Каналы передают данные и одновременно синхронизируют: небуферизованные обеспечивают встречу отправителя и получателя, буферизованные развязывают их по скорости, направленные (chan<- и <-chan) делают контракты явными, а select позволяет ждать несколько каналов и ставить таймауты. Ты понял, что close — это сигнал завершения для range, что общая память без синхронизации порождает гонки данных, которые ловит бесценный детектор -race, и что защитить состояние можно через sync.Mutex. Всё вместе это собирается в паттерн worker pool — рабочую лошадку конкурентного Go. Держи в голове девиз языка: не борись за разделяемую память под замками там, где данные можно просто передать по каналу.