Clojure-高性能编程-全-

Clojure 高性能编程(全)

原文:zh.annas-archive.org/md5/7e6ff31fbdb3e72e93f13499ae8b2caa

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自本书第一版于 2013 年 11 月出版以来,Clojure 已经得到了更广泛的应用,并见证了许多成功案例。Clojure 的新版本加强了其性能故事,同时保持其根源——简单和实用。这一版对 Clojure 1.7 进行了重大更新,并增加了一章关于性能测量的内容。

Java 虚拟机在 Clojure 程序的性能中起着巨大的作用。本书的这一版增加了对 JVM 性能管理工具的关注,并探讨了如何使用这些工具。本书更新为使用 Java 8,尽管它也突出了使用 Java 7 或 8 特性的地方。

本书主要更新是为了使读者更实用。我希望这一版能更好地为读者提供性能测量和性能分析工具,以及分析并调整 Clojure 代码性能特性的知识。

本书涵盖内容

第一章,设计性能,根据性能对各种用例进行分类,并分析如何解释它们的性能方面和需求。

第二章,Clojure 抽象,是对各种 Clojure 数据结构、抽象(持久数据结构、变量、宏、转换器等)及其性能特性的导游。

第三章,依赖 Java,讨论了如何通过使用 Java 互操作性和 Clojure 的特性来提高性能。

第四章,主机性能,讨论了主机堆栈如何影响性能。作为一个托管语言,Clojure 的性能与其主机直接相关。

第五章,并发,是一个高级章节,讨论了 Clojure 和 JVM 中的并发和并行特性。并发是提高性能的一个越来越重要的方式。

第六章,性能测量,涵盖了性能基准和测量其他因素的各个方面。

第七章,性能优化,讨论了为了识别性能瓶颈并获得良好性能需要采取的系统步骤。

第八章,应用性能,讨论了为性能构建应用程序。这涉及到处理影响整体性能的外部子系统因素。

本书所需条件

为了能够完成所有示例,你需要为你的操作系统获取 Java 开发工具包(JDK)版本 8 或更高。本书讨论了 Oracle HotSpot JVM,因此如果你可能的话,你可能想要获取 Oracle JDK 或 OpenJDK(或 Zulu)。你还应该从leiningen.org/获取最新的 Leiningen 版本(截至写作时为 2.5.2),以及从jd.benow.ca/获取 JD-GUI。

本书面向对象

这本书是为对学习如何编写高性能代码感兴趣的 Clojure 中级程序员所写。如果你是 Clojure 的绝对初学者,你应该先学习语言的基础知识,然后再回到这本书。你不需要对性能工程或 Java 非常熟悉。然而,一些 Java 的先验知识会使得理解与 Java 相关的章节变得更加容易。

习惯用法

在本书中,你会找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称将如下所示:“请注意,Clojure 中的identical?与 Java 中的==相同。”

代码块设置如下:

user=> (identical? "foo" "foo")  ; literals are automatically interned
true
user=> (identical? (String. "foo") (String. "foo"))  ; created string is not interned
false

新术语重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中会像这样显示:“点击下一个按钮将你带到下一个屏幕。”

注意

警告或重要注意事项以如下方式显示。

小贴士

小技巧和窍门会像这样显示。

读者反馈

我们始终欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。

要发送给我们一般性的反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件的主题中提及本书的标题。

如果你在一个领域有专业知识,并且你对撰写或为本书做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在你已经是 Packt 图书的骄傲所有者,我们有一些可以帮助你从购买中获得最大收益的东西。

错误表

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将在勘误部分显示。

盗版

互联网上对版权材料的盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以追究补救措施。

请通过<copyright@packtpub.com>联系我们,并提供疑似盗版材料的链接。

我们感谢您在保护我们的作者和提供有价值内容的能力方面的帮助。

电子书,折扣优惠等

您知道吗?Packt 为每本书提供电子书版本,提供 PDF 和 ePub 文件。您可以在www.PacktPub.com升级到电子书版本,作为印刷书客户,您有权享受电子书副本的折扣。有关更多详情,请联系<customercare@packtpub.com>

www.PacktPub.com,您还可以阅读一系列免费的技术文章,注册各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

问题

如果您在这本书的任何方面遇到问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。

第一章. 设计性能

Clojure 是一种安全、函数式编程语言,为用户带来了巨大的力量和简洁性。Clojure 也是动态和强类型化的,并且具有非常好的性能特性。自然地,计算机上进行的每一项活动都有相关的成本。构成可接受性能的标准因用例和工作负载而异。在当今世界,性能甚至成为多种类型应用程序的决定性因素。我们将从性能的角度讨论 Clojure(在JVMJava 虚拟机)上运行),以及其运行环境,这正是本书的目标。

Clojure 应用程序的性能取决于各种因素。对于一个特定的应用程序,理解其用例、设计实现、算法、资源需求和与硬件的匹配,以及底层软件能力是至关重要的。在本章中,我们将研究性能分析的基础,包括以下内容:

  • 根据用例类型对性能预期进行分类

  • 概述分析性能的结构化方法

  • 术语表,通常用于讨论性能方面

  • 每个程序员都应该知道的性能数字

用例分类

不同类型的用例的性能要求和优先级各不相同。我们需要确定各种用例可接受的性能标准。因此,我们将它们分类以识别其性能模型。在细节上,对于任何类型的用例,都没有一成不变的性能秘方,但研究它们的普遍性质肯定有帮助。请注意,在现实生活中,本节中列出的用例可能相互重叠。

面向用户的软件

面向用户的软件性能与用户的预期紧密相关。相差数十毫秒可能对用户来说并不明显,但与此同时,等待几秒钟以上可能不会受到欢迎。在正常化预期中的一个重要元素是通过提供基于持续时间的反馈来吸引用户。处理此类场景的一个好主意是在后台异步启动任务,并从 UI 层轮询它以生成基于持续时间的用户反馈。另一种方法是对用户逐步渲染结果,以平衡预期。

预期并不是用户界面性能的唯一因素。常见的技巧,如数据分阶段或预计算,以及其他一般优化技术,可以在很大程度上提高用户体验的性能。请记住,所有类型的用户界面都落入此类用例类别——网页、移动网页、GUI、命令行、触摸、语音操作、手势……无论你叫它什么。

计算和数据处理任务

非平凡的密集型计算任务需要相应数量的计算资源。CPU、缓存、内存、计算算法的效率和并行性都会涉及到性能的确定。当计算结合网络分布或从磁盘读取/写入时,I/O 限制因素就会发挥作用。这类工作负载可以进一步细分为更具体的用例。

CPU 密集型计算

CPU 密集型计算受执行它所花费的 CPU 周期的限制。循环中的算术处理、小矩阵乘法、判断一个数是否是梅森素数等,都会被认为是 CPU 密集型任务。如果算法复杂度与迭代/操作数N相关,例如O(N)O(N²)等,那么性能取决于N的大小以及每一步需要多少 CPU 周期。对于可并行化的算法,可以通过分配多个 CPU 核心给任务来提高这类任务的性能。在虚拟硬件上,如果 CPU 周期是突发性的,性能可能会受到影响。

内存限制型任务

内存限制型任务受内存的可用性和带宽限制。例如,大文本处理、列表处理等。例如,在 Clojure 中,如果coll是一个由大映射组成的大序列,那么(reduce f (pmap g coll))操作将是内存限制型,即使我们在这里使用pmap来并行化操作。请注意,当内存是瓶颈时,更高的 CPU 资源无法帮助,反之亦然。内存不可用可能迫使你一次处理更小的数据块,即使你有足够的 CPU 资源可用。如果你的内存最大速度是X,而你的算法在单核上以速度X/3访问内存,那么你的算法的多核性能不能超过当前性能的三倍,无论你分配多少 CPU 核心给它。内存架构(例如,SMP 和 NUMA)对多核计算机的内存带宽有贡献。与内存相关的性能也受页面错误的影响。

缓存限制型任务

当任务的速度受可用缓存量限制时,该任务就是缓存限制型。当一个任务从少量重复的内存位置检索值时,例如小矩阵乘法,这些值可能会被缓存并从那里检索。请注意,CPU(通常是)有多个缓存层,当处理的数据适合缓存时,性能将达到最佳,但如果数据不适合缓存,处理仍然会发生,但速度会慢一些。可以使用缓存无关算法最大限度地利用缓存。如果并发缓存/内存限制型线程的数量高于 CPU 核心数,很可能会在上下文切换时清空指令流水线和缓存,这可能导致性能严重下降。

一个输入/输出(I/O)密集型任务

如果依赖的 I/O 子系统运行得更快,一个输入/输出(I/O)密集型任务会运行得更快。磁盘/存储和网络是数据处理中最常用的 I/O 子系统,但也可以是串行端口、USB 连接的卡片阅读器或任何 I/O 设备。一个 I/O 密集型任务可能消耗很少的 CPU 周期。根据设备速度、连接池、数据压缩、异步处理、应用程序缓存等,可能会有助于性能。I/O 密集型任务的一个显著方面是,性能通常依赖于等待连接/查找的时间以及我们进行的序列化程度,而很少依赖于其他资源。

实际上,许多数据处理工作负载通常是 CPU 密集型、内存密集型、缓存密集型和 I/O 密集型任务的组合。这类混合工作负载的性能实际上取决于在整个操作过程中 CPU、缓存、内存和 I/O 资源的均匀分布。只有当某一资源过于繁忙而无法为其他资源让路时,才会出现瓶颈情况。

在线事务处理

在线事务处理OLTP)系统按需处理业务交易。它们可以位于用户界面 ATM 机、销售点终端、网络连接的票务柜台、ERP 系统等系统之后。OLTP 系统以低延迟、可用性和数据完整性为特点。它们运行日常业务交易。任何中断或故障都可能导致销售或服务直接且立即受到影响。这类系统应设计为具有弹性,而不是从故障中延迟恢复。当性能目标不明确时,您可能希望考虑优雅降级作为一种策略。

请求 OLTP 系统回答分析查询是一个常见的错误,它们并不是为此优化的。一个有经验的程序员了解系统的能力,并根据需求提出设计更改是可取的。

在线分析处理

在线分析处理OLAP)系统旨在短时间内回答分析查询。它们通常从 OLTP 操作中获取数据,其数据模型针对查询进行了优化。它们基本上提供数据合并(汇总)、钻取和切片切块,以供分析使用。它们通常使用可以即时优化即席分析查询的特殊数据存储。对于这类数据库来说,提供类似数据透视表的功能非常重要。通常,OLAP 立方体用于快速访问分析数据。

将 OLTP 数据输入到 OLAP 系统中可能涉及工作流程和多阶段批量处理。这类系统的性能关注点是高效处理大量数据的同时,还要处理不可避免的故障和恢复。

批量处理

批处理是指预定义作业的自动化执行。这些通常是批量作业,在非高峰时段执行。批处理可能涉及一个或多个作业处理阶段。通常批处理与工作流自动化结合使用,其中一些工作流步骤是在离线状态下执行的。许多批处理作业工作在数据准备阶段,为下一阶段的处理挑选数据。

批处理作业通常优化以最佳利用计算资源。由于几乎没有东西可以调节对降低某些特定子任务延迟的需求,这些系统倾向于优化吞吐量。许多批处理作业涉及大量的 I/O 处理,并且通常分布在集群上。由于分布,处理作业时优先考虑数据局部性;也就是说,数据和处理应该是本地的,以避免在读写数据时的网络延迟。

对性能的

在实践中,非平凡应用程序的性能很少是巧合或预测的结果。对于许多项目来说,性能不是一种选择(而是一种必需品),这就是为什么这在今天尤为重要。容量规划、确定性能目标、性能建模、测量和监控是关键。

调整一个设计不良的系统以实现性能,如果不说实际上是不可能的,那么比从一开始就设计一个良好的系统要困难得多。为了达到性能目标,在应用程序设计之前应该知道性能目标。性能目标用延迟、吞吐量、资源利用率和工作负载来表述。这些术语将在本章下一节中讨论。

资源成本可以根据应用场景来识别,例如浏览产品、将产品添加到购物车、结账等。创建代表用户执行各种操作的工作负载配置文件通常是有帮助的。

性能建模是检查应用程序设计是否支持性能目标的一种现实检查。它包括性能目标、应用程序场景、约束、测量(基准结果)、工作负载目标以及如果有的话,性能基线。它不是测量和负载测试的替代品,相反,模型是通过这些来验证的。性能模型可能包括性能测试用例,以断言应用程序场景的性能特征。

将应用程序部署到生产环境几乎总是需要某种形式的容量规划。它必须考虑今天的性能目标和可预见的未来的目标。它需要了解应用程序架构,以及外部因素如何转化为内部工作负载。它还需要对系统提供的响应性和服务水平有合理的预期。通常,容量规划在项目早期进行,以减轻配置延迟的风险。

性能词汇表

在性能工程中,有几个技术术语被广泛使用。理解这些术语很重要,因为它们是性能相关讨论的基础。这些术语共同构成了性能词汇表。性能通常以几个参数来衡量,每个参数都有其作用——这样的参数是词汇表的一部分。

延迟

延迟 是单个工作单元完成任务所需的时间。它并不表示任务的完成成功。延迟不是集体的,它与特定的任务相关。如果两个类似的工作任务——j1j2 分别耗时 3 毫秒和 5 毫秒,它们的延迟将被视为如此。如果 j1j2 是不同的任务,那么这并没有区别。在许多情况下,类似工作的平均延迟被用于性能目标、测量和监控结果。

延迟是衡量系统健康状况的重要指标。高性能系统通常依赖于低延迟。高于正常水平的延迟可能是由于负载或瓶颈造成的。在负载测试期间测量延迟分布有助于了解情况。例如,如果超过 25% 的类似工作在类似负载下比其他工作有显著更高的延迟,那么这可能是值得调查的瓶颈场景的指标。

当一个名为 j1 的任务由名为 j2j3j4 的较小任务组成时,j1 的延迟不一定是 j2j3j4 各自延迟的总和。如果 j1 的任何子任务与其他任务并发,j1 的延迟可能会小于 j2j3j4 延迟的总和。I/O 密集型任务通常更容易出现较高的延迟。在网络系统中,延迟通常基于往返另一个主机的总时间,包括从源到目的地的延迟,然后返回源。

吞吐量

吞吐量是指在单位时间内完成的成功任务或操作的数量。在单位时间内执行的最顶层操作通常属于同一类,但延迟可能不同。那么,吞吐量告诉我们关于系统的什么信息呢?它是系统执行的速度。当你进行负载测试时,你可以确定特定系统可以执行的最大速率。然而,这并不能保证系统性能的最终、整体和最大速率。

吞吐量是决定系统可扩展性的因素之一。较高层次任务的吞吐量取决于并行生成多个此类任务的能力,以及这些任务的平均延迟。吞吐量应在负载测试和性能监控期间进行测量,以确定峰值吞吐量和最大持续吞吐量。这些因素有助于决定系统的规模和性能。

带宽

带宽是指通信通道上的原始数据速率,以每秒一定数量的比特来衡量。这包括不仅包括有效载荷,还包括执行通信所需的所有开销。一些例子包括:Kbits/sec、Mbits/sec 等。大写字母 B,如 KB/sec,表示字节,即每秒千字节。带宽通常与吞吐量进行比较。虽然带宽是原始容量,但对于同一系统,吞吐量是成功任务完成率,通常涉及往返。请注意,吞吐量涉及延迟的操作。为了在给定带宽下实现最大吞吐量,通信/协议开销和操作延迟应尽可能小。

对于存储系统(如硬盘、固态硬盘等),衡量性能的主要方式是IOPS(每秒输入输出),它是通过传输大小乘以的,表示为每秒字节数,或者进一步表示为 MB/sec、GB/sec 等。IOPS 通常用于顺序和随机工作负载的读写操作。

将系统的吞吐量映射到另一个带宽可能会导致处理两个系统之间的阻抗不匹配。例如,一个订单处理系统可能执行以下任务:

  • 与磁盘上的数据库进行交易

  • 将结果通过网络发送到外部系统

根据磁盘子系统、网络带宽以及订单处理执行模型的不同,吞吐量可能不仅取决于磁盘子系统和网络的带宽,还取决于它们当前的负载情况。并行化和流水线是提高给定带宽吞吐量的常见方法。

基准和基准测试

性能基准线,或简称基准线,是参考点,包括对已知配置中良好定义和理解的性能参数的测量。基准线用于收集我们可能后来为另一个配置基准测试的相同参数的性能测量。例如,收集“在 50 个并发线程负载下 10 分钟内的吞吐量分布”是这样一种性能参数,我们可以用它作为基准和基准测试。基准线与硬件、网络、操作系统和 JVM 配置一起记录。

性能基准,或简称基准,是在各种测试条件下记录性能参数测量的过程。一个基准可以由一个性能测试套件组成。基准可能收集从小到大的数据量,并且可能根据用例、场景和环境特性而持续不同时长。

基准线是在某个时间点进行的基准测试的结果。然而,基准与基准线是独立的。

性能分析

性能 分析,或简称分析,是在程序运行时对其执行的分析。程序可能由于各种原因表现不佳。分析器可以分析和找出程序各部分的执行时间。手动在程序中放置语句以打印代码块的执行时间是可能的,但随着您尝试迭代地改进代码,这会变得非常繁琐。

分析器对开发者非常有帮助。根据分析器的工作原理,主要有三种类型——仪器化、采样和基于事件。

  • 基于事件的分析器:这些分析器仅适用于选定的语言平台,并在开销和结果之间提供了良好的平衡;Java 通过 JVMTI 接口支持基于事件的性能分析。

  • 仪器分析器:这些分析器在编译时或运行时修改代码以注入性能计数器。它们本质上是侵入性的,并增加了显著的性能开销。然而,您可以使用仪器分析器非常选择性地分析代码区域。

  • 采样分析器:这些分析器在“采样间隔”暂停运行时并收集其状态。通过收集足够的样本,它们可以了解程序大部分时间花在了哪里。例如,在 1 毫秒的采样间隔下,分析器在一秒钟内会收集 1000 个样本。采样分析器也适用于执行速度超过采样间隔的代码(即,代码可能在两次采样事件之间执行几个工作迭代),因为暂停和采样的频率与任何代码的整体执行时间成比例。

分析并不仅限于测量执行时间。有能力的分析器可以提供内存分析、垃圾回收、线程等方面的视图。这些工具的组合有助于找到内存泄漏、垃圾回收问题等。

性能优化

简而言之,优化是在性能分析之后增强程序的资源消耗。性能不佳的程序的症状可以从高延迟、低吞吐量、无响应、不稳定、高内存消耗、高 CPU 消耗等方面观察到。在性能分析期间,可以通过分析程序来识别瓶颈,并通过观察性能参数逐步调整性能。

选择更好和更合适的算法是优化代码的全面好方法。对于 CPU 密集型代码,可以通过计算成本更低的操作进行优化。对于缓存密集型代码,可以尝试使用更少的内存查找来保持良好的命中率。对于内存密集型代码,可以使用自适应内存使用和保守的数据表示来存储在内存中进行优化。对于 I/O 密集型代码,可以尝试尽可能少地序列化数据,并且操作批处理将使操作更少地聊天,从而提高性能。并行性和分布式是其他,整体上好的提高性能的方法。

并发与并行

我们今天使用的绝大多数计算机硬件和操作系统都提供了并发功能。在 x86 架构上,对并发的硬件支持可以追溯到 80286 芯片。并发是在同一台计算机上同时执行多个进程。在较老的处理器中,并发是通过操作系统内核的上下文切换来实现的。当并发部分由硬件并行执行而不是仅仅切换上下文时,这被称为并行性。并行性是硬件的特性,尽管软件堆栈必须支持它,以便你在程序中利用它。我们必须以并发的方式编写程序,以利用硬件的并行性特性。

虽然并发是利用硬件并行性和加快操作的自然方式,但值得记住的是,拥有比你的硬件支持的并行性显著更高的并发性可能会导致任务调度到不同的处理器核心,从而降低分支预测并增加缓存未命中。

在低级别上,用于并发的进程/线程、互斥锁、信号量、锁定、共享内存和进程间通信是通过创建进程/线程、互斥锁、信号量、锁定、共享内存和进程间通信来实现的。JVM 对这些并发原语和线程间通信提供了出色的支持。Clojure 既有低级也有高级并发原语,我们将在并发章节中讨论。

资源利用率

资源利用率是衡量应用程序消耗的服务器、网络和存储资源的度量。资源包括 CPU、内存、磁盘 I/O、网络 I/O 等。可以从 CPU 密集型、内存密集型、缓存密集型和 I/O 密集型任务的角度分析应用程序。资源利用率可以通过基准测试、在给定吞吐量下测量利用率来得出。

工作负载

工作负载是指应用程序需要完成的工作量。它通过总用户数、并发活跃用户、交易量、数据量等来衡量。处理工作负载时应考虑负载条件,例如数据库当前持有的数据量、消息队列的填充程度、I/O 任务的积压情况以及更多。

每个程序员都应该知道的延迟数字

随着时间的推移,硬件和软件都取得了进步。各种操作的延迟使事情有了新的视角。以下表格展示了 2015 年的延迟数字,经加州大学伯克利分校的 Aurojit Panda 和 Colin Scott 允许复制(www.eecs.berkeley.edu/~rcs/research/interactive_latency.html)。每个程序员都应该知道的延迟数字如下表所示:

操作 2015 年所需时间
L1 缓存引用 1ns(纳秒)
分支预测错误 3 ns
L2 缓存引用 4 ns
互斥锁/解锁 17 ns
使用 Zippy(Zippy/Snappy:code.google.com/p/snappy/) 压缩 1KB 2μs(1000 ns = 1μs:微秒)
在商品网络上发送 2000 字节 200ns(即 0.2μs)
SSD 随机读取 16 μs
同一数据中心内的往返 500 μs
从 SSD 顺序读取 1,000,000 字节 200 μs
磁盘寻道 4 ms(1000 μs = 1 ms)
从磁盘顺序读取 1,000,000 字节 2 ms
从 CA 到荷兰的数据包往返 150 ms

前面的表格显示了计算机中的操作及其引起的延迟。当 CPU 核在 CPU 寄存器中处理一些数据时,它可能需要几个 CPU 循环(以 3 GHz CPU 为例,每纳秒运行 3000 个循环),但一旦它必须回退到 L1 或 L2 缓存,延迟就会慢数千倍。前面的表格没有显示主内存访问延迟,这大约是 100 纳秒(根据访问模式而变化)——大约是 L2 缓存的 25 倍慢。

摘要

我们学习了深入思考性能的基础知识。我们了解了常见的性能词汇,以及性能方面可能变化的用例。通过查看不同硬件组件的性能数据,我们得出了性能优势如何传递到我们的应用中的结论。在下一章中,我们将深入探讨各种 Clojure 抽象的性能方面。

第二章:Clojure 抽象

Clojure 有四个基本理念。首先,它被建立为一个函数式语言。它不是纯函数式(如纯粹函数式),但强调不可变性。其次,它是一种 Lisp 方言;Clojure 足够灵活,用户可以在不等待语言实现者添加新特性和结构的情况下扩展语言。第三,它是为了利用并发来应对新一代挑战而构建的。最后,它被设计为托管语言。截至目前,Clojure 实现存在于 JVM、CLR、JavaScript、Python、Ruby 和 Scheme 上。Clojure 与其宿主语言无缝融合。

Clojure 丰富的抽象。尽管语法本身非常简洁,但抽象是细粒度的、大部分可组合的,并且旨在以最简单的方式解决广泛的问题。在本章中,我们将讨论以下主题:

  • 非数值标量的性能特征

  • 不变性以及纪元时间模型通过隔离铺平了性能之路

  • 持久数据结构和它们的性能特征

  • 惰性及其对性能的影响

  • 临时对象作为高性能、短期逃逸通道

  • 其他抽象,如尾递归、协议/类型、多方法等

非数值标量与池化

Clojure 中的字符串和字符与 Java 中的相同。字符串字面量是隐式池化的。池化是一种只存储唯一值在堆中并在需要的地方共享引用的方法。根据 JVM 供应商和您使用的 Java 版本,池化数据可能存储在字符串池、Permgen、普通堆或堆中标记为池化数据的一些特殊区域。当不使用时,池化数据会像普通对象一样受到垃圾回收。请看以下代码:

user=> (identical? "foo" "foo")  ; literals are automatically interned
true
user=> (identical? (String. "foo") (String. "foo"))  ; created string is not interned
false
user=> (identical? (.intern (String. "foo")) (.intern (String. "foo")))
true
user=> (identical? (str "f" "oo") (str "f" "oo"))  ; str creates string
false
user=> (identical? (str "foo") (str "foo"))  ; str does not create string for 1 arg
true
user=> (identical? (read-string "\"foo\"") (read-string "\"foo\""))  ; not interned
false
user=> (require '[clojure.edn :as edn])  ; introduced in Clojure 1.5
nil
user=> (identical? (edn/read-string "\"foo\"") (edn/read-string "\"foo\""))
false

注意,Clojure 中的 identical? 与 Java 中的 == 相同。字符串池化的好处是没有重复字符串的内存分配开销。通常,在 JVM 上的应用程序在字符串处理上花费相当多的时间。因此,当有机会同时处理重复字符串时,将它们池化是有意义的。今天的大多数 JVM 实现都有一个非常快速的池化操作;然而,如果您有较旧的版本,您应该测量 JVM 的开销。

字符串池化的另一个好处是,当你知道两个字符串标记被池化时,你可以使用 identical? 比非池化字符串标记更快地比较它们是否相等。等价函数 = 首先检查引用是否相同,然后再进行内容检查。

Clojure 中的符号总是包含池化字符串引用,因此从给定字符串生成符号的速度几乎与池化字符串一样快。然而,从同一字符串创建的两个符号不会相同:

user=> (identical? (.intern "foo") (.intern "foo"))
true
user=> (identical? (symbol "foo") (symbol "foo"))
false
user=> (identical? (symbol (.intern "foo")) (symbol (.intern "foo")))
false

关键字基于其实现建立在符号之上,并设计为与identical?函数一起用于等价性。因此,使用identical?比较关键字以进行相等性检查将更快,就像池化的字符串标记一样。

Clojure 越来越被用于大量数据处理,这包括文本和复合数据结构。在许多情况下,数据要么以 JSON 或 EDN(edn-format.org)的形式存储。在处理此类数据时,您可以通过字符串池化或使用符号/关键字来节省内存。请记住,从这种数据中读取的字符串标记不会被自动池化,而从中读取的符号和关键字则必然会被池化。在处理关系型或 NoSQL 数据库、Web 服务、CSV 或 XML 文件、日志解析等情况时,您可能会遇到这种情况。

池化与 JVM 的垃圾回收(GC)相关联,而垃圾回收反过来又与性能密切相关。当您不池化字符串数据并允许重复存在时,它们最终会在堆上分配。更多的堆使用会导致 GC 开销增加。池化字符串会有微小但可测量且即时的性能开销,而 GC 通常是不可预测且不清晰的。在大多数 JVM 实现中,GC 性能并没有像硬件性能提升那样以相似的比例增长。因此,通常,有效的性能取决于防止 GC 成为瓶颈,这在大多数情况下意味着最小化它。

身份、值和时态时间模型

Clojure 的一个主要优点是其简单的设计,这导致了可塑性强、美观的组合性。用符号代替指针是一种存在了几十年的编程实践。它已在几种命令式语言中得到广泛应用。Clojure 剖析了这个概念,以揭示需要解决的核心问题。以下小节将说明 Clojure 的这一方面。

我们使用逻辑实体来表示值。例如,30这个值如果没有与逻辑实体关联,比如age,就没有意义。这里的逻辑实体age是身份。现在,尽管age代表一个值,但这个值可能会随时间变化;这引出了状态的概念,它代表在某个时间点的身份值。因此,状态是时间的函数,并且与我们在程序中执行的操作有因果关系。Clojure 的力量在于将身份与其在特定时间保持为真的值绑定在一起,而身份保持与它可能后来代表的任何新值隔离。我们将在第五章 并发 中讨论状态管理。

变量和修改

如果你之前使用过命令式语言(如 C/C++、Java 等),你可能对变量的概念很熟悉。变量是对内存块的一个引用。当我们更新其值时,我们实际上是在更新存储值的内存位置。变量继续指向存储旧版本值的那个位置。因此,本质上,变量是值存储位置的别名。

有一点分析可以揭示,变量与读取或突变其值的进程紧密相关。每一次突变都是一个状态转换。读取/更新变量的进程应该了解变量的可能状态,以便理解状态。你能在这里看到问题吗?它混淆了身份和状态!在处理变量时,在时间上引用一个值或状态是不可能的——除非你完全控制访问它的进程,否则值可能会随时改变。可变模型不适应导致其状态转换的时间概念。

可变性的问题并不止于此。当你有一个包含可变变量的复合数据结构时,整个数据结构就变得可变了。我们如何在不破坏可能正在观察它的其他进程的情况下突变它?我们如何与并发进程共享这个数据结构?我们如何将这个数据结构用作哈希表中的键?这个数据结构什么也没传达。它的意义可能会随着突变而改变!我们如何在不补偿可能以不同方式突变它的时间的情况下将这样的事物发送给另一个进程?

不变性是函数式编程的一个重要原则。它不仅简化了编程模型,还为安全性和并发性铺平了道路。Clojure 在整个语言中支持不变性。Clojure 还支持快速、以突变为导向的数据结构,以及通过并发原语实现线程安全的状态管理。我们将在接下来的章节中讨论这些主题。

集合类型

Clojure 中有几种集合类型,这些类型根据其属性进行分类。以下维恩图根据集合是否计数(counted?返回true)、是否关联(associative?返回true)或顺序(sequential?返回true)来描述这种分类:

集合类型

之前的图展示了不同类型的数据结构所共有的特征。顺序结构允许我们对集合中的项目进行迭代,计数结构的项数可以随时间保持不变,关联结构可以通过键来查看相应的值。"CharSequence"框显示了 Java 类型字符序列,可以使用seq charseq将其转换为 Clojure 序列。

持久性数据结构

正如我们在上一节中注意到的,Clojure 的数据结构不仅不可变,而且可以在不影响旧版本的情况下产生新值。操作以这种方式产生新值,使得旧值仍然可访问;新版本的产生符合该数据结构的复杂度保证,并且旧版本和新版本继续满足复杂度保证。这些操作可以递归应用,并且仍然可以满足复杂度保证。Clojure 提供的这种不可变数据结构被称为 持久数据结构。它们是“持久”的,即当创建新版本时,旧版本和新版本在值和复杂度保证方面都“持续”存在。这与数据的存储或持久性无关。修改旧版本不会妨碍使用新版本,反之亦然。两个版本都以类似的方式持续存在。

在启发 Clojure 持久数据结构实现的出版物中,其中两本尤为知名。Chris Okasaki 的 纯函数式数据结构 对持久数据结构和惰性序列/操作的实施产生了影响。Clojure 的持久队列实现是从 Okasaki 的 批处理队列 中改编而来的。Phil Bagwell 的 理想哈希树,虽然旨在用于可变和命令式数据结构,但被改编以实现 Clojure 的持久映射/向量/集合。

构建较少使用的数据结构

Clojure 支持一种著名的字面量语法,用于创建列表、向量、集合和映射。以下列表展示了创建其他数据结构的一些较少使用的方法:

  • 映射 (PersistentArrayMapPersistentHashMap):

    {:a 10 :b 20}  ; array-map up to 8 pairs
    {:a 1 :b 2 :c 3 :d 4 :e 5 :f 6 :g 7 :h 8 :i 9}  ; hash-map for 9 or more pairs
    
  • 排序映射 (PersistentTreeMap):

    (sorted-map :a 10 :b 20 :c 30)  ; (keys ..) should return sorted
    
  • 排序集合 (PersistentTreeSet):

    (sorted-set :a :b :c)
    
  • 队列 (PersistentQueue):

    (import 'clojure.lang.PersistentQueue)
    (reduce conj PersistentQueue/EMPTY [:a :b :c :d])  ; add to queue
    (peek queue)  ; read from queue
    (pop queue)  ; remove from queue
    

如您所见,如 TreeMap(按键排序)、TreeSet(按元素排序)和 Queue 这样的抽象应该通过调用它们各自的 API 来实例化。

复杂度保证

以下表格总结了 Clojure 中各种持久数据结构的复杂度保证(使用大 O 表示法):

操作 PersistentList PersistentHashMap PersistentArrayMap PersistentVector PersistentQueue PersistentTreeMap
count O(1) O(1) O(1) O(1) O(1) O(1)
conj O(1) O(1) O(1)
first O(1) O(<7) O(<7)
rest O(1) O(<7) O(<7)
doseq O(n) O(n) O(n) O(n) O(n)
nth O(n) O(<7) O(<7)
last O(n) O(n) O(n)
get O(<7) O(1) O(<7) O(<7) O(log n)
assoc O(<7) O(1) O(<7) O(log n)
dissoc O(<7) O(1) O(<7) O(log n)
peek O(1) O(1)
pop O(<7) O(1)

列表是一种顺序数据结构。它为计数和仅涉及第一个元素的操作提供常数时间访问。例如,conj将元素添加到头部并保证O(1)复杂度。同样,firstrest也提供O(1)保证。其他所有操作都提供O(n)复杂度保证。

持久哈希映射和向量在底层使用 32 个分支因子的 trie 数据结构。因此,尽管复杂度是O(log [32] n),但只有 2³²个哈希码可以放入 trie 节点中。因此,log[32] 2³²,结果是6.4,小于7,是最坏情况下的复杂度,可以认为是接近常数时间。随着 trie 的增长,由于结构共享,需要复制的部分成比例地变得很小。持久哈希集实现也是基于哈希映射的;因此,哈希集共享哈希映射的特性。在持久向量中,最后一个不完整的节点放在尾部,这总是可以从根直接访问。这使得使用conj到末尾的操作是常数时间操作。

持久树映射和树集基本上分别是排序映射和排序集。它们的实现使用红黑树,通常比哈希映射和哈希集更昂贵。持久队列在底层使用持久向量来添加新元素。从持久队列中移除元素会从向量中移除头部,该向量是从添加新元素的位置创建的。

算法在数据结构上的复杂度并不是其性能的绝对度量。例如,使用哈希映射涉及计算 hashCode,这不包括在复杂度保证中。我们应该根据实际用例选择数据结构。例如,何时应该使用列表而不是向量?可能是在需要顺序或后进先出LIFO)访问时,或者当为函数调用构造抽象语法树AST)时。

