基本概念
I/O
输入输出(input/output)的对象可以是文件(file)、网络(socket)、进程之间的管道。在Linux系统中,都用文件描述符(fd)来表示。
阻塞与非阻塞
没有数据传过来时,读会阻塞直到有数据;缓冲区满了,写操作也会阻塞。非阻塞都是直接返回。阻塞和非阻塞强调的是调用者是否等待。
同步与异步
数据就绪后需要应用程序自己去读是同步。数据就绪后通过回调给到应用程序是异步。同步与异步强调的是获取数据的操作是由调用者还是被调用者完成。
内核空间与用户空间
在 Linux 中,应用程序的稳定性远远比不上操作系统程序,为了保证操作系统的稳定性,分出了内核空间和用户空间。内核空间运行操作系统程序和驱动程序,用户空间运行应用程序。所有的系统资源操作都在内核空间进行,比如读写磁盘文件、内存分配和回收以及网络接口调用等。不难看出,一次网络IO读取过程中,数据并不是直接从网卡读取到用户空间中的应用程序缓冲区,而是先从网卡拷贝到内核空间缓冲区,然后再从内核拷贝到用户空间中的应用程序缓冲区。对于网络IO写入过程则相反,先将数据从用户空间中的应用程序缓冲区拷贝到内核缓冲区,再从内核缓冲区把数据通过网卡发送出去。
零拷贝
零拷贝是一种避免多次内存复制的技术,用来优化读写IO操作。
Linux 内核中的 mmap 函数可以将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址,不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理内存地址。这种方式实现用户空间和内核空间共享一个缓存数据,避免了内核空间与用户空间的数据交换。I/O 复用中的 epoll 函数中就是使用了 mmap 减少了内存拷贝。
Java 中,在用户空间中又存在一个拷贝,即从 Java 堆内存中拷贝到临时的直接内存中,通过临时的直接内存拷贝到内核空间中去。此时的直接内存和堆内存都是属于用户空间。DirectBuffer 是直接分配物理内存(非堆内存)的,它直接将过程简化为数据直接保存到非堆内存,这样就减少了一次拷贝。注意,DirectBuffer 只优化了用户空间内部的拷贝。而在 NIO 中,MappedByteBuffer 是通过本地类调用 mmap 进行文件内存映射的,可以直接将文件从网卡拷贝到用户空间,只进行一次数据拷贝,从而减少了传统的 read() 方法从网卡拷贝到内核空间这一步。
网络IO模型
Linux 网络IO模型包括:同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O 。需要说明的是,操作系统层面的IO模型和Java中的IO模型是一一对应的,Java只是对操作系统API进行了封装。
同步阻塞IO
用户线程发起read 请求后就阻塞了,此时会让出 CPU ,不能再干其它事情 。内核等待网卡数据到来,把数据从网卡拷贝到内核空间,接着把数据拷贝到用户空间,再把用户线程唤醒。这样情况下,需要为每个连接都分配一个线程,在大量连接的场景下就需要大量的线程,会造成巨大的性能损耗,这也是传统阻塞IO的最大缺陷。
同步非阻塞IO
用户线程在发起 read 请求后立即返回,如果没读取到数据,用户线程会不断轮询发起 read 请求,直到数据到达(内核准备好数据)后才停止轮询,在等待数据从内核空间拷贝到用户空间这段时间里,线程还是阻塞的,等数据到了用户空间再把线程唤醒。非阻塞IO模型虽然避免了由于线程阻塞问题带来的大量线程消耗,但是频繁地重复轮询大大增加了请求次数,对CPU消耗也比较明显。
IO多路复用
用户线程的读取操作分成两步了,线程先发起 select 调用,目的是问内核数据准备好了吗?等内核把数据准备好了,用户线程再发起 read 调用。在等待数据从内核空间拷贝到用户空间这段时间里,线程还是阻塞的。注意,等待 select 返回过程也是阻塞的,所以说IO多路复用并非完全非阻塞。那为什么叫 I/O 多路复用呢?因为一次 select 调用可以向内核查多个数据通道(Channel)的状态,所以叫多路复用。

