Содержание

Паттерны конкурентности в Go

Конкурентность нужна, когда программа должна делать несколько вещей "одновременно": читать много файлов, обслуживать клиентов чата, скачивать URL, ждать ответа внешнего API и не блокировать всю работу из-за одной медленной операции.

В Go для этого есть горутины, каналы, sync.WaitGroup, sync.Mutex, context и несколько устойчивых паттернов. Но важно понимать: горутина сама по себе не делает программу быстрее и безопаснее. Она просто запускает работу параллельно с другой работой. Правильность появляется только тогда, когда вы понимаете, кто передает данные, кто их закрывает, кто ждет завершения и как всё останавливается.

Конкурентность и параллелизм

Эти слова часто путают.

Конкурентность - это структура программы, где несколько задач могут продвигаться независимо. Например, чат-сервер держит 100 подключений и каждое может прислать сообщение в любой момент.

Параллелизм - это фактическое выполнение нескольких задач одновременно на разных ядрах процессора.

Аналогия: повар готовит несколько блюд. Он поставил суп вариться, пока режет салат. Это конкурентность. Если на кухне два повара одновременно режут и жарят, это уже параллелизм.

Go помогает удобно писать конкурентные программы, а runtime сам решает, как распределить горутины по потокам ОС.

Горутина

Горутина запускается ключевым словом go:

go sendEmail(user)

Полный пример:

package main import ( "fmt" "time" ) func printMessage(message string) { for i := 0; i < 3; i++ { fmt.Println(message, i) time.Sleep(300 * time.Millisecond) } } func main() { go printMessage("background") printMessage("main") }

Важно: когда main завершается, программа завершается целиком. Если фоновая горутина еще не закончила работу, она будет остановлена вместе с процессом.

Как дождаться горутин: sync.WaitGroup

Если вы запустили несколько горутин и хотите дождаться всех, используйте sync.WaitGroup:

var wg sync.WaitGroup for _, file := range files { wg.Add(1) go func(file string) { defer wg.Done() processFile(file) }(file) } wg.Wait()

Что здесь происходит:

  1. wg.Add(1) говорит: появилась еще одна задача.
  2. Горутина выполняет работу.
  3. defer wg.Done() уменьшает счетчик задач при выходе из функции.
  4. wg.Wait() блокирует текущую горутину, пока счетчик не станет нулем.

Обратите внимание на передачу file аргументом в анонимную функцию. Так каждая горутина получает свое значение.

go func(file string) { processFile(file) }(file)

Каналы: передача данных между горутинами

Канал - это безопасная труба между горутинами. Одна горутина отправляет значение, другая получает.

messages := make(chan string) go func() { messages <- "hello" }() message := <-messages fmt.Println(message)

Отправка в канал:

messages <- "hello"

Чтение из канала:

message := <-messages

Небуферизованный канал синхронизирует отправителя и получателя: отправка будет ждать, пока кто-то прочитает значение.

Буферизованные каналы

Буферизованный канал может хранить несколько значений:

jobs := make(chan string, 10) jobs <- "a.txt" jobs <- "b.txt"

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

Закрытие каналов

Канал закрывает отправитель, когда новых значений больше не будет:

close(jobs)

Читать из закрытого канала можно:

for job := range jobs { process(job) }

Цикл завершится, когда канал закрыт и все значения из него прочитаны.

Worker pool

Worker pool нужен, когда задач много, но одновременно выполнять все нельзя. Например, у вас 10 000 файлов. Запустить 10 000 горутин можно, но это может перегрузить диск, сеть или внешний API.

Идея:

  1. Есть канал задач.
  2. Есть фиксированное число workers.
  3. Каждый worker читает задачи из канала.
  4. Когда задачи закончились, канал закрывается.
type Result struct { File string Err error } func worker(id int, jobs <-chan string, results chan<- Result) { for file := range jobs { err := processFile(file) results <- Result{File: file, Err: err} } } func run(files []string) []Result { jobs := make(chan string) results := make(chan Result) const workerCount = 4 var wg sync.WaitGroup for i := 0; i < workerCount; i++ { wg.Add(1) go func(id int) { defer wg.Done() worker(id, jobs, results) }(i) } go func() { for _, file := range files { jobs <- file } close(jobs) }() go func() { wg.Wait() close(results) }() var collected []Result for result := range results { collected = append(collected, result) } return collected }

