Глава 9. Конкурентность: горутины и каналы

168 просмотров
0 лайков
0 в избранном

Введение

Конкурентность — это то, за что Go любят и выбирают. Пока другие языки прикручивают асинхронность через callbacks, промисы и async/await, в Go запуск параллельной работы выглядит как go f() — два символа перед вызовом функции. За этой простотой стоит продуманная модель: лёгкие горутины вместо тяжёлых потоков ОС и каналы для безопасного общения между ними. Девиз Go звучит так: «Не общайтесь через разделяемую память; вместо этого разделяйте память, общаясь». То есть не боритесь за один кусок памяти под замками, а передавайте данные друг другу по каналам. В этой главе ты научишься запускать горутины, синхронизировать их через sync.WaitGroup, передавать данные по каналам (буферизованным и небуферизованным), задавать каналам направление, мультиплексировать их через select, правильно закрывать каналы и читать их через range. Разберёшься, что такое гонка данных и как её ловит детектор -race, когда нужен sync.Mutex, и в конце соберёшь боевой паттерн — пул воркеров. Это самая насыщенная глава курса, но именно она открывает настоящую мощь языка.

Горутина: сверхлёгкий поток

Горутина — это функция, выполняющаяся конкурентно с остальными. Запускается словом go перед вызовом. В отличие от потоков операционной системы, которые стоят мегабайты памяти, горутина стартует с нескольких килобайт стека и растёт по мере надобности. Поэтому в Go-программе спокойно живут сотни тысяч горутин — планировщик языка сам распределяет их по нескольким системным потокам.

package main

import "fmt"

func main() {
    // go запускает foo() конкурентно и НЕ ждёт её завершения.
    go foo()
    // Программа тут же летит дальше и... завершается,
    // не дав foo даже начать. main вышел — программа умерла.
    fmt.Println("main закончился")
}

func foo() {
    for i := 0; i < 5; i++ {
        fmt.Println("foo:", i)
    }
}

Этот код почти наверняка не напечатает ничего из foo. Причина фундаментальна: когда завершается функция main, программа завершается целиком, не дожидаясь горутин. Горутина — это обещание сделать работу «когда-нибудь», и если её никто не ждёт, она может вообще не успеть выполниться. Значит, нам нужен способ дождаться горутин.

sync.WaitGroup: дожидаемся горутин

sync.WaitGroup — это счётчик незавершённых горутин. Ты говоришь ему, сколько горутин запускаешь (Add), каждая по завершении отмечается (Done), а main блокируется на Wait, пока счётчик не обнулится. Это самый простой и правильный способ дождаться завершения работы.

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup

    // Говорим: будем ждать 2 горутины. Add ВСЕГДА до go.
    wg.Add(2)

    go worker("A", &wg)
    go worker("B", &wg)

    // Блокируемся, пока обе горутины не позовут Done().
    wg.Wait()
    fmt.Println("все воркеры завершились")
}

// wg передаём УКАЗАТЕЛЕМ: WaitGroup нельзя копировать,
// все горутины должны отмечаться в одном и том же счётчике.
func worker(name string, wg *sync.WaitGroup) {
    // defer гарантирует Done() даже при раннем выходе или панике.
    defer wg.Done()
    for i := 0; i < 3; i++ {
        fmt.Println(name, i)
    }
}

Три правила WaitGroup, которые спасут тебя от боли: Add вызывай до запуска горутины (иначе Wait может проскочить), wg передавай указателем (*sync.WaitGroup), а Done ставь через defer в самом начале горутины, чтобы он сработал при любом выходе.

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

Горутины запустили, дожидаться научились. Но как передать данные из горутины наружу? Через глобальные переменные — опасно (об этом ниже, в разделе про гонки). Правильный путь — каналы. Канал (chan) — это типизированная труба, по которой одна горутина отправляет значения, а другая принимает. Канал одновременно передаёт данные и синхронизирует горутины.

// Создаём канал для int значений.
ch := make(chan int)

// Отправка в канал: стрелка указывает В канал.
ch <- 42

// Приём из канала: стрелка указывает ИЗ канала (слева от него).
v := <-ch
fmt.Println(v) // 42

Запомни направление стрелки раз и навсегда: ch <- v — «значение v втекает в канал ch» (отправка), а v := <-ch — «значение вытекает из канала в переменную v» (приём). Стрелка всегда «показывает», куда движутся данные.

Небуферизованные каналы блокируют

По умолчанию make(chan int) создаёт небуферизованный канал — без внутреннего хранилища. Его главное свойство: отправка блокируется, пока кто-то не начнёт принимать, и наоборот. Это делает канал точкой рандеву (встречи) двух горутин: они синхронизируются в момент передачи.

