Плохая оптимизация блокировки в asyncio

Обновление: отредактирован заголовок, чтобы сосредоточиться на основной проблеме. Полное обновление смотрите в моем ответе.

В следующем коде a() и b() идентичны. Каждый из них считает от 0 до 9 одновременно, получая и выдавая блокировку каждые 2 счета.

import asyncio

lock = asyncio.Lock()

def a ():
 yield from lock.acquire()
 for i in range(10):
  print('a: ' + str(i))
  if i % 2 == 0:
   lock.release()
   yield from lock.acquire()
 lock.release()

def b ():
 yield from lock.acquire()
 for i in range(10):
  print('b: ' + str(i))
  if i % 2 == 0:
   lock.release()
   yield from lock.acquire()
 lock.release()

asyncio.get_event_loop().run_until_complete(asyncio.gather(a(), b()))

print('done')

Я ожидал чередующийся вывод, но вместо этого я получаю:

b: 0
b: 1
b: 2
b: 3
b: 4
b: 5
b: 6
b: 7
b: 8
b: 9
a: 0
a: 1
a: 2
a: 3
a: 4
a: 5
a: 6
a: 7
a: 8
a: 9
done

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

Мне кажется, что это ошибка. Я прав? или есть другое объяснение?

Следующий код, модифицированный дополнительным начальным "noop" yield, работает нормально, как и ожидалось. Это заставляет меня поверить, что блокировка действительно справедлива и, вероятно, правильна.

import asyncio

lock = asyncio.Lock()

def a ():
 yield from lock.acquire()
 yield from asyncio.sleep(0)
 for i in range(10):
  print('a: ' + str(i))
  if i % 2 == 0:
   lock.release()
   yield from lock.acquire()
 lock.release()

def b ():
 yield from lock.acquire()
 yield from asyncio.sleep(0)
 for i in range(10):
  print('b: ' + str(i))
  if i % 2 == 0:
   lock.release()
   yield from lock.acquire()
 lock.release()

asyncio.get_event_loop().run_until_complete(asyncio.gather(a(), b()))

print('done')

Выход:

a: 0
b: 0
a: 1
a: 2
b: 1
b: 2
a: 3
a: 4
b: 3
b: 4
a: 5
a: 6
b: 5
b: 6
a: 7
a: 8
b: 7
b: 8
a: 9
b: 9
done

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

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

Как еще объяснить первый вывод?


