三:Redis为什么使用单线程架构
Redis使用单线程的原因是因为相比多线程速度比较快。速度快体现在两点:
- 访问内存的时间小于线程上下文切换的开销。
- 多路IO复用,epoll模型速度快。
1.访问内存的时间小于线程上下文切换的开销
从第一篇中我们知道内存的速度大概是100ns,而一次线程上下文切换大概1500ns。线程上下文切换的时间是一次内存访问的15倍,所以Redis使用多线程是得不偿失的。并且多线程相比单线程实现起来考虑到线程安全,需要的数据结构会比较复杂,
单线程可以简化Redis的实现。
那什么时候使用多线程呢?当操作使用的存储慢速的时候,例如使用磁盘存储时,使用多线程时有优势的。我们知道CPU相比磁盘的速度是非常快的,所有计算存储体系中才会有,三级缓存,内存,磁盘。由于磁盘的访问速度慢,如果每写一点数据就直接存储,那么操作的大多数时间都耗费在操作磁盘上了,那么有没有办法提高速度呢?
计算的大多数问题,增加一个中间层就能很好的解决问题。
如果总共写入1M数据,每1Kb数据都写入磁盘,那么大多数时间都耗费在磁盘的寻址,多次重复的写耗费的时间,如果我添加一个Buffer,当Buffer快满时,我再统一写入磁盘,那么我只需要一次寻址,一次写操作。没错java里的IO就是这样干的~
每次写1KB数据:
时间 = 1024次寻址 + 1024次写时间
使用buffer:
时间 = 1次寻址 + 1次写时间
2.多路IO复用,epoll模型速度快
IO多路复用是用来解决对多个I/O监听的。我们先来整体看下Linux系统中的5大IO模型。
- IO阻塞模型
- IO非阻塞模型
- IO多路复用模型
- 信号驱动式IO模型
- 异步IO模型
当进行一次网络IO(假设为读操作),会涉及两个系统对象:1.这个IO的进程/线程。2.系统内核(kernel)。它会经历两个阶段:
- 等待数据准备。
- 数据准备完成,将数据从内核拷贝到内存。
上面的IO模型的区别就是在这两个阶段有所不同。
IO阻塞模型
当前IO发起recvfrom系统调用,内核开始第一个阶段准备数据,此时IO线程会阻塞一直等待内核准备完数据,
当内核准备好数据,将数据复制到内存,此时IO线程解除阻塞,从内存读取数据。
从上面的过程中我们看到了,在两个阶段都是阻塞的。
IO非阻塞模型
当前IO发去recvfrom系统调用,内核开始第一个阶段的数据准备,此时IO线程并不会被内核阻塞而是直接返回
一个Error,当IO线程判断结果是Error时,再次发送请求给内核,知道内核数据准备好,复制到内存,此时IO线程
再去操作数据。所以IO非阻塞模型下,IO线程需要不断的主动询问内核数据是否准备好。
多路IO复用模型
IO非阻塞下,我们可以看到IO线程一直做检查,为什么不检查多个IO请求呢?于是多路IO复用诞生。
多个IO线程注册到选择器上,选择器一直轮询,判断其中是否有IO线程数据准备好,当任一线程数据准备
好则开始处理数据。
Linux的IO多路复用模型有三种实现:
- select实现
无差别轮询,单个进程能够监视的文件描述符数量存在最大限制,一般是1024 - poll实现
相比select,由于使用链表存储,文件描述符的数量没有限制 - epoll实现
效率比select/pollh高很多,使用红黑树存储,查增改的效率比数组和链表高很多。
如果我们要实现100万的并发连接,select每一个进程支持1024个连接,那么我们需要开辟1k个进程。
poll,我们需要一个进程可以,但是每扫描一次的时间时O(n)= O(100万)。
epoll事件驱动,epoll_wait()系统调用,通过此调用收集在epoll监控中已经发生的事件,时间是O(1)。
信号驱动式IO模型
当IO线程发起调用,会向内核注册一个信号处理函数,然后线程返回不阻塞;当内核数据准备好后,发送一个信号给IO线程,此时IO线程调用recvfrom系统调用开始读取数据进行处理。
异步IO模型
IO线程告知内核启动某个操作,并让内核在整个操作完成后通知应用程序。与信号量驱动IO的区别时,
信号量是内核通知线程何时启动一个IO操作(recvfrom调用),异步IO则是有内核通知线程操作何时完成。
IO多路复用模型epoll实现
使用epoll的过程是三个步骤
- 调用epoll_create()创建一个epoll对象。
- 调用epoll_ctl向epoll对象中添加连接的socket
- epoll_wait收集发生的事件的连接。
int s = socket(AP_INET, SOCKET_STREAM, 0);
bind(s,...)
listen(s,...)
//创建epoll对象
int epfd = epoll_create(...);
//将所有需要监听的socket添加到epfd中
epoll_ctl(epfd,...);
whiie(1){
//如果没有就绪的socket(读取到数据)阻塞在这里
int n = epoll_wait(...)
for(接受到数据的socket) {
//处理数据
}
}
struct eventpoll{
.....
//红黑树的根节点(每一个节点是Epitem),整个树存储所有的监听的socket,每个节点存储一个socket
struct rb_root rbr;
//双向链表存储就绪列表,并非直接引用socket,而是通过Epitem间接引用
struct list_head rdlist;
.....
}
struct epitem {
rbn;
rdlink;
next;
ffd;
nwait;
pwqlist;
ep;
fllink;
event;
}
现在我们来梳理下epoll的流程:
- 进程调用epoll_create,内核创建eventpoll对象,这个对象中主要包含rbr(红黑树)所有监视的socket,
rdlist(双向链表) - socket1,socket2,socket3被监听,注册(添加)到eventpoll对象的rbr监视树上。
- 此时socket1接收到数据,中断程序会给eventpoll的就绪列表rdlist添加socket1的引用
- 当执行到epoll_wait,检测到rdlist不为空,获取到socketk开始处理。
参考
https://segmentfault.com/a/1190000021163843?utm_source=tag-newest
https://blog.csdn.net/shenya1314/article/details/73691088
https://blog.csdn.net/historyasamirror/article/details/5778378
https://blog.csdn.net/tjiyu/article/details/52959418?utm_source=distribute.pc_relevant.none-task
https://blog.csdn.net/bird73/article/details/79792548
https://www.cnblogs.com/aspirant/p/9166944.html
https://blog.csdn.net/armlinuxww/article/details/92803381