zl程序教程

您现在的位置是:首页 >  后端

当前栏目

《CLR via C#》笔记:第5部分 线程处理(1)

c#笔记线程 处理 部分 CLR via
2023-06-13 09:13:01 时间
  • 本博客所总结书籍为《CLR via C#(第4版)》清华大学出版社,2021年11月第11次印刷(如果是旧版书籍或者pdf可能会出现书页对不上的情况)

  • 你可以理解为本博客为该书的精简子集,给正在学习中的人提供一个“glance”,以及对于部分专业术语或知识点给出解释/博客链接。
  • 【本博客有如下定义“Px x”,第一个代表书中的页数,第二个代表大致内容从本页第几段开始。(如果有last+x代表倒数第几段,last代表最后一段)】
  • 电子书可以在博客首页的文档-资源归档中找到,或者点击:传送门自行查找。如有能力请支持正版。(很推荐放在竖屏上阅读本电子书,这多是一件美事)
  • 欢迎加群学习交流:637959304 进群密码:(CSGO的拆包密码) 

  • 本人对于线程的经验仅限于开辟线程以及线程同步方面的简易操作。对于深入工程应用实践仍有缺乏,故本大部分的笔记补充能容可能会在未来半年-一年内陆续进行。

目录

第二十六章 线程基础

Windows为什么要支持线程

  • 在计算机的早期岁月,操作系统没有线程的概念。事实上,整个系统只运行着一个执行线程,其中同时包含操作系统代码和应用程序代码。只用一个执行线程的问题在于,长时间运行的任务会阻止其他任务执行。例如,在16位 Windows的那些日子,打印文档的应用程序很容易“冻结”整个机器,造成OS(操作系统)和其他应用程序停止响应。有些应用程序的bug会造成死循环,同样会造成整个机器停止工作。(P591 last2)
  • Microsoft 设计新的OS内核时,决定在一个进程中运行应用程序的每个实例。进程实际是应用程序的实例要使用的资源的集合。每个进程都被赋予了一个虚拟地址空间,确保在一个进程中使用的代码和数据无法由另一个进程访问。这就确保了应用程序实例的健壮性,因为一个进程无法破坏另一个进程使用的代码或数据。此外,进程访问不了OS 的内核代码和数据;所以,应用程序代码破坏不了操作系统代码或数据。由于应用程序代码破坏不了其他应用程序或者OS自身,所以用户的计算体验变得更好了。除此之外,系统变得比以往更安全,因为应用程序代码无法访问另一个应用程序或者OS自身使用的用户名、密码、信用卡资料或其他敏感信息。(P592 2)
  • 对于CPU本身,如果机器只有一个CPU,应用程序发生死循环,CPU也会执行死循环,不能执行其他任何东西。为此Microsoft提出线程的概念解决该类问题。 线程的职责是对CPU进行虚拟化。Windows为每个进程都提供了该进程专用的线程(功能相当于一个 CPU)。应用程序的代码进入死循环,与那个代码关联的进程会“冻结”,但其他进程(它们有自己的线程)不会冻结,它们会继续执行!(P592 3)

