Асинхронное программирование в Go: от теории к практике
Go (Golang) славится своей встроенной поддержкой асинхронного программирования. В отличие от многих других языков, где асинхронность требует сложных библиотек и коллбэков, Go предлагает элегантные и мощные примитивы: горутины (goroutines) и каналы (channels). В этом руководстве мы разберем все ключевые концепции на практике, чтобы вы могли писать эффективный, параллельный код на Go.
Что такое горутины (Goroutines)?
Горутина — это легковесный поток выполнения, который управляется рантаймом Go. В отличие от системных потоков, горутины потребляют очень мало ресурсов (всего несколько килобайт стека), что позволяет запускать тысячи и даже миллионы горутин одновременно.
Запустить горутину проще простого — достаточно добавить ключевое слово go перед вызовом функции:
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Привет из горутины!")
}
func main() {
go sayHello() // Запускаем горутину
time.Sleep(time.Millisecond * 100) // Даем время горутине выполниться
fmt.Println("Главная функция завершена")
}
Обратите внимание: если не добавить time.Sleep, программа может завершиться до того, как горутина начнет выполняться. Для синхронизации лучше использовать другие механизмы, которые мы рассмотрим ниже.
Каналы (Channels): передача данных между горутинами
Каналы — это типизированные «трубы», через которые горутины могут обмениваться данными. Канал создается с помощью функции make и оператора chan. Данные отправляются в канал с помощью оператора <-, а принимаются из него также с помощью <-, но слева от канала.
Рассмотрим пример с буферизированным и небуферизированным каналом:
package main
import "fmt"
func main() {
// Небуферизированный канал (размер 0)
ch := make(chan string)
go func() {
ch <- "Сообщение из горутины" // Отправляем данные
}()
msg := <-ch // Принимаем данные (блокируется, пока не придет сообщение)
fmt.Println(msg)
// Буферизированный канал (размер 2)
bufferedCh := make(chan int, 2)
bufferedCh <- 10
bufferedCh <- 20
fmt.Println(<-bufferedCh) // 10
fmt.Println(<-bufferedCh) // 20
}
Важно: Небуферизированный канал блокирует отправителя до тех пор, пока получатель не примет данные. Буферизированный канал блокирует отправителя только когда буфер заполнен.
Select: мультиплексирование каналов
Оператор select позволяет горутине ожидать сразу несколько каналов и обрабатывать тот, который первым готов к работе. Это аналог switch, но для каналов. select — мощный инструмент для создания таймаутов, отмены операций и неблокирующих проверок.
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
// Горутина 1: отправляет данные через 1 секунду
go func() {
time.Sleep(1 * time.Second)
ch1 <- "Данные из канала 1"
}()
// Горутина 2: отправляет данные через 2 секунды
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(500 * time.Millisecond):
fmt.Println("Таймаут! Ни один канал не ответил за 500 мс")
}
}
В этом примере select сработает на таймауте, так как ни один канал не готов за 500 мс. Если бы мы убрали таймаут, программа бы ждала первый готовый канал.
Синхронизация с sync.WaitGroup
Когда нужно дождаться завершения группы горутин, на помощь приходит sync.WaitGroup. Он использует счетчик: Add(delta) увеличивает счетчик, Done() уменьшает, а Wait() блокирует выполнение до тех пор, пока счетчик не станет равен нулю.
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // Уменьшаем счетчик при завершении горутины
fmt.Printf("Работник %d начал работу\n", id)
time.Sleep(time.Duration(id) * 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() до запуска горутины, чтобы избежать состояния гонки (race condition).
Практический пример: параллельный парсинг URL
Объединим все знания в реальном примере — параллельная загрузка нескольких веб-страниц с использованием горутин, каналов и sync.WaitGroup:
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
func fetchURL(url string, ch chan<- string, wg *sync.WaitGroup) {
defer wg.Done()
start := time.Now()
resp, err := http.Get(url)
if err != nil {
ch <- fmt.Sprintf("Ошибка при запросе %s: %v", url, err)
return
}
defer resp.Body.Close()
elapsed := time.Since(start)
ch <- fmt.Sprintf("URL: %s | Статус: %d | Время: %v", url, resp.StatusCode, elapsed)
}
func main() {
urls := []string{
"https://golang.org",
"https://google.com",
"https://github.com",
"https://