目录
本节重点掌握
- 理解五种IO模型的基本概念, 重点是IO多路转接.
- 掌握select编程模型, 能够实现select版本的TCP服务器.
- 掌握poll编程模型, 能够实现poll版本的TCP服务器.
- 掌握epoll编程模型, 能够实现epoll版本的TCP服务器.
- 理解epoll的LT模式和ET模式.
- 理解select和epoll的优缺点对比.
什么是IO
IO=等+数据拷贝 ,等即等待某种资源就绪,比如read/recv,调的时候os会去监测接收缓冲区是否有数据,没有就会把进程阻塞住,(这就是在等待)有数据了read/recv就进行拷贝后返回,所以IO不能忽略掉"等"这个,之前没有讲这个等是因为系统方面不够直观,我们在调函数时写到文件中一下子就写到了,其实是写到了os页缓冲区,由os帮我们写到文件,在网络这里我们能够直观感受到等,也就是我们要把东西写到fd中的缓冲区,再刷新到os的全局网卡缓冲区,至于这期间的决策是由os自主决定的
IO(Input/Output,输入 / 输出) 是程序与外部设备(文件、网络、内存、外设等)之间的数据交换过程,核心是「数据的读取(Input,从外部读入程序)」和「数据的写入(Output,从程序写出到外部)」。(但要注意有个等的概念,必须强调这个等,后面的讲解会感受到的)
所以什么才是一个高效的IO?
IO=等+数据拷贝
对于数据拷贝,你可以减少拷贝次数(层次)或者优化拷贝(零拷贝),尽可能的在内核态拷贝即可
但是相同的数据量,等这个比例优化空间很大,减少这个比重即可
任何IO过程中, 都包含两个步骤. 第一是等待 , 第二是拷贝. 而且在实际的应用场景中, 等待消耗的时间往往都远远高于拷贝的时间. 让IO更高效, 最核心的办法就是让等待的时间尽量少.
IO的方式

- 阻塞式 IO:钓鱼者(进程)一直等鱼漂动(数据就绪),期间啥也不做 → 等的比重高,效率低;
- 非阻塞 IO:钓鱼者频繁检查鱼漂(轮询数据是否就绪),期间可做其他事 → 等的比重降低,但轮询仍有开销;
- 信号驱动式 IO:钓鱼者正常做事,鱼漂动了(数据就绪)会主动通知(信号)→ 等的比重进一步降低;
- 多路复用/转接(如 epoll):一个人(单进程 / 线程)同时看多个鱼竿(多个 fd),哪个鱼漂动了(哪个 fd 就绪)就处理哪个 → 等的比重大幅降低
- 异步 IO:钓鱼者把鱼竿交给别人(操作系统),鱼上钩后直接拿到鱼(数据已拷贝到用户态)→ 完全不参与 "等" 和 "钓",等的比重为 0。
本质上:张三、李四、王五的钓鱼效率基本无差别,区别在于李四王五可以干别的事情,赵六田七高效,本质上除了异步IO,其他都有等
平时用的最多的就是多路复用和阻塞式IO
- 同步 IO(阻塞 / 非阻塞 / 信号驱动 / 多路复用):钓鱼者(进程)必须参与 "等鱼漂动" 或 "钓(数据拷贝)" 的过程;
- 异步 IO:钓鱼者完全不参与 "等" 和 "钓",由操作系统完成所有步骤后,直接交付结果。
同步IO就是直接或者间接参与等或者拷贝两个步骤,而异步是一个都不参与,os去完成,完成放在你指定的缓冲区,你自己去处理就好了
1.阻塞式IO2.非阻塞式IO,本质就是内核有数据就返回,没有系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码.(需要程序员轮询去检查,这对cpu来说是一个浪费)
**3.信号驱动:**内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作.
4.IO多路转接: 虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态.
5.异步IO: 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据).
对于之前学的read等系统调用接口,即负责数据的拷贝,又负责等,而且只能传一个文件描述符,由于多路转接是多个文件描述符 ,所以又有一批新的接口,能够传多个文件描述符,但它只负责等,只负责等,只有来检查数据有没有准备好,后面再用recvfrom负责拷贝
高级IO几个重要的概念
同步通信vs异步通信
同步和异步关注的是消息通信机制.
所谓同步,就是在发出一个调用 时,在没有得到结果之前,该调用 就不返回. 但是一旦调用返回,就得到返回值了; 换句话说,就是由调用者 主动等待这个调用的结果;
异步则是相反,调用 在发出之后,这个调用就直接返回了,所以没有返回结果; 换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果; 而是在调用 发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用.
另外, 我们回忆在讲多进程多线程的时候, 也提到同步和互斥. 这里的同步通信和进程之间的同步是完全不想干的概念.
进程/线程同步也是进程/线程之间直接的制约关系
是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系. 尤其是在访问临界资源的时候.
以后在看到 " 同步 " 这个词 , 一定要先搞清楚大背景是什么 . 这个同步 , 是同步通信异步通信的同步 , 还是同步与互斥的同步.
阻塞vs非阻塞
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态
阻塞调用 是指调用结果返回之前,当前线程会被挂起. 调用线程只有在得到结果之后才会返回.
非阻塞调用 指在不能立刻得到结果之前,该调用不会阻塞当前线程.先返回某个标记(标记此时没有数据)
其他高级IO
非阻塞 IO ,纪录锁,系统 V 流机制, I/O 多路转接(也叫 I/O 多路复用) ,readv 和 writev 函数以及存储映射IO( mmap ),这些统称为高级 IO.
我们此处重点讨论的是 I/O 多路转接
非阻塞IO
在 Linux 中,文件描述符(fd)默认是阻塞模式 ,其 "阻塞 / 非阻塞" 状态是通过 fd 对应的内核结构体字段 标记的
cpp
// Linux 内核源码:include/linux/fs.h
struct file {
// ... 其他字段 ...
unsigned int f_flags; // 存储文件的状态标志,包括阻塞/非阻塞
// ... 其他字段 ...
};
当我们通过 open 打开文件时,若不指定 O_NONBLOCK 标志,f_flags 中就不会包含 O_NONBLOCK,此时 fd 是阻塞模式 ;若指定 O_NONBLOCK,则 f_flags 会包含该标志,fd 变为非阻塞模式。
两个方式:一个open的时候指定非阻塞,一个是fcntl
由于socket创建的时候无法设置为非阻塞,所以一般还需要fcntl修改属性
fcntl

