什么是IO多路复用

185 阅读9分钟

一、什么是IO

I : Input (输入)

O: Output(输出)

简单理解:磁盘读写操作 (输入流写入,输出流读取)

二、那什么是流???

假设我们有个大水缸,水缸里灌满了水,为了能够让水从大水缸里拿出来使用,就需要接一个水龙头,打开水龙头,水就出来了。可是大水缸除了放水,我们还希望能不断的蓄水,于是就需要有另外一个口可以给水缸蓄水,蓄水口默认是关闭的,每次都要打开蓄水口。假设我们往大水缸里蓄水,水有N杯,我们只能一杯一杯的往里灌,每次灌入就要打开蓄水口,为了提高蓄水速度,我们想到了一个办法,准备一个盆,每个盆只能装512杯水,当盆装满后,再从盆里往水缸里灌入。

水就相当于计算机里的数据,而数据是有顺序而且以字节方式存在的,入水口和出水口对应输入输出流,而后面加的盆就是内核缓冲区,大水缸就是我们的磁盘,磁盘操作相对于cpu的处理速度来说非常慢,所以为了提高效率,引入了内核缓冲区。

三、什么是多路复用????????

把多路来源的信号通过一定的技术处理,让它们在同一条信道上传输而不产生相互的干扰

四、什么是阻塞I/O????????

服务端处理客户端的连接和请求的数据(伪代码如下)

listenfd = socket();   // 打开一个网络通信端口
bind(listenfd);        // 绑定
listen(listenfd);      // 监听
while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

可以看到,服务端的线程阻塞在了两个地方

一个是 accept 函数

一个是 read 函数。

如果再把 read 函数的细节展开,我们会发现其阻塞在了两个阶段。

这就是传统的阻塞IO


所以,如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 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。

六、什么是IO多路复用

方法一:为每个客户端创建一个线程

服务器端的线程资源很容易被耗光

方法二:每 accept 一个客户端连接后,将这个文件描述符(connfd)放到一个数组里,然后弄一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞 read 方法

这样,我们就成功用一个线程处理了多个客户端连接。


是不是有点多路复用的feel了????????

但这和我们用多线程去将阻塞 IO 改造成看起来是非阻塞 IO 一样,这种遍历方式也只是我们用户自己想出的小把戏,每次遍历遇到 read 返回 -1 时仍然是一次浪费资源的系统调用。

所以,有没有办法又不浪费系统调用资源,又不浪费线程????

操作系统老大能不能提供给我们一个有这样效果的函数,我们将一批文件描述符通过一次系统调用传给内核,由内核层去遍历(而不是在用户态调用,再陷入到内核态中去遍历),才能真正解决这个问题。

答案是:有!

select

select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理:

从这看,怎么来优化上面的服务端代码呢?

首先一个线程不断接受客户端连接,并把 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);
  ...
}

优点:

优化了非阻塞IO的模型,不会过度创先(占用)线程

缺点:

1、受 fd_set 的大小限制,32 位系统最多能监听 1024 个 fd,64 位最多监听 2048 个(最大数量限制)

2、select 需要维护一个用来存放文件描述符fd的数据结构,每次调用select都需要把fd集合从用户区拷贝到内核区,而select系统调用结束后,又需要把fd集合从内核区拷贝到用户区,这个系统开销在fd数量很多时会很大(内存复制开销)

3、每次调用select系统调用时,都需要在内核遍历传入的整个文件描述符集合,逐个检测,查看是否有就绪的文件描述符,然后返回就绪文件描述符的个数。也就是说,select对文件描述符是线性扫描的,当注册的文件描述符fd的数量很多时,效率会较低,时间复杂度为O(n)(时间复杂度)

poll

poll的实现原理和select基本一致,在select基础上进行了部分优化

优化点:基于链表结构来存储,将文件描述符和与其对应的事件关联起来

优点(对比select):

1、没有最大文件描述符数量的限制(相对select而言)。(基于链表存储)poll 主要是解决了这个最大文件描述符数量的限制问题。

当然,它还是有上限的,这个上限是操作系统所支持的能开启的最大文件描述符数量(cat /proc/sys/fs/file-max)。

2、优化了编程接口。select()函数有5个参数,而poll()减少到了3个参数。并且每次调用select函数前,都必须重置该函数中的3个fd_set类型的参数值,而poll不需要重置。

缺点:

poll 同样需要维护一个用来存放文件描述符的数据结构(pollfd),当注册的文件描述符无数量很多时,会使得用户区和内核区之间传递该数据结构的复制开销很大。(内存复制开销)

综合poll和select的性能局限性缺点:

select 和 poll 都需要在返回后,通过遍历整个文件描述符集合来获取就绪的文件描述符。事实上,在网络连接中,同时连接的大量客户端在某一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的递增,其效率也会线性递减。

epoll

是之前select和poll的增强版本

epoll使用一个epoll文件描述符管理多个被监听的文件描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户区和内核区只需要拷贝一次被监听的文件描述符的数据结构(epoll_event)即可。

epoll 既解决了select的最大文件描述符数量限制的问题,又解决了poll的内存复制开销大、时间复杂度大的问题

优点:(对比select和poll)

1、和poll一样,没有最大文件描述符数量的限制(相对select而言)。

2、epoll 虽然也需要维护用来存放文件描述符的数据结构(epoll_event),但是它只需要将该数据结构拷贝进内核区一次,不需要重复拷贝。

总结

一切的开始,都起源于这个操作系统提供的 read 函数

由此引发了一系列的演进

阻塞IO -> 非阻塞IO -> IO多路复用

select -> poll ->epoll

这个演进引发什么思考?

为什么IO多路复用效率高?