package main

import "fmt"

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

    // Отправляющая горутина.
    go func() {
        for i := 0; i < 5; i++ {
            ch <- i // блокируется, пока main не примет
        }
        close(ch) // закрываем: больше значений не будет
    }()

    // Принимаем в main. range сам завершится, когда канал закроют.
    for n := range ch {
        fmt.Println("получено:", n)
    }
}

Тут одновременно четыре важные вещи: отправка ch <- i ждёт приёмника, close(ch) сообщает «поток данных окончен», for n := range ch читает значения пока канал открыт и автоматически выходит после закрытия. Именно так каналы заменяют ручную синхронизацию: не нужен ни WaitGroup, ни счётчики.

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

Если задать второй аргумент make(chan int, 3), канал получит буфер на 3 элемента. Отправка блокируется только когда буфер полон, приём — только когда буфер пуст. Буфер сглаживает разницу в скорости между отправителем и получателем, но не бесконечен.

// Канал с буфером на 2 элемента.
ch := make(chan string, 2)

ch <- "первый"  // не блокирует — в буфере есть место
ch <- "второй"  // не блокирует — заняли второе место
// ch <- "третий" // ЗАБЛОКИРУЕТ: буфер полон, приёмника нет -> deadlock

fmt.Println(<-ch) // первый (FIFO — первым пришёл, первым вышел)
fmt.Println(<-ch) // второй

Небуферизованный канал — про синхронизацию (гарантированную встречу), буферизованный — про развязку по скорости. Не хватайся за буфер, чтобы «на всякий случай» — чаще всего нужен именно небуферизованный канал, а лишний буфер прячет ошибки синхронизации.

Направленные каналы

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

// producer принимает канал ТОЛЬКО ДЛЯ ОТПРАВКИ: chan<- int.
// Внутри можно только слать, приём — ошибка компиляции.
func producer(out chan<- int) {
    for i := 0; i < 5; i++ {
        out <- i
    }
    close(out)
}

// consumer принимает канал ТОЛЬКО ДЛЯ ПРИЁМА: <-chan int.
// Внутри можно только принимать; отправка и close — ошибка компиляции.
func consumer(in <-chan int) {
    for n := range in {
        fmt.Println("обработано:", n)
    }
}

func main() {
    ch := make(chan int) // обычный двунаправленный канал
    go producer(ch)      // Go сам сузит его до chan<- int
    consumer(ch)         // и до <-chan int
}

Мнемоника направления: если <- стоит после слова chan (chan<- int) — данные втекают в канал, это «только отправка». Если <- стоит перед словом chan (<-chan int) — данные вытекают, это «только приём». Обычный двунаправленный канал автоматически приводится к направленному при передаче в функцию.

Закрытие канала и проверка «канал закрыт»

Закрывает канал отправитель вызовом close(ch) — это сигнал «данных больше не будет». Приём из закрытого канала возвращает нулевое значение и false во второй переменной. Никогда не закрывай канал со стороны получателя и не закрывай дважды — это паника.

ch := make(chan int, 2)
ch <- 10
close(ch)

// Форма "comma, ok" для приёма: ok == false, если канал закрыт и пуст.
v, ok := <-ch
fmt.Println(v, ok) // 10 true — значение из буфера

v, ok = <-ch
fmt.Println(v, ok) // 0 false — канал закрыт, значений нет

select: ждём сразу несколько каналов

select — это «switch для каналов». Он ждёт, пока станет готова любая из операций с каналами, и выполняет соответствующую ветку. Если готовы несколько — выбирает случайную. Это ядро сложной конкурентности: таймауты, отмена, объединение нескольких источников.

package main

import (
    "fmt"
    "time"
)

func main() {
    c1 := make(chan string)
    c2 := make(chan string)

    go func() { time.Sleep(100 * time.Millisecond); c1 <- "от c1" }()
    go func() { time.Sleep(200 * time.Millisecond); c2 <- "от c2" }()

    // Ждём два ответа. select берёт тот канал, что готов раньше.
    for i := 0; i < 2; i++ {
        select {
        case msg := <-c1:
            fmt.Println(msg)
        case msg := <-c2:
            fmt.Println(msg)
        }
    }
}
// Частая идиома: таймаут через select и time.After.
select {
case res := <-slowChannel:
    fmt.Println("успели:", res)
case <-time.After(1 * time.Second):
    // time.After возвращает канal, в который через секунду придёт значение.
    fmt.Println("таймаут: операция слишком долгая")
}

Без select невозможно аккуратно реализовать таймаут или ждать несколько каналов одновременно — select превращает каналы из простой трубы в полноценный инструмент координации.

