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

ОСНОВЫ ЯЗЫКА: GO Горутины и каналы

Обязательно

Горутины и каналы в Go: основы конкурентности

Go

Как запускать независимые задачи, передавать значения через каналы, ждать завершение и избегать гонок и взаимных блокировок.

1. go запускает функцию отдельной горутиной

1 / 5

Горутины дешевле потоков ОС: планировщик Go распределяет их по небольшому числу потоков, а стек начинается с нескольких килобайт.

  • go say() возвращается сразу, функция продолжается независимо.
  • Завершение main завершает программу, не дожидаясь остальных.
  • time.Sleep не гарантирует завершение работы: нужна синхронизация.
main go say() горутина say() return из main = конец процесса

2. Канал передаёт значение и синхронизирует

2 / 5

В небуферизованном канале отправитель и получатель встречаются «рука об руку». Буфер позволяет отправить несколько значений до блокировки.

  • ch <- 42 отправляет, value := <-ch принимает.
  • make(chan int) создаёт синхронный канал.
  • make(chan int, 2) добавляет буфер, но не отменяет блокировку при переполнении.
горутина A 42 горутина B Нет буфера: встреча обязательна 1 2 Буфер полон → следующая отправка ждёт

3. close завершает поток, select выбирает событие

3 / 5

Отправитель закрывает канал после последнего значения. range дочитывает буфер и завершается, а select ждёт первую готовую операцию.

  • v, ok := <-ch: ok == false означает закрытый и пустой канал.
  • Отправка в закрытый канал и повторный close вызывают panic.
  • time.After добавляет таймаут, default делает выбор неблокирующим.
selectодна готовая ветка <-ch1 <-ch2 time.After(50ms) close(ch) → range дочитал → цикл завершён

4. WaitGroup ждёт работу, deadlock означает тупик

4 / 5

WaitGroup считает незавершённые задачи. Add вызывают до запуска, Done откладывают внутри горутины, Wait ждёт нуля.

  • Каждому Add(1) нужен ровно один Done().
  • Если все горутины ждут друг друга, возникает deadlock.
  • Перед запуском продумывают, кто отправляет, кто принимает и кто закрывает канал.
wg.Add(3) wg.Wait() task 1 task 2 task 3 Done × 3счётчик = 0 Все ждут и никто не отправляет → deadlock

5. Общую память защищают, гонки измеряют

5 / 5

Каналы передают владение данными, но общий счётчик или кэш требуют синхронизации. Одновременный count++ без защиты теряет обновления.

  • sync.Mutex ограждает критическую секцию через Lock и Unlock.
  • go test -race ./... обнаруживает не синхронизированные обращения.
  • Закрытый канал отдаёт нулевые значения; nil-канал блокирует операции навсегда.
горутина A горутина B MutexLock → count++Unlock countодна секция go test -race ./...Не угадывать порядок, а установить его

Одна из причин, по которой 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: «не общайтесь через общую память — делите память через общение». Идея не новая: это модель 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, чтобы не ждать бесконечно:

живой пример

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мс")
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

time.After(d) возвращает канал, в который один раз приходит событие через время d. Кто раньше - тот и выиграл: здесь медленная горутина не успевает, срабатывает таймаут. Так ограничивают ожидание ответа базы, внешнего API или пользователя.

Ветка 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. До 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 выполняет одна горутина за раз.

живой пример

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
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Мьютекс обычно кладут полем в структуру рядом с данными, которые он охраняет, и не копируют такую структуру по значению. Для одиночного счётчика достаточно 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 значений с индексами записи и чтения, счётчик занятых мест, флаг закрытия, очереди ждущих отправителей и получателей и один мьютекс, под которым всё это меняется. Отправка в небуферизованный канал, когда получатель уже ждёт, копирует значение прямо в стек получателя, минуя буфер. Значения по каналу всегда копируются, поэтому отправлять большие структуры дорого, а указатели на них опасно: обе стороны получают доступ к одной памяти, и защиту от гонки канал больше не даёт.

живой пример

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-канал не готов никогда")
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Коротко

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