Потоки, блокировки и гонки
Что это такое
Два потока, которым досталось одно общее значение, — это две независимые последовательности чтений и записей, и о порядке они между собой не договаривались. Если итог начинает зависеть от этого порядка, говорят о гонке. Блокировка же убирает саму возможность выбора: держать её одновременно вправе только один поток, поэтому код внутри критической секции выполняется так, будто он один на всей машине, а всё сложное в конкурентности с общим состоянием вытекает из одного — сколько вы кладёте в эту секцию и в каком порядке захватываете блокировки.
Почему это важно
Гонка не падает громко. Программа миллион раз подряд работает правильно, а затем выдаёт неверный итог, и в журнале при этом нет ничего необычного. Хуже того: компилятор и процессор вправе переставлять операции, которые в исходнике выглядят последовательными, поэтому исправление, работающее на вашей машине, может отказать на другой. У блокировок есть и свой, второй по частоте отказ — программа встаёт целиком, ведь каждый из двух потоков держит то, что нужно другому. Знание правил позволяет выбирать небольшие доказуемые приёмы вместо надежды на удачное стечение времени.
Ключевые идеи
Гонка — это отсутствующий порядок
Чтения и записи общей памяти упорядочены лишь тогда, когда их к этому что-то вынуждает. Иначе другой поток способен увидеть частично применённое обновление: список, у которого длина выросла раньше, чем в него положили элемент. Ошибка здесь, однако, не в медлительности: другой поток просто заглянул в середину. Исправить её можно одним: сделать так, чтобы всё обновление выглядело для остальных единым шагом.
общий список, длина 0
поток 1: кладёт элемент (длина всё ещё 0)
поток 2: читает длину 0 -> цикл кончается раньше, чем элемент виден
Блокировка делает секцию атомарной
Пока поток владеет блокировкой взаимного исключения, ту же секцию не выполнит никто другой, а его записи станут видны следующему владельцу. Какого размера эта секция — вопрос проектного решения: слишком узкая разрывает операцию, которая обязана быть целой, слишком широкая выстраивает в очередь работу, которая могла бы идти параллельно. Внутрь идёт всё, что должно быть видно вместе, наружу — всё медленное: сетевой вызов, чтение с диска.
lock(mutex)
count = count + 1 # чтение и запись здесь — один шаг
unlock(mutex)
Взаимоблокировка рождается из порядка блокировок
Пусть поток A держит первую блокировку и ждёт вторую, а поток B держит вторую и ждёт первую — тогда ни один из них не сдвинется с места. Условие здесь — цикл в графе ожидания, и складывается он из четырёх частей сразу: взаимного исключения, удержания при ожидании, отсутствия отзыва и самого цикла. Любую из них можно разорвать: захватывайте блокировки в одном глобальном порядке, обходитесь одной блокировкой где возможно или берите try-lock, который отказывается ждать.
A: держит m1, ждёт m2
B: держит m2, ждёт m1 <- цикл, оба ждут вечно
Неизменяемые данные не требуют блокировки
Самая дешёвая синхронизация — та, которая вообще не нужна. Значение, которое после публикации никто не меняет, могут безопасно читать сколько угодно потоков. Именно поэтому в конкурентном коде так часто передают между задачами неизменяемые сообщения, а не изменяют общую структуру. Там, где изменения неизбежны, гонку убирает по построению ограничение данных одним потоком — или одним владельцем, которому все остальные пишут сообщения.
Ограниченные очереди вместо общих структур
Очередь между потоками-производителями и потоками-исполнителями — рабочий приём на все случаи. Ограниченная очередь к тому же бесплатно даёт обратное давление: наполнившись, она заставляет производителя ждать, и система замедляется вместо того, чтобы исчерпать память. Синхронизация самой очереди написана один раз и протестирована, а код вокруг неё никогда не трогает общее состояние напрямую.
производители -> [ ограниченная очередь ] -> исполнители
полная очередь блокирует производителя — это и есть сигнал замедлиться
Практика
Сломайте, потом почините
Напишите программу, которая запускает четыре потока; каждый увеличивает общий счётчик сто тысяч раз, а в конце печатается итог. Запустите её несколько раз.
Готово, когда: итог неверен и меняется от запуска к запуску, а после добавления блокировки вокруг увеличения он точно верен каждый раз.
Воспроизведите взаимоблокировку
Напишите два потока, которые захватывают две блокировки в противоположном порядке, с короткой задержкой между захватами. Запустите и понаблюдайте зависание, а затем заставьте один поток захватывать блокировки в том же порядке, что и другой.
Готово, когда: первый вариант зависает надёжно, второй всегда завершается, и вы записали правило порядка, которое это исправило.
Частые ошибки
- Чтение общего значения вне блокировки — можно получить половину обновления; исправить: читайте и записывайте в одной критической секции либо сделайте значение неизменяемым.
- Удержание блокировки во время сетевого вызова — все остальные потоки выстраиваются за самым медленным запросом; исправить: сначала получите данные, а под блокировкой уже применяйте результат.
- Уверенность, что компилятор сохранит порядок строк — перестановка законна всюду, где её не запрещает синхронизация; исправить: полагайтесь на блокировки или атомарные операции, а не на порядок операторов.
- Захват двух блокировок в разном порядке — взаимоблокировка всплывает только тогда, когда оба пути выполняются одновременно; исправить: задайте один глобальный порядок блокировок и следуйте ему везде.
- Совместное использование изменяемой коллекции между потоками — её внутренности безопасны, а ваша последовательность вызовов нет; исправить: защитите последовательность или передавайте владение по очереди одному потоку.
Что читать дальше
- Асинхронность, промисы и циклы событий (01-cs-fundamentals/05-concurrency-async-programming/02-async-promises-and-event-loops) — второй способ перекрывать работу, где единицей служит не поток, а обратный вызов.
- Операционные системы (01-cs-fundamentals/15-operating-systems) — потоки, планировщик и модель памяти, на которых держатся эти блокировки.
- Отладка (01-cs-fundamentals/11-debugging) — как сделать сбой, зависящий от времени, воспроизводимым прежде, чем браться за исправление.