基本概念

1. 关于linux文件描述符

在Linux中,一切都是文件,除了文本文件、源文件、二进制文件等,一个硬件设备也可以被映射为一个虚拟的文件,称为设备文件。例如,stdin 称为标准输入文件,它对应的硬件设备一般是键盘,stdout 称为标准输出文件,它对应的硬件设备一般是显示器。对于所有的文件,都可以使用 read() 函数读取数据,使用 write() 函数写入数据。

“一切都是文件”的思想极大地简化了程序员的理解和操作,使得对硬件设备的处理就像普通文件一样。所有在Linux中创建的文件都有一个 int 类型的编号,称为文件描述符(File Descriptor)。使用文件时,只要知道文件描述符就可以。例如,stdin 的描述符为 0,stdout 的描述符为 1。

在Linux中,socket 也被认为是文件的一种,和普通文件的操作没有区别,所以在网络数据传输过程中自然可以使用与文件 I/O 相关的函数。可以认为,两台计算机之间的通信,实际上是两个 socket 文件的相互读写。

文件描述符有时也被称为文件句柄(File Handle),但“句柄”主要是 Windows 中术语,所以本教程中如果涉及到 Windows 平台将使用“句柄”,如果涉及到 Linux 平台将使用“描述符”。

2. Socket

socket IP+Port

一台计算机(一个IP地址)最多65535个port

socket是两端的 是四元组 只要满足可以唯一确认,区分每一个socket对应关系即可

ip:port + ip:port

网络上两个程序通过一个双向的通信连接实现数据的交换 这个连接的一端称为一个socket。

本质是一个API

一个Socket有两个内核缓冲区和一个等待队列

{% asset_img image-20210208212926670.png 发送与接收 %}

Socket的真正实例在内核中

可以在服务端的内核获取客户端Socket 比如在select函数中 在java socket Demo中

都是在服务端获取到客户端的socket 通过这些socket与客户端的socket(这些socket某方面就成了服务端的socket) 进行服务端与客户端的通信

客户端发送报文到服务端 服务端网卡发出中断请求 执行中断处理程序 解析出报文 报文中有ip:port 找到对应socket 将数据发送到对应socket的输入缓冲区 然后进程读取socket的输入缓存区即可。

更多见unix网络编程

3. I/O

I/O 输入/输出 重要

两种:

  • 文件系统IO
  • 网卡Socket IO 用于网络通信 在这个上面有IO模型 IO模型向上延伸支持编程模型

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缓存 I/O 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

IO模型前置知识是计算机组成 中断等

4. 用户空间与内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

5. 进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  1. 保存处理机上下文,包括程序计数器和其他寄存器。
  2. 更新PCB信息。
  3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
  4. 选择另一个进程执行,并更新其PCB。
  5. 更新内存管理的数据结构。
  6. 恢复处理机上下文。

注:总而言之就是很耗资源,具体的可以参考这篇文章:进程切换

6. 进程阻塞

当进程进入阻塞状态,是不占用CPU资源的

7. DMA设备

