从实践模拟角度再议bio nio【重点】
从实践角度重新理解BIO和NIO
https://mp.weixin.qq.com/s/rsvAmmoJiseEmjChI95m6Q
1 bio的2次阻塞与缺陷
服务器端在启动后,首先需要等待客户端的连接请求(第一次阻塞),如果没有客户端连接,服务端将一直阻塞等待,然后当客户端连接后,服务器会等待客户端发送数据(第二次阻塞),如果客户端没有发送数据,那么服务端将会一直阻塞等待客户端发送数据。
BIO会产生两次阻塞,第一次在等待连接时阻塞,第二次在等待数据时阻塞。
当我们的服务器接收到一个连接后,并且没有接收到客户端发送的数据时,是会阻塞在read()方法中的,那么此时如果再来一个客户端的请求,服务端是无法进行响应的。在不考虑多线程的情况下,BIO是无法处理多个客户端请求的。
2 多线程的bio
我们只需要在每一个连接请求到来时,创建一个线程去执行这个连接请求,就可以在BIO中处理多个客户端请求了,这也就是为什么BIO的其中一条概念是服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理。
while (true) { System.out.println(); System.out.println("服务器正在等待连接..."); Socket socket = serverSocket.accept(); 【重点阻塞】 new Thread(new Runnable() { @Override public void run() { System.out.println("服务器已接收到连接请求..."); System.out.println(); System.out.println("服务器正在等待数据..."); try { socket.getInputStream().read(buffer); 【重点阻塞】 } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } System.out.println("服务器已经接收到数据"); System.out.println(); String content = new String(buffer); System.out.println("接收到的数据:" + content); } }).start(); }
3 多线程bio弊端
1)线程切换
2)线程资源浪费在不说话的连接上
4 模拟nio,同步非阻塞模型
其实NIO需要解决的最根本的问题就是存在于BIO中的两个阻塞,分别是等待连接时的阻塞和等待数据时的阻塞。
如果单线程服务器在等待数据时阻塞,那么第二个连接请求到来时,服务器是无法响应的。如果是多线程服务器,那么又会有为大量空闲请求产生新线程从而造成线程占用系统资源,线程浪费的情况。
单线程服务器接收数据时阻塞,而无法接收新请求的问题,那么其实可以让服务器在等待数据时不进入阻塞状态,问题不就迎刃而解了吗?
4.1
//设置为非阻塞 serverSocketChannel.configureBlocking(false);【重点非阻塞】 while(true) { SocketChannel socketChannel = serverSocketChannel.accept(); if(socketChannel==null) { //表示没人连接 System.out.println("正在等待客户端请求连接..."); Thread.sleep(5000); }else { System.out.println("当前接收到客户端请求连接..."); } if(socketChannel!=null) { //设置为非阻塞 socketChannel.configureBlocking(false); 【重点非阻塞】 byteBuffer.flip();//切换模式 写-->读 int effective = socketChannel.read(byteBuffer); if(effective!=0) { String content = Charset.forName("utf-8").decode(byteBuffer).toString(); System.out.println(content); }else { System.out.println("当前未收到客户端消息"); } }
在这种解决方案下,虽然在接收客户端消息时不会阻塞,但是又开始重新接收服务器请求,用户根本来不及输入消息,服务器就转向接收别的客户端请求了
4.2
我们将连接存储在一个list集合中,每次等待客户端消息时都去轮询,看看消息是否准备好,如果准备好则直接打印消息。
//设置为非阻塞 serverSocketChannel.configureBlocking(false); while(true) { SocketChannel socketChannel = serverSocketChannel.accept(); if(socketChannel==null) { //表示没人连接 System.out.println("正在等待客户端请求连接..."); Thread.sleep(5000); }else { System.out.println("当前接收到客户端请求连接..."); socketList.add(socketChannel); 【重点缓存】 } for(SocketChannel socket:socketList) { 【重点遍历缓存】 socket.configureBlocking(false); int effective = socket.read(byteBuffer); if(effective!=0) { byteBuffer.flip();//切换模式 写-->读 String content = Charset.forName("UTF-8").decode(byteBuffer).toString(); System.out.println("接收到消息:"+content); byteBuffer.clear(); }else { System.out.println("当前未收到客户端消息"); } }
我们采用了一个轮询的方式来接收消息,每次都轮询所有的连接,看消息是否准备好,测试用例中只是三个连接,所以看不出什么问题来,但是我们假设有1000万连接,甚至更多,采用这种轮询的方式效率是极低的。
另外,1000万连接中,我们可能只会有100万会有消息,剩下的900万并不会发送任何消息,那么这些连接程序依旧要每次都去轮询,这显然是不合适的。
5 真实nio
在真实NIO中,并不会在Java层上来进行一个轮询,而是将轮询的这个步骤交给我们的操作系统来进行,他将轮询的那部分代码改为操作系统级别的系统调用(select函数,在linux环境中为epoll),在操作系统级别上调用select函数,主动地去感知有数据的socket。
我们写的Java程序其本质在轮询每个Socket的时候也需要去调用系统函数,那么轮询一次调用一次,会造成不必要的上下文切换开销。用户态-内核态切换
5.1 windows select
如果select没有查询到到有数据的请求,那么将会一直阻塞(是的,select是一个阻塞函数)。如果有一个或者多个请求已经准备好数据了,那么select将会先将有数据的文件描述符置位,然后select返回。返回后通过遍历查看哪个请求有数据。
-
底层存储依赖bitmap,处理的请求是有上限的,为1024。
- fd 用户-内核拷贝
- 再次遍历
5.2 linux poll
poll内部存储不依赖bitmap,而是使用pollfd数组的这样一个数据结构,数组的大小肯定是大于1024的。但仍有后2个缺点
5.3 epoll
epoll和上述两个函数最大的不同是,它的fd是共享在用户态和内核态之间的,所以可以不必进行从用户态到内核态的一个拷贝,这样可以节约系统资源;另外,在select和poll中,如果某个请求的数据已经准备好,它们会将所有的请求都返回,供程序去遍历查看哪个请求存在数据,但是epoll只会返回存在数据的请求,这是因为epoll在发现某个请求存在数据时,首先会进行一个重排操作,将所有有数据的fd放到最前面的位置,然后返回(返回值为存在数据请求的个数N),那么我们的上层程序就可以不必将所有请求都轮询,而是直接遍历epoll返回的前N个请求,这些请求都是有数据的请求。
6
-
Java NIO (non-blocking I/O): 同步非阻塞,服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。
阻塞io个人认为可再分为单线程和多线程,缺点:
single:连接1的read会阻塞连接2的connect
multi:线程切换、僵尸连接
本质上io都是阻塞的,multi版本业务上非阻塞
io复用之非阻塞体现在用户态select/epoll阻塞,内核态非阻塞轮训各channel,which在连接创建时被注册到selector上
另一片比较重要的文章:5种io模型摘要