线程开销

  • 线程很强大,因为它们使Windows即使在执行长时间运行的任务时也能随时响应。另外,线程允许用户使用一个应用程序(比如“任务管理器”)强制终止似乎已经冻结的应用程序(它也有可能正在执行一个长时间运行的任务)。但和一切虚拟化机制一样,线程有空间(内存耗用)和时间(运行时的执行性能)上的开销。(P592 4)
  • 每个线程都有以下要素:(P592 last) 1、线程内核对象(thread kernel object):OS为系统中创建的每个线程都分配并初始化这种数据结构之一。数据结构包含一组对线程进行描述的属性(本章后面讨论)。数据结构还包含所谓的线程上下文(threadcontext)。上下文是包含CPU寄存器集合的内存块。对于x86,x64和ARM CPU架构,线程上下文分别使用约700,1240和350字节的内存。 2、线程环境块(thread environment block,TEB):TEB是在用户模式(应用程序代码能快速访问的地址空间)中分配和初始化的内存块。TEB耗用1个内存页(x86,x64和ARM CPU中是4KB)。TEB包含线程的异常处理链首(head)。线程进入的每个try块都在链首插入一个节点(node);线程退出 try块时从链中删除该节点。此外,TEB还包含线程的“线程本地存储”数据,以及由GDI(GraphicsDevice Interface,图形设备接口)和l OpenGL图形使用的一些数据结构。 3、用户模式栈(user-mode stack):用户模式栈存储传给方法的局部变量和实参。它还包含一个地址;指出当前方法返回时,线程应该从什么地方接着执行。Windows 默认为每个线程的用户模式栈分配1MB内存。更具体地说,Windows只是保留1MB地址空间,在线程实际需要时才会提交(调拨)物理内存。 4、内核模式栈(kernel-mode stack):应用程序代码向操作系统中的内核模式函数传递实参时,还会使用内核模式栈。出于对安全的考虑,针对从用户模式的代码传给内核的任何实参,Windows 都会把它们从线程的用户模式栈复制到线程的内核模式栈。一经复制,内核就可验证实参的值。由于应用程序代码不能访问内核模式栈,所以应用程序无法更改验证后的实参值。OS内核代码开始处理复制的值。除此之外,内核会调用它自己内部的方法,并利用内核模式栈传递它自己的实参、存储函数的局部变量以及存储返回地址。在32位 Windows上运行,内核模式栈大小是12KB;64位. Windows是24 KB。 5、DLL线程连接(attach)和线程分离(detach)通知:Windows的一个策略是,任何时候在进程中创建线程,都会调用进程中加载的所有非托管DLL 的 DllMain方法,并向该方法传递DLL_THREAD_ATTACH标志。类似地,任何时候线程终止,都会调用进程中的所有非托管DLL 的 DIIMain方法,并向方法传递DLL_THREAD_DETACH标志。有的DLL需要获取这些通知,才能为进程中创建/销毁的每个线程执行特殊的初始化或(资源)清理操作。例如,C-Runtime库 DLL会分配一些线程本地存储状态。线程使用C-Runtime库中包含的函数时需要用到这些状态。
  • 通过上下文切换操作,牺牲一定性能换取进程的互不干扰持续运行(一个进程死循环后强制关闭不会影响其他进程),提升用户体验。(P594 1)
  • 执行上下文切换所需的时间取决于CPU架构和速度。而填充CPU缓存所需的时间取决于系统中运行的应用程序、CPU缓存的大小以及其他各种因素。所以,无法为每一次上下文切换的时间开销给出确定值,甚至无法给出估计值。唯一确定的是,要构建高性能应用程序和组件,就应该尽量避免上下文切换。(P595 1) 此外,执行垃圾回收时,CLR必须挂起(暂停)所有线程,遍历它们的栈来查找根以便对堆中的对象进行标记,再次遍历它们的栈(有的对象在压缩期间发生了移动,所以要更新它们的根),再恢复所有线程。所以,减少线程的数量也会显著提升垃圾回收器的性能。每次使用调试器并遇到断点,Windows 都会挂起正在调试的应用程序中的所有线程,并在单步执行或者运行应用程序时恢复所有线程。所以,线程越多,调试体验越差。(P595 2)

停止疯狂

  • 线程利用率分析(P596-P597)

CPU发展趋势

  • 今天的计算机使用了以下三种多CPU 技术 1、多个CPU(不常用) 2、超线程芯片:这种技术(Intel专利)允许一个芯片在操作系统中显示成两个。芯片中包含两组架构状态(比如CPU寄存器),但芯片只有一组执行资源。对于Windows,这看起来是安装了两个CPU,所以 Windows 会同时调度两个线程。但芯片一次只能执行一个线程。一个线程由于缓存未命中(cache miss)、分支预测错误(branch misprediction)或者要等待数拆(data dependency)而暂停时,芯片将切换到另一个线程。一切都是在硬件中发生的,Windows对此一无所知;它以为有两个线程正在并发运行。Windows不知道实际使用的是超线程CPU。如果一台机器上安装了多个超线程CPU,Windows首先在每个CPL上都调度一个线程,使线程真正并发运行,然后在已经处于“忙”状态的CPU上调度其他线程。Intel声称超线程CPU能提升10%~30%的性能。 3、多核芯片

CLR线程和Windows线程

  • CLR使用Windows的线程处理功能。(P598 last)

使用专用线程执行异步的计算机限制操作

  • 不推荐使用专用线程执行异步的计算机限制操作。(P599 1) 推荐线程池来执行异步的计算限制操作。(第27章内容)

使用线程的理由

  • 主要出于两方面原因使用线程:可响应性(通常是对于客户端GUI应用程序),性能(对于客户端和服务器应用程序)(P601 last2)

