0,0
рейтинг
9 октября 2013 в 13:37

Разработка → Сопрограммы в Python из песочницы tutorial

Предлагаю обсудить такую интересную, но мало используемую возможность python, как сопрограммы (coroutines).
Сопрограммы в питоне основаны на генераторах (ими, они, собственно и являются).
Поэтому, предлагаю начать именно с генераторов, в общем понимании. А потом разберём как написать свою сопрограмму.

Генераторы


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

Я подразумеваю что вы знаете как они работают, поэтому просто напомню, как это выглядит.
Возьмём вот такую функцию:

def read_file_line_by_line(file_name):
  with open(file_name, 'r') as f:
      while True:
        line = f.readline()
        if not line:
          break
        yield line


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

В общем случае, работа с генераторами выглядит следующим образом:

In [78]: lines_generator = read_file_line_by_line("data.csv")
In [79]: type(lines_generator)
Out[79]: generator
In [83]: lines_generator.next()
Out[83]: 'time,host,event\n'
In [84]: lines_generator.next()
Out[84]: '1374039728,localhost,reboot\n'
In [85]: lines_generator.next()
Out[85]: '1374039730,localhost,start\n'
In [86]: lines_generator.next()

---------------------------------------------------------------------------

StopIteration                             Traceback (most recent call last)

<ipython-input-86-65df1a2cb71b> in <module>()

----> 1 lines_generator.next()

StopIteration: 

# Соответственно у меня в файле только 3 строчки 
# Как только читать больше нечего, возникает исключение StopIteration, как и с любым итерируемым оъектом. 


Естественно, чаще мы читаем значения из генератора в цикле, а не построчно.
Вот таким нехитрым способом мы можем получить все уникальные строки из сколь угодно большого файла:

uniq = []
for line in lines_generator:
  if line not in uniq:
      uniq.append(line)


Так же возможна короткая запись генератора:

In [92]: gen = (x for x in xrange(0, 100*10000))
In [93]: gen.next()
Out[93]: 0
In [94]: gen.next()
Out[94]: 1
In [95]: gen.next()
Out[95]: 2
In [96]: gen.next()
Out[96]: 3
In [97]: gen.next()
Out[97]: 4


Похоже на списковые выражения, верно? Только не требует создания всего списка range(0, 100*10000) в памяти, возвращаемое значение «вычисляется» каждый раз при обращении.

Сопрограммы как частный случай генераторов


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

О стандарте можно почитать тут PEP 342.

Предлагаю сразу начать с примера. Напишем простую реализацию генератора, который может складывать два аргумента, хранить историю результатов и выводить историю.

def calc():
    history = []
    while True:
        x, y = (yield)
        if x == 'h':
            print history
            continue
        result = x + y
        print result
        history.append(result)

c = calc()

print type(c) # <type 'generator'>

c.next() # Необходимая инициация. Можно написать c.send(None)
c.send((1,2)) # Выведет 3
c.send((100, 30)) # Выведет 130
c.send((666, 0)) # Выведет 666
c.send(('h',0)) # Выведет [3, 130, 666]
c.close() # Закрывем генератор


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

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

def coroutine(f):
    def wrap(*args,**kwargs):
        gen = f(*args,**kwargs)
        gen.send(None)
        return gen
    return wrap
 
@coroutine
def calc():
    history = []
    while True:
        x, y = (yield)
        if x == 'h':
            print history
            continue
        result = x + y
        print result
        history.append(result)


На этом примере можно понять как писать свои более сложные (и полезные) сопрограммы.

Заключение.


Хоть проблемы, которые можно решить этим инструментом затрагивают очень многие области (такие как асинхронное программирование), многие разработчики предпочитают более привычные инструменты ООП. Но при этом сопрограммы могут быть очень полезным инструментом в вашем арсенале, поскольку они достаточно наглядны, а создание фунций более дешёвая операция по сравнению с созданием объекта класса.

Да и определённый академический интерес они представляют, как мне кажется.

Вот такая вот первая статья.

