IO多路复用详解
假如你想了解IO多路复用,那本文或许可以帮助你
本文的最大目的就是想要把select、epoll在执行过程中干了什么叙述出来,所以具体的代码不会涉及,毕竟不同语言的接口有所区别。
基础知识
IO多路复用涉及硬件、操作系统、应用程序三个层面,了解这些知识是很有帮助的。
假如已经了解,可直接跳过
Linux系统中断
中断是指计算机在执行期间,系统内发生任何非寻常的或非预期的急需处理事件,使得CPU暂时中断当前正在执行的程序而转去执行相应的事件处理程序,待处理完毕后又返回原来被中断处继续执行或调度新的进程执行的过程。
硬中断
通过硬件产生相应的中断请求,称为硬中断。
我们的硬件设备如:鼠标、键盘、网卡、磁盘等,假如想要让CPU处理它们的数据(如按下键盘、移动鼠标、处理网卡缓冲区的报文数据等)都需要通过中断控制器(一个硬件设备)向数据总线中发送中断请求(IRQ Interrupt ReQuest的缩写),CPU收到IRQ后会将当前进程信息保存到进程描述符中,然后在中断向量表中找到对应中断处理程序的地址,然后执行中断处理程序,在执行完处理程序后,从进程描述符中恢复原进程。
简化以上过程:外设 ==> 中断控制器 ==> CPU ==> 挂起当前进程 ==> 中断向量表 ==> 中断处理程序 ==> 恢复原进程。
软中断
软中断是在通信进程之间通过模拟硬中断而实现的一种通信方式。软中断仅在当前运行的进程中产生。
我们经常用到的系统调用就是一个软中断,因为中断向量号为0x80故又称80中断。
下面会解释系统调用到底做了什么
延伸阅读:
详解操作系统中断,该文章介绍了8259A中断控制器以及中断触发和处理的过程。
系统调用
上面说过,系统调用是一种软中断。那么操作系统为什么要给我们提供系统调用呢?以及系统调用的实现过程又是如何的?
用户态和内核态
我们知道操作系统本身也是一个程序,我们平时写的程序都跑在操作系统之上,计算机的硬件资源都是由操作系统内核进行管理的。假如我们需要使用某一硬件的资源,是不能直接访问的。因为为了提高操作系统的稳定性和安全性,应用程序需要和系统程序分开。CPU将程序执行的状态分为了不同的级别,从0到3,数字越小,访问级别越高。0代表内核态,在该特权级别下,所有内存上的数据都是可见的,可访问的。3代表用户态,在这个特权级下,程序只能访问一部分的内存区域,只能执行一些限定的指令。这就把内存分为了用户态和内核态。
由于内存分为用户态和内核态,当我们需要访问操作系统的内部函数时,就需要使用系统调用了,为了规范操作系统提供的系统调用,IEEE制定了一个标准接口族,被称为POSIX(Portable Operating System Interface of Unix)。比如一些常用的接口:fork、pthread_create、open等。
系统调用过程
下面叙述一下系统调用的过程是怎样的,要求知道大概流程。
进程A请求系统调用(80中断),此过程会将系统调用号放入eax寄存器,并往ebx、ecx、edx、esi,、edi寄存器放入参数
栈切换:当前栈从用户栈切换到内核栈(用户态和内核态使用的是不同的栈),关于Linux的栈可以看此文:Linux 中的各种栈:进程栈 线程栈 内核栈 中断栈
当前栈指的是ESP寄存器的值所指向的栈,ESP的值位于用户栈的范围,那程序的当前栈就是用户栈,反之亦然。
寄存器SS的值指向当前栈所在的页。因此,将用户栈切换到内核栈的过程是:将当前ESP、SS等寄存器的值存到内核栈上。
将ESP、SS等值设置为内核栈的相应值。通过中断向量表找到system_call的地址(0x80的地址)
<开始system_call>将用户态的一些寄存器信息保存在自己的堆栈即内核堆栈上(system_call 中的save_all实现)
save_all是一个宏,它将依次压入: %es %ds %eax %ebp %edi %esi %edx %ecx %ebx
system_call根据eax寄存器的调用号找到特定的系统函数指针,并在寄存器中读取参数
执行特定的系统函数
将执行结果保存到eax寄存器中
恢复之前保存的寄存器
执行iret,从中断程序返回<system_all结束>
iret是汇编指令,将原来用户态保存的现场恢复回来,包含代码段、指令指针寄存器等。这时候用户态进程恢复执行。
栈切换:当前栈要从内核栈切换回用户栈
运行进程A,进程A往eax寄存器中读返回数据
system_call的部分代码
// system_call的开头部分
......
SAVE_ALL // 保存寄存器的值到栈中,以免被覆盖
......
cmpl $(nr_syscalls), %eax // 比较eax寄存器中的值和系统调用号大1的值(验证系统调用号的有效性)
jae syscall_badsys // 如果系统调用无效,指向syscall_badsys
// 如果系统调用号有效,则会执行以下代码
syscall_call:
call *sys_call_table(0, %eax, 4) // 查找中断服务程序并执行, sys_call_table其实就是系统调用表
.....
RESTORE_REGS // 恢复之前保存的寄存器
......
iret // 从中断程序返回
在网上找来的大致流程图:
Socket基础
本文是以socket分析的,所以需要了解一下socket的基础知识。
Socket API
以TCP为例,其一般使用模式如下:
- socket: 创建socket 对象。这个 socket 对象包含了输入缓冲区、输出缓冲区与等待队列等成员。
- bind: 绑定ip和端口
- listen: 设置backlog,简单来说就是设置能连多少个客户端,想要进一步了解的朋友可以看此文:TCP/IP协议中backlog参数。
- accept: 等待客户端连接(阻塞),得到一个与客户端建立连接的socket
- read: 从socket输入缓冲区中读取数据,缓冲区为空时阻塞
- wiret: 向socket输出缓冲区中写入数据,缓冲区空间不够时阻塞
只要将全数据放到缓冲区就可以返回了,至于如何发送及保证数据完整性,就不是它的事了。
Socket 缓冲区读写机制
下面详细说一下,socket缓冲区的读写机制,分BIO和NIO两种情况
每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区 。
我们调用write()/send()时,操作系统并不立即向网络中传输数据 ,而是先将数据拷贝到输出缓冲区中,然后根据网络协议和阻塞模式进行处理。
我们调用read()/recv()时,假如对应socket的输入缓冲区没有数据时,会根据阻塞模式进行不同的处理。
BIO
数据发送
- 输出缓冲区的可用长度大于待发送的数据,则数据将全部被拷贝到输出缓冲区,返回。
- 输出缓冲区的长度小于待发送的数据长度,则数据能拷贝多少就先拷贝多少(分批拷贝),一直等待直到数据可以全部被拷贝到输出缓冲区,返回
数据接收
- 输入缓冲区没数据时,程序就会一直阻塞等待,直到有数据可读为止。读buffer大小的数据。返回值是成功读取到的数据的长度。
- 输入缓冲区有数据时,读buffer大小的数据,返回,返回值是成功读取到的数据的长度。
NIO
数据发送
- 输出缓冲区剩余大小大于待发送的数据大小,那数据将完整拷贝到输出缓冲区,返回。
- 输出缓冲区剩余大小小于待发送的数据大小,那本次write()/send()则为尽可能拷贝,有多少空间就拷贝多少数据,返回,而且返回值为成功拷贝到输出缓冲区的数据长度。
数据接收
- 输入缓冲区没数据时,马上返回,此时的返回值为0。
- 输入缓冲区有数据时,读buffer大小的数据,返回,返回值是成功读取到的数据的长度。
补充:
socket关闭时,若输出缓冲区中的数据仍有数据,这些数据依然会被系统发送过去;若输入缓冲区中的数据仍有数据,这部分数据将被丢弃。
延伸阅读:
谈谈socket缓冲区,该文章介绍了TCP、UDP在阻塞和非阻塞下的收发情况,以及在收发过程中的一些常见情景。
BIO时socket接收数据过程
下面通过叙述socket等待recv过程,将前面的内容串联一下。
上面说过,调用socket会在内核态创建socket 对象。这个 socket 对象包含了输入缓冲区、输出缓冲区与等待队列等成员,如下图。
当我们调用recv读取输入缓冲区中的数据时,由于缓冲区中没有数据,进程A就会从工作队列中移除,也就是说进程A处于阻塞态了。同时,进程A创建的socket的等待队列加入进程A的地址,用于唤醒进程A。如图:
之后的流程是这样的:
- 进程A会一直阻塞,直到网卡收到对端发来的数据,由网卡的DMA设备接收数据,将数据放到内存中的网卡缓冲区
- 然后网卡向中断控制器发送信号,而中断控制器会在条件允许的情况下发送中断请求(IRQ)
- CPU收到IRQ后,挂起当前程序,执行中断
- CPU根据中断向量表找到网卡的中断处理程序,CPU执行该中断处理程序
- 中断处理程序根据报文数据的端口,将数据从网卡缓冲区复制到进程A的socket的输入缓冲区中
- 然后根据socket的等待队列唤醒进程A,将进程A加入到工作队列中,即进程A变为就绪态。
整个过程如图:
将上述过程简化一下,就大概是下图了
这是BIO的情况,一个进程只能监听一个socket,即使使用多进程或多线程也很难解决c10k的问题,因此需要IO多路复用技术。
补充:网卡DMA设备
DMA是指外部设备不通过CPU而直接与系统内存交换数据的接口技术。
网卡DMA设备的处理流程:
- 网卡收到对端socket发来的数据
- 网卡的DMA设备取数据
- 将DMA中读到的数据放到RAM中的网卡缓冲区
更多关于DMA设备的内容,可查看:DMA(直接存储器存取)
IO多路复用
正文开始
I/O多路复用就是通过一种机制,让一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。常用的IO多路复用的实现有:select、poll、epoll。select、poll、epoll是系统调用,在调用的过程中会阻塞,在读数据的时候也会阻塞,但它可以同时监听多个文件描述符。
select
基本使用
select函数原型:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- 参数
- nfds:有效位(见下面解释)
- 要监听的文件描述符,以读、写、异常的顺序传
- timeout,设置为大于0的数,等待多少秒后返回;设置为0,立即返回;设置为NULL,阻塞直到可用;
- 返回
- 就绪文件描述符个数
返回前,原监听的文件描述符会被标记,然后从内核态覆盖到用户态(下面流程部分的第9步)
- 就绪文件描述符个数
源码刨析:Linux select内核源码剖析
关于fd_set
fd_set在Linux下是bitmap,长度大小为1024(在linux源码中定义的)。
linux提供了一组宏,可以对fd_set进行操作,这里不过多介绍了,想要了解的可以看这里:select机制内核源码剖析-fd_set部分。
有效位解释:
假如现在要监听5、6、7文件描述符,那么其bitmap应该为000001110000.....
,但为了提高效率,可以把后面没用的0
去掉,把有效位设为8(最大的文件描述符加1)变为:00000111
。至于是如何实现的,这里可以列出源码:
/ * do_select函数,作用是遍历所有监听的文件描述符,调用对应驱动程序的poll函数 */
/ * ..... */
/ * 此为监听文件文件描述符过程,n为有效位 */
for (i = 0; i < n; ++rinp, ++routp, ++rexp)
/ * ..... */
例子
使用select比较简单,如下面这段伪代码:
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...);
int fds[] = 存放需要监听的socket;
&rset = 根据fds构建出的位图; // 读监听
while(1){
int n = select(..., &rset, ...)
for(int i=0; i < fds.length; i++){
// FD_ISSET是fd_set的宏,可以判断该位置上的bitmap是否为1
if(FD_ISSET(fds[i], &rset)){
// fds[i]的数据处理
read(fds[i], buffer);
doSomething(buffer);
}
}}
下面以这段伪代码为例子,描述select的流程。
流程
- 进程A创建多个socket对象(调用socket或accpet函数)
- 调用select,进行系统调用(80中断),将bitmap即描述符数据复制到内核态
- 进程A从运行队列中移除
- select:
- ①. 如果 fds 中的所有 socket 都没有数据,select 会阻塞
- ②. 遍历监听每个socket
- ③.将进程A加入到socket等待队列中
- 网卡收到对端socket的数据
- 网卡通过DMA将报文保存到RAM中的网卡缓冲区
- 网卡发起硬中断IRQ
- ①. 修改CPU寄存器,将堆栈指针指向内核态堆栈
- ②. 保存进程用户态堆栈信息到进程描述符
- ③. 根据IRQ到中断向量表找到中断处理程序
- ④. 执行网卡的中断处理程序
- a. 将数据从网卡缓冲区转移到对应的socket的读缓冲区(根据socket端口)
- b. 将进程A从socket等待队列出队,并将进程A放到运行队列中
- CPU根据调度算法执行进程A
- select:
①. select遍历所有socket,找到就绪的,并设置标记( 把bitmap中已经就绪的不变为1,未就绪的变为0)
如:监听描述符为3、4、5,那么它的bitmap数据应该是
000111
,假如描述符3和5已经就绪,那么bitmap变为000101
。②. 将内核空的bitmap覆盖到进程A中的bitmap,并返回就绪的socket数
- 进程A拿到就绪的socket数,遍历bitmap数据,找到就绪的描述符
- 进行读写等操作
整个过程的前部分和使用BIO监听一个socket时一样,其核心部分就是select把不同的socket的等待队列都指向了进程A,所以当执行中断处理程序时,进程A就会被唤醒(如图),从而实现了可以监听多个文件描述符(socket)的效果。
缺点
根据上面的流程的叙述,我们很容易就可以发现select的缺点
- 传参时,bitmap需要从用户态复制到内核态
- 返回时,修改(标记)后的bitmap需要从内核态复制到用户态
- 由于每次返回都会修改原bitmap,所以每次都要把bitmap重新置位,不能复用
- 有三次遍历(监听时、标记时、进程找就绪socket时),十分浪费资源
- 在linux下,bitmap的长度不能超过1024,可以修改linux源代码并重新编译内核解决问题,但是由于bitmap需要在用户态与内核态之间传来传去,而且需要遍历,效果可能不太理想。
poll
poll的机制与select类似,与select在本质上没有多大差别,所以在这里只做简单介绍。poll和select一样,管理多个描述符也是进行轮询,根据描述符的状态进行处理。poll以链表的形式存储文件描述符,而且最大文件描述符数量没有限制。但poll和select同样存在一个缺点:包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。所以,监听fd很多的时候建议用epoll。
epoll
epoll是select和poll出现后被开发出来的,既然是新的东西,那当然不能走select和poll的老路子。在上文也说过select和poll的主要问题是每次文件描述符都要从用户态复制到内核态,然后监听的已经就绪后再复制回来。在这个过程中,没有就绪的描述符也会返回,所有需要在进程中轮询查看每个描述符的状态,浪费资源。为此,epoll会在内核空间中开辟一片空间,用于存放文件描述符等数据,返回时只需要返回就绪的文件描述符即可。这样未就绪的文件描述符可以继续监听,进程也不需要遍历查看哪个文件描述符就绪了。
基本使用
epoll常用的有三个接口,epoll_create
、epoll_ctl
、epoll_wait
。
int epoll_create(int size);
在内核区创建一个eventpoll结构(该结构包含:监听事件列表、就绪队列、等待队列 等),并且将一个句柄fd返回给用户态。监听事件列表是用红黑树实现的。epoll 通过 socket 句柄来作为 key,把 socket 保存在红黑树中。
红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。
而就绪队列是双向链表int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
进程通过之前返回的fd,添加/修改/删除文件的监听事件,这个接口操作的是监听事件列表int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待事件的产生,比较像调用select()
,不过返回的是就绪队列。
关于三个接口的参数详情,以及调用它们后,是如何用代码实现的,可以看此文章:epoll内核源码分析。
例子
同样用一段伪代码说明一下epoll的大概的使用流程。
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int epfd = epoll_create(...);
//将所有需要监听的socket添加到epfd中
epoll_ctl(epfd, ...);
while(1){
int n = epoll_wait(...)
for(接收到数据的socket){
//处理
}
}
流程
- 进程A创建多个socket对象(调用socket或accpet函数)
- 调用epoll_create,在内核中创建eventpoll结构,返回fd
- 调用epoll_ctl将socket加入到eventpoll的监听事件列表中(通过参数指定监听读/写就绪、水平/边缘触发等)
- 调用epoll_wait,假如eventpoll的就绪队列中有数据,则返回,否则阻塞(可以指定timeout参数不让其一直阻塞,但这里不展开)
- 进程A从运行队列中移除
- epoll:
- ①. 将进程A的地址加入到eventpoll的等待队列中
- ②. 将eventpoll的地址加入每个socket的等待队列中
- 网卡收到对端socket的数据
- 网卡通过DMA将报文保存到RAM中的网卡缓冲区
- 网卡发起硬中断IRQ
- ①. 修改CPU寄存器,将堆栈指针指向内核态堆栈
- ②. 保存进程用户态堆栈信息到进程描述符
- ③. 根据IRQ到中断向量表找到中断处理程序
- ④. 执行网卡的中断处理程序
- a. 将数据从网卡缓冲区转移到对应的socket的读缓冲区(根据socket端口)
- b. 从socket等待队列中找到eventpoll,调用
ep_poll_callback
函数处理。
- epoll的ep_poll_callback函数:
- ①. 将当前socket添加到eventpoll的就绪队列
- ②. 唤醒等待队列中的进程,即进程A
- CPU根据调度算法执行进程A
epoll_wait
将eventpoll中的就绪列表从内核态复制到用户态
- 进程A拿到就绪列表
- 进行读写等操作
延伸阅读:
Epoll 如何工作的?
该文章讲解了epoll 的实现原理、在实现过程中调用了哪些函数,会产生怎样的效果。
IO多路复用详解的更多相关文章
- IO模式和IO多路复用详解
网络编程里常听到阻塞IO.非阻塞IO.同步IO.异步IO等概念,总听别人装13不如自己下来钻研一下.不过,搞清楚这些概念之前,还得先回顾一些基础的概念. 1 基础知识回顾 注意:咱们下面说的都是Lin ...
- IO 多路复用详解
转自:https://blog.csdn.net/sehanlingfeng/article/details/78920423
- java中的io系统详解 - ilibaba的专栏 - 博客频道 - CSDN.NET
java中的io系统详解 - ilibaba的专栏 - 博客频道 - CSDN.NET 亲,“社区之星”已经一周岁了! 社区福利快来领取免费参加MDCC大会机会哦 Tag功能介绍—我们 ...
- JAVA IO 类库详解
JAVA IO类库详解 一.InputStream类 1.表示字节输入流的所有类的超类,是一个抽象类. 2.类的方法 方法 参数 功能详述 InputStream 构造方法 available 如果用 ...
- 并发编程——IO模型详解
我是一个Python技术小白,对于我而言,多任务处理一般就借助于多进程以及多线程的方式,在多任务处理中如果涉及到IO操作,则会接触到同步.异步.阻塞.非阻塞等相关概念,当然也是并发编程的基础. ...
- iostat磁盘IO命令详解
Linux IO 实时监控iostat命令详解 简介: 对于I/O-bond类型的进程,我们经常用iostat工具查看进程IO请求下发的数量.系统处理IO请求的耗时,进而分析进程与操作系统的交互过程中 ...
- 18、标准IO库详解及实例
标准IO库是由Dennis Ritchie于1975年左右编写的,它是Mike Lestbain写的可移植IO库的主要修改版本,2010年以后, 标准IO库几乎没有进行什么修改.标准IO库处理了很多细 ...
- 基于JavaSE阶段的IO流详解
1.IO流基本概述 在Java语言中定义了许多针对不同的传输方式,最基本的就是输入输出流(俗称IO流),IO流是属于java.io包下的内容,在JavaSE阶段主要学下图所示的: 其中从图中可知,所有 ...
- IO模型详解
IO编程包括: 文件读写 操作 StringIO 和 BytesIO 内存中 操作文件和目录 OS 序列化 json pickling 操作系统内核空间(缓冲区)收发数据: 内核态(内核空间)---- ...
随机推荐
- 玩转STM32MP157-开发环境搭建
(一)STM32MP 1.什么是 STM32MPU STM32MPU是 ST 推出的 Cortex-A7 + Cortex-M4 多核异构处理器 STM32MPU151 是单核 A7+M4,.STM3 ...
- DNS 解析过程
DNS 是应用层协议,用于将域名转换成 IP 地址. 1. 解析过程 DNS 的核心系统是一个三层的树状.分布式服务,基本对应域名的结构. 根域名服务器:管理顶级域名服务器,返回 com.net.cn ...
- 使用远程Docker进行集成测试
目录 需求背景 使用docker进行环境搭建 以中心化的docker server改进集成测试 Docker Server远程链接配置 Testcontainers 框架 Testcontainers ...
- 尝鲜一试,Azure静态网站应用服务(Azure Static Web Apps) 免费预览,协同Github自动发布静态SPA
背景 最近在浏览微软的文档的时候发现,微软喜欢用Hugo这个文档框架,有些技术产品的文档页面就用Hugo来做的,同时搭配Github + Azure Static Web Apps Service这个 ...
- 3、oracle表空间及索引操作
3.1.创建表空间和用户授权: 1.创建表空间: CREATE TABLESPACE <表空间名> LOGGING DATAFILE '<存放路径>' SIZE 50M AUT ...
- 关于vim
全部删除:按esc后,然后dG全部复制:按esc后,然后ggyG全选高亮显示:按esc后,然后ggvG或者ggVG 在win中使用<C-a>是全选,如果需要在vim中使用<C-a&g ...
- js 对json数据进行检索 插件 linq.js
有时界面需要很多数据.但是多次访问服务器效率很低,所以需要检索json数据,最好是像sql查询语句那种 linq.js 插件 LINQ,语言集成查询(Language Integrated Query ...
- thinkphp5.0 QQ第三方登录详解
一.前期准备工作 到QQ互联官网进行开发资质认证,并创建网站应用.获取到appid和appkey后,下载demo文件. demo文件下载方式:QQ互联>文档资料>SDK及资源下载>p ...
- GPU 高性能计算
背景 近日忽然想到,在CPU类型的服务器即使给到足够的运算资源,与GPU类型的服务器做运算来讲仍然是相差甚远,而本人有一台闲置的AMD vega8集显的电脑.想要用来做计算,来探究其与CPU运算的差别 ...
- Docker:docker搭建redis6.0.8集群
下载redis镜像 #拉取镜像 docker pull redis:6.0.8 查看版本 #查看版本 docker inspect redis 生成redis.conf配置文件 #在 /home/re ...