先说总结
IO 多路复用的概念可以从网络 IO 的阻塞模型谈起。早期网络编程通常依赖阻塞的 read 函数读取数据,这会导致线程被阻塞,无法处理其他任务。为避免线程阻塞,常使用多线程来处理新的客户端连接。然而,随着客户端连接数的增加,多线程切换带来的操作系统开销逐渐成为瓶颈,难以支撑高并发的场景。为了解决这个问题,人们开始尝试单线程 + 非阻塞 read 函数的方式。
单线程轮询 + 非阻塞 read 的方案,确实能在一定程度上避免多线程带来的资源消耗与阻塞问题,但频繁的轮询操作同样会浪费系统资源,造成大量无效操作。为此,操作系统提供了 select 和 poll 函数,使程序可以传入一批文件描述符,并获取每个文件描述符的就绪状态。利用这种批量处理的方式,应用程序只需一次系统调用就能获得就绪状态。然而,select 和 poll 函数底层仍依赖遍历的方式来刷新就绪状态,带来一些限制。
具体而言,select 函数存在一些问题,比如较高的内存占用、内部实现为遍历、返回结果仍需用户逐个检查获取就绪状态等。poll 虽然解决了 select 的文件描述符数量限制问题,但与 select 的内部机制依然相似。
相较而言,epoll 函数在设计上采用了异步事件通知机制来获取就绪状态,并且在内核中保留了文件描述符的副本以避免重复分配内存。最终,epoll 返回所有就绪状态的文件描述符,从而成为 IO 多路复用的最佳方案。
综上,IO 多路复用是一种使用单个线程管理所有客户端网络事件的技术。服务器端通过一个线程调用 epoll 获取网络连接的就绪状态,然后利用多线程来处理这些已就绪的连接,从而避免了“一连接一线程”的模式,变为“一有效连接一线程”,极大提升了系统的并发效率。
以下是操作系统实现的细节&源码
网卡 – 内核缓冲区 – 用户缓冲区
# 打开一个网络通信端口,文件描述符
listenfd = socket();
# 绑定
bind(listenfd);
# 监听
listen(listenfd);
while(1){# 阻塞等待客户端建立连接connfd = accept(listenfd);# 阻塞读数据,等待客户端发送的数据就绪(数据经网卡拷贝到了内核缓冲区)int n = read(connfd,buf);# 拿到数据后的具体业务处理dosomeThing(buf);# 关闭连接,循环等待下一个连接close(connfd);
}
阻塞read函数,数据未到达(数据到达网卡并拷贝到了内核缓冲区)时阻塞
同步阻塞模型、多线程网络模型
当客户端连接后,将后续步骤丢给一个新的线程处理,即为多线程网络模型,也是同步阻塞模型
# 打开一个网络通信端口,文件描述符
listenfd = socket();
# 绑定
bind(listenfd);
# 监听
listen(listenfd);
while(1){# 阻塞等待客户端建立连接connfd = accept(listenfd);# 创建一个新的线程处理,然后立刻返回,继续下一个循环pthread_create(workNewThread);
}void workNewThread(){# 阻塞读数据,等待客户端发送的数据就绪(数据经网卡拷贝到了内核缓冲区)int n = read(connfd,buf);# 拿到数据后的具体业务处理dosomeThing(buf);# 关闭连接,循环等待下一个连接close(connfd);
}
非阻塞read函数
非阻塞read函数,当数据未就绪时,返回-1,当数据到达时(数据到达网卡,并且从网卡拷贝到内核缓冲区),阻塞读取数据(数据从内核缓冲区拷贝到用户缓冲区)
# 打开一个网络通信端口,文件描述符
listenfd = socket();
# 绑定
bind(listenfd);
# 监听
listen(listenfd);
while(1){# 阻塞等待客户端建立连接connfd = accept(listenfd);# 将连接放到数据中 addConnfdToArray(connfd);
}void loopHandle(){# 循环数据,依次取出连接,调用非阻塞readwhile(1){connfd = array[index];# 阻塞读数据,等待客户端发送的数据就绪(数据经网卡拷贝到了内核缓冲区)int n = read(connfd,buf);if(n != -1){# 拿到数据后的具体业务处理dosomeThing(buf);# 关闭连接,循环等待下一个连接close(connfd);}}
}
每次遍历遇到read返回-1时,仍然是一次浪费资源的资源调用,在while循环里做系统调用,跟分布式项目里循环做rpc调用一样,是不划算的
select函数
将一批文件描述符通过一次系统调用传给内核,由内核层遍历
int select(int nfds,fd_set *readfds,fd_set *writefds,fd_set *exceptfds,struct timeval *timeout);
// nfds:监控的文件描述符集里最大文件描述符加1
// readfds:监控有读数据到达文件描述符集合,传入传出参数
// writefds:监控写数据到达文件描述符集合,传入传出参数
// exceptfds:监控异常发生达文件描述符集合, 传入传出参数
// timeout:定时阻塞监控时间,3种情况
// 1.NULL,永远等下去
// 2.设置timeval,等待固定时间
// 3.设置timeval里时间均为0,检查描述字后立即返回,轮询
服务端处理流程
- 首先一个线程不断接受客户端连接,将socket文件描述符放到一个list里
- 另一个线程不再自己遍历,而是调用select,将这批文件描述符交给操作系统去遍历
- select 返回后,用户遍历list,已准备就绪的文件描述符会做上标识,用户自行判断处理
select 函数
- select 调用需要传入fd数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的
- select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销
- select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历
这种方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用)
poll 函数
poll 也是操作系统提供的系统调用函数
int poll(struct pollfd *fds, nfds_tnfds, int timeout);struct pollfd {intfd; /*文件描述符*/shortevents; /*监控的事件*/shortrevents; /*监控事件中满足条件返回的事件*/
};
它和select的区别,去掉了select 只能监听1024个文件描述符的限制
epoll 函数
epoll 函数解决了select 和 poll 的一些问题
- 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需要告诉内核修改的部分即可
- 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步IO事件唤醒
- 内核仅会将有IO事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合
具体操作系统提供了三个函数
# 创建一个epoll句柄
int epoll_create(int size);# 向内核添加、修改或删除要监控的文件描述符
int epoll(int epfd,int op,int fd,struct epoll_event *event);# 类似发起了select调用
int epoll_wait(int epfd, struct epoll_event *events, int max events, int timeout);