Chapter 10 Data Structures

坏的程序员担心代码。好的

程序员担心数据结构及其关系。

拉纳斯托瓦尔兹

对算法的认真讨论包括其数据结构的时间复杂度[CLRS01]。然而,对于并行程序,时间复杂度包括并发效应,因为这些影响可能会非常大,如第3章所示。换句话说,一个好的程序员的数据结构关系包括那些与并发性相关的方面。

本章将揭示一些复杂的问题:

1.然而,完全按照第6章中给出的良好建议设计的数据结构可能在某些类型的系统上完全无法扩展。

2.完全按照第6章和第9章中给出的良好建议设计的数据结构仍然可能无法在某些类型的系统上进行扩展。

3.即使是只读的无同步数据结构遍历,也可能无法在某些类型的系统上进行扩展。

4.避免上述并发症的数据结构遍历仍然可以被并发更新所阻碍。

第10.1节介绍了本章数据结构的激励应用程序。第6章展示了分区如何提高可伸缩性,因此第10.2节讨论了可分区的数据结构。第9章描述了如何推迟一些操作可以大大提高性能和可伸缩性,这是第10.3节所讨论的一个主题。第10.4节介绍了不可分区的数据结构,将其划分为大部分和可分区的部分,这提高了性能和可伸缩性。由于本章不能深入研究每个并发数据结构的细节,所以第10.5节调查了一些重要的数据结构。尽管最佳性能和可伸缩性来自于设计,而不是事后的微观优化,但如第10.6节所述,微优化对于获得绝对最佳的性能和可伸缩性是必要的。最后,第10.7节对本章进行了总结。

10.1激励应用程序

做数学的艺术在于发现它

包含所有一般性细菌的特殊情况。

大卫希尔伯特

我们将使用薛定谔动物园的应用程序来评估性能[McK13]。薛定谔有一个包含大量动物的动物园,他想用一个内存数据库来跟踪它们,动物园里的每只动物都用这个数据库中的一个数据项来表示。每只动物都有一个独特的名称,用作一个键,并跟踪每只动物的各种数据。

出生、捕获和购买会导致插入,而死亡、发布和销售则会导致删除。因为薛定谔动物园包含了大量短命的动物,包括老鼠和昆虫,这个数据库必须处理高更新率。那些对薛定谔的动物感兴趣的人可以询问它们,薛定谔对他的猫的询问率非常可疑,以至于他怀疑他的老鼠可能在检查它们的死敌。无论它们的来源是什么,薛定谔的应用程序都必须处理对单个数据元素的高查询率。

正如我们将看到的,这个简单的应用程序可能会对并发数据结构构成挑战。

10.2可分区的数据结构

找到一种简单的方式是最复杂的任务。

亨利·考特尼,更新

今天有大量的数据结构在使用,以至于有很多本教科书覆盖了它们。本节主要介绍单个数据结构,即哈希表。这种集中的方法允许更深入地研究并发性是如何与数据结构交互的,也允许重点关注在实践中大量使用的数据结构。第10.2.1节概述了设计,第10.2.2节介绍了实现。最后,通过Section10.2.3discusses得到了最终的性能和可伸缩性。

10.2.1哈希表设计

第6章强调了为了实现可观的性能和可扩展性,因此在选择数据结构时,分区性必须是一流的标准。并行性的主力,即哈希表,很好地满足了这个标准。哈希表在概念上很简单,由一个哈希桶数组组成。哈希函数从给定元素的键映射到该元素将存储在其中的哈希桶。因此,每个哈希桶都指向一个元素的链接列表,称为哈希链。如果配置得当,这些哈希链将非常短,从而允许哈希表非常有效地访问其元素。

清单10.1:散列表数据结构

1结构2

3

4    }; 5

6结构7

8

9    }; 10

11结构

12

13

14

15    };

