Почему Python threading.Condition() notify() требует блокировки?

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

Когда поток T1 имеет этот код:

cv.acquire()
cv.wait()
cv.release()

и поток T2 имеет этот код:

cv.acquire()
cv.notify()  # requires that lock be held
cv.release()

происходит то, что T1 ожидает и снимает блокировку, затем T2 получает ее, уведомляет cv который просыпается T1. Теперь существует состояние гонки между освобождением T2 и повторным запросом T1 после возвращения из wait(), Если T1 попытается восстановить сначала, он будет излишне ресуспендирован до T2. release() выполнен.

Примечание: я намеренно не использую with Заявление, чтобы лучше проиллюстрировать гонку с явными звонками.

Это похоже на недостаток дизайна. Есть ли какое-либо объяснение этому или я что-то упустил?

6 ответов

Решение

Это не окончательный ответ, но он должен охватывать соответствующие детали, которые мне удалось собрать об этой проблеме.

Во-первых, реализация потоков в Python основана на Java. в Java Condition.signal() Документация гласит:

Реализация может (и обычно делает) требовать, чтобы текущий поток удерживал блокировку, связанную с этим условием, когда вызывается этот метод.

Теперь вопрос состоял в том, зачем применять это поведение в частности в Python. Но сначала я хочу рассказать о плюсах и минусах каждого подхода.

Что касается того, почему некоторые думают, что часто лучше держать блокировку, я нашел два основных аргумента:

  1. С минуты официант acquire() перед блокировкой wait() - он гарантированно будет уведомлен о сигналах. Если соответствующий release() произошло до сигнализации, это позволило бы последовательность (где P= производитель и C= потребитель) P: release(); C: acquire(); P: notify(); C: wait() в этом случае wait() соответствует acquire() из того же потока будет пропустить сигнал. Есть случаи, когда это не имеет значения (и даже может считаться более точным), но есть случаи, когда это нежелательно. Это один из аргументов.

  2. Когда ты notify() вне блокировки это может вызвать инверсию приоритета планирования; то есть поток с низким приоритетом может в конечном итоге получить приоритет над потоком с высоким приоритетом. Рассмотрим рабочую очередь с одним производителем и двумя потребителями (LC= потребитель с низким приоритетом и HC= потребитель с высоким приоритетом), где LC в данный момент выполняет рабочий элемент, а HC заблокирован в wait(),

Может произойти следующая последовательность:

P                    LC                    HC
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                     execute(item)                   (in wait())
lock()                                  
wq.push(item)
release()
                     acquire()
                     item = wq.pop()
                     release();
notify()
                                                     (wake-up)
                                                     while (wq.empty())
                                                       wait();

Тогда как если notify() случилось раньше release() ЛНР не смог бы acquire() до того, как ХК проснулся. Вот где произошла инверсия приоритета. Это второй аргумент.

Аргумент в пользу уведомления за пределами блокировки относится к высокопроизводительной многопоточности, где потоку не нужно возвращаться в спящий режим, чтобы просто снова проснуться в следующий раз, когда он получит временный интервал, что уже объяснялось, как это может произойти в мой вопрос.

Питона threading модуль

В Python, как я уже сказал, вы должны удерживать блокировку при уведомлении. Ирония заключается в том, что внутренняя реализация не позволяет базовой ОС избегать инверсии приоритетов, поскольку она обеспечивает порядок FIFO для официантов. Конечно, может пригодиться тот факт, что порядок официантов является детерминированным, но остается вопрос, зачем применять такую ​​вещь, когда можно утверждать, что было бы более точным проводить различие между блокировкой и переменной условия, для чего в некоторые потоки, которые требуют оптимизированного параллелизма и минимальной блокировки, acquire() не должен сам по себе регистрировать предыдущее состояние ожидания, а только wait() позвони сам.

Можно утверждать, что программисты Python в любом случае не заботятся о производительности - хотя это все еще не отвечает на вопрос, почему при реализации стандартной библиотеки нельзя допускать, чтобы несколько стандартных вариантов поведения были возможны.

Остается сказать, что разработчики threading Возможно, модуль по какой-то причине специально хотел получить заказ FIFO, и обнаружил, что это как-то лучший способ его достижения, и хотел установить это как Condition за счет других (возможно, более распространенных) подходов. Для этого они заслуживают пользы от сомнений до тех пор, пока не смогут объяснить это сами.

Есть несколько причин, которые являются убедительными (когда взяты вместе).

1. Уведомитель должен взять блокировку

Притворись, что Condition.notifyUnlocked() существует.

Стандартная договоренность производителя / потребителя требует наличия замков с обеих сторон:

def unlocked(qu,cv):  # qu is a thread-safe queue
  qu.push(make_stuff())
  cv.notifyUnlocked()
def consume(qu,cv):
  with cv:
    while True:       # vs. other consumers or spurious wakeups
      if qu: break
      cv.wait()
    x=qu.pop()
  use_stuff(x)

Это не удается, потому что оба push() и notifyUnlocked() может вмешаться между if qu: и wait(),

Написание любого из

def lockedNotify(qu,cv):
  qu.push(make_stuff())
  with cv: cv.notify()
def lockedPush(qu,cv):
  x=make_stuff()      # don't hold the lock here
  with cv: qu.push(x)
  cv.notifyUnlocked()

работает (это интересное упражнение для демонстрации). Преимущество второй формы состоит в том, что снято требование qu быть потокобезопасным, но больше не нужно блокировать его, чтобы обойти вызов notify() как хорошо.

Осталось объяснить предпочтения для этого, особенно с учетом того, что (как вы заметили) CPython пробуждает уведомленный поток, чтобы он переключился на ожидание мьютекса (а не просто перемещает его в эту очередь ожидания).

