Java IO

IO 流简介

IO 即 Input/Output,输入和输出。

数据输入到计算机内存的过程即输入,数据从内存输出到外部存储(比如数据库,文件,远程主机)的过程即输出。

数据传输过程类似于水流,因此称为 IO 流。

IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流

  • InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。

  • OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。

字节流

InputStream(字节输入流)

java.io.InputStream抽象类是所有字节输入流的父类。

InputStream 常用方法:

  • read():返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回 -1 ,表示文件结束。

  • read(byte b[ ]) : 从输入流中读取一些字节存储到数组 b 中。如果数组 b 的长度为零,则不读取。如果没有可用字节读取,返回 -1。如果有可用字节读取,则最多读取的字节数最多等于 b.length , 返回读取的字节数。这个方法等价于 read(b, 0, b.length)

  • read(byte b[], int off, int len):在read(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。

  • skip(long n):忽略输入流中的 n 个字节 ,返回实际忽略的字节数。

  • available():返回输入流中可以读取的字节数。

  • close():关闭输入流释放相关的系统资源。

从 Java 9 开始,InputStream 新增加了多个实用的方法:

  • readAllBytes():读取输入流中的所有字节,返回字节数组。

  • readNBytes(byte[] b, int off, int len):阻塞直到读取 len 个字节。

  • transferTo(OutputStream out):将所有字节从一个输入流传递到一个输出流。

FileInputStream 是一个比较常用的字节输入流对象,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中。

try (InputStream fis = new FileInputStream("input.txt")) {
    System.out.println("Number of remaining bytes:"
            + fis.available());
    int content;
    long skip = fis.skip(2);
    System.out.println("The actual number of bytes skipped:" + skip);
    System.out.print("The content read from file:");
    while ((content = fis.read()) != -1) {
        System.out.print((char) content);
    }
} catch (IOException e) {
    e.printStackTrace();
}
​

image-20240419210458789

输出:

Number of remaining bytes:11
The actual number of bytes skipped:2
The content read from file:JavaGuide

像下面这段代码在我们的项目中就比较常见,我们通过 readAllBytes() 读取输入流所有字节并将其直接赋值给一个 String 对象。

// 新建一个 BufferedInputStream 对象
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));
// 读取文件的内容并复制到 String 对象中
String result = new String(bufferedInputStream.readAllBytes());
//String的构造方法可以传入byte数组
System.out.println(result);
​

ObjectInputStream 用于从输入流中读取 Java 对象(反序列化),ObjectOutputStream 用于将对象写入到输出流(序列化)。

ObjectInputStream input = new ObjectInputStream(new FileInputStream("object.data"));
MyClass object = (MyClass) input.readObject();
input.close();
​

另外,用于序列化和反序列化的类必须实现 Serializable 接口,对象中如果有属性不想被序列化,使用 transient 修饰。

OutputStream(字节输出流)

java.io.OutputStream抽象类是所有字节输出流的父类。

