CLR 目前的多线程技术依然是 Windows 操作系统所提供的,不过 .NET CLR 开发小组似乎保留了将其分离的权利。在某些环境,CLR
线程并不会直接映射到一个 Windows 线程上,他们可能会用 Windows fiber 来代替,以期获得更好的执行性能。未来的 CLR
版本甚至会直接用某个已存在的空闲线程来代替 "new Thread()" 执行其他任务。CLR 线程使用了 Windows
线程开发的很多技巧,这样我们可以使用简单的代码来处理原本需要花费很多精力才能完成的工作。这种包装分离带来的另外一个好处就是,我们无需改动我们的代
码就能获得 CLR 和操作系统升级带来的性能提升。
除了直接使用 CLR 线程,我们还可以使用 Thread.BeginThreadAffinity() 等手段直接使用操作系统级别的线程,只不过要记得调用相关方法去 "End"。
早
期的 DOS 和 Windows 16-bit
都是单线程操作系统,这种操作系统上的某个进程一旦陷入死循环,整个操作系统都完蛋,你能做的只有重启计算机。Windows NT 3.1
是微软开发的第一个支持多线程功能的操作系统,这从某种程度上可以说是 Windows 成为 "健壮性"
操作系统的标志。在支持多线程的操作系统里,每个进程都拥有自己的线程,也就是说理论上不会陷入上述那样的尴尬状况了。死循环的线程被冻结,而其他线程依
旧能正常运转,用户也就有机会强行结束那个死掉的家伙。
严格来说,线程是一笔昂贵的开支。创建线程并不简单,首先得分配并初始化一个线程
内核对象(thead kernel object),并为这个线程保留 1MB user-mode stack 和 12KB 以上的
kernel-mode stack。在完成这些之后,线程才被创建。Windows 会发送消息通知目标进程以及其所有 DLL
线程可用。而销毁线程同样需要需要发送消息通知,最后还得释放所有的保留空间。
在单 CPU
计算机上,任何时候都只有一个线程在执行。Windows 保持线程对象状态,并决定接下来哪个线程会被执行。每个线程每次大概可以获得 20 毫秒的
CPU 执行时间片,然后切换执行另外一个线程。这个过程有个专业术语叫 "线程上下文切换(context
switch)"。操作系统需要花费相当代价才能走完一次切换:
(1) 进入内核模式。
(2) 将 CPU 寄存器信息保存到当前正在执行的线程内核对象。
(3) 获取一个 Spinlock,按计划决定下一个要执行的线程,然后释放 Spinlock。如果下一个线程属于其他的进程,那么我们还得为虚拟地址交换付出更多代价。
(4) 从要被执行的线程内核对象载入 CPU 寄存器信息。
(5) 离开内核模式。
所
有这些操作可能导致操作系统和应用程序比单线程操作系统执行得更慢,但这些都是值得的,芯片生产商带来的超线程(hyper-threading)和多核
(mulit-core) CPU 为多线程提供了真正的舞台,每个内核上都可以真正并发执行一个线程。超线程 CPU
包含两个逻辑内核,每个逻辑内核都拥有自己的寄存器,只是它们需要共享 CPU 缓存等资源。当一个逻辑 CPU
因某种原因被暂停,芯片会切换到另外一个逻辑 CPU 继续执行任务,超线程芯片能带来 10% - 30% 左右的性能提升。而像 Pentium
D、Athlon 64 X2 这类真正的多核 CPU 芯片,它们集成了多个真正意义上的物理内核,每个内核都有自己的完整的寄存器和缓存,这才是
100% 的性能提升。现在某些服务器用的芯片会同时使用多核和超线程技术,因此你可能在任务管理器中看到 4 个或 8 个 CPU
显示。芯片发展已经从单纯的主频提升转移到多核集成上来,不久我们就可以使用 4 核、8 核,甚至是更多更强大的多核处理器。
创建和销
毁线程代价不菲,过多的线程会消耗掉大量的内存和 CPU 资源。为了改善这种状况,CLR 提供了一种称之为 "线程池(thread pool)"
的技术。直观来说,线程池就是为应用程序提供的一堆可用线程集合,线程池在进程所有应用程序域(AppDomain)间共享。
在
CLR
初始化之初,线程池内是没有任何线程的,其内部有一个专门存储请求的队列。当应用程序试图执行异步等操作时,这些方法调用会被包装并加入到线程池队列中。
线程池从队列提取任务请求,并为其分配可用线程。如果线程池内没有可用线程,那么一个新的线程会被创建。线程完成任务执行后,并不会被摧毁。相反,它被放
回到线程池中,然后等待被分配给其他任务。线程池会尝试用同一个线程来处理应用程序的多个任务请求。而一旦应用程序在极短时间内发出多个请求,那么它会尝
试创建额外的线程来分配队列中的任务,这有可能导致池内线程数量急剧增加,同时耗费大量的系统资源。当请求完成,池内多余的线程会在空闲 2
分钟后被释放并回收相关资源,直到某个最小线程阀值设置。
当任务请求超出最小阀值设置,线程池并不会立即创建新线程,而是等待大约
500
毫秒左右。这么做的目的是看看在这段时间内是否有其他工作线程完成任务来接手这个请求,这样就可以避免创建新线程的消耗。最小线程阀值设置
(ThreadPool.SetMinThreads)最好不要小于 CPU 内核数量,否则会导致性能问题。
在线程池内部,它包含两种
类型的线程,分别是 worker thread 和 I/O threads。工作线程用来处理 compute-bound 异步操作(包括初始化
I/O-bound 操作),而 I/O 线程则用于异步执行诸如文件访问、网络通讯、数据库操作、WebService
调用以及某些硬件设备控制等。CLR 允许开发人员设置线程池的最大线程数量,并确保池内线程数量不会超出这个设置。CLR 2.0
ThreadPool 默认为每个 CPU 处理器提供 25 个工作线程以及 1000 个 I/O
线程,通常情况下这已经足够了,并不需要我们做出特别的处理。