读书笔记—CLR via C#同步构造28-29章节

前言

这本书这几年零零散散读过两三遍了,作为经典书籍,应该重复读反复读,既然我现在开始写博了,我也准备把以前觉得经典的好书重读细读一遍,并且将笔记整理到博客中,好记性不如烂笔头,同时也在写的过程中也可以加深自己理解的深度,当然同时也和技术社区的朋友们共享

类库和线程安全

在类设计中,类和方法的线程安全的设计尽量和FCL保持一致

  • 保证所有的静态方法都是线程安全的
  • 不保证实例方法是线程安全的

基元用户模式和基元内核模式构造

用户模式构造

  • 易失构造(volatile construct),它包含一个简单数据类型的变量上执行原子性的读或写操作
    • VolatileWrite,强迫address中的值在调用时写入。除此之外,按照程序顺序,在之前的加载和存储操作必须在调用VilatileWrite之前发生。也就是说调用VolatileWrite来写入最后一个值
    • VolatileRead,强迫address中的值在调用时读取。除此之外,按照程序顺序,在之后的加载和存储操作必须在调用VilatileRead之后发生。也就是说调用VolatileRead来读取第一个值
    • MemoryBarrier,不访问内存,强迫按照程序顺序,之前的加载和存储操作在调用MemoryBarrier之前完成。与此同时,之后的加载和存储操作在调用MemoryBarrier之后完成
    • C#语法volatile关键字,支持任意类型的静态或实例字段
    • JIT编译器确保对易失字段的所有访问都是以易失读取或者易失写入的方式执行,不要求显式调用Thread的静态VolatileRead或VolatileWrite方法。另外volatile关键字告诉C#和JIT编译器不将字段缓冲到CPU的寄存器中,确保字段的所有读写操作都在RAM中执行
    • volatile的限制
      • 强制全局易失构造,放弃了优化,影响了性能。弥补方式可以单独使用Thread.VolatileWrite和Thread.VolatileRead方法代替
      • C#不支持以传引用的方式将volatile字段传给方法
  • 互锁构造(interlocked construct),它包含一个简单数据类型的变量上执行原子性的读和写操作
    • Interlocked,每个方法都执行一次原子读取以及写入操作。除此之外,Interlocked的所有方法都建立了完整的内存栅栏。换言之,调用某个Interlocked方法之前的任何变量写入都在这个Interlocked方法调用之前执行;而这个调用之后的任何变量读取都在这个调用之后读取
    • Interlocked的使用性能非常高,使用场景也非常多,比如在事件的add和remove操作当中,使用了Interlocked方法来实现线程安全
  • Other Tips
    • SpinWait结构内部调用Thread的静态Sleep、Yield和SpinWait方法
    • Sleep(0),告诉系统,当前线程放弃当前时间片的剩余部分。强迫系统调度另一个线程。然而系统可能重新调度刚刚调用Sleep的线程,不允许较低优先级的线程运行
    • Sleep(1),总是强迫进行一次上下文切换,而由于内部系统计时器的解析的问题,Windows总是强迫线程睡眠超过1毫秒的世界
    • Yield,使处于“饥饿”状态的、具有相等或更低优先级的一个线程有机会得以运行
    • SpinWait方法,强迫自身暂停,运行超线程CPU切换到另一个线程

内核模式构造

WaitHandle基类内部有一个SafeWaitHandle字段,容纳一个Win32内核对象句柄。构造时初始化。内核模式构造的每个方法都代表一个完整的内存栅栏。

  • WaitHandle
    • EventWaitHandle,内核维护Boolean变量。如果事件为false,在事件上等待的线程就阻塞,如果为true就解除阻塞。
      • AutoResetEvent,自动重置,只唤醒一个阻塞的线程,自动重置为false
      • ManualResetEvent ,手动重置,解除正在等待它的所有线程的阻塞,必须将事件手动重置为false
    • Semaphore,内核维护Int32变量。信号量为0时,在信号量上等待的线程会阻塞;信号量大于0时,就解除阻塞。在一个信号量上等待的线程解除阻塞时,内核自动从信号量的计数中减1
    • Mutex,互斥锁,工作方式和AutoResetEvent或计数为1的Semaphore类似,因为都只释放一个正在等待的线程
      • 线程检查
      • 异常处理及数据保护
      • 支持递归锁(可以手动实现递归锁,性能较好)
  • ThredPool.RegisterWaitForSingleObject,无须让线程无谓地等待内核对象,而是在一个内核对象变得可用时调用一个方法
  • 使用场景
    • 多个线程在一个自动重置事件上等待时,设置事件只导致一个线程被解除阻塞
    • 多个线程在一个手动重置事件上等待时,设置事件会导致所有线程被解除阻塞
    • 多个线程在一个信号量上等待时,释放信号量导致releaseCount个线程被解除阻塞

