在先前的文章《Unix之IO模型》已经讲述到5种IO模型以及对应的同步异步和阻塞非阻塞相关核心概念,接下来看下Java的IO模型在服务端的网络编程中是如何演进,注意这里用启动Java程序表示一个JVM进程,而JVM进程中以多线程方式进行协作,这里讲述以线程为主展开.
BIO与多线程设计
BIO 概述
accept
& read
都需要等待客户端建立连接和发起请求才能够进行让服务端程序进行响应,也就是上述的方法在服务端的编程中会让服务端的主线程产生阻塞,当其他客户端与Java服务端尝试建立连接和发请求的时候会被阻塞,等待前面一个客户端处理完之后才会处理下一个客户端的连接和请求,以上是Java的BIO体现服务端单线程BIO模型
// server.java
// 仅写部分服务端核心代码
ServerSocket server = new ServerSocket(ip, port);
while(true){
Socket socket = server.accept(); // 接收客户端的连接,会阻塞
out.put("收到新连接:" + socket.toString());
// client have connected
// start read
BufferedReader br = new BuufferedReader(new InputstreamReader(socket.getInputStream));
String line = br.readLine(); // 等待客户端发起请求进行读取操作,会阻塞
// decode ..
// process ..
// encode ..
// send ..
}
1) 上述代码accept方法以及read方法需要等待客户端发送数据过来,服务端才能从操作系统的底层网卡获取数据,在没有获取数据之前将处于阻塞状态
2) 其次,可以看到上述的服务端程序只能处理一个客户端的连接和请求操作,只有当前的客户端连接以及请求执行完之后才能接收下一个客户端的连接以及请求处理操作
3) 不足: 上述代码压根无法满足服务端处理多客户端的连接和请求,同时造成CPU空闲,尤其是在接收客户端读取的时候,如果此时客户端一直没有发起请求操作,那么其他客户端建立的连接请求根本无法处理,因此对上述进行改进为多线程处理方式
基于1:1的多线程BIO模型
// thread-task.java
public class IOTask implements Runnable{
private Socket client;
public IOTask(Socket client){
this.client = client;
}
run(){
while(!Thread.isInterrupt()){
// read from socket inputstream
// encode reading text
// process
// decode sent text
// send
}
}
}
// server.java
ServerSocket server = new ServerSocket(ip, port);
while(true){
Socket client = server.accept();
out.put(“收到新连接:” + client.toString());
new Thread(new IOTask(client)),start();
}
1) 通过多线程的方式将Accept与IO读写操作的阻塞方式进行分离,主线程处理accept接收客户端的连接,新开线程接收客户端的请求进行请求处理操作
2) 上述的方式仅仅是将阻塞的方式进行分离,但是如果处理的客户端数量越来越多的时候,上述服务器创建线程会越来越多,容易造成机器CPU出现100%情况,那么我们可以如何控制线程的方式,在并发编程中,一般通过管理并发处理任务的多线程技术是采用线程池的方式,于是就有了以下的M:N的多线程网络编程的BIO模型
基于M:N的线程池实现的BIO模式
// server.java
ExecutorService executors = Executros.newFixedThreadPool(MAX_THREAD_NUM);
ServerSocket server = new ServerSocket(ip,port);
while(true){
Socket client = server.accept();
out.put(“收到新连接:” + client.toString());
threadPool.submit(new IOTask(ckient));
}
1) 上述运行结果与1:1的线程模型是一致的,但是相比1:1创建线程的方式,充分利用池化技术重复利用线程资源,有助于降低CPU占用的资源
2) 其次,上述的BIO都是属于阻塞式IO处理,每一次的accept操作以及read操作都需要等待客户端的操作才能给予响应,如果客户端不发生操作,那么新创建的线程将一直处于阻塞状态,将占用资源迟迟没有释放,也容易造成CPU发生瓶颈,于是我们想到能否等到客户端有发起相应的操作的时候线程才进行处理呢,在客户端还没有发生请求操作的时候,服务端线程资源是否可以优先处理其他任务,提升CPU利用率呢,这也就是接下来的非阻塞式IO,即Non-Blocking IO
NIO设计
1) Channel通道:服务端与客户端建立连接以及进行数据传输的通道,分为ServerSocketChannel(接收客户端的TCP连接通道)以及SocketChannel(建立与服务端的连接通道)
2) Buffer缓存区: 客户端与服务端在channel中建立一个连续数组的内存空间,用于在channel中接收和发送数据数据实现两端的数据通信
3) Selector选择注册器,对比IO复用模型,Selector中包含select函数,用于向系统内核注册网络编程中的Aceept,Read以及Write等事件,相对于从Java而言,是指channel(不论是服务端还是客户端通道)可以向注册器selector发起注册事件,底层交由select()
向操作系统进行事件注册
基于单线程通道轮询的NIO模式(NIO模型)
// server.java
ServerSocketChannel server = ServerSocketChannel.open();
// 设置所有的socket默认伪阻塞,必须设置服务端的通道为非阻塞模式
server.configureBlocking(false);
// 绑定端口以及最大可接收的连接数backlog
server.socket().bind(new InetSocketAddress(PORT), MAX_BACKLOG_NUM);
while(true){
SocketChannel client = server.accept();
// 非阻塞获取,所以client可能为null
if(null != client){
// 设置客户端的通道为非阻塞
client.configureBlocking(false);
// 进行IO操作
// read
ByteBuffer req = ByteBuffer.allocate(MAX_SIZE);
while(client.isOpen() &/& client.read(req)!= -1){
// BufferCoding是自己封装的一个解码工具类,结合ByteBuffer与Charset使用,这里不演示代码实现
// decode
byte[] data = BufferCoding.decode(req);
if(data != null){
break;
}
}
// prepared data to send
sentData = process(data);
// encode
ByteBuffer sent = BufferCoding.encode(sentData);
// write
client.writeAndFlush(sent);
}
}
1) 上述的代码与BIO的设计基本无差,只是在原有的基础上设置为非阻塞的操作,然后通过不断轮询的方式不断监控连接和读取操作,与BIO的多线程设计差别不大,只是BIO是多线程方式实现,这里是单线程实现
2) 小结:上述代码使用BIO的API方式,也就是说不断polling的过程都是调用阻塞的API去检查是否就绪的状态,结合先前的Unix的IO模型,非阻塞可以继续改进为给予select的方式来实现,而select不是属于调用阻塞式API而是通过事件轮询的方式等待套接字中的描述符变为就绪状态再进行业务处理操作
基于单线程的select事件轮询IO模式(IO多路复用模型)
1) 服务端启动的时候,首先需要创建channel并注册到selector上才能够监听到客户端建立的连接
2) 其次客户端要与服务端建立通信,也需要在客户端自己创建channel并注册到selector上
3) 当selector监听到客户端的连接就会转发给服务端的Accept事件进行处理
4) 当selector监听到客户端发起请求的操作,就会转发给READ事件进行处理,并且如果需要将数据通知客户端,需要在指定的事件上添加写操作
5) 此时selector监听到写操作的时候,就会转发给处理WRITE事件进行处理,并且当前在进行写操作之后取消写操作的事件
// server.java
ServerSocketChannel server = ServerSocketChannel,open();
server.configureBlocking(false);
Selector selector = Selector.open();
// 服务端只注册ACCEPT,作为接入客户端的连接
// DataWrap封装读写缓存ByteBuffer
server.register(selector, SelectionKey.OP_ACCEPT, server);
server.socket().bind(new InetSocketAddress(PORT), MAX_BACKLOG_NUM);
while(true){
int key = selector.select();
if(key == 0) continue;
// 获取注册到selecor所有感兴趣的事件
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> it = keys.iterator();
while(it.hasNext()){
SelectionKey key = it.next();
it.remove();
if(key.isAcceptable()){
// 接收accept事件
ServerSocketChannel serverChannel = (ServerSocketChannel)key.attachment();
SocketChannel client = server.accept();
client.configureBlocking(false);
// 客户端已经获取到连接,告诉客户端的channel可以开始进行读写操作
client.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE, new DataWrap(256, 256));
}
// read
if(key.isReadable()){
//...
// 在事件中添加写操作
key.interestOps(key.interestOps() | SelectionKey.OP_WRITE);
}
if(key.isWriteable()){
// ...
// 成功完成写操作,这个时候取消写操作
key.interestOps(key.interestOps() & (~SelectionKey.OP_WRITE));
}
}
}
1) 上述代码相比第一种方案的实现,主要是采用select函数调用获取注册的事件,阻塞于select方法的调用
2) 另外上述的代码相比第一种稍微更为复杂,操作也更为繁琐
3) 通过上述代码展示,我们也看到NIO的实现方式稍微比BIO复杂一些,都是基于连接线程架构的web服务实现方式,比较依赖于线程,而线程资源更容易造成cpu的瓶颈,因而就有了基于事件驱动设计的web服务,对于IO事件驱动设计主要是基于Reactor模式实现,面向连接与事件的编程方式,此时的线程主要处理IO就绪事件并响应IO事件.
可伸缩性IO设计
单Reactor模式
单Reactor模式 + 多线程
多Reactor模式 + 多线程
最后,这里仅展示Web服务的IO设计思路演进,对于Reactor模式设计后面会详细阐述,另外可以思考下,BIO与NIO之间的区别在哪!
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有