O(<7)表示接近常数时间

你可能知道,大 O符号用于表示任何算法效率的上界(最坏情况)。变量n用于表示算法中的元素数量。例如,在排序关联集合上进行的二分搜索,如排序向量,是对数时间,即O(log [2] n)或简单地O(log n)算法。由于 Java 集合中最多可以有 2³²(技术上由于有符号正整数是 2³¹)个元素,log[2] 2³²是 32,因此二分搜索在最坏情况下可以是O(≤32)。同样,尽管持久集合的操作是 O(log[32] n),但在最坏情况下实际上最多是 O(log[32] 2³²),这是O(<7)。请注意,这比对数时间低得多,接近常数时间。这意味着即使在最坏的情况下,持久集合的性能也不是很糟糕。

持久数据结构的连接

尽管持久数据结构具有出色的性能特性,但两个持久数据结构的连接操作一直是一个线性时间 O(N) 操作,除了最近的一些发展。截至 Clojure 1.7,concat 函数仍然提供线性时间连接。在 core.rrb-vector 贡献项目中进行的 Relaxed Radix Balanced (RRB) 树的实验工作正在进行中(github.com/clojure/core.rrb-vector),这可能提供对数时间 O(log N) 连接。对细节感兴趣的读者应参考以下链接:

序列和懒性

"A seq is like a logical cursor."
--Rich Hickey

序列(通常称为 seqs)是按顺序消费一系列数据的方式。与迭代器一样,它们允许用户从头部开始消费元素,并逐个实现一个元素。然而,与迭代器不同,序列是不可变的。此外,由于序列只是底层数据的视图,它们不会修改数据的存储结构。

使序列与众不同的地方在于它们本身不是数据结构;相反,它们是对数据流的数据抽象。数据可能由算法或与 I/O 操作连接的数据源产生。例如,resultset-seq 函数接受一个 java.sql.ResultSet JDBC 实例作为参数,并以 seq 的形式产生懒实现的行数据。

Clojure 数据结构可以通过 seq 函数转换为序列。例如,(seq [:a :b :c :d])返回一个序列。对空集合调用 seq 返回 nil。

序列可以被以下函数消费:

  • first:此函数返回序列的头部

  • rest:此函数返回移除头部后的剩余序列,即使它是空的

  • next:此函数返回移除头部后的剩余序列或 nil,如果它是空的

懒性

Clojure 是一种严格的(即“懒”的对立面)语言,可以在需要时显式地利用懒性。任何人都可以使用 lazy-seq 宏创建一个懒评估序列。一些 Clojure 对集合的操作,如 mapfilter 等,都是有意为之的懒操作。

惰性简单来说就是值只有在实际需要时才会被计算。一旦值被计算,它就会被缓存起来,这样任何对值的未来引用都不需要重新计算它。值的缓存称为 记忆化。惰性和记忆化常常是相辅相成的。

数据结构操作中的惰性

惰性和记忆化结合在一起形成了一种极其有用的组合,可以保持函数式算法的单线程性能与其命令式对应物相当。例如,考虑以下 Java 代码:

List<String> titles = getTitles();
int goodCount = 0;
for (String each: titles) {
  String checksum = computeChecksum(each);
  if (verifyOK(checksum)) {
    goodCount++;
  }
}

如前文片段所示,它具有线性时间复杂度,即 O(n),整个操作都在单次遍历中完成。相应的 Clojure 代码如下:

(->> (get-titles)
  (map compute-checksum)
  (filter verify-ok?)
  count)

现在,既然我们知道 mapfilter 是惰性的,我们可以推断出 Clojure 版本也具有线性时间复杂度,即 O(n),并且可以在一次遍历中完成任务,没有显著的内存开销。想象一下,如果 mapfilter 不是惰性的,那么复杂度会是什么?它需要多少次遍历?不仅仅是 map 和 filter 都会各自进行一次遍历,即 O(n),每个;在最坏的情况下,它们各自会占用与原始集合一样多的内存,因为需要存储中间结果。

在强调不可变性的函数式语言如 Clojure 中了解惰性和记忆化的价值非常重要。它们是持久数据结构中 摊销 的基础,这涉及到关注复合操作的整体性能,而不是微观分析其中每个操作的性能;操作被调整以在最重要的操作中更快地执行。

另一个重要的细节是,当惰性序列被实现时,数据会被记忆化并存储。在 JVM 上,所有以某种方式可达的堆引用都不会被垃圾回收。因此,结果就是,整个数据结构除非你失去了序列的头部,否则会一直保留在内存中。当使用局部绑定处理惰性序列时,确保你不会从任何局部引用惰性序列。当编写可能接受惰性序列(s)的函数时,注意任何对惰性 seq 的引用都不应该超出函数执行的生存期,形式为闭包或其他。

构建惰性序列

现在我们知道了惰性序列是什么,让我们尝试创建一个重试计数器,它应该只在重试可以进行时返回 true。这如下面的代码所示:

(defn retry? [n]
  (if (<= n 0)
    (cons false (lazy-seq (retry? 0)))
    (cons true (lazy-seq (retry? (dec n))))))

lazy-seq 宏确保栈不会被用于递归。我们可以看到这个函数会返回无限多的值。因此,为了检查它返回的内容,我们应该限制元素的数量,如下面的代码所示:

user=> (take 7 (retry? 5))
(true true true true true false false)

现在,让我们尝试以模拟的方式使用它:

(loop [r (retry? 5)]
  (if-not (first r)
    (println "No more retries")
    (do
      (println "Retrying")
      (recur (rest r)))))

如预期,输出应该打印 Retrying 五次,然后打印 No more retries 并退出,如下所示:

Retrying
Retrying
Retrying
Retrying
Retrying
No more retries
nil

让我们再举一个更简单的例子来构建一个惰性序列,它可以从指定的数字倒数到零:

(defn count-down [n]
  (if (<= n 0)
    '(0)
    (cons n (lazy-seq (count-down (dec n))))))

我们可以如下检查它返回的值:

user=> (count-down 8)
(8 7 6 5 4 3 2 1 0)

惰性序列可以无限循环而不会耗尽栈空间,当与其他惰性操作一起工作时非常有用。为了在节省空间和性能之间保持平衡,消耗惰性序列会导致元素以 32 的倍数进行分块。这意味着尽管惰性序列是按顺序消耗的,但它们是以 32 个元素为一个块来实现的。

自定义分块

默认块大小 32 可能不是所有惰性序列的最佳选择——当你需要时可以覆盖分块行为。考虑以下代码片段(改编自 Kevin Downey 在gist.github.com/hiredman/324145的公开 gist):

(defn chunked-line-seq
  "Returns the lines of text from rdr as a chunked[size] sequence of strings.
  rdr must implement java.io.BufferedReader."
  [^java.io.BufferedReader rdr size]
  (lazy-seq
    (when-let [line (.readLine rdr)]
      (chunk-cons
        (let [buffer (chunk-buffer size)]
          (chunk-append buffer line)
          (dotimes [i (dec size)]
            (when-let [line (.readLine rdr)]
              (chunk-append buffer line)))
  (chunk buffer))
(chunked-line-seq rdr size)))))

根据前面的代码片段,用户可以传递一个块大小,该大小用于生成惰性序列。在处理大型文本文件时,例如处理 CSV 或日志文件时,较大的块大小可能很有用。你会在代码片段中注意到以下四个不太为人所知的功能:

  • clojure.core/chunk-cons

  • clojure.core/chunk-buffer

  • clojure.core/chunk-append

  • clojure.core/chunk

虽然chunk-cons是针对分块序列的clojure.core/cons的等价物,但chunk-buffer创建了一个可变的块缓冲区(控制块大小),chunk-append将一个项目追加到可变块缓冲区的末尾,而chunk将可变块缓冲区转换为不可变块。

clojure.core命名空间中列出了与分块序列相关的几个函数,如下所示:

  • chunk

  • chunk-rest

  • chunk-cons

  • chunk-next

  • chunk-first

  • chunk-append

  • chunked-seq?

  • chunk-buffer

这些函数没有文档说明,所以我鼓励你研究它们的源代码以了解它们的功能,但我建议你不要对未来 Clojure 版本中它们的支持做出任何假设。

宏和闭包

通常,我们定义一个宏,以便将代码的参数体转换为闭包并将其委托给函数。请看以下示例:

(defmacro do-something
  [& body]
  `(do-something* (fn [] ~@body)))

当使用这样的代码时,如果主体将一个局部变量绑定到一个惰性序列,它可能比必要的保留时间更长,这可能会对内存消耗和性能产生不良影响。幸运的是,这可以很容易地修复:

(defmacro do-something
  [& body]
  `(do-something* (^:once fn* [] ~@body)))

注意^:once提示和fn*宏,这使得 Clojure 编译器清除闭包引用,从而避免了这个问题。让我们看看它是如何工作的(来自 Alan Malloy 的groups.google.com/d/msg/clojure/Ys3kEz5c_eE/3St2AbIc3zMJ的示例):

user> (let [x (for [n (range)] (make-array Object 10000))
      f (^:once fn* [] (nth x 1e6))]  ; using ^:once
        (f))
#<Object[] [Ljava.lang.Object;@402d3105>
user> (let [x (for [n (range)] (make-array Object 10000))
            f (fn* [] (nth x 1e6))]         ; not using ^:once
        (f))
OutOfMemoryError GC overhead limit exceeded

前一个条件的体现取决于可用的堆空间。这个问题很难检测,因为它只会引发 OutOfMemoryError,这很容易被误解为堆空间问题而不是内存泄漏。作为预防措施,我建议在所有关闭任何可能懒加载序列的情况下使用 ^:oncefn*

转换器

Clojure 1.7 引入了一个名为转换器的新抽象,用于“可组合算法转换”,通常用于在集合上应用一系列转换。转换器的想法源于累加函数,它接受形式为 (result, input) 的参数并返回 result。累加函数是我们通常与 reduce 一起使用的函数。转换器接受一个累加函数,将其功能包装/组合以提供额外的功能,并返回另一个累加函数。

处理集合的 clojure.core 中的函数已经获得了一个 arity-1 变体,它返回一个转换器,即 mapcatmapcatfilterremovetaketake-whiletake-nthdropdrop-whilereplacepartition-bypartition-allkeepkeep-indexeddeduperandom-sample

考虑以下几个例子,它们都做了相同的事情:

user=> (reduce ((filter odd?) +) [1 2 3 4 5])
9
user=> (transduce (filter odd?) + [1 2 3 4 5])
9
user=> (defn filter-odd? [xf]
         (fn
           ([] (xf))
           ([result] (xf result))
           ([result input] (if (odd? input)
                               (xf result input)
                               result))))
#'user/filter-odd?
user=> (reduce (filter-odd? +) [1 2 3 4 5])
9

在这里,(filter odd?) 返回一个转换器——在第一个例子中,转换器将累加函数 + 包装起来,以返回另一个组合的累加函数。虽然我们在第一个例子中使用了普通的 reduce 函数,但在第二个例子中,我们使用了接受转换器作为参数的 transduce 函数。在第三个例子中,我们编写了一个转换器 filter-odd?,它模拟了 (filter odd?) 的行为。让我们看看传统版本和转换器版本之间的性能差异:

;; traditional way
user=> (time (dotimes [_ 10000] (reduce + (filter odd? (range 10000)))))
"Elapsed time: 2746.782033 msecs"
nil
;; using transducer
(def fodd? (filter odd?))
user=> (time (dotimes [_ 10000] (transduce fodd? + (range 10000))))
"Elapsed time: 1998.566463 msecs"
nil

性能特性

转换器背后的关键点在于每个转换可以允许多么正交,同时又能高度可组合。同时,转换可以在整个序列上同步发生,而不是每个操作都产生懒加载的块序列。这通常会给转换器带来显著的性能优势。当最终结果太大而无法一次性实现时,懒加载序列仍然会很有用——对于其他用例,转换器应该能够适当地满足需求并提高性能。由于核心函数已经被彻底改造以与转换器一起工作,因此经常用转换器来建模转换是有意义的。

瞬态

在本章前面,我们讨论了不可变性的优点和可变性的陷阱。然而,尽管可变性在本质上是不安全的,但它也具有非常好的单线程性能。现在,如果有一种方法可以限制局部上下文中的可变操作,以提供安全保证,那将等同于结合性能优势和局部安全保证。这正是 Clojure 提供的称为 transients 的抽象。

首先,让我们验证它是安全的(仅限于 Clojure 1.6):

user=> (let [t (transient [:a])]
  @(future (conj! t :b)))
IllegalAccessError Transient used by non-owner thread  clojure.lang.PersistentVector$TransientVector.ensureEditable (PersistentVector.java:463)

如前所述,直到 Clojure 1.6,一个线程中创建的 transient 不能被另一个线程访问。然而,在 Clojure 1.7 中允许这种操作,以便 transducers 能够与 core.async (github.com/clojure/core.async) 库良好地协作——开发者应在跨线程上保持 transients 的操作一致性:

user=> (let [t (transient [:a])] (seq t))

IllegalArgumentException Don't know how to create ISeq from: clojure.lang.PersistentVector$TransientVector  clojure.lang.RT.seqFrom (RT.java:505)

因此,transients 不能转换为 seqs。因此,它们不能参与新持久数据结构的生成,并且会从执行范围中泄漏出来。考虑以下代码:

(let [t (transient [])]
  (conj! t :a)
  (persistent! t)
  (conj! t :b))
IllegalAccessError Transient used after persistent! call  clojure.lang.PersistentVector$TransientVector.ensureEditable (PersistentVector.java:464)

persistent! 函数永久地将 transient 转换为等效的持久数据结构。实际上,transients 只能用于一次性使用。

persistenttransient 数据结构之间(transientpersistent! 函数)的转换是常数时间,即它是一个 O(1) 操作。transients 只能从未排序的映射、向量和集合中创建。修改 transients 的函数有:conj!disj!pop!assoc!dissoc!。只读操作,如 getnthcount 等,在 transients 上按常规工作,但像 contains? 和那些暗示 seqs 的函数,如 firstrestnext,则不工作。

快速重复

函数 clojure.core/repeatedly 允许我们多次执行一个函数,并产生一个结果的惰性序列。Peter Taoussanis 在他的开源序列化库 Nippy (github.com/ptaoussanis/nippy) 中编写了一个对 transient 有意识的变体,其性能显著提高。它在他的许可下被复制,如下所示(注意,该函数的参数数量与 repeatedly 不同):

(defn repeatedly*
  "Like `repeatedly` but faster and returns given collection type."
  [coll n f]
  (if-not (instance? clojure.lang.IEditableCollection coll)
    (loop [v coll idx 0]
      (if (>= idx n)
        v
        (recur (conj v (f)) (inc idx))))
    (loop [v (transient coll) idx 0]
      (if (>= idx n)
        (persistent! v)
        (recur (conj! v (f)) (inc idx))))))

性能杂项

除了本章前面看到的重大抽象之外,Clojure 还有其他一些较小但同样非常关键的性能部分,我们将在本节中看到。

在生产环境中禁用断言

断言在开发过程中捕获代码中的逻辑错误非常有用,但它们在生产环境中可能会带来运行时开销,你可能想避免。由于 assert 是一个编译时变量,断言可以通过将 assert 绑定到 false 或在代码加载之前使用 alter-var-root 来静默。不幸的是,这两种技术都很难使用。Paul Stadig 的名为 assertions 的库(github.com/pjstadig/assertions)通过通过命令行参数 -ea 到 Java 运行时启用或禁用断言来帮助解决这个特定用例。

要使用它,你必须将其包含在你的 Leiningen project.clj 文件中作为依赖项:

:dependencies [;; other dependencies…
                            [pjstadig/assertions "0.1.0"]]

你必须使用这个库的 assert 宏而不是 Clojure 自己的,因此应用程序中的每个 ns 块都应该类似于以下内容:

(ns example.core

  (:refer-clojure :exclude [assert])

  (:require [pjstadig.assertions :refer [assert]]))

在运行应用程序时,你应该将 -ea 参数包含到 JRE 中,以启用断言,而排除它则意味着在运行时没有断言:

$ JVM_OPTS=-ea lein run -m example.core
$ java -ea -jar example.jar

注意,这种用法不会自动避免依赖库中的断言。

解构

解构 是 Clojure 的内置迷你语言之一,并且可以说是开发期间的一个顶级生产力提升器。这个特性导致了将值解析以匹配绑定形式的左侧。绑定形式越复杂,需要完成的工作就越多。不出所料,这会有一点性能开销。

通过使用显式函数在紧密循环和其他性能关键代码中解开数据,可以轻松避免这种开销。毕竟,这一切都归结于让程序工作得少,做得多。

递归和尾调用优化(TCO)

函数式语言有与递归相关的尾调用优化概念。所以,想法是当递归调用处于尾位置时,它不会占用递归的栈空间。Clojure 支持一种用户辅助递归调用形式,以确保递归调用不会耗尽栈空间。这有点像命令式循环,但速度非常快。

在执行计算时,在紧密循环中使用 loop-recur 而不是迭代合成数字可能非常有意义。例如,我们想要将 0 到 1,000,000 之间的所有奇数相加。让我们比较一下代码:

(defn oddsum-1 [n]  ; using iteration
  (->> (range (inc n))
    (filter odd?)
    (reduce +)))
(defn oddsum-2 [n]  ; using loop-recur
  (loop [i 1 s 0]
    (if (> i n)
      s
      (recur (+ i 2) (+ s i)))))

当我们运行代码时,我们得到了有趣的结果:

user=> (time (oddsum-1 1000000))
"Elapsed time: 109.314908 msecs"

250000000000
user=> (time (oddsum-2 1000000))
"Elapsed time: 42.18116 msecs"

250000000000

time 宏作为性能基准工具远非完美,但相对数值表明了一种趋势——在随后的章节中,我们将探讨 Criterium 库进行更科学的基准测试。在这里,我们使用 loop-recur 不仅是为了更快地迭代,而且我们还能通过只迭代大约其他示例一半的次数来改变算法本身。

迭代提前结束

在对集合进行累积时,在某些情况下,我们可能希望提前结束。在 Clojure 1.5 之前,loop-recur是唯一的方法。当使用reduce时,我们可以使用 Clojure 1.5 中引入的reduced函数来完成,如下所示:

;; let coll be a collection of numbers
(reduce (fn ([x] x) ([x y] (if (or (zero? x) (zero? y)) (reduced 0) (* x y))))
             coll)

在这里,我们乘以集合中的所有数字,并在找到任何数字为零时,立即返回结果零,而不是继续到最后一个元素。

函数reduced?有助于检测何时返回了减少值。Clojure 1.7 引入了ensure-reduced函数,用于将非减少值装箱为减少值。

多态方法与协议

多态方法是一种出色的多态分派表达式抽象,其分派函数的返回值用于分派。与多态方法相关联的分派函数在运行时维护,并在每次调用多态方法时查找。虽然多态方法在确定分派方面提供了很多灵活性,但与协议实现相比,性能开销实在太高。

协议(defprotocol)在 Clojure 中通过reify、记录(defrecord)和类型(deftypeextend-type)实现。这是一个大讨论话题——既然我们在讨论性能特征,那么只需说协议实现基于多态类型进行分派,并且比多态方法快得多就足够了。协议和类型通常是 API 的实现细节,因此它们通常由函数来呈现。

由于多态方法的灵活性,它们仍然有其位置。然而,在性能关键代码中,建议使用协议、记录和类型。

内联

宏在调用位置内联展开并且避免了函数调用。因此,存在一定的性能优势。还有一个definline宏,允许你像正常宏一样编写函数。它创建了一个实际函数,在调用位置内联:

(def PI Math/PI)
(definline circumference [radius]
  `(* 2 PI ~radius))

注意

注意,JVM 也会分析它运行的代码,并在运行时进行代码内联。虽然你可以选择内联热点函数,但这种技术仅能提供适度的性能提升。

当我们定义一个var对象时,其值在每次使用时都会被查找。当我们使用指向longdouble值的:const元数据定义var对象时,它将从调用位置内联:

(def ^:const PI Math/PI)

这在适用时众所周知可以提供不错的性能提升。请看以下示例:

user=> (def a 10)
user=> (def ^:const b 10)
user=> (def ^:dynamic c 10)
user=> (time (dotimes [_ 100000000] (inc a)))
"Elapsed time: 1023.745014 msecs"
nil
user=> (time (dotimes [_ 100000000] (inc b)))
"Elapsed time: 226.732942 msecs"
nil
user=> (time (dotimes [_ 100000000] (inc c)))
"Elapsed time: 1094.527193 msecs"
nil

摘要

性能是 Clojure 设计的基础之一。Clojure 中的抽象设计用于简单性、强大性和安全性,同时始终考虑性能。我们看到了各种抽象的性能特征,以及如何根据性能用例做出抽象决策。

在下一章中,我们将看到 Clojure 如何与 Java 交互,以及我们如何提取 Java 的力量以获得最佳性能。

第三章。依赖 Java

由于托管在 JVM 上,Clojure 有几个方面真正有助于理解 Java 语言和平台。这种需求不仅是因为与 Java 的互操作性或理解其实现,还因为性能原因。在某些情况下,Clojure 默认可能不会生成优化的 JVM 字节码;在另一些情况下,你可能希望超越 Clojure 数据结构提供的性能——你可以通过 Clojure 使用 Java 替代方案来获得更好的性能。本章将讨论这些 Clojure 的方面。在本章中,我们将讨论:

  • 检查从 Clojure 源代码生成的 Java 和字节码

  • 数值和原始类型

  • 操作数组

  • 反射和类型提示

检查 Clojure 代码的等效 Java 源代码

检查给定 Clojure 代码的等效 Java 源代码可以提供对其性能可能产生影响的深刻见解。然而,除非我们将命名空间编译到磁盘上,否则 Clojure 仅在运行时生成 Java 字节码。当使用 Leiningen 进行开发时,只有 project.clj 文件中 :aot 向量下的选定命名空间被输出为包含字节码的编译 .class 文件。幸运的是,有一个简单快捷的方法可以知道 Clojure 代码的等效 Java 源代码,那就是 AOT 编译命名空间,然后使用 Java 字节码反编译器将字节码反编译成等效的 Java 源代码。

有多种商业和开源的 Java 字节码反编译器可用。我们将在这里讨论的一个开源反编译器是 JD-GUI,你可以从其网站下载(jd.benow.ca/#jd-gui)。使用适合你操作系统的版本。

创建一个新项目

让我们看看如何从 Clojure 代码精确地得到等效的 Java 源代码。使用 Leiningen 创建一个新的项目:lein new foo。然后编辑 src/foo/core.clj 文件,添加一个 mul 函数来找出两个数字的乘积:

(ns foo.core)

(defn mul [x y]
  (* x y))

将 Clojure 源代码编译成 Java 字节码

现在,要编译 Clojure 源代码成字节码并将它们输出为 .class 文件,运行 lein compile :all 命令。它将在项目的 target/classes 目录下创建 .class 文件,如下所示:

target/classes/
`-- foo
    |-- core$fn__18.class
    |-- core__init.class
    |-- core$loading__4910__auto__.class
    `-- core$mul.class

你可以看到 foo.core 命名空间已经被编译成了四个 .class 文件。

将 .class 文件反编译成 Java 源代码

假设你已经安装了 JD-GUI,反编译 .class 文件就像使用 JD-GUI 应用程序打开它们一样简单。

将 .class 文件反编译成 Java 源代码

检查 foo.core/mul 函数的代码如下:

package foo;

import clojure.lang.AFunction;
import clojure.lang.Numbers;
import clojure.lang.RT;
import clojure.lang.Var;

public final class core$mul extends AFunction
{
  public static final Var const__0 = (Var)RT.var("clojure.core", "*");

  public Object invoke(Object x, Object y) { x = null; y = null; return Numbers.multiply(x, y);
  }
}

从反编译的 Java 源代码中很容易理解,foo.core/mul 函数是 foo 包中 core$mul 类的一个实例,该类扩展了 clojure.lang.AFunction 类。我们还可以看到,在方法调用(Object, Object)中,参数类型是 Object 类型,这意味着数字将被装箱。以类似的方式,你可以反编译任何 Clojure 代码的类文件来检查等效的 Java 代码。如果你能结合对 Java 类型以及可能的反射和装箱的了解,你就可以找到代码中的次优位置,并专注于需要改进的地方。

在没有本地清除的情况下编译 Clojure 源代码

注意方法调用中的 Java 代码,其中说 x = null; y = null; ——代码是如何丢弃参数、将它们设置为 null 并有效地将两个 null 对象相乘的呢?这种误导性的反编译是由于本地清除引起的,这是 Clojure JVM 字节码实现的一个特性,在 Java 语言中没有等效功能。

从 Clojure 1.4 版本开始,编译器支持在动态 clojure.core/*compiler-options* 变量中使用 :disable-locals-clearing 键,我们无法在 project.clj 文件中进行配置。因此,我们无法使用 lein compile 命令,但我们可以使用 lein repl 命令启动一个 REPL 来编译类:

user=> (binding [*compiler-options* {:disable-locals-clearing true}] (compile 'foo.core))
foo.core

这将在本节前面看到的相同位置生成类文件,但没有 x = null; y = null;,因为省略了本地清除。

数值、装箱和原始类型

数值是标量。关于数值的讨论被推迟到本章,唯一的原因是 Clojure 中数值的实现有强烈的 Java 基础。从版本 1.3 开始,Clojure 采用了 64 位数值作为默认值。现在,longdouble 是惯用的默认数值类型。请注意,这些是原始的 Java 类型,不是对象。Java 中的原始类型导致高性能,并在编译器和运行时级别具有多个优化。局部原始类型是在栈上创建的(因此不会对堆分配和 GC 贡献),可以直接访问而无需任何类型的解引用。在 Java 中,也存在数值原始类型的对象等效物,称为 装箱数值——这些是分配在堆上的常规对象。装箱数值也是不可变对象,这意味着 JVM 不仅在读取存储的值时需要解引用,而且在需要创建新值时还需要创建一个新的装箱对象。

显然,boxed 数值比它们的原始等效类型要慢。Oracle HotSpot JVM 在启动时使用-server选项,会积极内联那些包含对原始操作调用的函数(在频繁调用时)。Clojure 在多个级别自动使用原始数值。在let块、loop块、数组以及算术操作(+-*/incdec<<=>>=)中,会检测并保留原始数值。以下表格描述了原始数值及其 boxed 等效类型:

原始数值类型 Boxed 等效类型
字节型 (1 字节) java.lang.Byte
短整型 (2 字节) java.lang.Short
整型 (4 字节) java.lang.Integer
浮点型 (4 字节) java.lang.Float
长整型 (8 字节) java.lang.Long
双精度浮点型 (8 字节) java.lang.Double

在 Clojure 中,有时你可能会发现由于运行时缺少类型信息,数值作为 boxed 对象传递或从函数返回。即使你无法控制此类函数,你也可以强制转换值以将其视为原始值。byteshortintfloatlongdouble函数从给定的 boxed 数值值创建原始等效值。

Lisp 的传统之一是提供正确的(数值塔)算术实现。当发生溢出或下溢时,低类型不应该截断值,而应该提升到更高类型以保持正确性。Clojure 遵循这一约束,并通过素数(素数)函数提供自动提升+', -', *', inc', 和 dec'。自动提升以牺牲一些性能为代价提供了正确性。

在 Clojure 中,也存在任意长度或精度的数值类型,允许我们存储无界数字,但与原始类型相比性能较差。bigintbigdec函数允许我们创建任意长度和精度的数字。

如果我们尝试执行可能超出其最大容量的原始数值操作,操作会通过抛出异常来保持正确性。另一方面,当我们使用素数函数时,它们会自动提升以提供正确性。还有另一组称为不检查操作的操作,这些操作不检查溢出或下溢,可能返回不正确的结果。

在某些情况下,它们可能比常规和素数函数更快。这些函数是unchecked-addunchecked-subtractunchecked-multiplyunchecked-divideunchecked-incunchecked-dec。我们还可以通过*unchecked-math*变量启用常规算术函数的不检查数学行为;只需在源代码文件中包含以下内容:

(set! *unchecked-math* true)

在算术运算中,常见的需求之一是用于找出自然数除法后的商和余数的除法。Clojure 的/函数提供了一个有理数除法,返回一个比例,而mod函数提供了一个真正的模除法。这些函数比计算除法商和余数的quotrem函数要慢。

数组

除了对象和原始数据类型之外,Java 还有一种特殊的集合存储结构,称为数组。一旦创建,数组在不需要复制数据和创建另一个数组来存储结果的情况下不能增长或缩小。数组元素在类型上总是同质的。数组元素类似于可以修改它们以保存新值的位置。与列表和向量等集合不同,数组可以包含原始元素,这使得它们在没有 GC 开销的情况下成为一种非常快速的存储机制。

数组通常是可变数据结构的基础。例如,Java 的java.lang.ArrayList实现内部使用数组。在 Clojure 中,数组可用于快速数值存储和处理、高效算法等。与集合不同,数组可以有一个或多个维度。因此,您可以在数组中布局数据,如矩阵或立方体。让我们看看 Clojure 对数组的支持:

描述 示例 备注
创建数组 (make-array Integer 20) 类型为(boxed)整数的数组
(make-array Integer/TYPE 20) 原始类型整数的数组
(make-array Long/TYPE 20 10) 原始 long 类型二维数组
创建原始数组 (int-array 20) 大小为 20 的原始整数数组
(int-array [10 20 30 40]) 从向量创建的原始整数数组
从集合创建数组 (to-array [10 20 30 40]) 从可序列化创建的数组
(to-array-2d [[10 20 30][40 50 60]]) 从集合创建的二维数组
克隆数组 (aclone (to-array [:a :b :c]))
获取数组元素 (aget array-object 0 3) 获取 2-D 数组中索引[0][3]的元素
修改数组元素 (aset array-object 0 3 :foo) 在 2-D 数组中设置索引[0][3]的值为 obj :foo
修改原始数组元素 (aset-int int-array-object 2 6 89) 在 2-D 数组中设置索引[2][6]的值为 89
获取数组长度 (alength array-object) alengthcount要快得多
遍历数组 (def a (int-array [10 20 30 40 50 60]))``(seq``(amap a idx ret``(do (println idx (seq ret))``(inc (aget a idx))))) 与 map 不同,amap返回一个非懒加载的数组,在遍历数组元素时速度要快得多。注意,amap只有在正确类型提示的情况下才更快。下一节将介绍类型提示。
对数组进行归约 (def a (int-array [10 20 30 40 50 60]))``(areduce a idx ret 0``(do (println idx ret)``(+ ret idx))) 与归约不同,areduce在数组元素上要快得多。请注意,只有当正确类型提示时,归约才更快。下一节将介绍类型提示。
转换为原始数组 (ints int-array-object) 与类型提示一起使用(见下一节)

int-arrayints类似,还有其他类型的函数:

数组构造函数 原始数组转换函数 类型提示(不适用于变量) 泛型数组类型提示
布尔数组 booleans ^booleans ^"[Z"
字节型数组 bytes ^bytes ^"[B"
短整型数组 shorts ^shorts ^"[S"
字符数组 chars ^chars ^"[C"
整型数组 ints ^ints ^"[I"
长整型数组 longs ^longs ^"[J"
浮点数组 floats ^floats ^"[F"
双精度数组 doubles ^doubles ^"[D"
对象数组 –– ^objects ^"[Ljava.lang.Object"

数组之所以比其他数据结构更受欢迎,主要是因为性能,有时也因为互操作性。在类型提示数组和使用适当的函数处理它们时,应格外小心。

反射和类型提示

有时,由于 Clojure 是动态类型的,Clojure 编译器无法确定要调用的对象类型。在这种情况下,Clojure 使用反射,这比直接方法分派要慢得多。Clojure 解决这个问题的方法是所谓的类型提示。类型提示是一种用静态类型注解参数和对象的方法,这样 Clojure 编译器就可以生成用于高效分派的字节码。

知道在哪里放置类型提示的最简单方法是打开代码中的反射警告。考虑以下确定字符串长度的代码:

user=> (set! *warn-on-reflection* true)
true
user=> (def s "Hello, there")
#'user/s
user=> (.length s)
Reflection warning, NO_SOURCE_PATH:1 - reference to field length can't be resolved.
12
user=> (defn str-len [^String s] (.length s))
#'user/str-len
user=> (str-len s)
12
user=> (.length ^String s)  ; type hint when passing argument
12
user=> (def ^String t "Hello, there")  ; type hint at var level
#'user/t
user=> (.length t)  ; no more reflection warning
12
user=> (time (dotimes [_ 1000000] (.length s)))
Reflection warning, /private/var/folders/cv/myzdv_vd675g4l7y92jx9bm5lflvxq/T/form-init6904047906685577265.clj:1:28 - reference to field length can't be resolved.
"Elapsed time: 2409.155848 msecs"
nil
user=> (time (dotimes [_ 1000000] (.length t)))
"Elapsed time: 12.991328 msecs"
nil

在前面的代码片段中,我们可以清楚地看到,使用反射的代码与不使用反射的代码在性能上有很大的差异。在处理项目时,你可能希望所有文件都打开反射警告。你可以在 Leiningen 中轻松做到这一点。只需在project.clj文件中添加以下条目:

:profiles {:dev {:global-vars {*warn-on-reflection* true}}}

这将在每次通过 Leiningen 在开发工作流程中开始任何类型的调用时自动打开警告反射,例如 REPL 和测试。

原始数组

回想一下前一小节中关于amapareduce的例子。如果我们打开反射警告运行它们,我们会收到警告说它们使用了反射。让我们给它们添加类型提示:

(def a (int-array [10 20 30 40 50 60]))
;; amap example
(seq
 (amap ^ints a idx ret
    (do (println idx (seq ret))
      (inc (aget ^ints a idx)))))
;; areduce example
(areduce ^ints a idx ret 0
  (do (println idx ret)
    (+ ret idx)))

注意,原始数组提示^ints在变量级别不起作用。因此,如果你定义了变量a,如下所示,它将不起作用:

(def ^ints a (int-array [10 20 30 40 50 60]))  ; wrong, will complain later
(def ^"[I" a (int-array [10 20 30 40 50 60]))  ; correct
(def ^{:tag 'ints} a (int-array [10 20 30 40 50 60])) ; correct

这种表示法用于整数数组。其他原始数组类型有类似类型提示。请参考前一小节中关于各种原始数组类型的类型提示。

原始类型

原始局部变量的类型提示既不是必需的,也是不允许的。然而,你可以将函数参数类型提示为原始类型。Clojure 允许函数最多有四个参数可以进行类型提示:

(defn do-something
  [^long a ^long b ^long c ^long d]
  ..)

注意

封装箱可能会导致某些情况下的结果不是原始类型。在这种情况下,你可以使用相应的原始类型强制转换它们。

宏和元数据

在宏中,类型提示的方式与其他代码部分不同。由于宏是关于转换抽象语法树AST),我们需要有一个心理图来表示转换,并且我们应该在代码中添加类型提示作为元数据。例如,如果str-len是一个用于查找字符串长度的宏,我们使用以下代码:

(defmacro str-len
  [s]
  `(.length ~(with-meta s {:tag String})))
