IO多路复用

683 阅读22分钟

Linux操作系统中断

举个通俗易懂的例子:比如你现在正在打游戏,快要打完了,boss就只剩下一丁点的血量了,这时候你的外卖到了,外卖小哥都在敲门了,此时你就把游戏进行存档,去拿外卖,外卖小哥敲门就相当于一个中断请求IRQ,此时会通过中断处理程序将游戏进行存档(存档就是中断处理程序),并且将吃东西的进程唤醒,并成运行状态。(咱们把打游戏和吃东西都当作是一个一个的任务,

中断处理程序的作用:将打游戏进行存档,接着将一开始处于睡眠状态的吃东西进程唤醒,使它从睡眠状态变成运行状态。

从以上可以看出中断对于操作系统性能来说太重要了,比如一开始你正在打游戏,即打游戏进程处于运行状态,此时吃东西进程应该处于睡眠状态,因为外卖还没有到,不应该让吃东西进程一直处于运行状态(相当于你一直张着嘴),我们可以先将不处于运行状态,即睡眠状态的任务或者进程先放进等待队列里面去,当设备的数据,比如外卖来了之后,设备再给CPU发起一个中断请求IRQ外卖小哥敲门就是相当于一个中断请求),接着CPU就会执行中断处理程序了,但是执行中断处理程序不应该影响上一个运行的任务或者进程执行的进度,即需要将其存档,接下来中断处理程序就会将数据放入到响应的设备缓冲区当中,接下来就会把之前因为设备缓冲区中缺少数据而等待的任务或者进程进行唤醒,让它转移到运行队列里面去,处于运行状态,中断处理程序执行结束之后就会恢复之前挂起的打游戏任务或者进程,此时CPU不仅仅只能执行吃东西这个任务或者进程了,还可以一边吃东西一边打游戏了,多个任务并行执行。

每一个中断都会有对应的中断处理程序

1.什么是系统中断?(软中断/硬中断)

  • 硬中断:硬件发起的中断,硬中断是比较典型的中断,一般都是异步中断,是由外部设备产生的中断,可以发生在任意时间。例子:比如当前计算机的网卡设备就受到一组报文之后,会被DMA设备(网卡上的一块内嵌设备)直接转移到内存条的一个空间内,叫做网卡缓冲区,然后网卡就会向CPU发起一个中断请求IRQCPU接着就会执行网卡中断对应的中断处理程序。异步中断与当前占用CPU的进程没有直接关系的,更像是一种事件驱动模型,去驱动关联的进程,可以进行下一步的工作。

  • 软中断:由CPU产生的中断。例子:比如CPU正在执行一个代码块(10/0),我们都知道0是不能作为除数的,此时CPU检测到这行代码之后,就会发起一个软中断,就会让当前进程从用户态切换到内核态,会保留一些数据,当应用程序恢复到用户态之后,会检查某个寄存器的区域,会发现产生错误了,应用程序就有机会该错误或者输出异常信息或者直接结束。

系统调用就是借助软中断完成的,即0x80中断0x80中断(128中断)也是一种软中断。即系统调用的中断处理程序是0x80中断

2.系统中断,内核会做什么事情?

每一个中断都会有对应的中断处理程序,例子:比如当前计算机的网卡设备就受到一组报文之后,会被DMA设备(网卡上的一块内嵌设备)直接转移到内存条的一个空间内,叫做网卡缓冲区,然后网卡就会向CPU发起一个中断请求IRQ,此时可能进程C正在占用着CPU,此时进程C肯定有一个运行状态,比如进程C已经在多个寄存器中存储数据,正在准备下一步计算呢,进程C为了不延误中断处理程序的处理需要做一件事情,会将进程C使用的寄存器保存到进程描述符中(每一个进程都有一个进程描述符,进程描述符的很多字段会保留用户态下进程瞬时的运行状态数据,以便中断处理程序执行结束之后,恢复进程C为运行状态时可以接着执行),然后接着进程CPU会从用户态切换到内核态了(为什么要切换呢?因为接下来执行的程序与进程C用户程序就没有关系了),我们知道每个进程都会有两个堆栈,即(用户栈/内核栈),用户栈中存储比如你代码声明的变量,这些都是存储在用户态空间中的数据。而当我们需要进行系统调用(比如申请系统资源)的时候就需要从用户态切换到内核态,内核栈中也存储类似声明变量等数据。接着来CPU就不管进程C用户态的代码了,,CPU会执行网卡中断对应的中断处理程序了,执行完中断处理程序之后,那么CPU又会从内核态切换回用户态,怎么切回呢?就是从进程C的进程描述符中加载进程C的上一个运行状态。

