Go: Goroutines and Concurrent Programming

Эта статья представляет собой введение в конкурентное программирование на Go через практические примеры. Давайте сразу перейдем к написанию конкурентной программы!
Goroutines
Предположим, у нас есть функция, которая произносит фразу слово за словом с паузами:
// say выводит каждое слово фразы.
func say(phrase string) {
for _, word := range strings.Fields(phrase) {
fmt.Printf("Simon says: %s...\n", word)
dur := time.Duration(rand.Intn(100)) * time.Millisecond
time.Sleep(dur)
}
}
Вызовем её из функции main:
func main() {
say("go is awesome")
}
Теперь создадим двух говорящих, каждый со своей фразой:
// say выводит каждое слово фразы.
func say(id int, phrase string) {
for _, word := range strings.Fields(phrase) {
fmt.Printf("Worker #%d says: %s...\n", id, word)
dur := time.Duration(rand.Intn(100)) * time.Millisecond
time.Sleep(dur)
}
}
Запустим программу:
func main() {
say(1, "go is awesome")
say(2, "cats are cute")
}
Функции выполняются последовательно. Чтобы они говорили одновременно, добавим go перед вызовом функции say():
func main() {
go say(1, "go is awesome")
go say(2, "cats are cute")
time.Sleep(500 * time.Millisecond)
}
Теперь они действительно соревнуются за наше внимание! Когда мы пишем go f(), функция f() выполняется независимо от остальных.
Если вы знакомы с конкурентностью в Python, JavaScript или других языках с async/await, не пытайтесь применить этот опыт к Go. Go использует совершенно другой подход к конкурентности. Попробуйте взглянуть на это свежим взглядом.
Функции, запускаемые с go, называются goroutines. Среда выполнения Go управляет этими goroutines и распределяет их между потоками операционной системы, работающими на ядрах CPU. По сравнению с потоками ОС, goroutines легковесны, поэтому вы можете создавать сотни или тысячи их.
Вы можете задаться вопросом, зачем нам нужен time.Sleep() в функции main. Давайте это проясним.
Зависимые и независимые goroutines
Goroutines полностью независимы. Когда мы вызываем go say(), функция выполняется сама по себе. main не ждёт её завершения. Поэтому если мы напишем main так:
func main() {
go say(1, "go is awesome")
go say(2, "cats are cute")
}
— программа ничего не выведет. main завершается раньше, чем наши goroutines успевают что-то сказать, и поскольку main завершена, вся программа завершается.
Функция main также является goroutine, но она запускается неявно при старте программы. Таким образом, у нас есть три goroutines: main, say(1) и say(2), все они независимы. Единственная особенность в том, что когда main завершается, всё остальное тоже завершается.
WaitGroup
Использование time.Sleep() для ожидания goroutines — плохая идея, потому что мы не можем предсказать, сколько времени они займут. Лучший подход — использовать wait group:
func main() {
var wg sync.WaitGroup // (1)
wg.Add(1) // (2)
go say(&wg, 1, "go is awesome")
wg.Add(1) // (2)
go say(&wg, 2, "cats are cute")
wg.Wait() // (3)
}
// say выводит каждое слово фразы.
func say(wg *sync.WaitGroup, id int, phrase string) {
for _, word := range strings.Fields(phrase) {
fmt.Printf("Worker #%d says: %s...\n", id, word)
dur := time.Duration(rand.Intn(100)) * time.Millisecond
time.Sleep(dur)
}
wg.Done() // (4)
}
wg ➊ имеет внутри счётчик. Вызов wg.Add(1) ➋ увеличивает его на единицу, а wg.Done() ➍ уменьшает. wg.Wait() ➌ блокирует goroutine (в данном случае main) до тех пор, пока счётчик не достигнет нуля. Таким образом, main ждёт завершения say(1) и say(2) перед выходом.
Однако этот подход смешивает бизнес-логику (say) с логикой конкурентности (wg). В результате мы не можем легко запустить say в обычном, неконкурентном коде.
В Go принято разделять логику конкурентности и бизнес-логику. Обычно это делается с помощью отдельных функций. В простых случаях, как у нас, подойдут даже анонимные функции:
func main() {
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
say(1, "go is awesome")
}()
go func() {
defer wg.Done()
say(2, "cats are cute")
}()
wg.Wait()
}
Вот как это работает:
Мы знаем, что будет две goroutines, поэтому сразу вызываем
wg.Add(2). Альтернативно, мы можем вызыватьwg.Add(1)перед запуском каждой goroutine — результат будет тот же.Анонимные функции запускаются с
goтак же, как и обычные.defer wg.Done()гарантирует, что goroutine уменьшит счётчик перед выходом, даже еслиsayвызовет панику.Сама
sayничего не знает о конкурентности и просто работает.
WaitGroup.Go
Метод WaitGroup.Go (Go 1.25+) автоматически увеличивает счётчик wait group, запускает функцию в goroutine и уменьшает счётчик, когда она завершается. Это означает, что мы можем переписать пример выше без использования wg.Add() и wg.Done():
func main() {
var wg sync.WaitGroup
wg.Go(func() {
fmt.Println("go is awesome")
})
wg.Go(func() {
fmt.Println("cats are cute")
})
wg.Wait()
fmt.Println("done")
}
Реализация использует Add и Done так же, как мы делали раньше:
// https://github.com/golang/go/blob/master/src/sync/waitgroup.go
func (wg *WaitGroup) Go(f func()) {
wg.Add(1)
go func() {
defer wg.Done()
f()
}()
}
Channels
Запуск множества goroutines — это здорово, но как они обмениваются данными? В Go goroutines могут передавать значения друг другу через channels. Канал — это как окно, через которое одна goroutine может что-то бросить, а другая — поймать.
┌─────────────┐ ┌─────────────┐
│ goroutine A │ │ goroutine B │
│ └────┘ │
│ X <- chan <- X │
│ ┌────┐ │
│ │ │ │
└─────────────┘ └─────────────┘
Goroutine B отправляет значение X в goroutine A.
Вот как это работает:
func main() {
// Чтобы создать канал, используйте `make(chan type)`.
// Канал может принимать только значения указанного типа:
messages := make(chan string)
// Чтобы отправить значение в канал,
// используйте синтаксис `channel <-`.
// Отправим "ping":
go func() { messages <- "ping" }()
// Чтобы получить значение из канала,
// используйте синтаксис `<-channel`.
// Получим "ping" и выведем его:
msg := <-messages
fmt.Println(msg)
}
Когда программа выполняется, первая goroutine (анонимная) отправляет сообщение второй (main) через канал messages.
Отправка значения через канал — это синхронная операция. Когда отправляющая goroutine записывает значение в канал (messages <- "ping"), она блокируется и ждёт, пока кто-то получит это значение (<-messages). Только после этого она продолжает работу:
func main() {
messages := make(chan string)
go func() {
fmt.Println("B: Sending message...")
messages <- "ping" // (1)
fmt.Println("B: Message sent!") // (2)
}()
fmt.Println("A: Doing some work...")
time.Sleep(500 * time.Millisecond)
fmt.Println("A: Ready to receive a message...")
<-messages // (3)
fmt.Println("A: Message received!")
time.Sleep(100 * time.Millisecond)
}
После отправки сообщения в канал ➊ goroutine B блокируется. Только когда goroutine A получает сообщение ➌, goroutine B продолжает и выводит "message sent" ➋.
Таким образом, каналы не только передают данные, но и помогают синхронизировать независимые goroutines. Это пригодится позже.
Паттерн Producer-Consumer
В программировании часто встречается паттерн "производитель-потребитель":
Производитель поставляет данные.
Потребитель получает и обрабатывает их.
В следующих примерах мы исследуем, как производители и потребители могут взаимодействовать через каналы.
Работаем с функцией, которая считает цифры в словах:
// counter хранит количество цифр в каждом слове.
// Ключ — слово, значение — количество цифр.
type counter map[string]int
// countDigitsInWords считает количество цифр
// в словах фразы.
func countDigitsInWords(phrase string) counter {
words := strings.Fields(phrase)
// ...
return stats
}
Результатный канал
Сделаем следующее:
Запустим goroutine.
В этой goroutine пройдёмся по словам, посчитаем цифры в каждом и запишем в канал
counted(производитель).Во внешней функции читаем значения из канала и заполняем счётчик
stats(потребитель).
func countDigitsInWords(phrase string) counter {
words := strings.Fields(phrase)
counted := make(chan int)
go func() {
// Проходим по словам,
// считаем количество цифр в каждом,
// и записываем в канал counted.
}()
// Читаем значения из канала counted
// и заполняем stats.
return stats
}
Генератор
До сих пор мы предполагали, что функция countDigitsInWords заранее знает все слова.
Но в реальной жизни мы не можем рассчитывать на такую роскошь. Данные могут приходить из базы данных или по сети, и функция не знает, сколько слов будет.
Давайте смоделируем эту ситуацию, передав функцию-генератор next вместо фразы. Каждый вызов next() даёт нам следующее слово из источника. Когда слов больше нет, она возвращает пустую строку.
Последовательная программа выглядела бы так:
// counter хранит количество цифр в каждом слове.
// Ключ — слово, значение — количество цифр.
type counter map[string]int
// countDigitsInWords считает количество цифр в словах,
// получая следующее слово через next().
func countDigitsInWords(next func() string) counter {
stats := counter{}
for {
word := next()
if word == "" {
break
}
count := countDigits(word)
stats[word] = count
}
return stats
}
func main() {
phrase := "0ne 1wo thr33 4068"
next := wordGenerator(phrase)
stats := countDigitsInWords(next)
printStats(stats)
}
Теперь добавим конкурентность.
Генератор с goroutines
Если вы попытаетесь решить упражнение как предыдущее, столкнётесь с парой проблем:
func countDigitsInWords(next func() string) counter {
counted := make(chan int)
// считаем цифры в словах
go func() {
for {
// должна вернуться, когда
// слов больше нет
word := next()
count := countDigits(word)
counted <- count
}
}()
// заполняем stats словами
stats := counter{}
for {
count := <-counted
// как выйти из цикла,
// когда слов больше нет?
if ... {
break
}
// откуда должно браться слово?
stats[...] = count
}
return stats
}
Подумайте, что отправлять в канал counted, чтобы решить обе проблемы. Обратите внимание на тип пары.
Reader и Worker
Для более сложных задач полезно иметь goroutine для чтения данных (reader) и другую для обработки данных (worker). Используем этот подход в нашей функции:
┌───────────────┐ ┌───────────────┐
│ отправляет │ │ считает цифры │ ┌────────────────┐
│ слова для │ → (pending) → │ в словах │ → (counted) → │ заполняет stats│
│ подсчёта │ │ │ └────────────────┘
└───────────────┘ └───────────────┘
reader канал worker канал внешняя функция
Сделаем следующее:
Запустим goroutine, которая получает слова из генератора и отправляет их в канал
pending(reader).Запустим вторую goroutine, которая читает из
pending, считает цифры и записывает в каналcounted(worker).Во внешней функции читаем из
countedи обновляем итоговый счётчикstats.
func countDigitsInWords(next func() string) counter {
pending := make(chan string)
counted := make(chan pair)
// отправляет слова для подсчёта
go func() {
// Получаем слова из генератора
// и отправляем их в канал pending.
}()
// считает цифры в словах
go func() {
// Читаем слова из канала pending,
// считаем количество цифр в каждом слове,
// и отправляем результаты в канал counted.
}()
// Читаем значения из канала counted
// и заполняем stats.
return stats
}
Именованные goroutines
После разделения логики между reader и worker функция стала довольно объёмной:
func countDigitsInWords(next func() string) counter {
// ...
// отправляет слова для подсчёта
go func() {
// ...
}()
// считает цифры в словах
go func() {
// ...
}()
// заполняет stats
// ...
return stats
}
Чётко видны три логических блока:
Отправка слов для подсчёта.
Подсчёт цифр в словах.
Заполнение итоговых результатов.
Было бы удобно выделить эти блоки в отдельные функции, которые обмениваются данными через каналы:
func countDigitsInWords(next func() string) counter {
pending := make(chan string)
go submitWords(next, pending)
counted := make(chan pair)
go countWords(pending, counted)
return fillStats(counted)
}
Выходной канал
Вот функция, к которой мы пришли:
// countDigitsInWords считает количество цифр в словах,
// получая следующее слово через next().
func countDigitsInWords(next func() string) counter {
pending := make(chan string)
go submitWords(next, pending)
counted := make(chan pair)
go countWords(pending, counted)
return fillStats(counted)
}
Она выглядит хорошо, но есть ещё одна вещь, которую мы могли бы изменить.
Канал pending создаётся в родительской функции только для передачи в дочернюю функцию submitWords. Лучше было бы создать канал в submitWords и вернуть его родителю, чтобы submitWords полностью владела им. То же самое относится к каналу counted и функции countWords.
Тогда countDigitsInWords будет выглядеть так:
func countDigitsInWords(next func() string) counter {
pending := submitWords(next)
counted := countWords(pending)
return fillStats(counted)
}
Теперь владение ясно, и программу легче понимать. Но куда делись все goroutines? Мы запускаем их внутри submitWords и countWords так:
// submitWords отправляет слова для подсчёта.
func submitWords(next func() string) chan string {
out := make(chan string)
go func() {
for {
word := next()
out <- word
if word == "" {
break
}
}
}()
return out
}
// countWords считает цифры в словах.
func countWords(in chan string) chan pair {
out := make(chan pair)
go func() {
for {
word := <-in
count := countDigits(word)
out <- pair{word, count}
if word == "" {
break
}
}
}()
return out
}
Убедимся, что программа всё ещё работает как ожидается:
func main() {
phrase := "0ne 1wo thr33 4068"
next := wordGenerator(phrase)
stats := countDigitsInWords(next)
printStats(stats)
}
У этого подхода есть недостаток: submitWords и countWords теперь более сложные и запускают goroutines. С другой стороны, countDigitsInWords проще и надёжнее (особенно если мы сделаем каналы направленными, что мы обсудим позже). Какой вариант выбрать, зависит от ваших предпочтений, но определённо не стоит смешивать два метода.
Возврат выходного канала из функции и заполнение его внутри внутренней goroutine — это распространённый паттерн в Go.
Резюме
Goroutines — это легковесные потоки выполнения в Go, которые позволяют писать конкурентные программы.
WaitGroup используется для синхронизации и ожидания завершения нескольких goroutines.
Channels обеспечивают безопасную передачу данных между goroutines и их синхронизацию.
Разделение логики на reader и worker делает код более понятным и масштабируемым.
Паттерн выходного канала упрощает композицию конкурентных функций.






