Redis 系列 · 第三篇

Redis 号称单线程处理请求,为什么几万个客户端同时连着也不卡?

这一篇讲的其实不是 Redis 的功能,是操作系统内核提供的能力——I/O 多路复用(select/poll/epoll/kqueue)。Redis 只是这个能力最有名的使用者之一:它靠这套机制,用极少的线程同时看着成千上万个客户端连接,谁有数据到了就处理谁,不用给每个连接单独开一个线程。这篇就"算在 Redis 上",用 Redis 的真实进程做实验。

本机真实开了 50 个并发客户端连接,全部由同一组线程处理——线程数量在开连接前后完全没变。

51 个连接
真实并发客户端数(50 个阻塞连接 + 1 个查询)
4 个线程
开连接前后,真实线程数完全不变

问题:怎么同时看住一万个连接

一个网络连接对应一个 socket,操作系统要知道"这个 socket 上有没有数据可读"才能处理它。最直接的两种做法都有明显问题。

每连接一线程

连接一多,线程就爆了

一万个连接开一万个线程,光是线程切换的开销就能把机器拖垮,内存也扛不住——这是很多老式服务器架构的真实瓶颈。

挨个轮询

问一圈,大部分都是空跑

单线程挨个问"你有数据吗?你有吗?"——一万个里可能只有一个真的有数据,剩下 9999 次询问全是浪费。

I/O 多路复用

让内核一次性告诉你答案

把所有 socket 一次性注册给内核,让内核帮你盯着;内核只在真的有 socket 就绪时才把你唤醒,而且直接告诉你是哪几个——一次系统调用,不是一万次。

真实实测:51 个连接,线程数纹丝不动

本机真实运行的 Redis 8.10.1,先看基线线程数,再开 50 个阻塞的客户端连接。

真实实测 $ ps -M <redis_pid> # 开连接之前 4 个线程 $ for i in 1..50; do redis-cli BLPOP nonexistent_key_$i 30 & done $ redis-cli INFO clients | grep connected_clients connected_clients:51 $ ps -M <redis_pid> # 开了 50 个连接之后 4 个线程 # 完全没变 $ lsof -p <redis_pid> | grep -c TCP 52 # 真实打开了 52 个 socket(50 客户端 + 监听 socket + 1)

52 个真实的 socket,由同样的 4 个线程处理,一个都没多开。这不是"因为这些连接很闲所以不需要线程"——是 Redis 压根就没打算给每个连接分配专属线程。

真实实测:精确唤醒,不是慢慢轮询

50 个连接里,只往其中一个推数据,看响应有多快。

真实实测 $ time redis-cli RPUSH nonexistent_key_25 "hello" 0.008 秒 # 客户端 25(之前一直在阻塞等待)立刻收到: nonexistent_key_25 hello

8 毫秒——如果 Redis 真的在挨个轮询这 50 个连接问"你有数据了吗",响应时间会随着连接数增长而变差;真实观察到的是几乎瞬时的唤醒,说明内核是直接把"25 号就绪了"这个信息递给了 Redis,不是靠慢慢问出来的。

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。这不是猜的——直接看编译好的二进制里链了什么符号:

真实实测 $ nm /opt/homebrew/bin/redis-server | grep kevent U _kevent # 真实引用了 macOS 的 kqueue 系统调用

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 万个连接,唤醒代价几乎一样的根本原因。

演示:挨个问 vs 内核直接告诉你

50 个 socket,只有 1 个真正就绪,对比两种发现"谁就绪了"的方式各自要付出多少代价。

选择策略
监视的 socket 数轮询需要的检查次数多路复用需要的系统调用次数
50501
5005001
5,0005,0001
50,00050,0001

轮询的代价随监视数量线性增长,多路复用的代价只取决于"这一轮到底有几个就绪"——这张表和上面的演示动画,都是对同一条真实规律(kevent() 返回值等于就绪数量,不是监视总数)的简化建模,用来直观对比两种策略的量级差异。

参考与说明

  • 本文"51 个连接线程数不变"和"8 毫秒精确唤醒"两组实验均在本机真实运行的 Redis 8.10.1 上完成,通过真实的 ps -Mlsofredis-cli INFOtime 命令得到,未做任何删改。
  • nm_kevent 符号的检查,是直接对本机真实安装的 /opt/homebrew/bin/redis-server 二进制文件做的静态分析,不是理论推测。
  • 源码引用(ae.c 的多路复用层选择逻辑、ae_kqueue.caeApiPoll)取自 redis/redis 仓库 8.10.1 标签,与本机安装的 Redis 8.10.1 完全一致。
  • 没有涉及:Linux 上 epoll 的具体实现(本机是 macOS,测的是 kqueue,两者设计细节不同但解决的是同一个问题)、Redis 6+ 引入的多线程 I/O(网络读写可以并行,但命令执行仍然是单线程,这是另一个话题)、事件循环里读事件和写事件的具体调度顺序。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电