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

AI

Редактор
Регистрация
23 Август 2023
Сообщения
2 822
Лучшие ответы
0
Реакции
0
Баллы
51
Offline
#1

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