person user2297550    schedule 17.01.2017    source источник
comment
Я считаю, что это ошибка в реализации блокировки asyncio. Перед этой строкой должен стоять yield: github.com /python/cpython/blob/master/Lib/asyncio/locks.py#L171   -  person user2297550    schedule 18.01.2017
comment
Чего вы пытаетесь достичь?   -  person Udi    schedule 18.01.2017
comment
@Udi Я только изучаю асинхронность, и это похоже на проблему с реализацией. Моя проблема с github может прояснить немного больше: github.com/python/asyncio/issues/486   -  person user2297550    schedule 18.01.2017
comment
Если вы начинаете работать с asyncio, попробуйте решить проблемы, связанные с вводом-выводом.   -  person Udi    schedule 18.01.2017
comment
Из любопытства, почему вы считаете это плохой оптимизацией? Неоспариваемые блокировки должны работать с минимальными накладными расходами (например, threading.Lock() CPython не освобождает GIL, если он может немедленно получить блокировку). Смысл asyncio.Lock в том, чтобы гарантировать, что если вы вернетесь к циклу обработки событий, удерживая блокировку, никто другой не сможет получить блокировку, пока вы чего-то ожидаете. Если бы вы действительно делали что-то параллельное в своей сопрограмме, блокировка имела бы значение, но вы не имеете значения (ничего не ожидается, пока блокировка удерживается), поэтому блокировка не имеет значения.   -  person ShadowRanger    schedule 12.06.2019
comment
@ user2297550: Вы неправильно поняли. Целью параллелизма здесь является одновременное выполнение задач, связанных с вводом-выводом, без потоковой обработки. Вот почему основной модуль, обеспечивающий все это, называется asyncio; если вы читаете первые три абзаца документации модуля, в них упоминается утилита для ввода-вывода и задач, связанных с вводом-выводом. Весь смысл фреймворка состоит в том, чтобы выполнять функции ЦП, а затем, когда обычный код блокируется (при вводе-выводе, синхронизации и т. д.), он вместо этого совместно передает ЦП чему-то другому, готовому к работе. Но если блокировка не требуется, он всегда будет делать все возможное без передачи...   -  person ShadowRanger    schedule 14.06.2019
comment
... контроль, потому что переключение между сопрограммами обходится дорого, и цель состоит в том, чтобы сделать все как можно быстрее, а не чередовать выполнение ради чередования. Вы всегда можете явно передать управление, если вы действительно этого хотите (чтобы избежать беспокойства по поводу двойного сна, вы всегда можете добавить if not lock.locked(): yield from asyncio.sleep(0) непосредственно перед yield from lock.acquire(), чтобы убедиться, что вы так или иначе передаете управление), но они не собираются делать выбор дизайна, который по умолчанию замедляет всех без какой-либо пользы для производительности или правильности.   -  person ShadowRanger    schedule 14.06.2019
comment
Я отмечу, что ваша ошибка, по-видимому, мотивирована, по крайней мере, частично, невозможностью проверить, удерживается ли блокировка в данный момент; Я не знаю, когда она была добавлена, но к тому времени, когда asyncio была официально добавлена ​​в качестве базовой библиотеки Python, asyncio.Lock породила метод .locked() для тестирования именно этого.   -  person ShadowRanger    schedule 14.06.2019


Ответы (2)


Обновление: следующее устарело в свете моего комментария (ссылка) по проблеме с github. В комментарии отмечается, что вы можете использовать Lock.locked(), чтобы предсказать, даст ли Lock.acquire() результат или нет. Он также отмечает, что многие другие сопрограммы не работают в быстром случае, поэтому бессмысленно даже рассматривать возможность исправления их всех. Наконец, в нем рассказывается, как была устранена другая проблема, и предполагается, что ее можно было бы исправить лучше. Это был запрос на метод asyncio.nop(), который просто уступал бы планировщику и больше ничего не делал. Вместо того, чтобы добавить этот метод, они решили перегрузить asyncio.sleep(0) и «деоптимизировать» его (в контексте этого обсуждения lock.acquire()), чтобы он уступал планировщику, когда аргумент равен 0.

Оригинальный ответ ниже, но заменен абзацем выше:

Основная причина того, что реализация asyncio.lock пытается быть слишком умной в своем первые три строки и не возвращает управление планировщику, если нет официантов:

if not self._locked and all(w.cancelled() for w in self._waiters):
    self._locked = True
    return True

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

Неэффективный обходной путь — всегда yield from asyncio.sleep(0) непосредственно перед получением блокировки.

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

Также обратите внимание, что в документации для блокировки неоднозначно сказано: «Этот метод блокируется до тех пор, пока блокировка не будет разблокирована, затем устанавливает ее в заблокированную и возвращает значение True». Конечно, создается впечатление, что он передаст управление планировщику до получения блокировки.

На мой взгляд, правильно, чтобы реализация блокировки всегда уступала и не была слишком умной. В качестве альтернативы реализация блокировки должна иметь метод, который говорит вам, будет ли она уступать в случае получения, чтобы ваш код мог вручную уступить, если получение блокировки не даст. Другая альтернатива состоит в том, чтобы вызов acquire() возвращал значение, которое говорит вам, действительно ли он дал результат. Это менее предпочтительно, но все же лучше, чем статус-кво.

Кто-то может подумать, что лучшим обходным решением может быть ручной выход во время release(). Однако если вы посмотрите на узкую петлю, которая освобождает и повторно получает после части работы, то это равносильно тому же самому - в общем случае он все равно будет уступать дважды, один раз во время выпуска, снова во время получения, добавляя неэффективности.

