Потоки, блокировки и гонки

Что это такое

Два потока, которым досталось одно общее значение, — это две независимые последовательности чтений и записей, и о порядке они между собой не договаривались. Если итог начинает зависеть от этого порядка, говорят о гонке. Блокировка же убирает саму возможность выбора: держать её одновременно вправе только один поток, поэтому код внутри критической секции выполняется так, будто он один на всей машине, а всё сложное в конкурентности с общим состоянием вытекает из одного — сколько вы кладёте в эту секцию и в каком порядке захватываете блокировки.

Почему это важно

Гонка не падает громко. Программа миллион раз подряд работает правильно, а затем выдаёт неверный итог, и в журнале при этом нет ничего необычного. Хуже того: компилятор и процессор вправе переставлять операции, которые в исходнике выглядят последовательными, поэтому исправление, работающее на вашей машине, может отказать на другой. У блокировок есть и свой, второй по частоте отказ — программа встаёт целиком, ведь каждый из двух потоков держит то, что нужно другому. Знание правил позволяет выбирать небольшие доказуемые приёмы вместо надежды на удачное стечение времени.

Ключевые идеи

Гонка — это отсутствующий порядок

Чтения и записи общей памяти упорядочены лишь тогда, когда их к этому что-то вынуждает. Иначе другой поток способен увидеть частично применённое обновление: список, у которого длина выросла раньше, чем в него положили элемент. Ошибка здесь, однако, не в медлительности: другой поток просто заглянул в середину. Исправить её можно одним: сделать так, чтобы всё обновление выглядело для остальных единым шагом.

общий список, длина 0
поток 1: кладёт элемент        (длина всё ещё 0)
поток 2: читает длину 0 -> цикл кончается раньше, чем элемент виден

Блокировка делает секцию атомарной

Пока поток владеет блокировкой взаимного исключения, ту же секцию не выполнит никто другой, а его записи станут видны следующему владельцу. Какого размера эта секция — вопрос проектного решения: слишком узкая разрывает операцию, которая обязана быть целой, слишком широкая выстраивает в очередь работу, которая могла бы идти параллельно. Внутрь идёт всё, что должно быть видно вместе, наружу — всё медленное: сетевой вызов, чтение с диска.

lock(mutex)
  count = count + 1     # чтение и запись здесь — один шаг
unlock(mutex)

Взаимоблокировка рождается из порядка блокировок

Пусть поток A держит первую блокировку и ждёт вторую, а поток B держит вторую и ждёт первую — тогда ни один из них не сдвинется с места. Условие здесь — цикл в графе ожидания, и складывается он из четырёх частей сразу: взаимного исключения, удержания при ожидании, отсутствия отзыва и самого цикла. Любую из них можно разорвать: захватывайте блокировки в одном глобальном порядке, обходитесь одной блокировкой где возможно или берите try-lock, который отказывается ждать.

A: держит m1, ждёт m2
B: держит m2, ждёт m1        <- цикл, оба ждут вечно

Неизменяемые данные не требуют блокировки

Самая дешёвая синхронизация — та, которая вообще не нужна. Значение, которое после публикации никто не меняет, могут безопасно читать сколько угодно потоков. Именно поэтому в конкурентном коде так часто передают между задачами неизменяемые сообщения, а не изменяют общую структуру. Там, где изменения неизбежны, гонку убирает по построению ограничение данных одним потоком — или одним владельцем, которому все остальные пишут сообщения.

Ограниченные очереди вместо общих структур

Очередь между потоками-производителями и потоками-исполнителями — рабочий приём на все случаи. Ограниченная очередь к тому же бесплатно даёт обратное давление: наполнившись, она заставляет производителя ждать, и система замедляется вместо того, чтобы исчерпать память. Синхронизация самой очереди написана один раз и протестирована, а код вокруг неё никогда не трогает общее состояние напрямую.

производители -> [ ограниченная очередь ] -> исполнители
полная очередь блокирует производителя — это и есть сигнал замедлиться

Практика

Сломайте, потом почините

Напишите программу, которая запускает четыре потока; каждый увеличивает общий счётчик сто тысяч раз, а в конце печатается итог. Запустите её несколько раз.

Готово, когда: итог неверен и меняется от запуска к запуску, а после добавления блокировки вокруг увеличения он точно верен каждый раз.

Воспроизведите взаимоблокировку

Напишите два потока, которые захватывают две блокировки в противоположном порядке, с короткой задержкой между захватами. Запустите и понаблюдайте зависание, а затем заставьте один поток захватывать блокировки в том же порядке, что и другой.

Готово, когда: первый вариант зависает надёжно, второй всегда завершается, и вы записали правило порядка, которое это исправило.

Частые ошибки

  • Чтение общего значения вне блокировки — можно получить половину обновления; исправить: читайте и записывайте в одной критической секции либо сделайте значение неизменяемым.
  • Удержание блокировки во время сетевого вызова — все остальные потоки выстраиваются за самым медленным запросом; исправить: сначала получите данные, а под блокировкой уже применяйте результат.
  • Уверенность, что компилятор сохранит порядок строк — перестановка законна всюду, где её не запрещает синхронизация; исправить: полагайтесь на блокировки или атомарные операции, а не на порядок операторов.
  • Захват двух блокировок в разном порядке — взаимоблокировка всплывает только тогда, когда оба пути выполняются одновременно; исправить: задайте один глобальный порядок блокировок и следуйте ему везде.
  • Совместное использование изменяемой коллекции между потоками — её внутренности безопасны, а ваша последовательность вызовов нет; исправить: защитите последовательность или передавайте владение по очереди одному потоку.

Что читать дальше