Компьютеры
Today

Goroutines в Go: лёгкая конкурентность без лишней драмы

В Go есть слово, после которого у многих загораются глаза: goroutine.

Звучит почти как заклинание. Поставил go перед вызовом функции — и код побежал параллельно. Красота. Минимум синтаксиса, максимум ощущения, что ты теперь управляешь маленькой армией рабочих процессов.

Но вот в чём подвох: goroutines действительно простые в запуске, но не всегда простые в контроле.

Это как завести котёнка. Взять легко. А потом он уже сидит в коробке с проводами, уносит носок и внезапно требует архитектурных решений 😸

Goroutine — это лёгкая единица выполнения в Go. Она похожа на поток, но управляется рантаймом Go, а не напрямую операционной системой.

Главная идея такая: ты можешь запускать много независимых задач, не создавая вручную потоки, не работая напрямую с pthreads и не превращая код в ритуальный танец вокруг callback-ов.

Пишешь:

go doSomething()

И функция doSomething начинает выполняться конкурентно с остальным кодом.

Коротко. Удобно. Даже слишком соблазнительно.

Конкурентность — не всегда параллельность

С goroutines часто начинается путаница. Кажется, если мы написали go, значит всё теперь обязательно выполняется прямо одновременно на разных ядрах процессора.

Не совсем.

Конкурентность — это когда программа умеет заниматься несколькими задачами в один промежуток времени. Параллельность — когда задачи реально выполняются одновременно, например на разных CPU cores.

Разница тонкая, но полезная.

Представь повара на кухне. Он поставил суп вариться, пока нарезает овощи, потом проверил духовку, потом вернулся к соусу. Он не делает всё физически одновременно, но эффективно переключается между задачами. Это конкурентность.

А если на кухне три повара и каждый реально делает свою работу в один и тот же момент — это уже параллельность.

Go умеет и так, и так. Goroutines дают модель конкурентного выполнения, а рантайм уже решает, как распределить их по системным потокам и ядрам.

Синтаксис go f() не гарантирует, что функция будет выполняться “прямо сейчас на отдельном ядре”. Он говорит рантайму: “выполни это конкурентно, когда сможешь”.

Обычно этого достаточно. И это одна из причин, почему Go так приятно использовать для сетевых сервисов, воркеров, API, брокеров сообщений и всего, где много ожидания: запросы, база, сеть, диск, таймауты, очереди.

Самый простой пример

Начнём с маленького кода:

package main

import (
	"fmt"
	"time"
)

func sayHello() {
	fmt.Println("Привет из goroutine")
}

func main() {
	go sayHello()

	time.Sleep(100 * time.Millisecond)
	fmt.Println("Привет из main")
}

Тут sayHello запускается в отдельной goroutine, а main продолжает выполнение. Мы добавили time.Sleep, чтобы программа не завершилась сразу.

И вот это первый важный момент.

Goroutine не удерживает программу живой. Если main завершился, процесс заканчивается вместе со всеми goroutines. Даже если они не договорили, не дописали файл и не успели мяукнуть.

Поэтому такой код может ничего не вывести:

package main

import "fmt"

func main() {
	go fmt.Println("Меня могут не дождаться")
}

Программа стартует goroutine и сразу завершает main. Рантайм не обязан ждать.

Запустить goroutine — легко. Дождаться её — отдельная задача.

WaitGroup: когда надо дождаться всех

Для ожидания набора goroutines часто используют sync.WaitGroup.

package main

import (
	"fmt"
	"sync"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()

	fmt.Println("Worker", id, "закончил работу")
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}

	wg.Wait()
	fmt.Println("Все worker-ы завершились")
}

WaitGroup работает довольно просто: мы увеличиваем счётчик через Add, каждая goroutine вызывает Done, а Wait блокирует выполнение, пока счётчик не станет нулём.

На практике defer wg.Done() почти всегда ставят в начале функции. Это защищает от ранних return, ошибок и других поворотов сюжета.

WaitGroup отвечает только на вопрос: “все закончили?” Он не собирает ошибки, не отменяет задачи и не передаёт результаты.

Это важное ограничение. Новички иногда пытаются превратить WaitGroup в универсальный менеджер всего. Но он не для этого. Он просто ждёт.

Как кот у двери. Молча. Упрямо. Без обработки ошибок.

Ошибки из goroutines сами не возвращаются

Вот неприятный момент: если функция запускается как goroutine, ты не можешь просто вернуть из неё ошибку в вызывающий код.

