Pull to refresh

Мир без корутин. Итераторы-генераторы

Reading time20 min
Views6.6K

1. Введение


Чтобы максимально запутать проблему — поручите ее решение программистам ;). Но если серьезно, то на мой взгляд с корутинами происходит нечто подобное, т.к., вольно или нет, с их помощью происходит замыливание создавшейся ситуации. Последняя характеризуется тем, что по-прежнему остаются проблемы параллельного программирования, которые никуда не уходят, и, главное, корутины не способствуют кардинальному их решению.

Начнем с терминологии. «Уж сколько раз твердили миру», но до сих пор «мир» все еще задаются вопросами отличия асинхронного программирования от параллельного (см. дискуссию по теме асинхронности в [1]). Суть проблем понимания асинхронности в его сравнении с параллелизмом начинается с определения самого параллелизма. Его попросту нет. Есть некое интуитивное понимание, которое часто трактуется по-разному, но нет научного определения, которое снимало бы все вопросы столь же конструктивно, как дискуссию на тему результата операции «дважды два».

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

Мы должны иметь строгое определение и прозрачные средства описания параллелизма, приводящие к непротиворечивым результатам подобно «топорному» Феликсу и любому программному калькулятору. Нельзя, чтобы понятие «параллелизм» ассоциировалось со средствам его реализации (с числом тех же ядер). А уж что там «под капотом» — это должно интересовать в первую очередь только тех, кто занимается реализацией «машинерии», но не тех, кто подобным условным «параллельным калькулятором» пользуется.

Но мы имеем то, что имеем. А имеем мы, если не повальное увлечение, то активное обсуждение корутин и асинхронного программирования. А чем еще заниматься, если многопоточностью мы, похоже, пресытились, а чего-то иного и не предлагается? Поговаривают даже о какой-то магии ;) Но все становится явным, если понимать причины. А они лежат именно там — в плоскости параллелизма. Его определения и его реализации.

Но спустимся с глобальных и в какой-то степени философских вершин науки программирования (computer since) на нашу «грешную землю». Здесь, ничуть не умаляя достоинств популярного по нынешним временам языка Kotlin, хочется признаться в своей увлеченности языком Python. Возможно, когда-нибудь и в какой-то иной ситуации мои предпочтения изменятся, но по факту пока все именно так.

Этому способствуют несколько причин. В их числе — свободный доступ к Python. Это не самый сильный аргумент, т.к. пример с тем же Qt говорит, что ситуация может в любой момент измениться. Но пока Python, в отличие от Kotlin, свободен, хотя бы в форме той же среды PyCharm фирмы JetBrains (за что им отдельное спасибо), то мои симпатии на его стороне. Привлекает и то, что существует масса русскоязычной литературы, примеров на Python в Интернете, как обучающих, так и вполне реальных. На Kotlin они не в таком количестве и не столь велико их разнообразие.

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

Поскольку я фактически чистый программист на С++, то это многое объясняет. Например, если в Python корутины и генераторы присутствуют достаточно давно, то в С++ им еще предстоит завоевать свое место. Но нужно ли это С++? На мой взгляд язык программирования нужно расширять обоснованно. Такое впечатление, что С++ тянул насколько это было возможно, а теперь спешно пытается наверстать упущенное. Но аналогичные проблемы параллелизма можно реализовать, используя другие понятия и модели, которые более фундаментальны, чем корутины/сопрограммы. А то, что за этим утверждением стоят не просто слова, далее и будет продемонстрировано.

Если уж признаваться во всем, то признаюсь и в том, что в отношении С++ я достаточно консервативен. Конечно, его объекты и возможности ООП для меня «наше все», но я, скажем так, критически настроен к шаблонам. Ну, очень уж не глянулся мне когда-то их своеобразный «птичий язык», который, как показалось, сильно затрудняет восприятие кода и понимание алгоритма. Хотя изредка я даже прибегал к их помощи, но на все это хватит пальцев одной руки. Библиотеку STL я уважаю и без нее не обхожусь :) Поэтому, исходя даже из этого факта, меня иногда посещают сомнения по поводу шаблонов. Поэтому по-прежнему избегаю их насколько могу. Вот и теперь с содроганием жду «шаблонные корутины» в С++ ;)

