news 2026/10/22 21:33:17

Linux服务端I/O进化史:从阻塞、Epoll到零拷贝与io_uring

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux服务端I/O进化史:从阻塞、Epoll到零拷贝与io_uring

1. 一切的起点:文件描述符与管道

聊服务端I/O演进,绕不开一个最底层的东西:文件描述符(fd)。在Linux世界里,一切皆文件,网络连接是fd、磁盘文件是fd、管道是fd、甚至某些设备也是fd。I/O操作的演进史,本质上就是“怎么把这些fd上的数据,高效地从内核搬到用户态,再从用户态搬回内核”的历史。

管道(pipe)是这条演进线上最早也最典型的一个原语。它的本质就是内核里一个环形缓冲区,一端写、一端读,数据流是单向的。我最早接触管道,还是写命令行的时候用ls | grep这种管道符,真正在代码里用pipe()系统调用,是后来写多进程通信模块才开始的。

管道有几个很有意思的特性,直接影响了后续很多设计。

第一,管道有容量上限。Linux 2.6.11 之前默认是4KB,后来改成65536字节(64KB),可以用fcntl(fd, F_SETPIPE_SZ, size)调整。为什么要有上限?因为管道是内核缓冲区,如果无限大,一个进程写数据、另一个进程不读,内存会被耗尽。这个设计思路延伸到socket缓冲区、epoll就绪队列,都是同一个道理:所有靠内核中转的数据通道,都必须有水位线。

第二,管道天然带阻塞语义。读端没有数据时,read()会卡住,直到有数据写入或者对端关闭;写端缓冲区满时,write()也会卡住。这个“卡住”其实就是阻塞I/O,它是Unix早期最朴素的I/O模型:进程发起系统调用,内核把数据准备好,然后再返回。

用生活类比的话,早期的阻塞I/O就像你去银行柜台办业务,一个柜员只服务你一个人,办完你走了,才叫下一个号。这个模型简单、正确,但有个致命问题:大量连接的时候,每个连接需要一个进程或线程去伺候。服务端动辄百万连接,跑几十万个线程,单是线程栈的内存和上下文切换开销就把CPU吃干抹净了。

所以,服务端I/O演进的第一条主线,就是解决“一个线程只能服务一个fd”的困境。这里的核心矛盾是:进程要等待I/O事件,等待期间CPU和内存资源被白白占用。

2. 阻塞到非阻塞:多路复用如何杀出重围

既然阻塞式“一对一”伺候不行,程序员们首先想到的是“轮询”:把fd全部设为非阻塞,然后用一个循环去遍历所有fd,逐个read(),有数据就处理,没数据就跳过。这套思路用生活类比就是餐厅门口没有排队系统,服务员每隔几秒跑到每个餐桌前问一句“你好了没”。它的优势是线程能复用了,一个线程可以服务所有连接;缺点是轮询本身在用户态空转,连接多的时候白白的CPU浪费极其严重,而且每次read()都是一次系统调用,进入了内核态。单机几千个连接,这种忙轮询勉强能撑,但要想再往上走,需要操作系统层面的“通知”机制。

2.1 select 和它的三个短板

Linux 内核最早给出的答案是select()。它的用法是:把一堆fd塞给内核,内核帮用户盯着,只要其中任何一个fd可读或可写,select()就返回,然后用户再遍历fd集合找出到底是哪些fd有事件。

select()有三个著名的短板,每个都让人抓狂。

第一个,fd数量限制。它用fd_set位图表示fd集合,默认大小是FD_SETSIZE,通常是1024。也就是说,一个select()最多监控1024个fd,这在动辄上万连接的场景下根本不够用。

第二个,内核态和用户态之间的拷贝。每次调用select(),都要把整个fd集合从用户态拷贝到内核态,内核检查完所有fd后再拷贝回来。fd多的时候,这几次拷贝的时间非常可观。

第三个,也是用户骂得最多的,返回后你还得自己遍历所有fd去确认哪个有事件。select()只告诉你“有事件了”,不告诉你具体是哪几个fd,所以你得把传给它的那个fd集合从头到尾再扫一遍。这个复杂度是O(n),fd数量一上去,性能瀑布式下滑。

2.2 poll 换汤不换药

