Java 中的 Async File NIO 有什么好处?

Pet*_*ter 10 java asynchronous nio java-io

根据AsynchronousFileChannelAsynchronousChannelGroup的文档,异步 NIO 使用专用线程池,“处理 IO 事件”。我找不到任何明确的说明在这种情况下“处理”是什么意思,但根据this,我很确定在一天结束时,这些专用线程上会发生阻塞。为了缩小范围,我正在使用 Linux 并基于Alex Yursha 的回答,没有非阻塞 IO 之类的东西,只有 Windows 在某些级别上支持它。

我的问题是:使用异步 NIO 与在我自己创建的专用线程池上运行的同步 IO 相比有什么好处?考虑到引入的复杂性,什么情况下仍然值得实施?

rzw*_*oot 10

这主要是关于手动调整缓冲区大小。这样,您可以节省大量内存,但前提是您要尝试处理大量(数千个)同时连接。

首先是一些简化和警告:

  • 我将假设一个非笨拙的调度程序。有些操作系统在处理数千个线程方面做得很差。当用户进程启动 1000 个完整线程时,操作系统会崩溃并没有内在的原因,但有些操作系统会这样做。NIO 可以提供帮助,但这有点不公平——通常你应该升级你的操作系统。几乎所有的 linux,我相信 win10 肯定不会有这么多线程的问题,但是一些旧的 linux 端口在 ARM hack 上,或者像 windows 7 这样的东西 - 这可能会导致问题。

  • 我将假设您使用 NIO 来处理传入的 TCP/IP 连接(例如 Web 服务器或 IRC 服务器,类似的东西)。如果您尝试同时读取 1000 个文件,同样的原则也适用,但请注意,您确实需要考虑瓶颈所在。例如,同时从单个磁盘读取 1000 个文件是一项毫无意义的练习——这只会减慢速度,因为您使磁盘的运行更加困难(如果它是旋转磁盘,则这会加倍)。对于网络,尤其是在快速管道上时,瓶颈不是管道或网卡,这使得“同时处理 1000 个连接”成为一个很好的例子。事实上,我将使用一个聊天服务器作为示例,其中 1000 人都连接到一个巨大的聊天室。

同步模型

在同步模型中,生活相对简单:我们将制作 2001 个线程:

  • 1 个线程来侦听套接字上的新传入 TCP 连接。该线程将创建 2 个“处理程序”线程并返回侦听新连接。
  • 每个用户从套接字读取直到看到输入符号的线程。如果它看到这一点,它将获取到目前为止收到的所有文本,并用这个需要发送的新字符串通知所有 1000 个“发送者”线程。
  • 每个用户一个线程,该线程将在“要发送的文本消息”缓冲区中发送字符串。如果没有任何东西要发送,它将等待直到有新消息发送给它。

每个单独的移动部件都可以轻松编程。单一java.util.concurrent数据类型的一些战术使用,甚至一些基本synchronized()块将确保我们不会遇到任何竞争条件。我设想每个部分可能有 1 页代码。

但是,我们确实有 2001 个线程。每个线程都有一个堆栈。在 JVM 中,每个线程都获得相同大小的堆栈(您不能创建一个线程,但可以使用不同大小的堆栈),并且您可以使用-Xss参数配置它的大小。您可以将它们设置为小到 128k,但即便如此,对于堆栈仍然128k * 2001= ~256MB ,我们还没有覆盖任何堆(人们来回发送的所有字符串,卡在发送队列中) ),或应用程序本身,或 JVM 基础知识。

在引擎盖下,有 16 个内核的 CPU 会发生什么,有 2001 个线程,每个线程都有自己的一组条件,这些条件会导致它唤醒。对于接收者来说,它的数据是通过管道传入的,对于发送者来说,它要么是网卡表明它已准备好发送另一个数据包(以防它正在等待将数据推送到线路上),要么等待obj.wait()呼叫得到通知(接收来自用户的文本的线程会将该字符串添加到 1000 个发件人中的每一个的所有队列中,然后通知他们所有人)。

这是很多上下文切换:一个线程唤醒,Joe: Hello, everybody, good morning!在缓冲区中查看,将其转换为数据包,将其发送到网卡的内存缓冲区(这一切都非常快,只是 CPU 和内存交互),然后例如,重新入睡。然后 CPU 内核将继续寻找另一个准备做一些工作的线程。

