更多 Java IO & NIO方面的文章,请参见文集《Java IO & NIO》
缓冲区
缓冲区的引入是为了减少频繁I/O操作而引起频繁的系统调用,当你操作一个流时,更多的是以缓冲区为单位进行操作,这是相对于用户空间而言。对于内核来说,也需要缓冲区。
几个基本概念
同步 IO VS 异步 IO 的区别:
- 同步 IO:数据访问的时候进程会阻塞
- 异步 IO:数据访问的时候进程不会阻塞
阻塞 IO 和非阻塞 IO 的区别:
- 阻塞 IO:应用程序的调用不会立即返回
- 非阻塞 IO:应用程序的调用会立即返回
同步 IO 包括:
- 阻塞 IO Blocking IO
- 非阻塞 IO NonBlocking IO
- 多路复用 IO Multiplexing IO
- 信号驱动 IO Signal Driven IO
阻塞 IO Blocking IO
Linux 下默认所有的 Socket 都是阻塞 IO
阻塞 IO 分为两个步骤:
- 步骤 1. 等待数据准备,拷贝到 OS 内核缓存区 (该过程中应用程序进程都会被阻塞)
- 步骤 2. 从 OS 内核缓存区拷贝到应用程序的地址空间 (该过程中应用程序进程都会被阻塞)
基本步骤如下,其中包括一次系统调用 recvfrom:
非阻塞 IO NonBlocking IO
可以将 Socket 设置为非阻塞 IO,例如 Java NIO 中可以设置 SocketChannel:channel.configureBlocking(false);
在上述步骤 1 等待数据的过程中,应用程序进程不会被阻塞,而是不断询问 OS 内核数据有没有准备好:
- 如果数据没有准备好,OS 内核返回一个 error,应用程序进程过一段时间再次询问(该过程中应用程序进程不会被阻塞)
- 如果数据已经准备好,则进入上述步骤 2,将数据从 OS 内核缓存区拷贝到应用程序的地址空间(该过程中应用程序进程都会被阻塞)
基本步骤如下,其中包括多次系统调用 recvfrom:
多路复用 IO Multiplexing IO
- 单个进程可以同时处理多个网络连接的 IO,即监听多个端口的 IO
- 适用于连接数很高的情况
- 实现方式:select,poll,epoll 系统调用
- 注册多个端口的监听 Socket,比如 8080,8081
- 当用户进程调用 select 方法后,整个用户进程被阻塞,OS 内核会监听所有注册的 Socket
- 当任何一个端口的 Socket 中的数据准备好了( 8080 或者 8081),select 方法就会返回
- 随后用户进程再调用 read 操作,将数据从 OS 内核缓存区拷贝到应用程序的地址空间。
-
多路复用 IO 类似于 多线程结合阻塞 IO
- 要实现监听多个端口的 IO,还可以通过多线程的方式,每一个线程负责监听一个端口的 IO
- 如果处理的连接数不是很高的话,使用 多路复用 IO 不一定比使用 多线程结合阻塞 IO 的服务器性能更好,可能延迟还更大
- 多路复用 IO 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接
多路复用 IO Multiplexing IO 的优点:
- 对于耗时短的处理场景高效
- OS 可以在多个事件源上等待,避免多线程结合阻塞 IO 方式带来的复杂度及性能开销
- 事务分离,将与应用无关的多路分解和分配机制与应用相关的回调函数分离开
多路复用 IO Multiplexing IO 的缺点:
- 处理耗时长的操作会造成事务分发的阻塞,影响后续事件的处理。
基本步骤如下,其中包括两次系统调用 select 和 recvfrom:
具体的使用,可以参见 Java NIO Buffer, Channel 及 Selector 中所述的 Selector 选择器。
异步 IO Asynchronous IO
异步 IO 用的很少。
- 用户进程发起异步 read 操作后,OS 内核立即返回,用户进程不会阻塞,而是去做其他事情。
- OS 内核等待数据准备好,随后将数据从 OS 内核缓存区拷贝到应用程序的地址空间,随后给用户进程发送一个信号,用户进程接着处理数据。
select 及 epoll
阻塞I/O模式下,一个线程只能处理一个流的I/O事件。如果想要同时处理多个流,要么多进程(fork),要么多线程(pthread_create),很不幸这两种方法效率都不高。
非阻塞忙轮询的I/O方式,我们发现我们可以同时处理多个流了,我们只要不停的把所有流从头到尾问一遍,又从头开始。这样就可以处理多个流了,但这样的做法显然不好,因为如果所有的流都没有数据,那么只会白白浪费CPU。
为了避免CPU空转,可以引进了一个代理(一开始有一位叫做 select 的代理,后来又有一位叫做 poll 的代理,不过两者的本质是一样的)。这个代理比较厉害,可以同时观察许多流的I/O事件,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中醒来,于是我们的程序就会轮询一遍所有的流:
while true {
select(streams[]) // 当前线程阻塞
for i in streams[] { // 轮询一遍所有的流
if i has data
read until unavailable
}
}
但是依然有个问题,我们从 select 那里仅仅知道了,有I/O事件发生了,但却并不知道是那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll之会把哪个流发生了怎样的I/O事件通知我们。
在讨论epoll的实现细节之前,先把epoll的相关操作列出:
-
epoll_create
:创建一个epoll对象,一般epollfd = epoll_create()
-
epoll_ctl
:往epoll对象中增加/删除某一个流的某一个事件,比如:-
epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);
有缓冲区内有数据时epoll_wait返回 -
epoll_ctl(epollfd, EPOLL_CTL_DEL, socket, EPOLLOUT);
缓冲区可写入时 epoll_wait返回
-
-
epoll_wait(epollfd,...)
:等待直到注册的事件发生
一个epoll模式的代码大概的样子是:
while true {
active_stream[] = epoll_wait(epollfd) // 当前线程阻塞
for i in active_stream[] { // epoll之会把哪个流发生了怎样的I/O事件通知我们
read or write till unavailable
}
}