OutputStream 常用方法:

  • write(int b):将特定字节写入输出流。

  • write(byte b[ ]) : 将数组b 写入到输出流,等价于 write(b, 0, b.length)

  • write(byte[] b, int off, int len) : 在write(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。

  • flush():刷新此输出流并强制写出所有缓冲的输出字节。

  • close():关闭输出流释放相关的系统资源。

FileOutputStream 是最常用的字节输出流对象,可直接指定文件路径,可以直接输出单字节数据,也可以输出指定的字节数组。

try (FileOutputStream output = new FileOutputStream("output.txt")) {
    byte[] array = "JavaGuide".getBytes();
    output.write(array);
} catch (IOException e) {
    e.printStackTrace();
}
​

FileOutputStream 通常也会配合 BufferedOutputStream(字节缓冲输出流,后文会讲到)来使用。

FileOutputStream fileOutputStream = new FileOutputStream("output.txt");
BufferedOutputStream bos = new BufferedOutputStream(fileOutputStream)
​

ObjectInputStream 用于从输入流中读取 Java 对象(ObjectInputStream,反序列化),ObjectOutputStream将对象写入到输出流(ObjectOutputStream,序列化)。

ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream("file.txt")
Person person = new Person("Guide哥", "JavaGuide作者");
output.writeObject(person);
​

字符流

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

:字节流在操作字符时,很可能有编码问题,抽取字符流的原因是为了方便我们解决编码问题

因此,I/O 流就干脆提供了一个直接操作字符的接口(字符流底层还是用的字节流),方便我们平时对字符进行流操作

如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。

字符流默认采用的是 Unicode 编码,我们可以通过构造方法自定义编码。

常用字符编码所占字节数

utf8 :英文占 1 字节,中文占 3 字节,unicode:任何字符都占 2 个字节,gbk:英文占 1 字节,中文占 2 字节

Reader(字符输入流)

java.io.Reader抽象类是所有字符输入流的父类。

Reader 用于读取文本, InputStream 用于读取原始字节。

Reader 常用方法:

  • read() : 从输入流读取一个字符。

  • read(char[] cbuf) : 从输入流中读取一些字符,并将它们存储到字符数组 cbuf中,等价于 read(cbuf, 0, cbuf.length)

  • read(char[] cbuf, int off, int len):在read(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。

  • skip(long n):忽略输入流中的 n 个字符 ,返回实际忽略的字符数。

  • close() : 关闭输入流并释放相关的系统资源

InputStreamReader 是字节流转换为字符流的桥梁,其子类 FileReader 是基于该基础上的封装,可以直接操作字符文件。

// 字节流转换为字符流的桥梁
public class InputStreamReader extends Reader {
}
// 用于读取字符文件
public class FileReader extends InputStreamReader {
}
​

FileReader 代码示例:

try (FileReader fileReader = new FileReader("input.txt");) {
    int content;
    long skip = fileReader.skip(3);
    System.out.println("The actual number of bytes skipped:" + skip);
    System.out.print("The content read from file:");
    while ((content = fileReader.read()) != -1) {
        System.out.print((char) content);
    }
} catch (IOException e) {
    e.printStackTrace();
}

Writer(字符输出流)

java.io.Writer抽象类是所有字符输出流的父类。

Writer 常用方法:

  • write(int c) : 写入单个字符。

  • write(char[] cbuf):写入字符数组 cbuf,等价于write(cbuf, 0, cbuf.length)

  • write(char[] cbuf, int off, int len):在write(char[] cbuf) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。

  • write(String str):写入字符串,等价于 write(str, 0, str.length())

  • write(String str, int off, int len):在write(String str) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。

  • append(CharSequence csq):将指定的字符序列附加到指定的 Writer 对象并返回该 Writer 对象。

  • append(char c):将指定的字符附加到指定的 Writer 对象并返回该 Writer 对象。

  • flush():刷新此输出流并强制写出所有缓冲的输出字符。

  • close():关闭输出流释放相关的系统资源。

OutputStreamWriter 是字符流转换为字节流的桥梁,其子类 FileWriter 是基于该基础上的封装,可以直接将字符写入到文件。

// 字符流转换为字节流的桥梁
public class OutputStreamWriter extends Writer {
}
// 用于写入字符到文件
public class FileWriter extends OutputStreamWriter {
}

try (Writer output = new FileWriter("output.txt")) {
    output.write("你好,我是Guide。");
} catch (IOException e) {
    e.printStackTrace();
}

缓冲流

缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,降低了CPU通过内存访问硬盘的次数。提高效率 。

基本原理: 是在创建流对象的时候,会创建一个内置默认大小的缓冲区数组。在操作时,一次读取/写入多个字节到缓冲区数组,相较于原来一次读写一个,减少系统IO次数,从而提高读写效率

image-20240419210507782

字节缓冲流这里采用了装饰器模式来增强 InputStreamOutputStream子类对象的功能。

举个例子,我们可以通过 BufferedInputStream(字节缓冲输入流)来增强 FileInputStream 的功能。

// 新建一个 BufferedInputStream 对象
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));

字符缓冲流