Гонка данных и детектор -race

Теперь самое опасное. Если несколько горутин одновременно читают и пишут одну переменную без синхронизации — это гонка данных (data race). Результат непредсказуем: значение теряется, программа даёт разные ответы при каждом запуске, а иногда падает. Гонки коварны тем, что на маленьких данных «вроде работает», а в проде под нагрузкой ломается.

package main

import (
    "fmt"
    "sync"
)

var counter int // общая переменная — источник гонки

func main() {
    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 — обычно меньше
}

Операция counter++ на самом деле три действия: прочитать, прибавить, записать. Две горутины могут прочитать одно и то же старое значение и обе записать «старое+1» — один инкремент потерялся. Запусти программу с флагом детектора гонок, и Go покажет точное место проблемы.

go run -race main.go
# ==================
# WARNING: DATA RACE
# Read at 0x... by goroutine 8:
#   main.main.func1()
# ...
# ==================

Правило: любой конкурентный код всегда прогоняй под -race, особенно тесты (go test -race ./...). Детектор гонок — один из лучших инструментов Go, он ловит то, что глазами не увидишь.

sync.Mutex: защищаем общую память

Если разделяемую память всё же нужно использовать (не всё удобно гнать через каналы), доступ к ней защищают взаимным исключением — sync.Mutex. Мьютекс — это замок: горутина берёт Lock(), делает своё дело, отпускает Unlock(). Пока замок занят, другие ждут. Так одновременно в «критической секции» находится только одна горутина.

package main

import (
    "fmt"
    "sync"
)

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

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()         // берём замок
            counter++         // критическая секция — тут мы одни
            mu.Unlock()       // отпускаем замок
        }()
    }
    wg.Wait()
    fmt.Println(counter) // теперь ГАРАНТИРОВАННО 1000
}

Мьютекс vs каналы — не «или-или». Каналы хороши, когда данные передаются между горутинами. Мьютекс проще, когда несколько горутин лишь обновляют общее состояние (счётчик, кэш, карту). Выбирай по смыслу: «передача владения» — канал, «защита общего поля» — мьютекс.

Ловушка переменной цикла в горутине

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

// Опасно в старых версиях Go: все горутины могут напечатать "3 3 3".
for i := 0; i < 3; i++ {
    go func() {
        fmt.Println(i) // i — общая на все итерации в Go < 1.22
    }()
}

// Надёжный способ, работающий ВЕЗДЕ: передать i аргументом.
// Аргумент копируется в момент запуска — каждая горутина получит своё значение.
for i := 0; i < 3; i++ {
    go func(n int) {
        fmt.Println(n) // 0, 1, 2 в каком-то порядке
    }(i)
}

В Go 1.22 семантику исправили: теперь переменная цикла своя на каждой итерации, и первый вариант работает корректно. Но привычка передавать значение аргументом (или создавать локальную копию i := i) остаётся хорошим тоном — код одинаково верен на любой версии.

Конвейер (pipeline)

Мощная идиома: несколько стадий, соединённых каналами, где выход одной стадии — вход следующей. Каждая стадия — горутина, которая читает из входного канала, обрабатывает и пишет в выходной. Данные текут по трубе, стадии работают параллельно.