Иное дело Python. Шаблонов в нем пока не заметил и это успокаивает. Но, с другой стороны, это же, как ни странно, настораживает. Однако, когда я смотрю на код Kotlin и, тем более, на его подкапотный код, то тревога быстро проходит ;) Правда, думаю, это все же дело привычки и моих предубеждений. Надеюсь, что со временем я натренирую себя на адекватное их (шаблонов) восприятие.

Но… вернемся к сопрограммам. Оказывается, что ныне они проходят под именем корутин. Что же нового произошло со сменой названия? Да, собственно ничего. Как и ранее рассматривается множество поочередно исполняемых функций. Так же, как и ранее, перед выходом из функции, но до завершения ее работы, фиксируется точка возврата, с которой в последствии работа возобновляется. Поскольку последовательность переключения не оговаривается, то программист сам управляет этим процессом, создавая свой планировщик (scheduler). Часто это просто циклический перебор функций. Такой, как, например, событийный цикл Round Robin в видео Олега Молчанова[3].

Вот так «на пальцах», как правило, выглядит современное введение в сопрограммы-корутины и асинхронное программирование. Понятно, что с погружением в данную тематику, возникают новые термины и понятия. Генераторы — одни из них. Далее их пример будет базой для демонстрации «параллельных предпочтений», но уже в моей автоматной интерпретации.

2. Генераторы списков данных


Итак, — генераторы (generators). С ними часто ассоциируется асинхронное программирование и корутины. Обо всем этом доступно повествует серия видео от Олега Молчанова. Так, к ключевой особенности генераторов он относит их «возможность ставить выполнение функции на паузу, чтобы затем продолжить ее выполнение с того же самого места, в котором она остановилась в прошлый раз» (подробнее см. [3]). И в этом, учитывая выше сказанное по поводу достаточно уже древнего определения сопрограмм, ничего нового нет.

Но, как оказывается, генераторы нашли достаточно специфическое применение для создания списков данных. Введению в эту тему посвящено уже видео от Егорова Артема [4]. Но, как представляется, таким их применением мы смешиваем качественно разные понятия — операции и процессы. Расширяя описательные возможности языка, мы во многом затушевываем проблемы, которые при этом могут возникнуть. Тут как бы, как говорится, не заиграться. Использование генераторов-корутин для описания данных способствует, как мне кажется, именно этому. Отметим, что Олег Молчанов также предупреждает, что не следует ассоциировать генераторы со структурами данных, подчеркивая, что «генераторы — это функции» [3].

Однако вернемся к использованию генераторов для определения данных. Трудно скрыть, что мы создали именно процесс, вычисляющий элементы списка. А потому к подобному списку, как к процессу, сразу возникают вопросы. Например, как его использовать повторно, если корутины по определению работают только «в одну сторону»? Как вычислить произвольный его элемент, если индексирование процесса невозможно? И т.д. и т.п. Артем на эти вопросы ответов не дает, лишь предупреждая, что, мол, повторный доступ к элементам списка организовать нельзя, а индексация недопустима. Поиск в интернете убеждает, что аналогичные вопросы возникли не только у меня, но решения, которые при этом предлагаются, не так уж тривиальны и очевидны.

Еще одна проблема — скорость генерации списка. Сейчас мы формируем единственный элемент списка на каждом переключении корутины, а это увеличивает время генерации данных. Процесс можно серьезно ускорить, если генерировать элементы «пачками». Но, скорее всего, с этим будут проблемы. А как остановить уже запущенный процесс? Или другое. Список может быть весьма большим, в котором используются лишь отдельные элементы. В такой ситуации для эффективного доступа часто используют мемоизацию (memorization) данных. Кстати, почти сразу нашел статью на эту тему для Python см. [5] (дополнительно о мемоизации в терминах автоматов см. статью [6]). А как быть в этом случае?

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

