七周七并发模型:线程与锁

Posted by 蔡华的博客 on January 17, 2018

为何使用多线程和锁

  • 为了能够并行的计算一些东西
  • 因为在多线程下,如果要修改同一个对象是值会出现竞态条件(即代码行为取决于各操作的时序)。它的表现可能是值的不对,甚至某些时候这个值的前半段是一个线程修改的,后半段是另一个线程修改的。因为这个对象可能不是原子的。

锁,或者说是并行控制的进化

第一阶段:锁与同步方法

基础的锁

  • 从代码层面看就是一个lock函数

同步方法

  • 在java或者C#中可以通过attribute来让方法同步执行,但是这个的效率是很低的。
  • 同时还引出了另外一个问题,当同时使用多个锁的时候不但效率会进一步降低,而且容易产生死锁。

基础锁带来的问题

死锁
  • 当出现多个锁的时候,因为执行顺的问题,导致多个对象都持有一个锁,同时在等待其他人释放锁。例子就是哲学家进餐问题。
  • 哲学家进餐问题的解决方法就是:

    一个线程想使用多把锁时,就需要考虑死锁的可能。幸运的是,有一个简单的规则可以避开死锁——总是按照一个全局的固定的顺序获取多把锁。

外星方法问题
  • 所谓外星方法指的是在锁的block中调用了一个外部的方法,但是这个方法中可能会要求获得当前锁的权限,但是因为这个锁正在被使用,导致了代码出现死锁。
  • 解决方案:
    • 唯一的解决思路是避免持有锁时调用外星方法;
    • 一种方法是在遍历之前对listeners进行保护性复制(defensive copy),再针对这份副本进行遍历;
    • PS:但是我的理解是这个情况只有当数据是并发read时,如果是并发的write想来就有问题了。

第二阶段: 进击的锁

中断死锁

  • 死锁问题是很让人沮丧的,而且也是很多时候都会遇到的。在我们的C#代码中使用lock来做锁,如果遇到死锁,我们是没有办法终止这个被锁的线程的。但是好在还有办法可以结束锁,在java中就是书中说的ReentrantLock ,对应的在C#中是Monitor
  • PS:java的部分可以看下这篇文章
  • PS2:两个类的功能基本是一致的,都包含了超时的功能。

问题是否得到了解决?

虽然tryLock()方案避免了无尽地死锁,但这并不是一个足够好的方案。首先,这个方案并不能避免死锁——它只是提供了从死锁中恢复的手段。其次,这个方案会受到活锁现象的影响——如果所有死锁线程同时超时,它们极有可能再次陷入死锁。虽然死锁没有永远持续下去,但对资源的争夺状况却没有得到任何改善。

有一些方法可以减小活锁的几率。比如为每个线程设置不同的超时时间,来减少所有线程同时超时的几率。但通过设置超时来处理死锁不能说是一个好的方案——以后我们还可以做得更好。

条件变量

  • C#中的Monitor有wait、Pulse和PulseAll,对应了ReentrantLock的reachSixCondition对应。用法参考这里
  • 该方法可以显著调高并发度

原子变量

  • C#中可以使用Interlocked.Increment来做计算,这个是原子性的。在计数类的场景比较实用。
  • 这个是为了解决基础锁使用过程中对于数据的操作比较繁琐的问题。比如对于计数类的变量,需要保证其get\set都有锁的保护,这样写的方法就比较多了。
  • 原子变量是无锁(lock-free)非阻塞(non-blocking)算法的基础,这种算法可以不用锁和阻塞来达到同步的目的。无锁的代码比起有锁的代码更为复杂。

第三阶段:终极形态

  • 书中以一个大数据的单词解析为例。

线程池

  • 好处就是可以节约线程数,因此往往不能无限制的使用多线程。

影响线程池最优大小的因素有很多,例如硬件的性能、线程任务是CPU密集型还是IO密集型、是否有其他任务在同时运行。还有很多其他原因也会产生影响。

话虽如此,但也存在经验法则:对于CPU密集型的任务,线程池大小应接近于可用核数;对于IO密集型的任务,线程池可以设置得更大些。 当然,最佳的方法是建立一个真实环境下的压力测试来衡量性能

解决方案的进化

生产者消费者模式
  • 将数据的解析与计算分割为生产者与消费者。从而分析出性能的瓶颈在消费者(统计单词量)这边。
  • 使用多线程分别进行生产和消费。

关键在于生产者和消费者可能不会(几乎肯定不会)保持相同的速度。比如,当生产者的速度快于消费者的速度时,队列会越来越大。Wikipedia的dump差不多有40 GiB,很容易就让队列大小超过内存容量。 相比之下,阻塞队列只允许生产者的速度在一定程度上超过消费者的速度,但不会超过很多。

消费者端的并发执行
  • 将解析出来的数据放入到一个HashMap中
  • 在计算上,使用多线程来对HashMap进行统计,但是真正的计算方法本身采样lock。
  • 问题是从结果统计看并没有提高效率。其原因为:

答案是因为过度竞争——过多的线程尝试同时使用一个共享资源。在我们的程序中,消费者花费大量时间等待被其他消费者锁住的counts,它们的等待时间比实际运算时间还要长,最终导致惨烈的性能下降。

使用ConcurrentHashMap来解决共享资源的并发访问
  • C#中对应的是ConcurrentDictionary<TKey, TValue>.AddOrUpdate Method (TKey, TValue, Func<TKey, TValue, TValue>)。也就是并发集合,依赖于内置的并发集合来提高并发的访问。
使用多个ConcurrentHashMap
  • 使用ConcurrentHashMap并没有完全的释放多核的威力。
  • 对原来的数据进一步拆分,进而分配给不同的线程来统计,极大的利用了多核。

优缺点

优点

  • 线程与锁模型的最大优点是其适用面很广。它是其他许多技术的基础,适用于解决很多类型的问题。同时,线程与锁模型更接近于“本质”——近似于对硬件工作方式的形式化——正确使用时,其效率很高。这也意味着它能够解决从小到大不同粒度的问题。
  • 另外,这个模型可以被轻松地集成到大多数编程语言中。语言设计者们可以轻易让一门指令式语言或面向对象语言支持线程与锁模型。

缺点

  • 线程与锁模型没有为并行提供直接的支持。
  • 线程与锁模型仅支持共享内存模型。如果要支持分布式内存模型(无论是地理分布型或者容错型),就需要寻求其他技术的帮助。这也意味着线程与锁模型不适用于单个系统无力解决的问题。
  • 多线程的难点不在于难以编程,而在于难以测试。
  • 我们要全程保证所有对象的同步都是正确的、必须按照顺序来获取多把锁、持有锁时不调用外星方法。