poll()算是select()的改良版,它把 fd_set 位图换成了struct pollfd数组,按序存储,解除了1024个fd的上限问题,这是最大改革。但“调用时把全部fd传入内核、返回后遍历全部fd确认事件”这个结构没有根本变化,复杂度依旧是O(n)。

所以select()和poll()适合几百到几千连接的中小规模场景。我见过一些老的项目代码,用select()扛着2000来个长连接,CPU就能跑得很吃力。当连接数要过万甚至更高,这些模型基本就是炮灰了。

那个年代的通用解法是“多进程+select”,一个进程管几百个fd,进程间靠信号或管道通信。麻烦是进程多了,上下文切换开销又上来了,而且维护进程间的负载均衡很痛苦。

2.3 epoll 的降维打击

Linux 2.6 时代引入了epoll,它彻底换了一个思路:不要每次调用都把全部fd传给内核了,而是先在内核里建立一棵红黑树注册fd,再把事件挂到每个fd对应的等待队列上,内核通过中断或回调机制把就绪fd挂到一条就绪链表里。用户只需要用一个epoll_wait()去取就绪链表里的fd即可。

这就是关键点:调用的时候,内核不会再遍历每一个fd,而是直接拿着就绪链表给用户;返回后,用户只需要处理就绪链表上那些真实发生事件的fd,不需要再全量扫描所有fd。

epoll的事件复杂度降到了O(k),k是实际就绪的fd数量,而不是连接总数。在百万连接的场景下,真正活跃的连接可能只有几千个,这个差距就是天壤之别。

epoll还提供了两种触发模式,水平触发(LT,Level Triggered)和边缘触发(ET,Edge Triggered),这也是新手最容易踩坑的地方。

LT模式下,只要fd还有数据没读完,epoll_wait()就会一直返回它;ET模式下,只在状态发生变化的那一次通知你,如果没一次性读完,剩余数据得等下一个新事件到来才能触达。

我用生活类比解释一下:LT是客服服务方式“你问题没解决,我就一直催你”;ET是“你的工单状态变了,我只通知你一次,后续你得自己跟进”。ET模式效率更高,因为它减少了重复的事件通知次数,但它的代价是:你必须把fd设置成非阻塞,并且在read()或者write()时用循环尽可能一次处理完,还要小心 EAGAIN 错误。好多人第一次写ET模式,忘了一直读到EAGAIN,导致数据积压在缓冲区里,业务表现就是“连接假死,数据齐了却不处理”。

写高并发服务端,epoll几乎是绕不开的底座。但epoll也不是银弹。它的局限性在于:它仍然是“通知模型”,数据还是要靠应用主动read()/write()去拷贝。也就是说,“哪个fd就绪了”这件事,内核已经帮你解决得漂亮,但“把数据从内核拷到用户态”这个动作,仍然需要应用来发号施令。

打个比方:epoll是一个特别出色的前台,它火速告诉你“快递到了”,但快递包裹从驿站搬到你家这个过程,还是得你自己动手跑一趟。这个“跑一趟”的开销,就是下一次演进要解决的核心痛点。

3. 数据拷贝的战争:mmap 带来的新思路

把数据从内核态搬到用户态,再从用户态搬回内核态,这中间其实是两次数据拷贝。一次是从内核缓冲区拷贝到用户缓冲区,一次是从用户缓冲区拷贝到socket发送缓冲区。每次拷贝都要占CPU时间,数据量大时,这部分开销会显著吃掉吞吐量和CPU资源。

I/O演进中非常重要的一步,是用内存映射(mmap)绕过“用户态缓冲区”这个中转站。

mmap()做的事情,是把一个文件(或设备)的某个区域直接映射到进程的虚拟地址空间。映射完成后,应用读文件就像读内存一样,直接用指针访问。内核通过缺页异常机制,在应用真正访问到某个页时才把对应数据从磁盘加载到页缓存中,然后让应用的页表指向这块缓存。

这里隐含一个关键点:mmap的读写并不是真正的“无拷贝”,它把数据从磁盘读入页缓存的那一次DMA拷贝依然是存在的。但它确实省掉了传统read()路径上“页缓存 → 用户缓冲区”的那一次CPU拷贝。因为应用通过指针访问的就是页缓存本身,内核不需要再把数据倒腾一份到用户态缓冲区。