Кстати, по теме списков и генераторов об их достоинствах и недостатках, пересекающихся со сделанными выше замечаниями, можно посмотреть еще одно видео Олега Молчанова [7].

3. Генераторы-корутины


В следующем видео Олега Молчанова [8] рассматривается применение генераторов для согласования работы сопрограмм. Собственно для этого они и предназначены. Обращает на себя внимание выбор моментов переключения сопрограмм. Их расстановка следует простому правилу — ставим оператор yield перед блокирующими функциями. Под последними понимаются функции, время возврата из которых столь велико по сравнению с другими операциями, что вычисления ассоциируются с их остановкой. Из-за этого их и назвали блокирующими.

Переключение эффективно, когда приостановленный процесс продолжает свою работу ровно тогда, когда блокирующий вызов не будет ждать, а быстро выполнит свою работу. И, похоже, ради этого и был затеян весь этот «сыр-бор» вокруг модели сопрограмм/корутин и, соответственно, был дан толчок развитию асинхронного программирования. Хотя, отметим, первоначальный замысел сопрограмм был все же в другом — в создании виртуальной модели параллельных вычислений.

В рассматриваемом видео, как и в общем случае для корутин, продолжение работы сопрограммы определяет внешняя среда, представляющая собой событийный планировщик. В данном случае он представлен функцией с именем event_loop. И, вроде бы, все логично: планировщик выполнит анализ и продолжит работу сопрограммы, вызвав оператор next(), ровно тогда, когда необходимо. Проблема подстерегает там, где ее не ждали: планировщик может быть достаточно сложным. В предыдущем видео Молчанова (см. [3]) было все просто, т.к. выполнялась простая попеременная передача управления, при которой блокировок не было, т.к. не было соответствующих вызовов. Тем не менее, подчеркнем, что в любом случае хотя бы простой планировщик, но необходим.

Проблема 1. Интересно было убедиться, что в любой момент оператор next() может легко превратиться в блокирующий (см. event_loop). Дело в том, что он не возвращает управления до тех пор, пока генератор не достигнет очередного yield. Если что-то этому помешает, то функция, вызвавшая next(), будет заблокирована.

Проблема 2. Как это ни удивительно, но блокирующим может стать даже оператор select, если не указать параметр — величину таймаута. В видео он приведен именно в блокирующем варианте.

Но дело даже не в необходимости планировщика, а в том, что он берет на себя несвойственные ему функции. Ситуация осложняется еще тем, что нужно реализовать алгоритм совместной работы множества сопрограмм. Сравнение планировщиков, рассмотренных в упомянутых двух видео Олега Молчанова подобную проблему отражает достаточно наглядно: алгоритм планировщика работы с сокетами в [8] заметно сложнее алгоритма «карусели» в [3].

3. К миру без корутин


Раз мы уверены, что мир без корутин возможен, противопоставляя им при этом автоматы, то необходимо показать, как аналогичные задачи решаются уже ими. Продемонстрируем это на том же на примере работы с сокетами. Заметим, что его исходная реализация получилась не столь тривиальной, чтобы в ней можно было разобраться сходу. Это подчеркивает неоднократно и сам автор видео. С подобными проблемами в контексте сопрограмм сталкиваются и другие. Так, недостатки корутин, связанные со сложностью их восприятия, понимания, отладки и т.п. обсуждаются в видео [10].

Сначала несколько слов о сложности рассматриваемого алгоритма. Причиной этому — динамический и множественный характер процессов обслуживания клиентов. Для этого создается сервер, который слушает заданный порт и по мере появления запросов порождает множество функций обслуживания клиентов, достучавшихся до него. Поскольку клиентов может быть много, появляются они непредсказуемо, то создается динамический список из процессов обслуживания сокетов и обмена информации с ними. Код решения на генераторах Python, рассмотренный в видео [8], представлен в листинге 1.

Листинг 1. Сокеты на генераторах
import socket
from select import select
tasks = []
to_read = {}
to_write = {}