ht_elem {

结构化cds_list_head hte_next;无符号长hte_hash;

ht_bucket {

结构体cds_list_head htb_head;spinlock_t htb_lock;

hashtab {

无符号长ht_nbuckets;

int(*ht_cmp)(结构体ht_elem*htep,void*关键);结构体ht_bucket ht_bkt[0];

此外,每个桶都有自己的锁,因此哈希表中不同桶的元素中的元素都可以被添加、删除和完全独立地查找。因此,一个具有大量桶(以及锁)的大型哈希表,每个桶包含少量元素,应该提供出色的可伸缩性。

10.2.2散列表的实现

清单10.1(hash_bkt.c)显示了一组使用简单固定大小哈希表的数据结构,图10.1显示了它们如何组合在一起。散选结构(清单10.1中的第11-15行)包含四个ht_桶结构(清单10.1中的第6-9行),->ht_nbuckets字段控制桶的数量,->ht_cmp字段持有指向键比较函数的指针。每个这样的桶都包含一个列表头->htb_head和一个lock ->htb_lock。列表头通过它们的-> hte_next字段链接ht_elem结构(清单10.1中的第1-4行),每个ht_elem结构也在->hte_hash字段中缓存相应元素的散列值。ht_elem结构包含在一个更大的结构中,其中可能包含一个复杂的密钥。

图10.1显示了包含两个元素的桶0和包含一个元素的桶2。

清单10.2显示了映射和锁定函数。第1行和第2行显示了宏HASH2BKT(),它从哈希值映射到相应的ht_bucket结构。

清单10.2:散列表映射和锁定

1 #define HASH2BKT (htp, h) \

2                            (&(htp)->ht_bkt[h % (htp)->ht_nbuckets]) 3

4个静态void hashtab_lock(结构体对话框选项卡*htp,

5                                                                              无符号的长散列)6{

7                            spin_lock(&HASH2BKT(htp,hash)->htb_lock);8}

9

10个静态void hashtab_unlock(结构体散选选项卡*htp,

11                                                                                    无符号长散列)12{

13                            spin_uloke(&HASH2BKT(htp,hash)->htb_lock);14 }

清单10.3:哈希表查找

1结构ht_elem *

2 hashtab_lookup(结构体哈希选项卡*htp,无符号长哈希,

3                                                空白*键)4{

5                            结构ht_bucket *htb;

6                            结构ht_elem *htep;7

8                           htb = HASH2BKT(htp,哈希);

9                            cds_list_for_each_entry(htep,&htb->htb_head,hte_next){

10                                                   如果(htep->hte_hash !=哈希)

11                                                                           持续

12                                                   如果(htp->ht_cmp(htep,键))

13                                                                           返回htep;14                            }

15                         返回NULL;16 }

这个宏使用了一个简单的模量:如果需要更激进的哈希值,那么调用者需要在从键映射到哈希值时实现它。其余两个函数获取并释放与指定的哈希值对应的->htb_lock。

清单10.3显示了hashtab_lookup(),如果存在,则返回一个指向具有指定哈希和键的元素的指针,否则返回NULL。这个函数同时接受一个哈希值和一个指向键的指针,因为这允许这个函数的用户使用任意键和任意哈希函数。第8行从哈希值映射到指向相应哈希桶的指针。每一个通过循环跨越线9-14检查桶的哈希链的一个元素。第10行检查哈希值是否匹配,如果不匹配,第11行继续转到下一个元素。第12行检查实际的键是否匹配,如果匹配,第13行返回一个指向匹配元素的指针。如果没有元素匹配,则第15行返回NULL。

清单10.4显示了分别从哈希表中添加和删除元素的hashtab_add()和hashtab_del()函数。

hashtab_add()函数只需在第4行上设置元素的散列值,然后将其添加到第5行和第6行上相应的桶中。hashtab_del()函数只是从它所在的任何哈希链中删除指定的元素,这是因为哈希链列表的双链接性质。在调用这两个函数中的任何一个之前,调用者需要确保没有其他线程正在访问或修改这个同一桶,例如,通过预先调用hashtab_lock()。

清单10.4:哈希表的修改

1空

2

3    {

4

5

6

7    } 8

9空10 {

11

12    }

hashtab_add(结构体哈希表卡*htp,无符号长哈希,结构体ht_elem *htep)

htep->hte_hash =哈希;

cds_list_add(&htep->hte_next,

&HASH2BKT(htp,hash)->htb_head);

hashtab_del(结构体ht_elem *htep)

cds_list_del_init(&htep->hte_next);

清单10.5:散列表分配和免费的

1结构哈希选项卡*

2个hashtab_alloc(无符号长nn桶,

3                                             int(*cmp)(结构体ht_elem*htep,void*键))4{

5                            结构hashtab *htp;

6                            int i;7

8                           htp = malloc (sizeof(*htp)+

9                                                                  (结构ht_bucket));

10                            如果(htp == NULL)

11                                                   返回空;

12                           htp->ht_nbuckets = nbuckets;

13                          htp->ht_cmp = cmp;

14                           (i = 0;我<nbbets;我++){

15                                                   CDS_INIT_LIST_HEAD(&htp->ht_bkt[i] .htb_head);

16                                                   spin_lock_init(&htp->ht_bkt[i] .htb_lock);17日                            }

18                           返回htp;19 }

20

21 void hashtab_free(结构体共享选项卡*htp)22 {

23                           自由(htp);24 }

清单10.5显示了hashtab_alloc()和hashtab_free(),它们分别执行散列表分配和释放。分配从第8-9行开始,并分配底层内存。如果第10行检测到内存已耗尽,则第11行向调用者返回NULL。否则,第12和行13初始化桶的数量和指向键比较功能的指针,并且循环跨越行14-17初始化桶本身,包括行15上的链列表头和行16上的锁。最后,第18行返回一个指向新分配的哈希表的指针。第21-24行上的hashtab_free()函数很简单。

10.2.3哈希表性能

2.1 GHz IntenXeon系统的单个28核插座使用具有262,144个桶的桶锁哈希表的性能结果如图10.2所示。性能确实几乎呈线性地扩展,但它远远低于理想的性能水平,即使只有28个cpu。这种不足的部分原因是,锁的获取和释放在单个CPU上没有导致缓存丢失,但在两个或多个CPU上确实没有丢失。

随着更多的cpu,情况只会变得更糟,如图10.3所示。我们不需要展示理想的性能:29个cpu及以上的性能就是全部

这显然比糟糕透顶更糟糕了。这清楚地强调了从适度数量的cpu中推断性能的危险。

当然,性能崩溃的一个可能原因可能是需要更多的散列桶。我们可以通过增加散列桶的数量来测试这一点。

然而,从图10.4中可以看出,更改桶的数量几乎没有影响:可伸缩性仍然非常糟糕。特别是,我们仍然看到29个cpu及以上的急剧下降,清楚地显示了在289页提出的复杂性。同样明显的是,还有其他的事情正在发生。

问题是,这是一个多套接字系统,cpu0-27和225-251映射到第一个套接字,如图10.5所示。因此,仅局限于前28个CPU的测试运行运行得相当好,但是涉及套接字0的CPu0-27以及套接字1的CPU 28的测试会产生跨套接字边界传递数据的开销。这可能会严重降低性能,正如在Section3.2.1中所讨论的那样。简而言之,大型多套接字系统除了需要完全分区外,还需要良好的参考局部性。本章的其余部分将讨论如何提供良好的参考地点

但与此同时,请注意,提供良好的引用位置的另一种方法是在哈希表中放置大型数据元素。例如,薛定谔可以通过在散列表的每个元素中放置他的动物的照片甚至视频来获得极好的缓存位置。但是对于那些需要包含小数据元素的散列表的人,请继续阅读!

迄今为止讨论的薛定谔动物园运行的一个关键特性是它们都是只读的。这使得由于锁定获取引起的缓存而导致的性能下降更加痛苦。即使我们没有更新底层的哈希表本身,我们仍然在为写入内存付出代价。当然,如果哈希表永远不会更新,我们可以完全免除相互排斥。这种方法非常简单,并留给读者作为一个练习。但是,即使偶尔更新,避免写也可以避免缓存丢失,并允许读缓存在所有缓存上复制数据,这反过来促进了引用的局部性。

因此,下一节将检查可以在读取中执行的优化——大多数是在更新很少,但可能随时发生的情况下。

10.3读取-主要为数据结构

适应疾病。

中国谚语

尽管分区的数据结构可以提供优秀的可伸缩性,但非效应会导致性能

和可伸缩性的严重下降。此外,在大多数需要读端同步的情况下,需要读端同步可能会降低性能。然而,我们可以通过使用RCU来实现性能和可伸缩性,这在9.5节中介绍。使用危险指针也可以获得类似的结果

清单10.6:受RCU保护的散列表读取端并发性控制

1

静止的

void hashtab_lock_lookup(结构体对话框选项卡*htp,

2

无符号长哈希)

3

{

4

rcu_read_lock() ;

5

}

6

7

静止的

void hashtab_unlock_lookup(结构体对话框选项卡*htp,

8

无符号长哈希)

9

{

10

rcu_read_unlock() ;

11

}

(hazptr.c)[Mic04a],这将包括在本节[McK13]中显示的性能结果中。

受10.3.1 RCU保护的哈希表实现

对于具有每个桶锁定的受RCU保护的散列表,更新器使用如第10.2节所示的锁定,但是读取器使用RCU。数据结构仍如清单10.1所示,而HASH2BKT()、hashtab_lock()和hashtab_unlock()函数仍如清单10.2所示。但是,读者使用清单10.6中所示的hashtab_lock_lookup()和hashtab_unlock_lookup()所体现的较轻的并发控制。

清单10.7显示了受RCU保护的每个桶锁定的哈希表的hashtab_lookup()。这与清单10.3中的相同,只是cds_list_for_wech_entry()被cds_list_for_each_entry_rcu()替换。这两个原语都遍历了htb->htb_head所引用的哈希链,但是cds_list_for_ each_entry_rcu()在并发插入的情况下也正确地强制执行了内存顺序。这是这两个哈希表实现之间的一个重要区别:与纯的每桶锁实现不同,RCU受到保护的实现允许查找与插入和删除并发运行,并且需要像cds_list_for_each_entry_rcu()这样的RCU感知原语来正确处理这个添加的并发性。还要注意,hashtab_lookup()的调用者必须在RCU读侧关键部分内,例如,调用者必须在调用hashtab_lookup()之前调用hashtab_lock_查找()(当然之后还会调用hashtab_uloke_查找())。

清单10.8显示了hashtab_add()和hashtab_del(),这两者与清单10.4所示的非RCU哈希表中的对应非常相似。hashtab_add()函数使用cds_list_add_rcu()而不是cds_list_add(),以确保在查找元素时元素添加到哈希表时进行正确的排序。hashtab_del()函数使用cds_list_ del_rcu()而不是cds_list_del_init()来允许元素在删除之前被查找的情况。与cds_list_del_init()不同,cds_list_del_ rcu()保持正向指针完整,因此hashtab_lookup()可以遍历到新删除的元素的后继元素。

当然,在调用hashtab_del()之后,调用者必须等待一个RCU宽限期(例如,通过调用synchronize_rcu()),然后才能释放或以其他方式重用新删除的元素的内存。

受10.3.2 RCU保护的散列表验证

尽管在第11章中详细介绍了验证的主题,但事实是,具有无锁RCU保护查找的哈希表需要尽早特别注意验证。

测试套件(“hash酷刑。h”)包含一个最致命的()函数,该功能验证特定的单线程添加、删除和查找是否能给出预期的结果。

并发测试运行将每个更新线程控制其部分的元素,这允许断言检查以下问题:

1.根据hastab_查找(),表中已经添加了一个即将添加的元素。

2.一个即将添加的元素被它的->in_ table标志标记为在表中。

3.根据hastab_查找(),正在删除的元素。

4.一个即将被删除的元素被其->in_table标志标记为不在表中。

此外,并发测试运行与更新同时运行查找,以捕获所有类型的数据结构损坏问题。有些运行还会与查找和更新同时不断调整哈希表的大小,以验证正确的行为,并验证调整大小是否不会过度延迟读取器或更新器。

最后,并发测试输出的统计信息可用于跟踪性能和可伸缩性问题,它提供了第10.3.3节中使用的原始数据。

所有代码都需要大量的验证工作,而高性能并发代码比大多数代码需要更多的验证。

受10.3.3 RCU保护的哈希表性能

图10.6显示了受RCU保护和受危险指针保护的哈希表对上一节的每个桶锁定实现的只读性能。

正如您所看到的,RCU和危险指针的性能和扩展能力都比每个桶锁定要好得多,因为只读复制避免了NUMA效果。这种差异随着线程数量的增加而增大。还显示了来自全局锁定的实现的结果,正如预期的那样,结果甚至比每个桶锁定的实现的结果更差。RCU的表现略好于危险指针。

图10.7显示了线性尺度上的相同数据。这将全局锁定跟踪降低到x轴上,但允许更容易识别RCU和危险指针的非理想性能。两者都显示了224个cpu的斜率的变化,这是由于硬件多线程。在224个和更少的cpu中,每个线程本身都有一个核心。在这种情况下,RCU比危险指针做得更好,因为后者的读侧内存障碍导致核心内的死时间。简而言之,RCU比危险指针更能利用来自单个硬件线程的核心。

这种情况在224个cpu以上有所改变。因为RCU从单个硬件线程中使用了每个核心的超过一半的资源,所以RCU从每个核心中的第二个硬件线程中获得的好处相对较小。危险指针跟踪的斜率在224个cpu时也会减小,但不那么显著,因为第二个硬件线程能够填充第一个硬件线程由于内存屏障延迟而停止的时间。正如我们将在后面的章节中看到的,第二硬件线程优势取决于工作负载。

但为什么RCU的表现不如理想的五倍呢?一种可能性是,由rcu_read_lock()和rcu_read_unlock()操作的每个线程计数器正在减缓事情的速度。因此,图10.8添加了RCU的QSBR变体的结果,其读侧原语不做任何工作。尽管QSBR的表现比RCU稍微好一些,但它仍然比理想值差五倍。

图10.9添加了完全不同步的结果,这可以工作,因为这是一个只读基准,没有同步。即使没有任何同步,性能仍然远远低于理想水平,因此在第289页显示了另外两个复杂性。

问题是,该系统有28个核的套接字,其缓存大小如第37页的表3.2所示。每个哈希桶(结构体ht_bucket)占用56个字节,每个元素(结构体zoo_he)对于RCU和QSBR的运行占用72个字节。生成图10.9的基准测试使用了262,144个桶和

多达262,144个元素,总共有33,554,448字节,这不仅溢出了1,048,576字节的L2缓存超过30倍,而且也令人不安地接近L3缓存大小的40,370,176字节,特别是考虑到这个缓存只有11种方式。这意味着L2缓存冲突将是规则,而且L3缓存冲突也不会很常见,因此产生的缓存丢失将降低性能。在这种情况下,瓶颈不在CPU中,而是在硬件内存系统中。

通过检查非同步的代码,还可以发现存在这种内存系统瓶颈的其他证据。这段代码不需要锁,因此每个哈希桶只占用16个字节,而RCU和QSBR的占用量是56个字节。类似地,每个哈希表元素只占用56个字节,而RCU和QSBR只有72个字节。因此,单cpu非同步运行的执行速度比QSBR或RCU都快了大约一半,这也就不足为奇了。

如果内存占用会进一步减少怎么办?图788页的E.5显示,RCU在由bsd前路由表表示的更小的数据结构上获得了非常接近理想的性能。

如前所述,薛定谔对他的猫[史表35]的受欢迎程度感到惊讶,但他意识到需要在他的设计中反映这种受欢迎程度。图10.10显示了64-CPU运行的结果,它改变了除了查找猫之外什么都不做的cpu的数量。RCU和危险指针都能很好地响应这一挑战,但桶锁定有负面影响,最终表现与全局锁定一样糟糕。这并不令人惊讶,因为如果所有的cpu除了查找猫之外什么都不做,那么与猫的桶相对应的锁实际上都是一个全局锁。

这个只有猫的基准测试说明了完全分区分片方法的一个潜在问题。只有与猫的分区关联的cpu才能访问

猫,限制了只有猫的吞吐量。当然,许多应用程序都具有良好的负载扩展特性,而且对于这些应用程序,分片工作得很好。然而,分片并不能很好地处理“热点”,薛定谔的猫代表的热点只是一个恰当的例子。

如果我们只打算读取这些数据,那么我们首先就不需要任何并发性控制。因此,图10.11显示了更新对阅读器的影响。在这个图的最左边,除了一个cpu外,所有cpu都在进行查找,而右边所有448个cpu都在进行更新。对于所有四种实现,每毫秒内的查找数量随着更新cpu数量的增加而减少,当然,当所有448个cpu都在更新时,每毫秒内达到零查找。与每个桶锁定相比,危险指针和RCU都很好,因为它们的读取器不会增加更新侧锁争用。RCU相对于风险指针做得很好,因为由于后者的读端内存障碍,更新者的数量增加,这导致了更大的开销,特别是在存在更新的情况下,特别是当执行涉及多个套接字时。因此,现代硬件似乎严重优化了内存屏障的执行,大大减少了只读情况下的内存屏障开销。

其中,图10.11显示了增加更新率对查找的影响,图-ure10.12显示了增加更新率对更新本身的影响。同样,在图的左边,除了一个cpu外,所有的cpu都在进行查找,而在图的右边,所有的448个cpu都在进行更新。危险指针和RCU一开始有一个显著的优势,因为与桶锁定不同,读取器不排除更新器。然而,随着更新cpu数量的增加,更新端开销开始人们知道它的存在,首先是RCU,然后是危险指针。当然,所有这三种实现都优于全局锁定。

在Figure10.11中观察到的查找性能的差异很可能会受到更新速率的差异的影响。检查这一点的一种方法是人为地限制每个桶锁定和危险指针的更新速率,以匹配RCU。这样做并不会显著提高每个桶锁定的查找性能,也不会缩小危险指针和RCU之间的差距。然而,从危险指针中删除读取侧内存障碍(从而导致不安全的实现)确实几乎缩小了危险指针和RCU之间的差距。

尽管这种不安全的危险指针实现通常对于基准测试目的足够可靠,但绝对不建议用于生产使用。

这种情况暴露了第289页列出的另一个并发症。

受10.3.4 RCU保护的散列表讨论

RCU和危险指针实现的一个结果是,一对并发读取器可能对猫的状态存在分歧。例如,其中一个阅读器可能在删除猫的数据结构之前获取了指向它的指针,而另一个阅读器可能在之后获取了相同的指针。第一个读者会相信这只猫还活着,而第二个读者会相信这只猫已经死了。

这种情况完全适合薛定谔的猫,但事实证明,它对正常的非量子猫也是相当合理的。毕竟,我们不可能确切地确定一只动物的出生或死亡时间。

为了看到这一点,我们假设我们发现一只猫的心跳。这就提出了一个问题,即我们应该在最后一次心跳后等多久才能宣布死亡。只等一毫秒显然是荒谬的,因为这样一来,一只健康的活猫就必须被宣布死亡——然后复活——每秒超过一次。等待整整一个月也是荒谬的,因为到那时,这只可怜的猫的死亡已经通过嗅觉清楚地知道了。

因为动物的心脏可以停止几秒钟,然后再次启动,所以在及时识别死亡和出现错误警报的可能性之间存在一种权衡。很有可能,一对兽医在最后一次心跳和宣布死亡之间的等待时间上存在分歧。例如,一名兽医可能会在最后一次心跳发生30秒后宣布死亡,而另一名兽医可能会坚持等待整整一分钟。在这种情况下,两位兽医会对猫在最后一次心跳后的第二次30秒内的状态产生分歧,如图10.13所示。

海森堡教会了我们如何应对这种不确定性,这是一件好事,因为计算硬件和软件的行为是相似的。例如,你怎么知道一个计算硬件已经失败了?通常是因为它没有及时做出反应。就像猫的心跳一样,这导致了一个不确定的窗口,即硬件是否真的失败了,而不仅仅是速度太慢。

此外,大多数计算系统都旨在与外部世界进行交互。因此,与外界的一致性至关重要。然而,正如我们在第266页的图9.28中所看到的那样,增加内部一致性可能会以降低外部一致性为代价。诸如RCU和危险指针等技术放弃了某种程度的内部一致性,以获得改进的外部一致性。

简而言之,内部一致性并不一定是所有问题领域的自然组成部分,而且在性能、可伸缩性、与外部世界的一致性方面往往要付出巨大的代价[HKLP12、HHK+13、Rin13]或以上所有内容。

10.4不可分区的数据结构

如果有指示,不要害怕迈出一大步。你不能只走两步就能跨越一个鸿沟。

大卫劳埃德乔治

固定大小的哈希表是完全可分区的,但是可调整大小的哈希表在增长或缩小时构成了部分分区的挑战,如图10.14所示。但是,事实证明,可以构建高性能的可扩展的受RCU保护的散列表,如下面的部分所述。

10.4.1可调整的哈希表设计

与21世纪初的情况形成鲜明对比的是,现在至少有三种不同类型的可扩展的受RCU保护的哈希表。第一个(也是最简单的)是由HerberbertXu[Xu10]为Linux内核开发的,并在下面的章节中进行描述。另外两个问题将在第10.4.4节中简要介绍。

第一个哈希表实现背后的关键见解是,每个数据元素可以有两组列表指针,其中一组目前被RCU阅读器使用(以及非RCU更新器使用),另一组用于构建一个新的调整大小的哈希表。这种方法允许查找、插入和删除与一个调整大小的操作同时运行(以及彼此同时运行)。

调整大小操作如图10.15–10.18所示,初始双桶状态如图10.15所示,随着时间从图推进到图。初始状态使用零索引链接将元素链接到散列桶中。A

分配了四桶数组,并使用单索引链接将元素链接到这四个新的哈希桶中。这导致了图10.16所示的状态(b),读取器仍然使用原始的双桶数组。

新的四桶数组将公开给读取器,然后会有一个宽限期操作等待所有读取器,从而导致状态(c),如图10.17所示。在这种状态下,所有的读取器都在使用新的四桶数组,这意味着旧的双桶数组现在可能会被释放,从而导致状态(d),如图10.18所示。

这个设计导致了一个相对简单的实现,这是下一节的主题。

10.4.2可重新调整大小的哈希表实现

调整大小是通过插入间接级别的经典方法来完成的,在本例中,是清单10.9的第11-20行(hash_reseze.c)中显示的ht结构。第27-30行所示的哈希表卡结构只包含一个指向当前ht结构的指针,以及一个用于序列化用来调整哈希表大小的并发尝试的自旋锁。如果我们要使用传统的基于锁或原子操作的实现,那么从性能和可伸缩性的角度来看,这种共享选项卡结构可能会成为一个严重的瓶颈。但是,由于调整大小的操作应该相对少见,所以我们应该能够很好地利用RCU。

ht结构表示哈希表的特定大小,由第12行中的->ht_nbuckets字段指定。大小存储在包含桶数组的相同结构中(第19行中的->ht_bkt[]),以避免大小和数组之间的不匹配。第13行上的->ht_resize_cur字段等于-1,除非正在进行调整大小操作,在这种情况下,它指示元素正在插入新哈希表的桶的索引,由引用

清单10.9:可重新调整的哈希表数据结构

1结构2

3

4    }; 5

6结构7

8

9    }; 10

11结构

12

13

14

15

16

17

18

19

20    }; 21

