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