Примитивы синхронизации

Какие примитивы синхронизации есть в Golang?

Примитивы синхронизации - это специальные конструкции, которые позволяют координировать и контролировать доступ к общим ресурсам в многопоточных или конкурентных программах. В Golang есть несколько пакетов, которые предоставляют различные примитивы синхронизации, такие как:

sync - содержит базовые примитивы, такие как мьютексы (mutexes), блокировки чтения-записи (read-write locks), группы ожидания (wait groups), однократные исполнители (once), условные переменные (condition variables) и другие.
sync/atomic - содержит функции для атомарных операций с примитивными типами данных, такими как целые числа, указатели, булевы значения и т.д.
context - содержит примитивы для управления жизненным циклом и отменой горутин (goroutines), а также для передачи метаданных между ними.
chan - является встроенным типом данных, который представляет собой канал для обмена данными между горутинами, который может быть использован как примитив синхронизации или коммуникации.

Источники:

Чем мьютекс отличается от семафора?

Мьютекс и семафор - это два способа синхронизации доступа к общим ресурсам в многопоточных программах. Они имеют разные преимущества и недостатки, и их выбор зависит от конкретной задачи.

Вот некоторые основные различия между мьютексом и семафором:

Мьютекс - это объект, который позволяет блокировать и разблокировать доступ к одному ресурсу, тогда как семафор - это переменная, которая позволяет контролировать доступ к нескольким ресурсам.
Мьютекс может быть захвачен или освобожден только тем потоком, который его заблокировал, тогда как семафор может быть изменен любым потоком.
Мьютекс обычно используется для взаимного исключения, т.е. для предотвращения состояний гонки, тогда как семафор обычно используется для координации, т.е. для синхронизации действий между потоками.
Мьютекс имеет только два состояния: заблокирован или разблокирован, тогда как семафор имеет целочисленное значение, которое может быть больше нуля.

Источники:

Что такое atomic и чем отличается от мьютекса?

Атомики (atomics) - это функции, которые позволяют выполнять простые операции с общим ресурсом, такие как чтение, запись, инкремент, декремент, обмен или сравнение и присваивание. Атомики гарантируют, что эти операции будут выполнены атомарно, то есть без вмешательства других горутин. Атомики реализованы в пакете sync/atomic, и для их использования нужно вызывать соответствующие функции с указателем на ресурс. Атомики подходят для ситуаций, когда требуется выполнить быстрые и простые операции с ресурсом, такие как увеличение или уменьшение счетчика, установка или сброс флага, или проверка или изменение состояния.
Все атомики реализованы через спецальные регистры процессора и в коде го они написаны на ассемблере
Атомики являются примитивами низкого уровня реализующими атомарные операции с памятью.

В общем, мьютексы и атомики имеют следующие отличия:

  • Мьютексы работают с любыми типами данных, а атомики - только с примитивными типами, такими как int, uint, bool, pointer и т.д.
  • Мьютексы требуют явной блокировки и разблокировки, а атомики - нет.
  • Мьютексы позволяют выполнять любые операции с ресурсом, а атомики - только ограниченный набор операций.
  • Мьютексы могут быть более медленными и затратными, чем атомики, из-за переключения контекста и ожидания блокировки.
  • Мьютексы могут приводить к взаимным блокировкам (deadlocks), если не использовать их правильно, а атомики - нет.

Источники:

Что можно использовать для ожидания выполнения N горутин?

Один из способов сделать это - использовать встроенную конструкцию WaitGroup из пакета sync. WaitGroup позволяет организовать синхронизацию между несколькими горутинами, которые выполняют параллельные или конкурентные задачи. WaitGroup имеет счетчик, который увеличивается при добавлении новой горутины и уменьшается при ее завершении. Основная горутина может вызвать метод Wait, который блокирует ее до тех пор, пока счетчик не станет равным нулю, то есть пока все горутины не закончат свою работу.

Например, вы можете написать следующий код, который запускает N горутин, каждая из которых печатает свой номер и засыпает на случайное время, а затем ожидает их завершения с помощью WaitGroup:

