Одна из причин, по которой Go выбирают для серверов, — конкурентность встроена прямо в язык. Запустить параллельную задачу здесь так же просто, как вызвать функцию. Разберём, как это устроено на уровне языка: горутины, каналы и инструменты для их согласования.
Зачем нужна конкурентность
Программа часто ждёт: ответа от базы данных, файла с диска, другого сервиса по сети. Пока один кусок кода ждёт, процессор простаивает — а мог бы заниматься другой задачей. Конкурентность — это умение программы держать в работе несколько задач сразу, переключаясь между ними.
В большинстве языков параллельные задачи делают через потоки операционной системы (OS threads), и они дорогие: каждый поток занимает мегабайты памяти, а переключение между ними стоит времени. Поэтому тысячи потоков — это уже проблема.
Go решает это иначе. Здесь есть горутины — лёгкие задачи, которыми управляет не операционная система, а сам runtime Go. Горутина стартует с крошечного стека (несколько килобайт), и их спокойно бывают сотни тысяч в одной программе.
Короткая формула: горутина — это функция, которая выполняется одновременно с остальным кодом.
Горутины: go func
Чтобы запустить функцию в отдельной горутине, перед её вызовом ставят ключевое слово go:
live example
package main
import (
"fmt"
"time"
)
func say(text string) {
fmt.Println(text)
}
func main() {
go say("привет из горутины") // запускается параллельно
say("привет из main") // обычный вызов
time.Sleep(100 * time.Millisecond) // костыль, чтобы дождаться, см. ниже
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
go say(...) не ждёт завершения функции — она запускается «в фоне», а выполнение main сразу идёт дальше. Главная тонкость: сама функция main тоже горутина (главная). Когда main завершается, программа выходит немедленно — и не ждёт остальные горутины.
Именно поэтому в примере стоит time.Sleep: без него main закончится раньше, чем горутина успеет что-то напечатать. Sleep здесь — плохой способ синхронизации (мы просто гадаем, сколько ждать). Дальше увидим правильные инструменты.
Горутиной можно запустить и анонимную функцию — это частый приём:
go func() {
fmt.Println("работаю в фоне")
}()
Каналы: обмен данными
Горутины работают параллельно, но им нужно как-то передавать данные и согласовывать действия. В Go для этого есть каналы (chan) — типизированные «трубы», по которым одна горутина отправляет значение, а другая принимает.
Девиз Go: «не общайтесь через общую память — делите память через общение». Идея не новая: это модель CSP (Communicating Sequential Processes) Тони Хоара, где независимые процессы договариваются только через передачу сообщений. То есть вместо того чтобы несколько горутин лезли в одну переменную (и мешали друг другу), они передают данные по каналу.
Канал создают через make, указывая тип значений:
ch := make(chan int) // канал для int
go func() {
ch <- 42 // отправить значение в канал
}()
value := <-ch // принять значение из канала
fmt.Println(value) // 42
Стрелка <- показывает направление: ch <- 42 — отправка, <-ch — приём.
Главное свойство такого канала — он синхронизирует горутины. Отправка ch <- 42 блокируется (ждёт), пока кто-то не будет готов принять. И наоборот: <-ch блокируется, пока кто-то не отправит. Это и есть удобная замена time.Sleep: приём из канала сам дождётся, когда данные появятся.
Буферизация
Канал из примера выше — небуферизованный: отправитель ждёт получателя «рука об руку». Иногда это не нужно — хочется, чтобы отправитель не блокировался, пока есть свободное место. Для этого задают размер буфера вторым аргументом make:
ch := make(chan int, 2) // буфер на 2 значения
ch <- 1 // не блокирует — место есть
ch <- 2 // не блокирует — место есть
// ch <- 3 — заблокировало бы: буфер полон
В буферизованный канал можно отправлять, пока буфер не заполнен. Когда он полон — отправка снова блокируется и ждёт, пока кто-то заберёт значение. Приём блокируется, только если буфер пуст.
Короткая формула: небуферизованный канал — про синхронизацию (передача = рукопожатие), буферизованный — про развязку отправителя и получателя по скорости.
Закрытие канала и range
Когда отправитель закончил слать данные, он может закрыть канал функцией close. Это сигнал получателю: «больше ничего не будет».
ch := make(chan int, 3)
go func() {
for i := 1; i <= 3; i++ {
ch <- i
}
close(ch) // закрываем, когда всё отправили
}()
for v := range ch { // читаем, пока канал не закрыт и не опустошён
fmt.Println(v) // 1, 2, 3
}
Цикл for ... range по каналу удобно читает значения одно за другим и сам завершается, когда канал закрыт и из него всё вычитано.
Несколько важных правил:
- Закрывает канал только отправитель, и только один раз. Повторный
close, отправка в закрытый канал и закрытиеnil-канала — это panic (аварийное падение). - Читать из закрытого канала можно: вернётся нулевое значение типа. Чтобы отличить реальное значение от «канал закрыт», используют двойную форму приёма:
v, ok := <-ch— здесьokравноfalse, если канал закрыт и пуст.
select: ждём несколько каналов
Часто горутина работает не с одним каналом, а с несколькими. Оператор select позволяет ждать сразу нескольких операций и среагировать на ту, что готова первой. Синтаксис похож на switch, но ветки — это операции с каналами:
select {
case msg := <-ch1:
fmt.Println("из ch1:", msg)
case msg := <-ch2:
fmt.Println("из ch2:", msg)
}
select блокируется, пока хотя бы одна из веток не сможет выполниться. Если готовы сразу несколько — выбирается случайная.
Частый приём — добавить таймаут через time.After, чтобы не ждать бесконечно:
live example
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(300 * time.Millisecond) // медленный ответ
ch <- "данные"
}()
select {
case msg := <-ch:
fmt.Println("получено:", msg)
case <-time.After(50 * time.Millisecond):
fmt.Println("не дождались за 50мс")
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
time.After(d) возвращает канал, в который один раз приходит событие через время d. Кто раньше - тот и выиграл: здесь медленная горутина не успевает, срабатывает таймаут. Так ограничивают ожидание ответа базы, внешнего API или пользователя.
Ветка default срабатывает, если ни один канал не готов прямо сейчас — это делает операцию неблокирующей.
WaitGroup: дождаться всех горутин
Часто нужно запустить несколько горутин и дождаться, пока все закончат, не придумывая каналы под каждую. Для этого в пакете sync есть WaitGroup — простой счётчик.
live example
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // +1 к счётчику перед запуском
go func(id int) {
defer wg.Done() // -1, когда горутина закончит
fmt.Println("задача", id, "готова")
}(i)
}
wg.Wait() // блокируется, пока счётчик не станет 0
fmt.Println("все готовы")
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Три шага: Add(n) увеличивает счётчик перед запуском горутины, Done() уменьшает его при завершении (надёжно делать это через defer), Wait() блокирует, пока счётчик не обнулится. Это и есть правильная замена time.Sleep из первого примера.
Обратите внимание: i передаётся в функцию как аргумент id. До Go 1.22 это было обязательно: переменная цикла была одна на все итерации, и все горутины видели её последнее значение. С Go 1.22 каждая итерация получает свою i, ловушки больше нет, но явный аргумент по-прежнему читается яснее. А с Go 1.25 есть форма короче: wg.Go(func() { ... }) сама делает Add(1) и Done().
Deadlock: когда все застряли
Раз отправка и приём блокируются, можно нечаянно получить ситуацию, где все горутины ждут друг друга и никто не двигается, — deadlock (взаимная блокировка). Go умеет ловить самый простой случай и падает с понятным сообщением:
func main() {
ch := make(chan int)
ch <- 1 // некому принять — main блокируется навсегда
}
// fatal error: all goroutines are asleep - deadlock!
Здесь отправка в небуферизованный канал ждёт получателя, но получателя нет — runtime это замечает и останавливает программу. Лечится тем, что приём делают в отдельной горутине, или используют буфер.
Уровень языка и продакшн
Всё выше — это конкурентность на уровне языка: как устроены горутины, каналы и select. В реальном сервере добавляются вещи посложнее: корректное завершение работающих горутин при остановке сервиса, отмена по context, ограничение числа одновременных задач, утечки горутин.
Эти темы — уже про backend, и они разобраны отдельно: см. конкурентность и graceful shutdown в Go. Здесь же важно усвоить базовые кирпичики, на которых всё это строится.
Глубже: общая память, гонки и мьютексрасширенное
Каналы — не единственный способ. Иногда горутинам нужна одна общая переменная: счётчик запросов, кэш, флаг. Наивный вариант ломается тихо:
count := 0
for i := 0; i < 1000; i++ {
go func() { count++ }()
}
count++ — это прочитать, прибавить, записать. Две горутины прочитали одно и то же значение, обе записали на единицу больше, один инкремент пропал. Итог будет случайным и разным от запуска к запуску. Это гонка данных, и Go умеет её находить: go run -race . или go test -race ./... печатает WARNING: DATA RACE с двумя стеками — где писали и где читали. Детектор замедляет программу в разы, поэтому его включают в тестах, а не в проде.
Лечат гонку мьютексом из пакета sync: участок кода между Lock и Unlock выполняет одна горутина за раз.
live example
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
count := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
count++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(count) // всегда 1000
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Мьютекс обычно кладут полем в структуру рядом с данными, которые он охраняет, и не копируют такую структуру по значению. Для одиночного счётчика достаточно sync/atomic (atomic.Int64 с методом Add) — без блокировки вовсе. Правило одно: к общей переменной либо ходят только через канал, либо только под замком; «иногда так, иногда сяк» и есть гонка.
Почему без замка ломается даже чтение, а не только count++? Go не обещает, что запись одной горутины вообще станет видна другой, пока их не связала синхронизация. Компилятор и процессор вправе переставлять и кешировать операции: горутина, которая в цикле ждёт for !done {}, может так и не увидеть done = true. Гарантии дают только конкретные пары, их называют отношением «происходит раньше» (happens before): отправка в канал происходит раньше, чем завершится соответствующее получение; Unlock — раньше следующего Lock; wg.Done — раньше, чем вернётся wg.Wait; операции sync/atomic упорядочены между собой. Всё, что горутина записала до такой точки, другая увидит после парной. Поэтому флаг остановки делают через канал, context или atomic.Bool, а не обычной переменной.
Глубже: канал в каждом состоянии и что у него внутрирасширенное
Вопрос «что будет, если …» про каналы задают чаще любого другого по Go, потому что ответы зависят от состояния канала, а их три: nil, открытый и закрытый. Таблица стоит того, чтобы её запомнить:
| Операция | nil-канал | Открытый | Закрытый |
|---|---|---|---|
Отправка ch <- v | блокируется навсегда | ждёт получателя или места в буфере | panic: send on closed channel |
Приём <-ch | блокируется навсегда | ждёт значения | остаток буфера, потом нулевое значение и ok == false сразу |
close(ch) | panic | закрывает | panic: close of closed channel |
Кроме состояния у канала есть направление в типе: chan<- int только для отправки, <-chan int только для приёма. Обычный chan int приводится к любому из них, и функции объявляют параметры направленным типом, чтобы компилятор не дал получателю закрыть канал или отправить в него.
Блокировка на nil-канале не ошибка, а приём: в select ветка с nil-каналом никогда не готова, и так выключают источник, который закончился, не ломая остальные ветки. Второе следствие таблицы: читать из закрытого канала безопасно и не блокирует, поэтому close работает как широковещательный сигнал «всё», который получат все ждущие сразу. Третье: если из одного канала читают несколько горутин, каждое значение достанется ровно одной из них, какой именно, не определено.
Внутри канал это структура hchan в куче: кольцевой буфер на cap значений с индексами записи и чтения, счётчик занятых мест, флаг закрытия, очереди ждущих отправителей и получателей и один мьютекс, под которым всё это меняется. Отправка в небуферизованный канал, когда получатель уже ждёт, копирует значение прямо в стек получателя, минуя буфер. Значения по каналу всегда копируются, поэтому отправлять большие структуры дорого, а указатели на них опасно: обе стороны получают доступ к одной памяти, и защиту от гонки канал больше не даёт.
live example
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
fmt.Println(len(ch), cap(ch)) // 2 2
v, ok := <-ch
fmt.Println(v, ok) // 1 true
<-ch
v, ok = <-ch
fmt.Println(v, ok) // 0 false: канал закрыт и пуст
var never chan int
select {
case <-never:
fmt.Println("не случится")
default:
fmt.Println("nil-канал не готов никогда")
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Коротко
- Горутина — лёгкая задача, запускается через
go func(...); их могут быть сотни тысяч, в отличие от потоков ОС. main— тоже горутина; когда она завершается, программа выходит, не дожидаясь остальных.- Канал (
chan) — типизированная труба для передачи данных между горутинами:ch <- vотправить,<-chпринять. - Направление в типе:
chan<- Tтолько отправка,<-chan Tтолько приём. Состояния канала:nilблокирует отправку и приём навсегда, закрытый отдаёт остаток и нулевое значение сok == false, отправка в закрытый и повторныйcloseэто panic. - Небуферизованный канал синхронизирует (отправка ждёт приёма); буферизованный (
make(chan T, n)) развязывает скорости. closeзакрывает канал (только отправитель);for rangeчитает до закрытия;v, ok := <-chпроверяет, закрыт ли канал.selectждёт несколько каналов сразу;time.Afterдаёт таймаут,default— неблокирующий вариант.sync.WaitGroup(Add/Done/Wait) ждёт завершения группы горутин — правильная заменаtime.Sleep.- Если все горутины заблокированы навстречу друг другу — deadlock; runtime ловит простые случаи и падает с ошибкой.
- Общая переменная без защиты — гонка данных;
-raceеё находит,sync.Mutex(илиatomic) чинит: либо канал, либо замок, но не голая переменная.
Что пощупать
Сто горутин-покупателей на десять единиц товара в практикуме remodov/marketplace-system-go: sync.WaitGroup дожидается всех, atomic.Int32 считает продажи, а база под FOR UPDATE не даёт продать лишнее.
Код: reserve_concurrency_test.go.
Сделаем сами
Ветка step-05-stock-and-locking.
Что почитать дальше
- Ошибки в Go — как горутины и функции сообщают об ошибках через значения
error. - Интерфейсы — как описывать поведение и абстрагироваться от конкретных типов.
- Указатели — что и как передаётся между горутинами по значению и по ссылке.