// Стадия 1: генерирует числа.
func gen(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

// Стадия 2: читает числа, возводит в квадрат, отдаёт дальше.
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() {
    // Соединяем стадии: gen -> sq -> потребитель.
    for n := range sq(gen(2, 3, 4)) {
        fmt.Println(n) // 4, 9, 16
    }
}

Заметь паттерн, общий для всех стадий: каждая закрывает свой выходной канал, когда исчерпала вход, и каждая читает вход через range до закрытия. Именно эта дисциплина «закрыл выход — потому что закрылся вход» делает конвейеры надёжными.

Fan-out / fan-in

Если одна стадия — узкое место, её распараллеливают: несколько горутин читают из одного входного канала (fan-out), а их результаты сливаются в один выходной (fan-in). Так тяжёлую работу раскидывают по нескольким исполнителям.

// merge (fan-in) сливает несколько каналов в один.
func merge(cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)

    // На каждый входной канал — горутина, перекладывающая его в out.
    forward := func(c <-chan int) {
        defer wg.Done()
        for n := range c {
            out <- n
        }
    }
    wg.Add(len(cs))
    for _, c := range cs {
        go forward(c)
    }

    // Закрываем out, когда все перекладчики закончили.
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

func main() {
    in := gen(2, 3, 4, 5)
    // fan-out: две горутины sq читают из одного in.
    c1 := sq(in)
    c2 := sq(in)
    // fan-in: сливаем результаты обратно.
    for n := range merge(c1, c2) {
        fmt.Println(n)
    }
}

Fan-out распределяет нагрузку, fan-in собирает результат. Это обобщение worker pool: воркеры — это fan-out от канала задач, а сбор результатов — fan-in. Понимая эти два движения, ты разберёшь почти любую конкурентную схему в Go.

sync.Once и sync.RWMutex

Ещё два полезных инструмента из пакета sync. sync.Once гарантирует, что кусок кода выполнится ровно один раз, сколько бы горутин его ни вызвали — идеально для ленивой инициализации (подключение к БД, загрузка конфига). sync.RWMutex — «умный» мьютекс: много читателей одновременно или один писатель, что ускоряет сценарии «часто читаем, редко пишем».

var once sync.Once
var config map[string]string

// loadConfig может вызываться из многих горутин,
// но тело once.Do выполнится РОВНО ОДИН РАЗ.
func loadConfig() {
    once.Do(func() {
        fmt.Println("загружаю конфиг (только раз)")
        config = map[string]string{"env": "prod"}
    })
}

// RWMutex: RLock/RUnlock для чтения, Lock/Unlock для записи.
var mu sync.RWMutex
var cache = map[string]int{}

func get(key string) int {
    mu.RLock()         // много читателей могут держать RLock одновременно
    defer mu.RUnlock()
    return cache[key]
}

func set(key string, v int) {
    mu.Lock()          // писатель — эксклюзивно, ждёт всех читателей
    defer mu.Unlock()
    cache[key] = v
}

Про context для отмены и таймаутов горутин мы поговорим отдельно в одной из следующих глав — это стандартный способ сказать целому дереву горутин «стоп, работу можно прекращать». А пока запомни: sync.Once — для однократной инициализации, RWMutex — когда чтений много, а записей мало.

Паттерн worker pool

Теперь соберём всё в главный боевой паттерн Go-конкурентности — пул воркеров. Идея: есть много однотипных задач и мы не хотим плодить горутину на каждую (их могут быть миллионы). Вместо этого запускаем фиксированное число воркеров, которые разбирают задачи из общего канала jobs и складывают результаты в канал results. Это ограничивает параллелизм и равномерно грузит систему.

package main

import (
    "fmt"
    "sync"
)

// worker читает задачи из jobs (только приём) и пишет в results (только отправка).
// Направленные каналы делают контракт функции явным.
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for j := range jobs { // range сам завершится, когда jobs закроют
        fmt.Printf("воркер %d обрабатывает задачу %d
", id, j)
        results <- j * j // «тяжёлая» работа: возводим в квадрат
    }
}

func main() {
    const numJobs = 9
    const numWorkers = 3

    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)

    var wg sync.WaitGroup

    // Запускаем фиксированный пул воркеров.
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }

    // Загружаем задачи и закрываем канал — сигнал «задач больше нет».
    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs)

    // Закрываем results в отдельной горутине, КОГДА все воркеры доделали.
    go func() {
        wg.Wait()
        close(results)
    }()

    // Собираем результаты, пока канал не закроется.
    sum := 0
    for r := range results {
        sum += r
    }
    fmt.Println("сумма квадратов:", sum) // 1+4+9+...+81 = 285
}

Разбери этот код по косточкам — в нём собрана вся глава: горутины-воркеры, направленные каналы (<-chan и chan<-), закрытие каналов как сигнал завершения, range по каналу, WaitGroup для координации закрытия results. Обрати внимание на тонкость: results закрывается в отдельной горутине после wg.Wait(), потому что закрыть его раньше, чем воркеры допишут, — паника, а не закрыть вовсе — вечная блокировка range results.

Кейс из реального проекта

Тебе нужно проверить доступность 200 URL из списка (конкурентный парсер/чекер ссылок). Делать это последовательно — минуты ожидания сети. Запускать 200 одновременных запросов — можно упереться в лимиты ОС и сервера. Идеальное решение — worker pool с ограниченным числом воркеров, каждый из которых берёт URL из канала, «ходит» по сети и кладёт результат обратно.

package main

import (
    "fmt"
    "sync"
)

// result — что вернул один воркер по одному URL.
type result struct {
    url    string
    status int
    err    error
}

// checkURL имитирует сетевую проверку (в реальности здесь http.Get).
func checkURL(url string) result {
    // ... реальный код делал бы http.Get(url) и читал resp.StatusCode ...
    return result{url: url, status: 200}
}

