网络通信就是通过服务端监听客户端的请求连接,建立连接,读取数据,进行处理。
我们来讲讲IO多路复用:
由于CPU的速度IO速度的上百倍,所以如何处理IO速度减少性能差距就成了难题。
为了方便理解,本节大部分采用伪代码的形式。
1、通过socket进行http通信(阻塞IO)
listenfd = socket(); // 打开一个网络通信端口
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(1) {
connfd = accept(listenfd); // 阻塞建立连接
int n = read(connfd, buf); // 阻塞读数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
}
代码执行顺序如下:
服务端会阻塞在accept函数,在客户端connect建立连接后,
又会阻塞在read函数,直到客户端执行write函数。
整体流程图如下:
阻塞IO的方法一旦有多个用户访问的情况下就需要一个个排队,且若客户端建立连接之后没有发送数据,则服务端会一直阻塞在read阶段无法往下进行,为了解决这个方法,需要使用多进程/线程。
2、非阻塞IO
while(1) {
connfd = accept(listenfd); // 阻塞建立连接
pthread_create(doWork); // 创建一个新的线程
}
void doWork() {
int n = read(connfd, buf); // 阻塞读数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
}
多线程关键点就在于主线程只负责监听连接请求,子线程处理业务逻辑,这里指的是读数据。
主线程在建立连接之后,创建子线程安排任务后,继续监听连接请求;而子线程来读取数据。
这样就可以通过多线程来解决卡死在read函数的问题,不过这样可算不上是非阻塞IO。而真正的非阻塞IO需要通过设置,将read函数稍作改变,执行read时会判断fd文件描述符(下文简称fd)是否就绪,若没有则返回-1直接退出,而不是阻塞在原地一直等待fd就绪。
设置的代码如下:
//对文件描述符设置非阻塞
int setnonblocking(int fd)
{
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}
流程如下:
这里有一个小瑕疵,当数据到内核缓冲区之前是非阻塞的,也就是fd就绪前,但从内核缓冲区到用户缓冲区的时候是阻塞的。整体流程如下:
虽然有所瑕疵,不过性能还是得到了较好的优化效果。但存在一个问题,当前的程序是主线程监听连接,为每一个用户创建一个子线程读取数据,处理业务,当处于高并发的环境下时,给每一个用户都创建线程资源很容易就被消耗殆尽。而仔细想想,资源浪费在每一个子线程都只循环read自己的fd上面,这就是最大的优化点。
所以解决方法就是,再分化出一个管理线程,管理线程来循环read每一个fd,当就绪时才分配工作线程。主线程在建立连接时就将fd放入fd数组中即可。
那么现在线程就分成了三类:主线程(监听连接);管理线程(遍历fd);工作线程(处理业务)
这样就使用了一个线程管理多个客户端,慢慢开始有框架出来了,开始有多路复用的意思了。
但还存在一个问题,虽然从每一个子线程read自己的fd变成了只有一个管理线程read所有的fd,但fd一旦多起来,管理线程在用户态read未就绪的fd时也存在着一定的性能消耗,于是为了解决这个问题,select就登场了。
3、select
select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理:
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,检查描述字后立即返回,轮询
让我们来整理下思路,主线程监听连接,将建立连接的fd放入数组,管理线程通过select只遍历已就绪的fd,工作线程处理业务。
当 select 函数返回后,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销。
可以看出几个瑕疵:
(1)select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
(2) select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
(3) select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
可以看到,这种方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用)。
4、poll
poll跟select几乎一模一样,只是取消了select的最大fd个数1024的限制,因为select是通过线性表存储,poll是通过链表,这里就不再多说了。
5、epoll
epoll也就是现在市面上使用最多的方式,解决了刚刚select所说的三个缺点:内核存在拷贝,内核是同步IO,只返回个数。epoll的底层是通过红黑树存储的。
(1)epoll仅仅只需要在第一次运行时将fd拷贝到内核态中,其余时刻仅需对一些仅有的fd进行传输。
(2)内核不再是通过轮询的方式找出就绪的文件描述符,而是通过异步IO通知唤醒,也就是文件就绪的时候自动通知,而非自己主动监听。
(3)内核只会返回就绪的fd,而非整体再进行遍历。
同时epoll支持ET和LT模式,而select和poll只支持LT。
LT是指电平触发(level trigger),当每有一个IO事件就绪时,内核会通知所有就绪但未处理的fd,直到该IO事件被处理;
ET是指边沿触发(Edge trigger),当每有一个IO事件就绪时,内核只会通知这次就绪的fd而非全部,如果在这次没有及时处理,该IO事件就不会再次通知了。
系统给出了epoll相关的三个函数:
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 max events, int timeout);
typedef union epoll_data {
无效* ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t事件; / * Epoll事件* /
epoll_data_t数据; / *用户数据变量* /
};
其中epoll_ctl的op参数的有效值为:
EPOLL_CTL_ADD:在文件描述符epfd所引用的epoll实例上注册目标文件描述符fd,并将事件事件与内部文件链接到fd。
EPOLL_CTL_MOD:更改与目标文件描述符fd相关联的事件事件。
EPOLL_CTL_DEL:从epfd引用的epoll实例中删除(注销)目标文件描述符fd。
events 成员变量:
可以是以下几个宏的集合,实际应用中经常用到(如游双的TinyWebServer):
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误; EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
epoll是用额外的空间(空间换时间)将活跃的fd挑出来返回过来,所以在高并发的情况下,只会处理活跃的连接数,非活跃的连接占用的资源非常少。但并不是所有情况下epoll的性能都优于select和poll的,若活跃的连接占大多数(有点像占空比的意思),理想点就是所有连接都是活跃的,由于epoll还需要占用额外的空间再保存活跃的连接数,反而会降低了效率,但实际应用中这种情况少之又少。
IO多路复用部分差不多就到这了,我们来总结一下:
第一阶段:IO阻塞,服务端监听连接,创建连接,读取数据,处理业务。若其中有多个客户端,则需要等待。
第二阶段:多线程,主线程监听,创建连接,子线程读取数据,处理业务。若高并发环境下,每一用户都拥有自己的线程,系统资源很容易消耗殆尽。
第三阶段:非阻塞IO,主线程监听,创建连接,管理线程循环监听fd数组,分配工作线程处理业务。若更高并发环境下,fd数组一旦多了管理线程每次read的性能消耗也不容忽略。
第四阶段:select,主线程监听,创建连接,管理线程调用select遍历fd数组,但未就绪的不会执行read进行IO操作,工作线程处理业务。三缺点,内核存在拷贝,内核是同步IO,只返回个数。
第五阶段:poll,同select,主要解决了fd上限为1024的限制。
第六阶段:epoll,主线程监听,创建连接,将fd注册至epoll内核表中,其中fd在内核态只全部拷贝一次,fd就绪会IO异步唤醒,用户态只遍历就绪的fd数组,工作线程处理业务。
这就是IO多路复用模型的演变,需求增加技术也要随之优化。IO多路复用之所以快还是得通过系统内核给出的条件,真正的优化还是需要学习内核,目前我们只是学习使用,学习之路还很漫长。
-----------------------------------------------------------------分割线,如有不对请指出
参考资料(图也是他的,本文只是加了点自己的理解):你管这破玩意叫 IO 多路复用?_程序员小灰的博客-CSDN博客