Inter-Process Communication

0x00 前言

这篇文章是自己在学习 CFHipsterRef Chapter 6 Inter-Process Communication 时做的笔记。

0x01 进程间通信(Inter-Process Communication)

指导叙事已经通过历史的幸运机遇将技术融合在一起,创造出比以往更好的东西。而且,虽然 Apple 的技术栈在很多方面都是如此,但是进程间通信是一个突出的反例。

不充分利用每个时刻可用的解决方案,而是有点堆积起来。结果,一小部分重叠,互相不兼容的 IPC 技术散步在各种抽象层上。

  • Mach Ports
  • Distributed Notifications
  • Distribued Objects
  • AppleEvents & AppleScript
  • Pasteboard
  • XPC

从低级内核抽象到高级内核抽象,面向对象 APIs,他们都有特定的性能和安全特性。但从根本上来说,它们都是从上下文边界之外发送和接收数据的机制。

0x02 Mach Ports

所有的进程间通信最终都是依赖 Mach kernel APIs 提供的功能。

Mach ports 轻量,功能强大,但是文档很少(有多么少?最新的权威资源是一个 1990 年左右的 Mach 3.0 PostScript 文件,放在卡内基梅隆大学 FTP 服务器上),直接使用不方便(有多不方便?哦,请看下面的代码)。

通过给定的 Mach port(端口)发送一个消息实质上是一个 mach_msg_send 调用,但是它需要一些配置才能构建要发送的消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
natural_t data;
mach_port_t port;

struct {
mach_msg_header_t header;
mach_msg_body_t body;
mach_msg_type_descriptor_t type;
} message;

message.header = (mach_msg_header_t) {
.msgh_remote_port = port,
.msgh_local_port = MACH_PORT_NULL,
.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0),
.msgh_size = sizeof(message)
};

message.body = (mach_msg_body_t) {
.msgh_descriptor_count = 1
};

message.type = (mach_msg_type_descriptor_t) {
.pad1 = data,
.pad2 = sizeof(data)
};

mach_msg_return_t error = mach_msg_send(&message.header);

if (error = MACH_MSG_SUCCESS) {
// ...
}

在接收端的事情就更容易一点了,因为消息只需要被声明,而不需要初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mach_port_t port;

struct {
mach_msg_header_t header;
mach_msg_body_t body;
mach_msg_type_descriptor_t type;
mach_msg_trailer_t trailer;
} message;

mach_msg_return_t error = mach_msg_receive(&message.header);

if (error == MACH_MSG_SUCCESS) {
natural_t data = messahe.type.pad1;
// ...
}

幸运的是,Core Foundation 和 Foundation 提供了更高级的 Mach ports APIs。CDMachPort / NSMachPort 是在可以用作 runloop 源的内核 APIs 之上的封装器(wrappers),而 CFMessagePort / NSMessagePort 便于两个端口(ports)之间的同步通信。

CFMessagePort 实际上对于简单的一对一通信是相当不错的。几行代码,一个本地命名端口就可以作为 runloop 源附加,以便于在每次接收到消息的时候都会有一个回调(callback)运行。

1
2
3
4
5
6
7
8
9
10
11
12
static CFDataRef Callback(CFMessagePortRef port,
SInt32 messageID,
CFDataRef data,
void *info) {
// ...
}

CFMessagePortRef localPort = CFMessagePortCreateLocal(nil, CFSTR("com.example.app.port.server"), Callback, nil, nil);

CFRunLoopSourceRef runLoopSource = CFMessagePortCreateRunLoopSource(nil, localPort, 0);

CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, kCFRunLoopCommonModes);

发送数据也很简单。只需指定远程端口,消息 payload,发送和接收的超时时间。CFMessagePortSendRequest 负责其余部分:

1
2
3
4
5
6
7
8
9
10
11
CFDataRef data;
SInt32 messageID = 0x1111; // Arbitrary
CFTimeInterval timeout = 10.0;

CFMessagePortRef remotePort = CFMessagePortCreateRemote(nil, CFSTR("com.example.app.port.client"));

