NIO通讯库的设计
1. 什么是NIO
我们平常使用的TCP通讯都是阻塞的,即在调用tcp.read()
的时候线程会阻塞(即暂停运行),直到真的接收到数据时此函数才会返回。
假如我们需要读取一个tcp,我们需要开启一个线程去调用
tcp.read()
,在接收到数据之前此线程会一直阻塞在这里,无法干其他任何事情。假如我们需要读取1000个tcp,难道我们要开启1000个线程分别去调用tcp.read()
吗?
我们想一想,线程调用tcp.read()
的时候如果没有数据则一直阻塞在这里,这样子是不是一种浪费,我们可以不可以实现调用tcp.read()
的时候,有数据的话返回,没有数据的话也直接返回无数据,这样线程可以去做其他事情。这就是所谓的非阻塞TCP通讯,NIO即为Nonblocking-IO。
而NIO最主要的功能远远不止非阻塞。
假如有即将到来的快递的话,我们怎么及时的收取快递呢,有以下办法:
1. 我们跑到快递员那里一直等着,直到快递到来,这就是阻塞IO。
2. 我们每隔一段时间跟快递员打个电话,问一下快递是否到来,如果快递收到了就去取,这就是非阻塞IO。
3. 上一种方式虽然节省了一些资源,但是仍需要去不断轮询,并且根据轮询的间隔,可能快递到了但是我们需要下一次轮询才能获取到,具有一定的延迟性。所以我们可以使用主动告知快递员:我可能会收到一个快递,如果快递到了麻烦请告知我。这就是NIO。
Java的NIO做的事情就是告知操作系统,我要从此tcp接受数据,如果数据来了,请告诉我,接下来Java的线程可以去干其他事情。
2. NIO的使用
NIO最核心的类即为Selector,这里我们可以把Selector看做前文所说的快递员。当我们需要进行TCP通讯的时候,首先构建一个快递员Selector,并且告知快递员有快递的时候通知我,接下来就可以去干自己其他的事情了。
Selector selector = Selector.open();//构建一个快递员Selector
SocketChannel channel=SocketChannel.open();//打开一个client端
channel.connect(new InetSocketAddress(ip,port));//连接到目标位置
channel.configureBlocking(false);//设置成非阻塞模式
channel.register(selector,SelectionKey.OP_READ);//告知快递员:有消息快递来的时候记得通知我。
既然被告知了快递,快递员就要开始运行了。
while(true){
selector.select();//快递员开始检查是否有快递到来,一直阻塞到收到新的快递,
Iterator<SelectionKey> iterator=selector.selectedKeys().iterator();//将收到的快递拿出来看一看
while(iterator.hasNext()){
try{
SelectionKey key=iterator.next();
if(key.isReadable()){//这个快递是新的数据
...//将快递给告知者
}else if(key.isAcceptable()){//这个快递是收到新的连接
...//将快递给告知者
}else if(key.isWritable()){//这个快递是表示这个连接可以写入
...//将快递给告知者
}
}finally{
iterator.remove();//既然这个快递已经给出去了,就把它从已经收到的快递记录里面删除
}
}
}
假如我们有一千个tcp的连接,我们只需要将这一千个tcp分别告诉快递员,快递员自己就能处理数据的接收了。这也意味着我们只需要一个线程来跑快递员,就能同时处理一千个tcp连接,运行效率大大提升。
3. NIO使用细节
- NIO的数据读取是通过Buffer类来传递(ps:Buffer类使用的是JVM的直接内存),我们平常主要使用的ByteBuffer。如
channel.write(ByteBuffer src)
和channel.read(ByteBuffer dst)
; - NIO的快递(事件)分为
OP_READ
,OP_ACCEPT
,OP_WRITE
,OP_CONNECT
四种类型,我们在注册的时候可以申明需要通知哪些事件,并且可以通过OP_READ|OP_CONNECT
这种方式同时注册多个事件。 - NIO注册注册事件的时候可以为该channel添加一个对象handler用来进行事件回调
channel.register(selector,OP,handler);
,当触发事件selectionKey的时候,可以通过selectionKey.attachment()
来获取注册的对象handler。 - 之前的例子,我们是在快递员selector运行之前注册事件的。但是在实际运行中我们可能在selector运行的过程中注册新的事件,这个时候我们需要注意:
- selector是非线程安全的。
- 当selector.select()正处于阻塞的时候,新的channel的注册不会马上生效,即使新channel接受到数据了,selector.select()也不会返回,新的注册只有在下次selector.select()才会生效。
- selector.select(time)是不可靠的,详见以下jdk说明:
This method does not offer real-time guarantees: It schedules the timeout as if by invoking the Object.wait(long) method.- 综上,当快递员selector已经在运行的时候新注册事件,我们需要在运行selector的同一个线程里面进行添加。
4. NIO通讯库的实现
- 首先实现一个快递员NIOCenter包含一个selector
public class NIOCenter{
private Selector selector;
private volatile boolean isRun=false;
priavet Queue<Runnable> queue=new ConcurrentLinkedQueue<>();//用来装注册事件
private void start(){
isRun=true;
selector=Selector.open();
new Thread(this::run).start();
}
public void run(){
while(isRun){
while(!queue.isEmpty()){//将注册事件队列里面的注册事件分别执行
Runnale registerTask=queue.remove();
registerTask.run();
}
selector.select();//开始阻塞
Iterator<SelectionKey> iterator=selector.selectedSelectionKeys().iterator();
while(iterator.hasNext()){
SelectionKey key=iterator.next();
Consumer<SelectionKey> handler=key.attachment();//取出注册的回调函数
if(key.isXXX){
handler.accept(key);//执行回调函数
}else ...
}
}
}
//注册client端的读事件
public void register(SocketChannel channel,Consumer<SelectionKey> consumer){
if(!isRun)return;
queue.add(()->{
channel.configureBlocking(false);
channel.register(selector,OP_READ,consumer);
};
selector.wakeup();//将selector从select()处唤醒,告知其需要注册新的channel
}
//注册server端accept新连接事件
public void register(ServerSocketChannel channel,Consumer<SelectionKey> consumer){
if(!isRun)returnl
queue.add(()->{
channel.configureBlocking(false);
channel.register(selector,OP_ACCPET,conusmer);
}
selector.wakeup();//将selector从select()处唤醒,告知其需要注册新的channel
}
}
- 接下来我们只需要分别实现TcpServer和TcpClient端即可,实现方法非常简单,只需要声明一个
SocketChannel
或者ServerSocketChannel
进行连接,然后注册到NIOCenter即可。
//TcpClient的简单实现
public class TcpClient{
private SocketChannel channel;
private ByteBuffer byteBuffer;
private Consumer<byte[]> handler;
public TcpClient(Consumer<byte[]> handler){
byteBuffer=ByteBuffer.allocate(SIZE);
channel=SocketChannel.open();
this.handler=handler;
}
public void connect(String ip,int port,NIOCenter nioCenter){
channel.connect(new InetSocketAdress(ip,port));//先使用阻塞的连接,保证先连接上
nioCenter.register(channel,this::read);//注册到NIOCenter中
}
//回调函数
public void read(SelectionKey key){
byteBuffer.clear();
channel.read(buteBuffer);//获取到已经读取的数据
byte[] data = new byte[this.byteBuffer.position()];
System.arraycopy(this.buffer.array(), 0, data, 0, data.length);//复制到字节数组中
handler.accpet(data);//执行回调函数。
}
//关闭channel后会自动从selector中取消已经注册的事件。
public void close(){
channel.close();
}
}
- TcpServer的实现类似,只不过要先注OP_ACCEPT事件,当accpet新的连接的时候,再给新的连接注册OP_READ事件
5. 注意事项
使用Java的NIO虽然将IO事件的处理放到selector.select()一个线程上进行处理,极大的减少了线程处理的开销,但是也意味着出现了其他的问题
- 所有事件放在一个循环里面进行处理,如果处理事件的方式是同步处理,当某一个事件处理的时间过长,会影响其他事件的响应或者处理。我们可以考虑使用异步的方式通过线程池来处理接受到的事件,或者通过缓冲区来减少处理事件的时间。
- 所有事件放在一个循环线程里面进行处理,意味着当处理事件的时候如果抛出了一个未处理异常导致这个循环停止,其他所有的事件再也不会响应了。所以要细致的处理各种异常,保证while循环不会因为异常停止。