剥洋葱式推演:一步步彻底搞懂 Redis 的 I/O 多路复用
我们要回答的核心问题是为什么 Redis 只有一个主线程却能同时服务几万个客户端且不被卡死为了搞懂这个我们必须回到一切的起点——最原始的网络通信。第一步认清物理现实网络请求分为“等”和“做”假设我们写了一段最基础的网络服务器代码只用一个单线程。当一个客户端Client A连接上来时服务端要处理它的请求必然要经历两个阶段等数据Wait for data数据从客户端通过网络电缆慢慢悠悠地传到网卡再进入操作系统的缓冲区。做事情Copy and Execute数据到齐了主线程把数据读进内存执行命令比如SET key value。痛点出现了阶段 2 的执行速度在内存中是极快微秒级的但是阶段 1 的“等”是受网络波动、用户输入速度影响的可能是几秒甚至几分钟。 如果单线程在处理 Client A 时Client A 一直不发数据这个单线程就会一直卡在阶段 1被阻塞休眠此时哪怕 Client B 带着数据来了单线程也无法去接待 Client B。这就是传统的同步阻塞 I/O (BIO)。第二步常规解法与死胡同多线程的代价单线程会被一个慢客户端卡死那最直觉的解决办法是什么多线程。来一个客户端我就为它new一个专属的线程。Client A 来分配线程 1。线程 1 去慢慢等数据。Client B 来分配线程 2。线程 2 立刻处理。看似完美但为什么 Redis 不用因为 Redis 的目标是同时处理 10 万个连接。如果开 10 万个线程内存爆炸每个线程本身就要消耗内存。CPU 瘫痪上下文切换CPU 核心数有限要在 10 万个线程之间来回切换光是“切换”的动作就把 CPU 算力耗尽了根本没时间执行真正的命令。锁竞争这么多线程同时修改内存里的数据为了保证安全必须加锁一加锁性能又暴跌。结论多线程是一条死胡同。Redis 决定死守“单线程”避免锁和上下文切换。那么单线程怎么解决被卡死的问题呢第三步思维破局非阻塞与多路复用既然单线程不能在某一个客户端上“傻等”那就必须改变规则。生活中的比喻老师单线程收 30 个学生客户端的试卷。传统模式老师站在学生 1 桌前看着他写写完收卷再去学生 2 桌前。如果学生 1 写了半小时老师就傻站半小时。这就是刚才讲的 BIO。无脑轮询模式NIO /select/poll老师不停地在教室里转圈“你写完没你写完没”转了 100 圈发现只有 1 个学生写完了。老师累得半死CPU 空转占用极高。多路复用模式I/O Multiplexing老师坐在讲台上喝茶。规定谁写完了就把手举起来触发事件。老师只要一看有人举手就直接走过去收卷。在这里多路 多个网络连接30 个学生复用 复用同一个主线程1 个老师 这就是 I/O 多路复用的本质核心用一个线程集中监听多个连接的“就绪状态”。有数据来的我才去处理没数据来的我绝不在你身上浪费时间。第四步底层的杀手锏操作系统的epoll上面比喻中的“多路复用”在 Linux 操作系统底层就是大名鼎鼎的epoll函数。单线程的 Redis 把所有连接进来的客户端 Socket全部交给了操作系统的epoll去管理。红黑树监听epoll在内核里维护了一棵红黑树里面挂着所有连接的客户端。事件驱动机制绝杀技当某一个客户端的网络数据到达网卡时网卡会通过硬件中断告诉 CPU。操作系统底层会把这个处于活跃状态的连接立刻放进一个名叫“就绪链表”的队列里。精准打击Redis 主线程只需每次去问epoll“就绪链表里有东西吗”有的话直接拿出来处理。时间复杂度从轮询的 O(N) 变成了 O(1)。第五步Redis 的最终拼图Reactor 事件模型有了epoll作为底座Redis 在自己代码里设计了一套完整的流水线这就是Reactor 模式文件事件处理器。整个流水线如下大管家多路复用程序死死盯住epoll返回的就绪链表。事件队列大管家把那些发来数据、准备好的连接排成一条长队事件队列。单线程执行器文件事件分派器Redis 那个传说中的单线程以极快的速度从队列中拿出一个个连接执行对应的命令因为是纯内存操作这步极快写回结果。然后再拿下一个。总结串联让我们把完整的逻辑链串起来 为什么 Redis 单线程能抗住高并发 因为它绝不把时间浪费在“等待网络传输”上。 它通过I/O 多路复用epoll让操作系统充当大管家代替它监控成千上万的连接。 只有当数据真的到了网卡、准备就绪时才会排成队列交给单线程处理。 单线程省去了所有的上下文切换和锁竞争以纯内存的速度执行完毕从而实现了看似违背常理的极致性能。