这带来的直接好处是:读大文件时,mmap比read()少一次拷贝,内存占用也更省。坏处也很明显:映射的页在内存中不一定是连续的,缺页异常在某些随机访问场景下会频繁触发,反而导致性能下降;另外,mmap映射的文件如果被另一进程截断或修改,处理不好会产生 SIGBUS,进程直接挂掉。

服务端场景里,mmap最核心的应用其实不是普通文件读写,而是共享内存通信。比如一些消息中间件,会把消息队列文件映射进内存,生产者和消费者通过读写同一块映射区来实现超低延迟通信。消息数据的传递完全在内存中完成,不需要经过几次系统调用来回搬运。

但mmap没有解决“发送数据”这个问题。服务端要把一个文件发出去,老的做法还是read()+write():先把文件数据从磁盘读进用户态缓冲区,再把这个缓冲区的内容写到socket缓冲区。两次拷贝加两次系统调用,数据绕着CPU走了很多弯路。

如果你写文件传输服务时用read()+write()去发送几十MB甚至上百MB的文件,测一下top里的CPU占用,很大一部分消耗就来自这些拷贝操作。我当时试着用传统方式传100MB文件,服务端CPU占用率能冲到60%以上,换成零拷贝后直接降到10%左右,直观感受非常强烈。

4. 零拷贝的入场:DMA、sendfile 与 splice 的接力

零拷贝不是一个单一的系统调用,而是一类技术的统称,它们的共同目标是:尽可能减少数据在内核和用户态之间的拷贝次数,最好一次都不要进用户态。

4.1 从原理上认识DMA

要理解零拷贝,先得搞懂DMA(Direct Memory Access,直接内存访问)。在没有DMA的年代,磁盘数据到内存的搬运需要CPU全程参与,CPU别的事都干不了。DMA控制器出现后,CPU只需要发一个“从磁盘读数据到内存地址X”的指令,然后就可以去忙别的,DMA控制器自己完成搬运,完成后再发起一个中断通知CPU。

DMA就把“搬运数据”这个苦力活从CPU身上卸掉了。有了DMA,零拷贝才能成立:因为拷贝不一定都要让CPU亲手干,有的是DMA来干。

4.2 sendfile 和它的两代实现

sendfile()系统的设计初衷是:把一个文件fd的内容直接发送到socket fd,全程不需要进入用户态。第一版实现里,数据路径是这样的:

磁盘 → DMA拷贝到页缓存 → CPU拷贝到socket发送缓冲区 → DMA拷贝到网卡

一次sendfile()调用,只有一次CPU拷贝,比read()+write()少了一次CPU拷贝和两次系统调用,效率已经大幅提升。但还有一次CPU拷贝,即“页缓存 → socket缓冲区”那一下。

Linux 2.4 之后,网卡驱动开始支持 SG-DMA(Scatter-Gather DMA),sendfile()可以做到真正的零CPU拷贝了。原理是:内核无需把页缓存里的数据复制到socket缓冲区,而只需要把“数据在页缓存中的位置和长度”这一组描述信息(即DMA描述符)传递给网卡。网卡DMA控制器直接根据这些描述符,从页缓存里把数据拖走发出去。

连线图是这样的:

磁盘 → DMA拷贝到页缓存 → 网卡DMA直接读取页缓存数据发送

从CPU视角看,整个过程完全没有经手数据,页缓存里的数据从磁盘DMA进来,被网卡DMA拖走,CPU仅做了元数据传递。这才是真正意义上的零拷贝。

不过sendfile()有一个限制:它适合“文件→socket”这种单向、无加工的数据发送场景。如果你想在发送过程中加个HTTP头,或者对数据做压缩、加密,那就不能直接用sendfile()了。这种情况还是得让一部分数据经过用户态,做加工后再发送。实际项目中典型的做法是:头部用小数据走普通write(),文件主体走sendfile()。

4.3 splice:让数据在内核里流动

splice()是sendfile()思路的进一步泛化。sendfile()只能文件到socket,splice()则可以在任意两个fd之间搬运数据,且不需要在用户态绕一圈。它的核心机制是在内核里建立一个管道,作为两个fd之间的中转。

具体来说,splice()调用里有个参数是两个fd,中间其实隐式用了一个pipe作为传输通道。源fd里的数据通过DMA或者页缓存引用直接挂到管道缓冲区上,目标fd再从管道缓冲区里取走,整个过程数据始终在内核态流转。比如把socket收到的数据直接splice()到一个文件fd,就成了一个不用read-write的代理转发。