SInt32 status = CFMessagePortSendRequest(remotePort, messageID, data, timeout, timeout, NULL, NULL);

if (status == kCFMessagePortSuccess) {
// ...
}

0x03 Distributed Notifications

在 Cocoa 中有很多种对象相互通信的方式:

当然,直接发送消息。还有 target-action,delegate(委托), callback(回调),它们都是解耦的,一对一的设计模式。KVO 允许多个对象订阅(subscribe)事件, 但是它并不是解耦的,而是强联系(strongly couples)。另一方面,通知(Notifications)允许消息被全局广播(broadcast),并且可以被已知的监听对象拦截。

每个应用管理其自己的用于基础应用程序发布与订阅(pub-sub)的 NSNotificationCenter 实例。但是也有一些鲜为人知的 Core Foundation API,CFNotificationCenterGetDistributedCenter 允许通知在系统范围内进行通信。

要监听通知,可以通过指定要监听的通知名称来为分布式通知中心(distributed nitification canter)添加一个观察者,还有一个每次收到通知的时候要执行的函数指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
static void Callback(CFNotificationCenterRef center,
void *observer,
CFStringRef name,
const void *object,
CFDictionaryRef userInfo) {
// ...
}

CFNotificationCenterRef distributedCenter = CFNotificationCenterGetDistributedCenter();

CFNotificationSuspensionBehavior = CFNitificationSuspensionBehaviorDeliverImmediately;

CFNotificationCenterAddObserver(distributedCenter, NULL, Callback, CFSTR("notification.identifier"), NULL, behavior);

发送一个分布式的通知甚至更简单;只需发布(post)标识符(identifier),对象(object)和用户信息(user info):

1
2
3
4
5
6
void *object;
CFDictionaryRef userInfo;

CFNotificationCenterRef distributedCenter = CFNotificationCenterGetDistributedCenter();

CFNotificationCenterPostNotification(distributedCenter, CFSTR("notification.identifier"), object, userInfo, true);

在链接两个应用的所有方法中,分布式通知(distributed notifications)是目前为止最简单的。使用它们发送较大的 payloads 不是一个好的主意,但是对于简单的任务,比如同步偏好设置或者触发一个数据获取,分布式通知是完美的。

0x04 Distributed Objects

分布式对象(Distributed Objects,DO)是 Cocoa 的一个远程消息传递功能,它与 NeXT 的鼎盛时期是在上世纪 90 年代中期。虽然它没有被广泛使用,但是完全无摩擦的 IPC 的梦想(the dream of totally frictionless IPC)在我们现代技术栈中仍然没有实现。

声明一个 DO 对象只需设置一个 NSConnection 并且给它注册一个特定的名字:

1
2
3
4
5
6
7
@protocol Protocol;

id <Protocol> vendedObject;

NSConnection *connection = [[NSConnection alloc] init];
[connection setRootObject:vendedObject];
[connection registerName:@"server"];

然后另一个应用将创建一个为该同一注册名称注册的 connection,并立即获取一个原子代理(atomic proxy),它的功能就像是原始对象:

1
2
id proxy = [NSConnection rootProxyForConnectionWithRegisteredName:@"server" host:nil];
[proxy setProtocolForProxy:@protocol(Protocol)];

每当一个分布式对象代理被消息传递时,将通过 NSConnection 创建一个远程程序调用(Remote Procedure Call, RPC),以针对被声明的对象来评估消息并将结果返回到 proxy(在幕后(Behind the scenes),一个有操作系统管理的共享 NSPortNameServer 实例负责连接(hooking up)已命名的连接(connections))。

分布式对象是简单的,透明和健壮的。

实际上,分布式对象不能像本地对象一样使用,要是因为发送到 proxy 的任何消息可能导致抛出异常就好了。不像其他的语言,Objective-C 不使用异常来控制流(control flow)。因此,在 @try/@catch 中包含的所有内容对于 Cocoa 的约定来说是不合适的。

