你不得不了解的网络IO模型知识
网络IO是表示数据在客户端和服务端之间传输和交换数据的技术。如下图,在客户端和服务端之间交换数据的时候离不开网络IO,网络IO能够知道数据是否准备好了,数据怎么读,什么时候读好了或写好了。
作为底层通信的基础技术,在绝大多数应用程序中都需要使用,不过大部分和网络IO打交道的都是一些中间件框架,比如web服务器tomcat,数据库mysql,消息队列rocketmq,rpc框架dubbo等,这些框架都离不开网络IO,它们帮我们屏蔽了底层通信的实现,作为一名后台开发人员,了解网络IO知识可以对网络请求完整流程有清晰的认知,了解网络io知识也可以对一些技术栈选型和中间件调优方面提供帮助,本文将结合我学习和自己的思考来分享网络io相关的理论知识,学习这些知识的时候我找了很多资料,希望对大家有帮助,让初学者少走弯路。
先给出一个网络IO知识大纲,大家看下应该会有似曾相识的感觉。
相信大家对上面列出的知识应该不陌生,但是如果只是停留在了解阶段还不够,如果能够系统的掌握他们的实现,特性就更好了,计算机技术也是不断发展的,IO体系也是不断在完善,在Java领域最早是Bio占据上风,但是随着计算机发展,到jdk1.4之后出现nio,到jdk1.7之后才有aio。
相信后端的朋友去面试,很可能面到过网络知识题目,其中就可能会问到同步阻塞IO,同步非阻塞IO的区别,IO多路复用的面试题,笔者之前面试也遇到过,这篇文章我会从同步阻塞IO讲解到异步阻塞IO,到IO多路复用,讲他们的实现,特性,相信看完这篇文章,再也不怕面试官问到这个题目了。
理解IO模型,我们要从两个方面来了解。
第一个是IO请求(数据准备)
第二个是IO操作(将数据从内核缓冲区拷贝到用户进程缓存区)(同步异步体现在这里)
下面我们一个一个来分析。
同步阻塞IO:BIO
相信大家和我一样,这种IO是我最早接触到的,在大学上课时候老师带我们用bio实现聊天功能。
bio服务端例子
//本机开启一个socket
ServerSocket serverSocket = new ServerSocket(63799);
//等待客户端连接,阻塞了
while (true) {
//阻塞等待客户端连接
Socket client = serverSocket.accept();
//新连接请求交给线程池处理
executorService.execute(new ClientAcceptRunnable(client));
System.out.println("client connected");
}
static class ClientAcceptRunnable implements Runnable {
private Socket socket;
private InputStream inputStream;
private OutputStream outputStream;
public ClientAcceptRunnable(Socket socket) {
this.socket = socket;
//从客户端获取输入流,读取客户端请求数据 inputStream.read
inputStream = socket.getInputStream();
outputStream = socket.getOutputStream();
}
@Override
public void run() {
String clientCommon = null;
while (true) {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
//读取客户端请求数据,这个方法是阻塞的
clientCommon = bufferedReader.readLine();
}
}
bio客户端例子
//和服务器建立连接
Socket serverSocket = new Socket("127.0.0.1", 63799);
上面bio的例子,他最大的缺陷在于服务端将阻塞等待客户端的连接,同时从客户端的InputStream读数据也会进入阻塞状态,因此我将Bio说成一个连接需要一个线程,这样通常效率是比较低的。通常将客户端连接交给多线程处理,这样可以同时处理多个请求,但是这样会消耗较多的线程资源。
如上图,应用程序发起一个读系统调用后(比如从客户端InputStream请求读数据),在等待数据准备好和数据从内核态拷贝到用户态整个请求的两个阶段都被阻塞,这种模型是一个连接需要一个线程(这个连接io读写前,线程也阻塞,不能处理其他事情),会受制操作系统线程数量限制。
同步阻塞IO特点
面向流IO,read,write方法都会阻塞,直到对方发送了数据包过来,需要使用多线程解决阻塞问题,多客户端请求同时处理
适用场景
api简单的特点,适合连接少且固定的架构,并发限制,jdk1.4之前的唯一选择。
同步非阻塞IO:NIO
这种IO方式是Jdk1.4之后出现的,api相比BIO来说更复杂了,但是他的性能更高,可以利用更少的线程处理更多的请求。
看下nio服务端的代码,初始化的代码就比BIO的代码多多了。
public static void main(String[] args) throws IOException {
//开启一个socket
ServerSocketChannel server = ServerSocketChannel.open();
//设置为非阻塞
server.configureBlocking(false);
//绑定端口
server.bind(new InetSocketAddress(8080));
//选择器
Selector selector = Selector.open();
//注册事件
server.register(selector, SelectionKey.OP_ACCEPT);
//创建暂存数据缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
while (true) {
int select = selector.select();
if (select == 0) {
continue;
}
//触发的事件集合
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
//客户端连接事件
if (selectionKey.isAcceptable()) {
ServerSocketChannel channel = (ServerSocketChannel) selectionKey.channel();
try {
SocketChannel accept = channel.accept();
if (null != accept) {
accept.configureBlocking(false);
accept.register(selector, SelectionKey.OP_READ);
}
} catch (IOException e) {
e.printStackTrace();
}
}
//有数据读事件
if (selectionKey.isReadable()) {
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
try {
socketChannel.read(byteBuffer);
String s = new String(byteBuffer.array());
System.out.println("hello: " + s);
byteBuffer.clear();
socketChannel.write(ByteBuffer.wrap(new String("aa").getBytes()));
} catch (IOException e) {
e.printStackTrace();
}
}
iterator.remove();
}
}
}
通过代码看nio是基于事件机制的IO模型。他不会像BIO一样阻塞等待客户端请求,有客户端的连接就会触发一个事件,这样不用每个连接创建一个线程了,只要一个专门负责接收连接事件的线程就够了,因此我说他是一个请求需要一个线程。
NIO特性
如上图,nio不会阻塞IO请求,发起系统调用后立即返回
数据准备阶段:发起系统调用,系统直接返回(数据好了就返回ok,没数据准备好就返回错误,非阻塞就体现在这里了),如果没有,发起多次系统调用,直到数据准备好
数据拷贝阶段:再次发起系统调用,数据拷贝好了,返回ok,应用程序处理数据
NIO基于Reactor反应堆,当socket流可读写socket时,操作系统将相应的通知应用进程,应用当前的读写缓冲区或写操作系统
支持面向缓冲的,基于通道的I/O操作方法,JDK 的 NIO 底层由 epoll 实现,该实现饱受诟病的空轮询 bug 会导致 cpu 飙升 100%,在发起io请求时,如果操作系统数据没准备好,不是让请求的线程阻塞睡眠,而是马上返回一个错误
NIO适用场景
适合连接短,如聊天,jdk1.4开始支持
异步非阻塞IO:AIO
AIO是在NIO上升级的版本,AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的IO模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作,我把aio叫做一个有效的请求一个线程。适合连接长,调用操作系统完成IO操作,编程复杂,jdk7开始支持!
这是I/O处理模式,epoll是AIO编程模式的实现;换句话说,AIO是一种接口标准,操作系统可以实现也可以不实现,也就是AIO依赖于具体操作系统实现。在windows中,AIO是通过IOCP实现的,参见JDK源代码,可以找到在Linux中,AIO是通过epoll实现的,见JDK源代码
IO多路复用技术:i/o multiplexing
如果一个或多个I/O条件准备就绪(即,输入准备就绪,或者描述符能够获得更多输出),我们希望得到通知,这种功能称为I/O多路复用,由select和poll函数提供。
一个多路复用器,可以使用同一个线程监听很多请求,如果有一个io请求的数据包准备好了就返回。
I/O多路复用通常用于以下场景中的网络应用程序:
1、当客户端处理多个描述符时(通常是交互式输入和网络套接字)
2、当客户端同时处理多个套接字时(这是可能的,但很少见)
3、如果TCP服务器同时处理侦听套接字及其连接的套接字
4、如果服务器同时处理TCP和UDP如果一个服务器处理多个服务,可能还有多个协议
与bio的比较?
不足:使用select时候,需要两个系统调用,一个是select函数,一个是recvform函数
优势:可以让一个线程等待多个描述符准备好,而不是一个线程等待一个描述符
和多线程使用BIO比较?
另一个密切相关的I/O模型是使用带有阻塞I/O的多线程处理。该模型非常类似于上面描述的模型,只是程序使用多个线程(每个文件描述符一个),然后每个线程都可以自由调用阻塞系统调用,如recvfrom。
在linux中,关于多路复用的使用,有三种不同的API,select、poll和epoll,
IO多路复用,可以减少传统IO处理线程,一个请求IO,对应一个线程,也就是说多路复用的是线程,通过将多个IO注册到一个多路复用器上,IO多路复用器监听某一个IO流(文件描述符)是否完成。
三种IO多路复用实现对比
1、select函数:最多监听1024个socket(32位机默认是1024个。64位机默认是2048.), 如果有一个socket的IO流准备好了,就通知应用程序处理,但是不会说是哪个socket好了,需要应用程序自己处理匹配socket(遍历轮训查找,性能低下),线程不安全。
2、poll函数:和select一样,但是没有1024个socket的限制(基于链表实现),但是也是线程不安全
3、epoll函数:线程安全,socket的IO流(文件描述符)准备好了,就会通知对应的socket进行处理,内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
各种IO模型的对比
总结
前四个模型之间的主要区别是第一阶段,因为前四个模型中的第二阶段是相同的:当数据从内核复制到调用方的缓冲区时,进程被阻塞在对recvfrom的调用中。然而,异步I/O处理这两个阶段,与前四个阶段不同。
看完BIO,NIO,AIO,IO多路复用的分析,在网络IO的理论方面是不是有了更多认知呢?
本文主要是理论知识,关于网络IO实战可以去学习netty的实现,netty对Java网络IO进行了封装并且运用到了很多中间件。
参考资料
1、Scalable IO in Java