TL;DR:实现异步回调基本上是允许控制流继续进行而不阻塞回调。在回调函数最终被调用之前,控制流可以自由地执行任何不依赖于回调结果的事情,例如,调用者可以像回调函数返回一样继续进行,或者调用者可以将其控制权交给其他函数。
由于问题是针对通用实现而不是特定语言,因此我的回答尽量笼统,以涵盖实现共性。
不同的语言对异步回调有不同的实现,但是原理是一样的。关键是将控制流与执行的代码分离。它们对应于执行上下文(如具有运行时堆栈的控制线程)和执行的任务。传统上,执行上下文和执行的任务通常是 1:1 关联的。使用异步回调,它们是解耦的。
一、原则
要将控制流与代码分离,将每个异步回调视为条件任务会很有帮助。当代码注册一个异步回调时,它实际上在系统中安装了任务的条件。然后当条件满足时调用回调函数。为了支持这一点,需要一个状态监控机制和一个任务调度器,这样,
程序员不需要跟踪回调的条件;
在条件满足之前,程序可以继续执行其他不依赖于回调结果的代码,而不阻塞条件;
一旦满足条件,回调就保证执行。程序员不需要安排它的执行;
回调执行后,调用者可以访问其结果。
2. 可移植性的实现
例如,如果您的代码需要处理来自网络连接的数据,则无需编写检查连接状态的代码。您只需注册一个回调,一旦数据可用于处理,该回调就会被调用。连接检查的繁琐工作留给了语言实现,众所周知,这很棘手,尤其是当我们谈论可伸缩性和可移植性时。
语言实现可以使用异步 io、非阻塞 io 或线程池或任何技术为您检查网络状态,一旦数据准备好,回调函数就会被安排执行。这里代码的控制流看起来像是直接从回调注册到回调执行,因为语言隐藏了中间步骤。这就是便携性的故事。
3. 可扩展性的实现
隐藏肮脏的工作只是整个故事的一部分。另一部分是,您的代码本身不需要阻塞等待任务条件。当您同时拥有大量网络连接并且其中一些可能已经准备好数据时,等待一个连接的数据是没有意义的。您的代码的控制流可以简单地注册回调,然后继续执行其他任务(例如,满足条件的回调),知道注册的回调将在其数据可用时无论如何都会执行。
如果满足回调的条件不涉及太多的CPU(例如,等待定时器,或等待网络数据),并且回调函数本身是轻量级的,那么单CPU(或单线程)可以同时处理大量回调,例如处理传入的网络请求。在这里,控制流可能看起来像是从一个回调跳转到另一个。这就是可扩展性的故事。
4. 并行的实现
有时,回调不是针对非阻塞 IO 条件而挂起的,而是针对诸如页面错误之类的阻塞操作;或者回调不依赖于任何条件,而是纯粹的计算逻辑。在这种情况下,异步回调不会为您节省 CPU 等待时间(因为没有空闲等待)。但是由于异步回调意味着回调函数可以与调用者或其他回调并行执行(受一定的数据共享和同步约束),语言实现可以将回调任务分派到不同的线程,实现并行的好处,如果该平台有多个硬件线程上下文。它仍然提高了可扩展性。
5. 生产力的实施
当代码需要处理链式回调时,即当回调以递归方式注册其他回调时,异步回调的生产力可能不是很积极,称为回调地狱。有办法挽救。
可以探索异步回调的语义,以便用其他语言结构代替绝望的嵌套回调。基本上可以有两种不同的回调视图:
从数据流来看:异步回调=事件+任务。注册回调实质上会生成一个事件,该事件将在满足任务条件时发出。在此视图中,链式回调只是其处理触发其他事件发射的事件。它可以自然地在事件驱动
编程中实现,其中任务执行由事件驱动。Promise 和 Observable 也可以看作是事件驱动的概念。当多个事件同时就绪时,它们关联的任务也可以同时执行。
从控制流的角度来看:注册回调将控制权交给其他代码,一旦满足其条件,回调执行就会恢复控制流。在这个视图中,链式异步回调只是可恢复的函数。多个回调可以以传统的“同步”方式一个接一个地编写,中间有yield操作(或等待)。它实际上变成了 coroutine。
我还没有讨论异步回调和它的调用者之间数据传递的实现,但是如果使用调用者和回调可以共享数据的共享内存,这通常并不困难。实际上 Golang 的 channel 也可以考虑在 yield/await 的方向上,但它侧重于数据传递。