fcntl(file control)是 C 标准库封装的系统调用函数,核心作用是「对已打开的文件描述符(fd)进行 "控制 / 修改" 操作」------ 比如修改 fd 的阻塞状态、获取 / 设置 fd 标志、管理文件锁等。
它是用户态操作内核 struct file 结构体字段(如 f_flags)的核心接口,弥补了 open 只能在 "打开文件时设置属性" 的不足。(也就是对已打开的 fd,修改 / 查询已有属性;)
fd:传入的文件描述符
cmd:标志位,传入不同后面追加的参数也不同
复制一个现有的描述符(cmd=F_DUPFD).
获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD).
获得/设置文件状态标记(cmd=F_GETFL或F_SETFL).
获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN).
获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW).
我们此处只是用 第三种功能 , 获取 / 设置文件状态标记 , 就可以将一个文件描述符设置为非阻塞 .
返回值 :失败返回-1,并且设置errno
成功则根据传入的cmd,返回不同类型的标记,比如你查询的是文件状态,则返回O_RDONLY等注意:如果你没有先使用F_GETFL进行查询,直接使用F_SETFL修改,就会导致新的覆盖掉原先的,比如原先的有追加模式,但是你仅仅修改成非阻塞那追加没了,跟原来不符会出现问题
代码测试非阻塞IO
在Linux中,文件描述符012,分别对应标准输入、标准输出、标准错误
cpp
#pragma once
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
// 设置非阻塞
void setNoBlock(size_t fd){
int flag=fcntl(fd,F_GETFL);//先查询
if(flag==-1){
std::cout<<"fcntl F_GETFL error"<<std::endl;
return ;
}
//设置成非阻塞
if(fcntl(fd,F_SETFL,flag|O_NONBLOCK)==-1){
std::cout<<"fcntl F_SETFL error"<<std::endl;
}
}
cpp
#include "SetNoBlock.hpp"
int main(){
//用键盘输入
char buffer[1024];
while(1){
//不断的从键盘当中输入,然后获取
//1.先测试阻塞版本
std::cout<<">>>";
fflush(stdout);//刷新缓冲区,否则有时候会积累,由os决定刷新
ssize_t size = read(0,buffer,sizeof(buffer)-1);
buffer[size]='\0';
if(size > 0){
std::cout<<"echo:"<<buffer<<std::endl;
}
else if(size==0){
std::cout<<"read end"<<std::endl;
}
else{
//由于阻塞式读写说明不会到这里,所以不会执行
}
}
return 0;
}

注意博主在编写的时候犯过一个致命错误:read的返回值应该是ssize_t而不是size_t
测试结果中:因为我们一开始没有把0号标准输入设为非阻塞式等待,所以此时是阻塞式等待,调用read时就会一直阻塞,直到有数据才返回
cpp
#include "SetNoBlock.hpp"
int main(){
//用键盘输入
//非阻塞式
setNoBlock(0);
char buffer[1024];
while(1){
//不断的从键盘当中输入,然后获取
std::cout<<">>>";
fflush(stdout);//刷新缓冲区,否则有时候会积累,由os决定刷新
ssize_t size = read(0,buffer,sizeof(buffer)-1);
if(size > 0){
buffer[size]=0;
std::cout<<"echo:"<<buffer<<std::endl;
}
else if(size==0){
std::cout<<"read end"<<std::endl;
}
else{
//由于非阻塞,所以这里会执行到
}
sleep(1);
}
return 0;
}

这是非阻塞式等待,结果显而易见,一开始非阻塞,所以一直输出多个>>>,并不是阻塞式只会输出一个>>>,然后阻塞在read,当你敲hello的时候他还会同时打印>>>,后面read的时候读取到hello然后回显
在重看read的返回值
返回>0表示有数据,=0表示读到EOF(这个表示不会有数据了,比如键盘当中输入ctrl+d就是向文件中发送EOF,比如网络中写端关闭文件描述符时,比如一个文件的末尾)
==-1就是表示读取失败,设置全局的error
如果是非阻塞应该返回啥呢? >0不合适 =0不合适
所以设置了返回-1时,还需要看error,如果error是EAGAIN或者EWOULDBLOCK那就是非阻塞的无数据,-1 + errno 标识。
注意还有一个标识EINTR,此时表示读取的时候被信号中断,需要continue接着读取即可
cpp
#include "SetNoBlock.hpp"
#include <cstring>
int main(){
//用键盘输入
//非阻塞式
setNoBlock(0);
char buffer[1024];
while(1){
//不断的从键盘当中输入,然后获取
std::cout<<">>>";
fflush(stdout);//刷新缓冲区,否则有时候会积累,由os决定刷新
ssize_t size = read(0,buffer,sizeof(buffer)-1);
// std::cout<<"size="<<size<<std::endl;
if(size > 0){
buffer[size]=0;
std::cout<<"echo:"<<buffer<<std::endl;
}
else if(size==0){
std::cout<<"read end"<<std::endl;
}
else{
//由于非阻塞,所以这里会执行到
if(errno==EAGAIN||errno==EWOULDBLOCK){
//表示无数据
std::cout<<"no data"<<std::endl;
std::cout<<"size="<<size<<"errno="<<errno<<strerror(errno)<<std::endl;
}
else if(errno==EINTR){
//表示被信号打断
std::cout<<"interrupt"<<std::endl;
continue;
}
else{
std::cout<<"read error"<<std::endl;
}
}
sleep(1);
}
return 0;
}

