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



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

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

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

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

Асинхронное программирование в Python


Изначально в Python для решения задач асинхронного программирования использовались корутины, основанные на генераторах. Потом, в Python 3.4, появился модуль asyncio (иногда его название записывают как async IO), в котором реализованы механизмы асинхронного программирования. В Python 3.5 появилась конструкция async/await.

Для того чтобы заниматься асинхронной разработкой на Python, нужно разобраться с парой понятий. Это — корутины (coroutine) и задачи (task).

Корутины


Обычно корутина — это асинхронная (async) функция. Корутина может быть и объектом, возвращённым из корутины-функции.

Если при объявлении функции указано то, что она является асинхронной, то вызывать её можно с использованием ключевого слова await:

await say_after(1, ‘hello’)

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

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

Задачи


Задачи позволяют запускать корутины в цикле событий. Это упрощает управление выполнением нескольких корутин. Вот пример, в котором используются корутины и задачи. Обратите внимание на то, что сущности, объявленные с помощью конструкции async def — это корутины. Этот пример взят из официальной документации Python.

import asyncio
import time

async def say_after(delay, what):
    await asyncio.sleep(delay)
    print(what)

async def main():
    task1 = asyncio.create_task(
        say_after(1, 'hello'))

    task2 = asyncio.create_task(
        say_after(2, 'world'))

    print(f"started at {time.strftime('%X')}")

    # Ждём завершения обеих задач (это должно занять
    # около 2 секунд.)
    await task1
    await task2

    print(f"finished at {time.strftime('%X')}")
asyncio.run(main())

Функция say_after() имеет префикс async, в результате перед нами — корутина. Если немного отвлечься от этого примера, то можно сказать, что данную функцию можно вызвать так:

    await say_after(1, 'hello')
    await say_after(2, 'world')

При таком подходе, однако, корутины вызываются последовательно и на их выполнение уходит около 3 секунд. В нашем же примере осуществляется их конкурентный запуск. Для каждой из них используется задача. В результате время выполнения всей программы составляет около 2 секунд. Обратите внимание на то, что для работы подобной программы недостаточно просто объявить функцию main() с ключевым словом async. В подобных ситуациях нужно пользоваться модулем asyncio.

Если запустить код примера, то на экран будет выведен текст, подобный следующему:

started at 20:19:39
hello
world
finished at 20:19:41

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

Пример


В этом примере производится нахождение количества операций, необходимых на вычисление суммы десяти элементов последовательности чисел. Вычисления производятся, начиная с конца последовательности. Рекурсивная функция начинает работу, получая число 10, потом вызывает сама себя с числами 9 и 8, складывая то, что будет возвращено. Подобное продолжается до завершения вычислений. В результате оказывается, например, что сумма последовательности чисел от 1 до 10 составляет 55. При этом наша функция весьма неэффективна, здесь используется конструкция time.sleep(0.1).

Вот код функции:

import time

def fib(n):
    global count
    count=count+1
    time.sleep(0.1)
    if n > 1:
        return fib(n-1) + fib(n-2)
    return n

start=time.time()
global count
count = 0
result = fib(10)
print(result,count)
print(time.time()-start)

Что произойдёт, если переписать этот код с использованием асинхронных механизмов и применить здесь конструкцию asyncio.gather, которая отвечает за выполнение двух задач и ожидает момента их завершения?

import asyncio,time

async def fib(n):
    global count
    count=count+1
    time.sleep(0.1)
    event_loop = asyncio.get_event_loop()
    if n > 1:
        task1 = asyncio.create_task(fib(n-1))
        task2 = asyncio.create_task(fib(n-2))
        await asyncio.gather(task1,task2)
        return task1.result()+task2.result() 
    return n

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

Итоги


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

