Конкурентность в Go: горутины
Одной из самых известных особенностей языка Go является встроенная поддержка конкурентного выполнения программ.
В современных приложениях часто требуется выполнять несколько задач одновременно:
- обрабатывать несколько пользователей веб-сервера;
- загружать файлы из разных источников;
- отправлять запросы к нескольким сервисам;
- обрабатывать сообщения из очередей;
- выполнять фоновые задачи.
В традиционных языках для этого обычно используются потоки операционной системы (threads). Go предлагает более простой механизм — горутины (goroutines).
Что такое горутина?
Горутина — это лёгкая задача, которая выполняется параллельно с другими частями программы и управляется самим Go Runtime.
Запустить функцию в отдельной горутине можно с помощью ключевого слова go.
package main
import "fmt"
func hello() {
fmt.Println("Hello from goroutine")
}
func main() {
go hello()
fmt.Println("Hello from main")
}
В этом примере функция hello() запускается отдельно от основной функции main().
Возможный результат выполнения:
Hello from main
Hello from goroutine
Но порядок вывода не гарантирован. Например, программа может вывести:
Hello from goroutine
Hello from main
Горутина запускается независимо от основной программы. Если функция main() завершится, все запущенные горутины будут остановлены, даже если они ещё не закончили работу.
Сравнение потоков и горутин
| Поток ОС | Горутина | |
|---|---|---|
| Кто управляет | Операционная система | Среда выполнения Go |
| Размер памяти | Обычно мегабайты | Начинается примерно с нескольких килобайт |
| Создание | Относительно дорогое | Очень быстрое |
| Количество | Обычно ограниченное | Можно создавать тысячи и миллионы |
Именно поэтому Go позволяет легко запускать большое количество одновременно работающих задач.
Вместо создания большого количества тяжёлых потоков Go использует множество лёгких горутин, которые распределяются между потоками операционной системы.
Запуск нескольких горутин
Например, программа может запустить несколько одинаковых задач:
package main
import "fmt"
func worker(number int) {
fmt.Println("Worker", number)
}
func main() {
for i := 1; i <= 5; i++ {
go worker(i)
}
}
В данном примере создаются пять независимых горутин. Каждая выполняет функцию worker() со своим параметром.
Порядок вывода может отличаться при каждом запуске:
Worker 3
Worker 1
Worker 5
Worker 2
Worker 4
Это нормально: планировщик Go сам решает, какая горутина получит процессорное время первой.
Проблема обмена данными
Часто горутины должны не просто работать одновременно, а обмениваться результатами.
Например:
- одна горутина скачивает файл;
- другая обрабатывает полученные данные;
- третья сохраняет результат в базу данных.
Можно использовать общую переменную:
var result int
go func() {
result = 100
}()
fmt.Println(result)
Однако такой код опасен. Две горутины могут одновременно обращаться к одной области памяти, что приводит к ошибкам, называемым гонками данных (data race).
В Go стараются не организовывать обмен данными через общую память. Вместо этого используется специальный механизм — каналы.
Каналы (Channels)
Канал — это специальный объект, который позволяет горутинам безопасно обмениваться данными.
Создать канал можно с помощью функции make().
ch := make(chan int)
Этот канал предназначен для передачи целых чисел.
Отправка данных в канал
ch <- 100
Оператор <- означает передачу значения в канал.
Получение данных из канала
x := <-ch
Получение значения из канала автоматически ожидает, пока данные станут доступны.
Первый пример с каналом
package main
import "fmt"
func worker(ch chan int) {
ch <- 42
}
func main() {
ch := make(chan int)
go worker(ch)
result := <-ch
fmt.Println(result)
}
Результат выполнения:
42
Что происходит:
- Создаётся канал для передачи чисел.
- Запускается горутина worker().
- Горутина отправляет число 42 в канал.
- Главная программа получает это число.
Основная философия Go:
"Don't communicate by sharing memory; share memory by communicating."
Не передавайте данные через общую память — передавайте их через каналы.
Буферизированные каналы
В предыдущем примере использовался обычный канал без буфера. Такой канал работает синхронно: отправитель ждёт, пока получатель заберёт данные.
Иногда это удобно, но бывают ситуации, когда нужно временно сохранить несколько значений. Для этого существуют буферизированные каналы.
При создании канала можно указать его размер:
ch := make(chan int, 3)
Такой канал может хранить до трёх значений без ожидания получателя.
Например:
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 10
ch <- 20
ch <- 30
fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
}
Результат:
10
20
30
В этом случае отправка значений не блокируется, потому что в канале есть свободное место.
Если буфер заполнен, следующая отправка снова будет ждать, пока кто-нибудь не заберёт значение из канала.
Закрытие каналов
Иногда получателю нужно узнать, что больше данных не будет. Для этого канал можно закрыть с помощью функции close().
close(ch)
После закрытия канала новые значения отправлять нельзя.
ch <- 10
close(ch)
ch <- 20 // ошибка
Однако уже отправленные значения можно получить.
Получение данных из закрытого канала
При получении значения из канала можно дополнительно проверить, был ли канал закрыт.
value, ok := <-ch
Переменная ok будет иметь значение:
- true — значение получено;
- false — канал закрыт и данных больше нет.
Пример:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 100
ch <- 200
close(ch)
for {
value, ok := <-ch
if !ok {
break
}
fmt.Println(value)
}
}
Результат:
100
200
Цикл range по каналу
Вместо ручной проверки переменной ok чаще используется цикл range.
for value := range ch {
fmt.Println(value)
}
Такой цикл автоматически получает значения из канала и завершится, когда канал будет закрыт.
Пример с горутиной:
package main
import "fmt"
func worker(ch chan int) {
for i := 1; i <= 5; i++ {
ch <- i
}
close(ch)
}
func main() {
ch := make(chan int)
go worker(ch)
for value := range ch {
fmt.Println(value)
}
}
Результат:
1
2
3
4
5
Обычно закрывать канал должна та горутина, которая отправляет данные. Получатель обычно только читает из канала.
Направленные каналы
По умолчанию канал является двунаправленным: из него можно читать и в него можно писать.
chan int
Но иногда полезно ограничить использование канала. Например, функция может только отправлять данные.
Канал только для отправки
chan<- int
Канал только для получения
<-chan int
Пример:
func producer(out chan<- int) {
out <- 100
}
Теперь внутри функции невозможно случайно прочитать данные из канала.
Направленные каналы делают код безопаснее и помогают понять, какая часть программы отвечает за отправку данных, а какая — за получение.
Ожидание нескольких каналов: select
В реальных программах часто бывает несколько источников данных. Например:
- пришёл ответ от сервера;
- поступило новое сообщение;
- истёк таймер;
- пользователь отменил операцию.
Обычный оператор получения из канала может ждать только один канал. Для ожидания нескольких каналов используется конструкция select.
Конструкция select
Оператор select в Go похож на оператор switch, но вместо обычных условий он работает с операциями каналов.
Он позволяет одновременно ожидать несколько событий: какой канал первым получит данные — тот вариант и будет выполнен.
select {
case value := <-channel1:
// обработка данных из channel1
case value := <-channel2:
// обработка данных из channel2
}
Если данные доступны сразу в нескольких каналах, Go выберет один из вариантов случайным образом.
Пример использования select
Рассмотрим программу с двумя горутинами, которые отправляют данные с разной задержкой.
package main
import (
"fmt"
"time"
)
func worker1(ch chan string) {
time.Sleep(time.Second)
ch <- "Первый результат"
}
func worker2(ch chan string) {
time.Sleep(2 * time.Second)
ch <- "Второй результат"
}
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go worker1(ch1)
go worker2(ch2)
select {
case result := <-ch1:
fmt.Println(result)
case result := <-ch2:
fmt.Println(result)
}
}
В данном примере:
- первая горутина отправляет результат через 1 секунду;
- вторая — через 2 секунды;
- программа получает первый доступный результат.
Результат:
Первый результат
Такой подход используется, например, когда нужно отправить запрос нескольким серверам и использовать самый быстрый ответ.
Ветка default
В конструкции select может присутствовать специальная ветка default.
select {
case value := <-ch:
fmt.Println(value)
default:
fmt.Println("Данных пока нет")
}
Если ни один канал сейчас не готов, выполнится блок default.
Без default программа будет ждать. С default проверка становится неблокирующей.
Таймеры и тайм-ауты
Одна из самых частых задач в серверных приложениях — ограничить время ожидания ответа.
Например, если внешний сервис не ответил за 3 секунды, нужно прекратить ожидание.
Для этого часто используется функция time.After().
select {
case result := <-serverResponse:
fmt.Println(result)
case <-time.After(3 * time.Second):
fmt.Println("Время ожидания истекло")
}
Теперь программа будет ждать либо ответ сервера, либо завершение таймера.
Практически любой серьёзный сетевой сервис использует тайм-ауты. Без них одна зависшая операция может навсегда занять ресурс программы.
Бесконечный цикл с select
Очень часто select используется внутри бесконечного цикла для обработки событий.
for {
select {
case message := <-messages:
fmt.Println(message)
case command := <-commands:
fmt.Println(command)
}
}
Такой код может работать постоянно:
- ждать новые сообщения;
- реагировать на команды;
- обрабатывать события.
Подобный подход лежит в основе многих серверных приложений.
Worker Pool — практический пример
Один из самых популярных способов применения горутин — создание пула рабочих процессов (worker pool).
Идея простая:
- Есть очередь задач.
- Несколько горутин берут задачи из очереди.
- Каждая выполняет свою работу.
- Результаты отправляются обратно.
Например, есть список чисел, которые нужно обработать. Создадим несколько работников.
package main
import "fmt"
func worker(id int, jobs chan int, results chan int) {
for job := range jobs {
fmt.Println(
"Worker",
id,
"обрабатывает",
job,
)
results <- job * 2
}
}
func main() {
jobs := make(chan int, 5)
results := make(chan int, 5)
for i := 1; i <= 3; i++ {
go worker(i, jobs, results)
}
for i := 1; i <= 5; i++ {
jobs <- i
}
close(jobs)
for i := 1; i <= 5; i++ {
fmt.Println(<-results)
}
}
В этой программе:
- создаются 3 рабочих горутины;
- им передаются 5 задач;
- каждая задача выполняется только одним worker;
- результаты собираются через отдельный канал.
Worker Pool используется в веб-серверах, обработке файлов, очередях сообщений, обработке изображений и фоновых задачах.
Горутины и C# async/await
Разработчики, знакомые с C#, часто сравнивают горутины с async/await.
| Go | C# |
|---|---|
| goroutine | Task |
| channel | Channel / BlockingCollection |
| select | Task.WhenAny() |
| go function() | Task.Run() |
Однако концепции немного отличаются.
В C# разработчик чаще управляет задачами через async и await, а Go предлагает модель, где конкурентность является естественной частью языка.
Горутины позволяют писать конкурентные программы так, как будто это обычный последовательный код. Сложность управления потоками скрыта внутри среды Go.
Создайте программу, которая:
- запускает две горутины;
- первая отправляет числа от 1 до 5 в канал;
- вторая читает эти числа и выводит их;
- после завершения отправитель закрывает канал.
