数据结构精讲:从原理到实战–学习笔记03

数据结构精讲:从原理到实战–学习笔记03

本笔记是记录学习 《数据结构精讲:从原理到实战》,作者是:蔡元楠,Google Brain资深工程师。

如有侵权,联系删除!

链表

每一个元素就保存了两部分的内容,一部分是元素本身的值,另一部分是下一个元素的地址,而最后一个元素的下一个地址我们可以保存一个 0x0 的值来表示这个元素是最后一个了。这时候这些数据的内存就如下图所示:
在这里插入图片描述
这种不保存在连续存储空间中,而每一个元素里都保存了到下一个元素的地址的数据结构,我们称之为链表(Linked List)。链表上的每一个元素又可以称它为节点(Node),而链表中第一个元素,称它为头节点(Head Node),最后一个元素称它为尾节点(Tail Node)

此类线性链表的特点是:只有顺序访问才能遍历元素,而无法通过计算地址直接获取某个具体的值。所以时间复杂度为O(n)。但是它的优点是空间利用率很高,不会有闲置的内存空间,并且可以利用很小的碎片内存。

对于我们来说,只有节点里的值是可以利用上的,而保存节点地址的内存其实对于我们来说是无法应用的。所以链表的空间利用率上相当于值的大小除以值的大小和节点地址大小的和。但是在现实应用中,链表中保存的值远远不是一个基本类型就这么简单,当我们所保存的值的大小越大的时候,空间利用率也会越高。

对于数组来说插入的时间复杂度是O(n),而对于链表来说,如果保存一个尾地址,尾插和头插的时间复杂度都是O(1),中间插入的时间复杂度是O(n)

链表的各种形式

单链表(singly linked list)

在这里插入图片描述

双链表(doubly linked list)

在这里插入图片描述

环链表(circular linked list)

在这里插入图片描述

链表在 Apache Kafka 中的应用

如何重新设计定时器算法

一般我们可以把定时器的概念抽象成 4 个部分,它们分别是:

  1. 初始化定时器,规定定时器经过了多少单位时间之后超时,并且在超时之后执行特定的程序;

  2. 删除定时器,终止一个特定的定时器;

  3. 定时器超时进程,定时器在超时之后所执行的特定程序;

  4. 定时器检测进程,假设定时器里的时间最小颗粒度为 T 时间,则每经过 T 时间之后都会执行这个进程来查看是否定时器超时,并将其移除。

维护无序定时器列表

最简单粗暴的方法,当然就是直接用数组或者链表来维护所有的定时器了。从前面的学习中我们可以知道,在数组中插入一个新的元素所需要的时间复杂度是 O(N),而在链表的结尾插入一个新的节点所需要的时间复杂度是 O(1),所以在这里可以选择用链表来维护定时器列表。假设我们要维护的定时器列表如下图所示:

在这里插入图片描述

它表示现在系统维护了 3 个定时器,分别会在 3T、T 和 2T 时间之后超时。如果现在用户又插入了一个新定时器,将会在 T 时间后超时,我们会将新的定时器数据结构插入到链表结尾,如下图所示:
在这里插入图片描述每次经过 T 时间之后,定时器检测进程都会从头到尾扫描一遍这个链表,每扫描到一个节点的时候都会将里面的时间减去 T,然后判断这个节点的值是否等于 0 了,如果等于 0 了,则表示这个定时器超时,执行定时器超时进程并删除定时器,如果不等于,则继续扫描下一个节点。

这种方法的好处是定时器的插入和删除操作都只需要 O(1) 的时间。但是每次执行定时器检测进程的时间复杂度为 O(N)。如果定时器的数量还很小时还好,如果当定时器有成百上千个的时候,定时器检测进程就会成为一个瓶颈了。

维护有序定时器列表

这种方法是上述方法的改良版本。我们可以还是继续维护一个定时器列表,与第一种方法不一样的是,每次插入一个新的定时器时,并不是将它插入到链表的结尾,而是从头遍历一遍链表,将定时器的超时时间按从小到大的顺序插入到定时器列表中。还有一点不同的是,每次插入新定时器时,并不是保存超时时间,而是根据当前系统时间和超时时间算出一个绝对时间出来。例如,当前的系统时间为 NowTime,超时时间为 2T,那这个绝对时间就为 NowTime + 2T。

假设原来的有序定时器列表如下图所示:

在这里插入图片描述
当我们要插入一个新的定时器,超时的绝对时间算出为 25 Dec 2019 9:23:34,这时候我们会按照超时时间从小到大的顺序,将定时器插入到定时器列表的开头,如下图所示:
在这里插入图片描述
维护一个有序的定时器列表的好处是,每次执行定时器检测进程的时间复杂度为 O(1),因为每次定时器检测进程只需要判断当前系统时间是否是在链表第一个节点时间之后了,如果是则执行定时器超时进程并删除定时器,如果不是则结束定时器检测进程。