// worker берёт URL-ы из jobs и шлёт результаты в results.
func worker(jobs <-chan string, results chan<- result, wg *sync.WaitGroup) {
    defer wg.Done()
    for url := range jobs {
        results <- checkURL(url)
    }
}

func main() {
    urls := []string{
        "https://go.dev",
        "https://example.com",
        "https://pkg.go.dev",
        "https://golang.org",
    }

    jobs := make(chan string, len(urls))
    results := make(chan result, len(urls))

    var wg sync.WaitGroup
    const workers = 3 // ограничиваем параллелизм тремя горутинами

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

    for _, u := range urls {
        jobs <- u
    }
    close(jobs)

    // Закрываем results, когда все воркеры закончили.
    go func() {
        wg.Wait()
        close(results)
    }()

    // Собираем и печатаем отчёт по мере готовности.
    for r := range results {
        if r.err != nil {
            fmt.Printf("%s -> ОШИБКА: %v
", r.url, r.err)
            continue
        }
        fmt.Printf("%s -> %d
", r.url, r.status)
    }
}

Меняя одну константу workers, ты регулируешь нагрузку: 3 воркера — бережно, 50 — быстро, но агрессивно. Задачи и результаты ходят по каналам, никакой разделяемой памяти и мьютексов не понадобилось — это и есть тот самый «разделяйте память, общаясь». Такой скелет worker pool ты будешь переиспользовать в десятках реальных задач: рассылка писем, обработка изображений, импорт данных, опрос API.

Типичные ошибки

Забыть дождаться горутин

Запустил go f() и не дождался — main завершился, горутина не успела ничего сделать. Всегда синхронизируйся: WaitGroup для «дождаться всех» или канал, из которого читаешь результат. Горутина без ожидания — это выброшенная работа.

Deadlock из-за небуферизованного канала

Отправка в небуферизованный канал ch <- v блокируется, пока кто-то не примет. Если приёмника нет (например, забыл запустить горутину-получателя или пишешь в канал из той же единственной горутины), получишь fatal error: all goroutines are asleep - deadlock. Убедись, что на каждую отправку есть приём, а на приём — отправка или закрытие.

Закрытие канала не с той стороны или дважды

Закрывать канал должен только отправитель и только один раз. Закрытие получателем, повторное close или отправка в закрытый канал — это паника panic: close of closed channel / send on closed channel. Если отправителей несколько, координируй закрытие через WaitGroup, как в worker pool.

Гонка данных вместо синхронизации

Несколько горутин пишут в общую переменную, слайс или карту без защиты — классическая гонка. Карты в Go вообще не потокобезопасны и падают с fatal error: concurrent map writes. Защищай общее состояние sync.Mutex или передавай данные через каналы, и обязательно прогоняй код под go run -race / go test -race.

Практика

  1. Запусти три горутины, каждая печатает своё имя пять раз. Синхронизируй их через sync.WaitGroup так, чтобы main дождался всех перед выходом.
  2. Напиши генератор: функция возвращает <-chan int (только для приёма), внутри горутина шлёт числа от 1 до 10 и закрывает канал. В main прочитай их через range.
  3. Собери конвейер из двух стадий: gen отдаёт числа в канал, square читает их, возводит в квадрат и пишет в новый канал. Соедини стадии и напечатай результат.
  4. Воспроизведи гонку данных на общем счётчике из 1000 горутин, подтверди её флагом -race, затем почини двумя способами: через sync.Mutex и через канал-аккумулятор. Сравни.
  5. Реализуй worker pool из 4 воркеров, который обрабатывает 20 задач (например, вычисляет факториал числа), собирает результаты в канал и печатает их сумму. Убедись, что results закрывается корректно после wg.Wait().

Итог

Конкурентность в Go строится на двух простых кирпичах — горутинах и каналах, — но из них собираются сколь угодно сложные конструкции. Горутина запускается словом go и почти ничего не стоит, а чтобы её работа не пропала, горутины синхронизируют через sync.WaitGroup. Каналы передают данные и одновременно синхронизируют: небуферизованные обеспечивают встречу отправителя и получателя, буферизованные развязывают их по скорости, направленные (chan<- и <-chan) делают контракты явными, а select позволяет ждать несколько каналов и ставить таймауты. Ты понял, что close — это сигнал завершения для range, что общая память без синхронизации порождает гонки данных, которые ловит бесценный детектор -race, и что защитить состояние можно через sync.Mutex. Всё вместе это собирается в паттерн worker pool — рабочую лошадку конкурентного Go. Держи в голове девиз языка: не борись за разделяемую память под замками там, где данные можно просто передать по каналу.

Комментарии 0

Для добавления комментариев необходимо войти или зарегистрироваться.

Пока нет комментариев. Станьте первым!