type
status
date
slug
summary
tags
category
icon
password
Property
Apr 15, 2023 03:23 PM
通过锁可以使数据结构线程安全(thread safe)。
我们的挑战是:如何给数据结构加锁?对于特定数据结构,如何加锁才能让该结构功能正确?进一步,如何对该数据结构加锁,能够保证高性能,让许多线程同时访问该结构,即并发访问(concurrently)?
并发计数器
计数器是最简单的一种数据结构,使用广泛而且接口简单。
简单但无法扩展
如何让这段代码线程安全(thread safe):
这个并发计数器简单、正确。实际上,它遵循了最简单、最基本的并发数据结构中常见的数据模式:它只是加了一把锁,在调用函数操作该数据结构时获取锁,从调用返回时释放锁。
现在,有了一个并发数据结构,问题可能就是性能了。如果这个结构导致运行速度太慢,那么除了简单加锁,还需要进行优化。
可以看出,同步的计数器扩展性不好。线程更多时,性能更差。
这样做在
多CPU
环境下性能很差
。因为这种锁导致了多 CPU 情况下也只允许一个线程
在运行,其他都在自旋等待,没发挥出多 CPU 的优势。理想情况下,你会看到多处理上运行的多线程就像单线程一样快。达到这种状态称为完美扩展
(perfect scaling)。虽然总工作量增多,但是并行执行后,完成任务的时间并没有增加。
可扩展的计数
懒惰计数器通过多个
局部计数器
和一个全局计数器
来实现一个逻辑计数器,其中每个CPU核心
有一个局部计数器。具体来说,在 4 个 CPU 的机器上,有 4 个局部计数器和 1 个全局计数器。除了这些计数器,还有锁:每个局部计数器有一个锁,全局计数器有一个。懒惰计数器的基本思想是这样的:如果一个核心上的线程想增加计数器,那就增加它的局部计数器,访问这个局部计数器是通过对应的局部锁同步的。因为每个 CPU 有自己的局部计数器,不同 CPU 上的线程不会竞争,所以计数器的更新操作可扩展性好。
为了保持
全局计数器
更新(以防某个线程要读取该值),局部值会定期转移给全局计数器,方法是获取全局锁,让全局计数器加上局部计数器的值,然后将局部计数器置零。这种局部转全局的
频度
,取决于一个阈值
,这里称为 S(表示 sloppiness)。S 越小,懒惰计数器则越趋近于非扩展的计数器。S 越大,扩展性越强,但是全局计数器与实际计数的偏差越大。我们可以抢占所有的局部锁和全局锁(以特定的顺序,避免死锁),以获得精确值,但这种方法没有扩展性。时间 | L1 | L2 | L3 | L4 | G |
0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 1 | 1 | 0 |
2 | 1 | 0 | 2 | 1 | 0 |
3 | 2 | 0 | 3 | 1 | 0 |
4 | 3 | 0 | 3 | 2 | 0 |
5 | 4 | 1 | 3 | 3 | 0 |
6 | 5→0 | 1 | 3 | 4 | 5(来自 L1) |
7 | 0 | 2 | 4 | 5→0 | 10(来自 L4) |
并发链表
- 其实就是在对链表的头指针操作时加上锁
从代码中可以看出,代码插入函数
入口
处获取锁,结束时释放锁。如果 malloc 失败(在极少的时候),会有一点小问题,在这种情况下,代码在插入失败之前,必须释放
锁。我们调整代码,让获取锁和释放锁只环绕插入代码的
真正临界区
(缩小临界区)。前面的方法有效是因为部分工作实际上不需要锁,假定 malloc()是线程安全
的,每个线程都可以调用它,不需要担心竞争条件和其他并发缺陷。只有在更新共享列表时需要持有锁。下面展示了这些修改的细节。对于查找函数,进行了简单的代码调整,跳出主查找循环,到单一的返回路径。这样做减少了代码中需要获取锁、释放锁的地方,降低了代码中不小心引入缺陷(诸如在返回前忘记释放锁)的可能性。
扩展链表
手锁
(hand-over-hand locking,也叫作锁耦合
,lock coupling):每个节点都有一个锁,替代之前整个链表一个锁。遍历链表的时候,首先抢占下一个节点的锁,然后释放当前节点的锁。
它增加了链表操作的并发程度。但是实际上,在遍历的时候,每个结点获取锁、释放锁的开销巨大,很难比单锁的方法快。
并发队列
现有
两个锁
,一个负责队列头,另一个负责队列尾。这两个锁使得入队列操作和出队列操作可以并发执行,因为入队列只访问 tail 锁,而出队列只访问 head 锁。
Michael 和 Scott 使用了一个技巧,添加了一个假节点
(在队列初始化的代码里分配的)。该假节点分开了头和尾操作。并发散列表
本例的散列表使用我们之前实现的
并发链表
,性能特别好。每个散列桶
(每个桶都是一个链表)都有一个锁,而不是整个散列表只有一个锁,从而支持许多并发操作。这个简单的并发散列表
扩展性
(性能)极好,相较于单纯的链表。原因就是缩小了临界区
,减少
了不同线程间操作的冲突
小结
控制流变化时注意获取锁和释放锁;增加并发不一定能提高性能;有性能问题的时候再做优化。
避免不成熟的优化(premature optimization,对于所有关心性能的开发者都有用。我们让整个应用的某一小部分变快,却没有提高整体性能,其实没有价值。
参考
- 作者:GJJ
- 链接:https://blog.gaojj.cn/article/blog-54
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。