查看原文
其他

API的性能约定

半吊子全栈工匠 喔家ArchiSelf 2023-11-10

当今,任何软件系统都依赖于其他人的工作,可以参考《没有被了解的API?一个老码农眼中的API世界》。当然,我们写了一些代码,通过API调用操作系统和各种软件包中的函数,从而减少了代码量。随着分布式系统的日益普遍,我们的软件系统通过网络与服务器通信,依赖于网络相关的API函数和服务来实现正确的操作,也依赖于它们的执行性能以使整个系统拥有良好的性能。在涉及分页、网络延迟、资源共享等的复杂系统中,性能必然会有变化。然而,即使是在简单的环境设置中,当一个 API 或操作系统达不到性能预期时,我们的软件也会性能低下。

人们习惯于谈论应用程序和 API 实现之间的功能约定,以便在调用 API 函数时得到正确的行为表现。调用方必须满足某些初始要求,然后函数必须按照指定的要求执行。虽然如今的 API 规范并没有以一种正确性证明的方式来明确正确性的标准,但是 API 函数/接口的类型声明和文档描述了其逻辑行为的确定性。

然而,API 函数/接口的意义不仅只有功能的正确性。它消耗了什么资源,速度有多快?人们常常根据自己对某个函数的实现做出假设,对于任何复杂的API函数或者接口,不同的人可能会给出不同的性能预期,而API 文档很少提示执行成本高昂或者低廉。更复杂的是,当我们将应用程序针对API调整到 性能预期之后,新版本的 API 或者新的远程服务很可能会导致整体性能的变化,甚至会导致系统的崩溃。

因此,软件系统中API的性能约定值得更多的关注。

API 的性能分类

先看一段C语言的代码:

fs = fopen("~abel/mydata.txt", "r");
for ( i=0; i<10000; i++) {
ch = fgetc(fs);
//处理 ch
}

函数fopen的执行预计需要一段时间,fgetc的执行预计成本较低。这在直观上是有意义的,为了处理一个文件,一个流只需要打开一次,但是“获取下一个字符”函数将经常被调用,也许会成千上万次。这两个流函数是由库实现的,库文档清楚地说明了函数的功能,是函数的功能性约定。但没有提到性能,也没有向程序员暗示这两个函数在性能上有着本质的不同。因此,我们基于经验判断性能,而不是规范。

并非所有函数都有明显的性能属性,例如,fseek(fs, ptr, SEEK_SET);当目标文件的数据已经在缓冲区里时,这个函数可能性能很好。在一般情况下,它将涉及一个操作系统的调用,也许还包括 I/O操作。在冷存储的极端条件下,这个API的执行可能需要卷动上千米的磁带。即使在简单的情况下,这个函数也可能成本不低,具体的实现可能只是存储指针,并设置一个标记,这将在下一个读取或写入的流调用上比较困难,从而导致性能的不确定性。

鉴于此,我们可以简单根据经验对API的性能进行分类。

成本低廉恒定

这类API函数的性能表现是恒定的,例如,isdigit 和toupper, 这两个函数是性能恒定的。Java.util.HashMap.get在正常大小哈希表中的查找应该很快,但是哈希冲突可能会偶尔减慢的访问速度,类似的函数还有很多。

成本通常低廉

许多API函数被设计成大多数时候都很快,但是偶尔需要调用复杂的代码,例如,java.util.HashMap.put 在哈希表中存储一个新条目可能会超出当前表的大小,以至于会整表放大并重新哈希所有条目。

java.util.HashMap 在公开API的性能约定方面是一个很好的例子: “这个实现为基本操作(get 和 put)提供了常量时间性能,假设哈希函数将元素正确地分散存储桶中。对集合视图的迭代需要与 HashMap 的‘容量’成比例的时间... ”

fgetc 的性能取决于底层流的属性。如果是一个磁盘文件,那么该函数通常从用户的内存缓冲区读取,而不需要操作系统调用,但它必须偶尔调用操作系统来读取新的缓冲区。如果是从键盘读取输入,那么实现可能会调用操作系统来读取每个字符。

成本可预测

一些函数的性能随其参数的属性而变化,例如,要排序的数组的大小或要搜索的字符串长度。这些函数通常是数据结构或算法的实用程序,使用众所周知的算法,不需要系统调用。我们通常可以根据对底层算法的期望来判断性能,例如,qsort排序的平均计算复杂度是 nlog n。当使用复杂的数据结构例如 b 树的变体等,在这些地方可能很难确定底层的具体实现,可能更难估计性能。重要的是,可预测性可能只是可能的,例如 regexec 通常是可预测的,但是有一些变态的表达会导致指数时间的爆发。

成本未知