DO 也因为其他一些原因很笨拙。当尝试在一个 connection 上编组值(marshal values)时,对象和原语(primitives)之间的分隔尤其明显。此外,connections 是完全未加密的,并且对于底层通信信道(underlying communication channels)的缺乏可扩展性使其成为一个使用非常严重的处理断路器(deal-breaker)。

所有这些实际上是通过 Distributed Objects 指定代理行为(proxying behavior)的属性和方法参数注释使用的痕迹:

  • in:参数只作输入用,不会引用(Argument is used as input, but not referenced later)
  • out:参数通过引用用于返回一个值(Argument is used to return a value by reference)
  • inout:参数通过引用用于输入和返回(Argument is used as input and returned by reference)
  • const:参数是常量(Argument is constant)
  • oneway:返回无阻塞结果(Return without blocking for result)
  • bycopy:返回一个对象的副本(Return a copy of the object)
  • byref:返回一个对象的 proxy(Return a proxy of the object)

0x05 AppleEvents & AppleScript

AppleEvents 是经典的 Macintosh 操作系统中最久的遗产。在系统 7 中引入,AppleEvents 允许使用 AppleScript 本地控制 apps,或者使用一个功能叫 Program Linking 远程控制。到目前为止,使用 Cocoa 脚本桥(Cocoa Scripting Bridge)的 AppleScript 仍然是以程序方式与 OS X 应用程序交互的最直接的方式。

也就是说,这很容易成为最奇怪的技术之一。

AppleScript 使用自然语言语法,旨在使非程序员更容易使用。虽然它以人类可理解的方式成功传达了意图,但是它编写起来是一个噩梦。

为了更好地理解,下面介绍下如何让 Safari 在最前面的窗口的活动标签中打开一个 URL:

1
2
3
tell application "Safari"
set the URL of the front document to "http://nshipster.com"
end tell

在许多方面,AppleScript 的自然语言语法比一个资产更负责任(more of a liability than an asset)。英语,就像任何其他口头语言,在正常构造中具有大量的冗余和歧义。虽然这对于人类来说是完全可以接受的,但是所有这些是计算机难以解决的。

即使对于一个经验丰富的 Objective-C 开发者,没有经常参考文档或者示例代码几乎是不可能编写 AppleScript 的。

幸运的是,Scripting Bridge 为 Cocoa 应用程序提供了一个适当编程接口。

0x06 Cocoa Scripting Bridge

为了通过 Scripting Bridge 与一个应用程序交互,必须先生成编程接口:

1
$ sdef /Applications/Safari.app | sdp -fh --basename Safari

sdef 为应用程序生成脚本定义文件。这些文件然后可以通过管道传输到 sdp 以转换为另一种格式 —— 在这种情况下,一个 C 头文件(C header)。然后可以添加生成的 .h 文件并 #import 到一个项目里,以获得该应用程序的第一等(first-class)对象接口。

下面是和以前一样的例子,使用 Cocoa Scripting Bridge 表示:

1
2
3
4
5
6
7
8
9
10
#import "Safari.h"

SafariApplication *safari = [SBApplication applicationWithBundleIdentifier:@"com.appli.Safari"];

for (SafariWindow *window in safari.windows) {
if (window.visible) {
window.currentTab.URL = [NSURL URLWithString:@"http://nshipster.com"];
break;
}
}

相比 AppleScript,这个更冗长一点,但是这个更容易集成到现有的代码库里。也更清楚地了解同样的代码如何适应轻微的不同的行为(虽然这可能只是更熟悉 Objective-C 的效果)。

唉,AppleScript 的关注似乎在下降,因为发布的 OS X 和 iWork 应用程序极大地削弱了它们的脚本化(scriptability)。在这一点上,在你自己的应用程序中添加支持可能不太值得。

0x07 Pasteboard

粘贴板(Pasteboard)是 OS X 和 iOS 上最明显的进程间通信机制。每当用户在应用程序之间复制或粘贴一段文本,一张图像,或者一个文档时,通过 com.apple.pboard 服务来调解从一个进程到另一个进程的数据交换。

