非阻塞IO和异步IO在Java中的实现

89

尝试为自己总结这两个概念的区别(因为当我看到人们在一个句子中使用它们两个时,例如“非阻塞异步IO”,我真的很困惑,想弄清楚它是什么意思)。

因此,在我的理解中,非阻塞IO主要是操作系统处理IO的机制,如果有数据准备好,否则只返回错误/不执行任何操作。

在异步IO中,您只需提供回调函数,当数据可用时,应用程序将收到通知。

那么实际上什么是“非阻塞异步IO”?以及如何在Java中实现所有这些(标准JDK,不使用外部库,我知道有java.nio.channels.{Channels, Selector, SelectorKey}和java.nio.channels.{AsynchronousSocketChannel}):非阻塞IO,异步IO和非阻塞异步IO(如果有这样的东西)?


6
“非阻塞异步I/O”只是毫无意义的空话。我不明白为什么你认为需要外部库。它们最终都只是对操作系统功能的封装。 - user207421
1
你正确理解了这些术语。正如所指出的,“非阻塞异步IO”是多余的。如果底层I/O机制是非阻塞的,它就不需要是异步的,反之亦然。也许描述它的人之所以这样说是因为它已经被异步化了而变得非阻塞。(例如:android-async-http库是同步套接字I/O的异步包装器。) - Kevin Krumwiede
@KevinKrumwiede,你能提供一个实际的例子,说明async-io是如何阻塞的吗?(我唯一能想到的是回调函数和主进程共享同一个线程,并且在回调函数中有等待/ future.get()或类似的操作)。 - Aliaksandr Kazlou
5个回答

140

我看到这是一个旧问题,但我认为有些东西被忽略了,@nickdu试图指出但并不十分清楚。

在这个讨论中有四种与IO有关的类型:

阻塞IO

非阻塞IO

异步IO

异步非阻塞IO

混淆起源于我认为定义模糊,所以让我试着澄清一下。

首先,让我们谈谈IO。当我们有缓慢的IO时,这是最明显的,但IO操作可以是阻塞或非阻塞的。这与线程无关,而是与操作系统的接口有关。当我请求操作系统进行IO操作时,我可以选择等待所有数据准备就绪(阻塞),或获取现在可用的内容并继续进行(非阻塞)。默认情况下是阻塞IO。使用阻塞IO编写代码要容易得多,因为路径要清晰得多。但是,您的代码必须停止并等待IO完成。非阻塞IO需要在较低级别上与IO库进行接口,使用select和read / write而不是提供方便操作的更高级别库。非阻塞IO还意味着您需要在操作系统完成IO时有一些要处理的内容。这可能是多个IO操作或计算已完成的IO。

阻塞IO - 应用程序等待操作系统收集所有字节以完成操作或到达末尾。这是默认设置。对于非常技术性的人来说,初始化IO的系统调用将安装一个信号处理程序,等待处理器中断,当IO操作进展时会发生此中断。然后,系统调用将开始休眠,暂停当前进程的操作一段时间,或直到进程中断发生。

非阻塞式IO - 应用程序告诉操作系统它只需要现在可用的字节,然后继续执行,同时操作系统会并发地收集更多字节。代码使用select来确定哪些IO操作有可用字节。在这种情况下,系统调用将再次安装信号处理程序,但不是睡眠,而是将信号处理程序与文件句柄关联,并立即返回。进程将负责定期检查文件句柄是否已设置中断标志。通常使用select调用完成此操作。

现在,异步的概念开始引起混淆。异步的一般概念仅意味着进程在执行后台操作时继续进行,其实现机制并不具体。该术语是模棱两可的,因为非阻塞式IO和线程式阻塞式IO都可以被认为是异步的。两者都允许并发操作,但资源需求不同,代码也大不相同。因为你问了一个"什么是非阻塞式异步IO"的问题,所以我要使用更严格的异步定义,即执行IO的线程系统可能是非阻塞的,也可能是阻塞的。

一般定义

异步IO - 编程IO允许多个并发IO操作发生。IO操作同时进行,因此代码不会等待尚未准备好的数据。

更严格的定义

异步IO - 编程IO使用线程或多进程来允许并发执行IO操作。

现在,有了这些更清晰的定义,我们有以下四种IO范例。

阻塞IO - 标准单线程IO,在应用程序继续执行之前等待所有IO操作完成。易于编写,没有并发性,因此对于需要多个IO操作的应用程序来说速度慢。进程或线程将在等待IO中断时休眠。

异步IO - 线程式IO,应用程序使用执行线程以并发地执行阻塞IO操作。需要线程安全的代码,但通常比另一种选择更容易阅读和编写。获得多个线程的开销,但具有清晰的执行路径。可能需要使用同步方法和容器。

