通过本文将了解到以下内容:
Redis服务器采用单线程模型的原因
Redis单线程处理文件事件和时间事件
Redis事件的执行和调度
6.0版本redis的“真香”多线程
本质上Redis并不是单纯的单线程服务模型,一些辅助工作比如持久化刷盘、惰性删除等任务是由BIO线程
来完成的,这里说的单线程主要是说与客户端交互完成命令请求和回复的工作线程
。
至于Antirez大佬当时是怎么想的设计为单线程不得而知,只能从几个角度来分析,来确定单线程模型的选择原因:
Redis的所有操作都是基于内存
的,处理事件极快,因此使用多线程来切换线程提高CPU利用率的需求并不强烈;不是简单的Key-Value型
的NoSQL,这也是Redis备受欢迎的原因,其中常用的Hash、Zset、List等结构在value很大时,CURD的操作会很复杂,如果采用多线程模式在进行相同key操作时就需要加锁来进行同步,这样就可能造成死锁问题。至少笔者在使用集群化Redis时超过70%时,集群failover的频率会更高
),因此在数据较大时,当Redis作为主存,就必须使用多台机器构建集群化的Redis数据库系统,这样以来Redis的单线程模式又被集群化的处理所扩展了;Redis作为单线程服务要处理的工作一点也不少,Redis是事件驱动的服务器,主要的事件类型就是:
其中,时间事件是理解单线程逻辑模型的关键。
定时事件
:任务在等待指定大小的等待时间之后就执行,执行完成就不再执行,只触发一次;周期事件
:任务每隔一定时间就执行,执行完成之后等待下一次执行,会周期性的触发;Redis中大部分是周期事件,周期事件主要是服务器定期对自身运行情况进行检测和调整,从而保证稳定性HA,这项工作主要是ServerCron函数来完成的,周期事件的内容主要包括:
删除数据库的key
触发RDB和AOF持久化
主从同步
集群化保活
关闭清理死客户端链接
统计更新服务器的内存、key数量等信息
可见 Redis的周期性事件虽然主要处理辅助任务,但是对整个服务的稳定运行,起到至关重要的作用。
Redis的每个时间事件分为三个部分:
事件ID 全局唯一 依次递增
触发时间戳 ms级精度
事件处理函数 事件回调函数
时间事件Time_Event结构:
Redis的时间事件是存储在链表中的,并且是按照ID存储的,新事件在头部旧事件在尾部,但是并不是按照即将被执行的顺序存储的。
也就是第一个元素50ms后执行,但是第三个可能30ms后执行,这样的话Redis每次从链表中获取最近要执行的事件时,都需要进行O(N)遍历,显然性能不是最好的,最好的情况肯定是类似于最小栈MinStack的思路,然而Antirez大佬却选择了无序链表
的方式。
选择无序链表也是适合Redis场景的,因为Redis中的时间事件数量并不多,即使进行O(N)遍历性能损失也微乎其微,也就不必每次插入新事件时进行链表重排。
Redis存储时间事件的无序链表如图:
Redis服务中因为包含了时间事件和文件事件,事情也就变得复杂了,服务器要决定何时处理文件事件、何时处理时间事件、并且还要明确知道处理时间的时间长度,因此事件的执行和调度
就成为重点。
Redis服务器会轮流处理文件事件和时间事件,这两种事件的处理都是同步、有序、原子地执行
的,服务器也不会终止正在执行的事件,也不会对事件进行抢占。
事件执行调度规则
文件事件是随机出现的
,如果处理完成一次文件事件后,仍然没有其他文件事件到来,服务器将继续等待。在文件事件的不断执行中,时间会逐渐向最早的时间事件所设置的到达时间逼近并最终来到到达时间,这时服务器就可以开始处理到达的时间事件了。
由于时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间一般会比设定的时间稍晚一些。
事件执行调度的代码实现
Redis源码ae.c中对事件调度和执行的详细过程在aeProcessEvents中实现的,具体的代码如下:
int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
int processed = 0, numevents;
if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS))
return 0;
if (eventLoop->maxfd != -1 ||
((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
int j;
aeTimeEvent *shortest = NULL;
struct timeval tv, *tvp;
if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT))
shortest = aeSearchNearestTimer(eventLoop);
if (shortest) {
long now_sec, now_ms;
aeGetTime(&now_sec, &now_ms);
tvp = &tv;
long long ms =
(shortest->when_sec - now_sec)*1000 +
shortest->when_ms - now_ms;
if (ms > 0) {
tvp->tv_sec = ms/1000;
tvp->tv_usec = (ms % 1000)*1000;
} else {
tvp->tv_sec = 0;
tvp->tv_usec = 0;
}
} else {
if (flags & AE_DONT_WAIT) {
tv.tv_sec = tv.tv_usec = 0;
tvp = &tv;
} else {
tvp = NULL; /* wait forever */
}
}
numevents = aeApiPoll(eventLoop, tvp);
if (eventLoop->aftersleep != NULL && flags & AE_CALL_AFTER_SLEEP)
eventLoop->aftersleep(eventLoop);
for (j = 0; j < numevents; j++) {
aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
int mask = eventLoop->fired[j].mask;
int fd = eventLoop->fired[j].fd;
int fired = 0;
int invert = fe->mask & AE_BARRIER;
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++;
}
}
if (invert && fe->mask & mask & AE_READABLE) {
if (!fired || fe->wfileProc != fe->rfileProc) {
fe->rfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
}
}
processed++;
}
}
/* Check time events */
if (flags & AE_TIME_EVENTS)
processed += processTimeEvents(eventLoop);
return processed;
}
上面的源码可能读起来并不直观,在《Redis设计与实现》书中给出了伪代码实现:
def aeProcessEvents()
#获取当前最近的待执行的时间事件
time_event = aeGetNearestTimer()
#计算最近执行事件与当前时间的差值
remain_gap_time = time_event.when - uinx_time_now()
#判断时间事件是否已经到期 则重置 马上执行
if remain_gap_time < 0:
remain_gap_time = 0
#阻塞等待文件事件 具体的阻塞等待时间由remain_gap_time决定
#如果remain_gap_time为0 那么不阻塞立刻返回
aeApiPoll(remain_gap_time)
#处理所有文件事件
ProcessAllFileEvent()
#处理所有时间事件
ProcessAllTimeEvent()
可以看到Redis服务器是边阻塞边执行的,具体的阻塞事件由最近待执行时间事件的等待时间决定的,在阻塞该最小等待时间返回之后,开始处理事件任务,并且先执行文件事件、再执行时间事件,即使所有时间事件要即刻执行,也需要等待文件事件完成之后再执行时间事件,所以比预期的稍晚。
因为Redis 的瓶颈并不在 CPU,而在内存和网络。
内存不够的话,可以加内存或者做数据结构优化和其他优化等,但网络的性能优化才是大头,网络 IO 的读写在 Redis 整个执行期间占用了大部分的 CPU 时间,如果把网络处理这部分做成多线程处理方式,那对整个 Redis 的性能会有很大的提升。
Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程。之所以这么设计是不想 Redis 因为多线程而变得复杂,需要去控制 key、lua、事务,LPUSH/LPOP 等等的并发问题。
Redis 在最新的几个版本中加入了一些可以被其他线程异步处理的删除操作,比如: UNLINK、FLUSHALL ASYNC 和 FLUSHDB ASYNC,我们为什么会需要这些删除操作,而它们为什么需要通过多线程的方式异步处理?
我们知道Redis可以使用del命令删除一个元素,如果这个元素非常大,可能占据了几十兆或者是几百兆,那么在短时间内是不能完成的,这样一来就需要多线程的异步支持。
现在删除工作可以在后台进行。
优化方向:
提高网络 IO 性能,典型的实现比如使用 DPDK 来替代内核网络栈的方式。
使用多线程充分利用多核,典型的实现比如 Memcached。
所以总结起来,Redis 支持多线程主要就是两个原因:
可以充分利用服务器 CPU 资源,目前主线程只能利用一个核。
多线程任务可以分摊 Redis 同步 IO 读写负荷。
否,在conf文件进行配置
io-threads-do-reads yes
io-threads 线程数
官方建议:4 核的机器建议设置为 2 或 3 个线程,8 核的建议设置为 6 个线程,线程数一定要小于机器核数,尽量不超过8个。
流程简述如下:
主线程负责接收建立连接请求,获取 Socket 放入全局等待读处理队列。
主线程处理完读事件之后,通过 RR(Round Robin)将这些连接分配给这些 IO 线程。
主线程阻塞等待 IO 线程读取 Socket 完毕。
主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行。
主线程阻塞等待 IO 线程将数据回写 Socket 完毕。
解除绑定,清空等待队列。
该设计有如下特点:
IO 线程要么同时在读 Socket,要么同时在写,不会同时读或写。
IO 线程只负责读写 Socket 解析命令,不负责命令处理。
开启多线程后,是否会存在线程并发安全问题?
不会,Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。
这是 IO 模型的一种,即经典的 Reactor 设计模式,有时也称为异步阻塞 IO。
多路指的是多个 Socket 连接,复用指的是复用一个线程。多路复用主要有三种技术:Select,Poll,Epoll。
Epoll 是最新的也是目前最好的多路复用技术。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),且 Redis 在内存中操作数据的速度非常快(内存内的操作不会成为这里的性能瓶颈),主要以上两点造就了 Redis 具有很高的吞吐量。
最后
Redis 选择使用单线程模型处理客户端的请求主要还是因为 CPU 不是 Redis 服务器的瓶颈,所以使用多线程模型带来的性能提升并不能抵消它带来的开发成本和维护成本,系统的性能瓶颈也主要在网络 I/O 操作上;而 Redis 引入多线程操作也是出于性能上的考虑,对于一些大键值对的删除操作,通过多线程非阻塞地释放内存空间也能减少对 Redis 主线程阻塞的时间,提高执行的效率。
一句话讲完:之前用单线程是因为基于内存速度快,而且多路复用有多路复用的作用,也就是足够了,现在引入是因为在某些操作要优化,比如删除操作,因此引入了多线程。
深入了解Redis之事件原理和实现
《Redis设计与实现》黄健宏