ARTICLE DETAIL

资讯详情

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

epoll是阻塞还是非阻塞?四大I/O模型一次讲透

epoll是阻塞还是非阻塞?四大I/O模型一次讲透 有一次团队内部做技术分享我随口问了一个自认为很基础的问题“epoll 到底是阻塞的还是非阻塞的”结果好几个人脱口而出“非阻塞的呀epoll 不是专门解决阻塞 I/O 问题的吗”这个答案让我愣了几秒。细问下去才发现不少写了好几年业务代码的同学对“同步、异步、阻塞、非阻塞”这四兄弟的理解一直是糊的。有人觉得“异步非阻塞”有人觉得“同步阻塞”还有人把“多路复用”和“异步 I/O”当成一回事。但每次往深了问又说不清差别在哪里。其实这四组概念是组合关系不是等价关系。它们拼在一起构成了编程世界里最基础也最重要的四大 I/O 模型同步阻塞、同步非阻塞、异步阻塞、异步非阻塞。搞懂它们你不仅能把 BIO、NIO、Netty、epoll、io_uring 这些东西串起来日常排查高并发接口卡顿、数据库同步任务挂起、缓存刷新不及时思路也会清晰很多。这篇文章我想用自己的理解把这四个概念彻底掰开揉碎讲一遍重点放在“为什么”上为什么 epoll 不是异步 I/O为什么同步非阻塞在网络编程里很少单独用为什么真正异步 I/O 在天花板上要高一截最后会结合真实场景讲选型和一些常见坑希望能帮到正在被这些问题绕晕的读者。1. 一个面试题引发的思考你真的懂 epoll 是阻塞还是非阻塞吗先讲一个我遇到的真实案例。有次排查一个网关服务的 CPU 异常飙高问题拓扑很简单前端 Nginx 做反向代理后端是 Java 写的网关网关再往上游服务发请求。Nginx 和网关都是长连接但服务一上线CPU 就稳定在 90% 以上请求量却不高TPS 可能才几百。用top和perf一看用户态 CPU 几乎全部烧在epoll_wait返回后的循环处理里而epoll_wait本身占用很低。再看业务日志发现大量请求在等待上游响应时客户端已经断开了但网关线程还在傻傻地等结果。后来定位到的原因很简单当时那个版本的网关框架把上游调用的阻塞 I/O 包了一层线程池每个上游请求都会占用一个线程线程池被占满后新请求只能在队列里排队。表面上框架用了 epoll 做网络接入看起来是“非阻塞”但内部对上游的连接还是传统的同步阻塞模式。换句话说网络接入层和业务调用层用的是两套完全不同的 I/O 模型。这个案例特别典型因为它暴露了一个认知误区用了 epoll不意味着你的整个程序就是非阻塞的非阻塞也不等于异步异步更不等于性能一定好。要真正理解这些问题必须回到最底层先把“同步、异步、阻塞、非阻塞”这四个词的定义搞清楚。这四个词在中文互联网上被写烂了但很多文章喜欢堆术语什么“内核缓冲区”“用户态”“系统调用”讲得云里雾里。我这里换个思路先用最朴素的生活场景把概念立住再一点点往技术细节上靠。2. 先拆概念同步、异步、阻塞、非阻塞到底在说什么2.1 同步 vs 异步核心看“谁把最终结果交到你手上”先看同步和异步。这对概念的关键不在“快慢”而在结果由谁来交付、以什么方式交付。你给快递公司打了个电话客服说“包裹今天到”。如果客服让你在电话那头等他查完系统后直接告诉你“到了放在前台”——这是同步因为结果是对方当场给你的你需要在等待的过程中保持交互。如果客服说“你先挂了吧我查完让同事打给你”——这是异步。结果是后来通过另一个电话告诉你的整个过程不需要你一直保持连接。放到编程里也一样。同步模式下你发起一个调用调用方会一直等到结果出来才返回异步模式下你发起调用后立即返回真正的结果由系统在后续通过回调、信号、事件等方式通知你。这里有个容易混淆的点同步调用不一定阻塞异步调用也不一定非阻塞。比如你写了段代码用轮询的方式每隔 100 毫秒去查一次任务状态查不到就继续干别的——这是同步因为结果是你自己一遍遍去取的但这个过程不阻塞。反过来如果你注册了一个回调然后程序继续往下跑但注册回调的时候必须等内核把回调机制准备好这一步是阻塞的——这是异步但注册过程阻塞了。所以同步和异步回答的是“结果怎么拿到”的问题是自己主动去拿还是等着别人送过来。2.2 阻塞 vs 非阻塞核心看“等待时你能不能做别的事”再来看阻塞和非阻塞。这对概念的关键也不在快慢而在调用者在等待结果的过程中是不是被挂起了。还是快递那个例子。你在电话里等客服查快递中间不能挂电话不能干别的一直等到结果出来——这是阻塞。如果客服说“我这边查大概要一分钟你过会儿再来电话”你先去忙别的过一分钟再打回去问——这是非阻塞。程序里的对应关系是阻塞调用发起后当前线程会被操作系统挂起让出 CPU直到数据准备好才被唤醒非阻塞调用发起后当前线程不会挂起而是立即返回一个“数据还没准备好”的状态线程可以继续执行别的事情。所以阻塞和非阻塞回答的是“等待过程中线程能不能干别的事”的问题能干就是非阻塞不能干只能躺着等就是阻塞。2.3 两个维度组合得到的就是四大 I/O 模型把这两对概念放到同一个坐标系里就能得到四种组合维度\模型同步异步阻塞同步阻塞 I/OBIO异步阻塞 I/O多路复用非阻塞同步非阻塞 I/ONIO 轮询异步非阻塞 I/OAIO/IOCP/io_uring没错“阻塞”和“异步”是可以同时成立的这就是 select/poll/epoll 这类 I/O 多路复用模型的真实位置。很多人把 epoll 归到“非阻塞”其实并不准确epoll_wait 这个调用本身是阻塞的它只是把“等”的过程集中到了一处并且能同时等很多个 I/O 事件。这四种模型的具体细节下一节逐一说清楚。3. 四大 I/O 模型逐个拆解从 BIO 到 io_uring3.1 同步阻塞 I/OBIO最朴素也最容易理解同步阻塞 I/O 是最好理解的模型也是很多同学学网络编程时最先接触到的。代码长这样int sockfd socket(AF_INET, SOCK_STREAM, 0); bind(sockfd, ...); listen(sockfd, 128); while (1) { int connfd accept(sockfd, ...); // 处理连接read 会一直等到客户端发数据为止 read(connfd, buf, sizeof(buf)); // 处理 buf close(connfd); }这段代码里accept会阻塞当前线程直到有新连接进来read会阻塞当前线程直到客户端发来数据。整个线程在等待期间完全不干别的事就是“躺在那里等”。这种模型的优点是简单、直接每个连接一个线程逻辑清晰。缺点也致命线程数量受资源限制。假设一个线程占 1MB 栈空间一台 8GB 内存的机器最多开几千个线程再算上操作系统调度开销几千个并发连接就能把服务拖垮。早期的 Apache 和 Java 的 BIO 框架就是这么干的所以才有“C10K 问题”——一万人同时在线就直接崩了。这个模型的适用场景很明确连接数少、逻辑简单、对吞吐量要求不高的场景。比如内网工具、简易测试服务用 BIO 完全够没必要为了技术而技术。3.2 同步非阻塞 I/ONIO轮询带来的 CPU 浪费既然阻塞 I/O 的问题是“线程躺着等”那能不能让线程别躺着每次去问一句“数据好了吗”可以。这就引出了同步非阻塞 I/O。核心特点是把 socket 设置为非阻塞模式然后通过轮询的方式反复调用read每次调用如果内核缓冲区没有数据就立即返回一个EAGAIN错误码线程去做别的事过一会儿再回来问。// 伪代码示意 set_nonblocking(sockfd); while (1) { int n read(sockfd, buf, sizeof(buf)); if (n -1 errno EAGAIN) { // 数据没准备好做点别的再回来 do_something_else(); continue; } // 处理数据 }这个模型解决了线程挂起的问题一个线程可以同时管多个连接但它引入了新的问题轮询的效率极低。每次循环都要走一次系统调用从用户态切到内核态即使绝大多数读取都会得到“没数据”的答复白白浪费了 CPU 和上下文切换成本。所以单独使用同步非阻塞 I/O 的实战项目很少。它的价值在于为后面的多路复用模型打下了认知基础既然要频繁去问“数据好了吗”不如把“问”这件事集中起来一次问一批连接。3.3 I/O 多路复用select/poll/epoll异步通知与阻塞等待的组合I/O 多路复用是我最想讲透的一个模型因为多数人对它的误解最深。它做的事可以概括为一句话用一次阻塞等待替代 N 次轮询。程序把多个连接的文件描述符交给内核让内核去监视它们一旦其中任何一个有数据到达内核就通知程序。这是典型的“异步通知”机制——数据到达的事件是内核主动告诉你的。但程序在调用select或epoll_wait等待这个通知时线程仍然是阻塞的。所以它在坐标轴里的位置是“异步反射 阻塞等待”。用快递的例子类比。同步非阻塞是你每隔一分钟给快递公司打个电话问“到了吗”而 I/O 多路复用是你跟快递公司说“你别挂等包裹到了你叫我一声”然后你在电话这头等着期间不能干别的但不用一遍遍拨号了。三种典型实现里select和poll的问题很明显每次调用都要把完整文件描述符集合从用户态拷贝到内核态内核也是暴力遍历所有描述符来判断哪个就绪复杂度 O(n)。select还有最大描述符数量限制通常是 1024高并发场景根本不够用。epoll 针对这些问题做了一次大升级。它的核心是两个数据结构红黑树用于维护需要监视的文件描述符集合就绪链表用于存放已经就绪的文件描述符。新增监视用epoll_ctl操作红黑树等待事件用epoll_wait只遍历就绪链表复杂度降到 O(1)。内核通过回调机制在数据到达时直接把事件挂到就绪链表上不需要每次全量扫描。epoll 还提供了两种触发模式水平触发LT和边缘触发ET。LT 模式下只要缓冲区里有数据epoll_wait就会反复通知ET 模式下只有新数据到达的那一刻才会通知一次。ET 更高效但要求用户一次性把数据读完否则剩余数据不会再次触发通知很容易丢数据。所以我一般建议新手先老老实实用 LT深入理解了再上 ET。这个模型最大的价值是支撑起了高并发网络服务的基石。Nginx、Netty、Redis 单线程事件循环底层都是它。3.4 异步 I/OAIO/IOCP/io_uring真正把活交给内核到了异步非阻塞 I/O真正的异步 I/O模型又往前走了一步不仅是事件通知是异步的连数据拷贝都由内核帮你完成。前面三种模型里数据从内核缓冲区拷贝到用户缓冲区的过程都是用户程序自己调用read完成的这本身就是一次同步操作。而异步 I/O 的调用方式是你告诉内核“缓冲区我已经准备好了数据到了你直接帮我放进去放完告诉我一声”。整个过程中你的线程既没有阻塞等待也没有参与数据拷贝全部由内核代劳。Windows 上有 IOCPLinux 上有 libaio 和后来的 io_uring。io_uring 是近几年绕不开的话题它最直观的改进是减少了系统调用次数通过一对共享内存环提交队列 SQ 和完成队列 CQ程序把 I/O 请求写入 SQ内核处理完把结果写入 CQ双方通过内存共享完成交互大部分场景下连syscall都可以省掉。按作用位置再往下细分异步 I/O 还分磁盘 I/O 和网络 I/O。磁盘 I/O 场景里io_uring 配合 O_DIRECT 能发挥很大威力网络 I/O 场景里虽然也是异步但数据结构复杂度高、调试难度大生产环境中用真异步网络 I/O 的框架远没有多路复用那么普遍。很多号称“异步”的框架内核里用的其实还是 epoll。4. 操作系统视角一次 read() 调用到底发生了什么看到这里你可能会想为什么数据从内核拷贝到用户态就必须是同步的内核都帮我准备好了直接塞给我不行吗答案是可以但这涉及操作系统内核里非常深刻的机制设计。理解它才能真正明白“异步 I/O 为什么难”。4.1 等待数据与拷贝数据两个阶段必须分清一次完整的读操作本质上是两个阶段。第一阶段是等待数据就绪。对网络 I/O 来说就是等待数据包经过网卡、驱动、协议栈最终到达 socket 接收缓冲区对磁盘 I/O 来说就是等待数据从磁盘介质上读出来放进页缓存。第二阶段是把数据从内核空间拷贝到用户空间。同步阻塞模型的问题是两个阶段都阻塞同步非阻塞模型的问题是第一阶段不阻塞了但第二阶段仍然要自己阻塞地去拷贝多路复用模型做的是让第一阶段能同时等待大量连接但数据就绪后你要自己再发起一次read去拷贝真正的异步 I/O 才算把两个阶段都揽了过去。这个区别决定了性能和编码复杂度。真正异步 I/O 看起来最完美但它在实现上有几个绕不开的坎第一拷贝数据需要内核访问用户空间的缓冲区这要求缓冲区在 I/O 期间不能被换出物理内存于是需要“锁页”第二I/O 完成后的通知机制要设计得非常精巧既不能丢事件也不能乱序第三数据竞争和并发安全问题更复杂。这些在工程上都会转变成 API 设计的难度和调试的难度。也许这才是“异步 I/O 高性能但难落地”的根本原因Linux 花了这么多年才在 io_uring 上把异步做得比较顺手Windows 的 IOCP 倒是一开始就设计得很完整但生态限制让它没能成为全平台主流。4.2 为什么磁盘异步 I/O 比网络异步 I/O 难做很多时候有人问我为什么 Redis 的磁盘持久化明明看起来是异步的但官方文档还是建议用多个副本为什么数据库同步软件经常会出现“假死”这就要说到磁盘 I/O 和网络 I/O 的差异了。网络 I/O 的数据到达是有“事件”概念的网卡收包会触发中断协议栈会唤醒等待的进程epoll 就是利用这套中断机制做事件通知的。而磁盘 I/O 的数据读取更复杂需要经过文件系统层、块设备层、驱动层中间还有页缓存、I/O 调度算法等因素参与很难用一个简单的“事件”来概括。传统 Linux AIOlibaio有严格限制必须使用 O_DIRECT 绕过页缓存否则会退化成同步行为。这个限制让它在常规文件读写场景里很难受因为 O_DIRECT 要求用户缓冲区对齐到扇区大小业务代码还得自己管理缓存复杂度直线上升。io_uring 通过多阶段异步化设计解决了一部分问题绕过了不少传统 AIO 的雷区但同样要求你对内核版本和依赖库有足够掌控力。所以我一直有个观点不是所有系统都需要真异步 I/O。多路复用 用户态事件循环已经能解决 95% 的网络 I/O 性能问题。真异步 I/O 的用武之地更多在高性能存储、数据库内核、消息队列底层这类“寸土必争”的领域里。5. 实战选型从 Redis、Netty 到数据库同步理论说了一堆落地才是关键。我挑几个大家天天见到的组件说说它们底层分别用了什么 I/O 模型以及为什么。5.1 典型组件背后的 I/O 模型先看 Redis。Redis 6.x 之前是典型的单线程事件循环 epoll多路复用模型。注意“单线程”指的是主线程真正干活的主循环就是在一个线程里通过 epoll 同时等待 N 个连接的事件。这样设计的好处是避免了锁竞争代价是某个命令执行过慢会阻塞整个循环。所以 Redis 的官方文档反复强调“不要在服务端跑慢查询”本质原因不在 Redis 本身而在它底层的 I/O 模型。再看 Netty。Netty 是基于 Java NIO 封装的高性能网络框架底层用的也是多路复用Java 里对应SelectorLinux 上默认实现就是 epoll。Netty 引入了主从 Reactor 线程模型Boss 线程负责 accept 连接Worker 线程负责读写事件。这里没有用真正的异步 I/O因为 Java 的 NIO.2AIO在 Linux 上的底层实现并不理想Netty 作者在多年前就解释过Linux 上 epoll 的成熟度远高于 AIO刻意追求 AIO 反而会带来稳定性和调试成本。Nginx 也是同样的思路master-worker epoll用少量 worker 进程撑住海量并发。再聊聊数据库同步和数据中间件。很多人用 Canal、Debezium 这类工具做数据库同步因为热词里关联到了“数据库同步工具”“数据库同步软件”它们的核心逻辑是监听数据库的 binlog然后通过网络发送到下游。这个“监听 binlog”的动作本身是持续的流式读取网络部分的高效性直接决定了同步延迟。如果中间件的网络 I/O 模型落后比如用 BIO 线程池在大量表和频繁 DDL 的场景下binlog 一多就可能堆积。而用事件循环 多路复用的中间件单机可以扛住的同步通道数量会大很多。5.2 线程池的阻塞队列怎么选聊到线程池就绕不开“阻塞队列”这个词。很多业务系统里线程池 阻塞队列是用来平滑请求波峰的标准套路。但你是不是想过为什么叫阻塞队列它和“阻塞”这个词是什么关系阻塞队列就是线程池内部任务队列的标准实现它天然支持“队列空时让消费者线程阻塞等待队列满时让生产者线程阻塞等待”。这本身就是同步阻塞模型在并发编程里的体现——这里阻塞的是线程而不是 I/O。阻塞队列选得对不对直接影响线程池的行为。常见选项有四种。ArrayBlockingQueue有界、数组实现容量固定适合对背压有要求的场景LinkedBlockingQueue链表实现容量可设可不设默认无界适合任务量难以预估但不想丢任务的情况SynchronousQueue不存任务生产者直接把任务交给消费者没有缓冲适合“要实时处理、不要排队”的场景PriorityBlockingQueue和DelayQueue属于特殊需求比如优先级调度或延迟任务。我自己的经验是线上高并发推荐用有界队列 明确拒绝策略比如ArrayBlockingQueueCallerRunsPolicy这样系统在过载时会通过“调用者执行”进行天然反压而不是无限堆积任务导致内存爆炸。有界队列的容量也不是拍脑袋定的可以根据“线程数 × 平均处理时长 × 允许的最大排队时间”估算个量级再留 20%-30% 的余量。5.3 伪异步的坑事件循环 线程池不等于异步这里我必须多写一段因为这个坑太常见了。很多框架会说自己是“全异步的”但拆开内部实现其实就是多路复用事件循环 线程池处理业务逻辑的组合。当一个请求进来事件循环把读到的数据包装成任务交给线程池业务代码在线程池里执行。这里业务代码如果做了任何同步阻塞 I/O比如调用远程 HTTP 接口、读写数据库那么线程池的线程就会被卡住。线程池线程一旦被占满后续任务只能在队列里排队请求延迟暴涨。表面上看框架还是“异步进、异步出”但吞吐量早就被同步阻塞的业务逻辑锁死了。这就是我开头说那个网关 CPU 飙升案例的本质原因业务线程全部卡在上游请求的等待上线程池已空新任务来不及处理而不断轮询出来的事件又在疯狂触发新的线程池提交CPU 当然会被打满。所以选型时一定要看透三层东西接入层用的什么模型、业务层是否允许阻塞、线程池和队列参数的弹性有多大。这比“框架号称异步”重要一百倍。异步不是银弹它只是把“等待”转移到了一个更经济的地方。如果业务逻辑全是同步的那倒不如老老实实用同步模型 良好的资源隔离调参反而更容易。6. 高频问题与排查经验速查6.1 概念辨析速查表容易混淆的说法正解异步就是非阻塞错。异步关注“结果谁交付”非阻塞关注“等待时能否干别的”两者是不同维度epoll 是非阻塞 I/O不准确。epoll_wait 本身阻塞它属于“等待阶段异步通知 阻塞等待”模型Redis 是异步 I/O不准确。Redis 用的多路复用事件循环 单线程处理不是真异步 I/OJava NIO 就是异步 I/O不准确。Java NIO 的 Selector 是多路复用Java AIO 才是异步 I/O用了线程池就不是阻塞了错。线程池只是把阻塞分摊到多个线程上阻塞总量没有减少AIO 一定比 NIO 快不一定。具体看场景、系统调用开销和实现质量io_uring 在多数高并发场景才有明显优势6.2 我踩过的几个实用坑第一个坑读到EAGAIN就结束循环导致数据没读完。用非阻塞 socket 的时候read返回EAGAIN表示“当前没有数据”但有可能之前已经读到一部分数据了。正确处理姿势是先循环读到返回 0 或EAGAIN再根据已读数据判断是否完整而不是一遇到EAGAIN就直接丢弃本次累积的结果。第二个坑epoll ET 模式下漏读数据。ET 触发一次之后如果你没有把 socket 缓冲区里的所有数据都读完下一次数据到达时可能因为“没有新边沿”而不再触发事件客户端那边的数据就卡住了。解决方案要么在触发后循环读到EAGAIN要么干脆用 LT 模式。我用 ET 时一般会在while (read() 0)里读到返回 0 为止但要注意确实没有更多数据时及时退出否则会空转。第三个坑把阻塞 I/O 错误地放到事件循环线程里。有次写一个组件在 epoll 的事件回调里调了一个同步的网络库结果一个连接慢整个事件循环都堵住了其他连接全部超时。后来把所有网络读写的部分都迁移到工作线程池里事件循环只负责事件分发问题才消失。这个教训很值钱事件循环线程必须保持非阻塞任何可能阻塞的操作都要丢到独立线程去。6.3 顺带说下其他领域里的“同步/异步”写到这里我发现热搜词里还出现了一批看起来跟 I/O 模型毫无关系的词比如“同步 Buck 和异步 Buck”“异步 FIFO”“同步复位异步释放”。这些词里的“同步/异步”和本文的主题不是一个含义但尤为值得说清楚因为很多人会搞混。在硬件电路领域“同步 Buck”和“异步 Buck”指的是 DC-DC 降压电源里续流管用的什么器件。异步 Buck 用的是二极管续流电路简单但二极管压降导致效率偏低同步 Buck 用 MOSFET 替代二极管在轻载和重载下效率都更好但需要额外的驱动控制和死区保护避免上下管直通。这里的“同步”指的是上下两个开关管的驱动时序同步配合和软件里的“同步调用”没有关系。“异步 FIFO”在硬件设计里是指读写时钟不同域的 FIFO用来跨时钟域传输数据“同步复位异步释放”说的是复位信号作用于触发器的方式复位信号异步立即生效但释放时同步到时钟沿避免亚稳态。这些场景里的“同步/异步”都是在描述“信号之间是否存在统一的时钟关系”而不是“结果由谁交付”。搞清楚这一点你在看不同技术的文档时就不会一头雾水了。“同步/异步”是一个高度复用但含义随领域变化的概念判断它到底在说什么唯一的方法就是回到具体的上下文里看。7. 最后再分享一点个人体会把同步、异步、阻塞、非阻塞这四个概念真正吃透之后我再看任何中间件和框架都会先问三个问题它的 I/O 入口是阻塞还是非阻塞它的“结果获取”是同步还是异步用户态和内核态之间的数据拷贝发生在哪个阶段这三个问题问完一个系统的大致骨架就浮出来了。我见过太多人沉迷于“用了 epoll 就代表高性能”的执念也见过很多人谈异步色变觉得异步编程太复杂不敢碰。说实话没有哪种模型是绝对最优的只有适不适合当前场景。BIO 在小并发下开发效率最高多路复用是绝大部分高并发网络服务的最优解真异步 I/O 在特定领域有不可替代的地位但工程复杂度摆在那里。与其盲目追新不如把手头的技术栈摸透知道它底层是什么模型、瓶颈可能出现在哪里这样比什么都强。如果你看完这篇文章能清楚地回答出“epoll 是异步阻塞还是异步非阻塞”那我的目的就达到了。剩下的就是到真实的代码和故障现场里去验证这些模型到底是怎么跑起来的。
返回列表