Функция asyncio.run(main()) служит для создания и управления циклом событий. При ее запуске из переданной корутины (здесь main()) создается и запускается первая задача нового цикла событий с именем Task-1. Ее набор инструкций является основным набором инструкций для цикла событий, т.е. цикл событий функционирует, пока не будут выполнены все инструкции задачи Task-1. После выполнения этой задачи все незавершенные задачи (если они есть) будут отменены и цикл событий будет завершен. Эта функция не может быть вызвана дважды в одном потоке и служит основной точкой входа и выхода из цикла событий. При написании парсеров мы будем запускать её только один раз.
Код ниже является элементарным примером использования асинхронного кода с использованием асинхронной функции asyncio.sleep(n) (эта функция реализует все признаки ожидания выполнения I/O-bound операции и поэтому часто используется в примерах как ее имитация).
Для большей наглядности в коде будет использован вывод тайминга выполнения основных операций.
Пример 1:
import asyncio import time async def two(): name = asyncio.current_task().get_name() print(f'* {name}: hello {time.perf_counter() - start_time:.4f} секунды') await asyncio.sleep(1.5) print(f'* {name}: world {time.perf_counter() - start_time:.4f} секунды') async def one(): # Начало выполнения Task-1 (one()) print(f'--- start! {time.perf_counter() - start_time:.4f} секунды ---') # Выводим название текущей задачи print(f'--- Текущая задача: {asyncio.current_task().get_name()}---') # Создаем задачу с именем Задача-1 task = asyncio.create_task(two(), name='Задача-1') # Здесь происходит приостановка выполнения one() на 1 секунду и запуск task await asyncio.sleep(1) # Имитация ожидания выполнения I/O-bound операции # Ожидание завершено, продолжаем выполнение кода в Task-1 print(f'--- end sleep(1) {time.perf_counter() - start_time:.4f} секунды ---') # Ожидание завершения задачи task await task # Выполнение последней инструкции в Task-1. После завершения Task-1 цикл событий будет завершен print(f'--- end! {time.perf_counter() - start_time:.4f} секунды ---') # Фиксируем время начала запуска программы start_time = time.perf_counter() # Запуск асинхронной программы asyncio.run(one()) # Цикл событий завершен print(f'Время выполнения программы: {time.perf_counter() - start_time:.4f} секунды')
Вывод:
— start! 0.0018 секунды —
— Текущая задача: Task-1—
* Задача-1: hello 0.0019 секунды
— end sleep(1) 1.0109 секунды —
* Задача-1: world 1.5202 секунды
— end! 1.5204 секунды —
Время выполнения программы: 1.5225 секунды
Что происходит при выполнении:
При запуске run(one()) создаётся цикл событий. Из корутины one() создается и запускается задача Task-1.
Начинается выполнение ее инструкций:
Фиксируем начало выполнения Task-1 (one())
— start! 0.0016 секунды —
Проверяем, что выполняется именно Task-1
— Текущая задача: Task-1—
Создаем новую задачу task:
task = asyncio.create_task(two(), name=’Задача-1′)
После создания задача планируется циклом событий к запуску, но не запускается! Сам запуск задачи произойдет только при переключении контекста (обычно на ключевом слове await).
task — это объект типа Task (задача), а Задача-1 — это значение аргумента name этого объекта (его имя).
Используем имитацию ожидания I/O-bound операции,
await asyncio.sleep(1)
выполнение текущей задачи приостанавливается до завершения операции, а так как выполнение таких операций не зависит от интерпретатора, то происходит отпускание GIL(GIL — глобальная блокировка интерпретатора: позволяет интерпретатору выполнять в любой момент времени только одну инструкцию байткода в одном потоке).
Цикл событий позволяет захватить GIL другой задаче, которая готова начать/продолжить свое выполнение. Собственно вот этот процесс и называется переключением контекста. А так как у нас такая задача есть: task, то начинается ее выполнение.
Получаем имя выполняемой задачи, выводим ее первое сообщение:
* Задача-1: hello 0.0019 секунды
В task вызываем свое ожидание
await asyncio.sleep(1.5)
Теперь уже выполнение task приостанавливается примерно на полторы секунды, GIL отпущен.
Вот только до завершения ожидания в Task-1 захватить его некому, так как нет других задач, которые конкурировали бы за него.
Ожидание в Task-1 завершается.
Что происходит при завершении I/O-bound операции. Операционная система (именно ею выполняются I/O-bound операции) передает в цикл событий результат выполнения операции, цикл событий находит задачу, которая ждет их, и передает этот результат в соответствующую задачу. Как только это происходит, задача вновь готова захватить GIL и продолжить свою работу.
GIL захвачен, Task-1 возобновляет свое выполнение.
— end sleep(1) 1.0109 секунды —
Выполнение доходит до строчки await task. Так как задача task уже была запущена раньше, то здесь просто осуществляется ожидание завершения выполнения задачи task. Это значит, что выполнение Task-1 будет приостановлено до завершения task, GIL вновь отпущен, но до завершения оставшихся 0.5 секунды ожидания в task , захватывать его некому.
Ожидание в task завершается: происходит все то же самое, что происходит при завершении I/O-bound операции, GIL захвачен task, она возобновляет свою работу.
* Задача-1: world 1.5202 секунды
На этом выполнение task завершено, GIL отпущен.
Так как ожидание выполнения task в Task-1 завершается, то Task-1 захватывает GIL и продолжает свое выполнение.
— end! 1.5204 секунды —
Последняя инструкция в Task-1 выполнена, задача Task-1 завершила свою работу.
Цикл событий завершил свою работу
Время выполнения программы: 1.5225 секунды
Обратите внимание на общее время выполнения программы, оно едва превышает время выполнения самой «долгой» задачи, а это значит, что ожидание выполнения I/O-bound операций в этом примере происходило одновременно!
На таком простом примере можно понять, как происходит переключение между awaitable-объектами. Если вы не поняли, как это работает, советую запустить код у себя и поэкспериментировать с ним. Я думаю, у вас всё получится!
Кстати, когда вы будете изучать асинхронное программирование самостоятельно по другим материалам в интернете, помните, что функция run() относительно новая. Поэтому вы можете встретить старый синтаксис запуска цикла событий.
Пример 2:
import asyncio import time async def two(): name = asyncio.current_task().get_name() print(f'* {name}: hello {time.perf_counter() - start_time:.4f} секунды') await asyncio.sleep(1.5) print(f'* {name}: world {time.perf_counter() - start_time:.4f} секунды') async def one(): print(f'--- start! {time.perf_counter() - start_time:.4f} секунды ---') print(f'--- Текущая задача: {asyncio.current_task().get_name()}---') task = asyncio.create_task(two(), name='Задача-1') await asyncio.sleep(1) print(f'--- end sleep(1) {time.perf_counter() - start_time:.4f} секунды ---') await task print(f'--- end! {time.perf_counter() - start_time:.4f} секунды ---') # Фиксируем время начала запуска программы start_time = time.perf_counter() # Получаем цикл событий loop = asyncio.get_event_loop() # Передаем корутину one() для запуска в этот цикл событий loop.run_until_complete(one()) # Цикл событий завершен print(f'Время выполнения программы: {time.perf_counter() - start_time:.4f} секунды')
Результат будет точно такой же, однако, начиная с версии интерпретатора 3.12, вы получите предупреждение об устаревании. Возможно, однажды старый вариант перестанет работать совсем.
DeprecationWarning: There is no current event loop loop = asyncio.get_event_loop()