Когда несколько потоков работают с общими данными и хотя бы один пишет, результат может зависеть от чередования их шагов. Планировщик может прервать поток в любой момент, и на многоядерной машине потоки вообще выполняются одновременно.
Потерянное обновление. count++ компилируется в чтение значения в регистр, прибавление и запись обратно. Если оба потока прочитали 5, оба запишут 6 — одно увеличение потерялось. Десять потоков по миллиону инкрементов дадут заметно меньше десяти миллионов.
Check-then-act. «Если ключа нет в кэше — посчитать и положить», «если на счёте хватает денег — списать». Между проверкой и действием другой поток может изменить состояние. Даже потокобезопасная коллекция не спасает, если проверка и действие — два отдельных вызова: нужны атомарные составные операции (computeIfAbsent, putIfAbsent) или блокировка вокруг обоих.
Data race и race condition. Строго говоря, data race — одновременный доступ к одной ячейке памяти без синхронизации, где хотя бы один доступ — запись. Это ошибка на уровне модели памяти языка (в Java — нет гарантий видимости, в C++ — неопределённое поведение). Race condition шире — логическая ошибка порядка, она бывает и без data race (две атомарные операции в неудачном порядке) и даже между процессами или сервисами: два запроса одновременно бронируют последнее место.
Как устраняют:
- взаимное исключение — мьютекс,
synchronized, Lock вокруг критической секции; - атомарные операции —
AtomicInteger.incrementAndGet, CAS; - неизменяемость — нечего гонять, если данные не меняются;
- изоляция — у каждого потока свои данные (ThreadLocal, акторы, обмен сообщениями вместо общей памяти);
- в базе — транзакции с нужной изоляцией,
SELECT ... FOR UPDATE, оптимистическая блокировка по версии, атомарный UPDATE ... SET x = x - 1 WHERE x > 0.