Так не получится:

func loadData() error {
	return nil
}

func main() {
	go loadData() // error потерялся
}

Функция вернула error, но никто его не забрал. Мы запустили её отдельно и пошли дальше.

Если нужно собрать ошибки, можно использовать канал:

package main

import (
	"errors"
	"fmt"
)

func loadData() error {
	return errors.New("не удалось загрузить данные")
}

func main() {
	errCh := make(chan error, 1)

	go func() {
		errCh <- loadData()
	}()

	if err := <-errCh; err != nil {
		fmt.Println("Ошибка:", err)
	}
}

Здесь goroutine отправляет ошибку в канал, а main её читает. Для одной задачи это нормально. Для нескольких задач код уже начинает разрастаться, и тогда часто используют errgroup.

package main

import (
	"context"
	"fmt"

	"golang.org/x/sync/errgroup"
)

func main() {
	g, ctx := errgroup.WithContext(context.Background())

	g.Go(func() error {
		_ = ctx
		return nil
	})

	g.Go(func() error {
		return fmt.Errorf("что-то пошло не так")
	})

	if err := g.Wait(); err != nil {
		fmt.Println("Ошибка:", err)
	}
}

errgroup удобен тем, что похож на WaitGroup, но умеет возвращать ошибку. А версия с context помогает отменять связанные операции.

Для продакшен-кода errgroup часто приятнее обычного WaitGroup, если у задач есть ошибки.

Каналы: как goroutines разговаривают

Goroutines сами по себе — это только выполнение. Но им почти всегда нужно как-то обмениваться данными. Для этого в Go есть channels.

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

package main

import "fmt"

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

	go func() {
		ch <- "привет из goroutine"
	}()

	message := <-ch
	fmt.Println(message)
}

По умолчанию канал без буфера блокирует отправителя, пока кто-то не прочитает значение. И наоборот: чтение блокируется, пока кто-то не отправит значение.

Это удобно, потому что канал одновременно передаёт данные и синхронизирует goroutines.

Но блокировки — это и сила, и источник боли.

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

	ch <- "никто не читает"
}

Такой код зависнет с deadlock, потому что мы пытаемся отправить значение в канал, но никто его не читает.

Go честно скажет что-то вроде:

fatal error: all goroutines are asleep - deadlock!

И это хороший момент для философии.

Каналы не делают конкурентный код автоматически правильным. Они просто дают аккуратный способ общаться. Думать всё равно придётся тебе.

Buffered channels: маленький склад между goroutines

Канал может быть буферизированным:

ch := make(chan string, 2)

ch <- "one"
ch <- "two"

Такой канал может принять два значения без немедленного читателя. Буфер работает как небольшой склад. Пока склад не заполнен, отправитель не блокируется. Когда заполнен — ждёт.

Это полезно для очередей задач, worker pool и ситуаций, где производитель может быть чуть быстрее потребителя.

Но буфер не должен становиться способом “замести проблему под ковёр”.

Если ты ставишь огромный буфер, потому что иначе всё зависает, возможно, у тебя не решена главная проблема: кто производит данные, кто потребляет, с какой скоростью и что делать при перегрузке.

Буфер — это инструмент. Не мусорный бак.

Worker pool: классика жанра

Один из самых частых сценариев для goroutines — worker pool. У нас есть много задач и ограниченное количество workers, которые их выполняют.

Например, нужно обработать 1000 URL, но мы не хотим запускать 1000 одновременных запросов. Серверы обидятся. Сеть задымится. Кот посмотрит с осуждением.

package main

import (
	"fmt"
	"sync"
)

func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
	defer wg.Done()

	for job := range jobs {
		fmt.Printf("worker %d обрабатывает задачу %d\n", id, job)
	}
}

func main() {
	const workersCount = 3
	const jobsCount = 10

	jobs := make(chan int)

	var wg sync.WaitGroup

	for i := 1; i <= workersCount; i++ {
		wg.Add(1)
		go worker(i, jobs, &wg)
	}

	for j := 1; j <= jobsCount; j++ {
		jobs <- j
	}

	close(jobs)
	wg.Wait()

	fmt.Println("Все задачи обработаны")
}

Здесь jobs — канал задач. Несколько workers читают из него, пока канал не закрыт. Когда задач больше нет, мы вызываем close(jobs), цикл for job := range jobs заканчивается, workers завершаются, WaitGroup их дожидается.

Это очень go-шный паттерн.

