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

Если видишь все это в первый раз, то не понимаешь ровным счетом ничего и это не удивительно. Сама концепция корутин, задач, цикла событий и всего остального с этим связанного — очень нетривиальна и требует разбирательств, чем мы и будем заниматься в этой статье.
asyncio — это корутины (сопрограммы), которые позволяют писать асинхронные программы в синхронном стиле. Основная идея — это возможность прервать выполнение программы в производной точке и вернуться к ней позже. Если вы работали с генераторами, то, считайте, что с корутинами вы уже знакомы. Единственная разница, что генераторы возвращают значение, а корутины могут еще и принимать аргументы после инициализации. В Python, чтобы получить как корутину, так и генератор используется ключевое слово yield. Рассмотрим 2 небольших примера:# Пример 1 - простой генератор
def gen(n):
while n > 0:
yield n
n -= 1
g = gen(3)
print(next(g))
print(next(g))
print(next(g))
3 2 1
# Пример 2 - простая корутина
def coro(n):
while n > 0:
m = yield
yield n
n -= m
c = coro(3)
c.send(None) # инициализируем корутину
print(c.send(1))
c.send(None)
print(c.send(1))
c.send(None)
print(c.send(1))
3 2 1
В первом случае наш генератор ведет себя как простой генератор, во втором, как полноценная корутина. Разница не такая большая, но она есть. В первом случае мы работает с генератором как с обычным итератором и «прокручиваем» его вперед с помощью функции next. Во втором случае, с помощью метода send, мы передаем внутрь корутины значение на которое надо уменьшить n. Обратите внимание, что мы трижды отправили пустое значение c.send(None) и если в первом случае, это было сделано для того, чтобы проинициализировать корутину (генератор дошел до первого yield и остановился), то остальные 2, чтобы «прокрутить» ее в этой строчке yield n, где она возвращает значение и встает в режим ожидания. В принципе, можно было вместо c.send(None) использовать next(c), но семантически это не очень корректно.
Почему это важно? Да потому, что теперь мы может прервать исполнение кода в любой его точке и позже вернуться к ней и продолжить:
def coro_with_stop():
print('Coroutine has been started...')
n = yield 'Coroutine wait...'
print(f'Coroutine has been continued and takes value n = {n}...')
m = yield 'Coroutine wait again...'
return 'Coroutine has been finished!'
def intermediate_execution(n):
print(f'Some intermediate execution {n}')
intermediate_execution(1)
c = coro_with_stop()
print(next(c))
intermediate_execution(2)
intermediate_execution(3)
intermediate_execution(4)
print(c.send('3'))
intermediate_execution(5)
intermediate_execution(6)
try:
c.send(None)
except StopIteration as ret:
print(ret)
Some intermediate execution 1 Coroutine has been started... Coroutine wait... Some intermediate execution 2 Some intermediate execution 3 Some intermediate execution 4 Coroutine has been continued and takes value n = 3... Coroutine wait again... Some intermediate execution 5 Some intermediate execution 6 Coroutine has been finished!
Пример хорошо иллюстрирует как мы можем делать какие-то промежуточные операции и переодически возвращаться к нашей корутине по мере необходимости. Когда корутина завершается она выбрасывает исключение StopIteration и может возвращать значение почти как обычная функция.
Неблокирующий ввод-вывод.
Теперь возникает следующий вопрос, в чем же, собственно, заключается асинхронность в синхронном стиле? На самом деле, все очень просто, для этого сделаем эмуляцию блокирующего и небокирующего сокета (подробнее о сокетах и способах работы с ними поговорим в дальнейшем), если вдруг кто не знает что это и зачем, может почитать здесь:
def blocking_socket_emu(total_bytes=4096, chunk=1024, _id=1):
'''Блокируюший "сокет"'''
while total_bytes > 0:
print(f'Read {chunk} bytes from socket {_id}')
total_bytes -= chunk
print(f'Finish with socket {_id}')
def async_socket_emu(total_bytes=4096, chunk=1024, _id=1):
'''Неблокируюший "сокет"'''
while total_bytes > 0:
print(f'Read {chunk} bytes from socket {_id}')
total_bytes -= chunk
yield
print(f'Finish with socket {_id}')
Разница всего в одну строчку, мы добавили yield, но именно она делает наш код асинхронным.
Работать с блокирующим «сокетом» достаточно просто, достаточно вызвать его как функцию:
blocking_socket_emu(_id=1) blocking_socket_emu(chunk=512, _id=2)
Read 1024 bytes from socket 1 Read 1024 bytes from socket 1 Read 1024 bytes from socket 1 Read 1024 bytes from socket 1 Finish with socket 1 Read 512 bytes from socket 2 Read 512 bytes from socket 2 Read 512 bytes from socket 2 Read 512 bytes from socket 2 Read 512 bytes from socket 2 Read 512 bytes from socket 2 Read 512 bytes from socket 2 Read 512 bytes from socket 2 Finish with socket 2
Здесь ничего особенного не происходит, чтение из «сокетов» происходит синхронно, ровно в том порядке в котором они были заданы в коде.
А вот с корутинами все несколько сложнее, для того, чтобы запустить их в асинхронном режиме, нам нужен примитив, который будет менеджить нашу корутину, пусть это будет Task, а также планировщик для управления этими примитивами (задачами), который, по сути, представляет собой обычную очередь. Мы будем использовать для этой цели deque — очередь на базе двусвязного списка.
from collections import deque
class Task:
def __init__(self, coroutine):
self._coroutine = coroutine
self._is_ready = False
self._is_finished = False
def run(self):
try:
# инициализируем корутину
if not self._is_ready:
self._is_ready = True
return self._coroutine.send(None)
ret = self._coroutine.send(None)
return ret
except StopIteration:
self._is_finished = True
@property
def is_finished(self):
return self._is_finished
class Scheduler:
def __init__(self):
self._task_queue = deque([])
def new_task(self, coroutine):
task = Task(coroutine)
self.schedule(task)
def schedule(self, task):
self._task_queue.append(task)
def run(self):
while self._task_queue:
task = self._task_queue.popleft()
task.run()
if not task.is_finished:
self.schedule(task)
scheduler = Scheduler()
scheduler.new_task(async_socket_emu(_id=1))
scheduler.new_task(async_socket_emu(chunk=512, _id=2))
scheduler.run()
Read 1024 bytes from socket 1 Read 512 bytes from socket 2 Read 1024 bytes from socket 1 Read 512 bytes from socket 2 Read 1024 bytes from socket 1 Read 512 bytes from socket 2 Read 1024 bytes from socket 1 Read 512 bytes from socket 2 Finish with socket 1 Read 512 bytes from socket 2 Read 512 bytes from socket 2 Read 512 bytes from socket 2 Read 512 bytes from socket 2 Finish with socket 2
Как видно, «сокеты» чередуются в чтении данных между собой, и теперь уже возникает следующий вопрос, зачем все это нужно? На этом месте самое разобраться что же такое неблокирующая операция. На эту тему есть достаточно много статей, например, википедия.
Если говорить кратко, то неблокирующая операция та, которая не блокирует основной поток программы, что позволяет ему продолжить свою работу. Отличный пример — это операция sleep. Для наших блокирующих «сокетов» это будет аналогично времени ожидания данных, поэтому немного изменим их код:
import time
def blocking_socket_emu(total_bytes=4096, chunk=1024, _id=1):
while total_bytes > 0:
print(f'Read {chunk} bytes from socket {_id}')
time.sleep(0.5) # new
total_bytes -= chunk
print(f'Finish with socket {_id}')
start = time.time()
blocking_socket_emu(_id=1)
blocking_socket_emu(chunk=2048, _id=2)
blocking_socket_emu(chunk=512, _id=3)
print('Total time:', time.time()-start, 'seconds')
Read 1024 bytes from socket 1 Read 1024 bytes from socket 1 Read 1024 bytes from socket 1 Read 1024 bytes from socket 1 Finish with socket 1 Read 2048 bytes from socket 2 Read 2048 bytes from socket 2 Finish with socket 2 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Finish with socket 3 Total time: 7.042984962463379 seconds
Первый «сокет» отработал за 2 секунды, второй за 1, третий за 4, итого — 7 секунд!
Если мы добавим аналогичную блокирующую операцию в наш асинхронный «сокет», то результат будет аналогичным… почему? Потому что первое правило работы с асинхронным кодом — стараться максимально избегать блокирующих операций в нем! Так как происходит блокировка нашего планировщика. Чтобы не быть голословными проведем эксперимент, добавим блокирующую операцию в наш асинхронный «сокет»:
def async_socket_emu(total_bytes=4096, chunk=1024, _id=1):
while total_bytes > 0:
print(f'Read {chunk} bytes from socket {_id}')
time.sleep(0.5) # new
total_bytes -= chunk
yield
print(f'Finish with socket {_id}')
scheduler = Scheduler()
scheduler.new_task(async_socket_emu(_id=1))
scheduler.new_task(async_socket_emu(chunk=2048, _id=2))
scheduler.new_task(async_socket_emu(chunk=512, _id=3))
start = time.time()
scheduler.run()
print('Total time:', time.time()-start, 'seconds')
Read 1024 bytes from socket 1 Read 2048 bytes from socket 2 Read 512 bytes from socket 3 Read 1024 bytes from socket 1 Read 2048 bytes from socket 2 Read 512 bytes from socket 3 Read 1024 bytes from socket 1 Finish with socket 2 Read 512 bytes from socket 3 Read 1024 bytes from socket 1 Read 512 bytes from socket 3 Finish with socket 1 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Finish with socket 3 Total time: 7.041100978851318 seconds
Как же быть в том случае, если мы хотим, чтобы корутина поспала полсекунды и продолжила свою работу не блокируя остальные? Для этого существует асинхронный sleep. Реализация его не такая уж и сложная, давайте разбираться.
Асинхронный таймер.
Концептуально это будет выглядеть следующим образом: создаем дополнительную очередь задач в нашем планировщике, причем не простую, а очередь с приоритетом, где значением будет Task, а весом — временная метка в миллисекундах + время задержки. Там эти таски будут храниться в очереди до того момента, пока их временная метка не станет меньше текущего времени, тогда их можно будет переместить в основную очередь, где они продолжат свое выполнение.
Такая реализация довольно примитивна и имеет ряд недостатков, но вполне себе рабочая и может быть использована как пример.
import time
from collections import deque
from heapq import heappush, heappop # очередь с приоритетом
def get_timestamp(offset=0):
return time.time() * 1000 + (offset * 1000)
def async_sleep(t):
'''
Наш асинхронный sleep,
ничего особенного он не делает,
только дает планировщику понять,
что надо приостановить таску
'''
return 'sleep', t
class Task:
def __init__(self, coroutine):
self._coroutine = coroutine
self._is_ready = False
self._is_finished = False
def run(self):
try:
# инициализируем корутину
if not self._is_ready:
self._is_ready = True
return self._coroutine.send(None)
ret = self._coroutine.send(None)
return ret
except StopIteration:
self._is_finished = True
@property
def is_finished(self):
return self._is_finished
class TimeQueue:
def __init__(self):
self._q = []
def append(self, task, ts):
heappush(self._q, (ts, task))
def expired(self, ts):
while self._q:
ret_ts, task = heappop(self._q)
if ret_ts <= ts:
yield task
else:
heappush(self._q, (ret_ts, task))
break
@property
def is_empty(self):
return not bool(self._q)
class Scheduler:
def __init__(self):
self._task_queue = deque([])
self._time_queue = TimeQueue()
def new_task(self, coroutine):
task = Task(coroutine)
self.schedule(task)
def sleep(self, task, ts):
self._time_queue.append(task, ts)
def schedule(self, task):
self._task_queue.append(task)
def run(self):
while True:
if self._task_queue:
task = self._task_queue.popleft()
ret = task.run()
# проверяем, не надо ли таске немного поспать?
if ret and len(ret) == 2 and ret[0] == 'sleep':
self.sleep(task, get_timestamp(ret[1]))
else:
if not task.is_finished:
self.schedule(task)
for task in self._time_queue.expired(get_timestamp()):
task.run()
if not task.is_finished:
self.schedule(task)
if not self._task_queue and self._time_queue.is_empty:
break
def async_socket_emu(total_bytes=4096, chunk=1024, _id=1):
while total_bytes > 0:
print(f'Read {chunk} bytes from socket {_id}')
yield async_sleep(0.5) # теперь "сокет" спит в асинхронном режиме
total_bytes -= chunk
yield
print(f'Finish with socket {_id}')
scheduler = Scheduler()
scheduler.new_task(async_socket_emu(_id=1))
scheduler.new_task(async_socket_emu(chunk=2048, _id=2))
scheduler.new_task(async_socket_emu(chunk=512, _id=3))
start = time.time()
scheduler.run()
print('Total time:', time.time()-start, 'seconds')
Read 1024 bytes from socket 1 Read 2048 bytes from socket 2 Read 512 bytes from socket 3 Read 1024 bytes from socket 1 Read 2048 bytes from socket 2 Read 512 bytes from socket 3 Read 1024 bytes from socket 1 Finish with socket 2 Read 512 bytes from socket 3 Read 1024 bytes from socket 1 Read 512 bytes from socket 3 Finish with socket 1 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Finish with socket 3 Total time: 4.002506971359253 seconds
Отлично! Теперь наши «сокеты» отработали всего за 4 секунды, что равняется времени исполнения самого медленного сокета. Остальные «сокеты» выполнялись в неблокирующемся режиме, поэтому не мешали друг другу. Если бы у нас было бы еще парочка «сокетов», то это никак не повлияло бы на общее время выполнения всех задач. Профит!
В «эталонном» asyncio, наш асинхронный «сокет» выглядел бы и запускался вот так:
import asyncio
async def async_socket_emu(total_bytes=4096, chunk=1024, _id=1):
while total_bytes > 0:
print(f'Read {chunk} bytes from socket {_id}')
await asyncio.sleep(0.5)
total_bytes -= chunk
print(f'Finish with socket {_id}')
loop = asyncio.get_event_loop()
loop.create_task(async_socket_emu(_id=1))
loop.create_task(async_socket_emu(chunk=2048, _id=2))
loop.create_task(async_socket_emu(chunk=512, _id=3))
pending = asyncio.Task.all_tasks(loop=loop)
group = asyncio.gather(*pending, return_exceptions=True)
start = time.time()
loop.run_until_complete(group)
print('Total time:', time.time()-start, 'seconds')
Read 1024 bytes from socket 1 Read 2048 bytes from socket 2 Read 512 bytes from socket 3 Read 1024 bytes from socket 1 Read 2048 bytes from socket 2 Read 512 bytes from socket 3 Read 1024 bytes from socket 1 Finish with socket 2 Read 512 bytes from socket 3 Read 1024 bytes from socket 1 Read 512 bytes from socket 3 Finish with socket 1 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Read 512 bytes from socket 3 Finish with socket 3 Total time: 4.018957853317261 seconds
Все те же 4 секунды, отлично!
Заключение.
На этом конец первой части, мы рассмотрели такие важные компоненты асинхронного программирования как корутины, таски и планировщик задач, а также сделали свой асинхронный таймер.
Спасибо всем, кто дочитал до конца, оставляйте свои комментарии, а для тех, кому интересно поиграться с кодом, черновик статьи в формате jupiter notebook можно скачать здесь.