В прошлом посте мы рассмотрели как asyncio взаимодействует с генераторами, но на более низком уровне он взаимодействует с колбэками, а генераторы и корутины - вспомогательные возможности для более удобного написания кода.
Базовая функциональность лупа заключается в том, что он умеет вызывать в цикле синхронные функции. Пока один колбэк не отработает, луп не перейдет к другому. Такие функции можно добавить через вызов
loop.call_soon (или loop.call_soon_threadsafe, если мы хотим это сделать из другого потока). Дополнительно, у него есть некоторые другие функции вроде задания задержки вызова функции.Следующий важный объект - Future. Его задача достаточно простая - хранить некоторый результат и вызывать колбэк при его установке. И все ещё это обычная синхронная функция, но уже вызывающаяся не планировщиком лупа, а напрямую, когда кто-то делает
set_resultОднако взаимодействие с лупом через колбэки многим кажется достаточно неудобным, в связи с чем и было предложено поддержать генераторы, которые в дальнейшем превратились в async функции.
В качестве клея между асинк функциями (читай генераторами) и логикой вызова функций лупом выступает объект Task. Таск добавляет свой метод как колбэк в лупе и когда луп вызовет его, он промотает внутренний генератор на шаг вперед. Если при этом встретится Future, таск зарегистрируется в нем на ожидание результата и, таким образом, шаг будет завершен и управление вернется в луп. Когда кто-то установит результат на футуре, он сразу обратится к нашему таску и сможет передать ему управление.
При этом возникает важный вопрос, а кто пометит футуру завершенной? У нас есть несколько вариантов:
• Другой таск или колбэк, просто вызвав
set_result• Другой поток (так как объекты asyncio не потокобезопасны, это будет сделано через
call_soon_threadsafe). Например, так работает aiofiles или granian.• Сам луп согласно своей внутренней логике
Asyncio loop имеет множество методов, которые возвращают Future объекты и при этом включают какую-то внутреннюю логику. Одна из важнейших функций - работа с сокетами. Операционная система имеет два режима работы с сокетами - блокирующий и неблокирующий. В блокирующем режиме наши вызовы send/recv блокируют тред пока сокет не будет готов, в неблокирующем же - мы сразу получим предложение повторить вызов позже. Благодаря этому можно во время ожидания переключиться на другую работу. Дополнительно, ОС имеют разные варианты API для опроса сокетов на предмет доступности (вы, конечно, можете пытаться делать send/recv на каждом в цикле, но это очень не эффективно): select, epoll, kqueue, IOCP.
Таким образом, loop имеет методы вида
sock_recv, sock_sendall которые возвращают Future и говорят лупу заниматься обслуживанием таких сокетов (опрашивать готовность и собственно передать данные). Дальнейшее поведение лупа будет зависеть от реализации, но в случае Linux это будет вызов epoll между вызовами колбэков. Как только чтение/запись в сокет будет завершено, луп сам (или какая-то его подсистема, например Транспорт) установит результат футуре. Аналогично можно работать с pipe и сабпроцессами.Кратко:
1. Loop умеет вызывать синхронные колбэки
2. Future хранит результат и вызывает колбэки
3. Task адаптирует генератор под колбэки лупа и связывает с футурами
4. Loop сам опрашивает сокеты в неблокирующем режиме и обновляет футуры
5. Футуры можно обновлять из других колбэков или тредов
Дополнительные материалы:
• https://peps.python.org/pep-3156
• https://learn.microsoft.com/en-us/windows/win32/fileio/i-o-completion-ports
• https://www.kegel.com/c10k.html
• https://man7.org/linux/man-pages/man7/io_uring.7.html