先宏观把握,再微观掌握!
所谓BIO就是阻塞IO,NIO就是非阻塞IO,什么意思呢?下面宏观上理解一下!
首先要知道,Linux下的Java,是基于Linux系统调用实现的,所以我们先来了解下Linux的NIO是咋实现的,Java的NIO就是包装了下Linux的NIO接口而已。
BIO 简介
刚开始学网络编程时,我们一般会先接触类似下面的服务器代码:
C语言伪代码:
int serverSock = socket(AF_INET, SOCK_STREAM, 0);
bind(serverSock, ...);
listen(serverSock, ...);
int client_socket_fd = accept(serverSock, ...); // 阻塞等待客户端来连接,如果有连接,返回值为客户端的socket的fd
read(clientSock, ...);
...
}
上面是最普通的linux服务器下c语言网络编程代码,bind()、listen()是常规操作,不再解释,主要看accept()、read()。
服务器代码执行到accept()这里时,卡在了这里,等待客户端连接,当有个客户端连到服务器后,服务器代码收到来连接的请求,就执行完accept()这个系统调用,并返回此客户端的socket文件的文件句柄socket_fd,然后服务器代码执行到read()这里。
我们都知道,所谓socket,就是一种特殊的IO文件,读写socket文件和读写我们磁盘里的txt格式文件,其实是一个意思,只不过socket文件不是存在磁盘上的文件,但linux把socket也当成文件来看待,因此我们也可以给read()系统调用函数传入socket文件的文件句柄号来读取socket文件里的内容,只不过socket文件与txt磁盘文件内部具体读写代码实现不同而已,在这里,客户端的socket文件的文件句柄就是clientSocket。
在这里,read()函数主要作用是读出socket文件里的数据,有数据就返回数据,没数据就阻塞。当服务器程序执行到read(clientSock, ...)时 ,会尝试读取客户端socket文件里的内容,结果该特殊文件里发现里面没数据,代码就卡【阻塞】在这里等待socket文件里出现数据,有数据了就返回。
上面的程序很简单,但有个问题,一次只能连接一个客户端!再来几个客户端,该服务器程序正阻塞在read()函数这里,根本收不到,怎么办?
为了能拿到多个客户端发来的连接和数据,我们主要要做2件事:
1. 及时执行accept(),及时地发现有新的客户端发来连接;
2. 对每个已连接的客户端循环执行read(),及时地把每个客户端socket文件的新数据读出来。
方法1:
多开几个线程。
用一个单独的线程专门循环运行accept()这个函数,来一个新的客户端连接,就创建一个新线程去循环调用read(),读取新客户端的socket文件数据。
这是个办法,但是如果来了10万个客户端连接,那就要创建10万个新线程,极大的浪费了资源,而且创建线程这个动作本身就耗时间,且这10万个客户端连接,并不是每一个都很活跃,该方法高并发下并不可取!
方法2:
多线程 + select 系统函数
即,不再直接用read()这个函数来获取客户端socket文件是否有新数据出现,而是用别的linux系统调用【select】来获取客户端socket文件里有没有新数据,它和read()有啥区别呢?
最大的区别就是,read()一次只能监控一个socket文件是否有新数据,select一次性却可以监控多个客户端socket文件是否有数据可读!
fd_set read_fds; // socket文件句柄集合【用于存放多个想要监控的fd】
FD_SET(socket_fd, &read_fds); //将想要监控的socket文件的句柄放到集合read_fds里
int nums = select(... &read_fds, ...); //一次性监控多个socket文件是否可读,有可读文件,返回可读文件的数量
FD_ISSET(scoket_fd, read_fds); //判断该fd所指的文件是否可读
利用上面几个linux提供的系统函数,我们可以很方便的一次性监控多个socket文件是否可读,就不需要再开启那么多线程,一个一个检查了,而是只开一个线程,循环调用select(),达到了用一个线程监控多个socket文件是否可读的目的,非常的高效、节省系统资源!
select()函数工作原理是这样:调用它时,把想要监控的socket文件句柄放到read_fds集合中,然后传给select(),当集合中的某些socket文件可读时,select()就返回当前可读文件的数量,这时再调用FD_ISSET()来循环检查是哪些文件可读,执行相应的数据处理逻辑。
int nums = select(... &read_fds, ...);
while(...){
if (FD_ISSET(client_socket_fd_1, read_fds)){ //若客户端1的socket文件可读
read(cilent_socket_fd_1, &data1); //把新数据读到data1里,进行处理...
}
else if (FD_ISSET(client_socket_fd_2, read_fds)) {客户端2的socket文件可读
read(cilent_socket_fd_2, &data2);
}
... // 判断其它scoket文件是否可读
}
注意:select只返回可读客户端scoket文件的数量,并不会直接返回具体新数据,具体哪个socket文件可读,需要自己调用FD_ISSET()去检查,并且新数据需要你自己调用read()函数拿出来,但此时调用read()就省时很多了,因为此时你去读的socket文件都是有数据的,所以调用read()读取文件并不需要阻塞,直接就能把新数据读出来。
可以看到,我们用1个线程调用select,就监控了多个客户端socket文件,这就是所谓的 IO多路复用 。多路指的就是多个客户端scoket文件;复用,指的就是多个socket文件复用同一个线程。多路复用与方法1相比资源消耗低了很多,方法1中是每个线程调用read(),去监控1个socket文件,开了太多的线程。
这样,我们专门开一个线程执行accept(),再专门开一个线程执行select(),拿到可读socket文件的fd后,再用线程池去可读socket文件里拿出来新数据,响应及时了,系统资源也不会无限增加,省时省力省资源!
方法3:
多线程 + epoll
epoll()和select()函数是一模一样的功能,使用方法也大同小异,都是一次性监控多个socket文件是否可读,区别是,epoll可以更快的获知集合中哪些socket文件可读,select()速度要慢一些,如果并发量更高的服务器,往往采样epoll。
netty、tomcat等很多都使用了epoll这个函数来实现高效的网络数据收发功能,到目前为止,想要在linux系统获知究竟哪些已连接的客户端socket文件是否有新数据,是否可读,用epoll是最高效的函数,未来还会出现更高效的,即异步IO,这是以后的话题了,宏观把握章节暂时不再描述了。