Что такое состояние гонки?
- Почему count++ из двух потоков даёт неверный результат?
- Что такое критическая секция?
- Чем race condition отличается от data race?
Гонка — результат зависит от того, в каком порядке потоки выполнили свои шаги. Классика: count++ — это три шага (прочитать, прибавить, записать), и два потока теряют обновления.
- Критическая секция — участок, который должен выполняться одним потоком за раз
- Решения: блокировка, атомарные операции, неизменяемые данные, отказ от общего состояния
- «Проверить, потом действовать» (check-then-act) — гонка, даже если каждая операция атомарна
А ещё Гонки плохо воспроизводятся: тесты проходят, прод падает раз в неделю
Подробно
Когда несколько потоков работают с общими данными и хотя бы один пишет, результат может зависеть от чередования их шагов. Планировщик может прервать поток в любой момент, и на многоядерной машине потоки вообще выполняются одновременно.
Потерянное обновление. 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.
Пример
import threading
count = 0
def work():
global count
for _ in range(100_000):
count += 1 # прочитать, прибавить, записать — не атомарно
ts = [threading.Thread(target=work) for _ in range(8)]
[t.start() for t in ts]; [t.join() for t in ts]
print(count) # может быть меньше 800000
lock = threading.Lock()
def safe_work():
global count
for _ in range(100_000):
with lock: # критическая секция
count += 1Что хотят услышать
- Определение через зависимость от порядка выполнения.
- Разбор
count++на шаги и потерянное обновление. - Check-then-act как отдельный вид гонки.
- Способы устранения, включая неизменяемость и решения в базе.
Частые ошибки
- Думать, что потокобезопасная коллекция делает потокобезопасным весь код вокруг неё.
- Проверять отсутствие гонки одним запуском теста.
Проверьте себя
Один верный вариант. Подумайте, потом откройте ответ.
Показать варианты и ответ
- ✗Гонка возможна только на многоядерных процессорах
- ✗count++ атомарен в любом языке, гонки бывают только с файлами
- ✓Результат зависит от порядка шагов потоков; count++ теряет обновления
- ✗Гонку исправляет увеличение числа потоков и ядер
Что спросят дальше
Связанные вопросы
Разбор собеседования с sotto
После собеседования sotto разбирает разговор: оценка, каждый вопрос — хорошо, частично или слабо, чего не хватило в ответе и над чем поработать к следующему разу. Первые 30 минут бесплатно.