游双的书以及网上的很多都不怎么说人话,这里做一个总结,主要针对于epoll、多路复用以及select问题做个归纳总结;
I/O多路复用的含义:这个词以前在OS中见过,但是只是针对于通信DMA方式进行使用,并没有涉及网络通信;
首先明确一点,IO多路复用针对的是对于socket监听以处理上,衡量的是主机CPU是否有效利用;
对于最原始的socket处理情况,无外乎是:
- 创建socket并且绑定一个端口;
- listen监听;
- accept接受客户端的请求;
- 完成请求接受后,通过recv以及send来进行读写;
但是值得注意的是第四部,如果使用recv和send,会导致阻塞,直到读取成功和写入成功,无法再accept其他请求;
此时,如果有并发要求,即多个请求访问,该程序处于阻塞状态,无疑会拖慢时间;
因此,多线程处理应运而生;
当accept一个请求后,就开启一个新的线程进行recv或者send,并发处理,读写请求越多,所需要的线程就越多;
因此,采用上述方案无疑会增大内存空间,并且线程切换也会带来很大的开销;
例如:
// 伪代码描述
while(1) {
// accept阻塞
client_fd = accept(listen_fd)
// 开启线程read数据(fd增多导致线程数增多)
new Thread func() {
// recv阻塞(多线程不影响上面的accept)
if (recv(fd)) {
// logic
}
}
}
上述方法称之为同步阻塞;
同步说明请求和处理必须要同时进行,阻塞则说明接受和发送必须要阻塞进行;
对于接受和发送消息,可以采用非阻塞设定;
即recv和send不再采用阻塞等待形式,立即尝试发送和读取,如果没成功就报错;
所以也有同步非阻塞的操作,即多轮询尝试发送,每次即时判断,例如:
setNonblocking(listen_fd)
// 伪代码描述
while(1) {
// accept非阻塞(cpu一直忙轮询)
client_fd = accept(listen_fd)
if (client_fd != null) {
// 有人连接
fds.append(client_fd)
} else {
// 无人连接
}
for (fd in fds) {
// recv非阻塞
setNonblocking(client_fd)
// recv 为非阻塞命令
if (len = recv(fd) && len > 0) {
// 有读写数据
// logic
} else {
无读写数据
}
}
}
上述例子可以看到,recv为非阻塞,但是需要对每一个fd来进行判断,看看是否有接收数据;
这样会大量的浪费CPU;
上述问题如果可以做以下优化,就会大有提升:
- 是否可以使用某种方式不管是否有无数据,数据来了直接通知接收方即可;
- 是否可以不用轮询;
因此,IO多路复用应运而生;
IO多路复用可以使用一个线程监视多个文件句柄(可以简单的理解成socket),如果某个文件句柄就绪(socket缓冲区接收到数据),就通知应用程序进行相应的读写操作。并且没有文件句柄就绪的时候就阻塞应用进程,交出CPU,让他去进行其他的进程;
这里的IO指的就是网络IO,复用则是针对于这个监视进程;
现今使用的epoll以及select都是基于IO复用思想进行实现的,即一个进程管理一切;
关于数据传输的一些前情提要:这里很多人都没有把数据传输过程系统的结合在一起,看到了一些零散的东西,总结一下;
对于简单的同步阻塞而言,建立socket并且accept建立连接获得fd之后,就可以接收数据;
对于数据传输,有以下途径:
- 从链路到网卡;
- 数据到达网卡之后,会复制进内存;
- 网卡通过中断信号,告诉CPU有数据到达,需要CPU要进行相应的操作;
- CPU运行中断程序,讲数据写入socket对应的接受缓冲区内(通过端口和IP的组合寻找),并且唤醒阻塞等待的进程;
之所以能够唤醒等待的进程,主要是因为进程阻塞之后,就会把进程挂在在等待socket下的等待队列中;
等待队列在fd描述符的空间中,以指针形式指向;
当唤醒后,就会把该进程从等待队列取出,并且执行;
但是这是对于一个进程阻塞,挂在在socket对应的等待队列下,如何监控多个socket呢?
select方案:select方案可以看作最古老的一版本,现在不再用了;
select管理进程会创建一个数组,里面装有所有想要监听的socket的fd(可以通过fd找到所需要的socket);
针对于前情提要的问题,这里就很清楚;
当进程监听多个socket的时候,就会把该进程加在所有的socket等待队列中;
按照之前的前提理论,当一个socket内数据到达,会产生CPU中断,运行中断程序,采用回调函数,唤醒进程;
但是由于数组保存,没有多余的额外信息,进程并不知道是哪个socket来数据;
此时,只能进行O(n)的遍历,找到来数据的socket,进行操作;
往复循环;
因此select的问题显而易见:
- 每次调用select都需要把进程加入到每个socket的等待队列,并且唤醒的时候都需要移除,相当于两次O(n)遍历;
- 每次都将整个fd数组传递给内核,开销时间很大;
- 每次唤醒后,都需要遍历一下fd数组,找到是哪个socket被激活;
缺点显而易见,所以如果可以有以下改进:
- 如果可以只知道哪些socket被激活就好了;
- 如果只传递被激活的fd就好了;
所以epoll应运而生;
epoll方案:epoll可以看做select的加强版;
和简单的fd列表相比,epoll增加了eventpoll对象,其中有三个重要的数据结构:
- 红黑树;
- 双向就绪列表;
- eventpoll等待队列;
红黑树主要是用来存储socket集合,通过红黑树的平衡性质保证插入和释放时间等效;
当需要监听一个新的socket,采用epoll_ctl来进行添加,插入到红黑树内;
进程调用epoll_wait方法,如果就绪队列中有socket,直接读取,如果没有,则将进程阻塞,挂在道等待队列中;
和select不同的是,当某个socket传入数据或者处于就绪状态,中断程序会把对应的socket插入双向就绪列表;
因此,双向就绪列表本质上就存有处于可读写状态的socket;
此时,中断唤醒eventpoll等待队列的处理进程,该处理进程直接查双向就绪列表中的socket即可;
所以可以看到,通过引入eventpoll,相当于增加了一个中间层,来管理socket状态;