2. Условная переменная сама нуждается в блокировке

Condition имеет внутренние данные, которые должны быть защищены в случае одновременного ожидания / уведомления. (Взглянув на реализацию CPython, я вижу возможность того, что два несинхронизированных notify()s может ошибочно указывать на один и тот же ожидающий поток, что может привести к снижению пропускной способности или даже к тупику.) Конечно, он может защитить эти данные с помощью специальной блокировки; поскольку нам уже нужна блокировка, видимая пользователю, использование этой позволяет избежать дополнительных затрат на синхронизацию.

3. Несколько условий бодрствования могут потребоваться блокировки

(Адаптировано из комментария к сообщению в блоге, указанному ниже.)

def setTrue(box,cv):
  signal=False
  with cv:
    if not box.val:
      box.val=True
      signal=True
  if signal: cv.notifyUnlocked()
def waitFor(box,v,cv):
  v=bool(v)   # to use ==
  while True:
    with cv:
      if box.val==v: break
      cv.wait()

предполагать box.val является False и поток № 1 ждет в waitFor(box,True,cv), Тема №2 звонков setSignal; когда он выпустит cv, #1 все еще заблокирован при условии. Поток № 3 затем вызывает waitFor(box,False,cv)находит, что box.val является Trueи ждет. Тогда #2 звонки notify(), проснувшись № 3, который все еще не удовлетворен и снова блокируется. Теперь № 1 и № 3 ждут, несмотря на то, что одному из них должно быть выполнено условие.

def setTrue(box,cv):
  with cv:
    if not box.val:
      box.val=True
      cv.notify()

Теперь эта ситуация не может возникнуть: либо № 3 прибывает до обновления и никогда не ждет, либо он приходит во время или после обновления и еще не ждал, гарантируя, что уведомление переходит к #1, который возвращается из waitFor,

4. Аппаратному обеспечению может потребоваться блокировка

С изменяющимся ожиданием и отсутствием GIL (в некоторой альтернативной или будущей реализации Python) упорядочение памяти (см . Правила Java), налагаемое релизом блокировки после notify() и блокировка получения по возвращении из wait() может быть единственной гарантией того, что обновления уведомляющего потока будут видны ожидающему потоку.

5. Системам реального времени это может понадобиться

Сразу после цитируемого вами текста POSIX мы находим:

однако, если требуется предсказуемое поведение планирования, этот мьютекс должен быть заблокирован потоком, вызывающим pthread_cond_broadcast() или pthread_cond_signal().

Один пост в блоге содержит дальнейшее обсуждение обоснования и истории этой рекомендации (а также некоторых других вопросов здесь).

Пару месяцев назад у меня возник точно такой же вопрос. Но так как я имел ipython открыл, глядя на threading.Condition.wait?? Результат ( источник для метода) не заставил себя долго ждать.

Короче говоря, wait Метод создает другую блокировку, называемую официантом, получает ее, добавляет ее в список и затем, неожиданно, снимает блокировку с себя. После этого он снова получает официанта, то есть начинает ждать, пока кто-то не отпустит официанта. Затем он снова получает блокировку на себя и возвращается.

notify метод извлекает официанта из списка официантов (официант, как мы помним, это блокировка) и освобождает его, позволяя wait способ продолжить.

В том-то и дело, что wait метод не удерживает блокировку самого условия во время ожидания notify способ освободить официанта.

UPD1: кажется, я неправильно понял вопрос. Правильно ли то, что вы обеспокоены тем, что T1 может попытаться восстановить блокировку на себе до того, как T2 освободит ее?

Но возможно ли это в контексте Python GIL? Или вы думаете, что можно вставить IO-вызов перед освобождением условия, что позволило бы T1 проснуться и ждать вечно?

Это объясняется в документации Python 3: https://docs.python.org/3/library/threading.html#condition-objects.

Примечание: методы notify() и notify_all() не снимают блокировку; это означает, что пробужденный поток или потоки не вернутся из своего вызова wait() немедленно, а только тогда, когда поток, вызвавший notify() или notify_all(), окончательно откажется от владения блокировкой.

То, что происходит, - то, что T1 ждет и снимает блокировку, затем T2 получает это, уведомляет cv, который пробуждает T1.

Не совсем. cv.notify() вызов не пробуждает поток T1: он только перемещает его в другую очередь. Перед notify(), T1 ждал, чтобы условие было выполнено. После notify(), T1 ждет, чтобы получить блокировку. T2 не снимает блокировку, и T1 не "просыпается", пока T2 явно не вызовет cv.release(),

Нет условия гонки, так работают переменные условия.

Когда вызывается wait(), базовая блокировка снимается, пока не произойдет уведомление. Гарантируется, что вызывающий запрос повторно получит блокировку до возврата функции (например, после завершения ожидания).

Вы правы в том, что может быть некоторая неэффективность, если T1 напрямую проснулся при вызове notify(). Однако условные переменные, как правило, реализуются через примитивы ОС, и ОС часто будет достаточно умна, чтобы понять, что у T2 все еще есть блокировка, поэтому он не будет сразу активировать T1, а вместо этого ставит его в очередь на пробуждение.

Кроме того, в python это на самом деле не имеет значения, так как из-за GIL существует только один поток, поэтому потоки не смогут работать одновременно.


Кроме того, предпочтительнее использовать следующие формы, а не напрямую вызывать / отпускать:

with cv:
    cv.wait()

А также:

with cv:
    cv.notify()

Это гарантирует, что основная блокировка снята, даже если происходит исключение.

Другие вопросы по тегам