用户模式 vs 内核模式

  • 内核模式的构造比用户模式的构造慢得多
    • 它们需要Windows操作系统自身的协作
    • 托管代码》本地用户模式代码》本地内核模式代码
  • 内核模式的优点
    • 出现资源竞争时,内核模式不会占着CPU自旋无谓浪费CPU资源
    • 内核模式可实现本地和托管线程之间的同步
    • 内核模式构造支持跨进程同步
    • 内核模式构造支持安全性设置,防止未经授权账号访问
    • 内核模式拥有更好更强大的线程同步能力(协作、超时等)

混合构造

在没有线程竞争的时候,混合构造提供了基元用户模式构造所具有的性能上的优势。多个线程同时竞争一个构造时,混合构造还使用了基元内核构造模式来提供不”自旋“的优势。这也大大提高了性能

  • ManualResetEventSlim
  • SemaphoreSlim
  • Monitor和同步块,支持自旋、线程所有权和递归
    • 在一个对象构造时,对象的同步块索引初始化为-1,表明它不引用任何同步块。调用Monitor.Enter时,CLR在数组中找到一个空白同步块,并设置对象的同步块索引来引用该同步块。也就是说同步块和对象是动态关联的。调用Exit时,会检查是否有其他任何线程正在等待使用对象的同步块。如果没有线程等待它,同步块就自由了,Exit将对象的同步块索引设回-1,自由的同步块可以和另一个对象关
    • 建议:
      • 永远都不要向Monitor的方法传递一个类型对象引用
      • 永远都不要向Monitor的方法传递string字符串引用
      • 向Monitor方法传递值类型会引起装箱,造成线程获取被装箱对象上的锁。每次Enter都是针对不同的装箱锁对象,无法真正实现同步
      • [MethodImpl(MethodImplOptions.Synchronized)],传递this或者类型对象的引用,锁住方法范围的代码,建议不要使用
      • 类型构造器是线程安全的,所以类型构造器尽量保持短小和简单
      • JR说杜绝使用C#的lock关键字,为什么?没看懂啊?(应该是在对数据保护极度苛刻的场景)
  • ReaderWriterLockSlim
    • 一个线程向数据写入时,请求访问的其他所有线程都被阻塞
    • 一个线程从数据读取时,请求读取的其他线程允许继续执行,但请求写入的线程仍被阻塞
    • 向数据写入的一个线程结束后,要么解除一个writer线程的阻塞,使他能向数据写入,要么解除所有reader线程的阻塞,使他们能并发读取数据
    • 从数据读取的所有线程结束后,一个writer线程被解除阻塞,使它能向数据写入
    • 允许reader升级为writer,也允许writer降级为reader线程。这样会损耗性能,不建议使用
    • 同样支持递归
    • 自己设计读写锁的实现,参考OneManyLock
  • CountdownEvent
    • 这个构造在内部使用ManualResetEventSlim,阻塞线程,直到内部计数器变为0。行为看上去和Semaphore相反。
  • 建议:
    • 尽量避免阻塞线程,除非特殊用途
    • 尽量避免使用递归锁(尤其是递归的reader-writer锁),它会损害性能
    • 避免长时间占有锁(可用并发集合代替)
    • 对于计算限制的工作,可使用任务避免使用大量线程同步构造
    • 对于IO限制的工作,使用APM加回调

并发集合

  • ConcurrentQueen,FIFO,内部使用Interlocked
  • ConcurrentStack,LIFO,内部使用Interlocked
  • ConcurrentDictionary,无序键值对集合,内部使用Monitor
  • ConcurrentBag,无序数据项集合,允许重复项
  • 并发提供枚举查询功能,Queen、Stack和Bag获取快照,Dict不获取快照所以可能会变。前三者实现了IProducerComsumerCollection生产者消费者接口,可以转变为一个阻塞集合。如果集合已满,那么负责生产数据项的线程会阻塞,如果集合已空,那么负责消费数据项的线程会阻塞。
  • 非阻塞-》System.Collections.Concurrent.BlockingCollection-》阻塞集合

Other Tips

  • 在CLR中,对任何锁方法的调用都构成一个完整的内存栅栏,在栅栏之前写入的任何变量都必须在栅栏之前完成;在遮拦之后的任何变量读取都必须在栅栏之后开始
  • 双检锁中,使用用户基元对象Interlocked进行初始化赋值,这个分析可谓独到,虽然是极少见的使用场景,但是值得注意。这属于易失构造,当然还可以使用volatile关键字来解决问题
  • Lazy内部包装的策略
    • None:完全没有线程安全劫持(比如GUI应用程序)
    • ExecutionAndPublication(使用双检锁技术)
    • PublicationOnly(使用Interlocked.CompareExchange技术)
    • 可以使用LazyInitializer来代替Lazy对象