测试结果当中确实是非阻塞:一直no data,并且可以看到此时的size=-1,说明就是通过**-1+错误码来表示非阻塞无数据的状态**
这才是非阻塞的最终正确的写法
总结:fcntl是用来设置一个打开的fd的属性但是注意非阻塞的效率和阻塞都没太大差别,因为你需要轮询,你的cpu需要去调用read去查询资源是否就绪,而且会频繁的进行上下文的切换,从用户态<----->内核态,因为read的本质就是切换成内核态去检查数据是否就绪
单个进程 + 单个 IO 任务:阻塞 IO 和非阻塞式无多大差别,甚至有时候阻塞式效率更高
真正的高效还得是多路转接
I/O 多路转接之 select
IO=等+数据拷贝
select:只负责等,没有数据拷贝的功能,可以同时监测多个fd,数据拷贝交给read/write等函数
这个由内核来完成,内核帮你等,有数据了你来拿就行
了解select基本概念和接口介绍
系统提供select函数来实现多路复用输入/输出模型.
select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变;
函数原型
cpp
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
nfds:监视的所有的文件描述符中最大的那个+1
readfds:要监听"可读事件"的fd集合
writefds:要监听"可写事件"的fd集合
exceptfds:要监听"异常事件"的fd集合,比如多线程中别的线程关闭fd,但你仍要写入,异常不代表程序错误,只是一种特殊情况需要额外处理
timeout:超时时间(NULL=永久阻塞;0=非阻塞立即返回;>0=超时等待timeout秒)
这个timeval是一个结构体类型
设置s和ms,并且是一个输入输出型参数,返回时告诉你剩余多少s(所以后续还需要设置超时时间就需要重新初始化)
fd_set:
本质就是一个位图:用来表示文件描述符是哪几个,比特位的位置就是fd,置1表示监听,置0表示不监听
fd_set的最大容量由FD_SETSIZE决定(默认 1024),因此select最多只能监听 fd 编号 ≤ 1023 的文件描述符 ------ 这是select无法处理高并发的核心原因之一。这里就是告诉内核:你帮我关系一下这些bit=1的读事件,返回的时候用户去检查,哪些bit=1就是读事件就绪了,让内核和用户之间知晓对方要的和关心的
设置位图操作函数
cppvoid FD_CLR(int fd, fd_set *set); int FD_ISSET(int fd, fd_set *set); void FD_SET(int fd, fd_set *set); void FD_ZERO(fd_set *set);
函数宏 功能 示例 FD_ZERO(fd_set *set)清空 fd 集合(所有比特位置 0) FD_ZERO(&read_fds);FD_SET(int fd, fd_set *set)将 fd 加入集合(对应比特位置 1) FD_SET(0, &read_fds); // 监听键盘fdFD_CLR(int fd, fd_set *set)将 fd 移出集合(对应比特位置 0) FD_CLR(3, &read_fds); // 取消监听fd=3FD_ISSET(int fd, fd_set *set)检查 fd 是否在集合中(比特位是否为 1) if (FD_ISSET(0, &read_fds)) { ... }返回值:
>0:返回已就绪的fd的个数
==0:超时返回
<0:select调用失败,并且设置error
使用select接口编写一个tcp服务
这里我们复用之前写过的tcp服务器进行修改即可
之前我们的accept是会阻塞式等待的,这时候服务器完全阻塞住,并且你获取上来了后面也只是处理单个客户端
一:多线程处理
来一个客户端就创建一个线程去服务它,但是此时的弊端就是无法承受高并发,你Linux有一个上限的线程数量,并且你频繁的切换线程,有线程开销
二:线程池+阻塞IO
来一个客户端主线程就accept,然后把任务丢到任务队列中,别的线程去队列中获取任务之后返回,但你连接的客户端的数量取决于线程池的数量,毕竟还是会阻塞在read中,后面来的客户端都不能正常read和write,只会在任务队列当中等待
三:线程池+非阻塞式IO
你怎么去轮询你的read呢?那还不是占用某个线程去轮询?比如你有一个全局的fd数组,那还不是用线程池中的线程依次去检查数组,线程池中的线程会无休无止地遍历所有 fd 调用
read,即使 99% 的 fd 都没有数据,也必须逐个检查 ------ 本质是 "用 CPU 轮询替代阻塞等待",CPU 利用率极低(全耗在无意义的read调用上,无意义的指令+循环等待);为了降低 CPU 开销,你可以加sleep(比如 1ms),这会导致客户端数据的响应延迟至少 1ms;若去掉sleep,CPU 直接拉满;每个非阻塞read都是系统调用(用户态→内核态→用户态),遍历 1000 个 cfd 就是 1000 次系统调用,开销远超阻塞 IO。四:线程池 + 非阻塞 IO + IO 多路复用
所有可能阻塞的事件让内核去通知你,比如listen,处于全连接的需要被accept的
比如read,我不需要一个线程去单独处理一个客户端,仅需要有数据的时候,内核通知(这里仅仅是主线程阻塞在select,只要返回值>0,有就绪事件了),我就使用线程池去read,然后处理任务之后返回即可
注意:当listen完成三次握手,即进入全连接队列的时候,内核才会触发就绪事件
cpp
while (1){
fd_set rfds; // 读事件
FD_ZERO(&rfds); // 位图清空
FD_SET(_sockFd, &rfds); // 监听listen套接字
int n = select(_sockFd + 1, &rfds, NULL, NULL, NULL); // 阻塞式监听即可
if (n > 0){
// 说明有就绪事件
if (FD_ISSET(_sockFd, &rfds)){
// 检查是不是监听套接字就绪了
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
int client_fd = accept(_sockFd, (struct sockaddr *)&client_addr, &client_addr_len);
if (client_fd < 0){
// accept失败
std::cerr << "accept error:" << errno << strerror(errno) << std::endl;
}
std::string client_ip = inet_ntoa(client_addr.sin_addr);
int client_port = ntohs(client_addr.sin_port);
}
}
else{
// 调用失败
std::cerr << "select error:" << errno << strerror(errno) << std::endl;
exit(1);
}
}
前面的socket和listen不需要改变,仅仅改一下这段逻辑
注意位图的设置必须在while里面,如果你放在外面,当来连接的时候,内核通知你有数据了,它会将所有"就绪"的文件描述符标记为已就绪,对于未就绪的直接标记为0了。此时调用select成功返回,检查rfds,但是此时内核不会帮你把就绪事件的已就绪去掉,所以下一次进来的时候,select还是会调用成功,但实际fd可能没有准备好,导致阻塞在accept,所以必须每次循环时都要设置监听套接字
一般来说需要程序员手动维护一个数组,用来表示监听fd,这也是select设计的缺点核心原因是
select的fd_set会被内核修改:
- 调用
select前,你需要把要监听的 fd 加入fd_set;select返回后,fd_set会被内核覆盖为仅保留就绪的 fd(未就绪的 fd 会被清空);- 下一次调用
select时,你需要重新构建fd_set------ 而构建的依据,就是你手动维护的 fd 数组(记录了所有需要监听的 fd)。如果不维护这个数组,你根本不知道 "上一轮要监听哪些 fd",也就无法重新初始化
fd_set。之前提到过fd_set是一种类型,一般来说默认就是128字节,那就是1024bit位,那就注定了总共能监视的fd就1024个,是有上限的,所以我们进行文件描述符的管理的时候,可以设置文件数组的上限为1024
这里采用私有成员:vector和max
初始化的时候直接把_sockfd加入数组,
cpp
std::cout<<"server start..."<<std::endl;
while (1){
fd_set rfds; // 读事件
FD_ZERO(&rfds); // 位图清空
for(auto fd:_fds){
FD_SET(fd, &rfds);
std::cout<<"_fds:"<<fd<<" "; // 添加监听fd
}
std::cout<<std::endl;
int n = select(_maxfd + 1, &rfds, NULL, NULL, NULL); // 阻塞式监听即可
if (n > 0){
// 说明有就绪事件
std::cout<<"有就绪事件"<<std::endl;
handler_event(rfds);
}
else{
// 调用失败
std::cerr << "select error:" << errno << strerror(errno) << std::endl;
exit(1);
}
}
}
//当就绪的时候就去调用处理
void handler_event(fd_set& rfds){
// 检查哪个fd就绪了
for (auto fd : _fds){
if (FD_ISSET(fd, &rfds)){
if (fd == _sockfd){
// 监听套接字就绪,接受新连接
std::cout << "listen fd:" << fd << " is ready" << std::endl;
accept_fd(fd);
}
else{
// 普通连接套接字就绪,接收数据
std::cout << "fd:" << fd << " is ready" << std::endl;
recver(fd);
}
}
}
}
void recver(int sockfd){
char buf[1024] = {0};
int len = read(sockfd, buf, sizeof(buf) - 1);
if(len>0){
std::cout << "client saying:" << buf << std::endl;
}
else{
// 断开连接
close(sockfd);
// 删除监听
_fds.erase(std::find(_fds.begin(), _fds.end(), sockfd));
}
}
void accept_fd(int sockfd){
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
int client_fd = accept(_sockfd, (struct sockaddr *)&client_addr, &client_addr_len);
if (client_fd < 0){
// accept失败
std::cerr << "accept error:" << errno << strerror(errno) << std::endl;
}
// 调用成功
// std::string client_ip = inet_ntoa(client_addr.sin_addr);
// int client_port = ntohs(client_addr.sin_port);
// 将监听套接字加入数组中
if (_fds.size() < 1024){
_fds.push_back(client_fd);
if (client_fd > _maxfd)
{
_maxfd = client_fd;
}
}
else
{
// 数组满了
std::cerr << "too many clients" << std::endl;
close(client_fd); // 四次挥手直接主动关闭连接
}
}
1:我们不仅仅监听listenfd,还监听read的fd,这样我们无需阻塞式等待,不需要一个线程一个连接去专门的等待,来一个read的fd就绪了,我们就去处理即可
2:判断逻辑要写对,要循环去遍历rfds,依次增加,判断是否是listenfd
测试结果服务端
客户端用之前写的
可以看到每次都去监听,然后只需要阻塞在select即可,这样一有数据我们就去处理,fd=4是客户端连接互相通信的fd,fd=3是listenfd,所以每次的就绪事件都要加入3和后面的客户端fd
此时我们断开连接,看一下数组是否会删除4
服务端提醒有就绪事件,然后是4,可以发现下一次的检查fd数组已经没有4号了,说明被删除了
缺陷:
1:select的最大监听数由os内核已经提前决定了,比如这里的1024个,所以对于高并发来说并不友好
2:每次来一个就绪都需要遍历数组fds,数组越大每次遍历开销越大
3:每次调用
select,都要把用户态的fd_set完整拷贝到内核态,调用结束后又要拷贝回用户态 ------FD 数量越多,拷贝开销越大,完全是无意义的资源浪费。(这个select第一个参数为什么+1,因为进程调这个函数时,它是系统级,所以进入内核,内核也是要遍历才知道哪些就绪了,内核遍历文件描述符表时怎么知道遍历到哪结束,就是通过+1来知道的)4;先连接的 FD(编号小)总能优先被处理,后连接的 FD(编号大)大概率被延迟,甚至出现 "饥饿"(长时间得不到处理)
适用场景:
1:低并发场景(FD 数量 < 100):比如简单的小工具、本地测试程序,
select足够用,且跨平台兼容性好(Windows/Linux/Mac 都支持,epoll 仅 Linux 支持);2:快速开发:
select接口简单,代码量少,不需要理解 epoll 的epoll_create/epoll_ctl/epoll_wait一套流程;3:跨平台程序:如果你的程序需要跑在 Windows 上(Windows 没有 epoll,只有 select/iocp),
select是基础选择。
I/O多路转接之poll
poll也是一种Linux多路转接的方案
主要解决两个问题
1:select有一个上限的fd数量
2:每次调用都要重新设置一下fd
函数介绍