像open、 fseek、 pthread_create、许多“初始化”函数以及任何遍历网络的调用,大多是成本未知的。这些函数的执行成本较高,而且它们的性能常常有很大的差异。它们从池(线程、内存、磁盘、操作系统对象)中分配资源,通常需要对操作系统或I/O 资源的独占访问,常需要大量的初始化工作。通过网络的调用相对于本地访问总是昂贵的,但是成本的差异可能更大,这使得性能模型的形成变得更加困难。

线程库是性能问题的明显标志。Posix 标准花了很多年才稳定下来,并且在实现仍然被各种问题所困扰,基于线程的应用程序可移植性仍然存在风险。线程难以使用的一些原因有: 

(1)与操作系统紧密集成,几乎所有操作系统(包括 Unix 和 Linux)最初设计时都没有考虑到线程; 

(2)与其他库的交互,特别是保证线程安全而导致的性能问题; 

(3)线程的实现不同,表现为轻量级和重量级。

根据性能划分API

有些库提供了执行一个函数的多种方法,通常是因为这些方法的性能差别很大。

对于API函数fgetc而言,大多数程序员被告知使用这个库函数来获取每个字符并不是最快的方法,注重性能的人会读取一个大型的字符数组,并使用不同编程语言中的数组或指针操作提取每个字符。在极端情况下,应用程序可以将文件页映射到内存页,以避免将数据复制到数组中。例如fseek的调用,给调用方带来了更大的负担。

程序员总是被建议避免在程序中过早地进行优化,从而推迟了对性能的修订。确定性能的唯一方法就是衡量性能,通常先编写整个程序,然后再面对性能预期与实际交付之间的不匹配。

性能变化

“可预测成本”的API函数性能可以根据其参数的属性进行估计,”成本未知”的API函数也可能因为要求它们做什么而有很大的不同。在存储设备上打开流所需的时间当然取决于底层设备的访问时间,或许还取决于数据传输的速率。通过网络协议访问的存储可能成本较高, 但也是可变的。

许多API函数只是在大多数时候成本较低,或者有一个低成本的预期。由于各种原因,具有“成本未知”的API函数可能表现出很大的性能差异,原因之一是函数蠕变 ,其中一般函数随着时间的推移变得更加强大。I/O流就是一个很好的例子: 打开一个流调用操作系统和库中非常不同的代码,这取决于流的类型(本地磁盘文件、网络服务文件、管道、网络流、内存中的字符串等)。随着 I/O设备和文件类型范围的扩展,性能的差异只会增加。大多数 API 有着相同的命运,随着时间的推移逐步增加功能,不可避免地增加了性能变化。

另一个很大的变化来源是不同平台间库的移植差异。当然,平台的底层硬件和操作系统会有所不同,但是库的移植可能会导致 API 内的相对性能或 API 间性能的变化。对于一个初始的库移植版本而言,存在许多性能问题并不罕见,这些问题都是逐步修复的。有些线程库的移植性能差异非常大,线程异常可能以极端的形式出现,应用程序可能会极其缓慢甚至是死锁。

这些差异可能是难以建立API性能约定的原因,通常不需要精确地了解性能,但是需要根据预期行为的极端变化考虑可能会导致的问题。

调用失败时的性能

API 的说明一般包括了调用失败时的行为细节。返回错误代码和抛出异常是告诉调用方API未执行成功的常用方法。但是,与正常的API行为一样,没有指定故障发生时的性能。这里有三个典型的场景:

  • 快速失败。一个API调用失败得很快,和它的正常行为一样快或者更快。例如,sqrt (- 1)就会很快失败,即使 malloc 因为没有更多的内存可用而失败,其返回速度也应该和任何 malloc 调用的返回速度一样快,且后者必须从操作系统请求更多的内存。打开一个流读取一个不存在的磁盘文件返回的速度很可能和成功调用一样快。

  • 慢慢失败。有时候,一个API调用失败的速度非常慢,以至于应用程序可能希望以其他方式进行。例如,打开到另一台计算机的网络连接请求只有在几次长时间超时后才会返回失败。

  • 永远失败。有时候,一个API调用根本不允许应用程序继续运行。例如,等待从未释放的同步锁的调用可能永远不会返回。

对于API调用失败时的性能,在直觉上很少像对于正常调用时性能的直觉那样好。原因之一是编写、调试和调优程序提供的处理故障事件的经验远远少于处理普通事件的经验。另一个原因是,API调用可能在许多方面出现故障,其中一些是致命的,而且并非所有的调研失败都会在 API 规范中描述。即使是精确地描述了错误处理的异常机制,也不能使所有可能的异常都可见。此外,随着库功能的增加和增强,失败的机会也在增加。例如,封装了网络服务的API (ODBC/JDBC/UPnP ...)订阅了大量的网络故障机制。一个勤奋的程序员会尽量处理可能的调用失败用例。一种常见的技术是用 try... catch 块包围程序的大部分,这些块可以重试失败的整个部分。