CPU 内核具有内核缓存;事实上,有一个层次结构。有主 RAM,然后是 L3 缓存、L2 缓存、核心缓存——在现代架构中,CPU 不能真正在 RAM 上运行,他们需要芯片周围的基础设施来实现它需要读取或写入内存在不在这些缓存之一中的页面上,CPU 将冻结一段时间,直到基础设施可以将该 RAM 页面复制到其中一个缓存中。

每次内核切换时,它很可能需要加载一个新页面,这可能需要数百个周期,而 CPU 会摆弄它的拇指。一个写得不好的调度程序会导致比需要的更多的这种情况。如果您了解 NIO 的优势,通常会说“那些上下文切换很昂贵!” 出现 - 这或多或少是他们在谈论的(但是,剧透警告:异步模型也受此影响!)

异步模型

在同步模型中,确定 1000 个已连接用户中的哪些用户已准备好让事情发生的工作被“卡住”在等待事件的线程中;操作系统正在处理这 1000 个线程,并且会在有事情要做时唤醒线程。

在异步模型中,我们切换它:我们仍然有线程,但少得多(每个内核一到两个是个好主意)。这比连接用户少得多:每个线程负责所有连接,而不是仅负责 1 个连接。这意味着每个线程都会检查哪些连接的用户有事情要做(他们的网络管道有数据要读取,或者准备好让我们通过线路向他们推送更多数据)。

不同之处在于线程询问操作系统的内容:

  • [同步​​] 好的,我想睡觉,直到这个连接向我发送数据。
  • [async] 好的,我想睡觉,直到这千个连接之一向我发送数据,或者我注册我正在等待网络缓冲区清除,因为我有更多数据要发送,并且网络已清除, 或者 socketlistener 有一个新用户连接。

两种模式都没有固有的速度或设计优势——我们只是在应用程序和操作系统之间转移工作。

NIO 经常吹捧的一项优势是您无需“担心”竞争条件、同步、并发安全的数据结构。这是一个经常重复的谎言:CPU 有许多内核,因此如果您的非阻塞应用程序只创建一个线程,那么您的 CPU 的绝大多数将只是闲置在那里什么也不做,这是非常低效的。

这里最大的好处是:嘿,只有 16 个线程。那是128k * 16= 2MB 的堆栈空间。这与同步模型占用的 256MB形成鲜明对比!然而,现在发生了不同的事情:在同步模型中,很多关于连接的状态信息都“卡”在那个堆栈中。例如,如果我这样写:

让我们假设协议是:客户端发送 1 个 int,它是消息中的字节数,然后是那么多字节,即消息,UTF-8 编码。

// synchronous code
int size = readInt();
byte[] buffer = new byte[size];
int pos = 0;
while (pos < size) {
    int r = input.read(buffer, pos, size - pos);
    if (r == -1) throw new IOException("Client hung up");
    pos += r;
}
sendMessage(username + ": " + new String(buffer, StandardCharsets.UTF_8));
Run Code Online (Sandbox Code Playgroud)

运行此程序时,线程很可能最终会阻塞对read输入流的调用,因为这将涉及与网卡通信并将一些字节从其内存缓冲区移动到此进程的缓冲区中以完成工作。当它被冻结时,指向该字节数组的指针、size变量r等都在堆栈中。

在异步模型中,它不会那样工作。在异步模型中,你得到给你的数据,你得到任何存在的东西,然后你必须处理这个,因为如果你不这样做,这些数据就会消失。

因此,在异步模型中,您会得到一半的Hello everybody, good morning!消息。你得到代表的字节Hello eve,就是这样。就此而言,您已经获得了此消息的总字节长度,需要记住这一点,以及您目前收到的一半。您需要明确地创建一个对象并将这些东西存储在某个地方。

这是关键点:使用同步模型,您的许多状态信息都在堆栈中。在异步模型中,您自己制作数据结构来存储此状态

因为你自己制作这些,它们可以动态调整大小,而且通常要小得多:你只需要大约 4 个字节来存储大小,另外 8 个左右的字节用于指向字节数组的指针,少数用于用户名指针,仅此而已. 这比128k堆栈用来存储这些东西的数量级小。

现在,另一个理论上的好处是你不会得到上下文切换——而不是 CPU 和操作系统在 read() 调用没有数据可以给你时切换到另一个线程,因为网卡正在等待数据,现在是线程的工作了:好的,没问题——我将转到另一个上下文对象。

