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

от автора

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


ссылка на оригинал статьи https://habr.com/ru/articles/933464/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *