Redis为什么选择单线程

Redis到底是单线程还是多线程?

  • Redis的核心业务部分(命令处理)——单线程
  • 整个Redis——多线程

在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:

  • Redis v4.0:引入多线程异步处理一些耗时较久的任务,例如异步删除命令unlink
  • Redis v6.0:在核心网络模型中引入多线程,进一步提高对于多核CPU的利用率

因此,对于Redis的核心网络模型,在Redis 6.0之前确实都是单线程。是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况。

为什么Redis要选择单线程?

  • 抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
  • 多线程会导致过多的上下文切换,带来不必要的开销。
  • 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣。

Redis网络模型

Redis通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装,提供了统一的高性能事件API库AE:

image-20250221174114368

ae.c文件中会根据系统环境选择需要的实现,不同的操作系统会选择不同的epoll实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* Include the best multiplexing layer supported by this system.
* The following should be ordered by performances, descending. */
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
//linux系统
#ifdef HAVE_EPOLL
#include "ae_epoll.c"
#else
#ifdef HAVE_KQUEUE
//unix系统选用
#include "ae_kqueue.c"
#else
//兜底
#include "ae_select.c"
#endif
#endif
#endif

启动源码分析

1
2
3
4
5
6
7
8
9
10
//server.c
int main(int argc, char **argv) {
...
//初始化服务
initServer();
...
//开始监听事件循环
aeMain(server.el);
...
}
监听事件循环

先看开始监听事件的代码aeMain,其实就是不断轮询进行epoll_wait,看是否有监听的事件发生,如果有就进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
//循环监听事件
while (!eventLoop->stop) {
aeProcessEvents(eventLoop, AE_ALL_EVENTS|
AE_CALL_BEFORE_SLEEP|
AE_CALL_AFTER_SLEEP);
}
}

int aeProcessEvents(aeEventLoop *eventLoop,int flags){
...
//调用前置处理器beforeSleep
eventLoop->beforeSleep(eventLoop);
//等待FD就绪,类似epoll_wait
numevents=aeApiPoll(eventLoop,tvp)
for(j=0;j<numevents;j++){
//遍历处理就绪的FD,调用对应的处理器
}
}
初始化服务

initServer初始化服务,如果是epoll方式的话,相当于是调用了epoll_create + epoll_ctl完成ServerSocket的注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void initServer(void){
//...
//内部会调用aeApiCreate(eventLoop),类似epoll_create
server.el=aeCreateEventLoop(
server.maxclients+CONFIG_FDSET_INCR
);
//...
//监听TCP端口,创建ServerSocket,并得到FD
listenToPort()
//...
//注册 连接处理器,内部会调用aeApiAddEvent(&server.ipdf)监听FD
//处理客户端连接请求
createSocketAcceptHandler(&server.ipfd,acceptTcpHandler)
//注册 ae_api_poll的前置处理器
aeSetBeforeSleepProc(server.el,beforeSleep);
}

在initServer中除了注册ServerSocket,还需要定义一个acceptTcpHandler,处理客户端连接请求。在客户端连接成功后,该处理器内部会再为该客户端连接注册一个FD读事件并将客户端绑定到读处理器readQueryFromClient上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//客户端读事件处理器
void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
...
//接收socket连接,获取FD
fd=accept(s,sa,len);
...
//创建connection,关联fd
connection *conn=connCreateSocket();
conn.fd=fd;
...
//内部调用aeApiAddEvent(fd,READABLE)
//监听socket的FD读事件,并绑定到读处理器readQueryFromClient
connSetReadHandler(conn,readQueryFromClient);
}

注意,acceptTcpHandler是处理ServerSocket读事件(即客户端的连接请求)的,而其内部定义的readQueryFromClient是用来处理客户端读事件(即客户端发来的web请求)的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//读处理器
void readQueryFromClient(connection *conn){
//获取当前客户端,客户端宏有缓冲区用来读和写
client *c=connGetPrivateData(conn);
//获取c->querybuf缓冲区大小
long int qblen=sdslen(c->querybuf);
//读取请求数据到c->querybuf缓冲区
connRead(c->conn,c->querybuf+qblen,readlen);
...
//解析缓冲区字符串,转为Redis命令参数存入c->argv数组
processInputBuffer(c);
...
//处理c->agrv中的命令
//当前客户端命令具体应该由哪个具体的命令对象来执行
processCommand(c);
}