sendfile()在Linux 2.4后基本上可以视为splice()的特例,这也解释了为什么很多内核版本里sendfile()的实现底层就是调用splice()。

用这套机制的时候,有几个实际经验:

  • splice()要求两个fd中至少有一个是管道fd。这不是bug,是设计如此。所以如果你想在两个socket fd之间用splice()直接转发,内核会拒绝,必须先splice到管道,再从管道splice到目标socket。
  • 当数据吞吐不是瓶颈而是延迟敏感时,splice()的收益没有想象中高。因为管道本身会带来额外的调度和同步成本。
  • 零拷贝不等于零系统调用,它只是减少了数据拷贝的CPU开销。系统调用次数少了,但调用本身依然有成本。

4.4 mmap + write mk组合拳

如果sendfile()不适用(比如数据需要加工),但你想减少拷贝,还有个经典组合:mmap()文件 +write()到socket。

流程是:先把文件映射到进程地址空间,然后直接write()这块映射区到socket。和传统read()+write()相比,省掉了从页缓存拷到用户缓冲区的那一次拷贝。缺点是mmap区域被锁进页表,映射大文件时会占用不少虚拟内存地址空间;高并发场景下频繁mmap/unmmap还会带来TLB抖动。

实际项目里如果要做一个轻量级HTTP静态文件服务,用mmap+write或直接sendfile都能拿到不错的性能,选哪个主要看你对数据有没有处理需求。

5. 现代异步I/O:io_uring 与它要解决的问题

epoll+read()/write()这套组合统治了高并发服务端十几年,但它依然有令人遗憾的低效之处。业界开始反思:为什么一个网络I/O要经历那么多次系统调用?为什么每次read()/write()都要用户态和内核态来回切换?能不能让应用把一批I/O请求批量提交给内核,然后内核批量完成、批量通知?

5.1 经典AIO为什么流行不起来

Linux不是没有AIO,早期的POSIX AIO和Linux AIO(io_submit()/io_getevents())都存在,但发展始终不温不火。原因很多:AIO对文件I/O支持还可以,对网络I/O支持极差;io_getevents()轮询事件完成的机制在高并发下并不高效;另外,很多AIO实现里,数据还是要经过用户态缓冲区的拷贝,“异步”只是“异步发起”,拷贝开销依然在。

可以说,经典的AIO就是个没完全长成的半成品,始终没能成为主流。服务端这块还是靠epoll扛着。

5.2 io_uring 的设计思想

io_uring 是近年Linux I/O领域最令人兴奋的变革。它的核心设计哲学是:把“提交”和“完成”这两个环节的共享数据结构放到一块内核与应用共享的内存区域里,借此将每次I/O操作的系统调用开销大幅压缩下来。

具体工作方式是这样的。应用把要执行的I/O操作(比如“读这个fd的1024字节”)包装成一个 Submission Queue Entry(SQE),写进一块与内核共享的内存环形队列,然后通过io_uring_enter()一次性提交多个SQE。内核完成这些操作后,把结果写到 Completion Queue Entry(CQE)上,应用在用户态轮询这块共享内存里的CQE,获得完成状态。整个过程中,一个系统调用可以提交一批请求,完成通知也无需每次中断或系统调用。

它的强大之处还不止于网络I/O。io_uring 支持文件读写、网络读写、sendfile语义、超时定时器、多路等待等操作,基本上是把传统分散在多个系统调用里的能力统一到了一个异步框架里。写个简单的io_uring版echo服务,代码量比epoll版本多不了多少,但吞吐在同等条件下往往能比epoll高出百分之三四十。

io_uring 也不是毫无代价。它要求使用它的程序必须精心设计内存池和环形缓冲区的管理,否则共享内存的并发访问会成为新的瓶颈。另外,在某些云环境或老版本内核上,io_uring 的可用性受限。如果你在做用户态网络库或者服务端框架,io_uring 值得押注,但要记得做好内核版本兼容策略。

这里有个大坑要提醒:io_uring 的IORING_SETUP_SQPOLL模式,虽然能进一步减少系统调用(内核里有个内核线程替应用轮询SQ,应用连io_uring_enter()都可以不常调),但这个模式下CPU占用和内核线程调度问题也不容小觑。生产环境启用前必须做足压力和故障演练,别指望拿来就能跑得飞起。

