Redis 是一个高性能服务端的典范。它通过多路复用 epoll 来管理海量的用户连接,只使用一个线程来通过事件循环来处理所有用户请求,就可以达到每秒数万 QPS 的处理能力。
单线程的 Redis 虽然性能很高,但是却有两个问题。一个问题是没有办法充分发挥现代 CPU 的多核处理能力,一个实例只能使用一个核的能力。二是如果某个用户请求的处理过程卡住一段时间,会导致其它所有的请求都会出现超时的情况。所以,在线上的 redis 使用过程时是明确禁止使用 keys * 等长耗时的操作的。
那如何改进呢,思路和方向其实很明确。那就是和其它的主流程序一样引入多线程,用更多的线程来分担这些可能耗时的操作。事实上 Redis 也确实这么干了,在 6.0 以后的版本里,开始支持了多线程。我们今天就来领略一下 Redis 的多线程是如何实现的。
一、多线程 Redis 服务启动
首先获取多线程版本 Redis 的源码
# git clone https://github.com/redis/redis
# cd redis
# git checkout -b 6.2.0 6.2.0
默认情况下多线程是默认关闭的。如果想要启动多线程,需要在配置文件中做适当的修改。相关的配置项是 io-threads 和 io-threads-do-reads 两个。
#vi /usr/local/soft/redis6/conf/redis.conf
io-threads 4 #启用的 io 线程数量
io-threads-do-reads yes #读请求也使用io线程
其中 io-threads 表示要启动的 io 线程的数量。io-threads-do-reads 表示是否在读阶段也使用 io 线程,默认是只在写阶段使用 io 线程的。
现在假设我们已经打开了如上两项多线程配置。带着这个假设,让我们进入到 Redis 的 main 入口函数。
//file: src/server.c
int main(int argc, char **argv) {
......
// 1.1 主线程初始化
initServer();
// 1.2 启动 io 线程
InitServerLast();
// 进入事件循环
aeMain(server.el);
}
1.1 主线程初始化
在 initServer 这个函数内,Redis 主线程做了这么几件重要的事情。
-
初始化读任务队列、写任务队列
-
创建一个 epoll 对象
-
对配置的监听端口进行 listen
-
把 listen socket 让 epoll 给管理起来
//file: src/server.c
void initServer() {
// 1 初始化 server 对象
server.clients_pending_write = listCreate();
server.clients_pending_read = listCreate();
......
// 2 初始化回调 events,创建 epoll
server.el = aeCreateEventLoop(server.maxclients+CONFIG_FDSET_INCR);
// 3 绑定监听服务端口
listenToPort(server.port,server.ipfd,&server.ipfd_count);
// 4 注册 accept 事件处理器
for (j = 0; j < server.ipfd_count; j++) {
aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,
acceptTcpHandler,NULL);
}
...
}
接下来我们分别来看。
初始化 server 对象
在 initServer 的一开头,先是对 server 的各种成员变量进行初始化。值得注意的是 clients_pending_write 和 clients_pending_read 这两个成员,它们分别是写任务队列和读任务队列。将来主线程产生的任务都会放在放在这两个任务队列里。
主线程会根据这两个任务队列来进行任务哈希散列,以将任务分配到多个线程中进行处理。
aeCreateEventLoop 处理
我们来看 aeCreateEventLoop 详细逻辑。它会初始化事件回调 event,并且创建了一个 epoll 对象出来。
//file:src/ae.c
aeEventLoop *aeCreateEventLoop(int setsize) {
aeEventLoop *eventLoop;
eventLoop = zmalloc(sizeof(*eventLoop);
//将来的各种回调事件就都会存在这里
eventLoop->events = zmalloc(sizeof(aeFileEvent)*setsize);
......
aeApiCreate(eventLoop);
return eventLoop;
}
我们注意一下 eventLoop->events,将来在各种事件注册的时候都会保存到这个数组里。
//file:src/ae.h
typedef struct aeEventLoop {
......
aeFileEvent *events; /* Registered events */
}
具体创建 epoll 的过程在 ae_epoll.c 文件下的 aeApiCreate 中。在这里,真正调用了 epoll_create
//file:src/ae_epoll.c
static int aeApiCreate(aeEventLoop *eventLoop) {
aeApiState *state = zmalloc(sizeof(aeApiState));
state->epfd = epoll_create(1024);
eventLoop->apidata = state;
return 0;
}
绑定监听服务端口
我们再来看 Redis 中的 listen 过程,它在 listenToPort 函数中。调用链条很长,依次是 listenToPort => anetTcpServer => _anetTcpServer => anetListen。在 anetListen 中,就是简单的 bind 和 listen 的调用。
//file:src/anet.c
static int anetListen(......) {
bind(s,sa,len);
listen(s, backlog);
......
}
注册事件回调函数
前面我们调用 aeCreateEventLoop 创建了 epoll,调用 listenToPort 进行了服务端口的 bind 和 listen。接着就调用的 aeCreateFileEvent 就是来注册一个 accept 事件处理器。
我们来看 aeCreateFileEvent 具体代码。
//file: src/ae.c
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
aeFileProc *proc, void *clientData)
{
// 取出一个文件事件结构
aeFileEvent *fe = &eventLoop->events[fd];
// 监听指定 fd 的指定事件
aeApiAddEvent(eventLoop, fd, mask);
// 设置文件事件类型,以及事件的处理器
fe->mask |= mask;
if (mask & AE_READABLE) fe->rfileProc = proc;
if (mask & AE_WRITABLE) fe->wfileProc = proc;
// 私有数据
fe->clientData = clientData;
}
函数 aeCreateFileEvent 一开始,从 eventLoop->events 获取了一个 aeFileEvent 对象。
接下来调用 aeApiAddEvent。这个函数其实就是对 epoll_ctl 的一个封装。主要就是实际执行 epoll_ctl EPOLL_CTL_ADD。
//file:src/ae_epoll.c
static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
// add or mod
int op = eventLoop->events[fd].mask == AE_NONE ?
EPOLL_CTL_ADD : EPOLL_CTL_MOD;
......
// epoll_ctl 添加事件
epoll_ctl(state->epfd,op,fd,&ee);
return 0;
}
每一个 eventLoop->events 元素都指向一个 aeFileEvent 对象。在这个对象上,设置了三个关键东西
-
rfileProc:读事件回调
-
wfileProc:写事件回调
-
clientData:一些额外的扩展数据
将来 当 epoll_wait 发现某个 fd 上有事件发生的时候,这样 redis 首先根据 fd 到 eventLoop->events 中查找 aeFileEvent 对象,然后再看 rfileProc、wfileProc 就可以找到读、写回调处理函数。
回头看 initServer 调用 aeCreateFileEvent 时传参来看。