Почему counter++ из двух потоков теряет обновления, как устроен мьютекс, откуда берутся дедлоки, что гарантируют атомарные операции и почему процессор вправе переставить ваши записи.
Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки. Предыдущая тема — процессы и потоки.
counter++ — это три операцииПроцессор не умеет «прибавить к памяти» одним неделимым шагом без специального префикса. Компилятор превращает инкремент в загрузку в регистр, сложение и запись обратно.
mov eax, [counter] ; load
add eax, 1 ; add
mov [counter], eax ; store
Гонка данных (data race) — два потока обращаются к одной переменной без синхронизации, и хотя бы один пишет. Попробуйте выбрать порядок шагов так, чтобы одно прибавление потерялось.
lock xadd на x86 — держит кэш-линию на время операции, и ни одно обновление не теряется.В CPython есть GIL: в каждый момент байткод выполняет только один поток. Безопасен ли counter += 1 из нескольких потоков threading?
LOAD_GLOBAL counter
LOAD_CONST 1
BINARY_OP +=
STORE_GLOBAL counter
+= — несколько байткодов, и переключение между LOAD и STORE теряет обновление. В 3.10+ переключения стали реже, и тест «вроде проходит», но гарантии нет, а в сборке без GIL (3.13t) потери видны сразу. Нужен threading.Lock. В Node.js гонки данных между колбэками невозможны — один поток, — но гонки условий через await никуда не делись.Гонка условий (race condition) шире гонки данных: каждая операция может быть атомарной, а результат всё равно зависит от порядка. Классика — «проверить, потом действовать».
if not exists(path): open(path, "w"). Между проверкой и созданием файл может появиться. Правильно — одним вызовом: O_CREAT | O_EXCL.Выберите вариант и проходите по шагам: запросы Алисы и Боба приходят одновременно.
Мьютекс превращает «load, add, store» в критическую секцию: внутри в каждый момент только один поток. Остальные ждут у входа.
mu.Lock()
counter++ // критическая секция
mu.Unlock()
Счётчик теперь точный, но за это платят ожиданием. Двигайте ползунок: чем большую долю времени поток проводит под замком, тем больше остальные стоят в очереди и тем меньше выигрыш от добавления потоков.
futex(FUTEX_WAIT): ядро снимает поток с CPU.FUTEX_WAKE и переключение контекста: единицы микросекунд, в сотни раз дороже захвата.sync.Mutex немного крутится, потом паркует горутину; если ждут дольше 1 мс — переходит в честный режим. Java: synchronized так же крутится, потом паркует поток.Перевод денег: заблокировать счёт-источник, потом счёт-получатель. Два встречных перевода — и каждый держит то, что нужно другому.
Дедлок возможен, только если выполнены все четыре условия Коффмана: взаимное исключение, «держу и жду», нельзя отобрать ресурс, циклическое ожидание. Проще всего сломать последнее: брать блокировки всегда в одном порядке, например по id.
Ходите потоками по одному шагу. Потом включите упорядочивание и попробуйте снова.
fatal error: all goroutines are asleep. Частичный дедлок висит молча.tryLock(timeout), context с дедлайном, lock_timeout в Postgres. Postgres сам ищет циклы через deadlock_timeout (1 с) и откатывает одну транзакцию.Процессор умеет атомарно выполнить «прочитать — изменить — записать» для одного слова: lock xadd, lock cmpxchg на x86, ldaxr/stlxr или LSE-инструкции на ARM.
for {
old := atomic.LoadInt64(&x)
if atomic.CompareAndSwapInt64(&x, old, old+1) {
break // никто не успел вмешаться
}
// кто-то изменил x — повторяем
}
CAS (compare-and-swap) записывает новое значение, только если в памяти всё ещё старое. Добавляйте потоки и смотрите, как растёт число повторов.
Стек Трайбера: pop читает вершину A и её next, затем делает CAS(top, A, next). Если за это время вершина успела смениться и вернуться к A, CAS ничего не заметит.
Так бывает, когда узлы переиспользуются: пул объектов, ручное освобождение памяти в C/C++. Лечится счётчиком версий рядом с указателем (двойной CAS) или безопасным освобождением: hazard pointers, epoch-based reclamation. В Go и Java сборщик мусора не отдаст узел, пока на него ссылается поток, — поэтому классическая ABA там редкость.
У каждого ядра есть буфер записи (store buffer): запись уходит в него и попадает в кэш позже, а следующее чтение не ждёт. Для чужого ядра это выглядит так, будто чтение выполнилось раньше записи.
x = y = 0
T1: x = 1; r1 = y T2: y = 1; r2 = x
При любом чередовании операций результат r1 = r2 = 0 невозможен. На реальном x86 он случается. ARM переставляет ещё больше, плюс переставляет компилятор. Пройдите по шагам, затем запустите тысячу прогонов с барьером и без.
Модели памяти Go, Java, C++ и JS устроены одинаково: если в программе нет гонок данных, она ведёт себя так, будто операции идут по очереди. Порядок между потоками создают только синхронизирующие операции.
Unlock мьютекса происходит-до следующего Lock.wg.Done() — до возврата wg.Wait().volatile, C++: std::atomic, JS: Atomics.var data int
var ready bool // нужен atomic.Bool
go func() {
data = 42
ready = true
}()
for !ready { } // может никогда не выйти
fmt.Println(data) // может напечатать 0
Без синхронизации компилятор вправе вынести чтение ready из цикла, а процессор ARM — сделать запись ready видимой раньше data. volatile в C/C++ здесь не помогает: он только для памяти устройств.
Ленивая инициализация «без лишних блокировок»: проверить без замка, и только если пусто — взять замок и создать. Объект при этом может стать виден другому потоку недостроенным.
if (instance == null) { // без замка
synchronized (lock) {
if (instance == null)
instance = new Config(); // выделить, заполнить, опубликовать
}
}
return instance;
Компилятор и процессор вправе опубликовать ссылку раньше, чем заполнены поля. Пройдите по шагам. Исправление: volatile в Java, sync.Once в Go, std::call_once или статическая локальная переменная в C++.
Горутина-воркер пишет результат в result, а затем ставит done = true (обычные переменные). Главная горутина ждёт for !done {} и читает result. Тесты на ноутбуке проходят. Что на самом деле?
done один раз до цикла — и цикл не закончится даже на x86. go test -race такой код ловит сразу. Правильно — atomic.Bool, канал или sync.WaitGroup: они создают отношение happens-before.semaphore.Weighted.for !cond { c.Wait() }. Всегда в цикле: пробуждение не гарантирует, что условие истинно. Java: wait/notify, Python: threading.Condition.Переключайте примитив и долю писателей: кто внутри, кто ждёт и сколько успели сделать.
Если доля s работы выполняется последовательно — под общим замком, в одном потоке, — ускорение на N ядрах не больше 1 / (s + (1 − s) / N). Предел — 1/s, сколько ядер ни добавляй.
На деле хуже: ядра ещё и согласуют кэш-линию замка между собой, и эта цена растёт с числом участников. После некоторого числа ядер добавление потоков замедляет программу. Модель с этим эффектом — универсальный закон масштабируемости Гюнтера.
Лечат дроблением: шардированные счётчики и мапы, локальные буферы на поток со сбросом пачкой, обработка без общего состояния.
Сервис перенесли с 8 на 64 ядра, а пропускная способность почти не выросла. Мьютекс-профиль показывает: 40% времени запроса проходит под одним глобальным замком кэша. Какое ускорение максимально возможно, если ничего не менять в коде?
sync.Map или копирование при записи.go test -race ./... и go run -race — детектор гонок. В 2–20 раз медленнее, ловит только гонки, которые реально случились в прогоне, зато без ложных срабатываний. Держите в CI.-fsanitize=thread (ThreadSanitizer), то же устройство. Java: jcmd <pid> Thread.print сам находит дедлоки. Python: py-spy dump.pprof профили mutex и block; Linux — perf lock, offcputime из bcc; много futex в strace -c — потоки дерутся за замок.pg_locks, pg_blocking_pids(), log_lock_waits.==================
WARNING: DATA RACE
Write at 0x00c0000140a8 by goroutine 8:
main.inc()
/app/main.go:12 +0x44
Previous write at 0x00c0000140a8 by goroutine 7:
main.inc()
/app/main.go:12 +0x44
Goroutine 8 (running) created at:
main.main()
/app/main.go:18 +0x7c
==================
ConcurrentHashMap, errgroup, пулы. Потом — мьютекс с короткой секцией. Lock-free своими руками — в последнюю очередь.UNIQUE, условный UPDATE … WHERE, SELECT … FOR UPDATE, advisory locks.version, UPDATE … WHERE version = $1, при 0 строк — повторить.counter++ — load, add, store. Без синхронизации обновления теряются, и это норма, а не редкость.-race и TSan.Не общайтесь через общую память — делитесь памятью, общаясь.Go Proverbs, Роб Пайк