多线程解决方案及性能

多线程解决方案及性能

程序操作的开销: (取自<<深入理解并行编程>>表3.1)

 

操  作

开  销(ns)

比  率

单周期指令

0.6

1.0

最好情况的CAS

37.9

63.2

最好情况的锁

65.6

109.3

单次缓存未命中

139.5

232.5

CAS缓存未命中

306.0

510.0

光纤通信

3,000

5000

全球通信

130,000,000

216,000,000

 

一些与并发有关的概念

临界区

由同步机制保护的一段代码. 其执行过程受到同步原语的约束. 例如如果一组临界区受同一个全局锁的保护, 那么在同一时刻只有一个临界区能被执行. 如果某个线程正在这样的一个临界区中执行, 那么其他线程在执行临界区之前必须等待第一个线程执行完临界区.

 

 

数据锁

一种可扩展的锁设计. 对于特定的数据结构, 他的每一个实例都有自己的锁. 如果每一个线程使用数据结构的不同实例, 那么这些线程可以在临界区中并发执行. 其优势是只要数据实例增长, 就能自动扩展到增加的CPU中.

 

代码锁

这是一种简单的锁设计, 它是一种全局锁, 用于保护一组临界区. 这样是允许还是拒绝一个线程对这组临界区的访问, 是基于当前正在访问这些临界区的线程集合. 而不是基于线程想要访问的那些数据. 基于代码锁的程序, 增加数据集的大小通常不会增加扩展性. 与之相对的是数据锁.

 

 

互斥锁

互斥锁是一种互斥机制. 在同一时刻, 他仅仅允许一个线程进入被锁保护的临界区运行.

 

无效化

当一个CPU想要写入数据, 它必须首先确保该数据没有存在于其他CPU缓存中. 如果有必要, 需要从写数据的CPU向拥有该数据的CPU发送Invalidation(使无效)消息

 

RCU (Read-Copy-Update)

一种同步机制, 可以认为是读写锁和引用计数的替代品. RCU提供了极低的读端负载. 为了让已经存在的读端获得其优势, 写端为了已有读端的利益, 需要额外的负载来维护读端的旧版本数据. 读端既不阻塞也不自旋, 因此不会形成死锁. 而且它们也会看到过时的数据, 并能与写端并发运行. 因此, RCU非常适合用在大量读的情形, 其过时的数据可以被容忍或避免.

 

读临界区

被读写同步机制的读锁所保护的代码片段. 例如, 如果一个临界区被某个全局读写锁的读锁保护, 另一个临界区被同一个读写锁的写锁保护, 那么第一个临界区就是该锁的读临界区. 任意数量的线程可以并发的在读临界区中运行. 但是仅仅在没有写临界区运行时, 读临界区才能并发运行.(不能发生写操作)

 

写临界区

被读写同步机制的读锁所保护的代码片段. 例如, 如果一个临界区被某个全局读写锁的写锁保护, 另一个临界区被同一个读写锁的读锁保护, 那么第一个临界区就是该锁的写临界区. 在同一时刻, 只能有一个线程运行在写临界区中, 并且此时不能有任何线程运行在读临界区中.

 

设计模式与锁粒度

数据所有权 -释 放-> 数据帧 -批处理-> 代码锁 -批处理-> 单线程程序

数据所有权 <-分 割- 数据帧 <-分 割- 代码锁 <-拥  有- 单线程程序

 

解决线程不安全的一些方案

  1. Synchronized 代码锁

Synchronized是一个内存屏障. 限制编译器和CPU对指令乱序执行的优化.

Synchronized关键字的性能: 在1.8之后, Synchronized经过了优化, 在竞争激烈时性能差, 但是比CAS效率更高.

 

  1. CAS操作

针对单一变量, 在适用场景的效率很高, 但是可扩展性差. 锁竞争激烈时耗费时间长, 大量的失败重试和自旋消耗性能. CAS需要有2个原子操作.

原子计数的性能随线程的增加而降低.

原因: 在多CPU的内核中, 为了让所有CPU都能计数, 包含变量的缓存行在所有CPU间传播, 通过两兄弟CPU的互联模块和系统互联模块传输. 这种传播非常耗时.  --> 数据所有权. 每个CPU只更新自己的计数 把所有的线程计数相加就得到了总的计数.

一种方法是使用数组, 数组的每个元素代表一个CPU, 每个CPU可以快速的增加自己线程的变量值. 就不再需要代价高昂的跨越整个计算机系统的通信.

然而这种在更新端扩展性极佳的方法, 在存在大量的线程时, 会带来巨大的读取端的执行时间.

 

以牺牲数据的及时性获取在读取端的高扩展. 专门有一个线程负责把每线程计数传给全局计数, 读者只读取全局计数. 一旦写者更新完毕, 计数结果总是正确的. 这就是最终结果一致性.

 

  1. Lock ( ReentrantLock 读写锁 )