В проектах Praxis worker pool встречается в обработке файлов, скачивании URL, синхронизации директорий и фоновых задачах.

Mutex: защита общей памяти

Каналы удобны для передачи данных. Но иногда несколько горутин должны работать с общей структурой: например, с map[string]int для счетчиков.

Обычная map не безопасна для одновременной записи из нескольких горутин:

counts := map[string]int{} counts["error"]++ // если так делают несколько горутин, будет гонка

Используйте sync.Mutex:

var mu sync.Mutex counts := map[string]int{} mu.Lock() counts["error"]++ mu.Unlock()

Лучше через defer, если внутри блока может быть несколько выходов:

mu.Lock() defer mu.Unlock() counts["error"]++

sync.RWMutex дает отдельную блокировку для чтения и записи. Он полезен, когда чтений очень много, а записей мало.

Race detector

Гонка данных возникает, когда две горутины обращаются к одной памяти одновременно, и хотя бы одна из них пишет.

Запускайте:

go test -race ./...

Для маленьких программ:

go run -race main.go

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

select

select позволяет ждать несколько каналов:

select { case message := <-messages: fmt.Println("message:", message) case err := <-errors: fmt.Println("error:", err) case <-time.After(5 * time.Second): fmt.Println("timeout") }

Это похоже на switch, но для операций с каналами. Выполнится тот case, который готов первым.

context: отмена и таймауты

context.Context нужен, чтобы передавать отмену вниз по цепочке вызовов.

Например, HTTP-запрос должен отмениться через 5 секунд:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req)

Если таймаут закончится, context отменится, и HTTP-запрос тоже будет остановлен.

Внутри своей функции context проверяют так:

func work(ctx context.Context, jobs <-chan Job) error { for { select { case <-ctx.Done(): return ctx.Err() case job, ok := <-jobs: if !ok { return nil } process(job) } } }

Graceful shutdown

Graceful shutdown - это корректное завершение программы. Например, сервер получил Ctrl+C или SIGTERM от Docker/Kubernetes. Он должен перестать принимать новые запросы, но дать текущим запросам закончиться.

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() server := &http.Server{ Addr: ":8080", Handler: mux, } go func() { if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Printf("server error: %v", err) } }() <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { log.Printf("shutdown error: %v", err) }

Без graceful shutdown сервер может оборвать запрос, потерять сообщение из очереди или оставить файл в промежуточном состоянии.

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

  1. Запустить горутину и не иметь способа ее остановить.
  2. Писать в одну map из нескольких горутин без mutex.
  3. Закрывать канал со стороны получателя.
  4. Делать time.After в горячем бесконечном цикле и создавать лишние таймеры.
  5. Не передавать context в HTTP-запросы и запросы к БД.
  6. Игнорировать ошибки внутри горутин.
  7. Запускать слишком много параллельных операций без лимита.

Как выбрать инструмент

ЗадачаИнструмент
Дождаться группы горутинsync.WaitGroup
Передать данные между горутинамиканал
Ограничить количество обработчиковworker pool
Защитить общую map или счетчикsync.Mutex / sync.RWMutex
Отменить долгую операциюcontext.Context
Обработать Ctrl+C / SIGTERMsignal.NotifyContext
Найти гонки данныхgo test -race

Чеклист для проекта

Перед сдачей этапа с конкурентностью проверьте:

  • у каждой горутины есть понятное условие завершения;
  • количество параллельных операций ограничено;
  • ошибки из горутин возвращаются наружу или логируются с контекстом;
  • общие структуры защищены mutex-ом или не разделяются;
  • каналы закрывает отправитель;
  • долгие операции принимают context.Context;
  • go test -race ./... не показывает гонок.

Следующий шаг после статьи

Закрепите тему в реальном проекте и продолжайте обучение на курсах Praxis.

Продолжить изучение

Выбери следующую статью по маршруту или углубись в смежную тему.

Похожие статьи