读到此段时,有些感言。(CPU方面)现在的手游时代和原来的PC时代我觉得十分相似,现在的手游也注重于优化与效率,而再过若干年之后,是否也可以大胆地去使用CPU资源呢。而对于渲染(GPU)方面来说,我觉得目前来说短期来说毫无希望,我该如何把一个4K 120FPS 光追 3A大作的表现力100%复刻在手机上?不谈产品,仅对于GPU的使用率来说仍有很长的路要走。(不过我倒是见过极客湾把手机刷成Windows系统上面玩孤岛危机,而且还能有3-5帧的FPS,这还是底层指令集并不是很兼容的情况下,就离谱)

线程调度

  • Windows采用的算法:每个线程的内核对象都包含一个上下文结构。上下文(context)结构反映了线程上一次执行完毕后CPU寄存器的状态。在一个时间片(time-slice)之后,Windows检查现存的所有线程内核对象。在这些对象中,只有那些没有正在等待什么的线程才适合调度。Windows选择一个可调度的线程内核对象,并上下文切换到它。Windows实际记录了每个线程被上下文切换到的次数。(P603)
  • 然后,线程开始执行代码,并在其进程的地址空间处理数据。又过了一个时间片之后,Windows执行下一次上下文切换。Windows 从系统启动开始便一直执行上下文切换,直到系统关闭为止。
  • Windows之所以被称为抢占式多线程(preemptive multithreaded)操作系统,是因为线程可在任何时间停止(被抢占)并调度另一个线程。你在这个方面是有一定控制权的,虽然并不多。
  • 每个线程都分配了从0(最低)到31(最高)的优先级。系统决定为CPU分配哪个线程时,首先检查优先级31的线程,并以一种轮流(round-robin)方式调度它们。如果优先级31的一个线程可以调度,就把它分配给CPU。在这个线程的时间片结束时,系统检查是否有另一个优先级31的线程可以运行;如果是,就允许将那个线程分配给CPU。
  • 只要存在可调度的优先级31的线程,系统就永远不会将优先级0~30 的任何线程分配给CPU。这种情况称为饥饿(starvation)。较高优先级的线程占用了太多CPU时间,造成较低优先级的线程无法运行,就会发生这种情况。多处理器机器发生饥饿的可能性要小得多,因为这种机器上优先级为31的线程和优先级为30的线程可以同时运行。系统总是保持各个CPU处于忙碌状态,只有没有线程可调度的时候,CPU才会空闲下来。
  • 系统启动时会创建一个特殊的零页线程(zero page thread)。该线程的优先级是0,而且是整个系统唯一优先级为О的线程。在没有其他线程需要“干活儿”的时候,零页线程将系统RAM的所有空闲页清零。
  • 进程优先级(P605):“进程优先级类”和“相对线程优先级”如何映射到“优先级”值

前台线程和后台线程

  • CLR将每个线程要么视为前台线程,要么视为后台线程。一个进程的所有前台线程停止运行时,CLR强制终止仍在运行的任何后台线程。这些后台线程被直接终止;不抛出异常。(P608 3)
  • 在线程的生存期中,任何时候都可以从前台变成后台,或者从后台变成前台。应用程序的主线程以及通过构造一个Thread对象来显式创建的任何线程都默认为前台线程。相反,线程池线程默认为后台线程。另外,由进入托管执行环境的本机(native)代码创建的任何线程都被标记为后台线程。(P609 1)

第二十七章 计算限制的异步操作

CLR线程池基础

  • 创建和销毁线程需要消耗大量时间,太多的线程会浪费内存资源。由于操作系统必须调度可运行的线程并执行上下文切换,所以太多的线程还对性能不利。为了改善这个情况,CLR包含了代码来管理它自己的线程池(thread pool)。 线程池是你的引用程序能使用的线程集合。每CLR一个线程池,这个线程池由CLR控制的所有AppDoamin共享。一个进程有多个CLR,每个CLR有自己的线程池。(P612 1)
  • 在内部,线程池维护一个操作请求队列。应用程序执行一个异步操作时,就调用某个方法,将一个记录项(entry)追加到线程池的队列中。线程池的代码从这个队列中提取记录项,将这个记录项派发(dispatch)给一个线程池线程。如果线程池中没有线程,就创建一个新线程。创建线程会造成一定的性能损失(前面已讨论过了)。然而,当线程池线程完成任务后,线程不会被销毁。相反,线程会返回线程池,在那里进入空闲状态,等待响应另一个请求。由于线程不销毁自身,所以不再产生额外的性能损失。(P621 2)
  • 如果你的应用程序向线程池发出许多请求,线程池会尝试只用这一个线程来服务所有请求。然而,如果你的应用程序发出请求的速度超过了线程池线程处理它们的速度,就会创建额外的线程。最终,你的应用程序的所有请求都能由少量线程处理,所以线程池不必创建大量线程。(P621 3)

