基本概念

传统IO的种类

  • InputStream、OutputStream 基于字节流操作的 IO
  • Write、Reader基于字符流的IO
  • File基于磁盘操作的IO
  • Socket基于网络操作的IO

内核空间与用户空间
从OS的层次理解网络I/O模型 Linux 第1张

SRE实战 互联网时代守护先锋,助力企业售后服务体系运筹帷幄!一键直达领取阿里云限量特价优惠。
  • 内核负责网络与文件数据的读写
  • 用户程序通过系统调用获得网络和文件的数据

内核态与用户态的切换

//当前线程处于用户态
String str = "string";
int x = 2;
//切换至内核态
FileOutputStream fop = new FileOutputStream(new File("a.txt"));
OutputStreamWrite out = new OutputStreamWrite(fop, "GBK");
out.write("....");
out.append('\r\n');
out.close();
//用户态
int y = x + 2;

                     从OS的层次理解网络I/O模型 Linux 第2张

  • 程序为读写数据不得不发生系统调用。
  • 通过系统调用接口,线程从用户态切换到内核态,内核读写数据后,再切换回来。
  • 进程或线程的不同空间状态。

socket通信
                    从OS的层次理解网络I/O模型 Linux 第3张

  1. 服务器调用socket()、bind()、listen()完成初始化后,调用accept()阻塞等待,处于监听端口的状态,客户端调用socket()初始化后,调用connect()发出SYN段并阻塞等待服务器应答,服务器应答一个SYN-ACK段,客户端收到后从connect()返回,同时应答一个ACK段,服务器收到后从accept()返回。
  2. 数据传输的过程:
    建立连接后,TCP协议提供全双工的通信服务,但是一般的客户端/服务器程序的流程是由客户端主动发起请求,服务器被动处理请求,一问一答的方式。因此,服务器从accept()返回后立刻调用read(),读socket就像读管道一样,如果没有数据到达就阻塞等待,这时客户端调用write()发送请求给服务器,服务器收到后从read()返回,对客户端的请求进行处理,在此期间客户端调用read()阻塞等待服务器的应答,服务器调用write()将处理结果发回给客户端,再次调用read()阻塞等待下一条请求,客户端收到后从read()返回,发送下一条请求,如此循环下去。
  3. 如果客户端没有更多的请求了,就调用close()关闭连接,就像写端关闭的管道一样,服务器的read()返回0,这样服务器就知道客户端关闭了连接,也调用close()关闭连接。注意,任何一方调用close()后,连接的两个传输方向都关闭,不能再发送数据了。如果一方调用shutdown()则连接处于半关闭状态,仍可接收对方发来的数据。
  • 客户端
public class EchoClient {
	public static int DEFAULT_PORT = 9999;
	public static void main(String[] args) throws IOException {
		int port;
        try {
			port = Integer.parseInt(args[0]);
		} catch(RuntimeException e) {
        	port = DEFAULT_PORT;
		}
		Socket socket = new Socket("127.0.0.1", port);
		//键盘输入
		BufferedReader buff = new BufferedReader(new InputStreamReader(System.in));
		//Socket输出流,自动刷新
		PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
        //Socket输入流,读取服务端的数据并返回的大写数据
		BufferedReader buffin = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        String line = null;
        while((line = buff.readLine()) != null) {
        	if("stop".equals(line)) {
        		break;
			}
        	out.println(line);
			// 读取服务端返回的一行大写数据
			System.out.println(buffin.readLine());
		}

	}
}

也可使用linux下的nc命令代替客户端

  • 服务端
public class EchoServer {
	public static int DEFAULT_PORT = 9999;

	public static void main(String[] args){
		int port;
		try {
			port = Integer.parseInt(args[0]);
		} catch(RuntimeException e){
			port = DEFAULT_PORT;
		}

		try {
			ServerSocket serverSocket = new ServerSocket(port);
			Socket clientSocket = serverSocket.accept();
			String ip = clientSocket.getInetAddress().getHostAddress();
			System.out.println("port : " + port + '\t' + "ipaddress : " + ip);
			//server 输出流对应client输入流,反之亦然
			PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
			BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
			String inputline ;
			while((inputline = in.readLine()) != null){
				System.out.println(inputline);
				out.println(inputline.toUpperCase());
			}
		} catch (IOException e) {
			System.out.println("Exception caught when trying to listen on port" + port + "or listening for a connection");
			e.printStackTrace();
		}
	}
}

同步与异步

描述的是用户线程与内核的交互方式或者说关注的是消息通信机制:

  • 同步是指用户线程发起 I/O 请求后需要等待(堵塞)或者轮询(非堵塞)内核 I/O 操作完成后才能继续执行;
  • 异步是指用户线程发起 I/O 请求后仍继续执行,当内核 I/O 操作完成后会通知用户线程,或者调用用户线程注册的回调函数。

堵塞与非堵塞

关注的是用户线程调用内核 I/O 操作时,用户线程等待I/O操作完成前是否能做其他的事情:

  • 阻塞是指 I/O 操作需要彻底完成后才返回到用户空间;
  • 非阻塞是指 I/O 操作被调用后立即返回给用户一个状态值,无需等到 I/O 操作彻底完成。