BufferedReader (字符缓冲输入流)和 BufferedWriter(字符缓冲输出流)类似于 BufferedInputStream(字节缓冲输入流)和BufferedOutputStream(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。不过,前者主要是用来操作字符信息。

打印流

下面这段代码大家经常使用吧?

System.out.print("Hello!");
System.out.println("Hello!");

System.out 实际是用于获取一个 PrintStream 对象,print方法实际调用的是 PrintStream 对象的 write 方法。

PrintStream 属于字节打印流,与之对应的是 PrintWriter (字符打印流)。PrintStreamOutputStream 的子类,PrintWriterWriter 的子类。

public class PrintStream extends FilterOutputStream
    implements Appendable, Closeable {
}
public class PrintWriter extends Writer {
}

随机访问流

这里要介绍的随机访问流指的是支持随意跳转到文件的任意位置进行读写的 RandomAccessFile

RandomAccessFile 的构造方法如下,我们可以指定 mode(读写模式)。

// openAndDelete 参数默认为 false 表示打开文件并且这个文件不会被删除
public RandomAccessFile(File file, String mode)
    throws FileNotFoundException {
    this(file, mode, false);
}
// 私有方法
private RandomAccessFile(File file, String mode, boolean openAndDelete)  throws FileNotFoundException{
  // 省略大部分代码
}

读写模式主要有下面四种:

  • r : 只读模式。

  • rw: 读写模式

  • rws: 相对于 rwrws 同步更新对“文件的内容”或“元数据”的修改到外部存储设备。

  • rwd : 相对于 rwrwd 同步更新对“文件的内容”的修改到外部存储设备。

文件内容指的是文件中实际保存的数据,元数据则是用来描述文件属性比如文件的大小信息、创建和修改时间。

RandomAccessFile 中有一个文件指针用来表示下一个将要被写入或者读取的字节所处的位置。我们可以通过 RandomAccessFileseek(long pos) 方法来设置文件指针的偏移量(距文件开头 pos 个字节处)。如果想要获取文件指针当前的位置的话,可以使用 getFilePointer() 方法。

RandomAccessFilewrite 方法在写入对象的时候如果对应的位置已经有数据的话,会将其覆盖掉。

RandomAccessFile 比较常见的一个应用就是实现大文件的 断点续传 。何谓断点续传?简单来说就是上传文件中途暂停或失败(比如遇到网络问题)之后,不需要重新上传,只需要上传那些未成功上传的文件分片即可。分片(先将文件切分成多个文件分片)上传是断点续传的基础。

RandomAccessFile 可以帮助我们合并文件分片,

Java IO模型

Java IO模型

BIO 属于同步阻塞 IO 模型

同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。

image-20240419210514268

NIO

Java 中的 NIO 于 Java 1.4 中引入,对应 java.nio 包,提供了 Channel , SelectorBuffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO 。

Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。

image-20240419210521963

NIO在发起IO请求时不阻塞,操作时阻塞

image-20240419210528629

AIO (Asynchronous I/O)

AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。

异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作

image-20240419210534492

了解I/O

我们在平常开发过程中接触最多的就是 磁盘 IO(读写文件)网络 IO(网络请求和响应)

IO操作分为两步:1.IO请求(事件), 2,IO操作(读写)

应用程序是如何进行IO操作的?

我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。

并且,用户空间的程序不能直接访问内核空间。

当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。

因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间

从应用程序的视角来看,应用程序对操作系统内核发起IO调用,操作系统内核来执行具体IO操作

当应用程序发起 I/O 调用后,会经历两个步骤:

  1. 内核等待 I/O 设备准备好数据

  2. 内核将数据从内核空间拷贝到用户空间。

常见I/O模型

阻塞 I/O、非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O

image-20240419210539812

为什么需要把数据从内核复制到用户空间?

大多数文件系统的默认IO操作都是缓存IO。在Linux的缓存IO机制中,操作系统会将IO的数据缓存在文件系统的页缓存(page cache)。也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓存区拷贝到应用程序的地址空间中。这种做法的缺点就是,需要在应用程序地址空间和内核进行多次拷贝,这些拷贝动作所带来的CPU以及内存开销是非常大的

至于为什么不能直接让磁盘控制器把数据送到应用程序的地址空间中呢?最简单的一个原因就是应用程序不能直接操作底层硬件。

总的来说,IO分两阶段:

1)数据准备阶段

