一,线程模型
1,线程
在linux早期,0.01版本中,并没有线程的概念,只有进程。进程之间内存不共享,每一个进程都有自己的虚拟地址空间(如图1所示),每个进程都有自己独立的页表去映射物理内存。这时进程间相互隔离,不同的进程不能够访问同一片内存,进程在切换时,TLB(页表缓存)需要被清理。
图1
为了解决共享内存问题,于是出现了轻量级进程,进程依然有自己独立的虚拟内存空间,但是共享页表如下图(2)所示,这样就实现了不同进程能够访问同一片内存的问题。
图2
线程通过共享页表面实现了内存共享问题,下图(3)为glibc创建线程的源码
图3
从上面的代码看到,当我们使用ARCH_CLONE告诉操作系统创建线程时,放入了这一系列的clone_flags。接下来看看这些flag有何含义。详情如下图(4)
图4
每个线程或者进程在内核都有一个task_struct维护上下文,也称之为pcb。无论是调用系统调用sys_fork创建进程,还是调用sys_clone新建线程。内核都会为之创建一个task_struct。
2,redis事件循环设计
Redis从内核中获取三次握手成功的socket fd,然后根据fd中的数据调用命令完成操作,这些操作都是在内存中完成,然后响应返回给客户端。众所周知,内存的速度访问大于网络的访问速度,这时由于竹筒效应,redis的性能取决于网络IO的速度。
那么,我们来进一步量化这个速度差异,主存访问操作只需要100ns,而对于1GB的网络来说,发送2kb的数据需要20000ns,假设redis在完成一次用户操作需要500ns,那么一个线程一秒钟可以执行1s/500ns = 2000000个操作。所以redis不需要多线程,多线程还要考虑上锁,解锁带来性能消耗。于是redis就使用单线程周而复始(事件循环)的完成以下操作。
1,从内核中获取fd
2,解析fd中的数据
3,根据数据识别命令完成操作
4,将数据返回给客户端
在单线程的redis中想要实现事件循环并不是那么简单:读,写事件如果阻塞,将会导致整个事件循环停止。那么这就需要操作系统提供非阻塞的支持,在linux中提供 select,poll,epoll这三个操作系统提供的函数接口,来完成事件循环设计。
aeEventLoop结构体
其中fired字段表示需要处理的请求事件,redis调用epoll_wait系统调用将内核接收到的读写事件挂在fired上,然后redis循环处理这些事件。
二,内存数据结构
Redis中数据是以key,value的形式保存,在dictEntry结构中,*key指针表示数据的key值,*val指向对应的robj。这个robj可以是以sds,ziplist,skiplist等形式进行存储。
1,string类型sds
在redis2.6中字符类型的数据使用sdshdr结构体来保存,其结构如下图:
len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。
buf[],字符数组,用来保存实际数据。可以保存字符串。
2,双向链表
list数据结构是类型为List对象的底层实现之一,就是一个双向链表如下图所示
listNode 链表节点的结构里带有 prev 和 next指针,获取某个节点的前置节点或后置节点的时间复杂度只需O(1),而且这两个指针都可以指向 NULL,所以链表是无环链表;
list 结构因为提供了表头指针 head 和表尾节点 tail,所以获取链表的表头节点和表尾节点的时间复杂度只需O(1);
list 结构因为提供了链表节点数量 len,所以获取链表中的节点数量的时间复杂度只需O(1);
listNode 链表节使用 void* 指针保存节点值,并且可以通过 list 结构的 dup、free、match 函数指针为节点设置该节点类型特定的函数,因此链表节点可以保存各种不同类型的值;
链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存。能很好利用 CPU缓存的数据结构就是数组,因为数组的内存是连续的,这样就可以充分利用 CPU 缓存来加速访问。
还有一点,保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大。
3,zipList
既然上述有提到链表这种不连续的数据结构不能利用好cpu缓存,那么搞个内存连续的数据结构不就得了,那就是zipList。压缩列表就是将数据保存在连续的内存空间,有点像像数组数据保存的方式。
下面是包含两个元素的zip列表字符串“2”和“5”。它由15个字节组成,我们可以看到分成几个部分::
[0f 00 00 00] [0c 00 00 00] [02 00] [00 f3] [02 f6] [ff]
| | | | | |
zlbytes zltail entries “2” “5” end
前4个字节表示数字15,即整个ziplist的大小,第二个4字节是偏移量最后一个ziplist条目被找到的地方,也就是12,实际上是最后一个条目,即“5”,位于ziplist中的偏移量12处。下一个16位整数表示元素的个数Ziplist,它的值是2,因为里面只有两个元素。最后,“00 f3”是表示数字2的第一个条目。它是由前一项长度组成,它是零,因为这是我们的第一个条目以及对应于编码的字节F3 |1111xxxx|,其中XXXX在0001到1101之间。我们需要去掉"F"高阶位1111,并从“3”中减去1,因此条目值是“2”。下一个条目的prelen是02,因为第一个条目是恰好由两个字节组成的。条目本身F6是精确编码的和第一个表项一样,6-1 = 5,所以表项的值是5。最后,特殊条目FF标志着ziplist的结束。压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。
4,quicklsit
在2.6版本是没有这个数据结构的,在高版本中才有,我们来看6.2.6版本,是有这个数据结构的。Quicklist就是双向链表+压缩表,其结构如下图所示:
在quickList中head为头指针指向第一个节点,tail为尾指针,指向最后一个节点。寻找到第一个节点或者最后一个节点的时间复杂度为O(1),count表示所有的压缩表中一共有多少数据,len表示quicklistNodes的数量。
在quicklistNode中,prev指针指向上一个quicklistNode节点,next指向下一个节点。zl指针指向压缩表的首地址,sz表示压缩表的长度,count表示压缩表中的总数据量。
在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。
5,skipList
调表是双向链表的升级,在调表中数据是按照顺序保存的,其中level指针可以是多层,可以跨越节点去查询数据。其结构如下:
Sorted Set使用该数据结构进行数据存储,跳跃表是一个多层的有序链表,它采用了空间换时间的方式将查找的时间复杂度降到了O(logN)。
三,磁盘IO
bdsave磁盘io
首先看下内核处理磁盘写的过程。在调用fflush方法后数据进入到内核,如果数据写不是O_diret方式,那么数据将先写入页高速缓存pageCache,当调用fsync系统调用,数据将刷入磁盘。
以下为rdb保存时候的源码,可以看到调用了fsync保证数据落盘。