这个和select的参数基本一样
fds:这个是动态数组,和之前的一样,都是为了告诉内核帮我检测哪个fd,结构体数组,所以可以一次性设置多个,数组名是首元素的地址,首元素是struct pollfd,所以传数组名就可以用这个接收,后面内核自己去解引用就可以遍历整个数组
nfds:这个是告诉内核检测的fd的界限,这里你想传多大就多大,os能够接收的fd数组是多少你就可以传多少,这个就解决了select的1024上限问题,所以这个是内核的接收力问题了,不是poll的问题(最大的+1)
timeout:超时时间(NULL=永久阻塞;0=非阻塞立即返回;>0=超时等待timeout秒)
讲解struct pollfd类型
cppstruct pollfd { int fd; /* file descriptor */ short events; /* requested events */ short revents; /* returned events */ };fd+events:用户输入的告诉内核帮我关心
fd+revects:内核设置的告诉用户哪些就绪了
输入和输出进行分离,这里就解决了select需要一直重新设置的问题
events和revents的取值:
POLLIN和POLLOUT和POLLPRI比较常用
传events需要自己处理,不需要处理revents,这个由内核填写
如果结构体中的fd<0,那就表示不让内核进行管理,内核就不会去检查反馈给你
poll解决了两大问题:1.fd上限问题(不是已经设定好的1024,可以更大,内核能接收多少就可以设多少)
2.每次都需要初始化检测数组问题(输入输出分开)
但是poll依然没有解决select遗留下来的数组遍历问题,这是最大的开销,这也是它最终被
epoll取代的关键原因。不仅仅是用户态获取需要遍历,因为内核没有准确的告诉你哪个fd就绪了
内核检查的时候也需要遍历
而且每次调用poll的时候还有拷贝开销,需要将数组拷贝到内核态
但有时候poll低并发场景下适合,这样维护成本也低
I/O多路转接之epoll
这个是三个多路转接方案中最优的,高并发场景下优解
函数介绍
这 3 个函数从设计上彻底解决了 select/poll 的遍历、拷贝、数量限制问题,是 Linux 高并发网络编程的核心接口。
epoll_create

