【Linux网络】深入理解五种IO模型与非阻塞IO:从本质到实战

【Linux网络】深入理解五种IO模型与非阻塞IO:从本质到实战
草莓熊Lotso个人主页❄️个人专栏:《C知识分享》 《Linux 入门到实践零基础也能懂》✨生活是默默的坚持毅力是永久的享受 博主简介文章目录前言一. IO 的本质等 拷贝1.1 IO 是什么1.2 为什么 IO 会慢二. 五种 IO 模型详解2.1 阻塞 IOBlocking IO2.2 非阻塞 IONon-blocking IO2.3 信号驱动 IOSignal-driven IO2.4 IO 多路复用IO Multiplexing2.5 异步 IOAsynchronous IO2.6 同步 IO vs 异步 IO核心区别2.7 一张表搞懂五种 IO 模型三. 非阻塞 IO 实战基于 fcntl 实现3.1 fcntl 函数简介3.2 实现 SetNoBlock 函数3.3 轮询读取标准输入示例3.4 代码实战演示更加详细的解读四. 其他 IO 模型代码示例了解即可想看就看看4.1 信号驱动 IO 示例4.2 异步 IOPOSIX AIO示例结尾前言做后端开发的朋友们应该都有一个共识绝大多数服务的性能瓶颈最终都会落到 IO 上。无论是网络请求的收发、磁盘文件的读写还是数据库的交互本质上都是在处理 IO 操作。很多人写代码时只会用默认的阻塞 IO遇到高并发就只会无脑加线程结果线程越开越多上下文切换的开销反而拖垮了整个系统。其实问题的根源在于没有真正理解 IO 的本质也不知道操作系统提供了哪些更高效的 IO 模型。本文将从最底层的 IO 本质讲起用大家都能听懂的 “钓鱼” 类比带你彻底搞懂 Linux 下的五种 IO 模型最后通过实战代码演示非阻塞 IO 的实现。读完这篇文章你不仅能清晰区分同步 / 异步、阻塞 / 非阻塞这些容易混淆的概念还能理解为什么 IO 多路复用是高并发服务器的基石。一. IO 的本质等 拷贝1.1 IO 是什么在讲具体的 IO 模型之前我们必须先搞清楚一个最核心的问题IO 到底是什么很多人会说 IO 就是输入输出没错但这只是表面。从操作系统的角度来看任何一次 IO 操作都可以拆分成两个不可分割的阶段等待等待数据准备就绪对于读操作是等待网卡 / 磁盘把数据传到内核缓冲区对于写操作是等待内核发送缓冲区有空闲空间拷贝将数据从内核缓冲区拷贝到用户空间读或者从用户空间拷贝到内核缓冲区写也就是说IO 的本质 等 拷贝。这是一个极其重要的结论后面所有的 IO 模型本质上都是在这两个阶段做文章。1.2 为什么 IO 会慢知道了 IO 的本质我们就能明白 IO 慢的根本原因拷贝的速度是由硬件决定的几乎无法优化而等待的时间占比才是决定 IO 效率的关键。尤其是网络 IO数据需要经过千里之外的传输、路由器的转发等待的时间往往是拷贝时间的成百上千倍。所以高效 IO 的核心目标就是在单位时间内尽可能降低等待的比重。为了让大家更好地理解我们用一个生活中最常见的例子 —— 钓鱼来类比整个 IO 过程钓鱼的人 应用进程鱼竿 文件描述符fd鱼漂 就绪事件数据是否准备好鱼 我们要读写的数据池塘 网络 / 磁盘钓鱼的过程也分为两步等鱼咬钩等待把鱼钓上来拷贝。钓鱼效率低就是因为大部分时间都在等鱼咬钩要提高钓鱼效率就要想办法减少等待的时间占比。二. 五种 IO 模型详解基于上面的 “钓鱼” 类比我们来逐一讲解 Linux 下的五种 IO 模型。这五种模型分别对应了五种不同的钓鱼方式也代表了操作系统为我们提供的五种 IO 处理策略。2.1 阻塞 IOBlocking IO对应人物张三张三钓鱼的特点是死盯着鱼漂鱼漂不动我不动鱼漂动了我才动。在鱼咬钩之前他什么事都不干就一直等着。这就是最基础的阻塞 IO 模型也是所有套接字默认的工作方式。工作流程应用进程调用recvfrom系统调用向内核发起读请求如果内核缓冲区中没有数据进程会被挂起阻塞进入等待状态当数据到达内核缓冲区后内核将数据拷贝到用户空间拷贝完成后recvfrom返回成功进程被唤醒开始处理数据阻塞 IO 的优点是简单、易理解代码写起来最省事。但缺点也很明显一个线程只能处理一个 IO 操作在高并发场景下会导致线程数量爆炸系统性能急剧下降。2.2 非阻塞 IONon-blocking IO对应人物李四李四钓鱼就聪明多了他不会死盯着鱼漂。每隔一会儿就看一眼鱼漂如果没动静就去干别的事玩手机、看书过会儿再回来检查。看到鱼漂动了就立刻把鱼钓上来。这就是非阻塞 IO 模型。工作流程应用进程调用recvfrom系统调用如果内核缓冲区没有数据系统调用不会阻塞而是立即返回EWOULDBLOCK错误码进程不会被挂起可以去执行其他任务进程需要轮询反复调用recvfrom检查数据是否就绪当数据就绪后内核将数据拷贝到用户空间recvfrom返回成功这里要特别注意一个误区非阻塞 IO 并没有提高 IO 本身的效率它只是改变了等待的方式。在钓鱼这件事上李四和张三的效率是一样的但是李四把等待的时间利用起来做了别的事提高了整体的吞吐量。非阻塞 IO 的缺点也很突出轮询会大量消耗 CPU 资源。如果没有数据进程会一直在空转这在实际生产环境中是非常浪费的。所以非阻塞 IO 一般不会单独使用而是配合 IO 多路复用一起工作。2.3 信号驱动 IOSignal-driven IO对应人物王五王五经验更老道他在鱼竿顶部挂了一个铃铛。当鱼咬钩拉动鱼线时铃铛就会响。王五不用一直盯着鱼漂也不用每隔一会儿就检查他可以放心地去干别的事听到铃铛响再过来钓鱼就行。这就是信号驱动 IO 模型。工作流程应用进程提前注册SIGIO信号的处理函数调用sigaction系统调用告诉内核当数据准备好时给我发一个SIGIO信号进程继续执行其他任务不会被阻塞当数据到达内核缓冲区后内核向进程发送SIGIO信号进程在信号处理函数中调用recvfrom将数据从内核拷贝到用户空间拷贝完成后处理数据信号驱动 IO 的优点是在数据准备阶段进程完全不需要等待CPU 利用率很高。但它有一个致命的缺点在大量并发连接的场景下信号会被频繁触发导致信号队列溢出最终丢失信号。所以信号驱动 IO 在实际开发中用得很少。2.4 IO 多路复用IO Multiplexing对应人物赵六赵六是村里的钓鱼高手他一次拉了一车鱼竿把所有鱼竿都抛到水里。然后他来回巡视这些鱼竿只要看到哪个鱼漂动了就立刻过去把那条鱼钓上来。运气好的时候他可以同时钓上来好几条鱼。这就是IO 多路复用模型也叫 IO 多路转接是目前高并发服务器中使用最广泛的 IO 模型。工作流程应用进程调用select/poll/epoll等多路复用函数内核会同时监视多个文件描述符fd的就绪状态进程会阻塞在select调用上直到有一个或多个 fd 就绪当有 fd 就绪后select返回进程调用recvfrom将数据从内核拷贝到用户空间处理数据然后继续循环调用selectIO 多路复用的核心优势在于一个线程可以同时处理成百上千个 IO 连接。它不需要为每个连接创建一个单独的线程大大减少了线程创建和上下文切换的开销。这也是为什么 Nginx、Redis 等高性能中间件都采用 IO 多路复用模型的原因。2.5 异步 IOAsynchronous IO对应人物田七田七是村里的首富他根本不想自己钓鱼。他找了个司机小王对小王说“我想吃鱼了你帮我去钓钓满一桶了给我打电话。” 然后田七就回公司开会去了小王怎么钓鱼他完全不管等小王打电话通知他他直接过来拿鱼就行。这就是异步 IO 模型是五种模型中最彻底的非阻塞 IO。工作流程应用进程调用aio_read等异步 IO 系统调用内核立即返回进程继续执行其他任务内核负责等待数据准备并且自动将数据从内核缓冲区拷贝到用户指定的缓冲区拷贝完成后内核向进程发送信号或者调用预先注册的回调函数进程直接处理已经准备好的数据异步 IO 和前面四种模型最大的区别在于前面四种都是同步 IO而异步 IO 是真正的非阻塞。2.6 同步 IO vs 异步 IO核心区别很多人容易把同步 / 异步和阻塞 / 非阻塞搞混这里我们给出最清晰的定义同步 IO应用进程需要亲自参与 IO 的任何一个阶段等待或拷贝。也就是说当数据准备好后需要应用进程自己调用read/recv来完成数据拷贝。异步 IO应用进程完全不参与 IO 的任何阶段。内核会自动完成等待和拷贝两个步骤完成后通知应用进程直接使用数据。根据这个定义阻塞 IO、非阻塞 IO、信号驱动 IO、IO 多路复用都属于同步 IO。因为它们虽然在等待阶段的方式不同但最终都需要应用进程自己来完成数据拷贝的工作。只有异步 IO才是真正意义上的异步。2.7 一张表搞懂五种 IO 模型为了方便大家对比和记忆我把五种 IO 模型的特点整理成了下面这张表IO 模型等待方式拷贝方式同步 / 异步并发能力适用场景阻塞 IO进程阻塞等待进程自己拷贝同步极低简单的、并发量小的程序非阻塞 IO进程轮询检查进程自己拷贝同步低一般不单独使用信号驱动 IO信号通知进程自己拷贝同步中并发量不大的 UDP 程序IO 多路复用内核同时监视多个 fd进程自己拷贝同步高高并发服务器Nginx、Redis异步 IO内核等待内核自动拷贝异步极高对性能要求极高的场景目前应用较少三. 非阻塞 IO 实战基于 fcntl 实现讲完了理论我们来看一下如何在代码中实现非阻塞 IO。Linux 下我们可以通过fcntl函数来修改文件描述符的属性将其设置为非阻塞模式。3.1 fcntl 函数简介fcntl函数是 Linux 下用来操作文件描述符的万能函数它的原型如下#includeunistd.h#includefcntl.hintfcntl(intfd,intcmd,.../* arg */);fd要操作的文件描述符cmd要执行的操作命令arg可选参数根据不同的 cmd 传入不同的值fcntl函数有很多功能我们这里只用到其中一种获取 / 设置文件状态标记对应的 cmd 是F_GETFL和F_SETFL。3.2 实现 SetNoBlock 函数我们可以封装一个通用的SetNoBlock函数用来将任意文件描述符设置为非阻塞模式#includeunistd.h#includefcntl.h#includestdio.hvoidSetNoBlock(intfd){// 获取当前文件描述符的状态标记intflfcntl(fd,F_GETFL);if(fl0){perror(fcntl F_GETFL failed);return;}// 在原有标记的基础上添加非阻塞标记fcntl(fd,F_SETFL,fl|O_NONBLOCK);}代码解读首先调用fcntl(fd, F_GETFL)获取文件描述符当前的状态标记这是一个位图然后使用按位或操作将O_NONBLOCK非阻塞标记添加到原有标记中最后调用fcntl(fd, F_SETFL, fl)将新的标记设置回去3.3 轮询读取标准输入示例下面我们用非阻塞 IO 来实现一个轮询读取标准输入的程序#includestdio.h#includeunistd.h#includefcntl.h#includeerrno.hvoidSetNoBlock(intfd){intflfcntl(fd,F_GETFL);if(fl0){perror(fcntl F_GETFL failed);return;}fcntl(fd,F_SETFL,fl|O_NONBLOCK);}intmain(){// 将标准输入fd0设置为非阻塞模式SetNoBlock(0);printf(请输入内容程序会非阻塞读取\n);while(1){charbuf[1024]{0};ssize_tread_sizeread(0,buf,sizeof(buf)-1);if(read_size0){// 正常读取到数据printf(读取成功%s\n,buf);}elseif(read_size0){// 对端关闭标准输入一般不会出现printf(输入结束\n);break;}else{// 出错需要区分是真错误还是数据未就绪if(errnoEWOULDBLOCK||errnoEAGAIN){// 数据未就绪这是正常情况printf(当前没有数据1秒后重试...\n);sleep(1);continue;}else{// 真的发生了错误perror(read failed);break;}}}return0;}代码关键点解读非阻塞设置SetNoBlock(0)将标准输入设置为非阻塞模式这个设置是永久有效的不需要每次调用 read 都设置read 返回值处理read_size 0正常读取到数据read_size 0表示对端已经关闭连接对于标准输入来说就是用户按下了 CtrlDread_size 0需要进一步判断errno的值如果errno是EWOULDBLOCK或EAGAIN表示当前没有数据就绪这是非阻塞 IO 下的正常情况不是错误其他值表示真的发生了 IO 错误运行效果编译运行这个程序你会看到当你没有输入任何内容时程序会每隔 1 秒打印一次 “当前没有数据1 秒后重试…”当你输入内容并按下回车后程序会立即打印 “读取成功xxx”这就是非阻塞 IO 的典型表现调用 read 不会阻塞没有数据就立即返回有数据就正常读取。3.4 代码实战演示更加详细的解读// 非阻塞 I/O 读取测试程序// 功能演示如何以非阻塞方式从标准输入读取数据// 特点当数据未就绪时不会阻塞而是执行其他任务后继续轮询#includecerrno#includecstdio#includeunistd.h#includeiostream#includefcntl.h// // 函数功能将指定文件描述符设置为非阻塞模式// 参数fd - 要设置为非阻塞的文件描述符// 说明通过 fcntl 获取并修改文件状态标志添加 O_NONBLOCK// voidSetNoBlock(intfd){// 获取当前文件状态标志intflagfcntl(fd,F_GETFL);if(flag0){perror(fcntl);return;}// 设置非阻塞标志保留原有标志添加 O_NONBLOCKfcntl(fd,F_SETFL,flag|O_NONBLOCK);}// // 函数功能执行其他任务模拟在数据未就绪时做其他工作// 说明在实际应用中这里可以处理其他 I/O 或业务逻辑// voiddoOtherThing(){std::cout0 not ready, do other thing!std::endl;}// // 主函数非阻塞读取标准输入// 流程设置非阻塞 - 循环读取 - 处理各种返回值情况// intmain(){// 定义输入缓冲区大小为 128 字节charinbuffer[128];// 将标准输入fd0设置为非阻塞模式SetNoBlock(0);// 持续轮询直到遇到 EOF 或发生错误while(true){// 将缓冲区第一个字节设为 0常量时间初始化// 即使 read 失败缓冲区也有确定状态inbuffer[0]0;// 尝试非阻塞读取最多读取 sizeof(inbuffer) - 1 字节// 预留 1 字节空间用于 null 终止符ssize_t nread(0,inbuffer,sizeof(inbuffer)-1);// -----------------------// 情况1成功读取到数据// -----------------------if(n0){// 添加字符串终止符确保可以安全打印inbuffer[n]0;std::coutinbuffer: inbufferstd::endl;}// -----------------------// 情况2读到 EOF对方关闭了写端// -----------------------elseif(n0){std::coutenter done, breakstd::endl;break;// 正常退出循环}// -----------------------// 情况3read 返回 -1出错// -----------------------else{// 1. 非阻塞模式下只能以出错形式返回// 2. 底层没有数据read返回算不算出错不算// 3. 如果返回值0,你下来关系什么因为什么原因出错的错误码更详细的标识出错原因// 4. errno 11, EAGAIN || EWOULDBLOCK 不是真正的出错只是表明fd没有就绪// --- 假错误数据未就绪 ---if(errnoEAGAIN||errnoEWOULDBLOCK){// 执行其他任务避免空轮询浪费 CPUdoOtherThing();sleep(1);// 暂停 1 秒再试降低 CPU 占用std::coutread 0, 0 fd data not ready!std::endl;continue;// 继续下一次轮询}// --- 信号中断被信号打断 ---elseif(errnoEINTR){// 系统调用被信号中断可重试continue;}// --- 真错误其他未知错误 ---else{std::coutread error: nn, errnoerrnostd::endl;break;// 发生未知错误退出循环}}// 每次成功读取后暂停 1 秒sleep(1);}return0;}四. 其他 IO 模型代码示例了解即可想看就看看为了让大家对其他 IO 模型也有一个直观的认识这里再给出两个简单的代码示例。4.1 信号驱动 IO 示例#includestdio.h#includeunistd.h#includefcntl.h#includesignal.hintfd;// SIGIO信号处理函数voidio_handler(intsig){charbuf[100];intnread(fd,buf,sizeof(buf)-1);if(n0){buf[n]\0;printf(收到输入%s,buf);}}intmain(){fdSTDIN_FILENO;// 注册SIGIO信号的处理函数signal(SIGIO,io_handler);// 设置信号的接收进程为当前进程fcntl(fd,F_SETOWN,getpid());// 开启文件描述符的异步IO模式fcntl(fd,F_SETFL,fcntl(fd,F_GETFL)|O_ASYNC);printf(请输入内容程序会在后台等待信号\n);// 主进程循环等待信号while(1){pause();}return0;}4.2 异步 IOPOSIX AIO示例注意编译时需要链接实时库librt即gcc demo.c -o demo -lrt#includeaio.h#includeunistd.h#includefcntl.h#includestdio.h#includeerrno.hintmain(){charbuf[1024]{0};// 初始化异步IO控制块structaiocbcb{.aio_fildesSTDIN_FILENO,.aio_bufbuf,.aio_nbytessizeof(buf)};// 发起异步读取请求aio_read(cb);printf(异步读取请求已发起程序继续执行...\n);// 轮询等待读取完成实际项目中一般用信号或回调while(aio_error(cb)EINPROGRESS){printf(正在读取数据...\n);sleep(1);}// 获取读取结果ssize_tread_sizeaio_return(cb);if(read_size0){printf(读取成功%s\n,buf);}else{perror(aio_read failed);}return0;}本文核心要点总结IO 的本质是等 拷贝高效 IO 的核心是降低等待的比重五种 IO 模型中IO 多路复用是目前高并发场景下的最优解也是我们需要重点掌握的前四种都是同步 IO因为都需要应用进程自己完成数据拷贝只有异步 IO 是真正的非阻塞非阻塞 IO 一般不单独使用而是配合 IO 多路复用一起工作注意区分同步 / 异步和阻塞 / 非阻塞同步异步关注的是消息通信机制阻塞非阻塞关注的是程序等待时的状态结尾 我是草莓熊 Lotso若这篇技术干货帮你打通了学习中的卡点 【关注】跟我一起深耕技术领域从基础到进阶见证每一次成长 ❤️ 【点赞】让优质内容被更多人看见让知识传递更有力量 ⭐ 【收藏】把核心知识点、实战技巧存好需要时直接查、随时用 【评论】分享你的经验或疑问比如曾踩过的技术坑一起交流避坑 ️ 【投票】用你的选择助力社区内容方向告诉大家哪个技术点最该重点拆解 技术之路难免有困惑但同行的人会让前进更有方向愿我们都能在自己专注的领域里一步步靠近心中的技术目标结语本文从 IO 的本质出发用 “钓鱼” 的类比详细讲解了 Linux 下的五种 IO 模型并通过实战代码演示了非阻塞 IO 的实现。随着技术的发展现在协程C20 coroutine逐渐成为了 IO 编程的新趋势。协程在保留同步 IO 代码简洁性的同时又能达到异步 IO 的性能是未来 IO 编程的发展方向。但无论技术怎么发展IO 的本质永远不会变理解了这五种 IO 模型你就能更好地理解和使用这些新技术。下一篇文章我会带大家深入讲解 IO 多路复用的三种实现select、poll 和 epoll分析它们的优缺点和底层原理。如果你觉得这篇文章对你有帮助欢迎点赞、收藏、关注我们下期再见✨把这些内容吃透超牛的放松下吧✨ʕ˘ᴥ˘ʔづきらど