架构IO多路复用
Posted sysu_lluozh
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了架构IO多路复用相关的知识,希望对你有一定的参考价值。
服务器端编程经常需要构造高性能的IO模型,常见的IO模型有:
- 同步阻塞IO(Blocking IO)
传统的IO模型 - 同步非阻塞IO(Non-blocking IO)
默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK - IO多路复用(IO Multiplexing)
经典的Reactor设计模式,有时也称异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型 - 异步IO(Asynchronous IO)
经典的Proactor设计模式,也称为异步非阻塞IO
一、同步阻塞IO
服务端为了处理客户端的连接和请求的数据,写了如下代码
listenfd = socket(); // 打开一个网络通信端口
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(1)
connfd = accept(listenfd); // 阻塞建立连接
int n = read(connfd, buf); // 阻塞读数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
这段代码服务端的线程阻塞在了两个地方,一个是accept函数,一个是read函数
如果再把read函数的细节展开,发现其阻塞在了两个阶段
- 网卡将客户端发过来的数据拷贝到内核缓冲区
- 内核缓冲区设置关联的文件描述符为可读,将内核缓冲区中的数据拷贝到用户缓冲区
整体流程如下:
所以,如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接
二、非阻塞IO
为了解决上面的问题,其关键在于改造这个read函数
有一种办法是,每次都创建一个新的进程或线程,去调用read函数,并做业务处理
while(1)
connfd = accept(listenfd); // 阻塞建立连接
pthread_create(doWork); // 创建一个新的线程
void doWork()
int n = read(connfd, buf); // 阻塞读数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
这样,当给一个客户端建立好连接后,就可以立刻等待新的客户端连接,而不用阻塞在原客户端的read请求上
不过,这不叫非阻塞IO,只不过用了多线程的手段使得主线程没有卡在read函数上不往下处理,操作系统提供的read函数仍然是阻塞
所以真正的非阻塞 IO,不能是仅通过用户层处理,而是要操作系统提供一个非阻塞的read函数
这个read函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待
操作系统提供了这样的功能,只需要在调用read前,将文件描述符设置为非阻塞即可
fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);
这样,就需要用户线程循环调用read,直到返回值不为-1,再开始处理业务
这里注意到两个问题:
- 非阻塞的read,指的是在数据到达前,即数据还未到达网卡,或者到达网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的
当数据已到达内核缓冲区,此时调用read函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回 - 为每个客户端创建一个线程,服务器端的线程资源很容易被耗光
整体流程如下:
三、IO多路复用
当然还有个办法,可以每accept一个客户端连接后,将这个文件描述符(connfd)放到一个数组里,然后弄一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞read方法
fdlist.add(connfd);
while(1)
for(fd <-- fdlist)
if(read(fd) != -1)
doSomeThing();
但这和用多线程去将阻塞IO改造成非阻塞IO一样,这种遍历方式只是用户自己想出的小把戏,每次遍历遇到read返回-1时仍然是一次浪费资源的系统调用
在while循环里做系统调用,好比做分布式项目时在while里做rpc请求一样不划算
所以,还是得需要操作系统提供一个有这样效果的函数,将一批文件描述符通过一次系统调用传给内核,由内核层去遍历,才能真正解决这个问题
- 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,检查描述字后立即返回,轮询
首先一个线程不断接受客户端连接,并把socket文件描述符放到一个list里
while(1)
connfd = accept(listenfd);
fcntl(connfd, F_SETFL, O_NONBLOCK);
fdlist.add(connfd);
然后,另一个线程不再自己遍历,而是调用select,将这批文件描述符list交给操作系统去遍历
while(1)
// 把一堆文件描述符 list 传给 select 函数
// 有已就绪的文件描述符就返回,nready 表示有多少个就绪的
nready = select(list);
...
不过,当select函数返回后,用户依然需要遍历刚刚提交给操作系统的list
只不过,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销
while(1)
nready = select(list);
// 用户层依然要遍历,只不过少了很多无效的系统调用
for(fd <-- fdlist)
if(fd != -1)
// 只读已就绪的文件描述符
read(fd, buf);
// 总共只有 nready 个已就绪描述符,不用过多遍历
if(--nready == 0) break;
可以看出几个细节:
- select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的(可优化为不复制)
- select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销(内核层可优化为异步事件通知)
- 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
还记得上面select的三个细节么?
-
select调用需要传入fd数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的(可优化为不复制)
-
select在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销(内核层可优化为异步事件通知)
-
select仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
所以 epoll 主要就是针对这三点进行了改进。 -
内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可
-
内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒
-
内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合
具体,操作系统提供了这三个函数
第一步,创建一个 epoll 句柄
int epoll_create(int size);
第二步,向内核添加、修改或删除要监控的文件描述符
int epoll_ctl(
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);
具体流程如下:
以上是关于架构IO多路复用的主要内容,如果未能解决你的问题,请参考以下文章
五种网络IO模型以及多路复用IO中select/epoll对比
阻塞、非阻塞、多路复用、同步、异步、BIO、NIO、AIO 一文搞定