锁定是最糟糕的通用方法
同步机制,但不包括所有这些
其他机制也时不时地被尝试过。
向温斯顿·丘吉尔的遗体和他所引用的任何人致歉
在最近的并发研究中,锁定常常扮演着反派的角色。锁定被指责引发死锁、队列问题、饥饿、不公平、数据竞争以及各种其他并发罪行。有趣的是,在生产级共享内存并行软件中,锁定同样扮演着生产工具的角色。本章将探讨这种反派与英雄之间的二元对立,如图7.1所示。和7.2。
这种两面性背后有很多原因:
1.锁定的许多缺点都有实用的设计解决方案,在大多数情况下都行得通,例如:
(a)使用锁层次结构来避免死锁。
(b)死锁检测工具,例如Linux内核的lockdep[Cor06a]。
(c)适合锁定的数据结构,如数组、哈希表和基数树,将在第10章中介绍。
2.锁定的一些罪过只是在高度竞争的情况下才会出现的问题,而这种竞争只有在设计糟糕的程序中才会达到。
3.通过与其他同步机制结合使用锁定,可以避免一些锁定的缺点。这些其他机制包括统计计数器(见第5章)、引用计数器(见第9.2节)、危险指针(见第9.3节)、序列锁定读取器(见第9 .4节)、RCU(见第9.5节)和简化非阻塞数据结构(见第14.2节)。
4.直到最近,几乎所有大型共享内存并行程序都是秘密开发的,因此很难了解到这些实用的解决方案。
5.锁定对于某些软件组件非常有效,而对于其他组件则效果极差。开发人员在处理那些锁定效果良好的组件时,通常会对锁定持有更积极的看法;而那些处理过锁定效果不佳的组件的开发人员,则可能持相反态度,这将在第7.5节中讨论。
6.所有好的故事都需要一个反派,而锁具有着悠久而光荣的历史,作为研究论文的替罪羊。
本章将概述避免锁定更严重错误的几种方法。
我努力活下去。
贝蒂·戴维斯
鉴于锁定机制被指责为死锁和饥饿,共享内存并行开发人员的一个重要关注点就是保持生存。因此,以下各节将介绍死锁、活锁、饥饿、不公平和低效性。
死锁发生在一组线程中的每个成员都持有至少一个锁,同时又等待该组中某个成员持有的锁时。即使在一个包含单个线程的组中,当该线程尝试获取它已经持有的非递归锁时也会发生这种情况。因此,即使只有一个线程和一个锁,也可能发生死锁!
如果没有某种外部干预,死锁将永远存在。任何线程都无法获取它正在等待的锁,直到持有该锁的线程释放它,但持有该锁的线程不能释放它,直到它自己也获得了它正在等待的锁。
我们可以创建一个死锁场景的有向图表示,其中节点s代表线程和锁,如图7.3所示。 从线程到锁的箭头表示该线程持有锁,例如,线程B持有锁2和锁4。从线程到锁的箭头表示该线程正在等待锁,例如,线程B正在等待锁3。
死锁场景总是包含至少一个死锁循环。在图7.3中, 此循环为Thread B、Lock 3、Thread C、Lock 4,然后返回到Thread B。
尽管有些软件环境,如数据库系统,可以从现有的死锁中恢复,但这种方法要求其中一个线程被杀死或者锁被强行从其中一个线程中窃取。这种杀死和强行窃取对于事务来说是很好的,但对于内核和应用程序级别的锁使用来说却常常有问题:处理由此产生的部分更新结构可能极其复杂、危险且容易出错。
因此,内核和应用程序应避免死锁。避免死锁的策略包括锁定层次结构(第7.1.1.1节 ),局部锁定层次结构(第7.1.1.2节 ),分层锁定体系结构(第7.1.1.3节 ),时间锁定层次结构(第7.1.1.4节 ),处理包含锁指针的API的策略(第7.1.1.5节 ),条件锁定(第7.1.1.6节 ),首先获取所有需要的锁(第7.1.1. 7节 ),一次只锁一个设计(第7.1.1.8节 ),以及信号/中断处理程序的策略(第7.1.1.9节) 虽然没有一种避免死锁的策略适用于所有情况,但是有很好的工具可供选择。
7.1.1.1锁定层次结构
锁定层次结构对锁进行排序,并禁止无序地获取锁。在图7.3中 我们可能会按数字顺序排列锁,从而禁止一个线程在持有相同或更高编号的锁时获取给定的锁。线程B违反了这一层次结构,因为它试图在持有4号锁的情况下获取3号锁。这种违规行为导致了死锁的发生。
再次,应用锁定层次结构时,应按顺序排列锁并禁止乱序获取锁。对于不同类型的锁,从一种类型到另一种类型建立一个经过深思熟虑的层次结构是有帮助的。例如,在搜索树中每个节点上的锁,传统方法是按照要获取的锁地址顺序进行锁的获取。无论哪种方式,在大型程序中,明智的做法是使用如Linux内核lockdep[ Cor06a]这样的工具来强制执行锁定层次结构。
7.1.1.2本地锁定层次结构
然而,锁定层次结构的全局性使得它们难以应用于库函数。毕竟,当使用给定库函数的程序尚未编写时,糟糕的库函数实现者怎么可能遵循待定义的锁定层次结构呢?
一种特殊(但常见)的情况是,库函数不调用调用者的任何代码。在这种情况下,在持有库的任何锁的同时,调用者永远不会获得任何锁,因此不会出现包含库和调用者锁的死锁循环。
但是假设库函数确实调用了调用者的代码。例如,qsort()调用了调用者提供的比较函数。通常情况下,这个比较函数将作用于不变的局部数据,因此它不需要获取锁,如图7.4所示。 但是,也许有人足够疯狂,能够对一个键正在改变的集合进行排序,从而要求比较函数获取锁,这可能会导致死锁,如图7.5所示。 图书馆如何避免这种僵局?
在这种情况下,黄金法则是在调用unknowncode之前释放所有锁。要遵循这一规则,qsort()函数必须在调用比较函数之前释放所有锁。因此,在比较函数获取调用者的任何锁之前,qsort()不会持有其任何锁,从而避免死锁。
要了解局部锁定层次结构的好处,请比较图7.5 和7.6。 在这两个图中,应用程序函数foo()和bar()分别在持有锁A和锁B的情况下调用qsort()。由于这是qsort()的并行实现,因此它会获取锁C。函数foo()将函数cmp()传递给qsort(),而cmp()则获取锁B。函数bar()将一个简单的整数比较函数(未显示)传递给qsort(),这个简单的函数不会获取任何锁。
现在,如果qsort()在调用cmp()时持有锁C,违反了上面的gol den-release-all-locks规则,如图7.5所示, 可能会发生死锁。要了解这一点,请假设一个线程调用foo(),而另一个线程同时调用bar()。第一个线程将获取Lock A,第二个线程将获取Lock B。
如果第一个线程调用qsort()时获取了锁C,那么它在调用cmp()时将无法获取锁B。但由于第一个线程持有锁C,第二个线程调用qsort()时也无法获取锁C,因此无法释放锁B,从而导致死锁。
相反,如果qsort()在调用比较函数之前释放锁C,而这个函数在qsort()看来是未知代码,那么就可以避免死锁,如图7.6所示。
如果每个模块在调用未知代码之前释放所有锁,则如果每个模块单独地消除死锁,则可以避免死锁。因此,这条规则极大地简化了死锁分析,并极大地提高了模块化。
然而,这条黄金法则附带了一个警告。当你释放这些锁时,它们保护的任何状态都可能受到任意更改的影响,而这些更改对于函数调用者来说太容易被遗忘,从而导致微妙且难以重现的错误。由于qsort()比较函数很少获取锁,让我们切换到另一个例子。
考虑清单7.1中的递归树迭代器 (rec_ tree_ itr .c)这个迭代器会遍历树中的每个节点,调用用户的回调函数。在调用前释放树锁,在返回后重新获取。这段代码假设了以下几点:(1)当前节点的子节点数量没有变化,(2)递归过程中存储在栈上的祖先节点仍然存在,(3)已访问的节点本身没有被移除和释放。如果一个线程调用tree_add(),而另一个线程释放树锁以运行回调函数,可能会遇到这些风险。
一种策略是确保在释放锁的同时保持状态,例如,通过获取节点上的引用来防止其被释放。或者,可以在回调函数返回后重新获取锁并重新初始化状态。
1结构节点{ 2 intdata; 3 intnchildren; 4个结构节点**子节点;5}; 6 7结构树{ 8spinlock_ts; 9个结构节点*root;10}; 11 12 voidtree_for_each_rec(structtree *tr,struct node*nd, 13void(*callback)(struct node*)) 14{ 15结构节点**itr;16 17spin_unlock(&tr->s); 18回叫(nd); 19spin_lock(和tr->s);20 21 itr=nd->children; 22 for(int i=0;i<nd->nchildren;i++){ 23tree_for_each_rec(tr、*itr、回调); 24itr++; 25} 26} 27 28 voidtree_for_each(结构树*tr, 29void(*callback)(struct node*)) 30{ 31spin_lock(&tr->s); 32tree_for_each_rec(tr、tr->root、回调); 33spin_unlock(&tr->s);34} 35 36 voidtree_add(s truct tree*tr,struct node*parent, 37结构节点new_ch ild) 38{ 39spin_lock(&tr->s); 40父母->子女++; 41parent->children=realloc(parent->children, 42 sizeof(struct node*)* 43parent->nchildren); 44parent->children[parent->nchildre n-1]=new_child; 45spin_unlock(&tr->s);46} |
7.1.1.3分层锁定层次结构
不幸的是,一方面可能无法保留状态,另一方面也可能无法重新初始化,因此无法在调用未知代码之前释放所有锁,从而排除了局部锁定层次结构。然而,我们可以构建一个分层的锁定层次结构,如图7.7所示。 在这里,cmp()函数使用了一个新的锁D,该锁是在获取所有锁A、B和C后获得的,从而避免了死锁。因此,全局死锁层次结构有三层,第一层包含锁A和B,第二层包含锁C,第三层包含锁D。
请注意,通常无法通过机械方式将cmp()更改为使用新的Lock D。恰恰相反,往往需要进行深入的设计层面修改。然而,为了防止死锁,这种修改所需的努力通常是值得的。更重要的是,在设计阶段,即在生成任何代码之前,最好能检测到这种潜在的死锁!
另一个在调用未知代码之前释放所有锁是不切实际的例子是想象一个链表的迭代器,如清单7.2所示 (locked_list.c)。list_start()函数获取列表的锁并返回第一个元素(如果有),而list_next()会返回指向列表中下一个元素的指针,或者在到达列表末尾时释放锁并返回NULL。
清单7.3 显示此列表的用途。第1行 – 4定义包含单个整数的list_ints元素,以及第6行 – 17 显示如何遍历列表。第11行 锁定列表并获取第一个元素的指针,第13行 提供了指向我们enclosing list_ ints结构的指针,第14行 打印相应的整数,以及第15行 移动到下一个元素。这很简单,隐藏了所有的锁定。
也就是说,只要代码处理每个列表元素时没有获得跨其他调用tolist_start()或tolist_start()的锁,锁定就会保持隐藏状态。
1结构2 3 4}; 5 6结构7{ 8 9 10} 11 12结构13 14{ 15 16 17 18 19 20 21 22 23} | 锁定列表{spinlock_ts; structcds_list_headh; cds_list_head*list_start(structlocked_list*lp) spin_lock(&lp->s); returnlist_next(lp、&lp->h); cds_list_head*list_next(structlocked_list*lp, structcds_list_head np)structcds_list_head ret; ret=np->next; 如果(ret==&lp->h){ spin_unlock(&lp->s);ret=NULL; } returnret; |
list_ next(),这会导致死锁。我们可以通过分层锁定层次结构来避免死锁,以考虑列表迭代器锁定。
这种分层方法可以扩展到任意多的层次,但每一层的增加都会提高锁定设计的复杂性。对于某些面向对象的设计而言,这种复杂性的增加尤为不便,因为这些设计中控制流会在大量对象之间无序地来回传递。 面向对象设计习惯与避免死锁需求之间的这种不匹配是并行编程被一些人认为是如此困难的一个重要原因。
第9章介绍了高度分层锁定层次结构的一些替代方案。
7.1.1.4时间锁定层次结构
避免死锁的一种方法是推迟获取其中一个冲突锁。这种方法在Linux内核的RCU中使用,其call_rcu()函数由Linux内核调度器在持有锁时调用。这意味着call_rcu_()无法总是安全地调用调度器来唤醒线程,例如,为了唤醒一个RCU的kthread,以启动call_rcu()队列回调所需的新宽限期。
然而,宽限期持续时间很长,通常只需再等待一毫秒即可开始新的宽限期,因此这通常不是问题。因此,ifcall_rcu()会检测调度器中可能存在的死锁k,并安排稍后启动新的宽限期,具体是在定时器处理程序还是调度器时钟中断处理程序中,取决于配置。由于两个处理程序之间没有持有调度锁,因此成功避免了死锁。
因此,总体方法是遵循锁定层次结构,通过将锁定获取推迟到没有锁定的环境来实现。
7.1.1.5锁定层次结构和指向锁的指针
尽管有一些例外,但包含锁指针的外部API通常是一个设计不当的API。毕竟,将内部锁交给其他软件组件是信息隐藏的反面,而信息隐藏正是关键的设计原则。
一个例外是那些将某些实体交给其他实体处理的功能,在这种情况下,调用者的锁必须一直保持到移交完成,但函数返回前必须释放锁。例如,POSIXpthread_cond_wait()函数就是一个这样的例子,通过传递一个指向pthread_mutex_t的指针可以防止因唤醒丢失而导致的挂起。
清单7.4:协议分层和死锁 | |
1spin_lock(&lock2); | |
2 | layer_2_processing(包); |
3 | |
4 | spin_lock(&nextlayer->lock1); |
5 | spin_unlock(&lock2); |
6 | layer_1_processing(包); |
7 | spin_unlock(&nextlayer->lock1); |
7.1.1.7首先获取所需锁
在一个重要的条件锁定特例中,在执行任何处理之前,会先获取所有需要的锁,这些锁可能通过哈希涉及的数据结构地址来识别。在这种情况下,处理不必是幂等的:如果发现无法在不释放已获取的锁的情况下获取某个锁,只需释放所有锁并重试。只有当所有需要的锁都已持有时,才会进行任何处理。
一种相关的方法,两阶段锁定[BHG87],在事务数据库系统中长期使用。在两阶段锁定事务的第一阶段,获取锁但不释放。一旦所有需要的锁都被获取,事务进入第二阶段,在此阶段释放锁但不再获取。这种锁定方法允许数据库为其事务提供序列化保证,换句话说,确保事务所见和产生的所有值都与所有事务的某种全局顺序一致。许多这样的系统依赖于事务中止的能力,尽管可以通过避免在获取所有必要锁之前对共享数据进行任何更改来简化这一过程。活锁和死锁是这类系统中的问题,但在许多数据库教科书中可以找到实际解决方案。
7.1.1.8一次只锁定一个设计
在某些情况下,可以避免嵌套锁,从而避免死锁。例如,如果一个问题完全可分区,每个分区可以分配一个锁。那么,在给定分区工作的线程只需获取相应的锁即可。由于任何时候都不会有线程同时持有多个锁,因此死锁是不可能的。
但是,必须存在某种机制来确保在没有锁定的情况下保持所需的数据结构。第7.4节讨论了这种机制 其他一些内容在第9章中介绍。
7.1.1.9信号/中断处理程序
涉及信号处理程序的死锁通常可以通过指出在信号处理程序中调用pthread_ mutex_ lock()是不合法的来迅速解决[Ope 97]。然而,从信号处理程序中调用锁定原语是可能的(尽管往往不明智)。此外,几乎所有的操作系统内核都允许在中断处理程序中获取锁,这些中断处理程序类似于信号处理程序。
诀窍是在获取可能在信号(或中断)处理程序中获得的任何锁时阻塞信号(或禁用中断)。此外,如果持有此类锁,则在信号处理程序之外尝试获取任何已获取的锁而不阻塞信号是非法的。
如果锁被处理程序为多个信号获取,那么在获取该锁时,必须阻止每一个信号,即使该锁是在信号处理程序中获取的。
不幸的是,在一些操作系统中,包括Linux在内,锁定和解锁信号可能代价高昂,因此性能问题通常意味着在信号处理器中获得的锁只能在信号处理器中获得,而且使用无锁同步机制在应用程序代码和信号处理器之间进行通信。
或者,除了处理致命错误之外,完全避免使用信号处理器。
7.1.1.10讨论
共享内存并行程序员可以使用大量避免死锁的策略,但有些顺序程序却无法用这些方法解决。这就是为什么专家程序员的工具箱中不止一种工具的原因之一:锁定是一种强大的并发工具,但有些任务更适合用其他工具来处理。
尽管如此,本节中描述的策略在许多情况下都证明是很有用的。
虽然条件锁定可以是一种有效的死锁避免机制,但它也可能被滥用。例如,考虑清单7.6中所示的对称性很好的例子。 此示例的美观性隐藏了一个丑陋的活锁。要查看这一点,请考虑以下事件序列:
1.线程1在第4行获取锁1, 然后调用do_ one_ thing()。
2.线程2在行18上获取锁2, 然后调用do_a_第三件事()。
3.线程1试图在第6行获取loc k2, 但失败了,因为线程2持有它。
4.线程2尝试在行20上获取锁1, 但失败了,因为线程1持有它。
4spin_lock(&lock1);
7spin_unlock(&lock1);
8 gotoretry; 9}
10do_another_thing();
11spin_unlock(&lock2);
12spin_unlock(&lock1);13}
14
18spin_lock(&lock2);
21spin_unlock(&lock2);
22 gotoretry; 23}
24 do afourth_thing();
25spin_unlock(&lock1);
26spin_unlock(&lock2);27}
5.线程1在第7行释放锁1 然后跳转到第3行重试。
6.线程2在第21行释放锁2 ,然后跳到第17行重试。
7.活锁舞从头开始重复。
活锁可以被看作是一种极端的饥饿形式,其中一组线程饿死了,而不是其中只有一个。3
活锁和饥饿是软件事务内存实现中的严重问题,因此引入了争用管理器的概念来封装这些问题。对于锁定而言,简单的指数退避通常可以解决活锁和饥饿问题。其思路是在每次重试前引入指数级递增的延迟,如清单7.7所示。
为了获得更好的结果,退避应该被限定,通过排队锁定获得更好的高竞争结果[And90],这将在第7.3.2节中讨论更多。 当然,最好的方法是使用一个良好的并行设计,通过保持低锁竞争来避免这些问题。
不公平可以被认为是一种不那么严重的饥饿形式,其中一部分人
争夺给定锁的线程被授予了大部分的获取。这
1 void thread1(void)2{ 3个未签名的整数wait= 1; 4重试: 5spin_lock(&lock1); 6do_one_thing(); 7if(!spin_trylock(&lock2){ 8spin_unlock(&lock1); 9睡眠(等待); 10等待=等待<< 1; 11 gotoretry; 12} 13do_another_thing(); 14spin_unlock(&lock2); 15spin_unlock(&lock1);16} 17 18 void thread2(void)19{ 20个未签名的整数wait= 1; 21次重试: 22spin_lock(&lock2); 23 do athird_thing(); 24if(!spin_trylock(&lock1){ 25spin_unlock(&lock2); 26睡眠(等待); 27 wait= wait<< 1; 28 gotoretry; 29} 30 do afourth_thing(); 31spin_unlock(&lock1); 32spin_unlock(&lock2);33} |
可以在具有共享缓存或NUMA特性的机器上发生,例如,如图7.8所示。 如果CPU 0释放了一个其他所有CPU都在尝试获取的锁,那么CPU 0和1之间的互连共享意味着CPU 1将比CPU 2到7更有优势。因此,CPU 1很可能会获取该锁。如果CPU 1持有锁的时间足够长,以至于CPU 0在CPU 1释放锁时请求锁,反之亦然,那么锁可以在CPU 0和1之间切换,绕过CPU 2到7。
锁是通过原子指令和内存屏障实现的,通常涉及缓存未命中。正如我们在第三章中所见,这些指令相当昂贵,其开销大约比简单指令高出两个数量级。这可能成为锁定的一个严重问题:如果你用锁保护一条指令,开销会增加一百倍。即使假设完全可扩展性,也需要一百个CPU才能跟上单个CPU执行相同代码而不使用锁的情况。
这种情况不仅限于锁定。图7.9 展示了这一原理如何应用于古老的锯木活动。如图所示,锯一块木板会将木板的一小部分(锯片的宽度)转化为锯末。当然,锁是隔开时间而不是锯木,4 但就像锯木头一样,使用锁来分割时间会浪费一些时间,因为锁头和(更糟糕的是)锁竞争。一个重要的区别是,如果有人把一块木板锯成太小的碎片,大部分木板转化为锯末的情况会立即显现。相比之下,某个特定的锁获取是否浪费了过多时间则不总是显而易见。
这种情况强调了第6.3节讨论的同步粒度权衡的重要性,特别是图6.16:粒度过粗会限制可扩展性,而粒度过细会导致过多的同步开销。
获取锁可能很昂贵,但一旦获得,CPU的缓存就成为了一个有效的性能提升器,至少对于大型关键部分是这样。此外,一旦获得锁,该锁保护的数据就可以被锁持有者访问,而不会受到其他线程的干扰。
Rust编程语言通过允许开发者在锁和它保护的数据之间建立编译器可见的关联,进一步推进了锁/数据关联[JJKD21]。一旦建立了这种关联,尝试在没有相应锁的情况下访问数据将导致编译时诊断。希望这能大大减少这类错误的发生频率。当然,这种方法并不适用于数据分布在某些数据结构节点中的情况,也不适用于锁定对象纯粹抽象的情况,例如,当给定锁需要保护的状态机转换的小部分子集时。因此,Rust允许锁与类型关联,而不是数据项,甚至可以不关联任何东西。最后一种选择使Rust能够模拟传统的锁定用例,但在Rust开发者中并不流行。也许Rust社区会提出其他机制来适应其他锁定用例。
生活中只有你认为你知道的才是锁,但你并不知道。接受你的无知,尝试一些新的东西。
丹尼斯·维克斯
锁的种类多得令人惊讶,短小的章节不可能详尽地介绍所有种类。以下各节将讨论专用锁(第7.2.1节 ),读写锁(第7.2.2节 ),多角色锁(第7.2.3节 ),以及范围锁定(第7.2.4节) .
独占锁就是他们所说的:一次只有一个线程可以持有锁。因此,持有此类锁的线程对受该锁保护的所有数据都有独占访问权,因此得名。
当然,这一切都假设了锁被跨所有对据称受该锁保护的数据的访问持有。尽管有一些工具可以帮助(例如参见第12.3.1节),但确保在需要时始终获取锁的责任最终在于开发人员。
需要注意的是,无条件获取独占锁有两个效果:(1)等待所有先前持有该锁的线程释放它;(2)阻止任何其他获取尝试,直到该锁被释放。因此,在获取锁时,任何并发的获取操作都必须分为先前持有者和后续持有者的部分。不同类型的独占锁使用不同的分区策略[Bra11,GGL+19],例如:
1.严格的FIFO,获取锁的顺序是先获取的先开始。
3.按优先级顺序,具有较高优先级的线程比任何试图在同一时间获取锁的较低优先级线程更早地获得锁,但对具有相同优先级的线程来说,某些FIFO排序适用。
4.随机,即从所有尝试获取的线程中随机选择新的锁持有者,而不考虑时间。
5.不公平,使得某个给定的获取可能永远无法获得锁(参见第7.1.3节) .
不幸的是,具有更强保证的锁定实现通常会产生更高的开销,这促使了生产环境中各种各样的锁定实现。例如,实时系统通常需要在优先级级别内实现一定程度的FIFO排序,以及其他许多需求(见第14.3.5.1节),而非实时系统在面临高并发时可能只需要足够的排序来避免饥饿现象,最后,旨在避免并发的非实时系统可能根本不需要公平性。
读写锁[ CHP71]允许任意数量的读取器同时持有锁,或单个写入器单独持有锁。理论上,读写锁应能为频繁读取而很少写入的数据提供出色的可扩展性。实际上,其可扩展性取决于读写锁的具体实现方式。
经典的读写锁实现涉及一组原子操作的计数器和标志。这种实现方式与互斥锁在短临界区段中遇到的问题相同:获取和释放锁的开销大约是简单指令开销的两倍。当然,如果临界区足够长,获取和释放锁的开销就会变得微不足道。然而,由于每次只能有一个线程操作锁,所需的临界区大小会随着CPU数量的增加而增大。
可以通过使用线程独占锁来设计一个对读取者更为有利的读写锁[HW92]。读取时,线程仅获取自己的锁;写入时,线程获取所有锁。在没有写入者的情况下,每个读取者只会产生原子指令和内存屏障开销,而不会发生缓存未命中,这对于锁定原语来说是非常好的。不幸的是,写入者也会产生缓存未命中以及原子指令和内存屏障开销——这些开销会随着线程数量的增加而成倍增长。
简而言之,读写锁在许多情况下都非常有用,但每种实现方式都有其缺点。读写锁的经典用例涉及非常长的读侧关键部分,最好以数百微秒甚至毫秒为单位来衡量。
与独占锁一样,读写锁的获取无法完成,直到所有先前持有该锁的冲突者都释放了它。如果锁被读取持有,那么读取获取可以立即完成,但写入获取必须等待,直到不再有任何读者持有该锁。如果锁被写入持有,则所有获取都必须等待,直到写入者释放该锁。同样,不同的读写锁实现为读者提供不同程度的先进先出顺序,而对写入者则提供不同的顺序。
但假设大量读者持有锁,而作者正等待获取锁。是否应该允许读者继续获取锁,可能会导致作者被饿死?同样地,假设作者持有锁,且大量读者和作者都在等待获取锁。当前作者释放锁时,应将其交给读者还是其他作者?如果交给读者,那么在下一个作者被允许获取锁之前,应该允许多少读者获取锁?
对于这些查询问题,有许多可能的答案,它们具有不同的复杂度、开销和公平性。不同的实现方式可能会产生不同的成本,例如,某些类型的读写锁在从读持有者模式切换到写持有者模式时会产生极大的延迟。以下是一些可能的方法:
1.读者偏好实施无条件地优先考虑读者而非作者,可能允许无限期地阻止写入收购。
2.批处理公平实现确保当读取器和写入器同时获取锁时,两者都可通过批处理获得合理的访问权限。例如,锁可能允许每个CPU有五个读取器,然后是两个写入器,接着是另外五个读取器,依此类推。
3.作家偏好实现无条件地偏爱作家而不是读者,可能允许无限期地阻止阅读获取。
当然,这些区别只有在高锁竞争条件下才重要。
请牢记锁的等待/阻塞双重性质。这将在第9章讨论可伸缩高性能专用锁定替代方案时再次提及。
读写锁和独占锁在访问策略上有所不同:独占锁最多允许一个持有者,而读写锁则允许多个读持有者(但只有一个写持有者)。存在大量的可能访问策略,其中之一是VAX/VMS分布式锁管理器的策略。
(DLM)[ ST87],如表7.1所示。 空白单元格表示兼容模式,而含有“X”的单元格表示不兼容模式。
VAX/VMS DLM使用6种模式。为了进行比较,独占锁使用2种模式(未持有和已持有),而读写锁使用3种模式(未持有、读已持有和写已持有)。
第一个模式为null,或者未持有。该模式与所有其他模式兼容,这是可以预料的:如果一个线程没有持有锁,则它不应该阻止任何其他线程获取该锁。
第二种模式是并发读取,它与除独占模式以外的所有其他模式兼容。并发读取模式可用于累积数据结构的近似统计信息,同时允许更新过程并发进行。
第三种模式是并发写入,它与空值、并发读取和并发写入兼容。并发写入模式可用于更新近似统计信息,同时仍允许读取和并发更新并行进行。
第四种模式是受保护的读取,它与空、并发读取和受保护的读取兼容。受保护的读取模式可用于获取数据结构的一致快照,同时允许并发地进行读取但不允许更新。
第五种模式是受保护的写入模式,它与空值和并发读取兼容。受保护的写入模式可用于对可能干扰受保护的读取器但可被并发读取器容忍的数据结构执行更新。
第六种也是最后一种模式是“排他”模式,它只与“空”兼容。当需要排除所有其他访问时,使用“排他”模式。
有趣的是,VAX/VMS DLM可以模拟独占锁和读写锁。独占锁只使用空模式和独占模式,而读写锁可能使用空模式、受保护读模式和受保护写模式。
尽管VAX/VMS DLM策略在分布式数据库中得到了广泛应用,但在共享内存应用中似乎并未得到大量使用。其中一个可能的原因是,分布式数据库较高的通信开销可能会掩盖VAX/VMS更复杂的准入策略带来的更高开销。
尽管如此,VAX/VMS DLM是一个有趣的例子,说明了锁定背后的概念可以多么灵活。它还作为现代数据库管理系统所使用的锁定方案的一个非常简单的介绍,这些方案可以有三十多种锁定模式,而VAX/VMS仅有六种。
到目前为止讨论的锁定原语需要显式的获取和释放原语,例如,分别为spin_lock()和andspin_unlock()。另一种方法是使用面向对象的方法 资源获取初始化(RAII)模式[ES90]。5 这种模式常用于C++等语言中的自动变量,在对象进入作用域时调用相应的构造函数,在离开该作用域时调用相应的析构函数。通过让构造函数获取锁并让析构函数释放锁,也可以应用于锁定机制。
这种方法非常有用,事实上在1990年我确信这是唯一需要的锁定类型。6 RAII锁定的一个非常好的特性是,您不需要仔细释放每个退出该范围的代码路径上的锁,这一特性可以消除一系列麻烦的错误。
然而,RAII锁定也有其阴暗面。RAII使得封装锁的获取和释放变得相当困难,例如,在迭代器中。在许多迭代器实现中,你希望在迭代器的“开始”函数中获取锁,在“停止”函数中释放锁。而RAII锁定则要求锁的获取和释放必须在同一作用域级别进行,这使得这种封装变得困难甚至不可能。
严格的RAII锁定还禁止重叠的关键区段,因为作用域必须嵌套。这一禁令使得表达许多有用构造变得困难甚至不可能,例如,在多个并发尝试断言事件之间进行中介的锁定树。在任意大的并发尝试组中,只需一个成功,其余尝试的最佳策略是尽快且无痛地失败。否则,在大型系统(如数百个CPU)上,锁竞争会变得病态。因此,C++17[Smi19]在其unique_lock类中提供了严格RAII的逃逸机制,允许关键区段的作用域控制程度大致与显式获取和释放锁原语所能达到的程度相当。
图7.10展示了来自Linux内核RCU的严格-RAII-不友好数据结构示例。 在这里,每个CPU都被分配了一个leafrcu_node结构,而eachrcu_node结构则有一个指向其父结构(奇怪的是,这个父结构被命名为->parent)的指针,一直指向根rcu_node结构,该根结构的->parent指针为空。每个父结构中的childrcu_node结构数量可以不同,但通常为32或64个。eachrcu_node结构还包含一个名为->fqslock的锁。
通用方法是“先获取后释放”,即某个CPU有条件地获取其叶rcu_node结构的->fqslock,如果成功,则尝试获取父节点的->fqslock,然后释放子节点的->fqslock。此外,在每一层,CPU检查一个globalgp_flags变量,如果该变量表明其他CPU已触发事件,则第一个CPU退出竞争。这种先获取后释放的过程会持续进行,直到gp_flags变量显示有人赢得了比赛,某个尝试获取->fqslock失败,或者根rcu_node结构的->fqslock已被获取。如果根rcu_节点结构的->fqslock已被获取,则调用名为do_force_quiet状态()的函数。
实现此功能的简化代码如清单7.8所示。 此函数的作用是在CPU之间进行协调,这些CPU同时检测到需要调用thedo_force_quiescent_state()函数。在任何时候,只有一个do_ force_ quiescent_ state()实例处于活跃状态才有意义,因此如果有多个并发调用者,我们最多只需要其中一个真正调用do_ force_ quiescent_ state(),其余的则需要(尽可能快速且轻松地)放弃并离开。
为此,通过跨越线路7的环路进行每次通过 – 15尝试在rcu_node层次结构中向上移动一级。如果gp_flags变量已经设置(第8行 )或者如果尝试获取当前rcu_node结构的->fqslock失败(第9行 ),然后将局部变量ret设置为1。如果行10 看到局部变量rnp_old不为NULL,这意味着我们持有rnp_old的-> fqs_锁,第11行 释放此锁(但仅在尝试获取父rcu_node结构的>fqslock之后)。如果第12行 看到第8行 或9 看到了放弃的理由,第13行 返回给调用者。否则,我们必须获取当前rcu_node结构的>fqslock,所以第14行 将指向此结构的指针保存在局部变量rnp_ old中,以便为下一次循环遍历做好准备。
如果控制到达第16行 ,我们赢得了比赛,现在持有根rcu_node结构的-> fqslock。如果第16行 仍然看到全局变量gp_标志为零,第17行 将gp_flags设置为1,第18行 调用do_force_quiescent_state(),以及第19行 将gp_flags重置为零。无论如何,第21行 释放根rcu_node结构的-> fqslock。
1个类型定义intxchglock_t;
2#定义DEFINE_ XCHG_ LOCK (n) xchglock_ t n = 0 3
4空xchg_lock(xchglock_t *x p)5{
10} 11
12空xchg_unlock(xchglock_t * xp)13{
15}
此函数说明了不常见的层次锁定模式。使用严格的RAII锁定很难实现这种模式,7 就像前面提到的迭代器封装一样,所以在可预见的未来,将需要显式锁/解锁原语(或C++17-styleunique_lock转义)。
当你把梦变成现实时,它永远不是完整的实现。做梦比做梦容易。
沙伊·阿加西
开发人员几乎总是最好使用系统提供的任何锁定原语,例如POSIX pthread互斥锁[Ope97,But97]。然而,研究示例实现是有帮助的,考虑极端工作负载和环境带来的挑战也是有帮助的。
本节将回顾清单7.9中所示的实现。 此锁的数据结构只是一个int,如第1行所示 ,但可以是任何整型。此锁的初始值为零,表示“未锁定”,如第2行所示。
通过第4行所示的xchg_锁()功能进行锁定获取 – 10 . 此函数使用嵌套循环,外层循环反复原子地交换锁的值和值为1(表示“锁定”)。如果旧值已经是值为1(换句话说,其他人已经持有锁),那么内层循环(第7行 – 8)旋转,直到锁可用,此时外部循环尝试再次获取锁。
锁定释放由第12行所示的xchg_unlock()功能执行 – 15 . 第14行 将值零(“解锁”)原子交换到锁中,从而将其标记为已被释放。
这个锁是test-and-setlock[SR84]的一个简单示例,但非常相似的机制已经被广泛用作纯自旋锁在生产中。
有许多其他基于原子指令的锁定实现方法,其中许多在Mellor-Crummey和Scott的经典论文[ MCS91]中进行了综述。这些实现代表了多维设计权衡的不同点[ GGL+ 19,Gui18,McK96b]。例如,前一节介绍的基于原子交换的测试-设置锁在竞争较低时表现良好,并且具有较小的内存占用优势。它避免了将锁提供给无法使用它的线程,但因此在高竞争水平下可能会遭受不公平或甚至饿死现象。
相比之下,票锁[MCS91]曾经在Linux内核中使用过,在高竞争级别下避免了不公平。然而,由于其严格的先进先出规则,它可能会将锁授予当前无法使用它的线程,这可能是由于该线程被抢占或中断所致。另一方面,重要的是不要过分担心抢占和中断的可能性。毕竟,在许多情况下,这种抢占和中断可能恰好发生在获取锁之后。8
所有锁定实现中,当等待者在一个内存位置上轮转时,包括测试-设置锁和票锁,在高竞争水平下都会遇到性能问题。问题在于释放锁的线程必须更新相应内存位置的值。在低竞争情况下,这不是问题:对应的缓存行很可能仍然由持有锁的线程访问和写入。相反,在高竞争水平下,每个尝试获取锁的线程都会有一个只读的缓存行副本,持有锁的线程需要先使所有这些副本失效,才能执行释放锁的更新操作。一般来说,CPU和线程越多,在高竞争条件下释放锁时产生的开销就越大。
这种负可扩展性促使了多种不同的排队-局部-
想法[和90,GT 90,MCS 91,WKS 94,Cra 93,MLH 94,TS 93],其中一些 在最近版本的Linux内核中被使用[Cor14b]。队列锁通过为每个线程分配一个队列元素来避免高缓存失效开销。这些队列元素链接在一起形成一个队列,控制着锁将如何授予等待线程。关键在于每个线程在其自己的队列元素上进行轮转,因此锁持有者只需使下一个线程CPU的缓存中的第一个元素失效即可。这种安排大大减少了在高并发情况下锁传递的开销。
最近的排队锁实现还考虑了系统的架构,优先在本地授予锁,同时采取措施避免饥饿[SSVM02,RH03,RH02,JMRR02,MCM02]。这些方法可以类比于传统上用于调度磁盘I/O的电梯算法。
不幸的是,提高高并发下队列锁效率的调度逻辑,在低并发下也会增加其开销。因此,林本洪和阿南特·阿加瓦尔将简单的测试-设置锁与队列锁结合使用,在低并发时使用测试-设置锁,在高并发时切换到队列锁[LA94],从而在低并发时获得低开销,在高并发时实现公平性和高吞吐量。布朗宁等人采用了类似的方法,但避免使用单独的标志位,使得测试-设置快速路径使用与简单测试-设置锁相同的指令序列[BMMM05]。这种方法已在生产中得到应用。
在高竞争级别下,另一个问题出现在锁持有者被延迟时,特别是当这种延迟是由于抢占引起的,这可能导致优先级反转。在这种情况下,低优先级线程持有锁,但被中等优先级的CPU密集型线程抢占,导致高优先级进程在尝试获取锁时被阻塞。结果是,CPU密集型的中等优先级进程阻止了高优先级进程运行。一种解决方案是优先级继承[LR 80],尽管对此做法仍有一些争议[Yod 04a,Loc02],但它已被广泛用于实时计算[SRL90,Cor06b]。
另一种避免优先级反转的方法是在持有锁时防止抢占。由于在持有锁的同时防止抢占还能提高吞吐量,大多数专有的UNIX内核都提供了一种调度器意识同步机制[KWS97],这主要归功于某个大型数据库供应商的努力。这些机制通常以提示的形式出现,即在给定代码区域中应避免抢占,这种提示通常存储在一个机器寄存器中。这些提示通常表现为特定机器寄存器中的一个位,使得这些机制的每个锁获取开销极低。相比之下,L inux避开了这些提示。相反,L inux内核社区对调度器意识同步请求的回应是一种称为future-exes[FRK02,Mol06,Ros06,Dre11]的机制。
有趣的是,原子指令并不是实现锁所必需的[Dij65,Lam74]。关于基于简单加载和存储的锁实现问题的精彩阐述,可以在赫尔利希和沙维特的教科书中找到[HS08,HSLS20]。这里的主要观点是,尽管仔细研究这些实现既有趣又启发人,但目前它们的实际应用很少。然而,除了下面描述的一个例外,这种研究留作读者的练习。
Gamsa等人[GKAS99,第5.3节]描述了一种基于令牌的机制,在该机制中,令牌在各个CPU之间循环。当令牌到达某个CPU时,它具有独占性
访问受该令牌保护的任何内容。可以使用多种方案来实现基于令牌的机制,例如:
1.维护per-CPU标志,该标志最初对所有CPU都是零。当某个CPU的标志非零时,它持有令牌。当它用完令牌后,它将它的标志设为零,并将下一个CPU的标志设置为1(或任何其他非零值)。
2.维护每个CPU的计数器,初始设置为对应CPU的编号,我们假设该编号范围从零到N−1,其中N是系统中CPU的数量。当一个CPU的计数器大于下一个CPU的计数器(考虑计数器溢出的情况)时,第一个CPU持有令牌。完成令牌操作后,它将下一个CPU的计数器设置为其自身计数器值加一。
这种锁的独特之处在于,即使当前没有其他CPU使用它,某个CPU也不能立即获取它。相反,该CPU必须等待令牌轮到自己。这在CPU需要定期访问临界区但不能容忍令牌循环速率差异的情况下非常有用。Gamsa等人[GKAS99]用它实现了一种读取-复制更新的变体(见第9.5节),但它也可以用于保护周期性的CPU级操作,例如内存分配器使用的CPU级缓存刷新[MS 93]、垃圾收集的CPU级数据结构,或向共享存储(甚至大容量存储)刷新CPU级数据。
Linux内核现在使用队列自旋锁[C或14b],但由于实现复杂性导致性能在不同竞争级别下表现不佳,这一过程并非一帆风顺[Mar18,Dea18]。随着越来越多的人熟悉并行硬件,并且越来越多的代码被并行化,我们可以期待更多专用锁定原语的出现,例如Gueraro等人[GGL+19,Gui18]。然而,您应该仔细考虑这一重要的安全提示:尽可能使用标准同步原语。标准同步原语相比自定义实现的一大优势在于,它们通常更少出错。9
存在先于并支配本质。
让-保罗·萨特
并行编程中的一个关键挑战是提供存在性保证[GKAS99],以便尝试访问给定对象时可以依赖于该对象在整个访问尝试期间都存在。
清单7.10:没有存在性保证的元素锁定(有缺陷!) | |
1 int 2{ 3 4 5 6 7 8 9 10 11 12 13 14 15} | 删除(键入密钥) intb; 结构元素*p; b=哈希函数(键);p=hashtable[b]; spin_lock(&p->lock);hashtable[b]=NULL; spin_unlock(&p->lock); |
1.只要应用程序正在运行,基础模块中的全局变量和静态局部变量就会存在。
2.只要加载模块仍然处于加载状态,加载模块中的全局变量和静态局部变量就会一直存在。
3.只要模块的至少一个功能有活动实例,该模块就会一直加载。
4.给定函数实例的栈上变量将一直存在,直到该实例返回。
5.如果在某个函数中执行或已被该函数调用(直接或间接),那么该函数具有一个活动实例。
这些隐式存在性保证是直接的,尽管涉及隐式存在性保证的错误确实会发生。
但更有趣且麻烦的保证涉及堆内存:一个动态分配的数据结构会一直存在,直到被释放。需要解决的问题是同步释放该结构与同时对该结构进行并发访问。一种方法是使用显式保证,例如锁定。如果某个结构只有在持有特定锁的情况下才能被释放,那么持有该锁就保证了该结构的存在。
但这种保证依赖于锁本身的存在。一种直接的方法是将锁放在全局变量中以确保其存在,但全局锁定的缺点在于限制了可扩展性。一种随着数据结构规模增加而改进的可扩展性方法是在结构的每个元素中放置一个锁。不幸的是,在数据元素本身中放置要保护的数据元素的锁会受到微妙的竞争条件的影响,如清单7.10所示。
要了解这些条件中的一个,请考虑以下事件序列:
1.线程0调用delete(0),并到达第10行 上市,获取锁。
2.线程1同时调用delete(0),到达第10行 ,但锁旋转是因为线程0持有它。
清单7.11:基于锁的存在性保证的逐元素锁定 | |
1 int 2{ 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19} | 删除(键入密钥) intb; 结构元素*p;spinlock_t*sp; p=hashtable[b]; 如果(p== NULL||p-> key!= key){ sp解锁(sp); return0;} hashtable[b]=NULL;spin_unlock(sp); kfree (p); return1; |
3.线程0执行第11行 – 14,从哈希表中删除元素,释放锁,然后释放元素。 | |
4. Thread 0继续执行,并分配s内存,获取它刚刚释放的精确内存块。 | |
5. Thread 0然后将此内存块初始化为其他类型的结构。 | |
6.Thread1的spin_lock()操作失败,因为它认为的p->锁不再是自旋锁。 由于没有存在保证,数据元素的身份可能在线程尝试获取该元素的锁时发生变化 ! 修复此示例的一种方法是使用一组哈希全局锁,这样每个哈希桶都有自己的锁,如清单7.11所示。 这种方法允许获得正确的锁(在线9 )在获得指向数据元素的指针之前(在第10行) 尽管这种方法对于包含在单一可分区数据结构中的元素非常有效,例如列表中所示的哈希表,但如果某个数据元素可以是多个哈希表或更复杂的数据结构如树或图的成员时,就会出现问题。这些问题不仅可以通过解决方案解决,而且这些方案也是基于锁的软件事务内存实现的基础[ST95,DSS06]。然而,第9章描述了更简单且更快的方法来提供存在性保证。 | |
你要么死得像个英雄,要么活得够久,看到自己变成一个恶棍。 |
亚伦·埃克哈特饰演Harv ey Dent
正如现实生活中常有的情况,锁定可以是英雄也可以是反派,这取决于其使用方式以及所面临的问题。根据我的经验,编写整个应用程序的人对锁定感到满意,而编写并行库的人则不太满意,那些将现有顺序库并行化的人更是极其不满。以下部分将讨论这些观点差异的一些原因。
在编写整个应用程序(或整个内核)时,开发人员对设计拥有完全的控制权,包括同步设计。假设设计充分利用了分区技术,如第6章所述,锁定可以成为一种极其有效的同步机制,这一点通过生产级并行软件中大量使用锁定得到了证明。
然而,尽管这类软件通常基于锁定设计其大部分同步机制,但几乎总是会使用其他同步机制,包括特殊的计数算法(第5章)、数据所有权(第8章)、引用计数(第9.2节)、危险指针(第9.3节)、序列锁定(第9.4节)和读取复制更新(第9.5节)。此外,实践者还使用死锁检测工具[Cor06a]、锁获取/释放平衡工具[Cor0 4b]、缓存未命中分析工具[The11]、基于硬件计数器的性能分析工具[EGMDB11,Th12b]等。
如果设计得当,使用良好的同步机制组合和良好的工具,锁定对于应用程序和内核来说是相当有效的。
与应用程序和内核不同,库的设计者无法预知库将要交互的代码的锁定设计。事实上,这些代码可能多年后才会编写出来。因此,库设计者对同步设计的控制较少,必须更加谨慎。
死锁当然是特别令人关注的问题,第7.1.1节讨论的技术 需要应用。因此,一种流行的避免死锁的策略是确保库的锁是包围程序锁定层次结构的独立子树。然而,这可能比看起来要困难。
第7.1.1.2节讨论了其中一个并发症 ,即当库函数调用应用程序代码时,qsort()的比较函数参数就是一个例子。另一个复杂之处在于信号处理程序的交互。如果库函数在接收到信号后调用应用程序的信号处理程序,死锁就可能像库函数直接调用信号处理程序一样发生。最后一种复杂情况出现在那些可以在fork()/ exec()对之间使用的库函数中,例如由于系统()函数的使用。在这种情况下,如果你的库函数在fork()时持有锁,那么子进程将带着这个锁开始运行。因为释放锁的线程在父进程中运行而不是子进程中,如果子进程调用你的库函数,死锁就会发生。
以下策略可用于避免这些情况下的死锁问题:
1.不要使用回调或信号。
2.不要在回调函数或信号处理程序中获取锁。
3.让呼叫者控制同步。
4.参数化库API,将锁定委托给调用者。
5.明确避免回调死锁。
6.明确避免信号处理程序死锁。
7.避免调用fork()。
以下各节将讨论每种策略。
7.5.2.1既不使用回拨也不使用信号
如果库函数避免回调,而整个应用程序避免信号,则该库函数获得的任何锁都是锁定层次结构树的叶子节点。这种安排避免了死锁,如第7.1.1.1节所述。 虽然该策略在应用中非常有效,但有些应用必须使用信号处理程序,有些库函数(如第7.1.1.2节讨论的qsort()函数 )需要回拨电话。
下一节中描述的策略通常可用于这些情况。
7.5.2.2避免锁定回调和信号处理程序
如果回调函数和信号处理程序都不获取锁,那么它们就不会陷入死锁循环,这使得简单的锁定层次结构可以再次将库函数视为锁定层次树上的叶子节点。这种策略对于大多数qsort的使用非常有效,因为其回调函数通常只是比较传入的两个值。同样,对于许多信号处理程序来说,这一策略也非常适用,尤其是在信号处理程序内部获取锁通常是不被鼓励的情况下[Gro01],10 但如果应用程序需要从信号处理程序操纵复杂的数据结构,则可能会失败。
即使必须操作复杂的数据结构,以下一些方法可以避免在信号处理器中获取锁:
1.使用基于非阻塞同步的简单数据结构,将在第14.2.1节中讨论。
2.如果数据结构过于复杂,无法合理使用非阻塞同步,请创建一个队列来允许非阻塞的入队操作。在信号处理程序中,不要直接操作复杂的数据结构,而是在队列中添加一个元素来描述所需的变化。然后,可以有一个单独的线程从队列中移除元素,并使用常规锁定执行所需的变化。有许多现成的并发队列实现[KLP12,Des09b,MS96]。
应强制执行此策略,并定期对回调和信号处理程序进行检查(最好能实现自动化)。在执行这些检查时,要警惕那些聪明的程序员,他们可能(不明智地)用原子操作创建了自制锁。
7.5.2.3呼叫者控制同步化
让调用者控制同步在库函数对数据结构的独立调用可见实例进行操作时效果非常好,每个实例都可以单独同步。例如,如果库函数对搜索树进行操作,并且应用程序需要大量独立的搜索树, 然后应用程序可以为每棵树关联一个锁。应用程序根据需要获取和释放锁,因此库无需了解并行性。相反,应用程序控制并行性,使得锁定能够非常有效地工作,正如第7.5.1节所讨论的那样。
但是,如果库实现了一个需要内部并发的数据结构,例如,哈希表或并行排序,则此策略将失败。在这种情况下,库必须绝对控制自己的同步。
7.5.2.4 参数化Library同步
这里的想法是在库的API中添加参数,以指定要获取哪些锁、如何获取和释放这些锁。这种策略允许应用程序通过指定要获取的锁(通过传递指向特定锁的指针)以及如何获取这些锁(绕过锁获取和释放函数的指针),来承担避免死锁的全局任务。同时,它还允许给定的库函数通过决定在哪里获取和释放锁来控制自身的并发性。
特别是,这种策略允许锁的获取和释放功能根据需要阻塞信号,而无需库代码关注哪些信号需要被哪个锁阻塞。这种策略所采用的关注点分离可以非常有效,但在某些情况下,后续章节中介绍的策略可能更为适用。
也就是说,必须非常谨慎地考虑将锁的显式指针传递给外部API,如第7.1.1.5节中所讨论的那样。 虽然这种做法有时是正确的,但你应该先看看其他的设计,这样对你自己有好处。
7.5.2.5明确避免回调死锁
本策略的基本规则在第7.1.1.2节中进行了讨论 :“在调用未知代码之前释放所有锁。”这通常是最佳方法,因为它允许应用程序忽略库的锁定层次结构:库仍然是应用程序整体锁定层次结构的一个叶节点或隔离子树。
在无法在调用未知代码之前释放所有锁的情况下,可以使用第7.1.1 .3节中描述的分层锁定层次结构。 可以很好地工作。例如,如果未知代码是一个信号处理程序,这意味着库函数块会在所有锁获取过程中发出信号,这可能既复杂又缓慢。因此,在信号处理程序(可能是不明智地)获取锁的情况下,下一节中的策略可能会有所帮助。
7.5.2.6明确避免信号处理器死锁
假设某个库函数已知会获取锁,但不会阻塞信号。进一步假设需要在信号处理程序内外调用该函数,并且不允许修改此库函数。当然,如果不采取特别措施,那么当信号到达而该库函数持有锁时,信号处理程序调用同一库函数时可能会发生死锁,因为该函数试图重新获取同一锁。
以下方法可以避免死锁:
1.如果应用程序在信号处理程序中调用库函数,则每次从信号处理程序外部调用库函数时,必须阻塞该信号。
2.如果应用程序在信号处理程序中获取的锁上调用库函数,则每次库函数在信号处理程序之外调用时都必须阻塞该信号。
这些规则可以通过使用类似于Linux内核的lockdep锁依赖检查器[Cor06a]的工具来强制执行。lockdep的一个优点是它不会被人类的直觉所欺骗[Ros11]。
7.5.2.7 fork()和exec()之间使用的库函数
如前所述,如果正在执行库函数的线程在其他线程调用fork()时持有锁,则父进程的内存将被复制以创建
子进程意味着这个锁将在子进程的上下文中被持有。释放该锁的线程运行在父进程中,但不在子进程中,这意味着虽然父进程中的锁副本会被释放,但子进程中的锁副本永远不会被释放。因此,子进程尝试调用同一库函数(从而获取同一锁)时,会导致死锁。
解决这个问题的一个实用且直接的方法是在进程仍为单线程时,通过fork()创建一个子进程,并让这个子进程保持单线程状态。随后,可以向这个初始子进程发送创建更多子进程的请求,它可以在其多线程父进程中安全地执行所需的fork()和exec()系统调用。
解决此问题的另一种不太实用和直接的方法是让库函数检查锁的所有者是否仍在运行,如果不是,则通过重新初始化并获取锁来“破坏”它。然而,这种方法有几个漏洞:
1.受该锁保护的数据结构可能处于某种中间状态,因此简单地破坏该锁可能会导致任意内存损坏。
2.如果子进程创建了其他线程,那么两个线程可能会同时破坏锁,结果是这两个线程都认为自己拥有锁。这可能会再次导致任意内存损坏。
pthread_atfork()函数旨在帮助处理这些情况。其核心思想是注册一个三元函数组,其中一个在fork()之前由父进程调用,另一个在fork()之后由父进程调用,最后一个则在fork()之后由子进程调用。这样可以在这三个节点进行适当的清理操作。
请注意,pthread_在fork()处理程序中的编码通常相当微妙。pthread_ atfork()最有效的情况是数据结构可以由子进程重新初始化。这可能是POSIX标准禁止在fork()和exec()之间使用任何非异步信号安全函数的原因之一,该规则排除了在此期间获取锁的可能性。
fork()/exec()includeposix_spawn()andio_uring_spawn()[Tri 22,Edg 22]的其他替代方案。
7.5.2.8并行库:讨论
无论使用何种策略,库的API描述都必须包括对该策略的清晰描述以及调用者应如何与该策略交互。简而言之,
使用锁定构建并行库是可能的,但是并不像构建一个并行应用程序那么容易。
随着低成本多核系统的普及,一个常见的任务是将原本仅设计用于单线程使用的现有库并行化。这种对并行性的忽视非常普遍,可能导致库API在并行编程方面存在严重缺陷。潜在的缺陷包括:
1.隐式禁止分区。
2.需要锁定的回调函数。
3.面向对象的意大利面代码。
以下章节讨论了这些缺陷以及锁定的后果。
7.5.3.1 禁止分区
假设您正在编写一个单线程的哈希表实现。很容易且快速地维护哈希表中项目总数的确切计数,也很容易且快速地在每次添加和删除操作时返回这个确切计数。那么为什么不这样做呢?
一个原因是精确计数器在多核系统上执行或扩展性能不佳,如第5章中所见。因此,哈希表的并行化实现将无法执行或扩展良好。
那么,对此可以做些什么呢?一种方法是使用第5章中的算法返回一个近似计数。另一种方法是完全放弃元素计数。
不管怎样,都需要检查哈希表的使用情况,以了解为什么添加和删除操作需要精确计数。以下是一些可能性:
1.确定何时调整哈希表大小。在这种情况下,近似计数应该相当有效。从最长链的长度触发调整操作也可能很有用,因为可以以非常分区的方式计算和维护最长链。
2.估算遍历整个哈希表所需的时间。在这种情况下,近似计数也很有效。
3.例如,为了诊断目的,检查在向哈希表中添加和从哈希表中移除项目时是否有丢失。这显然需要精确计数。然而,鉴于这种使用方式具有诊断性质,可能只需维护哈希链的长度,然后在锁定增删操作的同时偶尔汇总这些长度即可。
事实证明,现在有一些性能和可伸缩性对并行库API施加的约束已经有了坚实的理论基础[AGH+11a,AGH+ 11b,McK 11b]。任何设计并行库的人都需要密切关注这些约束。
虽然很容易将锁定归咎于并发不友好的API所造成的问题,但这样做并无帮助。另一方面,人们很少
选择同情那位在(比如说)1985年做出这一选择的不幸开发者。当时能够预见并行需求的开发者实属罕见且勇敢,而要真正开发出一个良好的并行友好型API,则需要更加罕见的才华与幸运的结合。
时代变迁,代码亦须随之更新。然而,如果某个流行库拥有大量用户,那么对API进行不兼容的改动就显得十分愚蠢。通常,增加一个并行友好型API来补充现有的主要使用的顺序API是最佳选择。
然而,人性就是这样,我们可以预期我们的不幸开发者更可能抱怨锁定,而不是抱怨他或她自己的糟糕(尽管可以理解的)API设计选择。
7.5.3.2容易发生死锁的回调
第7.1.1.2节, 7.1.1.3 ,和7.5.2 描述了不当使用回调可能导致锁定问题。这些部分还介绍了如何设计库函数以避免这些问题,但期望一个没有并行编程经验的1990年代程序员能够遵循这样的设计是不现实的。因此,尝试将现有单线程、依赖大量回调的库并行化的人员很可能会多次诅咒锁定的罪恶。
如果一个库的回调密集型使用量非常大,明智的做法是再次向该库添加一个并行友好的API,以便现有用户可以逐步转换他们的代码。或者,在这些情况下提倡使用事务内存。尽管关于事务内存的研究尚未定论,第17.2节讨论了其优缺点。需要注意的是,除非硬件事务内存实现提供了前向保证(这在第17.3节中有所讨论),否则硬件事务内存无法在此处发挥作用,而这种情况很少见。其他看似相当实用的替代方案(尽管宣传较少)包括第7.1.1.6节中讨论的方法。 和7.1.1.7 以及将在第8章和第9章中讨论的那些。
7.5.3.3面向对象的意大利面代码
面向对象编程在20世纪80年代或90年代成为主流,因此大量单线程的面向对象代码出现在生产环境中。尽管面向对象可以是一种有价值的软件技术,但对象的无序使用很容易导致面向对象的意大利面代码。在这样的代码中,控制权从一个对象跳到另一个对象,几乎随机地移动,使得代码难以理解,更不用说适应锁定层次结构了。
尽管许多人可能会认为无论如何都应该清理这样的代码,但说起来容易做起来难。如果你的任务是并行化这样一个庞然大物,你可以通过使用第7.1.1.6节中描述的技术来减少抱怨锁定的机会。 以及7.1.1.7, 以及将在第8章和第9章讨论的内容。这种情况似乎是启发事务内存的用例,因此也值得尝试。话虽如此,在选择同步机制时应考虑第3章中讨论的硬件习惯。毕竟,如果同步机制的开销比被保护的操作高出几个数量级,结果就不会很好了。
这引出了一个在这些情况下值得提出的问题:代码是否应该保持顺序?例如,也许应该在进程中引入并行性 级别,而不是线程级别。一般来说,如果一个任务被证明是极其困难的,那么值得花一些时间思考不仅如何完成这个特定的任务,而且如何解决手头的问题。
成就解锁。
未知的
锁定或许是使用最广泛且最有用的同步工具。然而,它在从一开始就设计到应用程序或库中时效果最佳。鉴于大量现有的单线程代码将来可能需要并行运行,因此锁定不应成为你并行编程工具箱中的唯一工具。接下来的几章将讨论其他工具,以及它们如何最好地与锁定和其他工具协同工作。