processCommand代码如下,其实就是根据命令名称,去字典里找对应的函数实现:

1
2
3
4
5
6
7
8
9
10
11
int processCommand(client* c){
...
//根据命令名称,寻找命令对应的command,例如L setCommand
c->cmd=c->lastcmd=lookupCommand(c->argv[0]->ptr);
...
//执行command,得到响应结果,例如Ping命令,对应pingCommand
c->cmd->proc(c);
//把指向结果写出,例如ping命令,就返回pong给client
//shared.pong是字符串pong的sds对象
addReply(c,shared.pong);
}

addReply代码如下,将响应结果写入客户端缓冲区:

1
2
3
4
5
6
7
8
9
void addReply(client* c,robj* obj){
//尝试把结果写到c->buf客户端写缓冲区中
if(_addReplyToBuffer(c,obj->ptr,sdslen(obj->ptr))!=C_OK){
//如果c->buf写不下,则写到c->reply,这是一个链表,容量无上限
_addReplyProtoToList(c,obj->ptr,sdslen(obj->ptr));
}
//将客户端添加到server.clients_pending_write这个队列,等待被写出
listAddNodeHead(server.clients_pending_writer,c)
}

此时只是得到了响应并放入队列,还没有真正写出。写出部分需要使用aeEventLoop->beforeSleep前置处理器,每次有客户端事件发生时,都会先调用beforeSleep方法,这个方法负责会遍历server.clients_pending_write这个队列,监听FD写事件并绑定到写处理器sendReplyToClient:

1
2
3
4
5
6
7
8
9
10
11
12
13
void beforeSleep(struct aeEventLoop * eventLoop){
...
//定义迭代器,执行server.clients_pending_write->head
listIter li;
li->next=server.clients_pending_write->head;
li->direction=AL_STATE_HEAD;
//循环遍历待写出的client
while((ln=listNext(&li))){
//内部调用aeApiAddEvent(fd,WIRTEABLE),监听socket的FD读事件
//并且绑定写处理器,sendReplyToClient,可以把响应写到客户端socket
connSetWriterHandlerWithBarrier(c->conn,sendReplyToClient,ae_barrier)
}
}

Redis网络模型流程

image-20250221193842775

Redis 单线程网络模型的整体流程如下:

  1. serverSocket注册到epoll实例并绑定连接应答处理器tcpAcceptHandler,开启事件监听循环aeEventLoop
  2. 在循环中调用aeApiPoll等待 FD 就绪,此时一旦有 FD 就绪,说明有serverSocket读事件,即有客户端连接请求。
  3. 收到客户端连接请求后,将客户端的 FD 注册到epoll实例,并给客户端绑定命令请求处理器readQueryFromClient
  4. 一旦有客户端连接成功,aeApiPoll等待到的 FD 就有两种可能:服务端 socket 或客户端 socket。如果是服务端,说明收到了客户端连接请求,执行第 3 步;如果是客户端,说明是命令请求,需要读取请求数据并返回响应(或异常)。
  5. 读取请求数据并得到响应的操作需要通过readQueryFromClient来完成,主要进行四件事:将请求数据写入客户端缓冲区,解析缓冲区中的数据并转为 Redis 命令,执行命令并将结果写入buf(容量有上限的数组)或reply(容量无上限的链表),将client对象加入队列等待被写出。
  6. 在每次客户端有事件发生即收到请求时,都会先调用beforeSleep方法,为队列中的对象监听 FD 写事件并绑定写处理器,此时aeApiPoll等待到的事件又多了一种写事件的可能。当队列中对象写就绪时,就会由命令回复处理器sendReplyToClient负责将响应写出给客户端。

单线程网络模型存在瓶颈:第一个瓶颈是命令请求处理器,它需要从socket读数据并解析;第二个瓶颈是命令回复处理器,它需要将client缓冲区中的数据写入socket。这种网络IO操作的开销都是较大的,至于IO多路复用以及命令执行本身都没有太大的开销。

Redis6.0引入的多线程正是为了解决网络IO的瓶颈,仅在命令解析部分和写出回复部分开启多线程,而命令执行本身和IO多路复用模块依旧使用主线程。多线程模式的整体流程如下:

image-20250221194958819

__END__