Pull to refresh

Многопоточность в Python: очевидное и невероятное

Level of difficultyMedium
Reading time4 min
Views37K

В данной статье я покажу на практическом примере как устроена многопоточность в 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 будет иметь в своем распоряжении невалидные данные.

Состояние гонки на примере 2х потоков
Состояние гонки на примере 2х потоков

Промежуточный итог

Можно ли сделать вывод что в Python 3.10 избавились от race condition и нам не нужны примитивы синхронизации? Как бы не так :)

Проведя небольшое расследование я нашел вот такой коммит и сообщение в твиттере от Python Core Developer.

tweet.PNG
сообщение в твиттере от 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.

Спасибо что прочитали до конца, надеюсь что вам было интересно!

Полезные ссылки:

Tags:
Hubs:
Total votes 37: ↑34 and ↓3+38
Comments16

Articles