Уважаемые читатели! Как вы пишете асинхронный Python-код?


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


  1. MonkAlex
    15.11.2019 13:46
    +7

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


    1. CrazyElf
      15.11.2019 16:00
      +1

      Если добавить словосочетание «при определённых условиях», то прокатит )


      1. MonkAlex
        15.11.2019 16:06
        +1

        А тогда придётся условия описывать, что сразу сделает описание не таким красивым.

        По моему опыту, асинхронность может сделать программу медленнее. А может ничего не изменить. Может ускорить. В таком виде это звучит ни разу не оптимистично — а зачем мне тогда такая штука нужна? =)

        ПС: просто это настолько частое заблуждение, что немножко больно каждый раз читать такие вещи.


        1. CrazyElf
          15.11.2019 17:17
          +2

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


          1. MonkAlex
            15.11.2019 17:45

            Прекрасно это понимаю =)

            Главная проблема в том, что этой информации часто не хватает в тексте — не хватает теоретического пояснения, что как и почему и что же в итоге даст асинхронность, как её правильно готовить.


  1. dzaytsev91
    15.11.2019 15:55

    По ощущениями питон свернул куда-то не туда в плане асинхронщины
    — Отлаживаться очень сложно, код избыточный
    — Куча легаси и одноименных структур (к примеру есть два типа Future)
    — Есть большие вопросы о том как правильно ловить исключения во вложенных корутинах
    — По ощущениям люди пишут код дольше и ловят больше багов именно на асинхронном питоне
    — У себя в кампании заметил что рост производительности в проектах на бою не больше 30%
    — Очень сложно заставить питон грузить все ядра
    — До версии 3.8 не было нормальной возможности в дебаге получить результат асинхронной функции


    1. CrazyElf
      15.11.2019 16:04

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


    1. evgenyk
      15.11.2019 16:10

      Ну, насчет асинхронного программироварния, соглашусь.
      А вот насчет грузить все ядра, мне так не кажется. Есть ведь модуль multipocessing.
      Лично я для себя набросал небольшой фреймворк на его базе (по опыту разработки большой программы, используя опробованные подходы) и планирую польоваться им. Суть — работа выполняется в отдельных процессах, общение через очереди сообщений.
      Мне такой подход нравится гораздо больше, чем накидать асинхронных вызовов функций. Главное преимущество такого подхода — можно ясно понимать, когда и что происходит.


      1. CrazyElf
        15.11.2019 17:21
        +1

        Часто вообще бывает достаточно сделать map/reduce через pool.map/imap. Есть разные подходы к мультизадачности/асинхронности и это хорошо, когда они все реализованы в языке и доступны к использованию.


        1. evgenyk
          15.11.2019 17:29

          Ну, это в самых простых случаях. Иногда вообще можно через шелл процессы запускать.


    1. andreymal
      15.11.2019 17:42

      А что не так с исключениями? Все на них жалуются, а я вот четыре года юзаю asyncio и проблем не испытывал


  1. CrazyElf
    15.11.2019 15:58
    -1

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


    1. SirEdvin
      15.11.2019 16:32
      +4

      Вот только это два разных термина, по идее ....


    1. worldmind
      15.11.2019 16:40
      +2

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


      1. CrazyElf
        15.11.2019 17:20

        Да, вы правы. Поторопился.


      1. 0xd34df00d
        15.11.2019 21:34

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


        Упомянутые вами варианты — частные случаи.


        1. worldmind
          16.11.2019 09:59

          Логика в ваших словах есть, было бы интересно где увидеть эталонные определения терминов.


    1. Alesh
      15.11.2019 17:16

      Если пишите о короутинах протона, то именно конкурентный.


  1. maksim_R
    15.11.2019 20:15

    Асинхронный код будет быстрее, если время расходуется не процессором, а ожидает ответа с другой стороны. Синхронный код остается ждать ответа, а асинхронный тем временем продолжает делать еще что-то полезное.
    Наиболее разумное применение — скрейпинг. Пример: мне нужно было собрать данные с 4К страниц. Запустил синхронный код, вышло около 25 минут до завершения процесса. По результату обнаружил, что забыл еще один параметр прочитать и поэтому надо было повторять заново. Переписал на асинхронный — вышло около 2,5 минут. Ровно в 10 раз быстрее.


    1. andreymal
      16.11.2019 17:24

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


      1. maksim_R
        17.11.2019 00:14

        В моем случае такого риска не было.

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


  1. ratijas
    16.11.2019 21:38

    Для глубокого и фундаментального понимания асинхрощины питона, а также её исторического контекста, рекомендую прочесть книгу про монументальный фреймворк Twisted от издательства O'Reily. Если вы ещё сомневаетесь, то всё что вам нужно знать, чтобы захотеть почитать — корутины питона проектировали под обратную совместимость с Twisted, и об этом явно указано в документации.


  1. maslyaev
    16.11.2019 22:52

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

    Извиняюсь, а разве старый добрый вызов функции не делает вот это самое, что здесь описано?


    1. maksim_R
      17.11.2019 00:18

      … После этого возможность запуститься появится и у других корутин.


      Одна корутина ждет ответа откуда-нибудь, тем временем другая делает что-нибудь еще.


      1. maslyaev
        17.11.2019 13:17

        Ну да, в этом и есть смысл параллельного исполнения.
        Тем не менее, в статье совсем не раскрыто, в чём, собственно, разница между
        await asyncio.sleep(1)
        и старым добрым
        time.sleep(1)