Одна из причин, по которой Go выбирают для серверов, — конкурентность встроена прямо в язык. Запустить параллельную задачу здесь так же просто, как вызвать функцию. Разберём, как это устроено на уровне языка: горутины, каналы и инструменты для их согласования.
Зачем нужна конкурентность
Программа часто ждёт: ответа от базы данных, файла с диска, другого сервиса по сети. Пока один кусок кода ждёт, процессор простаивает — а мог бы заниматься другой задачей. Конкурентность — это умение программы держать в работе несколько задач сразу, переключаясь между ними.
В большинстве языков параллельные задачи делают через потоки операционной системы (OS threads), и они дорогие: каждый поток занимает мегабайты памяти, а переключение между ними стоит времени. Поэтому тысячи потоков — это уже проблема.
Go решает это иначе. Здесь есть горутины — лёгкие задачи, которыми управляет не операционная система, а сам runtime Go. Горутина стартует с крошечного стека (несколько килобайт), и их спокойно бывают сотни тысяч в одной программе.
Короткая формула: горутина — это функция, которая выполняется одновременно с остальным кодом.
Горутины: go func
Чтобы запустить функцию в отдельной горутине, перед её вызовом ставят ключевое слово go:
package main
import (
"fmt"
"time"
)
func say(text string) {
fmt.Println(text)
}
func main() {
go say("привет из горутины") // запускается параллельно
say("привет из main") // обычный вызов
time.Sleep(100 * time.Millisecond) // костыль, чтобы дождаться, см. ниже
}
go say(...) не ждёт завершения функции — она запускается «в фоне», а выполнение main сразу идёт дальше. Главная тонкость: сама функция main тоже горутина (главная). Когда main завершается, программа выходит немедленно — и не ждёт остальные горутины.
Именно поэтому в примере стоит time.Sleep: без него main закончится раньше, чем горутина успеет что-то напечатать. Sleep здесь — плохой способ синхронизации (мы просто гадаем, сколько ждать). Дальше увидим правильные инструменты.
Горутиной можно запустить и анонимную функцию — это частый приём:
go func() {
fmt.Println("работаю в фоне")
}()
Каналы: обмен данными
Горутины работают параллельно, но им нужно как-то передавать данные и согласовывать действия. В Go для этого есть каналы (chan) — типизированные «трубы», по которым одна горутина отправляет значение, а другая принимает.
Девиз Go: «не общайтесь через общую память — делите память через общение». То есть вместо того чтобы несколько горутин лезли в одну переменную (и мешали друг другу), они передают данные по каналу.
Канал создают через 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или отправка в закрытый канал — это 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, чтобы не ждать бесконечно:
select {
case msg := <-ch:
fmt.Println("получено:", msg)
case <-time.After(time.Second):
fmt.Println("не дождались за секунду")
}
Ветка default срабатывает, если ни один канал не готов прямо сейчас — это делает операцию неблокирующей.
WaitGroup: дождаться всех горутин
Часто нужно запустить несколько горутин и дождаться, пока все закончат, не придумывая каналы под каждую. Для этого в пакете sync есть WaitGroup — простой счётчик.
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("все готовы")
}
Три шага: Add(n) увеличивает счётчик перед запуском горутины, Done() уменьшает его при завершении (надёжно делать это через defer), Wait() блокирует, пока счётчик не обнулится. Это и есть правильная замена time.Sleep из первого примера.
Обратите внимание: i передаётся в функцию как аргумент id. Это защищает от классической ловушки с захватом переменной цикла.
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. Здесь же важно усвоить базовые кирпичики, на которых всё это строится.
Коротко
- Горутина — лёгкая задача, запускается через
go func(...); их могут быть сотни тысяч, в отличие от потоков ОС. main— тоже горутина; когда она завершается, программа выходит, не дожидаясь остальных.- Канал (
chan) — типизированная труба для передачи данных между горутинами:ch <- vотправить,<-chпринять. - Небуферизованный канал синхронизирует (отправка ждёт приёма); буферизованный (
make(chan T, n)) развязывает скорости. closeзакрывает канал (только отправитель);for rangeчитает до закрытия;v, ok := <-chпроверяет, закрыт ли канал.selectждёт несколько каналов сразу;time.Afterдаёт таймаут,default— неблокирующий вариант.sync.WaitGroup(Add/Done/Wait) ждёт завершения группы горутин — правильная заменаtime.Sleep.- Если все горутины заблокированы навстречу друг другу — deadlock; runtime ловит простые случаи и падает с ошибкой.
Что почитать дальше
- Ошибки в Go — как горутины и функции сообщают об ошибках через значения
error. - Интерфейсы — как описывать поведение и абстрагироваться от конкретных типов.
- Указатели — что и как передаётся между горутинами по значению и по ссылке.