Асинхронное программирование в Go: полное руководство для начинающих
Go (Golang) был создан с учётом потребностей современной разработки, где производительность и параллелизм играют ключевую роль. В отличие от многих языков, где асинхронность реализуется через колбэки или промисы, Go предлагает встроенные и элегантные механизмы: горутины и каналы. Это руководство познакомит вас с основами асинхронного программирования в Go и покажет, как писать эффективный конкурентный код.
Что такое горутины и чем они отличаются от потоков?
Горутина — это легковесный поток выполнения, управляемый рантаймом Go. В отличие от системных потоков ОС, горутины потребляют гораздо меньше ресурсов: их стек начинается с нескольких килобайт и может динамически расти и уменьшаться. Это позволяет запускать миллионы горутин одновременно без значительной нагрузки на память.
Запустить горутину очень просто — достаточно добавить ключевое слово go перед вызовом функции:
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Привет из горутины!")
}
func main() {
go sayHello() // Запуск горутины
time.Sleep(100 * time.Millisecond) // Ждём, чтобы горутина успела выполниться
fmt.Println("Главная функция завершена")
}
Важно отметить: если главная функция main() завершится, все запущенные горутины будут принудительно остановлены. Для синхронизации мы используем специальные инструменты.
Синхронизация с помощью WaitGroup
Простейший способ дождаться завершения горутин — использовать sync.WaitGroup. Он работает как счётчик: мы увеличиваем его при запуске горутины и уменьшаем при завершении. Главная функция блокируется до тех пор, пока счётчик не станет равен нулю.
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // Уменьшаем счётчик при завершении
fmt.Printf("Работник %d начал работу\n", id)
time.Sleep(time.Second)
fmt.Printf("Работник %d закончил работу\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // Увеличиваем счётчик перед запуском
go worker(i, &wg)
}
wg.Wait() // Ждём завершения всех горутин
fmt.Println("Все работники завершили работу")
}
Обратите внимание: wg.Add(1) вызывается до запуска горутины, а wg.Done() — внутри неё (обычно через defer). Это гарантирует, что счётчик будет корректно уменьшен даже при панике.
Каналы: общение между горутинами
Каналы (channels) — это типизированные «трубы», через которые горутины могут безопасно обмениваться данными. Каналы обеспечивают синхронизацию по умолчанию: отправка в канал блокирует горутину до тех пор, пока другая горутина не прочитает значение, и наоборот.
Создаются каналы с помощью функции make:
package main
import "fmt"
func main() {
// Создаём небуферизированный канал для целых чисел
ch := make(chan int)
// Запускаем горутину, которая отправляет данные
go func() {
ch <- 42 // Отправляем значение в канал
}()
// Читаем из канала (блокируется, пока не придёт значение)
value := <-ch
fmt.Println("Получено значение:", value)
}
Каналы бывают:
- Небуферизированные — синхронные, отправка блокируется до получения.
- Буферизированные — асинхронные, отправка блокируется только когда буфер заполнен.
// Создание буферизированного канала на 3 элемента
bufferedCh := make(chan string, 3)
bufferedCh <- "A"
bufferedCh <- "B"
bufferedCh <- "C" // Пока не заполнен — не блокирует
Оператор select: работа с несколькими каналами
Оператор select позволяет ожидать события сразу от нескольких каналов. Он выбирает первый канал, готовый к чтению или записи, и выполняет соответствующий блок кода. Это очень похоже на switch, но для каналов.
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "Сообщение из канала 1"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "Сообщение из канала 2"
}()
select {
case msg1 := <-ch1:
fmt.Println("Получено:", msg1)
case msg2 := <-ch2:
fmt.Println("Получено:", msg2)
case <-time.After(3 * time.Second):
fmt.Println("Таймаут — ни один канал не ответил")
}
}
Оператор select часто используется для реализации таймаутов, неблокирующих операций и паттернов вроде «fan-in» (объединение нескольких каналов в один).
Практический пример: параллельная обработка данных
Давайте объединим всё вместе и напишем программу, которая параллельно обрабатывает список URL и собирает результаты:
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
func fetchURL(url string, wg *sync.WaitGroup, results chan<- string) {
defer wg.Done()
start := time.Now()
resp, err := http.Get(url)
if err != nil {
results <- fmt.Sprintf("Ошибка при запросе %s: %v", url, err)
return
}
defer resp.Body.Close()
elapsed := time.Since(start)
results <- fmt.Sprintf("%s — статус %d, время: %v", url, resp.StatusCode, elapsed)
}
func main() {
urls := []string{
"https://google.com",
"https://github.com",
"https://golang.org",
}
var wg sync.WaitGroup
results := make(chan string, len(urls)) // Буферизированный канал
for _, url := range urls {
wg.Add(1)