22结构23

24

25    }; 26

27结构28

29

30    };

ht_elem {

结构rcu_head rh;

结构体cds_list_head hte_next[2];

ht_bucket {

结构体cds_list_head htb_head;spinlock_t htb_lock;

ht {

长ht_nbuckets;

长ht_resize_cur;结构ht *ht_new;int ht_idx;

int(*ht_cmp)(结构体ht_elem*htep,void*键);无符号长(*ht_gethash)(void*键);

void*(*ht_getkey)(结构体ht_elem *htep);结构体ht_bucket ht_bkt[0];

ht_lock_state {

结构体ht_bucket *hbp[2];int hls_idx[2];

hashtab {

结构ht *ht_cur;spinlock_t ht_lock;

-第14行上的>ht_new字段。如果没有正在进行的调整大小操作,则->ht_new为NULL。因此,调整大小操作通过分配一个新的ht结构并通过->ht_new指针引用它来进行,然后通过旧表的桶推进->ht_resize_cur。当所有元素都被添加到新表中时,新表将被链接到散列选项卡结构的->ht_cur字段中。一旦所有的老读者完成,旧哈希表的ht结构就可以被释放。

第15行上的->ht_idx字段表示哈希表的这个实例化所使用的两组列表指针中的哪一组,并用于索引第3行上的ht_elem结构中的->hte_next[]数组。

第16-18行上的->ht_cmp()、->ht_gethash()和->ht_getkey()字段共同定义了每个元素的键和哈希函数。->ht_cmp()函数将指定的键与指定元素的键进行比较,->ht_gethash()计算指定键的散列,并且->ht_getkey()从封闭的数据元素中提取键。

第22-25行所示的ht_lock_state用于将来自新hashtab_lock_mod()的锁定状态通信给hashtab_add()、hashtab_del()和hashtab_ unlock_mod()。此状态可防止在并发调整大小操作期间将算法重定向到错误的桶。

ht_bucket结构与以前相同,而ht_elem结构与以前实现的不同之处在于,只是提供了一个列表指针集的双元素数组来代替之前的单个列表指针集。

在固定大小的哈希表中,桶的选择非常简单:只需将哈希值转换为相应的桶索引。相反,在调整大小时,还需要确定从哪组中选择新旧桶。如果将从旧表中选择的桶已经分发到新表中,则应该从新表和旧表中选择桶

清单10.10:调整大小的散希表桶选择

1个静态结构体ht_bucket *

2 ht_get_bucket(结构体ht*htp,void*键,3                                             长符号,无符号长符号

5                           无符号长散列=htp->ht_gethash(键);6

7                         *b =散列% htp->ht_nbuckets;

8                            如果(h)

9                                                   *h =哈希;

10                           返回&htp->ht_bkt[*b];11 }

12

13个静态结构体ht_elem *

14 ht_search_bucket(结构htp,void键)15{

16                            长b;

17                            结构ht_elem *htep;

18                            结构ht_bucket *htbp;19

20                           htbp = ht_get_bucket(htp,key,&b,空);

21                            cds_list_for_each_entry_rcu (htep,

22                                                                                                              &htbp->htb_head,

23                                                                                                              hte_next[htp->ht_idx]) {

24                                                   如果(htp->ht_cmp(htep,键))

25                                                                           返回htep;26                            }

27                           返回NULL;28 }

表相反地,如果将从旧表中选择的桶尚未被分发,则应该从旧表中选择该桶。

桶的选择如清单10.10所示,其中显示了第1-11行上的ht_get_bucket()和第13-28行上的ht_search_bucket()。ht_get_bucket()功能返回一个对与指定哈希表中指定键对应的桶的引用,而不考虑调整大小。它还将键对应的桶索引存储到第7行参数b引用的位置中,将键对应的哈希值存储到第9行pa-ramemerh(如果非空)引用的位置中。然后,第10行返回对相应桶的引用。

ht_search_bucket()函数在指定的散列表版本中搜索指定的键。第20行获得对与指定键对应的桶的引用。循环生成行21-26搜索该桶,因此,如果第24行检测到匹配,则第25行返回一个指向封闭数据元素的指针。否则,如果没有匹配,第27行返回NULL以表示失败。

ht_get_bucket()和ht_search_bucket()的这种实现允许查找和修改与调整大小的操作并发地运行。

如清单10.6所示,但是更新端并发控制函数hashtab_lock_mod()和hashtab_ unlock_mod()现在必须处理并发调整大小操作的可能性,如清单10.11所示。

hashtab_lock_mod()跨越了列表中的第1-25行。第10行进入RCU读侧临界部分,以防止在遍历期间释放数据结构,第11行获取对当前哈希表的引用,然后第12行获得对对应于该哈希表中的桶的引用。13号线获得

清单10.11:可调整的哈希表更新端并发控制

1个静态空白

2 hashtab_lock_mod(结构体标签表卡*htp_master,void*键,

3                                                      结构体ht_lock_state *lsp) 4 {

5                            长b;

6                        无符号长h;

7                            结构ht *htp;

8                            结构ht_bucket *htbp;9

10                           rcu_read_lock() ;

11                           htp = rcu_dereference(htp_master->ht_cur);

12                           htbp = ht_get_bucket(htp,key,&b,&h);

13                            旋转锁(&htbp->htb_lock);

14                            lsp->hbp[0] = htbp;

15                            lsp->hls_idx[0] = htp->ht_idx;

16                         如果(b > READ_ONCE(htp->ht_resize_cur)){

17                                                   lsp->hbp[1] =空;

18                                                   返回;19                            }

20                           htp = rcu_dereference(htp->ht_new);

21                           htbp = ht_get_bucket(htp,key,&b,&h);

22                            旋转锁(&htbp->htb_lock);

23                            lsp->hbp[1] = htbp;

24                            lsp->hls_idx[1] = htp->ht_idx; 25 }

26

27静态空隙

28 hashtab_unlock_mod(结构体ht_lock_state *lsp)29 {

30                            spin_ulook(&lsp->hbp[0]->htb_lock);

31                            如果(lsp->hbp[1])

32                                                   spin_ulook(&lsp->hbp[1]->htb_lock);

33                           rcu_read_unlock() ; 34 }

桶的锁,这将防止任何并发调整大小的操作来分配该桶,尽管当然,如果该桶已经被分配,它将没有任何影响。第14-15行将桶指针和指针集索引存储在ht_lock_state结构中各自的字段中,从而将信息传递给hashtab_add()、hashtab_del()和hashtab_unlock_mod()。第16行检查并发调整大小操作是否已经在新的哈希表中分配了这个桶,如果没有,第17行表示没有已经调整大小的哈希桶,第18行返回所选择的哈希桶的锁(从而阻止并发调整大小操作分配这个桶),也在RCU读侧临界部分内。避免了死锁,因为旧表的锁总是比新表的锁获得,而且使用RCU可以防止在给定时间存在两个以上的版本,从而防止了死锁循环。

否则,并发调整大小操作已经分配了这个桶,因此第20行继续到新的哈希表,第21行选择与键对应的桶,第22行获得桶的锁。第23-24行将桶指针和指针集索引存储在ht_lock_state结构中各自的字段中,并再次将此信息传递给hashtab_add()、hashtab_del()和hashtab_unlock_mod()。因为这个桶已经调整了大小,因为hashtab_add()和hashtab_del()影响旧的和新的ht_bucket结构,所以两个锁在两个桶上一个。此外,还使用了ht_lock_state结构中每个阵列的两个元素,其中[0]元素与旧的ht_bucket结构有关,而[1]元素与新的结构有关。同样,hashtab_lock_mod()在RCU读取侧临界部分中退出。

清单10.12:可调整大小的哈希表访问函数

1结构ht_elem *

2 hashtab_lookup(结构体标签表卡*htp_master,void*键)3{

4                            结构ht *htp;

5                            结构ht_elem *htep;6

7                           htp = rcu_dereference(htp_master->ht_cur);

8                           htep = ht_search_bucket(htp,密钥);

9                           返回htep;10 }

11

12 void hashtab_add(结构体ht_elem *htep,

13                                                      结构体ht_lock_state *lsp) 14 {

15                            结构体ht_bucket *htbp = lsp->hbp[0];

16                            int i = lsp->hls_idx[0];17

18                            cds_list_add_rcu(&htep->hte_next[i],&htbp->htb_head);

19                            if((htbp = lsp->hbp[1])){

20                                                   cds_list_add_rcu(&htep->hte_next[!我],&htbp->htb_head);21                            }

22    } 23

24 void hashtab_del(结构体ht_elem *htep,

25                                                      结构体ht_lock_state *lsp) 26 {

27                            int i = lsp->hls_idx[0];28岁的时候

29                            cds_list_del_rcu(&htep->hte_next[i]);

30                            如果(lsp->hbp[1])

31                                                   cds_list_del_rcu(&htep->hte_next[!i]); 32 }

hashtab_unlock_mod()函数释放由hashtab_ lock_mod()获取的锁。第30行释放了在旧的ht_bucket结构上的锁。在第31行确定正在进行调整大小操作的不太可能的事件中,第32行释放在新的ht_bucket结构上的锁。无论如何,第33行退出RCU读侧临界部分。

现在我们有了桶选择和并发控制,我们准备搜索和更新可调整大小的散列表。hashtab_lookup()、hashtab_add()和hashtab_del()函数如清单10.12所示。

列表的第1-10行上的hashtab_lookup()函数执行散列查找。第7行获取当前的哈希表,第8行搜索与指定键对应的桶。第9行在搜索失败时返回一个指向搜索元素或空的指针。调用者必须位于RCU读侧关键区内。

列表的第12-22行上的hashtab_add()函数向哈希表添加了新的数据元素。第15行拾取要添加新元素的当前ht_bucket结构,而第16行拾取指针对的索引。第18行将新元素添加到当前的哈希桶中。如果第19行确定该桶已被分配到哈希表的新版本,则第20行还将新元素添加到相应的新桶中。调用者需要处理并发性,

清单10.13:可调整大小的哈希表大小

1 int

2

3

4

5

6    {

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48    }

hashtab_resize(结构体散选标签*htp_master,无符号长n桶,

int(*cmp)(结构体ht_elem*htep,void*键),

无符号长(键)(键)、键(键)(结构ht_elem htep))

结构ht *htp;

结构ht *htp_new;int i;

int idx;

结构ht_elem *htep;

结构ht_bucket *htbp;

结构体ht_bucket *htbp_new;长b;

如果spin_trylock(&htp_master->ht_lock))返回-EBUSY;

htp = htp_master->ht_cur;

htp_new=ht_alloc(nbuge,

cmp ?cmp : htp->ht_cmp,

gethash ?gethash : htp->ht_gethash, getkey ?getkey : htp->ht_getkey);

如果(htp_new == NULL){

spin_ulonk(&htp_master->ht_lock);

返回-ENOMEM;}

idx = htp->ht_idx;

htp_new->ht_idx = !idx;

rcu_assign_pointer(htp->ht_new,htp_new);synchronize_rcu();

为(i = 0;i < htp->ht_nbuckets;i++){ htbp = &htp->ht_bkt[i];

旋转锁(&htbp->htb_lock);

cds_list_for_each_entry(htep,&htbp->htb_head,hte_next[idx]){

htbp_new = ht_get_bucket(htp_new,htp_new->ht_getkey(htep),&b,NULL);spin_lock(&htbp_new->htb_lock);

cds_list_add_rcu(&htep->hte_next[!idx],&htbp_new->htb_head);spin_ulook(&htbp_new->htb_lock);

}

WRITE_ONCE(htp->ht_resize_cur,i);spin_oloke(&htbp->htb_lock);

}

rcu_assign_pointer ( htp_master - >ht_cur , htp_new ); synchronize_rcu() ;

spin_uloky(&htp_master->ht_lock);免费(htp);

返回0;

例如,在调用hashtab_add()之前调用hashtab_lock_mod(),在之后调用hashtab_unlock_mod()。

该列表的第24-32行上的hashtab_del()函数从哈希表中删除了一个现有的元素。第27行拾取指针对的索引,并且第29行从当前表中删除指定的元素。如果第30行确定该桶已被分配到哈希表的新版本,则第31行也从相应的新桶中删除指定的元素。与hashtab_add()一样,调用者负责并发控制,并且此并发控制足以与并发的调整大小操作进行同步。

实际的大小调整本身由hashtab_resize进行,如第311页的Listing10.13所示。第16行有条件地获取顶级->ht_lock,如果此获取失败,则第17行返回-EBUSY,以表示调整大小已经在进行中。否则,第18行拾取对当前哈希表的引用,并且第19-22行分配一个所需大小的新哈希表。如果指定了一组新的哈希/键函数,则将用于新表,否则将保留旧表的函数。如果第23行检测到内存分配失败,则第24行释放->ht_lock,第25行返回故障指示。

第27行拾取当前表的索引,第28行存储其与新哈希表的倒数,从而确保两个哈希表避免覆盖彼此的链接表。然后,第29行通过将对新表的引用安装到旧表的->ht_new字段中来开始桶-分配过程。第30行确保所有不知道新表的读取器在调整大小操作之前完成。

每次通过循环跨越行31-42,都将旧哈希表的一个桶的内容分配到新哈希表中。第32行获取对旧表的当前桶的引用,而第33行获取该桶的自旋锁。

每个通过循环跨越线34-39的通道都将来自当前旧表桶的一个数据元素添加到相应的新表桶中,在添加操作期间保持新表桶的锁。第40行更新->ht_resize_cur,以表示此桶已被分发。最后,第41行释放旧表桶锁。

一旦所有旧表桶被分配到新表中,执行就到达第43行。第43行将新创建的表安装为当前的表,并且第44行等待所有旧的阅读器(他们可能仍在引用旧的表)完成。然后,第45行释放可调整大小的序列化锁,第46行释放旧的哈希表,最后,第47行返回成功。

10.4.3可调整的哈希表讨论

图10.19将哈希表中262144和2097152个元素的固定大小的哈希表进行了比较。该图显示了每个元素计数的三个轨迹,一个用于固定大小262144桶哈列表,另一个用于固定大小524288桶哈列表,第三个用于在262144和524288个桶之间来回移动的可调整大小哈列表,每个调整大小操作之间有一毫秒的暂停。

最上面的三个痕迹是针对262,144个元素的哈希表。1虚线轨迹对应于两个固定大小的哈希表,而实线轨迹对应于可调整大小的哈希表。在这种情况下,短哈希链导致正常查找开销非常低,以至于调整大小的开销占了大部分范围。特别是,整个哈希表都适合于L3缓存中。

下面的三个轨迹是针对2,097,152个元素的哈希表。上面的虚线跟踪对应于262,144桶固定大小的哈希表,中间的实体跟踪用于低CPU计数,底部的高CPU计数对应于可调整大小的哈希表,另一个跟踪对应于524,288桶固定大小的哈希表。事实上,现在每个桶平均有8个元素,这只能预期会导致性能的急剧下降,如图所示。但更糟糕的是,哈希表元素占用128 MB,这溢出了每个套接字的39 MB L3缓存,其性能结果类似于第3.2.2节中描述的结果。由此产生的缓存溢出意味着内存系统即使是只读基准测试也会涉及到,正如您从下面三个轨迹的子线性部分可以看到的,内存系统可能是一个严重的瓶颈。

参考表3.1的最后一列,我们记得前28个cpu在第一个套接字中,每核一个cpu,这解释了可调整大小散列表的性能急剧下降超过28个cpu。虽然这种下降是急剧的,但请记住,这是由于不断地来回调整大小。显然,最好将大小调整到524288个桶,或者,更好的是,将8倍大小调整到2097152个元素,从而将每个桶的平均元素数量降低到产生上三个痕迹的运行所享受的水平。

这些数据的关键是,受RCU保护的可调整大小散列表的性能和缩放能力几乎与其固定大小的哈希表一样好。当然,实际调整大小操作期间的缓存丢失导致的,当内存系统成为瓶颈时,这种效果最为明显。这表明哈希表应该调整大量的大小,并且应该应用迟滞现象来防止由于过于频繁地调整大小操作而导致的性能下降。在内存丰富的环境中,哈希表的大小应该比减少得更积极。

另一个关键的点是,尽管散选标签结构是不可分区的,但它也是可读取的——主要是,这建议使用RCU。鉴于这个可调整大小的哈希表的性能和可伸缩性与受RCU保护的固定大小的哈希表非常接近,我们必须得出这种方法相当成功的结论。

最后,需要注意的是,插入、删除和查找可以与调整大小的操作同时进行。当调整大型哈希表的大小时,这种并发性非常重要,特别是对于必须满足严重响应时间限制的应用程序。

当然,ht_elem结构的一对指针集确实增加了一些内存开销,这将在下一节中讨论。

10.4.4其他可调整的哈希表

本节前面描述的可调整大小散列表的一个缺点是内存消耗。每个数据元素都有两对链表指针,而不是只有一对。是否有可能创建一个受RCU保护的可调整大小的哈希表,使它只做一对?

结果是,答案是肯定的。Josh Triplett等人[TMW11]产生了一个相对论哈希表,它逐步分割和组合相应的哈希链,以便读者在调整大小操作期间的所有点上总是看到有效的哈希链。这种增量的分裂和组合依赖于这样一个事实,即阅读器看到应该在其他哈希链中的数据元素是无害的:当这种情况发生时,阅读器将会由于键不匹配而忽略无关的数据元素。

图10.20显示了将相对论哈希表收缩两倍的过程,在这种情况下,将双桶哈希表收缩为单桶哈希表,也称为线性列表。此过程的工作方法是将旧的较大哈希表中的桶对合并为新的较小哈希表中的单个桶。为了使这个过程正确地工作,我们显然需要约束这两个表的哈希函数。其中一个约束是对两个表使用相同的底层哈希函数,但在从大收缩到小时抛出低阶位。例如,旧的双桶哈希表将使用该值的两个前位,而新的单桶哈希表可以使用该值的前位。通过这种方式,旧的大哈希表中的一对相邻的偶数和奇数桶可以合并成新的小哈希表中的单个桶,同时仍然有一个哈希值覆盖单个桶中的所有元素。

初始状态显示在图的顶部,随着时间从上到下的推进,从初始状态(a).开始收缩过程首先分配新的较小的桶数组,并让这个新的较小数组的每个桶引用旧的大哈希表中对应对的一个桶的第一个元素,从而产生状态(b).

然后将这两个散列链连接在一起,形成状态(c).在这种状态下,查找偶数元素的读者看不到任何变化,查找元素1和元素3的读者同样看不到任何变化。然而,查找其他一些奇数的读者也会遍历元素0和元素2。这是无害的,因为任何奇数都会比较不等于这两个元素。虽然存在一些性能损失,但另一方面,这与新的小哈希表完全到位后将经历的性能损失完全相同。

接下来,让读者可以访问新的小哈希表,从而产生状态(d).请注意,旧的阅读器可能仍然在遍历旧的大型哈希表,因此在这种状态下,两个哈希表都在使用中。

下一步是等待所有已存在的阅读器完成,从而导致状态(e)。在这种状态下,所有的读取器都在使用新的小哈希表,以便可以释放旧的大哈希表的桶,从而产生最终状态(f)。

增长一个相对论性哈希表逆转了收缩过程,但需要更多的宽限期步骤,如图10.21所示。初始状态(a)在这个图的顶部,随着时间从上到下的推进。

我们首先分配新的大型双桶哈希表,从而得到状态(b).请注意,每个新桶都引用了指向该桶的第一个元素。这些新的桶被发布给读者,从而导致状态(c).在宽限期操作之后,所有阅读器都使用新的大型散列表,导致状态(d).在这种状态下,只有那些遍历偶数值散列桶遍历元素0的阅读器,因此该元素现在被显示为白色。

在这一点上,旧的小散列桶可能会被释放,尽管许多实现使用这些旧桶来跟踪进程,并将项列表“解压缩”到它们各自的新桶中。在这些元素的第一次连续运行中,最后一个偶数元素现在更新了指向下一个元素的指针,以引用以下的偶数元素。在后续的宽限期操作之后,结果为状态(e)。垂直箭头表示下一个要解压缩的元素,元素1现在被染为黑色,表示只有那些穿越奇值哈希桶的读取器才能到达它。

接下来,在这些元素的第一次连续运行中,最后一个奇数元素现在更新了它指向下一个元素的指针,以引用以下奇数元素。在后续的宽限期操作之后,结果为状态(f)。最终解压缩操作(包括宽限期操作)将导致最终状态(g)。

简而言之,相对论散列表减少了每个元素列表指针的数量,而牺牲了在调整大小期间产生的额外宽限期。这些额外的宽限期通常不是问题,因为插入、删除和查找可能与调整大小的操作同时进行。

结果是,可以从到单个指针的一对指针减少每个元素的内存开销,同时仍然保留O (1)删除。这是通过增加分裂顺序列表[SS06]和RCU保护[Des09b,MDJ13c]来实现的。哈希表中的数据元素被安排成一个单个排序的链接列表,每个哈希桶引用该桶中的第一个元素。元素将通过在其指向下一个字段的指针中设置低阶位来删除,而这些元素将通过稍后遇到它们的遍历从列表中删除。

这个受RCU保护的拆分顺序列表很复杂,但它为所有的插入、删除和查找操作提供了无锁的进度保证。这种保证在实时应用程序中可能很重要。一个实现可以从用户空间RCU库的最新版本中获得[Des09b]。

10.5其他数据结构

所有的生活都是一个实验。你做的实验越多越好。

拉尔夫·瓦尔多·爱默生

前几节主要关注由于分区性(第10.2节)、高效处理读取主要是访问模式(第10.3节)或应用主要是避免非分区性的读取技术(第10.4节)而增强并发性的数据结构。本节将简要回顾一下其他的数据结构。

哈希表对于并行使用的最大优点之一是它是完全可分区的,至少在没有调整大小的时候是这样的。保持可分区性和大小独立性的一种方法是使用基数树,这也被称为trie。尝试对搜索键进行分区,并使用每个连续的键分区来遍历特里的下一级。因此,trie可以被认为是一组嵌套的哈希表,从而提供所需的分区性。尝试的一个缺点是,稀疏的密钥空间可能导致内存的使用效率低下。有许多压缩技术可以用于解决这个缺点,包括在遍历[ON07]之前将键值散列到较小的键空间[ON07]。根树在实践中被大量使用,包括在Linux内核[Pig06]中。

哈希表和trie的一个重要的特殊情况是什么可能是最古老的数据结构,数组和它的多维对应物,矩阵。矩阵的完全可分区特性在并行数值算法中得到了大量的利用。 自平衡树在顺序代码中被大量使用,其中AVL树和红黑树可能是最著名的例子[CLRS01]。早期并行化AVL树的尝试是复杂的,并不一定是那么有效的[Ell80],然而,最近对红黑树的工作提供了更好的性能和可伸缩性

通过使用RCU读取器和散列来保护读取和更新[HW11,HW14]。结果证明,红黑树可以积极地重新平衡,这对顺序程序很有效,但对并行使用却不一定很好。因此,最近的工作利用了RCU保护的“盆景树”,它的重新平衡不那么积极[CKZ12],权衡最佳树的深度来获得更有效的并发更新。

并发的跳过列表很适合RCU的读者,并且实际上代表了一种类似于RCU [Pug90]的技术的早期学术使用。

并发的双端队列在第6.1.2节中进行了讨论,并发堆栈和队列有很长的历史[Tre86],尽管通常不是最令人印象深刻的性能或可伸缩性。然而,它们仍然是并发库的一个共同特性[MDJ13d]。研究人员最近提出了放松堆栈和队列的排序约束[Sha11],一些工作表明,放松排序队列实际上比严格的FIFO队列具有更好的排序属性[HKLP12,KLP12,HHK+ 13]。

对并发数据结构的继续研究似乎有可能会产生具有惊人特性的新算法。

10.6微优化

细节在于成败。

未知的

本章中显示的数据结构是直接编码的,不适应底层系统的缓存层次结构。此外,许多实现使用指向函数的键到哈希转换和其他频繁操作。尽管这种方法提供了简单性和可移植性,但在许多情况下,它确实放弃了一些性能。

下面的部分涉及到专门化、内存保护和硬件考虑。请不要把这些简短的部分误认为是关于这个主题的权威论文。整本书都是关于优化特定CPU的,更不用说今天常用的CPU族了。

10.6.1专业化认证

第10.4节中的可调整大小散列表使用不透明类型。这允许很大的灵活性,允许使用任何类型的键,但由于对函数指针的调用,也会带来巨大的开销。现在,现代硬件使用复杂的分支预测技术来最小化这些开销,但另一方面,现实世界的软件往往比今天的大型硬件分支预测表所能够容纳的还要大。这尤其是通过指针调用的情况,在这种情况下,分支预测硬件除了记录分支获取/未获取的信息外,还必须记录一个指针。

这种开销可以通过对给定的键类型和哈希函数专门化哈希表实现来消除,例如,通过使用C++模板。这样做就消除了ht中的->ht_cmp()、->ht_gethash()和->ht_getkey()函数指针

结构如第307页上的清单10.9所示。它还通过这些指针消除了相应的调用,这可能允许编译器内联生成的固定函数,不仅消除了调用指令的开销,而且还消除了参数编组。

除此之外,与我在20世纪70年代初第一次开始学习编程时相比,现代硬件的最大好处之一是,所需要的专业化要少得多。这使得比四千节地址空间的时代更高的生产力。

10.6.2位和字节

本章中讨论的哈希表几乎没有试图保存内存。例如,在第307页上的清单10.9中的ht结构中的->ht_idx字段的值始终为0或1,但却占用了整整32位的内存。例如,可以通过从->ht_resize_key字段中窃取一些信息来消除它。这是因为->ht_resize_key字段足够大,可以处理内存中的每个字节,并且ht_bucket结构有超过一个字节长,因此->ht_resize_key字段必须有几位空闲。

这种位打包技巧经常用于高度复制的数据结构中,以及Linux内核中的页面结构。然而,可调整大小的哈希表的ht结构并没有完全被高度复制。相反,我们应该关注的是ht_bucket结构。缩小ht_bucket结构有两个主要的机会: (1)将->htb_lock字段放置在->htb_head指针之一的低阶位中;(2)减少所需的指针数量。

第一个机会可能是使用Linux内核中的位自旋锁,这是由包含/linux/bit_spinlock.h头文件提供的。这些用于Linux内核中的空间关键数据结构,但并非没有缺点:

1.它们明显比传统的自旋锁原语要慢得多。

2.它们不能参与Linux内核中的锁检测工具[死锁]。

3.它们不记录锁的所有权,这将使调试更加复杂。

4.它们不参与在-rt内核中的优先级提升,这意味着在保持位自旋锁时必须禁用抢占,这可能会降低实时延迟。

尽管有这些缺点,但当内存处于溢价状态时,位自旋锁仍然非常有用。

第10.4.4节介绍了第二个机会的一个方面,其中介绍了可调整大小的散列表,它只需要一组桶列指针来代替第10.4节中介绍的可调整大小散列表所需的对集。另一种方法是使用单链接的桶表来代替本章中使用的双链接列表。这种方法的一个缺点是,删除将需要额外的开销,要么通过标记传出指针以便以后删除,要么通过在桶列表中搜索被删除的元素。

清单10.14:64字节高速缓存行的对齐

1个结构

哈希埃勒姆

2

结构ht_elem;

3

长属性(对齐(64))计数器;

4    };

简而言之,在最小的内存开销和性能和简单性之间存在权衡。幸运的是,现代系统上可用的相对较大的内存允许我们优先考虑性能和简单性,而不是内存开销。然而,尽管2022年的袖珍智能手机拥有许多gb的内存,中档服务器拥有tb的内存,但有时需要采取极端措施来减少内存开销。

10.6.3硬件的注意事项

现代计算机通常以固定大小的块在cpu和主存之间移动数据,这些块的大小从32字节到256字节不等。这些块被称为高速缓存线,并且对于高性能和可伸缩性非常重要,正如在第3.2节中所讨论的那样。一种能同时破坏性能和可伸缩性的过时方法是将不兼容的变量放到同一个坐标线中。例如,假设一个可调整大小的哈希表数据元素与一个频繁递增的计数器具有ht_elem结构。频繁的增量会导致粗线轴出现在CPU上,但没有其他地方。如果其他cpu试图遍历包含该元素的散列桶列表,它们将会导致昂贵的缓存丢失,从而降低性能和可伸缩性。

在具有64字节缓存行的系统上解决这个问题的一种方法如清单10.14所示。在这里,GCC的对齐属性用于强制->计数器和ht_elem结构进入单独的缓存行。这将允许cpu以全速遍历哈希桶列表,尽管频繁增加。

当然,这就提出了这样一个问题:“我们是怎么知道缓存行的大小是64字节的?”在Linux系统上,这些信息可以从/sys/设备/系统/cpu/cpu*/缓存/目录中获得,甚至可以使安装过程重新构建应用程序以适应系统的硬件结构。但是,如果您希望应用程序也能在非linux系统上运行,那么这将会更加困难。此外,即使您满足于只在Linux上运行,这种自修改的安装也会带来验证挑战。例如,具有32字节粗线的系统可能工作得很好,但是具有64字节粗线的系统的性能可能会受到影响。

幸运的是,有一些经验法则在实践中工作得相当好,这些规则被收集到1995年的一篇论文[GKPS95]中。第一组规则涉及重新排列结构以适应缓存几何形状:

1.将读取的数据大多远离经常更新的数据。例如,将读取的数据放在结构的开头,将经常更新的数据放在最后。将很少被访问的数据放置在两者之间。

2.如果结构有一组字段,以便每个组通过一个独立的代码路径进行更新,则将这些组彼此分开。同样,它是可以帮助放置

很少访问组之间的数据。在某些情况下,将每个这样的组放到原始结构引用的单独结构中也可能是有意义的。

3.在可能的情况下,关联更新-主要是数据与CPU、线程,或任务。我们在第5章的计数器实现中看到了几个关于这个经验法则的非常有效的例子。

4.更进一步,按照每个cpu、每个线程或每个任务对数据进行分区,如在第8章中所讨论的。

有一些基于跟踪的结构域自动重排[GDZE10]。这项工作可以很好地简化从多线程软件中获得优秀的性能和可伸缩性所需的更艰苦的任务之一。

另外一套经验法则是用来处理锁的:

1.对于保护经常修改的数据的锁,请采取以下方法之一:

(a)将锁放置在与它保护的数据不同的数据线中。

(b)使用适用于高争用的锁,例如排队的锁。

(c)重新设计以减少锁的争用。(这种方法是最好的,但并不总是琐碎的。)

2.将未竞争的锁放到与它们所保护的数据相同的高速缓存行中。这种方法意味着将锁带到当前CPU的缓存丢失也会带来数据。

3.使用危险指针、RCU,或者,对于长时间的关键部分,读写锁。

当然,这些都是经验法则,而不是绝对的法则。需要进行一些实验来找出哪些方法最适用于特定的情况。

10.7总结

只有一件事比从经验中学习更痛苦,那就是不是从中学习

经验

阿奇博尔德麦克利什

本章主要关注散列表,包括不能完全可分区的可调整大小的散列表。第10.5节简要概述了一些非散列表的数据结构。然而,这个对散列表的阐述是对围绕高性能可伸缩数据访问的许多问题的一个优秀的介绍,包括:

1.完全分区的数据结构在小系统上工作得很好,例如,单套接字系统。

2.较大的系统需要参考的局部性和完全的分区。

3.以读取为主的技术,如危险指针和RCU,为以读取为主的工作负载提供了良好的参考位置,因此即使在更大的系统上也提供了优秀的性能和可伸缩性。

4.主要是读取的技术也可以很好地用于一些类型的不可分区的数据结构,例如可调整大小的哈希表。

5.大型数据结构可能会溢出CPU缓存,从而降低性能和可伸缩性。

6.通过将数据结构专门化于特定的工作负载,例如,用32位整数替换通用密钥,可以获得额外的性能和可伸缩性。

7.尽管对可移植性和极端性能的需求经常发生冲突,但有一些数据-结构-布局技术可以在这两组需求之间取得很好的平衡。

也就是说,如果没有可靠性,性能和可伸缩性几乎没有用处,所以下一章将介绍验证。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值