非阻塞IO - 单线程IO,在应用程序使用select来确定哪些IO操作准备就绪,从而允许执行其他代码或其他IO操作,同时让操作系统处理并发IO。进程在等待IO中断时不会休眠,而是负责检查文件句柄上的IO标记。由于需要使用select检查IO标记,代码更加复杂,但不需要编写线程安全的代码或同步方法和容器。低开销的执行却牺牲了代码的复杂性。执行路径复杂。

异步非阻塞IO - 一种混合IO方法,旨在通过使用线程降低复杂性,同时尽可能地使用非阻塞IO操作来保持可扩展性。这是最复杂的IO类型,需要同步方法和容器以及复杂的执行路径。这不是轻易考虑编写的IO类型,通常只在使用库来掩盖复杂性时使用,例如 Futures 和 Promises。


7
像AKKA和vert.x这样的框架,支持非阻塞特性。人们经常将它们误认为是非阻塞IO框架。这些框架可以做很多事情,但并非非阻塞IO。它们只支持上述所述的异步IO特性。 - Shibashis
8
这是最准确的答案。 - kimathie
5
我还不明白你的意思,而我已经解释了你试图表达的观点。异步IO是单线程还是多线程具有重要性。要使IO在单线程执行中异步化,必须使用非阻塞IO。要使IO在多线程执行中被松散地视为异步化,可以使用阻塞线程或者使用非阻塞IO和未阻塞线程。因此,非阻塞IO(单线程异步)和非常复杂的带线程的非阻塞IO被称为非阻塞异步IO。那么,使用阻塞线程的异步IO又该如何称呼呢? - AaronM
1
我选择并明确说明为什么要将其区分为“异步IO”。这仅仅是代数学。A = B + C,其中A =“非阻塞异步IO”,B =“非阻塞IO”,因此解出C,我们得到“异步IO”。 - AaronM
1
这里的过程有些不同。在同步IO系统中,一旦IO开始,进程就会进入睡眠状态。当信号中断发生时,睡眠被终止,信号处理程序运行,然后立即在睡眠之后的代码开始运行。在同步读取调用中,这是读取完成并且数据在进程中变为可用的时间。这也是为什么读取可能无法读取您告诉它的相同数量的数据,因为在中断发生时缓冲区中可能没有足够的数据。 - AaronM
显示剩余28条评论

63

那么什么是"非阻塞异步IO"呢?

要回答这个问题,首先必须理解不存在所谓的阻塞异步I/O。异步的概念本身就表明没有等待、阻塞或延迟。当你看到非阻塞异步I/O时,非阻塞一词只是进一步修饰该术语中的异步形容词。因此,实际上非阻塞异步I/O可能有些多余。

主要有两种类型的I/O:同步异步同步会阻塞当前执行线程直到处理完成,而异步不会阻塞当前执行线程,而是将控制权传递给操作系统内核进行进一步处理。然后内核会通知异步线程提交的任务已完成


异步通道组

Java中的异步通道概念由异步通道组支持。异步通道组基本上汇集了若干通道以便重复使用。异步API的消费者从组中检索通道(默认情况下JVM会创建一个),而通道在完成读/写操作后会自动将自己放回组中。最终,异步通道组由意外的是线程池支持。此外,异步通道是线程安全的。

支持异步通道组的线程池的大小由以下JVM属性配置:

java.nio.channels.DefaultThreadPool.initialSize

给定一个整数值,将建立一个线程池以支持通道组。否则,通道组将由开发人员透明地创建和维护。


那么这些如何在Java中实现呢?

好的,我很高兴你问了。这里是一个AsynchronousSocketChannel的例子(用于打开非阻塞客户端Socket连接到监听服务器)。这个示例摘自我的评论Apress Pro Java NIO.2

//Create an Asynchronous channel. No connection has actually been established yet
AsynchronousSocketChannel asynchronousSocketChannel = AsynchronousSocketChannel.open(); 

/**Connect to an actual server on the given port and address. 
   The operation returns a type of Future, the basis of the all 
   asynchronous operations in java. In this case, a Void is 
   returned because nothing is returned after a successful socket connection
  */
Void connect = asynchronousSocketChannel.connect(new InetSocketAddress("127.0.0.1", 5000)).get();


//Allocate data structures to use to communicate over the wire
ByteBuffer helloBuffer = ByteBuffer.wrap("Hello !".getBytes()); 

//Send the message

Future<Integer> successfullyWritten=  asynchronousSocketChannel.write(helloBuffer);

//Do some stuff here. The point here is that asynchronousSocketChannel.write() 
//returns almost immediately, not waiting to actually finish writing 
//the hello to the channel before returning control to the currently executing thread

doSomethingElse();

//now you can come back and check if it was all written (or not)

System.out.println("Bytes written "+successfullyWritten.get());

