CLR via C# —— 线程

线程的作用

        早期的操作系统没有 "线程" 的概念, 例如16位的 Windows 就是一个不支持多线程的操作系统. 这样的系统有一个特征: 整个系统同时只运行着一个任务, 包含操作系统代码还有应用程序的代码. 这带来了一个问题: 如果当前运行的这个任务需要很长一段时间才能执行完成, 它就会阻止其它任务执行. 如果某个程序含有 bug, 程序进入了一个死循环, 用户只好重新启动电脑了.

        这样的操作系统显然很不给力, 微软决定改进操作系统内核, 让它的健壮性, 可靠性, 扩展性以及安全性都要得到提高. 微软从1988年11月开始编写 Windows NT, 微软在设计这个系统内核的时候, 决定在一个进程中运行应用程序的每个实例, 进程则是应用程序的一个实例要使用的资源的集合. 每一个进程被赋予了一个虚拟地址空间, 确保一个进程无法访问另一个进程的代码和数据. 因为一个进程无法破坏另一个进程的代码和数据, 所以系统的健壮性提高了; 程序无法访问另一个应用程序的用户名, 密码等信息, 因此安全性提高了.

        虽然数据无法被破坏, 而且更安全, 但如果一个应用程序进入无限循环, 机器只有1个 CPU 的话, CPU 就会执行无限循环, 系统仍然会停止响应. 微软修正这个问题的办法就是 "线程". 线程的职责是对 CPU 进行虚拟化, Windows 为每个进程都提供了该进程专用的线程(可以理解为系统给每一个进程都分配了一个 CPU, 只不过是虚拟的), 当这个程序进入了一个无限循环, 并不会影响到其它程序的运行. 1993年7月推出的 Windows NT 3.1 是第一个支持多线程的 Windows 操作系统.

线程的开销

        线程无疑是个好东西, 它让操作系统即使在运行需要很长时间才能执行完的任务时也能随时响应, 线程还允许用户使用任务管理器强制终止似乎已经冻结的应用程序. 但好东西总是需要付出一定代价的!

线程的组成

        线程的每个部分都有一定的功能, 它们负责线程的创建, 进驻操作系统以及最后销毁. 它们都需要时间和空间.

        1. 线程内核对象(thread kernel object)  每个线程都有这样的一个数据结构, 它包含一组对线程进行描述的属性, 还包含了线程上下文(thread context). 上下文是一个内存块, 其中包含了 CPU 寄存器的集合. x86 的机器, 线程上下文大约有700字节, x64IA64, 上下文大小分别约1240字节和2500字节.

        2. 线程环境块(thread environment block, TEB)  TEB 是在用户模式(应用程序代码能快速访问的地址空间)中分配和初始化的一个内存块. TEB 需要1个内存页(x86 和 x64 CPU 中是4KB, IA64 CPU 中是8KB). TEB 包含线程的异常处理链首(head), 线程进入每个 try 块都在链首插入一个节点, 退出 try 时, 会删除改节点. 除此之外, TEB 还包含进程的 "线程本地存储" 数据, 以及由 GDI 和 OpenGL 图形使用的一些数据结构.

        3. 用户模式栈(user-mode stack)  用户模式栈用于存储传给方法的局部变量是实参. 它还包含一个地址, 指出当方法返回时, 线程接着应该从什么地方开始执行. 默认情况下, Windows 为每个线程的用户模式栈分配 1MB 的内存.

      4. 内核模式栈(kernel-mode stack)   应用程序的代码经常需要调用操作系统的内核模式的函数, 出于安全方面的考虑, OS会把调用的参数从 user mode stack 拷贝到 kernel mode stack, 拷贝完了后 OS 会对这些参数进行检验. 除此之外, 内核模式里的方法也要互相调用, 它们就靠这个栈保存局部变量, 方法参数和返回地址. 在32位系统上这个栈占12KB,64位系统上占24KB.

        5. DLL线程连接和线程分离通知(DLL thread-attach and thread-detach notifications)  当进程中创建了一个新线程, 都会调用该进程里加载的所有 DLL 的 DllMain 方法, 并向方法传递一个 DLL_THREAD_ATTACH 标记. 当有一个线程终止时, 也会调用该进程里加载的所有 DLL 的 DllMain 方法, 并向方法传递一个 DLL_THREAD_DETACH 标记. 不过对于C#以及其他托管语言编写的 DLL, 因为没有DllMain方法, 所以不会收到这通知, 这提升了性能. 对于非托管 DLL, 可以调用 Win32 DisableThreadLibraryCalls 来决定不理会这些通知.

