Механика горутин и каналов в Golang: разбор с примерами
- вторник, 11 февраля 2025 г. в 00:00:10
Запускайте каждый пример: Не нужно просто читать код. Напечатайте его, запустите, разберитесь в поведении.
Экспериментируйте и ломайте: Избавьтесь от каких то этапов, измените буфер каналов, модифицируйте счетчик горутин. Если что-то сломалось, это может помочь лучше понять, как оно работает.
Разберись в поведении: Прежде чем запустить измененный код, постарайся предсказать результат. Увидев неожиданное поведение разберись. Не принимай на веру объяснения критикуй и проверяй.
Визуализируй связи: Каждая визуализация иллюстрирует идею . Старайся рисовать свои связи когда изменяешь код
Это первая часть нашей серии "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
должен завершиться прежде чем начнется следующий. Давайте наглядно:
Мы можем снизить это время, давай внесем изменения в код, добавим горутины:
важно: перед вызовом функции используется `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 дождалась окончания работы горутин. Сделать мы это можем несколькими путями:
Подождать несколько секунд(костыль)
Использовать WaitGroup
(правильный способ, о нем дальше)
Используем каналы (о них дальше)
Подождем несколько. секунд чтобы горутины завершились.
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)
Проблема тут в том, что мы можем не знать сколько времени горутине необходимо на выполнение задачи. В нашем случае время константно, но мы не будем знать его в реальных кейсах.
Для управления конкурентностью в го применяется `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)
}
Мы немного разобрались как горутины работают. Теперь узнаем как две горутины взаимодействуют. Пришло время узнать про каналы.
Каналы это мощные примитивы взаимодействия между горутинами предоставляющий возможность безопасного обмена данными.
Представь что каналы это "трубы": одна горутина может послать данные в эту трубу и другая горутина сможет эти данные из нее забрать.
вот некоторые свойства каналов
Каналы это блокировки по своей сути
Запись в канал ch <- value
блокирует main пока другая горутина не прочтет из канала .
Чтение из канала <-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.
Теперь разберем.
main создает канал done
запуск функции в трех горутинах
каждая горутина получает ссылку на один и тот же канал
Все три горутины запускаются конкурентно
каждая останавливается на 2 секунды
порядок завершения может быть любой
Main заходит в цикл for i := 0; i < 3; i++
Каждый <-done
(без отправителя) по сути блокировка для main
порядок завершения может быть любой
<-done блокирует main если там нет данных, даже если main дойдет до цикла раньше горутин, дедлока не будет, горутина отправит в канал done сигнал, а main прочет и пойдет на следующую итерацию цикла
Так повторится трижды
Какая горутина прийдет первой или последней не имеет значения
⭐ Каждая запись (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!")
}
маин инициализирует 3 канала
ch
- для сообщений
senderDone
- сигнал о завершении
receiverDone
- сигнал о завершении
маин запускает 2 горутины
sender
receiver
Маин блокируется и ждет сигналов о завершении
sender отправляет "message 1"
в канал ch
.
receiver просыпается и обрабатывает сообщение:
Выводит: "Received: message 1"
.
Отправитель засыпает на 100 мс.
sender просыпается и отправляет "message 2"
в канал ch
.
receiver обрабатывает сообщение:
Выводит: "Received: message 2"
.
Отправитель снова засыпает на 100 мс.
sender просыпается и отправляет "message 3"
в канал ch
.
receiver обрабатывает сообщение:
Выводит: "Received: message 3"
.
Отправитель засыпает в последний раз.
Отправитель завершает сон и закрывает канал ch
.
Отправитель отправляет сигнал true
в канал senderDone
, указывая на завершение работы.
Получатель обнаруживает, что канал ch
закрыт.
Получатель выходит из цикла for-range
.
маин получает сигнал от senderDone
и прекращает ожидание.
маин блокируется до сигнала от receiverDone
.
Получатель отправляет сигнал о завершении в канал receiverDone
.
маин получает сигнал и выводит:
"All operations completed!"
.
Программа завершается.
Небуферизированные каналы блокируют как отправителя, так и получателя, пока другая сторона не будет готова. когда требуется высокочастотная передача данных, небуферизированные каналы могут стать узким местом, поскольку обе горутины вынуждены простаивать для обмена данными.
FIFO (первый зашел первый вышел - очередь)
Фиксированный размер, задается при создании
Блокирует отправителя если канал (буфер) заполнен
Блокирует получателя если канал пуст
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 читает из канала только дважды попытка записи в заполненный канал приведет к блоку
если раскомментировать запись в канал получим ошибку
Буферизированный канал | Канал без буфера | |
Цель | Чтобы не блокировать отправителя и получателя | Чтобы блокировать(синхронизировать) отправителя и получателя |
Когда использовать | Отправитель может продолжить работу не дожидаясь получателя | Отправитель не может продолжить работу без получателя |
Если буфер увеличивать пропускную способность | Важно чтобы обработка сообщений происходила немедленно | |
Блокирующее поведение | Блокируется если буфер полон | Блокируется отправитель пока принимающий не готов и наоборот |
Производительность | Может увеличить производительность за счет уменьшения синхронизации | Возможны задержки из-за синхронизации |
Примеры использования | Логирование с ограничением скорости обработки | Передача сигналов между горутинами. |
пакетная обработка, когда сообщения ставятся в очередь | передача данных без задержек | |
Сложность | Требует внимательной настройки буфера из-за переполнения | Все просто и понятно. |
Накладные расходы | Необходима доп память на буфер | доп память не нужна |
Модель конкурентности | Асинхронное взаимодействие | Строго синхронное выполнение |
Проблемные сценарии | Дедлок при переполнении | Дедлок при отсутствии отправителя/получателя |
Вам нужно развязать отправителя и получателя по времени.
Производительность может выиграть за счёт пакетной обработки или очередей сообщений.
Приложение может терпеть задержки в обработке сообщений, когда буфер заполнен.
Синхронизация между горутинами критически важна.
Вам нужна простота и немедленная передача данных.
Взаимодействие между отправителем и получателем должно происходить мгновенно.
В следующих публикациях рассмотрим:
📌 Следующий пост:
Паттерны конкурентности
Мьютексы и синхронизация памяти