---
title: "Горутины и каналы: первое знакомство"
description: "До сих пор наши программы делали одно дело за раз: строчка за строчкой, сверху вниз. Но реальные программы часто должны заниматься несколькими вещами как бы одновременно …"
canonical: https://graphlms.ru/go-basics/book/goroutines-intro
course: "Основы Go"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# Горутины и каналы: первое знакомство

> Источник: https://graphlms.ru/go-basics/book/goroutines-intro — курс «Основы Go», GraphLMS.

## О чём глава

До сих пор наши программы делали одно дело за раз: строчка за строчкой, сверху вниз. Но реальные программы часто должны заниматься несколькими вещами как бы одновременно — обслуживать запрос, пока качается файл; считать в фоне, пока ждёшь ответа. В Go для этого есть две идеи, которые работают в паре: **горутины** (дешёвые независимые «ниточки» выполнения) и **каналы** (способ безопасно передавать данные между ними).

Это вводная глава перед курсом «Конкурентность Go». В тонкости не углубляемся. Цель — три вещи: запустить горутину легко; каналы нужны, чтобы передавать данные и дожидаться друг друга; горутины нужно дождаться, иначе программа закончится раньше, чем они успеют сработать.

## Что такое горутина

Горутина — это функция, которую Go выполняет «сбоку», не дожидаясь её завершения. Запускается она одним словом — `go` перед вызовом:

```go
go doWork()      // запустили и сразу пошли дальше
go send(msg, to) // аргументы вычисляются сразу, а сама функция — отдельно
```

Главная интуиция: `go f()` не означает «сделай f() и верни результат». Это означает «начни делать f() где-то рядом, а я тем временем продолжу со следующей строки». Результат вызова `go` мы не получаем — горутина живёт сама по себе.

Почему это удобно? Горутины дешёвые. Поток операционной системы занимает заметный объём памяти, и держать их десятки тысяч накладно. Горутина стартует с маленьким стеком в несколько килобайт, а среда выполнения Go сама раскладывает множество горутин по небольшому числу потоков. Поэтому сервер на Go спокойно держит по горутине на каждый входящий запрос, даже когда их десятки тысяч.

Но у дешевизны есть оборотная сторона, на которой часто спотыкаются новички. Смотрите:

```go
func main() {
    go fmt.Println("привет из горутины")
    fmt.Println("привет из main")
}
```

Очень вероятно, что напечатается только «привет из main». Почему? Когда `main` доходит до конца, программа завершается целиком — и Go не ждёт, пока фоновые горутины доделают свои дела. Горутина просто не успела дойти до печати. Запомните это как главное правило главы: **горутины нужно дождаться**. Чем именно — разберём дальше.

## Ждём горутины через WaitGroup

Самый прямой способ сказать «подожди, пока эти горутины закончат» — это `sync.WaitGroup`. Идея как у счётчика дел: добавили N дел, каждая горутина по завершении отмечает «-1», а главная функция ждёт, пока счётчик не обнулится.

Три метода и всё:

- `wg.Add(n)` — «появилось n дел», вызываем **до** запуска горутин.
- `wg.Done()` — «одно дело сделано», вызываем внутри горутины (удобно через `defer`).
- `wg.Wait()` — «жду, пока все дела не закончатся», блокирует, пока счётчик не дойдёт до нуля.

```go play
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			fmt.Println("работник", id, "закончил")
		}(i)
	}

	wg.Wait()
	fmt.Println("все работники готовы")
}
```

Один из возможных выводов:

```text
работник 3 закончил
работник 1 закончил
работник 2 закончил
все работники готовы
```

Порядок строк «работник 1/2/3» между собой не гарантирован: горутины независимы, и при каждом запуске он может быть другим. А вот «все работники готовы» всегда будет последней: `wg.Wait()` не пропустит `main` дальше, пока счётчик не обнулится.

Обратите внимание на `func(id int){...}(i)`: мы передаём `i` аргументом. Так каждая горутина получает собственную копию числа. Начиная с Go 1.22 переменная цикла и так создаётся заново на каждой итерации, и можно было бы написать просто `fmt.Println("работник", i, ...)` внутри замыкания. Но в старом коде вы часто встретите передачу аргументом, а явная копия работает в любой версии.

В Go 1.25 у `WaitGroup` появился метод `wg.Go(f)`: он сам делает `Add(1)`, запускает `f` в горутине и вызывает `Done()` после неё. Тот же пример с ним короче:

```go play
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Go(func() {
			fmt.Println("работник", i, "закончил")
		})
	}

	wg.Wait()
	fmt.Println("все работники готовы")
}
```