Не надо запускать goroutine на каждую мелочь без контроля. Иногда лучше ограничить параллелизм и спокойно обрабатывать задачи через pool.

Закрытие каналов: кто должен закрывать?

Есть простое правило, которое спасает много нервов:

Канал обычно закрывает тот, кто в него пишет.

Читатель не должен закрывать канал, потому что он не знает, будут ли ещё отправители. Если читатель закроет канал, а отправитель попробует записать туда значение, программа упадёт с panic.

close(ch)
ch <- "boom" // panic: send on closed channel

Закрытие канала не нужно для каждой коммуникации. Канал закрывают, когда надо сказать: “значений больше не будет”.

Например, в worker pool мы закрываем jobs, потому что main отправил все задачи и сообщает workers: можно заканчивать.

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

Не надо закрывать каналы “для красоты”.

Это не дверь в квартире. Не каждый раз надо щёлкать замком.

select: когда ждёшь несколько событий

select позволяет goroutine ждать несколько операций с каналами.

select {
case msg := <-messages:
	fmt.Println("сообщение:", msg)
case err := <-errors:
	fmt.Println("ошибка:", err)
}

Сработает тот case, который готов первым. Если готовы несколько — Go выберет один случайно.

Частый сценарий — ждать данные или отмену через context:

package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context, jobs <-chan int) {
	for {
		select {
		case job := <-jobs:
			fmt.Println("обрабатываю", job)

		case <-ctx.Done():
			fmt.Println("worker остановлен")
			return
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	jobs := make(chan int)

	go worker(ctx, jobs)

	jobs <- 1
	cancel()

	time.Sleep(100 * time.Millisecond)
}

Здесь worker слушает и канал задач, и сигнал отмены. Когда вызывается cancel(), канал ctx.Done() закрывается, worker выходит.

Context — это нормальный способ сказать goroutine: “пора заканчивать”.

Не надо оставлять goroutines жить вечно, если работа уже не нужна. Иначе получишь goroutine leak.

Goroutine leak: когда кто-то остался жить в стене

Goroutine leak — это ситуация, когда goroutine больше не нужна, но продолжает висеть. Она может ждать чтения из канала, записи в канал, ответа сети или события, которое уже никогда не случится.

Одна такая goroutine — мелочь. Тысячи таких — уже проблема.

Пример:

package main

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

	go func() {
		ch <- "result"
	}()

	// Никто не читает из ch.
}

Goroutine зависнет на отправке, потому что получателя нет. В этой маленькой программе процесс быстро завершится, но в сервере такая ошибка может копиться часами.

В реальном API это выглядит так: пришёл HTTP-запрос, ты запустил goroutine, клиент отключился, результат уже никому не нужен, но goroutine продолжает работать. Потом таких запросов становится много. Память растёт. Метрики хмурятся.

А ты сидишь и думаешь: “Ну я же просто добавил go”.

Вот именно.

Каждая goroutine должна иметь понятный жизненный цикл: кто её запустил, что она делает, как она завершается и что происходит при отмене.

Это звучит скучно, но это один из главных признаков взрослого Go-кода.

Mutex: когда каналы не нужны

В Go любят повторять: “Do not communicate by sharing memory; instead, share memory by communicating”.

Фраза красивая. Почти как наклейка на ноутбук.

Но это не значит, что sync.Mutex запрещён. Иногда mutex проще, понятнее и честнее, чем канал, через который мы имитируем доступ к map.

Например, есть счётчик:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var mu sync.Mutex
	counter := 0

	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)

		go func() {
			defer wg.Done()

			mu.Lock()
			counter++
			mu.Unlock()
		}()
	}

	wg.Wait()
	fmt.Println(counter)
}

Без Mutex тут будет race condition: несколько goroutines одновременно читают и изменяют counter. Результат может быть неправильным.

Можно использовать atomic, можно перестроить код через каналы, но для простого защищённого состояния mutex часто нормален.

Каналы хороши для передачи владения и событий. Mutex хорош для защиты общего состояния.

Не надо воевать с инструментами. Лучше выбирать тот, который делает код проще.

Race condition и go test -race

Race condition — это когда несколько goroutines одновременно обращаются к одним данным, и хотя бы одна из них пишет.

Классика:

counter++

Эта строка выглядит атомарной, но внутри это несколько операций: прочитать значение, увеличить, записать обратно. Если две goroutines делают это одновременно, результат может потеряться.

Go даёт отличный инструмент:

go test -race ./...

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

Я бы запускал -race регулярно. Особенно перед релизами, после изменений в конкурентном коде и перед тем, как сказать “да там всё очевидно”.

