本节导航
基本概念
并发编程
TPL
线程基础
Windows 为什么要支持线程
线程开销
CPU 的发展
使用线程的理由
如何写一个简单 Parallel.For 循环
数据并行
Parallel.For 剖析
优秀软件的一个关键特征就是具有并发性. 过去的几十年, 我们可以进行并发编程, 但是
难度很大. 以前, 并发性软件的编写, 调试和维护都很难, 这导致很多开发人员为图省事
放弃了并发编程. 新版 .NET 中的程序库和语言特征, 已经让并发编程变得简单多了. 随
着 Visual Studio 2012 的发布, 微软明显降低了并发编程的门槛. 以前只有专家才能做并发
编程, 而今天, 每一个开发人员都能够 (而且应该) 接受并发编程.
许多个人电脑和工作站都有多核 CPU, 可以同时执行多个线程. 为了充分利用硬件, 您可以将代码并行化, 以便跨多个处理器分发工作.
在过去, 并行需要对线程和锁进行低级操作. Visual Studio 和. NET 框架通过提供运行时, 类库类型和诊断工具来增强对并行编程的支持. 这些特性是在. NET Framework 4 中引入的, 它们使得并行编程变得简单. 您可以用自然的习惯用法编写高效, 细粒度和可伸缩的并行代码, 而无需直接处理线程或线程池.
下图展示了. NET 框架中并行编程体系结构.
1 基本概念
1.1 并发编程
并发
同时做多件事情
这个解释直接表明了并发的作用. 终端用户程序利用并发功能, 在输入数据库的同时响应用户输入. 服务器应用利用并发, 在处理第一个请求的同时响应第二个请求. 只要你希望程序同时做多件事情, 你就需要并发.
多线程
并发的一种形式, 它采用多个线程来执行程序. 从字面上看, 多线程就是使用多个线程. 多线程是并发的一种形式, 但不是唯一的形式.
并行处理
把正在执行的大量的任务分割成小块, 分配给多个同时运行的线程.
为了让处理器的利用效率最大化, 并行处理 (或并行编程) 采用多线程. 当现代多核 CPU 行大量任务时, 若只用一个核执行所有任务, 而其他核保持空闲, 这显然是不合理的.
并行处理把任务分割成小块并分配给多个线程, 让它们在不同的核上独立运行. 并行处理是多线程的一种, 而多线程是并发的一种.
异步编程
并发的一种形式, 它采用 future 模式或回调 (callback) 机制, 以避免产生不必要的线程.
一个 future(或 promise)类型代表一些即将完成的操作. 在 .NET 中, 新版 future 类型
有 Task 和 Task. 在老式异步编程 API 中, 采用回调或事件(event), 而不是
future. 异步编程的核心理念是异步操作: 启动了的操作将会在一段时间后完成. 这个操作
正在执行时, 不会阻塞原来的线程. 启动了这个操作的线程, 可以继续执行其他任务. 当
操作完成时, 会通知它的 future, 或者调用回调函数, 以便让程序知道操作已经结束.
NOTE: 通常情况下, 一个并发程序要使用多种技术. 大多数程序至少使用了多线程 (通过线程池) 和异步编程. 要大胆地把各种并发编程形式进行混合和匹配, 在程序的各个部分使用
合适的工具.
1.2 TPL
任务并行库 (TPL) 是 System.Threading 和 System.Threading.Tasks 命名空间中的一组公共类型和 API.
TPL 动态地扩展并发度, 以最有效地使用所有可用的处理器. 通过使用 TPL, 您可以最大限度地提高代码的性能, 同时专注于您的代码的业务实现.
从. NET Framework 4 开始, TPL 是编写多线程和并行代码的首选方式.
2 线程基础
2.1 Windows 为什么要支持线程
在计算机的早期岁月, 操作系统没提供线程的概念. 事实上, 整个系统只运行着一个执行线程(单线程), 其中同时包含操作系统代码和应用程序代码. 只用一个执行线程的问题在于, 长时间运行的任务会阻止其他任务执行.
例如, 在 16 位 Windows 的那些日子里, 打印一个文档的应用程序很容易 "冻结" 整个机器, 造成 OS 和其他应用程序停止响应. 有的程序含有 bug, 会造成死循环. 遇到这个问题, 用户只好重启计算机. 用户对此深恶痛绝.
于是微软下定决心设计一个新的 OS, 这个 OS 必须健壮, 可靠, 易于是伸缩以安全, 同同时必须改进 16 位 Windows 的许多不足.
微软设计这个 OS 内核时, 他们决定在一个进程 (Process) 中运行应用程序的每个实例. 进程不过是应用程序的一个实例要使用的资源的一个集合. 每个进程都被赋予一个虚拟地址空间, 确保一个进程使用的代码和数据无法由另一个进程访问. 这就确保了应用程序实例的健壮性. 由于应用程序破坏不了其他应用程序或者 OS 本身, 所以用户的计算体验变得更好了.
听起来似乎不错, 但 CPU 本身呢? 如果一个应用程序进入无限循环, 会发生什么呢? 如果机器中只有一个 CPU, 它会执行无限循环, 不能执行其它任何东西. 所以, 虽然数据无法被破坏, 而且更安全, 但系统仍然可能停止响应. 微软要修复这个问题, 他们拿出的方案就是线程. 作为 Windows 概念, 线程的职责是对 CPU 进行虚拟化. Windows 为每个进程都提供了该进程专用的专用的线程 (功能相当于一个 CPU, 可将线程理解成一个逻辑 CPU). 如果应用程序的代码进入无限循环, 与那个代码关联的进程会被 "冻结", 但其他进程(他们有自己的线程) 不会冻结: 他们会继续执行!
2.2 线程开销
线程是一个非常强悍的概念, 因为他们使 Windows 即使在执行长时间运行的任务时也能随时响应. 另外, 线程允许用户使用一个应用程序 (比如 "任务管理器") 强制终止似乎冻结的一个应用程序 (它也有可能正在执行一个长时间运行的任务). 但是, 和一切虚拟化机制一样, 线程会产生空间(内存耗用) 和时间 (运行时的执行性能) 上的开销.
创建线程, 让它进驻系统以及最后销毁它都需要空间和时间. 另外, 还需要讨论一下上下文切换. 单 CPU 的计算机一次只能做一件事情. 所以, Windows 必须在系统中的所有线程 (逻辑 CPU) 之间共享物理 CPU.
在任何给定的时刻, Windows 只将一个线程分配给一个 CPU. 那个线程允许运行一个时间片. 一旦时间片到期, Windows 就上下文切换到另一个给线程. 每次上下文切换都要求 Windows 执行以下操作:
将 CPU 寄存器中的值保存到当前正在运行的线程的内核对象内部的一个上下文结构中.
从现有线程集合中选一个线程供调度(切换到的目标线程). 如果该线程由另一个进程拥有, Windows 在开始执行任何代码或者接触任何数据之前, 还必须切换 CPU"看得见" 的虚拟地址空间.
将所选上下文结构中的值加载到 CPU 的寄存器中.
上下文切换完成后, CPU 执行所选的线程, 直到它的时间片到期. 然后, 会发生新一轮的上下文切换. Windows 大约每 30ms 执行一次上下文切换.
上下文切换是净开销: 也就是说上下文切换所产生的开销不会换来任何内存或性能上的收益.
根据上述讨论, 我们的结论是必须尽可能地避免使用线程, 因为他们要耗用大量的内存, 而且需要相当多的时间来创建, 销毁和管理. Windows 在线程之间进行上下文切换, 以及在发生垃圾回收的时候, 也会浪费不少时间. 然而, 根据上述讨论, 我们还得出一个结论, 那就是有时候必须使用线程, 因为它们使 Windows 变得更健壮, 反应更灵敏.
应该指出的是, 安装了多个 CPU 或者一个多核 CPU)的计算机可以真正同时运行几个线程, 这提升了应用程序的可伸缩性 (在少量的时间里做更多工作的能力).Windows 为每个 CPU 内核都分配一个线程, 每个内核都自己执行到其他线程的上下文切换. Windows 确保单个线程不会在多个内核上同时被调度, 因为这会代理巨大的混乱. 今天, 许多计算机都包含了多个 CPU, 超线程 CPU 或者多核 CPU. 但是, Windows 最初设计时, 单 CPU 计算机才是主流, 所以 Windows 设计了线程来增强系统的响应能力和可靠性. 今天, 线程还被用于增强应用程序的可伸缩性, 但在只有多 CPU(或多核 CPU) 计算机上才有可能发生.
TIP: 一个时间片结束时, 如果 Windows 决定再次调度同一个线程(而不是切换到另外给一个线程), 那么 Windows 不会执行上下文切换. 线程将继续执行, 这显著改进了性能. 设计自己的代码时注意, 上下文切换能避免的就要尽量避免.
2.3 CPU 的发展
过去, CPU 速度一直随着时间在变快. 所以, 在一台旧机器上运行得慢的程序在新机器上一般会快些. 然而, CPU 厂商没有延续 CPU 越来越快的趋势. 由于 CPU 厂商不能做到一直提升 CPU 的速度, 所以它们侧重于将晶体管做得越来越小, 使一个芯片上能够容纳更多的晶体管. 今天, 一个硅芯片可以容纳 2 个或者更多的 CPU 内核. 这样一来, 如果在写软件时能利用多个内核, 软件就能运行得更快些.
今天的计算机使用了以下三种多 CPU 技术.
多个 CPU
超线程芯片
多核芯片
2.4 使用线程的理由
使用线程有以下三方面的理由.
使用线程可以将代码同其他代码隔离
这将提高应用程序的可靠性. 事实上, 这正是 Windows 在操作系统中引入线程概念的原因. Windows 之所以需要线程来获得可靠性, 是因为你的应用程序对于操作系统来说是的第三方组件, 而微软不会在你发布应用程序之前对这些代码进行验证. 如果你的应用程序支持加载由其它厂商生成的组件, 那么应用程序对健壮性的要求就会很高, 使用线程将有助于满足这个需求.
可以使用线程来简化编码
有的时候, 如果通过一个任务自己的线程来执行该任务, 或者说单独一个线程来处里该任务, 编码会变得更简单. 但是, 如果这样做, 肯定要使用额外的资源, 也不是十分 "经济"(没有使用尽量少的代码达到目的). 现在, 即使要付出一些资源作为代价, 我也宁愿选择简单的编码过程. 否则, 干脆坚持一直用机器语言写程序好了, 完全没必要成为一名 C# 开发人员. 但有的时候, 一些人在使用线程时, 觉得自己选择了一种更容易的编码方式, 但实际上, 它们是将事情 (和它们的代码) 大大复杂化了. 通常, 在你引入线程时, 引入的是要相互协作的代码, 它们可能要求线程同步构造知道另一个线程在什么时候终止. 一旦开始涉及协作, 就要使用更多的资源, 同时会使代码变得更复杂. 所以, 在开始使用线程之前, 务必确定线程真的能够帮助你.
可以使用线程来实现并发执行
如果 (而且只有) 知道自己的应用程序要在多 CPU 机器上运行, 那么让多个任务同时运行, 就能提高性能. 现在安装了多个 CPU(或者一个多核 CPU)的机器相当普遍, 所以设计应用程序来使用多个内核是有意义的.
3 数据并行(Data Parallelism)
3.1 数据并行
数据并行是指对源集合或数组中的元素同时 (即并行) 执行相同操作的情况. 在数据并行操作中, 源集合被分区, 以便多个线程可以同时在不同的段上操作.
数据并行性是指对源集合或数组中的元素同时任务并行库 (TPL) 通过 system.threading.tasks.parallel 类支持数据并行. 这个类提供了 for 和 for each 循环的基于方法的并行实现.
您为 parallel.for 或 parallel.foreach 循环编写循环逻辑, 就像编写顺序循环一样. 您不必创建线程或将工作项排队. 在基本循环中, 您不必使用锁. 底层工作 TPL 已经帮你处理.
下面代码展示顺序和并行:
- // Sequential version
- foreach (var item in sourceCollection)
- {
- Process(item);
- }
- // Parallel equivalent
- Parallel.ForEach(sourceCollection, item => Process(item));
并行循环运行时, TPL 对数据源进行分区, 以便循环可以同时在多个部分上运行. 在后台, 任务调度程序根据系统资源和工作负载对任务进行分区. 如果工作负载变得不平衡, 调度程序会在多个线程和处理器之间重新分配工作.
下面的代码来展示如何通过 Visual Studio 调试代码:
- public static void test()
- {
- int[] nums = Enumerable.Range(0, 1000000).ToArray();
- long total = 0;
- // Use type parameter to make subtotal a long, not an int
- Parallel.For<long>(0, nums.Length, () => 0, (j, loop, subtotal) =>
- {
- subtotal += nums[j];
- return subtotal;
- },
- (x) => Interlocked.Add(ref total, x)
- );
- Console.WriteLine("The total is {0:N0}", total);
- Console.WriteLine("Press any key to exit");
- Console.ReadKey();
- }
选择调试> 开始调试, 或按 F5.
应用在调试模式下启动, 并会在断点处暂停.
在中断模式下打开线程通过选择窗口调试> Windows> 线程. 您必须位于一个调试会话以打开或请参阅线程和其他调试窗口.
3.2 Parallel.For 剖析
查看 Parallel.For 的底层,
public static ParallelLoopResult For<TLocal>(int fromInclusive, int toExclusive, Func<TLocal> localInit, Func<int, ParallelLoopState, TLocal, TLocal> body, Action<TLocal> localFinally);
清楚的看到有个 func 函数, 看起来很熟悉.
- [TypeForwardedFrom("System.Core, Version=3.5.0.0, Culture=Neutral, PublicKeyToken=b77a5c561934e089")]
- public delegate TResult Func<out TResult>();
原来是定义的委托, 有多个重载, 具体查看文档[]
实际上 TPL 之前, 实现并发或多线程, 基本都要使用委托.
TIP: 关于委托, 大家可以查看(https://docs.microsoft.com/en-us/dotnet/csharp/tour-of-csharp/delegates). 或者《细说委托》(https://www.cnblogs.com/laoyu/archive/2013/01/13/2859000.html)
参考
https://www.cnblogs.com/laoyu/archive/2013/01/13/2859000.html
《C# 并发经典实例》
《CLR via C#》第 3 版
来源: https://www.cnblogs.com/lucky_hu/p/10569564.html