Порядок строк «работник» и здесь не гарантирован. Классическую тройку `Add`/`Done`/`Wait` вы всё равно встретите в учебниках и чужом коде, поэтому в курсе дальше используется она.

## Каналы: как горутины общаются

WaitGroup отвечает на вопрос «закончились ли горутины?». Но часто нам нужно не просто дождаться, а **получить результат** работы. Для этого есть каналы.

Канал — это типизированная труба, в которую одна горутина кладёт значение, а другая его забирает. Создаётся через `make`, тип значений указывается явно:

```go
ch := make(chan int)   // канал для int
ch <- 42               // отправить: значение «уходит» в канал
got := <-ch            // принять: значение «приходит» из канала
```

Стрелка `<-` всегда показывает, куда «течёт» значение: `ch <- x` кладёт в канал, `<-ch` достаёт. Маленькая мнемоника — стрелка указывает в сторону движения данных.

Самое важное про обычный (небуферизованный) канал: отправка и приём **встречаются**. Горутина, которая отправляет в канал, замирает и ждёт, пока кто-то на другом конце не заберёт значение. И наоборот: приём ждёт, пока кто-то не отправит. Поэтому такой канал не только передаёт данные, но и **синхронизирует**: после приёма получатель точно знает, что отправитель дошёл до строки с отправкой.

Вот передача одного значения из горутины в `main`:

```go play
package main

import "fmt"

func main() {
	ch := make(chan string)

	go func() {
		ch <- "посылка доставлена"
	}()

	got := <-ch
	fmt.Println("получили:", got)
}
```

Вывод: `получили: посылка доставлена`. Здесь `WaitGroup` не нужен: строка `got := <-ch` сама заставляет `main` ждать, пока горутина не отправит значение. Приём из канала — это и передача данных, и ожидание. Когда горутина должна вернуть результат, канал обычно проще, чем `WaitGroup` плюс общая переменная.

Разберём чуть более «рабочий» пример: горутина считает сумму и присылает её обратно.

```go play
package main

import "fmt"

func sum(nums []int, out chan<- int) {
	total := 0
	for _, n := range nums {
		total += n
	}
	out <- total
}

func main() {
	out := make(chan int)
	go sum([]int{1, 2, 3, 4, 5}, out)

	result := <-out
	fmt.Println("сумма:", result)
}
```

Вывод: `сумма: 15`. Заметили `chan<- int` в сигнатуре `sum`? Это канал, в который функции разрешено только **отправлять**. Так мы прямо в типе фиксируем намерение: функция-счётчик отдаёт результат, но не читает из канала. Компилятор за этим проследит, а читателю кода сразу понятна роль функции. Бывает и зеркальный вариант `<-chan int` — «только для приёма».

## Крошечный select

Бывает, что горутина ждёт сразу от нескольких каналов — кто первый пришлёт, того и обрабатываем. Для этого есть `select`. Он похож на `switch`, но ветки — это операции с каналами, и срабатывает та, что готова первой:

```go play
package main

import "fmt"

func main() {
	a := make(chan string, 1)
	b := make(chan string, 1)

	a <- "из канала A"

	select {
	case msg := <-a:
		fmt.Println("пришло:", msg)
	case msg := <-b:
		fmt.Println("пришло:", msg)
	}
}
```

Здесь каналы **буферизованные** — `make(chan string, 1)` создаёт канал с буфером на одно значение. В буферизованный канал можно положить значение и не ждать получателя сразу (пока в буфере есть место). Поэтому `a <- "из канала A"` в `main` не блокируется: значение легло в буфер. Дальше `select` смотрит: канал `a` готов отдать значение, `b` пуст — выбирается первая ветка. Вывод детерминирован: «пришло: из канала A».

Когда готовы сразу несколько веток, `select` выбирает одну равновероятно и псевдослучайно. Это не задаёт строгого приоритета и не гарантирует отсутствия голодания. Если же не готова ни одна и нет ветки `default`, `select` блокируется и ждёт. В этом маленьком примере мы намеренно подготовили только один канал, чтобы результат был предсказуем.

## Почему гонки здесь не запускаются

Примеры выше выполняются в браузере. Горутины там работают, но **по-настоящему параллельно не выполняются**: всё идёт в одном потоке, без нескольких ядер процессора и без детектора гонок.

Поэтому код, который демонстрирует именно гонку данных (когда две горутины без синхронизации лезут к одной переменной), мы показываем статично, без кнопки «запустить»:

