I/O 多路复用技术概述

最开始,传统的 socket 模型效率极差,下面我通过伪代码描述整个过程,服务端代码如下:

// 创建网络套接字
s = socket();
// 绑定端口及其它配置信息
bind(s);
// 开启监听
listen(s);
// 循环操作
while (true) {
	// 建立连接
	c = s.accept(listenfd);
	// 读取数据
	int data = read(s, buf);
	// 处理数据
	doSomething(buf);
	// 断开连接
	close(s);
}

客户端代码如下:

// 创建网络套接字
s = socket();
// 建立连接
connect(s);
// 发送数据
write(s, buf);
// 断开连接
close(s);

整个处理流程如下所示:

  1. 服务端绑定端口,调用 accept() 方法阻塞等待客户端连接
  2. 客户端连接服务端后,调用 write() 方法发送数据,数据发送完毕后断开连接
  3. 服务端调用 read() 方法阻塞等待客户端数据,收到数据后处理并关闭连接并跳转到步骤一,无限循环

上述流程很明显存在以下几个问题:

  1. 服务端无法同时处理多个客户端连接,而且阻塞读取数据时无法建立连接
  2. 服务端无法区别不同客户端,需要在消息中通过标识区分不同客户端
  3. 最致命的一点,假如某个客户端建立连接后一直不发数据,服务端再也无法处理任何请求
  4. 效率极差,每接受一次数据需要执行两次阻塞方法

accept() 是第一个阻塞方法,它需要阻塞等待客户端连接,连接基于 TCP 三次握手建立
read() 是第二个阻塞方法,该方法存在两次阻塞操作,均与 I/O 相关,第一次阻塞等待数据从网卡拷贝到内核缓冲区,第二次阻塞等待数据从内核缓冲区拷贝到用户缓冲区


为了解决上述问题,通过多线程处理 socket 的方式诞生了,具体伪代码如下:

// 创建网络套接字
s = socket();
// 绑定端口及其它配置信息
bind(s);
// 开启监听
listen(s);
// 循环操作
while (true) {
	// 建立连接
	c = s.accept(listenfd);
	pthread_create(work);
}

void work() {
	int n = read(s, buf); 
	doSomeThing(buf);
	close(connfd);
}

如上图所示,此时可以解决服务端无法处理多个客户端连接的问题,实现也很简单,每个客户端对应一个线程,在子线程中实现处理逻辑,主线程一直循环等待客户端连接。这样做的好处有以下几点:

  1. 服务端主线程调用只包含一个阻塞方法(阻塞等待客户端连接),read() 阻塞方法在子线程中调用
  2. 服务端可以同时处理多个客户端连接,即使某个客户端一直不发数据,也只会导致某个子线程一直阻塞,并不影响主线程建立连接

虽然多线程 socket 模型可以同时处理多个客户端,但线程毕竟是珍贵资源,如果每个客户端都维护一个线程的话,开销太大,不能支持多客户端。而且它仍然没有解决 socket 模型效率低下的主要原因:I/O 操作阻塞太严重

read() 方法阻塞等待 I/O 处理导致 CPU 绝大多数时候处于等待的状态,为了充分利用 CPU 资源,操作系统内核提供了全新的、非阻塞的 read() 方法,只需在调用 read() 方法前将 socket 对应文件描述符设置为非阻塞状态即可,如果数据没到达直接返回 -1,无须阻塞等待

有了非阻塞的 read() 方法,整体逻辑又可以优化为这样:

void work() {
	while(read(s, buf) == -1){
	}
	doSomeThing(buf);
	close(s);
}

这里有个细节需要注意,socket 传送数据时,整个数据中转流程如下:网卡 -》内核缓冲区 -》用户缓冲区,这里返回 -1 是说数据还未达到内核缓冲区,也就是说,数据未到达内核缓冲区前是非阻塞的,数据达到内核缓冲区后调用 read() 方法,数据从内核缓冲区拷贝到用户缓冲区的过程仍然是阻塞的,线程需要等待数据拷贝完毕后才能恢复


有了非阻塞的 read() 方法可以提高 CPU 利用率,让线程不在阻塞,但它仍然没解决客户端连接线程一对一问题,至此终于到本篇的标题,I/O 多路复用技术