2)内核空间复制回用户进程缓冲区阶段。如下图:

image-20240419210545844

阻塞IO就是当应用B发起读取数据申请时,在内核数据没有准备好之前,应用B会阻塞等待内核通知它数据准备成

PS:线程阻塞IO时,不会占用cpu

为什么线程在io操作时需要阻塞呢,根本原因应该是CPU的运行速度要远超过一个io的速度,CPU不可能去等待一个要很久才能完成的io操作,所以就是当进行io操作时线程需要进入一个阻塞状态

流程:

1、应用进程向内核发起recfrom读取数据。

2、准备数据报(应用进程阻塞)。

3、将数据从内核负责到应用空间。

4、复制完成后,返回成功提示。

非阻塞IO就是当应用B发起读取数据申请时,如果内核数据没有准备好会即刻告诉应用B,B会隔一段时间就去看看数据准备好没(轮询),在两次查询中间可以干别的事

但是,轮寻对于CPU来说是较大的浪费,一般只有在特定的场景下才使用。

image-20240419210620331

异步IO就是 应用告知内核启动某个操作,并让内核在整个操作完成之后,通知应用

这种模型与信号驱动模型的主要区别在于,信号驱动IO只是由内核通知我们合适可以开始下一个IO操作,而异步IO模型是由内核通知我们操作什么时候完成。

(这个发起异步IO的线程很懒,将数据从内核拷贝到用户空间这一步也懒得阻塞去做了)

img

如何判断同步和异步

异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待。发起调用后就能够立刻返回,数据到用户空间后会异步通知调用者

I/O多路复用

9.2 I/O 多路复用:select/poll/epoll | 小林coding (xiaolincoding.com)

为什么单线程的 Redis 如何做到每秒数万 QPS ? (qq.com)

一文让你透彻理解Linux的SOCKET编程(含实例解析) - 知乎 (zhihu.com)

基于 TCP 的 Socket 编程

img

1.服务端的程序要先跑起来

2.服务端调用socket()函数,创建socket

3.服务端调用bind()函数,给socket绑定ip地址和端口

4.服务端调用listen函数,该主动socket变成监听socket(被动),监听请求的线程会阻塞等待客户端的连接请求

5.客户端创建好socket后,指定目标服务器的IP和端口,调用connect()发起请求,进行三次握手

TCP三次握手中listen()与accept()原理tcp监听鱼在树上飞的博客-CSDN博客

一个监听socket里维护了两个连接队列:已连接队列和半连接队列

已连接队列和半连接队列的作用?

半连接队列用于暂存连接请求,等到三次握手结束后socket会被取出加入已连接队列中

已连接队列存储了已经建立好的连接,服务器可以随时使用这些连接进行数据交互。这允许服务器同时处理多个客户端的连接请求,实现并发服务。

image-20240419210635421

image-20240419210656342

5.1 客户端调用connect,向服务器发送SYN J包,connect进入阻塞状态

5.2 服务端监听到连接请求,也就是收到SYN J包,此时操作系统会创建一个半连接socket,将其加入半连接队列中, 返回客户端SYNK ACK J+1。

5.3 客户端收到SYN K ACK J+1后connect返回,并确认SYN K无误后对服务器发送ACK K+1 ,此时三次握手成功,连接建立成功,从半连接队列中取出该连接对应的半连接socket,加入已连接队列

6.TCP全连接队列不为空时,服务端唤醒可能在阻塞的accept,开始执行,从已连接队列中取出已连接socket,成功后返回已连接socket的描述字返回给应用进程,后续数据传输都用这个Socket。

注意,监听的 Socket 和真正用来传数据的 Socket 是两个:

  • 一个叫作监听 Socket,是调用socket()创建的;

  • 一个叫作已连接 Socket,是调用accept()创建的;

Ps:这俩个socket使用的是同一个端口

socket()

int  socket(int protofamily, int type, int protocol);//返回sockfd

进程用socket()来创建一个socket:通过系统调用创建一个socket,获得返回的描述符,该描述符唯一标识一个socket,把描述符加入该进程的描述符表。

创建socket时,可以传参:protofamily(协议族),type( sockety类型),protocol(协议)

