Как отлаживать и предотвращать проблемы синхронизации
Лучшая многопоточная ошибка — та, которую вы спроектировали так, чтобы она не возникла.
Защитное проектирование конкурентности — это набор приёмов, который снижает само число опасных мест: меньше общего изменяемого состояния — меньше гонок и тупиков.
Отлаживать гонки «постфактум» тяжело: они недетерминированны. Поэтому опытные разработчики делают ставку на архитектуру, при которой ошибки попросту негде взяться.
Принцип: меньше общего изменяемого состояния
Если данные никто не меняет, гонки невозможны — читать неизменяемое можно сколько угодно потоков. Поэтому предпочитайте неизменяемые структуры и передачу копий, а не общих ссылок.
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уже корректна и протестирована. - Отлаживать гонку принтами. Вывод сам меняет тайминги и «прячет» баг (эффект гейзенбага).
Итог
- Меньше общего изменяемого состояния — меньше ошибок.
- Неизменяемые данные безопасны для чтения из многих потоков.
- Очереди инкапсулируют синхронизацию — используйте их вместо ручных блокировок.
- Таймауты и единый порядок блокировок превращают зависание в управляемую ошибку.