5.3 用户态协议栈和RDMA,另一条路线

I/O演进也不光在内核里发生。以 DPDK 为代表的用户态协议栈,选择直接绕过内核网络协议栈,把网卡收包后的数据通过轮询模式在用户态处理。这样做的好处是彻底摆脱内核协议栈的锁竞争和拷贝路径,性能极高;坏处是你得自己维护TCP/IP协议栈的状态,开发难度和工作量陡增。

还有 RDMA(远程直接内存访问)技术,允许网卡直接把数据从一台机器的内存搬到另一台机器的内存,CPU完全不参与数据路径,延迟能到微秒级。它主要用在HPC(高性能计算)和分布式存储场景,属于特殊硬件支持下才能玩的方案。对大多数做互联网后端的人来说,内核的sendfile()和 io_uring 已经是性价比最高的收益点。

6. 从一条时间线看服务端I/O选型

讲了这么多,我把整个演进路径整理成一条稍微清晰的时间线和一张选型对照表,大家在设计自己的服务端架构时可以直接参考。

I/O演进大致可以分成这样几个阶段:

  • 第一阶段:阻塞I/O + 多线程/多进程。简单但资源消耗大,适合连接数中小且每个连接都长时间活跃的场景(比如少量长连接、传统数据库客户端场景)。
  • 第二阶段:非阻塞I/O +select()/poll()。线程复用,但事件扫描是O(n),适合几百到几千连接。
  • 第三阶段:epoll为代表的I/O多路复用。就绪事件是O(k),支撑万级到百万级连接。这是当前绝大多数服务端的主流底座。
  • 第四阶段:零拷贝技术(mmap、sendfile()、splice())。降低大块数据传输时的CPU拷贝负担,文件传输、静态资源服务、代理转发场景收益明显。
  • 第五阶段:io_uring为代表的现代异步I/O。把提交和完成做成共享内存环形队列,减少系统调用,统一文件/网络异步模型,是面向未来的新一代基座。

这几者并不是各自的替代关系。即使你要用io_uring,内核里对大文件发送依然可以用sendfile()语义高效完成。epoll在可预见的未来也还会长期存在,因为大量存量代码和依赖它的库都很难快速迁移。

下面是选型经验小结,按我实际试错得出的体会排的:

  • 长连接多、单连接流量小(比如IM在线推送):epoll+ 非阻塞 + 用户态缓冲区管理是稳如老狗的选择,io_uring天花板更高但迁移成本也高。
  • 连接数不多、数据传输量大(比如网关代理、文件服务):在epoll基础上配合sendfile(),效果立竿见影。
  • 需要大量磁盘I/O(比如日志收集、AI推理场景的数据预处理):io_uring 的黑马优势明显,异步读写文件与读写网络统一在一个框架里,程序结构能简洁很多。
  • 超低延迟、微秒级需求(比如高频交易、分布式存储):RDMA或用户态协议栈是另一套玩法,但硬件和研发成本都要算清楚。

我自己的实践体悟是:I/O模型的选型不是越新越好,而是看你的业务瓶颈到底在哪里。连接多则先上多路复用,数据大再上零拷贝,系统调用太频繁了再考虑io_uring。绝不要一开始就把全部筹码压在最激进的方案上,要知道,工程上最可怕的不是性能不够,而是模型太复杂导致没法稳定落地和排查问题。

7. 实操经验里的那些坑与心得

7.1 从 select 迁移到 epoll 的三条铁律

很多老代码从select()迁到epoll会踩相同的坑,我列出最常见的三点。

  • 不能用epoll_wait()返回的数组里的所有fd都做读写,这些fd里混着出错的和被对端关闭的,需要先用EPOLLERR/EPOLLHUP/EPOLLRDHUP事件过滤。
  • 删除fd后不要再操作它。epoll_ctl(fd, EPOLL_CTL_DEL, ...)之后,这个fd可能已被复用,旧事件回调却还挂着,导致错乱。最稳妥的做法是在fd关闭前先从epoll里摘除。
  • LT模式下也要控制好每次读取量。如果一次读不完,内核会持续通知,忙碌场景下反而会触发多次epoll_wait()。通常配合一个用户态缓冲区和水位线来限制读入量。

