👆点击“博文视点Broadview”,获取更多书讯
程序员编写代码执行I/O操作最终都逃不过文件这个概念。
在Unix/Linux世界中,文件是一个很简单的概念,作为程序员我们只需要将其理解为一个N字节的序列就可以了:
b2, b3, b4, ....... , bN
实际上,所有的I/O设备都被抽象为了文件这个概念,一切皆文件(Everything is File),磁盘、网络数据、终端,甚至进程间通信工具管道pipe等都被当成文件对待。
所有的I/O操作也都可以通过文件读写来实现,这一抽象可以让程序员使用一套接口 就能操作所有外部设备,如用open打开文件、read/write读写文件、seek改变读写位置、 close关闭文件等,这就是文件这个概念的强大之处。
文件描述符
《计算机底层的秘密》一书的6.3节讲到用read读取文件内容时,代码是这样写的:
read(buffer);
但这里忽略了一个关键问题,那就是虽然指定了往buffer中写数据,但是我们该从哪里读数据呢?
这里缺少的就是文件,该怎样使用文件呢?
大家都知道,在周末人气高的餐厅通常都会排队,然后服务员会给你一个排队序 号,通过这个序号服务员就能找到你,这里的好处就是服务员不需要记住你是谁、你的名字是什么、来自哪里、喜好是什么、是不是保护环境爱护小动物,等等,这里的关键点就是服务员对你一无所知,但依然可以通过一个号码找到你。
同样地,在Unix/Linux世界要想使用文件,我们也需要借助一个号码,这个号码就被称为文件描述符(file descriptors),其道理和上面那个排队使用的号码一样,因此,文件描述仅仅就是一个数字而已。当打开文件时内核会返回给我们一个文件描述符,当进行文件操作时我们需要把该描述符告诉内核,内核获取到这个数字后就能找到该数字所对应文件的一切信息并完成文件操作。
尽管外部设备千奇百怪,这些设备在内核中的表示以及处理方法也各不相同,但这些都不需要暴露给程序员,程序员需要知道的就是文件描述符这个数字而已。
使用文件描述符来处理I/O如图1所示。
图1 使用文件描述符来处理I/O
有了文件描述符,进程可以对文件一无所知,如文件是否存储在磁盘上、存储在磁盘的什么位置、当前读取到了哪里等,这些信息统统交由操作系统打理,进程不需要关心,程序员只需要针对文件描述符编程就足够了。
因此,我们来完善之前的文件读取程序:
char buffer[LEN];
int fd = open(file_name); // 获取文件描述符
read(fd, buffer);
怎么样,是不是非常简单。
如何高效处理多个I/O
经过了这么多的铺垫,终于来到高并发这一主题了,这里的高并发主要指服务器可以同时处理很多用户请求,现在的网络通信多使用socket编程,这也离不开文件描述符。
如果你有一个web服务器,三次握手成功以后通过调用accept来获取一个链接,调用该函数后我们同样会得到一个文件描述符,通过这个描述符我们就可以和客户端进行通信了。
// 通过accept获取客户端的文件描述符
int conn_fd = accept(...);
服务器的处理逻辑通常是读取客户端请求数据,然后执行某些处理逻辑:
if(read(conn_fd, buff) > 0) {
do_something(buff);
}
是不是非常简单。
既然我们的主题是高并发,那么服务器就不可能只和一个客户端通信了,而是可能会同时和成千上万个客户端进行通信,这时你需要处理的就不再是一个描述符这么简单,而是有可能要处理成千上万个描述符。
为了简单起见,现在我们假设该服务器只需要同时处理两个客户端的请求,有的读者可能会说,这还不容易,一个接一个地处理不就行了:
if(read(socket_fd1, buff) > 0) {
// 处理第一个
do_something();
}
if(read(socket_fd2, buff) > 0) {
// 处理第二个
do_something();
}
这里的read函数通常是阻塞式I/O,如果此时第一个用户并没有发送任何数据,那么该代码所在线程会被阻塞而暂停运行,这时我们就无法处理第二个请求了,即使第二个用户已经发出了请求数据,这对需要同时处理成千上万个客户端的server来说是不能容忍的。
聪明的你一定会想到使用多线程,为每个客户端请求开启一个线程,这样即使某个线程被阻塞也不会影响到处理其他线程,但这种方法的问题在于随着线程数量的增加, 线程调度及切换的开销将开始增加,这显然无法很好地应对高并发场景。
这个问题该怎么解决呢?
这里的关键点在于,我们事先并不知道一个文件描述对应的I/O设备是否是可读的、是否是可写的,在外部设备不可读或不可写的状态下发起I/O只会导致线程被阻塞而暂停运行。
我们需要改变思路。
不要打电话给我,有必要我会打给你
大家生活中肯定会接到过推销电话,而且肯定不止一个,这里的关键点在于推销员并不知道你是不是要买东西,只能来一遍一遍问你,因此一种更好的策略是不要让他们打电话给你,记下他们的电话,有需要的话打给他们,这样推销员就不会一遍一遍地来烦你了(虽然现实生活中这并不可能)。
在这个例子中,你就好比内核,推销员就好比应用程序,电话号码就好比文件描述符,推销员与你用电话沟通就好比I/O,处理多个文件描述符的更好方法其实就在于“不 要总打电话给内核,有必要的话内核会通知你”。
因此,相比《计算机底层的秘密》一书 6.3 节中我们通过 read 函数主动问内核该文件描述符对应的文件是否有数据可读,一种更好的方法是,我们把这些感兴趣的文件描述符一股脑扔给内核,并告诉内核:“我这里有 1 万个文件描述符,你替我监视着它们,有可以读写的文件描述符时 你就告诉我,我好处理”,而不是一遍一遍地问:“第一个文件描述可以读写了吗?第二个文件描述符可以读写吗?第三个文件描述符可以读写了吗?…”
这样应用程序就从繁忙的主动变为了清闲的被动——反正文件描述可读可写时内核会通知我,能偷懒我才不要那么勤奋。
这是一种方便程序员同时处理多个文件描述符的方法,这就是I/O多路复用技术(I/O multiplexing)。
I/O多路复用,I/O multiplexing
multiplexing一词其实多用于通信领域,为充分利用通信线路,希望在一个信道 中传输多路信号,为此需要将多路信号组合为一路,对多路信号进行组合的设备被称为multiplexer,显然接收方接收到信号后要恢复原先的多路信号,这个设备被称为 demultiplexer,如图2所示。
图2 通信领域中的多路复用
回到我们的主题。
I/O多路复用指的是这样一个过程:
(1)我们得到了一堆文件描述符,不管是与网络相关的,还是与文件相关等,任何 文件描述符都可以;
(2)通过调用某个函数告诉内核:“这个函数你先不要返回,你替我监视着这些描 述符,当其中有可以进行读写操作的文件描述符时你再返回”;
(3)该函数返回后我们即可获取到具备读写条件的文件描述,符并对其进行相应的处理。
通过该技术我们可以一次处理多路I/O,在Linux世界中使用I/O多路复用时有这样三种方式:select、poll和epoll。
接下来,我们简单介绍一下这I/O多路复用技术三剑客。
三剑客:select、poll与epoll
本质上select、poll、epoll都是同步I/O多路复用机制,原因在于调用这些函数时如果所需要监控的文件描述符都没有我们感兴趣的事件(如可读可写等)出现时,那么调用线程会被阻塞而暂停运行,直到有文件描述符产生这样的事件时该函数才会返回。
在select这种I/O多路复用机制下,我们能监控的文件描述集合是有限制的,通常不能超过1024个,从该机制的实现上看,当调用select时会将相应的进程(线程)放到被监控文件的等待队列上,此时进程(线程)会因调用select而阻塞暂停运行,当任何一个被监听文件描述符出现,如可读或可写事件时,就唤醒相应的进程(线程),但这里的问题是当进程被唤醒后程序员并不知道到底是哪个文件描述符可读或可写,因此要想知道哪些文件描述符已经就绪就必须从头到尾再检查一遍,这是select在监控大量文件描述符时低效的根本原因所在。
poll和select是非常相似的,poll相对于select的优化仅仅在于解决了被监控文件描述符不能超过1024个的限制,poll同样会有随着监控文件描述数量增加而出现性能下降的问题,无法很好地应对高并发场景,为解决这一问题epoll应运而生。
epoll解决问题的思路是在内核中创建必要的数据结构,该数据结构中比较重要的字段是一个就绪文件描述符列表,当任何一个被监听文件描述符出现我们感兴趣的事件时,除了唤醒相应的进程之外还会把就绪的文件描述符添加到就绪列表中,这样进程 (线程)被唤醒后可以直接获取就绪文件描述符而不需要从头到尾把所有文件描述符都遍历一边,非常高效。
实际上在Linux平台,epoll基本上就是高并发的代名词,大量与网络相关的框架、库等在其底层都能见到epoll的身影。
以上就是关于I/O多路复用的讲解!
本文节选自《计算机底层的秘密》一书,欢迎阅读本书了解更多相关内容。
限时五折优惠,快快扫码抢购吧!
(随机发放签名版)
发布:刘恩惠 审核:陈歆懿
如果喜欢本文 欢迎 在看丨留言丨分享至朋友圈 三连 < PAST · 往期回顾 > 企业级体验:未来体验管理的价值与趋势