def server():

    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_socket.bind(('localhost', 5001))
    server_socket.listen()

    while True:
        yield ('read', server_socket)
        client_socket, addr = server_socket.accept()    
        print('Connection from', addr)
        tasks.append(client(client_socket, addr))       
    print('exit server')

def client(client_socket, addr):

    while True:
        yield ('read', client_socket)
        request = client_socket.recv(4096)              

        if not request:
            break
        else:
            response = 'Hello World\n'.encode()

            yield ('write', client_socket)

            client_socket.send(response)                
    client_socket.close()                               
    print('Stop client', addr)

def event_loop():
    while any([tasks, to_read, to_write]):

        while not tasks:

            ready_to_read, ready_to_write, _ = select(to_read, to_write, [])

            for sock in ready_to_read:
                tasks.append(to_read.pop(sock))

            for sock in ready_to_write:
                tasks.append(to_write.pop(sock))
        try:
            task = tasks.pop(0)

            reason, sock = next(task)   

            if reason == 'read':
                to_read[sock] = task
            if reason == 'write':
                to_write[sock] = task
        except StopIteration:
            print('Done!')
tasks.append(server())
event_loop()


Алгоритмы сервера и клиента достаточно элементарны. Но должно насторожить, что сервер кладет функцию клиента в список задач. Дальше — больше: трудно понять алгоритм работы событийного цикла event_loop. Вплоть до того, как может быть пустым список task, если в нем, как минимум, должен всегда присутствовать процесс сервера?..

Далее вводятся словари to_read и to_write. Сама работа со словарями требует отдельного пояснения, т.к. она сложнее, чем работа с обычными списками. Из-за этого под них подстроена информация, возвращаемая операторами yield. Далее начинаются «пляски с бубном» вокруг словарей и все становится похожим на некое «бурление»: что-то появляется, чтобы быть помещенным в словари, откуда поступает в список task и т.д. и т.п. Можно «сломать голову», разбираясь во всем этом.

А как будет выглядеть решение поставленной задачи на автоматах? Для автоматов логичным будет создать модели, эквивалентные уже рассмотренным в видео процессам работы с сокетами. В модели сервера, похоже, ничего менять не надо. Это будет автомат, работающий подобно функции server(). Его граф приведен на рис. 1а. Действие автомата y1() создает серверный сокет и подключает его к заданному порту. Предикат x1() определяет подключение клиента, а при его наличии действие y2() создает процесс обслуживания клиентского сокета, помещая последний в список процессов classes, включающий классы активных объектов.

На рис. 1б приведен граф модели для отдельного клиента. Находясь в состоянии «0», автомат определяет готовность клиента передать информацию (предикат x1() — true) и принимает ответ в рамках действия y1() на переходе в состояние «1». Далее, когда клиент готов к приему информации (уже x2() должно быть в true), действие y2() реализует операцию посылки сообщения клиенту на переходе в начальное состояние «0». Если же клиент разрывает связь с сервером (в этом случае x3() — false), то автомат переходит в состояние «4», закрывая клиентский сокет в действии y3(). Процесс остается в состоянии «4» до тех пор, пока не будет исключен из списка активных классов classes (о формировании списка см. выше описание модели сервера).

На рис. 1в приведен автомат, реализующий запуск процессов подобно функции event_loop() листинга 1. Только в данном случае алгоритм его работы много проще. Все сводится к тому, что автомат проходит по элементам списка активных классов и для каждого из них вызывает метод loop(). Реализует это действие y2(). Действие y4() исключает из списка классы, которые попали в состоянии «4». Остальные действия работают с индексом списка объектов: действие y3() наращивает индекс, действие y1() его сбрасывает.

Возможности объектного программирования на Python отличны от объектного программирования на С++. Поэтому за основу будет взята самая простая реализация автоматной модели (если быть точным, то это имитация автомата). В ее основе объектный принцип представления процессов, в рамках которого каждому процессу соответствует отдельный активный класс (их часто называют также агентами). Класс содержит необходимые свойства и методы (см. подробнее о специфических автоматных методах — предикатах и действиях в [9]), а логика работы автомата (его функции переходов и выходов) сосредоточена в рамках метода, названного loop(). Для реализации логики поведения автомата будем использовать конструкцию if-elif-else.