(在linux系统中打开文件就会获得文件描述符,它是个很小的正整数。每个进程在PCB(Process Control Block)中保存着一份文件描述符表,文件描述符就是这个表的索引,每个表项都有一个指向已打开文件的指针

。根据unix一切都是文件的思想, socket也算是文件的一种,所以socket的描述符也是文件描述符)

socket中的fd:fild descriptor,(socket描述符)。

socketfd:socket:[18892]

  • socket :标识这是一个 socket 类型的 fd

  • [18892] :这个是一个 inode 号,能够唯一标识本机内的一条网络连接;

bind()函数

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

把一个地址族中的特定地址(IP+端口号组合)赋给socket。

//如果不bind,那就会在调用connect/listen时,系统随机赋一个端口

socketfd为socket的描述字

addr 指向要绑定给该socket的协议地址

addrlen是地址的长度

通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。

listen()、connect()函数

服务器在bind之后就会调用listen()来监听这个socket,这时候客户端如果调用connect()发出链接请求,服务器端就能收到这个请求

int listen(int sockfd, int backlog);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求(此时它变成了监听socket)。

connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与服务器的TCP连接。

监听connect请求->三次握手->建立连接,创建已连接socket只和listen有关,跟accept无关。

ps:这里说的是底层实现,跟应用开发层无关,应用层accept函数的主要作用是接受客户端的连接请求,并返回一个新的套接字用于通信。

accept()函数

accept()函数,就使用来 从传入的监听socket内的 已完成连接队列 中 的队首【队头】位置取出来一项已连接socket(已完成三次握手建立连接的),返回给应用程序进程,用于完成服务端和客户端的通信。

如果调用accept时,已完成连接队列是空的,那么accept就会阻塞等待已完成队列中有socket了再被唤醒

编程角度,我们要尽快的用accept()把已完成队列中的数据【TCP连接】取走,防止已完成队列积满,也为了快速响应

所以代码里在listen之后,会开一个线程 while循环的执行accept用来最快取出已完成连接的socket

(用while的原因是它调用一次只能处理一个客户端连接)

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); //返回连接connect_fd

参数sockfd

参数sockfd就是上面解释中的监听套接字,这个套接字用来监听一个端口,当有一个客户与服务器连接时,它使用这个一个端口号,而此时这个端口号正与这个套接字关联。当然客户不知道套接字这些细节,它只知道一个地址和一个端口号。

参数addr

这是一个结果参数,它用来接受一个返回值,这返回值指定客户端的地址,当然这个地址是通过某个地址结构来描述的,用户应该知道这一个什么样的地址结构。如果对客户的地址不感兴趣,那么可以把这个值设置为NULL。

参数len

如同大家所认为的,它也是结果的参数,用来接受上述addr的结构的大小的,它指明addr结构所占有的字节个数。同样的,它也可以被设置为NULL。

如果accept成功返回,则服务器与客户已经正确建立连接了,此时服务器通过accept返回的套接字来完成与客户的通信。

注意***:

accept默认会阻塞进程,直到有一个客户连接建立后返回,它返回的是一个新可用的套接字的描述字,这个套接字是连接套接字。

此时我们需要区分两种套接字,

监听套接字: 监听套接字正如accept的参数sockfd,它是监听套接字,在调用listen函数之后,是服务器开始调用socket()函数生成的,称为监听socket描述字(监听套接字)

连接套接字:一个套接字会从主动连接的套接字变身为一个监听套接字;而accept函数返回的是已连接socket描述字(一个连接套接字),它代表着一个网络已经存在的点点连接。

为什么要区分:

如果只用一个的话,那么当该socket用于建立连接时,它被占用期间,没人监听connect请求了,其他客户端无法请求。 所以一般都需要把一个socket独立出来用于监听connect请求,那么为了职责单一分明,干脆就把两种功能的socket分开了。

服务器的一个端口通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在(端口开放后就存在)。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。

连接套接字socketfd_new 并没有占用新的端口与客户端通信,依然使用的是与监听套接字socketfd一样的端口号

C10K

那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗?

并发 1 万请求,也就是经典的 C10K 问题 ,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。

从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。

不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远

方案演变