7.2 用了零拷贝不等于万事大吉

sendfile()虽然减少了CPU拷贝,但它不是免死金牌。在小文件传输时,sendfile()的优势极小,因为大部分开销其实在系统调用本身和网络协议栈上,而不是拷贝上。我遇到过一个项目,把几KB一个小文件的HTTP响应全部改成sendfile(),性能几乎没有变化,反而因为sendfile()对文件权限和fd类型有额外校验,代码还多出几个错误分支。

反过来,大文件场景用read()+write()是最容易被打脸的写法,100MB以上文件传完,CPU时间基本都在拷贝上。正确做法是,文件大于某个阈值(比如64KB)就走sendfile()或mmap,小数据走普通读写,这二者配合能拿到一个比较稳定的收益。

7.3 io_uring 日志里最常见的错误

我最早写 io_uring 的 echo 服务时,连踩了好几个坑。其中一个特别隐蔽:io_uring_prep_read()使用的缓冲区地址必须保证在I/O操作完成之前一直有效。因为io_uring是异步的,你提交这个读请求后,函数就返回了,如果这个缓冲区是栈上的局部变量,栈帧销毁后内存被复用,等到CQE返回时,数据可能已经被写到别的地方去了。这简直是隐蔽的内存破坏型bug。用io_uring必须自己管理一套稳定的内存池,提前分配好固定数量的buffer,循环复用,不能随手在栈上开数组往SQE里塞。

还有,用IORING_OP_ACCEPT时,accept的fd是直接放到CQE里的,但如果你一次提交了多个accept请求,要注意同一个listen fd的并发accept行为在内核里是串行处理的,不会出现多个请求同时accept到同一个连接的问题,但顺序是乱的,程序逻辑不要依赖accept顺序。

7.4 管道在服务端设计里的隐藏用法

管道虽然看起来是“老古董”,它在服务端设计里依然有用武之地。比如实现简单的流量控制,你可以用管道的容量天然限制生产者写入的速率;在进程间传递fd,用sendmsg()配合SCM_RIGHTS也是经典用法;splice()甚至可以把管道和socket连接起来,实现几乎无拷贝的数据转发。

我自己的体会是:管道、epoll、sendfile、io_uring,这些原语不是被时间淘汰的新旧关系,而是每一代设计都在延续前面一代的思路,解决同一组问题的不同侧面。理解一个原语最好的方式,是弄清楚它被发明出来之前那个时代里的痛点,以及它之后的人在这条路上又前进了哪一步。

8. 一个串联全篇的最小实践

纸上谈兵讲了这么多,最后分享一个我实际写过的最小实践,用很短的一段代码把epoll+sendfile()+ 非阻塞I/O串联起来,做一个静态文件传输的服务端核心。这个版本没有做线程池、没有做协议解析,只是为了看I/O全链路是怎么配合的。

代码逻辑大体是这样:

// 伪代码,重点看I/O模型的配合 int epfd = epoll_create(1024); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); struct epoll_event events[1024]; while (1) { int n = epoll_wait(epfd, events, 1024, -1); for (int i = 0; i < n; i++) { int fd = events[i].data.fd; if (fd == listen_fd) { int conn_fd = accept(listen_fd, NULL, NULL); set_nonblocking(conn_fd); ev.events = EPOLLIN | EPOLLRDHUP; ev.data.fd = conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev); } else if (events[i].events & EPOLLIN) { read_request(fd, request_buf); if (parse_request(request_buf) == REQ_FILE) { // 重点:文件内容直接用 sendfile 发送 int file_fd = open(request_path, O_RDONLY); off_t offset = 0; sendfile(fd, file_fd, &offset, file_size); close(file_fd); } } else if (events[i].events & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) { close(fd); } } }

这段代码在实际跑的时候有几个值得观察的现象:

第一,单个连接的吞吐明显比read()+write()版本高,尤其是大文件传输时,CPU占用率低了很多。第二,因为sendfile()是同步发送,在一个连接连续发送大文件时,这个worker其实是阻塞在sendfile()调用里的。如果要同时处理多个大文件下载,高负载下行为会退化,因为一个sendfile()没传完,其它连接的事件就得不到响应。所以生产级实现要么用线程池摊开,要么把发送改成基于写就绪事件的异步发送(针对大文件还要配合splice()分块)。