title
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import (
"fmt"
"math/rand"
"sync"
"time"
)

const N = 5 // количество горутин

func main() {
var wg sync.WaitGroup // создаем WaitGroup
wg.Add(N) // устанавливаем счетчик на N

for i := 1; i <= N; i++ {
go func(n int) { // запускаем горутину с номером n
// при выходе из горутины уменьшаем счетчик на 1
defer wg.Done()

fmt.Println("Горутина", n, "начала работу")
// засыпаем на случайное время от 0 до 10 секунд
time.Sleep(time.Duration(rand.Intn(10)) * time.Second)

fmt.Println("Горутина", n, "закончила работу")
}(i)
}

fmt.Println("Ожидаем завершения горутин")
// блокируем основную горутину, пока счетчик не станет равным нулю
wg.Wait()

fmt.Println("Все горутины завершились")
}

Есть общий ресурс. Хотим, чтобы к нему одновременно обращались только N горутин. Как это сделать?

Один из способов сделать это - использовать семафор (semaphore), который представляет собой переменную, которая хранит количество доступных ресурсов. Семафор может быть реализован с помощью канала (channel) с буфером размера N, который будет заполнен пустыми значениями.

Каждая горутина, которая хочет получить доступ к ресурсу, должна сначала получить значение из канала, а затем вернуть его обратно после завершения работы. Таким образом, канал будет выступать в роли блокировки, которая разрешает доступ только N горутинам одновременно.

Например, вы можете написать следующий код, который запускает M горутин, каждая из которых печатает свой номер и засыпает на случайное время, а затем ожидает их завершения с помощью семафора:

title
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
"fmt"
"math/rand"
"time"
)

const N = 3 // количество доступных ресурсов
const M = 10 // количество горутин

func main() {
sem := make(chan struct{}, N) // создаем канал с буфером размера N
for i := 1; i <= N; i++ {
sem <- struct{}{} // заполняем канал пустыми значениями
}
for i := 1; i <= M; i++ {
go work(i, sem) // запускаем горутину с номером i и каналом sem
}
time.Sleep(20 * time.Second) // ждем 20 секунд, пока все горутины закончат работу
fmt.Println("The End")
}

func work(number int, sem chan struct{}) {
// получаем значение из канала, блокируя доступ к ресурсу
<-sem

fmt.Println("Горутина", number, "начала работу")
// засыпаем на случайное время от 0 до 10 секунд
time.Sleep(time.Duration(rand.Intn(10)) * time.Second)

fmt.Println("Горутина", number, "закончила работу")

// возвращаем значение в канал, разблокируя доступ к ресурсу
sem <- struct{}{}
}

Запустим 1000 горутин с инкрементом инта. Получим в конце тысячу? Что делать, чтобы получить тысячу?

Если вы запустите 1000 горутин с инкрементом инта, то скорее всего вы не получите в конце тысячу. Это потому, что инкремент не является атомарной операцией, то есть он состоит из трех шагов: чтения, изменения и записи значения. Если несколько горутин одновременно пытаются выполнить инкремент, то может возникнуть состояние гонки (race condition), когда одна горутина перезаписывает значение, измененное другой горутиной, и тем самым теряет часть инкрементов.

Чтобы получить тысячу в конце, нужно синхронизировать доступ к общей переменной, которая хранит инт. Для этого можно использовать один из следующих способов:

  • Использовать мьютекс (mutex) из пакета sync, который позволяет блокировать и разблокировать доступ к переменной. Каждая горутина должна вызвать метод Lock перед инкрементом и метод Unlock после него, чтобы гарантировать, что только одна горутина может работать с переменной в один момент времени.
  • Использовать атомик (atomic) из пакета sync/atomic, который позволяет выполнять атомарные операции с примитивными типами данных. Вместо обычного инкремента можно использовать функцию AddInt32 или AddInt64, которая атомарно увеличивает значение переменной на заданное число и возвращает новое значение.