UPD:
Исправил в примере короткой записи генератора range на xrange.
В 2-й версии python range() создаёт весь список сразу, для создания генератора надо использовать xrange(), в 3-й версии range == xrange (т.е. возвращает генератор).
Александр Кушнарёв @Shoonoise
карма
15,2
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (26)

  • +2
    В разделе статьи про range() напишите, пожалуйста, что речь идёт про третий питон. Во втором питоне range(100*10000) создаёт огромный список в памяти.
    • +2
      Во втором питоне xrange() можно использовать.
      • +1
        тогда проще сразу написать xrange
        • +2
          Ну, справедливости ради, стоит сказать что xrange не будет работать в 3-й версии.
          Исправил на xrange и добавил в статью разъяснения по этому поводу.
    • +1
      Да, это моя ошибка.
      Имеется ввиду, конечно же, xrange для 2-й версии питона, который теперь проcто range в 3-й версии.
  • +4
    PEP-3156: Asynchronous IO Support Rebooted: the «asyncio» Module и python tulip являются более полной и проработанной версией того, что описано.
  • +6
    Для тех, кто хочет оценить всю магию сопрограмм и генераторов, есть две шикарнейшие презентации от Дэвида Бизли:

    dabeaz.com/generators/
    dabeaz.com/coroutines/
    • +1
      почему то после прочтения статьи я так и не понял зачем нужны Сопрограммы.

      ой. Это должен был быть комментарий к статье, а не к вашему посту. Хотя первую ссылку я осилил, и все равно такие не понял — зачем?
      • +1
        Сопрограммы реализуют кооперативную многозадачность, т.е. подход, когда в рамках одного системного потока есть много мелких «воркеров», каждый из которых делает свою задачу и сам решает, когда вернуть управление, с помощью yield. Многие современные асинхронные библиотеки, потипу gevent и прочего, скрещивают эти легковесные «воркеры» с асинхронной моделью, реализуемой системными вызовами поллинга сокета (select/poll/epoll). В итоге мы как раз и имеем все эти «суперскоростные» и «держащие 100к коннектов» приложения.
        • 0
          То есть, мы получаем такую вот ограниченную (на первый взгляд) псевдопараллельность без использования multiprocessing, при этом имея разделяемые переменные, но не имея дедлоков?
          • 0
            Это достаточно сложная тема.

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

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

            В рамках одного процесса тоже своя история, достойная отдельной статьи.
            • +1
              Касательно работы на одном процессоре — это мне известно, и я не понял, как это относится к моему вопросу?

              Собственно, мой вопрос был такой:
              Если мы используем multiprocessing, то мы имеем (небольшой) геморрой с передачей информации между процессами ОС, плюс оверхэд на переключение между процессами.
              Использовать многопоточность в python смысла особого нет, даже при наличии нескольких процессоров/ядер — привет-привет, GIL!

              А использование корутин позволяет на ленивых вычисления сделать имитацию параллельности, за счет того, что «тяжелые» операции выполняются по запросу, при это — без оверхэда многопроцессности, и без заморочек с GIL. Так?
              • +1
                Да, в целом так. Но при этом вы сразу упомянули и главный минус — тяжелая операция блокирует всю очередь корутин. Именно поэтому асинхронные приложения могут по производительности упираться, например, в работу с базой.

                А еще — именно такого рода «зеленые потоки» используются в вещах потипу Stackless Python и некоторых прочих интерпретаторах и виртуальных машинах.
        • +1
          и все равно не понял. Кооперативная многозадачность это в Symbian где все ваше приложение работет в одном потоке. Я что то слабо вижу свзяь межде генераторами (yield) и кооперативной многозадачностью. Вернее я ее вообще не вижу.
          • +1
            И здесь всё в одном потоке: когда сопрограмма ждет новых данных, она делает (yield) и управление передается в другое место. При этом после передачи управления обратно генератору-сопрограмме (вместе с какими то данными) выполнение продолжится с того же места.

            Это база для «нормальных» корутин, т.е. например шедулер нужно писать самому.
            Ниже кидали ссылку на PEP 342, там все понятно расписано.
      • 0
        Выше правильно сказали.
        Ещё, для пущей уверенности, можно прочитать раздел Motivation в стандарте www.python.org/dev/peps/pep-0342/.
  • +5
    Для бо́льшей совместимости лучше писать next(gen), а не gen.next(). Дело в том, что в Python 3 заметили и устранили нестандартность метода .next: этот метод «магический» (точнее, управляет поведением встроенных возможностей языка) аналогично прочим вроде .__iter__, .__gt__; но при этом в Python 2 он пишется без обрамляющих __. В Python 3 метода next у итераторов нет, вместо него используется __next__. Таким образом,
    In [92]: gen = (x for x in range(0, 100*10000))
    In [93]: gen.next()
    

    покажет AttributeError в Python 3, а
    In [92]: gen = (x for x in range(0, 100*10000))
    In [93]: gen.__next__()
    

    — то же самое в Python 2. next(gen) будет работать везде.
  • +1
    uniq = []
    for line in lines_generator:
    if line not in uniq:
    uniq.append(line)

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

    uniq = set()
    for line in lines_generator:
    if line not in uniq:
    uniq.add(line)

    А еще проще так:

    set(lines_generator)
    • 0
      Изначально у меня был комментарий, что лучше в этой ситуации использовать set(), а это всего лишь пример использования.
      Но потом я решил, что очевидно.
      А теперь, мне очевидно, что надо было лучше прорабатывать примеры.
      • 0
        А я в изменённом выше сообщении собирался сказать, что (x for x in range(N)) должно заменять на iter(range(N)). Правда, потом всё же сообразил, что iter(range(N)) со статьёй не вяжется. Но примеры действительно лучше бы выбрать такими, чтобы использование генераторов было уместным.
  • 0
    Как раз вчера смотрел примеры и документацию о реализации state machine и наткнулся на реализацию состояний через Co-routines.
    • +1
      Забавно, но моя вторая статья о python как раз про state machine.
      То же была мысль реализовать через корутины, но потом решил пойти более привычным путём. Хотел опубликовать здесь, но решил что будет мало кому интересно.
  • 0
    Как по мне, в статье очень наглядный пример функции-генератора и yield. Спасибо, что внесли ясность.
    • +1
      Про yield тут уже очень хорошо расписали. Так что я старался сильно не заострять на нём внимание.
      Но я рад что статья стала кому то полезной.
      • 0
        Читал ту статью. У вас понравился простой пример с чтением очень большого файла. Нормальная практическая задача, в отличие от (x*x for x in range(y)). А то насмотришься теоретических примеров, а где их применять — непонятно.
  • +1
    Сопрограммы создаются с помощью выражения value = (yield). В этом самом месте выполнение приостанавливается пока объект не будет вызван с аргументом coroutine.send(data). Затем выполнение продолжается с переданным значением, что бы сообщить об окончании вычисления мы используем метод .close(), который в свою очередь внутри сопрограммы возбуждает исключение GeneratorExit, которое мы можем перехватить. Пример как можно перехватить исключение:
    def match(pattern):
            print('Looking for ' + pattern)
            try:
                while True:
                    s = (yield)
                    if pattern in s:
                        print(s)
            except GeneratorExit:
                print("=== Done ===")
    

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