编辑:我应该提到支持异步NIO在JDK 1.7中已经实现。


8
有三种类型:阻塞、非阻塞和异步。你错过了关于如何在Java中使用外部库实现它们的问题的重点。 - user207421
8
通常情况下,异步 I/O 之所以是异步的,是因为 I/O 机制是阻塞的。在这个背景下,“异步”仅仅意味着它在另一个线程中执行。 - Kevin Krumwiede
我想所有的I/O在某种程度上都是阻塞的,如果不是在软件中就是在硬件中。无论你是否将其称为"阻塞"取决于API向你呈现的方式,即它是否阻止了你的线程。如果I/O在API之外是非阻塞的,那是因为它在API内部的某个层面上已经被做成了异步的。这就是为什么说"非阻塞异步I/O"是多余的。非阻塞和异步意味着彼此。 - Kevin Krumwiede
如果successfullyWritten尚未完成,successfullyWritten.get()会阻塞当前线程吗?如果是这样的话,这种用法似乎不太优雅。 - lin
1
@AjaxLeung - Java不使用操作系统提供的本机异步调用的一个原因是,对于某些平台,与上述强烈的断言相反,异步I/O确实会阻塞:https://lwn.net/Articles/724198/而且Java无法使用中断处理程序,因为这些通常不在用户空间运行,也不会将数据来回传递到用户空间。实际上,在底层,有时根本不是中断处理程序:https://lwn.net/Articles/663879/ - user2077221
显示剩余4条评论

8
非阻塞IO是指执行IO的调用立即返回,并且不会阻塞您的线程。
唯一确定IO是否完成的方法是轮询其状态或阻塞。将其视为“Future”。您启动一个IO操作,它会返回一个“Future”对象。您可以调用isDone()方法来检查它是否完成,如果完成了,请进行相应处理,否则继续做其他事情,直到下一次想要检查它是否完成。或者,如果您没其他事情可做,您可以调用get()方法,它将一直阻塞到完成为止。
异步IO是指执行IO的调用通过事件通知您完成,而不是通过返回值通知您完成。
这可能是阻塞的或非阻塞的。
阻塞异步IO
所谓的阻塞异步IO是指执行IO的调用是普通的阻塞调用,但您调用的东西将该调用封装在一个线程中,该线程将阻塞直到IO完成,然后委托处理IO结果给您的回调函数。也就是说,堆栈底层仍然有一个线程被IO阻塞,但您的线程没有被阻塞。
非阻塞异步IO
这实际上更常见,它意味着非阻塞IO不需要轮询其状态,与标准的非阻塞IO不同的是,它将在完成时调用您的回调函数。与阻塞异步IO相反,此方法在堆栈上没有任何被阻塞的线程,因此其速度更快,使用的资源更少,因为可以在不阻塞线程的情况下管理异步行为。
您可以将其视为“CompletableFuture”。它要求程序具有某种形式的异步事件框架,该框架可以是多线程或非多线程的。因此,回调可能在另一个线程中执行,也可能在当前任务完成后安排在现有线程上执行。
我在这里更详细地解释了区别。

1
回调函数既不是阻塞的也不是非阻塞的。我从未见过一个框架/语言/系统在等待回调函数调用时会停止线程,然后再从回调函数被触发的地方重新开始。也许这样的系统存在,但那会相当奇怪。正如你所说,通常注册回调函数后,执行过程与回调无关。这个答案似乎非常针对JavaScript,而问题是中性或Java为中心的。 - AaronM
1
看一下我的关于底层IO发生的澄清。我认为这会对你有所帮助,让事情变得更加清晰。 - AaronM
1
@AaronM 我修改了我的答案,以消除我认为让你觉得我很困惑的部分。你的答案很好,但我觉得在技术细节上有点过于详细了。我在一些语义问题上也有些不同意,但只是轻微的。我的示例是基于Java的,在我的回答中没有任何JavaScript。我感觉它适用于所有的语言和操作系统。你现在还有什么困惑或不同意见吗? - Didier A.
1
听起来很合理,我现在更喜欢它了。 我唯一的问题是异步非阻塞。 从开发人员层面看,它似乎准确,但从系统层面看,它不是。 如果IO是非阻塞的,则必须有某些内容检查IO何时完成。 内核不会自动调用您堆栈中的函数。 但正如您所提到的,这需要一个框架,而该框架将为开发人员管理该复杂性。 感谢上天。 - AaronM
1
关于JavaScript的注释,我应该说的是它倾向于事件驱动/函数式编程环境,我仍然认为它是这样。这在Java中并不常见,在JavaScript中非常常见,因此我做出了那个评论。但是所有这些类型的IO也被用于非事件驱动代码,传统的过程式代码也是如此。在这种情况下,异步变得更加复杂,但是很可能在不使用回调(或承诺或未来)的情况下进行非阻塞异步io。回调和其他替代方案确实使代码更易于理解。 - AaronM
显示剩余5条评论