阻塞与非阻塞主要是程序(线程)等待消息通知时的状态角度,同步与异步主要是从消息机制角度来说,这两组概念组合为四种情况,下面举几个网上的例子:

  • 同步堵塞 李华点火烧水,中间啥事也没干,并一直等到水开(阻塞),水开了山治关火(同步)
  • 同步非堵塞(轮询方式) 李华点火烧水,中间去看了电视,时不时看看水开了嘛(非阻塞),水开了山治关火(同步)
  • 异步堵塞 李华使用电水壶烧水,并一直等待电水壶烧水(阻塞),中间啥也没干,水开了自动断电(异步)。
  • 异步非堵塞 李华使用电水壶烧水,然后去看电视了(非阻塞),没有再管烧水壶,水开了自动断电(异步)。

IO模型演进

IO操作发生时会经历两个阶段:

  1. 用户进程等待系统内核数据准备
  2. 将数据从内核拷贝到用户进程中

下面简单介绍常见的五种 I/O 模型:

  1. 阻塞 I/O
  2. 非阻塞 I/O
  3. I/O 复用(select 和 poll)
  4. 信号驱动I/O(SIGIO)
  5. 异步 I/O

本节中将recvfrom函数视为系统调用。一般recvfrom函数的实现都有一个从应用程序进程中运行到内核中运行的切换,一段时间后再跟一个返回应用进程的切换。

阻塞 I/O

请求无法立即完成则保持阻塞。

                 从OS的层次理解网络I/O模型 Linux 第4张

  • 等待数据就绪。网络I/O的情况就是等待远端数据陆续抵达;磁盘I/O的情况就是等待磁盘数据从磁盘上读取到内核态内存中。
  • 数据复制。出于系统安全考虑,用户态的程序没有权限直接读取内核态内存,因此内核负责把内核态内存中的数据复制一份到用户态内存中。

进程阻塞的整段时间是指从调用recvfrom函数开始到它返回的这段时间,当进程返回成功提示时,应用进程开始处理数据报。

非阻塞 I/O
               从OS的层次理解网络I/O模型 Linux 第5张

  • socket设置为NONBLOCK(非阻塞)就是告诉内核,当所请求的I/O操作无法完成时,不要让进程进入睡眠状态,而是立刻返回一个错误码(EWOULDBLOCK),这样请求就不会阻塞;
  • I/O操作函数将不断地测试数据是否已经准备好,如果没有准备好,则继续测试,直到数据准备好为止。在整个I/O请求的过程中,虽然用户线程每次发起I/O请求后可以立即返回,但是为了等到数据,仍需轮询、重复请求,而这是对CPU时间的极大浪费。
  • 数据准备好了,从内核复制到用户空间。

I/O 复用(异步堵塞I/O)

I/O 多路复用会用到 select 或者 poll 函数,这两个函数也会使进程阻塞,但是和阻塞 I/O 所不同的的,这两个函数可以同时阻塞多个 I/O 操作。而且可以同时对多个读操作,多个写操作的 I/O 函数进行检测,直到有数据可读或可写时,才真正调用 I/O 操作函数。

                  从OS的层次理解网络I/O模型 Linux 第6张

从流程上看,使用select函数进行I/O请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select函数的优势是用户可以在一个线程内同时处理多个socket的I/O请求。用户可以注册多个socket,然后不断地调用select来读取被激活的socket,达到在同一个线程内同时处理多个I/O请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

I/O复用模型使用Reactor设计模式实现了这一机制。

调用select或poll函数的方法由一个用户态线程负责轮询多个socket,直到阶段1的数据就绪,再通知实际的用户线程执行阶段2的复制操作。通过一个专职的用户态线程执行非阻塞I/O轮询,模拟实现阶段1的异步化。

信号驱动I/O
                  从OS的层次理解网络I/O模型 Linux 第7张

首先,我们允许socket进行信号驱动I/O,并通过调用sigaction来安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好后,进程会收到一个SIGIO信号,可以在信号处理函数中调用recvfrom来读取数据报,并通知主循环数据已准备好被处理,也可以通知主循环,让它来读取数据报。

异步 I/O

调用 aio_read 函数,告诉内核描述字,缓冲区指针,缓冲区大小,文件偏移以及通知的方式,然后立即返回。当内核将数据拷贝到缓冲区后,再通知应用程序。
异步I/O模型使用Proactor设计模式实现了这一机制。
                  从OS的层次理解网络I/O模型 Linux 第8张

异步I/O模型告知内核:当整个过程(包括阶段1和阶段2)全部完成时,通知应用程序来读数据。

几种 I/O 模型的比较

前四种模型的区别是阶段1不相同,阶段2基本相同,都是将数据从内核拷贝到调用者的缓冲区。而异步 I/O 的两个阶段都不同于前四个模型。

同步 I/O 操作引起请求进程阻塞,直到 I/O 操作完成。异步 I/O 操作不引起请求进程阻塞。

                  从OS的层次理解网络I/O模型 Linux 第9张

关于这些模型的具体实现我打算放到Java I/O中进行讨论。

扫码关注我们
微信号:SRE实战
拒绝背锅 运筹帷幄