Почему Python threading.Условие () 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 заявление, чтобы лучше проиллюстрировать гонку явными вызовами.

это похоже на конструктивный недостаток. Есть ли какое-либо обоснование для этого, или я что-то упускаю?

5 ответов


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

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

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

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

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

  1. С минуты официант acquire()s замок-то есть, прежде чем отпустить его на 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(), LC не смог бы! .. --2--> до HC был разбужен. Здесь произошла инверсия приоритета. Это второй аргумент.

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

в Python threading модуль

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

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

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


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

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


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

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

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

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

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

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


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

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 нуждается в блокировке

на 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 is False и поток #1 ждет waitFor(box,True,cv). Нить #2 звонки setSignal; когда он выпускает cv, #1 по-прежнему заблокирован при условии. Thread #3 затем вызывает waitFor(box,False,cv), считает, что box.val is 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), порядок памяти (cf. правила Java) наложенный замок-отпустите после notify() и замок-приобрести по возвращении из wait() может быть единственной гарантией того, что обновления уведомляющего потока будут видны ожидающему потоку.

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

сразу после текста POSIX вы процитировали мы найти:

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

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


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

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

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

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


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

with cv:
    cv.wait()

и:

with cv:
    cv.notify()

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