2020年1月

一、迭代器失效

向容器添加或者删除元素可能会导致指向容器的指针、引用或者迭代器失效。使用已经失效的指针、引用或者迭代器将会导致程序出现异常,编码过程中一定要时刻注意迭代器失效的场景。

例如,以vector为例:

int main() {
    vector<int> v{1, 2};
    vector<int>::iterator it;

    for (it = v.begin(); it != v.end(); it++) {
        v.push_back(*it);
    }
    
    return 0;
}

执行以上代码会导致段错误:

原因:在循环中新增了元素,并且重新分配了内存空间,导致迭代器失效。使用已经失效的迭代器会导致程序出现段错误。

迭代器失效,主要有两个层面的意思:

  1. 无法通过迭代器++或--操作遍历整个stl容器,记作第一层失效
  2. 无法通过迭代器存取迭代器所指向的内存,记作第二层失效

二、失效场景

vector和string

如果增加或删除元素导致内存空间重新分配了,那么指向容器的迭代器都会失效(第二层失效)。如果存储空间未重新分配,指向删除元素之前的所有迭代器还有效(第一层失效),但是删除元素之后的所有迭代器都无效了(第二层失效)。

deque

插入到除首尾元素之外任何位置都会导致迭代器失效(第二层失效)。如果插入到首尾元素,迭代器会失效,但是指向已存在元素的指针和引用不失效(第一层失效)。

删除除首尾元素之外的元素,所有迭代器失效(第二层失效)。如果删除的是首尾元素,首前和尾后迭代器失效,其他元素的引用、指针和迭代器不会失效。

map和set

删除和添加元素会导致内部结构调整,迭代器失效,但是引用和指针任然有效(第一层失效)。

list

添加元素不会导致迭代器失效,但是删除元素会导致删除元素后面的所有迭代器失效(第一层失效)。list删除元素永远都会导致尾后迭代器失效(第二层失效)。

三、避免迭代器失效

避免迭代器失效的几种方法:

  1. 减小迭代器的使用范围,不保存迭代器的值。
  2. 避免在遍历迭代器的过程中修改容器。
  3. 不要保存首前和尾后指针。

vector避免删除失效

在遍历vector的过程中删除元素,会导致后面的迭代器失效。如果希望删除后还能继续使用迭代器,要使用erase方法,并接收返回值作为下一个迭代器使用。

正确的使用方式:

int main() {
    vector<int> v{1, 2, 3, 4, 5};
    vector<int>::iterator it;

    for (it = v.begin(); it != v.end();) {
        if (*it == 2 || *it == 4) {
            // 接收返回值作为下一个迭代器
            it = v.erase(it);
            continue;
        }
        cout << *it << endl;
        it++;
    }
}

set/map避免迭代器失效

set和map也和vector一样:

int main() {
        set<int> s{1, 2, 3, 4, 5};
    set<int>::iterator it;

    for (it = s.begin(); it != s.end();) {
        if (*it == 2 || *it == 4) {
            it = s.erase(it);
            continue;
        }
        cout << *it << endl;
        it++;
    }
    return 0;
}

一、RTT和RTO的概念

TCP作为一个面向连接的、可靠的传输协议,内部实现了一个重传计时器来保证数据能传输到对方。每发送一个数据包,就给这个数据设置一个重传计时器。如果在计时器超时之前收到了针对这个数据包的ack,就取消这个计时器。如果没有收到,则开始发起重传。计时器超时的时间被称为RTO,这个时间的确定取决于RTT。

关于两者详细的解释:

  • RTT(Round Trip Time):一个连接的往返时间,即数据发送时刻到接收到确认的时刻的差值;
  • RTO(Retransmission Time Out):重传超时时间,即从数据发送时刻算起,超过这个时间便执行重传。

关于RTT和RTO值的确定一直以来都是值得讨论的地方,如何让RTO能适应网络变化。

二、RTT的测量

每发送一个分组,TCP都会进行RTT采样,这个采样并不会每一个数据包都采样,同一时刻发送的数据包中,只会针对一个数据包采样,这个采样数据被记为sampleRTT,用它来代表所有的RTT。

采样的方法一般有两种:

  1. TCP Timestamp选项:在TCP选项中添加时间戳选项,发送数据包的时候记录下时间,收到数据包的时候计算当前时间和时间戳的差值就能得到RTT。这个方法简单并且准确,但是需要发送段和接收端都支持这个选项。
  2. 重传队列中数据包的TCP控制块:每个数据包第一次发送出去后都会放到重传队列中,数据包中的TCP控制块包含着一个变量,tcp_skb_cb->when,记录了该数据包的第一次发送时间。如果没有时间戳选项,那么RTT就等于当前时间和when的差值。