读写锁允许任意数量的线程, 但是仅仅允许一个写线程进入由锁保护的临界区. 在读写锁保护的临界区中, 只要有一个写线程, 就将阻止其他所有的读线程进入临界区, 反之亦然.

读写锁的的最关键之处在于公平性. 不停止的读者将饿死写者, 反之亦然.

在写操作多时性能很差. 只适合用在写次数不多的情况.

 

  1. 并行快速路径

并行快速路径是一种思路, 它的含义是在多数情况没有线程间通信和交互的开销, 而偶尔进行的跨进程通信又使用了精心设计的 ( 开销仍然很大 ) 全局算法. 例如最大线程计数: 总数不能近似超过某个值.

给每个线程一个每线程计数和max计数. 当线程计数达到max计数时, 把当前计数的一半分给总计数. 这样只在计数max/2的次数后才需要加一次锁, 大大降低了锁的竞争程度. 如果要更精确些, 就把max设的值小一点.

例如资源分配器缓存:

 

  1. RCU保护

见定义. 在允许过时数据的情况下具有良好的扩展性.并且可以采取措施消除过时数据

 

  1. 分割

双端队列进行并发操作的锁实现

哈希双端队列: 哈希永远是分割一个数据结构的最简单和最有效的方法.

 

双端队列的实现是 ”垂直并行化” 或者 ”管道” 的极佳例子.

 

  1. 层次锁

在获取一把粗粒度的锁同时, 再获取一把细粒度的锁. 但是我们花费了第二次获取锁的开销, 仅仅持有很短的时间. 在这种情况下, 简单的数据锁性能更好.

锁的层次是指为锁逐个编号, 禁止不按顺序获取锁. 这样当线程已经获取了编号相同或更高的锁, 就不允许再次获取编号相同或编号更低的锁.

层次锁的库函数: 幸运的是, 库函数一般不需要调用用户的代码, 因此库函数获取了自己的锁, 它绝不会再去获取调用者的锁, 这样避免出现库函数和调用者之间互相持锁的死循环. 如果万一某个库函数调用了调用者的代码, 比如qsort()调用了一个调用者提供的比较函数, 恰恰这个qsort()比较复杂也 使用了锁, 那么就可能出现死锁. 出现这种情况的黄金定律是: 在调用未知代码前释放所有锁. qsort()函数在调用比较函数前释放它持有的全部锁.

如果每个模块都在调用未知的方法之前都释放全部的锁, 那么每个模块自身都避免了死锁.

 

  1. 数据所有权

数据所有权方法按照线程或者CPU个数分割数据结构. 在不需要任何同步开销的情况下,每个线程或者CPU都可以访问属于它的子集.但是如果线程A想访问线程B的数据, 必须通过线程间通信, 或者把数据迁移到线程A上来.

 

  1. 资源分配器缓存

解决在大多数情况下快速的分配和释放内存, 在特殊的情况下高效的分配和释放内存之间的矛盾.

 

  1. 对锁友好的数据结构

可分割的Map集合

 

  1. 顺序锁

 

顺序锁主要用于保护以读取为主的数据. 多个读者观察到的状态必须一致. 不像读写锁, 顺序锁不能阻塞写者. 它反而更像危险指针, 如果检测到有并发的写者, 顺序锁强制读者重试. 使用顺序锁的时候, 设计很重要, 尽量不要让读者有重试的机会.

实现的关键是序列号. 没有写者时为偶数值, 如果一个更新正在进行中, 序列号是奇数值. 读者在每次访问之前和访问之后把序列号对照. 如果序列号是奇数, 或者两个快照的值不同, 则存在并发更新. 此时读者必须丢弃全部的数据重试.

 

  1. 危险指针

反向实现引用计数, 而不是增加存储在数据元素中的某个整数. 在每线程链表中存储指向该元素的指针, 这个链表里的元素被称为危险指针. 每个数据元素有一个”虚拟引用计数”, 其值可以通过计算有多少个危险指针指向该元素得到. 因此, 如果该元素已被标记为不可访问, 并且不可以有任何引用它的危险指针, 该元素就可以被安全的释放.

 

 

  1. 监视器

生产者和消费者问题. 可以减少锁竞争

 

  1. 通道

生产者 - 消费者模式可以提供高效的数据通信, 而不依赖于信号量, 互斥量或监视器来进行数据传输. 在单个生产者和单个消费者的情况下, 使用通道不必产生端对端的原子开销.

  1. 避免了对共享变量的原子操作(数据所有权)
  2. 不适用于多个生产者和消费者
  3. 避免了线程陷入阻塞. 取消了额外的同步开销

精确计数时性能的比较

数组:

每线程变量:

添加一致性的延迟处理:

 

使用信号代替原子变量可以减轻性能损失.

 

总结:

并行化只是优化的一种. 如果最后发现性能没有明显提升, 不建议使用并行方案. 需要重新考虑方案的设计, 进行更多优化.

猜你喜欢

转载自blog.csdn.net/secret_breathe/article/details/82432230