Конкурентность: Горутины и каналы
Добро пожаловать в один из самых сложных уроков! Конкурентность — это то, ради чего многие выбирают Go. Язык создан для простого и эффективного параллелизма.
Главный слоган Go:
«Не общайтесь через общую память — делитесь памятью через общение» (то есть используйте каналы, а не мьютексы).
Горутины (goroutines)
Горутина в Go — это лёгкая "нить" выполнения кода, которую язык умеет запускать самостоятельно и очень дёшево (тысячи горутин занимают всего несколько мегабайт памяти). Ты просто пишешь go funcName() или go func() { ... }(), и эта функция начинает работать параллельно с остальным кодом, не блокируя основной поток.
Горутины нужны, чтобы программа могла делать несколько дел одновременно: например, обрабатывать тысячи HTTP-запросов сразу, читать из базы, отправлять email и считать что-то тяжёлое — всё это без задержек для пользователей. Без горутин один долгий запрос "замораживал" бы весь сервер, а с ними Go легко справляется с высокой нагрузкой, оставаясь простым и эффективным.
Горутины — это суперлёгкий и удобный способ делать вещи параллельно, благодаря которому Go так любят для веб-сервисов и высоконагруженных систем.
go func() {
fmt.Println("Я работаю параллельно!")
}()
Горутины очень дешёвые: тысячи и даже миллионы — без проблем.
Простой пример
package main
import (
"fmt"
"time"
)
func worker(id int) {
fmt.Printf("Воркер %d стартовал\n", id)
time.Sleep(time.Second * time.Duration(id))
fmt.Printf("Воркер %d завершён\n", id)
}
func main() {
for i := 1; i <= 5; i++ {
go worker(i) // Запускаем параллельно
}
fmt.Println("Все воркеры запущены")
time.Sleep(6 * time.Second) // Даём им завершиться
fmt.Println("Главная горутина завершилась")
}
/* Вывод:
Все воркеры запущены
Воркер 1 стартовал
Воркер 3 стартовал
Воркер 2 стартовал
Воркер 4 стартовал
Воркер 5 стартовал
Воркер 1 завершён
Воркер 2 завершён
Воркер 3 завершён
Воркер 4 завершён
Воркер 5 завершён
Главная горутина завершилась
*/
Главная горутина (функция main) завершится — программа завершится, даже если другие горутины ещё работают. Поэтому часто используем time.Sleep или синхронизацию.
Синхронизация
Синхронизация в Go (и в многопоточности вообще) — это правила и инструменты, которые помогают нескольким горутинам работать с общими данными безопасно, чтобы они не "перепутали" всё и не сломали программу.
sync.WaitGroup
sync.WaitGroup — это простой и удобный механизм из пакета sync, который помогает главной горутине дождаться завершения нескольких других горутин (воркеров).
Представь: ты запускаешь 5 горутин, которые выполняют работу параллельно. Без WaitGroup главная программа может завершиться раньше, чем эти горутины закончат свою работу. WaitGroup решает эту проблему.
Как работает (по шагам):
- Создаёшь переменную:
var wg sync.WaitGroup - Перед запуском каждой горутины вызываешь
wg.Add(1)— говоришь: "ожидается ещё одна задача". - Внутри горутины в конце (лучше через
defer) вызываешьwg.Done()— говоришь: "эта задача завершена" (счётчик уменьшается на 1). - В главной горутине вызываешь
wg.Wait()— программа блокируется и ждёт, пока счётчик не станет 0 (то есть все горутины не завершатся).
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // +1 задача
go func(id int) {
defer wg.Done() // -1 по завершении
worker(id)
}(i)
}
wg.Wait() // Ждём все
fmt.Println("Все воркеры завершились")
errgroup
Когда ты запускаешь несколько горутин и хочешь подождать, пока все закончат (как с sync.WaitGroup), но ещё и собрать ошибки или отменить все при первой проблеме — на помощь приходит пакет golang.org/x/sync/errgroup. Это не стандартная библиотека, а расширение (go get golang.org/x/sync/errgroup), но оно настолько популярно и полезно, что используется почти в каждом серьёзном проекте на Go.
Зачем нужен errgroup?
Обычный sync.WaitGroup умеет только:
- Считать, сколько горутин запущено.
- Ждать, пока все завершатся.
Но он не умеет:
- Собирать ошибки из горутин.
- Отменять остальные горутины, если одна упала.
- Работать с контекстом (таймауты, отмена по сигналу).
errgroup решает все эти проблемы одним махом.
errgroup.Group
package main
import (
"fmt"
"golang.org/x/sync/errgroup"
"time"
)
func main() {
// Создаём группу задач
var g errgroup.Group
// Запускаем 5 "воркеров"
for i := 1; i <= 5; i++ {
num := i // копируем значение, чтобы каждая горутина получила своё
g.Go(func() error {
// Имитируем работу
time.Sleep(time.Duration(num) * 100 * time.Millisecond)
if num == 3 {
return fmt.Errorf("воркер %d сломался!", num) // одна задача упадёт
}
fmt.Printf("Воркер %d успешно закончил\n", num)
return nil
})
}
// Ждём все воркеры
if err := g.Wait(); err != nil {
fmt.Println("Ошибка:", err)
} else {
fmt.Println("Все воркеры успешно завершились!")
}
}
Результат:
```bash
Воркер 1 успешно закончил
Воркер 2 успешно закончил
Воркер 3 сломался!
Ошибка: воркер 3 сломался!
Почему это удобно:
g.Go(...)сам считает, сколько задач запущено.- Не нужно писать
wg.Add(1)иdefer wg.Done(). - Если хоть одна задача вернёт ошибку —
g.Wait()сразу вернёт эту ошибку. - Код короткий и читаемый.
Когда использовать errgroup
- Запускаешь несколько горутин и хочешь собрать ошибки.
- Нужна отмена по таймауту или сигналу.
- Хочешь ограничить параллелизм.
- Делаешь параллельные запросы к внешним сервисам, загрузку файлов, обработку данных.
Простое сравнение
| Задача | sync.WaitGroup | errgroup.Group |
|---|---|---|
| Просто ждать завершения | Да | Да |
| Собирать ошибки | Нет | Да (первая ошибка) |
| Отмена по контексту | Нет | Да (WithContext) |
| Ограничение параллелизма | Нет | Да (SetLimit) |
| Код проще и чище | Много boilerplate | Меньше кода |
errgroup— это какWaitGroupна стероидах: запускаешь горутины черезg.Go(), возвращаешьerrorесли что-то пошло не так, иg.Wait()скажет, всё ли ок. СWithContextдобавляешь таймауты и отмену — и получаешь надёжный параллельный код без утечек и зависаний.
sync.Mutex
Mutex (Мьютекс) в Go — это специальный "замок" из пакета sync, который позволяет только одной горутине (параллельному потоку) в момент времени работать с общими данными, чтобы избежать путаницы и ошибок.
Представь, что несколько человек одновременно пытаются писать в одну и ту же тетрадь: без замка один перезапишет текст другого, и получится каша (это называется гонка данных — race condition). Мьютекс решает проблему так: горутина "захватывает" замок через mu.Lock(), делает свою работу с данными (например, меняет счётчик или мапу), а потом "отпускает" через mu.Unlock() — и только тогда другая горутина может зайти. Он нужен, когда несколько горутин читают/пишут в одну переменную одновременно — без мьютекса программа может выдавать странные результаты или крашиться.
Мьютекс — это простой "светофор" для горутин, который защищает общие данные от одновременного доступа и делает параллельный код безопасным. 😊
Основные методы
Мьютекс имеет два основных метода:
Lock()— захватывает замок. Если он уже захвачен другой горутиной — текущая горутина блокируется и ждёт.Unlock()— освобождает замок. После этого одна из ждущих горутин может его захватить.
Простой пример
package main
import (
"fmt"
"sync"
)
var (
counter int // общая переменная
mu sync.Mutex // мьютекс для защиты counter
)
func increment(wg *sync.WaitGroup) {
mu.Lock() // захватываем замок
counter++ // критическая секция — только одна горутина здесь одновременно
mu.Unlock() // освобождаем замок
wg.Done()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go increment(&wg)
}
wg.Wait()
fmt.Println("Итог:", counter) // всегда будет ровно 1000
}
Без mu.Lock()/Unlock() значение counter было бы меньше 1000 из-за гонок данных.
Хорошая практика: defer Unlock()
mu.Lock()
defer mu.Unlock() // гарантированно освободим, даже если паника
counter++
sync.RWMutex
RWMutex (Read-Write Mutex) в Go — это улучшенная версия обычного мьютекса из пакета sync, которая различает чтение и запись.
Когда горутина хочет только читать данные — она захватывает "чтение" через RLock(), и много горутин могут читать одновременно (быстро и без ожидания). Но как только кто-то хочет записать (изменить данные) — он захватывает "запись" через Lock(), и тогда все остальные (и читающие, и пишущие) ждут, пока запись не закончится.
Это нужно, когда в программе данные чаще читают, чем меняют (например, кэш, конфиг, статистика, мапа пользователей) — обычный мьютекс заставлял бы всех ждать даже при чтении, а RWMutex позволяет множеству читателей работать параллельно, делая программу быстрее и эффективнее.
RWMutex— это "умный светофор": зелёный для всех читателей сразу, но красный для всех, когда кто-то пишет. 😊
Основные методы
RLock()/RUnlock()— замок для чтения. Много горутин могут захватить его одновременно.Lock()/Unlock()— замок для записи. Только одна горутина может захватить, и в этот момент никто не может читать.
Простой пример
package main
import (
"fmt"
"sync"
"time"
)
var (
data int // общая переменная
rw sync.RWMutex // RWMutex для защиты
)
func read(id int) {
rw.RLock() // несколько читателей могут быть здесь одновременно
defer rw.RUnlock()
fmt.Printf("Горутина %d читает: %d\n", id, data)
time.Sleep(100 * time.Millisecond) // имитируем работу
}
func write(id int, newValue int) {
rw.Lock() // только одна горутина может писать
defer rw.Unlock()
fmt.Printf("Горутина %d пишет: %d\n", id, newValue)
data = newValue
time.Sleep(200 * time.Millisecond)
}
func main() {
var wg sync.WaitGroup
// 5 читателей одновременно
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
read(id)
}(i)
}
// Один писатель
wg.Add(1)
go func() {
defer wg.Done()
write(99, 42)
}()
wg.Wait()
}
Вывод покажет, что все читатели работают почти одновременно, а писатель блокирует всех на время записи.
Когда использовать RWMutex
- Много чтений, мало записей (например, кэш, конфигурация, справочник).
- Чтение происходит часто и долго — обычный Mutex сильно тормозил бы программу.
- Примеры: кэш в веб-сервере, чтение базы данных в памяти, статистика.
Когда НЕ использовать
- Если записи происходят часто — RWMutex может быть медленнее обычного Mutex (из-за дополнительной логики).
- Если критическая секция очень короткая — разница незаметна, лучше простой
Mutex. - Если можно обойтись каналами или неизменяемыми данными — ещё лучше.
Сравнение с обычным Mutex
| Ситуация | sync.Mutex | sync.RWMutex |
|---|---|---|
| Только одна горутина одновременно | Да | Для записи — да, для чтения — нет |
| Много одновременных чтений | Нет (блокирует всех) | Да (разрешает) |
| Скорость при частых записях | Обычно быстрее | Может быть медленнее |
Каналы (channels)
Каналы в Go — это специальная "труба" для безопасной передачи данных между горутинами (параллельными потоками выполнения). Ты создаёшь канал командой ch := make(chan int) (например, для чисел), одна горутина отправляет данные через ch <- 42, а другая получает через value := <-ch — и всё это происходит синхронно и без риска гонок данных (race condition).
Каналы нужны, чтобы горутины могли общаться и координировать работу: передавать результаты вычислений, сигналы завершения, задачи в очередь или просто синхронизировать действия (как "светофор"). Без каналов пришлось бы использовать мьютексы и общие переменные, что сложнее и опаснее ошибок.
Каналы — это простой, безопасный и идиоматичный способ "разговаривать" между горутинами, благодаря которому Go так удобно пишет параллельный код. 😊
1. Создание каналов
ch := make(chan int) // небуферизованный канал для int
buffered := make(chan string, 10) // буферизованный на 10 элементов
- Небуферизованный — отправка и получение синхронны: отправитель блокируется, пока получатель не готов (и наоборот).
- Буферизованный — отправка работает, пока в буфере есть место. Если буфер полон — отправитель блокируется.
2. Основные операции
ch <- 42 // отправить значение в канал
x := <-ch // получить значение (блокирует, если канал пуст)
<-ch // получить и отбросить значение
close(ch) // закрыть канал (только отправитель должен это делать!)
3. Получение с проверкой
v, ok := <-ch
if ok {
fmt.Println("Получили:", v)
} else {
fmt.Println("Канал закрыт")
}
- После
close(ch)все получатели будут получать нулевое значение иok == false.
4. Итерация по каналу
Самый удобный способ читать всё до закрытия:
for value := range ch {
fmt.Println(value)
}
// Автоматически останавливается, когда канал закрыт и пуст.
5. select — работа с несколькими каналами
select выбирает готовую операцию (как switch, но для каналов):
select {
case v := <-ch1:
fmt.Println("Из ch1:", v)
case ch2 <- 100:
fmt.Println("Отправили в ch2")
case <-time.After(2 * time.Second):
fmt.Println("Таймаут!")
default:
fmt.Println("Ничего не готово прямо сейчас")
}
- Если несколько case готовы — выбирается случайный.
default— выполняется сразу, если ничего не готово (не блокирует).
6. Направленные каналы (direction)
Можно явно указать направление — это делает код безопаснее:
func producer(ch chan<- int) { // только отправка
ch <- 42
}
func consumer(ch <-chan int) { // только получение
v := <-ch
}
7. Типичные паттерны использования
- Сигнал завершения:
done := make(chan struct{})→done <- struct{}{} - Pipeline: один канал на выходе одной горутины — вход для следующей.
- Fan-out / Fan-in: несколько воркеров читают из одного канала (fan-out), результаты собираются в другой (fan-in).
- Ограничение параллелизма: буферизованный канал на N элементов как семафор.
8. Частые ошибки новичков
- Забыть
close(ch)— получатели будут висеть вечно. - Закрывать канал несколько раз — паника.
- Отправлять в закрытый канал — паника.
- Отправлять в nil-канал — вечная блокировка.
- Не использовать
rangeилиselect— легко получить deadlock.
sync.Once
sync.Once — это структура из пакета sync, которая гарантирует, что определённая функция выполнится ровно один раз, даже если её вызовут из многих горутин одновременно.
Это идеальный инструмент для ленивой инициализации (lazy initialization) чего-то дорогого: подключения к базе данных, загрузки конфигурации, создания singleton и т.д.
Как работает
У sync.Once есть только один важный метод:
Do(f func())— вызывает функциюfтолько один раз.
Все последующие вызовыDo()с той жеOnceпросто подождут завершения первого вызова (если он ещё идёт) и сразу вернутся, не запуская функцию снова.
Простой пример
package main
import (
"fmt"
"sync"
)
var (
once sync.Once
config map[string]string // будет инициализирован только один раз
)
func loadConfig() {
fmt.Println("Загружаю конфиг... (дорогая операция)")
config = map[string]string{
"host": "localhost",
"port": "8080",
}
}
func getConfig() map[string]string {
once.Do(loadConfig) // выполнится только при первом вызове
return config
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
cfg := getConfig()
fmt.Printf("Горутина %d получила конфиг: %v\n", id, cfg)
}(i)
}
wg.Wait()
}
Вывод:
Загружаю конфиг... (дорогая операция) // только один раз!
Горутина 1 получила конфиг: map[host:localhost port:8080]
Горутина 3 получила конфиг: map[host:localhost port:8080]
...
Даже если 5 горутин одновременно вызовут getConfig(), функция loadConfig выполнится ровно один раз.
Классический паттерн singleton
var (
once sync.Once
instance *MySingleton
)
func GetInstance() *MySingleton {
once.Do(func() {
instance = &MySingleton{}
// инициализация instance
})
return instance
}
Важные моменты
once.Do()безопасен для параллельного вызова из многих горутин.- Если функция внутри
Do()запаникует — паника пробросится, а последующие вызовыDo()снова попробуют выполнить функцию (но обычно это не нужно). - Обычно используют одну переменную
sync.Onceна одну инициализацию.
Когда использовать
- Инициализация глобальных ресурсов (БД, логгер, кэш).
- Ленивая загрузка тяжёлых объектов.
- Гарантия однократного выполнения setup-кода.
Когда НЕ использовать
- Если нужно выполнить что-то один раз, но не в параллельном коде — просто обычная функция.
- Если нужна повторная инициализация по условию — это не для
Once.
Атомарные операции (atomic)
Атомики (atomic operations) в Go — это специальные функции из пакета sync/atomic, которые позволяют безопасно читать и менять простые значения (числа, указатели) в нескольких горутинах одновременно, без использования мьютексов.
Представь счётчик посетителей сайта: если обычный count++ делать из разных горутин — может получиться ошибка (одна горутина перезапишет изменение другой). Атомики решают это на уровне процессора: операции вроде atomic.AddInt64(&count, 1) или atomic.LoadInt64(&count) выполняются как одно неделимое действие — никто не может "влезть" посередине.
Они нужны для простых вещей: счётчики, флаги (включено/выключено), указатели — когда не хочется тяжёлого мьютекса, а нужна максимальная скорость и безопасность.
Атомики — это сверхбыстрый и лёгкий "замок" только для простых переменных, чтобы горутины не путали числа при одновременной работе.
Основные функции sync/atomic
| Функция | Что делает | Пример использования |
|---|---|---|
atomic.AddInt64(&x, delta) | Прибавляет delta к x и возвращает новое значение | atomic.AddInt64(&counter, 1) — безопасный ++ |
atomic.LoadInt64(&x) | Безопасно читает текущее значение | v := atomic.LoadInt64(&counter) |
atomic.StoreInt64(&x, newValue) | Безопасно записывает новое значение | atomic.StoreInt64(&done, 1) — установить флаг |
atomic.CompareAndSwapInt64(&x, old, new) | Если x == old → меняет на new и возвращает true | Для lock-free алгоритмов |
atomic.SwapInt64(&x, new) | Меняет значение и возвращает старое | old := atomic.SwapInt64(&counter, 0) |
Поддерживаемые типы: int32, int64, uint32, uint64, uintptr, указатели (*T), atomic.Bool, atomic.Value (для любого типа).
Простой пример: безопасный счётчик
package main
import (
"fmt"
"sync"
"sync/atomic"
)
var counter int64 // обязательно фиксированный размер (int64)
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
atomic.AddInt64(&counter, 1) // атомарно +1
wg.Done()
}()
}
wg.Wait()
fmt.Println("Итог:", atomic.LoadInt64(&counter)) // всегда точно 1000!
}
Без atomic результат был бы случайным (меньше 1000) из-за гонок.
Пример с флагом (atomic.Bool)
var done atomic.Bool
go func() {
// долгая работа
done.Store(true) // атомарно устанавливаем флаг
}()
for !done.Load() {
fmt.Println("Ждём завершения...")
}
fmt.Println("Готово!")
Пример с указателем
var config atomic.Value // может хранить любой тип
config.Store(&Config{Version: 1})
// В другой горутине
current := config.Load().(*Config)
fmt.Println(current.Version)
Когда использовать атомики
- Счётчики (запросов, ошибок, обработанных задач).
- Флаги (done, stopped, ready).
- Указатели (атомарная замена конфигурации, singleton).
- В высоконагруженных местах, где мьютекс был бы bottleneck.
Когда НЕ использовать
- Сложные структуры (
struct,map,slice) — атомики работают только с простыми типами. - Несколько связанных операций ("прочитать → посчитать → записать") — нужна транзакция (мьютекс).
- Если код становится менее читаемым — лучше обычный мьютекс.
Сравнение с мьютексом
| Аспект | atomic | sync.Mutex |
|---|---|---|
| Скорость | Очень быстро (без блокировок) | Медленнее (может блокировать горутину) |
| Что можно защитить | Один простой тип | Любые данные, сложные операции |
| Читаемость | Иногда сложнее | Обычно понятнее |
| Гибкость | Низкая | Высокая |
Паттерны
Популярные паттерны работы с горутинами и каналами в Go
В Go параллелизм строится вокруг горутин (go func()) и каналов. Вот самые популярные и полезные паттерны, которые используют в реальных проектах.
Worker Pool (Пул воркеров)
Ограниченное число горутин обрабатывает много задач из очереди.
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// Запускаем фиксированное число воркеров
for w := 1; w <= 5; w++ {
go worker(w, jobs, results)
}
// Отправляем задачи
for j := 1; j <= 20; j++ {
jobs <- j
}
close(jobs) // больше задач не будет
// Собираем результаты
for r := 1; r <= 20; r++ {
<-results
}
}
func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
fmt.Printf("Воркер %d обрабатывает задачу %d\n", id, job)
time.Sleep(time.Second) // имитация работы
results <- job * 2
}
}
Зачем: ограничивает параллелизм (например, не более 10 одновременных запросов к API).
Pipeline (Конвейер)
Данные проходят через цепочку обработки: каждая стадия — отдельная горутина.
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
in := gen(2, 3, 4, 5)
// Стадии конвейера
out := sq(sq(in)) // дважды возводим в квадрат
for n := range out {
fmt.Println(n) // 16, 81, 256, 625
}
}
Зачем: удобно для обработки потоков данных (парсинг, преобразование, фильтрация).
Fan-out / Fan-in
Fan-out: одна горутина распределяет задачи по многим воркерам.
Fan-in: результаты собираются в один канал.
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// Fan-out: несколько воркеров читают из одного канала
for w := 1; w <= 10; w++ {
go worker(jobs, results)
}
// Отправляем задачи
for j := 1; j <= 50; j++ {
jobs <- j
}
close(jobs)
// Fan-in: собираем все результаты
for r := 1; r <= 50; r++ {
<-results
}
}
Зачем: максимальный параллелизм при обработке независимых задач.
Ожидание завершения всех горутин (WaitGroup)
Простой способ дождаться всех воркеров.
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Воркер %d завершён\n", id)
time.Sleep(time.Duration(id) * time.Second)
}(i)
}
wg.Wait()
fmt.Println("Все горутины завершились")
Ограничение параллелизма семафором
Буферизованный канал как счётчик одновременных задач.
sem := make(chan struct{}, 10) // максимум 10 одновременно
for _, task := range tasks {
sem <- struct{}{} // захватываем слот
go func(task Task) {
defer func() { <-sem }() // освобождаем слот
process(task)
}(task)
}
Какие паттерны использовать чаще всего
- Worker Pool — для ограниченного параллелизма.
- Pipeline — для последовательной обработки данных.
- Fan-out/Fan-in — для максимальной скорости на независимых задачах.
- WaitGroup — когда просто нужно дождаться всех.
Лучшие практики
- Не создавайте гонку данных — используйте каналы вместо общей памяти.
- Закрывайте каналы только на стороне отправителя.
- Используйте
defer wg.Done()в горутинах. - Буферизованные каналы — для ограничения скорости (rate limiting).