但这是一个红鲱鱼 - 操作系统是否在处理 1000 个上下文概念(1000 个线程),或者您的应用程序是否在处理 1000 个上下文概念(这些“跟踪器”对象)都没有关系。它仍然是 1000 个连接,每个人都在闲聊,所以每次你的线程继续检查另一个上下文对象并用更多数据填充它的字节数组时,很可能它仍然是缓存未命中,CPU 仍然会旋转数百个循环,而硬件基础设施将适当的页面从主 RAM 拉入缓存。所以这部分几乎没有那么相关,尽管上下文对象较小的事实会在一定程度上减少缓存未命中。

这让我们回到:主要的好处是您可以手动滚动这些缓冲区,这样做,您既可以使它们更小,又可以动态调整它们的大小。

异步的缺点

我们有垃圾收集语言是有原因的。我们不用汇编程序编写所有代码是有原因的。手动仔细管理所有这些挑剔的细节通常是不值得的。所以它在这里:通常这种好处是不值得的。但是就像 GFX 驱动程序和内核内核有大量的机器代码,并且驱动程序往往是在手动管理的内存环境中编写的,在某些情况下,仔细管理这些缓冲区是非常值得的。

不过成本很高。

想象一种具有以下属性的理论编程语言:

  • 每个功能都是红色或蓝色。
  • 红色函数可以调用蓝色或红色函数,没问题。
  • 蓝色函数也可以同时调用这两个函数,但是如果蓝色函数调用红色函数,那么您就会遇到一个几乎无法测试的错误,但会降低您在实际负载下的性能。Blue 只能通过分别定义调用和对调用结果的响应并将这对函数注入队列来调用红色函数。
  • 函数往往不会记录它们的颜色。
  • 一些系统功能是红色的。
  • 你的函数必须是蓝色的。

这似乎是一种完全愚蠢的语言灾难,不是吗?但这正是您在编写异步代码时所生活的世界!

问题是:在异步代码中,您不能调用阻塞函数,因为如果它阻塞,嘿,这是现在被阻塞的仅有的 16 个线程之一,这立即意味着您的 CPU 现在没有执行 1/16 的事情。如果所有 16 个线程最终都进入阻塞部分,则 CPU 实际上什么都不做,一切都被冻结了。你就是做不到。

有很多东西会阻塞:打开文件,甚至接触以前从未接触过的类(该类需要从磁盘的 jar 中加载,验证和链接),以及查看数据库,进行快速网络检查,有时询问当前时间就可以了。即使在调试级别进行日志记录也可能会这样做(如果最终写入磁盘,瞧 - 阻塞操作)。

您是否知道有任何日志框架承诺会启动一个单独的线程来处理磁盘上的日志,或者会不遗余力地记录它是否阻塞?我也不知道。

所以,阻塞的方法是红色的,你的异步处理程序是蓝色的。Tada - 这就是为什么 async 很难真正做到正确。

执行摘要

由于有颜色的函数问题,写好异步代码真的很痛苦。从表面上看,它也不是更快——事实上,它通常更慢。如果您想同时运行数千个操作,并且跟踪每个单独操作的相关状态数据所需的存储量很小,那么异步可能会大获全胜,因为您可以手动处理该缓冲区,而不是被迫每个依赖 1 个堆栈线。

如果你还剩下一些钱,那么开发人员的薪水会给你买很多内存条,所以通常正确的选择是使用线程,如果你想同时处理多个内存,就选择一个有很多内存的盒子连接。

请注意,像 youtube、facebook 等网站有效地采用了“在 RAM 上扔钱”的解决方案 - 他们将产品分片,以便许多简单且廉价的计算机协同工作来提供网站。不要敲它。

异步可以真正发挥作用的例子是我在这个答案中描述的聊天应用程序。另一种是,比如说,你收到一条短消息,你所做的就是对它进行散列,加密散列,然后用它来回应(散列,你不需要记住所有流入的字节,你可以把每个字节扔掉进入具有恒定内存负载的散列器,当字节全部发送时,瞧,你有你的散列)。相对于提供数据的速度,您正在寻找每个操作的少量状态和 CPU 功率。

一些不好的例子:是一个你需要做一堆数据库查询的系统(你需要一种异步方式来与你的数据库交谈,一般来说,数据库在尝试同时运行 1000 个查询时很糟糕),比特币挖掘操作(比特币挖掘是瓶颈,尝试在一台机器上同时处理数千个连接是没有意义的)。