这个函数就是创建一个epoll模型,返回一个fd,后面通过这个fd来控制
参数(历史参数,无实际意义)早期用来指定 "期望监听的 FD 数",现在只需传 > 0 的数即可
返回值:
成功返回一个epoll 文件描述符(epfd),后续通过这个 fd 操作 epoll 实例;
失败返回-1,并且设置错误码
epoll 实例是内核级别的,创建后内核会为其分配两块核心内存:
存放 "要监听的 FD 集合"(红黑树结构,增删改 O (logn));
存放 "就绪的 FD 列表"(双向链表,遍历 O (1));
对于epoll_create1,这个更安全
可选值:①
0:和 epoll_create 等价;②EPOLL_CLOEXEC:创建的 epoll_fd 会在 exec 时关闭(安全)
子进程并不需要这个 epfd(它有自己的业务逻辑),但会一直持有;
只要子进程不主动关闭 epfd,这个 fd 就不会被释放,导致:
文件描述符泄漏 :系统最大文件句柄数(
ulimit -n)有限,大量无用 epfd 会耗尽 fd 资源;逻辑异常 :如果子进程误操作 epfd(比如调用
epoll_ctl),会干扰父进程的 epoll 监听逻辑;资源无法回收:父进程退出后,子进程持有的 epfd 会一直占用内核的 epoll 实例资源(红黑树、就绪链表)。
如果fork之后exec那就无法操控epfd了,此时失去了对epfd的控制,别的进程就可能修改,导致干扰父进程,甚至如果别的进程一直运行,即使父进程的epfd数组释放了,但是内核还不会释放,因为别的进程的epfd数组一直都持有,导致fd泄漏
问题:fd泄漏和内核无法释放红黑树
所以使用epoll_create1更安全
epoll_ctl

epfd:之前使用epoll_create来创建的实例的fd
op:对 fd 的操作类型
①增:
EPOLL_CTL_ADD:添加 fd 到 epoll 实例(监控室加新 FD);②删:
EPOLL_CTL_DEL:从 epoll 实例删除 fd(监控室移除 FD);③改:
EPOLL_CTL_MOD:修改 fd 的监听事件(改 FD 的监控规则)fd:要管理的目标 FD(比如监听 fd lfd、客户端 fd cfd)
event:要监听的事件(输入参数)
cpp//The event argument describes the object linked to the file descriptor fd. //The struct epoll_event is defined as: typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };EPOLLIN : 表示对应的文件描述符可以读 ( 包括对端 SOCKET 正常关闭 );
EPOLLOUT : 表示对应的文件描述符可以写 ;
EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 ( 这里应该表示有带外数据到来 );
EPOLLERR : 表示对应的文件描述符发生错误 ;
EPOLLHUP : 表示对应的文件描述符被挂断 ;
EPOLLET : 将 EPOLL 设为边缘触发 (Edge Triggered) 模式 , 这是相对于水平触发 (Level Triggered) 来说的 .
EPOLLONESHOT :只监听一次事件 , 当监听完这次事件之后 , 如果还需要继续监听这个 socket 的话 , 需要再次把这个socket加入到epoll队列里返回值:
成功返回0
失败返回-1,并且设置错误码
epoll_wait

epfd:之前创建的epoll实例的fd
events:输出参数!内核返回的就绪事件数组(存放所有就绪的 FD 和事件)
maxevents:就绪数组的最大长度(必须 > 0,比如 1024)告诉内核最多往
events数组里写 maxevents个就绪事件timeout:超时时间(-1=永久阻塞;0=非阻塞立即返回;>0=超时等待timeout秒)
返回值:
成功返回就绪事件fd的个数
失败返回-1,并且设置错误码
补充知识:为什么内核知道网络/网卡当中有数据了???
内核不是 "轮询网卡有没有数据",而是通过硬件中断 + 软中断 + 内核事件驱动的方式,被动感知网卡数据,并把就绪事件高效通知给 epoll。
网卡收到数据 → 触发硬件中断 → 内核中断处理程序拷贝数据 → 软中断处理TCP/IP协议 → 数据放入对应socket的内核读缓冲区 → 内核检查该socket是否在epoll监听列表 → 把socket加入epoll就绪链表 → epoll_wait唤醒返回就绪事件
如果学了数电的知识还能更加深入的理解
网卡接收到数据,会存放到自己的硬件缓冲区,此时发送中断请求给中断控制器,控制器当中有编码器,编成一个唯一的网卡的中断信号,还会检查cpu是否屏蔽这个信号,然后用总线引脚再发送给cpu一个高电平脉冲信号(数电里的 "电平触发" 或 "边沿触发",取决于中断类型);cpu响应中断,执行中断处理程序,cpu中的中断译码器会解码,有个中断向量表,向量表中有对应信号的处理函数,发现是网卡的中断,并且是网卡有数据,就会执行对应的函数将数据拷贝到内核缓冲区,后面就是软中断,内核处理tcp/ip等解析
一个硬件的驱动不仅仅要注册read/write等函数,还需要注册中断函数,中断信号和中断函数的对应关系,后面中断信号来了,内核就知道要去执行什么函数硬件厂商遵循操作系统内核的标准接口编写驱动程序 → 操作系统加载驱动时,调用内核统一接口将中断处理函数注册到内核的中断映射表中。
理解epoll的工作原理,epoll模型为何如此高效???

