
Go (Golang) создан для эффективной параллельной и конкурентной работы. Его killer feature — легковесные потоки выполнения, называемые горутины (goroutines), и мощные средства синхронизации. Приглашаю разобраться подробно.
1. Что такое горутины и как они соотносятся с потоками?
Обычные потоки (threads):
В большинстве языков потоки создаются ОС, они "тяжёлые" (создание/переключение = дорого).Горутины (goroutines), это такой костыль go:
Это "зелёные" потоки Go — намного легче, чем системные потоки, планируются рантаймом Go (runtime
).
На одном системном потоке могут работать тысячи горутин.
Создать горутину — просто:
go myFunc() // вызовет функцию в отдельной горутине
Важно:
Горутины могут выполняться параллельно, если Go-программа запущена на многоядерном CPU.
Количество системных потоков регулирует планировщик Go (через
GOMAXPROCS
).
2. Проблема гонки данных (data race) и необходимость синхронизации
Если несколько горутин одновременно пишут/читают одну переменную — возникает гонка данных (data race). Это приводит к непредсказуемому поведению.
Пример гонки:
var counter int
go func() { counter++ }()
go func() { counter++ }()
Может случиться, что обе горутины увидят старое значение и запишут одинаковое новое.
3. Основные способы синхронизации данных в Go
A) Мьютексы (Mutex)
Мьютекс (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 в Go
Deadlock возникает, когда:
Горутина ждет данные из канала, в который никто не пишет.
Несколько горутин ждут друг друга через каналы.
Мьютексы (или другие примитивы синхронизации) захвачены в таком порядке, что ресурсы никогда не освобождаются.
B) RWMutex
sync.RWMutex
— позволяет нескольким читателям заходить одновременно, но писатель — только один и блокирует всех читателей.
var mu sync.RWMutex
// Для чтения
mu.RLock()
// ... читать ...
mu.RUnlock()
// Для записи
mu.Lock()
// ... писать ...
mu.Unlock()
C) Каналы (Channels)
Go-путь: синхронизация через обмен сообщениями, а не через блокировки.
ch := make(chan int)
go func() {
ch <- 42 // записать в канал (может заблокироваться)
}()
val := <-ch // получить из канала (может заблокироваться)
Канал может быть буферизированным или нет.
Позволяет строить очереди, worker pool, сигнализацию завершения.
D) sync/Atomic
Для простых операций над числами — атомарные операции (без мьютексов).
import "sync/atomic"
var counter int64
atomic.AddInt64(&counter, 1)
val := atomic.LoadInt64(&counter)
Быстрее, чем мьютексы, но только для примитивов (int, uint, pointer).
Не лучший вариант строить сложную логику через атомики
E) sync.WaitGroup
Используется для ожидания завершения группы горутин.
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
// ...
}()
go func() {
defer wg.Done()
// ...
}()
wg.Wait() // ждать завершения обеих горутин
F) sync.Once
Гарантирует, что функция будет вызвана ровно один раз (например, для инициализации singleton).
var once sync.Once
once.Do(func() {
// инициализация
})
G) sync.Cond
Сложный, низкоуровневый механизм для организации очередей, сигнализации.
4. Часто используемые пакеты
sync
— мьютексы, RWMutex, Once, WaitGroup, Cond, Poolsync/atomic
— атомарные операции над числами и указателямиcontext
— управление жизненным циклом (отмена/таймаут для горутин)runtime
— низкоуровневое управление планировщиком (например,GOMAXPROCS
)time
— таймеры, Ticker для периодических событий
5. Пример: потокобезопасный counter
Рассмотрим три варианта:
1. С мьютексом
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())
}
2. С атомиками
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())
}
3. Через канал (Go way)
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())
}
6. Советы и best practices
Мьютексы — используйте для защиты сложных структур, если нет необходимости в высокой скорости.
Атомики — для простых счётчиков, флагов и т.п.
RWMutex — если у вас много читателей и мало писателей.
Каналы — для построения concurrent pipeline, очередей и worker pool.
WaitGroup — всегда для ожидания завершения группы горутин.
Context — для управления отменой и таймаутами.
7. Частые ошибки
Не забыли
Unlock
послеLock
? Используйтеdefer
.Не делайте сложную бизнес-логику через атомики.
Не используйте глобальные переменные без защиты!
Не закрывайте канал, если кто-то еще пишет в него.
8. Заключение
Go — один из самых удобных языков для конкурентного программирования. Горутины дешевы, средства синхронизации богаты и просты в использовании.
Ключ к успеху — осознавать проблему гонки данных и правильно выбирать инструмент синхронизации под вашу задачу.