分阶段安装



本文介绍 R 中一项新功能,即软件包的分阶段安装。它可能对软件包作者和维护者感兴趣,尤其是对维护受影响软件包的人员感兴趣。

问题

我经常必须对所有 CRAN 和 BIOC 软件包运行检查,以测试我对 R 所做更改的影响。这是为了发现我自己的错误,但通常我也会发现软件包或 R 中现有的错误,或发现某些软件包依赖于未记录的 API 或行为。我针对基准 R-devel 版本运行所有 CRAN/BIOC 软件包测试,然后针对我的修改版本运行,然后比较结果,寻找新出现故障或新出现警告的软件包。在每次运行中,我都会重新安装(相同版本的)软件包,事实上,为了在合理的时间内完成,安装是并行运行的。

在过去几个月里,安装过程中随机出现的警告使这一过程变得越来越复杂,例如

警告:S3 方法 '[.fun_list', '[.grouped_df', 'all.equal.tbl_df' ... [... 截断].

这些警告出现在许多软件包中,但不可重复,因此它们使检查结果的分析变得复杂。一些处理是自动化的,在基本版本和修改版本中重新检查软件包,以减少由于远程系统暂时不可用而导致的差异数量。最初,安装警告还伴随着检查警告,例如

警告 in grep(pattern, x, invert = TRUE, value = TRUE, ...) : 输入字符串 1 在此区域设置中无效

这些检查警告的发出是因为截断有时意外地拆分了多字节 UTF-8 字符。我修复了截断,然后发现原始安装警告实际上是“S3 方法已在 NAMESPACE 中声明,但未找到”。

顺便说一下,在我运行的所有已安装软件包中,警告中只有两个不同的(很长的)方法列表,但对许多软件包重复出现。事实证明,它们是来自 dplyrrlang 软件包的导出方法列表。这两个软件包由于 C++ 代码编译而需要很长时间才能安装。它们还有很多反向依赖项,因此在安装它们时,另一个正在安装的软件包很可能会在部分安装状态下使用它们,这就是发出这些警告的原因。

我了解到,CRAN 团队确实也长期受到这个问题的影响,他们毫不意外地看到它也是由其他需要很长时间才能安装的软件包(不仅仅是 dplyrrlang)引起的。

原则上,此问题不仅发生在并行安装期间,也不仅影响定期检查所有 CRAN 和/或 BIOC 包的存储库维护者和 R 核心开发人员。只要从不同的 R 会话中使用相同的 R 库,就会出现此问题(并且在某些安装中,会话可能由不同的用户运行)。

软件包安装过程变得复杂,甚至可以运行任意代码,即使来自软件包本身,因此以不一致/部分安装的状态访问其他软件包的后果是不可预测的,并且可能是危险的。在过去几年中,随着 C++ 的广泛使用(以需要较长时间才能编译的模式),这种竞争条件发生的可能性似乎有所增加,因为以前没有观察到此问题。

现有的锁定目录无法解决此问题

默认情况下,软件包安装的当前实现通过将其移动到每个库的 00LOCK 目录(或每个软件包的 00LOCK-pkgname)中来备份软件包的旧安装。安装直接执行到库中的最终目录 pkgname 中。如果失败,则默认情况下会对其进行清理,并将旧版本移回;否则,如果成功,则会删除旧版本。如果在请求安装时锁定目录已存在,则安装将失败并出现错误,并且通常会手动删除该目录。在并行安装期间,将使用每个软件包锁定(00LOCK-pkgname)。

此锁定机制适用于在出错时备份和恢复软件包的先前版本,但它不能阻止访问部分安装的软件包。我最初一直在尝试扩展它以执行此操作,毕竟,让 R 遵守锁定目录并忽略被“锁定”的软件包似乎很自然,从而为该问题找到一个廉价的部分解决方案。“部分”是因为明显的竞争条件——在检查锁定目录的存在和访问软件包之间会发生什么。事实证明,实现既不便宜也不容易,最终我们决定采用分阶段安装

第一个观察结果是,不能简单地隐藏/忽略存在锁定目录的软件包——这是不可能的,因为在安装期间,需要能够看到(部分安装的)软件包。例如,这是在构建延迟加载数据库时(因此必须能够加载命名空间),也包括在从软件包运行自定义安装脚本(install.libs.R)时。必须自定义所有软件包访问/发现函数,以便它们仅对正在安装软件包的 R 会话使已锁定的软件包可见。将函数参数一直传递到软件包发现函数是不现实的,但原则上可以通过环境变量实现,其中一些环境变量已经在使用中。

