golang

Механика горутин и каналов в Golang: разбор с примерами

  • вторник, 11 февраля 2025 г. в 00:00:10
https://habr.com/ru/articles/881014/
Как работать с данной статьей
  1. Запускайте каждый пример: Не нужно просто читать код. Напечатайте его, запустите, разберитесь в поведении.

  2. Экспериментируйте и ломайте: Избавьтесь от каких то этапов, измените буфер каналов, модифицируйте счетчик горутин. Если что-то сломалось, это может помочь лучше понять, как оно работает.

  3. Разберись в поведении: Прежде чем запустить измененный код, постарайся предсказать результат. Увидев неожиданное поведение разберись. Не принимай на веру объяснения критикуй и проверяй.

  4. Визуализируй связи: Каждая визуализация иллюстрирует идею . Старайся рисовать свои связи когда изменяешь код

Это первая часть нашей серии "Mastering Go Concurrency". В ней мы поговорим о:

  • Как устроены горутины и их жизненный цикл

  • Общение горутин посредством каналов

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

  • Практические примеры и визуализации

Мы начнем с начала и постепенно будем двигаться вперед, в процессе улучшая насмотренность и интуицию для их эффективного применения.

Это будет немного долго, скорее очень долго, так что наберитесь терпения.

Мы весь процесс будем работать с примерами

Основы горутин

Давайте начнем с простой программы для загрузки нескольких файлов.

package main

import (
    "fmt"
    "time"
)

func downloadFile(filename string) {
    fmt.Printf("Starting download: %s\n", filename)
    // Simulate file download with sleep
    time.Sleep(2 * time.Second)
    fmt.Printf("Finished download: %s\n", filename)
}