Потому что именно после этой фразы обычно приходит баг.

И садится рядом.

Как кот.

Panic внутри goroutine

Если внутри goroutine случится panic и её никто не восстановит через recover, упадёт весь процесс.

Не только эта goroutine.

Весь сервис.

go func() {
	panic("ой")
}()

Для фоновых задач это особенно неприятно. Ты думал, что изолировал работу, а она взяла и уронила приложение.

Поэтому в worker-ах и фоновых goroutines часто ставят защиту:

go func() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("panic recovered:", r)
		}
	}()

	doWork()
}()

Но тут нужно не впасть в другую крайность. Просто проглотить panic и сделать вид, что ничего не случилось — плохая идея. Минимум надо залогировать ошибку, сохранить контекст и понять, можно ли продолжать работу.

recover — это не магическая таблетка. Это аварийный тормоз.

Если worker упал из-за повреждённых данных, можно обработать одну задачу как ошибочную. Если panic говорит о поломанной инварианте приложения, может быть лучше упасть и перезапуститься, чем продолжать в неизвестном состоянии.

Жизненный пример: фоновая отправка уведомлений

Допустим, у нас есть API, где пользователь создаёт заказ. После создания нужно отправить уведомление: письмо, Telegram-сообщение, webhook или что-то ещё.

Новичковый соблазн:

go sendNotification(orderID)

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

А потом начинаются вопросы.

Что если отправка упала? Что если приложение перезапустилось? Что если уведомлений стало слишком много? Что если внешний сервис тормозит? Что если goroutine зависла? Что если нужно повторить отправку?

Для маленького внутреннего сервиса такой подход может быть терпимым. Но для важной бизнес-логики лучше использовать очередь: RabbitMQ, NATS, Kafka, Redis Streams, что у тебя принято в проекте. API кладёт задачу в очередь, worker забирает и выполняет, ошибки логируются, retry контролируется.

Goroutine — не замена очереди.

Она хороша для конкурентного выполнения внутри процесса. Но если задача должна пережить рестарт приложения, иметь retry, аудит и нормальную доставку, нужна внешняя система.

Это как записка на стикере и задача в трекере. Иногда стикера хватает. Но зарплату по стикерам лучше не считать.

Жизненный пример: параллельные запросы к сервисам

Другой сценарий, где goroutines прямо сияют, — параллельные запросы.

Представь API gateway, который собирает данные из нескольких сервисов: профиль пользователя, настройки, подписки, последние события. Если делать запросы последовательно, общее время будет складываться.

А если запустить их параллельно, можно дождаться всех и вернуть ответ быстрее.

package main

import (
	"context"
	"fmt"

	"golang.org/x/sync/errgroup"
)

func loadProfile(ctx context.Context) error {
	return nil
}

func loadSettings(ctx context.Context) error {
	return nil
}

func loadEvents(ctx context.Context) error {
	return nil
}

func main() {
	ctx := context.Background()
	g, ctx := errgroup.WithContext(ctx)

	g.Go(func() error {
		return loadProfile(ctx)
	})

	g.Go(func() error {
		return loadSettings(ctx)
	})

	g.Go(func() error {
		return loadEvents(ctx)
	})

	if err := g.Wait(); err != nil {
		fmt.Println("не удалось собрать данные:", err)
		return
	}

	fmt.Println("данные собраны")
}

Вот здесь goroutines выглядят очень естественно. Есть несколько независимых операций, их можно выполнить одновременно, ошибки собрать через errgroup, отмену прокинуть через context.

Аккуратно. Читабельно. Без ощущения, что мы строим реактор в подвале.

Сколько goroutines можно запускать?

Goroutines лёгкие, но не бесплатные.

Да, они дешевле системных потоков. Да, их можно запускать тысячами. Но каждая goroutine всё равно требует памяти, планирования, стека, контекста выполнения и внимания рантайма.

Если ты запускаешь goroutine на каждый входящий запрос — это нормально, Go HTTP server и так работает примерно в этом духе. Если запускаешь goroutine на каждую маленькую подзадачу внутри каждого запроса без ограничений — уже надо подумать.

Особенно опасны ситуации вида:

for _, item := range hugeList {
	go process(item)
}

Если hugeList содержит миллион элементов, ты только что запустил миллион goroutines. Может быть, рантайм и выдержит какое-то время. Но вопрос не в героизме рантайма. Вопрос в том, зачем ты это сделал.