在 OS X 上有 NSPasteBoard,在 iOS 上有 UIPasteboard。他们几乎是一样的,虽然像大多数同行(counterparts),iOS 提供了一个更干净,更现代的 API,比 OS X 上功能略少。

以代码的方式写入 Pasteboard 几乎和在 GUI 应用程序中调用编辑(Edit)> 复制(Copy) 一样简单:

1
2
3
4
5
NSImage *image;

NSPasteboard *pasteboard = [NSPasteboard generalPasteboard];
[pasteboard clearContents];
[pasteboard writeObjects:@[image]];

相互的粘贴操作更复杂一点,需要迭代 Pasteboard 的内容:

1
2
3
4
5
6
NSPasteboard *pasteboard = [NSPasteboard generalPasteboard];

if ([pasteboard canReadObjectForClasses:@[[NSImage class]] options:nil]) {
NSArray *contents = [pasteboard readObjectsForClasses:@[[NSImage class]] options:nil];
NSImage *image = [contents firstObject];
}

什么使得 Pasteboard 作为传输数据的机制特别引人注目的是同时提供了复制到粘贴板上的多个表示(representations)的内容的概念。例如,一段选择的文本可以被复制为富文本(rich text, RTF)和纯文本(TXT)两种,允许例如 WYSIWYG(所见即所得,What you see is what you get) 编辑器通过抓取富文本表示来保留样式信息,以及代码编辑器仅使用纯文本表示。

甚至可以通过遵循 NSPasteboardItemDataProvider 协议在按需的基础上提供这些表示。这允许仅在必要时生成诸如来自富文本的纯文本的派生表示。

每个表示都被一个唯一类型标识符(Unique Type Identifier, UTI)标识,该概念在下一章中更详细地讨论。

0x08 XPC

XPC 是 SDK 中最先进的进程间通信。它的架构目标是避免长时间允许的进程,以适应可用的资源,并尽可能延迟地初始化。将 XPC 结合到应用程序里面的动机不是为了做不可能的事情,而是为进程间通信提供更好的特权分离和故障隔离。

它是 NSTask 的替代品,并且还有更多。

2011 年推出的 XPC 为 OS X 上的 App Sandbox,iOS 上的远程视图控制器(Remote View Controllers)和应用扩展提供基础架构。它也被系统框架和第一方应用(first-party)广泛使用:

1
$ find /Applications -name \*.xpc

通过调查 XPC 服务,可以更好地理解在自己的应用程序中使用 XPC 的时机。在应用程序中经常出现的主题,像图像和视频转换服务,系统调用,webservice 集成和第三方认证。

XPC 负责进程间通信和 service 生命周期管理。从注册 service,运行它和与其他 service 通信的一切都由 launchd 处理。一个 XPC service 可以根据需要启动,或者如果它们崩溃则重新启动,又或者如果空闲则终止。因此,service 应该被设计为完全无状态的,以便允许在任何执行点都能突然终止。

作为 iOS 采用并移植到 OS X 的新安全模式的一部分,XPC 服务在默认的情况下允许在最受限制的环境中:没有文件系统访问,没有网络访问,没有 root 权限提升。任何功能必须由一个授权集列入白名单。

XPC 可以通过 libxpc C API 或者 NSXPCConnection Objective-C API 访问(虽然应该总是尝试使用可用于完成特定任务的最高级的 API,本书在标题中有 “Low-Level” 这个词,因此本节中的示例将使用 libxpc)。

XPC 服务驻留在一个应用程序 bundle 中,或者通过 launchd 在后台运行。

服务调用 xpc_main 和一个事件处理程序来接收新 XPC 连接:

1
2
3
4
5
6
7
8
9
10
11
12
static void connection_handler(xpc_connection_t peer) {
xpc_connection_set_event_handler(peer, ^(xpc_object_t event) {
peer_event_handler(peer, event);
});

xpc_connection_resume(peer);
}