首先,我查看了软件包如何检查另一个软件包是否已安装。这是一项非常常见的任务,我发现了许多流行的方法(installed.packages()requireNamespace()require().packages()system.file()find.package()packageVersion)。我可能很容易忽略某些情况,因为我只是抓取了所有软件包的源代码,并且除了检查软件包是否已安装之外,很可能还有更多类型的软件包访问。如果我们错过了处理任何情况,则由此产生的竞争条件将极其难以调试(不可重复运行,仅在某些系统上显示等)。此外,某些工具或软件包直接查看库目录来发现软件包并非不可能。最后,软件包访问函数将产生不小的性能开销。

分阶段安装

因此,分阶段安装是该问题的实施解决方案。它仅与默认使用的锁定目录一起使用。一个包首先被安装到锁定目录下的一个临时目录中(在 00LOCK00LOCK-pkgname 下)。当包被安装时,这个临时目录就是该 R 会话的 R 库,因此 R 会话使用标准方法看到部分安装的包。但是,其他包看不到它。在包被安装(字节编译、创建惰性加载数据库、编译和构建本机代码、测试加载等)后,它被移动到最终位置(pkgname)并对其他包可见。目录移动在同一文件系统内是非常快速的,在 POSIX/Unix 中它是原子的(在 Windows 中它也很快速,但不容易保证原子性)。

因此,分阶段安装提供了文件系统级别部分安装包的隔离,并且所有包访问 API 甚至基于文件的 API 使用都可以保持原样。从一开始就很清楚,问题反而会出现在包在安装后被移动到不同的目录,而原始目录不再存在这一事实中。

当包对临时安装目录名称进行硬编码(将其保存到某个配置文件中,将其保存在 R 对象中,或通过链接器将其作为绝对路径或链接器 rpath 保存到共享对象中)时,它们在分阶段安装中会失败。幸运的是,只有少数来自 CRAN 和 BIOC 的包会出现这种情况,并且相对容易发现,而无需花费数天进行调试(与如果需要更新包访问代码以尊重锁定目录而需要的调试相比)。

在共享对象中硬编码的路径

这个问题只存在于 CRAN 和 BIOC 中的几个包中,当一个包动态链接其一个共享对象与另一个 共享对象时,并且在这样做时使用链接器 rpathrunpath)或绝对共享对象路径。这个问题在 Windows 中不存在,因为无法以这种方式对路径进行硬编码,但在 Linux、Solaris、macOS 和其他 Unix 系统中存在。理想情况下,受影响的包应更新以避免此类链接。请注意,链接到来自 其他 包的共享对象对于分阶段安装来说不是问题。

在 Windows 中,包无法执行此操作,因此它们将在同一包中使用静态链接。我认为在所有系统上执行相同操作是最简单的;由于代码大小,磁盘空间开销在当今时代几乎无关紧要,而且如果在 Windows 上可以,为什么在其他系统上不行呢?一个例子是来自 BIOC 的 Rhtslib,它现在在 Windows 和 macOS 上使用静态链接,但在包括 Linux 在内的其他系统上使用 rpath 进行动态链接。

如果由于某些原因无法进行静态链接,则仍可以使用符号动态链接程序变量。在 Linux 和 Solaris 上,$ORIGIN 是一个链接程序变量,它指向当前共享对象被发现的位置,因此可以将 rpath 设置为 \$ORIGIN/../usrlibs..libs 中获取,即包中共享对象的公共目录)。在 macOS 上,可以使用 @loader_path 以相同的方式。这些符号变量由动态链接程序解释,因此即使在包移动到最终位置后,也可以找到依赖项。

在非 Windows 系统上进行阶段性安装期间,R 将检查共享对象中的硬编码路径。这需要操作系统特定的外部工具,这些工具通常在从源代码构建包的系统上可用。在 Linux 上,它使用 readelf,它是 binutils 的一部分。在 macOS 上,它使用 otool,它是 CLT(命令行工具)的一部分,因此应该在所有从源代码构建包的系统上可用。在 Solaris 上,使用 elfedit

最后,当安装包并且所需的特定于操作系统的外部工具可用时,R 会自动修复共享对象中的硬编码路径。在 Linux 上,在可用时使用 patchelf 来修复 rpath 和绝对链接路径,它通常在名为 patchelf 的单独包中提供,但不幸的是通常不会默认安装。在 macOS 上,使用 install_name_tool,它是 CLT 的一部分,就像 otool 一样,因此应该可用。在 Solaris 上,使用 elfedit,它应该在操作系统中可用。在 Linux 和 Solaris 上,也可以使用 chrpath,但只能修复 rpath,而不能修复到其他共享库的绝对路径,但它们在非 macOS 系统上应该很少见。