При таком подходе «событийный цикл» уже никак не связан с анализом готовности сокетов. Их проверяют сами процессы, которые в рамках предикатов используют все тот же оператор select. Оперируют они в данной ситуации единственным сокетом, а не их списком, проверяя его на операцию, которая ожидается именно для этого сокета и именно в той ситуации, которая определяется алгоритмом работы. Кстати, в процессе отладки подобной реализации проявилась неожиданно блокирующая суть оператора select.

Рис. 1. Графы автоматных процессов работы с сокетами
image

Автоматный объектный код на Python для работы с сокетами представлен на листинге 2. Это и есть наш своеобразный «мир без корутин». Это «мир» с другими принципами проектирования программных процессов. Он характеризуется наличием алгоритмической модели параллельных вычислений (подробнее о ней см. [9], что главное и качественное отличие технологии автоматного программирования (АП) от «корутинной технологии».

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

Листинг 2. Сокеты на автоматах
import socket
from select import select

timeout = 0.0; classes = []

class Server:
    def __init__(self): self.nState = 0;

    def x1(self):
        self.ready_client, _, _ = select([self.server_socket], [self.server_socket], [], timeout)
        return self.ready_client

    def y1(self):
        self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.server_socket.bind(('localhost', 5001))
        self.server_socket.listen()
    def y2(self):
        self.client_socket, self.addr = self.server_socket.accept()
        print('Connection from', self.addr)
        classes.append(Client(self.client_socket, self.addr))

    def loop(self):
        if (self.nState == 0):      self.y1();      self.nState = 1
        elif (self.nState == 1):
            if (self.x1()):         self.y2();      self.nState = 0

class Client:
    def __init__(self, soc, adr): self.client_socket = soc; self.addr = adr; self.nState = 0

    def x1(self):
        self.ready_client, _, _ = select([self.client_socket], [], [], timeout)
        return self.ready_client
    def x2(self):
        _, self.write_client, _ = select([], [self.client_socket], [], timeout)
        return self.write_client
    def x3(self): return self.request

    def y1(self): self.request = self.client_socket.recv(4096);
    def y2(self): self.response = 'Hello World\n'.encode(); self.client_socket.send(self.response)
    def y3(self): self.client_socket.close(); print('close Client', self.addr)

    def loop(self):
        if (self.nState == 0):
            if (self.x1()):                     self.y1(); self.nState = 1
        elif (self.nState == 1):
            if (not self.x3()):                 self.y3(); self.nState = 4
            elif (self.x2() and self.x3()):     self.y2(); self.nState = 0

class EventLoop:
    def __init__(self): self.nState = 0; self.i = 0

    def x1(self): return self.i < len(classes)

    def y1(self): self.i = 0
    def y2(self): classes[self.i].loop()
    def y3(self): self.i += 1
    def y4(self):
        if (classes[self.i].nState == 4):
            classes.pop(self.i)
            self.i -= self.i

    def loop(self):
        if (self.nState == 0):
            if (not self.x1()): self.y1();
            if (self.x1()):     self.y2(); self.y4(); self.y3();

namSrv = Server(); namEv = EventLoop()
while True:
    namSrv.loop(); namEv.loop()


Код листинга 2 много технологичнее кода листинга 1. И это заслуга автоматной модели вычислений. Способствует этому и интеграция автоматного поведения в объектную модель программирования. В результате логика поведения автоматных процессов концентрируется ровно там, где порождается, а не делегируется, как это практикуется у корутин, в событийный цикл управления процессами. Новое решение провоцирует к созданию универсального «событийного цикла», прообразом которого можно считать код класса EventLoop.

4. О принципах SRP и DRY


Принципы «единой ответственности» — SRP (The Single Responsibility Principle) и «не повторяйся» — DRY (don't repeat yourself) озвучены в контексте еще одного видео Олега Молчанова [11]. Соответствуя им, функция должна содержать только целевой код, чтобы не нарушать принцип SRY, и не содействовать повторению «лишнего кода», чтобы не нарушать принцип DRY. В этих целях предлагается использовать декораторы. Но есть и другое решение — автоматное.

В предыдущей статье [2], еще не подозревая о наличии подобных принципов, был приведен пример, использующий декораторы. Рассмотрен счетчик, который, кстати, при желании мог бы порождать и списки. Упомянут при этом объект-секундомер, измеряющий время работы счетчика. Если объекты соответствуют принципам SRP и DRY, то их функциональность не столь важна, как протокол взаимодействия. В реализации код счетчика никак не связан с кодом секундомера, а изменение любого из объектов не затронет другого. Их связывает только протокол, о котором объекты договариваются «на берегу» и далее ему строго следуют.

Таким образом параллельная автоматная модель по сути перекрывает возможности декораторов. Она гибче и проще реализует их возможности, т.к. не «окружает» (не декорирует) собой код функции. В целях объективной оценки и сравнения автоматной и обычной технологии на листинге 3 представлен объектный аналог счетчика, рассмотренного в предыдущей статье [2], где за комментариями представлены упрощенные варианты с временами их исполнения и исходный вариант счетчика.

Листинг 3. Автоматная реализация счетчика
import time
# 1) 110.66 sec
class PCount:
    def __init__(self, cnt ): self.n = cnt; self.nState = 0
    def x1(self): return self.n > 0
    def y1(self): self.n -=1
    def loop(self):
        if (self.nState == 0 and self.x1()):
            self.y1();
        elif (self.nState == 0 and not self.x1()):  self.nState = 4;

class PTimer:
    def __init__(self, p_count):
        self.st_time = time.time(); self.nState = 0; self.p_count = p_count
#    def x1(self): return self.p_count.nStat == 4 or self.p_count.nState == 4
    def x1(self): return self.p_count.nState == 4
    def y1(self):
        t = time.time() - self.st_time
        print ("speed CPU------%s---" % t)
    def loop(self):
       if (self.nState == 0 and self.x1()): self.y1(); self.nState = 1
       elif (self.nState == 1): pass

cnt1 = PCount(1000000)
cnt2 = PCount(10000)
tmr1 = PTimer(cnt1)
tmr2 = PTimer(cnt2)
# event loop
while True:
    cnt1.loop(); tmr1.loop()
    cnt2.loop(); tmr2.loop()

# # 2) 73.38 sec
# class PCount:
#     def __init__(self, cnt ): self.n = cnt; self.nState = 0
#     def loop(self):
#         if (self.nState == 0 and self.n > 0): self.n -= 1;
#         elif (self.nState == 0 and not self.n > 0):  self.nState = 4;
# 
# class PTimer:
#     def __init__(self): self.st_time = time.time(); self.nState = 0
#     def loop(self):
#        if (self.nState == 0 and cnt.nState == 4):
#            t = time.time() - self.st_time
#            print("speed CPU------%s---" % t)
#            self.nState = 1
#        elif (self.nState == 1): exit()
# 
# cnt = PCount(100000000)
# tmr = PTimer()
# while True:
#     cnt.loop();
#     tmr.loop()

# # 3) 35.14 sec
# class PCount:
#     def __init__(self, cnt ): self.n = cnt; self.nState = 0
#     def loop(self):
#         if (self.nState == 0 and self.n > 0):
#             self.n -= 1;
#             return True
#         elif (self.nState == 0 and not self.n > 0):  return False;
#
# cnt = PCount(100000000)
# st_time = time.time()
# while cnt.loop():
#     pass
# t = time.time() - st_time
# print("speed CPU------%s---" % t)

# # 4) 30.53 sec
# class PCount:
#     def __init__(self, cnt ): self.n = cnt; self.nState = 0
#     def loop(self):
#         while self.n > 0:
#             self.n -= 1;
#             return True
#         return False
#
# cnt = PCount(100000000)
# st_time = time.time()
# while cnt.loop():
#     pass
# t = time.time() - st_time
# print("speed CPU------%s---" % t)

# # 5) 18.27 sec
# class PCount:
#     def __init__(self, cnt ): self.n = cnt; self.nState = 0
#     def loop(self):
#         while self.n > 0:
#             self.n -= 1;
#         return False
# 
# cnt = PCount(100000000)
# st_time = time.time()
# while cnt.loop():
#     pass
# t = time.time() - st_time
# print("speed CPU------%s---" % t)

# # 6) 6.96 sec
# def count(n):
#   st_time = time.time()
#   while n > 0:
#     n -= 1
#   t = time.time() - st_time
#   print("speed CPU------%s---" % t)
#   return t
#
# def TestTime(fn, n):
#   def wrapper(*args):
#     tsum=0
#     st = time.time()
#     i=1
#     while (i<=n):
#       t = fn(*args)
#       tsum +=t
#       i +=1
#     return tsum
#   return wrapper
#
# test1 = TestTime(count, 2)
# tt = test1(100000000)
# print("Total ---%s seconds ---" % tt)


Сведем в таблицу времена работы различных вариантов и прокомментируем результаты работы.

  1. Классическая автоматная реализация — 110.66 сек
  2. Автоматная реализация без автоматных методов — 73.38 сек
  3. Без автомата-секундомера — 35.14
  4. Счетчик в форме while с выходом на каждой итерации — 30.53
  5. Счетчик с блокирующим циклом — 18.27
  6. Исходный счетчик с декоратором — 6.96

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

Еще немного сэкономили в 3-м варианте, создав более простую реализации счетчика, но с выходом из него на каждой итерации цикла работы счетчика (имитация работы сопрограммы). Исключив приостановку счетчика (см. вариант 5) мы добились наиболее сильного сокращения работы счетчика. Но при этом мы потеряли преимущества сопрограммной работы. Вариант 6 — это уже повторен исходный счетчик с декоратором и он имеет наименьшее время работы. Но, как и вариант 5, это блокирующая реализация, которая не может нас устраивать в контексте обсуждения сопрограммной работы функций.

5. Выводы


Использовать ли автоматную технологию или довериться корутинам — решение целиком и полностью лежит на программисте. Нам здесь важно, чтобы он знал, что есть иной, чем корутины, подход/технология к проектированию программ. Можно даже представить следующий экзотический вариант. Сначала, на этапе проектирования модели, создается автоматная модель решения. Она строго научна, доказательна и хорошо документируется. Затем ее, например, с целью повышения быстродействия, «уродуют» до «обычного» варианта кода так, как это демонстрирует листинг 3. Можно представить даже «обратный рефакторинг» кода, т.е. переход от 7-го варианта к 1-му, но это, хотя и возможный, но наименее вероятный ход событий :)

На рис. 2 представлены слайды из видео на тему «асинхронщины» [10]. И «плохое», похоже, перевешивает «хорошее». И если на мой взгляд автоматы — это всегда хорошо, то в случае асинхронного программирования выбирай, как говорится, на свой вкус. Но, похоже, вариант «плохо» будет наиболее вероятен. И об этом, проектируя программу, программисту должен знать заранее.

Рис. 2. Характеристики асинхронного программирования
image

Безусловно, и автоматный код в чем-то «не без греха». У него будет несколько больший объем кода. Но, во-первых, он лучше структурирован и потому в нем легче разобраться и его проще сопровождать. А, во-вторых, не всегда он будет больше, т.к. с повышением сложности, скорее всего, будет даже выигрыш (за счет, например, повторного использования автоматных методов) Он проще и понятнее в отладке. Да, в конце он концов, он полностью соответствует принципам SRP и DRY. А это, порой, перевешивает многое.

Хотелось бы, а, наверное, даже необходимо, обратить внимание на, скажем так, стандарт проектирования функций. Программист должен, насколько это возможно, исключить проектирование блокирующих функций. Для этого она должна или только запускать процесс вычислений, который затем проверяется на завершенность, или иметь средства для проверки готовности к запуску по типу рассмотренной в примерах функции select. О том, что подобные проблемы имеют давнюю «докорутинную историю», говорит код, использующий функции еще времен DOS, который приведен в листинге 4.

Листинг 4. Чтение символов с клавиатуры
/*
int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);

    int C=0;
    while (C != 'e')
    {
        C = getch();
        putchar (C);
    }
    return a.exec();
}
*/
//*
int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);

    int C=0;
    while (C != 'e')
    {
        if (kbhit()) {
            C = getch();
            putch(C);
        }
    }

    return a.exec();
}
//*/


