我们在分析各种系统异常和故障的时候,通常会用到 pstack(jstack) /pldd/ lsof/ tcpdump/ gdb(jdb)/ netstat/vmstat/ mpstat/truss(strace)/iostat/sar/nmon(top)等系列工具,这些工具从某个方面为我们提供了诊断信息。但这些工具常常带有各类“副作用”,比如 truss(见于 AIX/Solaris) 或者 strace(见于 Linux) 能够让我们检测我们应用的系统调用情况,包括调用参数和返回值,但是却会导致应用程序的性能下降;这对于诊断毫秒级响应的计费生产系统来说,影响巨大。
有没有一个工具,能够兼得上述所有工具的优点,又没有副作用呢?答案是有!对于 Solaris/BSD/OS X 系统来说,那就是 DTrace 工具(后来,Linux 也终于有了自己类似的工具,stap)。
mac $ sudo iosnoop
DTrace(全称Dynamic Tracing),也称为动态跟踪,是由 Sun™ 开发的一个用来在生产和试验性生产系统上找出系统瓶颈的工具,可以对内核(kernel)和用户应用程序(user application)进行动态跟踪并且对系统运行不构成任何危险的技术。在任何情况下它都不是一个调试工具, 而是一个实时系统分析寻找出性能及其他问题的工具。 DTrace 是个特别好的分析工具,带有大量的帮助诊断系统问题的特性。还可以使用预先写好的脚本利用它的功能。 用户也可以通过使用 DTrace D 语言创建他们自己定制的分析工具, 以满足特定的需求。
Solaris(包括 OpenSolaris)、FreeBSD 和 Mac OS X 中内置的 Dynamic Tracing (DTrace) 功能提供一个用于动态地跟踪应用程序的简单环境。与调试不同,DTrace 可以根据需要打开或关闭,而且使用跟踪功能不需要以特殊方式构建应用程序。
上面的所有平台都支持使用标准的 DTrace 探测。这包括在代码中不同函数边界由操作系统实现的那些探测。这些探测称为 Function Boundary Tracing (FBT),可以通过它们探测特定函数的启动或停止。
这个功能的局限是,只能使用它探测函数,而不能探测应用程序的功能性片段。如果想探测组成同一操作的多个函数,或者想检查某一函数的片段,FBT 就无能为力了。
对于自己的应用程序,可以使用 User-land Statically Defined Tracing (USDT) 解决这个问题。USDT 让开发人员可以在代码中重要的位置添加特定的探测。还可以使用 USDT 从正在运行的应用程序获取数据,这些数据可作为跟踪应用程序的探测的参数而被访问。
开始在系统中添加 USDT 探测之前,首先需要考虑想让探测报告什么、它们可能提供什么信息以及潜在的性能问题。
探测设计
如果发现标准的 FBT 探测不适合您的需求,就需要考虑在应用程序中添加静态的探测。
当使用 DTrace 在应用程序中添加探测时,要考虑的第一个问题是,您实际上想用探测实现什么目的。探测有助于查明许多问题和信息,但是您应该明确探测的目标。应该选择特定的领域,比如功能性、性能和其他可测量的信息,这些信息应该是只使用函数边界上的标准进入/退出探测无法获得的。
因此,在简单的层面上,应该考虑两种主要的探测类型:
信息探测:这些探测提供或汇总在执行程序期间难以以其他方式获得的信息。例如,内部结构的大小或内容,或者不由函数直接处理的事件的操作或触发。在现有的操作系统探测中有许多这类探测。例如,可以获取磁盘 I/O 统计数据或虚拟内存系统中的错误数量。
操作探测:这些探测在特定事件或语句序列前后触发,可以在语句序列的开头和末尾使用它们获取内部结构的特定信息,或者监视一组语句的执行时间。因为可以把这些探测放在任何地方以表示操作的开始和结束,所以它们可以跨多个函数,也可以只覆盖函数中的一小部分。这比使用函数边界实用得多,函数边界提供的范围可能太大或太小。根据惯例,这些探测通常包含后缀 start 和 done。
决定了探测的类型之后,要考虑的下一个问题是,是否希望在探测中暴露更多信息,如果是这样,是什么信息以及应该采用什么格式提供它们。在 DTrace 中,探测可以通过参数暴露信息,可以通过编写适当的 DTrace 脚本或单行代码访问这些参数。例如,如果要检查一个文件 I/O 函数,可以把正在写的文件的名称添加到函数的探测中。
在定义探测时,指定这些参数的名称和类型:probe write__file__start(int id, char *filename);。
在监视期间,DTrace 脚本可以通过变量 arg0、arg1 等访问每个参数。因此,可以使用 printf(“%s\n”, copyinstr(arg1)); 输出文件名(第二个参数)。
要想选择正确的要暴露的数据,必须了解您希望在监视应用程序时从探测获得什么。例如,对于上面的 I/O 函数示例,如果这个函数用于写多个文件,那么文件名可能很重要。但是,如果此函数只写同一个文件,那么完全不需要在探测中暴露文件名。
因此,必须考虑以什么方式提供信息。希望能够按操作类型、文件或网络端口汇总数据吗?希望知道数据大小,还是想知道正在写的实际数据?这些问题都与程序和环境的具体情况相关。
还应该考虑以这种方式提供信息的开销,应该通过共享信息尽可能降低开销,尤其是对于大型结构。应该限制提供的信息量,如果可能的话,应该进行信息汇总(但是注意,对于 DTrace 探测来说,字符串的复制、缩减或重新格式化对性能影响更大)。
以这种方式引入探测有两种方法:多个探测以及使用特殊的 ‘是否启用探测’ 检查。当编写探测并使用 dtrace 命令生成头文件时,以一种非常简单的风格支持后一个解决方案。可以把代码块放在判断此探测是否已经启用(例如是否正在监视它)的检查代码内,还可以在其中执行更多操作,这对于汇总或整理数据很有用(见 清单 1)。
清单 1. 检查探测是否已经启用的代码
if (WRITE_FILE_START_ENABLED())
{
…
}
前一种方法使用单独的探测,这让用户可以使用所需的探测获取所需的信息。例如,对于 I/O 示例,可以通过概念上嵌套的探测结构提供不同层次的信息,这样就可以决定需要的信息:
write-file-start (id)
write-file-data(filename, buffer)
write-file-done (id)
在监视应用程序时,如果只想监视写文件操作的速度,可以使用 write-file-start 和 write-file-done 探测,它们提供供引用的 ID。如果需要文件名数据,可以监视 write-file-data 探测以输出这一信息。
最后,对于所有探测,必须牢记一点:有权监视 DTrace 探测的任何人都能够访问它们暴露的信息。例如,如果在探测中暴露电子邮件地址或邮件内容,那么具有 DTrace 权限的任何用户都能够读取这些信息。以这种方式暴露敏感信息一定要小心。如果可能的话,应该只提供统计数据;如果必须暴露可能敏感的真实信息,可以考虑对数据进行模糊处理,让别人判断不出真正的内容。
定义探测
在 Solaris/OpenSolaris 上,可以使用 /usr/include/sys/sdt.h 中的宏来定义探测。根据希望包含的参数数量,通过调用适当的宏,在代码中插入探测。例如,可以使用 DTRACE_PROBE(“prime”,”calc-start”) 插入一个无参数的探测。
如果希望共享参数,根据在触发探测时共享的参数数量,使用不同的宏(从 1 编号到 5)。例如,使用 DTRACE_PROBE(“prime”,”calc-start”,prime) 共享一个参数。
只在 Solaris/OpenSolaris 上支持这种方法。在 Solaris/OpenSolaris、FreeBSD 和 Mac OS X 上都支持的另一种可移植性更好的方法(这也提供更简便的在代码中插入探测的方法)是创建探测定义文件,其中包含希望插入代码中的每个探测,包括希望通过每个探测共享的参数的定义。
这个文件的格式与 C 语言相似。必须指定一个或多个提供者,在每个提供者中指定希望在代码中支持的探测。探测定义的示例见 清单 2。
清单 2. 示例探测定义
provider primes {
/* Start of the prime calculation */
probe primecalc__start(long prime);
/* End of the prime calculation */
probe primecalc__done(long prime, int isprime);
/* Exposes the size of the table of existing primes */
probe primecalc__tablesize(long tablesize);
};
provider 是在应用程序中安装探测之后提供者的名称。DTrace 中的探测由提供者、模块、函数和探测名标识:provider:module:function:name。对于 USDT 探测,可以只指定其中的 provider 和 name 部分。
探测的名称取自 probe 关键字后面的字符串。可以用双下划线分隔探测名中的单词。在跟踪期间希望使用探测时,把双下划线改为单一连字符。例如,这个文件中的探测名 primecalc__start() 可以表示为 primes::primecalc-start(提供者和探测名的组合)。
定义中每个探测的参数用来标识 C 代码中参数的数据类型。在跟踪期间用 arg0、arg1、argN 等引用参数。因此,对于 primecalc__done 探测,素数是 arg0,不确定是否是素数则用 arg1 表示。
创建了探测定义文件之后,使用 dtrace 命令把探测定义转换为头文件:$ dtrace -o probes.h -h -s probes.d。
上面的命令指定输出文件名 (-o)、希望生成头文件 (-h) 以及源探测定义文件名 (-s)。
产生的头文件包含宏,可以通过在代码中放置这些宏插入探测。可以在代码中希望触发探测的任何地方使用它们,次数不限。
探测宏的名称取决于您定义的探测名。例如,primecalc__done 的宏是 PRIMES_PRIMECALC_DONE。既然有了探测定义文件(在构建应用程序时还要使用它)和头文件,现在就该在 C 源代码中插入探测了。
标识探测的位置
为了说明把探测放在什么地方,我们来看一个用于判断素数的简单程序。这里的代码不是最高效的,DTrace 探测可以帮助我们发现问题。
最初的源代码见 清单 3。
清单 3. 用于判断素数的程序的源代码
#include
long primes[1000000] = { 3 };
long primecount = 1;
int main(int argc, char **argv)
{
long divisor = 0;
long currentprime = 5;
long isprime = 1;
while (currentprime < 1000000)
{
isprime = 1;
for(divisor=0;divisor<primecount;divisor++)
{
if (currentprime % primes[divisor] == 0)
{
isprime = 0;
}
}
if (isprime)
{
primes[primecount++] = currentprime;
printf(“%d is a prime\n”,currentprime);
}
currentprime = currentprime + 2;
}
}
添加 DTrace 探测之后的代码见 清单 4。
清单 4. 添加 DTrace 探测之后的代码
#include
#include "probes.h"
long primes[1000000] = { 3 };
long primecount = 1;
int main(int argc, char **argv)
{
long divisor = 0;
long currentprime = 5;
long isprime = 1;
while (currentprime < 1000000)
{
isprime = 1;
PRIMES_PRIMECALC_START(currentprime);
for(divisor=0;divisor<primecount;divisor++)
{
if (currentprime % primes[divisor] == 0)
{
isprime = 0;
}
}
PRIMES_PRIMECALC_DONE(currentprime,isprime);
if (isprime)
{
primes[primecount++] = currentprime;
PRIMES_PRIMECALC_TABLESIZE(primecount);
printf(“%d is a prime\n”,currentprime);
}
currentprime = currentprime + 2;
}
}
决定探测的位置要考虑许多因素:
由 dtrace 生成的头文件已经包含在源代码中。
primecalc-start 和 primecalc-done 探测的位置紧挨着执行计算的主循环外边。您可能想把探测放在外层 while 循环的开头和末尾,因为这似乎是插入探测的合理位置。但是,正如前面提到的,对于这些用来监视特定功能领域的探测,应该尽可能接近要监视的实际操作。如果把探测放在 while 循环的开头和末尾,就会包含许多与素数的实际计算无关的操作。这些额外步骤与您真正想监视的操作花费的时间会加在一起,尽管在这个应用程序中不会有显著的差异,但是在其他应用程序中可能差异很大。
primecalc-tablesize 探测的设计目的不是监视执行时间,而是监视表的大小。显然,放置这个探测的位置应该尽可能接近修改值的地方。这一点很重要,因为从跟踪的角度来说,即使不打算监视值随时间的变化,也希望知道修改值的准确位置。
注意,done 探测提供数字和这个数字是否判断为素数。在 for 循环结束之后,就已经知道数字是否是素数,尽管在 if 语句之前并不使用判断结果。另外,通过提供 isprime 变量的值,可以在代码中放置 done 探测,用这个值作为参数,以此替代其他探测。在脚本中,可以使用这个值通过谓词分别统计素数和非素数花费的时间。
对于其他操作,应用相同的规则。应该确保提供统计数据(不一定是计时数据)的任何探测尽可能接近修改此信息的地方。可以在代码中多次放置同一个探测。在这里,可以在主代码块的头中变量初始化代码后面放上 PRIMES_PRIMECALC_TABLESIZE 宏,从而提供最初的值。
编译应用程序
可以使用 C 编译器像编译其他应用程序一样编译 DTrace 应用程序。在 Mac OS X/FreeBSD 上,可以完全像平常一样编译应用程序。但是在 Solaris/OpenSolaris 上,必须修改对象文件并生成一个包含 DTrace 探测的新的对象文件。
在 Solaris/OpenSolaris 上,编译过程会在最终链接之前修改对象文件,还必须链接一个单独生成的对象文件,其中包含希望在应用程序中启用的探测。修改对象文件的过程是自动的 — 也就是说,您指定对象文件,过程会修改文件并把修改保存回源代码文件。在这个过程中生成对象文件。一般的操作次序是:
把每个源代码文件编译为对象文件,例如:$ gcc -c primes.c。
编译所有源代码文件之后,创建一个 DTrace 探测对象文件,其中包含要链接进主程序的探测。例如,对于单一对象文件,可以使用以下命令:$ dtrace -G -s probes.d -o probes.o primes.o。
上面的命令读取对象文件 primes.o 和探测定义 probes.d,然后生成探测对象文件 probes.o。如果有多个包含 DTrace 探测的对象文件,可以在命令行上指定更多对象文件。例如:$ dtrace -G -s probes.d -o probes.o file1.o file2.o file3.o。
链接应用程序,包括所有对象文件和生成的探测对象文件:$ gcc -o primes primes.o probes.o。
生成最终的 primes 可执行程序,可以运行并探测它了。
在 FreeBSD/Mac OS X 上不需要生成单独的探测对象文件。这使编译过程大大简化了:
把每个源代码文件编译为对象文件,例如:$ gcc -c primes.c。
链接应用程序,包括所有对象文件:$ gcc -o primes primes.o。
包含 DTrace 探测的应用程序已经准备好了。
现在来尝试使用探测。
通过编写脚本使用探测
我们只在一个非常基本的程序中添加了一些非常基本的探测,但是仍然可以获得一些有用的执行信息。例如,可以使用 start 和 done 探测查明找到所有素数和所有非素数花费多长时间。因为素数比非素数少得多,应该可以看到这两类元素的计时数据有很大差异。示例脚本见 清单 5。
清单 5. 显示寻找素数和非素数的时间差异的脚本
#!/usr/sbin/dtrace -s
#pragma D option quiet
primes*:::primecalc-start
{
self->start = timestamp;
}
primes*:::primecalc-done
/arg1 == 1/
{
@times[“prime”] = sum(timestamp - self->start);
}
primes*:::primecalc-done
/arg1 == 0/
{
@times[“nonprime”] = sum(timestamp - self->start);
}
END
{
normalize(@times,1000000);
printa(@times);
}
这个脚本使用谓词区分最终结果为素数和非素数的计算,把从 start 探测到 done 探测的时间数据累加起来。时间数据放在一个关联数组中,使用聚合函数 sum() 执行聚合。
END 块中的 normalize() 函数把结果除以一百万,得到以毫秒为单位的时间数据。如果在运行 primes 程序的同时运行这个脚本,会得到与 清单 6 相似的输出。
清单 6. 输出
$ dtrace -s timing.d
^C
prime 10784
nonprime 16340221
结果表明,寻找非素数花费的时间远远高于寻找素数的时间。这是因为非素数比素数多得多。
技巧和注意事项
在使用 DTrace 探测时要考虑许多问题,下面是您应该了解的一些问题:
不要把探测放在函数进入点和退出点。因为已经可以使用 FBT 探测自动地访问这些位置,所以这么做的惟一好处只是提供与函数名不同的探测名。如果要添加 USDT 探测,应该在希望探测的操作点上创建探测,不应该在函数边界上。
不要让 DTrace 探测作为函数中的最后一个语句。否则,对于某些平台和编译器组合,代码优化过程可能会把探测优化掉(实际上完全删除探测),或者把探测与发出调用的函数联系在一起(这可能会消除参数数据,或者导致怪异的计时问题)。
如果要编译供链接的库,希望能够使用探测,就需要在创建库之前对对象文件运行 DTrace 进程。另外,在 Solaris/OpenSolaris 上,需要在库中包含生成的对象文件和普通的对象文件。在使用复杂的构建过程时必须格外小心,比如 automake 应用的构建过程会把对象文件放在一个临时目录中,在生成库时显式地使用它。必须确保对添加到库中的对象文件运行 dtrace 命令。
生成的探测对象文件与它基于的对象文件必须匹配。如果对象文件改变了,必须重新生成探测对象文件,否则链接会失败。
如果使用 autoconf、cmake 或相似的程序,希望保持跨平台兼容性,对于 dtrace 要注意的惟一一点是使用 -G 生成探测对象文件。很容易在配置期间对此进行测试。