В данной статье я покажу на практическом примере как устроена многопоточность в Python, расскажу про потоки, примитивы синхронизации и о том зачем они нужны.
Изначально я планировал что это будет простая и короткая заметка, но пока готовил и тестировал код нашел интересный неочевидный момент связанный с внутренностями CPython, так что не спешите закрывать вкладку, даже если уверены что знаете о потоках в Python всё :)
Код
Представим что нам в программе нужен счетчик. Казалось бы, ничего сложного:
class Counter:
def __init__(self):
self.val = 0
def change(self):
self.val += 1
Изменять счетчик мы планируем из независимых потоков, каждый поток изменяет значение счетчика X раз
def work(counter, operationsCount):
for _ in range(operationsCount):
counter.change()
def run_threads(counter, threadsCount, operationsPerThreadCount):
threads = []
for _ in range(threadsCount):
t = threading.Thread(target=work, args=(counter, operationsPerThreadCount))
t.start()
threads.append(t)
for t in threads:
t.join()
Функция “main” выглядит так:
if __name__ == "__main__":
threadsCount = 10
operationsPerThreadCount = 1000000
expectedCounterValue = threadsCount * operationsPerThreadCount
counters = [Counter()]
for counter in counters:
run_threads(counter, threadsCount, operationsPerThreadCount)
print(f"{counter.class.name}: expected val: {expectedCounterValue}, actual val: {counter.val}")
Вопрос: какое значение счетчика выведет программа?
Ответ
Результат зависит от версии Python на которой был запущен скрипт.
Когда я в первый раз запустил эту программу я был ошарашен результатами, я был уверен на 100% что увижу в консоли противоположный результат. Результат выполнения скрипта на Python 3.11.5:
Counter: expected val: 10000000, actual val: 10000000
CPython неведомым способом смог обеспечить атомарность небезопасной по умолчанию операции increment.
Как он это сделал? Давайте разбираться.
Проверяем на других версиях Python
Перед тем как погружаться в детали реализации стандартной библиотеки и внутренностей рантайма я решил проверить поведение программы на других версиях языка. В этом мне здорово помогла утилита pyenv
Скрипт автоматизирующий выполнение программы на разных версиях Python
#!/bin/bash
versions=(3.7 3.8 3.9 3.10 3.11)
for version in ${versions[*]}
do
pyenv shell $version
python3 --version
python3 main.py
echo '\n'
done
Результаты:
Python 3.7.17
Counter: expected val: 10000000, actual val: 4198551
Python 3.8.18
Counter: expected val: 10000000, actual val: 4999351
Python 3.9.18
Counter: expected val: 10000000, actual val: 3551269
Python 3.10.13
Counter: expected val: 10000000, actual val: 10000000
Python 3.11.5
Counter: expected val: 10000000, actual val: 10000000
Почему в одних версиях Python значение счетчика совпадает c ожидаемым а в других нет? Всему виной состояние гонки.
Состояние гонки на примере операции increment
Почему с нашим счетчиком возникает операция гонки? Всё дело в том что операция increment состоит из нескольких шагов:
прочитать значение (currVal = self.val)
увеличить (newVal =currVal + 1)
записать новое значение (self.val = newVal)
И переключение контекста между потоками может произойти после шага 1 или шага 2 , что приведет к тому что поток перед выполнением шага 3 будет иметь в своем распоряжении невалидные данные.
Промежуточный итог
Можно ли сделать вывод что в Python 3.10 избавились от race condition и нам не нужны примитивы синхронизации? Как бы не так :)
Проведя небольшое расследование я нашел вот такой коммит и сообщение в твиттере от Python Core Developer.
Продолжаем эксперименты
Рассмотрим альтернативную реализацию счетчика, отличающуюся от обычной одной строчкой:
class CounterWithConversion:
def __init__(self):
self.val = 0
def change(self):
self.val += int(1) # единственное отличие - операция преобразования типа
И запустим тесты:
Python 3.7.17
CounterWithConversion: expected val: 10000000, actual val: 1960102
Python 3.8.18
CounterWithConversion: expected val: 10000000, actual val: 2860607
Python 3.9.18
CounterWithConversion: expected val: 10000000, actual val: 2558964
Python 3.10.13
CounterWithConversion: expected val: 10000000, actual val: 3387681
Python 3.11.5
CounterWithConversion: expected val: 10000000, actual val: 2310891
Видим, что такой код ломает потокобезопасность даже на последних версиях Python.
Синхронизация неизбежна
Мы попробовали разные реализации и разные версии Python и везде были свои проблемы. Поэтому чтобы быть точно уверенными в счетчике то нам необходимо добавить в него синхронизацию, чтобы избавиться от гонки за данные:
class ThreadSafeCounter:
def __init__(self):
self.val = 0
self.lock = threading.Lock()
def change(self):
with self.lock:
self.val += 1
Результаты
На этот раз без сюрпризов :)
Python 3.7.17
ThreadSafeCounter: expected val: 1000000, actual val: 1000000
Python 3.8.18
ThreadSafeCounter: expected val: 1000000, actual val: 1000000
Python 3.9.18
ThreadSafeCounter: expected val: 1000000, actual val: 1000000
Python 3.10.13
ThreadSafeCounter: expected val: 1000000, actual val: 1000000
Python 3.11.5
ThreadSafeCounter: expected val: 1000000, actual val: 1000000
Итоги
В данной статье я постарался показать на простом примере как работают потоки, что такое состояние гонки и как синхронизация помогает его избегать а также рассказал про любопытную баг-фичу которую обнаружил в процессе написания статьи.
Если вы хотите поэкспериментировать самостоятельно то я опубликовал весь код из статьи на GitHub.
Спасибо что прочитали до конца, надеюсь что вам было интересно!
Полезные ссылки:
Комментарии (13)
redfox0
29.09.2023 16:28-1Мне нравится, как сделано в расте, никогда не забудешь взять мьютекс или освободить его:
// Here we're using an Arc to share memory among threads, and the data inside // the Arc is protected with a mutex. let data = Arc::new(Mutex::new(0_u32)); // … { let mut data = data.lock().unwrap(); *data += 1; // the lock is unlocked here when `data` goes out of scope. }
mazdayka
29.09.2023 16:28И зачем для счётчика целый класс? Просто переменную использовать не судьба. a=0,a=a+1... профит
omaxx
К сожалению вариант с Lock на питоне до 3.10 занимает на порядок больше времени:
lebron32rus Автор
Такова цена, синхронизация не бесплатная)
omaxx
вот еще решение:
lebron32rus Автор
Версии без GIL само собой будут пошустрее, но я без понятия как они на проде себя показывают.
Спасибо за ценные комментарии????
omaxx
Ну я имел ввиду не то, что pypy шустрее, а то, что примеры без lock отработали без ошибок.
lebron32rus Автор
Запустил несколько раз, через раз срабатывает на 3.10 а на 3.9 видимо проблемы нет)
lebron32rus Автор
Придумал кейс который ломает pypy, видимо JIT бессилен перед таким способом прибавить единицу)
lebron32rus Автор
Оказалось что я перепутал, pypy это версия с GIL + JIT
Semy
Но почему она такая дорогая в Python < 3.10? И почему так улучшились дела в 3.10+?
omaxx
Мне кажется проблема все-таки не в lock:
slonopotamus
И вот тут мы плавно подходим к тому что из-за GIL писать на питоне многопоточный код совершенно бессмысленно, т.к. казалось бы независимые вычисления тормозят друг друга.