3.硬件中断触发的过程(ps:8259芯片中断控制器的工作流程)

硬件中断触发的过程如下图所示 可编程中断控制器.jpg

  • INTR:中断引脚。用于接收中断信号的,
  • 8259A中断控制器:用于连接硬件设备的,我们知道咱们的硬件设备都是直接插到主板上的,然后插口可以通过一根电线连接到这个中断控制器来,当电线中的电流发生变化之后就相当于插槽中的硬件产生中断了,中断控制器就会经过一一系列的处理完成中断请求。8259A中断控制器有8个接口即IR0~IR7,但是咱们的设备不只有8个,8259A中断控制器是可以通过级联的方式,即IR2接口。
  • 中断请求寄存器: 存储各个设备的中断信号。比如假设它用8位表示,哪个位数上的至值为1,则表示此时对应的设备上面有中断信号。比如00000001表示此时键盘上有中断信号。
  • 优先级别解析器:因为中断信号也是有中断优先级的。优先级别解析器会根据IRQ后面的序号来判断哪个先执行,序号越小优先级越高,此时IRQ0的优先级别最高,先执行。
  • 正在服务寄存器:保存当前CPU正在处理的中断请求序号,比如此时正在执行的中断请求时硬盘设备引发的中断,即IRQ1,则此时保存的值为1。当CPU执行该中断请求之后会将正在服务寄存器中的值清空,8259A中断控制器知道正在服务寄存器中的值清空之后,就会按照中断请求优先级将后面的等待的中断请求依次执行,CPU会通过数据总线把下一个执行的中断请求序号写进正在服务寄存器
  • IRQ中断程序入口映射表:系统在启动的时候就会将中断请求的序号(即向量)跟代码段地址绑定起来。CPU通过INTR中断引脚只能知道中断请求的序号,CPU需要通过序号即向量找到对应的代码段地址,然后CPU会到对应的地址执行对应的中断处理程序代码。

Socket基础

Java Demo

Server端的代码如下:

/**
 * @Auther: huangshuai
 * @Date: 2021/12/26 20:03
 * @Description:Socket Demo
 * @Version:
 */
