← назад к разделу

Одна из причин, по которой 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.
  • Интерфейсы — как описывать поведение и абстрагироваться от конкретных типов.
  • Указатели — что и как передаётся между горутинами по значению и по ссылке.