Есть глобальная мапа, глобальный мьютекс. Две функции. Одна блочит мьютекс, а вторая нет. Что произойдет?

Если одна функция блокирует мьютекс перед работой с глобальной мапой, а другая нет, то может возникнуть ситуация, когда две функции одновременно пытаются изменить мапу, что может привести к состоянию гонки (race condition) и неопределенному поведению программы.

Состояние гонки означает, что результат работы программы зависит от случайного порядка выполнения операций, и может быть непредсказуемым или некорректным.

Например, если одна функция пытается добавить элемент в мапу, а другая пытается удалить элемент из мапы, то может случиться, что элемент будет удален до того, как он будет добавлен, или наоборот, или что элемент будет добавлен или удален дважды, или что мапа будет повреждена.

Сколько нужно ядер, чтобы начать использовать sync.Map?

sync.Map может быть полезен, если у вас высоконагруженная система с большим количеством ядер процессора (32+), и вы сталкиваетесь с проблемой ложной разделяемости (false sharing), когда разные горутины конкурируют за доступ к одному и тому же кеш-линии (cache line). В этом случае sync.Map может снизить количество конфликтов и повысить скорость работы с картой.

Однако, если у вас небольшое количество ядер (меньше 8), и вы часто записываете в карту, то sync.Map может быть неэффективнее, чем обычная карта с мьютексом, так как он использует сложную внутреннюю структуру, которая требует дополнительных вычислений и памяти.

Таким образом, нет однозначного ответа на вопрос, сколько нужно ядер, чтобы начать использовать sync.Map. Это зависит от конкретной задачи, характера операций с картой, нагрузки на систему и других факторов.

Ложная разделяемость - это проблема, которая может возникнуть в многопроцессорных системах,
когда разные процессоры или ядра конкурируют за доступ к одному и тому же кеш-линии (cache line), то есть блоку памяти,
который загружается в кеш процессора для ускорения работы с данными.
Если один процессор изменяет данные в кеш-линии, то другие процессоры
должны обновить свои копии этой кеш-линии, что приводит к дополнительным задержкам и снижению производительности.
Ложная разделяемость может возникать, когда разные процессоры работают с разными данными, которые случайно попадают в одну кеш-линию, или когда разные процессоры работают с одними и теми же данными, но не синхронизируют свой доступ к ним.
Ложная разделяемость может быть устранена с помощью различных методов, таких как:

  • Изменение размера или выравнивания структур данных, чтобы избежать перекрытия кеш-линий.
  • Использование атомарных (atomic) операций, которые не требуют блокировки кеш-линии.
  • Использование специальных инструкций, которые позволяют указать процессору, что данные в кеш-линии не будут изменяться.
  • Использование разных уровней кеша для разных типов данных.

Источники:

Как устроена WaitGroup под капотом и как ее можно реализовать самому?

Под капотом WaitGroup реализован с помощью атомарных операций, которые обеспечивают потокобезопасность и высокую производительность. WaitGroup использует 64-битное целое число, которое разделено на две части: старшие 32 бита хранят счетчик горутин, а младшие 32 бита хранят счетчик ожидающих горутин.

Каждый раз, когда вызывается метод Add, WaitGroup атомарно увеличивает счетчик горутин на заданное значение. Каждый раз, когда вызывается метод Done, WaitGroup атомарно уменьшает счетчик горутин на единицу и проверяет, не стал ли он равным нулю. Если да, то WaitGroup атомарно увеличивает счетчик ожидающих горутин на единицу и разблокирует одну из ожидающих горутин с помощью сигнальной переменной (signal variable).

Каждый раз, когда вызывается метод Wait, WaitGroup атомарно уменьшает счетчик ожидающих горутин на единицу и проверяет, не стал ли он отрицательным. Если да, то WaitGroup атомарно возвращает счетчик ожидающих горутин в исходное состояние и блокирует текущую горутину с помощью сигнальной переменной.

title
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package main

import (
"fmt"
"sync"
"sync/atomic"
"time"
)