linux内核中,更新rtt的函数为tcp_ack_update_rtt

三、RTO的计算

3.1 经典方法

为了避免单次RTT波动,计算RTO时新引入了变量SRTT,表示更加平滑的RTT数值,它的计算方法:

SRTT = x(SRTT) + (1 - x)RTT;

x被称为平滑因子,一般建议设置在[0.8, 0.9],意思是SRTT值百分之八十来自于之前的值,百分之二十来自于当前值。然后计算RTO的方法为:

RTO = min(ubound, max(lbound, y(SRTT)));

y是时延离散因子,推荐值为[1.3, 2.0],ubound是RTO的上边界,lbound是RTO的下边界。

算法的缺点

在RTT波动较大时,RTO不能明显适应网络变化。

3.2 标准方法

标准方法引入了平均偏差的概念,它类似于统计学里面的方差,但是因为方差的计算过程代价较大,对于快速TCP来说不太适合。假设rtt的值为M,RTO的计算方式为:

srtt = (1 - g)srtt + g(M);
rttval = (1 - h)rttval + h(|M - rttval|);
RTO = srtt + 4(rttval);

其中g设置为1/8,h设置为1/4,对srtt而言,它有1/8取决于当前值,7/8取决于现有值。当RTT变化时,偏差增量越大,RTO的增量也越大。

关于计算RTT和RTO的算法,还有很多种,历史上针对这个的探讨从未停止过。

比较出名的拥塞算法还有谷歌的bbr算法,高版本的linux内核已经合入了bbr算法作为拥塞控制算法。

四、其他

4.1 TCP Timestamps选项

时间戳选项的作用是为了方便计算RTT,每发出一个数据包,就记录下发送时间,收到数据包时就能准确的获知到数据包往返时间了。

通过TCPDUMP抓包很容易就能看到Timestamps选项:

数据结构之B树

一、B树的基本概念

B树是一种多叉树,被广泛应用于数据库索引中。它也是一种特殊的搜索树,和搜索树最大的不同在于它的每个节点都包含了n个关键字和n+1个指向子节点的指针。它的表现形式为:

B树

B树的特点:

  1. 假设x.key为当前节点中的关键字,x.child.key是子节点中的关键字,那么它们之间存在以下关系:
    x.child.key <= x.key1 <= x.child2.key <= x.key2 <= x.child3.key <= ... <= x.keyn
  2. 每个节点的关键字个数都有上界和下界,上下界用B树的最小度数t ≥ 2来表示。
  3. 除了根节点以外,每个节点必须有t - 1个关键字,除了根节点以外,每个节点都有t个孩子。
  4. 每个节点最多有2t - 1个关键字,最多有2t个孩子。当一个节点恰好有2t - 1个关键字时,称该节点是满的。
  5. t = 2的时候B树最简单,每个内部节点有2、3或者3个孩子,也被称作2-3-4树。
  6. t值越大,B的高度就越小。

为什么B树广泛应用于索引中

因为磁盘读取的最小单位是扇区,每个扇区是512字节。操作系统读取磁盘的最小单位是块,一个块包含了若干个扇区(一般一个块是4096字节,包含8个扇区)。如果和红黑树或其他二叉搜索树一样,每个节点只保存一个数据,那么磁盘读取的效率就相当低了。如果需要读取多个数据,就要执行多次磁盘IO操作才能完成任务了,而磁盘IO在系统中属于较为耗时的操作,因此多次IO势必导致效率大大降低。

B树就是为了改进这一问题而衍生出来的,B树的节点一般设置为磁盘的块大小,也就是4K,里面包含了多个数据节点的内容,这样一次IO就能读到多个数据内容。并且由于B树也具有搜索树的性质,因此很快就能定位到数据内容。

二、B树操作

B树的主要操作有两个:分裂和合并。因为B树的每个节点包含关键字的数量为[t - 1, 2t - 1],当节点的关键字数量超出后,就要对节点进行分裂操作,分裂操作会导致B树高度增加。当节点关键字被删除,数量不满足条件时就要合并两个节点,合并节点会导致B树高度下降。

3.1 增加节点

以一个度为4的B树为例,插入S后,B树节点的关键字数量变成了7,需要进行分裂。

B树分裂

此时对节点的分裂过程为:

  1. 新生成节点,将S右边的关键字和子节点移到新节点中。
  2. S左边的关键字和子节点保存在当前节点。
  3. S节点往上提,放到父节点中的合适位置。
  4. 如果S节点上提导致父节点的数量也超出了,还需要继续对父节点进行分裂。
