Конкурентность и многопоточность в Go: от А до Я
Go (Golang) был спроектирован с учетом современных вычислительных нагрузок. В эпоху многоядерных процессоров и облачных сервисов умение эффективно работать с конкурентностью стало обязательным навыком для бэкенд-разработчика. В этой статье мы разберем, как Go решает задачи параллельного выполнения кода, чем горутины отличаются от потоков ОС, и как правильно использовать каналы и мьютексы.
1. Горутины: легковесные потоки
Горутина (goroutine) — это функция или метод, выполняющийся конкурентно с другими горутинами. В отличие от потоков операционной системы, горутины имеют минимальные накладные расходы: их стек начинается с 2-4 КБ и может динамически расти. Запустить горутину невероятно просто — достаточно добавить ключевое слово go перед вызовом функции.
package main
import (
"fmt"
"time"
)
func say(text string) {
for i := 0; i < 3; i++ {
fmt.Println(text)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("Hello from goroutine")
say("Hello from main")
// Даем время горутине завершиться
time.Sleep(500 * time.Millisecond)
}
Обратите внимание: программа завершается сразу после выполнения функции main(), не дожидаясь завершения запущенных горутин. Для синхронизации мы используем time.Sleep (в реальных проектах — sync.WaitGroup или каналы).
2. Каналы: безопасная передача данных
Каналы (channels) — это типизированные "трубы", через которые горутины обмениваются данными. Они решают две ключевые задачи: синхронизацию выполнения и передачу значений. Каналы бывают буферизированные и небуферизированные.
Небуферизированный канал
Операция отправки блокирует горутину до тех пор, пока другая горутина не выполнит чтение. Это обеспечивает синхронизацию "на лету".
package main
import "fmt"
func main() {
ch := make(chan string)
go func() {
ch <- "Привет из горутины!"
}()
msg := <-ch
fmt.Println(msg)
}
Буферизированный канал
Канал с буфером позволяет отправить несколько значений без немедленного чтения, пока буфер не заполнится.
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
// ch <- 30 // deadlock! буфер переполнен
fmt.Println(<-ch) // 10
fmt.Println(<-ch) // 20
}
Range и close
Закрытие канала сигнализирует получателям, что данных больше не будет. Цикл for range автоматически завершается при закрытии канала.
package main
import "fmt"
func producer(ch chan int) {
for i := 1; i <= 5; i++ {
ch <- i
}
close(ch)
}
func main() {
ch := make(chan int)
go producer(ch)
for val := range ch {
fmt.Println("Получено:", val)
}
}
3. Синхронизация и мьютексы
Несмотря на мощь каналов, иногда требуется прямой доступ к общим данным. Пакет sync предоставляет мьютексы (sync.Mutex) и ридер-мьютексы (sync.RWMutex) для защиты критических секций.
package main
import (
"fmt"
"sync"
)
var counter = 0
var mu sync.Mutex
func increment(wg *sync.WaitGroup) {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go increment(&wg)
}
wg.Wait()
fmt.Println("Итоговый счетчик:", counter)
}
Здесь sync.WaitGroup ожидает завершения всех горутин, а мьютекс предотвращает состояние гонки (data race). Без мьютекса результат был бы непредсказуем.
4. Паттерны конкурентности
В Go сложились устоявшиеся шаблоны работы с горутинами и каналами. Рассмотрим три самых популярных.
Worker Pool (пул воркеров)
Ограничивает количество одновременно работающих горутин, что важно для контроля нагрузки.
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
fmt.Printf("Воркер %d обрабатывает задачу %d\n", id, job)
results <- job * 2
}
}
func main() {
const numJobs = 10
const numWorkers = 3
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
var wg sync.WaitGroup
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
wg.Wait()
close(results)
for r := range results {
fmt.Println("Результат:", r)
}
}
Fan-Out / Fan-In
Fan-Out — распределение задач между несколькими горутинами. Fan-In — сбор результатов в один канал.
package main
import (
"fmt"
"sync"
)
func producer(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func merge(cs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
output := func(c <-chan int) {
for n := range c {
out <- n
}
wg.Done()
}
wg.Add(len(cs))