关于 .d 文件一些思考与理解

Reactive Cocoa 里的 .d 文件到底有什么用呢?

问题的由来

最近在开发过程中,遇到了一个自己还无法回答的问题,就是 Reactive Cocoa 里的两个 .d 文件到底有啥用,以及怎么用?

DTrace

DTrace 是一个动态追踪技术,说的可能更接地气一点,就是可以使用 DTrace 附加在一个已经运行的程序上,且不会打断当前程序的运行,也不需要重新编译或者启动此程序。

乍一听,感觉很不错,好像我们可以搞点事情了,但放到 macOS 和 iOS 的场景下就有了一些限制。

DTrace 只能在 macOS 上运行,Apple 也在 iOS 上使用 DTrace,用以支持像 Instruments 这样的工具,但对于第三方开发者,DTrace 只能运行于 macOS 或 iOS 模拟器。

基本概念

这篇文章本身的目的是为了解决文章开篇提到的问题,所以不会科普太多 DTrace 技术本身的基本概念。这里只是把下面用到的概念说明一下,方便读者理解。

在 DTrace 里有两个比较重要的概念,它们分别是probe(探针)和 dtrace file(DTrace 脚本)。

探针是指我们利用在代码里埋的点,插的桩,它有一套标准的定义,本文在这里不展开了,感兴趣可以阅读这个资料:DTrace Book

DTrace 脚本,是用 D 语言编写的脚本,既可以用 DTrace 脚本声明 probe,也可以触发 probe。

声明 probe 的例子:

// 声明 probe
provider syncengine_sync {
probe strategy_go_to_state(int);
}

或者调用 probe 的例子:

// 调用 probe
syncengine_sync*:::strategy_go_to_state
{
printf("Transitioning to state %d\n", arg0);
}

那么怎么启用 DTrace 呢?整体来说,有两种途径:

  • 使用 dtrace 脚本来触发,也就是 .d 文件
  • 使用 dtrace 命令来触发,也就是命令行里的 dtrace 命令

注意,如果想使用 dtrace 还需要关闭 System Integrity Protection,也就是常说的 Rootless,具体操作步骤是:

  • 重新启动你的macOS机器
  • 当屏幕变成空白时,按住 Command + R,直到出现苹果的启动标志。这将使你的电脑进入 Recovery Mode
  • 现在,从顶部找到 Utilities 菜单,然后选择 Terminal
  • 在终端窗口打开后,输入 csrutil disable && reboot
  • 只要 csrutil disable 命令成功,你的电脑就会在禁用 Rootless 后重新启动

DTrace 的使用场景

那么从使用者的角度来说,DTrace 只适用于两种场景:

  • 追踪系统内核代码:使用者只需要直接调用系统预埋的 probe 即可。
  • 追踪 App 侧的自定义代码:使用者一方面需要在 App 侧埋 probe,另一方面也需要去调用自己埋的 probe。

对于追踪系统内核的代码,其实有很多文章在说明,这里就不展开来说了,感兴趣可以看看网上的文章, 大多都是在将这种场景的使用方式.

今天的目标也是为了解释第二个场景,进而说明 Reactive Cocoa 里的 .d 文件的用途。

在自己的代码里使用 DTrace 技术

这里我们设计一个 CLI 工具,这个 CLI 工具不会停止,也不会做任何事儿,它的逻辑大概如下(其实就是一个无限循环):

#import <Foundation/Foundation.h>
int main(int argc, const char * argv[]) {
@autoreleasepool {
while (1) { }
}
return 0;
}

声明探针

此时我们在工程里创建一个 provider.d 文件声明一个自定义的 probe

provider zsq {
probe go();
};

此时的文件目录是如下

▶ tree
.
├── main.m
└── zsq.d

预埋探针

在 Xcode 里面的 build rule 会有这么一个自动化操作,如果判断出目标文件是 .d 文件,也就是 dtrace 文件,会生成对应的 .h 文件。

