拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis事件驱动框架深度解析:文件事件与时间事件如何协同

Redis事件驱动框架深度解析:文件事件与时间事件如何协同 很多人在聊 Redis 高性能时总是把功劳归结为“内存操作快”。内存快只是其中一半的答案——另一半在于 Redis 在事件驱动这块如何调度“什么时候读、什么时候写、什么时候去做后台任务”。这套调度机制就是由 ae 层实现的核心框架。这篇主要讲一件事Redis 事件驱动框架到底是怎么把网络事件和定时任务统一在一个主循环里转起来的。我会从阻塞 IO 的困境出发顺着源码把 aeEventLoop、文件事件、时间事件、beforesleep 这些关键设计一个个拆开最后再聊聊这个框架对日常工程设计的启发。适合读过一些 Redis 源码但没理清主循环脉络的读者也适合想在自己的服务里借鉴事件驱动思路的开发者。1. 单线程高并发的秘密为什么Redis需要事件驱动1.1 阻塞IO的困境一个连接拖垮一切先回到最原始的网络服务模型。假设我们用传统阻塞式 socket 写一个服务器accept()等待客户端连接连接建立后read()读取请求。问题就出在这个read()上——如果客户端连上后迟迟不发数据那么read()会一直卡在那里后续所有连接都要等着。这就像只有一个窗口的银行柜台第一位客户站在窗口前不办业务也不走后面排队的只能干瞪眼。很多人第一反应是那用多线程啊一个连接一个线程谁也不影响谁。这当然能解决问题但代价不小。线程多了以后操作系统要频繁进行上下文切换线程之间共享数据还要加锁锁竞争一上来性能反而可能比单线程更差。而且 Redis 的命令执行大多都在内存里完成耗时通常只有几十微秒到几百微秒用多线程来并发的收益远远抵不上锁和切换带来的开销。Redis 选了另一条路让任何一个连接都不能把主线程卡住。这也就是事件驱动模型的核心思路——把“等待”集中起来把“处理”分发出去。1.2 非阻塞IO与多路复用让内核替我们等要让主线程“不被任何连接卡住”前提是 socket 必须设置为非阻塞模式。非阻塞 socket 下read()没有数据时不会傻等而是立即返回一个错误码。但问题也随之而来如果每个连接都去轮询一遍有没有数据十万个连接就是十万次系统调用照样浪费。所以事件驱动还需要第二块拼图IO 多路复用。把一堆文件描述符统一交给内核内核帮忙盯着一旦某个 fd 可读或者可写就立刻通知程序。程序只需要在事件循环里问一次内核“哪些 fd 有动静”然后处理返回的那一小批就绪事件。用生活化的话说这相当于把“挨个询问每位客户是否要办业务”改成了“前台在叫号叫到谁谁就上来”。银行不需要盯着每一位客户只需要坐等号牌系统广播。Redis 也只需要等着内核告诉它连接 A 来了新数据连接 B 的发送缓冲区空了可以继续写。1.3 事件驱动框架的构成文件事件 时间事件Redis 的事件驱动框架在代码上就是ae.c、ae.h以及各平台的多路复用实现文件。整个 ae 层抽象出两类事件文件事件对应网络连接的读、写状态变化比如“某个客户端连接可读了”“某个 fd 可写了”。时间事件对应定时或周期性任务比如serverCron这个固定 100ms 跑一次的周期性调度函数。这两类事件统一挂在一个aeEventLoop结构上由aeMain()这个主循环不断驱动。网络请求来了主循环在 epoll 上醒来去处理网络空闲了主循环靠着设定的超时时间也能按节奏醒来执行周期任务。Redis 能在一个单线程里同时兼顾高并发网络请求和复杂后台任务靠的就是这个机制。2. 拆开Redis的两个“事件心脏”文件事件与时间事件2.1 aeEventLoop驱动一切的中枢结构ae.h里定义了整个框架的核心结构体aeEventLoop。第一次读源码时我建议先把里面的关键字段标记出来typedef struct aeEventLoop { int maxfd; // 当前已注册的最大文件描述符 int setsize; // 最多可监听的文件事件数 long long timeEventNextId; // 下一个时间事件的ID aeFileEvent *events; // 文件事件表以fd为下标 aeFiredEvent *fired; // 已就绪的文件事件数组 aeTimeEvent *timeEventHead; // 时间事件链表头 int stop; // 停止循环的标志 void *apidata; // 底层多路复用专用的数据(如epoll实例) aeBeforeSleepProc *beforesleep; // 进入等待前执行的回调 aeBeforeSleepProc *aftersleep; // 等待返回后执行的回调 } aeEventLoop;可以看到文件事件用数组存直接用 fd 当下标时间事件用链表存因为 Redis 里定时任务数量很少链表足够。这两者的数据结构选择直接对应了它们各自的使用频率和查找方式。2.2 文件事件以fd为下标的回调表aeFileEvent结构体长这样typedef struct aeFileEvent { int mask; // AE_READABLE 或 AE_WRITABLE或两者都有 aeFileProc *rfileProc; // 读事件处理器 aeFileProc *wfileProc; // 写事件处理器 void *clientData; // 透传给回调函数的业务数据 } aeFileEvent;mask 是个位标记。比如一个客户端连接通常同时关心“可读”和“可写”mask 就是AE_READABLE | AE_WRITABLE。注册事件时Redis 把events[fd].rfileProc设置为对应的读处理器wfileProc设置为写处理器。以 fd 当数组下标的好处太明显了多路复用层返回“第几个 fd 就绪”之后事件循环可以直接定位到这个 fd 对应的回调处理器不需要遍历查找。Redis 之所以在大量连接下依然能保持低延迟这种 O(1) 的定位能力是很关键的一块地基。业务层代码里比如networking.c的acceptTcpHandler、readQueryFromClient都是通过注册文件事件的方式挂到这套框架上的。2.3 时间事件按时间排队的任务链表再看aeTimeEventtypedef struct aeTimeEvent { long long id; // 唯一标识 long long when; // 下一次执行的时间(毫秒级绝对时间) long long period; // 执行间隔-1表示只执行一次 aeTimeProc *proc; // 事件处理器 void *clientData; struct aeTimeEvent *next; // 链表指针 } aeTimeEvent;when是基于mstime()计算出的绝对时间戳单位是毫秒。如果period是 -1那么这个时间事件执行一次后就会被删除如果period是正数那么每次执行完框架会把when重新设置为当前时间加上period让它继续排在链表尾部等下一轮。Redis 的很多“后台任务”都以时间事件的形式存在最典型的是serverCron默认每 100 毫秒执行一次。另外还有一些临时任务也会注册进来比如 RDB 保存完成后的回调、某些延迟执行的清理动作。总体上时间事件的数量级只有个位数所以用链表完全够用没必要引入定时器堆之类的结构。3. 事件循环怎么跑从注册事件到epoll_wait3.1 多路复用层的抽离epoll、kqueue与selectRedis 在 ae 层抽象出了统一接口然后在编译时根据平台挑选具体的多路复用实现。文件列表里有ae_epoll.c、ae_kqueue.c、ae_evport.c和ae_select.c。对应关系可以看这张表实现文件底层系统调用适用平台特点ae_epoll.cepoll_create / epoll_ctl / epoll_waitLinux高并发下性能最好是生产环境最常用的实现ae_kqueue.ckqueue / keventmacOS、BSD 系苹果系统和 BSD 上表现优秀ae_evport.cevent portsSolaris 系老牌 Solaris 自带的事件端口机制ae_select.cselect一切平台兼容性最强但支持的文件描述符数量受限所以同样一个aeMain()主循环在 Linux 上跑的是 epoll在 macOS 上跑的是 kqueue。上层业务代码不关心底层是谁这种替换能力也是事件驱动框架值得借鉴的一个工程点。我们在自己写服务时也应该把“等待事件”这种变化点隔离起来方便在不同系统上切换实现。3.2 创建事件循环与注册文件事件aeCreateEventLoop()负责初始化。它先分配aeEventLoop结构体然后调用aeApiCreate()——在 epoll 实现里这一步就是创建epoll_create()的实例并把 fd 存进apidata指向的aeApiState结构。同时它还会分配events和fired两个数组setsize决定了最多能监听多少个文件事件。注册一个文件事件时Redis 调用aeCreateFileEvent()int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask, aeFileProc *proc, void *clientData)核心逻辑有两步第一步更新eventLoop-events[fd]上的 mask并把相应的rfileProc或wfileProc设置成传入的proc第二步调用aeApiAddEvent()在 epoll 里就是执行epoll_ctl()把 fd 和对应的事件掩码注册进 epoll 实例。注意如果 fd 之前已经注册过epoll_ctl的操作会从 ADD 变成 MOD这块在ae_epoll.c里通过op变量做了区分。这里有个容易忽略的细节Redis 在ae_epoll.c里调用epoll_ctl时并没有给event.events加上EPOLLET标志。也就是说Redis 的 epoll 工作在**水平触发LT**模式不是很多人以为的边缘触发ET。水平触发模式下只要 fd 上还有未读数据epoll_wait 就会反复返回这个 fd。Redis 的做法是每次读事件触发后尽量把数据读到不能再读返回EAGAIN配合 LT 模式反而让代码更安全——如果这次没读完下一轮 epoll_wait 还会继续通知。3.3 aeProcessEvents的核心调度流程aeMain()是个很薄的主循环void aeMain(aeEventLoop *eventLoop) { eventLoop-stop 0; while (!eventLoop-stop) { if (eventLoop-beforesleep ! NULL) eventLoop-beforesleep(eventLoop); aeProcessEvents(eventLoop, AE_ALL_EVENTS | AE_CALL_BEFORE_SLEEP); } }每一轮循环先执行beforesleep回调再进入aeProcessEvents()。真正复杂的调度逻辑全在aeProcessEvents()里大致流程如下如果传入的标志允许处理时间事件就先调用aeSearchNearestTimer()遍历时间事件链表找出“距离下一次执行时间最近”的那个计算出还需要等多少毫秒存进tvp。计算完超时时间后调用aeApiPoll(eventLoop, tvp)。在 epoll 实现里就是调用epoll_wait()把就绪的文件描述符和事件掩码填入fired数组返回本次就绪的事件数量。遍历fired数组对每个就绪事件取出对应的events[fd]。代码里处理的顺序是如果就绪掩码包含读事件先执行rfileProc如果包含写事件再执行wfileProc。也就是说同一轮循环里可读可写的 fd读事件优先于写事件。如果本轮没有处理任何文件事件或者文件事件还没耗尽时间事件的槽位就继续处理时间事件调用processTimeEvents()。返回本轮处理的文件事件数量。一个很关键的设计在于第 1 步和第 2 步的配合tvp直接决定了epoll_wait的超时时间。假设当前不存在任何网络事件但serverCron距离下次执行还差 40ms那么epoll_wait最多睡 40ms 就会醒来执行时间事件。这就保证了在空闲状态下周期任务依然能按时跑事件循环不会被“无事可睡”困死。4. 时间事件执行的微妙之处定时任务如何“迟到”4.1 processTimeEvents的处理逻辑processTimeEvents()负责扫描时间事件链表把到期的任务拿出来执行。它的处理思路比较朴素先记录一个maxId表示本轮开始前已经存在的时间事件里最大的 id。遍历链表时只处理id maxId的事件。也就是说本轮执行过程中新注册的时间事件不会在同一次processTimeEvents()里立刻被触发要等下一轮循环。判断当前时间now是否已经晚于te-when如果晚于就执行te-proc()。proc的返回值如果是AE_NOMORE-1说明这个任务是一次性的把它从链表中摘掉并释放如果返回的是正整数就把te-when更新为now te-period让它继续排队。所以一个周期性时间事件实际执行间隔并不是严格的period而是period加上循环处理耗时。如果事件处理器自己跑得慢后续周期就会顺延。这是所有事件循环模型的共同特性算不上缺陷但写代码时要清楚这一点。4.2 serverCron占用Redis最多的时间事件serverCron是 Redis 最重要的周期事件注册周期默认是1000ms / hzhz可以从配置文件调整默认 10也就是每 100ms 执行一次。如果你把hz调到 100那么serverCron的执行间隔会缩短到 10ms过期键清理、客户端超时检查等任务的响应速度会更快但代价就是主线程被占用的 CPU 会增多。serverCron干的事情很多更新服务器统计信息、通过activeExpireCycle()进行过期键主动删除、检查 RDB/AOF 持久化任务的执行状态、关闭超时空闲客户端、更新 LRU 时钟、处理收到的信号等等。可以说 Redis 里所有不依赖网络事件的后台“心跳”几乎都集中在这一个时间事件里。4.3 计时精度的边界新增时间事件不唤醒主循环时间事件这里藏着一个非常容易踩的坑如果主线程当前正阻塞在epoll_wait里此时新注册一个时间事件是不会打断这次阻塞的。举个例子假设epoll_wait的超时时间被设定为 80ms而你在第 10ms 时注册了一个 5ms 后要执行的一次性时间事件。那么对不起这个时间事件虽然在第 15ms 就该执行但主线程要到第 80ms 从epoll_wait返回后才会在processTimeEvents()里发现它到期了。实际执行时间被拖长了 65ms。这个问题在 Redis 里不算严重因为大部分需要精确触发的事情并不依赖 ae 的时间事件机制而是通过文件事件的内核通知来驱动。但在自己动手写事件驱动服务时如果业务里有“延迟任务必须准点执行”的强需求就值得注意了要么直接把等待超时设得很短要么单独开一个定时器线程用线程安全的方式唤醒主循环。这不算框架缺陷而是任何“一个循环里做等待”的方案都必须接受的取舍。5. beforeSleep事件循环“空档期”里的批量收尾5.1 为什么需要beforeSleep这个钩子事件循环每一轮都在做“等待 - 处理 - 再等待”的循环。但 Redis 有些工作不适合在事件回调里立刻做而更适合在“这一轮忙完、下一轮还没开始等”的空档统一处理。为了干这件事aeEventLoop留了一个beforesleep函数指针。名字叫 beforeSleep意思是“在进入下一次睡眠之前”。aeMain()的主循环里每次迭代先调用beforesleep(eventLoop)然后再进入aeProcessEvents()。注意这里它并不在aeProcessEvents()里面而是主循环自己调用的外部钩子。Redis 启动时在server.c里设置aeSetBeforeSleepProc(server.el, beforeSleep);这个 hook 机制本身很值得借鉴它让主循环保持纯粹又给了业务层一个可以批量处理“收尾逻辑”的时机。如果你写自己的事件驱动服务也可以预留类似的回调点比把所有逻辑都塞进事件处理函数里要干净得多。5.2 Redis在beforeSleep中做了什么Redis 的beforeSleep()干了不少正事调用handleClientsWithPendingWrites()把那些命令执行后已经写入到输出缓冲、但还没真正发送给客户端的响应尝试写出去。如果一次写不完再给对应 fd 注册写事件等下次可写时继续写。这种“延迟批量发送”有效减少了系统调用次数提升了吞吐。刷 AOF如果 AOF 开启了但策略不是每条命令都立刻刷盘那么累积在 AOF 缓冲里的数据会在beforeSleep里调用flushAppendFile()刷到磁盘。处理过期键和各类定时任务比如activeExpireCycle()会在这里尝试清理一批过期键而不是全等serverCron。更新各种时间缓存、处理模块事件包括集群、哨兵等特殊场景下的周期性动作。所以你会发现Redis 其实把“周期性的、不紧急的、适合集中处理的事情”都塞进了这个空档而不是在每个事件回调里零散地做。这个设计让命令处理的路径变得非常短——客户端请求来了执行命令写入回复缓冲马上就能继续处理下一个请求剩下发送时机由空档期去调度。说到这也可以顺带解释一个经典问题为什么删除一个超大 key 会卡顿因为DEL命令直接跑在主线程上几百万元素的集合要逐个释放内存这段耗时没有任何事件回调能绕过。命令执行完beforeSleep才运行然后才回到epoll_wait。这期间所有新请求都在内核等待队列里候着表现出来就是整个实例的响应变慢。解决办法也简单用UNLINK异步删除或者把大 key 拆小。6. 内核之外事件驱动模型给工程实践的三个启发6.1 不要在事件循环里做重活读完 ae 框架最直接的一个体会就是事件循环主线程里的一切操作都必须短平快。Redis 的命令设计本身也在向这个原则靠拢比如O(N)命令要被谨慎使用、KEYS这种全库扫描命令被警告替代为SCAN。原因不是这些命令不能实现而是它们会阻塞事件循环的正常流转。我们自己写服务时也是一样。如果事件回调里出现了耗时的计算、同步的磁盘写、加锁等待整个服务的延迟就会呈现“周期性心跳变慢”的规律。遇到这种需求正确的姿势是把重任务丢到独立线程完成后通过管道或事件通知来唤醒主线程而不是在主线程里硬扛。6.2 阻塞命令并没有“阻塞”主线程很有意思的是Redis 里像BLPOP这种看起来是“阻塞命令”的操作实际上并没有把主线程阻塞住。它的实现方式是客户端执行BLPOP时如果没有数据可取Redis 会把这个客户端标记为blocked状态然后主线程继续事件循环处理其他请求。直到数据准备就绪或者客户端超时Redis 再把这个客户端从阻塞队列里唤醒把命令重新执行一遍或返回超时结果。这本质上是事件驱动框架的优势用状态机代替线程阻塞。一个连接长时间没有数据不代表整个进程需要停下来等它。受这个启发我在自己的网络服务里也尽量避免用阻塞式等待来实现交互协议而是让每个连接变成一个带有状态的回调谁到了时间或者来了数据谁就继续推进。6.3 从ae框架到自研事件驱动骨架如果你也想写一个轻量的事件驱动服务完全可以照抄 ae 的分层思路底层抽象一个poller封装epoll_wait或kqueue对外只暴露“注册事件”和“等待事件”两个接口。中间维护一张“fd - 回调”的表事件就绪时直接查表调用。上层再加一个定时器链表每次等待事件前计算出最近到期时间作为 poll 的超时参数。这套骨架一旦跑起来你会发现非常多中间件和服务都可以往里面塞TCP 服务器、定时任务调度器、RPC 客户端的心跳管理器。这也是为什么读懂了 Redis 的 ae 层再去看其他框架会觉得似曾相识——事件循环的本质都是相似的差异只在各个业务回调里填充了什么逻辑。如果打算跟着源码走一遍我建议的顺序是ae.h看结构体定义ae.c看主循环和事件处理ae_epoll.c看多路复用封装最后回到networking.c看acceptTcpHandler和readQueryFromClient是怎么把网络请求挂到这个框架上的。看完这几个文件Redis 的高性能“发动机舱”基本就在你脑子里了。
返回列表