在阶段性安装期间,会自动检测硬编码路径并进行修复,并附有信息性消息。当无法修复路径(工具不可用或修复不成功)时,安装将失败。此外,还会从其最终位置对包进行测试加载,这可以自行检测某些硬编码路径的问题,即使没有可用于分析共享对象的工具。

包在安装期间通常从 R_PACKAGE_DIR 环境变量中获取其安装目录名称,例如供在构建脚本或 make 文件中使用。在阶段性安装中,此变量保存临时安装目录。请注意,在构建本机代码后,首先从其临时安装目录对包进行测试加载。包不应尝试以任何方式引用最终安装目录名称。

R 代码中硬编码的路径

软件包通常需要访问其自身安装目录中的文件,这些文件始终可以通过 system.file(package=) 调用来获取。一些软件包会保存通过 system.file() 获取的目录名称,但这种做法在分阶段安装中很危险,应避免使用。

在分阶段安装中,可能会发生这种情况:在软件包仍在临时安装目录中运行时执行目录保存,通常是在为惰性加载准备软件包时。为惰性加载做准备涉及获取软件包的所有 R 文件,因此还执行对全局变量的所有赋值。

因此,像这样(来自 pd.ecoli)的赋值在软件包中 R 源文件中的顶级保存了临时安装目录

globals$DB_PATH <- system.file("extdata", "pd.ecoli.sqlite",
                               package="pd.ecoli")

有时,对 system.file(package=) 的调用会隐藏在更深层次的赋值中,这些赋值在为惰性加载数据库准备时加载命名空间时执行,包括在设置 S4 类的赋值中。我认为修复这些模式的最佳方法是始终调用 system.file(),因此在这种情况下,使用如下函数,并且 绝不要将结果保存在函数中明显不是局部变量的任何内容中。

getDbPath <- function() system.file("extdata", "pd.ecoli.sqlite",
                                    package="pd.ecoli") 

但是,即使不理想,也可以在 .onLoad 软件包挂钩中修复此类硬编码路径(pd.ecoli 已经在分阶段安装之前修复了它们,但仅在 .onAttach 中,因此仍然可以访问错误的路径)

