想要理解协程并不容易。如果你被大量的文章冲晕了头脑又没有对它上手过,或者是只是无脑async/await地使用,你很难理解其优越性在哪里,也不明白它下层到底是怎样工作的。
本文假设读者已经明白什么是阻塞/非阻塞,什么是同步和异步以及进程、线程等基本概念。
协程是拿来干啥的
协程机制是为了解决开发者线性的处理逻辑(先干这个,再干那个)和现实中不得不阻塞等待的执行(IO要等,计算要等,但是用户输入等不起)之间的矛盾而诞生的。
硬件CPU线程资源是有限的,哪个程序能用,哪个程序不能用,哪个程序要让出来给别人,都需要OS调度;这个调度是进程(程序)级别的,或者是线程级别的。但是这种调度它不理解我们程序到底在干什么。
问题来了:正如上面所说的,程序处理逻辑是线性的,执行却不是线性的。我们希望找到一种方式,用线性的代码做非线性的事情。
协程机制的本质是一种用户态、单线程、“Task”级别的调度。它消灭了“原生代码”——我们一行行跑的函数可以说已经不存在了;我们的执行逻辑还在,而且看起来非常像是阻塞的同步代码,但内里其实已经变成异步。
其核心是迭代器,如果你不熟悉迭代器是什么,不妨从Python入手。总的来说,你yield from 它一次,它就会从内部yield返回一个结果;下次再yield from时,它就会从上次返回时的代码行继续,并再执行到yield或者return然后给外层结果。
所有代码都变成了迭代器,可以在某处“暂停挂起”并返回;在这些“新原生代码”的最外面,套上一个调度它们的eventloop;在这些“新原生代码”的最底端,把返回结果从即时的返回值,替换成一种在暂停前是一个没有实质内容、暂停结束恢复执行后就具有了结果内容的“future”对象。
此处的“暂停挂起”的能力一方面是由迭代器赋予的,另一方面是由future/promise这类对象赋予的,函数的返回就是一次暂停,但这种返回并不是全无意义的,因为暂停意味着程序在等待未来某个结果的就绪,它恢复执行时你要把结果还给他,怎么还呢?通过它返回的future。
有些人觉得,我写多了JavaScript,写多了async/await就能轻易理解协程了。但是这样你只看见一个个 async await 把看似同步的代码变成了异步的,它下面到底发生了什么其实非常复杂。
await这个调用栈兔子洞的最下面到底是什么?async函数为什么要用loop.run之类的函数启动?为什么说异步函数变成了状态机?我们不妨先看看协程机制的目标是什么。
协程机制的终极目标
这个根本目标是:eventloop作为一个最外层程序,驱动所有task的异步流程,它想要做到:eventloop本身要基本做到永远不要被阻塞,它只管不断跑一个回调函数就绪队列里的函数,直到所有Task都终止。所有对这个loop干正事(执行已就绪函数)产生哪怕1ms阻塞的事情,我选择不等待,而是外包出去,给executor让他去管理子进程、子线程;对于网络IO,eventloop则只需要在必要时刻非阻塞式轮询一下,等于丢给kernel、操作系统。
当然,如果跑到最后没有已就绪函数,所有task都要等“外包”返回结果才能继续,整个系统就遇到了固有的阻塞,而这已经是并发优化这一层所能做的极限了。
协程机制设计
在听我的解释以前,尽量忘掉那些其他的解释吧,什么“可暂停可恢复”、“挂起”、“执行后面的然后返回future”……
迭代器:内部含有yield的函数,在外侧使用yield from调用他,会执行一次到yield语句处并保存现场暂停,外侧函数则会得到此次yield出来的东西;外部再次调用它,则会从此行下面继续执行。
协程/Task/Future:在代码设计上它们基本上是一个东西:都是迭代器,执行一次,其逻辑就会向下走一步;执行有限步后会得到返回结果。只不过经过不同的包装用以完成不同的功能。
async的作用:将函数包装成异步协程函数,执行它一次之后会返回一个协程/future,而不是最终返回结果。
await的作用:await就是yield from,future类实现了__await__函数。它后面必须要跟一个future或者会返回future的函数,实现非常简单,如果后方future未完成,则返回future本身。这就是为什么说遇到await,协程就会挂起暂停,因为这次执行它直接返回了future;如果完成了,返回future.result,这是coroutine能恢复执行的秘诀。第一次执行(立即,非阻塞,如果被调函数是设计良好的)得到一个future并返回上层,这就是coroutine 不阻塞的秘诀。
eventloop 在协程机制中发挥的作用是什么?我们通过研究一下run_once函数就能明白。
def _run_once(self):
"""Run one full iteration of the event loop.
This calls all currently ready callbacks, polls for I/O,
schedules the resulting callbacks, and finally schedules
'call_later' callbacks.
"""
sched_count = len(self._scheduled)
if (sched_count > _MIN_SCHEDULED_TIMER_HANDLES and
self._timer_cancelled_count / sched_count >
_MIN_CANCELLED_TIMER_HANDLES_FRACTION):
# Remove delayed calls that were cancelled if their number
# is too high
new_scheduled = []
for handle in self._scheduled:
if handle._cancelled:
handle._scheduled = False
else:
new_scheduled.append(handle)
heapq.heapify(new_scheduled)
self._scheduled = new_scheduled
self._timer_cancelled_count = 0
else:
# Remove delayed calls that were cancelled from head of queue.
while self._scheduled and self._scheduled[0]._cancelled:
self._timer_cancelled_count -= 1
handle = heapq.heappop(self._scheduled)
handle._scheduled = False
timeout = None
if self._ready or self._stopping:
timeout = 0
elif self._scheduled:
# Compute the desired timeout.
when = self._scheduled[0]._when
timeout = min(max(0, when - self.time()), MAXIMUM_SELECT_TIMEOUT)
//见下方注
event_list = self._selector.select(timeout)
self._process_events(event_list)
# Handle 'later' callbacks that are ready.
end_time = self.time() + self._clock_resolution
while self._scheduled:
handle = self._scheduled[0]
if handle._when >= end_time:
break
handle = heapq.heappop(self._scheduled)
handle._scheduled = False
self._ready.append(handle)
# This is the only place where callbacks are actually *called*.
# All other places just add them to ready.
# Note: We run all currently scheduled callbacks, but not any
# callbacks scheduled by callbacks run this time around --
# they will be run the next time (after another I/O poll).
# Use an idiom that is thread-safe without using locks.
ntodo = len(self._ready)
for i in range(ntodo):
handle = self._ready.popleft()
if handle._cancelled:
continue
if self._debug:
try:
self._current_handle = handle
t0 = self.time()
handle._run()
dt = self.time() - t0
if dt >= self.slow_callback_duration:
logger.warning('Executing %s took %.3f seconds',
_format_handle(handle), dt)
finally:
self._current_handle = None
else:
handle._run()
handle = None # Needed to break cycles when an exception occurs.
- 检查ready、stop队列。
- 检查scheduled队列。
- 进行一次网络select(轮询进行一次),然后对应将其完成回调函数放入就绪队列。
注:之所以它敢在这里设置一个阻塞的timeout,是因为eventloop自己是完全顺序、同步的。如果在某一步,ready、stopping队列都是空的,即使我不阻塞继续往下跑,那么在从现在到第一个计时器任务触发之间的这段时间里,如果网络请求数据就是不到位,那么ready、stop两个列表根本不会有变化,所有task还都在pending状态,我还是要回去不阻塞继续select,这样产生了更多SYSCALL进入内核空间的开销。还不如我就让他暂且阻塞,而且一旦有数据就绪,它立刻就返回了,实际上还是谁都没有阻塞。
eventloop 的主要任务就是执行这些地方收集来的就绪的回调函数。
eventloop还会从这些Task的返回值得到future对象,这说明这些Task被挂起了,eventloop就给它们设置一个当future完成时恢复Task执行的回调 future.add_done_callback。
谁将这些回调函数设置成就绪的?其他回调函数,或者其他线程的程序!
future.set_result 函数里面会调用loop的函数,把完成回调加入就绪队列。
def set_result(self, result):
"""Mark the future done and set its result.
If the future is already done when this method is called, raises
InvalidStateError.
"""
if self._state != _PENDING:
raise exceptions.InvalidStateError(f'{self._state}: {self!r}')
self._result = result
self._state = _FINISHED
self.__schedule_callbacks()//让eventloop去调度执行future上挂的callback
协程的局限
协程不能:
- 进行代码级别的调度。只有你使用gather函数显示声明了某些异步函数是要并发执行的时候,eventloop才会并发地调度它们,在某个异步函数内部,一旦向下走的调用栈上出现了一个await,整个异步函数作为整体Task都会被挂起。
- 解决线程同步问题。协程本身是单线程的,单单看协程也根本不存在线程同步问题。
协程+多线程/多进程
在这个过程中,比如我要进行大文件读写,或者进行计算密集操作,eventloop就无法独自承担这些任务了,必须外包给多线程或者多进程,然后异步完成这些任务。可是我们在run_once函数中并未看到针对此处的处理。
看一下run_in_executor函数:
def run_in_executor(self, executor, func, *args):
self._check_closed()
if self._debug:
self._check_callback(func, 'run_in_executor')
if executor is None:
executor = self._default_executor
# Only check when the default executor is being used
self._check_default_executor()
if executor is None:
executor = concurrent.futures.ThreadPoolExecutor(
thread_name_prefix='asyncio'
)
self._default_executor = executor
return futures.wrap_future(
executor.submit(func, *args), loop=self)
executor.commit() 函数,对某个executor(基于线程或进程)提交任务。
最终看到ExecutorManagerThread在进行自身循环的时候会调用wait_result_broken_or_wakeup,通过connection对象获取Worker返回的结果:
def run(self):
# Main loop for the executor manager thread.
while True:
self.add_call_item_to_queue()
result_item, is_broken, cause = self.wait_result_broken_or_wakeup()
if is_broken:
self.terminate_broken(cause)
return
if result_item is not None:
self.process_result_item(result_item)
# Delete reference to result_item to avoid keeping references
# while waiting on new results.
del result_item
# attempt to increment idle process count
executor = self.executor_reference()
if executor is not None:
executor._idle_worker_semaphore.release()
del executor
if self.is_shutting_down():
self.flag_executor_shutting_down()
# Since no new work items can be added, it is safe to shutdown
# this thread if there are no pending work items.
if not self.pending_work_items:
self.join_executor_internals()
return
def wait_result_broken_or_wakeup(self):
# Wait for a result to be ready in the result_queue while checking
# that all worker processes are still running, or for a wake up
# signal send. The wake up signals come either from new tasks being
# submitted, from the executor being shutdown/gc-ed, or from the
# shutdown of the python interpreter.
result_reader = self.result_queue._reader
assert not self.thread_wakeup._closed
wakeup_reader = self.thread_wakeup._reader
readers = [result_reader, wakeup_reader]
worker_sentinels = [p.sentinel for p in self.processes.values()]
ready = mp.connection.wait(readers + worker_sentinels)
cause = None
is_broken = True
result_item = None
if result_reader in ready:
try:
result_item = result_reader.recv()
is_broken = False
except BaseException as e:
cause = traceback.format_exception(type(e), e, e.__traceback__)
elif wakeup_reader in ready:
is_broken = False
with self.shutdown_lock:
self.thread_wakeup.clear()
return result_item, is_broken, cause
def process_result_item(self, result_item):
# Process the received a result_item. This can be either the PID of a
# worker that exited gracefully or a _ResultItem
if isinstance(result_item, int):
# Clean shutdown of a worker using its PID
# (avoids marking the executor broken)
assert self.is_shutting_down()
p = self.processes.pop(result_item)
p.join()
if not self.processes:
self.join_executor_internals()
return
else:
# Received a _ResultItem so mark the future as completed.
work_item = self.pending_work_items.pop(result_item.work_id, None)
# work_item can be None if another process terminated (see above)
if work_item is not None:
if result_item.exception:
work_item.future.set_exception(result_item.exception)
else:
work_item.future.set_result(result_item.result)
然后对WorkItem中绑定的future做set_result操作,这也就调用了future上的对应callback,把恢复对应协程的函数加入就绪队列。因为ExecutorManagerThread和eventloop线程都是处于同一个进程的(所谓Local)所以可以跑过去修改这个future。
而这之中完全都是python的threading库和multiprocessing库,所有发生在eventloop的注册过程都是非阻塞的,这其中的关键就是async函数中新建的future被executor得到,让他可以修改其状态,恢复主线程上协程运行。