红黑树相关文章https://blog.csdn.net/Laydya/article/details/146770518
epoll_create的本质就是创建红黑树和双向循环链表
但是底层的实际物理结构是同一块内存,逻辑看成两种数据结构,一个通过left和right来调节红黑树,一个通过prev和next来调节双向循环链表
当创建的时候,创建结构体struct file,对应的
struct file会通过private_data字段指向这个eventpoll对象,epoll 的核心数据结构(红黑树、就绪链表)都封装在struct eventpoll中,最后通过epfd来管理这个模型
cpp// 1. epoll的核心对象:封装红黑树、就绪链表 struct eventpoll { // 关键1:红黑树的根节点 ------ 存储所有被监听的fd(event) struct rb_root_cached rbr; // 关键2:就绪链表 ------ 存储已就绪的fd(event) struct list_head rdllist; // 等待队列 ------ 阻塞在epoll_wait的进程 wait_queue_head_t wq; // ... 其他字段(如锁、标志位) }; // 2. epfd对应的struct file(简化版) struct file { const struct file_operations *f_op; // epoll的文件操作集(epoll_file_ops) loff_t f_pos; void *private_data; // 关键!指向struct eventpoll对象 // ... 其他字段 };通过epoll_ctl告诉内核需要关心的fd,本质就是往红黑树当中插入结点
当网卡收到数据之后,cpu硬中断,内核后续处理软中断,数据解析之后放到接收缓冲区,随后调用可读回调函数,内核会调用
sock_def_readable()(socket 可读回调函数);这个函数会检查:当前sock是否被 epoll 监听(即是否关联了struct epitem);如果没被监听:什么都不做,等待用户主动调用
read(sockfd)读取数据;如果被监听:进入下一步(epoll 通知逻辑)。
内核通过
sock关联的struct epitem,找到对应的struct eventpoll(epoll 核心对象);检查该epitem是否已经在eventpoll的就绪链表(rdllist) 中
- 如果不在:把
epitem加入rdllist,并标记该epitem为 "就绪";- 如果已在(水平触发 LT):不重复加入(避免重复通知);
- 如果是边缘触发(ET):仅在 "数据从无到有" 时加入一次(即使数据没读完,也不重复通知)。
唤醒 epoll_wait 阻塞的进程(进程调度)
被唤醒的进程继续执行
epoll_wait函数:
- 遍历
eventpoll的就绪链表(rdllist);- 把就绪的
sockfd、事件类型(如 EPOLLIN)等信息,拷贝到用户态传入的events数组中;- 返回就绪事件的数量(n),
epoll_wait调用结束。这里内核无需遍历/轮询所有的结点,仅仅需要把就绪双向链表中的结点拷贝给用户数组即可
我们把整个数据结构+回调机制称为epoll模型
对于三个函数,第一个就是创建epoll模型,返回的文件描述符便于后面两个函数找到这个epoll模型,也就是epoll_ctl找到这个模型,对红黑树进行增删查改,然后wait是找到这个模型,然后找到就绪队列
所以就绪队列当中都是就绪的,我们只要检测哪些事件就绪就行,不像前面讲的,还要遍历一遍,我们这里不需要遍历,因为就绪队列当中都是就绪的
我们完全可以在应用层设一个缓冲区,然后拿到缓冲区处理,如果一次拿不完没事,因为是队列先进先出,后面再拿
辅助数组就是这里的红黑树,只不过之前的辅助数组需要我们去维护,需要我们增删查改(logN),而红黑树是os给我们维护的,通过key=fd进行排序的
代码编写
仅仅需要修改一下部分逻辑,设计成epoll模型即可,不需要循环遍历,只需要取出就绪结点进行分析即可
cpp
void server_init(){
_sockfd = socket(AF_INET,SOCK_STREAM,0);//创建套接字
if(_sockfd<0){
//创建失败
std::cerr<<"socket error:"<<errno<<strerror(errno)<<std::endl;
exit(1);
}
//创建结构体然后bind
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(_port);
server_addr.sin_addr.s_addr = inet_addr(_ip.c_str());
if(bind(_sockfd,(struct sockaddr*)&server_addr,sizeof(server_addr))<0){
//bind失败
std::cerr<<"bind error:"<<errno<<strerror(errno)<<std::endl;
}
//listen监听模式
if(listen(_sockfd,5)<0){
//listen失败
std::cerr<<"listen error:"<<errno<<strerror(errno)<<std::endl;
}
//listen成功,要加入epoll模型
_epollfd = epoll_create(1);
if(_epollfd<0){
//epoll创建失败
std::cerr<<"epoll error:"<<errno<<strerror(errno)<<std::endl;
}
struct epoll_event event;
event.data.fd = _sockfd;
event.events = EPOLLIN;
if(epoll_ctl(_epollfd,EPOLL_CTL_ADD,_sockfd,&event)<0){
//epoll_ctl失败
std::cerr<<"epoll_ctl error:"<<errno<<strerror(errno)<<std::endl;
}
}
void server_start(){
while(1){
struct epoll_event event[DEFAULT_EVENTS];
int n= epoll_wait(_epollfd,event,DEFAULT_EVENTS,-1);
for(int i=0;i<n;i++){
//这里肯定有就绪事件了
handler_event(event[i]);
}
}
}
void handler_event(epoll_event &event)
{
// 检查
int fd= event.data.fd;
if (event.events & EPOLLIN){
if(fd == _sockfd){
// 监听套接字就绪,接受新连接
std::cout << "listen fd:" << fd << " is ready" << std::endl;
accept_fd(fd);
}
else{
// 普通连接套接字就绪,接收数据
std::cout << "fd:" << fd << " is ready" << std::endl;
recver(fd);
}
}
}
void recver(int sockfd)
{
char buf[1024] = {0};
int len = read(sockfd, buf, sizeof(buf) - 1);
if(len>0){
std::cout << "client saying:" << buf << std::endl;
}
else{
// 断开连接
close(sockfd);
// 删除监听
epoll_ctl(_epollfd, EPOLL_CTL_DEL, sockfd, NULL);
}
}
void accept_fd(int sockfd){
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
int client_fd = accept(_sockfd, (struct sockaddr *)&client_addr, &client_addr_len);
if (client_fd < 0){
// accept失败
std::cerr << "accept error:" << errno << strerror(errno) << std::endl;
}
// 调用成功
// std::string client_ip = inet_ntoa(client_addr.sin_addr);
// int client_port = ntohs(client_addr.sin_port);
// 将监听套接字加入epoll中
std::cout << "accept a new client:" << client_fd << std::endl;
epoll_event event;
event.data.fd = client_fd;
event.events = EPOLLIN;
if( epoll_ctl(_epollfd, EPOLL_CTL_ADD, client_fd, &event)<0){
// epoll_ctl失败
std::cerr << "epoll_ctl error:" << errno << strerror(errno) << std::endl;
}
}
基本逻辑都是一样的
实验结果:
这里的fd=3是监听套接字,后面的5是客户端,4呢?4就是epfd,用来找epoll模型的
注意这里的代码是有缺陷的:如果数据不完整呢?
第一你怎么保证你读到的是一个完整的报文,第二如果你循环读(加一个for循环一直等),比如第一次有一半的数据,还没有传完,你要读到\r\n才算一个完整的报文,所以你for循环读取,这时候就变成阻塞在read这里了,因为你是内部循环,第一次epoll模型提醒你有数据了,但都不是一个完整的报文,然后你去读,又想读完整的,for循环阻塞住了,整个服务器直接阻塞在这个read了,因为你后面即使有数据你一直for一直阻塞,根本退不出去
这个缺陷的本质:"字节流的无边界性" + "阻塞 read 破坏 epoll 模型"
解决方案:第一设置fd成非阻塞,后面read增加判断逻辑,应用层把收到的不完整的报文进行暂时存储,后续来了再拼接成完整的报文进行处理
epoll的两种通知模式
什么叫有就绪事件?就绪就是底层的IO条件满足了,可以进行某种IO行为了
对此,epoll模型有两种通知模式
水平触发(Level Trigger,简称 LT) 和 边缘触发(Edge Trigger,简称 ET)
这个学了数电能够更好的理解,有低电平和高电平,边缘触发是低变高的时候,水平触发是处于高电平的时候
LT:当缓冲区有数据,epoll通知,但是read没有读取完,epoll下回还接着通知
- 简单、安全:即使程序漏读了数据,epoll 会持续通知,不会丢失数据;
- 无需循环读:可以一次读一部分,后续 epoll 会再次触发通知;
- 默认模式:epoll 创建后,fd 默认是 LT 模式。
ET:缓冲区有数据,epoll通知,read没有读取完,下次就不通知了,直到数据变化(再次增多)
- 高性能:减少不必要的通知次数,适合高并发场景;
- 必须循环读 / 写 :收到通知后,必须用非阻塞 fd 循环读 / 写,直到返回
EAGAIN(否则会丢失数据);- 需显式设置 :在
epoll_event.events中添加EPOLLET标志,才能启用 ET 模式。event.events = EPOLLIN | EPOLLET; // 同时设置EPOLLIN(读事件)和EPOLLET(ET模式)
总结:LT是会重复入就绪队列,ET只有数据状态变化才会入队列
为什么这个ET必须配合非阻塞fd???
假设你的应用层缓冲区是512字节,底层的是2048字节,如果是阻塞式+ET模式,你第一次只是读取了部分数据,但是ET模式就不在通知你了,这会导致数据长时间没有被读取甚至丢失,除非你的数据很快发送了变化,此时epoll就会通知你
接着你提出来解决方案,就是使用for循环一直读取,但是此时你的for第一次读第二次读,直到后面就会阻塞在read根本退不出循环,因为底层缓冲区没有结束标识符
所以ET只能和非阻塞fd配合,使用read的返回值为-1+宏判断,如果读到EAGAIN就表示本次缓冲区为null
- 应用层用 ET 模式(配合非阻塞 + 循环读),能快速读空接收缓冲区;
- 接收缓冲区空了,接收方会给发送方通告更大的滑动窗口,发送方可以更快发送数据,提升 TCP 层的传输效率;
- 这一过程能避免 "接收缓冲区占满导致滑动窗口缩小",同时减少 "TCP 延迟应答时的缓冲区压力";
对于LT阻塞和非阻塞都可以,因为LT会一直通知,所以不怕数据丢失
select和poll只有LT模式,epoll模式是LT,可以调整成ET
简单需求可以使用LT,高性能必须使用ET
Nginx 默认采用 ET 模式使用 epoll.ET 模式大幅减少了 epoll 的 "无效通知次数" 和 "用户态 - 内核态的切换开销(每次通知都要切换)
高并发下,LT 的通知次数是 ET 的 N 倍(N = 数据批次),而 "内核 - 用户态切换" 是操作系统的高成本操作 ------ 次数减半,整体开销会大幅降低(通常能提升 30% 以上的吞吐量)。
ET 不是 "无条件高效",它的高效建立在 "正确使用" 的基础上:
- 必须配合非阻塞 fd:否则会卡在 "循环读 / 写" 中,反而导致服务器卡死;
- 必须实现应用层缓冲区:处理 TCP 粘包 / 分包,避免数据丢失;
- 仅在高并发场景体现优势:如果只有几个客户端,LT 和 ET 的效率差距几乎可以忽略,LT 反而更简单。
epoll的惊群效应
这部分有些面试官可能会考,所以可以看一下这篇文章
Apache与Nginx网络模型
Apache与Nginx网络模型_apache select和nginx epoll模型区别-CSDN博客
总结IO多路转接方案的优缺点
select、poll、epoll 是 Linux 下三种经典的 IO 多路复用技术,核心都是 "用一个线程管理多个 fd",但在设计、性能、适用场景上有明显差异。
select
解决的问题
- 最早的 IO 多路复用方案,解决了 "单线程只能处理一个 fd" 的问题,让一个线程可以同时监听多个 fd 的 IO 事件。
优点
- 兼容性好:几乎所有操作系统都支持(跨平台性强);
- 实现简单:API 接口简单,上手成本低。
缺点
- fd 数量限制 :默认受限于
FD_SETSIZE(通常是 1024),无法监听超过 1024 个 fd;- 效率低 :每次调用
select都要把fd_set从用户态拷贝到内核态,且内核需要遍历所有传入的 fd(O (N) 复杂度);- 不可重用 :
fd_set会被内核修改,每次调用前都要重新初始化;- 仅支持水平触发(LT):无边缘触发(ET)能力。
poll
解决的问题
- 解决了 select 的 "fd 数量限制" 问题(动态数组替代固定位图);
- 解决了 select 的 "事件设置 / 返回不分离" 问题(
events/revents分离,无需重复设置监听事件)。优点
- 无 fd 数量限制,支持监听任意数量的 fd;
events和revents分离,只要监听的事件不变,events无需重新初始化,比 select 更易用;- 事件类型更丰富(支持 POLLPRI、POLLERR 等);
- 兼容性较好(多数类 Unix 系统支持)。
缺点
- 效率低:每次调用仍需拷贝整个 pollfd 数组到内核态,内核遍历所有 fd(O (N) 复杂度);
- 需手动重置
revents:避免残留值导致的误判;重置 revents 是 poll 开发的 "防御性编程"- 仅支持水平触发(LT),无边缘触发(ET)能力;
- 高并发下拷贝开销显著增加(fd 越多,数组越大,拷贝越慢)。
epoll
解决的问题
- 解决了 select/poll 的 **"效率低""拷贝开销大""无 ET"** 三大核心问题,是高并发场景下的最优解。
优点
- 无 fd 数量限制:理论上受限于系统最大文件描述符数(通常可配置到数十万);
- 效率高 :
- 内核用红黑树管理监听的 fd,添加 / 删除 / 查找的复杂度是 O (logN);
- 内核用就绪链表 存储就绪事件,
epoll_wait只需拷贝就绪事件(而非所有 fd),且无需遍历所有 fd(O (1) 复杂度);- 支持两种触发模式:水平触发(LT)和边缘触发(ET),ET 模式大幅减少通知次数,提升高并发性能;
- 可重用:注册的 fd 和事件会被内核持久化存储,无需每次调用都重新传入。
缺点
- 兼容性差:仅 Linux 系统支持,无法跨平台;
- 实现复杂: ET 模式需要配合非阻塞 fd 和应用层缓冲区,代码复杂度
Reactor模式
Reactor(反应器)是高性能 IO 编程的核心设计模式,也是 epoll/select/poll 这类 IO 多路复用技术的典型应用范式,简单说就是 **"事件驱动、被动响应"**:内核检测到 IO 事件(如 fd 可读 / 可写)后,主动通知应用程序,程序再根据事件类型调用对应的处理函数,全程不用主动轮询。特别适合处理高并发、IO 密集型的场景(如服务器开发)。
对于之前的代码就是一个简单的单线程reactor模式,主线程采用epoll监听,监听到了就绪事件使用处理器handler_event进行分配任务,不同的任务对应不同的分配器,比如accept_fd之类的
对于高并发应当实现多线程版本,主线程去监听,别的线程去处理不同的任务
编写代码时的技巧
对于**
EPOLLHUP** :对方关闭了连接(比如客户端调用close),这里我们可以使用epoll 会触发这个事件;
EPOLLERR:文件描述符(如 socket)发生错误(比如网络断开),epoll 会自动触发 这个事件(无需在epoll_ctl中注册)。
cpp// 如果触发了EPOLLERR(错误),就给事件加上EPOLLIN(读)和EPOLLOUT(写)标记 if(events & EPOLLERR) events |= (EPOLLIN | EPOLLOUT); // 如果触发了EPOLLHUP(对方关闭),同样加上读写标记 if(events & EPOLLHUP) events |= (EPOLLIN | EPOLLOUT); // 之后正常处理读事件:如果事件包含EPOLLIN,调用recver(读逻辑) if((events & EPOLLIN) && ...) connections_[sock]->recver_(); // 正常处理写事件:如果事件包含EPOLLOUT,调用sender(写逻辑) if((events & EPOLLOUT) && ...) connections_[sock]->sender_();无论什么逻辑最后都走recver和sender逻辑,在逻辑里面read就可以判断了,这样就减少了编写额外的异常函数,利用
read/write的返回值来判断并处理异常,既简化了代码,又保证了可靠性
发送时的问题
不要在连接建立时就注册 EPOLLOUT 事件,内核发送缓冲区默认是有空间的,连接建立时 EPOLLOUT 会立即就绪,epoll 会持续通知 "写就绪",导致大量无效的写事件处理;正确做法是:只有当 "应用层发送缓冲区有未发送数据" 时,才注册 EPOLLOUT 事件;数据发完后,立即取消注册。
- 上层处理完响应后,先存到应用层发送缓冲区,不直接发送;
- 尝试发送时,用非阻塞
send写数据,根据发送结果决定是否注册 EPOLLOUT 事件;- 数据没发完→注册 EPOLLOUT,等 epoll 通知 "写就绪" 后继续发;
- 数据发完→一定要取消 EPOLLOUT,避免无效通知。
Proactor模式
Proactor 是另一种异步 IO 的设计模式 ,和 Reactor(同步 IO 多路复用)的核心区别是:Proactor 让内核完成 "IO 操作的执行",应用程序只负责 "处理 IO 完成后的结果" ,全程无需主动调用read/write,是真正的 "异步 IO"。
Proactor 是 **"内核帮你做 IO,做完了告诉你结果"的异步 IO 模式,适合对性能要求极高的场景(如高吞吐的存储服务、网络网关);而 Reactor 是"内核告诉你可以做 IO 了,你自己做"** 的同步 IO 多路复用模式,是目前服务端开发的主流(如 Nginx/Redis 早期版本用 Reactor)。
至此学习完了基础IO和高级IO,基本的开发路线已完毕























