0.基础知识
0.1 什么NIO
对于java
来说,NIO
是new io
。从java1.4
开始引入的一套全新的io
的api
。它提供了Channels and Buffers
、Asynchronous IO
、Selectors
等新概念。
对于操作系统来说,NIO
是non-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
协议。对 IPV4
和 IPV6
也都是支持的。套接字可以理解为一个输入输出流的组合。也就是说由4个元素确定唯一一个套接字:【本地ip:端口,远程ip:端口】。
一个客户端同时最多能建立多少连接?
因为每个网络访问请求都必须占用一个随机端口,因此客户端最多能建立多少端口受限于总的可用端口数(65535)。每一个网卡(或虚拟网卡)的端口都是相互隔离的。因此能建立多少请求可以通过:(网卡数 * 可用端口数)来计算。
一个服务端同时最多能建立多少连接?
服务端始终只监听本地1个端口(例如80端口),无论建立多少连接,始终都只这一个本地端口打交道。而套接字根据4个元素确定,变量就是远程ip和远程端口。因此可以说理论上服务端可以同时建立无数个连接。具体机器上能建立多少连接取决你的内存和cpu的配置(维护一个tcp连接需要消耗内存和cpu)。
对于服务端来说,需要建立网络连接之前需要做一些准备活动。
创建套接字(指定
ip
类型、协议类型)。int socket(int domain, int type, int protocol)
将套接字和地址端口绑定起来 。
bind(int fd, sockaddr * addr, socklen_t len)
转换套接字的功能,向系统表示这个套接字是用来等待用户请求的,而不是主动发起请求。
int listen (int socketfd, int backlog)
被动监听客户的握手请求(阻塞),三次握手成功就标识连接成功。
int accept(int listensockfd, struct sockaddr *cliaddr, socklen_t *addrlen)
对于客户端来说,是建立连接的发起方,需要使用socket
主动发起请求
创建套接字(指定
ip
类型、协议类型)。int socket(int domain, int type, int protocol)
新建一个请求,和服务器建立链接,需要指定服务器的ip和端口。此时会发起TCP的三次握手。
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen)
对于TCP的三次握手是由系统内核完成的,客户端和服务器会相互发送SYN
和ACK
指令确认对方都有信息的收发能力。
2. 传输数据(操作系统底层,系统调用)
当连接建立完成之后,就可以通过read
和write
函数来读取发送数据了。当我们发送数据时,调用write
函数会将数据写入内核空间的套接字缓冲区。上层应用并不关心数据是否实际已经发送到目标端。它只在乎数据是否已经写到了内核空间的套接字缓冲区。当套接字缓冲区已满,但是应用还需要继续写入新数据,就会导致阻塞,也就是说应用程序会在write函数调用处停留,并不会直接返回。具体从套接字缓冲区发送到目标端的这部分工作由系统内核完成,不需要上层应用考虑。
对于读取数据来说,也有缓冲区的概念。read
函数可以从缓冲区中读取指定大小字节数的数据。当文件读取完毕(EOF),会返回0;当缓冲区的数据还未就绪时,读取会被阻塞。如果需要读取全部数据,需要循环调用read
方法,直到数据被全部读取。
3. 关闭连接(操作系统底层,系统调用)
关闭连接需要经历4次挥手过程。关闭请求由客户端主动发起,服务端被动关闭。
第一次挥手:当服务端收到客户端关闭连接的请求时,服务端会出于半关闭状态(CLOSE-WAIT
)。此时表示客户端已经没有要发送的数据了。但是服务端如果还需要发送数据,客户端必须接收,这个过程必须持续一段时间。
第二次挥手:服务端发送ACK
表示收到客户端发到的关闭连接命令。【ACK
只表示已收到消息,没有任何业务上的意义】
第三次挥手:等到服务端也没有数据发送时,服务端才会向客户端发送同意关闭连接的指令。
第四次挥手:客户端发送ACK
表示收到服务端发到的关闭连接命令。【ACK
只表示已收到消息,没有任何业务上的意义】,服务端收到之后马上就关闭了连接(比客户端先关闭)。
客户端在第四次挥手之后等待2ms
后再关闭连接。
为什么客户端要等待
2ms
之后再关闭连接?
因为要确保第四次挥手ACK
已经被服务端接收。如果服务端在第三次握手之后一直没有收到ACK
,则会重发第三次握手的指令。此时客户端还没被关闭(因为会延迟2ms
再关闭),于是客户端也可以重发ACK
指令,保证整个关闭流程可以走完。
为什么关闭连接要四次握手,而不是三次握手呢?
关闭的前提是双方都已经没有数据发送了,且要发送的数据都已经被对方接受到了。因此要保证服务端数据都已经发送完毕,所以必须多一次握手请求,让服务端告诉客户端我已经没有数据发送了,同意关闭连接。
4.网络通信的演进过程(操作系统底层,系统调用)
上文第一节和第二节都是讲述的BIO(阻塞io),在accept
、write
、read
方法都会有阻塞。我们可以设想一下,如果需要通过BIO来编写一个生产可用的服务器。必然需要能提供同时处理多个请求的能力。因此我们需要这样写:
1 | //伪代码 |
于是一个简单的模型就产生了,每个线程管理一个socket。如果有1000个用户同时访问,就会产生1000个线程。这样做可以支持同时处理多个请求。但是也有很大的弊端:
- 线程数太多,消耗cpu(上下文切换)
- 线程数太多,消耗内存(虚拟机栈)
其实问题的核心就是线程数太多,那么我们需要进化出一套一个进程或者一个线程管理多个socket的方案,这个就是NIO,非阻塞IO。那么阻塞还是非阻塞是由谁决定的呢?肯定是底层内核决定的,因为应用层所有访问硬件的操作都是内核提供支持的。因此必须内核得到非阻塞的进化,我们应用上层才能非阻塞。
当我们底层accept
、write
、read
变成非阻塞之后,那么调用都会立即返回,只是说有没有结果而已。接下来基于非阻塞IO提供的接口,我们可以把代码修改一下,用一个线程解决同时服务多个用户请求的问题。
1 | //伪代码 |
通过上面代码,使用一个线程就搞定了多个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则没有时间限制。注意,select和poll都是同步api。
1 | //伪代码 |
到了这里,你以为poll就银弹吗?不,poll也是有确定的。他虽然减少了用户态到内核态的切换,但是他在内核里面依然是线性遍历所有的套接字。随着套接字的增加,性能依然衰减严重。另外,每次调用poll都会要重复传输要监听的套接字,假设套接字很多,那么会涉及到数据的重复拷贝,也影响性能。
4.2 epoll
为了解决上面提到的两个问题,于是epoll
出现了。epoll
有三个api
,分别是:epoll_create
、epoll_ctl
、epoll_wait
。
- 应用会调用epoll_create创建一个文件描述符,放入待命空间
- 应用会使用
epoll_ctl
将刚刚创建的文件描述符和要监听的文件描述符绑定,并且设置感兴趣的事件。 - 当硬件有相关感兴趣的事件产生,就会把相关的文件描述符从待命空间移动到就绪空间。
- 我们可以调用
epoll_wait
查看就绪空间是否有可用的文件描述符,如果有的话,我们拿出来再做处理。
epoll是如何解决poll带来的问题呢?
- 他开辟了一个待命空间,会把新的文件描述符都往待命空间里面放。因此缓存了文件描述符。(避免了每次poll都要传很多重复的文件描述符)
- 待命空间会收到硬件的信息(比如网卡收到消息了),于是通过红黑树结构,可以很快的定位到文件描述符在待命空间的位置,把这个文件描述符移动到就绪空间。(避免了poll在内核线性遍历所有文件描述符的情况)