注意:每次上提到父亲节点的关键字都是被分裂节点的中间关键字。

分裂示例

以下是度为3的B树分裂的过程,每个节点最多有5个关键字,最少2个关键字。

初始时的B树:

B树分裂-1

插入B,这只是一个简单的对叶节点插入的过程,插入后不会影响其他节点,B树的条件也还满足,直接插入就行:

B树分裂-2

插入Q,因为插入Q后会导致RSTUV节点关键字超出,因此要分裂这个节点。T节点作为中间节点放到父节点中(也可以把S提到父节点,T放在UV节点):

B树分裂-3

插入L,它被放到JK节点之后,也是一个简单的叶节点插入。但是因为根节点的关键字满了,所以对根节点分裂,此时将P提出来作为根节点,树的高度加1:

B树分裂-4

插入F,放在ABCDE节点之后,插入后将导致节点分裂,节点C提到父节点:

B树分裂-5

2.3 删除节点

在B树中删除节点,将会引发节点的合并。相对于增加节点来说,删除节点的远比增加节点要复杂。

以上面的B树为例,初始状态为:

删除节点-1

删除关键字F,作为叶子节点,删除F后并没有影响到B树的性质,直接删除即可。得到以下B树:

删除关键字F

删除关键字M,因为M所处的节点是内部节点,删除M后会导致NO关键字所在的节点没有父节点。此时需要把M的前驱关键字L替换掉M,然后删掉L

也可以把M的后继关键字N替换上来,但是M替换后会导致子节点不满足关键字数量条件。

删除节点M

删除关键字GG所处的节点也是内部节点,删除后会导致DE或者JK所处的节点没有父节点,此时也需要和上面删除M一样在子节点中找到前驱或者后继替换上来。但是这里不同的是,两个子节点都是只有t - 1个关键字,再从中拿掉一个关键字后会导致子节点关键字数量不满足。此时就需要合并两个子节点,然后直接删除G节点:

删除节点G

删除关键字D,D所处的节点是叶子节点,可以和删除节点F一样,直接删除。但是这里也有一个不同的点是,父节点和父节点的兄弟节点此时都只有t - 1个节点,此时除了删除节点D以外,还要合并父亲节点,此时树的高度减一:

删除节点D.png

删除关键字BB所在的节点关键字数量是t - 1,删除B后会导致节点的最小关键字数量不满足条件。因此要从父节点或者兄弟节点借一个关键字。此时就分为两种情况:

  1. 如果兄弟节点的关键字个数也是t - 1,那么直接和兄弟节点合并,从父节点提取一个关键字下来(下面删除C时候的场景)。
  2. 如果兄弟节点的关键字个数大于t - 1(目前就是这个情况,兄弟节点有3个关键字),此时就从父节点借一个关键字C替换掉B,借掉C后,就相当于删除了一个内置节点的元素,所以父亲节点要从它后继节点中找一个关键字补上,也就是E。最终的结果就是用C覆盖B,再用E覆盖原来C的位置,再删除E

删除关键字B

删除节点C,此时的情况就是上面的第一种情况了,兄弟节点的关键字个数是t - 1,要合并两个节点,得到:

删除关键字C

一、磁盘的基本元素

磁盘由多个盘片组成,每个盘片的基本结构为:

磁盘结构

各标识含义:

  • A是磁道,多个磁盘的同一个磁道重叠起来叫做柱面,它包含了很多个扇区。
  • B是几何上的扇区,只做标示,此处无特殊含义。
  • C是扇区,扇区是磁盘的最小组成单元,通常是512字节(有的磁盘时4096字节)。
  • D是磁盘块(簇),块/簇是操作系统虚拟出来的概念,它由多个扇区组成。

读取磁盘数据时,磁盘上的磁头不断旋转变道,然后读取数据。因此寻道时间越短,I/O操作越快,目前磁盘的平均寻道时间一般在3-15ms,一般都在10ms左右。

关于随机IO和顺序IO

一般在测试磁盘性能的时候,都会额外测试一个功能点就是随机读写的性能。与随机读写相对的是顺序读写,他们的区别在于本次IO和上一次IO地址的差别。如果本次IO给出的初始扇区地址,和上一次IO的结束扇区地址,是不是完全连续的,或者相隔不多,则本次IO算是一个顺序IO。如果相差太大,则算一次随机IO。顺序IO,因为本次初始扇区和上次结束扇区相隔很近,则磁头几乎不用换道或换道时间极短,所以读写速度快;而随机IO中磁头需要很长的换道时间,导致磁头不停换道,读写速度非常慢。