最开始的多路复用思路是这样的:一旦服务端建立连接,将对应 socket 的文件描述符放入数组中,创建一个线程无限遍历这个数组,依次调用 read() 方法,这样就可以通过一个线程处理多个连接,具体伪代码如下:

while(1) {
	for(fd <-- fdlist) {
		if(read(fd) != -1) {
			doSomeThing();
		 }
	}
}

这种方式虽然能通过单个线程处理多个客户端连接,但效率只会更差,主要原因有两点:

  1. 假设某个 read() 方法不返回 -1,它就是一次阻塞调用,此时剩余的所有客户端请求都需要等待这段阻塞时间
  2. 即使 read() 方法返回 -1,其中也涉及到系统调用,存在上下文切换,效率不高

总的来说,从应用程序角度是没法实现真正的多路复用技术的,因为无论你怎么处理,涉及到系统调用、阻塞这种操作系统内核维护的问题无法解决


到这里,第一代操作系统维护的多路复用技术产生了:select 系统调用,其中它的大体思路如下:

用户无须创建线程遍历文件描述符,而是将文件描述符数组发送给操作系统,让操作系统去遍历,确定哪个文件描述符可以读写,然后告诉我们去处理

select() 系统调用函数定义如下:

// nfds:监控的文件描述符集里最大文件描述符加1
// readfds:监控有读数据到达文件描述符集合,传入传出参数
// writefds:监控写数据到达文件描述符集合,传入传出参数
// exceptfds:监控异常发生达文件描述符集合, 传入传出参数
// timeout:定时阻塞监控时间
int select(
    int nfds,
    fd_set *readfds,
    fd_set *writefds,
    fd_set *exceptfds,
    struct timeval *timeout);

此时服务端逻辑可以优化为这样:

while(1) {
	// 建立连接
	connfd = accept(listenfd);
	// 设置非阻塞
	fcntl(connfd, F_SETFL, O_NONBLOCK);
	// 将文件描述符加到链表
	fdlist.add(connfd);
}

while(1) {
	// 调用 select 系统调用,返回就绪的描述符个数
	nready = select(list);
	for(fd <-- fdlist) {
		if(fd != -1) {
			read(fd, buf);
			// 总共只有 nready 个已就绪描述符,不用过多遍历
			if(--nready == 0) break;
			}
	}
}

select 系统调用在收到文件描述符后,会在操作系统层面遍历所有描述符,在就绪的文件描述符做上标识,并返回就绪的个数

总得来说,select 具有以下缺点:

  1. 应用程序需要源源不断的将文件描述符传给操作系统内核
  2. select 方法只会返回就绪的描述符的个数,还需我们自己遍历判断哪个描述符就绪

和上面提到的多路复用实现思路相比,select 只是省去上下文切换的消耗,不过文件描述符越多,提高的效率越多。按上面提到的思路,假设有 n 个描述符每轮循环就需要 n 次系统调用,就绪 m 个就需要再调用 m 次 read() 系统调用、使用 select 就可以节省为 1 次 select() 系统调用和 m 次就绪 read() 系统调用

后来内核又在 select 的基础上提出了 poll,poll 主要去掉 select 最多只能监听 1024 个文件描述符的上限,其它变化不大


最后,操作系统提出了 epoll,epoll 和 select/poll 采用完全不同的处理模式,并解决了很多 select/poll 没有实现的功能:

针对 select/poll 的缺点,epoll 分别做了如下改进:

  1. 内核自己保存文件描述符集合(红黑树结构),不用每次调用 select 时全量传,只需告诉内核修改部分即可
  2. 内核不再采用轮询的方法,改为异步 IO 事件唤醒
  3. 内核只返回就绪的文件描述符,应用程序无需再轮询整个文件描述符集合

其中 epoll 对外提供的方法如下:

// 创建 epoll 句柄
int epoll_create(int size);
// 向内核添加、修改或删除文件描述符
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 发起 epoll 调用
int epoll_wait(int epfd, struct epoll_event *events, int max events, int timeout);

最后简单总结一下:I/O 多路复用技术实际属于随着时代发展,业务量倒逼操作系统内核实现更多功能的过程,这些功能在应用程序中也可以实现,但就像上面说的那样,效率高不了。因为涉及到系统调用存在上下文切换的消耗,I/O 多路复用技术实际节省的也就是这部分上下文切换的损耗


参考:微信公众号《低并发编程》

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值