这一篇讲的其实不是 Redis 的功能,是操作系统内核提供的能力——I/O 多路复用(select/poll/epoll/kqueue)。Redis 只是这个能力最有名的使用者之一:它靠这套机制,用极少的线程同时看着成千上万个客户端连接,谁有数据到了就处理谁,不用给每个连接单独开一个线程。这篇就"算在 Redis 上",用 Redis 的真实进程做实验。
本机真实开了 50 个并发客户端连接,全部由同一组线程处理——线程数量在开连接前后完全没变。
一个网络连接对应一个 socket,操作系统要知道"这个 socket 上有没有数据可读"才能处理它。最直接的两种做法都有明显问题。
一万个连接开一万个线程,光是线程切换的开销就能把机器拖垮,内存也扛不住——这是很多老式服务器架构的真实瓶颈。
单线程挨个问"你有数据吗?你有吗?"——一万个里可能只有一个真的有数据,剩下 9999 次询问全是浪费。
把所有 socket 一次性注册给内核,让内核帮你盯着;内核只在真的有 socket 就绪时才把你唤醒,而且直接告诉你是哪几个——一次系统调用,不是一万次。
本机真实运行的 Redis 8.10.1,先看基线线程数,再开 50 个阻塞的客户端连接。
52 个真实的 socket,由同样的 4 个线程处理,一个都没多开。这不是"因为这些连接很闲所以不需要线程"——是 Redis 压根就没打算给每个连接分配专属线程。
50 个连接里,只往其中一个推数据,看响应有多快。
8 毫秒——如果 Redis 真的在挨个轮询这 50 个连接问"你有数据了吗",响应时间会随着连接数增长而变差;真实观察到的是几乎瞬时的唤醒,说明内核是直接把"25 号就绪了"这个信息递给了 Redis,不是靠慢慢问出来的。
select、poll、epoll(Linux)、kqueue(macOS/BSD)、evport(Solaris)是同一类机制在不同操作系统上的具体实现,性能和设计不完全一样。Redis 自己的事件循环层会按平台自动选择最优的一种。
src/ae.c · redis @ 8.10.1, L30 /* Include the best multiplexing layer supported by this system. * The following should be ordered by performances, descending. */ #ifdef HAVE_EVPORT #include "ae_evport.c" #else #ifdef HAVE_EPOLL #include "ae_epoll.c" #else #ifdef HAVE_KQUEUE #include "ae_kqueue.c" #else #include "ae_select.c" #endif #endif #endif
本机是 macOS,没有 epoll(Linux 专属)也没有 evport(Solaris 专属),所以真实链的是 ae_kqueue.c。这不是猜的——直接看编译好的二进制里链了什么符号:
kevent 是 kqueue 机制唯一的入口函数——本机这个真实的 redis-server 二进制文件里确确实实引用了它。真实的等待逻辑就是一次 kevent() 调用:
src/ae_kqueue.c · redis @ 8.10.1, L124 static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) { ... retval = kevent(state->kqfd, NULL, 0, state->events, eventLoop->setsize, &timeout); ... /* retval 就是这次真正就绪的 fd 数量,内核已经帮你筛好了 */ }
一次 kevent() 调用,不管注册了多少个 socket,内核只把真正就绪的那几个塞进 state->events 数组里返回——这就是 50 个连接和 5 万个连接,唤醒代价几乎一样的根本原因。
50 个 socket,只有 1 个真正就绪,对比两种发现"谁就绪了"的方式各自要付出多少代价。
| 监视的 socket 数 | 轮询需要的检查次数 | 多路复用需要的系统调用次数 |
|---|---|---|
| 50 | 50 | 1 |
| 500 | 500 | 1 |
| 5,000 | 5,000 | 1 |
| 50,000 | 50,000 | 1 |
轮询的代价随监视数量线性增长,多路复用的代价只取决于"这一轮到底有几个就绪"——这张表和上面的演示动画,都是对同一条真实规律(kevent() 返回值等于就绪数量,不是监视总数)的简化建模,用来直观对比两种策略的量级差异。