基于同步阻塞的网络模型-只有一个线程来处理连接socket:当服务端在还没处理完一个客户端的网络 I/O 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。也就是说,服务器在同一时刻只能与一个客户端建立连接

基于最原始的阻塞网络 I/O, 如果服务器要支持多个客户端怎么办?

基于同步阻塞的网络模型-多进程模型:为每个客户端(已连接socket)分配一个进程来处理连接请求

服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork() 函数创建一个子进程

img

缺点:进程资源的创建与销毁,进程上下文切换消耗很大

基于同步阻塞的网络模型-多线程模型:改用更轻量级的线程来处理客户端连接。即使我们可以使用线程池来避免线程频繁创建和销毁的消耗,但是每维护一个TCP连接都需要一个线程,如果同时来一万个,就要开一万个线程,维护线程的成本太高。

img

I/O多路复用

既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?答案是有的,那就是 I/O 多路复用技术。

一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。

(IO多路复用到底复用的是什么?IO多路复用复用的是一个用户线程)

我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件

事件包括什么?

读写事件(输入数据到达),连接事件(socket三次握手连接完成,加入到已连接队列中,IO多路复用接收到已连接事件时,会调用accept系统调用,来处理连接请求,返回socket描述字给应用进程)

select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可

img

select/poll

poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要在内核态和用户态两次遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合

ps:需要先调用select把所有socket集合拷贝到内核,然后内核中遍历检测集合中是否有产生了事件的就绪socket,然后把整个集合拷贝到用户态,然后用户态遍历找到就绪socket,再处理 (每次调用select尝试获取产生了事件的socket都要这样处理一次)

https://vdn6.vzuu.com/SD/349279b4-9119-11eb-85d0-1278b449b310.mp4?pkey=AAUnpoLfdNXg4eS1zHD9vM-N9PkoA_gDUjkr_37bhp-UsQjeH1dOfQ0ibMPiZPAAOjoAEzY1AhRILUabqnOfUv49&c=avc.0.0&f=mp4&pu=078babd7&bu=078babd7&expiration=1696761796&v=ks6

select 使用固定长度的 BitsMap,默认最大值为 1024,只能监听 0~1023 的文件描述符。

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制

epoll

先复习下 epoll 的用法。如下的代码中,先用e poll_create 创建一个 epol l对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到epfd中,最后调用 epoll_wait 等待数据。

int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)

int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中

while(1) {
    int n = epoll_wait(...);
    for(接收到数据的socket){
        //处理
    }
}

img

epoll 通过两个方面,很好解决了 select/poll 的问题。

第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以下次调用epoll_ctl只需要加入新出现的待检测socket,不需要每次都将所有已连接socket拷贝到内核,减少了内核和用户空间大量的数据拷贝和内存分配。 (红黑树维护待检测socket减少拷贝消耗)

第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,会返回有事件发生的文件描述符的个数和产生事件的文件描述符本身,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。(事件驱动-回调(有事件来了就调用回调函数将socket加入链表)+链表(记录就绪事件),相比于轮询,减少了检测消耗)

epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低

在 epoll 的系列函数里, epoll_create 用于创建一个 epoll 对象,epoll_ctl 用来给 epoll 对象添加或者删除一个 socket。epoll_wait 就是查看它当前管理的这些 socket 上有没有可读可写事件发生。

图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的! (qq.com)

