大半年前,看到 Redis 即将推出“多线程 IO”的特性,基于当时的各种资料,和 unstable 分支的代码,写了《多线程的 Redis》,浅尝辄止地介绍了下特性,不够华也不实。本文将深入到实处,内容包含:
要分析多线程 IO,必须先搞清楚经典的单线程异步 IO。文章会先介绍单线程 IO 的知识,然后再引出多线程 IO,如果已经熟悉,可以直接跳到多线程 IO 部分。
接下来我们一起啃下这两块大骨头。代码基于: https://github.com/antirez/redis/tree/6.0
Redis 核心的工作负荷是一个单线程在处理,但为什么还那么快?
(为避免歧义,此处的异步处理 IO 不是“同步/异步 IO”,特指 IO 处理过程是异步的,描述的对象是处理过程。)
假设客户端发送了以下命令:
GET key-how-to-be-a-better-man?
redis 回复:
努力加把劲把文章写完
要处理命令,则 redis 必须完整地接收客户端的请求,并将命令解析出来,再将结果读出来,通过网络回写到客户端。整个工序分为以下几个部分:
其中解析和执行是纯 cpu/内存操作,而接收和返回主要是 IO 操作,这是我们要关注的重点。以接收为例,redis 要完整接收客户端命令,有两种策略:
用聊天的例子做对应,假设你在回答多个人的问题,也有同步和异步的策略:
很显然异步的效率更高,要实现高并发必须要异步,因为同步有太多时间浪费在等待上了,遇到网络不好的客户端直接就被拖垮。异步的策略简单可总结如下:
下次能给时
再给,不等,直到全部给完异步没有零散的等待,但有个问题是,如果 redis 不一直阻塞等命令来,咋个知道“网络包有数据了”、“下次能给时”这两个时机?如果一直去轮训问肯定效率很低,要有个高效的机制,来通知 redis 这两个时刻,由这些时刻来触发动作。这就是事件驱动。
一个新TCP包来了
、可以再次发给客户端数据
这两个时机都是事件。与之对应的就是 redis 和客户端之间 socket 的可读、可写事件 [1] ,就像微信聊天中新消息提醒一样。linux 中的 epoll 就是干这个事的,redis 基于 epoll 等机制抽象出了一套事件驱动框架 [2],整个 server 完全由事件驱动,有事件发生就处理,没有就空闲等待。
redis 启动后会进入一个死循环 aeMain,在这个循环里一直等待事件发生,事件分为 IO 事件和 timer 事件,timer 事件是一些定时执行的任务,如 expire key 等,本文只聊 IO 事件。
epoll 处理的是 socket 的可读、可写事件,当事件发生后提供一种高效的通知方式,当想要异步监听某个 socket 的读写事件时,需要去事件驱动框架中注册要监听事件的 socket,以及对应事件的回调 function。然后死循环中可以通过 epoll_wait 不断地去拿发生了可读写事件的 socket,依次处理即可。
可读
可以简单理解为,对应的 socket 中有新的 tcp 数据包到来。
可写
可以简单理解为,对应的 socket 写缓冲区已经空了 (数据通过网络已经发给了客户端)
一图胜前言,完整、详细流程图如下:
上面详细梳理了单线程 IO 的处理过程,IO 都是非阻塞,没有浪费一丁点时间,虽然是单线程,但动辄能上 10W QPS。不过也就这水平了,难以提供更多的自行车。
同时这个模型有几个缺陷:
redis 主线程的时间消耗主要在两个方面:
当 value 比较大时,瓶颈会先出现在同步 IO 上 (假设带宽和内存足够),这部分消耗在于两部分:
这部分数据读写会占用大量的 cpu 时间,也直接导致了瓶颈。如果能有多个线程来分担这部分消耗,那 redis 的吞吐量还能更上一层楼,这也是 redis 引入多线程 IO 的目的。[3]
上面已经梳理了单线程 IO 的处理流程,以及多线程 IO 要解决的问题,接下来将目光放到:如何用多线程分担 IO 的负荷。其做法用简单的话来说就是:
完整流程图如下:
beforesleep 中,先让 IO 线程读数据,然后再让 IO 线程写数据。读写时,多线程能并发执行,利用多核。
将读任务均匀分发到各个 IO 线程的任务链表 io_threads_list[i],将 io_threads_pending[i] 设置为对应的任务数,此时 IO 线程将从死循环中被激活,开始执行任务,执行完毕后,会将 io_threads_pending[i] 清零。函数名为:handleClientsWithPendingReadsUsingThreads
将写任务均匀分发到各个 IO 线程的任务链表 io_threads_list[i],将 io_threads_pending[i] 设置为对应的任务数,此时 IO 线程将从死循环中被激活,开始执行任务,执行完毕后,会将 io_threads_pending[i] 清零。函数名为:handleClientsWithPendingWritesUsingThreads
beforeSleep 中主线程也会执行其中一个任务 (图中忽略了),执行完后自旋等待 IO 线程处理完。
读任务要么在 beforeSleep 中被执行,要么在 IO 线程被执行,不会再在读回调中执行
写任务会分散到 beforeSleep、IO 线程、写回调中执行
主线程和 IO 线程交互是无锁的,通过标志位设置进行,不会同时写任务链表
性能据测试提升了一倍以上 (4 个 IO 线程)。 [4]
欢迎您的提问、指正、建议等。首发在这里