50ae12a099b01792 8 951 98 30 964 info@severcart.org
Русский English

Асинхронное программирование в Python. Часть 1

29 декабря 2017 г.

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

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

Примеры синхронных программ:

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

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

Когда нам нужно писать такие программы? Иногда асинхронность помогает разрешать те или иные проблемы программирования.

Далее представлена на концептуальном уровне программа, которая может быть кандидатом на асинхронность.

Упрощенный web сервер

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

Почему? Потому что web сервер должен обрабатывать сотни, а иногда и тысячи подключений от пользователей одновременно, а не обслуживать только одного клиента.

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

Каковы реальные пределы оптимизации указанного подхода? Скорость сети, скорость файлового IO, скорость запроса к базе данных, скорость других подключенных услуг и т. д. Общей особенностью этого списка являются все они являются функциями ввода-вывода. Они все на много порядков медленнее, чем скорость работы CPU.

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

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

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

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

Подготовительные действия

Все примеры в этой статье были протестированы в Python 3.6.1. Также нам понадобятся модули Twisted и gevent для запуска примеров. Установить их не составит труда менеджером пакетов pip. В качестве ОС рекомендуется использовать Linux или MacOS.

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

Пример 1: Синхронная программа

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

#!/usr/bin/env python3
# -*- coding:utf-8 -*-
"""
example_1.py
Простой пример программы, демонстрирующей синхронный запуск 'tasks'
"""

import queue

def task(name, work_queue):
    if work_queue.empty():
        print(f'Task {name} nothing to do')
    else:
        while not work_queue.empty():
            count = work_queue.get()
            total = 0
            for x in range(count):
                print(f'Task {name} running')
                total += 1
            print(f'Task {name} total: {total}')


def main():
    """
    Главная точка входа в программу
    """
    # создание очереди 'work'
    work_queue = queue.Queue()

    # помещаем значения 'work' в очередь
    for work in [15, 10, 5, 2]:
        work_queue.put(work)

    # создаём задачи
    tasks = [
        (task, 'One', work_queue),
        (task, 'Two', work_queue)
    ]

    # запускаем задачи
    for t, n, q in tasks:
        t(n, q)

if __name__ == '__main__':  
    main()

task в программе - это просто функция, которая принимает строку и очередь. В процессе выполнения она просматривает очередь на наличие элементов для обработки, и если они там есть, то забирает значение из очереди, запуская цикл for с количеством итераций, равному этому значению. В завершении, распечатывается итоговое значение. Цикл будет итерироваться до тех пор, пока в очереди есть элементы.

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

Пример 2. Простой кооперативный параллелизм

Следующая версия программы демонстрирует возможности двух задач работать совместно с использованием генераторов. Добавление оператора yield в функцию task означает, что после выполнения этого оператора, функция завершает свою работу, но сохраняя свой контекст до следующего запуска. Затем цикл выполнения задачи возобновляет выполнение программы используя вызвав метод t.next(). Этот оператор перезапускает задачу в том месте, где она ранее вызывалась.

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

#!/usr/bin/env python3
# -*- coding:utf-8 -*-
"""
example_2.py
Короткий пример демонстрирующий простой автомат в Python
"""

import queue

def task(name, queue):
    while not queue.empty():
        count = queue.get()
        total = 0
        for x in range(count):
            print(f'Task {name} running')
            total += 1
            yield
        print(f'Task {name} total: {total}')

def main():
    """
    Главная точка входа в программу
    """
    # создание очереди для 'work'
    work_queue = queue.Queue()

    # помещение некоторых 'work' в очередь
    for work in [15, 10, 5, 2]:
        work_queue.put(work)

    # создаём задачи
    tasks = [
        task('One', work_queue),
        task('Two', work_queue)
    ]

    # запуск задач
    done = False
    while not done:
        for t in tasks:
            try:
                next(t)
            except StopIteration:
                tasks.remove(t)
            if len(tasks) == 0:
                done = True


if __name__ == '__main__':
    main()

После рассмотрения вывода работы программы, становится видно, что поочерёдно выполняются задачи One и Two, расходуя содержимое work_queue. Как и было задумано, обе task выполняют свою работу, и каждая из них заканчивает обработку двух элементов из очереди. Но опять же, довольно много работы для достижения результата.

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

Часть 2.