线程的切换

        线程将 CPU 进行虚拟化后变成一个个的逻辑 CPU, 而物理 CPU 只有1个(多核CPU则有多个CPU), 一个 CPU 同时只能做一件事. 在任何时候, Windows 只将一个线程分配给一个 CPU, 那个线程允许运行一个"时间片", 一旦时间片到期, Windows 就上下文切换到另一个线程,  每次上下文切换都执行以下操作:

        1. 将 CPU 寄存器中的值保存在当前正在运行的线程内核对象的一个上下文结构中.

        2. 调度下一个线程运行. 如果这个线程属于另一个的进程, Windows 还要先切换虚拟地址空间.

        3. 将所选线程的上下文结构中的值加载到 CPU 的寄存器中.

合理使用线程

        通常你使用线程可能为了将代码和其它代码隔离来提高程序的可靠性, 或者使用线程来简化编码或者使用线程来实现并发执行. 上面可以看出, 创建一个线程需要不少资源的, 创建一个线程就应该让它做事, 而不是闲着.

        打开任务管理器, 可以发现一些程序创建了很多线程, 但这个应用程序大部分占用的 CPU 却为0, 也就是说它不在做事情! 在编写应用程序的时候, 可以考虑下创建这个线程是否有必要, 是否可以通过其它比较经济方式来代替使用线程.

        实际上我不是很清楚为什么 Jeffrey Richter 在第698页说"Well, without a doubt, we can say for sure that all of these applications we've just discussed are using threads inefficiently.", 一个应用程序创建了很多线程, 就因为它们现在没有在工作, 就认为它们是效率低下的? 如果那些线程是为了你在操作这个应用程序时获得较高的响应, 我觉得这些暂时没有被使用的线程是有存在的价值的. 如果你是从另外一个角度理解"效率低下", 觉得它大部分时间是不工作的, 你删除它们, 如果你的应用程序响应变得不灵敏, 那又如何说呢?

线程调度和优先级

        操作系统需要决定在什么时间调度哪个线程, 并执行多长时间, 上下文结构反映了当前线程上一次执行时, 线程的 CPU 寄存器状态, 在一个时间片之后, Windows 会检查所有线程内核对象, 在这些对象中, 只有那些没有正在等待什么的线程才适合调度, 可以使用 Spy++ 查看每个线程被上下文切换到的次数.

        优先级用 0-31 表示, 0表示优先级最低. 系统首先检查优先级为31的线程, 并使用轮流(round-robin)的方式调度他们. 只要存在可以调度的优先级为31的线程, 系统永远不会将优先级为0-30的线程交给 CPU 来执行, 这种情况称为饥饿(starvation). 实际上在编写程序时, 我们不知道该为我刚刚创建的这个线程指定什么优先级, 为什么是20而不是21? 所以 Windows 公开了优先级系统的一个抽象层, 分别是 Lowest, Below Normal, Normal, Above Normal, Highest, 代码中可以设置 Thread 的 Priority 属性指定这个线程的优先级别.

前台线程和后台线程

        这个概念不难理解. 比如有个应用程序创建了一个新的线程, 线程只可能是前台线程和后台线程这两种, 如果创建的是前台线程, 只有当所有前台线程都执行完毕时程序才执行结束, 如果创建的是后台线程, 如果前台线程都结束了, 不管后台线程有没有结束, 系统都将结束后台线程的运行, 且不会抛出什么异常.

        可以通过设置线程实例的 IsBackground 属性来决定这个线程是前台线程还是后台线程.

本文链接: http://www.cnblogs.com/technology/archive/2011/05/22/2053567.html

参考: Jeffrey Richter <CLR via C#>第三版第25章

作者:Create Chen
出处:http://technology.cnblogs.com
说明:文章为作者平时里的思考和练习,可能有不当之处,请博客园的园友们多提宝贵意见。
知识共享许可协议本作品采用知识共享署名-非商业性使用-相同方式共享 2.5 中国大陆许可协议进行许可。

原文地址:https://www.cnblogs.com/technology/p/2053567.html