Перейти к содержимому
Теория · Горутины и каналы · урок 9 из 13

Mutex и гонка данных

На кухне есть кладовая, а на двери — листок с числом мешков муки. Повар, который принёс мешок, читает число, прибавляет единицу и записывает новое. Что будет, если два повара прочитают листок одновременно? Оба увидят 41, оба запишут 42, и один мешок пропадёт из учёта.

Горутины попадают в ту же ловушку. Тысяча горутин увеличивает одну переменную, каждая на единицу, а main ждёт их всех:

GO
package main

import "fmt"
import "sync"

func main() {
    var wg sync.WaitGroup
    n := 0
    for range 1000 {
        wg.Go(func() { n++ })
    }
    wg.Wait()
    fmt.Println(n)
}

Программа должна напечатать 1000, но на компьютере с несколькими ядрами обычно печатает меньше, например 970. Каждый запуск даёт другое число, а на одном ядре ошибка может не проявиться ни разу.

Гонка данных

Операция n++ состоит из трёх шагов:

  1. Прочитать n.
  2. Прибавить единицу.
  3. Записать результат.

Две горутины могут одновременно прочитать 41 и обе записать 42, и одно увеличение пропадёт.

Когда две горутины одновременно обращаются к одной переменной и хотя бы одна из них пишет, это гонка данных. Результат такой программы непредсказуем.

Как найти гонку

Найти гонку помогает флаг -race. Команда go run -race main.go печатает WARNING: DATA RACE и строки, где горутины встретились.

Гонка в map

С map гонка заканчивается ещё хуже, если несколько горутин пишут в одну map без защиты:

  • среда выполнения часто это замечает и останавливает программу с fatal error: concurrent map writes;
  • это не паника: перехватить её через recover нельзя.

На собеседовании про это любят спрашивать: обычная map в Go не защищена от одновременной записи.

sync.Mutex

Решение на кухне — один ключ от кладовой. Кто взял ключ, тот заходит и обновляет листок, а остальные ждут у двери. Тип sync.Mutex из пакета sync и есть такой ключ: он пускает к данным одну горутину за раз. У него два метода:

  • Lock забирает ключ; горутина, которая вызвала Lock у запертого Mutex, ждёт, пока владелец не вызовет Unlock;
  • Unlock возвращает ключ.
GO
var wg sync.WaitGroup
var mu sync.Mutex
n := 0
for range 1000 {
    wg.Go(func() {
        mu.Lock()
        n++
        mu.Unlock()
    })
}
wg.Wait()
fmt.Println(n) // 1000

Давайте разберём тело горутины:

  1. mu.Lock() забирает ключ или ждёт, пока его вернут.
  2. n++ выполняет все три шага, пока горутина держит ключ.
  3. mu.Unlock() возвращает ключ следующей горутине.

Другая горутина не может вклиниться между чтением и записью, поэтому ни одно увеличение не теряется, и программа печатает 1000 при каждом запуске.

Нулевое значение и копирование

Mutex объявляют и передают по тем же правилам, что и WaitGroup:

  • нулевое значение Mutex не заперто и готово к работе;
  • литерал sync.Mutex{} даёт то же значение, поэтому var wg, mu = sync.WaitGroup{}, sync.Mutex{} объявляет обе переменные готовыми к работе;
  • Mutex нельзя копировать;
  • структуру с полем sync.Mutex передают по указателю, а её методы объявляют с получателем-указателем.

Правила работы с Mutex

Здесь аналогия с ключом заканчивается. Настоящая дверь без ключа не откроется, а Mutex никого не держит силой: горутина, которая не вызвала Lock, всё равно дотянется до n. Отсюда три правила:

  • Lock должны вызывать все горутины, которые трогают общие данные;
  • читать общую переменную тоже нужно под Mutex, если другие горутины в неё пишут;
  • между Lock и Unlock держат как можно меньше кода, потому что остальные горутины в это время ждут.

Частая ошибка

Функция вызвала Lock, но на одном из путей вышла, не вызвав Unlock:

GO
var mu sync.Mutex
var stock = 5

func take(n int) bool {
    mu.Lock()
    if stock < n {
        return false // ✗
    }
    stock -= n
    mu.Unlock()
    return true
}

Ранний return блокирует все следующие вызовы take:

  1. После вызова take(10) Mutex остаётся запертым: повар унёс ключ с собой.
  2. Следующий вызов take ждёт на Lock вечно.
  3. Если так ждут все горутины, программа останавливается с fatal error: all goroutines are asleep - deadlock!.

Исправление — defer mu.Unlock() сразу после mu.Lock(): тогда Mutex отпирается при любом выходе из функции.

Главное

  • Гонка данных — одновременный доступ к переменной, где хотя бы одна горутина пишет; флаг -race её находит.
  • Одновременная запись в map без защиты может остановить программу с fatal error: concurrent map writes.
  • mu.Lock() и mu.Unlock() пускают к данным одну горутину за раз; Lock вызывают все, кто трогает данные.
  • defer mu.Unlock() сразу после Lock отпирает Mutex при любом выходе; Mutex не копируют.

Подпишись, чтобы пройти курс

Курс бесплатный. Войди через Telegram и подпишись на канал — откроются все уроки.

Войти другим способом