![image-20240419210723465

epoll_wait其实就是完成了select要做的事,调用epoll_wait时会阻塞等待事件触发被唤醒,如果链表里没有socket就阻塞(处理请求时是同步),等IO事件来了epoll_wait被异步唤醒返回事件,

然后以边缘或水平触发模式,进程去系统调用read读取内核中的数据放到用户态中,这步read可以是阻塞,也可以是非阻塞。

read 调用,不管是阻塞还是非阻塞,都是一个同步的过程,因为调用者需要等待内核态的数据拷贝到用户态。

然而真正的异步,*应用程序并不需要主动发起拷贝动作

epoll其实就是select,poll基础上对拷贝消耗和检测消耗的优化,实际上实现还是大同小异

(处理的事件包括TCP连接事件)

IO多路复用是同步还是异步

image-20231010111708722

系统调用的角度来看:select,poll,epoll本质上都是同步I/O,他们发起IO请求,等待内核数据就绪事件是阻塞等待的,而异步I/O的发起者进程则无需关注具体的IO操作,只需要等待操作的结果,(如果调用epoll的进程可以直接回去等通知,不需要阻塞等待,请求发起者不需要关注IO操作的执行,这种叫做异步)

但是从epoll内部的实现机制中:每个 IO 事件触发后异步唤醒epoll_wait,是异步的

边缘触发和水平触发

epoll 支持两种事件触发模式,分别是边缘触发(*edge-triggered,ET*)*和*水平触发(*level-triggered,LT*)

这两个术语还挺抽象的,其实它们的区别还是很好理解的。

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完

  • 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;\

//水平触发
ret = read(fd, buf, sizeof(buf));

//边缘触发
while(true) {
    ret = read(fd, buf, sizeof(buf);
    if (ret == EAGAIN) break;
}

这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。

如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 readwrite)返回错误,错误类型为 EAGAINEWOULDBLOCK

一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用,Linux 手册关于 select 的内容中有如下说明:

Under Linux, select() may report a socket file descriptor as "ready for reading", while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.

我谷歌翻译的结果:

在Linux下,select() 可能会将一个 socket 文件描述符报告为 "准备读取",而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况。也有可能在其他情况下,文件描述符被错误地报告为就绪。因此,在不应该阻塞的 socket 上使用 O_NONBLOCK 可能更安全。

简单点理解,就是多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。

IO多路复用到底是不是异步的? - 知乎 (zhihu.com)

TCP连接建立并进行数据交换的过程

阻塞IO的TCP连接:

listenfd = socket();   // 打开一个网络通信端口
bind(listenfd);        // 绑定
listen(listenfd);      // 监听
while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

img

img

image-20231008183721906

IO多路复用的效率为什么高

多路复用之所以效率高,是因为操作系统提供了事件轮询API(select、poll和epoll系统调用),可以用一个线程就可以监控多个文件描述符,一次系统调用就可以监控当前所有的socket中有读写事件触发是哪些,然后程序再进行处理(将On次系统调用变成O1级)。

Reactor 非阻塞同步网络模式Proactor 异步网络模式

9.3 高性能网络模式:Reactor 和 Proactor | 小林coding (xiaolincoding.com)

1.Reactor是什么

Reactor 模式也叫 Dispatcher 模式,我觉得这个名字更贴合该模式的含义,

I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程

是基于面向对象思想对IO多路复用的封装

组成

Reactor 模式主要由 Reactor 和处理资源池这两个核心部分组成,它俩负责的事情如下:

  • Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;

  • 处理资源池负责处理事件,如 read -> 业务逻辑 -> send;

2.实现方案

三种实现方案:

  • 单 Reactor 单进程 / 线程;

  • 单 Reactor 多线程 / 进程;

  • 多 Reactor 多进程 / 线程;

单Reactor 单进程/线程

img

优点

单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现简单

不需要考虑进程间通信,也不用担心多进程竞争

这种方案存在 2 个缺点:

  • 第一个缺点,因为只有一个进程,无法充分利用 多核 CPU 的性能

  • 第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟

所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景

redis6.0版本之前采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。

单 Reactor 多线程 / 进程;

img

优点

优势在于能够充分利用多核 CPU

缺点

1.既然引入多线程,那么自然就带来了多线程竞争资源的问题。要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁

2.因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方

多 Reactor 多进程 / 线程;

img

优点:

多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下:

  • 主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。

  • 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。

而且解决了单reactor多线程的单reactor性能瓶颈问题

Reactor 和 Proactor 的区别

  • Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。

  • Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。

因此,Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。这里的「事件」就是有新连接、有数据可读、有数据可写的这些 I/O 事件这里的「处理」包含从驱动读取到内核以及从内核读取到用户空间。

举个实际生活中的例子,Reactor 模式就是快递员在楼下,给你打电话告诉你快递到你家小区了,你需要自己下楼来拿快递。而在 Proactor 模式下,快递员直接将快递送到你家门口,然后通知你。

无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件