这种方法的好处是执行定时器检测进程和删除定时器的时间复杂度为 O(1),但因为要按照时间从小到大排列定时器,每次插入的时候都需要遍历一次定时器列表,所以插入定时器的时间复杂度为 O(N)。

维护定时器“时间轮”

“时间轮”(Timing-wheel ) 在概念上是一个用数组并且数组元素为链表的数据结构来维护的定时器列表,常常伴随着溢出列表(Overflow List) 来维护那些无法在数组范围内表达的定时器。“时间轮”有非常多的变种,今天我来解释一下最基本的“时间轮”实现方式。

首先基本的“时间轮”会将定时器的超时时间划分到不同的周期(Cycle)中去,数组的大小决定了一个周期的大小。例如,一个“时间轮”数组的大小为 8,那这个“时间轮”周期的大小就为 8T。同时,我们维护一个最基本的“时间轮”还需要维护以下几个变量:

  1. “时间轮”的周期数,用 S 来表示;

  2. “时间轮”的周期大小,用 N 来表示;

  3. “时间轮”数组现在所指向的索引,用 i 来表示。

现在的时间我们可以用 S×N + i 来表示,每次我们执行完一次定时器检测进程之后,都会将 i 加 1。当 i 等于 N 的时候,我们将 S 加 1,并且将 i 归零。因为“时间轮”里面的数组索引会一直在 0 到 N-1 中循环,所以我们可以将数组想象成是一个环,例如一个“时间轮”的周期大小为 8 的数组,可以想象成如下图所示的环:
在这里插入图片描述

那么我们假设现在的时间是 S×N + 2,表示这个“时间轮”的当前周期为 S,数组索引为 2,同时假设这个“时间轮”已经维护了一部分定时器链表,如下图所示:
在这里插入图片描述
如果我们想新插入一个超时时间为 T 的新定时器进这个时间轮,因为 T 小于这个“时间轮”周期的大小 8T,所以表示这个定时器可以被插入到当前的“时间轮”中,插入的位置为当前索引为 1 + 2 % 8 = 3 ,插入新定时器后的“时间轮”如下图所示:
在这里插入图片描述
如果我们现在又想新插入一个超时时间为 9T 的新定时器进这个“时间轮”,因为 9T 大于或等于这个“时间轮”周期的大小 8T,所以表示这个定时器暂时无法被插入到当前的周期中,我们必须将这个新的定时器放进溢出列表里。溢出列表存放着新定时器还需要等待多少周期才能进入到当前“时间轮”中,我们按照下面公式来计算还需等待的周期和插入的位置:

还需等待的周期:9T / 8T = 1
新定时器插入时的索引位置:(9T + 2T) % 8T = 3

我们算出了等待周期和新插入数组的索引位置之后,就可以更新溢出列表,如下图所示:在这里插入图片描述

在“时间轮”的算法中,定时器检测进程只需要判断“时间轮”数组现在所指向的索引里的链表为不为空,如果为空则不执行任何操作,如果不为空则对于这个数组元素链表里的所有定时器执行定时器超时进程。而每当“时间轮”的周期数加 1 的时候,系统都会遍历一遍溢出列表里的定时器是否满足当前周期数,如果满足的话,则将这个位置的溢出列表全部移到“时间轮”相对应的索引位置中。

在这种基本“时间轮”的算法里,定时器检测进程的时间复杂度为 O(1),而插入新定时器的时间复杂度取决于超时时间,因为插入的新定时器有可能会被放入溢出列表中从而需要遍历一遍溢出列表以便将新定时器放入到相对应周期的位置。

Apache Kafka 的 Purgatory 组件

Apache Kafka 是一个开源的消息系统项目,主要用于提供一个实时处理消息事件的服务。与计算机网络里面的 TCP 协议需要用到大量定时器来判断是否需要重新发送丢失的网络包一样,在 Kafka 里面,因为它所提供的服务需要判断所发送出去的消息事件是否被订阅消息的用户接收到,Kafka 也需要用到大量的定时器来判断发出的消息是否超时然后重发消息。

而这个任务就落在了 Purgatory 组件上。在旧版本的 Purgatory 组件里,维护定时器的任务采用的是 Java 的 DelayQueue 类来实现的。DelayQueue 本质上是一个堆(Heap)数据结构,这个概念将会在第 09 讲中详细介绍。现在我们可以把这种实现方式看作是维护有序定时器列表的一种变种。这种操作的一个缺点是当有大量频繁的插入操作时,系统的性能将会降低。

因为 Kafka 中所有的最大消息超时时间都已经被写在了配置文件里,也就是说我们可以提前知道一个定时器的 MaxInterval,所以新版本的 Purgatory 组件则采用的了我们上面所提到的变种“时间轮”算法,将插入定时器的操作性能大大提升。根据 Kafka 所提供的检测结果,采用 DelayQueue 时所能处理的最大吞吐率为 25000 RPS,采用了变种“时间轮”算法之后,最大吞吐率则达到了 105000 RPS。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章