- 适用于 JDK 23 的 GraalVM(最新版)
- 适用于 JDK 24 的 GraalVM(抢先体验版)
- 适用于 JDK 21 的 GraalVM
- 适用于 JDK 17 的 GraalVM
- 归档
- 开发版
GraalVM 中的工具入门
在 GraalVM 平台中,工具有时被称为Instruments。可以使用 Instrument API 来实现这些 Instruments。Instruments 可以跟踪非常细粒度的 VM 级运行时事件,以分析、检查和分析在 GraalVM 上运行的应用程序的运行时行为。
简单工具 #
为了给工具开发人员提供更简单的起点,我们创建了一个 简单工具 示例项目。这是一个包含丰富 javadoc 的 Maven 项目,它实现了一个简单的代码覆盖率工具。
我们建议您克隆存储库并探索源代码,将其作为工具开发的起点。以下部分将提供一个使用简单工具源代码作为运行示例的,构建和运行 GraalVM 工具所需步骤的指导。这些部分不涵盖 Instrument API 的所有功能,因此我们鼓励您查看 javadoc 以获取更多详细信息。
要求 #
如前所述,简单工具是一个代码覆盖率工具。最终,它应该向开发人员提供有关执行了多少百分比的源代码行以及哪些行被执行的信息。考虑到这一点,我们可以为工具定义一些高级要求。
- 该工具跟踪加载的源代码。
- 该工具跟踪执行的源代码。
- 在应用程序退出时,该工具计算并打印每行覆盖率信息。
Instrument API #
工具的主要起点是子类化 TruffleInstrument 类。不出所料,简单工具代码库恰好就是这么做的,它创建了 SimpleCoverageInstrument 类。
该类上的 Registration 注释确保新创建的工具与 Instrument API 注册,换句话说,它将被框架自动发现。它还提供有关工具的一些元数据:ID、名称、版本、工具提供的服务以及工具是内部工具还是外部工具。为了使该注释生效,DSL 处理器需要处理此类。在简单工具的情况下,这通过在 Maven 配置 中将 DSL 处理器作为依赖项来自动完成。
现在,我们将回顾 SimpleCoverageInstrument
类,即它覆盖了 TruffleInstrument
中哪些方法。这些方法是 onCreate、onDispose 和 getOptionDescriptors。onCreate
和 onDispose
方法不言自明:它们在创建和处置工具时由框架调用。我们稍后将讨论它们的实现,但首先让我们讨论剩下的一个:getOptionDescriptors
。
Truffle 语言实现框架 带有自己的指定命令行选项的系统。这些选项允许工具用户从命令行或在创建 多语言上下文 时控制工具。它基于注释,并且此类选项的示例是 SimpleCoverageInstrument
的 ENABLED 和 PRINT_COVERAGE 字段。这两个字段都是类型为 OptionKey 的静态 final 字段,并用 Option 进行注释,类似于 Registration
注释,它为该选项提供了一些元数据。再次,与 Registration
注释一样,为了使 Option
注释生效,需要 DSL 处理器,它将生成 OptionDescriptors 的子类(在本例中名为 SimpleCoverageInstrumentOptionDescriptors
)。应从 getOptionDescriptors
方法返回此类的实例,以使框架知道工具提供的选项。
回到 onCreate
方法,我们作为参数接收一个 Env 类的实例。此对象提供了许多有用的信息,但对于 onCreate
方法,我们主要关心 getOptions 方法,该方法可用于读取传递给工具的选项。我们使用它来检查 ENABLED
选项是否已设置,如果已设置,则通过调用 enable 方法启用工具。类似地,在 onDispose
方法中,我们检查选项以了解 PRINT_COVERAGE
选项的状态,如果已启用,则调用 printResults 方法,该方法将打印结果。
“启用工具”意味着什么?一般来说,这意味着我们告诉框架我们感兴趣的事件以及我们想要如何对这些事件做出反应。查看我们的 enable
方法,它执行以下操作。
- 首先,它定义 SourceSectionFilter。此过滤器是我们要关注的源代码部分的声明性定义。在我们的示例中,我们关心所有被认为是表达式的节点,我们不关心内部语言部分。
- 其次,我们获取一个 Instrumenter 类的实例,它是一个允许我们指定我们希望检测系统哪些部分的对象。
- 最后,使用
Instrumenter
类,我们指定一个 Source Section Listener 和一个 Execution Event Factory,这两者将在接下来的两个部分中介绍。
Source Section Listener #
Language API 提供了 Source 的概念,它是源代码单元,以及 SourceSection,它是 Source
的一个连续部分,例如,一个方法、一个语句、一个表达式等等。有关更多详细信息,请参阅相应的 javadoc。
简单工具的第一个要求是跟踪加载的源代码。Instrument API 提供了 LoadSourceSectionListener,它在子类化并与检测器注册后,允许用户对运行时加载源部分做出反应。这正是我们在 GatherSourceSectionsListener 中所做的,它在工具的 enable 方法中注册。GatherSourceSectionsListener
的实现非常简单:我们覆盖了 onLoad 方法以通知工具已加载的每个源部分。工具维护着一个从每个 Source
到 Coverage 对象的映射,该对象维护着每个源加载的源部分的集合。
Execution Event Node #
客户语言被实现为抽象语法树 (AST) 解释器。语言实现者使用标签对某些节点进行注释,这使我们能够使用前面提到的 SourceSectionFilter
以语言无关的方式选择我们感兴趣的节点。
Instrument API 的主要功能在于它能够在 AST 中插入专门的节点,这些节点“包装”了感兴趣的节点。这些节点使用语言开发人员使用的相同基础架构构建,并且从运行时的角度来看,与语言节点没有区别。这意味着用于将客户语言优化为如此高性能语言实现的所有技术也适用于工具开发人员。
有关这些技术的更多信息,请参阅 语言实现文档。需要说明的是,为了使简单工具满足其第二个要求,我们需要使用自己的节点检测所有表达式,该节点将在表达式执行时通知我们。
对于此任务,我们使用 CoverageNode。它是 ExecutionEventNode 的子类,顾名思义,它用于检测执行期间的事件。ExecutionEventNode
提供了许多要覆盖的方法,但我们只关心 onReturnValue。当“包装”的节点返回值时,即它成功执行时,将调用此方法。实现相当简单。我们只需通知工具具有此特定 SourceSection
的节点已执行,工具会更新其覆盖率映射中的 Coverage
对象。
工具仅在每个节点上通知一次,因为逻辑由 flag 保护。事实上,此标志用 CompilationFinal 进行注释,并且对工具的调用之前是对 transferToInterpreterAndInvalidate() 的调用,这是 Truffle 中的一种标准技术,它确保一旦不再需要此检测(节点已执行),则检测以及任何性能开销将从进一步的编译中删除。
为了使框架知道何时在需要时实例化 CoverageNode
,我们需要为它提供一个工厂。工厂是 CoverageEventFactory,它是 ExecutionEventNodeFactory 的子类。此类只需确保每个 CoverageNode
都知道它正在检测的 SourceSection
,方法是在提供的 EventContext 中查找它。
最后,当我们 启用工具 时,我们告诉检测器使用我们的工厂来“包装”由过滤器选择的节点。
用户与工具的交互 #
Simple Tool 的第三个也是最后一个要求是通过打印行覆盖率到标准输出与用户交互。该工具覆盖了 onDispose 方法,该方法在工具被释放时被调用,这一点并不奇怪。在此方法中,我们检查是否设置了正确的选项,如果是,则计算并打印由我们的 Coverage
对象映射记录的覆盖率。
这是一种向用户提供有用信息的简单方法,但绝不是唯一的方法。工具可以将其数据直接转储到文件,或运行显示信息的 Web 端点,等等。Instrument API 为用户提供的机制之一是将工具注册为服务,以便其他工具可以查找。如果我们查看 Registration 注释,我们可以看到它提供了 services
字段,我们可以在其中指定工具提供给其他工具的服务。这些服务需要被明确地 registered。这允许工具之间更清晰的关注点分离,例如,我们可以有一个“实时覆盖率”工具,它将使用我们的 SimpleCoverageInstrument
通过 REST API 向用户提供按需覆盖率信息,以及一个“低覆盖率中止”工具,如果覆盖率下降到阈值以下,它将停止执行,两者都使用 SimpleCoverageInstrument
作为服务。
注意:出于隔离原因,工具服务不可用于应用程序代码,工具服务只能从其他工具或宿主语言使用。
将工具安装到 GraalVM #
到目前为止,Simple Tool 似乎满足了所有要求,但问题仍然是:我们如何使用它?如前所述,Simple Tool 是一个 Maven 项目。将 JAVA_HOME
设置为 GraalVM 安装并运行 mvn package
将生成一个 target/simpletool-<version>.jar
。这是 Simple Tool 的分发形式。
该 Truffle framework 提供了语言/工具代码和应用程序代码之间清晰的分离。因此,将 JAR 文件放在类路径上不会导致框架意识到需要一个新工具。为了实现这一点,我们使用 --vm.Dtruffle.class.path.append=/path/to/simpletool-<version>.jar
,如我们的简单工具的 启动脚本 中所示。该脚本还显示我们可以 设置 Simple Tool 为其指定的 CLI 选项。这意味着,如果我们执行 ./simpletool js example.js
,我们将启动 GraalVM 的 js
启动器,将工具添加到框架类路径,并运行包含的 example.js 文件,并启用 Simple Tool,从而产生以下输出
==
Coverage of /path/to/simpletool/example.js is 59.42%
+ var N = 2000;
+ var EXPECTED = 17393;
function Natural() {
+ x = 2;
+ return {
+ 'next' : function() { return x++; }
+ };
}
function Filter(number, filter) {
+ var self = this;
+ this.number = number;
+ this.filter = filter;
+ this.accept = function(n) {
+ var filter = self;
+ for (;;) {
+ if (n % filter.number === 0) {
+ return false;
+ }
+ filter = filter.filter;
+ if (filter === null) {
+ break;
+ }
+ }
+ return true;
+ };
+ return this;
}
function Primes(natural) {
+ var self = this;
+ this.natural = natural;
+ this.filter = null;
+ this.next = function() {
+ for (;;) {
+ var n = self.natural.next();
+ if (self.filter === null || self.filter.accept(n)) {
+ self.filter = new Filter(n, self.filter);
+ return n;
+ }
+ }
+ };
}
+ var holdsAFunctionThatIsNeverCalled = function(natural) {
- var self = this;
- this.natural = natural;
- this.filter = null;
- this.next = function() {
- for (;;) {
- var n = self.natural.next();
- if (self.filter === null || self.filter.accept(n)) {
- self.filter = new Filter(n, self.filter);
- return n;
- }
- }
- };
+ }
- var holdsAFunctionThatIsNeverCalledOneLine = function() {return null;}
function primesMain() {
+ var primes = new Primes(Natural());
+ var primArray = [];
+ for (var i=0;i<=N;i++) { primArray.push(primes.next()); }
- if (primArray[N] != EXPECTED) { throw new Error('wrong prime found: ' + primArray[N]); }
}
+ primesMain();
其他示例 #
以下示例旨在展示可以使用 Instrument API 解决的常见用例。
- Coverage Instrument:一个覆盖率工具示例,用于构建 Simple Tool。它在后面的文本中作为运行示例使用,在适当时。
- Debugger Instrument:关于如何实现调试器的草图。请注意,Instrument API 已经提供了一个 Debugger Instrument,可以直接使用。
- Statement Profiler:一个能够分析语句执行的分析器。
工具事件监听器 #
Instrument API 在 com.oracle.truffle.api.instrumentation
包中定义。工具代理可以通过扩展 TruffleInstrument
类来开发,并可以使用 Instrumenter
类附加到正在运行的 GraalVM 实例。一旦附加到正在运行的语言运行时,工具代理只要语言运行时没有被释放,就可以一直使用。GraalVM 上的工具代理可以监控各种 VM 级别的运行时事件,包括以下任何事件
- 源代码相关事件:代理可以在监控的语言运行时每次加载新的 Source 或 SourceSection 元素时收到通知。
- 分配事件:代理可以在监控的语言运行时的内存空间中每次分配新对象时收到通知。
- 语言运行时和线程创建事件:代理可以在监控的语言运行时的每次创建新的 执行上下文 或新线程时收到通知。
- 应用程序执行事件:代理在监控的应用程序每次执行特定的一组语言操作时收到通知。此类操作的示例包括语言语句和表达式,从而允许工具代理以非常高的精度检查正在运行的应用程序。
对于每个执行事件,工具代理可以定义过滤标准,GraalVM 工具运行时将使用这些标准来仅监控相关的执行事件。目前,GraalVM 工具接受以下两种过滤器类型之一
AllocationEventFilter
用于按分配类型过滤分配事件。SourceSectionFilter
用于过滤应用程序中的源代码位置。
可以使用提供的生成器对象创建过滤器。例如,以下生成器创建了一个 SourceSectionFilter
SourceSectionFilter.newBuilder()
.tagIs(StandardTag.StatementTag)
.mimeTypeIs("x-application/js")
.build()
示例中的过滤器可用于监控给定应用程序中所有 JavaScript 语句的执行。还可以提供其他过滤选项,例如行号或文件扩展名。
类似于示例中的源部分过滤器可以使用标签指定要监控的一组执行事件。在 com.oracle.truffle.api.instrumentation.Tag
类中定义了语句和表达式等语言无关的标签,并且所有 GraalVM 语言都支持这些标签。除了标准标签外,GraalVM 语言还可以提供其他语言特定的标签,以启用对语言特定事件进行细粒度分析。(例如,GraalVM JavaScript 引擎提供 JavaScript 特定的标签来跟踪 Array
、Map
或 Math
等 ECMA 内置对象的用法。)
监控执行事件 #
应用程序执行事件可以实现非常精确和详细的监控。GraalVM 支持两种不同类型的工具代理来分析此类事件,即
- 执行监听器:一个工具代理,可以在每次发生给定运行时事件时收到通知。监听器实现
ExecutionEventListener
接口,不能将任何状态与源代码位置关联。 - 执行事件节点:一个可以使用 Truffle Framework AST 节点表达的工具代理。此类代理扩展了
ExecutionEventNode
类,并且具有与执行监听器相同的功能,但可以将状态与源代码位置关联。
简单工具代理 #
CoverageExample
类中提供了一个简单的自定义工具代理示例,用于执行运行时代码覆盖率。以下是该代理、其设计及其功能的概述。
所有工具都扩展了 TruffleInstrument
抽象类,并通过 @Registration
注释注册在 GraalVM 运行时中
@Registration(id = CoverageExample.ID, services = Object.class)
public final class CoverageExample extends TruffleInstrument {
@Override
protected void onCreate(final Env env) {
}
/* Other methods omitted... */
}
工具覆盖了 onCreate(Env env)
方法,以便在工具加载时执行自定义操作。通常,工具会使用此方法将自己注册到现有的 GraalVM 执行环境中。例如,使用 AST 节点的工具可以按以下方式注册
@Override
protected void onCreate(final Env env) {
SourceSectionFilter.Builder builder = SourceSectionFilter.newBuilder();
SourceSectionFilter filter = builder.tagIs(EXPRESSION).build();
Instrumenter instrumenter = env.getInstrumenter();
instrumenter.attachExecutionEventFactory(filter, new CoverageEventFactory(env));
}
工具使用 attachExecutionEventFactory
方法将自己连接到正在运行的 GraalVM,并提供以下两个参数
SourceSectionFilter
:一个源部分过滤器,用于通知 GraalVM 要跟踪的特定代码部分。ExecutionEventNodeFactory
:Truffle AST 工厂,它提供工具 AST 节点,以便代理在每次执行运行时事件(如源过滤器所指定)时执行这些节点。
可以按以下方式实现一个基本的 ExecutionEventNodeFactory
,它可以对应用程序的 AST 节点进行工具化
public ExecutionEventNode create(final EventContext ec) {
return new ExecutionEventNode() {
@Override
public void onReturnValue(VirtualFrame vFrame, Object result) {
/*
* Code to be executed every time a filtered source code
* element is evaluated by the guest language.
*/
}
};
}
执行事件节点可以实现某些回调方法,以拦截运行时执行事件。示例包括
onEnter
:在评估与过滤后的源代码元素(例如,语言语句或表达式)对应的 AST 节点之前执行。onReturnValue
:在源代码元素返回值后执行。onReturnExceptional
:在过滤后的源代码元素抛出异常时执行。
执行事件节点是按每个代码位置创建的。因此,它们可以用于存储与工具化应用程序中给定源代码位置相关的特定数据。例如,工具节点可以简单地使用节点局部标志跟踪所有已访问的代码位置。可以使用此类节点局部 boolean
标志来跟踪以下方式中的 AST 节点的执行
// To keep track of all source code locations executed
private final Set<SourceSection> coverage = new HashSet<>();
public ExecutionEventNode create(final EventContext ec) {
return new ExecutionEventNode() {
// Per-node flag to keep track of execution for this node
@CompilationFinal private boolean visited = false;
@Override
public void onReturnValue(VirtualFrame vFrame, Object result) {
if (!visited) {
CompilerDirectives.transferToInterpreterAndInvalidate();
visited = true;
SourceSection src = ec.getInstrumentedSourceSection();
coverage.add(src);
}
}
};
}
如上代码所示,ExecutionEventNode
是一个有效的 AST 节点。这意味着工具代码将与工具化应用程序一起由 GraalVM 运行时优化,从而最大限度地降低工具化开销。此外,这允许工具开发人员直接从工具节点使用 Truffle 框架编译器指令。在示例中,编译器指令用于通知 Graal 编译器 visited
可以被视为编译最终的。
每个工具节点都绑定到特定的代码位置。工具可以使用提供的 EventContext
对象访问这些位置。上下文对象使工具节点能够访问有关当前正在执行的 AST 节点的各种信息。通过 EventContext
可用于工具代理的查询 API 示例包括
hasTag
:查询工具化节点以获取某个节点Tag
(例如,检查语句节点是否也是条件节点)。getInstrumentedSourceSection
:访问与当前节点关联的SourceSection
。getInstrumentedNode
:访问与当前工具事件对应的Node
。
细粒度表达式分析 #
工具代理甚至可以分析语言表达式等部分事件。为此,代理需要使用两个源部分过滤器进行初始化
// What source sections are we interested in?
SourceSectionFilter sourceSectionFilter = SourceSectionFilter.newBuilder()
.tagIs(JSTags.BinaryOperation.class)
.build();
// What generates input data to track?
SourceSectionFilter inputGeneratingLocations = SourceSectionFilter.newBuilder()
.tagIs(StandardTags.ExpressionTag.class)
.build();
instrumenter.attachExecutionEventFactory(sourceSectionFilter, inputGeneratingLocations, factory);
第一个源代码节过滤器(示例中的 `sourceSectionFilter`)是一个普通过滤器,与之前描述的其他过滤器等效,用于识别要监控的源代码位置。第二个节过滤器 `inputGeneratingLocations` 由代理使用,用于指定应针对特定源代码节监控的 *中间值*。中间值对应于 *所有* 与监控代码元素执行相关的可观察值,并通过 `onInputValue` 回调报告给检测代理。例如,假设代理需要分析 JavaScript 中所有提供给求和操作(`+`)的 *操作数* 值。
var a = 3;
var b = 4;
// the '+' expression is profiled
var c = a + b;
通过过滤 JavaScript 二元表达式,检测代理可以为上面的代码片段检测以下运行时事件。
onEnter()
:针对第 3 行的二元表达式。onInputValue()
:针对第 3 行的二元运算符的第一个操作数。回调报告的值将为 `3`,即 `a` 局部变量的值。onInputValue()
:针对二元运算符的第二个操作数。回调报告的值将为 `4`,即 `b` 局部变量的值。onReturnValue()
:针对二元表达式。提供给回调的值将是表达式完成评估后返回的值,即值 `7`。
通过将源代码节过滤器扩展到 *所有* 可能的事件,检测代理将观察到类似以下执行跟踪(伪代码)的内容。
// First variable declaration
onEnter - VariableWrite
onEnter - NumericLiteral
onReturnValue - NumericLiteral
onInputValue - (3)
onReturnValue - VariableWrite
// Second variable declaration
onEnter - VariableWrite
onEnter - NumericLiteral
onReturnValue - NumericLiteral
onInputValue - (4)
onReturnValue - VariableWrite
// Third variable declaration
onEnter - VariableWrite
onEnter - BinaryOperation
onEnter - VariableRead
onReturnValue - VariableRead
onInputValue - (3)
onEnter - VariableRead
onReturnValue - VariableRead
onInputValue - (4)
onReturnValue - BinaryOperation
onInputValue - (7)
onReturnValue - VariableWrite
onInputValue
方法可以与源代码节过滤器结合使用,以拦截非常细粒度的执行事件,例如语言表达式使用的中间值。可用于检测框架的中间值在很大程度上取决于每种语言提供的检测支持。此外,语言可能提供与语言特定 `Tag` 类关联的附加元数据。
更改应用程序的执行流程 #
到目前为止,我们介绍的检测功能使用户能够 *观察* 运行应用程序的某些方面。除了被动监控应用程序的行为之外,Instrument API 还支持在运行时主动 *更改* 应用程序的行为。此类功能可用于编写复杂的检测代理,这些代理会影响运行应用程序的行为以实现特定的运行时语义。例如,可以更改正在运行应用程序的语义,以确保某些方法或函数永远不会执行(例如,在调用它们时抛出异常)。
具有此类功能的检测代理可以通过利用执行事件监听器和工厂中的 `onUnwind` 回调来实现。例如,让我们考虑以下 JavaScript 代码
function inc(x) {
return x + 1
}
var a = 10
var b = a;
// Let's call inc() with normal semantics
while (a == b && a < 100000) {
a = inc(a);
b = b + 1;
}
c = a;
// Run inc() and alter it's return type using the instrument
return inc(c)
可以利用 `ExecutionEventListener` 实现一个将 `inc` 的返回值始终更改为 `42` 的检测代理,如下所示
ExecutionEventListener myListener = new ExecutionEventListener() {
@Override
public void onReturnValue(EventContext context, VirtualFrame frame, Object result) {
String callSrc = context.getInstrumentedSourceSection().getCharacters();
// is this the function call that we want to modify?
if ("inc(c)".equals(callSrc)) {
CompilerDirectives.transferToInterpreter();
// notify the runtime that we will change the current execution flow
throw context.createUnwind(null);
}
}
@Override
public Object onUnwind(EventContext context, VirtualFrame frame, Object info) {
// just return 42 as the return value for this node
return 42;
}
}
例如,可以使用以下工具执行拦截所有函数调用的事件监听器
@TruffleInstrument.Registration(id = "UniversalAnswer", services = UniversalAnswerInstrument.class)
public static class UniversalAnswerInstrument extends TruffleInstrument {
@Override
protected void onCreate(Env env) {
env.registerService(this);
env.getInstrumenter().attachListener(SourceSectionFilter.newBuilder().tagIs(CallTag.class).build(), myListener);
}
}
启用后,每次函数调用返回时,工具将执行其 `onReturnValue` 回调。回调读取关联的源代码节(使用 `getInstrumentedSourceSection`)并查找特定的源代码模式(在本例中为函数调用 `inc(c)`)。一旦找到这种代码模式,工具就会抛出一个特殊的运行时异常,称为 `UnwindException`,该异常指示检测框架关于当前应用程序执行流程的更改。此异常将被检测代理的 `onUnwind` 回调拦截,该回调可用于向原始检测应用程序返回 *任何* 任意值。
在示例中,所有对 `inc(c)` 的调用都将返回 `42`,无论任何应用程序特定数据。更现实的工具可能会访问和监控应用程序的多个方面,并且可能不依赖于源代码位置,而是依赖于对象实例或其他应用程序特定数据。