对于BIO和NIO的理解
一、BIO(同步型阻塞IO):
传统的服务器对于收到的每一个socket请求都会创建一个线程去处理它的IO事件,但是处理IO事件时,即在线程中使用socket.read()方法时,该方法会一直阻塞到该线程可以进行read()操作为止,我们首先分析一下这种阻塞是否有必要:如果有大规模的socket请求到来,服务器都会将创建它们对应的线程,由于创建线程的代价是很大的,既浪费内存又浪费CPU的执行时间,而它们在请求read方法被阻塞时,它们会在内存停留(或者操作系统支持阻塞挂起则会将线程的数据区和程序区放到磁盘中等待),磁盘IO过程同样耗费时间。
二、NIO(同步型非阻塞IO):
2.1:NIO概述
可不可以将这些阻塞的线程换一种方式等待,使得内存中不需要有很多因为等待IO而被阻塞的线程,因为这些线程在整个时间线看来同时运行的可能也只有一部分IO事件到来的线程在运行。如果我们事先就知道哪些线程现在就可以进行读写操作了,我们只用一个线程或者多个线程(肯定要比原来的少很多)来处理这些读写操作,那我们就可以保证线程在进行这些读写操作时,它就不会被阻塞了(因为我们已经知道了它已经可以进行读写我们才将它放入到线程中进行处理),这样一来,我们所需要的线程就大大减少,我们甚至可以只用一个线程专门批处理这些已经准备好IO读写的socket请求。
就好比我开了一家面馆,BIO:如果来一位客人我就给他搬出来一张新的桌子,客人走了我再把桌子搬回去,可是我馆子里最多只能放十张桌子,那么到第11位客人来的时候,我就不知道该怎么办了,而每个客人需要等待服务员点菜,服务员只能轮流给这些客人提供服务,在服务员还没到达之前,客人就只能占用着桌子干等。NIO:我就准备2张桌子,我让客人吃饭前提前预定,一张用来让服务员根据名单去检查哪个客人预订的时间到了,一张专门用来吃饭,我看现在谁能吃饭了,我就把他请进店里,饭都准备好放桌子上了,进来直接吃饭直接走。
由于进行读写操作时不会再发生阻塞了,因此NIO为非阻塞型IO(读写操作前就对能不能立即读写进行判断,因此一定是它可以立即读写了才会执行读写操作),何为同步型:就是读写操作是操作系统通知我可以读写了我用户程序自己去读写。异步型:读写操作是操作系统通知我读写事件完成了,也就是操作系统已经把读写操作完成了,放到你的用户数据区也就是堆内存里了。(简单来说就是操作系统通知给你的是你可以开始读写了;还是通知你读写已经完成了,你已经可以用了)的区别。NIO的读写得自己从Buffer里面读取。因此NIO是同步型IO。
NIO的三个核心组件:Selector、Buffer和Channel。

