linux非阻塞IO和IO多路复用详解

0.基础知识

0.1 什么NIO

对于java来说,NIOnew io。从java1.4开始引入的一套全新的ioapi。它提供了Channels and BuffersAsynchronous IOSelectors等新概念。

对于操作系统来说,NIOnon-blocking io。是非阻塞可多路复用的io。因此当我们讨论非阻塞io时,实际上都是讨论操作系统底层提供的能力。

本文章的第一章到第四章讲的都是linux系统函数提供的非阻塞api的使用。对于到java的nio,在不同的平台会使用不同的操作系统底层的io函数。比如Linux 2.6之前是select、poll,2.6之后是epoll,Windows是IOCP。

0.2 什么是文件描述符

在类unix系统中,所有一切皆文件。比如你打开一个套接字,在系统表现层面会创建一个文件描述符。当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文件,所有执行I/O操作的系统调用都会通过文件描述符。因此下文中提到的socket套接字和文件描述符其实是指代同一个东西。文件描述符在形式上是一个非负整数。

0.3 内核态和用户态

https://segmentfault.com/a/1190000039774784

1. 建立网络连接的过程(操作系统底层,系统调用)

建立网络链接底层是使用的Socket套接字。它既可以支持TCP协议、也可以支持UDP协议。对 IPV4IPV6 也都是支持的。套接字可以理解为一个输入输出流的组合。也就是说由4个元素确定唯一一个套接字:【本地ip:端口,远程ip:端口】。

一个客户端同时最多能建立多少连接?

因为每个网络访问请求都必须占用一个随机端口,因此客户端最多能建立多少端口受限于总的可用端口数(65535)。每一个网卡(或虚拟网卡)的端口都是相互隔离的。因此能建立多少请求可以通过:(网卡数 * 可用端口数)来计算。

一个服务端同时最多能建立多少连接?

服务端始终只监听本地1个端口(例如80端口),无论建立多少连接,始终都只这一个本地端口打交道。而套接字根据4个元素确定,变量就是远程ip和远程端口。因此可以说理论上服务端可以同时建立无数个连接。具体机器上能建立多少连接取决你的内存和cpu的配置(维护一个tcp连接需要消耗内存和cpu)。

对于服务端来说,需要建立网络连接之前需要做一些准备活动。

  1. 创建套接字(指定ip类型、协议类型)。

    int socket(int domain, int type, int protocol)

  2. 将套接字和地址端口绑定起来 。

    bind(int fd, sockaddr * addr, socklen_t len)

  3. 转换套接字的功能,向系统表示这个套接字是用来等待用户请求的,而不是主动发起请求。

    int listen (int socketfd, int backlog)

  4. 被动监听客户的握手请求(阻塞),三次握手成功就标识连接成功。

int accept(int listensockfd, struct sockaddr *cliaddr, socklen_t *addrlen)

对于客户端来说,是建立连接的发起方,需要使用socket主动发起请求

  1. 创建套接字(指定ip类型、协议类型)。

    int socket(int domain, int type, int protocol)

  2. 新建一个请求,和服务器建立链接,需要指定服务器的ip和端口。此时会发起TCP的三次握手。

int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen)

建立网络连接的过程

对于TCP的三次握手是由系统内核完成的,客户端和服务器会相互发送SYNACK指令确认对方都有信息的收发能力。

TCP三次握手

2. 传输数据(操作系统底层,系统调用)

当连接建立完成之后,就可以通过readwrite函数来读取发送数据了。当我们发送数据时,调用write函数会将数据写入内核空间的套接字缓冲区。上层应用并不关心数据是否实际已经发送到目标端。它只在乎数据是否已经写到了内核空间的套接字缓冲区。当套接字缓冲区已满,但是应用还需要继续写入新数据,就会导致阻塞,也就是说应用程序会在write函数调用处停留,并不会直接返回。具体从套接字缓冲区发送到目标端的这部分工作由系统内核完成,不需要上层应用考虑。

对于读取数据来说,也有缓冲区的概念。read函数可以从缓冲区中读取指定大小字节数的数据。当文件读取完毕(EOF),会返回0;当缓冲区的数据还未就绪时,读取会被阻塞。如果需要读取全部数据,需要循环调用read方法,直到数据被全部读取。

3. 关闭连接(操作系统底层,系统调用)

关闭连接需要经历4次挥手过程。关闭请求由客户端主动发起,服务端被动关闭。

四次挥手过程

第一次挥手:当服务端收到客户端关闭连接的请求时,服务端会出于半关闭状态(CLOSE-WAIT)。此时表示客户端已经没有要发送的数据了。但是服务端如果还需要发送数据,客户端必须接收,这个过程必须持续一段时间。

第二次挥手:服务端发送ACK表示收到客户端发到的关闭连接命令。【ACK只表示已收到消息,没有任何业务上的意义】

第三次挥手:等到服务端也没有数据发送时,服务端才会向客户端发送同意关闭连接的指令。

第四次挥手:客户端发送ACK表示收到服务端发到的关闭连接命令。【ACK只表示已收到消息,没有任何业务上的意义】,服务端收到之后马上就关闭了连接(比客户端先关闭)。

客户端在第四次挥手之后等待2ms后再关闭连接。