处理暂停或死锁的唯一方法是设置一个看门狗线程,该线程期望一个正常运行的应用程序定期向看门狗发送通知,说明“我仍在正常运行。”如果间隔的时间过长,看门狗就会采取行动,例如,保存状态、中止主线程或者重新启动整个应用程序等。如果一个交互式程序调用可能缓慢失败的API函数来响应用户的命令,可以使用看门狗终止整个命令,并返回到一个已知的状态,允许用户继续执行其他命令。这就产生了一种防御式的编程风格。

API的性能约定

为什么 API 必须遵守性能约定呢?因为应用程序的主要结构可能取决于 API 是否遵守了这样的性能约定。程序员根据性能期望选择 API、数据结构和整个程序结构。如果预期或性能严重错误,程序员不能仅仅通过调优 API 调用来恢复,而是必须重写程序的主要部分。

实际上, 明确性能约定的程序较难与不遵守性能约定的APi相配合。当然,有许多程序的结构和性能很少受到库性能的影响。然而,如今许多的“常规 业务程序”,特别是基于 web 服务的软件,广泛使用了对整体性能至关重要的库。

即使性能上的微小变化也会导致用户对程序的感知发生重大变化,在处理各种媒体的程序中尤其如此。偶事实上,比起允许帧速率滞后而言,放弃视频流的帧可能是可以接受的,但是人们可以检测到音频中的轻微中断,因此音频媒体性能的微小变化可能会产生重大影响。这种担忧引起了人们对服务质量概念的极大兴趣,在许多方面,服务质量是为了确保高性能。

尽管违反性能约定的情况较少,而且较少出现灾难性的事故,但在使用软件库时注意性能可以帮助我么生成更健壮的软件。以下是一些关注点和使用策略。

谨慎地选择API和程序结构

如果我们有幸从头开始编写一个程序,那么在开始编写时,最好考虑一下性能约定的含义。如果这个程序一开始是一个原型,然后在服务中保持一段时间,那么毫无疑问它至少会被重写一次; 重写是一个重新思考 API 和结构选择的机会。

API 要在新版本和移植发布时提供一致的性能约定

一个新的实验性 API 也会吸引某些用户。此后,更改性能约定肯定会激怒开发人员,并可能导致他们重写自己的程序。一旦 API 成熟,性能约定的不变性就很重要了。事实上,大多数通用 API (例如 libc)之所以变得如此,部分原因在于它们的性能约定在 其API 发展的过程中是稳定的。

人们可能希望 API 的开发者能够定期测试新版本,以验证它们没有引入性能衰退。不幸的是,这样的测试很少进行。但是,这并不意味着我们不能对依赖的 API 进行自己的测试。使用分析器,通常可以发现程序依赖的那些API。编写一个性能测试套件,将一个库的新版本与早期版本的性能记录进行比较,这样可以给程序员提供一个早期警告,随着新库的发布,他们自己代码的性能将发生变化。

许多程序员希望计算机及其软件能够一致地随着时间的推移而变得更快。也就是说,希望一个库或一个计算机系统的每个新版本都能平等地提高所有 API 函数的性能,这实际上对于供应商来说是很难保证的。许多用户希望图形库、驱动程序和硬件的新版本能够提高所有图形应用程序的性能,但他们同样热衷于多种功能的改进,这通常会降低旧功能的性能,可能只是轻微地降低。

人们也可以希望 API 规范将性能约定明确化,这样在使用、修改或移植代码的时候就能遵守约定。注意,函数对动态内存分配的使用,无论是隐式的还是自动的,都应该是API文档的一部分。

防御式编程

在调用性能未知或高可变的 API 函数时,程序员可以使用特殊的注意事项,异常处理优先。我们可以将初始化移到性能关键区域之外,并尝试预热 API 可能使用的任何缓存数据(例如字体)。对于表现出大量性能差异或拥有大量内部缓存数据的 API 而言, 可以通过提供助手函数将关于如何分配或初始化这些结构的提示从应用程序传递给 API。健康检测可以建立一个可能不可用的服务器列表,从而避免一些长时间的故障暂停。

调优 API 公开的参数

有些库提供了影响其API性能的明确方法,例如,分配给文件的缓冲区大小、表的初始大小或缓存的大小等。操作系统还提供了调优选项,调整这些参数可以在性能约定的范围内提高性能。调优虽然不能解决总体问题,但可以减少嵌入在库中的固定选项,那些选项可能会严重影响性能。