int main(int argc, const char *argv[]) {
xpc_main(connection_handler);
exit(EXIT_FAILURE);
}

每个 XPC connection 是一对一的,这意味着这个 service 在不同的连接上运行,每次调用 xpc_connection_create 都会创建一个新的对等体(这类似于 BSD sockets API 中的 accept,服务器监听单个文件描述符(file descriptor),为每个入站连接创建额外的描述符)。

1
2
3
4
5
6
xpc_connection_t c = xpc_connection_create("com.example.service", NULL);
xpc_connection_set_event_handler(c, ^(xpc_object_t event) {
// ...
});

xpc_connection_resume(c);

当通过一个 XPC connection 发送消息时,它会自动被派发(dispatched)到一个由 runtime 管理的队列上。一旦 connection 在远程端被打开,消息就出队(dequeued)并发送。

每个消息都是一个字典(dictionary),具有字符串键(keys)和强类型值(strongly-typed values):

1
2
3
4
xpc_dictionary_t message = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_uint64(message, "foo", 1);
xpc_connection_send_message(c, message);
xpc_release(message);

XPC 对象对一下基本类型进行操作:

  • Data
  • Boolean
  • Double
  • String
  • Signed Integer
  • Unsigned Integer
  • Date
  • UUID
  • Array
  • Dictionary
  • Null

XPC 提供了一个简便的方法从 dispatch_data_t 数据类型转换,这简化了从 GCD 到 XPC 的工作流:

1
2
3
4
5
void *buffer;
size_t length;
dispatch_data_t ddata = dispatch_data_create(buffer, length, DISPATCH_TARGET_QUEUE_DEFAULT, DISPATCH_DATA_DESTRUCTOR_MUNMAP);

xpc_object_t xdata = xpc_data_create_with_dispatch_data(ddata);
1
2
3
4
5
6
dispatch_queue_t queue;
xpc_connection_send_message_with_reply(c, message, queue, ^(xpc_object_t reply) {
if (xpc_get_type(event) == XPC_TYPE_DICTIONARY) {
// ...
}
});

0x09 Registering Services

XPC 还可以注册为 launchd 来用,配置为匹配 IOKit 事件自动启动,BSD 通知或者 CFDistributedNotifications。这些标准在 service 的 launchd.plist 文件中指定:

launchd.plist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<key>LaunchEvents</key>
<dict>
<key>com.apple.iokit.matching</key>
<dict>
<key>com.example.device-attach</key>
<dict>
<key>idProduct</key>
<integer>2794</integer>
<key>idVendor</key>
<integer>725</integer>
<key>IOProviderClass</key>
<string>IOUSBDevice</string>
<key>IOMatchLaunchStream</key>
<true/>
<key>ProcessType</key>
<string>Adaptive</string>
</dict>
</dict>
</dict>

launchd 属性列表最近添加的是 ProcessType key,高级描述了启动代理(launch agent)的预期目的。基于规定的竞争行为,操作系统将相应地自动地调节 CPU 和 I/O 带宽。

Process Types and Contention Behavior

Standart Default value
Adaptive Contend with apps when doing work on their behalf
Background Never contend with apps
Interactive Always contend with apps

要注册一个 service 去大约每 5 分钟运行一次(允许一个系统资源的宽限期在以更积极的优先级调度之前变得更可用),一组标准被传递到 xpc_activity_register

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
xpc_object_t criteria = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_int64(criteria, XPC_ACTIVITY_INTERVAL, 5 * 60);
xpc_dictionary_set_int64(criteria, XPC_ACTIVITY_GRACE_PERIOD, 10 * 60);

xpc_activity_register("com.example.app.activity", criteria, ^(xpc_activity_t activity) {
// Process Data

xpc_activity_set_state(activity, XPC_ACTIVITY_STATE_CONTINUE);

dispatch_async(dispatch_get_main_queue(), ^{
// Update UI

xpc_activity_set_state(activity, XPC_ACTIVITY_STATE_DONE);
});
});