电子说
上文花了太多的笔墨描述BIO,接下来的非阻塞IO我们只抓主要矛盾,其余参考BIO即可。
如果你看过其他介绍非阻塞IO的文章,下面这个图片你多少会有点眼熟。
NIO模型
非阻塞IO指的是进程发起系统调用之后,内核不会将进程投入睡眠,而是会立即返回一个结果,这个结果可能恰好是我们需要的数据,又或者是某些错误。
你可能会想,这种非阻塞带来的轮询有什么用呢?大多数都是空轮询,白白浪费CPU而已,还不如让进程休眠来的合适。
这个问题暂且搁置一下,我们先看Java在语法层面是如何提供非阻塞功能的,细节慢慢聊。
public class NoBlockingServer {
public static List<SocketChannel> channelList = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
try {
// 相当于serverSocket
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 将监听socket设置为非阻塞
serverSocketChannel.configureBlocking(false);
serverSocketChannel.socket().bind(new InetSocketAddress(8099));
while (true) {
// 这里将不再阻塞
SocketChannel socketChannel = serverSocketChannel.accept();
if (socketChannel != null) {
// 将连接socket设置为非阻塞
socketChannel.configureBlocking(false);
channelList.add(socketChannel);
} else {
System.out.println("没有客户端连接!!!");
}
for (SocketChannel client : channelList) {
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// read也不阻塞
int num = client.read(byteBuffer);
if (num > 0) {
System.out.println("收到客户端【" + client.socket().getPort() + "】数据:" + new String(byteBuffer.array()));
} else {
System.out.println("等待客户端【" + client.socket().getPort() + "】写数据");
}
}
// 加个睡眠是为了避免strace产生大量日志,否则不好追踪
Thread.sleep(1000);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
Java提供了新的API,ServerSocketChannel
以及SocketChannel
,相当于BIO中的ServerSocket
和Socket
。此外,通过下面两行的配置,将监听socket和连接socket设置为非阻塞。
// 将监听socket设置为非阻塞
serverSocketChannel.configureBlocking(false);
// 将连接socket设置为非阻塞
socketChannel.configureBlocking(false);
我们上文强调过, Java自身并没有将socket设置为非阻塞的本事,一定是在某个时间点上,操作系统内核提供了这个功能,才使得Java设计出了新的API来提供非阻塞功能 。
之所以需要上面两行代码的显式设置,也恰好说明了内核是默认将socket设置为阻塞状态的,需要非阻塞,就得额外调用其他系统调用。我们通过man
命令查看一下socket()
这个方法(截图的中间省略了一部分内容):
man 2 socket
image-20221225144028751
我们可以看到socket()
函数提供了SOCK_NONBLOCK
这个类型,可以通过fcntl()
这个方法将socket从默认的阻塞修改为非阻塞,不管是对监听socket还是连接socket都是一样的。
现在解释上面提到的问题:这种非阻塞带来的轮询有什么用?观察一下上面的代码就可以发现,我们全程只使用了1个main线程就解决了所有客户端的连接以及所有客户端的读写操作。
serverSocketChannel.accept();
会立即返回调用结果。
返回的结果如果是一个SocketChannel
对象(系统调用底层就是个socket描述符),说明有客户端连接,这个SocketChannel
就表示了这个连接;然后利用socketChannel.configureBlocking(false);
将这个连接socket设置为非阻塞。这个设置非常重要,设置之后对连接socket所有的读写操作都变成了非阻塞,因此接下来的client.read(byteBuffer);
并不会阻塞while循环,导致新的客户端无法连接。再之后将该连接socket加入到channelList
队列中。
如果返回的结果为空(底层系统调用返回了错误),就说明现在还没有新的客户端要连接监听socket,因此程序继续向下执行,遍历channelList
队列中的所有连接socket,对连接socket进行读操作。而读操作也是非阻塞的,会理解返回一个整数,表示读到的字节数,如果>0
,则继续进行下一步的逻辑处理;否则继续遍历下一个连接socket。
下面给出一张accept()
返回一个连接socket情况下的动图,希望对大家理解整个流程有帮助。
我将上面的程序在CentOS下再次用strace
程序追踪一下,具体步骤不再赘述,下面是out日志文件的内容(我忽略了绝大多数没用的)。
非阻塞IO的系统调用分析
NIO模型
再放一遍这个图,有一个细节需要大家注意,系统调用向内核要数据时,内核的动作分成两步:
只有在第1步时,系统调用是非阻塞的,第2步进程依然需要等待这个拷贝过程,然后才能返回,这一步是阻塞的。
非阻塞IO模型仅用一个线程就能处理所有操作,对比BIO的一个客户端需要一个线程而言进步还是巨大的。但是他的致命问题在于会不停地进行系统调用,不停的进行accept()
,不停地对连接socket进行read()
操作,即使大部分时间都是白忙活。要知道,系统调用涉及到用户空间和内核空间的多次转换,会严重影响整体性能。
所以,一个自然而言的想法就是,能不能别让进程瞎轮询。
比如有人告诉进程监听socket是不是被连接了,有的话进程再执行accept()
;比如有人告诉进程哪些连接socket有数据从客户端发送过来了,然后进程只对有数据的连接socket进行read()
。
这个方案就是 I/O多路复用 。
全部0条评论
快来发表一下你的评论吧 !