Начинаем серию статей о проблемах многопоточности, параллелизме, concurrency и других интересных штуках.
- Race condition и Data Race
- Deadlocks, Livelocks и Starvation
- Примитивы синхронизации в Go
- Безопасная работа с каналами в Go
- Goroutine Leaks
Race condition и data race — две разные проблемы многопоточности, которые часто путают. Попробуем разобраться.
Race condition
Существует много формулировок определения:
Race condition представляет собой класс проблем, в которых корректное поведение системы зависит от двух независимых событий, происходящих в правильном порядке, однако отсутствует механизм, для того чтобы гарантировать фактическое возникновение этих событий.
Race condition — ошибка проектирования многопоточной системы или приложения, при которой работа системы или приложения зависит от того, в каком порядке выполняются части кода.
Race condition — это нежелательная ситуация, которая возникает, когда устройство или система пытается выполнить две или более операций одновременно, но из-за природы устройства или системы, операции должны выполняться в правильной последовательности, чтобы быть выполненными правильно.
Race condition — это недостаток, связанный с синхронизацией или упорядочением событий, что приводит к ошибочному поведению программы.
Но мне нравиться наиболее короткое и простое:
Race condition — это недостаток, возникающий, когда время или порядок событий влияют на правильность программы.
Важно, что Race condition — это семантическая ошибка.
В проектирование электронных схем есть похожая проблема:
Состязание сигналов — явление в цифровых устройствах несоответствия работы данного устройства с заданным алгоритмом работы по причине возникновения переходных процессов в реальной аппаратуре.
Рассмотрим пример, где результат не определен:
go func() {
fmt.Printf("A->")
}()
go func() {
fmt.Printf("B")
}()
Если запустить такой код много раз, то можно увидеть примерно такое:
A->B
A->B
A->B
A->B
BA->
A->B
Результат выполнения кода зависит от порядка выполнения горутин. Это типичная ошибка race condition. Ситуации могут быть гораздо сложней и не очевидней.
Учитывая, что race condition семантическая ошибка, нет общего способа который может отличить правильное и неправильное поведение программы в общем случае.
Помочь могут хорошие практики и проверенные паттерны.
Еще один пример:
x := 0
for {
go func() {
x++
}()
go func() {
if x%2 == 0 {
time.Sleep(1 * time.Millisecond)
fmt.Println(x)
}
}()
}
В результате на консоле получим четные и нечетные числа, а расчитывали увидеть только четные.
Проблемы с доступом к общим ресурсам проще обнаружить автоматически и решаются они обычно с помощью синхронизации:
var mu sync.Mutex
x := 0
for{
go func() {
mu.Lock()
x++
mu.Unlock()
}()
go func() {
mu.Lock()
if x%2 == 0 {
time.Sleep(1 * time.Millisecond)
fmt.Println(x)
}
mu.Unlock()
}()
}
или локальной копией:
x := 0
for i := 0; i < 1000; i++ {
go func() {
x++
}()
go func() {
y := x
if y%2 == 0 {
time.Sleep(1 * time.Millisecond)
fmt.Println(y)
}
}()
}
Data Race
Data race это состояние когда разные потоки обращаются к одной ячейке памяти без какой-либо синхронизации и как минимум один из потоков осуществляет запись.
Пример с балансом на счету:
type account struct {
balance int
}
func deposit(acc *account, amount int) {
acc.balance += amount
}
Запускаем в разных горутинах:
acc := account{balance: 0}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(n int) {
deposit(&acc, 1)
wg.Done()
}(i)
}
wg.Wait()
fmt.Printf("balance=%d\n", acc.balance)
Изначально баланс равен 0, депозитим 1000 раз по 1. Ожидаем баланс равный 1000, но результат другой:
balance=876
Потеряли много денег.
Причина в том, что операция acc.balance += amount не атомарная. Она может разложиться на 3:
tmp := acc.balance
tmp = tmp + amount
acc.balance = tmp
Пока мы меняем временную переменную в одном потоке, в других уже изменен основной balance. Таким образом теряется часть изменений.
Например, у нас 2 параллельных потока выполнения, каждый должен прибавить к балансу по 1:
tmp := acc.balance // 100 || tmp := acc.balance // 100
tmp = tmp + amount // 101 || tmp = tmp + amount // 101
acc.balance = tmp // 101 || acc.balance = tmp // 101
Ожидали получить баланс=102, а получили = 101.
У Data Race есть точное определение, которое не обязательно связано с корректностью, и поэтому их можно обнаружить. Существует множество разновидностей детекторов гонки данных (статическое/динамическое обнаружение, обнаружение на основе блокировок, обнаружение основанное на предшествующих событий, обнаружение гибридного data race).
У Go есть хороший Data Race Detector с помощью которого такие ошибки можно обнаружить.
Решается проблема с помощью синхронизации:
var mu sync.Mutex
func deposit(acc *account, amount int) {
mu.Lock()
acc.balance += amount
mu.Unlock()
}
Иногда более эффективным решением будет использовать пакет atomic.
func deposit(acc *account, amount int64) {
atomic.AddInt64(&acc.balance, amount)
}
Race Condition и Data Race
Функция для перевода средств с одного счета на другой:
func transfer1(accFrom, accTo *account, amount int) error {
if accFrom.balance < amount {
return fmt.Errorf("accFrom.balance<amount")
}
accTo.balance += amount
accFrom.balance -= amount
return nil
}
На одном счету у нас будет 1000, а на другом 0. Переводим по 1 в 1000 горутинах и ожидаем, что все деньги из одного счета перетекут в другой:
accFrom := account{balance: 1000}
accTo := account{balance: 0}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(n int) {
err := transfer1(&accFrom, &accTo, 1)
if err != nil {
fmt.Printf("error for n=%d\n", n)
}
wg.Done()
}(i)
}
wg.Wait()
fmt.Printf("accFrom.balance=%d\naccTo.balance=%d\n", accFrom.balance, accTo.balance)
Но результат может быть таким:
accFrom.balance=84
accTo.balance=915
Если запустить цикл на большее кол-во операций, то можно получить еще интересней:
accFrom.balance=0
accTo.balance=997
При вызове из нескольких потоков без внешней синхронизации эта функция допускает как data race (несколько потоков могут одновременно пытаться обновить баланс счета), так и race condition (в параллельном контексте это приведет к потере денег).
Для решения можно применить синхронизацию и локальную копию. Общая логика может быть не такой линейной и в итоге код может выглядит например так:
func transfer2(accFrom, accTo *account, amount int) error {
mu.Lock()
bal := accFrom.balance
mu.Unlock()
if bal < amount {
return fmt.Errorf("accFrom.balance<amount")
}
mu.Lock()
accTo.balance += amount
mu.Unlock()
mu.Lock()
accFrom.balance -= amount
mu.Unlock()
return nil
}
У нас синхронизированы все участки с записью и чтением, у нас есть локальная копия, Race Detector больше не ругается на код. Запускаем 1000 операций и получаем верный результат:
accFrom.balance=0
accTo.balance=1000
Но что если горутин будет 10к:
accFrom.balance=-15
accTo.balance=1015
Мы решили проблему data race, но race condition остался. В данном случае можно сделать блокировку на всю логику перевода средств, но это не всегда возможно.
Решив Data Race через синхронизацию доступа к памяти (блокировки) не всегда решается race condition и logical correctness.
Код примеров github.