Java基础8—IO流与IO多路复用

1. IO流

几乎所有的程序都离不开信息的输入和输出,比如从键盘读取数据,从文件中获取或者向文件中存入数据,在显示器上显示数据。这些情况下都会涉及有关输入/输出的处理。

在Java中,把这些不同类型的输入、输出源抽象为流(Stream),其中输入或输出的数据称为数据流(Data Stream),用统一的接口来表示。

1.1 IO流的分类

数据流是指一组有顺序的、有起点和终点的字节集合

  • 按照流的流向分,可以分为输入流和输出流。注意:这里的输入、输出是针对程序来说的。
    • 输出:把程序(内存)中的内容输出到磁盘、光盘等存储设备中。
    • 输入:读取外部数据(磁盘、光盘等存储设备的数据)到程序(内存)中。
  • 按处理数据单位不同分为字节流和字符流。
    • 字节流:每次读取(写出)一个字节,当传输的资源文件有中文时,就会出现乱码
    • 字符流:每次读取(写出)两个字节,有中文时,使用该流就可以正确传输显示中文。
  • 按照流的角色划分为节点流和处理流。
    • 节点流:从或向一个特定的地方(节点)读写数据。如FileInputStream。
    • 处理流(包装流):是对一个已存在的流的连接和封装,通过所封装的流的功能调用实现数据读写。如BufferedReader。

下图是Java IO 流的整体架构图:

在这里插入图片描述

1.2 常见IO流

1.2.1 InputStream

java.io 包下所有的字节输入流都继承自 InputStream,并且实现了其中的方法。InputStream 中提供的主要数据操作方法如下:

  • int read():从输入流中读取一个字节的二进制数据。
  • int read(byte[] b):将多个字节读到数组中,填满整个数组。
  • int read(byte[] b, int off, int len):从输入流中读取长度为 len 的数据,从数组 b 中下标为 off 的位置开始放置读入的数据,读完返回读取的字节数。
  • void close():关闭数据流。
  • int available():返回目前可以从数据流中读取的字节数(但实际的读操作所读得的字节数可能大于该返回值)。
  • long skip(long l):跳过数据流中指定数量的字节不读取,返回值表示实际跳过的字节数。

对数据流中字节的读取通常是按从头到尾顺序进行的,如果需要以反方向读取,则需要使用回推(Push Back)操作。 在支持回推操作的数据流中经常用到如下几个方法:

  • boolean markSupported():用于测试数据流是否支持回推操作,当一个数据流支持 mark() 和 reset() 方法时,返回 true,否则返回 false。
  • void mark(int readlimit):用于标记数据流的当前位置,并划出一个缓冲区,其大小至少为指定参数的大小。
  • void reset():将输入流重新定位到对此流最后调用 mark() 方法时的位置。

字节输入流 InputStream 有很多子类,日常开发中,经常使用的一些类见下图:

在这里插入图片描述

2.2 字节输出流

与字节输入流类似,java.io 包下所有字节输出流大多是从抽象类 OutputStream 继承而来的。OutputStream 提供的主要数据操作方法:

  • void write(int i):将字节 i 写入到数据流中,它只输出所读入参数的最低 8 位,该方法是抽象方法,需要在其输出流子类中加以实现,然后才能使用。
  • void write(byte[] b):将数组 b 中的全部 b.length 个字节写入数据流。
  • void write(byte[] b, int off, int len):将数组 b 中从下标 off 开始的 len 个字节写入数据流。元素 b[off] 是此操作写入的第一个字节,b[off + len - 1] 是此操作写入的最后一个字节。
  • void close():关闭输出流。
  • void flush():刷新此输出流并强制写出所有缓冲的输出字节。

为了加快数据传输速度,提高数据输出效率,输出数据流会在提交数据之前把所要输出的数据先暂时保存在内存缓冲区中,然后成批进行输出,每次传输过程都以某特定数据长度为单位进行传输,在这种方式下,数据的末尾一般都会有一部分数据由于数量不够一个批次,而存留在缓冲区里,调用 flush() 方法可以将这部分数据强制提交。

在这里插入图片描述

2.2.3 字符输入流

从JDK1.1开始,java.io 包中加入了专门用于字符流处理的类,它们是以Reader和Writer为基础派生的一系列类。