public class TcpServer {
    public static void main(String[] args) {
        try{
            ServerSocket ss = new ServerSocket(6666);
            while (true) {
                Socket s = ss.accept();
                System.out.println("A client connected!");
                DataInputStream dis = new DataInputStream(s.getInputStream());
                DataOutputStream dos = new DataOutputStream(s.getOutputStream());
                String str = null;
                if ((str = dis.readUTF()) != null) {
                    System.out.println(str);
                    System.out.println("from " + s.getInetAddress() + ", port #" + s.getPort());
                }
                dos.writeUTF("Hello," + s.getInetAddress() + ", port" + s.getPort());
                dis.close();
                dos.close();
                s.close();
            }
        }catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Client端的代码如下:

/**
 * @Auther: huangshuai
 * @Date: 2021/12/26 20:10
 * @Description:
 * @Version:
 */
public class TcpClient {
    public static void main(String[] args) {
        try {
            Socket s = new Socket("127.0.0.1", 6666);
            OutputStream os = s.getOutputStream();
            DataOutputStream dos = new DataOutputStream(os);
            dos.writeUTF("Hello, Server!");
            DataInputStream dis = new DataInputStream(s.getInputStream());
            System.out.println(dis.readUTF());
            dos.flush();
            dos.close();
            dis.close();
            s.close();
        }catch (Exception e) {
            e.printStackTrace();
        }

    }
}

Socket读写缓冲区工作机制

image.png

APP在用户态中想要实现两端的数据传输,首先需要从用户态切换到内核态中的Socket套接字实例,上图中的两个客户端都是Socket套接字实例,发送或者接收数据时最终都会映射到内核中的write/send函数(或者读操作最终会映射到内核中的read/recv函数),先将数据放入到输出缓冲区当中,然后再将数据传输到对端,接收数据时,也是先通过Socket套接字实例的read/recv系统调用从输入缓冲区中读取数据到内核空间,接着将内核空间中的数据拷贝到用户空间中。

Socket两种模式

BIO阻塞式IO

写操作

  • 当发送数据时,会先检查输出缓冲区中的可用空间大小是否小于你发送的数据大小,如果小于的话,那么这次写操作的系统调用会被阻塞,一直阻塞到输出缓冲区中的可用空间大小大于你要发送数据的大小。
  • TCP/IP协议正在操作输出缓冲区中的数据时,也会加锁,如果此时你要发送数据的话也会阻塞,直到TCP/IP操作结束释放锁。
  • 当你发送的数据很大的时候,比如10MB,但是假设整个输出缓冲区的大小为5mb,此时系统调用会把10mb分两份,会先将5mb的数据写进输出缓冲区当中,接着当前进程同样也会被阻塞挂起。当输出缓冲区中的5mb数据被TCP/IP协议传输完时,会将当前进程唤醒,接着将剩下的5mb数据再放入到输出缓冲区当中,直接返回结束。

读操作

  • 当进行读操作时,如果此时输入缓冲区没有数据地话,当前进程会阻塞,一直等待直到输入缓冲区中有数据。当输入缓冲区有数据时,会通过中断处理程序将当前读操作地进程唤醒。
  • 当进行读操作时,如果此时你想要读取大小为50mb的数据,但是此时输入缓冲区中的数据有100mb,此时你只能将输入缓冲区中的50mb数据读取出来,如果你想要读取完整个输入缓冲区中的数据,需要再次读取。

NIO非阻塞IO

写操作

当发送数据时,同样也是先将用户态的数据拷贝到内核空间中,先检查输出缓冲区的可用空间大小是否够用,假设此时发送数据大小为2mb,输出缓冲区的大小为1mb,此时会先将1mb的数据放入输出缓冲区中,接着直接返回到用户态,并且告诉用户态此时已经将1mb的数据写入到输出缓冲区中,还有1mb的数据没有处理。如果你想要发送完的话,需要再次进行系统调用将剩下的1mb数据写入到输出缓冲区中。NIO是一种非阻塞式,会不断地重试写数据写不进去地话不会阻塞,假设此时输出缓冲区没有空间了,系统调用会直接返回-1到用户态,-1表示这次写数据失败了,原因是空间满了。

读操作

当进行读操作时,如果此时输入缓冲区没有数据的话,会直接返回而不会将当前进程阻塞。

面试题

如果Socket套接字的Server端一直抢占不到CPU,那么Socket套接字Client端一直发送过来数据会不会有问题? image.png Socket套接字的Server端一直抢占不到CPU,那么Socket套接字Client端一直发送过来数据就会导致Client端的输出缓冲区和Server端的输入缓冲区打满。再者我们知道Socket底层是是使用TCP协议将Client端中的输出缓冲区数据传输到Server端中的输入缓冲区中。

TCP能够保证数据不丢(即Client端写入输出缓冲区中的数据不会丢失);TCP能够保证传输数据顺序性。我们知道从Client端的输出缓冲区发送数据到Server端的输入缓冲区的过程是不受应用程序控制的,而是由OS上的TCP协议实现程序控制的。这个TCP协议实现程序里面有拥塞控制滑动窗口和重试机制等等的保证数据不丢失和顺序性,例如当从Client端的输出缓冲区发送数据到Server端失败时,会进行重试,比如隔个5秒钟再发一次,还失败的话,再隔10秒钟再发.....。Server端的输入缓冲区接收到数据之后会发送ACK进行确认。那么当Socket套接字Client端一直发送过来数据就会导致Client端的输出缓冲区和Server端的输入缓冲区打满时出现什么情况呢?

  • BIO模式下:在Client端的APP应用进程尝试写数据到输出缓冲区中,但是此时输出缓冲区已经满了,当前进程就会阻塞被挂起,直到输出缓冲区中有空间可写。

  • NIO模式下:在Client端的APP应用进程尝试写数据到输出缓冲区中,但是此时输出缓冲区已经满了,当前进程就会返回错误信息而不是等待,不断地尝试写,直到输出缓冲区中有空间可写。

系统调用,用户态和内核态

1.为什么要有这两种状态?(用户栈/内核栈)

一个进程被创建出来时,系统会给当前进程分配两个空间,分别是用户栈(用户空间)和内核栈(内核空间),

系统调用的原理图如下:

系统调用的过程.jpg

2.什么时候进程会切换至内核态

  • 当用户程序需要系统调用的时候。比如当执行到用户程序某行代码时,需要申请资源或者访问硬件或者访问硬盘等等操作,用户代码肯定是不能直接去访问硬件的,这时候就需要进行系统调用,切换至内核态。(系统调用
  • 外部设备数据就绪了,设备会给CPU发起一个硬中断请求,CPU不管当前线程正在干什么,会立马响应设备中断,当前的进程就会从用户态切换到内核态,然后使用内核堆栈去执行中断处理程序。(硬中断
  • 当前用户程序的某一行代码出现了错误,比如执行10/0时,也会从用户态切换到内核态。(软中断

3.进程状态切换时,都要做什么事情?

当处于进程的用户态时,如下图所示

image.png 当需要将用户态切换到内核态时,需要考虑的是后面切换回用户态的时候数据状态不丢,所以需要在切换到内核态之前把一些数据存储到进程描述符fd中,以便后面恢复用户态运行。如下图所示

image.png

image.png

  • 将用户态的寄存器的瞬时数据保存到进程fd中。
  • 将堆栈地址由原来的指向用户堆栈改变指向为内核堆栈。那么它是怎么知道用户堆栈地址和内核堆栈地址的呢》是从进程描述符fd中读取的.
  • 代码地址由原来指向用户代码改变指向为内核代码。行号归零

BIO通信底层原理

BIO通信底层原理,直接看图很清晰,如下:

socket bio通信底层原理.jpg

BIO的缺点:一个进程只能监听一个Socket,使用BIO很难实现c10k,c100k等c10k表示服务端要支持一万个客户端连接,要想实现只能通过开启一万个进程的方式,但是这是不现实的。

显然只能通过实现一个进程只能监听多个Socket才能实现。,这个技术就是多路复用

Linux select 多路复用函数

在linux中,我们可以通过使用select函数实现I/O端口的复用,传递给select函数的参数会告诉内核:

  • 我们所关心的文件描述符,即想要监听哪些Socket
  • 对每个描述符,我们所关心的状态,即哪些描述符就绪了。
  • 我们要等待多长时间。即select要阻塞多长时间呢?传null表示某个Socket就绪了之后就会返回,也可以传0表示这是一个非阻塞的select调用,如果没有Socket就绪的话直接返回-1,说明当前没有Socket就绪。传入大于0的话,如果在这个时间内还没有Socket就绪的话直接返回,即这个参数表示select最大的等待时间。

select函数返回后,内核告诉我们以下信息

  • 对我们的要求已经做好准备的描述符的个数。即就绪的Socket数量。
  • 对于三种条件哪些描述符已经做好准备(读、写、异常)。 有了这些返回信息后,我们可以调用合适的I/O函数(通常是read或者write),并且这些函数不会再阻塞。因为此时Socket读缓冲区里面已经有数据了。

select函数的定义如下图所示

image.png select函数返回值为做好准备的文件描述符的个数,超时返回为0,错误返回-1。 重要参数说明如下:

  • maxfdp1表示本次调用selectbitmap中的最大有效位数到哪里,比如5的话,后面一大堆的0,因为一共有1024位,如果每次都是检查全部的1024位太费性能了。
  • fd_set是一个bitmap结构,长度为1024是使用final修饰的常量,不能修改的。但是能使用的也就1021个,也就是说只能同时监听1021socket。哪个位上为1表示对应的socket已经就绪了。
  • readset参数表示你关心的socket读就绪的编号,比如此时编号为5,6,7socket都就绪的话,这个readset参数传00000111000.......
  • writeset参数表示你关心的socket写就绪的编号,比如此时编号为5,6,7socket都就绪的话,这个writeset参数传00000111000.......
  • timeout表示此次调用select函数最大允许等待的时长,我们一般传null表示有socket就绪了之后才返回,否则一直阻塞等待;传0表示一开始检查是否有已经就绪的socket,如果没有直接返回不会阻塞;传大于0表示在这个时间内等待直到有socket就绪,如果超过这个时间都没有socket就绪的话,不再等待直接返回。timeval的数据结构如下:
struct timeval {
    long tv_sec;/*秒*/
    long tv_usec;/*微秒*/
}

相关的函数说明如下图所示

image.png

使用c语言演示select函数的demo如下:

image.png 从代码中可以看出,select系统调用后,返回了一个置位后的&rset,这样用户态只需进行很简单的二进制比较,就能很快知道哪些socket需要read数据,有效提高了效率。

问题

  • 1、bitmap最大1024位,一个进程最多只能处理1024个客户端

  • 2、&rset不可重用,每次socket有数据就相应的位会被置位

  • 3、文件描述符数组拷贝到了内核态(只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)),仍然有开销。select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)

  • 4、select并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历。select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

select方式,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + N次就绪状态的文件描述符的 read 系统调用。(read系统调用不再阻塞。只可能会在select函数中阻塞

image.png

select底层原理分析

直接看图如下:

Linux select原理图.jpg

Linux poll 多路复用函数

poll函数的定义如下:

int poll(struct pollfd* fds, nfds_t nfds, int timeout);

pollfd的结构如下:

struct pollfd {
    int fd;/*file descriptor*/
    short events;/*requested events*/
    short revents;/*return events*/
}

image.png

image.png

优点

  • 1、poll使用pollfd数组来代替select中的bitmap,数组没有1024的限制,可以一次管理更多的client。它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。

  • 2、当pollfds数组中有事件发生,相应的revents置位为1,遍历的时候又置位回零,实现了pollfd数组的重用

问题

poll 解决了select缺点中的前两条,其本质原理还是select的方法,还存在select中原来的问题

  • 1、pollfds数组拷贝到了内核态,仍然有开销
  • 2、poll并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历

Linux epoll 多路复用函数

epoll是在2.6内核中提出的,是之前的selectpoll的增强版本,相对于selectpoll来说,epoll更加灵活,没有描述符的限制,epoll使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放在一个事件表中,这样用户空间和内核空间的copy 只需一次。

epoll操作过程中需要三个接口,分别如下:

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
int epoll_wait(int epfd, struct epoll_event* event, int maxevents, int timeout);
  • epoll_create函数是一个系统函数,函数将在内核空间内开辟一块新的空间,可以理解为epoll结构空间,返回值为epoll的文件描述符编号,方便后续操作使用。
  • epoll_ctlepoll的事件注册函数,epollselect不同,select函数是调用指定需要监听的描述符和事件,epoll先将用户感兴趣的描述符事件注册到epoll空间内,此函数是非阻塞函数,作用仅仅是增删改epoll空间内的描述符信息。
    • 参数一:epfd,很简单,epoll结构的进程fd编号,函数将依靠该编号找到对应的epoll结构。
    • 参数二:op表示当前请求类型,有三个宏定义:(EPOLL_CTL_ADD:注册新的fdepfd中、(EPOLL_CTL_MOD:修改已经注册的fd监听事件)、(EPOLL_CTL_DEL:从epfd中删除一个fd)。
    • 参数三:fd表示需要监听的文件描述符,一般指的是socket_fd
    • 参数四:event告诉内核对该fd资源感兴趣的事件。

image.png

  • epoll_wait等待事件的产生,类似于select()调用,根据参数timeout来决定是否阻塞。
    • 参数一:epfd指定感兴趣的epoll事件列表。
    • 参数二:*events是一个指针,必须指向一个epoll_event结构数组,当函数返回时,内核会把就绪状态的数据拷贝到该数组中!
    • 参数三:maxevents标明参数二epoll_event数组最多能够接收的数据量,即本次操作最多能获取多少就绪数据。
    • 参数四:timeout单位为毫秒,0值时表示立即返回,非阻塞调用;-1值表示阻塞调用,直到有用户噶感兴趣的事件就绪为止;>0表示阻塞调用,阻塞指定事件内如果有事件就绪则提前返回,否则等到指定时间后返回。
    • 返回值:本次就绪的fd个数。

epoll对文件描述符的操作有两种模式:LT(水平触发)ET(边缘触发)LT模式是默认模式,两者区别如下:

  • LT(水平触发):事件就绪后,用户可以选择处理或者不处理,如果用户本次未处理,那么下次调用epoll_wait时仍然会将未处理的事件打包给你。
  • ET(边缘触发):事件就绪后,用户必须处理,因为内核不给你兜底了,内核把就绪的事件打包给你后,就把对应的就绪事件清楚掉了。

三步调用

  • epoll_create:创建一个 epoll 句柄
  • epoll_ctl:向内核添加、修改或删除要监控的文件描述符
  • epoll_wait:类似发起了select() 调用

image.png

image.png

事件通知机制

  • 1、当有网卡上有数据到达了,首先会放到DMA(内存中的一个buffer,网卡可以直接访问这个数据区域)中

  • 2、网卡向cpu发起中断,让cpu先处理网卡的事

  • 3、中断号在内存中会绑定一个回调,哪个socket中有数据,回调函数就把哪个socket放入就绪链表中

epoll底层原理分析

直接看图如下:

linux epoll 原理图.jpg