func main() {
    fmt.Println("Starting downloads...")

    startTime := time.Now()

    downloadFile("file1.txt")
    downloadFile("file2.txt")
    downloadFile("file3.txt")

    elapsedTime := time.Since(startTime)

    fmt.Printf("All downloads completed! Time elapsed: %s\n", elapsedTime)

// Starting downloads...
// Starting download: filel.txt
// Finished download: filel.txt
// Starting download: file2.txt
// Finished download: file2.txt
// Starting download: file3.txt
// Finished download: file3.txt
// All downloads completed! Time elapsed: 6s
// Program exited.

тык

Выполнение заняло 6 секунд, каждый вызов downloadFile должен завершиться прежде чем начнется следующий. Давайте наглядно:

Каждый файл открывается в течении 2 секунд, последовательно. Итого 6с
Каждый файл открывается в течении 2 секунд, последовательно. Итого 6с

Мы можем снизить это время, давай внесем изменения в код, добавим горутины:

важно: перед вызовом функции используется `go`

package main

import (
    "fmt"
    "time"
)

func downloadFile(filename string) {
    fmt.Printf("Starting download: %s\n", filename)
    // Simulate file download with sleep
    time.Sleep(2 * time.Second)
    fmt.Printf("Finished download: %s\n", filename)
}

func main() {
    fmt.Println("Starting downloads...")

    // Launch downloads concurrently
    go downloadFile("file1.txt")
    go downloadFile("file2.txt")
    go downloadFile("file3.txt")

    fmt.Println("All downloads completed!")
}

// Starting downloads...
// All downloads completed!
// Program exited.

тык

Погоди, ничего не напечаталось, как считаешь почему?

Давай наглядно.

Основная горутина закончила свою работу раньше остальных.
Основная горутина закончила свою работу раньше остальных.

Мы понимаем, что функция main завершилась ДО того как отработал горутины. Первый вывод:

Все горутины зависят от функции main

Важно понимать: Функци main по сути та же горутина.

Чтобы исправить ситуацию нам нужно, чтобы main дождалась окончания работы горутин. Сделать мы это можем несколькими путями:

  1. Подождать несколько секунд(костыль)

  2. Использовать WaitGroup (правильный способ, о нем дальше)

  3. Используем каналы (о них дальше)

Подождем несколько. секунд чтобы горутины завершились.

package main

import (
    "fmt"
    "time"
)

func downloadFile(filename string) {
    fmt.Printf("Starting download: %s\n", filename)
    // Simulate file download with sleep
    time.Sleep(2 * time.Second)
    fmt.Printf("Finished download: %s\n", filename)
}

func main() {
    fmt.Println("Starting downloads...")

    startTime := time.Now() // Record start time

    go downloadFile("file1.txt")
    go downloadFile("file2.txt")
    go downloadFile("file3.txt")

    // Ожидаем завершения горутин
    time.Sleep(3 * time.Second)

    elapsedTime := time.Since(startTime)

    fmt.Printf("All downloads completed! Time elapsed: %s\n", elapsedTime)

тык

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

Переходим к WaitGroup

Для управления конкурентностью в го применяется `sync.WaitGroup`, благодаря wg можно ожидать завершение горутин.

package main

import (
    "fmt"
    "sync"
    "time"
)

func downloadFile(filename string, wg *sync.WaitGroup) {
    // Сообщим wg о том что мы закончили перед выходом из функции
    defer wg.Done()

    fmt.Printf("Starting download: %s\n", filename)
    time.Sleep(2 * time.Second)
    fmt.Printf("Finished download: %s\n", filename)
}

func main() {
    fmt.Println("Starting downloads...")

    var wg sync.WaitGroup

    // Сообщим wg что мы собираемся запускать 3 горутины
    wg.Add(3)

    go downloadFile("file1.txt", &wg)
    go downloadFile("file2.txt", &wg)
    go downloadFile("file3.txt", &wg)

    // Ждем завершения всех горутин
    wg.Wait()

    fmt.Println("All downloads completed!")
}

// Starting downloads...
// Starting download: file3.txt
// Starting download: filel.txt
// Starting download: file2.txt
// Finished download: file3.txt
// Finished download: filel.txt
// Finished download: file2.txt
// All downloads completed!
// Program exited.

тык

Теперь наглядно:

Механизм счетчика

  • WaitGroup инициализирует внутренний счетчик

  • wg.Add(n) увеличивает счетчик на n

  • wg.Done() уменьшает счетчик на 1

  • wg.Wait() блокирует main до тех пор пока счетчик не станет 0

Механизм синхронизации

  • main вызывает add(3) до запуска горутин

  • Каждая горутина вызывает Done() по завершению функции (defer wg.Done())

  • main блокируется до тех пор пока счетчик wait() не станет 0

  • Когда счетчик становится равным 0, блокировка main снимается и программа может завершаться

Частые ошибки
// ТАК ДЕЛАТЬ НЕЛЬЗЯ 
go downloadFile("file1.txt", &wg)
wg.Add(1)  // Нарушен порядок!

// ТАК ДЕЛАТЬ НЕЛЬЗЯ
wg.Add(2)  // Нарушен счетчик
go downloadFile("file1.txt", &wg)
go downloadFile("file2.txt", &wg)
go downloadFile("file3.txt", &wg)

// ТАК ДЕЛАТЬ НЕЛЬЗЯ
func downloadFile(filename string, wg *sync.WaitGroup) {
    // не указан wg.Done()
    fmt.Printf("Downloading: %s\n", filename)
}

Каналы

Мы немного разобрались как горутины работают. Теперь узнаем как две горутины взаимодействуют. Пришло время узнать про каналы.

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

Представь что каналы это "трубы": одна горутина может послать данные в эту трубу и другая горутина сможет эти данные из нее забрать.

вот некоторые свойства каналов

  1. Каналы это блокировки по своей сути

  2. Запись в канал ch <- value блокирует main пока другая горутина не прочтет из канала .

  3. Чтение из канала <-ch блокирует main пока другая горутина не запишет из канала.

package main

import "fmt"

func main() {
    // создаем канал
    ch := make(chan string)

    // Отправляем данные в канал(main блокируется)
    ch <- "hello"  // тут deadlock!

    // получение данных из канала
    msg := <-ch
    fmt.Println(msg)
}
//fatal error: all goroutines are asleep - deadlock!
//goroutine 1 [chan send]:
//main.main ()
// / tmp/sandbox364224576/prog.go: 10 +0x36
//Program exited.

тык

почему произошел дедлок в ch <- "hello" ? Поскольку каналы это механизмы блокировки/синхронизации запись в канал заблокирует выполнение main до тех пор пока другая горутина не прочтет из него, но другой горутины нет и мы заблокировались навсегда - deadlock.

Необходимо это исправить

package main

import "fmt"

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

    // создадим горутину для записи в канал
    go func() {
        ch <- "hello"  // в этот раз блокировки не будет main тоже горутина
    }()

    // main прочитает данные 
    msg := <-ch  // блокировка до момента чтения
    fmt.Println(msg)
}

тык

На этот раз благодаря другой горутине, main горутина не заблокировалась и смогла прочесть сообщение из канала, но горутина записавшая данные в канал была заблокирована, пока main не прочел данные из канала (то есть горутины синхронизировались)

Исправим проблему связанную с тем, что main не дожидался завершения остальных горутин, воспользуемся новыми знаниями.

package main

import (
    "fmt"
    "time"
)

func downloadFile(filename string, done chan bool) {
    fmt.Printf("Starting download: %s\n", filename)
    time.Sleep(2 * time.Second)
    fmt.Printf("Finished download: %s\n", filename)

    done <- true // отправляем сигнал о завершении
}

func main() {
    fmt.Println("Starting downloads...")

    startTime := time.Now()

    // создаем канал для отслеживания статуса горутин
    done := make(chan bool)

    go downloadFile("file1.txt", done)
    go downloadFile("file2.txt", done)
    go downloadFile("file3.txt", done)

    // Ждем пока все горутины сигнализируют о закрытии
    for i := 0; i < 3; i++ {
        <-done // Получаем сигнал от каждой завершенной горутины 
    }

    elapsedTime := time.Since(startTime)
    fmt.Printf("All downloads completed! Time elapsed: %s\n", elapsedTime)
}

//Starting downloads...
//Starting download: file3.txt
//Starting download: file2.txt
//Starting download: filel.txt
//Finished download: filel.txt
//Finished download: file2.txt
//Finished download: file3.txt
//All downloads completed! Time elapsed: 2s
//Program

тык

можно потыкаться, и предсказать что будет если изменить значение в цикле с 3 на 2.

Теперь разберем.

Запуск

  1. main создает канал done

  2. запуск функции в трех горутинах

  3. каждая горутина получает ссылку на один и тот же канал

Выполнение функции

  1. Все три горутины запускаются конкурентно

  2. каждая останавливается на 2 секунды

  3. порядок завершения может быть любой

Цикл

  1. Main заходит в цикл for i := 0; i < 3; i++

  2. Каждый <-done (без отправителя) по сути блокировка для main

  3. порядок завершения может быть любой

Цикл

  1. <-done блокирует main если там нет данных, даже если main дойдет до цикла раньше горутин, дедлока не будет, горутина отправит в канал done сигнал, а main прочет и пойдет на следующую итерацию цикла

  2. Так повторится трижды

  3. Какая горутина прийдет первой или последней не имеет значения

⭐ Каждая запись (done <- true) имеет ровно столько же чтений (<-done)

⭐ Main синхронизируется с горутинами через цикл

Как две горутины могут общаться

Мы уже наблюдали как горутины общаются между собой. Ведь main это тоже горутина!

package main

import (
    "fmt"
    "time"
)

func sender(ch chan string, done chan bool) {
    for i := 1; i <= 3; i++ {
        ch <- fmt.Sprintf("message %d", i)
        time.Sleep(100 * time.Millisecond)
    }
    close(ch) // Закрываем канал после завершения отправки
    done <- true
}

func receiver(ch chan string, done chan bool) {
        // range позволяет читать канал пока он не закроется(главное его закрыть)
    for msg := range ch {
        fmt.Println("Received:", msg)
    }
    done <- true
}

func main() {
    ch := make(chan string)
    senderDone := make(chan bool)
    receiverDone := make(chan bool)

    go sender(ch, senderDone)
    go receiver(ch, receiverDone)

    // блокируемся до тех пор пока не отработают функции
    <-senderDone
    <-receiverDone

    fmt.Println("All operations completed!")
}

потыкать код

Выполнение

Запуск(t=0ms)

  • маин инициализирует 3 канала

    ch - для сообщений

    senderDone - сигнал о завершении

    receiverDone - сигнал о завершении

  • маин запускает 2 горутины

    sender

    receiver

  • Маин блокируется и ждет сигналов о завершении

    Первое сообщение (t=1ms)

    1. sender отправляет "message 1" в канал ch.

    2. receiver просыпается и обрабатывает сообщение:

      • Выводит: "Received: message 1".

    3. Отправитель засыпает на 100 мс.

    Второе сообщение (t=101ms)

    1. sender просыпается и отправляет "message 2" в канал ch.

    2. receiver обрабатывает сообщение:

      • Выводит: "Received: message 2".

    3. Отправитель снова засыпает на 100 мс.

    Третье сообщение (t=201ms)

    1. sender просыпается и отправляет "message 3" в канал ch.

    2. receiver обрабатывает сообщение:

      • Выводит: "Received: message 3".

    3. Отправитель засыпает в последний раз.

    Закрытие канала (t=301ms)

    1. Отправитель завершает сон и закрывает канал ch.

    2. Отправитель отправляет сигнал true в канал senderDone, указывая на завершение работы.

    3. Получатель обнаруживает, что канал ch закрыт.

    4. Получатель выходит из цикла for-range.

    Завершение (t=302-303ms)

    1. маин получает сигнал от senderDone и прекращает ожидание.

    2. маин блокируется до сигнала от receiverDone.

    3. Получатель отправляет сигнал о завершении в канал receiverDone.

    4. маин получает сигнал и выводит:

      • "All operations completed!".

    5. Программа завершается.

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

Зачем нам они нужны?

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

Свойства

  1. FIFO (первый зашел первый вышел - очередь)

  2. Фиксированный размер, задается при создании

  3. Блокирует отправителя если канал (буфер) заполнен

  4. Блокирует получателя если канал пуст

package main

import (
    "fmt"
    "time"
)

func main() {
    // создали буфф канал вместимостью 2
    ch := make(chan string, 2)

    // отправляем два сообщения. блокировки не будет буффер вмещает
    ch <- "first"
    fmt.Println("Sent first message")
    ch <- "second"
    fmt.Println("Sent second message")

    // При попытке отправить третье сообщение будет блок
    // ch <- "third"  // разкомментируй

    // читаем из канала
    fmt.Println(<-ch)  // "first"
    fmt.Println(<-ch)  // "second"

// Sent first message
// Sent second message first second
// Program exited.
}

тык

Почему не заблокировалась главная горутина (main)?

  1. буферизированный канал не блокирует отправителя пока не заполнится буфер.

  2. Размер буфера определяет сколько сообщений поместится в канал до блокировки.

  3. Буфер уже заполнен двумя сообщениями и без конкурентного чтения попытка записи в заполненный канал приведет к дедлоку на этапе компиляции(компилятор это заметит)

  4. Так как main читает из канала только дважды попытка записи в заполненный канал приведет к блоку

если раскомментировать запись в канал получим ошибку

Когда какой канал использовать?

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

Канал без буфера

Цель

Чтобы не блокировать отправителя и получателя

Чтобы блокировать(синхронизировать) отправителя и получателя

Когда использовать

Отправитель может продолжить работу не дожидаясь получателя

Отправитель не может продолжить работу без получателя

Если буфер увеличивать пропускную способность

Важно чтобы обработка сообщений происходила немедленно

Блокирующее поведение

Блокируется если буфер полон

Блокируется отправитель пока принимающий не готов и наоборот

Производительность

Может увеличить производительность за счет уменьшения синхронизации

Возможны задержки из-за синхронизации

Примеры использования

Логирование с ограничением скорости обработки

Передача сигналов между горутинами.

пакетная обработка, когда сообщения ставятся в очередь

передача данных без задержек

Сложность

Требует внимательной настройки буфера из-за переполнения

Все просто и понятно.

Накладные расходы

Необходима доп память на буфер

доп память не нужна

Модель конкурентности

Асинхронное взаимодействие

Строго синхронное выполнение

Проблемные сценарии

Дедлок при переполнении

Дедлок при отсутствии отправителя/получателя

Основные выводы

✅ Используйте буферизированные каналы, если:

  1. Вам нужно развязать отправителя и получателя по времени.

  2. Производительность может выиграть за счёт пакетной обработки или очередей сообщений.

  3. Приложение может терпеть задержки в обработке сообщений, когда буфер заполнен.

✅ Используйте небуферизированные каналы, если:

  1. Синхронизация между горутинами критически важна.

  2. Вам нужна простота и немедленная передача данных.

  3. Взаимодействие между отправителем и получателем должно происходить мгновенно.


Эти базовые принципы закладывают основу для более сложных тем.

В следующих публикациях рассмотрим:

📌 Следующий пост:

  1. Паттерны конкурентности

  2. Мьютексы и синхронизация памяти