5

同步 vs. 异步

"异步"是一个相对的术语,适用于各种计算,而不仅仅是IO。某个操作本身不能做到"异步",它总是相对于其他某些东西而言才会"异步"。通常,异步意味着一些操作在请求IO计算的线程之外的另一个执行线程中发生,并且在请求和计算线程之间没有明确的同步(等待)机制。如果请求线程在计算线程进行工作时等待(休眠、阻塞),我们就称这样的操作为同步操作。还有一些混合情况。有时,请求线程不会立即等待,而是在发出IO请求后异步地执行一些固定数量的实用工作,但如果IO结果尚未准备好,则稍后会阻塞(同步)以等待其结果。

阻塞 vs. 非阻塞

在更广义上,“阻塞”和“非阻塞”可以粗略地用来表示“同步”和“异步”相应地。你会经常看到人们使用"阻塞"与"同步"互换使用,"非阻塞"则用于表示"异步"。在这个意义上,"非阻塞异步"是多余的,正如其他人上面提到的那样。

然而,在更狭义上,"阻塞"和"非阻塞"可能指的是不同的内核IO接口。这里值得说一下,现在所有的IO操作都是由操作系统内核执行的,因为对于IO硬件设备(如磁盘或网络接口卡)的访问是被操作系统抽象掉的。这意味着你从用户空间代码请求的每个IO操作最终都会通过阻塞或非阻塞接口在内核中执行。

当通过阻塞接口调用时,内核将假定你的线程想要同步地获取结果,并将其置于休眠状态(取消排队、阻塞),直到IO结果可用。因此,在内核满足IO请求期间,该线程将无法执行任何其他有用的工作。例如,在Linux上,所有磁盘IO都是阻塞的。

非阻塞内核接口的工作方式不同。你要告诉内核你想要哪些IO操作。内核不会阻塞(取消排队)你的线程,并立即从IO调用返回。你的线程可以继续进行某些有用的工作。内核线程将异步地执行IO请求。然后,你的代码需要偶尔检查内核是否已经完成了工作,之后你可以使用结果。例如,在Linux上提供了非阻塞IO的epoll接口。还有旧的pollselect系统调用用于同样的目的。值得注意的是,非阻塞接口大多适用于网络。

请注意,某些高级IO API在底层使用阻塞内核IO并不意味着调用该API时您的线程一定会被阻塞。这样的API可以实现一种机制来生成一个新线程或使用不同的现有线程来执行该阻塞IO请求。它将通过某种方式(回调、事件或让您的线程轮询)稍后通知您的调用线程已完成IO请求。也就是说,非阻塞IO语义可以由第三方库或运行时在阻塞OS内核接口之上使用附加线程在用户空间中实现。
结论 要了解每个特定运行时或库如何实现IO异步性,您必须深入了解它是否生成新线程或依赖于异步内核接口。
后记 现实情况是,这些天你很少会遇到真正的单线程系统。
例如,大多数人将Node.js称为具有“单线程非阻塞”IO。然而,这是一种简化。在Linux上,真正的非阻塞IO仅适用于通过epoll接口进行的网络操作。对于磁盘IO,内核将始终阻塞调用线程。为了实现磁盘IO的异步性(相对较慢),Node.js运行时(或者更准确地说是libuv)维护一个专用线程池。每当请求异步磁盘IO操作时,运行时将工作分配给该池中的一个线程。该线程将执行标准阻塞磁盘IO,而主(调用)线程将继续进行异步操作。更不用说由V8运行时单独维护的用于垃圾收集和其他托管运行时任务的众多线程了。

5
我认为IO有三种类型:
同步阻塞 同步非阻塞 异步
同步非阻塞和异步都被认为是非阻塞的,因为调用线程不会等待IO完成。因此,虽然非阻塞异步IO可能是多余的,但它们并不相同。当我打开一个文件时,我可以以非阻塞模式打开它。这是什么意思?这意味着当我发出读取()时,它不会阻塞。它要么返回可用的字节,要么指示没有可用的字节。如果我没有启用非阻塞IO,则读取()将阻塞,直到数据可用。如果我想让一个线程处理多个IO请求,我可能需要启用非阻塞IO。例如,我可以使用select()查找哪些文件描述符或套接字有可读数据。然后我在这些文件描述符上进行同步读取。这些读取中没有一个应该阻塞,因为我已经知道数据可用,并且我已经以非阻塞模式打开了文件描述符。
异步IO是您发出IO请求的地方。该请求排队,因此不会阻塞发出线程。当请求失败或成功完成时,您将收到通知。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接