// MyWaitGroup - собственная реализация WaitGroup
type MyWaitGroup struct {
counter int64 // счетчик горутин
waiter int64 // счетчик ожидающих горутин
signal sync.Cond // сигнальная переменная
}

// Add - добавляет n горутин в группу
func (wg *MyWaitGroup) Add(n int) {
atomic.AddInt64(&wg.counter, int64(n)) // атомарно увеличиваем счетчик горутин на n
}

// Done - уменьшает счетчик горутин на 1 и разблокирует одну ожидающую горутину, если счетчик стал равным 0
func (wg *MyWaitGroup) Done() {
if atomic.AddInt64(&wg.counter, -1) == 0 { // атомарно уменьшаем счетчик горутин на 1 и проверяем, не стал ли он равным 0
wg.signal.L.Lock() // блокируем сигнальную переменную
atomic.AddInt64(&wg.waiter, 1) // атомарно увеличиваем счетчик ожидающих горутин на 1
wg.signal.Broadcast() // разблокируем все ожидающие горутины
wg.signal.L.Unlock() // разблокируем сигнальную переменную
}
}

// Wait - блокирует текущую горутину, пока счетчик горутин не станет равным 0
func (wg *MyWaitGroup) Wait() {
wg.signal.L.Lock() // блокируем сигнальную переменную
if atomic.AddInt64(&wg.waiter, -1) < 0 { // атомарно уменьшаем счетчик ожидающих горутин на 1 и проверяем, не стал ли он отрицательным
atomic.StoreInt64(&wg.waiter, 0) // атомарно возвращаем счетчик ожидающих горутин в исходное состояние
wg.signal.Wait() // блокируем текущую горутину
}
wg.signal.L.Unlock() // разблокируем сигнальную переменную
}

func main() {
var wg MyWaitGroup // создаем экземпляр MyWaitGroup
wg.Add(2) // добавляем две горутины в группу
work := func(id int) {
defer wg.Done() // при выходе из горутины вызываем метод Done
fmt.Printf("Горутина %d начала работу\n", id)
time.Sleep(2 * time.Second) // имитируем работу горутины
fmt.Printf("Горутина %d завершила работу\n", id)
}
// запускаем две горутины
go work(1)
go work(2)
wg.Wait() // ожидаем завершения всех горутин в группе
fmt.Println("Горутины завершились")
}

Источники:

В чем разница между Mutex и RWMutex?

Mutex и RWMutex в Go оба используются для синхронизации доступа к данным в многопоточной среде, но они работают немного по-разному:

Mutex:

  • Mutex предоставляет взаимоисключающую блокировку, которая позволяет только одной горутине в любой момент времени иметь доступ к защищенным данным.
  • Если другая горутина пытается получить доступ к данным, когда Mutex заблокирован, она будет заблокирована до тех пор, пока Mutex не будет разблокирован.

RWMutex:

  • RWMutex (Reader-Writer Mutex) предоставляет более гибкую семантику блокировки.
  • RWMutex позволяет множеству горутин получить параллельный доступ для чтения (блокировка чтения), но только одной горутине получить эксклюзивный доступ на запись (блокировка записи).
    Это означает, что несколько горутин могут одновременно читать данные, но запись данных может производить только одна горутина.

Когда нужно использовать Mutex, а когда RWMutex?

Использование Mutex:

  • Mutex следует использовать, когда у вас есть данные, которые могут быть изменены одновременно несколькими горутинами.
  • Mutex обеспечивает взаимоисключающую блокировку, что позволяет только одной горутине в любой момент времени иметь доступ к защищенным данным.

Использование RWMutex:

  • RWMutex следует использовать, когда у вас есть данные, которые часто читаются, но редко обновляются.
  • RWMutex позволяет множеству горутин получить параллельный доступ для чтения, но только одной горутине получить эксклюзивный доступ на запись.
    Это может улучшить производительность, если у вас есть данные, которые часто читаются, так как несколько горутин могут одновременно читать данные.

Источники:

Вот вам и sync primitives

Поделиться