套接字
所谓套接字(Socket),可以抽象成两个程序进行通讯连接中的一个端点,提供了应用层进程利用网络协议交换数据的机制。要通过互联网进行通信,至少需要一对套接字,一个运行于客户机端,另一个运行于服务器端。不同编程语言对套接字(Socket)都有对应的封装,如 Java 中的 ServerSocket/Socket,Python 中引用套接字的模式是 socket 。本质上来说,套接字是操作系统层面的产物,它既是一种编程模型,同时又是一个文件(操作系统提供支持网络通信的一种文件格式)。
Socket 编程模型
套接字(Socket)通信过程如下图所示,这里以流式套接字(TCP)为例:
下面对上图的流程简单说明:
应用程序通过系统调用 socket 创建一个套接字,它是操作系统分配给应用程序的一个文件描述符(用来标识套接字(Socket)的)。
应用程序会通过系统调用 bind,绑定地址和端口,给套接字命名一个名称。
系统会调用 listen 创建一个队列用于存放客户端进来的连接。
应用服务会通过系统调用 accept 来监听客户端的连接请求。
双向管道文件
套接字(Socket)是一个支持网络通信的文件,存储的是数据。服务端 Socket 文件存储的是客户端 Socket 文件描述符;客户端 Socket 文件存储的是传输数据。
当一个客户端连接到服务端的时候,操作系统就会创建一个客户端 Socket 的文件。然后操作系统将这个文件的文件描述符写入服务端程序创建的服务端 Socket 文件中。进程可以通过 accept() 方法,从服务端 Socket 文件中读出客户端的 Socket 文件描述符,从而拿到客户端的 Socket 文件。Socket 是一个双向的管道文件,当线程想要读取客户端传输来的数据时,就从客户端 Socket 文件中读取数据;当线程想要发送数据到客户端时,就向客户端 Socket 文件中写入数据。
注意:
1 服务端维护的 Socket 数量是 N+1,包括 N 个与客户端对应的 Socket 和一个监听 Socket 。
2 操作系统创建的 Socket 是由文件系统管理的,内核中有一个文件列表(fd)管理这些 Socket。
IO 多路复用
如何同时监视多个 Socket 呢?答案就是多路复用。
在 IO 多路复用技术中,应用进程(或线程)需要维护一个 Socket 集合(可以是数组、链表等),然后定期遍历这个集合,判断每个 Socket 文件的状态。这些 Socket 文件的状态如:服务端 Socket 文件写入客户端 Socket 文件描述符,客户端 Socket 文件的读、写等操作。这样的做法在客户端 Socket 较少的情况下没有问题,但是如果接入的客户端 Socket 较多,比如达到上万,那么每次轮询的开销都会很大。
为了解决这个问题,就需要一个观察者角色,观察者需要知道每个 Socket 文件的状态,这样就可以在 Socket 文件状态发生改变时,把相关信息推送应用进程了。这种方式就不需要应用进程主动轮询。不难发现,最合适的观察者其实就是操作系统本身,因为操作系统非常清楚每一个 Socket 文件的状态(包括服务端和客户端的 Socket),毕竟对 Socket 文件的读写都要经过操作系统。具体来说,每个 Socket 对应着一个端口号,而网络数据包中包含了 ip 和端口的信息,内核可以通过端口号找到对应的 Socket 。
总结起来就是:单线程或单进程同时监测若干个文件描述符是否可以执行IO操作的能力,IO多路复用解决的本质问题是用更少的资源完成更多的事。注意,处理IO多路复用的问题,需要操作系统提供内核级别的支持。如 Linux 下有三种提供IO多路复用的 API,分别是 select、poll 以及epoll。
小结
本篇文章对网络通信相关的基本概念进行了说明,并重点对常见的 I/O 模型进行介绍,接着介绍套接字并引出 I/O 多路复用。