# 前言
《CLR VIR C#》 这本书太厚,笔记也记得太多,因此还是分成两篇上传
这是第二篇,主要记录多线程相关章节笔记
# 第二十六章 线程基础
- 进程:进程是应用程序要使用的资源集合
- 每个进程都有一个虚拟地址空间,确保在一个进程中使用的代码和数据无法由另一个进程访问
- 以此确保应用程序实例的健壮性以及安全性
- 线程:对 CPU 进行虚拟化
- 每个进程都有自己专用线程
- 确保应用程序代码陷入死循环时仅 冻结 与代码关联的进程
- Windows 永远不会调度进程,而只是调度线程
- 线程开销
- 线程内核对象
- 线程环境块(TEB)
- 用户模式栈
- 存储传给方法的局部变量和实参
- 还包含一个地址:指出方法返回时,继续运行的地址
- 默认 1M 内存
- 内核模式栈
- 32 位 12KB
- 64 位 24KB
- DLL 线程连接和线程分离通知
- windows 下任何时候在进程中创建线程,都会调用进程中加载的所有非托管 DLL 的 DllMain 方法
- 上下文切换
- 系统在所有线程(逻辑 CPU)之间共享物理 CPU
- 任何时刻只将一个线程分配给一个 CPU,该线程能运行一个 时间片 (量程) 的长度
- 时间片到期就会将上下文切换到另一个线程
- 切换操作
- 将 cpu 寄存器值保存到当前正在运行的线程内核对象内部的一个上下文结构中
- 从现有线程集合选出一个线程调度,若该线程由另一个进程拥有,在执行任何代码前还必须切换 cpu 『看见』虚拟地址空间
- 将所选上下文结构中的值加载到 cpu 寄存器中
- Windows 大约每 30 毫秒执行一次上下文切换,上下文切换是净开销,主要是为了给用户提供一个健壮的、响应灵敏的操作系统
- 新线程还可能执行不在高速缓存中的代码,而导致重新缓存,下一次切换又会导致这种问题
- 注:一个时间片结束,若系统决定调度同一个线程,那么就不会发生上下文切换(尽量避免上下文切换)
- 垃圾回收 GC 线程
- clr 暂停所有线程,遍历它们的栈以查找根以便对堆中对象进行标记
- 然后再次遍历它们的栈 (有的对象可能在压缩期间发生移动) 更新它们的根
- 因此减少线程数量可以提升垃圾器回收性能
- 调试器也会挂起所有线程,并在单步执行后恢复所有线程,因此线程越多,调试体验越差
- 停止浪费
- 如果只关心性能,那么任何机器最优线程数就是 cpu 数目
- 一个线程至少都是 1M 的内存开销,什么都不做的情况下也会白白占用
- 使用线程的理由
- 可响应性(gui)
- 性能(多 cpu)
- 线程调度和优先级
- 可使用 Microsoft Spy++ 查看每个线程被上下文切换到的次数
- 抢占式调度,使用算法判断在什么时候调度哪些线程多长时间
- 每个线程都分配了 0 (最低)~31 (最高) 的优先级
- 较高优先级线程总是抢占较低优先级线程(总是调度更高优先级线程)
- 较高优先级线程开启时,会立即挂起低优先级线程
- 0 页线程:系统启动创建,唯一一个,用于将 ram 空闲页清零
- 应用程序开发人员不直接处理这些优先级,而是通过优先级类进行
- Idle、Below Normal、normal、above normal、high、realme(仅管理员和 power 权限)
- 高优先级线程在其生存周期的大多数时间都应处于等待状态,这样才不至于影响系统总体响应能力
- clr 为自己保留了 idle 和 time-critical 优先级,clr 的终结器线程以 time-critical 优先级运行
- 前台线程和后台线程
- 应该进程停止时,clr 强制终止仍在运行的任何后台线程
- 线程是比较宝贵的资源,最好使用线程池
# 第二十七章 计算限制的异步操作
- 线程池
- 每 clr 一个线程池
- 由 clr 控制的所有 appdomain 共享
- clr 初始化时池中没有线程,内部维护一个操作请求队列(记录项)
- 应用程序执行一个异步操作时,追加一个记录项到队列,线程池内部从该队列提取记录项
- 池中没有线程时,创建新线程,并在执行完毕后返回池内
- 如果应用程序发出许多请求,线程池会尝试用该线程重复处理,不过发出请求速度远超处理速度,则会导致生成新的线程
- 当池内线程空闲一段时间后,线程会醒来终止自己以释放资源
- 执行上下文
- 每个线程都关联了一个执行上下文数据结构
- 上下文流动会有一点性能消耗,可用 ExecutionContext.SupressFlow 类阻止
- 协作取消和超时
- 协作式:要取消的操作必须显式支持取消
- CancellationTokenSource
- 要执行一个不允许被取消操作,也可以传递 CancellationToken.None 静态实例
- CanBeCancelled 返回 false
- 一般 CancellationTokenSource 获取的的 token 会返回 true
- 向 CancellationTokenSource 登记回调方法,将由调用 register 的线程调用回调
- 若 useSynchonizationContext 设置为 true,则可能通过调用线程的 SynchonizationContext 执行
- 多个 CancellationTokenSource 可以链接起来
- 可支持延迟取消
- Task
- Task.Run 接受的取消令牌,只能处理在调度前的取消(没什么用),想取消运行中任务必须任务内显式取消
- 子线程异常,可以注册 TaskScheduler.UnobservedTaskException 以进行处理
- 任务完成时自动启动新任务
- 伸缩性良好的软件不应使线程阻塞,调用 wait、或在任务未完成时调用 result 属性
- task.result 属性内部会调用 wait
- 使用 continuewith
- 可以指定新任务只有在第一个任务被取消时才执行,等(TaskContiniationOptions)
- 任务启动子任务
- TaskCreationOptions.AttachToParent:将一个 task 与其创建者关联,除非子任务都完成,否则父线程不认为已结束
- 延续任务也可以使用:TaskContiniationOptions.AttachToParent 将延续任务指定成子任务
- 任务内部揭秘
- 一系列构成任务状态字段,如代表 task 的唯一 id(从 1 开始,以 1 递增,首次查询对象该属性时分配)
- 补充状态
- cancellationtoken
- continuewithtask 集合
- 为抛出未处理异常的子任务准备的 task 对象集合
- 若不需要任务的附加功能,threadpool.queueuserworkitem 资源利用率更好
- 任务工厂
- 可以用于创建一组共享相同配置的 task 对象
- TaskFactory
- TaskFactory
- continuewhenall、continuewhenany 无论如何都会执行延续任务,不受 taskcontinuatuonoption 标志中会导致取消的枚举控制
- 任务调度器
- taskschedule 负责执行调度任务
- fcl 提供两个调度器类型
- 线程池任务调度器
- taskschedule.default
- 同步上下文任务调度器
- 线程池任务调度器
- 默认情况下,所有任务均使用线程池任务调度器
- 同步上下文调度器适合提供了 gui 的应用程序,用于将所有任务都调度给 gui 线程
- 该调度器不使用线程池
- 可使用 taskschedule.fromcurrentsynchrolizationcontext 方法获得其引用
- 任务调度器将任务放到 gui 线程的队列中
- Parallel
- for、foreach
- for 更快
- invoke
- 调用方法都有接受 ParallelOptions 参数的重载
- 默认并发操作最大数目为 -1(可用 cpu 数)
- 委托重载版本:
- localinit:处理一个工作项前调用
- 经测试为线程池每个 Task 调用一次
- body:处理的每一项都调用一次
- 经测试为循环中每个对象都会调用一次
- localFinally:处理好每个任务工作项后调用 (即使主体委托异常也会调用)
- 经测试为线程池每个 Task 调用一次
- localinit:处理一个工作项前调用
- parallel 所有方法都让调用线程一块参与处理
- 参数 parallelloopstate
- stop:告诉循环停止处理更多工作
- 其它处于运行中的任务也 应该 停止,IsStopped 属性返回 true
- break:告诉循环不再处理当前项之后的项
- 循环确保当前项之前项处理好后返回
- 上述两个选项都会导致后续循环不再 执行
- Break 和 Stop 都不能停止已经开始的项,只能防止新任务的开始
- lowestbreackiteration:调用过 break 方法最低的项,若无则为 null
- stop:告诉循环停止处理更多工作
- PLINQ
- AsParallel:并行执行
- AsSequential:切换回顺序执行
- ForAll
- AsOrdered:保持数据项顺序,会降低性能
- WithDegreeOfParallelism:允许指定最多允许多少个线程处理,默认每个内核一个线程查询
- timer 类 定时器
- 使用 threadpool.queueuserworkitem,参数 period timeoutinfinity + 回调完成手动 change 方法避免(回调耗时太长而在未结束时就)重复调用
- 垃圾回收时会终止 timer,因此需要存在一个引用
- asnyc 异步方法中:使用 taskdelay+await,避免线程上下文切换(异步 delay 方法使其回归池中直接执行其它任务)
- 不建议设置线程池线程限制
- 线程调度
- threadpool.queueuserworkitem 和 timer 总是将自己放入全局队列,工作线程采用先进先出 (FIFO) 方式从队列取出(因此会有线程同步锁)
- 默认 taskscheduler 调度
- 每个 task 默认进入全局队列
- 每个工作线程有自己的本地队列,调度一个 task 时,该 task 被添加到调用线程本地队列
- 工作线程先检查本地队列,通过后入先出 (LIFO) 从本地队列取出任务(本地队列无需同步锁)
- 工作线程若发现本地队列空了,会尝试从另一个工作线程『偷取』一个 task(会有同步锁)
- 线程池默认工作者线程等于 cpu 数,然后监视任务完成速度,若完成事件过长 (事件未公布),则会创建更多线程
# 第二十八章 I/O 限制的异步操作
- 若线程不发生阻塞,在调用异步方法后,线程回到线程池较好的情况是根本不发生线程切换,并立即处理下一个任务
- 在线程池线程调用 thread.sleep (或等待线程同步锁) 可能会让线程池觉得 cpu 不饱和,而创建额外线程
- 而阻塞线程可能醒来,发生上下文切换(该项线程池有优化,不会立即让其处理任务,以减少上下文切换的可能)
- 线程越少,垃圾回收速度越快(挂起所有线程、扫描所有线程根,对线程池空闲线程来说处于它们栈顶,扫描根花费更少)
- 线程池判断线程数超过需要数量,会允许多余线程终止自身回收资源
- 异步操作:少量线程执行大量操作
- 异步函数
- 一旦将方法标记为 async,编译器就会将方法转换为一个实现了状态机的一个类型
- 因此运行线程执行状态机中一些代码并返回,而不必一次性执行结束
- await 操作符实际会在 Task 对象上调用 continuewith 传递用于恢复状态机的方法,然后线程返回(所以说异步方法本身不开启新线程,await 操作符等待的是另外开启了新线程的 task)
- 将来该 task 执行完毕后,线程池某个线程激活 continueeith 回调方法,造成一个线程恢复状态机
- 若 await 等待有返回值,编译器会生成代码查询 task 对象 result 属性并将结果赋值给接收返回值的变量
- 注 1:方法标记为 async 后,编译器会生成相关代码,在状态机开始前创建对应的一个 task 对象,并在状态机执行完毕时自动完成
- 有返回值的 task 对象,执行完毕后将 result 属性设置为方法返回值
- 注 2:await 之前代码由一个线程执行,之后代码可能由另一个线程执行
- 异步函数转换为状态机
- 实参和局部变量被编译为状态机的字段
- 任何时候使用 await 操作符都会获取操作数,并尝试在它上面调用 getwaiter 方法 (可能是实例或扩展方法)
- 调用 getwaiter 返回称为 awaiter (等待者) 对象,它将被等待对象与状态机沾合起来
- 状态机获得 awaiter 后,查询其 iscompleted 属性
- 若操作已以同步方式完成属性将返回 true,作为优化状态机将继续执行并调用 awaiter 的 getresult 方法
- 若操作以异步方式完成,则返回 false,状态机 awaiter 的 oncompleted 方法并传递委托 (引用状态机的 movenext 方法)
- 将来 awaiter 在任务完成时调用委托以执行 movenext,状态机根据相关字段到达代码正确位置 (上次离开位置)
- 回到离开位置后,调用 awaiter 的 getresult 方法获取结果并继续执行
- 异步函数扩展性
- 编译器可以在 await 任何操作数上调用 getawaiter(所以操作数不一定是 task 对象,可以是提供了 getawaiter 的任意类型)
- 异步函数在返回 void 时,编译器会生成状态机,但是不再创建 task 对象
- beginxxx、endxxx 方法已过时,可通过 task.factory.fromasync 将其转变为基于 task 的模型
- 异步函数的异常
- 若状态机出现未处理异常,代表异步函数的 task 对象会因未处理异常而完成,等待该 task 的代码会看见异常
- 返回 void 的异步函数由于没有 task,出现的异常由编译器生成的代码捕获,并于调用者同步上下文重新抛出(若调用者为 gui 线程则会造成进程终止)
- 若异步操作执行很快,await 操作符生成的代码会做检测,如果异步操作在线程返回前完成,则阻止线程返回,直接执行下一行代码
- task.run 可以接受标记为 async 的异步 lambda 表达式
- 应用程序及线程处理模型
- gui 应用程序引入的线程处理模型中 ui 元素只能由创建它的线程更新
- 当异步操作完成时,由一个线程池线程完成 task 对象并恢复状态机
- 对于某些应用程序模型没问题,但对另一些如 gui 应用程序就会有问题
- 因此线程池必须以某种方式告诉 gui 线程更新 ui 元素
- synchronizationcontext 派生对象将应用程序模型连接到它的线程处理模型
- 等待一个 task 时会获取调用线程的 synchronizationcontext 对象
- 线程池线程完成 task 后,会使用该 synchronizationcontext 对象,确保为应用程序模型使用正确的线程处理模型
- 所以当 gui 线程等待一个 task 时,await 操作符后面的代码保证在 gui 线程上执行,使代码能更新 ui 元素
- gui 应用程序引入的线程处理模型中 ui 元素只能由创建它的线程更新
# 第二十九章 基元线程同步构造
- 一个线程池线程阻塞时,线程池会创建额外线程,创建、销毁和调度线程所需时间比较昂贵 —— 不要阻塞拥有的线程,使它们能重用于执行其它任务
- 假定一个线程池线程试图获取一个它暂时无法获取的锁(导致阻塞),线程池就可能创建一个新的线程
- 当阻塞线程再次运行时,会与创建的新线程一块运行,导致 cpu 需要调度比核心数更多的线程,增加上下文切换的几率
- 多个线程同时访问共享数据时,线程同步能防止数据损坏
- 多线程测试应该在 cpu 核心尽量多的机器上测试,因为 cpu 核心数量越多,多个线程同时访问资源的概率越大
- 应尽可能避免线程同步
- 只读访问是没问题的
- 基元用户模式和内核模式构造
- 应尽量使用基元用户模式构造:它们使用特殊 cpu 指令协调线程(意味着协调是在硬件发生,所以更快)
- 但是用户模式构造在等待构造过程中,线程会一直浪费 cpu 时间
- 基元内核模式构造
- 由操作系统系统自身提供
- 要求在应用程序的线程中调用由操作系统内核实现的函数
- 将线程从用户模式切换为内核模式(或相反)会导致巨大的性能损失(因此要避免使用)
- 优点:线程通过内核模式构造获取其它线程拥有资源时,Windows 会阻塞线程以避免它浪费 cpu 时间
- 对于一个构造上等待的线程,若拥有构造线程不释放
- 用户模式构造:线程将一直在一个 cpu 上运行,形成 活锁
- 内核模式构造:线程将一直阻塞,形成 死锁
- 于是有了 混合构造
- 应尽量使用基元用户模式构造:它们使用特殊 cpu 指令协调线程(意味着协调是在硬件发生,所以更快)
- 用户模式构造
- 易变构造(volatile construct)
- 在特定时间,它在包含一个简单数据类型的变量上执行原子性的读 『或』 写操作
- 互锁构造(interlocked construct)
- 在特定时间,它在包含一个简单数据类型的变量上执行原子性的读 『和』 写操作
- 所有易变和互锁构造都要求传递对包含简单数据类型的一个变量的引用(内存地址)
- 易变构造详情
- Volatile.Write:方法强迫变量的值在调用时写入。此外,按照编码顺序,之前的加载和存储操作必须在调用 volatile 方法『之前』发生(调用之前其它变量的写入修改必须先完成,但不保证其它变量的写入执行顺序)
- Volatile.Read:方法强迫变量的值在调用时读取。此外,按照编码顺序,之后的加载和存储操作必须在调用 volatile 『之后』发生(调用方法之后的其它变量的读取都必须在这方法之后执行,但不保证后续其它变量的读取执行顺序)
- 注 1:简单来说,当线程通过共享内存相互通信时,调用 volatile.write 来写入最后一个值,调用 volatile.read 来读取第一个值
- 注 2:每个方法执行的都是一个 原子读取 或 原子写入
- 为简化操作,c# 编译器提供了 volatile 关键字:jit 编译器确保对易变字段的所有访问都是以易变读取或写入方式执行,该关键字还告诉 c# 和 jit 编译器不将字段缓存到 cpu 的寄存器中,确保所有读写操作都在 ram 中进行
- volatile 关键字可能会比 volatile 方法更慢(因为线程可能并不要求在所有地方访问都是易变的),不支持以传引用方式传递 volatile 变量
- 互锁构造详情
- 每个方法都执行一次 原子读取 及 写入操作
- 每个 interlocked 方法都建立了完整的内存栅栏(memory fence)
- 即调用某个 interlocked 方法之前的任何变量写入都在这个方法之前执行,而这个调用之后的任何变量读取都在这个调用之后读取
- 简单的自旋锁:通过在 while 循环中通过 interlocked 修改值一直判断直到成功
- interlocked anything 模式:在 interlocked 基础操作上,例如 interlocked.CompareExchange 执行更丰富的操作
- 结构体的 spanwait、spanlock 不要标记为 readonly,因为内部会有状态修改
- spanwait
- 内部调用 thread 的 sleep、yield、spanwait 方法
- sleep 休眠时间并不一定精确
- sleep (0):告诉系统表示放弃线程当前时间片的剩余部分,强迫系统调度另一个线程
- yield:要求 Windows 在当前 cpu 上调度另一个线程(若没有则返回 false,调用 yield 的线程继续运行)
- 调用 yield 介于 sleep (0) 和 sleep (1) 之间:sleep (0) 不允许较低优先级线程运行、sleep (1) 则总是强迫进行上下文切换(由于内部系统计时器解析度原因,sleep 至少 1ms)
- thread.spanwait:强迫当前线程暂停,使 cpu 有机会切换到另一个线程运行(实际执行一个特殊 cpu 指令,非超线程 cpu 会被忽略?)
- 易变构造(volatile construct)
- 乐观并发模式
- 假设多线程操作互不干涉
- 在提交数据更新前,检查数据是否被修改
- 若被修改,则回滚重新执行
- 适用于数据争用不大,冲突较少环境,偶尔回滚事物成本低于读取数据时锁定数据的成本
- do~while 循环中执行操作,并使用 interlocked.compareexchsnge 判断是否修改成功,修改失败则重新执行代码块
- 内核模式构造
- 为什么慢:要求操作系统配合、在内核对象上调用的每个方法都造成调用线程从托管代码转换为本机用户模式代码,再转换为本机内核模式代码,然后,还要朝相反方向一路返回(这些转换耗费更多 cpu 时间)
- 优点:发生竞争时,没有竞争赢的线程会阻塞,能更有效节省资源
- WaitHandle
- EventWaitHandle(事件)
- AutoResetEvent
- ManualResetEvent
- Semaphore(信号量)
- Mutex(基于事件和信号量)
- EventWaitHandle(事件)
- 在一个内核模式构造上调用的每个方法都代表一个完整的内存栅栏
- 所有这些类的构造器都在内部调用 win32 的 createevent、createsemaphore 或 openmutex 并传递一个 string 实参
- 内核模式构造的一个常见用途是创建在任何时刻只允许它的一个实例运行的应用程序
- 事件(event)
- 其实只是由内核维护的 boolean 变量,事件为 false ,在事件上等待的线程就阻塞;事件为 true ,就解除阻塞
- 自动重置事件 (AutoResetEvent):为 true 时只唤醒一个阻塞的线程 (因为在解除第一个阻塞线程时,内核将事件自动重置回 false,造成其余线程继续阻塞)
- 手动重置事件 (ManualResetEvent):解除正在等待它的所有线程的阻塞,因为内核不会将事件自动重置回 false
- 信号量 (semaphore)
- 其实就是由内核维护的 int32 变量
- 信号量为 0 时,等待的线程会阻塞
- 信号量大于 0 时,解除阻塞
- 在信号量上等待的线程解除阻塞时,内核自动从信号量计数减 1
- 同时信号量还关联一个最大计数
- 自动重置事件在行为上和最大计数为 1 的信号量相似,区别在于:自动重置事件连续多次调用 set 无妨,信号量上连续多次调用 release 会使内部计数一直递增(超过最大计数会抛出异常)
- 互斥体 (mutex)
- 与 autoresetevent 及 计数为 1 的 semaphore 类似
- 三者都是一次只释放一个正在等待的线程
- 互斥体有一些额外逻辑,造成比其它构造更复杂
- 查询并记录调用线程 id,确保调用 releasemutex 的是获取 mutex 的线程(如果不是就会抛出异常)
- 拥有 mutex 的线程因为任何原因终止,在 mutex 上等待的线程会被唤醒并抛出异常
- mutex 还维护一个递归计数,指出拥有该 mutex 线程拥有了它多少次:拥有线程再次在 mutex 上等待会递增计数,releasemutex 递减计数,只有计数变成 0,另一个线程才能成为 mutex 的拥有者 zc
- 即 mutex 对象支持递归
- 因此 mutex 会更慢
- 套间(apartment)
- 定义了一组对象的逻辑组合,这些对象共享同一组并发性和重入限制
- 应该线程想要使用 com,必须先进入一个套间
- com 规定,只有运行在对象套间中的线程才能访问该对象
- STA(单线程套间)、MTA(多线程套间)
# 第三十章 混合线程同步构造
- 为了提升应用程序总体性能,可以让一个现在在用户模式 自旋 一小段时间,再让线程转换为内核模式
- 在 clr 中,对任何锁方法的调用都构成了一个完整的内存栅栏
- 在栅栏之前写入的任何变量都必须在栅栏之前完成
- 在栅栏之后的任何变量读取都必须在栅栏之后开始
- monitor 与同步块
- monitor 提供了支持自旋、线程所有权、和递归的互斥锁
- 堆中每个对象都可以关联一个名为 同步块 的数据结构
- 同步块包含字段,为内核对象、拥有线程的 id、递归计数及等待线程计数提供相应字段
- monitor 接受任何堆对象引用,其方法对指定对象的同步块中字段进行操作
- 为堆中每个对象都关联一个同步块数据结构很浪费,为节省内存 clr 团队采用一种更经济的方式提供上述功能
- clr 在初始化时在堆中分配一个同步块数组
- 每当一个对象在堆中创建时,都有两个额外字段与其关联:类型指针 (包含类型的类型对象的内存地址)、同步块索引 (包含同步块数组中的一个整数索引)
- 一个对象在构造时,其同步块索引初始化为 -1,表明不引用任何同步块
- 调用 monitor.enter 时,clr 在数组种找到一个空白同步块,并设置对象的同步块索引,使其引用同步块
- 调用 monitor.exit 时,会检查是否有其它任何线程正在等待使用对象的同步块
- 若没有线程在等待它,同步块就自由了,exit 将对象同步块索引设回 -1
- 自由同步块将来可以被另一个对象关联复用
- 注:因此每个对象的同步块索引都隐式为公共的,因此推荐使用私有锁
- 问题
- 变量能引用一个代理对象 (派生自 marshalbyrefobject),但锁定的是代理对象而不是代理引用的实际对象
- 传递类型对象引用,类型对象以 appdomain 中立方式加载的话,线程就会跨越进程中所有 appdomain 在那个类型上获取锁,破坏了 appdomain 提供的隔离能力
- 字符串可以留用,跨越 appdomain 传递字符串时传递的是引用,字符串关联的同步块索引可变(所以不要用字符串做锁)
- 传递值类型会导致装箱,每次都相当于是不同对象
- MethodImpl (MethodImplOptions.Synchronized) 特性会导致方法被 monitor 包围,若为实例方法,实例类型会被传递、若为静态方法,类型的类型对象会被传递,造成锁定 appdomain 中立类型(不要使用该特性)
- 调用类型构造器(静态构造器)时 clr 为了确保只有一个线程初始化类型对象及其静态字段,也会加锁,也可能会有问题 —— 因此尽量避免使用类型构造器,或使其尽量简短
- 不建议使用 lock 语句
- 其中使用 try catch 捕获异常,可能会导致线程访问损坏数据
- 这也会导致性能降低
- lockTaken 变量:假设一个线程进入 try 块,且在调用 monitor.enter 前退出,就不应该调用 finally 种的 exit 释放锁,该变量初始化为 false ,enter 将其设为 true,以解决这个问题(spinlock 也支持这个模式)
- ReaderWriterLockSlim
- 一个线程向数据写入时,阻塞请求访问的其它所有线程
- 一个线程从数据读取时,其它请求读取线程运行进入,请求写入线程被阻塞
- 请求写入线程执行完毕后,要么解除一个写入线程阻塞,要么解除所有读取线程阻塞
- 请求读取的所有线程结束后,解除一个请求写入线程阻塞
- 该类支持线程所有权和递归功能,不过这个功能代价较高且很少需要,可以在构造函数传入 lockrecursionpolicy.norecursion
- 注:为了以线程安全维护这些信息,内部甚至要使用一个 互斥的自旋锁
- 提供方法将 reader 线程升级为 writer 线程(降低性能,一般也没什么用)
- 自己实现一个读写锁会更快:要么允许一个 writer 线程访问,要么允许多个 reader 线程访问
- Countdownevent
- 使用一个 manualeventresetdlim
- 阻塞一个线程,直到内部计数器变成 0(行为与 semaphore 相反)
- 一旦其 currentcount 变成 0 后,就不能再更改了(再更改会抛出异常,tryaddcount 会返回 false)
- Barrier
- 这个类型用于处理比较稀有问题,一般用不上
- 控制的一系列线程需要并行工作,以在一个算法不同阶段推进
- 当 clr 使用垃圾回收器服务器版本时,gc 算法为每个内核都创建一个线程
- 线程同步构造小结
- 代码尽量不阻塞任何线程
- 避免多个线程同时操作数据
- 尽量使用 volatile 和 interlocked 方法(或 视情况使用 interlocked anything 模式)
- 不要刻意把线程 打上标签:即不要创建用于特定任务的线程,而应该从线程池出租短暂时间
- 要在一些列操作中原子性地操作状态,可以使用 monitor,也可使用 readerwriter 锁代替 monitor,不过需要清楚场景。另外还有 spinlock 比 monitor 快,不过更可能浪费 cpu 时间
- 双检锁技术(double-check locking)
- 经常用于 将单例推迟到首次请求该对象时进行
- 单例延迟初始化前提是:可能根本不需要创建单例,否则在类构造器中创建单实例对象更经济和简单
- 在锁操作中,创建的变量对象可能被更换顺序
- 想法是:生成对象 (分配内存)- 调用构造器初始化 - 赋值
- 实际有可能:生成对象 (分配内存)- 赋值 - 调用构造器
- 正常情况没事,但要是在给变量赋值后,调用构造器之前另一个线程访问了就有问题了
- 为解决这种极端问题,可以使用 volatile 方法为变量赋值(将变量直接标记为 volatile 也可以,但是会使所有读取操作具有易变性,导致性能无谓损害)
- 可能根本不需要创建单例可以使用双检锁,否则可以使用 贪婪模式,利用 clr 保证对调用类构造器的线程安全性特点:首次访问类的任何成员都会调用类构造器
- 缺点:若类定义了其它任何静态成员,就会在访问其它任何静态成员时创建 单例 对象
- 懒加载模式
- Lazy 泛型:也可以传递线程安全枚举支持
- executionandpublication 使用双检锁技术
- publicationonly 使用 interlocked.compareexchange 技术
- LazyInotializer 静态方法
- Lazy 泛型:也可以传递线程安全枚举支持
- 条件变量模式
- 自旋:让线程连续 自旋,反复测试条件
- 但是自旋会浪费 cpu 时间,而且不能对构成复合条件的多个变量进行原子性的测试
- monitor 支持根据一个复合条件来同步操作,而且不会浪费 cpu 资源:wait、pulse (解除等待最久线程)、pulseall
- 异步的同步构造
- 避免阻塞线程,否则 cpu 会创建更多线程处理任务,使用异步函数等待互斥资源
- semaphoreslim
- waitasync
- 一般创建最大计算为 1 可实现保护资源的互斥访问
- 与 monitor 类似,只是不支持线程所有权和递归(正好)
- reader-writer 语义:concurrentexclusiveschedulerpair
- 并发集合类
- 尽量不要调用阻塞接口
- concurrentstack、concurrentqueue
- 内部使用 interlocked 方法操纵集合
- concurrentbag
- 每个线程一个迷你集合,使用 interlocked 添加数据至迷你集合
- 试图取出数据时,先检查调用线程的迷你集合,若没有,再使用 monitor 检查其它线程的迷你集合(『窃取』数据项)
- concurrentdictionary
- 内部使用 monitor,不过对数据项进行操作时,锁只被占用极短时间
- concurrentstack、concurrentqueue 和 concurrentbag 都实现了 iproducerconsumercollection 接口,可通过 blockingcollection 转变为一个阻塞集合(生产者 - 消费者 模式)
- 尽量不要使用,它们生命意义就在于阻塞线程
- 内部使用 semaphoreslim 进行控制
- completeadding 方法通知消费者不再生产,造成 getconsumingenumerable 的一个 foreach 循环终止