Часто лучше использовать worker pool, semaphore или errgroup с ограничением параллелизма.

g.SetLimit(10)

У errgroup.Group есть метод SetLimit, который помогает ограничить число одновременно выполняющихся задач. Это сильно лучше, чем случайно устроить DDoS самому себе.

Параллелизм без лимитов — это не скорость. Это азартная игра.

Порядок выполнения не гарантирован

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

for i := 1; i <= 3; i++ {
	go fmt.Println(i)
}

Можно увидеть 1 2 3, можно 2 1 3, можно вообще ничего, если main завершится слишком рано.

Планировщик Go не обязан подстраиваться под твоё ощущение красоты. Он делает свою работу.

Если порядок важен — проектируй порядок явно: собирай результаты, сортируй, используй индексы, синхронизацию или отдельный aggregator.

Goroutines дают свободу выполнения. А свобода без договорённостей быстро превращается в хаос.

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

Самая частая ошибка — запускать goroutine и забывать, кто её остановит. Вторая — писать в общий map из нескольких goroutines без защиты. Третья — использовать канал там, где обычный mutex был бы проще. Четвёртая — считать, что go func() автоматически делает код быстрее.

Не делает.

Если задача CPU-bound, параллелизм может помочь, но только если есть свободные ядра и работа действительно делится. Если задача упирается в базу, сеть или внешний API, goroutines помогут скрыть ожидание, но не отменят лимиты этих систем.

Ещё одна классика — захват переменной цикла. В новых версиях Go с этим стало лучше для range, но сам принцип всё равно полезно помнить: внимательно смотри, какие переменные захватывает goroutine и когда она реально начнёт выполняться.

for i := 0; i < 3; i++ {
	i := i

	go func() {
		fmt.Println(i)
	}()
}

Такой явный i := i раньше часто использовали, чтобы каждая goroutine получила своё значение. Даже если язык постепенно закрывает старые ловушки, привычка думать о захвате переменных всё равно полезна.

Как я бы подходил к goroutines в проекте

Я бы не начинал с вопроса “куда бы добавить goroutine?”. Это неправильный вопрос. Он похож на “куда бы добавить микросервис?” — звучит опасно уже на старте.

Лучше спросить иначе:

Есть ли тут независимая работа, которую можно выполнять конкурентно?
Как я дождусь результата?
Как я получу ошибку?
Как я отменю выполнение?
Что будет при panic?
Есть ли лимит параллелизма?
Что произойдёт при остановке сервиса?

Если на эти вопросы есть ответы, goroutines обычно ложатся в код красиво. Если ответов нет, go перед функцией только откладывает проблему на потом.

А потом — пятница вечер, продакшен, график памяти растёт, кот смотрит на тебя так, будто всё знал заранее.

Практичный минимум

Для нормального Go-кода с goroutines я бы держал в голове такой набор правил:

  • каждая goroutine должна завершаться;
  • ошибки надо явно собирать;
  • для отмены используй context;
  • общий state защищай через Mutex, atomic или владение через канал;
  • не запускай бесконечное количество goroutines без лимита;
  • не используй goroutine вместо очереди задач;
  • проверяй конкурентный код через go test -race;
  • не глотай panic молча;
  • закрывай каналы только там, где это действительно нужно;
  • выбирай простой инструмент, а не самый “идеологически чистый”.

Это не догмы. Скорее страховочная сетка. Она не мешает писать быстро, но помогает не упасть лицом в баг, который воспроизводится раз в три дня под нагрузкой.

Итог

Goroutines — одна из самых приятных частей Go. Они делают конкурентное программирование доступным без тяжёлой церемонии. Запустить задачу параллельно легко, организовать worker pool удобно, собрать несколько запросов одновременно приятно, написать сетевой сервис — вообще красота.

Но простота запуска не отменяет ответственности.

Goroutine должна иметь понятный жизненный цикл. Её нужно дождаться или отменить. Ошибку нужно забрать. Доступ к общим данным нужно защитить. Количество параллельной работы нужно ограничить.

И тогда goroutines становятся не источником хаоса, а нормальным рабочим инструментом. Не магией. Не серебряной пулей. Просто хорошим способом сказать программе: “эти вещи можно делать одновременно, но давай без цирка”.

А если где-то в коде хочется написать go someFunction() и убежать — остановись на секунду. Спроси себя: кто потом уберёт за этой goroutine?

Потому что если не ты, то никто.

Разве что кот.

Но кот, как обычно, занят коробкой 😸