同其他程序设计语言使用ASCII字符集不同,Java使用Unicode字符集来表示字符串和字符。ASCII字符集以一个字节(8bit)表示一个字符,可以认为一个字符就是一个字节(byte)。但Java使用的Unicode是一种大字符集,用两个字节(16bit)来表示一个字符,这时字节与字符就不再相同。为了实现与其他程序语言及不同平台的交互,Java提供一种新的数据流处理方案,称作读者(Reader)和写者(Writer)。

Reader是所有的输入字符流的父类,它是一个抽象类。

CharReader和SringReader是两种基本的介质流,它们分别将Char数组、String中读取数据。PipedReader 是从与其它线程共用的管道中读取数据。BufferedReader很明显是一个装饰器,它和其他子类负责装饰其他Reader对象。FilterReader是所有自定义具体装饰流的父类,其子类PushBackReader对Reader对象进行装饰,会增加一个行号。InputStreamReader是其中最重要的一个,用来在字节输入流和字符输入流之间作为中介,可以将字节输入流转换为字符输入流。FileReader 可以说是一个达到此功能、常用的工具类,在其源代码中明显使用了将FileInputStream 转变为Reader 的方法。Reader 中各个类的用途和使用方法基本和InputStream 中的类使用一致。

2.2.4 字符输出流

Writer是所有的输出字符流的父类,它是一个抽象类。

