持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情
\
前记
Python Asyncio提供了很多基础的API以及对应的对象,如果只用于编写简单的HTTP API处理函数,那么这些Python Asyncio是足够的,但在面对一些复杂点多需求或者编写自己的网络相关框架时,就需要基于Python Asyncio的基础API封装成高级对象。目前比较常用的有两个,一个是用于管理代码域超时的timeout以及一个用于结构化并发的TaskGroup,它们最先出现在Trio这个协程库中,后来Anyio库也支持这两个对象,现在,准备发布Python 3.11中Asyncio库也包括这两个功能。
注: 正常情况下,调用经过封装的高级对象的耗时肯定会大于直接调用基础API的耗时,但是高级对象能使代码结构更加优美。比如
starlette框架在集成anyio后,性能降低了4.5%,具体见:github.com/encode/star…
1.人性化的超时
通常情况下,我们的代码调用结果只有成功或者是失败,但是对于客户端的网络调用来说还存在另外一种情况,就是网络调用可能会永远挂起,不会响应成功或者失败,然后就一直占用着文件描述符等系统资源。所以大多数的客户端都会实现超时机制,但是客户端支持的超时API都是只针对自己的对应调用,比如httpx这个库,它的对应调用如下:
# 使用get方法请求, 超时时间为9秒
import asyncio
import httpx
asyncio.run(httpx.AsyncClient().get(url="http://so1n.me", timeout=9))
这个调用会请求到http://so1n.me,然后等待响应,如果该网站超过9秒仍未返回响应或者由于网络原因导致该调用没有返回响应,那么就会抛出一个超时错误。
这种设计的非常OK的,使用起来非常简单,但如果现在要求的更改为在9秒内请求两次http://so1n.me后还按照上面的写法,就会变得很糟糕,代码如下:
import asyncio
import httpx
async def demo() -> None:
await httpx.AsyncClient().get(url="http://so1n.me", timeout=9)
await httpx.AsyncClient().get(url="http://so1n.me", timeout=9)
这种情况下假设该方法的每个请求时长为8秒, 那么他的总请求时长为16秒, 已经超出总的超时时长为9秒的要求的, 但每个请求都没有触发超时机制,所以并不会抛出异常。
不过这时我们可以换个思路, 因为超时的原本意思是在n秒后中断此次请求, 也就是在某个时刻时终止请求, 那么我们只要在调用时计算出距离超时时刻还有多少时间差,并设置到timeout参数中,就可以使demo调用符合我们的要求了,代码改写后如下:
import asyncio
import httpx
async def demo(timeout: int = 9) -> None:
deadline: float = time.time() + 9
await httpx.AsyncClient().get(url="http://so1n.me", timeout=time.time() - deadline)
await httpx.AsyncClient().get(url="http://so1n.me", timeout=time.time() - deadline)
这段代码可以完美的工作, 假设第一个请求的时长为5秒, 那么第二次请求的超时参数的值会是4秒, 这是非常ok, 代码也依然保持简单。 不过目前还是有个缺点, 就是每次都计算一次超时时间, 然后再显示传进去, 这个超时是不可传递的, 如果有一个抽象能方便的使用, 那是非常好的,比如像使用wait_for后的代码:
import asyncio
import httpx
import time
async def sub_demo() -> None:
await httpx.AsyncClient().get(url="http://so1n.me")
await httpx.AsyncClient().get(url="http://so1n.me")
async def demo() -> None:
await asyncio.wait_for(sub_demo(), timeout=9)
asyncio.run(demo())
这段代码通过wait_for使一个函数内的调用共享一个截止时间,当抵达截止时间时, 无论执行当前已经执行到哪个函数, 都会触发超时异常。不过这样的实现会差点意思, 因为每有一个共享截止时间的代码范围, 就需要把对应的逻辑独立出来成一个新的函数, 这样的代码不是特别的优雅, 而且当需要传的参数比较多时, 这简直就是灾难了(当然也可以写成闭包的形式)。
好在Python通过with语句提供了一个代码范围的管理,所以我们可以尝试通过with语句来管理这片代码范围的执行超时,那该如何实现呢?熟悉with语句的开发者都知道,with语句实际上是一个带有__enter__方法和__exit__方法的类,这两个方法分别提供了进入代码范围和退出代码范围的调用,对于超时这个需求在结合with语句后,只需要在进入代码范围初始化一个计时器,退出时关闭计时器,如果计时器数完(也就是超时了)且尚未被退出逻辑关闭,则会引发超时,并取消代码范围的协程,大概的伪逻辑如下:
async def demo() -> None:
# 该代码只为了演示逻辑,实际上无法正常运行
timer = Timer(9)
await httpx.AsyncClient().get(url="http://so1n.me")
await httpx.AsyncClient().get(url="http://so1n.me")
timer.close()
通过伪代码逻辑可以看出两个运行的协程并跟timer并没有任何联系,timer无法管理到这两个协程的,所以timer超时时,两个协程还能正常运行,那该如何与他们建立联系呢?文章《Python的可等待对象在Asyncio的作用》中讲到在一个协程函数中通过await执行的子协程,是交给执行该协程函数对应的task对象管理的,也就是我们对执行协程函数的task对象进行的任何操作都是会传播到对应的子协程的,所以我们只要在进入代码范围时捕获到当前的task,然后通过loop.call_at方法在指定时间调用task.cancel取消task对象,并由task对象传播到被调用的子协程,如下代码:
import asyncio
import httpx
async def demo() -> None:
current_task: asyncio.Task = asyncio.Task.current_task()
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
# 设置9秒后超时
timer: asyncio.events.TimerHandle = loop.call_at(loop.time() + 9, lambda: current_task.cancel())
await httpx.AsyncClient().get(url="http://so1n.me")
await httpx.AsyncClient().get(url="http://so1n.me")
if not timer.cancelled():
timer.cancel()
这段是可以正常运行的,接下来我们就需要把这段逻辑封装到一个类里面,这样调用者只需要简单的调用就可以实现整块代码域的超时管理,对应的代码如下:
# 这是一个简化版本的伪代码, 存在一些逻辑漏洞, 但是都包含了主要流程了,
import asyncio
from typing import Optional, Type
from types import TracebackType
class Deadline(object):
def __init__(
self,
delay: Optional[float],
loop: Optional[asyncio.AbstractEventLoop] = None,
timeout_exc: Optional[Exception] = None,
):
# 代表多少秒后超时
self._delay: Optional[float] = delay
# asyncio需要的事件循环
self._loop = loop or asyncio.get_event_loop()
# 当超时时,如何抛异常
self._timeout_exc: Exception = timeout_exc or asyncio.TimeoutError()
# 控制结束的future
self._deadline_future: asyncio.Future = asyncio.Future()
# 注册with语句捕获的future
self._with_scope_future: Optional[asyncio.Future] = None
if self._delay is not None:
# 计算截止时间和注册截止时间回调,通知event loop在截止时间执行超时机制
self._loop.call_at(self._loop.time() + self._delay, self._set_deadline_future_result)
def _set_deadline_future_result(self) -> None:
# 当到截止时间时, 设置执行结束, 并对还在执行的with future进行cancel操作
self._deadline_future.set_result(True)
if self._with_scope_future and not self._with_scope_future.cancelled():
self._with_scope_future.cancel()
def __enter__(self) -> "Deadline":
# 进入with语句范围
if self._with_scope_future:
# 一个实例同时只能调用一次, 多次调用会出错
raise RuntimeError("`with` can only be called once")
if self._delay is not None:
# 启动了超时机制
# 获取当前运行的task
main_task: Optional[asyncio.Task] = asyncio.Task.current_task(self._loop)
if not main_task:
raise RuntimeError("Can not found current task")
# 注册with语句所在的future
self._with_scope_future = main_task
return self
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> Optional[bool]:
# 由于执行完成或者是异常退出with语句范围
if self._with_scope_future:
self._with_scope_future = None
else:
return None
if self._deadline_future.done():
# 如果控制结束的future已经结束, 代表此次with语句范围的代码执行超时了
raise self._timeout_exc
else:
return None
现在超时类编写完成,它的使用方法如下:
import asyncio
async def demo() -> None:
with Deadline(delay=9):
await httpx.AsyncClient().get(url="http://so1n.me")
await httpx.AsyncClient().get(url="http://so1n.me")
asyncio.run(demo())
可以看到,这样的使用方法非常方便,不过这个功能在Python3.11已经提供了,可以通过github.com/python/cpyt…
2.结构化并发
结构化并发借鉴了结构化编程这一名词,它的作用就是确保调用者进行了一个调用后还能控制这个调用过程,或者是得到调用结果,具体的结构化并发描述见Notes on structured concurrency, or: Go statement considered harmful或者译文【译】「结构化并发」简析,或:有害的go语句。
在使用Python Asyncio编写代码时,会为了提高并发能力而通过asyncio.create创建很多Task运行,这种情况下可能导致调用者无法得到协程的运行结果,比如一个服务端为了提高并发能力,在接收到请求时通常都会分发给其它协程去处理,这时就可能导致代码不属于结构化并发, 下面通过一个生产消费者来模拟这一个行为,代码如下:
import asyncio
import random
async def request_handle(data):
# 处理请求
print(data)
await asyncio.sleep(1)
async def recv_request(queue: asyncio.Queue):
while True:
# 接收请求
data = await queue.get()
# 分发给其它协程处理
asyncio.create_task(request_handle(data))
async def send_request(queue: asyncio.Queue):
# 发送请求
while True:
await queue.put(random.randint(0, 100))
await asyncio.sleep(0.01)
async def main():
queue: asyncio.Queue = asyncio.Queue()
asyncio.create_task(recv_request(queue))
await (send_request(queue))
asyncio.run(main())
这段代码首先是通过asyncio.create_task创建一个发送者在后台运行着,然后通过await等待send_request调用运行结束,不过send_request是不会结束的,它会一直运行下去,并且每隔0.01秒就会发送一个数据到queue里面。同时在后台运行的recv_request就会从queue获取到数据,并且为了不阻塞自己的处理逻辑,会通过create_task创建一个请求处理者来处理这个请求。
这段程序可以一直运行着,但是调用者不知道后台运行的任务是否一直在正常的运行着,而且可能需要他们在运行出错时捕获到对应的错误,并把错误抛出来,于是需要对main函数进行一些改造:
async def main():
queue: asyncio.Queue = asyncio.Queue()
recv_coro = recv_request(queue)
send_coro = send_request(queue)
await asyncio.gather(recv_coro, send_coro)
这样就能捕获到发送消息的协程和接收消息的协程的异常,并把错误抛出来了,但是对于接收消息并分发给其它协程这段逻辑却无法通过asyncio.gather来管理,因为该逻辑是收到消息就会创建一个协程来处理的,它是实时创建的,而asyncio.gather只能管理已经创建的Corotinue。
如果有一个类,可以像timeout管理这个作用域的所有派生出来的协程,捕获派送协程的异常,那就很棒了。而在Python3.11或者是Anyio中可以通过TaskGroup解决这个问题,在使用TaskGroup后,recv_request代码改写为如下:
import asyncio
import random
async def request_handle(data):
# 处理请求
print(data)
await asyncio.sleep(1)
async def recv_request(queue: asyncio.Queue):
async with asyncio.task_group.TaskGroup() as tg:
while True:
# 接收请求
data = await queue.get()
# 分发给其它协程处理
tg.create_task(request_handle(data))
可以看到这个代码改动不大,首先是通过asyncio.task_group.TaskGroup创建一个对象并开启一个代码域,然后通过tg这个对象的create_task方法派生一个协程来处理数据,这个用法跟asyncio.create_task很像,但是通过tg.create_task创建的协程是会被tg管理的。
这时,如果request_handle对应的协程抛出来异常,tg对象也会退出并抛出对应的异常,同时这个代码域执行完毕后,也不会退出这片代码域,而是需要等所有通过tg.create_task创建的协程执行完毕后才会退出。
通过上面的timeout可以猜到TaskGroup也是在__aenter__时获取当前task对象并在后续使用着,现在通过taskgroups.py了解TaskGroup是如何执行的:
from asycnio import events
from asycnio import exceptions
from asycnio import tasks
class TaskGroup:
def __init__(self):
self._entered = False
self._exiting = False
self._aborting = False
self._loop = None
self._parent_task = None
self._parent_cancel_requested = False
self._tasks = set()
self._errors = []
self._base_error = None
self._on_completed_fut = None
async def __aenter__(self):
# 限制只能调用一次
if self._entered:
raise RuntimeError(
f"TaskGroup {self!r} has been already entered")
self._entered = True
if self._loop is None:
self._loop = events.get_running_loop()
# 获取当前的task
self._parent_task = tasks.current_task(self._loop)
if self._parent_task is None:
raise RuntimeError(
f'TaskGroup {self!r} cannot determine the parent task')
return self
async def __aexit__(self, et, exc, tb):
self._exiting = True
propagate_cancellation_error = None
if (exc is not None and
self._is_base_error(exc) and
self._base_error is None):
self._base_error = exc
if et is not None:
if et is exceptions.CancelledError:
if self._parent_cancel_requested and not self._parent_task.uncancel():
# Do nothing, i.e. swallow the error.
pass
else:
# 如果有一个协程已经取消了,就设置取消的exc
propagate_cancellation_error = exc
if not self._aborting:
# 取消所有的task
self._abort()
# 如果还有派生的协程来运行,就陷在这个逻辑中
while self._tasks:
if self._on_completed_fut is None:
self._on_completed_fut = self._loop.create_future()
try:
# 创建一个中间future来捕获所有派生协程的异常,并等待协程运行完毕
await self._on_completed_fut
except exceptions.CancelledError as ex:
# TaskGroup不会使_on_completed_fut抛出取消异常,但是如果main_task被取消时,会传播到_on_completed_fut
if not self._aborting:
# 与上面一样设置错误,并取消所有协程
propagate_cancellation_error = ex
self._abort()
self._on_completed_fut = None
assert not self._tasks
# 如果有异常,则抛出
if self._base_error is not None:
raise self._base_error
if propagate_cancellation_error is not None:
raise propagate_cancellation_error
if et is not None and et is not exceptions.CancelledError:
self._errors.append(exc)
# 抛出所有运行期间的异常
if self._errors:
errors = self._errors
self._errors = None
me = BaseExceptionGroup('unhandled errors in a TaskGroup', errors)
raise me from None
def create_task(self, coro, *, name=None, context=None):
# 判断目前是否生效,如果不生效就无法派生协程
if not self._entered:
raise RuntimeError(f"TaskGroup {self!r} has not been entered")
if self._exiting and not self._tasks:
raise RuntimeError(f"TaskGroup {self!r} is finished")
# 通过事件循环创建协程
if context is None:
task = self._loop.create_task(coro)
else:
task = self._loop.create_task(coro, context=context)
tasks._set_task_name(task, name)
# 添加task执行结果回调
task.add_done_callback(self._on_task_done)
# 把task添加到对应的self._task,这样其它方法就会判断协程是否运行完毕了
self._tasks.add(task)
return task
def _is_base_error(self, exc: BaseException) -> bool:
assert isinstance(exc, BaseException)
return isinstance(exc, (SystemExit, KeyboardInterrupt))
def _abort(self):
self._aborting = True
# 取消所有派生的协程
for t in self._tasks:
if not t.done():
t.cancel()
def _on_task_done(self, task):
# 安全的删除对应的task
self._tasks.discard(task)
if self._on_completed_fut is not None and not self._tasks:
if not self._on_completed_fut.done():
# 如果最后一个派生的协程运行结束,则设置中间future,这样TaskGroup.__aexit__的while循环就能继续执行了
self._on_completed_fut.set_result(True)
# 如果task已经取消或者没有异常,则不走下面的逻辑
if task.cancelled():
return
exc = task.exception()
if exc is None:
return
# 把异常添加到类中
self._errors.append(exc)
if self._is_base_error(exc) and self._base_error is None:
self._base_error = exc
# 最后处理下当前task
if self._parent_task.done():
# Not sure if this case is possible, but we want to handle
# it anyways.
self._loop.call_exception_handler({
'message': f'Task {task!r} has errored out but its parent '
f'task {self._parent_task} is already completed',
'exception': exc,
'task': task,
})
return
if not self._aborting and not self._parent_cancel_requested:
self._abort()
self._parent_cancel_requested = True
self._parent_task.cancel()
3.总结
可以看到,这两个功能都是通过task把我们的调用向子协程进行传播,这样一来就可以通过task方便的控制对应的协程,但是这也有一个缺点,就是一处函数为async,则处处函数都是async(对于需要IO调用的函数来说),而Go语言的协程就没有这种担忧,但是Go语言创建的协程是无法被管理的,除非创建协程的时候把Context对象传进去,并在对应的协程中通过channel来捕获Context对象的方法,这就要求开发Go库的开发者需要有良好的开发能力,能考虑到使用者在调用时是否需要考虑到超时,结构化并发等需求。