Введение: Зачем нужно асинхронное программирование в Go?
Go (Golang) с самого проектирования был ориентирован на эффективную работу с конкурентностью. В отличие от многих языков, где асинхронность реализуется через сложные коллбэки или промисы, Go предлагает элегантную модель на основе горутин (goroutines) и каналов (channels). Эта модель позволяет писать асинхронный код, который читается как синхронный, но выполняется параллельно, максимально утилизируя ресурсы многоядерных процессоров.
В этом руководстве мы разберем все ключевые концепции асинхронного программирования в Go: от создания горутин до сложных паттернов синхронизации с использованием каналов и примитивов из пакета sync. Материал будет полезен как новичкам, только осваивающим Go, так и опытным разработчикам, желающим систематизировать знания.
1. Горутины (Goroutines): Легковесные потоки
Горутина — это легковесный поток выполнения, управляемый рантаймом Go. В отличие от потоков ОС, горутины имеют крошечный стек (начинающийся с нескольких КБ), который может динамически расти и сжиматься. Это позволяет запускать десятки тысяч горутин одновременно без существенного потребления памяти.
Запуск горутины
Чтобы запустить функцию как горутину, достаточно добавить ключевое слово go перед вызовом функции:
package main
import (
"fmt"
"time"
)
func printNumbers() {
for i := 1; i <= 5; i++ {
fmt.Println(i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
// Запускаем функцию как горутину
go printNumbers()
// Основная горутина продолжает работу
fmt.Println("Основная горутина работает")
// Даем время на выполнение дочерней горутины
time.Sleep(1 * time.Second)
fmt.Println("Программа завершена")
}
Важно: Программа завершается, когда завершается функция main(). Если основная горутина закончит работу раньше, все дочерние горутины будут принудительно остановлены. Поэтому в примере мы используем time.Sleep для ожидания — на практике для синхронизации применяются каналы или sync.WaitGroup.
Анонимные функции в горутинах
Горутины часто создаются с помощью анонимных функций:
go func() {
fmt.Println("Выполняется в горутине")
}()
2. Каналы (Channels): Связь между горутинами
Каналы — это типизированные "трубы", через которые горутины могут отправлять и получать значения. Это основной механизм синхронизации и обмена данными в Go. Каналы обеспечивают безопасный доступ к данным без использования явных блокировок (мьютексов).
Создание и использование канала
Канал создается с помощью функции make(). Оператор <- используется для отправки и получения данных:
package main
import "fmt"
func sum(s []int, c chan int) {
total := 0
for _, v := range s {
total += v
}
c <- total // отправляем результат в канал
}
func main() {
s := []int{7, 2, 8, -9, 4, 0}
c := make(chan int)
go sum(s[:len(s)/2], c)
go sum(s[len(s)/2:], c)
x, y := <-c, <-c // получаем данные из канала
fmt.Println(x, y, x+y)
}
В этом примере мы запускаем две горутины, каждая считает сумму половины слайса. Результаты отправляются в канал, а основная горутина получает их и выводит общую сумму.
Буферизированные каналы
По умолчанию каналы небуферизированы — отправка блокируется до тех пор, пока получатель не будет готов принять значение. Буферизированные каналы имеют очередь заданного размера:
ch := make(chan int, 2) // канал с буфером на 2 элемента
ch <- 1
ch <- 2
// ch <- 3 // ошибка: deadlock, буфер переполнен
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
3. Оператор select и таймауты
Оператор select позволяет горутине ожидать несколько операций с каналами одновременно. Он блокируется до тех пор, пока один из его кейсов не будет готов к выполнению.
Пример использования select
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "один"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "два"
}()
select {
case msg1 := <-ch1:
fmt.Println("Получено из ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("Получено из ch2:", msg2)
case <-time.After(500 * time.Millisecond):
fmt.Println("Таймаут! Ни один канал не ответил за 500 мс")
}
}
В этом примере select выберет кейс с таймаутом, так как обе горутины "спят" дольше 500 мс. Это позволяет реализовать неблокирующие операции и обработку таймаутов.
4. Синхронизация с sync.WaitGroup и sync.Mutex
Хотя каналы решают большинство задач синхронизации, иногда удобнее использовать примитивы из пакета sync.
sync.WaitGroup: Ожидание завершения горутин
WaitGroup позволяет дождаться завершения набора горутин:
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // уменьшаем счетчик при завершении
fmt.Printf("Работник %d начал работу\n", id)
// имитация работы
fmt.Printf("Работник %d закончил работу\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // увеличиваем счетчик
go worker(i, &wg)
}