一文吃透Redis源码(执行模型篇)
Reactor模型
背景知识
-
为了高效处理网络 IO 的 「连接事件」 、 「读事件」 、 「写事件」 ,演化出了 Reactor 模型
-
Reactor 模型中主要有 reactor、acceptor、handler 三类角色:
-
reactor:分配事件
-
acceptor:接收连接请求
-
handler:处理业务逻辑
-
Reactor 模型分为 3 类:
-
单 Reactor 单线程:accept -> read -> 处理业务逻辑 -> write 都在一个线程
-
单 Reactor 多线程:accept/read/write 在一个线程,处理业务逻辑在另一个线程
-
多 Reactor 多线程 / 进程:accept 在一个线程/进程,read/处理业务逻辑/write 在另一个线程/进程
Redis中的Reactor模型
-
Redis 6.0 以下版本,属于单 Reactor 单线程模型,监听请求、读取数据、处理请求、写回数据都在一个线程中执行,这样会有 3 个问题:
-
单线程无法利用多核
-
处理请求发生耗时,会阻塞整个线程,影响整体性能
-
并发请求过高,读取/写回数据存在瓶颈
-
针对并发请求高,读/写数据存在瓶颈的问题,Redis 6.0 进行了优化,引入了 IO 多线程 ,把读写请求数据的逻辑,用多线程处理,提升并发性能,但 处理请求的逻辑依旧是单线程处理
-
除了 Redis,你还了解什么软件系统使用了 Reactor 模型吗?
-
Netty、Memcached 采用多 Reactor 多线程模型。
-
Nginx 采用多 Reactor 多进程模型,不过与标准的多 Reactor 多进程模型有些许差异。Nginx 的主进程只用来初始化 socket,不会 accept 连接,而是由子进程 accept 连接,之后这个连接的所有处理都在子进程中完成。
-
为了高效处理网络 IO 的 「连接事件」 、 「读事件」 、 「写事件」 ,演化出了 Reactor 模型
Redis事件
事件分类
Redis 事件循环主要处理两类事件:文件事件、时间事件
-
文件事件包括:client 发起新连接、client 向 server 写数据、server 向 client 响应数据
-
时间事件:Redis 的各种定时任务(主线程中执行)
启动时
Redis 在启动时,会创建 aeEventLoop,初始化 epoll 对象,监听端口,之后会注册文件事件、时间事件:
-
文件事件:把 listen socket fd 注册到 epoll 中,回调函数是 acceptTcpHandler(新连接事件)
-
时间事件:把 serverCron 函数注册到 aeEventLoop 中,并指定执行频率
启动后
-
Redis Server 启动后,会启动一个死循环,持续处理事件(ae.c 的 aeProcessEvents 函数)
-
有文件事件(网络 IO),则优先处理。例如,client 到 server 的新连接,会调用 acceptTcpHandler 函数,之后会注册读事件 readQueryFromClient 函数,client 发给 server 的数据,都会在这个函数处理,这个函数会解析 client 的数据,找到对应的 cmd 函数执行
-
cmd 逻辑执行完成后,server 需要写回数据给 client,会先把响应数据写到对应 client 的 内存 buffer 中,在下一次处理 IO 事件之前,Redis 会把每个 client 的 buffer 数据写到 client 的 socket 中,给 client 响应
-
如果响应给 client 的数据过多,则会分多次发送,待发送的数据会暂存到 buffer,然后会向 epoll 注册回调函数 sendReplyToClient,待 socket 可写时,继续调用回调函数向 client 写回剩余数据
-
在这个死循环中处理每次事件时,都会先检查一下,时间事件是否需要执行,因为之前已经注册好了时间事件的回调函数 + 执行频率,所以在执行 aeApiPoll 时,timeout 就是定时任务的周期,这样即使没有 IO 事件,epoll_wait 也可以正常返回,此时就可以执行一次定时任务 serverCron 函数,这样就可以在一个线程中就完成 IO 事件 + 定时任务的处理
单线程
单线程实情
-
很多人认为 Redis 是单线程,这个描述是不准确的。准确来说 Redis 只有在处理 「客户端请求」 比如接收客户端请求、解析请求和进行数据读写等操作时,是单线程的。但整个 Redis Server 并不是单线程的,还有后台线程,比如文件关闭、AOF 同步写和惰性删除在辅助处理一些工作。
-
Redis 选择单线程处理请求,是因为 Redis 操作的是 「内存」 ,加上设计了 「高效」 的数据结构,所以操作速度极快,利用 「IO 多路复用」 机制,单线程依旧可以有非常高的性能。
后台线程
-
但如果一个请求发生耗时,单线程的缺点就暴露出来了,后面的请求都要「排队」等待,所以 Redis 在启动时会启动一些 「后台线程」 来辅助工作,目的是把耗时的操作,放到后台处理,避免主线程操作耗时影响整体性能
-
关闭 fd、AOF 刷盘、释放 key 的内存,这些耗时操作,都可以放到后台线程中处理,对主逻辑没有任何影响 后台线程处理这些任务,就相当于一个消费者,生产者(主线程)把耗时任务丢到队列中(链表),消费者不停轮询这个队列,拿出任务就去执行对应的方法即可:
-
BIO_CLOSE_FILE:close(fd) 文件关闭后台任务。
-
BIO_AOF_FSYNC:fsync(fd) AOF 日志同步写回后台任务
-
BIO_LAZY_FREE:free(obj) / free(dict) / free(skiplist) 惰性删除后台任务
-
Redis 后台任务使用 bio_job 结构体来描述,该结构体用了三个指针变量来表示任务参数
如果我们创建的任务,所需要的参数大于 3 个,最直接的方法就是,使用指针数组,因为指针数组本身就是一个个指针,可以通过index的顺序标记参数的含义类型,通过index就能快速获取不同的参数对应的指针这样就可以传递任意数量参数了。因为这里 Redis 的后台任务都比较简单,最多 3 个参数就足够满足需求,所以 job 直接写死了 3 个参数变量,这样做的好处是维护起来简单直接struct bio_job {
time_t time;
void *arg1, *arg2, *arg3; //传递给任务的参数
};
后台进程
后台进程只有RDB和AOF rewrite时才会fork子进程。
小结
-
Redis是一个多进程多线程的程序:在Redis中不但有fork的方式创建进程,也有通过pthread_create的方式创建线程,二者都能起到异步执行任务的效果
-
fork是一个沉重的方案:除了以守护进程的方式启动时候会进行fork,bgsave也会进行fork。但是fork比thread的代价大的多,fork出来的子进程会复制一份父进程的虚拟地址表(虚拟内存技术,子进程复制父进程的地址表,复用原有的地址空间,当某个地址上的数据涉及修改的时候才会把数据复制一份到自己的地址空间)从而也可能会导致出现写时复制等内存高损耗的开销。
-
Thread需要解决并发问题:多线程虽然资源开销没有fork那么沉重,但是由于多线程的地址空间都属于同一个进程(线程属于进程),那么必然要解决并发问题。然而Redis的设计很巧妙,无论是bioInit的bioProcessBackgroundJobs使用分type的方式让每给线程依次执行列表上的任务,还是initThreadedIO使用信号量的方式控制线程的协调,都能避开内存共享带来的并发问题,从而即享受了多线程的优势,又避免了多线程的劣势。
Redis 6.0多IO线程
背景
Redis 6.0 之前,处理客户端请求是单线程,这种模型的缺点是,只能用到 「单核」 CPU。如果并发量很高,那么在读写客户端数据时,容易引发性能瓶颈,所以 Redis 6.0 引入了多 IO 线程解决这个问题
多IO线程
-
配置文件开启 io-threads N 后,Redis Server 启动时,会启动 N - 1 个 IO 线程(主线程也算一个 IO 线程),这些 IO 线程执行的逻辑是 networking.c 的 IOThreadMain 函数。但默认只开启多线程 「写」 client socket,如果要开启多线程 「读」 ,还需配置 io-threads-do-reads = yes
-
Redis 在读取客户端请求时,判断如果开启了 IO 多线程,则把这个 client 放到 clients_pending_read 链表中(postponeClientRead 函数),之后主线程在处理每次事件循环之前,把链表数据轮询放到 IO 线程的链表(io_threads_list)中
-
同样地,在写回响应时,是把 client 放到 clients_pending_write 中(prepareClientToWrite 函数),执行事件循环之前把数据轮询放到 IO 线程的链表(io_threads_list)中
-
主线程把 client 分发到 IO 线程时,自己也会读写客户端 socket(主线程也要分担一部分读写操作),之后 「等待」 所有 IO 线程完成读写,再由主线程 「串行」 执行后续逻辑
-
每个 IO 线程,不停地从 io_threads_list 链表中取出 client,并根据指定类型读、写 client socket
-
IO 线程在处理读、写 client 时有些许差异,如果 write_client_pedding < io_threads * 2,则直接由 「主线程」 负责写,不再交给 IO 线程处理,从而节省 CPU 消耗
官方建议
-
服务器最少 4 核 CPU 才建议开启 IO 多线程,4 核 CPU 建议开 2-3 个 IO 线程,8 核 CPU 开 6 个 IO 线程,超过 8 个线程性能提升不大
-
开启多 IO 线程后,性能可提升 1 倍。当然,如果 Redis 性能足够用,没必要开 IO 线程
分布式锁的原子性保证
-
无论是 IO 多路复用,还是 Redis 6.0 的多 IO 线程,Redis 执行具体命令的主逻辑依旧是 「单线程」 的
-
执行命令是单线程,本质上就保证了每个命令必定是 「串行」 执行的,前面请求处理完成,后面请求才能开始处理
-
所以 Redis 在实现分布式锁时,内部不需要考虑加锁问题,直接在主线程中判断 key 是否存在即可,实现起来非常简单
问题解答
如果将命令处理过程中的命令执行也交给多 IO 线程执行,除了对原子性会有影响,还会有什么好处和坏处?
-
好处:
-
每个请求分配给不同的线程处理,一个请求处理慢,并不影响其它请求
-
请求操作的 key 越分散,性能会变高(并行处理比串行处理性能高)
-
可充分利用多核 CPU 资源
-
坏处:
-
操作同一个 key 需加锁,加锁会影响性能,如果是热点 key,性能下降明显
-
多线程上下文切换存在性能损耗
-
多线程开发和调试不友好