redis中server.c中的main函数负责启动Redis服务。启动流程大致如下:
- 启动前的一系列检查动作,包括是否是sentinel启动,配置项初始化,ACL,rdb/AOF等
- 读取配置项(一系列动作)
- 打印启动日志
- 调用initServer函数初始化Redis运行时数据
- 。。。
- 启动事件循环器(提供服务)
- 服务停止
下面是initServer函数的主要流程:
- 设置一些信号处理函数, 例如SIGNT CANCEL
- 创建事件循环器
- 开启接受文件事件的端口,TCP Socket,TLS Socket,UnixSocket。如果都没有配置就会报错
- 初始化数据库
- 创建时间事件,执行函数是serverCorn,负责处理Redis中的定时任务(过期数据,RDB等)
- 为文件事件注册处理函数(acceptTcpHandler,acceptTLSHandler,acceptUnixHandler)
- 注册事件循环器的狗子函数,事件循环器在每次阻塞前后都会调用钩子函数
- 打开AOF
- Cluster初始化
Redis是一个事件驱动的程序,主要处理文件事件和时间事件。
- 文件事件:利用IO复用机制,监听Socket等文件描述符上发生的事件(由客户端或其他Redis服务器发送网络请求触发)
- 时间事件:负责完成Redis内部定时任务
Redis事件机制实现的代码在ae.h,ae.c中,分为ae抽象层和I/O复用层上下两层,其中ae抽象层基于I/O服用层。由于I/O复用涉及操作系统内核,不同的操作系统提供了不同的API接口,Redis也针对不同的操作系统做了适配。最常见的是Linux操作系统的epoll。
aeEventLoop是Redis中的事件循环器,负责管理所有的事件。几个比较重要的字段有
aeFileEvent *events:已注册的文件事件表
aeFiredEvent *fired:已就绪的文件事件表
aeTimeEvent *timeEventHead :时间事件表
| ae层 | I/O层 | Linux系统调用 | |
|---|---|---|---|
| Redis启动 创建事件循环器 | aeCreateEventLoop() | aeApiCreate() | epoll_create() |
| 添加事件 监听端口 | aeCreateFileEvent() | aeApiAddEvent() | epoll_ctl() |
| 运行时(循环调用) | aeProcessEvents() | aeApiPoll() | epoll_wait() epoll_ctl() |
- 在Redis启动时,initServer()函数会调用 aeCreateEventLoop()函数,创建事件循环器,在Linux内核层面就是创建了一个epoll实例
- 随后会调用aeCreateFileEvent()函数,监听相应的Socket文件描述符。在内核层面就是让epoll实例绑定了相应的端口
- 在运行时,会循环调用aeProcessEvents()函数,提供服务,直至redis关闭。
- 首先计算最大阻塞时间。
如果有时间事件,就用最近的时间事件的事件-当前的时间作为阻塞时间
如果没有时间事件,就检查是否允许阻塞,如果允许,就阻塞到有文件事件就绪。如果不允许,就不断询问系统是否有文件事件就绪。 - 进程阻塞前beforeSleep()函数
- 进程阻塞,aeApiPoll()函数实现,底层调用epoll_wait()函数。
- 进程阻塞后afterSleep()函数
- epoll_wait()函数返回值是已就绪的文件描述符数量k,如果数量大于0代表已经有事件就绪。由于aeEventLoop中的event字段是文件事件起始文件描述符的地址,epoll底层会将已就绪的事件进行重排,放在前面,所以只需要从头开始遍历k个就可以了,时间复杂度是O(k)。
- 先处理文件事件,根据不同的事件类型,调用不同的事件处理器处理。
- 再处理时间事件
IO分为BIO,NIO,AIO。分别为同步阻塞,同步非阻塞,异步非阻塞。
需要注意的是同步/异步是针对调用方的,当调用方发起请求,需要主动询问服务端是否可以连接就是同步IO。若调用方发起请求之后,马上做自己的其他事情,不必等待结果。当服务端准备好连接时再通知调用方,这种就是异步IO。
阻塞/非阻塞是针对服务端的。首先网络I/O是不需要CPU的,CPU只能和内存交互。当客户端发起请求,要先发送到服务端的缓冲区的,由于IO速度很慢,服务端的CPU需要等到IO就绪才能读取数据,其余时间都只能等着。这就是阻塞。相反,服务端的CPU只在缓冲区就绪时才读取数据,其余时间可以处理别的事情,这就是非阻塞。
BIO是传统的阻塞IO,服务端一个线程对应一个套接字。当客户端发起连接时,客户端要等到服务端socket套接字就绪,服务端要等待缓冲区就绪,所以客户端和服务端都是阻塞的,效率很低。
为了解决这个问题,可以采用服务端多线程的BIO,就是让不同的线程分别处理不同的socket连接,这样显然会提高服务端的处理速度,但是对服务端的性能要求很高,因为多线程是要耗费计算机资源的。
NIO。NIO分为分多种,包括非阻塞I/O模型,I/O复用模型,信号驱动I/O模型等。
- 非阻塞IO模型:服务端进程会循环地询问系统内核,缓冲区数据是否准备好,直到数据准备为止。这段时间进程时非阻塞的。
- IO多路复用:服务端用一个管理进程来监控所有的socket连接,如果某一个缓冲区数据准备好,监控进程就会修改socket文件描述符的状态,将他标为就绪。工作进程就会处理已经就绪的连接数据。因此,服务端的工作进程是可以不用阻塞于某一个socket连接的。
- 信号驱动式I/O模型:我们可以用信号,让内核在描述符就绪时发送SIGIO信号通知我们
AIO:
在IO中共有三个进程。一个时客户端,一个是服务端的用户进程,另一个是服务端的系统内核。
考虑以下两个场景:
客户端发送连接请求,一定要通过服务端内核才能传输数据,所以客户端是调用方。
服务端用户进程要处理客户端发过来的数据,由于数据是保存在缓冲区的,缓冲区是否就绪是由内核来判断的,所以,用户进程是调用方。
无论哪种情况,用户内核都是服务提供方,若为异步IO,那么用户进程和客户端都不会阻塞,因此只有异步非阻塞IO。很显然异步IO有明显的优势,但是目前的操作系统内核对AIO的支持还不是很完善。目前普遍使用就是NIO。Redis使用的就是IO多路复用
select,poll ,epoll这三个函数都是Linux系统提供的系统调用函数,用于监测socket连接的文件描述符是否就绪。
最开始出现的就是select,这个函数的实现是在客户端构造了一个1024bit长度的bitmap。每一位代表一个fd,0代表未就绪,1代表就绪。用户进程在系统调用时,将bitmap拷贝到内核空间,内核对每一个文件描述符进行轮询,置位,再返回给用户进程。用户进程再依次轮询,从而知道哪个socket连接就绪。
但是这有明显的缺点:
- bitmap长度固定1024,只能监测1024个连接,对于某些高负载服务器来讲,连接数都上万,太少了。
- bitmap不可以复用,每次都需要重新申请。
- bitmap需要从用户进程拷贝到内核
- 用户进程每次都需要遍历bitmap,时间复杂度位O(1024)
poll 在select进行了改进,他不再使用bitmap,而是使用event的结构体数组,增加了revent字段。这样就没有长度限制了,而且数据也可以复用。但是问题3,4依然没有解决
epoll完美解决了select的全部问题。
epoll在poll的基础上,通过共享内存的方式,避免了revent数组的拷贝,同时,在内核层面上对event结构体的数据结构进行了优化。
首先使用了红黑树的结构来存储event结构体,这样就可以在O(log n)的时间复杂度下对fd进行增删改查。
然后,对fd进行了重排,将就绪的fd放到等待列表的前端,并返回最大的fd就绪数量k,这样用户进程就不需要轮询了,只需要遍历前K个fd就可以了。