.onAttach <- function(libname, pkgname) {
    globals$DB_PATH <- system.file("extdata", "pd.ecoli.sqlite",
                                   package="pd.ecoli",
                                   lib.loc=libname)
    ...

.onLoad 中修复的问题是软件包的二进制映像仍然包含硬编码的临时安装目录名称,因此检查工具在不加载命名空间的情况下查看文件会报告错误(但是,本文后面描述的工具加载了命名空间,因此它会看到挂钩执行后的状态)。

在分阶段安装期间,R 检查包含临时安装目录的硬编码路径,如果发现任何,则安装会失败并显示一条信息性消息。这是一种保守的方法,因为在某些情况下,硬编码的安装目录实际上永远不会用于访问文件,但它可以防止难以发现的错误。

R 代码中硬编码路径的问题比共享对象中的路径更常见,但它仍然只直接影响 CRAN 和 BIOC 中的少数软件包。

测试分阶段安装的包

包作者可以通过使用 R-devel 的最新版本尝试使用 R CMD INSTALL --staged-install 安装来测试其包的分阶段安装。安装期间的检查应具有足够的防御性以发现大多数问题:如果分阶段安装成功,并且该包与非分阶段安装一起使用(也适用于包依赖项),那么它也应该适用于分阶段安装。目前,唯一已知的例外情况是当包将其临时安装路径保存到外部文件时,而该文件不会自动检查。对于检查未检测到的任何其他问题,我将乐于收到报告。

我在 Linux 上的测试表明,目前有 21 个 CRAN 和 4 个 BIOC 包无法安装,因为它们在其 R 代码中硬编码了临时安装路径。2 个 CRAN 和 2 个 BIOC 包无法安装,因为它们在其共享对象中硬编码了临时安装路径。一些包无法安装,因为它们依赖于这些包:总而言之,在 CRAN/BIOC 中,有 48 个包无法使用分阶段安装安装,但可以使用非分阶段安装安装。CRAN 团队已经在多个平台和多个 C 编译器上运行了许多其他测试。

共享对象中硬编码路径的问题很容易从安装日志/输出中诊断出来,其中包含错误消息中的共享对象名称,通常还包含用于构建包的本机代码的编译/链接命令(因此大多数情况下,人们只需在输出中搜索“rpath”即可)。此外,包作者确实必须明确指定使用 rpath 或绝对路径进行链接,因此需要在包的构建脚本或 make 文件中记录它。

R 代码中硬编码路径的问题有点难以诊断,安装只执行一个简单的检查来找出是否存在硬编码路径,但检查出在哪里有点费时。我编写了一个简单的程序(sicheck),它找出硬编码路径是什么(有时已经知道路径会有所帮助,当人们可以在 R 包源中搜索后缀时)。它还尝试找出 R 表达式(对象路径),以从包名称空间的环境中获取这些硬编码路径。可以在 此处 找到 CRAN 和 BIOC 3.9 包的最新版本的程序和结果。

例如,包franc有以下报告

Package contains these hard-coded paths (sercheck):
CONTAINS: franc/speakers.json
CONTAINS: franc/data.json 

Package contains these objects with hard-coded paths (walkcheck):
OBJPATH:  as.list(getNamespace("franc"), all.names=TRUE)[["speakers_file"]] franc/speakers.json 
SPATH:  franc$speakers_file franc/speakers.json 
OBJPATH:  as.list(getNamespace("franc"), all.names=TRUE)[["datafile"]] franc/data.json 
SPATH: franc$datafile franc/data.json 

在上面,CONTAINS: franc/speakers.json表示sicheck工具找到了到franc/speakers.json的硬编码路径(复制到此文本的输出排除了完整路径的前缀,包括00LOCK-franc目录)。该名称在包名称空间的变量datafile中硬编码(OBJPATH:SPATH:部分)。很容易看出,这是因为包的源文件speakers.R在顶层有此赋值

speakers_file <- system.file("speakers.json", package = packageName())

一个稍微不那么微不足道的示例是包zonator。其报告包括

CONTAINS: zonator/extdata/test_project/zsetup/01/01_out
OBJPATH:  as.list(as.list(getNamespace("zonator"), all.names=TRUE)[[".options"]],all.names=TRUE)[["results.dir"]] zonator/extdata/test_project/zsetup/01/01_out 
SPATH:  zonator$.options$results.dir zonator/extdata/test_project/zsetup/01/01_out 

硬编码路径是extdata/test_project/zsetup/01/01_out。它在包的源文件options.R中硬编码,在(顶层命令)中

assign("results.dir", file.path(.options$setup.dir, "01/01_out"), envir = .options)

我首先使用grep在源代码中查找01_out,找到了这行代码。在尝试解释更复杂的 object 路径之前,首先尝试此方法可能总是最简单的,但当硬编码路径没有唯一后缀时,它无济于事,例如当它只是包安装根目录的路径时。然后,需要分析 object 路径。在此示例中,object 路径仍然易于理解。可执行文件(OBJPATH)可执行以获取 R 中的值(不包括硬编码路径前缀)

> as.list(as.list(getNamespace("zonator"), all.names=TRUE)[[".options"]],all.names=TRUE)[["results.dir"]]
Registered S3 methods overwritten by 'ggplot2':
  method         from 
  [.quosures     rlang
  c.quosures     rlang
  print.quosures rlang
[1] "zonator/extdata/test_project/zsetup/01/01_out"

SPATHzonator$.options$results.dir)试图更简洁,但不可执行。这些路径的特殊元素是

$name | named vector element
[i]   | unnamed vector element
-A    | attributes
-E    | environment
 @    | S4 data part

请注意,当前该工具不会尝试找到到 object 的最短路径。

退出

目前尚未默认启用分阶段安装,但计划很快启用。出于某种原因无法修复分阶段安装(或无法及时修复)的包,在切换后仍可以使用当前的非分阶段程序进行安装。

包可以通过其DESCRIPTION文件中的StagedInstall字段选择退出。包无需选择加入,因为这将成为默认设置。R CMD INSTALL还有新选项:--staged-install--no-staged-install

总结

分阶段安装是 R-devel 中R CMD INSTALL的新功能,计划很快默认启用。它在安装期间隔离包,以便其他 R 会话不会意外访问它们,这是并行安装正确功能的关键,但与可能使用多个 R 会话的任何安装相关。

一些软件包需要修复才能与分阶段安装配合使用,软件包作者应与存储库维护人员合作并及时更新其软件包。存储库维护人员在增强 R 的过程中也扮演着非常重要的角色,这可能并不明显。向 R 添加功能通常会给他们带来大量工作,因为他们需要在不同平台上测试软件包、分析输出,有时还需要调试软件包以找出向谁报告错误或帮助没有足够技术技能自行执行此操作的软件包维护人员。

除了存储库维护人员的“常规”工作负载之外,此功能还与 CRAN 团队密切合作实施,尤其是 Brian Ripley 提供了宝贵的建议、评论、审查并通过测试发现了许多问题。