person user2297550    schedule 18.01.2017

Непонятно, чего вы пытаетесь достичь, но похоже, что Lock — это не тот инструмент, который вам нужен. Чтобы чередовать код Python, вы можете сделать так же просто, как:

def foo(tag, n):
    for i in range(n):
        print("inside", tag, i)
        yield (tag, i)


print('start')

for x in zip(foo('A', 10), foo('B', 10)):
    print(x)

print('done')

Нет необходимости в asyncio или threading. В любом случае, asyncio без IO не имеет большого смысла.

threading.Lock используется для синхронизации важных частей программы, которые в противном случае выполняются в независимых потоках. asyncio.Lock позволит другим сопрограммам продолжить ввод-вывод, пока одна сопрограмма ожидает:

import asyncio
import random

lock = asyncio.Lock()


async def foo(tag):
    print(tag, "Start")
    for i in range(10):
        print(tag, '>', i)
        await asyncio.sleep(random.uniform(0.1, 1))
        print(tag, '<', i)

    async with lock:
        # only one coroutine can execute the critical section at once.
        # other coroutines can still use IO.
        print(tag, "CRITICAL START")
        await asyncio.sleep(1)
        print(tag, "STILL IN CRITICAL")
        await asyncio.sleep(1)
        print(tag, "CRITICAL END")

    for i in range(10, 20):
        print(tag, '>', i)
        await asyncio.sleep(random.uniform(0.1, 1))
        print(tag, '<', i)

    print(tag, "Done")


print('start')

loop = asyncio.get_event_loop()
tasks = asyncio.gather(foo('A'), foo('B'), foo('C'))
loop.run_until_complete(tasks)
loop.close()

print('done')

Имейте в виду, что ключевое слово yield не всегда соответствует английскому значению yield :-) .

Вы можете видеть, что имеет больше смысла, если async with lock получит блокировку немедленно, не дожидаясь, пока другие сопрограммы выполнят дополнительную работу: первая сопрограмма, достигшая критической части, должна начать ее выполнение. (т. е. добавление await asyncio.sleep(0) перед async with lock: просто не имеет никакого смысла.)

person Udi    schedule 18.01.2017
comment
Спасибо за ваш ответ, но, пожалуйста, еще раз взгляните на примеры кода в вопросе. Если в критической секции есть асинхронные операции, как в вашем примере, то проблем нет. То есть, если критическая секция возвращает управление планировщику, то проблем нет, потому что другие сопрограммы будут работать. Однако, если критическая секция является частью чисто вычислительной задачи, то другие сопрограммы не будут выполняться, даже если первая может неоднократно снимать и повторно получать блокировку. См. также: github.com/python/asyncio/issues/486 - person user2297550; 18.01.2017
comment
Я добавил еще одно предложение к своему ответу. Если ваш код привязан исключительно к процессору, блокировки, асинхронность или многопоточность вам не помогут. - person Udi; 18.01.2017
comment
Еще одно предложение для понимания проблемы - попытаться предсказать вывод самого первого фрагмента кода в вопросе (не глядя на опубликованный вывод). Большинство программистов предположили бы, что сопрограммы будут работать одновременно — немного A, немного B, немного A, немного B... Но это не так. - person user2297550; 18.01.2017
comment
ой, мой первый пример выше был неправильным. См. zip() выше - person Udi; 18.01.2017
comment
ХОРОШО. Спасибо за ваше любезное объяснение. Я все еще чувствую, что есть проблема, поэтому я еще подумаю, прежде чем согласиться. (Для задач, связанных исключительно с ЦП, многопоточность позволяет всем им выполняться одновременно, а не последовательно, что полезно в некоторых случаях. А при многопоточности один поток не может монополизировать блокировку. Без непродуманной оптимизации, которую я указываю, asyncio будет вести себя так же, но, увы, преждевременная оптимизация мешает хорошей семантике.) - person user2297550; 18.01.2017