从根本上说,每个释放线程的异步函数最终都会编译为回调,通常由操作系统执行。
在现代术语中,这种风格通常被称为Promise,但它自古以来就是所有优秀操作系统的一部分。一般的方法是获取一个回调函数并注册它,然后开始某种操作。当操作完成时,回调被调用。
这一直到处理器级别,其中 IO 设备发出一条中断线信号,该中断线馈送到 OS 内核、内核模式驱动程序、用户模式驱动程序,最后是应用程序的某种等待句柄线程正在等待(例如窗口消息或异步 IO)。
让我们深入研究一个主要示例,看看它是如何完成的。我们将通过the main .NET Github repo 和Win32 docs on MSDN。 类似的原则适用于大多数现代操作系统。我假设我已经对基本 IO 操作和现代 PC 的基本组件有相当了解。
批量 IO 类如FileStream、Socket、PipeStream、SerialPort
这些使用非常相似的方法。让我们看看FileStream。
查看源代码,it utilizes 一个名为 AsyncWindowsFileStreamStrategy 的类,该类又使用名为 Overlapped IO 的 Win32 API。它最终通过一个回调函数传递给ThreadPoolBoundHandle.AllocateNativeOverlapped,并将生成的OVERLAPPED struct 传递给Win32 API,例如ReadFileEx。
我们没有 Win32 的源代码,但总的来说,这些函数将调用 Kernel32 和 ntdll API。这些依次进入内核模式,文件系统驱动程序传递给磁盘驱动程序。
大多数大容量 IO 硬件(如驱动器和网络适配器)使用的系统是 Direct Memory Access。驱动程序只会告诉硬件在 RAM 中放置数据的位置。硬件直接将数据加载到 RAM,完全绕过 CPU。
然后它向 CPU 发出一条中断线信号,CPU 停止正在执行的操作并将控制权转移到内核的中断处理程序。然后将控制权转移到驱动程序链上,返回到用户模式,最终应用程序中的回调准备就绪。
什么在应用程序中获取回调? ThreadPool 类(native version, which is here),它使用 IO Completion Port(用于将大量 IO 回调合并到一个手柄等待)。我们应用程序中的本机级别线程不断循环调用GetQueuedCompletionStatus,如果没有可用的内容,则阻塞。一旦它返回,相关的回调就会被触发,它会一直反馈到我们的 FileStream 并最终继续我们离开的函数,稍后会看到。
这可能在我们原来的原生线程上,也可能不在,这取决于我们如何设置SynchronizationContext。如果我们需要编组一个回调到 UI 线程,这可以通过窗口消息。
等待句柄,例如 ManualResetEvent、Semaphore 和 ReaderWriterLock,以及经典的窗口消息传递
这些完全阻塞调用线程,它们不能直接与async/await 一起使用,因为它们完全依赖于 Win32 线程模型。但是整个模型有点类似于Task:您可以等待一个事件或多个事件,并在需要时分派您的回调。其中有一些与async/await 兼容的单独版本。
等待事件本质上是对内核的调用,表示“请暂停我的线程,直到某某发生。”
本地操作系统线程被挂起时会发生什么?
本机操作系统线程在处理器内核上持续运行。 Win32 内核调度程序设置硬件处理器计时器以中断线程并让步给可能需要运行的其他线程。在任何时候,如果本机线程被 Win32 调度程序挂起(无论是在询问时还是因为调度程序产生),它都会从可运行线程队列中删除。一旦线程准备好再次运行,它就会被放入可运行队列中,并在调度程序有机会时运行。
如果没有更多线程可以运行,处理器将进入低功耗HALT,并在下一个中断信号时被唤醒。
Task 和 async/await
这是一个非常大的话题,我主要会留给其他人。但是回到我最初的前提,即释放线程会触发操作系统级别的回调:Task 是如何做到这一点的?
首先,我们已经犯了一个错误。线程和任务是不同的东西。 线程只能由内核挂起,任务只是我们想要完成的一个工作单元,我们可以根据需要拾取和丢弃。
当await 在最深层次(我们想要暂停执行的点)被击中时,任何回调都会像我们上面提到的那样注册。调用时,回调函数会将Task 的延续代码排队到调度程序执行。 Taskutilizes the existing scheduler 由 CLR 设置,用于根据需要拾取和删除任务和延续。
最后,TaskScheduler 是实现如何调度Tasks 的逻辑的类:它们应该通过ThreadPool 执行吗?是否应该将它们编组回 UI 线程,或者甚至只是在循环中内联执行?