为什么存在磁盘块?(簇)

  1. 读取方便:由于扇区的数量比较小,数目众多在寻址时比较困难,所以操作系统就将相邻的扇区组合在一起,形成一个块,再对块进行整体的操作。
  2. 分离对底层的依赖:操作系统忽略对底层物理存储结构的设计。通过虚拟出来磁盘块的概念,在系统中认为块是最小的单位。

我们平常所说的4K对齐也就是指的块大小,它表示操作系统读取磁盘时一次读取的数据大小。如果操作系统一次读取4K,但是块大小只有2K,就相当于一次IO要做2次磁盘寻址。而如果磁盘块大小刚好也是4K,那么一次IO就只需一次寻址。相对而言,磁盘寻址效率是很低的,多一次磁盘寻址肯定会更加导致IO效率低,因此对磁盘进行4K对齐也是提高了系统的IO性能。

二、linux系统下的查看扇区和磁盘块

使用fdisk -l可以看到磁盘的扇区大小:

查看扇区大小

使用tune2fs -l 可以看到读取磁盘的块大小,下面这个磁盘的块大小是4096

块大小

linux内核中的container_of函数

一、container_of的用途

众所周知,linux内核是用C写的,并且内核中是存在许多数据结构的,栈、链表、哈希表以及红黑树等等。但是C语言中一个致命的缺点就是没有泛型,没有泛型的话所有的数据结构就无法通过一套代码来实现。那有没有办法可以使得这些数据结构成为通用的呢?答案肯定是有的,不然如果每个结构体都要实现一套自己的链表,内核将会变得臃肿、复杂且不好维护。要知道C语言虽然没有泛型,但是它有指针,实现内核的大佬们就通过指针来实现了属于C的泛型

以链表为例,首先定义一个全局通用的链表节点:

struct list_node {
    struct list_node *prev, *next;
}

节点中只包含了prevnext两个成员,不含有任何数据内容。所有的数据内容都要另外再定义结构体,把这个链表节点包含进来,再通过这个链表节点实现链表移动。例如实现一个lru缓存节点的链表:

struct lru_cache {
    int page_addr;
    struct list_node ln;
}
数据节点说的是lru_cache结构类型的节点,链表节点是lru_cache中ln成员节点。

它通过ln元素串起来的数据形式为:

图片看起来很好理解,关键的问题就在于如何通过这个节点来组成链表,以及如何通过这个链表节点找到本身的数据节点呢?这便是container_of发挥作用的时候了。

container_of的作用就是给定结构体类型和数据成员名返回结构体本身的地址,它需要三个参数:

  1. 数据节点指针,表示当前某个数据节点中的链表节点地址,即某个lru_cache节点中ln的地址。
  2. 当前数据节点的数据类型,即struct lru_cache这个结构体。
  3. 链表节点在数据节点中的名字,即ln

假设ptr是某个节点中ln元素的地址,那么通过container_of(ptr, struct lru_cache, ln)就能得到这个节点的地址了。通过它来完成一次节点遍历的过程可以描述为:

struct list_node *p = head;
struct lru_cache *lru_node;
while (p) {
    lur_node = container_of(ptr, struct lru_cache, ln);
    // 处理节点
    // print(lru_node);
    // ...
    
    p = p->next == head ? NULL : p->next;
}

二、container_of的实现

container_of的宏定义:

#define container_of(ptr, type, member) ({          \  
    const typeof( ((type *)0)->member ) *__mptr = (ptr); \  
    (type *)( (char *)__mptr - offsetof(type,member) );})

宏定义有三个变量,展开后一共有两行语句:

  1. const typeof( ((type *)0)->member ) *__mptr = (ptr);
  2. (type *)( (char *)__mptr - offsetof(type,member) );}

这两行语句的解析:

先通过传入的type生成一个该类型的指针,(type *)0表示指向NULL的type类型的指针,假设这个指针为p,语句就变成了:

const typeof( p->member ) *__mptr = (ptr);

然后定义一个链表节点类型的指针__mptr指向ptr,因为不知道ptr的数据类型,所以要通过typeof (p->member)得到数据类型。此时__mptr的指向是:

因此,到这里想要得到lru_cache的地址,只要把__mptr的地址减去ln成员在结构体中的偏移就行了。

这也正是第二个语句的作用:先通过offset_of获取到偏移,再通过(char *)强制转换__mptr的数据类型,使得它的步长是1。最后减去偏移就得到数据节点的地址。