Как отлаживать и предотвращать проблемы синхронизации

Лучшая многопоточная ошибка — та, которую вы спроектировали так, чтобы она не возникла.

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

Отлаживать гонки «постфактум» тяжело: они недетерминированны. Поэтому опытные разработчики делают ставку на архитектуру, при которой ошибки попросту негде взяться.

Принцип: меньше общего изменяемого состояния

Если данные никто не меняет, гонки невозможны — читать неизменяемое можно сколько угодно потоков. Поэтому предпочитайте неизменяемые структуры и передачу копий, а не общих ссылок.

from collections import namedtuple

Point = namedtuple("Point", ["x", "y"])
p = Point(1, 2)

# «изменение» создаёт новый объект, старый неизменен -> безопасно читать из многих потоков
p2 = p._replace(x=10)
print(p)
print(p2)

Вывод:

Point(x=1, y=2)
Point(x=10, y=2)

Принцип: общайтесь через очереди, а не через переменные

Вместо того чтобы несколько потоков лезли в общий список под блокировкой, пусть они обмениваются сообщениями через потокобезопасную очередь (queue.Queue). Очередь сама инкапсулирует всю синхронизацию.

import queue, threading

q = queue.Queue()

def worker():
    while True:
        item = q.get()       # блокируется, пока пусто
        if item is None:
            break
        print("обработал", item)
        q.task_done()

threading.Thread(target=worker, daemon=True).start()
for i in range(3):
    q.put(i)
q.join()

Чек-лист защиты

ПриёмОт чего спасает
Таймаут на acquireвечный deadlock превращается в ошибку
Единый порядок блокировоккруговое ожидание
Неизменяемые данныегонки на чтении/записи
Очереди вместо общих переменныхручная синхронизация и её баги
Минимум критической секциипотеря параллелизма

Как работает под капотом

Потокобезопасные очереди внутри используют блокировку и условные переменные: get() на пустой очереди усыпляет поток через wait(), а put() будит его через notify(). Вы получаете готовую правильную синхронизацию и не пишете её руками. Похожая идея лежит в основе модели акторов и каналов, которые мы разберём позже: данные передаются сообщениями, а не разделяются.

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

  • Делиться изменяемым объектом «на всякий случай». Каждый общий мутабельный объект — потенциальная гонка.
  • Изобретать свою очередь на списках и блокировках. Готовая queue.Queue уже корректна и протестирована.
  • Отлаживать гонку принтами. Вывод сам меняет тайминги и «прячет» баг (эффект гейзенбага).

Итог

  • Меньше общего изменяемого состояния — меньше ошибок.
  • Неизменяемые данные безопасны для чтения из многих потоков.
  • Очереди инкапсулируют синхронизацию — используйте их вместо ручных блокировок.
  • Таймауты и единый порядок блокировок превращают зависание в управляемую ошибку.
Проверьте себя
1. Почему неизменяемые (immutable) данные безопасны для конкурентного доступа?
AИх нельзя прочитать
BРаз никто их не меняет, гонки на запись невозможны
CОни хранятся только на диске
DОни работают лишь в одном потоке
2. В чём преимущество queue.Queue перед ручной синхронизацией общего списка?
AОна быстрее любого алгоритма
BОчередь сама инкапсулирует корректную синхронизацию, и её не нужно писать руками
CОна не требует памяти
DОна запрещает многопоточность