golang

Потоки, горутины, синхронизация и мьютексы в Go

  • понедельник, 4 августа 2025 г. в 00:00:05
https://habr.com/ru/articles/933464/
Потому что 42...
Потому что 42...

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, Pool

  • sync/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 — один из самых удобных языков для конкурентного программирования. Горутины дешевы, средства синхронизации богаты и просты в использовании.
Ключ к успеху — осознавать проблему гонки данных и правильно выбирать инструмент синхронизации под вашу задачу.