Подтвердить что ты не робот

Почему 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 reacquiring после возвращения из wait(). Если T1 сначала попытается выполнить повторную загрузку, он будет излишне ресуспендирован до завершения T2 release().

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

Это кажется ошибкой дизайна. Существует ли какое-либо обоснование для этого, или я что-то не хватает?

4b9b3361

Ответ 1

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

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

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

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

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

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

  • Когда вы 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 не смог бы acquire() до того, как HC проснулся. Здесь произошла инверсия приоритета. Это второй аргумент.

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

Модуль Python threading

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

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

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

Ответ 2

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

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() могут ошибочно нацеливаться на один и тот же ожидающий поток, что может привести к снижению пропускной способности или даже к взаимной блокировке.) Конечно, это может защитить эти данные с помощью выделенной блокировки; поскольку нам уже нужна блокировка, видимая пользователю, использование этой позволяет избежать дополнительных затрат на синхронизацию.

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

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

def setSignal(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). setSignal № 2 вызывает setSignal; когда он выпускает cv, # 1 все еще блокируется при условии. waitFor(box,False,cv) # 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().

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

Ответ 3

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

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

Ответ 4

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

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

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

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

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

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

Ответ 5

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

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

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

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


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

with cv:
    cv.wait()

и

with cv:
    cv.notify()

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