执行简单的计算限制操作

  • 调用ThreadPool类定义的方法,来将一个异步的计算限制操作放到线程池的队列中。
  • 方法向线程池队列添加一个工作项(work item)以及可选的状态数据,然后所有方法会立即返回。编写的回调方法必须匹配System.Threading.WaitCallback委托类型。(P613 1)
static Boolean QueueUserworkItem(waitcallback callBack) ;
static Boolean QueueUserWorkItem(waitcallback callBack,Object state);
//System.Threading.WaitCallback委托类型
deiegate void waitcallback (Object state) ;

执行上下文

  • 每个线程都关联了一个执行上下文数据结构。执行上下文(execution context)包括的东西有安全设置(压缩栈、Thread 的 Principal属性和 Windows身份)、宿主设置(参见System.Threading.HostExecutionContextManager)以及逻辑调用上下文数据(参见System.Runtime.Remoting.Messaging.CallContext 的 LogicalSetData和 LogicalGetData方法)。 线程执行代码时,可能会受到线程执行上下文设置的影响。(P614 1)
  • 通过阻止执行上下文的流动来影响线程逻辑调用上下文中的数据,代码示例(P615 2)

协作式取消和超时

  • Microsoft .NET Framework 提供了标准的取消操作模式。这个模式是协作式的,意味着要取消的操作必须显式支持取消。换言之,无论执行操作的代码,还是试图取消操作的代码,都必须使用本节提到的类型。(P615 last2)
  • 取消操作首先要创建一个 System.Threading.CancellationTokenSource对象。该对象包含了和管理取消有关的所有状态。构造好一个 CancellationTokenSource(一个引用类型)之后,可从它的 Token属性获得一个或多个CancellationToken(一个值类型)实例,并传给你的操作,使操作可以取消。CancellationToken实例是轻量级值类型,包含单个私有字段,即对其CancellationTokenSource对象的引用。在计算限制操作的循环中,可定时调用CancellationToken的 IsCancellationRequested属性,了解循环是否应该提前终止,从而终止计算限制的操作。提前终止的好处在于,CPU不需要再把时间浪费在你对结果不感兴趣的操作上。(P615 last) 可调用CancellationTokenSource的Register方法登记一个或多个在取消CancellationTokenSource时调用的方法。 可以调用Dispose从关联的CancellationTokenSource中删除已登记的回调,才调用Cancel时,就不会再调用这个回调。 可通过链接另一组CancellationTokenSource来创建一个新的CancellationTokenSource对象。(P618)

任务

  • 很容易调用ThreadPool的QueueUserWoekItem方法发起一次异步的计算限制操作。 最大的问题是没有内建的机制让你知道操作在什么时候完成,也没有机制在操作完成时获得返回值。为了克服这些限制(并解决其他一些问题)。 Microsoft引入了任务的概念。我们通过System.Threading.Tasks 命名空间中的类型来使用任务。(P619 3)