;; below is another way to write the same macro
(defmacro str-len
  [s]
  `(.length ~(vary-meta s assoc :tag `String)))

在前面的代码中,我们通过标记类型为String来修改符号s的元数据,在这个例子中恰好是java.lang.String类。对于数组类型,我们可以使用[Ljava.lang.String来表示字符串对象的数组,以及其他类似类型。如果你尝试使用之前列出的str-len,你可能注意到这仅在将字符串绑定到局部变量或变量时才有效,而不是作为字符串字面量。为了减轻这种情况,我们可以将宏编写如下:

(defmacro str-len
  [s]
  `(let [^String s# ~s] (.length s#)))

在这里,我们将参数绑定到一个类型提示的 gensym 局部变量,因此调用.length时不会使用反射,并且不会发出此类反射警告。

通过元数据进行的类型提示也适用于函数,尽管符号不同:

(defn foo [] "Hello")
(defn foo ^String [] "Hello")
(defn foo (^String [] "Hello") (^String [x] (str "Hello, " x)))

除了前面片段中的第一个示例外,它们都提示返回java.lang.String类型。

字符串连接

Clojure 中的str函数用于连接和转换成字符串标记。在 Java 中,当我们编写"hello" + e时,Java 编译器将其转换为使用StringBuilder的等效代码,在微基准测试中比str函数快得多。为了获得接近 Java 的性能,在 Clojure 中我们可以使用类似的机制,通过宏直接使用 Java 互操作来避免通过str函数的间接操作。Stringer (github.com/kumarshantanu/stringer)库采用相同的技巧,在 Clojure 中实现快速字符串连接:

(require '[stringer.core :as s])
user=> (time (dotimes [_ 10000000] (str "foo" :bar 707 nil 'baz)))
"Elapsed time: 2044.284333 msecs"
nil
user=> (time (dotimes [_ 10000000] (s/strcat "foo" :bar 707 nil 'baz)))
"Elapsed time: 555.843271 msecs"
nil

在这里,Stringer 也在编译阶段积极连接字面量。

杂项

在类型(如deftype中),可变实例变量可以可选地注解为^:volatile-mutable^:unsynchronized-mutable。例如:

(deftype Counter [^:volatile-mutable ^long now]
  ..)

defprotocol不同,definterface宏允许我们为方法提供返回类型提示:

(definterface Foo
  (^long doSomething [^long a ^double b]))

proxy-super宏(在proxy宏内部使用)是一个特殊情况,你不能直接应用类型提示。原因是它依赖于由proxy宏自动创建的隐式this对象。在这种情况下,你必须显式地将this绑定到一个类型:

(proxy [Object][]
  (equals [other]
    (let [^Object this this]
      (proxy-super equals other))))

在 Clojure 中,类型提示对于性能非常重要。幸运的是,我们只需要在需要时进行类型提示,并且很容易找出何时需要。在许多情况下,类型提示带来的收益会超过代码内联带来的收益。

使用数组/数值库提高效率

你可能已经注意到在前面的章节中,当处理数值时,性能很大程度上取决于数据是否基于数组和原始类型。为了实现最佳效率,程序员可能需要在计算的各个阶段都非常细致地将数据正确地强制转换为原始类型和数组。幸运的是,Clojure 社区的性能爱好者及早意识到这个问题,并创建了一些专门的开源库来减轻这个问题。

HipHip

HipHip 是一个用于处理原始类型数组的 Clojure 库。它提供了一个安全网,即它严格只接受原始数组参数来工作。因此,静默传递装箱的原始数组作为参数总是会引发异常。HipHip 宏和函数很少需要在操作期间程序员进行类型提示。它支持原始类型的数组,如 intlongfloatdouble

HipHip 项目可在 github.com/Prismatic/hiphip 找到。

到编写本文时,HipHip 的最新版本是 0.2.0,支持 Clojure 1.5.x 或更高版本,并标记为 Alpha 版本。HipHip 为所有四种原始类型的数组提供了一套标准操作:整数数组操作在命名空间 hiphip.int 中;双精度数组操作在 hiphip.double 中;等等。所有操作都针对相应类型进行了类型提示。在相应命名空间中,intlongfloatdouble 的所有操作基本上是相同的,除了数组类型:

类别 函数/宏 描述
核心函数 aclone 类似于 clojure.core/aclone,用于原始类型
alength 类似于 clojure.core/alength,用于原始类型
aget 类似于 clojure.core/aget,用于原始类型
aset 类似于 clojure.core/aset,用于原始类型
ainc 将数组元素按指定值增加
等价于 hiphip.array 操作 amake 使用表达式计算出的值创建一个新的数组并填充
areduce 类似于 clojure.core/areduce,带有 HipHip 数组绑定
doarr 类似于 clojure.core/doseq,带有 HipHip 数组绑定
amap 类似于 clojure.core/for,创建一个新的数组
afill! 类似于前面的 amap,但覆盖数组参数
数学运算 asum 使用表达式计算数组元素的总和
aproduct 使用表达式计算数组元素乘积
amean 计算数组元素的平均值
dot-product 计算两个数组的点积
查找最小/最大值、排序 amax-index 在数组中找到最大值并返回其索引
amax 在数组中找到最大值并返回它
amin-index 在数组中找到最小值并返回其索引
amin 在数组中找到最小值并返回它
apartition! 数组的三角划分:小于、等于、大于枢轴
aselect! 将最小的k个元素聚集到数组的开头
asort! 使用 Java 的内置实现原地排序数组
asort-max! 部分原地排序,将前k个元素聚集到数组末尾
asort-min! 部分原地排序,将最小的k个元素聚集到数组顶部
apartition-indices! apartition!类似,但修改的是索引数组而不是值
aselect-indices! aselect!类似,但修改的是索引数组而不是值
asort-indices! asort!类似,但修改的是索引数组而不是值
amax-indices 获取索引数组;最后k个索引指向最大的k个值
amin-indices 获取索引数组;前k个索引指向最小的k个值

要将 HipHip 作为依赖项包含在你的 Leiningen 项目中,请在project.clj中指定它:

:dependencies [;; other dependencies
               [prismatic/hiphip "0.2.0"]]

作为使用 HipHip 的一个示例,让我们看看如何计算数组的归一化值:

(require '[hiphip.double :as hd])

(def xs (double-array [12.3 23.4 34.5 45.6 56.7 67.8]))

(let [s (hd/asum xs)] (hd/amap [x xs] (/ x s)))

除非我们确保xs是一个原始双精度浮点数数组,否则 HipHip 在类型不正确时会抛出ClassCastException,在其他情况下会抛出IllegalArgumentException。我建议探索 HipHip 项目,以获得更多关于如何有效使用它的见解。

primitive-math

我们可以将*warn-on-reflection*设置为 true,让 Clojure 在调用边界处使用反射时警告我们。然而,当 Clojure 必须隐式使用反射来执行数学运算时,唯一的解决办法是使用分析器或将 Clojure 源代码编译成字节码,并使用反编译器分析装箱和反射。这就是primitive-math库发挥作用的地方,它通过产生额外的警告并抛出异常来帮助。

primitive-math库可在github.com/ztellman/primitive-math找到。

截至撰写本文时,primitive-math 版本为 0.1.4;你可以通过编辑project.clj将其作为依赖项包含在你的 Leiningen 项目中,如下所示:

:dependencies [;; other dependencies
               [primitive-math "0.1.4"]]

以下代码展示了如何使用它(回想一下将.class 文件反编译成 Java 源代码部分的示例):

;; must enable reflection warnings for extra warnings from primitive-math
(set! *warn-on-reflection* true)
(require '[primitive-math :as pm])
(defn mul [x y] (pm/* x y))  ; primitive-math produces reflection warning
(mul 10.3 2)                        ; throws exception
(defn mul [^long x ^long y] (pm/* x y))  ; no warning after type hinting
(mul 10.3 2)  ; returns 20

虽然primitive-math是一个有用的库,但它解决的问题大部分已经被 Clojure 1.7 中的装箱检测功能所处理(参见下一节检测装箱数学)。然而,如果你无法使用 Clojure 1.7 或更高版本,这个库仍然很有用。

检测装箱数学

装箱数学难以检测,是性能问题的来源。Clojure 1.7 引入了一种在发生装箱数学时警告用户的方法。这可以通过以下方式配置:

(set! *unchecked-math* :warn-on-boxed)

(defn sum-till [n] (/ (* n (inc n)) 2))  ; causes warning
Boxed math warning, /private/var/folders/cv/myzdv_vd675g4l7y92jx9bm5lflvxq/T/form-init3701519533014890866.clj:1:28 - call: public static java.lang.Number clojure.lang.Numbers.unchecked_inc(java.lang.Object).
Boxed math warning, /private/var/folders/cv/myzdv_vd675g4l7y92jx9bm5lflvxq/T/form-init3701519533014890866.clj:1:23 - call: public static java.lang.Number clojure.lang.Numbers.unchecked_multiply(java.lang.Object,java.lang.Object).
Boxed math warning, /private/var/folders/cv/myzdv_vd675g4l7y92jx9bm5lflvxq/T/form-init3701519533014890866.clj:1:20 - call: public static java.lang.Number clojure.lang.Numbers.divide(java.lang.Object,long).

;; now we define again with type hint
(defn sum-till [^long n] (/ (* n (inc n)) 2))

当使用 Leiningen 时,你可以在 project.clj 文件中添加以下条目来启用装箱数学警告:

:global-vars {*unchecked-math* :warn-on-boxed}

primitive-math(如 HipHip)中的数学运算是通过宏实现的。因此,它们不能用作高阶函数,并且因此可能与其他代码组合不佳。我建议探索该项目,看看什么适合你的程序用例。采用 Clojure 1.7 通过 boxed-warning 功能消除了装箱发现问题。

退回到 Java 和原生代码

在一些情况下,由于 Clojure 中缺乏命令式、基于栈的、可变变量,可能会导致代码的性能不如 Java,我们可能需要评估替代方案以提高性能。我建议你考虑直接用 Java 编写此类代码以获得更好的性能。

另一个考虑因素是使用原生操作系统功能,例如内存映射缓冲区 (docs.oracle.com/javase/7/docs/api/java/nio/MappedByteBuffer.html) 或文件和不受保护的操作 (highlyscalable.wordpress.com/2012/02/02/direct-memory-access-in-java/)。请注意,不受保护的操作可能具有潜在风险,通常不建议使用。这些时刻也是考虑在 C 或 C++ 中编写性能关键代码,然后通过 Java Native InterfaceJNI)访问它们的良机。

Proteus – Clojure 中的可变局部变量

Proteus 是一个开源的 Clojure 库,它允许你将局部变量视为局部变量,从而只允许在局部作用域内进行非同步修改。请注意,这个库依赖于 Clojure 1.5.1 的内部实现结构。Proteus 项目可在 github.com/ztellman/proteus 找到。

你可以通过编辑 project.clj 将 Proteus 作为依赖项包含在 Leiningen 项目中:

:dependencies [;;other dependencies
               [proteus "0.1.4"]]

在代码中使用 Proteus 非常简单,如下代码片段所示:

(require '[proteus :as p])
(p/let-mutable [a 10]
  (println a)
  (set! a 20)
  (println a))
;; Output below:
;; 10
;; 20

由于 Proteus 只允许在局部作用域中进行修改,以下代码会抛出异常:

(p/let-mutable [a 10 add2! (fn [x] (set! x (+ 2 x)))]
  (add2! a)
  (println a))

可变局部变量非常快,在紧密循环中可能非常有用。Proteus 在 Clojure 习惯用法上是非传统的,但它可能在不编写 Java 代码的情况下提供所需的性能提升。

摘要

由于 Clojure 强大的 Java 互操作性和底层支持,程序员可以利用接近 Java 的性能优势。对于性能关键代码,有时有必要了解 Clojure 如何与 Java 交互以及如何调整正确的旋钮。数值是一个需要 Java 互操作性以获得最佳性能的关键领域。类型提示是另一个重要的性能技巧,通常非常有用。有几个开源的 Clojure 库使程序员更容易进行此类活动。

在下一章中,我们将深入挖掘 Java 之下,看看硬件和 JVM 堆栈如何在提供我们所获得性能方面发挥关键作用,它们的限制是什么,以及如何利用对这些理解的应用来获得更好的性能。

第四章:主机性能

在前面的章节中,我们提到了 Clojure 如何与 Java 交互。在本章中,我们将更深入地了解内部结构。我们将触及整个堆栈的几个层次,但我们的主要重点是 JVM,特别是 Oracle HotSpot JVM,尽管有多个 JVM 供应商可供选择(en.wikipedia.org/wiki/List_of_Java_virtual_machines)。在撰写本文时,Oracle JDK 1.8 是最新的稳定版本,并且有早期的 OpenJDK 1.9 构建可用。在本章中,我们将讨论:

  • 从性能角度来看,硬件子系统是如何工作的

  • JVM 内部结构的组织以及它与性能的关系

  • 如何测量堆中各种对象占用的空间量

  • 使用 Criterium 对 Clojure 代码进行延迟分析

硬件

有各种硬件组件可能会以不同的方式影响软件的性能。处理器、缓存、内存子系统、I/O 子系统等,它们对性能的影响程度各不相同,这取决于具体的使用场景。在接下来的章节中,我们将探讨这些方面的每一个。

处理器

自 1980 年代末以来,微处理器一直采用流水线和指令级并行性来提高其性能。在 CPU 级别处理指令通常包括四个周期:取指解码执行回写。现代处理器通过并行运行这些周期来优化它们——当一条指令正在执行时,下一条指令正在解码,再下一条正在取指,依此类推。这种风格被称为指令流水线

在实践中,为了进一步加快执行速度,阶段被细分为许多更短的阶段,从而导致了更深的超级流水线架构。流水线中最长阶段的长度限制了 CPU 的时钟速度。通过将阶段拆分为子阶段,处理器可以以更高的时钟速度运行,每个指令需要更多的周期,但处理器仍然在每个周期内完成一条指令。由于现在每秒有更多的周期,尽管每个指令的延迟现在更高,但我们仍然在每秒吞吐量方面获得了更好的性能。

分支预测

即使处理器遇到条件if-then形式的指令,也必须提前取指和解码。考虑一个等价的 Clojure 表达式(if (test a) (foo a) (bar a))。处理器必须选择一个分支来取指和解码,问题是它应该取if分支还是else分支?在这里,处理器对要取指/解码的指令做出猜测。如果猜测是正确的,就像往常一样,这是一个性能提升;否则,处理器必须丢弃取指/解码过程的结果,并从另一个分支重新开始。

处理器使用片上分支预测表来处理分支预测。它包含最近的代码分支和每个分支两个比特位,指示分支是否被取用,同时也容纳了单次未取用的情况。

今天,分支预测在处理器性能方面非常重要,因此现代处理器专门分配硬件资源和特殊的预测指令来提高预测准确性并降低误预测的代价。

指令调度

高延迟指令和分支通常会导致指令流水线中的空循环,这些循环被称为停顿气泡。这些循环通常被用来通过指令重排的方式执行其他工作。指令重排通过硬件层面的乱序执行和编译器层面的编译时指令调度(也称为静态指令调度)来实现。

处理器在执行乱序执行时需要记住指令之间的依赖关系。这种成本可以通过使用重命名寄存器来在一定程度上减轻,其中寄存器值被存储到/从内存位置加载,可能是在不同的物理寄存器上,这样它们就可以并行执行。这要求乱序处理器始终维护指令及其使用的相应寄存器的映射,这使得它们的设计复杂且功耗高。除了一些例外,今天几乎所有高性能 CPU 都具有乱序设计。

良好的编译器通常对处理器有极高的了解,并且能够通过重新排列处理器指令来优化代码,从而减少处理器指令流水线中的气泡。一些高性能 CPU 仍然只依赖于静态指令重排而不是乱序指令重排,从而简化设计并节省芯片面积——节省的面积被用来容纳额外的缓存或 CPU 核心。低功耗处理器,如 ARM 和 Atom 系列,使用顺序设计。与大多数 CPU 不同,现代 GPU 使用顺序设计,具有深管道,这通过非常快的上下文切换得到补偿。这导致 GPU 具有高延迟和高吞吐量。

线程和核心

通过上下文切换、硬件线程和核心实现并发和并行性在当今非常普遍,并且我们已经将其视为实现程序的标准。然而,我们应该理解为什么我们最初需要这样的设计。我们今天编写的绝大多数现实世界代码在指令级并行性方面并没有超过适度的范围。即使有基于硬件的乱序执行和静态指令重排,每个周期也真正并行执行的指令不超过两个。因此,除了当前运行的程序之外,另一个潜在的指令来源是可以流水线和并行执行的程序。

管道中的空闲周期可以专门用于其他正在运行的程序,这些程序假设有其他当前正在运行的程序需要处理器的关注。同时多线程SMT)是一种硬件设计,它使这种类型的并行成为可能。英特尔在其某些处理器中实现了名为HyperThreading的 SMT。虽然 SMT 将单个物理处理器呈现为两个或更多逻辑处理器,但真正的多处理器系统每个处理器执行一个线程,从而实现同时执行。多核处理器每个芯片包含两个或更多处理器,但具有多处理器系统的特性。

通常,多核处理器在性能上显著优于 SMT 处理器。SMT 处理器的性能可能会根据用例而变化。在代码高度可变或线程不竞争相同硬件资源的情况下,性能达到峰值,而当线程在同一个处理器上缓存绑定时,性能会下降。同样重要的是,有些程序本身并不是本质上并行的。在这种情况下,如果没有在程序中显式使用线程,可能很难使它们更快。

内存系统

理解内存性能特性对于了解对我们所编写的程序可能产生的影响非常重要。那些既是数据密集型又是本质上并行的程序,例如音频/视频处理和科学计算,在很大程度上受到内存带宽的限制,而不是处理器的限制。除非内存带宽也增加,否则增加处理器并不会使它们更快。考虑另一类程序,例如主要受内存延迟限制但不受内存带宽限制的 3D 图形渲染或数据库系统。在这种情况下,SMT 可能非常适用,因为线程不会竞争相同的硬件资源。

内存访问大致占处理器执行的所有指令的四分之一。代码块通常以内存加载指令开始,其余部分取决于加载的数据。这会导致指令停滞,并防止大规模的指令级并行。更糟糕的是,即使是超标量处理器(每时钟周期可以发出多个指令)每周期最多也只能发出两个内存指令。构建快速内存系统受到自然因素的影响,例如光速。它影响信号往返到 RAM。这是一个自然的硬限制,任何优化都只能绕过它。

处理器和主板芯片组之间的数据传输是导致内存延迟的因素之一。这可以通过使用更快的总线前端总线FSB)来抵消。如今,大多数现代处理器通过在芯片级别直接集成内存控制器来解决这个问题。处理器与内存延迟之间的显著差异被称为内存墙。由于处理器时钟速度达到功率和热量限制,近年来这一现象已经趋于平稳,但尽管如此,内存延迟仍然是一个重大问题。

与 CPU 不同,GPU 通常实现持续的高内存带宽。由于延迟隐藏,它们在高强度计算工作负载期间也利用带宽。

缓存

为了克服内存延迟,现代处理器在处理器芯片上或芯片附近放置了一种非常快速的内存。缓存的作用是存储最近使用过的内存数据。缓存有不同的级别:L1 缓存位于处理器芯片上;L2 缓存比 L1 更大,且距离处理器更远。通常还有一个 L3 缓存,它比 L2 更大,且距离处理器更远。在英特尔 Haswell 处理器中,L1 缓存的大小通常是 64 千字节(32 KB 指令加 32 KB 数据),L2 每核心 256 KB,L3 是 8 MB。

虽然内存延迟非常糟糕,幸运的是缓存似乎工作得非常好。L1 缓存比访问主内存要快得多。在现实世界的程序中报告的缓存命中率是 90%,这为缓存提供了强有力的论据。缓存就像是一个内存地址到数据值块的字典。由于值是一个内存块,因此相邻内存位置的缓存几乎没有额外的开销。请注意,L2 比 L1 慢且更大,L3 比 L2 慢且更大。在英特尔 Sandybridge 处理器上,寄存器查找是瞬时的;L1 缓存查找需要三个时钟周期,L2 需要九个,L3 需要 21 个,而主内存访问需要 150 到 400 个时钟周期。

互连

处理器通过两种类型的架构的互连与内存和其他处理器通信:对称多处理SMP)和非一致性内存访问NUMA)。在 SMP 中,总线通过总线控制器将处理器和内存互连。总线充当广播设备。当有大量处理器和内存银行时,总线往往会成为瓶颈。与 NUMA 相比,SMP 系统的构建成本更低,但扩展到大量核心更困难。在 NUMA 系统中,处理器和内存的集合通过点到点的方式连接到其他这样的处理器和内存组。每个这样的组被称为节点。节点的本地内存可以被其他节点访问,反之亦然。英特尔的 HyperTransportQuickPath 互连技术支持 NUMA。

存储和网络

除了处理器、缓存和内存之外,存储和网络是最常用的硬件组件。许多现实世界中的应用程序往往比执行密集型应用程序更受 I/O 限制。这类 I/O 技术不断进步,市场上可供选择的组件种类繁多。考虑这类设备时,应基于具体的使用案例的性能和可靠性特性。另一个重要标准是了解它们在目标操作系统驱动程序中的支持程度。当前存储技术大多基于硬盘和固态硬盘。网络设备和协议的应用范围根据业务用例而大相径庭。I/O 硬件的详细讨论超出了本书的范围。

Java 虚拟机

Java 虚拟机是一个以字节码为导向、具有垃圾回收功能的虚拟机,它定义了自己的指令集。这些指令具有等效的字节码,由Java 运行时环境JRE)进行解释和编译,以适应底层的操作系统和硬件。对象通过符号引用来引用。在 JVM 中,数据类型在所有平台和架构上的所有 JVM 实现中都是完全标准化的,作为一个单一的规范。JVM 还遵循网络字节序,这意味着在不同架构上的 Java 程序之间可以使用大端字节序进行通信。Jvmtop([code.google.com/p/jvmtop/](https://code.google.com/p/jvmtop/))是一个方便的 JVM 监控工具,类似于 Unix-like 系统中的 top 命令。

即时编译器

即时编译器JIT)是 JVM 的一部分。当 JVM 启动时,即时编译器对正在运行的代码几乎一无所知,因此它只是简单地解释 JVM 字节码。随着程序的运行,即时编译器开始通过收集统计数据和分析调用和字节码模式来分析代码。当一个方法调用的次数超过某个阈值时,即时编译器会对代码应用一系列优化。最常见的优化是内联和本地代码生成。最终和静态方法和类是内联的绝佳候选者。即时编译并非没有成本;它占用内存来存储分析过的代码,有时它不得不撤销错误的推测性优化。然而,即时编译几乎总是通过长时间的代码执行来摊销成本。在罕见的情况下,如果代码太大或者由于执行频率低而没有热点,关闭即时编译可能是有用的。

一个 JRE 通常有两种 JIT 编译器:客户端和服务器。默认使用哪种 JIT 编译器取决于硬件和平台类型。客户端 JIT 编译器是为客户端程序,如命令行和桌面应用程序设计的。我们可以通过使用-server选项启动 JRE 来调用服务器 JIT 编译器,这实际上是为服务器上长时间运行程序设计的。服务器中 JIT 编译的阈值高于客户端。两种 JIT 编译器的区别在于,客户端针对的是低延迟,而服务器假定运行在高资源硬件上,并试图优化吞吐量。

Oracle HotSpot JVM 中的 JIT 编译器会观察代码执行以确定最频繁调用的方法,这些方法是热点。这些热点通常只是整个代码的一小部分,可以低成本地关注和优化。HotSpot JIT编译器是懒惰和自适应的。它是懒惰的,因为它只编译那些超过一定阈值的、已经调用的方法,而不是它遇到的全部代码。将代码编译成本地代码是一个耗时的过程,编译所有代码将是浪费。它是自适应的,因为它会逐渐增加对频繁调用代码编译的积极性,这意味着代码不是只优化一次,而是在代码重复执行的过程中多次优化。当一个方法调用超过第一个 JIT 编译器的阈值后,它将被优化,计数器重置为零。同时,代码的优化计数设置为 1。当调用再次超过阈值时,计数器重置为零,优化计数增加;这次应用更积极的优化。这个过程会一直持续到代码不能再优化为止。

HotSpot JIT 编译器执行了大量优化。其中一些最显著的优化如下:

  • 内联(Inlining): 方法的内联——非常小的方法、静态和 final 方法、final 类中的方法,以及只涉及原始数值的小方法,是内联的理想候选者。

  • 锁消除(Lock elimination): 锁定是一个性能开销。幸运的是,如果锁对象监视器无法从其他线程访问,则可以消除锁。

  • 虚拟调用消除(Virtual call elimination): 通常,程序中的一个接口只有一个实现。JIT 编译器会消除虚拟调用,并用类实现对象上的直接方法调用替换它。

  • 非易失性内存写入消除(Non-volatile memory write elimination): 对象中的非易失性数据成员和引用不保证对当前线程以外的线程可见。这个标准被用来不更新这样的引用在内存中,而是通过本地代码使用硬件寄存器或栈。

  • 本地代码生成:JIT 编译器为频繁调用的方法及其参数生成本地代码。生成的本地代码存储在代码缓存中。

  • 控制流和局部优化:JIT 编译器经常重新排序和拆分代码以提高性能。它还分析控制流的分支,并根据这些分支优化代码。

很少有理由禁用 JIT 编译,但可以通过在启动 JRE 时传递 -Djava.compiler=NONE 参数来实现。默认的编译阈值可以通过传递 -XX:CompileThreshold=9800 到 JRE 可执行文件来更改,其中 9800 是示例阈值。XX:+PrintCompilation-XX:-CITime 选项使 JIT 编译器打印 JIT 统计信息和 JIT 所花费的时间。

内存组织

JVM 使用的内存被分为几个部分。作为基于栈的执行模型,JVM 中的一个内存部分是栈区域。每个线程都有一个栈,栈帧以后进先出LIFO)的顺序存储在其中。栈包括一个程序计数器PC),它指向 JVM 内存中当前正在执行的指令。当调用方法时,会创建一个新的栈帧,其中包含局部变量数组和操作数栈。与传统的栈不同,操作数栈包含加载局部变量/字段值和计算结果的指令——这种机制也用于在调用之前准备方法参数,并存储返回值。栈帧本身可能分配在堆上。检查当前线程中栈帧顺序的最简单方法是通过执行以下代码:

(require 'clojure.repl)
(clojure.repl/pst (Throwable.))

当一个线程需要的栈空间超过 JVM 可以提供的空间时,会抛出StackOverflowError

堆是对象和数组分配的主要内存区域。它在所有 JVM 线程之间共享。堆的大小可能是固定的或可扩展的,这取决于启动 JRE 时传递的参数。尝试分配比 JVM 能提供的更多堆空间会导致抛出OutOfMemoryError。堆中的分配受垃圾回收的影响。当一个对象不再通过任何引用可访问时,它将被垃圾回收,值得注意的是,弱、软和虚引用除外。由非强引用指向的对象在 GC 中的回收时间较长。

方法区域在逻辑上属于堆内存的一部分,包含诸如字段和方法信息、运行时常量池、方法代码和构造函数体等每个类的结构。它在所有 JVM 线程之间共享。在 Oracle HotSpot JVM(至版本 7)中,方法区域位于一个称为永久代的内存区域中。在 HotSpot Java 8 中,永久代被一个称为元空间的本地内存区域所取代。

内存组织

JVM 包含提供给 Java API 实现和 JVM 实现的本地代码和 Java 字节码。每个线程堆栈维护一个独立的本地代码调用栈。JVM 堆栈包含 Java 方法调用。请注意,Java SE 7 和 8 的 JVM 规范不包含本地方法栈,但 Java SE 5 和 6 则包含。

HotSpot 堆和垃圾回收

Oracle HotSpot JVM 使用代际堆。主要有三个代:年轻代持久代(旧代)和永久代(仅限于 HotSpot JDK 1.7)。随着对象在垃圾回收中存活,它们从Eden移动到Survivor空间,再从Survivor空间移动到持久代空间。新实例在Eden段分配,这是一个非常便宜的操作(和指针增加一样便宜,比 C 的malloc调用更快),如果它已经有足够的空闲空间。当 Eden 区域没有足够的空闲空间时,会触发一次小型的垃圾回收。这次回收会将Eden中的活动对象复制到Survivor空间。在同样的操作中,活动对象会在Survivor-1中进行检查,并复制到Survivor-2,从而只保留Survivor-2中的活动对象。这种方案保持EdenSurvivor-1为空且无碎片,以便进行新的分配,这被称为复制收集

HotSpot 堆和垃圾回收

在年轻代达到一定的存活阈值后,对象会被移动到持久代/旧代。如果无法进行小型的垃圾回收,则会尝试进行一次大型的垃圾回收。大型垃圾回收不使用复制,而是依赖于标记-清除算法。我们可以使用吞吐量收集器(SerialParallelParallelOld)或低延迟收集器(ConcurrentG1)来处理旧代。以下表格显示了一些非详尽的选项,用于每个收集器类型:

收集器名称 JVM 标志
序列 -XX:+UseSerialGC
并行 -XX:+UseParallelGC
并行压缩 -XX:+UseParallelOldGC
并发 -XX:+UseConcMarkSweepGC-XX:+UseParNewGC-XX:+CMSParallelRemarkEnabled
G1 -XX:+UseG1GC

之前提到的标志可以用来启动 Java 运行时。例如,在以下命令中,我们使用并行压缩垃圾回收启动了一个 4GB 堆的服务器 JVM:

java \
  -server \
  -Xms4096m -Xmx4096m \
  -XX:+UseParallelOldGC XX:ParallelGCThreads=4 \
  -jar application-standalone.jar

有时,由于多次运行完全垃圾回收,持久代可能变得非常碎片化,以至于可能无法将对象从 Survivor 空间移动到持久代空间。在这些情况下,会触发带有压缩的完全垃圾回收。在此期间,由于完全垃圾回收正在进行,应用程序可能看起来没有响应。

测量内存(堆/栈)使用情况

JVM 性能下降的一个主要原因是垃圾回收。了解我们创建的对象如何使用堆内存以及如何通过降低足迹来减少对 GC 的影响,这当然是有帮助的。让我们检查对象表示如何导致堆空间。

在 64 位 JVM 上,每个(未压缩的)对象或数组引用都是 16 字节长。在 32 位 JVM 上,每个引用都是 8 字节长。由于 64 位架构现在越来越普遍,64 位 JVM 更有可能在服务器上使用。幸运的是,对于高达 32GB 的堆大小,JVM(Java 7)可以使用压缩指针(默认行为),其大小仅为 4 字节。Java 8 虚拟机可以通过压缩指针访问高达 64GB 的堆大小,如下表所示:

未压缩 压缩 32 位
参考指针 8 4 4
对象头 16 12 8
数组头 24 16 12
超类填充 8 4 4

此表说明了不同模式下的指针大小(经 Attila Szegedi 授权复制:www.slideshare.net/aszegedi/everything-i-ever-learned-about-jvm-performance-tuning-twitter/20)。

我们在前一章中看到了每种原始类型占用多少字节。现在让我们看看在小于 32GB 的堆大小的 64 位 JVM 上,使用压缩指针(常见情况)的复合类型的内存消耗情况:

Java 表达式 64 位内存使用量 描述(b = 字节,填充到内存字大小的近似 8 的倍数)
new Object() 16 字节 12 字节头 + 4 字节填充
new byte[0] 16 字节 12 字节obj头 + 4 字节int长度 = 16 字节数组头
new String("foo") 40 字节(字面量内部化) 12 字节头 + (12 字节数组头 + 6 字节字符数组内容 + 4 字节长度 + 2 字节填充 = 24 字节) + 4 字节哈希
new Integer(3) 16 字节(装箱整数) 12 字节头 + 4 字节int
new Long(4) 24 字节(装箱长整型) 12 字节头 + 8 字节long值 + 4 字节填充
class A { byte x; } new A(); 16 字节 12 字节头 + 1 字节值 + 3 字节填充
class B extends A {byte y;} new B(); 24 字节(子类填充) 12 字节引用 + (1 字节值 + 7 字节填充 = 8 字节) 用于 A + 1 字节用于y的值 + 3 字节填充
clojure.lang.Symbol.intern("foo")// clojure 'foo 104 字节(40 字节内部化) 12 字节头 + 12 字节命名空间引用 + (12 字节名称引用 + 40 字节内部字符) + 4 字节int哈希 + 12 字节元数据引用 + (12 字节_str引用 + 40 字节内部字符) – 40 字节内部str
clojure.lang.Keyword.intern("foo")// clojure :foo 184 字节(由工厂方法完全内部化) 12 字节引用 + (12 字节符号引用 + 104 字节内部值) + 4 字节int哈希 + (12 字节_str引用 + 40 字节内部char)

比较由相同给定字符串创建的符号和关键字所占用的空间,可以说明尽管关键字相对于符号有轻微的额外开销,但关键字是完全内联的,并且会提供更好的内存消耗和随时间进行的垃圾回收保护。此外,关键字作为弱引用进行内联,这确保了当内存中没有任何关键字指向内联值时,它会被垃圾回收。

确定程序工作负载类型

我们经常需要确定一个程序是否受 CPU/缓存、内存、I/O 或竞争限制。当一个程序受 I/O 或竞争限制时,CPU 使用率通常较低。你可能需要使用分析器(我们将在第七章性能优化中看到这一点)来找出线程是否因为资源竞争而陷入停滞。当一个程序受 CPU/缓存或内存限制时,CPU 使用率可能不是瓶颈来源的明确指标。在这种情况下,你可能想要通过检查程序中的缓存未命中来进行有根据的猜测。在 Linux 系统上,如perf (perf.wiki.kernel.org/)、cachegrind (valgrind.org/info/tools.html#cachegrind)和oprofile (oprofile.sourceforge.net/)等工具可以帮助确定缓存未命中的数量——更高的阈值可能意味着程序受内存限制。然而,由于 Java 的 JIT 编译器需要预热才能观察到有意义的行为,因此使用这些工具与 Java 结合并不简单。项目perf-map-agent (github.com/jrudolph/perf-map-agent)可以帮助生成你可以使用perf实用程序关联的方法映射。

解决内存效率低下问题

在本章前面的部分,我们讨论了未经检查的内存访问可能成为瓶颈。截至 Java 8,由于堆和对象引用的工作方式,我们无法完全控制对象布局和内存访问模式。然而,我们可以关注频繁执行的代码块,以减少内存消耗,并尝试在运行时使它们成为缓存绑定而不是内存绑定。我们可以考虑一些降低内存消耗和访问随机性的技术:

  • JVM 中的原始局部变量(long、double、boolean、char 等)是在栈上创建的。其余的对象是在堆上创建的,并且只有它们的引用存储在栈上。原始变量具有较低的开销,并且不需要内存间接访问,因此推荐使用。

  • 在主内存中以顺序方式布局的数据比随机布局的数据访问更快。当我们使用大型(比如说超过八个元素)持久映射时,存储在 tries 中的数据可能不会在内存中顺序布局,而是在堆中随机布局。此外,键和值都会被存储和访问。当你使用记录(defrecord)和类型(deftype)时,它们不仅提供了数组/类语义来布局其中的字段,而且它们不存储键,与常规映射相比,这非常高效。

  • 从磁盘或网络读取大量内容可能会由于随机内存往返而对性能产生不利影响。在第三章《依赖 Java》中,我们简要讨论了内存映射字节数据缓冲区。您可以利用内存映射缓冲区来最小化堆上的碎片化对象分配/访问。虽然nio(github.com/pjstadig/nio/)和clj-mmap(github.com/thebusby/clj-mmap)等库帮助我们处理内存映射缓冲区,但bytebuffer(github.com/geoffsalmon/bytebuffer)和gloss(github.com/ztellman/gloss)让我们能够处理字节数据缓冲区。还有其他抽象,如 iota(github.com/thebusby/iota),它帮助我们以集合的形式处理大文件。

由于内存瓶颈是数据密集型程序中潜在的性能问题,降低内存开销在很大程度上有助于避免性能风险。了解硬件、JVM 和 Clojure 实现的高级细节有助于我们选择适当的技巧来解决内存瓶颈问题。

使用 Criterium 测量延迟

Clojure 有一个叫做time的小巧的宏,它评估传递给它的代码的主体,然后打印出所花费的时间,并简单地返回值。然而,我们可以注意到,代码执行所需的时间在不同的运行中变化很大:

user=> (time (reduce + (range 100000)))
"Elapsed time: 112.480752 msecs"
4999950000
user=> (time (reduce + (range 1000000)))
"Elapsed time: 387.974799 msecs"
499999500000

与这种行为变化相关的有几个原因。当 JVM 冷启动时,其堆段为空,并且对代码路径一无所知。随着 JVM 的持续运行,堆开始填满,GC 模式开始变得明显。JIT 编译器有机会分析不同的代码路径并进行优化。只有在经过相当多的 GC 和 JIT 编译轮次之后,JVM 的性能才变得不那么不可预测。

Criterium (github.com/hugoduncan/criterium)是一个 Clojure 库,用于在机器上科学地测量 Clojure 表达式的延迟。关于其工作原理的摘要可以在 Criterium 项目页面上找到。使用 Criterium 的最简单方法是将其与 Leiningen 一起使用。如果你想使 Criterium 仅在 REPL 中可用,而不是作为项目依赖项,请将以下条目添加到~/.lein/profiles.clj文件中:

{:user {:plugins [[criterium "0.4.3"]]}}

另一种方法是在project.clj文件中包含criterium

:dependencies [[org.clojure/clojure "1.7.0"]
               [criterium "0.4.3"]]

完成文件编辑后,使用lein repl启动 REPL:

user=> (require '[criterium.core :as c])
nil
user=> (c/bench (reduce + (range 100000)))
Evaluation count : 1980 in 60 samples of 33 calls.
             Execution time mean : 31.627742 ms
    Execution time std-deviation : 431.917981 us
   Execution time lower quantile : 30.884211 ms ( 2.5%)
   Execution time upper quantile : 32.129534 ms (97.5%)
nil

现在,我们可以看到,在某个测试机器上,平均而言,该表达式花费了 31.6 毫秒。

Criterium 和 Leiningen

默认情况下,Leiningen 以低级编译模式启动 JVM,这使其启动更快,但会影响 JRE 在运行时可以执行的优化。为了在服务器端用例中使用 Criterium 和 Leiningen 运行测试时获得最佳效果,请确保在project.clj中覆盖默认设置,如下所示:

:jvm-opts ^:replace ["-server"]

^:replace提示使 Leiningen 用:jvm-opts键下提供的选项替换其默认设置。你可能需要根据需要添加更多参数,例如运行测试的最小和最大堆大小。

摘要

软件系统的性能直接受其硬件组件的影响,因此了解硬件的工作原理至关重要。处理器、缓存、内存和 I/O 子系统具有不同的性能行为。Clojure 作为一种托管语言,了解宿主(即 JVM)的性能特性同样重要。Criterium 库用于测量 Clojure 代码的延迟——我们将在第六章“测量性能”中再次讨论 Criterium。在下一章中,我们将探讨 Clojure 的并发原语及其性能特性。

第五章:并发

并发是 Clojure 的主要设计目标之一。考虑到 Java 中的并发编程模型(与 Java 的比较是因为它是 JVM 上的主要语言),它不仅太底层,而且如果不严格遵循模式,很容易出错,从而自食其果。锁、同步和无保护变异是并发陷阱的配方,除非极端谨慎地使用。Clojure 的设计选择深刻影响了并发模式以安全且功能的方式实现的方式。在本章中,我们将讨论:

  • 硬件和 JVM 级别的底层并发支持

  • Clojure 的并发原语——原子、代理、引用和变量

  • Java 内置的并发特性既安全又实用,与 Clojure 的结合

  • 使用 Clojure 特性和 reducers 进行并行化

底层并发

没有显式的硬件支持,无法实现并发。我们在前几章讨论了 SMT 和多核处理器。回想一下,每个处理器核心都有自己的 L1 缓存,而多个核心共享 L2 缓存。共享的 L2 缓存为处理器核心提供了一个快速机制来协调它们的缓存访问,消除了相对昂贵的内存访问。此外,处理器将写入内存的操作缓冲到一个称为脏写缓冲区的地方。这有助于处理器发布一系列内存更新请求,重新排序指令,并确定写入内存的最终值,这被称为写吸收

硬件内存屏障(栅栏)指令

内存访问重排序对于顺序(单线程)程序的性能来说非常好,但对于并发程序来说则是有害的,因为在某个线程中内存访问的顺序可能会破坏另一个线程的预期。处理器需要一种同步访问的手段,以便内存重排序要么被限制在不在乎的代码段中,要么在可能产生不良后果的地方被阻止。硬件通过“内存屏障”(也称为“栅栏”)这一安全措施来支持这种功能。

在不同的架构中可以发现多种内存屏障指令,它们可能具有不同的性能特征。编译器(或者在 JVM 的情况下是 JIT 编译器)通常了解它在上面运行的架构上的 fence 指令。常见的 fence 指令有读、写、获取和释放屏障等。屏障并不保证最新的数据,而是只控制内存访问的相对顺序。屏障会在所有写入发布后、屏障对发出它的处理器可见之前,将写缓冲区刷新。

读写屏障分别控制读和写的顺序。写操作通过写缓冲区进行;但读操作可能发生顺序混乱,或者来自写缓冲区。为了保证正确的顺序,使用获取和释放,块/屏障被使用。获取和释放被认为是 "半屏障";两者一起(获取和释放)形成一个 "全屏障"。全屏障比半屏障更昂贵。

Java 支持和 Clojure 等价物

在 Java 中,内存屏障指令是由高级协调原语插入的。尽管栅栏指令的运行成本很高(数百个周期),但它们提供了一个安全网,使得在关键部分内访问共享变量是安全的。在 Java 中,synchronized 关键字标记一个 "关键部分",一次只能由一个线程执行,因此它是一个 "互斥" 工具。在 Clojure 中,Java 的 synchronized 的等价物是 locking 宏:

// Java example
synchronized (someObject) {
    // do something
}
;; Clojure example
(locking some-object
  ;; do something
  )

locking 宏建立在两个特殊形式 monitor-entermonitor-exit 之上。请注意,locking 宏是一个低级和命令式的解决方案,就像 Java 的 synchronized 一样——它们的使用并不被认为是 Clojure 的惯用用法。特殊形式 monitor-entermonitor-exit 分别进入和退出锁对象的 "monitor"——它们甚至更低级,不建议直接使用。

测量使用此类锁定代码的性能的人应该意识到其单线程与多线程延迟之间的差异。在单线程中锁定是廉价的。然而,当有两个或更多线程争夺同一对象监视器的锁时,性能惩罚就开始显现。锁是在称为 "内在" 或 "monitor" 锁的对象监视器上获取的。对象等价性(即,当 = 函数返回 true)永远不会用于锁定的目的。确保从不同的线程锁定时对象引用是相同的(即,当 identical? 返回 true)。

线程通过获取监视器锁涉及一个读屏障,该屏障使线程本地的缓存数据、相应的处理器寄存器和缓存行无效。这迫使从内存中重新读取。另一方面,释放监视器锁会导致写屏障,将所有更改刷新到内存中。这些是昂贵的操作,会影响并行性,但它们确保了所有线程的数据一致性。

Java 支持一个volatile关键字,用于类中的数据成员,它保证在同步块之外对属性的读取和写入不会发生重排序。值得注意的是,除非属性被声明为volatile,否则它不能保证在所有访问它的线程中都是可见的。Clojure 中 Java 的volatile等价于我们在第三章, 依赖 Java中讨论的元数据^:volatile-mutable。以下是在 Java 和 Clojure 中使用volatile的示例:

// Java example
public class Person {
    volatile long age;
}
;; Clojure example
(deftype Person [^:volatile-mutable ^long age])

读取和写入volatile数据需要分别使用读获取或写释放,这意味着我们只需要一个半屏障就可以单独读取或写入值。请注意,由于半屏障,读取后跟写入的操作不保证是原子的。例如,age++表达式首先读取值,然后增加并设置它。这使得有两个内存操作,这不再是半屏障。

Clojure 1.7 引入了对volatile数据的一级支持,使用一组新的函数:volatile!vswap!vreset!volatile?。这些函数定义了可变的(可变的)数据,并与之一起工作。然而,请注意,这些函数不与deftype中的可变字段一起工作。以下是如何使用它们的示例:

user=> (def a (volatile! 10))
#'user/a
user=> (vswap! a inc)
11
user=> @a
11
user=> (vreset! a 20)
20
user=> (volatile? a)
true

volatile数据的操作不是原子的,这就是为什么即使创建volatile(使用volatile!)也被认为是潜在不安全的。一般来说,volatile可能在读取一致性不是高优先级但写入必须快速的情况下很有用,例如实时趋势分析或其他此类分析报告。volatile在编写有状态的转换器(参考第二章, Clojure 抽象)时也非常有用,作为非常快速的状态容器。在下一小节中,我们将看到其他比volatile更安全(但大多数情况下更慢)的状态抽象。

原子更新和状态

读取数据元素、执行一些逻辑,并更新为新值的操作是一个常见的用例。对于单线程程序,这不会产生任何后果;但对于并发场景,整个操作必须作为一个原子操作同步执行。这种情况如此普遍,以至于许多处理器在硬件级别使用特殊的比较并交换(CAS)指令来支持这一功能,这比锁定要便宜得多。在 x86/x64 架构上,这个指令被称为 CompareExchange(CMPXCHG)。

不幸的是,可能存在另一种线程更新了变量,其值与正在执行原子更新的线程将要比较的旧值相同。这被称为“ABA”问题。在某些其他架构中发现的“Load-linked”(LL)和“Store-conditional”(SC)等指令集提供了一种没有 ABA 问题的 CAS 替代方案。在 LL 指令从地址读取值之后,用于更新地址的新值的 SC 指令只有在 LL 指令成功后地址未被更新的情况下才会执行。

Java 中的原子更新

Java 提供了一组内置的无锁、原子、线程安全的比较和交换抽象,用于状态管理。它们位于java.util.concurrent.atomic包中。对于布尔型、整型和长型等原始类型,分别有AtomicBooleanAtomicIntegerAtomicLong类。后两个类支持额外的原子加减操作。对于原子引用更新,有AtomicReferenceAtomicMarkableReferenceAtomicStampedReference类用于任意对象。还有对数组的支持,其中数组元素可以原子性地更新——AtomicIntegerArrayAtomicLongArrayAtomicReferenceArray。它们易于使用;以下是一个示例:

(import 'java.util.concurrent.atomic.AtomicReference)
(def ^AtomicReference x (AtomicReference. "foo"))
(.compareAndSet x "foo" "bar")
(import 'java.util.concurrent.atomic.AtomicInteger)
(def ^AtomicInteger y (AtomicInteger. 10))
(.getAndAdd y 5)

然而,在哪里以及如何使用它取决于更新点和代码中的逻辑。原子更新不保证是非阻塞的。在 Java 中,原子更新不是锁的替代品,而是一种便利,仅当范围限制为对一个可变变量的比较和交换操作时。

Clojure 对原子更新的支持

Clojure 的原子更新抽象称为atom。它底层使用AtomicReference。对AtomicIntegerAtomicLong的操作可能比 Clojure 的atom稍微快一些,因为前者使用原语。但由于它们在 CPU 中使用的比较和交换指令,它们并不便宜。速度实际上取决于突变发生的频率以及 JIT 编译器如何优化代码。速度的好处可能不会在代码运行了几十万次之后显现出来,并且原子频繁突变会增加重试的延迟。在实际(或类似实际)负载下测量延迟可以提供更好的信息。以下是一个使用原子的示例:

user=> (def a (atom 0))
#'user/a
user=> (swap! a inc)
1
user=> @a
1
user=> (compare-and-set! a 1 5)
true
user=> (reset! a 20)
20

swap! 函数在执行原子更新时提供了一种与 compareAndSwap(oldval, newval) 方法明显不同的风格。当 compareAndSwap() 比较并设置值时,如果成功则返回 true,如果失败则返回 false,而 swap! 则会持续在一个无限循环中尝试更新,直到成功。这种风格是 Java 开发者中流行的模式。然而,与更新循环风格相关的一个潜在陷阱也存在。随着更新者的并发性提高,更新的性能可能会逐渐下降。再次,原子更新的高并发性引发了一个问题:对于使用场景来说,是否真的有必要进行无协调的更新。《compare-and-set!reset!` 是相当直接的。

传递给 swap! 的函数必须是无副作用的(即纯函数),因为在竞争时该函数会在循环中多次重试。如果函数不是纯函数,副作用可能会在重试的次数内发生。

值得注意的是,原子不是“协调”的,这意味着当原子被不同的线程并发使用时,我们无法预测操作在其上工作的顺序,也无法保证最终结果。围绕原子的代码应该考虑到这种约束来设计。在许多场景中,由于缺乏协调,原子可能并不适合——在设计程序时要注意这一点。原子通过额外的参数支持元数据和基本验证机制。以下示例说明了这些功能:

user=> (def a (atom 0 :meta {:foo :bar}))
user=> (meta a)
{:foo :bar}
user=> (def age (atom 0 :validator (fn [x] (if (> x 200) false true))))
user=> (reset! age 200)
200
user=> (swap! age inc)
IllegalStateException Invalid reference state  clojure.lang.ARef.validate (ARef.java:33)

第二个重要的事情是,原子支持在它们上添加和移除监视器。我们将在本章后面讨论监视器。

使用原子条带化实现更快的写入

我们知道当多个线程尝试同时更新状态时,原子会表现出竞争。这意味着当写入不频繁时,原子具有很好的性能。有一些用例,例如度量计数器,需要快速且频繁的写入,但读取较少,可以容忍一些不一致性。对于这样的用例,我们不必将所有更新都指向单个原子,而可以维护一组原子,其中每个线程更新不同的原子,从而减少竞争。从这些原子中读取的值不能保证是一致的。让我们开发这样一个计数器的示例:

(def ^:const n-cpu (.availableProcessors (Runtime/getRuntime)))
(def counters (vec (repeatedly n-cpu #(atom 0))))
(defn inc! []
  ;; consider java.util.concurrent.ThreadLocalRandom in Java 7+
  ;; which is faster than Math/random that rand-int is based on
  (let [i (rand-int n-cpu)]
    (swap! (get counters i) inc)))
(defn value []
  (transduce (map deref) + counters))

在上一个示例中,我们创建了一个名为counters的向量,其大小与计算机中 CPU 核心的数量相同,并将每个元素初始化为初始值为 0 的原子。名为inc!的函数通过从counters中随机选择一个原子并增加 1 来更新计数器。我们还假设rand-int在所有处理器核心上均匀地分布了原子的选择,因此我们几乎没有任何竞争。value函数简单地遍历所有原子,并将它们的deref返回值相加以返回计数器的值。该示例使用clojure.core/rand-int,它依赖于java.lang.Math/random(由于 Java 6 支持)来随机找到下一个计数器原子。让我们看看当使用 Java 7 或更高版本时,我们如何优化它:

(import 'java.util.concurrent.ThreadLocalRandom)
(defn inc! []
  (let [i (.nextLong (ThreadLocalRandom/current) n-cpu)]
    (swap! (get counters i) inc)))

在这里,我们导入java.util.concurrent.ThreadLocalRandom类,并定义了inc!函数,使用ThreadLocalRandom来选择下一个随机原子。其他一切保持不变。

异步代理和状态

当原子是同步的,代理(agents)是 Clojure 中实现状态变化的异步机制。每个代理都与一个可变状态相关联。我们向代理传递一个函数(称为“动作”),并附带可选的额外参数。这个函数由代理排队在另一个线程中处理。所有代理共享两个公共线程池——一个用于低延迟(可能为 CPU 密集型、缓存密集型或内存密集型)的工作,另一个用于阻塞(可能为 I/O 相关或长时间处理)的工作。Clojure 提供了send函数用于低延迟动作,send-off用于阻塞动作,以及send-via来在用户指定的线程池上执行动作,而不是预配置的线程池之一。sendsend-offsend-via都会立即返回。以下是我们可以如何使用它们的示例:

(def a (agent 0))
;; invoke (inc 0) in another thread and set state of a to result
(send a inc)
@a  ; returns 1
;; invoke (+ 1 2 3) in another thread and set state of a to result
(send a + 2 3)
@a  ; returns 6

(shutdown-agents)  ; shuts down the thread-pools
;; no execution of action anymore, hence no result update either
(send a inc)
@a  ; returns 6

当我们检查 Clojure(截至版本 1.7.0)的源代码时,我们可以发现低延迟动作的线程池被命名为pooledExecutor(一个有界线程池,初始化为最大'2 + 硬件处理器数量'个线程),而高延迟动作的线程池被命名为soloExecutor(一个无界线程池)。这种默认配置的前提是 CPU/缓存/内存密集型动作在有限线程池上运行最优化,默认线程数。I/O 密集型任务不消耗 CPU 资源。因此,可以同时执行相对较多的此类任务,而不会显著影响 CPU/缓存/内存密集型工作的性能。以下是您可以如何访问和覆盖线程池的示例:

(import 'clojure.lang.Agent)
Agent/pooledExecutor  ; thread-pool for low latency actions
Agent/soloExecutor  ; thread-pool for I/O actions
(import 'java.util.concurrent.Executors)
(def a-pool (Executors/newFixedThreadPool 10))  ; thread-pool with 10 threads
(def b-pool (Executors/newFixedThreadPool 100)) ; 100 threads pool
(def a (agent 0))
(send-via a-pool a inc)  ; use 'a-pool' for the action
(set-agent-send-executor! a-pool)  ; override default thread-pool
(set-agent-send-off-executor! b-pool)  ; override default pool

如果一个程序通过代理执行大量 I/O 或阻塞操作,限制用于此类操作的线程数量可能是有意义的。使用set-agent-send-off-executor!覆盖send-off线程池是限制线程池大小的最简单方法。通过使用send-via与适合各种 I/O 和阻塞操作的适当大小的线程池,可以更细致地隔离和限制代理上的 I/O 操作。

异步、队列和错误处理

向代理发送操作会立即返回,而不会阻塞。如果代理尚未忙于执行任何操作,它将通过将触发操作执行的队列中的操作入队来“反应”,在相应的线程池中。如果代理正在忙于执行另一个操作,新操作将简单地入队。一旦从操作队列中执行了操作,就会检查队列是否有更多条目,如果找到,则触发下一个操作。这个触发操作的“反应”机制消除了需要消息循环或轮询队列的需要。这之所以可能,是因为控制了指向代理队列的入口点。

动作在代理上异步执行,这引发了如何处理错误的疑问。错误情况需要通过一个显式、预定义的函数来处理。当使用默认的代理构造,如(agent :foo)时,代理在没有错误处理程序的情况下创建,并在任何异常发生时挂起。它缓存异常,并拒绝接受更多操作。在代理重启之前,它会在发送任何操作时抛出缓存的异常。可以使用restart-agent函数重置挂起的代理。这种挂起的目的是安全和监督。当在代理上执行异步操作时,如果突然发生错误,则需要引起注意。查看以下代码:

(def g (agent 0))
(send g (partial / 10))  ; ArithmeticException due to divide-by-zero
@g  ; returns 0, because the error did not change the old state
(send g inc)  ; throws the cached ArithmeticException
(agent-error g)  ; returns (doesn't throw) the exception object
(restart-agent g @g)  ; clears the suspension of the agent
(agent-error g)  ; returns nil
(send g inc)  ; works now because we cleared the cached error
@g  ; returns 1
(dotimes [_ 1000] (send-off g long-task))
;; block for 100ms or until all actions over (whichever earlier)
(await-for 100 g)
(await g)  ; block until all actions dispatched till now are over

有两个可选参数:error-handler:error-mode,我们可以在代理上配置这些参数以对错误处理和挂起有更精细的控制,如下面的代码片段所示:

(def g (agent 0 :error-handler (fn [x] (println "Found:" x))))  ; incorrect arity
(send g (partial / 10))  ; no error encountered because error-handler arity is wrong
(def g (agent 0 :error-handler (fn [ag x] (println "Found:" x))))  ; correct arity
(send g (partial / 10))  ; prints the message
(set-error-handler! g (fn [ag x] (println "Found:" x)))  ; equiv of :error-handler arg
(def h (agent 0 :error-mode :continue))
(send h (partial / 10))  ; error encountered, but agent not suspended
(send h inc)
@h  ; returns 1
(set-error-mode! h :continue)  ; equiv of :error-mode arg, other possible value :fail

为什么你应该使用代理

正如“原子”实现只使用比较和交换而不是锁定一样,底层的“代理”特定实现主要使用比较和交换操作。代理实现仅在事务(将在下一节讨论)中调度操作或重启代理时使用锁定。所有操作都在代理中排队并串行分发,无论并发级别如何。这种串行性质使得可以独立且无争用地执行操作。对于同一个代理,永远不会同时执行多个操作。由于没有锁定,对代理的读取(deref@)永远不会因为写入而被阻塞。然而,所有操作都是相互独立的——它们的执行没有重叠。

实现甚至确保动作的执行会阻塞队列中后续的动作。尽管动作是在线程池中执行的,但同一代理的动作永远不会并发执行。这是一个出色的排序保证,也由于其串行性质而扩展了自然的协调机制。然而,请注意,这种排序协调仅限于单个代理。如果一个代理的动作发送给两个其他代理,它们不会自动协调。在这种情况下,您可能希望使用事务(将在下一节中介绍)。

由于代理区分低延迟和阻塞作业,作业将在适当的线程池中执行。不同代理上的动作可以并发执行,从而最大限度地利用线程资源。与原子不同,代理的性能不会因高竞争而受阻。事实上,对于许多情况,由于动作的串行缓冲,代理非常有意义。一般来说,代理非常适合高容量 I/O 任务,或者在操作顺序在高度竞争场景中提供优势的情况下。

嵌套

当一个代理的动作发送给同一个代理的另一个动作时,这就是嵌套的情况。如果代理不参与 STM 事务(将在下一节中介绍),这本来可能没有什么特别之处。然而,代理确实参与了 STM 事务,这给代理的实现带来了一定的约束,需要对动作进行第二层缓冲。目前,可以说嵌套发送被排队在代理的线程局部队列中,而不是常规队列中。线程局部队列只对执行动作的线程可见。在执行动作时,除非出现错误,否则代理会隐式调用相当于release-pending-sends函数的功能,将动作从第二级线程局部队列转移到正常动作队列。请注意,嵌套只是代理的实现细节,没有其他影响。

协调事务引用和状态

在前面的章节中,我们了解到原子提供了原子读-更新操作。如果我们需要在两个或更多原子之间执行原子读-更新操作怎么办?这显然是一个协调问题。某个实体必须监控读取和更新的过程,以确保值不会被破坏。这就是引用的作用——提供一个基于软件事务内存(STM)的系统,该系统负责在多个引用之间执行并发原子读-更新操作,以确保所有更新都通过,或者在失败的情况下,没有任何更新。与原子一样,在失败的情况下,引用会从头开始重试整个操作,使用新的值。

Clojure 的 STM 实现是粗粒度的。它工作在应用级别的对象和聚合(即聚合的引用),范围仅限于程序中的所有 refs,构成了“Ref 世界”。对 ref 的任何更新都只能以同步方式发生,在事务中,在 dosync 代码块内,在同一线程中。它不能跨越当前线程。实现细节揭示,在事务的生命周期内维护了一个线程本地的交易上下文。一旦控制达到另一个线程,相同的上下文就不再可用。

与 Clojure 中的其他引用类型一样,对 ref 的读取永远不会被更新阻塞,反之亦然。然而,与其他引用类型不同,ref 的实现并不依赖于无锁自旋,而是内部使用锁、低级等待/通知、死锁检测和基于年龄的抢占。

alter 函数用于读取和更新一个 ref 的值,而 ref-set 用于重置值。大致来说,对于 refs 来说,alterref-set 类似于原子操作中的 swap!reset!。就像 swap! 一样,alter 接受一个无副作用的函数(和参数),并且可能在竞争时重试多次。然而,与原子不同,不仅 alter,而且 ref-set 和简单的 deref 也可能在竞争时导致事务重试。以下是一个如何使用事务的非常简单的例子:

(def r1 (ref [:a :b :c]))
(def r2 (ref [1 2 3]))
(alter r1 conj :d)  ; IllegalStateException No transaction running...
(dosync (let [v (last @r1)] (alter r1 pop) (alter r2 conj v)))
@r1  ; returns [:a :b]
@r2  ; returns [1 2 3 :c]
(dosync (ref-set r1 (conj @r1 (last @r2))) (ref-set r2 (pop @r2)))
@r1  ; returns [:a :b :c]
@r2  ; returns [1 2 3]

Ref 特性

Clojure 在事务中维护了 原子性一致性隔离性ACI)特性。这与许多数据库提供的 ACID 保证中的 A、C 和 I 相重叠。原子性意味着事务中的所有更新要么全部成功完成,要么全部不完成。一致性意味着事务必须保持一般正确性,并应遵守验证设置的约束——任何异常或验证错误都应回滚事务。除非共享状态受到保护,否则对它的并发更新可能导致多步事务在不同步骤中看到不同的值。隔离性意味着事务中的所有步骤都将看到相同的值,无论更新多么并发。

Clojure 引用使用一种称为多版本并发控制(MVCC)的技术来为事务提供快照隔离。在 MVCC 中,不是通过锁定(这可能会阻塞事务),而是维护队列,以便每个事务都可以使用自己的快照副本进行操作,该副本在其“读点”处获取,独立于其他事务。这种方法的主要好处是,只读事务外的操作可以无冲突地通过。没有引用冲突的事务可以并发进行。在与数据库系统的粗略比较中,Clojure 引用隔离级别在读取事务外的引用时为“读取已提交”,而在事务内部默认为“可重复读”。

引用历史和在事务中解除引用操作

我们之前讨论过,对引用的读取和更新操作都可能导致事务重试。事务中的读取可以配置为使用引用历史,这样快照隔离实例就存储在历史队列中,并由事务中的读操作使用。默认情况下,不使用历史队列,这可以节省堆空间,并在事务中提供强一致性(避免数据陈旧)。

使用引用历史可以降低因读冲突导致的事务重试的可能性,从而提供弱一致性。因此,它是一种性能优化的工具,但会牺牲一致性。在许多场景中,程序不需要强一致性——如果我们知道权衡利弊,我们可以适当选择。Clojure 引用实现中的快照隔离机制由自适应历史队列支持。历史队列会动态增长以满足读请求,并且不会超过为引用设置的极限。默认情况下,历史记录是禁用的,因此我们需要在初始化时指定它,或者稍后设置。以下是如何使用历史的示例:

(def r (ref 0 :min-history 5 :max-history 10))
(ref-history-count r)  ; returns 0, because no snapshot instances are queued so far
(ref-min-history r)  ; returns 5
(ref-max-history r)  ; returns 10
(future (dosync (println "Sleeping 20 sec") (Thread/sleep 20000) (ref-set r 10)))
(dosync (alter r inc))  ; enter this within few seconds after the previous expression
;; The message "Sleeping 20 sec" should appear twice due to transaction-retry
(ref-history-count r)  ; returns 2, the number of snapshot history elements
(.trimHistory ^clojure.lang.Ref r)
(ref-history-count r)  ; returns 0 because we wiped the history
(ref-min-history r 10)  ; reset the min history
(ref-max-history r 20)  ; reset the max history count

最小/最大历史限制与数据陈旧窗口的长度成比例。它还取决于更新和读操作之间的相对延迟差异,以确定在给定主机系统上最小历史和最大历史的工作范围。可能需要一些尝试和错误才能得到正确的范围。作为一个粗略的估计,读操作只需要足够的 min-history 元素来避免事务重试,因为在一次读操作期间可以有那么多更新。max-history 元素可以是 min-history 的倍数,以覆盖任何历史超限或欠限。如果相对延迟差异不可预测,那么我们必须为最坏情况规划一个 min-history,或者考虑其他方法。

事务重试和抢占

一个事务可以在五个不同的状态之一内部运行——运行中、提交中、重试、已杀死和已提交。一个事务可能因为各种原因而被杀死。异常是杀死事务的常见原因。但让我们考虑一个特殊情况,即一个事务被多次重试,但似乎没有成功提交——解决方案是什么?Clojure 支持基于年龄的抢占,其中较老的事务会自动尝试中止较新的事务,以便较新的事务稍后重试。如果抢占仍然不起作用,作为最后的手段,在 10,000 次重试尝试的硬性限制之后,事务将被杀死,然后抛出异常。

使用 ensure 提高事务一致性

Clojure 的事务一致性在性能和安全之间取得了良好的平衡。然而,有时我们可能需要可序列化的一致性来保持事务的正确性。具体来说,在面临事务重试的情况下,当一个事务的正确性依赖于一个 ref 的状态,在该事务中,ref 被另一个事务同时更新时,我们有一个称为“写偏斜”的条件。关于写偏斜的维基百科条目en.wikipedia.org/wiki/Snapshot_isolation,描述得很好,但让我们看看一个更具体的例子。假设我们想要设计一个具有两个引擎的飞行模拟系统,并且系统级的一个约束是不允许同时关闭两个引擎。如果我们把每个引擎建模为一个 ref,并且某些机动确实需要我们关闭一个引擎,我们必须确保另一个引擎是开启的。我们可以使用ensure来实现。通常,当需要在 refs 之间维护一致的关系(不变性)时,就需要ensure。这不能通过验证函数来保证,因为它们只有在事务提交时才会起作用。验证函数将看到相同的值,因此无法提供帮助。

写偏斜可以通过同名的ensure函数来解决,该函数本质上阻止其他事务修改 ref。它类似于锁定操作,但在实践中,当重试代价高昂时,它提供了比显式的读取和更新操作更好的并发性。使用ensure非常简单——(ensure ref-object)。然而,由于它在事务期间持有的锁,它可能在性能上代价高昂。使用ensure来管理性能涉及到在重试延迟和由于确保状态而丢失的吞吐量之间进行权衡。

使用交换操作减少事务重试

交换操作与它们应用的顺序无关。例如,从事务 t1 和 t2 中递增计数器引用 c1 将产生相同的效果,无论 t1 和 t2 提交更改的顺序如何。引用为事务中可交换的更改函数提供了特殊的优化——commute函数,它与alter(相同的语法)类似,但具有不同的语义。像alter一样,commute函数在事务提交期间原子性地应用。然而,与alter不同,commute不会在竞争时导致事务重试,并且没有关于commute函数应用顺序的保证。这实际上使得commute在作为操作结果返回有意义值时几乎无用。事务中的所有commute函数都在事务提交期间使用事务中的最终引用值重新应用。

如我们所见,交换操作减少了竞争,从而优化了整体事务吞吐量的性能。一旦我们知道一个操作是可交换的,并且我们不会以有意义的方式使用它的返回值,那么在决定是否使用它时几乎没有权衡——我们只需继续使用它。实际上,考虑到引用事务,考虑到交换,程序设计不是一个坏主意。

代理可以参与事务

在关于代理的上一节中,我们讨论了代理如何与排队更改函数协同工作。代理也可以参与引用事务,从而使得在事务中结合使用引用和代理成为可能。然而,代理不包括在“引用世界”中,因此事务作用域不会扩展到代理中更改函数的执行。相反,事务仅确保发送给代理的更改在事务提交发生前被排队。

嵌套子节,在关于代理的早期章节中,讨论了一个第二层线程局部队列。这个线程局部队列在事务期间用于持有发送给代理的更改,直到提交。线程局部队列不会阻塞发送给代理的其他更改。事务外部的更改永远不会在线程局部队列中缓冲;相反,它们被添加到代理中的常规队列。

代理参与事务提供了有趣的设计角度,其中协调的以及独立/顺序操作可以作为工作流程进行流水线处理,以获得更好的吞吐量和性能。

嵌套事务

Clojure 事务具有嵌套感知性并且可以很好地组合。但是,为什么需要嵌套事务呢?通常,独立的代码单元可能有自己的低粒度事务,高级代码可以利用这些事务。当高级调用者本身需要将操作包装在事务中时,就会发生嵌套事务。嵌套事务有自己的生命周期和运行状态。然而,外部事务可以在检测到失败时取消内部事务。

“ref 世界”快照ensurecommute在嵌套事务的所有(即外部和内部)级别之间共享。因此,内部事务被视为在外部事务内部的其他引用更改操作(类似于alterref-set等)。监视和内部锁的实现由各自的嵌套级别处理。内部事务中的竞争检测会导致内部和外部事务的重新启动。所有级别的提交最终在最外层事务提交时作为全局状态生效。监视器,尽管在每个单独的事务级别上跟踪,但在提交时最终生效。仔细观察嵌套事务实现可以看出,嵌套对事务性能的影响很小或没有。

性能考虑

Clojure Ref 可能是迄今为止实现的最复杂的引用类型。由于其特性,特别是其事务重试机制,在高度竞争场景下,这样的系统可能会有良好的性能可能并不立即明显。

理解其细微差别和最佳使用方式将有所帮助:

  • 我们在事务中不使用具有副作用的变化,除非可能将 I/O 变化发送到代理,其中变化被缓冲直到提交。因此,根据定义,我们在事务中不执行任何昂贵的 I/O 工作。因此,这项工作的重试成本也会很低。

  • 事务的更改函数应该尽可能小。这降低了延迟,因此重试的成本也会更低。

  • 任何没有与至少另一个引用同时更新的引用不需要是引用——在这种情况下原子就足够好了。现在,由于引用只在组中才有意义,它们的竞争与组大小成正比。在事务中使用的小组引用导致低竞争、低延迟和高吞吐量。

  • 交换函数提供了在不产生任何惩罚的情况下提高事务吞吐量的好机会。识别这些情况并考虑交换进行设计可以帮助显著提高性能。

  • 引用非常粗粒度——它们在应用聚合级别工作。通常,程序可能需要更细粒度地控制事务资源。这可以通过引用条带化实现,例如 Megaref (github.com/cgrand/megaref),通过提供关联引用的受限视图,从而允许更高的并发性。

  • 在高竞争场景中,事务中引用组的大小不能小,考虑使用代理,因为它们由于序列性质而没有竞争。代理可能不是事务的替代品,但我们可以使用由原子、引用和代理组成的管道,以减轻竞争与延迟的担忧。

引用和事务的实现相当复杂。幸运的是,我们可以检查源代码,并浏览可用的在线和离线资源。

动态变量绑定和状态

在 Clojure 的引用类型中,第四种是动态变量。从 Clojure 1.3 开始,所有变量默认都是静态的。必须显式声明变量以使其成为动态的。一旦声明,动态变量就可以在每线程的基础上绑定到新的值。不同线程上的绑定不会相互阻塞。这里有一个示例:

(def ^:dynamic *foo* "bar")
(println *foo*)  ; prints bar
(binding [*foo* "baz"] (println *foo*))  ; prints baz
(binding [*foo* "bar"] (set! *foo* "quux") (println *foo*))  ; prints quux

由于动态绑定是线程局部的,所以在多线程场景中使用可能会很棘手。长期以来,动态变量被库和应用滥用,作为传递给多个函数使用的公共参数的手段。然而,这种风格被认为是一种反模式,并受到谴责。通常,在反模式动态变量中,变量会被宏包装,以在词法作用域中包含动态线程局部绑定。这会导致多线程和懒序列出现问题。

那么,如何有效地使用动态变量呢?动态变量的查找成本比静态变量查找更高。即使是传递一个函数参数,在性能上也要比查找动态变量便宜得多。绑定动态变量会带来额外的开销。显然,在性能敏感的代码中,最好根本不使用动态变量。然而,在复杂或递归调用图场景中,动态变量可能非常有用,用于持有临时的线程局部状态,在这些场景中,性能并不重要,而且不会被宣传或泄露到公共 API 中。动态变量绑定可以像栈一样嵌套和展开,这使得它们既吸引人又适合这类任务。

验证和监视引用类型

变量(静态和动态)、原子、引用和代理提供了一种验证作为状态设置的价值的方法——一个接受新值作为参数的 validator 函数,如果成功则返回逻辑 true,如果出错则抛出异常/返回逻辑 false(错误和 nil 值)。它们都尊重验证函数返回的结果。如果成功,更新将通过,如果出错,则抛出异常。以下是声明验证器并将其与引用类型关联的语法:

(def t (atom 1 :validator pos?))
(def g (agent 1 :validator pos?))
(def r (ref 1 :validator pos?))
(swap! t inc)  ; goes through, because value after increment (2) is positive
(swap! t (constantly -3))  ; throws exception
(def v 10)
(set-validator! (var v) pos?)
(set-validator! t (partial < 10)) ; throws exception
(set-validator! g (partial < 10)) ; throws exception
(set-validator! r #(< % 10)) ; works

验证器在更新引用类型时会导致实际失败。对于变量和原子,它们通过抛出异常简单地阻止更新。在代理中,验证失败会导致代理失败,需要代理重新启动。在引用内部,验证失败会导致事务回滚并重新抛出异常。

观察引用类型变化的另一种机制是“观察者”。与验证者不同,观察者是被动的——它在更新发生后才会被通知。因此,观察者无法阻止更新通过,因为它只是一个通知机制。对于事务,观察者仅在事务提交后才会被调用。虽然一个引用类型上只能设置一个验证者,但另一方面,可以将多个观察者与一个引用类型关联。其次,在添加观察者时,我们可以指定一个键,这样通知就可以通过键来识别,并由观察者相应地处理。以下是使用观察者的语法:

(def t (atom 1))
(defn w [key iref oldv newv] (println "Key:" key "Old:" oldv "New:" newv))
(add-watch t :foo w)
(swap! t inc)  ; prints "Key: :foo Old: 1 New: 2"

与验证者一样,观察者是在引用类型的线程中同步执行的。对于原子和引用,这可能没问题,因为通知观察者的同时,其他线程可以继续进行它们的更新。然而在代理中,通知发生在更新发生的同一线程中——这使得更新延迟更高,吞吐量可能更低。

Java 并发数据结构

Java 有许多旨在并发和线程安全的可变数据结构,这意味着多个调用者可以同时安全地访问这些数据结构,而不会相互阻塞。当我们只需要高度并发的访问而不需要状态管理时,这些数据结构可能非常适合。其中一些采用了无锁算法。我们已经在原子更新和状态部分讨论了 Java 原子状态类,所以这里不再重复。相反,我们只讨论并发队列和其他集合。

所有这些数据结构都位于 java.util.concurrent 包中。这些并发数据结构是为了利用 JSR 133 “Java 内存模型和线程规范修订” (gee.cs.oswego.edu/dl/jmm/cookbook.html) 的实现而量身定制的,该实现首次出现在 Java 5 中。

并发映射

Java 有一个可变的并发哈希映射——java.util.concurrent.ConcurrentHashMap(简称 CHM)。在实例化类时可以可选地指定并发级别,默认为 16。CHM 实现在内部将映射条目分区到哈希桶中,并使用多个锁来减少每个桶的竞争。读取永远不会被写入阻塞,因此它们可能是过时的或不一致的——这种情况通过内置的检测来应对,并发出锁以再次以同步方式读取数据。这是针对读取数量远多于写入的场景的优化。在 CHM 中,所有单个操作几乎都是常数时间,除非由于锁竞争陷入重试循环。

与 Clojure 的持久映射相比,CHM 不能接受 nullnil)作为键或值。Clojure 的不可变标量和集合自动适合与 CHM 一起使用。需要注意的是,只有 CHM 中的单个操作是原子的,并表现出强一致性。由于 CHM 操作是并发的,聚合操作提供的致性比真正的操作级致性要弱。以下是我们可以使用 CHM 的方法。在 CHM 中提供更好一致性的单个操作是安全的。聚合操作应保留在我们知道其一致性特征和相关权衡的情况下使用:

(import 'java.util.concurrent.ConcurrentHashMap)
(def ^ConcurrentHashMap m (ConcurrentHashMap.))
(.put m :english "hi")                    ; individual operation
(.get m :english)                           ; individual operation
(.putIfAbsent m :spanish "alo")    ; individual operation
(.replace m :spanish "hola")         ; individual operation
(.replace m :english "hi" "hello")  ; individual compare-and-swap atomic operation
(.remove m :english)                     ; individual operation
(.clear m)    ; aggregate operation
(.size m)      ; aggregate operation
(count m)    ; internally uses the .size() method
;; aggregate operation
(.putAll m {:french "bonjour" :italian "buon giorno"})
(.keySet m)  ; aggregate operation
(keys m)      ; calls CHM.entrySet() and on each pair java.util.Map.Entry.getKey()
(vals m)       ; calls CHM.entrySet() and on each pair java.util.Map.Entry.getValue()

java.util.concurrent.ConcurrentSkipListMap 类(简称 CSLM)是 Java 中另一种并发可变映射数据结构。CHM 和 CSLM 之间的区别在于,CSLM 在所有时间都提供具有 O(log N) 时间复杂度的排序视图。默认情况下,排序视图具有键的自然顺序,可以通过在实例化 CSLM 时指定 Comparator 实现来覆盖。CSLM 的实现基于跳表,并提供导航操作。

java.util.concurrent.ConcurrentSkipListSet 类(简称 CSLS)是一个基于 CSLM 实现的并发可变集合。虽然 CSLM 提供了映射 API,但 CSLS 在行为上类似于集合数据结构,同时借鉴了 CSLM 的特性。

并发队列

Java 内置了多种可变和并发内存队列的实现。队列数据结构是用于缓冲、生产者-消费者风格实现以及将这些单元管道化以形成高性能工作流程的有用工具。我们不应将它们与用于批量作业中类似目的的持久队列混淆。Java 的内存队列不是事务性的,但它们只为单个队列操作提供原子性和强一致性保证。聚合操作提供较弱的致性。

java.util.concurrent.ConcurrentLinkedQueue (CLQ) 是一个无锁、无阻塞的无界“先进先出” (FIFO) 队列。FIFO 意味着一旦元素被添加到队列中,队列元素的顺序就不会改变。CLQ 的 size() 方法不是一个常数时间操作;它取决于并发级别。这里有一些使用 CLQ 的例子:

(import 'java.util.concurrent.ConcurrentLinkedQueue)
(def ^ConcurrentLinkedQueue q (ConcurrentLinkedQueue.))
(.add q :foo)
(.add q :bar)
(.poll q)  ; returns :foo
(.poll q)  ; returns :bar
队列 是否阻塞? 是否有界? FIFO? 公平性? 备注
CLQ 无阻塞,但 size() 不是常数时间操作
ABQ 可选 容量在实例化时固定
DQ 元素实现了 Delayed 接口
LBQ 可选 容量灵活,但没有公平性选项
PBQ 元素按优先级顺序消费
SQ 可选 没有容量;它充当一个通道

java.util.concurrent 包中,ArrayBlockingQueue (ABQ),DelayQueue (DQ),LinkedBlockingQueue (LBQ),PriorityBlockingQueue (PBQ) 和 SynchronousQueue (SQ) 实现了 BlockingQueue (BQ) 接口。它的 Javadoc 描述了其方法调用的特性。ABQ 是一个基于数组的固定容量、FIFO 队列。LBQ 也是一个 FIFO 队列,由链表节点支持,并且是可选的有界(默认 Integer.MAX_VALUE)。ABQ 和 LBQ 通过阻塞满容量时的入队操作来生成“背压”。ABQ 支持可选的公平性(有性能开销),按访问它的线程顺序。

DQ 是一个无界队列,接受与延迟相关的元素。队列元素不能为 null,并且必须实现 java.util.concurrent.Delayed 接口。元素只有在延迟过期后才能从队列中移除。DQ 对于在不同时间安排元素的处理非常有用。

PBQ 是无界且阻塞的,同时允许按优先级从队列中消费元素。元素默认具有自然排序,可以通过在实例化队列时指定 Comparator 实现来覆盖。

SQ 实际上根本不是一个队列。相反,它只是生产者或消费者线程的一个屏障。生产者会阻塞,直到消费者移除元素,反之亦然。SQ 没有容量。然而,SQ 支持可选的公平性(有性能开销),按线程访问的顺序。

Java 5 之后引入了一些新的并发队列类型。自从 JDK 1.6 以来,在java.util.concurrent包中,Java 有BlockingDequeBD),其中LinkedBlockingDequeLBD)是唯一可用的实现。BD 通过添加Deque(双端队列)操作来构建在 BQ 之上,即从队列两端添加元素和消费元素的能力。LBD 可以实例化一个可选的容量(有限制)以阻塞溢出。JDK 1.7 引入了TransferQueueTQ),其中LinkedTransferQueueLTQ)是唯一实现。TQ 以扩展 SQ 概念的方式,使得生产者和消费者阻塞元素队列。这将通过保持它们忙碌来更好地利用生产者和消费者线程。LTQ 是 TQ 的无限制实现,其中size()方法不是常数时间操作。

Clojure 对并发队列的支持

我们在《Clojure 抽象》的第二章中介绍了持久队列。Clojure 有一个内置的seque函数,它基于 BQ 实现(默认为 LBQ)来暴露预写序列。序列可能是惰性的,预写缓冲区控制要实现多少元素。与块大小为 32 的块序列不同,预写缓冲区的大小是可控制的,并且在所有时间都可能被填充,直到源序列耗尽。与块序列不同,32 个元素的块不会突然实现。它是逐渐和平稳地实现的。

在底层,Clojure 的seque使用代理在预写缓冲区中填充数据。在seque的 2 参数版本中,第一个参数应该是正整数,或者是一个 BQ(ABQ、LBQ 等)的实例,最好是有限制的。

使用线程的并发

在 JVM 上,线程是并发的事实上的基本工具。多个线程生活在同一个 JVM 中;它们共享堆空间,并竞争资源。

JVM 对线程的支持

JVM 线程是操作系统线程。Java 将底层 OS 线程包装为java.lang.Thread类的实例,并围绕它构建 API 以与线程一起工作。JVM 上的线程有多个状态:新建、可运行、阻塞、等待、定时等待和终止。线程通过覆盖Thread类的run()方法或通过将java.lang.Runnable接口的实例传递给Thread类的构造函数来实例化。

调用Thread实例的start()方法将在新线程中启动其执行。即使 JVM 中只运行一个线程,JVM 也不会关闭。调用带有参数true的线程的setDaemon(boolean)方法将线程标记为守护线程,如果没有其他非守护线程正在运行,则可以自动关闭该线程。

所有 Clojure 函数都实现了 java.lang.Runnable 接口。因此,在新的线程中调用一个函数非常简单:

(defn foo5 [] (dotimes [_ 5] (println "Foo")))
(defn barN [n] (dotimes [_ n] (println "Bar")))
(.start (Thread. foo5))  ; prints "Foo" 5 times
(.start (Thread. (partial barN 3)))  ; prints "Bar" 3 times

run() 方法不接受任何参数。我们可以通过创建一个不需要参数的高阶函数来解决这个问题,但内部应用参数 3

JVM 中的线程池

创建线程会导致操作系统 API 调用,这并不总是便宜的。一般的做法是创建一个线程池,可以回收用于不同任务。Java 有内置的线程池支持。名为 java.util.concurrent.ExecutorService 的接口代表了线程池的 API。创建线程池最常见的方式是使用 java.util.concurrent.Executors 类中的工厂方法:

(import 'java.util.concurrent.Executors)
(import 'java.util.concurrent.ExecutorService)
(def ^ExecutorService a (Executors/newSingleThreadExecutor))  ; bounded pool
(def ^ExecutorService b (Executors/newCachedThreadPool))  ; unbounded pool
(def ^ExecutorService c (Executors/newFixedThreadPool 5))  ; bounded pool
(.execute b #(dotimes [_ 5] (println "Foo")))  ; prints "Foo" 5 times

之前的例子等同于我们在上一小节中看到的原始线程的例子。线程池也有能力帮助跟踪新线程中执行函数的完成情况和返回值。ExecutorService 接受一个 java.util.concurrent.Callable 实例作为参数,用于启动任务的几个方法,并返回 java.util.concurrent.Future 以跟踪最终结果。

所有 Clojure 函数也实现了 Callable 接口,因此我们可以如下使用它们:

(import 'java.util.concurrent.Callable)
(import 'java.util.concurrent.Future)
(def ^ExecutorService e (Executors/newSingleThreadExecutor))
(def ^Future f (.submit e (cast Callable #(reduce + (range 10000000)))))
(.get f)  ; blocks until result is processed, then returns it

这里描述的线程池与我们在之前的代理部分中简要看到的相同。当不再需要时,线程池需要通过调用 shutdown() 方法来关闭。

Clojure 并发支持

Clojure 有一些巧妙的内置功能来处理并发。我们已经在之前的小节中讨论了代理,以及它们如何使用线程池。Clojure 中还有一些其他并发功能来处理各种用例。

Future

在本节中,我们之前已经看到了如何使用 Java API 来启动一个新线程,以执行一个函数。我们还学习了如何获取结果。Clojure 有一个内置的支持称为 "futures",以更平滑和集成的方式完成这些事情。futures 的基础是 future-call 函数(它接受一个无参数函数作为参数),以及基于前者的宏 future(它接受代码体)。两者都会立即启动一个线程来执行提供的代码。以下代码片段说明了与 future 一起工作的函数以及如何使用它们:

;; runs body in new thread
(def f (future (println "Calculating") (reduce + (range 1e7))))
(def g (future-call #(do (println "Calculating") (reduce + (range 1e7)))))  ; takes no-arg fn
(future? f)                  ; returns true
(future-cancel g)        ; cancels execution unless already over (can stop mid-way)
(future-cancelled? g) ; returns true if canceled due to request
(future-done? f)         ; returns true if terminated successfully, or canceled
(realized? f)               ; same as future-done? for futures
@f                              ; blocks if computation not yet over (use deref for timeout)

future-cancel 的一个有趣方面是,它有时不仅能够取消尚未开始的任务,还可能中止那些正在执行一半的任务:

(let [f (future (println "[f] Before sleep")
                (Thread/sleep 2000)
                (println "[f] After sleep")
                2000)]
  (Thread/sleep 1000)
  (future-cancel f)
  (future-cancelled? f))
;; [f] Before sleep  ← printed message (second message is never printed)
;; true  ← returned value (due to future-cancelled?)

之前的场景发生是因为 Clojure 的 future-cancel 以一种方式取消未来(future),如果执行已经开始,可能会被中断,导致 InterruptedException,如果未显式捕获,则简单地终止代码块。注意来自未来中执行代码的异常,因为默认情况下,它们不会被详细报告!Clojure 的未来(futures)使用“solo”线程池(用于执行可能阻塞的操作),这是我们之前在讨论代理时提到的。

承诺

一个承诺(promise)是代表一个可能发生也可能不发生的计算结果的占位符。承诺并不直接与任何计算相关联。根据定义,承诺并不暗示计算何时发生,因此实现承诺。

通常,承诺起源于代码的一个地方,由知道何时以及如何实现承诺的代码的其他部分实现。这通常发生在多线程代码中。如果承诺尚未实现,任何尝试读取值的尝试都会阻塞所有调用者。如果承诺已经实现,那么所有调用者都可以读取值而不会被阻塞。与未来(futures)一样,可以使用 deref 在超时后读取承诺。

这里有一个非常简单的例子,展示了如何使用承诺:

(def p (promise))
(realized? p)  ; returns false
@p  ; at this point, this will block until another thread delivers the promise
(deliver p :foo)
@p  ; returns :foo (for timeout use deref)

承诺是一个非常强大的工具,可以作为函数参数传递。它可以存储在引用类型中,或者简单地用于高级协调。

Clojure 并行化与 JVM

我们在 第一章,通过设计实现性能 中观察到,并行化是硬件的函数,而并发是软件的函数,由硬件支持辅助。除了那些本质上纯粹顺序的算法外,并发是实现并行化和提高性能的首选方法。不可变和无状态数据是并发的催化剂,因为没有可变数据,线程之间没有竞争。

摩尔定律

在 1965 年,英特尔联合创始人戈登·摩尔观察到,集成电路每平方英寸的晶体管数量每 24 个月翻一番。他还预测这种趋势将持续 10 年,但实际上,它一直持续到现在,几乎半个世纪。更多的晶体管导致了更强的计算能力。在相同面积内晶体管数量增加,我们需要更高的时钟速度来传输信号到所有的晶体管。其次,晶体管需要变得更小以适应。大约在 2006-2007 年,电路能够工作的时钟速度达到了大约 2.8GHz,这是由于散热问题和物理定律的限制。然后,多核处理器应运而生。

Amdahl 定律

多核处理器自然需要分割计算以实现并行化。从这里开始,出现了一个冲突——原本设计为顺序运行的程序无法利用多核处理器的并行化特性。程序必须被修改,以便在每一步找到分割计算的机会,同时考虑到协调成本。这导致了一个限制,即程序的速度不能超过其最长的顺序部分(竞争,或串行性),以及协调开销。这一特性被 Amdahl 定律所描述。

通用可扩展性定律

尼尔·冈瑟博士的通用可扩展性定律(USL)是 Amdahl 定律的超集,它将 竞争(α)一致性(β) 作为量化可扩展性的首要关注点,使其非常接近现实中的并行系统。一致性意味着协调开销(延迟)在使并行化程序的一部分结果对另一部分可用时的协调。虽然 Amdahl 定律表明竞争(串行性)会导致性能水平化,但 USL 表明性能实际上随着过度并行化而下降。USL 用以下公式描述:

C(N) = N / (1 + α ((N – 1) + β N (N – 1)))

在这里,C(N) 表示相对容量或吞吐量,以并发源为依据,例如物理处理器,或驱动软件应用的用户。α 表示由于共享数据或顺序代码而引起的竞争程度,β 表示维护共享数据一致性所造成的惩罚。我鼓励您进一步研究 USL(www.perfdynamics.com/Manifesto/USLscalability.html),因为这是研究并发对可扩展性和系统性能影响的重要资源。

Clojure 对并行化的支持

依赖于变动的程序在创建对可变状态的竞争之前无法并行化其部分。它需要协调开销,这使情况变得更糟。Clojure 的不可变特性更适合并行化程序的部分。Clojure 还有一些结构,由于 Clojure 考虑了可用的硬件资源,因此适合并行化。结果是,操作针对某些用例场景进行了优化。

pmap

pmap 函数(类似于 map)接受一个函数和一个或多个数据元素集合作为参数。该函数被应用于数据元素集合中的每个元素,这样一些元素可以并行地由该函数处理。并行度因子由 pmap 实现在运行时选择,通常大于可用的处理器总数。它仍然以惰性方式处理元素,但实现因子与并行度因子相同。

查看以下代码:

(pmap (partial reduce +)
        [(range 1000000)
         (range 1000001 2000000)
         (range 2000001 3000000)])

要有效地使用 pmap,我们必须理解它的用途。正如文档所述,它是为计算密集型函数设计的。它针对 CPU 密集型和缓存密集型工作进行了优化。对于高延迟和低 CPU 任务,如阻塞 I/O,pmap 是一个不合适的选择。另一个需要注意的陷阱是 pmap 中使用的函数是否执行了大量的内存操作。由于相同的函数将在所有线程中应用,所有处理器(或核心)可能会竞争内存互连和子系统带宽。如果并行内存访问成为瓶颈,由于内存访问的竞争,pmap 无法真正实现操作的并行化。

另一个关注点是当多个 pmap 操作同时运行时会发生什么?Clojure 不会尝试检测同时运行多个 pmap。对于每个新的 pmap 操作,都会重新启动相同数量的线程。开发者负责确保并发 pmap 执行的性能特性和程序的响应时间。通常,当延迟原因是至关重要的,建议限制程序中运行的 pmap 并发实例。

pcalls

pcalls 函数是使用 pmap 构建的,因此它借鉴了后者的属性。然而,pcalls 函数接受零个或多个函数作为参数,并并行执行它们,将调用结果作为列表返回。

pvalues

pvalues 宏是使用 pcalls 构建的,因此它间接共享了 pmap 的属性。它的行为类似于 pcalls,但它接受零个或多个在并行中使用 pmap 评估的 S-表达式。

Java 7 的 fork/join 框架

Java 7 引入了一个名为 "fork/join" 的新并行框架,该框架基于分而治之和工作窃取调度算法。使用 fork/join 框架的基本思路相当简单——如果工作足够小,则直接在同一线程中执行;否则,将工作分成两部分,在 fork/join 线程池中调用它们,并等待结果合并。

这样,工作会递归地分成更小的部分,例如倒置树,直到最小的部分可以仅用单个线程执行。当叶/子树工作返回时,父节点将所有子节点的结果合并,并返回结果。

Fork/Join 框架在 Java 7 中通过一种特殊的线程池实现;请查看 java.util.concurrent.ForkJoinPool。这个线程池的特殊之处在于它接受 java.util.concurrent.ForkJoinTask 类型的作业,并且每当这些作业阻塞,等待子作业完成时,等待作业使用的线程会被分配给子作业。当子作业完成其工作后,线程会被分配回阻塞的父作业以继续执行。这种动态线程分配的方式被称为“工作窃取”。Fork/Join 框架可以从 Clojure 内部使用。ForkJoinTask 接口有两个实现:java.util.concurrent 包中的 RecursiveActionRecursiveTask。具体来说,RecursiveTask 在 Clojure 中可能更有用,因为 RecursiveAction 是设计用来处理可变数据的,并且其操作不会返回任何值。

使用 Fork/Join 框架意味着选择将作业拆分成批次的批大小,这是并行化长时间作业的一个关键因素。批大小过大可能无法充分利用所有 CPU 核心;另一方面,批大小过小可能会导致更长的开销,协调父/子批次。正如我们将在下一节中看到的,Clojure 与 Fork/join 框架集成以并行化减少器实现。

使用减少器实现并行化

减少器是 Clojure 1.5 中引入的新抽象,预计在未来版本中将对 Clojure 的其余实现产生更广泛的影响。它们描绘了在 Clojure 中处理集合的另一种思考方式——关键概念是打破集合只能顺序、惰性或产生序列处理的观念,以及更多。摆脱这种行为保证一方面提高了进行贪婪和并行操作的可能性,另一方面则带来了约束。减少器与现有集合兼容。

例如,对常规的 map 函数的敏锐观察揭示,其经典定义与产生结果机制(递归)、顺序(顺序)、惰性(通常)和表示(列表/序列/其他)方面相关联。实际上,这大部分定义了“如何”执行操作,而不是“需要做什么”。在 map 的情况下,“需要做什么”是关于对其集合参数的每个元素应用函数。但由于集合类型可以是各种类型(树状结构、序列、迭代器等),操作函数不知道如何遍历集合。减少器将操作的“需要做什么”和“如何做”部分解耦。

可约性、减少函数、减少转换

集合种类繁多,因此只有集合本身知道如何导航自己。在归约器模型的基本层面上,每个集合类型内部都有一个“reduce”操作,可以访问其属性和行为,以及它返回的内容。这使得所有集合类型本质上都是“可归约”的。所有与集合一起工作的操作都可以用内部“reduce”操作来建模。这种操作的新建模形式是一个“归约函数”,它通常有两个参数,第一个参数是累加器,第二个是新输入。

当我们需要在集合的元素上叠加多个函数时,它是如何工作的?例如,假设我们首先需要“过滤”、“映射”,然后“归约”。在这种情况下,使用“转换函数”来建模归约函数(例如,对于“过滤”),使其作为另一个归约函数(对于“映射”)出现,这样在转换过程中添加功能。这被称为“归约转换”。

实现可归约集合

虽然归约函数保留了抽象的纯度,但它们本身并不具有实用性。在名为clojure.core.reducers的命名空间中,归约操作与mapfilter等类似,基本上返回一个包含归约函数的归约集合——这些归约函数嵌入在集合内部。一个可归约集合尚未实现,甚至不是懒实现——而只是一个准备实现的配方。为了实现一个可归约集合,我们必须使用reducefold操作之一。

实现可归约集合的reduce操作是严格顺序的,尽管与clojure.core/reduce相比,由于堆上对象分配的减少,性能有所提升。实现可归约集合的fold操作可能是并行的,并使用“reduce-combine”方法在 fork-join 框架上操作。与传统的“map-reduce”风格不同,使用 fork/join 的 reduce-combine 方法在底层进行归约,然后通过再次归约的方式结合。这使得fold实现更加节省资源,性能更优。

可折叠集合与并行性

通过fold进行的并行化对集合和操作施加了某些限制。基于树的集合类型(持久映射、持久向量和持久集合)适合并行化。同时,序列可能无法通过fold进行并行化。其次,fold要求单个归约函数应该是“结合律”,即应用于归约函数的输入参数的顺序不应影响结果。原因是,fold可以将集合的元素分割成可以并行处理的段,而这些元素可能组合的顺序事先是未知的。

fold函数接受一些额外的参数,例如“组合函数”,以及用于并行处理的分区批次大小(默认为 512)。选择最佳分区大小取决于工作负载、主机能力和性能基准测试。某些函数是可折叠的(即可以通过fold并行化),而另一些则不是,如下所示。它们位于clojure.core.reducers命名空间中:

  • 可折叠mapmapcatfilterremoveflatten

  • 不可折叠take-whiletakedrop

  • 组合函数catfoldcatmonoid

Reducers 的一个显著特点是,只有在集合是树类型时,它才能在并行中折叠。这意味着在折叠它们时,整个数据集必须加载到堆内存中。这在系统高负载期间会有内存消耗的缺点。另一方面,对于这种情况,一个懒序列是一个完全合理的解决方案。在处理大量数据时,使用懒序列和 reducers 的组合来提高性能可能是有意义的。

摘要

并发和并行性在多核时代对性能至关重要。有效地使用并发需要深入了解其底层原理和细节。幸运的是,Clojure 提供了安全且优雅的方式来处理并发和状态。Clojure 的新特性“reducers”提供了一种实现细粒度并行性的方法。在未来的几年里,我们可能会看到越来越多的处理器核心,以及编写利用这些核心的代码的需求不断增加。Clojure 使我们处于应对这些挑战的正确位置。

在下一章中,我们将探讨性能测量、分析和监控。

第六章:测量性能

根据预期的和实际性能,以及测量系统的缺乏或存在,性能分析和调整可能是一个相当复杂的过程。现在我们将讨论性能特性的分析以及测量和监控它们的方法。在本章中,我们将涵盖以下主题:

  • 测量性能和理解结果

  • 根据不同的目的进行性能测试

  • 监控性能和获取指标

  • 分析 Clojure 代码以识别性能瓶颈

性能测量和统计学

测量性能是性能分析的基础。正如我们在这本书中之前提到的,我们需要根据各种场景测量几个性能参数。Clojure 的内置time宏是一个测量执行代码体所花费时间的工具。测量性能因素是一个更为复杂的过程。测量的性能数字之间可能存在联系,我们需要分析这些联系。使用统计概念来建立联系因素是一种常见的做法。在本节中,我们将讨论一些基本的统计概念,并使用这些概念来解释测量的数据如何为我们提供更全面的视角。

一个小小的统计术语入门

当我们有一系列定量数据,例如相同操作的延迟(在多次执行中测量)时,我们可以观察到许多事情。首先,也是最明显的,是数据中的最小值和最大值。让我们用一个示例数据集来进一步分析:

23 19 21 24 26 20 22 21 25 168 23 20 29 172 22 24 26

中位数,第一四分位数,第三四分位数

我们可以看到,这里的最低延迟是 19 毫秒,而最高延迟是 172 毫秒。我们还可以观察到,这里的平均延迟大约是 40 毫秒。让我们按升序排序这些数据:

19 20 20 21 21 22 22 23 23 24 24 25 26 26 29 168 172

之前数据集的中心元素,即第九个元素(值为 23),被认为是数据集的中位数。值得注意的是,中位数比平均数均值更能代表数据的中心。左半部分的中心元素,即第五个元素(值为 21),被认为是第一四分位数。同样,右半部分的中心值,即第 13 个元素(值为 26),被认为是数据集的第三四分位数。第三四分位数和第一四分位数之间的差值称为四分位距(IQR),在本例中为 5。这可以用以下箱线图来表示:

中位数,第一四分位数,第三四分位数

箱线图突出了数据集的第一个四分位数、中位数和第三个四分位数。如图所示,两个“异常值”延迟数值(168 和 172)异常地高于其他数值。中位数在数据集中不表示异常值,而平均值则表示。

中位数、第一个四分位数、第三个四分位数

直方图(前面显示的图表)是另一种显示数据集的方法,我们将数据元素分批处理在时间段内,并暴露这种时间段的频率。一个时间段包含一定范围内的元素。直方图中的所有时间段通常大小相同;然而,当没有数据时,省略某些时间段并不罕见。

百分位数

百分位数用参数表示,例如 99 百分位数,或 95 百分位数等。百分位数是指所有指定百分比的数值元素都存在的值。例如,95 百分位数意味着数据集中值N,使得数据集中 95%的元素值都低于N。作为一个具体的例子,本节前面讨论的延迟数值数据集中的 85 百分位数是 29,因为在 17 个总元素中,有 14 个(即 17 的 85%)其他元素在数据集中的值低于 29。四分位数将数据集分成每个 25%元素的块。因此,第一个四分位数实际上是 25 百分位数,中位数是 50 百分位数,第三个四分位数是 75 百分位数。

方差和标准差

数据的分布,即数据元素与中心值之间的距离,为我们提供了对数据的进一步了解。考虑第i个偏差作为第i个数据集元素值(在统计学中,称为“变量”值)与其平均值之间的差异;我们可以将其表示为方差和标准差。我们可以将其“方差”和“标准差”表示如下:

方差 = 方差和标准差,标准差(σ)= 方差和标准差 = 方差和标准差

标准差用希腊字母“sigma”表示,或简单地表示为“s”。考虑以下 Clojure 代码来确定方差和标准差:

(def tdata [23 19 21 24 26 20 22 21 25 168 23 20 29 172 22 24 26])

(defn var-std-dev
  "Return variance and standard deviation in a vector"
  [data]
  (let [size (count data)
        mean (/ (reduce + data) size)
        sum (->> data
                 (map #(let [v (- % mean)] (* v v)))                 (reduce +))
        variance (double (/ sum (dec size)))]
    [variance (Math/sqrt variance)]))

user=> (println (var-std-dev tdata))
[2390.345588235294 48.89116063497873]

您可以使用基于 Clojure 的平台 Incanter (incanter.org/)进行统计分析。例如,您可以使用 Incanter 中的(incanter.stats/sd tdata)来找到标准差。

经验法则说明了数据集元素与标准差之间的关系。它说,数据集中 68.3%的所有元素都位于平均值一个(正或负)标准差的范围内,95.5%的所有元素位于两个标准差范围内,99.7%的所有数据元素位于三个标准差范围内。

观察我们最初使用的延迟数据集,从平均值出发的一个标准差是方差和标准差(方差和标准差范围-9 到 89),包含所有元素的 88%。从平均值出发的两个标准差是方差和标准差范围-58 到 138),包含所有元素的 88%。然而,从平均值出发的三个标准差是(方差和标准差范围-107 到 187),包含所有元素的 100%。由于经验法则通常适用于具有大量元素的均匀分布数据集,因此经验法则所陈述的内容与我们的发现之间存在不匹配。

理解 Criterium 输出

在第四章“主机性能”中,我们介绍了 Clojure 库Criterium来测量 Clojure 表达式的延迟。以下是一个基准测试结果的示例:

user=> (require '[criterium.core :refer [bench]])
nil
user=> (bench (reduce + (range 1000)))
Evaluation count : 162600 in 60 samples of 2710 calls.
             Execution time mean : 376.756518 us
    Execution time std-deviation : 3.083305 us
   Execution time lower quantile : 373.021354 us ( 2.5%)
   Execution time upper quantile : 381.687904 us (97.5%)

Found 3 outliers in 60 samples (5.0000 %)
low-severe 2 (3.3333 %)
low-mild 1 (1.6667 %)
 Variance from outliers : 1.6389 % Variance is slightly inflated by outliers

我们可以看到,结果中包含了一些我们在本节 earlier 讨论过的熟悉术语。高平均值和低标准差表明执行时间的变化不大。同样,下四分位数(第一四分位数)和上四分位数(第三四分位数)表明它们与平均值并不太远。这一结果意味着代码在响应时间方面相对稳定。Criterium 重复执行多次以收集延迟数值。

然而,为什么 Criterium 试图对执行时间进行统计分析?如果我们简单地计算平均值,会有什么遗漏呢?结果发现,所有执行的响应时间并不总是稳定的,响应时间的显示往往存在差异。只有在正确模拟负载下运行足够的时间,我们才能获得关于延迟的完整数据和其它指标。统计分析有助于了解基准测试是否存在问题。

指导性能目标

我们在第一章“设计性能”中仅简要讨论了性能目标,因为该讨论需要参考统计概念。假设我们确定了功能场景和相应的响应时间。响应时间是否应该保持固定?我们能否通过限制吞吐量来优先考虑规定的响应时间?

性能目标应指定最坏情况的响应时间,即最大延迟、平均响应时间和最大标准差。同样,性能目标还应提及最坏情况的吞吐量、维护窗口吞吐量、平均吞吐量和最大标准差。

性能测试

对性能的测试要求我们知道我们要测试什么,我们希望如何测试,以及为测试执行而设置的环境。有几个需要注意的陷阱,例如缺乏接近真实硬件和生产使用的资源,类似的操作系统和软件环境,测试用例中代表性数据的多样性,等等。测试输入的多样性不足可能导致单调的分支预测,从而在测试结果中引入“偏差”。

测试环境

对测试环境的担忧始于生产环境的硬件代表。传统上,测试环境硬件是生产环境的缩小版。在非代表性硬件上进行的性能分析几乎肯定会歪曲结果。幸运的是,近年来,得益于通用硬件和云系统,提供与生产环境相似的测试环境硬件并不太难。

用于性能测试的网络和存储带宽、操作系统和软件应当然与生产环境相同。同样重要的是要有代表测试场景的“负载”。负载包括不同的组合,包括请求的并发性、请求的吞吐量和标准偏差、数据库或消息队列中的当前人口水平、CPU 和堆使用情况等。模拟一个代表性负载是很重要的。

测试通常需要对执行测试的代码片段进行相当多的工作。务必将其开销保持在最低,以免影响基准测试结果。在可能的情况下,使用除测试目标以外的系统生成请求。

要测试的内容

任何非平凡系统的实现通常涉及许多硬件和软件组件。在整个系统中对某个功能或服务进行性能测试需要考虑它与各种子系统的交互方式。例如,一个 Web 服务调用可能会触及多个层次,如 Web 服务器(请求/响应打包和解包)、基于 URI 的路由、服务处理程序、应用程序-数据库连接器、数据库层、日志组件等。仅测试服务处理程序将是一个严重的错误,因为这并不是 Web 客户端将体验到的性能。性能测试应该在系统的外围进行,以保持结果的真实性,最好有第三方观察者。

性能目标陈述了测试的标准。测试不需要达到的目标内容是有用的,尤其是在测试并行运行时。运行有意义的性能测试可能需要一定程度的隔离。

测量延迟

执行一段代码所获得的延迟可能在每次运行时略有不同。这需要我们多次执行代码并收集样本。延迟数值可能会受到 JVM 预热时间、垃圾收集和 JIT 编译器启动的影响。因此,测试和样本收集应确保这些条件不会影响结果。Criterium 遵循这种方法来产生结果。当我们以这种方式测试非常小的代码片段时,它被称为微基准测试

由于某些操作的延迟在运行期间可能会变化,因此收集所有样本并将它们按时间段和频率分离成直方图是很重要的。在测量延迟时,最大延迟是一个重要的指标——它表示最坏情况的延迟。除了最大值之外,99 百分位和 95 百分位的延迟数值也很重要,以便从不同角度看待问题。实际上收集延迟数值比从标准差推断它们更重要,正如我们之前提到的,经验法则仅适用于没有显著异常值的高斯分布。

在测量延迟时,异常值是一个重要的数据点。异常值比例较高可能表明服务退化的可能性。

比较延迟测量

当评估用于项目的库或提出针对某些基线的替代解决方案时,比较延迟基准测试有助于确定性能权衡。我们将检查基于 Criterium 的两个比较基准测试工具,称为 Perforate 和 Citius。两者都使得按上下文分组运行 Criterium 基准测试变得容易,并可以轻松查看基准测试结果。

Perforate (github.com/davidsantiago/perforate) 是一个 Leiningen 插件,允许定义目标;目标(使用perforate.core/defgoal定义)是一个具有一个或多个基准测试的常见任务或上下文。每个基准测试使用perforate.core/defcase定义。截至 0.3.4 版本,一个示例基准测试代码可能看起来像以下代码片段:

(ns foo.bench
  (:require [perforate.core :as p]))

(p/defgoal str-concat "String concat")
(p/defcase str-concat :apply
  [] (apply str ["foo" "bar" "baz"]))
(p/defcase str-concat :reduce
  [] (reduce str ["foo" "bar" "baz"]))

(p/defgoal sum-numbers "Sum numbers")
(p/defcase sum-numbers :apply
  [] (apply + [1 2 3 4 5 6 7 8 9 0]))
(p/defcase sum-numbers :reduce
  [] (reduce + [1 2 3 4 5 6 7 8 9 0]))

你可以在project.clj中声明测试环境,并在定义目标时提供设置/清理代码。Perforate 提供了从命令行运行基准测试的方法。

Citius (github.com/kumarshantanu/citius) 是一个库,它为 clojure.test 和其他调用形式提供集成钩子。它比 Perforate 施加更严格的约束,并提供了关于基准测试的额外比较信息。它假设每个测试套件中有一个固定的目标(案例)数量,其中可能有多个目标。

截至 0.2.1 版本,一个示例基准测试代码可能看起来像以下代码片段:

(ns foo.bench
  (:require [citius.core :as c]))

(c/with-bench-context ["Apply" "Reduce"]
  {:chart-title "Apply vs Reduce"
   :chart-filename "bench-simple.png"}
  (c/compare-perf
    "concat strs"
    (apply str ["foo" "bar" "baz"])
    (reduce str ["foo" "bar" "baz"]))
  (c/compare-perf
    "sum numbers"
    (apply + [1 2 3 4 5 6 7 8 9 0])
    (reduce + [1 2 3 4 5 6 7 8 9 0])))

在上一个示例中,代码运行基准测试,报告比较总结,并绘制平均延迟的柱状图图像。

并发下的延迟测量

当我们使用 Criterium 基准测试一段代码时,它只使用一个线程来确定结果。这为我们提供了一个关于单线程结果的公平输出,但在许多基准测试场景中,单线程延迟与我们需要的相差甚远。在并发情况下,延迟通常与单线程延迟有很大差异。特别是当我们处理有状态对象(例如从 JDBC 连接池中绘制连接、更新共享内存状态等)时,延迟很可能会随着竞争程度成比例变化。在这种情况下,了解代码在不同并发级别下的延迟模式是有用的。

我们在前一小节中讨论的 Citius 库支持可调的并发级别。考虑以下共享计数器实现的基准测试:

(ns foo.bench
  (:require
    [clojure.test :refer [deftest]]
    [citius.core :as c])
  (:import [java.util.concurrent.atomic AtomicLong]))

(def a (atom 0))
(def ^AtomicLong b (AtomicLong. 0))

(deftest test-counter
  (c/with-bench-context ["Atom" "AtomicLong"] {}
    (c/compare-perf "counter"
      (swap! a unchecked-inc) (.incrementAndGet b))))

;; Under Unix-like systems run the following command in terminal:
;; CITIUS_CONCURRENCY=4,4 lein test

当我在第四代四核英特尔酷睿 i7 处理器(Mac OSX 10.10)上运行这个基准测试时,在并发级别 04 的平均延迟是并发级别 01 的平均延迟的 38 到 42 倍。由于在许多情况下 JVM 用于运行服务器端应用程序,因此在并发下的基准测试变得尤为重要。

测量吞吐量

吞吐量是以时间单位来表示的。粗粒度吞吐量,即在一个较长时期内收集的吞吐量数字,可能会隐藏这样一个事实:吞吐量实际上是在爆发式地而不是均匀分布地交付的。吞吐量测试的粒度取决于操作的性质。批量处理可能处理数据爆发,而网络服务可能提供均匀分布的吞吐量。

平均吞吐量测试

尽管截至版本 0.2.1 的 Citius 在基准测试结果中显示了外推吞吐量(每秒,每线程),但由于各种原因,这个吞吐量数字可能并不能很好地代表实际的吞吐量。让我们构建一个简单的吞吐量基准测试 harness,如下所示,从辅助函数开始:

(import '[java.util.concurrent ExecutorService Executors Future])
(defn concurrently
  ([n f]
    (concurrently n f #(mapv deref %)))
  ([n f g]
    (let [^ExecutorService
          thread-pool (Executors/newFixedThreadPool n)
          future-vals (transient [])]
      (dotimes [i n]
        (let [^Callable task (if (coll? f) (nth f i) f)
              ^Future each-future (.submit thread-pool task)]
          (conj! future-vals each-future)))
      (try
        (g (persistent! future-vals))
        (finally
          (.shutdown thread-pool))))))

(defn call-count
  []
  (let [stats (atom 0)]
    (fn
      ([] (deref stats))
      ([k] (if (identical? :reset k)
             (reset! stats 0)
             (swap! stats unchecked-inc))))))

(defn wrap-call-stats
  [stats f]
  (fn [& args]
    (try
      (let [result (apply f args)]
        (stats :count)
        result))))

(defn wait-until-millis
  ([^long timeout-millis]
    (wait-until-millis timeout-millis 100))
  ([^long timeout-millis ^long progress-millis]
    (while (< (System/currentTimeMillis) timeout-millis)
      (let [millis (min progress-millis
                     (- timeout-millis (System/currentTimeMillis)))]
        (when (pos? millis)
          (try
            (Thread/sleep millis)
            (catch InterruptedException e
              (.interrupt ^Thread (Thread/currentThread))))
          (print \.)
          (flush))))))

现在我们已经定义了辅助函数,让我们看看基准测试代码:

(defn benchmark-throughput*
  [^long concurrency ^long warmup-millis ^long bench-millis f]
  (let [now        #(System/currentTimeMillis)
        exit?      (atom false)
        stats-coll (repeatedly concurrency call-count)
        g-coll     (->> (repeat f)
                     (map wrap-call-stats stats-coll)
                     (map-indexed (fn [i g]
                                    (fn []
                                      (let [r (nth stats-coll i)]
                                        (while (not (deref exit?))
                                          (g))
                                        (r)))))
                     vec)
        call-count (->> (fn [future-vals]
                          (print "\nWarming up")
                          (wait-until-millis (+ (now) warmup-millis))
                          (mapv #(% :reset) stats-coll) ; reset count
                          (print "\nBenchmarking")
                          (wait-until-millis (+ (now) bench-millis))
                          (println)
                          (swap! exit? not)
                          (mapv deref future-vals))
                     (concurrently concurrency g-coll)
                     (apply +))]
    {:concurrency concurrency
     :calls-count call-count
     :duration-millis bench-millis
     :calls-per-second (->> (/ bench-millis 1000)
                         double
                         (/ ^long call-count)
                         long)}))

(defmacro benchmark-throughput
  "Benchmark a body of code for average throughput."
  [concurrency warmup-millis bench-millis & body]
  `(benchmark-throughput*
    ~concurrency ~warmup-millis ~bench-millis (fn [] ~@body)))

现在我们来看看如何使用 harness 测试代码的吞吐量:

(def a (atom 0))
(println
  (benchmark-throughput
    4 20000 40000 (swap! a inc)))

这个 harness 只提供了简单的吞吐量测试。为了检查吞吐量模式,你可能想要将吞吐量分配到滚动固定时间窗口中(例如每秒吞吐量)。然而,这个主题超出了本文的范围,尽管我们将在本章后面的性能监控部分中涉及到它。

负载、压力和耐久性测试

测试的一个特点是每次运行只代表执行过程中的一个时间片段。重复运行可以建立它们的总体行为。但是,应该运行多少次才算足够呢?对于某个操作可能有几种预期的负载场景。因此,需要在各种负载场景下重复测试。简单的测试运行可能并不总是表现出操作的长期行为和响应。在较长的时间内以不同的高负载运行测试,可以让我们观察任何可能不会在短期测试周期中出现的异常行为。

当我们在远超预期的延迟和吞吐量目标的负载下测试一个操作时,这就是压力测试。压力测试的目的是确定操作在超出其开发的最大负载时表现出的合理行为。观察操作行为的另一种方法是观察它在非常长时间运行时的表现,通常为几天或几周。这种长时间的测试被称为耐久测试。虽然压力测试检查操作的良好行为,但耐久测试检查操作在长时间内的稳定行为。

有几种工具可以帮助进行负载和压力测试。Engulf (engulf-project.org/) 是一个用 Clojure 编写的基于 HTTP 的分布式负载生成工具。Apache JMeter 和 Grinder 是基于 Java 的负载生成工具。Grinder 可以使用 Clojure 进行脚本化。Apache Bench 是一个用于 Web 系统的负载测试工具。Tsung 是一个用 Erlang 编写的可扩展、高性能的负载测试工具。

性能监控

在长时间测试期间或应用上线后,我们需要监控其性能,以确保应用继续满足性能目标。可能存在影响应用性能或可用性的基础设施或运营问题,或者偶尔的延迟峰值或吞吐量下降。通常,监控通过生成持续的反馈流来减轻这种风险。

大概有三种组件用于构建监控堆栈。一个收集器将每个需要监控的主机上的数字发送出去。收集器获取主机信息和性能数字,并将它们发送到一个聚合器。聚合器接收收集器发送的数据,并在用户代表可视化器请求时持久化这些数据。

项目 metrics-clojure (github.com/sjl/metrics-clojure) 是一个 Clojure 封装的 Metrics (github.com/dropwizard/metrics) Java 框架,它作为一个收集器。Statsd 是一个知名的聚合器,它本身不持久化数据,而是将数据传递给各种服务器。其中最受欢迎的可视化项目之一是 Graphite,它不仅存储数据,还为请求的时段生成图表。还有其他几种替代方案,特别是用 Clojure 和 Ruby 编写的 Riemann (riemann.io/),它是一个基于事件处理的聚合器。

通过日志进行监控

近年来出现的一种流行的性能监控方法是通过对日志的监控。这个想法很简单——应用程序以日志的形式发出指标数据,这些数据从单个机器发送到中央日志聚合服务。然后,这些指标数据在每个时间窗口内进行聚合,并进一步移动以进行归档和可视化。

作为此类监控系统的示例,你可能想使用 Logstash-forwarder (github.com/elastic/logstash-forwarder) 从本地文件系统抓取应用程序日志并将其发送到 Logstash (www.elastic.co/products/logstash),在那里它将指标日志转发到 StatsD (github.com/etsy/statsd) 进行指标聚合,或者转发到 Riemann (riemann.io/) 进行事件分析和监控警报。StatsD 和/或 Riemann 可以将指标数据转发到 Graphite (graphite.wikidot.com/) 进行归档和时间序列指标数据的图表化。通常,人们希望将非默认的时间序列数据存储(如 InfluxDBinfluxdb.com/) 或可视化层(如 Grafanagrafana.org/) 与 Graphite 连接起来。

关于这个话题的详细讨论超出了本文的范围,但我认为探索这个领域对你大有裨益。

环境监控(web 监控)

如果你使用 Ring (github.com/ring-clojure/ring) 开发 Web 软件,那么你可能觉得 metrics-clojure 库的 Ring 扩展很有用:metrics-clojure.readthedocs.org/en/latest/ring.html ——它跟踪了许多有用的指标,这些指标可以以 JSON 格式查询,并通过网络浏览器与可视化集成。

要从网络层发出连续的指标数据流,服务器端事件 (SSE) 可能是一个好主意,因为它具有低开销。http-kit (www.http-kit.org/) 和 Aleph (aleph.io/),它们与 Ring 一起工作,今天都支持 SSE。

反省

Oracle JDK 和 OpenJDK 都提供了两个名为 JConsole (可执行名称 jconsole) 和 JVisualVM (可执行名称 jvisualvm) 的 GUI 工具,我们可以使用它们来反省正在运行的 JVM 以获取仪器化数据。JDK 中还有一些命令行工具 (docs.oracle.com/javase/8/docs/technotes/tools/),可以窥探正在运行的 JVM 的内部细节。

反省一个正在运行的 Clojure 应用程序的一种常见方法是运行一个 nREPL (github.com/clojure/tools.nrepl) 服务,这样我们就可以稍后使用 nREPL 客户端连接到它。使用 Emacs 编辑器(内嵌 nREPL 客户端)进行 nREPL 的交互式反省在一些人中很受欢迎,而其他人则更喜欢编写 nREPL 客户端脚本来执行任务。

通过 JMX 进行 JVM 仪器化

JVM 通过可扩展的 Java 管理扩展 (JMX) API 内置了一种反省管理资源的机制。它为应用程序维护者提供了一种将可管理资源作为“MBeans”公开的方法。Clojure 有一个名为 java.jmx 的易于使用的 contrib 库 (github.com/clojure/java.jmx),用于访问 JMX。有一些开源工具可用于通过 JMX 可视化 JVM 仪器化数据,例如 jmxtransjmxetric,它们与 Ganglia 和 Graphite 集成。

使用 Clojure 获取 JVM 的快速内存统计信息相当简单:

(let [^Runtime r (Runtime/getRuntime)]
  (println "Maximum memory" (.maxMemory r))
  (println "Total memory" (.totalMemory r))
  (println "Free memory" (.freeMemory r)))
Output:
Maximum memory 704643072
Total memory 291373056
Free memory 160529752

分析

我们在第一章中简要讨论了分析器类型,即“通过设计进行性能”。我们之前讨论的与反省相关的 JVisualVM 工具也是一个 CPU 和内存分析器,它随 JDK 一起提供。让我们看看它们在实际中的应用——考虑以下两个 Clojure 函数,它们分别对 CPU 和内存进行压力测试:

(defn cpu-work []
  (reduce + (range 100000000)))

(defn mem-work []
  (->> (range 1000000)
       (map str)
       vec
       (map keyword)
       count))

使用 JVisualVM 非常简单——从左侧面板打开 Clojure JVM 进程。它具有采样和常规分析器风格的分析。当代码运行时,开始对 CPU 或内存使用进行分析,并等待收集足够的数据以在屏幕上绘制。

分析

以下展示了内存分析的实际操作:

分析

注意,JVisualVM 是一个非常简单、入门级的分析器。市场上有多款商业 JVM 分析器,用于满足复杂需求。

操作系统和 CPU/缓存级别分析

仅对 JVM 进行性能分析可能并不总是能揭示全部情况。深入到操作系统和硬件级别的性能分析通常能更好地了解应用程序的情况。在类 Unix 操作系统中,如tophtopperfiotanetstatvistaupstatepidstat等命令行工具可以帮助。对 CPU 进行缓存缺失和其他信息的分析是捕捉性能问题的有用来源。在 Linux 的开源工具中,Likwidcode.google.com/p/likwid/github.com/rrze-likwid/likwid)对于 Intel 和 AMD 处理器来说体积小但效果显著;i7zcode.google.com/p/i7z/github.com/ajaiantilal/i7z)专门针对 Intel 处理器。还有针对更复杂需求的专用商业工具,如Intel VTune Analyzer

I/O 性能分析

分析 I/O 可能也需要特殊的工具。除了iotablktrace之外,iopingcode.google.com/p/ioping/github.com/koct9i/ioping)对于测量 Linux/Unix 系统上的实时 I/O 延迟很有用。vnStat工具对于监控和记录 Linux 上的网络流量很有用。存储设备的 IOPS 可能无法完全反映真相,除非它伴随着不同操作的延迟信息,以及可以同时发生的读取和写入次数。

在 I/O 密集型的工作负载中,必须随着时间的推移寻找读取和写入 IOPS,并设置一个阈值以实现最佳性能。应用程序应限制 I/O 访问,以确保不超过阈值。

摘要

提供高性能应用程序不仅需要关注性能,还需要系统地测量、测试、监控和优化各种组件和子系统的性能。这些活动通常需要正确的技能和经验。有时,性能考虑甚至可能将系统设计和架构推回设计图板。早期采取的结构化步骤对于确保持续满足性能目标至关重要。

在下一章中,我们将探讨性能优化工具和技术。

第七章:性能优化

性能优化本质上具有累加性,因为它通过将性能调优添加到对底层系统如何工作的了解以及性能测量结果中来实现。本章建立在之前涵盖“底层系统如何工作”和“性能测量”的章节之上。尽管你会在本章中注意到一些类似食谱的部分,但你已经知道了利用这些部分所需的前提条件。性能调优是一个测量性能、确定瓶颈、应用知识以尝试调整代码,并重复这一过程直到性能提高的迭代过程。在本章中,我们将涵盖:

  • 为提高性能设置项目

  • 识别代码中的性能瓶颈

  • 使用 VisualVM 分析代码

  • Clojure 代码的性能调优

  • JVM 性能调优

项目设置

虽然找到瓶颈对于修复代码中的性能问题是至关重要的,但有一些事情可以在一开始就做,以确保更好的性能。

软件版本

通常,新的软件版本包括错误修复、新功能和性能改进。除非有相反的建议,最好使用较新版本。对于使用 Clojure 的开发,考虑以下软件版本:

  • JVM 版本:截至本文撰写时,Java 8(Oracle JDK,OpenJDK,Zulu)已发布为最新稳定的生产就绪版本。它不仅稳定,而且在多个领域(特别是并发)的性能优于早期版本。如果你有选择,请选择 Java 8 而不是 Java 的旧版本。

  • Clojure 版本:截至本文撰写时,Clojure 1.7.0 是最新稳定的版本,它在性能上比旧版本有多个改进。还有一些新功能(transducers,volatile)可以使你的代码性能更好。除非没有选择,否则请选择 Clojure 1.7 而不是旧版本。

Leiningen project.clj 配置

截至 2.5.1 版本,默认的 Leiningen 模板(lein new foolein new app foo)需要一些调整才能使项目适应性能。确保你的 Leiningen project.clj文件有适当的以下条目。

启用反射警告

在 Clojure 编程中最常见的陷阱之一是无意中让代码退回到反射。回想一下,我们在第三章中讨论了这一点,依赖 Java。启用,启用反射警告非常简单,让我们通过向project.clj添加以下条目来修复它:

:global-vars {*unchecked-math* :warn-on-boxed ; in Clojure 1.7+
              *warn-on-reflection* true}

在之前的配置中,第一个设置*unchecked-math* :warn-on-boxed仅在 Clojure 1.7 中有效——它会发出数字装箱警告。第二个设置*warn-on-reflection* true在更早的 Clojure 版本以及 Clojure 1.7 中也能工作,并在代码中发出反射警告信息。

然而,将这些设置包含在 project.clj 中可能不够。只有当命名空间被加载时才会发出反射警告。你需要确保所有命名空间都被加载,以便在整个项目中搜索反射警告。这可以通过编写引用所有命名空间的测试或通过执行此类操作的脚本来实现。

在基准测试时启用优化 JVM 选项

在 第四章 中,我们讨论了 Leiningen 默认启用分层编译,这以牺牲 JIT 编译器的优化为代价提供了较短的启动时间。默认设置对于性能基准测试来说非常具有误导性,因此你应该启用代表你在生产中使用的 JVM 选项:

:profiles {:perf {:test-paths ^:replace ["perf-test"]
                  :jvm-opts ^:replace ["-server"
                                       "-Xms2048m" "-Xmx2048m"]}}

例如,前面的设置定义了一个 Leiningen 配置文件,它覆盖了默认 JVM 选项,以配置一个具有 2 GB 固定大小堆空间的 server Java 运行时。它还将测试路径设置为目录 perf-test。现在你可以按照以下方式运行性能测试:

lein with-profile perf test

如果你的项目有需要不同 JVM 选项的性能测试套件,你应该根据需要定义多个配置文件来运行测试。

区分初始化和运行时

大多数非平凡项目在能够运行之前需要设置很多上下文。这些上下文的例子可能包括应用程序配置、内存状态、I/O 资源、线程池、缓存等等。虽然许多项目从临时的配置和初始化开始,但最终项目需要将初始化阶段与运行时分离。这种区分的目的不仅是为了净化代码的组织,而且是为了在运行时接管之前尽可能多地预先计算。这种区分还允许初始化阶段(根据配置条件)轻松地对初始化代码进行性能日志记录和监控。

非平凡程序通常分为多个层次,例如业务逻辑、缓存、消息传递、数据库访问等等。每个层次都与一个或多个其他层次有依赖关系。通过使用第一性原理编写代码,可以执行初始化阶段的隔离,许多项目实际上就是这样做的。然而,有一些库通过允许你声明层次之间的依赖关系来简化这个过程。组件 (github.com/stuartsierra/component) 和 Prismatic 图 (github.com/Prismatic/plumbing) 是此类库的显著例子。

Component 库有很好的文档。可能不容易明显地看出如何使用 Prismatic 图进行依赖解析;以下是一个虚构的例子来说明:

(require '[plumbing.core :refer [fnk]])
(require '[plumbing.graph :as g])

(def layers
  {:db      (fnk [config]    (let [pool (db-pool config)]
                               (reify IDatabase ...)))
   :cache   (fnk [config db] (let [cache-obj (mk-cache config)]
                               (reify ICache    ...)))
   :service (fnk [config db cache] (reify IService  ...))
   :web     (fnk [config service]  (reify IWeb      ...))})

(defn resolve-layers
  "Return a map of reified layers"
  [app-config]
  (let [compiled (g/compile layers)]
    (compiled {:config app-config})))

这个例子仅仅展示了层依赖图的构建,但在测试中,你可能需要不同的构建范围和顺序。在这种情况下,你可以定义不同的图并在适当的时候解决它们。如果你需要为测试添加拆解逻辑,你可以在每个拆解步骤中添加额外的fnk条目,并用于拆解。

识别性能瓶颈

在前面的章节中,我们讨论了代码的随机性能调整很少有效,因为我们可能没有在正确的位置进行调整。在我们可以调整代码中的这些区域之前,找到性能瓶颈至关重要。找到瓶颈后,我们可以围绕它进行替代解决方案的实验。在本节中,我们将探讨如何找到瓶颈。

Clojure 代码中的延迟瓶颈

延迟是寻找瓶颈的起点,也是最明显的度量指标。对于 Clojure 代码,我们在第六章中观察到,代码分析工具可以帮助我们找到瓶颈区域。当然,分析器非常有用。一旦通过分析器发现热点,你可能会找到一些方法来在一定程度上调整这些热点的延迟。

大多数分析器在聚合上工作,一批运行,按资源消耗对代码中的热点进行排名。然而,调整延迟的机会往往在于分析器可能没有突出的长尾。在这种情况下,我们可能需要采用直接钻取技术。让我们看看如何使用Espejito (github.com/kumarshantanu/espejito)进行这样的钻取,这是一个用于在单线程执行路径中的测量点测量延迟的 Clojure 库(截至版本 0.1.0)。使用Espejito有两个部分,都需要修改你的代码——一个用于包装要测量的代码,另一个用于报告收集到的测量数据。以下代码演示了一个虚构的电子商务用例,即向购物车添加商品:

(require '[espejito.core :as e])

;; in the top level handler (entry point to the use case)
(e/report e/print-table
  ...)

;; in web.clj
(e/measure "web/add-cart-item"
  (biz/add-item-to-cart (resolve-cart request) item-code qty)
  ...)

;; in service.clj (biz)
(defn add-item-to-cart
  [cart item qty]
  (e/measure "biz/add-cart-item"
    (db/save-cart-item (:id cart) (:id item) qty)
    ...))

;; in db.clj (db)
(defn save-cart-item
  [cart-id item-id qty]
  (e/measure "db/save-cart-item"
    ...))

只需在代码的最外层(顶级)层报告一次调用。测量调用可以在调用路径中的任何位置进行。请注意不要在紧密循环中放置测量调用,这可能会使内存消耗增加。当此执行路径被触发时,功能按常规工作,同时延迟被透明地测量和记录在内存中。e/report调用打印出记录的指标表。一个示例输出(编辑以适应)可能如下所示:

|                 :name|:cumulat|:cumul%|:indiv |:indiv%|:thrown?|
|----------------------+--------+-------+-------+-------+--------|
|    web/add-cart-item |11.175ms|100.00%|2.476ms|22.16% |        |
| biz/add-item-to-cart | 8.699ms| 77.84%|1.705ms|15.26% |        |
|    db/save-cart-item | 6.994ms| 62.59%|6.994ms|62.59% |        |

在这里,我们可以观察到数据库调用是最昂贵的(单个延迟),其次是网络层。我们的调整偏好可能会根据测量点的昂贵程度来指导。

只在热点时进行测量

在深入测量中我们没有涵盖的一个重要方面是环境是否已准备好进行测量。e/report调用每次都是无条件调用的,这不仅会有自己的开销(表格打印),而且 JVM 可能尚未预热,JIT 编译器可能尚未启动以正确报告延迟。为了确保我们只报告有意义的延迟,让我们在以下示例条件下触发e/report调用:

(defmacro report-when
  [test & body]
  `(if ~test
    (e/report e/print-table
      ~@body)
    ~@body))

现在,假设它是一个基于Ringgithub.com/ring-clojure/ring)的 Web 应用程序,并且你希望在 Web 请求包含参数report且其值为true时才触发报告。在这种情况下,你的调用可能如下所示:

(report-when (= "true" (get-in request [:params "report"]))
  ...)

基于条件的调用期望 JVM 在多次调用之间保持运行,因此它可能不适用于命令行应用程序。

这种技术也可以用于性能测试,在某个预热期间可能进行非报告调用,然后通过提供自己的报告函数而不是e/print-table来进行报告调用。你甚至可以编写一个采样报告函数,它会在一段时间内汇总样本,并最终报告延迟指标。不仅限于性能测试,你还可以使用它进行延迟监控,其中报告函数记录指标而不是打印表格,或将延迟分解发送到指标聚合系统。

垃圾回收瓶颈

由于 Clojure 运行在 JVM 上,因此必须了解应用程序中的 GC 行为。你可以在project.clj或 Java 命令行中指定相应的 JVM 选项来在运行时打印 GC 详细信息:

:jvm-options ^:replace [..other options..
 "-verbose:gc" "-XX:+PrintGCDetails"
 "-XX:+PrintGC" "-XX:+PrintGCTimeStamps"
                        ..other options..]

这会导致在应用程序运行时打印出 GC 事件的详细摘要。为了将输出捕获到文件中,你可以指定以下参数:

:jvm-options ^:replace [..other options..
                        "-verbose:gc" "-XX:+PrintGCDetails"
                        "-XX:+PrintGC" "-XX:+PrintGCTimeStamps"
 "-Xloggc:./memory.log"
                        ..other options..]

也有必要查看完全垃圾回收(GC)事件之间的时间以及事件期间的时间:

:jvm-options ^:replace [..other options..
                        "-verbose:gc" "-XX:+PrintGCDetails"
                        "-XX:+PrintGC" "-XX:+PrintGCTimeStamps"
 "-XX:+PrintGCApplicationStoppedTime"
 "-XX:+PrintGCApplicationConcurrentTime"
                        ..other options..]

以下是一些其他有用的选项,用于调试 GC:

  • -XX:+HeapDumpOnOutOfMemoryError

  • -XX:+PrintTenuringDistribution

  • -XX:+PrintHeapAtGC

之前选项的输出可以帮助你识别可以尝试通过选择合适的垃圾回收器、其他代际堆选项和代码更改来修复的 GC 瓶颈。为了方便查看 GC 日志,你可能喜欢使用 GUI 工具,如GCViewergithub.com/chewiebug/GCViewer)来完成此目的。

等待在 GC 安全点的线程

当代码中存在一个长时间紧循环(没有任何 I/O 操作)时,如果在循环结束时或内存不足(例如,无法分配)时发生 GC,则执行该循环的线程无法被带到安全点。这可能会在 GC 期间对其他关键线程产生灾难性的影响。你可以通过启用以下 JVM 选项来识别这类瓶颈:

:jvm-options ^:replace [..other options..
                        "-verbose:gc" "-XX:+PrintGCDetails"
                        "-XX:+PrintGC" "-XX:+PrintGCTimeStamps"
 "-XX:+PrintSafepointStatistics"
                        ..other options..]

之前选项生成的 safepoint 日志可能有助于您在 GC 期间识别紧循环线程对其他线程的影响。

使用 jstat 检查 GC 细节

Oracle JDK(也称为 OpenJDK、Azul 的 Zulu)附带了一个名为jstat的实用工具,可以用来检查 GC 细节。您可以在docs.oracle.com/javase/8/docs/technotes/tools/unix/jstat.html找到有关此实用工具的详细信息——以下示例展示了如何使用它:

jstat -gc -t <process-id> 10000
jstat -gccause -t <process-id> 10000

之前提到的第一个命令每 10 秒监控一次各种堆代中的对象分配和释放,以及其他 GC 统计信息。第二个命令还打印出 GC 的原因以及其他详细信息。

检查 Clojure 源代码生成的字节码

我们在第三章依赖 Java中讨论了如何查看任何 Clojure 代码生成的等效 Java 代码。有时,生成的字节码与 Java 之间可能没有直接关联,这时检查生成的字节码非常有用。当然,这要求读者至少对 JVM 指令集有一些了解(docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html)。这个工具可以让你非常有效地分析生成字节码指令的成本。

项目no.disassemblegithub.com/gtrak/no.disassemble)是一个非常实用的工具,用于发现生成的字节码。将其包含在您的project.clj文件中作为 Leiningen 插件:

:plugins [[lein-nodisassemble "0.1.3"]]

然后,在 REPL 中,您可以逐个检查生成的字节码:

(require '[no.disassemble :as n])
(println (n/disassemble (map inc (range 10))))

之前的代码片段打印出了在那里输入的 Clojure 表达式的字节码。

吞吐量瓶颈

吞吐量瓶颈通常源于共享资源,这些资源可能是 CPU、缓存、内存、互斥锁、GC、磁盘和其他 I/O 设备。每种资源都有不同的方法来查找利用率、饱和度和负载水平。这也很大程度上取决于所使用的操作系统,因为它管理这些资源。深入探讨特定于操作系统的确定这些因素的方法超出了本文的范围。然而,我们将在下一节中查看一些这些资源的分析以确定瓶颈。

吞吐量的净效应表现为与延迟的倒数关系。根据 Little 定律,这是自然的——我们将在下一章中看到。在第六章测量性能中,我们讨论了并发下的吞吐量测试和延迟测试。这应该大致是吞吐量趋势的良好指标。

使用 VisualVM 分析代码

Oracle JDK(也称为 OpenJDK)附带了一个名为VisualVM的强大分析器;与 JDK 一起提供的发行版被称为 Java VisualVM,可以通过二进制可执行文件来调用:

jvisualvm

这将启动 GUI 分析器应用程序,您可以通过它连接到正在运行的 JVM 实例。分析器具有强大的功能(visualvm.java.net/features.html),这些功能对于查找代码中的各种瓶颈非常有用。除了分析堆转储和线程转储外,VisualVM 还可以实时交互式地绘制 CPU 和堆消耗,以及线程状态。它还具有针对 CPU 和内存的采样和跟踪分析器。

监视器选项卡

监视器选项卡提供了运行时的图形概述,包括 CPU、堆、线程和加载的类:

监视器选项卡

此选项卡用于快速查看信息,将更深入的挖掘留给其他选项卡。

线程选项卡

在以下截图中,线程选项卡显示了所有线程的状态:

线程选项卡

查找是否有任何线程正在竞争、进入死锁、利用率低或占用更多 CPU 是非常有用的。特别是在具有内存状态的并发应用程序中,以及在由线程共享的有限 I/O 资源(如连接池或对其他主机的网络调用)的应用程序中,如果您设置了线程名称,此功能提供了深入了解。

注意名为citius-RollingStore-store-1citius-RollingStore-store-4的线程。在理想的无竞争场景中,这些线程将具有绿色的运行状态。请看图像右下角的图例,它解释了线程状态:

  • 运行:一个线程正在运行,这是理想的状态。

  • 睡眠:一个线程暂时放弃了控制权。

  • 等待:一个线程正在临界区等待通知。调用了Object.wait(),现在正在等待Object.notify()Object.notifyAll()将其唤醒。

  • 挂起:一个线程在许可(二进制信号量)上挂起,等待某些条件。通常与java.util.concurrent API 中的并发阻塞调用一起出现。

  • 监视器:一个线程已达到对象监视器,等待某些锁,可能是等待进入或退出临界区。

您可以为感兴趣的线程安装线程检查器插件以获取详细信息。要检查命令行中的线程转储,您可以使用jstackkill -3命令。

采样器选项卡

采样器选项卡是轻量级的采样分析器选项卡,可以采样 CPU 和内存消耗。您可以轻松地找到代码中的热点,这些热点可能从调整中受益。然而,采样分析受采样周期和频率的限制,无法检测内联代码等。它是瓶颈的良好一般指标,看起来与我们第六章中看到的截图相似,测量性能。您一次可以分析 CPU 或内存。

CPU选项卡显示整体 CPU 时间分布和每个线程的 CPU 消耗。在采样进行时,您可以获取线程转储并分析转储。有多个 VisualVM 插件可用于更深入的分析。

内存选项卡显示堆直方图指标,包括对象的分布和实例计数。它还显示 PermGen 直方图和每个线程的分配数据。在项目中设置线程名称是一个非常好的主意,并且强烈推荐这样做,这样在工具中就可以轻松定位这些名称。在此选项卡中,您可以强制进行垃圾回收,为分析获取堆转储,并以多种方式查看内存指标数据。

设置线程名称

在 Clojure 中设置线程名称使用 Java 互操作非常简单:

(.setName ^Thread (Thread/currentThread) "service-thread-12")

然而,由于线程通常跨越多个上下文,在大多数情况下,您应该像以下那样在有限的范围内进行操作:

(defmacro with-thread-name
  "Set current thread name; execute body of code in that context."
  [new-name & body]
  `(let [^Thread thread# (Thread/currentThread)
         ^String t-name# thread#]
     (.setName thread# ~new-name)
     (try
       ~@body
       (finally
         (.setName thread# t-name#)))

现在,您可以使用这个宏来执行任何具有指定线程名称的代码块:

(with-thread-name (str "process-order-" order-id)
  ;; business code
  )

这种设置线程名称的方式确保在离开线程局部作用域之前恢复原始名称。如果您的代码有多个部分,并且您为每个部分设置不同的线程名称,您可以通过查看在分析和监控工具上出现的任何竞争时的名称来检测哪些代码部分导致竞争。

分析器选项卡

分析器选项卡允许您在 JVM 中分析运行中的代码,并分析 CPU 和内存消耗。此选项比采样器选项卡具有更大的开销,并且在 JIT 编译、内联和准确性方面提出了不同的权衡。此选项卡在可视化方面的多样性不如采样器选项卡。此选项卡与采样器选项卡的主要区别在于它更改运行代码的字节码以进行准确测量。当您选择 CPU 分析时,它开始对代码进行 CPU 分析。如果您从 CPU 切换到内存分析,它将重新对运行代码进行内存分析,并且每次您想要不同的分析时都会重新进行仪器分析。这种仪器分析的一个缺点是,如果您的代码部署在应用程序容器中,如 Tomcat,它可能会大幅减慢一切。

虽然您可以从采样器中获得大多数常见的 CPU 瓶颈信息,但您可能需要分析器来调查采样器和其他分析技术已发现的瓶颈。您可以使用仪器分析器有选择地分析并深入已知瓶颈,从而将其不良影响限制在代码的小部分。

Visual GC选项卡

Visual GC是一个 VisualVM 插件,可以实时地直观显示 GC 状态。

The Visual GC tab

如果您的应用程序使用大量内存并且可能存在 GC 瓶颈,此插件可能对各种故障排除目的非常有用。

替代分析器

除了 VisualVM 之外,还有几个针对 Java 平台的第三方性能分析器和性能监控工具。在开源工具中,Prometheus (prometheus.io/) 和 Moskito (www.moskito.org/) 相对流行。开源性能工具的非详尽列表在这里:java-source.net/open-source/profilers

有几个商业专有性能分析器你可能想了解一下。YourKit (www.yourkit.com/) 的 Java 性能分析器可能是许多人用来分析 Clojure 代码的最著名性能分析器,他们从中获得了很大的成功。还有其他针对 JVM 的性能分析工具,例如 JProfiler (www.ej-technologies.com/products/jprofiler/overview.html),这是一个基于桌面的性能分析器,以及基于网络的托管解决方案,如 New Relic (newrelic.com/) 和 AppDynamics (www.appdynamics.com/)。

性能调优

一旦通过测试和性能分析结果对代码有了深入理解,我们就需要分析值得考虑优化的瓶颈。更好的方法是找到表现最差的片段并对其进行优化,从而消除最薄弱的环节。我们在前几章讨论了硬件和 JVM/Clojure 的性能方面。优化和调整需要根据这些方面重新思考设计和代码,然后根据性能目标进行重构。

一旦确定了性能瓶颈,我们必须找出根本原因,并逐步尝试改进,看看什么有效。性能调优是一个基于测量、监控和实验的迭代过程。

调优 Clojure 代码

识别性能瓶颈的本质对于实验代码的正确方面非常有帮助。关键是确定成本的来源以及成本是否合理。

CPU/缓存绑定

正如我们在本章开头所指出的,设置具有正确 JVM 选项和项目设置的项目会让我们了解反射和装箱,这是在糟糕的设计和算法选择之后常见的 CPU 绑定性能问题的来源。一般来说,我们必须看看我们是否在进行不必要的或次优的操作,尤其是在循环内部。例如,transducers 在 CPU 绑定操作中比懒序列更适合更好的性能。

尽管建议公共函数使用不可变数据结构,但在性能必要时,实现细节可以使用瞬态和数组。在适当的情况下,记录是映射的一个很好的替代品,因为前者有类型提示和紧密的字段布局。对原始数据类型的操作比它们的包装类型更快(因此推荐)。

在紧密循环中,除了 transients 和 arrays,您可能更喜欢使用带有未检查数学的 loop-recur 以提高性能。您也可能喜欢避免在紧密循环中使用多方法和动态变量,而不是传递参数。使用 Java 和宏可能是最后的手段,但如果有这样的性能需求,仍然是一个选项。

内存受限

在代码中分配更少的内存总是可以减少与内存相关的性能问题。优化内存受限代码不仅关乎减少内存消耗,还关乎内存布局以及如何有效地利用 CPU 和缓存。我们必须检查我们是否使用了适合 CPU 寄存器和缓存行的数据类型。对于缓存和内存受限代码,我们必须了解是否存在缓存未命中以及原因——通常数据可能太大,无法适应缓存行。对于内存受限代码,我们必须关注数据局部性,代码是否过于频繁地访问互连,以及数据在内存中的表示是否可以简化。

多线程

带有副作用共享资源是多线程代码中竞争和性能瓶颈的主要来源。正如我们在本章的 VisualVM 代码分析 部分所看到的,更好地分析线程可以更好地了解瓶颈。提高多线程代码性能的最佳方法是减少竞争。减少竞争的简单方法是增加资源并减少并发性,尽管只有最优的资源水平和并发性对性能才是有益的。在设计并发时,仅追加、单写者和无共享方法都工作得很好。

另一种减少竞争的方法可能是利用线程局部队列直到资源可用。这种技术与 Clojure 代理所使用的技术类似,尽管它是一个复杂的技术。第五章 并发 对代理进行了详细说明。我鼓励您研究代理源代码以更好地理解。当使用 CPU 受限资源(例如 java.util.concurrent.atomic.AtomicLong)时,您可以使用一些 Java 8 类(如 java.util.concurrent.atomic.LongAdder,它也在处理器之间平衡内存消耗和竞争条带化)使用的竞争条带化技术。这种技术也很复杂,通用的竞争条带化解决方案可能需要牺牲读一致性以允许快速更新。

I/O 受限

I/O 受限任务可能受到带宽或 IOPS/延迟的限制。任何 I/O 瓶颈通常表现为频繁的 I/O 调用或未受约束的数据序列化。将 I/O 限制在仅所需的最小数据上是一种常见的减少序列化和降低延迟的机会。I/O 操作通常可以批量处理以提高吞吐量,例如 SpyMemcached 库使用异步批量操作以实现高吞吐量。

I/O 密集型瓶颈通常与多线程场景相关。当 I/O 调用是同步的(例如,JDBC API),自然需要依赖多个线程在有限资源池上工作。异步 I/O 可以减轻线程的阻塞,让线程在 I/O 响应到达之前做其他有用的工作。在同步 I/O 中,我们付出了线程(每个分配了内存)在 I/O 调用上阻塞的成本,而内核则安排它们。

JVM 调优

经常 Clojure 应用程序可能会从 Clojure/Java 库或框架中继承冗余,这会导致性能不佳。追踪不必要的抽象和不必要的代码层可能会带来可观的性能提升。在将依赖库/框架包含到项目中之前,考虑其性能是一个好的方法。

JIT 编译器、垃圾收集器和安全点(在 Oracle HotSpot JVM 中)对应用程序的性能有重大影响。我们在第四章“主机性能”中讨论了 JIT 编译器和垃圾收集器。当 HotSpot JVM 达到无法再执行并发增量 GC 的点时,它需要安全地挂起 JVM 以执行完全 GC。这也被称为“停止世界”GC 暂停,可能持续几分钟,而 JVM 看起来是冻结的。

Oracle 和 OpenJDK JVM 在被调用时接受许多命令行选项,以调整和监控 JVM 中组件的行为方式。对于想要从 JVM 中提取最佳性能的人来说,调整 GC 是常见的做法。

您可能想尝试以下 JVM 选项(Oracle JVM 或 OpenJDK)以提升性能:

JVM 选项 描述
-XX:+AggressiveOpts 侵略性选项,启用压缩堆指针
-server 服务器类 JIT 阈值(用于 GUI 应用程序请使用 -client)
-XX:+UseParNewGC 使用并行 GC
-Xms3g 指定最小堆大小(在桌面应用程序上保持较小)
-Xmx3g 指定最大堆大小(在服务器上保持最小/最大相同)
-XX:+UseLargePages 减少(如果操作系统支持)转换查找缓冲区丢失,详情请见 www.oracle.com/technetwork/java/javase/tech/largememory-jsp-137182.html

在 Java 6 HotSpot JVM 上,并发标记清除(CMS)垃圾收集器因其 GC 性能而受到好评。在 Java 7 和 Java 8 HotSpot JVM 上,默认的 GC 是并行收集器(以提高吞吐量),而撰写本文时,有一个提议在即将到来的 Java 9 中默认使用 G1 收集器(以降低暂停时间)。请注意,JVM GC 可以根据不同的目标进行调整,因此同一配置可能对不同的应用程序效果不佳。请参考 Oracle 发布的以下链接中关于调整 JVM 的文档:

背压

在负载下看到应用程序表现不佳并不罕见。通常,应用程序服务器简单地看起来无响应,这通常是高资源利用率、GC 压力、更多线程导致更繁忙的线程调度和缓存未命中等多种因素的综合结果。如果已知系统的容量,解决方案是在达到容量后拒绝服务以应用背压。请注意,只有在系统经过负载测试以确定最佳容量后,才能最优地应用背压。触发背压的容量阈值可能与单个服务直接相关,也可能不直接相关,而是可以定义为负载标准。

摘要

值得重申的是,性能优化始于了解底层系统的工作原理,以及在我们构建的系统上代表硬件和负载的性能测量。性能优化的主要组成部分是使用各种类型的测量和剖析来识别瓶颈。之后,我们可以应用实验来调整代码的性能,并再次进行测量/剖析以验证。调整机制取决于瓶颈的类型。

在下一章中,我们将看到在构建应用程序时如何解决性能问题。我们的重点将放在影响性能的几个常见模式上。

第八章:应用性能

最早的计算设备是为了执行自动计算而建造的,随着计算机能力的增强,它们因为能够进行多少以及多快地计算而变得越来越受欢迎。即使今天,这种本质仍然存在于我们通过在计算机上运行的应用程序来期待计算机能够比以前更快地执行我们的业务计算的预期中。

与我们在前几章中看到的较小组件级别的性能分析和优化相比,提高应用层性能需要整体方法。更高级别的关注点,例如每天服务一定数量的用户,或者通过多层系统处理已识别的负载量,需要我们思考组件如何组合以及负载是如何设计通过它的。在本章中,我们将讨论这些高级关注点。与上一章类似,总体而言,本章适用于用任何 JVM 语言编写的应用程序,但重点在于 Clojure。在本章中,我们将讨论适用于代码所有层的通用性能技术:

  • 选择库

  • 日志记录

  • 数据大小

  • 资源池化

  • 提前获取和计算

  • 阶段化和批量处理

  • 李特尔法则

选择库

大多数非平凡应用都高度依赖于第三方库来实现各种功能,例如日志记录、处理网络请求、连接数据库、写入消息队列等。许多这些库不仅执行关键业务功能的一部分,而且出现在性能敏感的代码区域,影响整体性能。在充分进行性能分析之后,我们明智地选择库(在功能与性能权衡方面)是至关重要的。

选择库的关键因素不是确定使用哪个库,而是拥有我们应用程序的性能模型,并且对代表性负载下的用例进行了基准测试。只有基准测试才能告诉我们性能是否存在问题或可接受。如果性能低于预期,深入分析可以显示第三方库是否导致了性能问题。在第六章测量性能和第七章性能优化中,我们讨论了如何测量性能和识别瓶颈。您可以针对性能敏感的用例评估多个库,并选择适合的库。

库通常会随着新版本的发布而提高(或偶尔降低)性能,因此测量和配置(比较,跨版本)应该成为我们应用程序开发和维护生命周期中的持续实践。另一个需要注意的因素是,库可能会根据用例、负载和基准表现出不同的性能特征。魔鬼在于基准的细节。确保你的基准尽可能接近你应用程序的代表性场景。

通过基准测试进行选择

让我们简要地看看一些通用用例,在这些用例中,第三方库的性能是通过基准测试暴露的。

Web 服务器

由于其通用性和范围,Web 服务器通常会受到大量的性能基准测试。这里有一个针对 Clojure Web 服务器的基准测试示例:

github.com/ptaoussanis/clojure-web-server-benchmarks

Web 服务器是复杂的软件组件,它们可能在各种条件下表现出不同的特性。正如你将注意到的,性能数字根据 keep-alive 模式与非 keep-alive 模式以及请求量而变化——在撰写本文时,Immutant-2 在 keep-alive 模式下表现更好,但在非 keep-alive 基准测试中表现不佳。在生产中,人们通常在应用程序服务器前使用反向代理服务器,例如 Nginx 或 HAProxy,这些服务器与应用程序服务器建立 keep-alive 连接。

网络路由库

如此列出的 Clojure 有几个 Web 路由库:

github.com/juxt/bidi#comparison-with-other-routing-libraries

同样的文档还显示了一个以Compojure为基准的性能基准测试,其中(在撰写本文时)Compojure 的表现优于Bidi。然而,另一个基准测试比较了 Compojure、Clout(Compojure 内部使用的库)和CalfPath路由。

github.com/kumarshantanu/calfpath#development

在这个基准测试中,截至本文撰写时,Clout 的表现优于 Compojure,而 CalfPath 的表现优于 Clout。然而,你应该注意任何更快库的注意事项。

数据序列化

在 Clojure 中,有几种方法可以序列化数据,例如 EDN 和 Fressian。Nippy 是另一个序列化库,它有基准测试来展示它在 EDN 和 Fressian 上的性能表现:

github.com/ptaoussanis/nippy#performance

我们在第二章Clojure 抽象中介绍了 Nippy,展示了它是如何使用 transients 来加速其内部计算的。即使在 Nippy 内部,也有几种不同的序列化方式,它们具有不同的特性/性能权衡。

JSON 序列化

解析和生成 JSON 是 RESTful 服务和 Web 应用程序中非常常见的用例。Clojure contrib 库 clojure/data.json (github.com/clojure/data.json) 提供了这项功能。然而,许多人发现 Cheshire 库 github.com/dakrone/cheshire 的性能远优于前者。Cheshire 包含的基准测试可以通过以下命令运行:

lein with-profile dev,benchmark test

Cheshire 内部使用 Jackson Java 库 github.com/FasterXML/jackson,它以其良好的性能而闻名。

JDBC

使用关系型数据库的应用程序中,JDBC 访问是另一个非常常见的用例。Clojure contrib 库 clojure/java.jdbc github.com/clojure/java.jdbc 提供了 Clojure JDBC API。Asphalt github.com/kumarshantanu/asphalt 是一个替代的 JDBC 库,比较基准测试可以按照以下方式运行:

lein with-profile dev,c17,perf test

到本文写作时为止,Asphalt 的性能优于 clojure/java.jdbc 几微秒,这在低延迟应用中可能很有用。然而,请注意,JDBC 性能通常受 SQL 查询/连接、数据库延迟、连接池参数等因素的影响。我们将在后面的章节中更详细地讨论 JDBC。

日志记录

日志记录是一种普遍的活动,几乎所有非平凡的应用程序都会进行。日志调用非常频繁,因此确保我们的日志配置针对性能进行了优化非常重要。如果您对日志系统(尤其是在 JVM 上)不熟悉,您可能需要花些时间先熟悉这些内容。我们将介绍 clojure/tools.loggingSLF4JLogBack 库(作为一个组合)的使用,并探讨如何使它们性能良好:

为什么选择 SLF4J/LogBack?

除了 SLF4J/LogBack,Clojure 应用程序中还有几个日志库可供选择,例如 Timbre、Log4j 和 java.util.logging。虽然这些库本身没有问题,但我们通常被迫选择一个可以覆盖我们应用程序中大多数其他第三方库(包括 Java 库)的库进行日志记录。SLF4J 是一个 Java 日志门面,它可以检测任何可用的实现(LogBack、Log4j 等)——我们选择 LogBack 只是因为它性能良好且高度可配置。clojure/tools.logging 库提供了一个 Clojure 日志 API,它会在类路径中检测 SLF4J、Log4j 或 java.util.logging(按此顺序),并使用找到的第一个实现。

设置

让我们通过使用 LogBack、SLF4J 和clojure/tools.logging为使用 Leiningen 构建的项目设置日志系统。

依赖项

您的project.clj文件应该在:dependencies键下包含 LogBack、SLF4J 和clojure/tools.logging依赖项:

[ch.qos.logback/logback-classic "1.1.2"]
[ch.qos.logback/logback-core    "1.1.2"]
[org.slf4j/slf4j-api            "1.7.9"]
[org.codehaus.janino/janino     "2.6.1"]  ; for Logback-config
[org.clojure/tools.logging      "0.3.1"]

之前提到的版本是当前的,并且是在写作时有效的。如果您有的话,您可能想使用更新的版本。

LogBack 配置文件

您需要在resources目录下创建一个logback.xml文件:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

  <appender name="FILE"
            class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>${logfile.general.name:-logs/application.log}</file>
    <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
      <!-- daily rollover -->
      <fileNamePattern>${logfile.general.name:-logs/application.log}.%d{yyyy-MM-dd}.%i.gz</fileNamePattern>
      <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
        <!-- or whenever the file size reaches 100MB -->
        <maxFileSize>100MB</maxFileSize>
      </timeBasedFileNamingAndTriggeringPolicy>
      <!-- keep 30 days worth of history -->
      <maxHistory>30</maxHistory>
    </rollingPolicy>
    <append>true</append>
    <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
      <layout class="ch.qos.logback.classic.PatternLayout">
        <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
      </layout>
 <immediateFlush>false</immediateFlush>
    </encoder>
  </appender>

 <appender name="AsyncFile" class="ch.qos.logback.classic.AsyncAppender">
 <queueSize>500</queueSize>
 <discardingThreshold>0</discardingThreshold>
 <appender-ref ref="FILE" />
 </appender>

  <!-- You may want to set the level to DEBUG in development -->
  <root level="ERROR">
 <appender-ref ref="AsyncFile" />
  </root>

  <!-- Replace com.example with base namespace of your app -->
  <logger name="com.example" additivity="false">
    <!-- You may want to set the level to DEBUG in development -->
    <level value="INFO"/>
 <appender-ref ref="AsyncFile" />
  </logger>

</configuration>

之前的logback.xml文件故意设计得简单(为了说明),只包含足够的配置,以便您开始使用 LogBack 进行日志记录。

优化

优化点在我们在本节中之前看到的logback.xml文件中被突出显示。我们将immediateFlush属性设置为false,这样消息在刷新到追加器之前会被缓冲。我们还用异步追加器包装了常规文件追加器,并编辑了queueSizediscardingThreshold属性,这比默认设置得到了更好的结果。

除非进行优化,否则日志配置通常是许多应用程序性能不佳的常见原因。通常,性能问题只有在高负载且日志量非常大时才会显现。之前讨论的优化只是众多可能的优化中的一部分。在 LogBack 文档中,例如编码器(logback.qos.ch/manual/encoders.html)、追加器(logback.qos.ch/manual/appenders.html)和配置(logback.qos.ch/manual/configuration.html)部分提供了有用的信息。互联网上也有关于如何使用 7 个 LogBack 调整来即时提高 Java 日志的技巧blog.takipi.com/how-to-instantly-improve-your-java-logging-with-7-logback-tweaks/,可能提供有用的指导。

数据大小

在数据大小方面,抽象的成本起着重要作用。例如,一个数据元素是否可以适应处理器缓存行直接取决于其大小。在 Linux 系统中,我们可以通过检查/sys/devices/system/cpu/cpu0/cache/目录下的文件中的值来找出缓存行大小和其他参数。请参阅第四章,主机性能,其中我们讨论了如何计算原语、对象和数据元素的大小。

我们在数据大小方面通常遇到的一个问题是我们在任何时间点在堆中保留多少数据。正如我们在前面的章节中提到的,垃圾回收(GC)对应用程序性能有直接影响。在处理数据时,我们通常并不真的需要我们持有的所有数据。考虑一下生成某个时间段(月份)内已售商品的总结报告的例子。在计算子时间段(按月)的总结数据后,我们不再需要项目详情,因此在我们添加总结的同时删除不需要的数据会更好。请看以下示例:

(defn summarize [daily-data]  ; daily-data is a map
  (let [s (items-summary (:items daily-data))]
    (-> daily-data
      (select-keys [:digest :invoices])  ; keep required k/v pairs
      (assoc :summary s))))

;; now inside report generation code
(-> (fetch-items period-from period-to :interval-day)
  (map summarize)
  generate-report)

如果我们在之前的summarize函数中没有使用select-keys,它将返回一个包含额外:summary数据以及地图中所有其他现有键的映射。现在,这种事情通常与懒序列结合使用,因此为了使此方案有效,重要的是不要保留懒序列的头部。回想一下,在第二章中,我们讨论了保留懒序列头部所带来的风险。

减少序列化

我们在前面章节中讨论过,通过 I/O 通道进行序列化是延迟的常见来源。过度序列化的风险不容忽视。无论我们是从数据源通过 I/O 通道读取还是写入数据,所有这些数据都需要在处理之前准备、编码、序列化、反序列化和解析。涉及的数据越少,对每一步来说越好,以便降低开销。在没有涉及 I/O(如进程间通信)的情况下,通常没有必要进行序列化。

在与 SQL 数据库一起工作时,过度序列化的一个常见例子是。通常,有一些常见的 SQL 查询函数可以检索表或关系的所有列——它们被各种实现业务逻辑的函数调用。检索我们不需要的数据是浪费的,并且与我们在上一段中讨论的原因一样,对性能有害。虽然为每个用例编写一个 SQL 语句和一个数据库查询函数可能看起来工作量更大,但这样做会带来更好的性能。使用 NoSQL 数据库的代码也容易受到这种反模式的影响——我们必须注意只检索我们需要的,即使这可能导致额外的代码。

在减少序列化时,有一个需要注意的陷阱。通常,在没有序列化数据的情况下,需要推断一些信息。在这种情况下,如果我们删除了一些序列化以推断其他信息,我们必须比较推断成本与序列化开销。这种比较可能不仅限于每个操作,而且可能是整体上的,这样我们就可以考虑我们可以分配的资源,以便为我们的系统各个部分实现能力。

将数据块化以减轻内存压力

当我们不考虑文件大小而读取文本文件时会发生什么?整个文件的内容将驻留在 JVM 堆中。如果文件大于 JVM 堆容量,JVM 将终止,抛出OutOfMemoryError。如果文件很大,但不足以迫使 JVM 进入 OOM 错误,它将为其他操作在应用程序中留下相对较少的 JVM 堆空间。当我们执行任何不考虑 JVM 堆容量的操作时,也会发生类似的情况。幸运的是,可以通过分块读取数据并在读取更多之前处理它们来解决这个问题。在第三章《依赖 Java》中,我们简要讨论了内存映射缓冲区,这是另一种补充解决方案,你可能愿意探索。

文件/网络操作的大小

让我们以一个数据摄取过程为例,其中半自动作业通过文件传输协议(FTP)将大型逗号分隔文件(CSV)上传到文件服务器,另一个自动作业(用 Clojure 编写)定期运行以通过网络文件系统(NFS)检测文件的到达。检测到新文件后,Clojure 程序处理文件,更新数据库中的结果,并归档文件。程序检测并处理多个文件并发。CSV 文件的大小事先未知,但格式是预定义的。

根据前面的描述,一个潜在的问题是,由于可能同时处理多个文件,我们如何分配 JVM 堆空间给并发文件处理作业?另一个问题是操作系统对一次可以打开的文件数量有限制;在类 Unix 系统中,你可以使用ulimit命令来扩展限制。我们不能任意地读取 CSV 文件内容——我们必须限制每个作业的内存量,并限制可以并发运行的作业数量。同时,我们也不能一次读取文件中的非常少的行,因为这可能会影响性能:

(def ^:const K 1024)

;; create the buffered reader using custom 128K buffer-size
(-> filename
  java.io.FileInputStream.
  java.io.InputStreamReader.
  (java.io.BufferedReader. (* K 128)))

幸运的是,我们可以在从文件(甚至从网络流)读取时指定缓冲区大小,以便根据需要调整内存使用和性能。在前面的代码示例中,我们明确设置了读取器的缓冲区大小,以促进这一点。

JDBC 查询结果的大小

Java 的 SQL 数据库接口标准 JDBC(技术上不是一个缩写),支持通过 JDBC 驱动程序获取查询结果时的获取大小。默认的获取大小取决于 JDBC 驱动程序。大多数 JDBC 驱动程序保持一个较低的默认值,以避免高内存使用和内部性能优化原因。这个规范的一个显著例外是 MySQL JDBC 驱动程序,它默认完全获取并存储所有行到内存中:

(require '[clojure.java.jdbc :as jdbc])

;; using prepare-statement directly
(with-open
  [stmt (jdbc/prepare-statement
          conn sql :fetch-size 1000 :max-rows 9000)
   rset (resultset-seq (.executeQuery stmt))]
  (vec rset))

;; using query
(jdbc/query db [{:fetch-size 1000}
           "SELECT empno FROM emp WHERE country=?" 1])

当使用 Clojure contrib 库的java.jdbc(自版本 0.3.7 起github.com/clojure/java.jdbc),可以在准备语句时设置获取大小,如前一个示例所示。请注意,获取大小并不能保证成比例的延迟;然而,它可以安全地用于内存大小调整。我们必须测试由于获取大小引起的性能影响延迟变化,这在不同负载和特定数据库及 JDBC 驱动程序的使用场景中是必须的。另一个需要注意的重要因素是,:fetch-size的好处只有在查询结果集是增量且惰性消费的情况下才有用——如果函数从结果集中提取所有行以创建一个向量,那么从内存节省的角度来看,:fetch-size的好处就消失了。除了获取大小之外,我们还可以传递:max-rows参数来限制查询返回的最大行数——然而,这表示额外的行将从结果中截断,而不是数据库是否会在内部限制行数以实现这一点。

资源池

在 JVM 上,有一些资源初始化成本相当高。例如,HTTP 连接、执行线程、JDBC 连接等。Java API 识别这些资源,并内置了对创建某些资源池的支持,这样消费者代码在需要时可以从池中借用资源,并在工作结束时简单地将其返回到池中。Java 的线程池(在第五章 Chapter 5 中讨论,并发)和 JDBC 数据源是突出的例子。这个想法是保留初始化对象以供重用。尽管 Java 不支持直接对资源类型进行池化,但总可以在自定义昂贵资源周围创建一个池抽象。请注意,池化技术在 I/O 活动中很常见,但也可以同样适用于初始化成本高的非 I/O 目的。

JDBC 资源池

Java 支持通过 javax.sql.DataSource 接口获取 JDBC 连接,该接口可以被池化。一个 JDBC 连接池实现了这个接口。通常,JDBC 连接池是由第三方库或 JDBC 驱动本身实现的。一般来说,很少 JDBC 驱动实现连接池,因此像 Apache DBCP、c3p0、BoneCP、HikariCP 等开源第三方 JDBC 资源池库非常流行。它们还支持验证查询以清除可能由网络超时和防火墙引起的陈旧连接,并防止连接泄漏。Apache DBCP 和 HikariCP 可以通过它们各自的 Clojure 包装库 Clj-DBCP (github.com/kumarshantanu/clj-dbcp) 和 HikariCP (github.com/tomekw/hikari-cp) 从 Clojure 访问,并且有一些 Clojure 示例描述了如何构建 C3P0 和 BoneCP 池 (clojure-doc.org/articles/ecosystem/java_jdbc/connection_pooling.html)。

连接不是唯一需要池化的 JDBC 资源。每次我们创建一个新的 JDBC 预编译语句时,根据 JDBC 驱动程序的实现,通常整个语句模板都会发送到数据库服务器以获取预编译语句的引用。由于数据库服务器通常部署在不同的硬件上,可能存在网络延迟。因此,预编译语句的池化是 JDBC 资源池库的一个非常理想特性。Apache DBCP、C3P0 和 BoneCP 都支持语句池化,而 Clj-DBCP 包装库可以开箱即用地实现预编译语句的池化,以获得更好的性能。HikariCP 认为,如今,语句池化已经由 JDBC 驱动程序内部完成,因此不需要显式池化。我强烈建议使用连接池库进行基准测试,以确定它是否真的适用于您的 JDBC 驱动程序和应用程序。

I/O 批处理和节流

众所周知,嘈杂的 I/O 调用通常会导致性能不佳。一般来说,解决方案是将几条消息批在一起,然后在一个负载中发送。在数据库和网络调用中,批处理是一种常见且有用的技术,可以提高吞吐量。另一方面,大的批处理大小实际上可能会损害吞吐量,因为它们倾向于产生内存开销,并且组件可能无法一次性处理大的批处理。因此,确定批处理大小和节流与批处理本身一样重要。我强烈建议在代表性负载下进行自己的测试,以确定最佳批处理大小。

JDBC 批处理操作

JDBC 在其 API 中已经很长时间支持批量更新,包括 INSERTUPDATEDELETE 语句。Clojure contrib 库 java.jdbc 通过其自己的 API 支持 JDBC 批量操作,如下所示:

(require '[clojure.java.jdbc :as jdbc])

;; multiple SQL statements
(jdbc/db-do-commands
  db true
  ["INSERT INTO emp (name, countrycode) VALUES ('John Smith', 3)"
   "UPDATE emp SET countrycode=4 WHERE empid=1379"])

;; similar statements with only different parametrs
(jdbc/db-do-prepared
  db true
  "UPDATE emp SET countrycode=? WHERE empid=?"
  [4 1642]
  [9 1186]
  [2 1437])

除了批量更新支持外,我们还可以进行批量 JDBC 查询。其中一种常见的技术是使用 SQL WHERE 子句来避免 N+1 查询问题。N+1 问题指的是当我们对主表中的每一行执行一个查询到另一个子表时的情况。类似的技巧可以用来将同一表上的几个相似查询合并为一个,并在程序之后分离数据。

考虑以下使用 clojure.java.jdbc 0.3.7 和 MySQL 数据库的示例:

(require '[clojure.java.jdbc :as j])

(def db {:subprotocol "mysql"
         :subname "//127.0.0.1:3306/clojure_test"
         :user "clojure_test" :password "clojure_test"})

;; the snippet below uses N+1 selects
;; (typically characterized by SELECT in a loop)
(def rq "select order_id from orders where status=?")
(def tq "select * from items where fk_order_id=?")
(doseq [order (j/query db [rq "pending"])]
  (let [items (j/query db [tq (:order_id order)])]
    ;; do something with items
    …))

;; the snippet below avoids N+1 selects,
;; but requires fk_order_id to be indexed
(def jq "select t.* from orders r, items t
  where t.fk_order_id=r.order_id and r.status=? order by t.fk_order_id")
(let [all-items (group-by :fk_order_id (j/query db [jq "pending"]))]
  (doseq [[order-id items] all-items]
    ;; do something with items
    ...))

在前面的例子中,有两个表:ordersitems。第一个片段从 orders 表中读取所有订单 ID,然后通过循环查询 items 表中的相应条目。这是你应该注意的 N+1 查询性能反模式。第二个片段通过发出单个 SQL 查询来避免 N+1 查询,但除非列 fk_order_id 已索引,否则可能表现不佳。

API 级别的批量支持

在设计任何服务时,提供一个用于批量操作的 API 非常有用。这为 API 增加了灵活性,使得批量大小和节流可以以细粒度的方式控制。不出所料,这也是构建高性能服务的一个有效方法。在实现批量操作时,我们经常遇到的一个常见开销是识别每个批次中的每个项目及其在请求和响应之间的关联。当请求是异步的时,这个问题变得更加突出。

解决项目识别问题的解决方案是通过为请求(批次)中的每个项目分配一个规范或全局 ID,或者为每个请求(批次)分配一个唯一的 ID,并为请求中的每个项目分配一个属于批次的本地 ID。

确切解决方案的选择通常取决于实现细节。当请求是同步的时,你可以省略对每个请求项的识别(参考 Facebook API:developers.facebook.com/docs/reference/api/batch/,其中响应中的项遵循请求中的相同顺序)。然而,在异步请求中,可能需要通过状态检查调用或回调来跟踪项。所需的跟踪粒度通常指导适当的项识别策略。

例如,如果我们有一个用于订单处理的批量 API,每个订单都会有一个唯一的 Order-ID,可以在后续的状态检查调用中使用。在另一个例子中,假设有一个用于创建物联网(IoT)设备 API 密钥的批量 API——在这里,API 密钥事先并不知道,但它们可以在同步响应中生成和返回。然而,如果这必须是一个异步批量 API,服务应该响应一个批量请求 ID,稍后可以使用该 ID 查找请求的状态。在请求 ID 的批量响应中,服务器可以包括请求项目 ID(例如设备 ID,对于客户端可能是唯一的,但不是所有客户端都是唯一的)及其相应的状态。

节流对服务的请求

由于每个服务只能处理一定的容量,因此我们向服务发送请求的速率很重要。对服务行为的期望通常涉及吞吐量和延迟两个方面。这要求我们以指定的速率发送请求,因为低于该速率可能会导致服务利用率不足,而高于该速率可能会使服务过载或导致失败,从而引起客户端利用率不足。

假设第三方服务每秒可以接受 100 个请求。然而,我们可能不知道该服务的实现有多稳健。尽管有时并没有明确指定,但在每秒内一次性发送 100 个请求(例如在 20 毫秒内),可能会低于预期的吞吐量。例如,将请求均匀地分布在 1 秒的时间内,比如每 10 毫秒发送一个请求(1000 毫秒 / 100 = 10 毫秒),可能会增加达到最佳吞吐量的机会。

对于节流,令牌桶 (zh.wikipedia.org/wiki/Token_bucket) 和 漏桶 (zh.wikipedia.org/wiki/Leaky_bucket) 算法可能很有用。在非常细粒度的层面上进行节流需要我们缓冲项目,以便我们可以保持均匀的速率。缓冲会消耗内存,并且通常需要排序;队列(在第五章中介绍,并发),管道和持久存储通常很好地服务于这个目的。再次强调,由于系统限制,缓冲和排队可能会受到背压的影响。我们将在本章后面的部分讨论管道、背压和缓冲。

预计算和缓存

在处理数据时,我们通常会遇到一些情况,其中一些常见的计算步骤先于几种后续步骤。也就是说,一部分计算是共同的,而剩余的是不同的。对于高延迟的常见计算(如访问数据时的 I/O 和内存/CPU 处理),将它们一次性计算并存储为摘要形式是非常有意义的,这样后续步骤就可以简单地使用摘要数据并从该点继续进行,从而降低整体延迟。这也被称为半计算数据的分阶段处理,是优化非平凡数据处理的一种常见技术。

Clojure 对缓存有良好的支持。内置的 clojure.core/memoize 函数执行基本的计算结果缓存,但在使用特定的缓存策略和可插拔后端方面没有灵活性。Clojure 的 contrib 库 core.memoize 通过提供几个配置选项来弥补 memoize 的缺乏灵活性。有趣的是,core.memoize 中的功能也作为单独的缓存库很有用,因此公共部分被提取出来,作为名为 core.cache 的 Clojure contrib 库,core.memoize 是在这个库之上实现的。

由于许多应用程序出于可用性、扩展性和维护原因部署在多个服务器上,它们需要快速且空间高效的分布式缓存。开源的 memcached 项目是一个流行的内存分布式键值/对象存储,可以作为 Web 应用的缓存服务器。它通过散列键来识别存储值的服务器,并且没有开箱即用的复制或持久性。它用于缓存数据库查询结果、计算结果等。对于 Clojure,有一个名为 SpyGlass 的 memcached 客户端库(github.com/clojurewerkz/spyglass)。当然,memcached 不仅限于 Web 应用;它也可以用于其他目的。

并行管道

想象一下这样的情况,我们必须以一定的吞吐量执行工作,每个工作包括相同序列的不同大小的 I/O 任务(任务 A),一个内存受限的任务(任务 B),以及再次,一个 I/O 任务(任务 C)。一个简单的方法是创建一个线程池并在其上运行每个工作,但很快我们会意识到这不是最佳方案,因为我们无法确定每个 I/O 资源的利用率,因为操作系统调度线程的不确定性。我们还观察到,尽管几个并发工作有类似的 I/O 任务,但我们无法在我们的第一种方法中批量处理它们。

在下一个迭代中,我们将每个作业分成阶段(A、B、C),使得每个阶段对应一个任务。由于任务已知,我们为每个阶段创建一个适当大小的线程池并执行其中的任务。任务 A 的结果需要由任务 B 使用,B 的结果需要由任务 C 使用——我们通过队列启用这种通信。现在,我们可以调整每个阶段的线程池大小,批量处理 I/O 任务,并调整它们以实现最佳吞吐量。这种安排是一种并发管道。一些读者可能会觉得这种安排与 actor 模型或阶段事件驱动架构SEDA)模型有微妙的相似之处,这些是针对此类方法更精细的模型。回想一下,我们在第五章中讨论了几种进程内队列,并发

分布式管道

采用这种方法,可以通过网络队列将作业执行扩展到集群中的多个主机,从而卸载内存消耗、持久性和交付到队列基础设施。例如,在某个场景中,集群中可能有几个节点,它们都在运行相同的代码,并通过网络队列交换消息(请求和中间结果数据)。

下图展示了简单的发票生成系统如何连接到网络队列:

分布式管道

RabbitMQ、HornetQ、ActiveMQ、Kestrel 和 Kafka 是一些知名的开源队列系统。偶尔,作业可能需要分布式状态和协调。Avout (avout.io/) 项目实现了 Clojure 的原子和 ref 的分布式版本,可用于此目的。Tesser (github.com/aphyr/tesser) 是另一个用于本地和分布式并行性的 Clojure 库。Storm (storm-project.net/) 和 Onyx (www.onyxplatform.org/) 项目是使用 Clojure 实现的分布式实时流处理系统。

应用反向压力

我们在上章简要讨论了反向压力。没有反向压力,我们无法构建一个具有可预测稳定性和性能的合理负载容忍系统。在本节中,我们将看到如何在应用程序的不同场景中应用反向压力。在基本层面上,我们应该有一个系统最大并发作业数的阈值,并根据该阈值,在一定的到达率之上拒绝新的请求。被拒绝的消息可能由客户端重试,如果没有对客户端的控制,则忽略。在应用反向压力到面向用户的服务时,检测系统负载并首先拒绝辅助服务可能是有用的,以保存容量并在高负载下优雅地降级。

线程池队列

JVM 线程池由队列支持,这意味着当我们向已经达到最大运行作业数量的线程池提交作业时,新作业将进入队列。默认情况下,队列是无界的,这不适合应用背压。因此,我们必须创建一个由有界队列支持的线程池:

(import 'java.util.concurrent.LinkedBlockingDeque)
(import 'java.util.concurrent.TimeUnit)
(import 'java.util.concurrent.ThreadPoolExecutor)
(import 'java.util.concurrent.ThreadPoolExecutor$AbortPolicy)
(def tpool
  (let [q (LinkedBlockingDeque. 100)
        p (ThreadPoolExecutor$AbortPolicy.)]
    (ThreadPoolExecutor. 1 10 30 TimeUnit/SECONDS q p)))

现在,在这个池中,每当尝试添加的作业数量超过队列容量时,它将抛出一个异常。调用者应将异常视为缓冲区满的条件,并通过定期调用 java.util.concurrent.BlockingQueue.remainingCapacity() 方法等待直到缓冲区再次有空闲容量。

Servlet 容器,如 Tomcat 和 Jetty

在同步的 TomcatJetty 版本中,每个 HTTP 请求都会从用户可以配置的公共线程池中分配一个专用线程。正在服务的并发请求数量受线程池大小的限制。一种常见的控制到达率的方法是设置服务器的线程池大小。在开发模式下,Ring 库默认使用嵌入的 Jetty 服务器。Ring 中的嵌入 Jetty 适配器(在 Ring 中)可以通过程序配置线程池大小。

在 Tomcat 和 Jetty 的异步(Async Servlet 3.0)版本中,除了线程池大小外,还可以指定处理每个请求的超时时间。然而,请注意,线程池大小在异步版本中不会像在同步版本中那样限制请求数量。请求处理被转移到 ExecutorService(线程池),它可能会缓冲请求,直到有可用的线程。这种缓冲行为很棘手,因为这可能会导致系统过载——你可以通过定义自己的线程池来覆盖默认行为,而不是使用 servlet 容器的线程池,在等待请求达到一定阈值时返回 HTTP 错误。

HTTP Kit

HTTP Kit (http-kit.org/) 是一个高性能的异步(基于 Java NIO 实现)的 Clojure 网络服务器。它内置了对通过指定队列长度应用背压以处理新请求的支持。截至 HTTP Kit 2.1.19,请参阅以下代码片段:

(require '[org.httpkit.server :as hk])

;; handler is a typical Ring handler
(hk/run-server handler {:port 3000 :thread 32 :queue-size 600})

在前面的代码片段中,工作线程池大小为 32,最大队列长度指定为 600。如果没有指定,则应用背压的默认最大队列长度为 20480。

Aleph

Aleph (aleph.io/) 是另一个基于 Java Netty (netty.io/) 库的高性能异步网络服务器,而 Java Netty 又是基于 Java NIO。Aleph 通过其与 Netty 兼容的自定义原语扩展了 Netty。Aleph 的工作线程池通过一个选项指定,如下面的代码片段所示,截至 Aleph 0.4.0:

(require '[aleph.http :as a])

;; handler is a typical Ring handler
(a/start-server handler {:executor tpool})

在这里,tpool指的是在子节线程池队列中讨论的有界线程池。默认情况下,Aleph 使用一个动态线程池,最大限制为 512 个线程,通过Dirigiste (github.com/ztellman/dirigiste)库达到 90%的系统利用率。

反压不仅涉及将有限数量的工作项入队,当对等方速度慢时,还会减慢工作项的处理速度。Aleph 通过“在内存耗尽之前不接受数据”——它回退到阻塞而不是丢弃数据,或者引发异常并关闭连接来处理每个请求的反压(例如,在流式响应数据时)。

性能和排队理论

如果我们观察多次运行中的性能基准数字,即使硬件、负载和操作系统保持不变,这些数字也很少完全相同。每次运行之间的差异可能高达-8%到 8%,没有明显的原因。这可能会让人感到惊讶,但深层次的原因是计算机系统的性能本质上具有随机性。计算机系统中存在许多小因素,使得在任何给定时间点的性能不可预测。至多,性能变化可以通过一系列随机变量的概率来解释。

基本前提是每个子系统或多或少像是一个队列,其中请求等待它们的轮次被服务。CPU 有一个指令队列,其 fetch/decode/branch-predict 的时序不可预测,内存访问再次取决于缓存命中率以及是否需要通过互连进行调度,而 I/O 子系统使用中断来工作,这些中断可能又依赖于 I/O 设备的机械因素。操作系统调度等待而不执行线程。构建在所有这些之上的软件基本上在各种队列中等待以完成任务。

Little 定律

Little 定律指出,在稳态下,以下情况成立:

Little 定律Little 定律

这是一个相当重要的定律,它使我们能够了解系统容量,因为它独立于其他因素。例如,如果满足请求的平均时间是 200 毫秒,而服务率约为每秒 70 次,那么正在被服务请求的平均数量是70 次/秒 x 0.2 秒 = 14 个请求

注意,Little 定律没有讨论请求到达率或延迟(由于 GC 和/或其他瓶颈)的峰值,或者系统对这些因素的响应行为。当到达率在某个点出现峰值时,您的系统必须拥有足够的资源来处理服务请求所需的并发任务数量。我们可以推断出,Little 定律有助于测量和调整一段时间内的平均系统行为,但我们不能仅基于这一点来规划容量。

根据 Little 定律进行性能调整

为了保持良好的吞吐量,我们应该努力维持系统总任务数的上限。由于系统中可能存在许多种类的任务,并且在没有瓶颈的情况下,许多任务可以愉快地共存,因此更好的说法是确保系统利用率和瓶颈保持在限制范围内。

通常,到达率可能不在系统的控制范围内。对于此类场景,唯一的选择是尽可能减少延迟,并在系统中的总作业达到一定阈值后拒绝新的请求。你可能只能通过性能和负载测试来了解正确的阈值。如果你可以控制到达率,你可以根据性能和负载测试来调节到达(流量),以保持稳定的流动。

摘要

设计用于性能的应用程序应基于预期的系统负载和使用案例的模式。测量性能对于指导过程中的优化至关重要。幸运的是,有几个著名的优化模式可以利用,例如资源池、数据大小、预取和预计算、分阶段、批量处理等等。实际上,应用程序的性能不仅取决于使用案例和模式——整个系统是一个连续的随机事件序列,可以通过统计方法进行评估,并由概率指导。Clojure 是一种用于高性能编程的有趣语言。这本书规定了性能的许多指针和实践,但没有一个咒语可以解决所有问题。魔鬼在于细节。了解惯用和模式,通过实验看看哪些适用于你的应用程序,并知道哪些规则你可以为了性能而弯曲。

posted @ 2025-09-10 14:11  绝不原创的飞龙  阅读(14)  评论(0)    收藏  举报