我当时测试的结果是:单线程、1GB文件、本机回环地址,read()+write()版本耗时大约6秒多,sendfile()版本大约3秒多,CPU占用从接近80%降到20%上下。当然,真实的网络环境下瓶颈会跑到网卡和TCP协议栈上,差距不会这么夸张,但服务端压力是肉眼可见地降下来了。

这个最小实践的价值在于:把 epoll、非阻塞I/O、零拷贝这条演进主线全部跑通,你再去读成熟框架的源码,看到那些复杂的内存池和事件状态机,就不容易看晕了,因为它们都是为了解决这个最小模型在真实场景里的工程缺陷而层层加码的。

我个人在实际排障和重构中的体验是,Linux的I/O体系就像一座搭了很多年的城市:管道是早年修的小巷子,epoll是后来建的主干道,sendfile和零拷贝是高架桥,io_uring则是一条正在全线施工的地铁。每条路都有它服务的人群和场景,服务端程序员要做的就是搞清楚自己的业务流量在哪条路上跑,然后选择最匹配的那条路,同时留好未来切换的余地。不要被术语吓退,也不要被新名词牵着走,把每一个原语背后的“为什么”想明白,写起高并发服务来心里会踏实很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 21:31:52

AI进课堂慢落地:技术牌与内容牌的先后顺序

1. 先搞清楚“慢落地”到底卡在哪 “慢落地”这个词最近在圈子里被反复提起&#xff0c;说的不是技术跑不动&#xff0c;而是AI进课堂这件事&#xff0c;从演示到日常&#xff0c;中间隔着一道看不见的墙。我过去两年参与过三个不同学段的智能化教学辅助项目&#xff0c;从小学…

作者头像 李华
网站建设 2026/10/22 21:31:48

健身房管理系统从课设到毕设:核心业务闭环与并发避坑指南

简介&#xff1a;这份健身房管理系统项目源码包面向计算机相关专业的毕业设计、课程设计及大作业场景&#xff0c;尤其适合人工智能、计算机科学与技术等方向的学生使用。系统围绕健身房日常运营设计&#xff0c;覆盖会员、前台管理员、教练与管理者等多角色&#xff0c;包含会…

作者头像 李华
网站建设 2026/10/22 21:28:01

基于Python与CNN的中草药识别实战:从数据到模型

1. 项目拆解与整体设计思路1.1 这个题目到底在做什么“基于Python-CNN机器学习深度学习的常见中草药识别”这种课程设计题目&#xff0c;最近几年在各大高校里出现的频率越来越高。我先用大白话把题目拆开&#xff1a;你要做的不是“背诵一堆草药名字”&#xff0c;而是让计算机…

作者头像 李华
网站建设 2026/10/22 21:26:33

大模型训练提速:ops-math算子库的融合、布局、调度与精度四板斧

前些日子帮一个团队排查训练慢的问题。显存占用接近100%&#xff0c;可一个训练步走下去&#xff0c;算力核心的有效利用率却只有三成出头。这情况不算罕见&#xff1a;模型越来越大&#xff0c;可真正拖后腿的往往不是模型结构本身&#xff0c;而是藏在计算图里那些细碎小算子…

作者头像 李华
网站建设 2026/10/22 21:22:16

1250亿MoE在RTX 3090上跑到100+ tok/s:全链路推理优化实录

第一次看到这个需求描述的时候&#xff0c;我愣了几秒&#xff1a;1250亿参数的稀疏MoE模型&#xff0c;跑在一块24GB显存的RTX 3090上&#xff0c;还要到100 tok/s&#xff0c;旁边配的双路Xeon v4怎么看都像五年前的老古董。正常想法是这需求要么抄错了参数&#xff0c;要么打…

作者头像 李华
网站建设 2026/10/22 21:22:16

三层交换机VLAN间通信配置实战:SVI与路由开启详解

1. 为什么还要折腾三层交换机做VLAN间通信 刚入行那会儿&#xff0c;我对"三层交换机"这个概念一直有点模糊。二层交换机负责同一个网段内的数据转发&#xff0c;路由器负责不同网段之间的路由&#xff0c;那三层交换机到底站在哪个位置&#xff1f;后来在实际项目里…

作者头像 李华