Модель памяти free-threaded Python

Корректна ли данная программа? Всегда ли она должна завершится? (по мотивам вопроса Остановить потоки при завершениии программы)

import time
import threading

class Foo:
    def __init__(self, delay, count = 1):
        self._running = False
        self._delay = delay
        self._count = count
    def start(self):
        assert not self._running
        self._running = True
        self._threads = []
        for _ in range(self._count):
            self._threads.append(threading.Thread(target=self._work))
            self._threads[-1].start()
    def stop(self):
        assert self._running
        self._running = False
        for t in self._threads:
            t.join()
        print(f"{type(self).__name__} завершение работы {len(self._threads)}")
    def _is_running(self):
        return self._running
    def _work(self):
        while self._is_running():  # Не оптимально, но для сравнения
            if self._delay:
                print("_work(): цикл")
                time.sleep(self._delay)
    def __enter__(self):
        return self
    def __exit__(self, exc_type, exc_value, traceback):
        self.stop()

delay, count = 0, 7
with Foo(delay, count) as f:
    f.start()
    time.sleep(3*delay + 1)

Вроде бы, читая PEP 583 – A Concurrency Memory Model for Python можно подумать, что более менее (в отличие от C/C++/Java), впрочем он не слишком строг. Кроме того, всё таки он Withdrawn и от 2008 года, а при наличии GIL я в этой программе и не сомневаюсь.

Или для поддержки free-threaded Python обязательно необходимо стать более строгим?

import time
import threading

class Boo:
    def __init__(self, delay, count = 1):
        self._running = threading.Event()
        self._delay = delay
        self._count = count
    def start(self):
        assert not self._running.is_set()
        self._running.set()
        self._threads = []
        for _ in range(self._count):
            self._threads.append(threading.Thread(target=self._work))
            self._threads[-1].start()
    def stop(self):
        assert self._running.is_set()
        self._running.clear()
        for t in self._threads:
            t.join()
        print(f"{type(self).__name__} завершение работы {len(self._threads)}")
    def _is_running(self):
        return self._running.is_set()
    def _work(self):
        while self._is_running():  # Не оптимально, но для сравнения
            if self._delay:
                print("_work(): цикл")
                time.sleep(self._delay)
    def __enter__(self):
        return self
    def __exit__(self, exc_type, exc_value, traceback):
        self.stop()

delay, count = 0, 7
with Boo(delay, count) as b:
    b.start()
    time.sleep(3*delay + 1)

Смущает, что в Python не добавили поддержку атомарных переменных, поэтому вообще неясна необходимость модификаций. Возможно всё и так должно нормально работать, точно так же, как и в случае GIL?

К примеру, протоколы когерентности памяти для x86 (MESI, MOESI, ...), вроде как, не нарушают корректности первого варианта, даже если GIL отключен. За все ARM не скажу, хотя на некоторых он должен быть корректен.

А вот гарантии для всех платформ с поддержкой free-threaded Python, лично мне пока не понятны, то ли плохо искал и не нашёл нужного документа, то ли плохо прочитал имеющиеся (PEP583, PEP703).

Кроме того неясно достаточно ли threading.Event() близок к атомарным типам того же C++, нет ли у него проблем масштабирования метода is_set()? Или лучше использовать какой-нибудь иной примитив, типа, threading.Lock()?

P.S.

Цена вопроса:

import sys
assert 0 == sys.flags.gil
delay, count = 0, 15

for ttype in Foo, Boo, BooLock:
    with ttype(delay, count) as t:
        t.start()
        %timeit t._is_running()

Для macOS, Python 3.14t:

215 ns ± 9.87 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
Foo завершение работы 15
2.25 μs ± 34.3 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
Boo завершение работы 15
9.96 μs ± 307 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
BooLock завершение работы 15
1.94 μs ± 25.3 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
Foo завершение работы 150
22.8 μs ± 447 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
Boo завершение работы 150
1.38 ms ± 14.1 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
BooLock завершение работы 150

Где BooLock использует with self._lock: return self._running.

Похоже под macOS threading.Event() ни разу не атомарный тип, хотя и без проблем масштабирования и существенно лучше чем threading.Lock().


Ответы (2 шт):

Автор решения: Andrey Tabakov

Вопрос на данный момент открыт и является предметом обсуждения.

https://discuss.python.org/t/pre-pep-safe-parallel-python/103636

На данный момент нет спецификации и гарантий, это работает как есть (AS IS). Возможно, мы увидим принятое решение в PEP805. Однако на данный момент - его просто нет.

→ Ссылка
Автор решения: Serge3leo

Вроде бы, понял. Для неизменямых типов, вопрос в актуальности адреса значения.

Код вполне корректный. Согласно PEP 703, Container Thread-Safety, завершение потоков гарантировано на всех платформах free-threaded Python. Поскольку без GIL, грубо говоря, "быстрый" путь всей адресной/индексной арифметики на атомарных инструкциях. Загрузка и запись адреса атрибута (self._running, он же, self.__dict__["_running"]) выполняется атомарной инструкцией. Т.е. гарантировано корректно получаем актуальный адрес, либо True, либо False, а потом ветвимся.

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

Что касается, производительности класса threading.Event, реализованного в Lib/threading.py:

class Event:
    ...
    def is_set(self):
        """Return true if and only if the internal flag is true."""
        return self._flag

Без GIL, это тоже набор атомарных загрузок, просто их больше, поэтому она медленнее, но с масштабированием у неё нормально. Таким образом использование threading.Event для обслуживания события остановки потоков возможно, но избыточно и неэффективно.

Что касается модели памяти free-threaded Python в общем случае, а не только неизменяемых типов данных. Документов и спецификаций, которые бы актуализировали бы PEP 583, пока не нашлось, поэтому это больше мои личные домыслы. Место механизма GIL занял механизм QSBR (вместо блокировки - атомарные инструкции с семантикой Release и Acquire), побочным эффектом которого является, грубо говоря, получение текущим потоком изменений памяти сделанных другими потоками. Таким образом, на настоящий момент (3.14), PEP 583 выглядит более менее актуальным.

→ Ссылка