关于协程的终极思考(详细直观总结)

506 阅读9分钟

想要理解协程并不容易。如果你被大量的文章冲晕了头脑又没有对它上手过,或者是只是无脑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.
  1. 检查ready、stop队列。
  2. 检查scheduled队列。
  3. 进行一次网络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

协程的局限

协程不能:

  1. 进行代码级别的调度。只有你使用gather函数显示声明了某些异步函数是要并发执行的时候,eventloop才会并发地调度它们,在某个异步函数内部,一旦向下走的调用栈上出现了一个await,整个异步函数作为整体Task都会被挂起。
  2. 解决线程同步问题。协程本身是单线程的,单单看协程也根本不存在线程同步问题。

协程+多线程/多进程

在这个过程中,比如我要进行大文件读写,或者进行计算密集操作,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得到,让他可以修改其状态,恢复主线程上协程运行。