Потоки, горутины, синхронизация и мьютексы в Go
- понедельник, 4 августа 2025 г. в 00:00:05
Go (Golang) создан для эффективной параллельной и конкурентной работы. Его killer feature — легковесные потоки выполнения, называемые горутины (goroutines), и мощные средства синхронизации. Приглашаю разобраться подробно.
Обычные потоки (threads):
В большинстве языков потоки создаются ОС, они "тяжёлые" (создание/переключение = дорого).
Горутины (goroutines), это такой костыль go:
Это "зелёные" потоки Go — намного легче, чем системные потоки, планируются рантаймом Go (runtime
).
На одном системном потоке могут работать тысячи горутин.
Создать горутину — просто:
go myFunc() // вызовет функцию в отдельной горутине
Важно:
Горутины могут выполняться параллельно, если Go-программа запущена на многоядерном CPU.
Количество системных потоков регулирует планировщик Go (через GOMAXPROCS
).
Если несколько горутин одновременно пишут/читают одну переменную — возникает гонка данных (data race). Это приводит к непредсказуемому поведению.
Пример гонки:
var counter int
go func() { counter++ }()
go func() { counter++ }()
Может случиться, что обе горутины увидят старое значение и запишут одинаковое новое.
Мьютекс (mutual exclusion) — классическая примитивная блокировка.
В Go — тип sync.Mutex
.
Применение:
import "sync"
var mu sync.Mutex
var counter int
func inc() {
mu.Lock()
counter++
mu.Unlock()
}
Только одна горутина в критической секции (между Lock()
и Unlock()
).
Важно: Всегда Unlock
после Lock
, иначе — deadlock!
В Go (как и в других языках), deadlock (взаимоблокировка) — это ситуация, при которой горутины навсегда застревают, ожидая друг друга или ресурсы, которые никогда не освободятся. В результате программа зависает и не может продолжить выполнение.
Deadlock возникает, когда:
Горутина ждет данные из канала, в который никто не пишет.
Несколько горутин ждут друг друга через каналы.
Мьютексы (или другие примитивы синхронизации) захвачены в таком порядке, что ресурсы никогда не освобождаются.
sync.RWMutex
— позволяет нескольким читателям заходить одновременно, но писатель — только один и блокирует всех читателей.
var mu sync.RWMutex
// Для чтения
mu.RLock()
// ... читать ...
mu.RUnlock()
// Для записи
mu.Lock()
// ... писать ...
mu.Unlock()
Go-путь: синхронизация через обмен сообщениями, а не через блокировки.
ch := make(chan int)
go func() {
ch <- 42 // записать в канал (может заблокироваться)
}()
val := <-ch // получить из канала (может заблокироваться)
Канал может быть буферизированным или нет.
Позволяет строить очереди, worker pool, сигнализацию завершения.
Для простых операций над числами — атомарные операции (без мьютексов).
import "sync/atomic"
var counter int64
atomic.AddInt64(&counter, 1)
val := atomic.LoadInt64(&counter)
Быстрее, чем мьютексы, но только для примитивов (int, uint, pointer).
Не лучший вариант строить сложную логику через атомики
Используется для ожидания завершения группы горутин.
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
// ...
}()
go func() {
defer wg.Done()
// ...
}()
wg.Wait() // ждать завершения обеих горутин
Гарантирует, что функция будет вызвана ровно один раз (например, для инициализации singleton).
var once sync.Once
once.Do(func() {
// инициализация
})
Сложный, низкоуровневый механизм для организации очередей, сигнализации.
sync
— мьютексы, RWMutex, Once, WaitGroup, Cond, Pool
sync/atomic
— атомарные операции над числами и указателями
context
— управление жизненным циклом (отмена/таймаут для горутин)
runtime
— низкоуровневое управление планировщиком (например, GOMAXPROCS
)
time
— таймеры, Ticker для периодических событий
Рассмотрим три варианта:
package main
import (
"fmt"
"sync"
)
type SafeCounter struct {
mu sync.Mutex
value int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
counter := &SafeCounter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
counter.Inc()
wg.Done()
}()
}
wg.Wait()
fmt.Println("Final value:", counter.Value())
}
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type AtomicCounter struct {
value int64
}
func (c *AtomicCounter) Inc() {
atomic.AddInt64(&c.value, 1)
}
func (c *AtomicCounter) Value() int64 {
return atomic.LoadInt64(&c.value)
}
func main() {
counter := &AtomicCounter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
counter.Inc()
wg.Done()
}()
}
wg.Wait()
fmt.Println("Final value:", counter.Value())
}
package main
import (
"fmt"
"sync"
)
type ChanCounter struct {
ch chan int
value int
}
func NewChanCounter() *ChanCounter {
c := &ChanCounter{
ch: make(chan int),
}
go c.run()
return c
}
func (c *ChanCounter) run() {
for v := range c.ch {
c.value += v
}
}
func (c *ChanCounter) Inc() {
c.ch <- 1
}
func (c *ChanCounter) Close() {
close(c.ch)
}
func (c *ChanCounter) Value() int {
return c.value
}
func main() {
counter := NewChanCounter()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
counter.Inc()
wg.Done()
}()
}
wg.Wait()
counter.Close()
fmt.Println("Final value:", counter.Value())
}
Мьютексы — используйте для защиты сложных структур, если нет необходимости в высокой скорости.
Атомики — для простых счётчиков, флагов и т.п.
RWMutex — если у вас много читателей и мало писателей.
Каналы — для построения concurrent pipeline, очередей и worker pool.
WaitGroup — всегда для ожидания завершения группы горутин.
Context — для управления отменой и таймаутами.
Не забыли Unlock
после Lock
? Используйте defer
.
Не делайте сложную бизнес-логику через атомики.
Не используйте глобальные переменные без защиты!
Не закрывайте канал, если кто-то еще пишет в него.
Go — один из самых удобных языков для конкурентного программирования. Горутины дешевы, средства синхронизации богаты и просты в использовании.
Ключ к успеху — осознавать проблему гонки данных и правильно выбирать инструмент синхронизации под вашу задачу.