有些库提供具有相同语义函数的替代实现,通过选择最好的具体实现进行调优会比较容易。Java Collection就是这种结构的一个很好的例子。越来越多的 API被设计用于动态地适应使用,使程序员无需选择最佳的参数设置。如果一个哈希表满了,它会自动扩展并重新哈希。如果一个文件是按顺序读取的,那么就可以分配更多的缓冲区,以便在更大的块中读取。

测量性能以验证假设

定期进行概要分析,从可信赖的基础上衡量性能偏差。

常见建议是检测关键数据结构,以确定每个结构是否正确使用。例如,可以测量哈希表的完整程度或发生哈希冲突的频率。或者,可以验证一个以写性能为代价而设计的快速读取结构。添加工具来准确地度量许多 API 调用的性能是困难的,这需要大量的工作,而且可能不值得。然而,在那些对应用程序的性能至关重要的 API 调用上添加工具 ,可以在出现问题时会节省大量时间。

所有这些都不是为了阻止开发自动化仪表和测量的工具,或者开发详细说明性能约定的方法。这些目标并不容易实现,回报可能也不会很大。通常可以在没有事先检测软件的情况下进行性能度量,例如,使用 DTrace等工具,优点是在出现问题之前不需要任何工作。它们还可以帮助诊断当修改代码或库影响性能时出现的问题。

使用日志: 检测和记录异常

当分布式服务组成一个复杂的系统时,可能会出现越来越多的违反性能约定的行为。在许多配置中,度量过程偶尔会发出服务请求,以检查 SLA 是否满足由于这些服务对性能的要求,例如, XML-RPC、 SOAP 或 REST在网络连接上的调用。应用程序会检测这些服务的失败,并且通常会适应得当。然而,响应缓慢,特别是当有许多这样的服务互相依赖时,会很快破坏系统性能。

如果这些服务的客户端能够记录他们所期望的性能,并生成有助于诊断问题的日志 ,那将会很有帮助。当你的文件备份看起来不合理的慢,那是不是比昨天慢呢?比操作系统更新之前还要慢?或者是否有一些合理的解释,例如,备份系统发现一个损坏的数据结构并开始一个长的过程来重新构建它)?

诊断不透明软件组合中的性能问题需要软件在报告性能和发现问题方面发挥作用。虽然我们不能在软件内部解决性能问题 ,但可以对操作系统和网络进行调整或修复。如果备份设备由于磁盘几乎已满而速度较慢,那么我们会断定可以添加更多的磁盘空间。好的日志和相关的工具会有所帮助,日志在计算机系统演进中是一个被低估和忽视的领域,可以参考《日志分析的那些挑战》和《全栈必备 Log日志》。

小结

软件系统依赖于各种独立组件的组合来工作,这意味着它们以可接受的速度执行所需的计算。静态检查是难保证系统的性能的,软件工程实践已经开发出了测试组件和组合的方法,这些方法可以工作得非常好。每次应用程序绑定到动态库或在操作系统接口上时,都需要验证组合的正确性和API的性能约定。

诚然,API的性能约定没有功能正确性约定那么重要,但是软件系统的核心体验往往取决于它。

特别福利

你关注软件系统的架构和性能么?作为一名软件工程师如何拥抱AI 呢?如何让各种各样的数据、算法和模型更高效的服务于目标产品呢?这里高兴地告诉大家一个和老码农一起工作和学习的机会,笔者所在的小度策略架构团队招人了:

策略架构工程师

地点:百度科技园,北京

干啥?

  • 负责小度技能开发平台的前后端研发工作

  • 提升算法落地效率, 与产品、算法团队紧密配合, 研究业界先进技术方案,依托平台能力推动算法规模化应用、快速落地

  • 提升平台工程质量, 在高性能、可扩展、高可用、易运维等方向进行平台技术优化

  • 研究业界新技术方案及应用领域,进行产品与技术探索,并推动落地

要求并不高

  • 计算机或相关专业本科以上学历,5年以上开发经验,素质较好可以适度放开

  • 数据结构和算法的基本功扎实,能应用常见的设计模式和架构方法

  • 熟练掌握PHP和Python, 熟悉Linux开发环境, 了解C/C++开发,有MySQL、Redis等数据库开发经验,熟悉网络编程,有完整的后端项目开发经验

  • 掌握HTML/CSS/JavaScript,至少熟悉React/Vue中的一种框架,有前端项目开发经验

  • DevOps意识,了解系统全链路跟踪,具备一定的数据分析能力 

  • 良好的团队合作态度,具有工匠精神,具有较强的沟通能力

如果对自然语言处理或深度学习技术有了解,能快速入职者优先,另,有对话系统或者开放平台研发经验者优先。

工作地点:百度科技园,北京

简历直发我的邮箱:caohongwei01@baidu.com



【关联阅读】

继续滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存