阻塞IO、非阻塞IO、以及多路复用原理
提示:写完文章后,目录可以自动生成,如何生成可参考右边的帮助文档
什么是I/O
在计算机系统中I/O就是输入(Input)和输出(Output)的意思,针对不同的操作对象,可以划分为磁盘I/O模型,网络I/O模型,内存映射I/O, Direct I/O、数据库I/O等,只要具有输入输出类型的交互系统都可以认为是I/O系统,也可以说I/O是整个操作系统数据交换与人机交互的通道,这个概念与选用的开发语言没有关系,是一个通用的概念。
在如今的系统中I/O却拥有很重要的位置,现在系统都有可能处理大量文件,大量数据库操作,而这些操作都依赖于系统的I/O性能,也就造成了现在系统的瓶颈往往都是由于I/O性能造成的。因此,为了解决磁盘I/O性能慢的问题,系统架构中添加了缓存来提高响应速度;或者有些高端服务器从硬件级入手,使用了固态硬盘(SSD)来替换传统机械硬盘;因此,一个系统的优化空间,往往都在低效率的I/O环节上,很少看到一个系统CPU、内存的性能是其整个系统的瓶颈。也正因为如此,Java在I/O上也一直在做持续的优化,从JDK 1.4开始便引入了NIO模型,大大的提高了以往BIO模型下的操作效率。
在介绍阻塞IO、非阻塞IO、以及多路复用的原理之前,先来看看一个网络IO的总体调用过程
1、客户端的进程想要向服务端的进程发送或申请数据
2、数据到达服务器端所在计算机的网卡,然后操作系统将其拷贝到内核所对应的socket缓冲区。
3、服务端进程将内核缓冲区的数据拷贝到自己的进程中,然后对数据进行处理(可能为读,可能为写)
4、服务端对处理完的数据发送到内核所对应的socket缓冲区,然后再从网卡发送到客户端。
一、BIO(阻塞IO)
BIO (Blocking I/O):同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。简单来试想一下一个服务端与客户端连接的一个场景。当有客户端连接时,服务器端需为其单独分配一个线程,如果该连接不做任何操作就会造成不必要的线程开销。BIO的缺点很明显,首先单独为每一个连接分配一个线程,线程的开销是很明显的,假如说有上万个连接,那么我就得分配上万个进程。对此的改进可能有一些人会想到用线程池,但是线程池要设多大,多大才合理,这个很难判断,但这也是一个优化的方法。
BIO总体流程:
1、服务器端启动一个SeverSocket
2、客户端启动Socket对服务器端发起通信,默认情况下服务器端需为每个客户端创建一个线程与之通讯
3、客户端发起请求后,先咨询服务器端是否有线程响应,如果没有则会等待或被拒绝
4、如果有线程响应,客户端线程会等待请求结束后,再继续执行
BIO的执行代码如下:
//BIO-服务器端
public class BIOSever {
public static void main(String[] args) throws IOException {
//在BIO中,可以使用线程池进行优化
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
ServerSocket serverSocket = new ServerSocket(6666);
System.out.println("服务器已启动");
while (true){
System.out.println("等待客户端连接.....(阻塞中)");
Socket socket = serverSocket.accept();
System.out.println("客户端连接");
cachedThreadPool.execute(new Runnable() {
public void run() {
handler(socket);
}
});
}
}
//从客服端socket读取数据
public static void handler(Socket socket){
try{
InputStream inputStream = socket.getInputStream();
byte[] b = new byte[1024];
while (true){
System.out.println("等待客户端输入.....(阻塞中)");
int read = inputStream.read(b);
if (read != -1){
System.out.println(new String(b, 0, read));
}else {
break;
}
}
inputStream.close();
}catch (Exception e){
e.printStackTrace();
}finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
//BIO-客户端
public class BIOClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 6666);
OutputStream outputStream = socket.getOutputStream();
Scanner scanner = new Scanner(System.in);
while (scanner.hasNextLine()){
String message = scanner.nextLine();
if ("exit".equals(message)) {
break;
}
outputStream.write(message.getBytes());
}
outputStream.close();
socket.close();
}
}
可以看到,当服务端分配一个线程处理客户端的时候,其服务端所分配的线程会调用read()函数,此函数其实会一直阻塞,等待数据的返回,而且该函数其实涉及到系统调用,即会从当前进程的用户态陷入到内核态,这也是一个很大的开销
二、NIO
BIO的缺点显而易见,首先想一下为什么会出现BIO,也就是说为什么线程要阻塞,其根本原因是内核要阻塞,那内核程序能不能支持一下非阻塞的方式,也即我管理的连接,当没有数据到达的时候,我返回-1,让其不阻塞。因此NIO就出现了,源自内核的支持,NIO在原则上可以让一个线程管理上万个连接。来看看NIO是如何做的
1、客户端想要连接服务端,服务端监听到客户端的连接,然后将这个连接分配到一个用于管理这些连接的线程上(可以理解为这个线程管理了一个具有多个连接的数组)
2、线程开始遍历当前数据,通过read()操作轮询是否有可读事件发生,如果有可读的事件发送,则进行读处理,如果没有,那么内核直接返回一个没有读事件发生的标识给线程,那么线程就可以一次遍历所有的连接来处理读写事件。
先来说一下该非阻塞IO的优点
1、不用像BIO一样创建多个线程,用一个线程就可以管理多个连接,减少了创建线程所带来的开销
看似我们解决了阻塞IO带来的问题,但是该阻塞IO也有一些缺点
1、虽然能用一个线程管理了多个连接,但是,不管连接中有没有可读可写的数据,都要进行遍历一边,如果连接数一多,那么这个O(n)的遍历复杂度还是挺消耗性能的
2、注意到,每次进行连接的读写的时候,都要调用read()或write的操作,上面说到,每一次read()和write()操作都要涉及到系统调用,从用户态陷入到内核态,想一想,N个连接就是N次的系统调用,这个开销还是挺大的
Select、Poll、Epoll
上述的NIO中,都是在用户态来完成轮询read或write操作,N个连接就是N次的系统调用,那有没有一种方式,即把我需要轮询的连接read和write操作转移到内核中?即我通过调用一个函数,然后这个函数就在内核中帮我监听这些连接,然后返回可读写的事件返回给用户态。而这其实就是内核所提供的select、poll、epoll函数
Select
Select函数如下
/* According to POSIX.1-2001 */
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
void FD_CLR(int fd, fd_set *set); //从fdset中删除fd
int FD_ISSET(int fd, fd_set *set); //判断fd是否已存在fdset
void FD_SET(int fd, fd_set *set); //将fd添加到fdset
void FD_ZERO(fd_set *set); //fdset所有位清0
select参数说明如下:
nfds:监控的文件描述符集中,待测试的最大描述符+1,这里可理解为用户空间需要监听的连接
readfds:监控有读数据到达文件描述符集合
writefds:监控有写数据到达文件描述符集合
exceptfds:监控异常发生达文件描述符集合
timeout:定时阻塞监控时间,3种情况:
1)NULL,永远等下去
2)设置 timeval ,等待固定时间
3)设置 timeval 里时间均为0,检查描述字后立即返回,轮询
这里不对源码做深入讲解,只需要知道内核提供了一个函数,帮助用户空间监听其连接,并返回有事件的连接,那么在用户空间,我们只需要轮询从select函数中返回的数据是否有可读写的标志,如果有,那么就做进一步的处理。而对比于上面的NIO,select将用户态的遍历转移到了内核态,也即通过一次系统调用就能返回可读写的事件。
poll
poll函数其实跟select的原理差不多,不同的地方是,select是把fd(可理解为连接)采用数组的方式存放,而poll是以链表的方式,采用链表的方式不需要一片连续的内存空间,这对内核空间来说非常重要。
epoll
epoll 是Linux内核中的一种可扩展IO事件处理机制,最早在 Linux 2.5.44内核中引入,可被用于代替POSIX select 和 poll 系统调用,并且在具有大量应用程序请求时能够获得较好的性能( 此时被监视的文件描述符数目非常大,与旧的 select 和 poll 系统调用完成操作所需 O(n) 不同, epoll能在O(1)时间内完成操作,所以性能相当高)
int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核需要监听的数目一共有多大。当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close() 关闭,否则可能导致fd被耗尽。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数,第一个参数是 epoll_create() 的返回值,第二个参数表示动作,使用如下三个宏来表示:
EPOLL_CTL_ADD //注册新的fd到epfd中;
EPOLL_CTL_MOD //修改已经注册的fd的监听事件;
EPOLL_CTL_DEL //从epfd中删除一个fd;
第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,struct epoll_event 结构如下
typedef union epoll_data
{
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
EPOLLIN //表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT //表示对应的文件描述符可以写;
EPOLLPRI //表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR //表示对应的文件描述符发生错误;
EPOLLHUP //表示对应的文件描述符被挂断;
EPOLLET //将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT//只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数events用来从内核得到事件的集合,maxevents 告之内核这个events有多大,这个 maxevents 的值不能大于创建 epoll_create() 时的size,参数 timeout 是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时
总结
提示:这里对文章进行总结:
例如:以上就是今天要讲的内容,本文仅仅简单介绍了BIO、NIO、多路复用的原理