//所以,不是调用ThreadPool的 QueueUserWorkItem方法,而是用任务来做相同的事情:
ThreadPool.QueueuserworkItem (ComputeBoundop,5);//调用QueueUserworkItem
new Task (computeBoundop,5) .start () ;//用Task来做相同的事情
Task.Run ( (=> ComputeBoundOp (5));//另一个等价的写法
  • 为了创建一个Task,需要调用构造器并传递一个Action或Action<Object>委托。这个委托就是你想执行的操作。如果传递的是期待一个Object 的方法,还必须向Task 的构造器传递最终要传给操作的实参。调用Run时可以传递一个Action或Func委托来指定想要执行的操作。无论调用构造器还是Run,都可选择传递一个CancellationToken,它使Task能在调度前取消(详情参见稍后的27.5.2节“取消任务”)。(P619 last2)
  • 取消任务:可用一个CancellationTokenSource取消Task。(P622 3)
  • 任务完成时自动启动新任务:伸缩性好的软件不应该使线程阻塞。调用Wait,或者在任务尚未完成时查询任务的Result属性,极有可能造成线程池创建新线程,这增大了资源的消耗,也不利于性能和伸缩性。下面重写了之前的代码,让任务完成时可启动另一个任务,且不阻塞任何线程:(P623 2) 注意,执行Sum 的任务可能在调用ContinueWith 之前完成。但这不是一个问题,因为ContinueWith方法会看到 Sum任务已经完成,会立即启动显示结果的任务。(P623 3)
//创建并启动一个Task,继续另一个任务
Task<Int32> t = Task.Run ( () =>Sum(CancellationToken.None,10000)) ;
//continuewith返回一个Task,但一般都不需要再使用该对象(下例的cwt)
Task cwt = t.Continueith(task => Console.writeLine (""The sum is: " + task.Result));
  • 任务可以启动子任务:任务支持父/子关系。(P625 1)
  • 任务内部揭秘。(P625-P627)
  • 任务工厂:有时需要创建一组共享相同配置的Task对象。为避免机械地将相同的参数传给每个Task的构造器,可创建一个任务工厂来封装通用的配置。System.Threading.Tasks命名空间定义了一个TaskFactory类型和一个TaskFactory类型。代码示例:(P627 2)
  • 任务调度器:TaskScheduler(P629-P630)

Parallel的静态For,ForEach和Invoke方法

  • 一些常见的编程情形可通过任务提升性能。为简化编程,静态System.Threading.Tasks.Parallel类封装了这些情形,它内部使用Task对象。(P630 last3) 如果有循环语句每次循环都调用一些函数方法,那么推荐使用Parallel的静态For,ForEach和Invoke方法。
  • Parallel的所有方法都让调用线程参与处理。因此,如果循环内容只能顺序执行,那么就无法使用。同时要避免会修改任何共享数据的工作项,该情况如果加同步锁那么就和普通循环一样,不加锁则可能损坏共享数据。(P631 last2)

并行语言集成查询(PLINQ)

  • Microsoft的语言集成查询(Language Integrated Query,LINQ)功能提供了一个简捷的语法来查询数据集合。可用LINQ轻松对数据项进行筛选、排序、投射等操作。使用LINQ to Objects时,只有一个线程顺序处理数据集合中的所有项;我们称之为顺序查询(sequential query)。要提高处理性能,可以使用并行LINQ(Parallel LINQ),它将顺序查询转换成并行查询,在内部使用任务(排队给默认TaskScheduler),将集合中的数据项的处理工作分散到多个CPU上,以便并发处理多个数据项。 和 Parallel的方法相似,要同时处理大量项,或者每一项的处理过程都是一个耗时的计算限制的操作,那么能从并行LINQ获得最大的收益。(P634 1)
  • 代码示例(P634-P636)

执行顶式计算限制操作

  • System.Threading命名空间定义了一个Timer类,可用它让一个线程池线程定时调用一个方法。构造Timer类的实例相当于告诉线程池:在将来某个时间(具体由你指定)回调你的一个方法。Timer类提供了几个相似的构造器:(P636 last)
public sealed class Timer : MarshalByRefObject, IDisposable
{
//callback 参数标识希望由一个线程池线程回调的方法。
        public Timer (TimerCallback callback,object state,Int32 dueTime, Int32 period) ;
        public Timer (Timercallback callback,object state,uInt32 dueTime,UInt32 period) ;
        public Timer(Timercallback callback,0bject state,Int64 dueTime, Int64 period) ;
        public Timer(TimerCallback callback,Object state,Timespan dueTime,TimeSpan period);
}
//回调方法必须和System.Threading.TimerCallback委托类型匹配
delegate void Timercallback i object state) ;
  • Dispose方法取消计时器。(P637 last2)
  • 代码示例(P637-P638)

线程池如何管理线程

  • 在这么多年的时间里,随着CLR的每个版本的发布,其内部的实现已发生了显著变化。未来的版本还会继续变化。最好是将线程池看成一个黑盒。不要拿单个应用程序去衡量它的性能,因为它不是针对某个单独的应用程序而设计的。相反,作为一个常规用途的线程调度技术,它面向的是大量应用程序;它对某些应用程序的效果要好于对其他应用程序。(P639 last2)
  • 设置线程池限制:不建议,因为可能会发生饥饿或者死锁。(P639 last)
  • 如何管理工作者线程。(P640 last3)

CLR的线程池