下图是Writer及其一些常用子类的继承图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EIypsNOj-1632818221879)(https://pics2.baidu.com/feed/e7cd7b899e510fb33cba2b1adbfc8093d3430cf4.jpeg?token=d612405b2f4ec2757e3f6f1be3e08ad5&s=23D2836E6C80BF741CF98D090000D0C1)]

CharWriter、StringWriter 是两种基本的介质流,它们分别向Char 数组、String 中写入数据。PipedWriter 是向与其它线程共用的管道中写入数据。BufferedWriter 是一个装饰器为Writer 提供缓冲功能。PrintWriter 和PrintStream 极其类似,功能和使用也非常相似。OutputStreamWriter是其中最重要的一个,用来在字节输出流和字符输出流之间作为中介,可以将字节输出流转换为字符输出流。FileWriter 可以说是一个达到此功能、常用的工具类,在其源代码中明显使用了将OutputStream转变为Writer 的方法。Writer 中各个类的用途和使用方法基本和OutputStream 中的类使用一致。

2.3 字节流和字符流的区别

  • 字节流在操作时本身不会用到缓冲区(内存),是文件本身直接操作的;而字符流在操作时使用了缓冲区,通过缓冲区再操作文件。

  • 字节流一般用来处理图像,视频,以及PPT的文件。字符流一般用于处理纯文本类型的文件,如TXT文件等。字节流可以用来处理纯文本文件,但是字符流不能用于处理图像视频等非文本类型的文件

  • 字符流更新一点,存在很多字节流的方法,也存在字节流中不存在的方法

既然有了字节流,为什么还要有字符流?

问题本质想问:不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?

回答:字符流是由 Java 虚拟机将字节转换得到的,问题就出在这个过程还算是非常耗时,并且,如果我们不知道编码类型就很容易出现乱码问题。所以, I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。


2. 序列化

把Java对象转换为字节序列的过程称为对象的序列化,也就是将对象写入到IO流中。

序列化是为了解决在对对象流进行读写操作时所引发的问题。序列化机制允许将实现序列化的Java对象转换位字节序列,这些字节序列可以保存在磁盘上,或通过网络传输,以达到以后恢复成原来的对象。序列化机制使得对象可以脱离程序的运行而独立存在。

2.1 序列化的实现

要对一个对象序列化,这个对象就需要实现Serializable接口,如果这个对象中有一个变量是另一个对象的引用,则引用的对象也要实现Serializable接口,这个过程是递归的

Serializable接口中没有定义任何方法,只是作为一个标记来指示实现该接口的类可以进行序列化。

class Person implements Serializable{
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

public class Test {
    public static void main(String[] args) throws IOException{
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("D:\\person.txt"));
        Person person = new Person("22", 22);
        objectOutputStream.writeObject(person);
    }
}

序列化只能保存对象的非静态成员变量,而不能保存任何成员方法和静态成员变量,并且保存的只是变量的值,变量的修饰符对序列化没有影响。

有一些对象类不具有可持久化性,因为其数据的特性决定了它会经常变化,其状态只是瞬时的,这样的对象是无法保存去状态的,如Thread对象或流对象。对于这样的成员变量,必须用 transient 关键字标明,否则编译器将报错。任何用 transient 关键字标明的成员变量,都不会被保存(序列化)。

另外,序列化可能涉及将对象存放到磁盘上或在网络上发送数据,这时会产生安全问题。对于一些需要保密的数据(如用户密码等),不应保存在永久介质中,为了保证安全,应在这些变量前加上 transient 关键字。

2.2 反序列化的实现

反序列化就是从 IO 流中恢复对象。

反序列化也只需两步即可完成:

步骤一:创建 ObjectInputStream 输入流

步骤二:调用ObjectInputStream对象的readObject()得到序列化的对象。

2.3 序列化版本号serialVersionUID

我们知道,反序列化必须拥有 class 文件,但随着项目的升级,class文件也会升级,序列化怎么保证升级前后的兼容性呢?

java序列化提供了一个private static final long serialVersionUID 的序列化版本号,只有版本号相同,即使更改了序列化属性,对象也可以正确被反序列化回来。

序列化版本号可自由指定,如果不指定,JVM会根据类信息自己计算一个版本号,这样随着class的升级,就无法正确反序列化;不指定版本号另一个明显隐患是,不利于jvm间的移植,可能class文件没有更改,但不同jvm可能计算的规则不一样,这样也会导致无法反序列化。

什么情况下需要修改serialVersionUID呢?分三种情况。

  • 如果只是修改了方法,反序列化不容影响,则无需修改版本号;如果只是修改了静态变量,瞬态变量(transient修饰的变量),反序列化不受影响,无需修改版本号;
  • 如果修改了非瞬态变量,则可能导致反序列化失败。如果新类中实例变量的类型与序列化时类的类型不一致,则会反序列化失败,这时候需要更改serialVersionUID。
  • 如果只是新增了实例变量,则反序列化回来新增的是默认值;如果减少了实例变量,反序列化时会忽略掉减少的实例变量。

2.4 序列化使用场景

  • 所有需要网络传输的对象都需要实现序列化接口,通过建议所有的javaBean都实现Serializable接口。
  • 对象的类名、实例变量(包括基本类型,数组,对其他对象的引用)都会被序列化;方法、类变量、transient实例变量都不会被序列化。
  • 序列化对象的引用类型成员变量,也必须是可序列化的,否则,会报错。
  • 反序列化时必须有序列化对象的class文件。
  • 当通过文件、网络来读取序列化后的对象时,必须按照实际写入的顺序读取。
  • 单例类序列化,需要重写readResolve()方法;否则会破坏单例原则。
  • 同一对象序列化多次,只有第一次序列化为二进制流,以后都只是保存序列化编号,不会重复序列化。
  • 建议所有可序列化的类加上serialVersionUID 版本号,方便项目升级。

transient

对于不想进行序列化的变量,使用 transient 关键字修饰。

transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。

关于 transient 还有几点注意:

  • transient 只能修饰变量,不能修饰类和方法。
  • transient 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0
  • static 变量因为不属于任何对象(Object),所以无论有没有 transient 关键字修饰,均不会被序列化。

3. IO模型

我们知道,在操作系统中,无论是网络IO(数据从网卡读取到内核空间)还是磁盘IO(数据从外存读取到内核空间),应用程序都需要必须通过操作系统来读取数据,当一个read读取操作发生时,它会经历两个阶段:1. 等待数据准备; 2. 将数据从内核空间拷贝到用户空间。

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。

对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。

当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

因此,在请求数据和获取数据之间有一段时间间隔,我们怎么使用这段时间呢?一般有如下几种方式:

  • 阻塞IO(BIO): 调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的去检查这个函数有没有返回,必须等这个函数返回才能进行下一步动作

  • 非阻塞IO(NIO,New IO)非阻塞等待,每隔一段时间就去检测IO事件是否就绪。没有就绪就可以做其他事。

  • IO多路复用: **linux用select/poll函数实现IO复用模型,这两个函数也会使进程阻塞,但是和阻塞IO所不同的是这两个函数可以同时阻塞多个IO操作。**而且可以同时对多个读操作、写操作的IO函数进行检测。知道有数据可读或可写时,才真正调用IO操作函数

  • 信号驱动IO: linux用套接口进行信号驱动IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO时间就绪,进程收到SIG IO信号。然后处理IO事件。

  • **异步IO(AIO):**linux中,可以调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序

3.1 BIO

BIO(Blocking I/O)即同步阻塞I/O,每当线程去读取数据时,如果数据没准备好,则阻塞该线程直至收到数据。

多线程BIO

因此,我们可以使用多线程的方式,都建立新的线程去进行IO请求,这样多个请求之间不互相干扰,很大程度上解决了单线程BIO的问题,也称为多线程BIO。

BIO的多线程模型缺点

但这个多线程模型存在致命缺陷,那就是严重依赖线程,主要表现在:

  • 线程的创建和销毁成本很高,在Linux这样的操作系统中,线程本质上就是一个进程。
  • 线程本身占用较大内存,像Java的线程栈,一般至少分配512K~1M的空间,如果系统中的线程数过千,恐怕整个JVM的内存都会被吃掉一半。
  • 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统load偏高、CPU使用率特别高(超过20%以上),导致系统几乎陷入不可用的状态。

BIO过程

  1. 应用进程向内核发起recvfrom读取数据。
  2. 准备数据报(应用进程阻塞)。
  3. 将数据从内核负责到应用空间。
  4. 复制完成后,返回成功提示。

3.2 NIO

NIO在BIO的基础上进行了改进,此时线程读取数据时,如果没有读取到并不会阻塞而是直接返回,间隔一段时间后再去读取,因此避免了阻塞的发生,但还存在如下问题:

  • 如果都多个io,需要一个一个检测,每次检测调用read都会发生上下文切换,代价较大
  • 第一次读取不到时,不知道应该等待多久再尝试读取一次

3.3 IO多路复用

上面的NIO虽然是非阻塞的,但还是每个线程等待一个文件描述符就绪,因此就想到了这么一种机制:程序注册一组socket文件描述符给操作系统,表示“我要监视这些fd是否有IO事件发生,有了就告诉程序处理”。

在这种情况下多路复用的性能要比使用多线程的性能要好很多。

目前支持I/O多路复用的系统调用有 select,pselect,poll,epoll,实际上他们都是同步I/O,读写过程是阻塞的。

3.3.1 select

select()的机制中提供一种fd_set的数据结构,实际上是一个long类型的数组,每一个数组元素都能与一打开的文件句柄(不管是Socket句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一Socket或文件可读。

select机制的问题

  • 每次调用select,都需要把fd_set集合从用户态拷贝到内核态,如果fd_set集合很大时,那这个开销也很大
  • 同时每次调用select都需要在内核遍历传递进来的所有fd_set,如果fd_set集合很大时,那这个开销也很大
  • 为了减少数据拷贝带来的性能损坏,内核对被监控的fd_set集合大小做了限制,并且这个是通过宏控制的,大小不可改变(限制为1024)

简单理解

你(用户态)去吃饭,在菜单上写了一百道菜,服务员拿菜单去后厨(内核态)问,后厨就根据现在做好的菜给你对单子,遍历菜单找到做好的菜并端上去

3.3.2 Poll

poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。也就是说,poll只解决了上面的问题3,并没有解决问题1,2的性能开销问题。

poll改变了文件描述符集合的描述方式,使用了pollfd结构而不是select的fd_set结构,使得poll支持的文件描述符集合限制远大于select的1024

简单理解

还是点菜,与select的区别是你可以点无数道菜

3.3.3 Epoll

epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了

Epoll的高效在于:

  • epoll不是在每次调用epoll_wait的时候,将描述符传送给内核,而是在epoll_ctl的时候传送描述符给内核。而Select和Poll都是在调用select()或poll()时将描述符拷贝进内核(select()或poll函数肯定比epoll_ctl()更频繁),而epoll_wait调用时无需传送任何描述符到用户态
  • 在内核态中,使用一个描述符就绪的链表,当描述符就绪的时候,在内核态中会使用回调函数,该函数会将对应的描述符添加入就绪队列中,那么当epoll_wait调用的时候,就不需要内核态遍历所有的描述符查看是否有就绪的事件,而是用户直接查看就绪队列是否为空
  • epoll还有一个函数:
    • epoll_create:用于生成一个专用的文件描述符
    • epoll_ctl:该函数用于控制某个文件描述符上的事件,可以注册事件,修改事件,删除事件。
    • epoll_wait:该函数用于轮询I/O事件的发生;

简单理解

同样,在吃饭之前,你把你喜欢吃的菜提前告诉服务员,服务员弄个菜单,后厨就去做,做好了就放到取菜区。现在你来吃饭,你问服务员有什么菜呀,服务员让你去看取菜区,你一看有就拿过来吃了,突然你又想吃别的菜,你就让服务员在菜单上加一项,你什么时候想吃了就来看看取菜区有没有,就不用拿着菜单一个一个问后厨了。

Epoll提供了两种触发机制:

  • 水平触发(level Trigger):默认工作模式,即当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次通知此事件。(超过水平线就触发,多次)
  • 边缘触发(edge Trigger):当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。(边缘转换时才触发,一次)

select/poll/epoll的区别:

selectpollepoll
操作方式遍历遍历回调
底层实现数组链表红黑树
IO效率每次调用都进行线性遍历,时间复杂度为O(n)每次调用都进行线性遍历,时间复杂度为O(n)事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1)
最大连接数1024(x86)或2048(x64)无上限无上限
fd拷贝每次调用select,都需要把fd集合从用户态拷贝到内核态每次调用poll,都需要把fd集合从用户态拷贝到内核态调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝

总结:

  • select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
  • select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。

3.4 异步IO

进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
这个模型工作机制是:告诉内核启动某个操作,并让内核在整个操作(包括第二阶段,即将数据从内核拷贝到进程缓冲区中)完成后通知我们

3.4.1 io_uring

io_uring 实现异步 I/O 的方式其实是一个生产者-消费者模型:

  1. 用户进程生产 I/O 请求,放入提交队列(Submission Queue,后续简称 SQ)。
  2. 内核消费 SQ 中的 I/O 请求,完成后将结果放入完成队列(Completion Queue,后续简称 CQ)。
  3. 用户进程从 CQ 中收割 I/O 结果。

SQ 和 CQ 是内核初始化 io_uring 实例的时候创建的。为了减少系统调用和减少用户进程与内核之间的数据拷贝,io_uring 使用 mmap(memory map)的方式让用户进程和内核共享 SQ 和 CQ 的内存空间

另外,由于先提交的 I/O 请求不一定先完成,SQ 保存的其实是一个数组索引(数据类型 uint32),真正的 SQE(Submission Queue Entry)保存在一个独立的数组(SQ Array)。所以要提交一个 I/O 请求,得先在 SQ Array 中找到一个空闲的 SQE,设置好之后,将其数组索引放到 SQ 中。

用户进程、内核、SQ、CQ 和 SQ Array 之间的基本关系如下:

初始化

内核提供了 io_uring_setup 系统调用来初始化一个 io_uring 实例。

io_uring_setup 的返回值是一个文件描述符,暂且称为 ring_fd,用于后续的 mmap 内存映射和其它相关系统调用的参数。

提交I/O请求

初始化完成之后,我们需要向 io_uring 提交 I/O 请求。默认情况下,使用 io_uring 提交 I/O 请求需要:

  1. 从 SQ Arrary 中找到一个空闲的 SQE。
  2. 根据具体的 I/O 请求设置这个 SQE。
  3. 将 SQE 的数组索引放到 SQ 中
  4. 调用系统调用 io_uring_enter 提交 SQ 中的 I/O 请求。

为了进一步提升性能,io_uring 提供了内核轮询的方式来提交 I/O 请求(设置 params.flags 的 IORING_SETUP_SQPOLL 位):创建一个内核线程(简称 SQ 线程)对 SQ 进行轮询(polling),发现有未提交的 I/O 请求就自动进行提交:

收割I/O结果

默认情况下,调用 io_uring_enter 来收割 I/O:

用户进程可以通过遍历 CQ 的 [head, tail) 区间获取已完成的 CQE 并进行处理,然后移动 head 指针到 tail,完成 I/O 收割。

如此,io_uring 的 I/O 提交和收割都可以做到不经过系统调用。


版权声明:本文为clyss1234原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。