为什么客户端要等待2ms之后再关闭连接?
因为要确保第四次挥手ACK已经被服务端接收。如果服务端在第三次握手之后一直没有收到ACK,则会重发第三次握手的指令。此时客户端还没被关闭(因为会延迟2ms再关闭),于是客户端也可以重发ACK指令,保证整个关闭流程可以走完。

为什么关闭连接要四次握手,而不是三次握手呢?
关闭的前提是双方都已经没有数据发送了,且要发送的数据都已经被对方接受到了。因此要保证服务端数据都已经发送完毕,所以必须多一次握手请求,让服务端告诉客户端我已经没有数据发送了,同意关闭连接。

4.网络通信的演进过程(操作系统底层,系统调用)

上文第一节和第二节都是讲述的BIO(阻塞io),在acceptwriteread方法都会有阻塞。我们可以设想一下,如果需要通过BIO来编写一个生产可用的服务器。必然需要能提供同时处理多个请求的能力。因此我们需要这样写:

1
2
3
4
5
6
7
//伪代码
while(true){
//等待用户访问请求
Socket socket = accept();
//开启一个新线程管理这个socket读写;
new IoThread(socket).run();
}

于是一个简单的模型就产生了,每个线程管理一个socket。如果有1000个用户同时访问,就会产生1000个线程。这样做可以支持同时处理多个请求。但是也有很大的弊端:

  • 线程数太多,消耗cpu(上下文切换)
  • 线程数太多,消耗内存(虚拟机栈)

其实问题的核心就是线程数太多,那么我们需要进化出一套一个进程或者一个线程管理多个socket的方案,这个就是NIO,非阻塞IO。那么阻塞还是非阻塞是由谁决定的呢?肯定是底层内核决定的,因为应用层所有访问硬件的操作都是内核提供支持的。因此必须内核得到非阻塞的进化,我们应用上层才能非阻塞。

当我们底层acceptwriteread变成非阻塞之后,那么调用都会立即返回,只是说有没有结果而已。接下来基于非阻塞IO提供的接口,我们可以把代码修改一下,用一个线程解决同时服务多个用户请求的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//伪代码
List<SocketChannel> scList = new ArrayList<>();
while(true){
//非阻塞,如果没有客户端连接就会返回Null
SocketChannel sc = accept();
if(sc!=null){
//如果有客户端连接,就加入到连接列表中
scList.add(sc);
}
//遍历所有列表中的连接,看是否有可读数据
for(SocketChannel sc : scList){
//非阻塞,没有数据可读就返回0
sc.read();
}
}

通过上面代码,使用一个线程就搞定了多个socket的管理。但这样问题就没有产生新问题吗?要知道每次获取数据都是需要调用内核的api,一旦调用内核的api就涉及到用户态到内核态的切换。这个切换的成本是非常高的,假设我们有10000个连接,那么上面代码中for循环需要调用10000次内核接口查看每个socket是否有可读数据(复杂度O(n))。这就产生了10000次用户态到内核态之间的来回切换。

4.1 select和poll

为了解决频繁来回切换的问题,就提出了多路复用的理论。能不能提供一个接口,我把所有需要监听的套接字一次性传过去,内核再直接返回给我可读写的socket就好了(多路复用只监听读写状态,并不负责具体读写操作)。这样就把复杂度从O(n)降低到了O(1)。select和poll实现了上面提到的多路复用。select和poll的区别是select一次只能监听1024个fd(文件描述符),而poll则没有时间限制。注意,selectpoll都是同步api。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//伪代码
List<SocketChannel> scList = new ArrayList<>();
while(true){
//非阻塞,如果没有客户端连接就会返回Null
SocketChannel sc = accept();
if(sc!=null){
//如果有客户端连接,就加入到连接列表中
scList.add(sc);
}
//一次系统调用,查看所有的套接字中是否有可用的。仅仅返回可以用的socket。
List avaList = poll(scList)
for(SocketChannel sc : avaList){
sc.read()
}
}

到了这里,你以为poll就银弹吗?不,poll也是有确定的。他虽然减少了用户态到内核态的切换,但是他在内核里面依然是线性遍历所有的套接字。随着套接字的增加,性能依然衰减严重。另外,每次调用poll都会要重复传输要监听的套接字,假设套接字很多,那么会涉及到数据的重复拷贝,也影响性能。

4.2 epoll

为了解决上面提到的两个问题,于是epoll出现了。epoll有三个api,分别是:epoll_createepoll_ctlepoll_wait

  1. 应用会调用epoll_create创建一个文件描述符,放入待命空间
  2. 应用会使用epoll_ctl将刚刚创建的文件描述符和要监听的文件描述符绑定,并且设置感兴趣的事件。
  3. 当硬件有相关感兴趣的事件产生,就会把相关的文件描述符从待命空间移动到就绪空间。
  4. 我们可以调用epoll_wait查看就绪空间是否有可用的文件描述符,如果有的话,我们拿出来再做处理。

epoll模型

epoll是如何解决poll带来的问题呢?

  1. 他开辟了一个待命空间,会把新的文件描述符都往待命空间里面放。因此缓存了文件描述符。(避免了每次poll都要传很多重复的文件描述符)
  2. 待命空间会收到硬件的信息(比如网卡收到消息了),于是通过红黑树结构,可以很快的定位到文件描述符在待命空间的位置,把这个文件描述符移动到就绪空间。(避免了poll在内核线性遍历所有文件描述符的情况)

http://tutorials.jenkov.com/java-nio/index.html

https://cloud.tencent.com/developer/article/1853890