Асинхронное программирование в Go: полное руководство с примерами

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Зачем нужно асинхронное программирование в 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)
    }                

Блоги

Рекомендаций по книгам