了解BIO,NIO,AIO就需要了解几个概念:,
概念1: BIO是同步阻塞,NIO是同步非阻塞,AIO是异步非阻塞, NIO,BIO的实质都调用系统内核提供的不同的方法;
概念2:select,poll,epoll都是实现NIO的一种技术,其实质就是系统内核提供的一个API。从select到epoll,每一次系统内核的升级,每一个新的思想和技术的更新,都为了解决上一代思想和技术存在的问题;
概念3:在Linux操作系统中,万物皆文件,设备,套接字,目录等都是文件,每一个打开的文件(正在使用的套接字也是一个打开的文件)都对应一个文件描述符(非负整数,类似Java的变量,C语言的指针);
概念4:每个进程的PCB中都有一个指向一张表的指针,该表最重要的部分就是包含一个指针数组,数组里的每个元素就是一个文件描述符,每个文件描述符都指向一个文件 ,一个进程中,文件描述符默认从0开始,0表示IO标准输入,1表示标准输出,进程的文件描述符位于/proc/进程号/fd/目录下,如下图所示;
有了以上概念,下面将具体介绍BIO和NIO:
BIO : Block-IO 阻塞IO
BIO的实质就是调用内核的socket(),bind(),listen(),accept()等内核AIP完成服务端对客户端的监听;
上图是一个Java代码编写的简单的socket监听程序,该程序执行时(线程在执行时),系统内核进行了以下几步操作:
1.调用内核socket(...,...)方法,创建一个服务端socket,该方法返回一个数值,该数值被称为文件描述符(fd),这个fd指向这个服务端socket;
2.调用内核bind(5,8090,...)方法绑定socket(fd=5),以及端口8090;
3.调用内核listen(5,....)方法开启监听,监听socket(fd=5);
4.调用accept(5,)阻塞,等待客户端建立连接;
5.当有客户端建立连接时,内核accept()方法返回该客户端socket的文件描述符(比如fd=6) ,Java代码通过accept ()返回的客户端socket实际就是这个fd=6,只是被Java包装成了Socket类;
6.调用内核recvfrom(6,....)方法,获取fd=6的客户端发送的数据,该方法是阻塞的;
7.调用内核write(1,....)方法,将获取到的数据输出(写入fd=1 ,文件描述符1表示输出);
以上是一个Java socket程序在建立监听到数据时与内核交互的主要流程;
(注意,以上流程基于Java1.4版本之前,使用BIO技术的老版本Java。1.4之后的Java版本,在实际监听时已经不使用BIO阻塞等待了,而是使用poll,本文主要为了循序渐进的介绍BIO和NIO)
以上的方案:问题主要有几点:
1.当有一个客户端建立连接后,服务端线程会 一直阻塞,等待客户端发送数据,此时f服务端无法再接收其他客户端的连接;
2.如果按照图中,为了能接收不同的客户端,每接收一个客户端就新建一个线程,则资源过于浪费(创建新线程的相关系统资源耗费很大);
因此,BIO方案在连接量大的时候是行不通的。
由以上分析可以得出,BIO方案要么需要一直阻塞等待,要么就需要创建大量的线程,而创建线程也是为了解决阻塞的问题,因而根源就在于---阻塞!
如果能解决阻塞,就可以解决BIO存在的问题。
为了解决BIO的问题,提出了NIO的概念,NIO主要有两层意思:
1.:框架层面: new-IO
2.系统内核层面 : non-block 非阻塞IO。
NIO的实质就在于系统内核调用上发生了变化,当调用内核socket()方法时,传入SOCK_NONLOCK属性,则在后续调用accept()或者recvfrom()方法时无论是否获取到数据,都会立即返回,如果有数据(有客户端建立连接),则返回该客户端的文件描述符,如果未获取到信息,则返回负数。
因此上述流程可以更改为:
1.调用socket(...,SOCK_NONLOCK,...)方法,创建一个服务端socket,并得到文件描述符(fd=5),注意此时设置了参数SOCK_NONLOCK,表示该socket是非阻塞的;
2.调用内核bind(5,8090,...)方法绑定socket(fd=5),以及端口8090;
3.调用内核listen(5,....)方法开启监听;
4.调用accept(5,...)无论是否有客户端建立连接,均直接返回,由于程序是while(true)循环,因此会不断重复调用accept(),也就实现了非阻塞,在一个线程内可以监听多个客户端;
5.当有客户端建立连接时,内核accept()方法返回客户端的信息(fd=6);
6.调用内核recvfrom(6,....)方法,获取fd=6的客户端发送的数据,该方法也不再阻塞,如果fd=6的客户端此时没有数据发过来,则recvfrom()直接返回,程序继续循环,此时如果有新的客户端过来(fd=7),则服务端可以正常接收新的客户端访问;
可以看到,我们通过在socket()方法增加SOCK_NONBLOCK参数,将同步阻塞变成了同步非阻塞,实现了简单的NIO。
多路复用器:
这种模式就是SELECT
这其中涉及到一个概念,IO多路复用,是一种同步非阻塞模式,也就是同一个线程能够监控(轮询)多个channel,或者说一次系统调用,可以同时获取多个IO的状态(句柄 fd)。
select 就是一种多路复用器的实现
但以上方案也有问题:
1.为了获取到客户端的响应数据,服务端需要不断的调用recvfrom()方法,轮询每个客户端(fd)。极端情况下,如果有10000个客户端,但只有1个客户端有响应数据,则需要调用10000次recvfrom(),但只有一次是有意义的,其他9999次都是浪费!
为了解决上述这种做无用功的问题,内核提供了select()方法,在调用recvfrom()方法之前先调用一次select()方法,将需要监听的客户端都传入select()中(实际传入的就是fd的列表),该方法会通过返回值告诉程序哪些客户端(fd)有数据响应,则程序只需要对有数据响应的客户端调用recvfrom()方法,获取数据。
poll和select是类似的原理,不再赘述。
SELECT方式减少了recvfrom()方法的调用,解决了一重复调用导致的资源消耗问题,但select本身还是有问题:
1.每次调用select方法都需要把所有的fd作为参数传给内核,在有很多个fd的时候,如果每次都作为参数传过去,也是很耗费资源的;
2.实际上内核对于select()方法的实现方式也是遍历,也就是说select()方法把程序调内核遍历变成了内核自己遍历自己,虽然减少了外部程序调内核的开销,但遍历的本质没有变;
为了彻底解决每次都传值以及循环遍历导致的性能开销问题,人们提出了几种方案:
1.对于每次传fd列表的问题,在内存中开辟一个空间,将fd列表存储起来,以后只在有新客户端建立时才传fd;
2.对于循环遍历的问题,引入了操作系统的中断事件概念,在操作系统中,任何操作都会产生一个中断,CPU通过中断的级别来判断需要优先处理哪些中断。数据传输也是一样,当有数据进来时(数据通过IO进入内存后),操作系统会产生一个中断事件,可以通过监听这种中断事件达到监听数据的目的。
这种基于中断事件的技术就是epoll
系统内核提供了三个epoll的方法:
epoll_create():先在内存创建一个eventpoll对象,并返回该对象的文件描述符efd,eventpolll对象包含一个就绪队列和一个等待队列,等待队列使用了红黑树,便于数据的删除和增加,因为epoll_ctl()方法会不断的增加和删除等待队列的数据,就绪队列使用了一个双向链表; 这里需要注意的是,这颗红黑树是采用了mmap技术,也就是说这颗红黑树的内存是用户空间和内核空间共享的,当有一个新的文件描述符产生时,用户线程对该红黑树进行操作,添加一个节点,内核就可以通过操作该空间,直接读取到最新的文件描述符集合。
epoll_ctl(efd,add,fd,accept):该方法用于向eventpoll对象的等待队列添加需要监听的socket,监听socket的的accept事件;
epoll_wait(efd):监听 eventpoll对象的就绪列表,有数据则返回,没有则阻塞,epoll_wait()可以设置超时等待时间,负数表示一直阻塞;
1.调用socket(...,SOCK_NONLOCK,...)方法,创建一个服务端socket,并得到文件描述符(fd=5),注意此时设置了参数SOCK_NONLOCK,表示该socket是非阻塞的;
2.调用内核bind(5,8090,...)方法绑定socket(fd=5),以及端口8090;
3.调用内核listen(5,....)方法开启监听;
4.调用epoll_create()方法创建一个eventpoll对象,并返回该对象的文件描述符efd=8;
5.调用epoll_ctl(efd8,add,fd5,accept)方法,在eventpoll(efd=8)的等待队列中添加socket(fd=5),同时向内核的中断处理程序注册一个回调函数,监听socket(fd=5)的accept事件,该函数的作用就是当有事件响应时,向就绪列表添加该事件(socket)的描述符fd;
6.调用epoll_wait(efd=8),监听eventpoll的就绪队列,是否有事件过来,epoll_wait()返回的是该事件的fd,如果没有则阻塞等待或者直接返回;
7.当有客户端连接进来时(TCP建立连接时,会产生一个中断事件),当事件到来时,中断程序会通过之前注册的回调函数给epollevent对象的就绪列表添加已经就绪的socket的fd(fd=5),当程序调用epoll_wait()的时候,会返回该该socket的fd(fd=5),程序收到后可以判断出是服务端socket的accept()事件有了响应,会调用accept()方法,与新的客户端建立连接,并返回新客户端的fd(fd=9)。然后会再一次调用epoll_ctl(efd8,add,fd9,read),表示向eventpoll(efd8)的等待列表增加客户端efd9的读(read)事件,实现对客户端efd9的读事件监听;
8.此时如果有另外一个客户端连接进来(socket fd=5的accept事件),同时客户端fd9有数据传输进来(socket fd=9 的read事件),中断程序会通过回调函数将这两个socket对应的fd添加到就绪列表,当程序调用eopll_wait()时,这两个fd会通过epoll_wait()返回给服务端,服务端收到后根据判断会分别调用recvfrom()方法读取数据以及accept()返回新的客户端(fd=10),然后调用epoll_ctl(efd8,add,fd10,read)向eventpoll的等待队列增加一个新的客户端fd10的读取事件。
可以看到,程序只有在由新的事件需要监听时才调用epoll_ctl()方法,同时只需要调用epoll_wait()就可以知道有哪些事件到达了,然后再去调用accept()或者recvfrom()方法,如此则解决了select中需要遍历所有客户端的问题。
与select和poll相比,epoll有以下几个优点:
1.通过epoll_ctl()方法将需要监听的socket(fd)放在内核空间efd中,就不需要像select一样每次都把所有的socket作为参数传进去,减少资源消耗;
2.select和poll是需要不断调用函数遍历查询是否有事件到达,而对于epoll而言,当有事件到来时,通过操作系统的中断机制就可以获取到到达的事件,中断程序通过注册的回调函数将事件的描述符fd添加到就绪列表,整个过程不需要程序参与,相当于异步,程序只需要调用epoll_wait()方法收集就绪的事件就可以了,这样就解决了遍历的问题;
3.select和poll在得到事件响应后是需要通过read()或者write()从内核读取数据,数据的流向相当于设备-内核-用户空间,而epoll由于使用了mmap技术,可以将设备的内存地址直接映射到用户空间,数据流向为设备-用户,中间省去了从内核拷贝数据的过程,因此可以节省大量的开销;