```go
// Так делать НЕ нужно: две горутины пишут в один счётчик без синхронизации.
// Это демонстрация гонки, а не пример для запуска — кнопки тут нет специально.
package main

import (
	"fmt"
	"sync"
)

func main() {
	var counter int
	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
}
```

Это *гонка данных* (data race): две горутины одновременно обращаются к одной переменной, и хотя бы одна из них пишет. `counter++` — это три шага: прочитать, прибавить, записать. Две горутины могут прочитать одно и то же 41, обе запишут 42, и одно увеличение потеряется. На настоящей машине программа будет печатать то 1000, то 958, то 991. Запуск через `go run -race main.go` покажет предупреждение `WARNING: DATA RACE` с указанием строк. Правильно — защищать общие данные (`sync.Mutex`, `sync/atomic`) или вообще не делить переменную, а передавать данные через каналы. Подробно это разбирается в продвинутом курсе.

## Частые ошибки

- **`main` заканчивается раньше горутин.** Без `WaitGroup` или приёма из канала фоновые горутины могут не успеть ничего напечатать. Всегда явно дожидайтесь.
- **Забыли `wg.Done()` (или забыли `Add`).** Если `Done` не вызовется — `Wait()` зависнет навсегда (deadlock). Надёжный приём: `defer wg.Done()` первой строкой горутины.
- **Чтение из канала, в который никто не пишет.** Приём из небуферизованного канала блокируется до отправки. Если отправителя нет — горутина повиснет. Следите, чтобы у каждой отправки был получатель и наоборот.
- **Захват общей переменной цикла в горутине.** Для переменных, объявленных циклом через `:=`, версия языка Go 1.22 создаёт отдельную переменную на итерацию; при `=` переменная остаётся общей. Передавайте значение аргументом — `go func(id int){...}(i)` — чтобы каждая горутина работала со своей копией, а не с общей меняющейся переменной.

## Попробуйте сами

Напишите `sumAsync(nums []int) <-chan int`: функция создаёт канал, запускает одну горутину, считает сумму и передаёт результат. В `main` вызовите её для `[]int{2, 4, 6}`, примите число и напечатайте его. Затем проверьте пустой срез. Не используйте `time.Sleep`.

### Проверка

Для первого входа результат `12`, для пустого — `0`:

```go
func sumAsync(nums []int) <-chan int {
    out := make(chan int)
    go func() {
        total := 0
        for _, n := range nums {
            total += n
        }
        out <- total
    }()
    return out
}
```

В `main` достаточно `fmt.Println(<-sumAsync([]int{2, 4, 6}))`. Для пустого среза цикл не выполнится ни разу, и горутина отправит `0`. Канал передаёт ровно одно значение, поэтому закрывать его не нужно.

Обратите внимание: приём здесь обязателен. Если `main` не прочитает из канала, горутина навсегда застрянет на строке `out <- total`. Такая «застрявшая» горутина называется утечкой, и ей посвящена одна из первых задач курса по конкурентности. И ещё: пока горутина считает, вызывающий код не должен менять элементы `nums` — иначе получится гонка данных из раздела выше.

## Готовы ли вы идти дальше

Проверьте себя без подсказок: напишите функцию с несколькими результатами и ошибкой, пройдите по срезу и соберите карту, измените поле структуры через указатель, проверьте `error` через `errors.Is`, напишите табличный тест. Если что-то из этого не получается, вернитесь к соответствующей главе: в курсе по конкурентности всё это используется без объяснений. Особенно часто там встречаются замыкания, указатели и общий массив среза; дженерики и встраивание можно освежить позже.

Порядок в курсе [«Конкурентность Go»](/go/book/goroutines) такой. Сначала главы о горутинах, каналах и `select`; устройство планировщика (G-M-P) можно пропустить при первом чтении и вернуться к нему потом. Из задач начните с 02, затем 03 и 04: они разогревают на утечках горутин, таймаутах и `select`. Задача 01 требует рекурсивной сборки каналов, её лучше решать после них.

Задачи там проверяются тестами с флагом `-race`. Он ловит гонки только на тех путях, которые тест реально выполнил, поэтому сначала добейтесь правильного результата, а потом смотрите на гонки.

## Что дальше

В курсе по конкурентности вас ждут закрытие каналов и `range` по каналу, ёмкость буфера, паттерны вроде fan-in и fan-out, отмена через `context`, мьютексы и атомики:

- [Горутины: подробно](/go/book/goroutines) — модель выполнения, планировщик, жизненный цикл.
- [Каналы: подробно](/go/book/channels) — буферизация, закрытие, `range`, паттерны и `select` всерьёз.
