首页 / 资讯中心 / 文章详情

嵌入式Linux I/O多路复用:select、poll与epoll实战选型指南

嵌入式Linux I/O多路复用:select、poll与epoll实战选型指南 ★ FEATURED ARTICLE
1. 从阻塞到多路复用为什么文件IO编程绕不开select和epoll做嵌入式Linux应用开发的朋友大概率都经历过这样一个阶段刚开始写文件IOopen、read、write、close四个函数走天下觉得一切都很美好。等到项目里需要同时处理多个输入源——比如一个串口要收传感器数据、一个网络socket要等上位机指令、还要监听按键事件——代码就开始变得别扭了。最直觉的做法是开线程一个线程管一路IO但嵌入式设备的资源往往很紧张线程多了内存吃不消上下文切换的开销也不划算。这时候I/O多路复用就成了必须掌握的基本功。I/O多路复用的核心思想其实很朴素让一个线程同时监视多个文件描述符当其中任意一个或多个描述符变为“就绪”状态可读、可写或出现异常时内核通知应用程序应用程序再去处理对应的IO操作。这样就不需要为每个描述符单独开线程或进程一个主循环就能把多路IO管理得井井有条。在嵌入式Linux环境下这个机制尤其重要因为嵌入式系统通常CPU核数少、内存有限能省一个线程就省一份资源。Linux下实现I/O多路复用主要有三种系统调用select、poll和epoll。它们解决的是同一个问题但设计思路和适用场景差别很大。select是最早出现的几乎所有Unix-like系统都支持可移植性最好但有几个硬伤文件描述符数量有限制通常是1024、每次调用都要把描述符集合从用户空间拷贝到内核空间、返回后还需要遍历整个集合才能知道哪些描述符就绪。poll用数组替代了位图解决了数量限制的问题但拷贝和遍历的问题依然存在。epoll是Linux特有的通过内核事件表机制把描述符的注册和等待分离避免了重复拷贝返回时直接给出就绪的描述符列表在大量描述符的场景下性能优势非常明显。这篇文章我会从实际嵌入式项目开发的角度出发把select、poll、epoll三种机制的用法、区别、选型思路和踩坑经验都梳理一遍。不管你是刚接触嵌入式Linux的新手还是已经用过这些接口但没深究过原理的老手应该都能从中找到对自己有用的内容。文章会涉及具体的代码示例、参数配置、性能对比以及实际调试中遇到的问题尽量做到“看完就能用用了不踩坑”。2. select的工作机制与嵌入式场景下的实操细节2.1 select的函数原型与参数含义select的函数原型看起来有点吓人但拆开看其实不复杂#include sys/select.h int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);nfds是所有被监视的文件描述符中最大值加一。这个参数容易搞错很多人以为是要监视的描述符个数其实不是。比如你监视的描述符是3、5、8那nfds应该是9最大值8加1。内核会根据这个值来决定遍历的范围传大了浪费效率传小了会漏掉描述符。readfds、writefds、exceptfds分别是要监视可读、可写和异常条件的描述符集合。这三个参数都是值-结果参数调用前你设置好要监视哪些描述符调用返回后内核会把不就绪的描述符从集合中清除留下的就是就绪的。这意味着每次调用select之前你都必须重新设置这三个集合因为它们已经被上一次调用修改了。timeout控制阻塞行为。传NULL表示永久阻塞直到有描述符就绪传一个时间结构体表示等待指定时间传一个全零的时间结构体表示非阻塞立即返回。在嵌入式主循环里通常会设置一个合理的超时时间比如100毫秒或500毫秒这样即使没有IO事件主循环也能定期执行一些其他任务比如状态机轮询、看门狗喂狗等。fd_set是一个位图结构Linux下通常是1024位对应文件描述符0到1023。操作这个集合需要用专门的宏FD_ZERO(fd_set *set)清空集合FD_SET(int fd, fd_set *set)把描述符加入集合FD_CLR(int fd, fd_set *set)从集合中移除描述符FD_ISSET(int fd, fd_set *set)判断描述符是否在集合中通常用于返回后判断是否就绪2.2 一个完整的select使用模板下面这个模板是我在嵌入式项目里常用的结构处理串口和网络socket两路输入#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include sys/select.h #include sys/socket.h #include sys/types.h #define SERIAL_FD 3 #define SOCKET_FD 5 #define BUF_SIZE 256 int main(void) { fd_set readfds; struct timeval tv; int maxfd (SERIAL_FD SOCKET_FD ? SERIAL_FD : SOCKET_FD) 1; char buf[BUF_SIZE]; int ret; while (1) { FD_ZERO(readfds); FD_SET(SERIAL_FD, readfds); FD_SET(SOCKET_FD, readfds); tv.tv_sec 0; tv.tv_usec 200000; /* 200ms超时 */ ret select(maxfd, readfds, NULL, NULL, tv); if (ret 0) { if (errno EINTR) { continue; /* 被信号中断重新调用 */ } perror(select); break; } else if (ret 0) { /* 超时执行周期性任务 */ continue; } if (FD_ISSET(SERIAL_FD, readfds)) { int n read(SERIAL_FD, buf, sizeof(buf)); if (n 0) { /* 处理串口数据 */ } } if (FD_ISSET(SOCKET_FD, readfds)) { int n read(SOCKET_FD, buf, sizeof(buf)); if (n 0) { /* 处理网络数据 */ } } } return 0; }这个模板有几个关键点值得注意。maxfd的计算必须在每次循环外算好因为描述符集合不变的话它就不变。超时时间设为200毫秒这样主循环每秒至少执行5次可以兼顾响应速度和周期性任务。EINTR的处理也很重要嵌入式系统里信号比较多select被信号中断后应该重新调用而不是直接退出。2.3 select在嵌入式开发中的典型坑第一个坑是描述符集合的重置。我见过不少初学者写出这样的代码在循环外面FD_SET一次循环里面直接调select。结果第一次调用之后集合就被内核修改了第二次调用时集合里可能什么都不剩select直接超时返回。正确的做法是每次调用select之前都重新FD_ZERO和FD_SET。第二个坑是nfds传错。有人觉得传1024最保险反正内核会遍历所有位。确实功能上没问题但每次调用内核都要检查1024个位即使你只监视了3个描述符效率白白浪费。在嵌入式设备上CPU本来就弱这种浪费能避免就避免。第三个坑是select返回后没有检查FD_ISSET就直接read。select返回大于0只说明有描述符就绪但具体是哪个需要你自己用FD_ISSET判断。如果对没有就绪的描述符调用read在阻塞模式下会导致程序卡住在非阻塞模式下会返回EAGAIN错误。第四个坑是文件描述符超过1023。select的fd_set是固定大小的位图Linux下默认是1024位。如果你的程序打开了大量文件或socket描述符超过1023FD_SET就会越界行为不可预测。嵌入式系统虽然通常不会开这么多描述符但如果用了某些第三方库或者动态加载模块描述符数量可能超出预期。这种情况下就应该考虑poll或epoll。提示在嵌入式开发中select的超时时间不要设得太短否则主循环空转浪费CPU也不要设得太长否则IO响应不及时。一般100ms到500ms是比较合理的范围具体要根据系统对响应时间的要求来调整。3. poll的改进思路与代码迁移要点3.1 poll如何解决select的数量限制poll的函数原型比select简洁一些#include poll.h int poll(struct pollfd *fds, nfds_t nfds, int timeout);第一个参数是一个pollfd数组每个元素描述一个要监视的描述符struct pollfd { int fd; /* 文件描述符 */ short events; /* 请求监视的事件 */ short revents; /* 实际发生的事件由内核填充 */ };events字段由应用程序设置表示关心哪些事件revents字段由内核在返回时填充表示实际发生了什么事件。这两个字段分离的设计比select的“值-结果”参数要清晰得多每次调用不需要重新设置整个集合只需要在需要改变监视目标时修改数组即可。nfds是数组的长度类型是nfds_t通常是无符号长整型。timeout是毫秒数传-1表示永久阻塞传0表示立即返回传正数表示等待指定毫秒。poll支持的事件类型比select丰富常用的有事件常量含义POLLIN有数据可读POLLOUT可以写入数据POLLERR发生错误POLLHUP对端挂断POLLNVAL描述符无效POLLPRI有紧急数据可读POLLERR、POLLHUP、POLLNVAL这三个事件不需要在events中设置内核会在revents中自动填充。3.2 从select迁移到poll的实操步骤如果你手头有基于select的代码想迁移到poll步骤其实不复杂但有几个细节要注意。第一步把fd_set替换成pollfd数组。原来用FD_SET加入的描述符现在变成数组的一个元素设置fd和events字段。第二步把select的nfds参数最大值加一替换成数组长度。poll不需要你计算最大值它直接遍历数组。第三步把超时时间从timeval结构体改成毫秒整数。select的timeval可以精确到微秒poll只能到毫秒对大多数嵌入式场景来说毫秒足够了。第四步把FD_ISSET判断改成检查revents字段。比如原来FD_ISSET(fd, readfds)现在变成fds[i].revents POLLIN。下面是一个poll版本的等价代码#include poll.h #define MAX_FDS 2 int main(void) { struct pollfd fds[MAX_FDS]; char buf[BUF_SIZE]; int ret; fds[0].fd SERIAL_FD; fds[0].events POLLIN; fds[1].fd SOCKET_FD; fds[1].events POLLIN; while (1) { ret poll(fds, MAX_FDS, 200); if (ret 0) { if (errno EINTR) continue; perror(poll); break; } else if (ret 0) { continue; /* 超时 */ } if (fds[0].revents POLLIN) { int n read(fds[0].fd, buf, sizeof(buf)); /* 处理串口数据 */ } if (fds[1].revents POLLIN) { int n read(fds[1].fd, buf, sizeof(buf)); /* 处理网络数据 */ } } return 0; }可以看到poll的代码结构比select清爽不少。不需要每次循环重置集合不需要计算maxfd判断就绪的方式也更直观。3.3 poll在实际项目中的取舍poll虽然解决了select的描述符数量限制和集合重置问题但它并没有解决性能上的根本问题。每次调用poll内核仍然需要把整个pollfd数组从用户空间拷贝到内核空间返回时再拷贝回来。如果监视的描述符很多比如几百上千个这个拷贝开销就很可观了。而且poll返回后应用程序仍然需要遍历整个数组来找出哪些描述符就绪时间复杂度是O(n)。在嵌入式场景下如果监视的描述符数量不多比如十几个以内poll和select的性能差异可以忽略不计选哪个主要看个人习惯和代码可移植性要求。select的可移植性更好几乎所有Unix-like系统都支持poll在Linux和大多数Unix系统上也支持但某些老旧的嵌入式系统可能没有。如果描述符数量在几十到几百之间poll比select更合适因为不受1024限制代码也更清晰。如果描述符数量上千或者对性能有极致要求那就应该上epoll了。还有一个实际问题是poll的timeout精度是毫秒而select可以到微秒。在嵌入式实时性要求高的场景下比如某些工业控制协议要求微秒级响应select可能更合适。但说实话Linux本身就不是硬实时系统微秒级的精度在实际中很难保证大多数场景毫秒级足够了。注意poll的revents字段在每次调用后由内核填充但events字段不会被修改。这意味着你不需要像select那样每次重新设置。但如果你在运行过程中动态添加或删除监视的描述符需要自己维护数组的有效性比如把不再监视的元素的fd设为-1poll会忽略fd为负数的元素。4. epoll的内核事件表机制与高并发嵌入式网关实践4.1 epoll的三个核心接口epoll是Linux 2.6内核引入的设计思路和select/poll完全不同。它把“注册监视目标”和“等待事件发生”这两个动作分开了通过三个接口配合完成#include sys/epoll.h int epoll_create(int size); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);epoll_create创建一个epoll实例返回一个文件描述符epfd。参数size在早期内核中用来提示内核分配多大的事件表现在已经被忽略但必须传一个大于0的值。创建成功后内核会在内核空间维护一个事件表红黑树用来存储被监视的描述符。epoll_ctl用来向epoll实例中添加、修改或删除监视的描述符。op参数可以是EPOLL_CTL_ADD、EPOLL_CTL_MOD或EPOLL_CTL_DEL。event参数描述要监视的事件类型常用的有EPOLLIN可读、EPOLLOUT可写、EPOLLET边缘触发、EPOLLONESHOT一次性触发等。epoll_wait等待事件发生。events是一个数组内核会把就绪的事件填充到这个数组中maxevents是数组的大小timeout是超时毫秒数-1表示永久阻塞0表示立即返回。返回值是就绪事件的数量。epoll_event结构体在Linux下是这样的struct epoll_event { uint32_t events; /* 事件类型 */ epoll_data_t data; /* 用户数据 */ }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;data是一个联合体你可以放文件描述符、指针或任意整数。通常的做法是放文件描述符这样在epoll_wait返回后能直接知道是哪个描述符就绪。4.2 水平触发与边缘触发的选择epoll支持两种触发模式水平触发Level TriggeredLT和边缘触发Edge TriggeredET。这是epoll使用中最容易混淆也最容易出问题的地方。水平触发的行为和select/poll类似只要描述符的读缓冲区有数据每次epoll_wait都会通知你。也就是说如果你这次没有把数据读完下次epoll_wait还会继续通知直到数据被读完。这种模式编程简单不容易丢数据是默认模式。边缘触发则不同只有当描述符状态发生变化时比如从无数据变为有数据epoll_wait才会通知一次。如果你这次没有把数据读完下次epoll_wait不会再通知除非又有新数据到达。这意味着在边缘触发模式下你必须一次性把数据读完通常需要配合非阻塞IO和循环read直到返回EAGAIN。边缘触发的优势是减少了epoll_wait的返回次数在高并发场景下性能更好。但编程复杂度更高容易出bug。我的建议是除非你有明确的性能需求并且对非阻塞IO编程很熟练否则优先用水平触发。嵌入式网关虽然可能处理几十上百个连接但水平触发的性能通常也够用稳定压倒一切。下面是一个边缘触发的读数据模板/* 设置非阻塞 */ int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); /* 边缘触发模式下读数据 */ while (1) { int n read(fd, buf, sizeof(buf)); if (n 0) { if (errno EAGAIN || errno EWOULDBLOCK) { break; /* 数据读完了 */ } /* 真正的错误 */ break; } else if (n 0) { /* 对端关闭 */ break; } /* 处理数据 */ }这个循环会一直读直到没有数据可读EAGAIN确保在边缘触发模式下不会丢数据。4.3 一个嵌入式网关的epoll实战框架假设我们要做一个嵌入式网关需要同时处理多个TCP客户端连接和一路串口数据。用epoll来实现大致框架如下#include sys/epoll.h #include fcntl.h #define MAX_EVENTS 64 #define BUF_SIZE 512 static int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main(void) { int epfd, nfds, i; struct epoll_event ev, events[MAX_EVENTS]; char buf[BUF_SIZE]; epfd epoll_create1(0); if (epfd 0) { perror(epoll_create1); return -1; } /* 添加监听socket */ set_nonblocking(listen_fd); ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); /* 添加串口 */ set_nonblocking(SERIAL_FD); ev.events EPOLLIN; ev.data.fd SERIAL_FD; epoll_ctl(epfd, EPOLL_CTL_ADD, SERIAL_FD, ev); while (1) { nfds epoll_wait(epfd, events, MAX_EVENTS, 500); if (nfds 0) { if (errno EINTR) continue; perror(epoll_wait); break; } for (i 0; i nfds; i) { int fd events[i].data.fd; if (fd listen_fd) { /* 接受新连接 */ int conn_fd accept(listen_fd, NULL, NULL); if (conn_fd 0) { set_nonblocking(conn_fd); ev.events EPOLLIN | EPOLLET; ev.data.fd conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, ev); } } else if (fd SERIAL_FD) { /* 处理串口数据 */ int n read(SERIAL_FD, buf, sizeof(buf)); if (n 0) { /* 转发到所有客户端或做协议解析 */ } } else { /* 处理客户端数据 */ int n read(fd, buf, sizeof(buf)); if (n 0) { /* 连接关闭或出错移除监视 */ epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } else { /* 处理数据 */ } } } } close(epfd); return 0; }这个框架有几个关键设计点。监听socket和串口用水平触发因为它们的处理逻辑简单水平触发不容易出错。客户端连接用边缘触发因为客户端数量可能较多边缘触发能减少epoll_wait的返回次数。每个连接都设为非阻塞这是边缘触发的前提条件。连接关闭时要从epoll中移除并关闭描述符避免描述符泄漏。4.4 epoll在嵌入式环境中的性能考量epoll的性能优势主要体现在大量描述符的场景下。select和poll每次调用都需要把描述符集合从用户空间拷贝到内核空间返回时再拷贝回来时间复杂度是O(n)。epoll通过内核事件表红黑树避免了重复拷贝epoll_wait返回时只把就绪的事件拷贝到用户空间时间复杂度是O(1)就绪事件数量。但在嵌入式场景下描述符数量通常不会太多。一个典型的嵌入式网关可能同时处理几十个连接这种规模下epoll和poll的性能差异并不明显。epoll的真正价值在于当描述符数量增长到几百上千时poll的O(n)遍历会成为瓶颈而epoll仍然保持高效。另一个考量是内存占用。epoll在内核空间维护红黑树和就绪链表每个被监视的描述符都需要分配内核数据结构。在内存紧张的嵌入式设备上如果描述符数量不多epoll的额外内存开销可能反而不划算。select和poll不需要内核维护额外数据结构内存开销更小。所以我的选型建议是描述符数量在几十个以内select或poll足够描述符数量在几百个以上或者需要处理大量并发连接用epoll如果对可移植性有要求代码需要在多种Unix-like系统上运行select是最安全的选择。提示epoll_create1(0)和epoll_create(size)功能类似但epoll_create1支持EPOLL_CLOEXEC标志可以在forkexec时自动关闭epoll描述符避免描述符泄漏。新代码建议用epoll_create1。5. 三种机制的对比选型与嵌入式项目中的决策依据5.1 从六个维度做横向对比把select、poll、epoll放在一起对比可以从以下几个维度来看对比维度selectpollepoll描述符数量限制有通常1024无无每次调用是否拷贝集合是是否仅注册时拷贝返回后是否需要遍历是是否直接返回就绪列表时间复杂度O(n)O(n)O(1)就绪事件数触发模式水平触发水平触发水平触发边缘触发可移植性最好较好Linux特有从表格可以直观看出epoll在性能上全面占优但可移植性最差。select和poll的性能瓶颈在于每次调用都要拷贝和遍历整个集合描述符越多越慢。epoll通过内核事件表把注册和等待分离只在描述符状态变化时才产生事件效率高得多。5.2 嵌入式项目中的选型决策树在实际嵌入式项目中做选型我通常会按下面的逻辑来判断第一步看描述符数量。如果程序同时监视的描述符不超过10个select和poll都行选哪个看个人习惯。如果超过100个优先考虑epoll。如果超过1024个select直接排除。第二步看可移植性要求。如果代码需要在多种嵌入式系统上运行比如既有Linux又有其他Unix-like系统select是最安全的选择。如果确定只跑Linuxepoll可以用。第三步看实时性要求。如果对响应时间有严格要求epoll的边缘触发模式可以减少事件通知次数降低延迟。但边缘触发编程复杂需要仔细处理非阻塞IO和EAGAIN。第四步看内存和CPU资源。内存非常紧张且描述符不多select或poll更省内存。CPU较弱但描述符较多epoll更省CPU。第五步看开发效率和维护成本。select和poll的编程模型简单直观新手容易上手。epoll的边缘触发模式容易出bug需要更有经验的开发者。如果团队里新手多水平触发的epoll或poll可能更合适。5.3 一个实际项目的选型过程我之前做过一个嵌入式数据采集项目设备需要同时处理一路RS485串口Modbus RTU协议、一路CAN总线通过socketcan、一路以太网TCP连接上报数据到服务器、还有几个GPIO按键输入。总共监视的描述符大概6到8个。最初我用的是select代码跑得挺稳。后来需求变更要支持多个TCP客户端同时连接最多20个描述符数量增加到20多个。select仍然能用但每次调用都要遍历1024个位感觉有点浪费。我改成了poll代码更清晰性能也略有提升。再后来客户要求支持最多100个TCP客户端这时候poll的O(n)遍历开始显现瓶颈我最终换成了epoll。这个过程中我最大的体会是不要一开始就追求“最优方案”。项目初期描述符少select完全够用开发速度快。随着需求增长再逐步优化比一开始就上epoll要务实得多。嵌入式开发讲究的是“够用就好”过度设计反而增加复杂度和风险。5.4 混合使用的可能性有人可能会问能不能在同一个程序里同时用select和epoll技术上是可以的但通常没必要。select和epoll监视的描述符集合如果有重叠会导致事件被重复处理逻辑变复杂。如果确实有特殊需求比如一部分描述符用select管理另一部分用epoll管理那不如统一用一种机制或者用epoll管理所有描述符。还有一种情况是主循环用epoll等待IO事件但某个描述符需要配合定时器做超时处理。这时候可以在epoll_wait的超时参数上做文章或者用timerfd把定时器也变成一个文件描述符加入epoll统一管理。timerfd是Linux特有的可以把定时器事件和IO事件统一到epoll框架里非常优雅。6. 调试I/O多路复用代码的常见问题与排查思路6.1 描述符泄漏最隐蔽也最致命的问题描述符泄漏是I/O多路复用代码中最常见的问题之一。表现是程序运行一段时间后select或poll开始返回错误提示“Too many open files”。原因是每次接受新连接或打开新文件后没有在连接关闭时正确关闭描述符。排查方法很简单在程序运行过程中定期查看/proc/pid/fd目录下的文件描述符数量。如果数量持续增长说明有泄漏。也可以在每个accept或open之后打印当前描述符数量观察变化趋势。修复方法是在连接关闭或出错时确保调用close关闭描述符并且从select/poll/epoll的监视集合中移除。对于epoll还需要调用epoll_ctl的EPOLL_CTL_DEL操作。注意close一个描述符会自动把它从epoll中移除但显式调用EPOLL_CTL_DEL更清晰也避免一些边界情况下的问题。6.2 阻塞与非阻塞的混淆在epoll的边缘触发模式下描述符必须设为非阻塞。如果忘了设置read在没数据时会阻塞导致整个epoll_wait循环卡住其他描述符的事件也无法处理。这个问题的表现是程序在处理某个连接时突然卡死不再响应任何IO事件。排查方法是检查所有加入epoll的描述符是否都设置了O_NONBLOCK。可以用fcntl的F_GETFL命令查看当前标志位。修复方法是在添加描述符到epoll之前统一调用set_nonblocking函数。在select和poll的水平触发模式下描述符可以是阻塞的因为水平触发会反复通知直到数据读完。但即使如此我也建议把描述符设为非阻塞这样可以避免一些边界情况下的意外阻塞。比如select返回可读但read时数据被其他进程读走了阻塞模式下就会卡住。6.3 EINTR信号中断的处理在嵌入式Linux中信号是常见的。当select、poll或epoll_wait被信号中断时会返回-1并设置errno为EINTR。如果代码没有处理EINTR直接perror退出程序就会因为一个无关紧要的信号而终止。正确的处理方式是检查errno是否为EINTR如果是则重新调用。这个逻辑在select、poll、epoll_wait中都需要。有些系统调用会自动重启SA_RESTART标志但select/poll/epoll_wait通常不会自动重启需要手动处理。do { ret epoll_wait(epfd, events, MAX_EVENTS, timeout); } while (ret 0 errno EINTR);这个do-while循环是处理EINTR的标准写法简单有效。6.4 边缘触发模式下的数据丢失边缘触发模式下如果一次没有把数据读完剩余数据不会触发新的事件通知导致数据“丢失”实际上是留在缓冲区里没被读取。这个问题的表现是客户端发送了大量数据但服务端只处理了第一部分后面的数据好像消失了。排查方法是检查read循环是否正确地读到了EAGAIN。在边缘触发模式下read必须循环直到返回EAGAIN或EWOULDBLOCK才能确保数据读完。如果read返回0表示对端关闭连接需要处理连接关闭逻辑。修复方法就是前面提到的边缘触发读数据模板用while循环反复read直到EAGAIN。同时要确保描述符是非阻塞的否则read在没数据时会阻塞而不是返回EAGAIN。6.5 超时时间设置不当导致的问题超时时间设置太短会导致主循环空转CPU占用率飙升。设置太长会导致IO响应不及时用户体验差。在嵌入式设备上CPU资源宝贵空转是不可接受的。我的经验是如果主循环里除了IO处理还有其他周期性任务比如状态机、看门狗超时时间应该和这些任务的周期匹配。比如看门狗需要每500毫秒喂一次那超时时间设为200到300毫秒比较合适留出余量。如果主循环只处理IO超时时间可以设长一些比如1秒减少空转。可以用top或htop命令观察程序的CPU占用率。如果空闲时CPU占用率很高说明超时时间太短或主循环里有忙等待。如果IO响应延迟明显说明超时时间太长。6.6 用strace辅助调试strace是调试I/O多路复用代码的利器。它可以跟踪程序的所有系统调用包括select、poll、epoll_wait、read、write等。通过strace的输出可以清楚地看到每次调用的参数、返回值和耗时。比如用strace -e traceselect,poll,epoll_wait,read,write -p pid可以只跟踪相关的系统调用。如果看到select反复返回0超时说明没有IO事件程序在空转。如果看到read返回EAGAIN说明非阻塞IO正常工作。如果看到epoll_wait返回后没有对应的read调用说明代码逻辑有问题没有处理就绪事件。strace的输出可能很多可以配合grep过滤。比如strace -e traceepoll_wait -p pid 21 | grep -v EAGAIN可以过滤掉EAGAIN的噪音只看有意义的事件。注意strace会显著降低程序的运行速度只适合调试阶段使用不要在生产环境中长期开启。在嵌入式设备上strace可能没有预装需要交叉编译或从包管理器安装。7. 从文件IO到网络编程I/O多路复用的延伸思考7.1 文件IO和网络IO的差异虽然select、poll、epoll可以同时监视普通文件和socket但普通文件总是就绪的select/poll/epoll对普通文件的监视没有意义。这是因为普通文件的读写不会阻塞磁盘IO的阻塞在VFS层处理不会反映到select的就绪状态上。所以I/O多路复用主要用于socket、串口、管道、字符设备等可能阻塞的描述符。在嵌入式开发中串口是最常见的非socket描述符。串口的read在没数据时会阻塞所以可以用select/poll/epoll来监视。CAN总线通过socketcan接口也是socket同样适用。GPIO通常通过sysfs或字符设备访问也可以加入多路复用框架。7.2 异步IO的对比除了I/O多路复用Linux还提供异步IOAIO接口比如io_submit、io_getevents。AIO的特点是应用程序发起IO请求后立即返回内核在后台完成IO完成后通知应用程序。这和I/O多路复用的“等待就绪再读写”模式不同。AIO在嵌入式场景下用得不多因为实现复杂而且Linux的AIO对普通文件的支持有限对socket的支持也不如epoll成熟。大多数嵌入式项目还是用I/O多路复用就够了。7.3 多线程与多路复用的结合在某些场景下单线程的I/O多路复用可能不够用。比如需要处理CPU密集型的协议解析单线程会导致IO事件处理延迟。这时候可以用多线程一个IO线程负责epoll_wait和读写数据多个工作线程负责数据处理。IO线程和工作线程之间用队列传递数据。这种架构在嵌入式网关中比较常见。IO线程用epoll管理所有连接收到数据后放入队列工作线程从队列取数据做协议解析和业务处理。这样IO线程不会被CPU密集型任务阻塞保持高响应性。但多线程引入了同步问题需要用互斥锁或原子操作保护共享数据增加了复杂度。如果单线程能搞定就不要用多线程。7.4 实际项目中的经验总结做了这么多年嵌入式Linux开发关于I/O多路复用我有几个体会想分享。第一不要过早优化。项目初期描述符少select完全够用代码简单直观。等到描述符数量真的成为瓶颈了再考虑迁移到poll或epoll。迁移成本并不高因为三种机制的代码结构类似。第二边缘触发要慎用。边缘触发性能好但容易出bug。如果团队里没有对非阻塞IO编程很熟练的人水平触发是更稳妥的选择。水平触发的性能在大多数嵌入式场景下也够用。第三错误处理要完善。I/O多路复用代码的错误路径很多select返回-1、read返回0、read返回-1且errno为EAGAIN、连接被重置等等。每个错误路径都要正确处理否则程序可能在异常情况下崩溃或泄漏资源。第四日志和调试信息要充足。在嵌入式设备上调试不方便程序里要有足够的日志输出。关键路径accept、close、epoll_ctl、read/write错误都要打印日志方便事后分析。日志级别要可配置生产环境中可以关闭详细日志节省存储空间。第五测试要覆盖边界情况。比如同时有大量连接、连接频繁断开重连、数据量很大、对端异常关闭等。这些边界情况最容易暴露bug。可以用脚本模拟大量客户端连接和断开观察程序的行为和资源占用。I/O多路复用是嵌入式Linux文件IO编程的核心技能之一掌握select、poll、epoll的用法和区别理解它们背后的设计思想能在实际项目中做出合理的选型决策这是每个嵌入式Linux开发者都应该具备的能力。希望这篇文章能帮你少走一些弯路在实际项目中把I/O多路复用用好用对。
阅读完成 · 觉得有帮助?
咨询建站