结合上面的例子,此时 Xcode 的 build system 就会生成一个 zsq.h 文件,在 build log 里我们可以查看到它。

此时我们可以看一下 zsq.h 里的内容,对我们比较有用的是两个基于探针行为定义的宏 ZSQ_GO_ENABLEDZSQ_GO,感兴趣可以展开下面的代码来查看。

/*
* Generated by dtrace(1M).
*/
#ifndef_ZSQ_H
#define_ZSQ_H
#if !defined(DTRACE_PROBES_DISABLED) || !DTRACE_PROBES_DISABLED
#include <unistd.h>
#endif /* !defined(DTRACE_PROBES_DISABLED) || !DTRACE_PROBES_DISABLED */
#ifdef__cplusplus
extern "C" {
#endif
#define ZSQ_STABILITY "___dtrace_stability$zsq$v1$1_1_0_1_1_0_1_1_0_1_1_0_1_1_0"
#define ZSQ_TYPEDEFS "___dtrace_typedefs$zsq$v2"
#if !defined(DTRACE_PROBES_DISABLED) || !DTRACE_PROBES_DISABLED
#defineZSQ_GO() \
do { \
__asm__ volatile(".reference " ZSQ_TYPEDEFS); \
__dtrace_probe$zsq$go$v1(); \
__asm__ volatile(".reference " ZSQ_STABILITY); \
} while (0)
#defineZSQ_GO_ENABLED() \
({ int _r = __dtrace_isenabled$zsq$go$v1(); \
__asm__ volatile(""); \
_r; })
extern void __dtrace_probe$zsq$go$v1(void);
extern int __dtrace_isenabled$zsq$go$v1(void);
#else
#defineZSQ_GO() \
do { \
} while (0)
#defineZSQ_GO_ENABLED() (0)
#endif /* !defined(DTRACE_PROBES_DISABLED) || !DTRACE_PROBES_DISABLED */
#ifdef__cplusplus
}
#endif
#endif/* _ZSQ_H */

通过这个自动生成的头文件,我们就可以在自己的代码中预埋自定义的 probe,大体的逻辑使用方式就是先判断 probe 是否 enable,如果 enable,再真的执行它。

#import <Foundation/Foundation.h>
#import "zsq.h"
int main(int argc, const char * argv[]) {
@autoreleasepool {
while (1) {
if(ZSQ_GO_ENABLED()) {
NSLog(@"Hello");
ZSQ_GO();
}
}
}
return 0;
}

触发探针

首先正常启用 CLI 命令后

// 启用 cli 命令行工具
..../SQTool

在没有触发 DTrace 之前,终端不会有任何输出

此时我们在另一个 terminal 里去启用 dtrace 来追踪预埋的探针

// -s 参数的 zsq.d 指的是定义的 probe 文件,
// -P 参数的 zsq30629 指的是 probe name + PID, probe name 在 .d 里查看, PID 命令可以通过 ps -A 查看
sudo dtrace -s zsq.d -P zsq30629
// 或者
sudo dtrace -P zsq30629 // 如果你的 .d 文件已经被 dtrace 加载了,就无须重复使用 -s 参数重复加载

此时,dtrace 服务被激活,CLI 里预埋的 probe 生效,我们就看到执行 CLI 里的 terminal 不断的在输出 Hello,也就是被 ZSQ_GO_ENABLED() 包裹的逻辑之一。

总结

至此,我们完成了自定义探针的定义,预埋和调用。

那基于前面的 demo,我们来理解下 Reactive Cocoa 里的 .d 文件到底干了什么?以及怎么用?

以 Reactive Cocoa 里的 RACCompoundDisposableProvider 为例,它只是定义了一个 provider 为 RACCompoundDisposable,probe 为 added 和 removed 的探针。

而在 RACCompoundDisposable.m 中,会在 addDisposable 中预埋 RACCOMPOUNDDISPOSABLE_ADDED 的探针,感兴趣可以展开下面的代码来查看。

