Skip to main content

Command Palette

Search for a command to run...

Go: Goroutines and Concurrent Programming

Updated
10 min read
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
}

Результатный канал

Сделаем следующее:

  1. Запустим goroutine.

  2. В этой goroutine пройдёмся по словам, посчитаем цифры в каждом и запишем в канал counted (производитель).

  3. Во внешней функции читаем значения из канала и заполняем счётчик 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            канал        внешняя функция

Сделаем следующее:

  1. Запустим goroutine, которая получает слова из генератора и отправляет их в канал pending (reader).

  2. Запустим вторую goroutine, которая читает из pending, считает цифры и записывает в канал counted (worker).

  3. Во внешней функции читаем из 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
}

Чётко видны три логических блока:

  1. Отправка слов для подсчёта.

  2. Подсчёт цифр в словах.

  3. Заполнение итоговых результатов.

Было бы удобно выделить эти блоки в отдельные функции, которые обмениваются данными через каналы:

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 делает код более понятным и масштабируемым.

  • Паттерн выходного канала упрощает композицию конкурентных функций.

More from this blog

Go & DevOps Blog

24 posts

Backend Developer | Python | Go | gRPC | Kubernetes | Ansible | IaC

Go: Goroutines and Concurrent Programming