Введение в конкурентность в Go
Язык Go (Golang) был разработан в Google с учётом современных требований к производительности и масштабируемности. Одной из ключевых особенностей Go является встроенная поддержка конкурентности (concurrency) — способности программы выполнять несколько задач одновременно. В отличие от традиционной многопоточности, где потоки управляются операционной системой, Go предлагает легковесные goroutines и удобные примитивы синхронизации — каналы.
В этой статье мы разберём:
- Что такое goroutines и как их запускать
- Каналы для обмена данными между goroutines
- Мьютексы для безопасного доступа к общим ресурсам
- Практические примеры и шаблоны конкурентного кода
1. Goroutines: лёгкие потоки Go
Goroutine — это функция, выполняющаяся конкурентно с другими goroutines. Она занимает всего несколько килобайт стека (в отличие от потоков ОС, где стек может быть мегабайтным). Запустить goroutine можно просто добавив ключевое слово go перед вызовом функции.
Пример: Запуск goroutines
package main
import (
"fmt"
"time"
)
func sayHello(name string) {
for i := 0; i < 3; i++ {
fmt.Printf("Привет, %s! (итерация %d)\n", name, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go sayHello("Алиса") // Запуск goroutine
go sayHello("Боб") // Ещё одна goroutine
time.Sleep(1 * time.Second) // Ждём завершения
fmt.Println("Главная функция завершена")
}
Важно: Программа завершается, когда завершается функция main, даже если другие goroutines ещё работают. Поэтому мы используем time.Sleep для ожидания. В реальном коде лучше использовать sync.WaitGroup.
Использование sync.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) // Увеличиваем счётчик перед запуском goroutine
go worker(i, &wg)
}
wg.Wait() // Блокируем main до завершения всех goroutines
fmt.Println("Все работники завершили работу")
}
2. Каналы: обмен данными между goroutines
Каналы (channels) — это типизированные "трубы", через которые goroutines могут отправлять и получать значения. Каналы обеспечивают безопасную синхронизацию без явных блокировок.
Создание и использование каналов
package main
import "fmt"
func main() {
// Создаём канал для целых чисел
ch := make(chan int)
// Запускаем goroutine, которая отправляет данные
go func() {
ch <- 42 // Отправляем значение в канал
}()
// Получаем значение из канала
value := <-ch
fmt.Println("Получено:", value)
}
По умолчанию каналы небуферизированные: отправка блокируется до тех пор, пока получатель не будет готов принять данные, и наоборот. Это обеспечивает синхронизацию.
Буферизированные каналы
package main
import "fmt"
func main() {
// Канал с буфером на 3 элемента
ch := make(chan string, 3)
// Отправляем без блокировки (пока буфер не заполнен)
ch <- "один"
ch <- "два"
ch <- "три"
// Читаем
fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
}
Закрытие каналов и range
package main
import "fmt"
func fibonacci(n int, ch chan int) {
a, b := 0, 1
for i := 0; i < n; i++ {
ch <- a
a, b = b, a+b
}
close(ch) // Закрываем канал после отправки всех данных
}
func main() {
ch := make(chan int, 10)
go fibonacci(10, ch)
// Читаем из канала, пока он не будет закрыт
for val := range ch {
fmt.Println(val)
}
}
3. Select: работа с несколькими каналами
Оператор select позволяет goroutine ожидать несколько операций с каналами одновременно. Он выбирает первый готовый канал.
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("Получено из ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("Получено из ch2:", msg2)
case <-time.After(500 * time.Millisecond):
fmt.Println("Таймаут, никто не ответил")
}
}
В этом примере select сработает по таймауту, так как оба канала будут готовы только через 1 и 2 секунды соответственно. Это полезно для реализации тайм-аутов и неблокирующих операций.
4. Мьютексы: защита общих данных
Хотя каналы — предпочтительный способ синхронизации в Go, иногда требуется защитить общую память. Для этого используется sync.Mutex.
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
var wg sync.WaitGroup
counter := Counter{}
for i := 0; i <