- (void)addDisposable:(RACDisposable *)disposable {
NSCParameterAssert(disposable != self);
if (disposable == nil || disposable.disposed) return;
BOOL shouldDispose = NO;
OSSpinLockLock(&_spinLock);
{
if (_disposed) {
shouldDispose = YES;
} else {
#if RACCompoundDisposableInlineCount
for (unsigned i = 0; i < RACCompoundDisposableInlineCount; i++) {
if (_inlineDisposables[i] == nil) {
_inlineDisposables[i] = disposable;
goto foundSlot;
}
}
#endif
if (_disposables == NULL) _disposables = RACCreateDisposablesArray();
CFArrayAppendValue(_disposables, (__bridge void *)disposable);
if (RACCOMPOUNDDISPOSABLE_ADDED_ENABLED()) {
RACCOMPOUNDDISPOSABLE_ADDED(self.description.UTF8String, disposable.description.UTF8String, CFArrayGetCount(_disposables) + RACCompoundDisposableInlineCount);
}
#if RACCompoundDisposableInlineCount
foundSlot:;
#endif
}
}
OSSpinLockUnlock(&_spinLock);
// Performed outside of the lock in case the compound disposable is used
// recursively.
if (shouldDispose) [disposable dispose];
}

那么基于这个探针,我们就可以使用 dtrace 去追踪 add 的行为。

那这种功能有什么用呢?

如果你在开发前端的时候使用 redux,估计你八成会使用到这样的一个 debug tool - reaction,它能将 saga 里的每个动作,展示在 debug tool 里面。

那么同理到我们的 Reactive Cocoa 中,我们就可以搞一个 debug tool 实时监控代码里某些行为,方便我们调试。

那 .d 文件对于我们意味着什么?

从组件化角度看,分为两种业务场景,一种是源码形式,一种是二进制形式

  • 在源码形式下,需要保留 .d 文件:由于组件的内的 .m 文件还会依赖 由 .d 自动生成的 .h 文件,如果想让编译通过,就需要保留 .d 文件。例如 RACCompoundDisposable.m 会依赖 RACCompoundDisposableProvider.d 生成的 RACCompoundDisposableProvider.h 文件,
  • 在二进制形式下,不需要保留 .d 文件:由于组件内的 .m 文件已经被编译成二进制,不再需要编译行为,.d 文件已经没有存在价值,也就不依赖 RACCompoundDisposableProvider.d 生成的 RACCompoundDisposableProvider.h 文件,

那结合上面的两个视角,我们在 CI 上又应该干点什么呢?

  • 基于现在的状况(即没有人把 .d 生成的 .h 放到公开头文件里),我们就可以把把 .d 文件当做 .m 文件一样看待,即在二进制产物中删掉 .d 文件即可。

那么可能就会有人,为啥不能把 .d 生成的 .h 放到公开头文件里呢,这个行为合理么?

  • 我认为是没有必要的,原因大致如下:
    • 首先 .d 文件生成的 .h 只是与 probe 相关的逻辑,为自己的组件提供了 dtrace 能力,方便自己的调试或者行为追踪。
    • 这种埋点自查的能力应当只在自己的组件内(例如组件 A)使用,即使提供给外界(组件 B)使用,那么组件 B 也无法追踪组件 A 的行为,组件 B 只能追踪自己的行为,如果想追踪自己的行为,那又为什么要用 A 里的 probe 呢?对吧。自己追踪自己就好,不要用别人的探针,避免歧义。
    • 所以这个 .d 文件从原理上是可以放到公开的 .h 文件中,但这并不是那么合理,所以从实际使用的角度上来说,是不应该将 .d 自动生成的 .h 文件放到公开的 .h 文件中。

好了,说到这里,我想你也大概明白了 .d 文件的作用和在组件化的时候要怎么对待它了,希望这篇文章能对你有所帮助!

参考资料