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

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

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

Комментарии (13)


  1. omaxx
    29.09.2023 16:28

    К сожалению вариант с Lock на питоне до 3.10 занимает на порядок больше времени:

    Python 3.7.9
    Counter: expected val: 1000000, actual val: 748022 @ 0.198 sec
    CounterWithConversion: expected val: 1000000, actual val: 184262 @ 0.293 sec
    ThreadSafeCounter: expected val: 1000000, actual val: 1000000 @ 4.17 sec
    
    Python 3.8.17
    Counter: expected val: 1000000, actual val: 654067 @ 0.226 sec
    CounterWithConversion: expected val: 1000000, actual val: 288048 @ 0.284 sec
    ThreadSafeCounter: expected val: 1000000, actual val: 1000000 @ 4.54 sec
    
    Python 3.9.17
    Counter: expected val: 1000000, actual val: 606116 @ 0.179 sec
    CounterWithConversion: expected val: 1000000, actual val: 592690 @ 0.23 sec
    ThreadSafeCounter: expected val: 1000000, actual val: 1000000 @ 5.51 sec
    
    Python 3.10.12
    Counter: expected val: 1000000, actual val: 1000000 @ 0.177 sec
    CounterWithConversion: expected val: 1000000, actual val: 520744 @ 0.231 sec
    ThreadSafeCounter: expected val: 1000000, actual val: 1000000 @ 0.407 sec
    
    Python 3.11.4
    Counter: expected val: 1000000, actual val: 1000000 @ 0.0982 sec
    CounterWithConversion: expected val: 1000000, actual val: 567276 @ 0.158 sec
    ThreadSafeCounter: expected val: 1000000, actual val: 1000000 @ 0.312 sec
    


    1. lebron32rus Автор
      29.09.2023 16:28
      +1

      Такова цена, синхронизация не бесплатная)


      1. omaxx
        29.09.2023 16:28

        вот еще решение:

        ❯ pypy3.9 main.py
        Counter: expected val: 1000000, actual val: 1000000 @ 0.00605 sec
        CounterWithConversion: expected val: 1000000, actual val: 1000000 @ 0.014 sec
        
        ❯ pypy3.10 main.py
        Counter: expected val: 1000000, actual val: 1000000 @ 0.00568 sec
        CounterWithConversion: expected val: 1000000, actual val: 1000000 @ 0.0146 sec
        


        1. lebron32rus Автор
          29.09.2023 16:28
          +1

          Версии без GIL само собой будут пошустрее, но я без понятия как они на проде себя показывают.

          Спасибо за ценные комментарии????


          1. omaxx
            29.09.2023 16:28

            Ну я имел ввиду не то, что pypy шустрее, а то, что примеры без lock отработали без ошибок.


            1. lebron32rus Автор
              29.09.2023 16:28

              Запустил несколько раз, через раз срабатывает на 3.10 а на 3.9 видимо проблемы нет)

              Python 3.9.17 (3f3f2298ddc56db44bbdb4551ce992d8e9401646, Jun 15 2023, 11:14:28)
              [PyPy 7.3.12 with GCC Apple LLVM 13.1.6 (clang-1316.0.21.2.5)]
              Counter: expected val: 1000000, actual val: 1000000
              CounterWithConversion: expected val: 1000000, actual val: 900000
              ThreadSafeCounter: expected val: 1000000, actual val: 1000000
              
              
              Python 3.10.12 (af44d0b8114cb82c40a07bb9ee9c1ca8a1b3688c, Jun 15 2023, 12:46:58)
              [PyPy 7.3.12 with GCC Apple LLVM 13.1.6 (clang-1316.0.21.2.5)]
              Counter: expected val: 1000000, actual val: 965024
              CounterWithConversion: expected val: 1000000, actual val: 1000000
              ThreadSafeCounter: expected val: 1000000, actual val: 1000000
              
              
              Python 3.9.17 (3f3f2298ddc56db44bbdb4551ce992d8e9401646, Jun 15 2023, 11:14:28)
              [PyPy 7.3.12 with GCC Apple LLVM 13.1.6 (clang-1316.0.21.2.5)]
              Counter: expected val: 1000000, actual val: 1000000
              CounterWithConversion: expected val: 1000000, actual val: 1000000
              ThreadSafeCounter: expected val: 1000000, actual val: 1000000
              
              
              Python 3.10.12 (af44d0b8114cb82c40a07bb9ee9c1ca8a1b3688c, Jun 15 2023, 12:46:58)
              [PyPy 7.3.12 with GCC Apple LLVM 13.1.6 (clang-1316.0.21.2.5)]
              Counter: expected val: 1000000, actual val: 1000000
              CounterWithConversion: expected val: 1000000, actual val: 1000000
              ThreadSafeCounter: expected val: 1000000, actual val: 1000000
              


            1. lebron32rus Автор
              29.09.2023 16:28
              +3

              Придумал кейс который ломает pypy, видимо JIT бессилен перед таким способом прибавить единицу)

              class CounterForPypy:
                def __init__(self):
                  self.val = 0
              
                def change(self):
                  self.val += random.randint(1, 1)
              
              Python 3.9.17 (3f3f2298ddc56db44bbdb4551ce992d8e9401646, Jun 15 2023, 11:14:28)
              [PyPy 7.3.12 with GCC Apple LLVM 13.1.6 (clang-1316.0.21.2.5)]
              CounterForPypy: expected val: 1000000, actual val: 645618
              
              
              Python 3.10.12 (af44d0b8114cb82c40a07bb9ee9c1ca8a1b3688c, Jun 15 2023, 12:46:58)
              [PyPy 7.3.12 with GCC Apple LLVM 13.1.6 (clang-1316.0.21.2.5)]
              CounterForPypy: expected val: 1000000, actual val: 447503
              


          1. lebron32rus Автор
            29.09.2023 16:28
            +1

            Оказалось что я перепутал, pypy это версия с GIL + JIT


      1. Semy
        29.09.2023 16:28

        Но почему она такая дорогая в Python < 3.10? И почему так улучшились дела в 3.10+?


        1. omaxx
          29.09.2023 16:28

          Мне кажется проблема все-таки не в lock:

          class ThreadSafeCounter:
            def change(self):
              with self.lock:
                self.val += 1
          
          class ThreadSafeCounterWithRandom:
            def change(self):
              with self.lock:
                self.val += random.randint(1, 1)
          
          ❯ ./script.sh
          Python 3.7.9
          ThreadSafeCounter: expected val: 1000000, actual val: 1000000 @ 3.3 sec
          ThreadSafeCounterWithRandom: expected val: 1000000, actual val: 1000000 @ 1.69 sec
          
          Python 3.8.17
          ThreadSafeCounter: expected val: 1000000, actual val: 1000000 @ 4.89 sec
          ThreadSafeCounterWithRandom: expected val: 1000000, actual val: 1000000 @ 1.55 sec
          
          Python 3.9.17
          ThreadSafeCounter: expected val: 1000000, actual val: 1000000 @ 3.33 sec
          ThreadSafeCounterWithRandom: expected val: 1000000, actual val: 1000000 @ 1.18 sec
          
          Python 3.10.12
          ThreadSafeCounter: expected val: 1000000, actual val: 1000000 @ 0.375 sec
          ThreadSafeCounterWithRandom: expected val: 1000000, actual val: 1000000 @ 1.24 sec
          
          Python 3.11.4
          ThreadSafeCounter: expected val: 1000000, actual val: 1000000 @ 0.296 sec
          ThreadSafeCounterWithRandom: expected val: 1000000, actual val: 1000000 @ 0.767 sec
          


          1. slonopotamus
            29.09.2023 16:28
            +1

            И вот тут мы плавно подходим к тому что из-за GIL писать на питоне многопоточный код совершенно бессмысленно, т.к. казалось бы независимые вычисления тормозят друг друга.


  1. 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.
    }
    


  1. mazdayka
    29.09.2023 16:28

    И зачем для счётчика целый класс? Просто переменную использовать не судьба. a=0,a=a+1... профит