DMA(Direct Memory Access,直接存储器访问) 是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于[ CPU ](https://baike.baidu.com/item/ CPU /120556)的大量中断负载。否则,CPU 需要从来源把每一片段的资料复制到暂存器,然后把它们再次写回到新的地方。在这个时间中,CPU 对于其他的工作来说就无法使用。

在本篇文章中 DMA设备将网卡收到的报文放到内存的网卡缓冲区中

补充文章/视频

  1. 【动画】当我们在读写Socket时,我们究竟在读写什么? https://juejin.cn/post/6844903629233766414

  2. 基本概念中有一些摘自这篇文章 建议阅读这篇文章

  3. 网络包的流转 https://plantegg.github.io/2019/05/08/%E5%B0%B1%E6%98%AF%E8%A6%81%E4%BD%A0%E6%87%82%E7%BD%91%E7%BB%9C–%E7%BD%91%E7%BB%9C%E5%8C%85%E7%9A%84%E6%B5%81%E8%BD%AC/

  4. linux select等 视频补充:https://www.bilibili.com/video/BV1Lk4y117gC?from=search&seid=13832656714087589503

  5. 面试视频与记录:http://zhuuu.work/2020/08/17/Linux/Linux-06-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8/

    https://www.bilibili.com/video/BV12i4y1G7UK/?spm_id_from=333.788.videocard.5

  6. Linux TCP/IP协议栈 数据发送接收流程:https://network.51cto.com/art/201909/603780.htm

    https://segmentfault.com/a/1190000008836467

    https://segmentfault.com/a/1190000008926093

    linux网络之数据包的接受过程:https://www.jianshu.com/p/e6162bc984c8

  7. Unix网络编程 第六章

基础知识点1:Linux操作系统中断

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

一个小故事讲中断请求和中断处理程序 中断流程

你正在打游戏 饿了 要吃东西 要点外卖(吃东西任务还处于休眠状态) 正在打BOSS 快打死了 此时外卖小哥敲门(中断请求IRQ) 需要去拿外卖 (CPU收到IRQ要求执行中断处理程序 先讲当前任务存档)

拿外卖 放到桌子上 中断处理程序做的

然后中断处理程序将吃东西任务可运行起来 可以执行了

中断可以使CPU执行可运行的任务 不必一直等着不可运行的任务 可将不可运行的任务挂起

  1. 可将等待外部设备数据的任务/进程(吃东西的任务) 放到设备相关的等待队列中

  2. 当设备的数据来了后 硬件会给CPU发起中断请求(外卖小哥敲门)

  3. CPU收到中断请求后 把当前任务存档(打游戏存档) 立马执行中断处理程序 中断处理程序将数据放到相关的缓冲区中

  4. 然后将等待缓冲区数据的等待队列中的进程(吃东西) 转移到运行队列中 说明这个进程可以执行了

  5. 中断处理程序结束

  6. 恢复挂起之前的进程的状态(打游戏) 但此时CPU不止执行打游戏的任务 可以执行吃东西的任务了 可以边打游戏边吃东西

    {% asset_img image-20210205152933932.png 中断示例 %}

硬中断

硬件发起的中断 外部设备对CPU的中断 典型的异步中断 由外部设备产生的 可能发生在任意时间

例子:

计算机网卡设备接收到一组报文后 报文会被 DMA设备 转移到内存条的一块空间内(内存上的网卡缓冲区) 然后网卡会向CPU发生中断信号IRQ 若此时CPU正在执行进程C 那么需要将进程C的各种寄存器保存到 [^2]进程描述符 (一块引用进程空间的东西 保留用户态下CPU的状态 将CPU上的瞬时数据保存起来)当前进程用户态切换到内核态 因为接下来执行的程序(中断处理程序)与用户程序无关了 中断处理程序是内核的程序 需要调用内核

CPU收到中断信号后会执行网卡对应的中断处理程序

中断处理程序执行结束后 CPU从内核态切换回用户态(从进程C的进程描述符恢复CPU的现场 恢复寄存器中保存的数据等)进程C继续执行自己的代码段

用户态切换到内核态:

每个进程都有两块堆栈 一块堆栈是用户态堆栈 其中保留用户态代码下声明的变量 数据等

一块堆栈是内核态堆栈,程序可能需要 [^3] 系统调用 申请一些资源时 系统调用的程序也声明变量等数据 这些数据在内核态堆栈保留

异步中断大部分情况下与当前占用CPU的进程没有直接关系 更像是一种事件驱动模型 驱动关联的进程 进行下一步工作

软中断

CPU产生的 硬中断服务程序对内核的中断

例子:

1/0

系统调用就是借助软中断完成的 即0x80中断(十六进制的80) 对应一种中断处理程序

不论是软中断还是硬中断 每一种中断都对应一种中断处理程序 都有一个系统编号

1.2 系统中断,内核会做什么?

见上面网卡的例子

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

见图:

可编程中断控制器:https://www.processon.com/view/link/5f5b1d071e08531762cf00ff

图中CPU上的INTR中断引脚 接收中断信号的

CPU可接很多硬件 每个硬件一个引脚不现实 需要中断控制器

中断控制器连接硬件设备 每个硬件设备插在主板上 有一根电线连在中断控制器上

当电线上电流发生变化(见微机原理)则产生中断了 中断控制器会代理这个设备向CPU发出中断请求

8259A中断控制器:

默认有八个接口 但设备不止八个 可以用一个接口再连一个中断控制器 可以级联

{% asset_img image-20210210211413558.png 中断控制器级联 %}

中断控制器内部

中断请求寄存器 若某个设备发起中断请求 在中断请求寄存器中会保存此设备的请求信号

可以在其中放多个设备的请求信号

然后到优先级解析器 中断是有优先级的 一个CPU每次只能执行一个中断处理程序

优先级解析器根据设备编号IRQ 进行排序 IRQ值低的优先

正在服务寄存器

比如

  1. CPU正在处理键盘的中断请求 则正在服务寄存器中则保存IRQ1

  2. CPU处理完键盘的中断后 正在服务寄存器中的值会清空

  3. 中断控制器发现 正在服务寄存器 处于空闲状态 会从优先级解析器中优先级高的信号 给CPU CPU进行处理

  4. CPU通过数据总线将正在处理的信号写到正在服务寄存器中

图中的IRQ中断程序入口映射表 在内核中

操作系统启动系统时 就会将设备编号IRQ与中断处理程序入口绑定起来 因为在中断控制器中只能发送编号 CPU只能收到对应的编号 收到编号后要知道去调用哪一个中断处理程序

{% asset_img image-20210205181909024.png IRQ中断程序入口映射表 %}

基础知识2:Socket基础

2.1 JAVA SocketDemo

import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
 
/**
 * 基于TCp的Socket通信,实现用户登录
 * 服务器端
 */
public class Server {
    public static void main(String[] args) {
 
        try  {
            //创建一个服务器socket,即serversocket,指定绑定的端口,并监听此端口
            ServerSocket serverSocket = new ServerSocket(8888);
            //调用accept()方法开始监听,等待客户端的连接
            System.out.println("***服务器即将启动,等待客户端的连接***");
            //得到连接上服务端的客户端的socket
            Socket socket = serverSocket.accept();
            //获取输入流,并读入客户端的信息 拿到客户端的输入流和输出流
            //输入流是客户端想要传递给服务端的数据 
            //输出流是交给服务器 服务器可以通过输出流把数据给客户端
            
            InputStream in = socket.getInputStream(); //字节输入流
            InputStreamReader inreader = new InputStreamReader(in); //把字节输入流转换为字符流
            BufferedReader br = new BufferedReader(inreader); //为输入流添加缓冲
            String info = null;
            while((info = br.readLine())!=null){
                System.out.println("我是服务器,客户端说:"+info);
    
            }
            socket.shutdownInput();//关闭输入流
 
            //获取输出流,相应客户端的信息
            OutputStream outputStream = socket.getOutputStream();
            PrintWriter printWriter = new PrintWriter(outputStream);//包装为打印流
            printWriter.write("欢迎您!");
            printWriter.flush(); //刷新缓冲
            socket.shutdownOutput();
 
            //关闭资源
            printWriter.close();
            outputStream.close();
 
            br.close();
            inreader.close();
            in.close();
            socket.close();
            serverSocket.close();
 
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}


import java.io.*;
import java.net.Socket;
 
/**
 * 客户端
 */
public class Client {
    public static void main(String[] args) {
        //创建客户端socket建立连接,指定服务器地址和端口
        try {
            //调用内核 创建套节字对象 根据IP和port信息 连接到对端
            Socket socket = new Socket("127.0.0.1",8888);
            //获取输出流,向服务器端发送信息
            OutputStream outputStream = socket.getOutputStream();//字节输出流
            PrintWriter pw = new PrintWriter(outputStream); //将输出流包装为打印流
            pw.write("用户名:admin;密码:123");
            pw.flush();
            socket.shutdownOutput();
 
            //获取输入流,读取服务器端的响应
            InputStream inputStream = socket.getInputStream();
            BufferedReader br = new BufferedReader(new InputStreamReader(inputStream));
            String info = null;
            while((info = br.readLine())!=null){
                System.out.println("我是客户端,服务器说:"+info);
 
            }
            socket.shutdownInput();
 
            //关闭资源
            br.close();
            inputStream.close();
            pw.close();
            outputStream.close();
            socket.close();
 
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

2.2 golang socket TCP 网络编程

https://tonybai.com/2015/11/17/tcp-programming-in-golang/

Go中暴露给语言使用者的tcp socket api是建立OS原生tcp socket接口之上的。由于Go runtime调度的需要,golang tcp socket接口在行为特点与异常处理方面与OS原生接口有着一些差别。

Go的设计者似乎认为I/O多路复用的这种通过回调机制割裂控制流 的方式依旧复杂,且有悖于“一般逻辑”设计,为此Go语言将该“复杂性”隐藏在Runtime中了:Go开发者无需关注socket是否是 non-block的,也无需亲自注册文件描述符的回调,只需在每个连接对应的goroutine中以**“block I/O”**的方式对待socket处理即可,这可以说大大降低了开发人员的心智负担。一个典型的Go server端程序大致如下:

img

golang典型的server端

func handleConn(c net.Conn) {
    defer c.Close()
    for {
        // read from the connection
        // ... ...
        // write to the connection
        //... ...
    }
}

func main() {
    l, err := net.Listen("tcp", ":8888")
    if err != nil {
        fmt.Println("listen error:", err)
        return
    }

    for {
        c, err := l.Accept()
        if err != nil {
            fmt.Println("accept error:", err)
            break
        }
        // start a new goroutine to handle
        // the new connection.
        go handleConn(c)
    }
}

用户眼中的goroutine中的"block socket" 实际上是通过Go runtime中的netpoller通过Non-block socket + I/O多路复用机制“模拟”出来的,真实的底层socket实际上是non-block的,只是runtime拦截了底层socket系统调用的错误码,并通过netpoller和goroutine调度让goroutine“阻塞”在用户层得到的conn上。比如:当用户层针对某个socket conn发起read操作时,如果该socket conn中尚无数据,那么runtime会将该socket conn加入到netpoller中监听,同时对应的goroutine被挂起,直到runtime收到socket conn数据ready的通知,runtime才会重新唤醒等待在该socket conn上准备read的那个goroutine。而这个过程从goroutine的视角来看,就像是read操作一直block在那个socket conn上似的。

TCP连接的建立

众所周知,TCP Socket的连接的建立需要经历客户端和服务端的三次握手的过程。连接建立过程中,服务端是一个标准的Listen + Accept的结构(可参考上面的代码),而在客户端Go语言使用net.Dial或DialTimeout进行连接建立

客户端

阻塞Dial:

conn, err := net.Dial("tcp", "google.com:80")
if err != nil {
    //handle error
}
// read or write on conn

或是带上超时机制的Dial:

conn, err := net.DialTimeout("tcp", ":8080", 2 * time.Second)
if err != nil {
    //handle error
}
// read or write on conn

2.3 Socket 读/写 缓冲区工作机制

【动画】当我们在读写Socket时,我们究竟在读写什么? https://juejin.cn/post/6844903629233766414

img

{% asset_img image-20210205191240228.png Socket缓冲区 %}

每个socket有两个缓冲区 写操作最终都会映射到内核上的write()/send()内核函数 读操作会映射到read()/recv()

写操作 是将数据写到数据缓冲区

一个APP程序是运行在用户态空间 如果APP想把数据发送到对端

2.3.1 写数据过程:

  1. 需要把数据从用户态空间拷贝到内核空间真正的socket实例中 write/send到输出缓冲区

  2. write/send 系统调用结束后 返回到用户空间

如何将数据传到对端?TCP/IP协议 硬件(网卡) 链路

在OS中实现的内核中的TCP/IP协议的程序 会把缓冲区内的数据最终包装成报文拷贝到网卡 然后发送到链路上 最终数据经过一系列路由交换机到对端的网卡硬件上。

拷贝到网卡上是通过DMA方式

2.3.2 读数据过程:

  1. 对端把数据发送到本端的输入缓冲区

  2. APP调用read操作会映射到内核上的read/recv系统调用 会将数据从输入缓冲区中拿出来拷贝到用户态空间

2.3.3 一些问题:

socket.close()socket.close()

2.3.4 I/O模型及IO流程:

IO模型摘自文章,也来自unix网络编程第六章

  • 阻塞式I/O BIO
  • 非阻塞式I/O NIO
  • I/O复用 (select epoll poll)
  • 信号驱动式I/O(SIGIO)
  • 异步I/O AIO

BIO NIO IO复用,信号驱动型I/O 都是同步IO SIGIO很少使用

内核中有很多IO函数 形成不同模型

I/O模型是对于双端都适用的

BIO:

{% asset_img image-20210210215721184.png BIO %}

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

所以,blocking IO的特点就是在IO执行的两个阶段都被block了。

写操作:
  1. APP发送数据,会检查输出缓冲区的可用空间大小是否小于要发送的数据大小 如果小于要发送的数据大小 本次系统调用会阻塞 直到输出缓冲区腾出空间

  2. 如果TCP/IP协议正在使用输出缓冲区 正在读取输出缓冲区 为输出缓冲区发送数据 会给输出缓冲区加锁 此时上层APP再次使用socket写数据 也会阻塞 直到TCP协议程序发送完 释放锁后 将要写的数据 写到输出缓冲区

举例 要写10mb数据 输出缓冲区空间是5mb

则先写5mb数据到输出缓冲区 然后阻塞 write/send写进程挂起 TCP协议来占用 将5mb发走

然后挂起的写进程唤醒后继续写剩余的5mb到输出缓冲区 返回

读操作:
  1. APP的操作会映射到内核上的read/recv的系统调用 系统调用首先检查输入缓冲区是否有数据

    如果有 则读取 否则 这些系统调用会阻塞当前调用进程 直到有数据到输入缓冲区 进程被唤醒

  2. 如果要读取的数据长度(APP层面的buffer)小于输入缓冲区的数据长度 不能一次性将所有数据读出 需要分次

NIO:

{% asset_img image-20210210220257624.png NIO %}

当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。

写操作:

APP发送数据(2mb) 看输出缓冲区空间够不够 如果不够(1mb) 尽可能拷贝数据到输出缓冲区(写1mb) 然后返回上层 告诉上层写了多少数据进缓冲缓冲区(写了1mb) 下一次可以继续写 一直重试 直到成功 在这个过程中 不阻塞

如果输出缓冲区没有空间了 APP写数据 系统调用 会立马返回-1 上层就可自定义策略(比如 等待 或 一直重试)

读操作:

有数据就读 没数据就返回 不会阻塞

I/O多路复用:对于多路复用下面会有更详细讲解

上面的BIO NIO都是一个线程/进程管一个客户端scoket 这里一个线程/进程 管多个客户端的socket 管多个客户端的输入/输出

IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。

{% asset_img image-20210210220758504.png I/O多路复用 %}

当用户进程调用了select,那么整个进程会被block

所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。

这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。

所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

异步IO:

{% asset_img image-20210210221148576.png AIO %}

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

NIO与BIO的区别

调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。

同步IO和异步IO的区别

两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。

有人会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。

而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

简而言之 同步IO总有一个地方阻塞,异步IO 没有一个地方阻塞

BIO是在数据没有准备好时阻塞 读取/写数据时阻塞

NIO是在数据每准备好时不阻塞 但写/读数据时还是会阻塞

IO多路复用同样 在读/写时还是会阻塞

异步IO 全程不阻塞

阻塞是对进程/线程的阻塞

IO模型比较:

{% asset_img image-20210210221538432.png IO模型比较 %}

通过上面的图片,可以发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

I/O流程

{% asset_img image-20210208211323358.png I/O流程 %}

Data是一个大概念 socket也是数据 流也是数据 在linux中叫文件描述符 windows中是句柄

read/write 进程在用户态和内核态间切换的阻塞与否

对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:

  1. 等待数据准备 (Waiting for the data to be ready)
  2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

2.3.5 面试场景

首先

数据的确认过程(ack):当写缓冲的内容拷贝到网卡后,是不会立即从写缓冲中将这些拷贝的内容移除的,而要等待对方的ack过来之后才会移除。如果网络状况不好,ack迟迟不过来,写缓冲很快就会满的。

速率:

还有个问题那就是如果读缓冲满了怎么办,网卡收到了对方的消息要怎么处理?一般的做法就是丢弃掉不给对方ack,对方如果发现ack迟迟没有来,就会重发消息。那缓冲为什么会满?是因为消息接收方处理的慢而发送方生产的消息太快了,这时候tcp协议就会有个动态窗口调整算法来限制发送方的发送速率,使得收发效率趋于匹配。如果是udp协议的话,消息一丢那就彻底丢了。

如果TCP服务器 负载很高 进程一直抢占不到CPU资源 对端客户端发送数据的问题 发送的数据是否会丢?速率问题 发送和接收速率不匹配 发送的太多 太快 服务端处理不过来

使用TCP协议 数据不会丢 保证顺序

首先客户端发送的数据首先从用户空间拷贝到内核空间的输出缓冲区

内核中的协议栈(TCP/IP协议)会将输出缓冲区数据发送到服务端 这个过程由OS上TCP实现程序控制的

OS协议程序里有拥塞控制 滑动窗口等机制 如果服务端的输入缓冲区已经满了

客户端底层的TCP协议程序会隔5ms 10ms … 尝试写

当数据到达服务端输入缓冲区后 也不是受上层sever控制 是服务端(接收端)的程序完成的

会发送ACK确认给客户端

数据由套接字接收到缓冲区 最终复制到应用程序的用户空间 ------整个工作机制

BIO时 上面的问题

服务端输入缓冲区满了 还是一直发送 客户端输出缓冲区最终也会满 底层TCP也会一直尝试写

此时 写进程会挂起 阻塞 等到缓冲区写成功后被唤醒 尝试写下面的数据

NIO时 上面的问题

客户端的输出和服务端输入缓冲区 都满了后 客户端再写 则会返回-1 写失败 不会阻塞

基础知识3: 系统调用,用户态<–>内核态

见图:https://www.processon.com/view/link/5f5edf94637689556170d993

视频:70

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

不同指令 不同作用

有一些普通指令 只会在用户空间内操作

不同的指令 为了保证系统安全 需要让进程处于不同的执行权级 CPU特权级别

interx86提供了0–3 4个可选项执行权级 即操作系统和CPU一起合作来限制用户模式程序所能做的事情。值越低 执行权级越高 值越高 执行权级越低

linux采用了0和3 0表示内核态 3表示用户态

处于内核态的CPU状态 可以访问所有内存 所有硬件设备 可以让当前进程调用调度程序 让出CPU切换给其他进程

处于用户态的CPU状态/进程 只能访问用户空间 用户态进程不能直接访问硬件设备 需要将访问硬件设备的操作映射给内核 让内核去做

linux在创建进程的时候会给每个进程分配两块空间 : 用户空间(用户栈)和内核空间(内栈)是两个字段 执行不同空间 分别在操作系统的两个空间中分配

{% asset_img image-20210205235000227.png 用户堆栈与内核堆栈 %}

堆栈中存程序执行过程中的变量等

CPU中有各种寄存器 存放举例 下面的图是假设 不是真的:

{% asset_img image-20210206161433828.png CPU寄存器 %}

当从用户态转换到内核态时:

  1. 由于程序还需要恢复 则将CPU(寄存器)中的瞬时数据保存到进程描述符中
  2. 切换寄存器C存放的堆栈地址 切换成内核的堆栈地址(从进程描述符中读)
  3. CPU的寄存器A之前存放的用户代码地址 现在指向内核代码地址
  4. CPU行号归0 从头开始执行 其他CPU寄存器D E 的操作数重置
  5. 执行内核代码 内核代码也会有创建变量等操作 这些操作就在内核空间中分配

从内核态切换回用户态:

  1. 把堆栈地址改回用户堆栈地址
  2. 恢复之前的用户态时的数据 恢复回CPU的寄存器中

在内核态和用户态之前切换时 参数的传递 通过拷贝传递

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

1/0

主要关注:系统调用的过程 IO的操作是交给内核做的

系统调用就是借助软中断完成的0x80中断(十六进制的80) 对应一种中断处理程序

具体系统调用流程:

图:https://www.processon.com/view/link/5f5edf94637689556170d993

如图:

int 0x80

在这个过程中80中断处理程序 相当于 路由处理的角色 系统函数很多 不可能给每个系统函数分配一个中断IRQ值 则用一个80中断的IRQ值 完成一个路由功能 完成所有系统调用的入口

3.3 进程用户态<—>内核态切换时,都要做什么事?

CPU中有各种寄存器 存放举例 下面的图是假设 不是真的:

{% asset_img image-20210206161433828.png CPU寄存器 %}

当从用户态转换到内核态时:

  1. 由于程序还需要恢复 则将CPU(寄存器)中的瞬时数据保存到进程描述符中
  2. 切换寄存器C存放的堆栈地址 切换成内核的堆栈地址(从进程描述符中读)
  3. CPU的寄存器A之前存放的用户代码地址 现在指向内核代码地址
  4. CPU行号归0 从头开始执行 其他CPU寄存器D E 的操作数重置
  5. 执行内核代码 内核代码也会有创建变量等操作 这些操作就在内核空间中分配

从内核态切换回用户态:

  1. 把堆栈地址改回用户堆栈地址
  2. 恢复之前的用户态时的数据 恢复回CPU的寄存器中

核心知识点1:BIO通信底层原理

视频:87

JAVA的

bio通信底层原理:https://www.processon.com/view/link/5f61bd766376894e32727d66

BIO的缺点:

  1. 服务端一个进程/线程只能监听一个socket 监听一个客户端 如果客户端多了 会创建大量的处理线程 每个线程都要占用栈空间和一些CPU事件 很难实现服务器端C10K C100K 即服务端支持1万或10万连接服务器的客户端 JAVA的线程是linux的轻量级的进程 搞不定 没办法一对多

  2. 阻塞会带来频繁上下文切换 大多上下文切换是无意义的

select epoll poll

具体见图 图的讲解:

一个BIO的大致过程:

客户端读数据(系统调用)-》等数据 -》发来数据(网卡发出中断请求)-》硬中断-》读数据(系统调用)

  1. 一个CPU运行着内核进程,进程A,进程B… 这是在一端(服务器)

    假设进程A在执行图中的代码片段 客户端socket读数据(阻塞操作) 但此时输入缓冲区内没有数据

  2. 客户端socket输入缓冲区没有数据 则进程A被阻塞 出队 放到客户端socket的等待队列中

  3. 对端socket输出缓冲区发送数据 数据经过TCP/IP数据形成报文 发送到服务器网卡 网卡缓冲区小 不能积压太多报文 **通过DMA设备将报文发送到服务器内存(在一块叫网卡缓冲区的地方)**中(这个步骤CPU不参与 由DMA完成) 报文转移到服务器内存后

  4. 服务器网卡发起硬件中断IRQ CPU收到中断信号后

  5. CPU响应中断 将当前进程:进程B 从用户态转到内核态 :

    ​ (1)保存进程用户态堆栈信息到进程描述符

    ​ (2)修改CPU寄存器,将堆栈指针指向当前进程内核态堆栈(切换到内核态 用内核空间)

    ​ (3)根据IRQ向量到向量表中查找合适的中断处理程序

    ​ (4)进程B执行网卡中断处理程序

  6. 在网卡中断程序中:从网卡缓冲区(内存的一部分)中取出报文 根据报文的端口信息 找到对应的客户端socket 将数据放进对应socket的输入缓冲区中 图中放到了socket-1 中断处理程序将socket-1的等待队列中的进程A唤醒 出队 放到CPU的运行队列

  7. 进程A出队到CPU运行队列的末端 此时客户端socket的输入缓冲区已经有数据

这个过程中更详细的过程:

网络包的流转 https://plantegg.github.io/2019/05/08/%E5%B0%B1%E6%98%AF%E8%A6%81%E4%BD%A0%E6%87%82%E7%BD%91%E7%BB%9C–%E7%BD%91%E7%BB%9C%E5%8C%85%E7%9A%84%E6%B5%81%E8%BD%AC/

核心知识点2:Linux select 多路复用函数

视频:97

linux select函数API:https://www.processon.com/view/link/5f601ed86376894e326d9730

多路复用技术:让一个进程/线程监听多个socket

select函数的本质作用就是判断是否可以进行I/O操作

select的本质是同步IO 因为检测到就绪后就负责读写 而读写过程是阻塞的。

在Linux中使用select函数实现I/O端口的复用 传递给select函数的参数会告诉内核:

  1. 我们关心的文件描述符 告诉内核要监听哪些socket

  2. 对每个描述符,我们所关心的状态 要关心socket是否可读了 是否可写了

  3. 我们需要等待多长时间 select阻塞多长时间

    ​ 3.1 传0 表示是非阻塞的select调用 会检查指定的socket的状态是否就绪 有就绪的给结果 不就绪返回-1 (说明没有一个就绪)

    ​ 3.2 可传大于0的值 表示select调用可以阻塞 但指定阻塞时长 逻辑与传0差不多

    ​ 3.3 传null 表示等到有某个socket就绪后返回 否则一直等待

从select函数返回后 内核告诉我们的信息:

  1. 对我们的要求已经做好准备的描述符的个数 即 就绪的socket的数量
  2. 对于三种条件哪些描述符已经做好准备(读,写,异常) 哪些socket是就绪的

有了这些返回信息 我们可以调用合适的I/O函数(read或write)且这些调用都不会被阻塞 因为比如调用read 读缓冲区已经有数据了 调用read会拿到数据 不会被阻塞

Linux select函数接口

#include <sys/select.h>
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,struct timeval *timeout);
int maxfdp1:表示bitmap类型的fd_set中最大有效位到哪 bitmap1024位 不能每一位都检查 很多位是0 需要告诉内核最大有效位到哪儿 比如下面*readset的举例就传7即可
fd_set *readset:假如如果要监听文件描述符id号是5,6,7的对应的socket 这三个socket的读就绪 read_set应该: 00000111 从第0位开始 监听的id号是几 则对应位数上是1 
fd_set *writeset: 表示要关心/监听的socket的写就绪 传入方法同read_set
fd_set *exceptset: 表示要关心/监听的socket是否发生异常
fd_set是bitmap结构的数据类型 数据结构 里面是二进制 比如0011.....
    fd_set是定长的1024长度 不易改 内核中定义的常量 即 可监听的文件描述符(socket)的个数是受限的 最大1024 达不到1024 能用1021左右个
   
struct timeval *timeout:表示select调用的时长 要等待的时间 一般传null 表示只有就绪后才返回  
    struct timeval{
        long tv_sec; /*秒*/
        long tv_usec; /*微秒*/
    }
三种情况(见上面给内核信息的3):
	1.timeout == NULL 等待无限长的时间
    2.timeout->tv_sec == 0 && timeout->tv_usec ==0不等待 直接返回(非阻塞)
    3.timeout->tv_sec !=0 || timeout->tv_usec !=0 等待指定的时间

返回:做好准备的文件描述符(socket)的个数,超时为0,错误为-1 视频:105

Linux提供了一组宏,来为fd_set进行赋值等操作

#include <sys/select.h>

/*FD_ZERO 将一个fd_set类型变量所有位设为0 */
int FD_ZERO(fd_set *fdset);

/* FD_CLR 清除某个位时可以使用FD_CLR 比如一个fd_set类型:00110101 想把第三位的1设置为0 则传入fd=3 fd_set 结果为:00100101*/
int FD_CLR(int fd,fd_set *fdset);

/*使用FD_SET将指定位置的bit值设置为1 与上面的FD_CLR相反 */
int FD_SET(int fd,fd_set *fd_set)
    
/*FD_ISSET来测试某个位是否被置位 */
int FD_ISSET(int fd,fd_set *fdset);

C++程序使用linux select函数的demo

sockfd = socket(AF_INET,SOCK_STREAM,0) //创建serversocket
memset(&addr,0,sizeof(addr))
addr.sin_family = AF_INET; //使用TCP/IP协议
addr.sin_port = htons(2000); //传参 端口号为2000 
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd,(struct sockaddr*)&addr,sizeof(addr)); //完成绑定端口号为2000
listen(sockfd,5); //开启监听 指定BACKLOG 长度是5 backlog参数控制的是已经握手成功的还在accept queue的大小。 即下面解释的队列的大小

//server有一个队列(BACKLOG) 保存有哪些想建立连接的客户端请求还没有处理 然后服务端使用accept函数从队列中拿到一个客户端与当前服务端连接的socket 客户端的socket在服务端的内存中 可以在这里read/write

for(i=0;i<5;i++){
    memset(&client,0,sizeof(client));
    addrlen = sizeof(client);
    fds[i]= accept(sockfd,(struct sockaddr*)&client,&addrlen);//accept 处理请求到服务器的客户端连接 会阻塞阻塞线程 直到有客户端来了(拿到客户端)才被唤醒 返回客户端socket的fd值 客户端的socket(每个连接都会对应一个socket)在服务器端的拿到的客户端的socket 客户端会把数据发送到这些socket的输入缓存区中
    //fds数组最终会有五个数据 存的是客户端socket的编号 这里BACKLOG队列暂时没有数据了
   // max计算出fds中最大值 比如fds[]={3,4,5,6,7} 则max=7 之后作为select的第一个参数传入
    if(fds[i] > max)
        max = fds[i];
}

while(1){
    FD_ZERO(&rset); //把rset归零
    //遍历fds的值 把位图(rset)中编号对应位置改为1
    //遍历结束 rset=000111110000.....
    //这里监听不管服务端socket的事 也可以放入rset进行监控 这里没放
    for(i = 0;i<5;i++){
        FD_SET(fds[i],&rset)
    }
    print("round again");
    
    //调用select函数 这里监听客户端的读事件 传入max+1 rset(关心读) 最后一个参数timeout 传入NULL表示只有指定的socket有读就绪(读缓冲区有数据 可以从中读取数据)之后 才会返回 否则一直阻塞
    
    //select函数是linux提供的库函数 但内部代码会发起系统内部(中断) 不是真正的内核里面的select实现 只是给用户提供的一个入口函数 其里面会使用80中断的方式 切换当前进程到内核态 调用内核中的系统函数 完成真正的系统调用 当进行真正的系统调用的时候 传入的参数都会拷贝一份到内核空间
    内核中执行的逻辑是:检查对应socket 如果socket有就绪则立马返回 如果没有则当前进程被挂起 等待被中断唤醒 
    //select返回的数据:假设3,5编号的socket 读缓冲区内有数据那么内核中   rset=0001010000....这个数据内核会拷贝给用户空间 那么用户空间的rset=0001010000...
    select(max + 1,&reset,NULL,NULL,NULL);
   
    //遍历当前fds数组 判断对应rset的位置是否被标记 如果被标记了 则说明指定位置的socket是就绪的 3,5是被标记的 则这两个会进入if(){}的逻辑 会读取对应socket的读缓冲区的数据 将其加载进用户态空间 服务端读 
    for(i=0;i<5;i++){
        if(FD_ISSET(fds[i],&rset)){
            memset(buffer,0,MAXBUF);
            //read对应socket的输入缓冲区的内容 将内容放到buffer里面
            read(fds[i],buffer,MAXBUF);
            print(buffer);
        }
    }
}

select函数的缺陷:
在上面代码的while()循环中 死循环

每次调用完select函数 rset都会被改变(一开始rset是告诉有哪些要连接的socket 调用后rset是告诉哪些socket已经就绪了) 则rset无法复用 每次调用select 一开始的rset应该是不变的

每次调用 rset都需要重新设置 重新传递给内核

 循环中每次rset的设置
 for(i = 0;i<5;i++){
        FD_SET(fds[i],&rset)
    }

img

核心知识点3:Linux select 多路复用底层原理分析

视频:120

linux select原理图:https://www.processon.com/view/link/5f62b9a6e401fd2ad7e5d6d1

具体见图

图解析:

  1. 在服务器socket有一个BCAKLOG队列 其中放服务器还没有处理的客户端连接(socket)目前假设有三个客户端socket还未处理

  2. 服务器处理完BCAKLOG队列中的客户端socket后 当前进程A(进程A处理这段代码)用户堆栈:fds=[3,4,5] 对应客户端文件描述符的编号 根据此构建出rset=00011100…

  3. 发起调用select函数 进程A中用户堆栈的数据rset拷贝到进程A内核堆栈 当前监控的三个socket的输入缓冲区都没有数据 select调用会阻塞 则将进程A放入这三个socket的等待队列 这三个socket的等待队列都要进程A的指针引用 进程A处于阻塞状态 select函数也不会执行了

  4. 假设客户端1和客户端3发送数据经过TCP/IP后形成报文 到服务器网卡 通过DMA设备 将报文放入服务器内存中的网卡缓冲区

  5. 服务器网卡发起硬件中断 CPU收到中断请求后 响应中断 假设收到中断时 CPU正在执行进程B

  6. 进程B 从用户态转到内核态 :

    ​ (1)保存进程用户态堆栈信息到进程描述符

    ​ (2)修改CPU寄存器,将堆栈指针指向当前进程内核态堆栈(切换到内核态 用内核空间)

    ​ (3)根据IRQ向量到向量表中查找合适的中断处理程序

    ​ (4)进程B执行网卡中断处理程序

  7. 在网卡中断程序中:从网卡缓冲区(内存的一部分)中取出报文 根据报文的端口信息 找到对应的客户端socket 将数据放进对应socket的输入缓冲区中 图中socket-1和socket-3的输入缓冲区被放进数据 看放过数据的socket的等待队列是否有进程 发现有进程A进程A从所有socket的等待队列中移出 因为进程A关注的socket有就绪状态的了

  8. 进程A回到CPU运行队列 将进程A内核态堆栈的数据rset(这里因为有就绪态socket已经改过了rset=00010100…)拷贝到进程A的用户态堆栈

  9. 回到用户态可以判断出哪些socket有数据 继续执行下面的代码

select可以监控socket 不只监控客户端的socket 还可监控服务端的socket

select用于监控描述符(socket)是否可以操作 是否可读了 可写了 有异常了

如果有数据发过来 那么直接读 也就不阻塞了 当然在读这个过程还是阻塞的

上面代码放进去的是客户端的socket 如果客户端的socket的输入缓冲区有数据了(可能是客户端发过来的)那么select则返回

服务端创建serverSocket

服务器socket监听端口通过accept从BACKLOG中获取到与请求连接的客户端socket 客户端socket就是与这些socket进行通信

对于select工作流程:

select
socketselectselectsocket了socketfdselect

另一个讲的不错的视频:https://www.bilibili.com/video/BV1Lk4y117gC?from=search&seid=13832656714087589503

核心知识点4:Linux epoll 多路复用函数

linux epoll函数:https://www.processon.com/view/link/5f6034210791295dccbc1426

视频:128

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

epoll 为了解决select的缺陷

select相对于epoll的缺点:

  • rset 需要来回拷贝 每次都传 每次都需要拷贝到内核 如果内核中socket就绪的话 内核会把数据拷贝到rset指向的内存 返回 每次会检查rset
  • rset每次调用select都需要重新置位 置为0 无法复用rset 数据一直在变化

epoll解决上面的问题与优势:

  • epoll在有一片空间 表示要关注的事件 事件列表(监听的) 还有一块空间 表示就绪列表

  • epoll通过epoll_ctl把需要关注的socket注册进事件列表 需要关注的socket变化概率不会太高

    解决select函数 socket变化 来回拷贝的问题

  • epoll中就绪列表的数据会拷贝回到epoll_event数组中,数组其中的数据都是就绪的数据 不存在不就绪的 直接处理即可 不需要像select函数按位检查 判断

  • select的bitmap的长度是1024 最多监听1024个socket(到不了) 使用epoll可以指定大小 可以设置事件列表的大小 远大于select

在内核上开辟了一片空间

epoll里有事件列表和就绪列表 事件列表用于监听 就绪列表可用于直接拿数据 放就绪的socket

还有一个等待队列 放阻塞的进程

epoll接口

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

int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大 size即epoll可以管理多少个socket epoll中有一个列表是要管理的socket的事件列表 里面存的都是要管理的socket信息 需要增删改数据
    //在内核上开一片空间 这片空间叫epoll linux上万物皆文件 则可以拿到epoll的fd(文件描述符/句柄) 在上层应用通过fd可以找到epoll
    
  //增删改上面的epoll事件列表的数据
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

1. int epoll_create(int size);

是一个系统函数,函数将在内核空间内开辟一块新空间,可以理解为epoll结构空间,返回值为epoll的文件描述符编号,方便后续操作使用。

参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议

2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll中有一个列表是要管理的socket的事件列表 里面存的都是要管理的socket信息 需要增删改数据

函数是对指定描述符fd执行op操作。 增删改上面的epoll事件列表的数据

  • epfd:是epoll_create()的返回值。改哪个epoll

  • op:表示op操作,表示当前请求类型,是要增删改中的哪个,用三个宏来表示:

​ 添加EPOLL_CTL_ADD(注册新的fd到epfd中 fd是要添加进epoll列表的数据/socket信息/文件描述符),删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。

  • fd:是需要监听的fd(文件描述符)需要告诉epoll增删改哪个socket
  • event:告诉内核对该fd资源(socket)感兴趣的事件。epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下:
struct epoll_event {
  __uint32_t events;  /* Epoll events */
  epoll_data_t data;  /* User data variable */
};

//events可以是以下几个宏的集合:表示对socket感兴趣的事件
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

对于APP(上层应用) 需要关注的是就绪的数据,在epoll里面还有一块空间:就绪列表 里面存的是事件列表中注册的事件就绪的信息

则epoll_wait函数就是访问这块就绪列表空间的函数

等待事件的产生,类似于select()调用 根据参数timeout,来决定是否阻塞

等待epfd上的io事件,最多返回maxevents个事件

参数一:epfd,指定感兴趣的epoll事件列表 要访问哪个epoll

参数二:*events,是一个指针,必须指向一个epoll_event结构数组,当函数返回时,内核会把就绪状态的数据拷贝到该数组中 一次调用后 内核会把就绪的数据拷贝到该数组中 该数组在用户空间

参数三:maxevents,表明参数二epoll_event数组最多能接收的数据量,即本次操作最多能获取多少就绪数据。 在C/C++中无法直接获得数组的length 需要告诉内核 数组有多大

参数四:timeout,单位为毫秒

​ 0:表示立即返回,非阻塞调用 直接到就绪列表中拿数据 有数据就拷贝到数组中 没有则返回且epoll_wait会返回-1

​ -1:阻塞调用,直到有用户感兴趣的事件就绪为止。访问就绪列表的操作会一直阻塞 直到有就绪数据为止才会返回

​ >0:阻塞调用,阻塞指定时间内如果有事件就绪则提前返回,否则等待指定时间后返回

​ 返回值:本次就绪的fd的个数

参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

工作模式

epoll_event的events是对什么事件感兴趣 可以选择对感兴趣事件的触发方式 以下两种:

epoll对文件描述符的操作有两种模式:LT(水平触发)ET(边缘触发)LT模式是默认模式,LT模式与ET模式的区别如下:
  LT模式:事件就绪后,用户可以选择处理或者不处理,如果用户本次未处理,那么下次调用epoll_wait时仍然会将未处理的事件打包给你。就绪列表中依然保存着未处理的情况

ET模式:事件就绪后,用户必须处理,因为内核不给你兜底了,内核把就绪的事件打包给你后,就把对应的就绪事件清理掉了。

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。

Demo

服务器端 
    省略绑定端口  监听端口代码
    注意在服务器内核有客户端socket
int epfd = epoll_create(10); //创建epoll 且指定事件列表大小为10 可监听10个fd
...
...
    
struct epoll_event events[5] //events数据 放就绪的
    
 //for循环 处理serversocket上拿到的客户端连接socket 
for(i=0;i<5;i++){
    static struct epoll_event ev; //将客户端连接的信息封装到了ev对象
    memset(&client,0,sizeof(client));
    addrlen = sizeof(client)
    ev.data.fd = accept(sockfd,(struct sockaddr*)&client,&addrlen); //存储fd编号
    ev.events = EPOLLIN; //对读事件感兴趣
    epoll_ctl(epfd,EPOLL_CTL_ADD,ev.data.fd,&ev); //把要监听的信息 加入事件(监听)列表
}
//for循环结束后 监听列表就有5条数据了 会监听5个客户端socket

while(1){
    prints("round again");
    //在循环内一直访问就绪列表 看哪个socket就绪了
    nfds = epoll_wait(epfd,events,5,10000); //就绪列表中就绪的数据拷贝到events数组中 参数中10000 表示定长阻塞调用 最多等10000毫秒 此函数返回后events就有数据了
    
    //处理就绪socket 
    for(i=0;i<nfds;i++){
        memset(buffer,0,MAXBUF);
        //从就绪socket的读缓存区读数据
        read(events[i].data.fd,buffer,MAXBUF);
        prints(buffer);
    }
}

核心知识点5:Linux epoll 多路复用底层原理分析

linux epoll原理图:https://www.processon.com/view/link/5f62f98f5653bb28eb434add

视频:150

图解析:

进程A执行上面的伪代码

int epfd = epoll_crate(...)epoll_ctl(...)epoll_wait(...)epoll_wait(...)

面试题总结

偏java

http://zhuuu.work/2020/08/17/Linux/Linux-06-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8/

https://www.bilibili.com/video/BV12i4y1G7UK/?spm_id_from=333.788.videocard.5

对于select工作流程:

kernelfdsocketselectsocketO(N)socketfdsocketsocket
select
socketselectselectsocket了socketfdselect

有错误请一定指出 欢迎讨论 共同进步!
我的博客:https://kkalts.github.io/f7.github.io/