Redis的线程模型与网络模型
为什么总听说Redis是单线程,还性能这么强?
答案通常是Redis基于内存,且采用Reactor事件驱动模型,且数据结构的精心设计。
在Redis4.0,作者增加了大key删除的lazy free线程;Redis6.0又给出了多IO线程的实现。
查看Redis下线程数也很容易:
# 找pid
$ ps aux | grep redis-server
# 看线程信息
$ top -p pid -H
因此Redis不能简单说单线程,但主IO线程还是单线程多路复用的实现,通常我们所说的单线程也是指的这个。
为了行文方便,这里IO复用还是以epoll为例。
Reactor事件驱动
Reactor事件驱动,这到底是指的什么?之前已经分享过epoll和reactor,这里我用一句话总结下:
事件驱动就是对epoll的封装,封装了什么?说白了就是对fd、事件类型和处理回调函数的绑定,和epoll那三个函数处理的封装。一个IO事件就绪了,就去调它绑定的回调处理函数。
说白了就是这么一回事,但前提还是要理解IO多路复用模型如epoll的原理和使用。
我们以epoll为例,redis对IO事件处理的方式简单3个点概括下:
-
为每个事件绑定fd、事件类型、和对应的读写处理函数
-
epoll就绪fd处理,读事件区分是listenfd还是connfd
- listenfd则accept,再加到epoll,监听读事件
- connfd则读用户数据(命令),处理,结果写到缓冲区 (大事件循环EventLoop中开始的beforeSleep,会把缓冲区数据写回客户端)
-
写事件处理,beforeSleep中还有待写回的fd,则监听可写事件。等着下次事件到来写回客户端。
线程模型与IO模型
图片来源:小林coding
图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 I/O 和命令处理都是单线程。Redis初始化,以及主IO线程的事件循环处理描述:
-
epoll_create() 获得epoll 实例,调用 epoll_ctl() 将 listenfd 加入到 epoll,关心可读事件,同时注册「连接事件」处理函数。
-
首先,先调用beforesleep 处理发送队列函数,看是发送队列里是否有任务,如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
-
接着,调用 epoll_wait 函数等待事件的到来:
- 如果是连接事件到来,则会调用连接事件处理函数,该函数会做这些事情:调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 注册「读事件」处理函数;
- 如果是读事件到来,则会调用读事件处理函数,该函数会做这些事情:调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送;
- 如果是写事件到来,则会调用写事件处理函数,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
事件和回调函数
事件 | 处理回调函数 | 事件处理 |
---|---|---|
连接可读事件 | acceptTcpHandler | 和客户端建立新连接 |
其他可读事件 | readQueryFromClient | 读用户数据,如命令 |
可写事件 | sendReplyToClient | 写回响应数据 |
epoll redis-demo
没有封装回调函数的简约版
int sock_fd,conn_fd; //监听套接字和已连接套接字的变量
sock_fd = socket() //创建套接字
bind(sock_fd) //绑定套接字
listen(sock_fd) //在套接字上进行监听,将套接字转为监听套接字
epfd = epoll_create(EPOLL_SIZE); //创建epoll实例,
//创建epoll_event结构体数组,保存套接字对应文件描述符和监听事件类型
ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE);
//创建epoll_event变量
struct epoll_event ee
//监听读事件
ee.events = EPOLLIN;
//监听的文件描述符是刚创建的监听套接字
ee.data.fd = sock_fd;
//将监听套接字加入到监听列表中
epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ee);
while (1) {
//等待返回已经就绪的描述符
n = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
//遍历所有就绪的描述符
for (int i = 0; i < n; i++) {
//如果是监听套接字描述符就绪,表明有一个新客户端连接到来
if (ep_events[i].data.fd == sock_fd) {
conn_fd = accept(sock_fd); //调用accept()建立连接
ee.events = EPOLLIN;
ee.data.fd = conn_fd;
//添加对新创建的已连接套接字描述符的监听,监听后续在已连接套接字上的读事件
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ee);
} else { //如果是已连接套接字描述符就绪,则可以读数据
receive(); // 读用户数据
do(); // 处理
write(); // 响应结果写缓冲区
}
}
}
源码片段
阅读Redis事件处理相关的核心源码,要有个心理上的准备即,很多结构体的设计就是对epoll和回调函数的封装。
对epoll两个结构的封装
- epollfd
- epoll_event
typedef struct aeApiState {
int epfd; //epoll实例的描述符
struct epoll_event *events; //epoll_event结构体数组,记录监听事件
} aeApiState;
对epoll事件和回调函数的封装
- mask识别关心事件
- 读写回调函数的绑定
typedef struct aeFileEvent {
int mask; //掩码标记,包括可读事件、可写事件和屏障事件
aeFileProc *rfileProc; //处理可读事件的回调函数
aeFileProc *wfileProc; //处理可写事件的回调函数
void *clientData; //私有数据
} aeFileEvent;
epoll_wait的封装以及事件回调处理
- aeApiPoll 就是对epoll IO复用的封装
- 事件就绪了,读写回调函数的处理,封装的更加优雅
int aeProcessEvents(aeEventLoop *eventLoop, int flags){
…
//调用aeApiPoll获取就绪的描述符
numevents = aeApiPoll(eventLoop, tvp);
…
for (j = 0; j < numevents; j++) {
aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
…
// 如果触发的是可读事件,调用事件注册时设置的读事件回调处理函数
if (!invert && fe->mask & mask & AE_READABLE) {
fe->rfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
}
// 如果触发的是可写事件,调用事件注册时设置的写事件回调处理函数
if (fe->mask & mask & AE_WRITABLE) {
if (!fired || fe->wfileProc != fe->rfileProc) {
fe->wfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
}
}
…
}
主IO线程的大死循环
beforesleep() 即把缓冲区数据写回给客户端,如有待写fd,则关注可写事件,等下次wait后写回。
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
//如果beforeSleep函数不为空,则调用beforeSleep函数
if (eventLoop->beforesleep != NULL)
eventLoop->beforesleep(eventLoop);
//调用完beforeSleep函数,再处理事件
aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
}
}
参考
- 《Redis源码剖析与实战》
- Redis 5.0 github