如图所示:客户端和服务器端进行Socket IO操作通过通道(Channel)来进行,通道中传输数据的载体是Buffer。而每个通道都可以注册到SelectorThread中的Selector中,selector通过调用select方法来检测是否有已经准备好的IO操作,以便对其进行读写。Selector通过轮询注册到其之中的通道所提供的读写是否就绪的信号来选择准备好的通道将它传给HandleThread来进行读写操作。如果没有准备好的通道,则select方法会发生阻塞(此时select线程本来就没事干,让它阻塞就阻塞呗)。
需要注意的是,NIO的非阻塞是针对读写操作是否会发生阻塞来定义的。但是Selector的select方法仍然有可能会阻塞(试想如果不阻塞,服务器一直没有请求到来,它继续运行又有什么意义呢,不断的遍历一个空的通道集合?所以说这种时候让它阻塞无可厚非)
2.2 select、poll和epoll的理解
首先来看,这三个都是应用程序请求操作系统服务的系统调用。之前说到,收到socket请求之后服务器可以对收到的数据进行读或写。在Linux操作系统中,我们需要用read函数对接收到的数据进行读写,这跟我们平常在磁盘中进行读写是一样的,回想一下在磁盘中进行读写需要哪些操作?我们需要用一个File类型的指针来获取指向文件的指针,而在操作系统层面,需要提供文件的描述符(fd)才能对这个文件进行操作。而在Linux系统我们要读取我们接收到的socket数据,这个数据会存放在一个文件当中。
需要说明的是,channel和文件描述符有着一一对应的关系,我们在之前说的Selector遍历每一个channel,也就可以视为在遍历每一个文件。
我们要将我们需要遍历的socket数据(也就是文件)的文件描述符提供给操作系统,这就需要将fd_set(是一种bitmap数据结构,每一个元素只能为0或1,fd_set[4]表示文件描述符为4的文件是否准备就绪:0表示未就绪、1表示就绪)复制一份到操作系统内核程序的内存区域(由于操作系统实现了虚拟内存,因此每个用户程序运行的内存空间和实际内核程序运行的内存空间是不一样的),将fd_set复制完成以后,操作系统就会来检测这些文件是否就绪(比如可读),如果有n个文件准备就绪,则select返回n。系统调用完成,将CPU还给用户程序,用户程序根据返回n的值来判断是否有就绪的文件,如果有,则需要遍历fd_set,对于fd_set的每一个fd元素进行判断,如果为1,则可以对该文件进行读取,执行相应的客户端逻辑。
1)select系统调用:
需要提供(fd_set,fd_set的长度,关心的事件,超时时间)四类参数,fd_set在select中使用数组存储,且固定的最大长度为2048,使用select系统调用之后,会将fd_set复制到操作系统内核区域中,然后操作系统去遍历查看每一个文件是否准备好相应事件(比如读),如果准备好了,就将对应的fd_set[ i ]设为1.则最终返回的n值就为+1,遍历完整个fd_set之后,返回给用户程序准备好对应事件的文件的总数。
用户程序由于只收到了准备好对应事件的文件总数,并不知道究竟是哪个文件准备好了,因此自身还得遍历一下fd_set看哪个文件描述符对应的元素被置为1了,对每一个fd_set[ i ]为1的文件描述符 i ,进行相应的处理,比如读取 i 中的数据等操作。
select的缺点是:
1)由于fd_set是数组类型,因此在内核空间中受长度限制(默认为2048),因此只能遍历最多2048个文件状态。(线性表的连续存储需要在内核空间找到连续的一部分空间分配内存,因此数组长度越大,在内核空间寻找对应容量的连续存储空间就越困难)
2)由于要将用户空间的fd_set复制一份到内核空间,这个操作需要一定的时间开销。
3)由于操作系统只返回一个就绪的文件数量,需要用户程序自己再遍历一次哪些元素被置为1。
4)fd_set不可重用,在下一次调用select之前,需要将整个fd_set元素全部置为0。
2)poll系统调用:
需要提供(fd_set,fd_set的长度,超时时间)三类参数,由于fd_set被定义为一个链表,链表的结点结构体中就将关心的事件作为成员变量收纳了,因此不需要再另外提供关心的事件,由于提供给poll的集合变成了链表,因此在内核空间中,为其分配空间就变得轻松了。此优化可以解决select的缺点 1)。但是poll仍然需要自己再遍历fd_set查看其value为0还是1来执行对应的逻辑(缺点3)。且仍然需要复制过程(缺点2)。
3)epoll系统调用
epoll 的核心数据结构是:1个红黑树和1个链表。
其提供了三个函数:
int epoll_create(); //创建一个epoll实例,包括红黑树和链表等。返回的是该epoll的文件描述符
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); //向哪个epoll实例(第一个参数提供),进行什么动作(比如添加或删除,第二个参数提供),要添加的文件描述符(第三个参数提供),以及要监听什么事件(第四个参数)。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); //阻塞timeout时间,将这段时间内epoll实例接收到的就绪的事件放到events列表中。返回值为events的length(也就是有几个就绪文件)
因此执行了epoll的epoll_wait()方法之后,可以遍历events来执行就绪文件的逻辑。
相较于前两者,epoll方法在存储文件描述符集合使用红黑树的存储结构,这样可以解决select的缺点1)。
epoll会在文件描述符注册进epoll实例时就将该文件描述符复制到内核中,这样能避免每次遍历时都需要初始化一个fd_set的bitmap并复制到内核空间中,解决了缺点 2)和 4)。
最后epoll_wait()函数返回的是已经就绪的events列表,因此只需要遍历这个列表实现其逻辑就可以了,而不用从所有注册进来的文件描述符中一个个找哪个就绪。因此解决了缺点3)
更多推荐



所有评论(0)