一条Redis命令是如何执行的
我们从客户端输入一条命令到服务端执行这条命令究竟发生了什么,今天就来探讨这个问题
转换命令为Redis协议
客户端并不会把我们输入的命令直接发送给服务端,任何客户端服务端之间的通信都遵循某种协议,这种协议方便服务端对命令进行解析,例如判断命令拼写是否错误
Redis客户端会将命令转换为RESP格式,RESP(REdis Serialization Protocol)然后发送给客户端
- 当然在这之前客户端会和服务端建立Socket连接,每个Socket被创建,会分配两个缓冲区:输入缓冲区和输出缓冲区,
- 写入函数并不会立即向网络中传输数据,而是先将数据写入缓冲区中,再由 TCP 协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情。
- 读取函数也是如此,它也是从输入缓冲区中读取数据,而不是直接从网络中读取。
注意:Redis 使用的是 I/O 多路复用功能来监听多 socket 链接的,这样就可以使用一个线程链接来处理多个请求,减少线程切换带来的开销,同时也避免了 I/O 阻塞操作,从而大大提高了 Redis 的运行效率。
用户空间和内核空间
以Linux系统为例,各种发行版包括:Ubuntu、CentOS,通常会包含它的应用程序和内核,一般内核是没有区别的,Linux内核就相当与Window操作系统,它通常与各种硬件驱动配合操作各种计算机硬件资源(CPU、内存、网卡),而用户应用如果要访问系统资源就必须使用Linux内核封装后提供的API(我们毕竟不能让用户应用直接访问系统资源,这样存在巨大的安全问题以及冲突),所以我们有必要将Linux内核和用户应用进行分离
以32位的计算机来说,内存总空间是2^32位,也就是4GB,通常内核空间会占用1GB,用户空间会占用3GB
这样用户进程和内核进程在寻址时(进程的寻址空间其实就映射到了磁盘空间)就会完全隔离,同时Linux对各种命令进行分级,有两个级别:r0和r3,其中r3级别最低,用户进程能够执行的命令一般就是r3级别的,而r0是级别最高的,只有内核才能执行,这样对命令分配权限就限制了用户进程对系统资源的随意访问
但是用户进程有时又必须访问系统资源,这个时候就需要进行状态切换,也就是用户态 —> 内核态
我们以磁盘操作为例,用户进程在写数据和读数据是都会操作缓存空间,写的时候先把数据写到缓存区,读的时候就从缓存区读,具体的状态切换的过程如下图:
网络IO同磁盘操作,总结来说IO操作的时间消耗主要体现在:
- 进程等待数据就绪
- 数据从内核空间拷贝到用户空间
而后面将要提到的五种IO模型都是从这两方面去进行优化的
五种IO模型
阻塞IO
- 用户进程调用系统API-recvfrom
- 内核会检查内核缓冲区数据是否准备就绪
- 如果数据没有就绪,就会一直等待直到数据就绪
- 数据就绪后还没完,进程还需等待数据从内核缓冲区拷贝到用户缓冲区
- 当这个完成recvfrom返回OK的信息,整个调用就完成了
总结来看阻塞IO在数据等待和数据拷贝这两个阶段一直处于阻塞状态(除了等待操作完成,它什么都没干,cup处于闲置状态,所以阻塞IO效率很低)
非阻塞IO
对比阻塞IO,非阻塞IO就不等待
- 如果数据没有就绪,直接返回错误结果
- 然后一直盲轮询询问数据是否就绪,
- 直到数据真正就绪,便等待数据拷贝
- 拷贝完成后,整个操作就完成了
总结来说:非阻塞IO在数据等待阶段确实是非阻塞的,但这段时候它做的操作只是盲轮询,并没有做什么更有意义的操作,这样它性能不仅没有提高还会导致cpu空转,cpu使用率暴增,而且在数据拷贝阶段它依然是阻塞的
虽然非阻塞IO看起来非常废,但是在IO多路复用需要结合它发挥作用
IO多路复用
总结阻塞IO和非阻塞IO,我们能够发现: 在调用系统API之后性能问题总是出现在数据等待阶段,这段时期阻塞IO会一直等待、非阻塞IO不等待但会一直轮询使得cpu空转利用率暴增,这些操作都导致了性能问题 所以为了避免这种问题,我们就提前询问内核那些系统数据准备好了,然后再去调用系统API
问题就是我们如何得知系统数据准备好了呢?
- 文件描述符
在调用recvfrom之前调用select监听fd是否就绪,如果所有fd都没有就绪就会阻塞等待否则就会返回就绪的fd, 此时调用recvfrom就会直接执行拷贝数据阶段
对比于阻塞IO和非阻塞IO,IO多路复用避免了可能存在已经就绪的fd被阻塞而进行无效的等待
- 不同的监听fd以及通知的方式
- select
- poll
- epoll
select和epoll的操作大致一致,epoll的性能最好,通常高性能网络服务器一般操作epoll这种方式,如果epoll这种方式在操作系统上不支持就会采用select,因为select通用性更好,基本所有系统都支持
它们的具体差异体现在:
- select和poll只会返回已就绪fd的数量,实际操作fd的时候需要遍历,而epoll会直接返回已经就绪的fd
- epoll会在通知用户进程fd就绪的同时,就把就绪的fd写入了用户空间
总结来说:IO多路复用就是利用单个线程来同时监听多个fd,并在某个fd可读、可写时得到通知,从而避免无效的等待,充分利用cpu资源
IO多路复用-select实现方案
IO的三种可能事件
Linux系统把一个IO可能发生的事件分为三类:读事件、写事件、异常事件
解析select源码
nfds
是要监听的fd_set的最大fd+1,其实就是fd数量的上限timeout
是超时时间,单位是秒,如果为null,当没有fd就绪时,内核线程就会一直等待,直到fd就绪fd_set
类型,首先它是一个结构体,并且是一个数组长度为32的结构体,但是__fd_mask类型占用4个字节,所以fd_set的总长度是1024个bit,而每个fd占用1bit,因此fd_set最大可以存储1024fd
select的流程
用户空间:
1.1.创建fd_set rfds,此时fd_set的所有的bit都会被初始化为0,这里假设只有IO读事件
1.2.假设要监听fd=1,2,5,此时会从低位往右遍历,将需要监听的fd的bit位置为1,即1,2,5下标(从1开始)对应的值为1
1.3.执行select(5+1, rfds, null, null, 3),监听所有的fd,同时会将rfds,拷贝到内核空间,因此从这一步开始,由用户态转换为内核态
2.4.遍历拷贝回来的rfds,找到就绪的fd,读取其中的数据
内核空间:
2.1.此时rfds已经拷贝到内核空间,开始监听,从低位遍历fd_set,直到遍历到nfds
2.2.判断bit位值为1的fd是否就绪,如果没有就绪,则休眠
2.3.等待数据就绪被唤醒或超时,假设此时fd=1数据就绪
2.4.内核再次遍历,将fd=1的保留,其余的置为0,然后select返回fd就绪的数量,同时内核态又会将rfds拷贝回用户态
就绪的fd处理完成后,就会继续从1.2开始执行,然后循环往复
select方案的缺点
- 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要继续拷贝回用户空间,涉及到多次数据拷贝和用户态和内核态的切换,开销大
- select无法得知具体哪个fd就绪,依然需要遍历整个fd_set
- fd_set监听的数量最大为1024个,在如今的高并发的场景下,已经远远达不到要求
IO多路复用-poll实现方案
poll对select进行了简单的改进,监听的fd不再限制为1024个,理论上可以是无限个
源码分析
- 相比与select的fd_set存储监听的fd,poll使用结构体pollfd来存储一个个的fd,所以我们在传递参数的时候理论上是一个pollfd的数组
- pollfd内部由三个属性组成,分别是fd,events,revents,在初始化时,只会初始化前两个属性
poll流程
- 创建pollfd数组,初始化每个pollfd,数组大小自定义
- 调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
- 内核遍历fd,判断是否就绪
- 数据就绪或者超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
- 在用户进程中判断n是否大于0,大于0说明由就绪的fd
- 大于0就遍历pollfd数组,找到就绪的fd
对比select方案
- select模式中fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限
- 监听fd越多,每次遍历消耗的时间也越久,性能反而下降
IO多路复用-epoll实现方案
epoll模式是对select和poll的改进,它提供的三个函数:
epoll源码分析
- epoll_create(int size),会创建eventpoll结构体,里面包含一颗红黑树rbr,一条链表rdlist,创建起初都为空;函数执行完毕回返回一个epfd的句柄,这个可以看作eventpoll的唯一标识,也就是说eventpoll可以由多个,而在监听fd时,我们要通过这个句柄,明确要使用的是哪个eventpoll
- select做的工作包括监听fd和等待fd就绪,而epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)只是将需要监听的fd添加到红黑树当中,并且会为每个添加的fd及其对应的事件设置一个回调函数ep_poll_callback,每当fd就绪,就会触发,回调函数会把fd加入到链表当中
- 函数epoll_wait回对监听的fd进行等待,同时会传入一个空的数组
events
用来接收就绪的fd,epoll_wait不会直接遍历红黑树去检查fd是否就绪,而是直接检查链表,如果链表长度大于0,说明有fd就绪,然后返回就绪fd的数量如果没有,等待,如果超时还是没有fd就绪,直接返回0
epoll流程
epoll是如何解决select中出现的三种问题的
IO多路复用-事件通知机制
这里主要是针对epoll的epoll_wait接收到的通知,也就是说在epoll_wait等待期间,如果有fd就绪,epoll_wait就会接收到通知
两种事件通知模式
- LevelTriggered:简称LT。当FD有数据可读时,会重复通知多次,直至数据处理完成。是Epoll的默认模式
- EdgeTriggered:简称ET。当FD有数据可读时,只会被通知一次,不管数据是否处理完成。
两种事件通知机制的不同
我们知道当fd就绪时,会触发回调,回调函数会见就绪的fd添加到聊表当中,当我们调用epoll_wait时,会得到通知,然后返回就绪的fd个数,并且将就绪的fd添加到events数组中,其实在这一步还有一些工作:
在拷贝之前,内核线程会将就绪fd从链表中移除,然后再执行拷贝,这里我们假设就绪fd还有数据没有读取完成,那么从这开始,两种通知模式就会有区别了:
对于LT模式:
LT模式下,拷贝过后,会把fd重新添加进入链表,那么当下次执行epoll_wait操作时,链表里就有原来未读完数据的fd
对于ET模式:
不会进行判断,直接结束,当下次执行epoll_wait操作时,未读完的fd已经从链表中移除了
明明LT看起来比较好,为什么还需要ET呢
首先反复的通知是非常消耗性能的,因为要不断的将fd从内核态拷贝到用户态,而对于ET有下面两种改进方法:
- 手动调用epoll_ctl(),将未读取完成的fd,修改它们在红黑树中的状态,这样会触发eventpoll对这些fd进行检查,判断它们是否真的数据没有读完,如果是的,将它们添加进入链表,这样就实现了手动添加fd,当这样就更LT没有区别了,因为如果数据没有读完,fd就会反复拷贝
- 为了不让fd反复拷贝,我们希望能够一次性读完数据,这样我们就应该采用非阻塞IO,在数据未读完时,就会不断请求fd,知道读完,非阻塞IO就会返回,而不会向阻塞IO一样继续等待
LT模式存在的问题
- 效率问题,fd数据不断在内核空间和用户空间之间拷贝
- 惊群现象,如果有多个进程同时监听某个fd,当这个fd就绪,那么所有的进程都会被唤醒,因为对于LT来说,每次拷贝过后会把fd重新添加进入链表,所以每当进程调用epoll_wait,都会收到通知,而fd其实在前几个进程中数据可能就读完了,也就是说唤醒后面的进程是没有意义的
总结
- ET模式避免了LT模式可能出现的惊群现象
- ET模式最好结合非阻塞IO读取FD数据,相比LT会复杂一些,但性能会更好