Здесь приведены два варианта чтения символов с клавиатуры. Первый вариант — блокирующий. Он заблокирует вычисления и не запустит оператор вывод символа, до тех пор, пока функция getch() не получит его от клавиатуры. Во втором варианте эта же функция будет запущена только в нужный момент, когда парная ей функция kbhit() подтвердит, что символ находится в буфере ввода. Тем самым блокировки вычислений не будет.

Если же функция «тяжела» сама по себе, т.е. требует значительного времени работы, а периодический выход из нее по типу работы сопрограмм (это можно сделать и не используя механизм тех же корутин, чтобы к нему не привязываться) сложно сделать или не имеет особого смысла, то остается помещать подобные функции в отдельный поток и затем контролировать завершение их работы (см. реализацию класса QCount в [2]).

Всегда можно найти выход, чтобы исключить блокировки вычислений. Выше мы показали, как можно создавать асинхронный код в рамках обычных средств языка, не используя при этом механизм сопрограмм/корутин и даже какую-либо специализированную среду типа среды автоматного программирования ВКП(а). А уж что и как использовать — решать уже непосредственно программисту.

Литература


1. Python Junior подкаст. Про асинхронность в питоне. [Электронный ресурс], Режим доступа: www.youtube.com/watch?v=Q2r76grtNeg, свободный. Яз. рус. (дата обращения 13.07.2020).
2. Параллелизм и эффективность: Python vs FSM. [Электронный ресурс], Режим доступа: habr.com/ru/post/506604, свободный. Яз. рус. (дата обращения 13.07.2020).
3. Молчанов О. Основы асинхронности в Python #4: Генераторы и событийный цикл Round Robin. [Электронный ресурс], Режим доступа: www.youtube.com/watch?v=PjZUSSkGLE8], свободный. Яз. рус. (дата обращения 13.07.2020).
4. 48 Генераторы и итераторы. Выражения -генераторы в Python. [Электронный ресурс], Режим доступа: www.youtube.com/watch?v=vn6bV6BYm7w, свободный. Яз. рус. (дата обращения 13.07.2020).
5. Мемоизация и каррирование (Python). [Электронный ресурс], Режим доступа: habr.com/ru/post/335866, свободный. Яз. рус. (дата обращения 13.07.2020).
6. Любченко В.С. О борьбе с рекурсией. «Мир ПК», № 11/02. www.osp.ru/pcworld/2002/11/164417
7. Молчанов О. Уроки Python cast #10 — Что такое yield. [Электронный ресурс], Режим доступа: www.youtube.com/watch?v=ZjaVrzOkpZk, свободный. Яз. рус. (дата обращения 18.07.2020).
8. Молчанов О. Основы асинхронности в Python #5: Асинхронность на генераторах. [Электронный ресурс], Режим доступа: www.youtube.com/watch?v=hOP9bKeDOHs, свободный. Яз. рус. (дата обращения 13.07.2020).
9. Модель параллельных вычислений. [Электронный ресурс], Режим доступа: habr.com/ru/post/486622, свободный. Яз. рус. (дата обращения 20.07.2020).
10. Полищук А. Асинхронщина в Python. [Электронный ресурс], Режим доступа: www.youtube.com/watch?v=lIkA0TDX8tE, свободный. Яз. рус. (дата обращения 13.07.2020).
11. Молчанов О. Уроки Python cast #6 — Декораторы. [Электронный ресурс], Режим доступа: www.youtube.com/watch?v=Ss1M32pp5Ew, свободный. Яз. рус. (дата обращения 13.07.2020).
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+3
Comments28

Articles