详解redis单线程设计思路
# 写在文章开头
我们都知道redis是一个基于单线程实现高效网络IO和键值对读写操作的内存数据库,本文将从源码的角度剖析一下redis高效的单线程设计。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili 。
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

# 详解redis单线程高效的原因
# 多线程存在的问题
人们常常认为单线程未能利用所有的CPU导致性能表现逊色与多线程,但我们也需要考虑多线程所操作的临界资源,假设我们通过多线程操作同一个临界资源,那么多线程竞争情况下,我们势必需要使用较粗粒度的锁来保证临界资源的线程安全,由此引发大量线程上下文切换的开销势必会导致系统的吞吐率下降。
我们还是redis的场景来看,用大量并发的线程操作8个(默认情况)内存数据库,此时如果使用多线程进行操作,势必要保护临界资源并发安全而采用较粗力度的锁,由此导致的大量线程阻塞争抢临界资源而导致操作各种大耗时操作显然是得不偿失的,并且多线程操作一般会引入各种同步原语,对于我们这种动辄十几万的内存数据库问题的定位和排查的难度都会大大增加。

所以redis的设计者就采用单线程来处理十万级别的内存操作,通过单线程极致的压榨和利用多路复用机制,简化的实现的复杂度确保redis的实现更专注的数据结构设计的单个动作操作的优化,也保证后续的问题追踪和排查难度大大降低,后续也可以针对性的对单个慢操作点进行针对性定位和优化,由此思路实现一个综合性的网络内存数据库最佳方案。
# 详解redis网络IO模型
我们都知道redis和客户端交互过程大抵分为以下几步:
redis服务端通过bind函数绑定端口后通过listen指令监听连接。- 通过
accept分配接受并处理客户端的连接。 - 通过建立的客户端
socket接收读写请的字符串将其转换内存数据库操作。 - 完成内存作后将操作结果
send给客户端。

对应我们也给出redis关于单线程整体操作的核心源代码:
int main(int argc, char **argv) {
struct timeval tv;
//......
//初始化各种默认值的配置
initServerConfig();
//......
//server初始化,其内部会完成数据结构、键值对数据库初始化、网络框架初始化工作(也就是我们说的端口绑定)
initServer();
//......
//事件循环轮询前置操作
aeSetBeforeSleepProc(server.el,beforeSleep);
//执行事件驱动框架,循环处理各种触发的事件,处理新连接和已建立socket的读写事件
aeMain(server.el);
//事件循环后置操作
aeDeleteEventLoop(server.el);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
这个环节中,accept接收连接、recv读取socket读写数据请求都是网络IO都可能是潜在的阻塞耗时点,为了避免让网络IO操作导致单线程阻塞,redis网络IO采用非阻塞模型,以Linux系统为例,它通过epoll模型,将所有已连接socket网络读写、监听socket等事件注册到epoll上,redis单线线程非阻塞调用内核函数epoll_wait获取并处理就绪的socket连接或者内存操作指令,由此实现非阻塞单线程的内存操作。

对应我们给出位于redis.c下的非阻塞轮询的核心代码段aeMain,可以看到其内部通过封装了epoll_wait方法aeApiPoll获取就绪的IO事件:
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
//单线程非阻塞循环调用aeProcessEvents获取网络就绪事件
while (!eventLoop->stop) {
if (eventLoop->beforesleep != NULL)
eventLoop->beforesleep(eventLoop);
aeProcessEvents(eventLoop, AE_ALL_EVENTS);
}
}
int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
//......
//调用aeApiPoll获取就绪的网络事件
numevents = aeApiPoll(eventLoop, tvp);
for (j = 0; j < numevents; j++) {
//将网络IO封装为文件事件
aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
int mask = eventLoop->fired[j].mask;
int fd = eventLoop->fired[j].fd;
int rfired = 0;
//根据事件类型的mask走到读事件(AE_READABLE)或者写事件(AE_WRITABLE)的逻辑进行内存操作
if (fe->mask & mask & AE_READABLE) {
rfired = 1;
fe->rfileProc(eventLoop,fd,fe->clientData,mask);
}
if (fe->mask & mask & AE_WRITABLE) {
if (!rfired || fe->wfileProc != fe->rfileProc)
fe->wfileProc(eventLoop,fd,fe->clientData,mask);
}
processed++;
}
}
//......
return processed; /* return the number of processed file/time events */
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 用读写请求复盘redis单线程操作整体流程
我们从一个实际的读写请求来总结一下redis单线程操作全过程:
- 在
redis服务端启动是时会绑定6379端口,将监听accept和read事件的socket注册到epoll上。 - 一旦这些
socket有就绪的事件,就会将其存放到epoll模型的就绪列表中。 redis服务端通过epoll_wait获取非阻塞获取就绪列表上的事件。- 解析事件的读写类型,将其封装为文件事件。
- 执行后续的指令解析、内存读写操作。
- 结果返回给客户端。
# 小结
因为采用了IO多路复用机制确保了单线程可以非阻塞获取就绪的网络事件,也保证了后续的内存操作能够及时被执行,由此使得redis实现单线程每秒十万级别的内存操作。
我是 sharkchili ,CSDN Java 领域博客专家,开源项目—JavaGuide contributor,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。 因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

# 参考
《Redis核心技术与实战》