[macOS翻译]进程间通信——IPC综述

699 阅读9分钟

本文由 简悦 SimpRead转码, 原文地址 nshipster.com

在许多方面,苹果公司的故事都是通过快乐的意外将技术融合在一起 ......

在许多方面,苹果公司的故事都是通过历史的偶然事件将各种技术融合在一起,从而创造出比以前更好的产品: OS X 是 MacOS 和 NeXTSTEP 的混合体。iCloud 是 MobileMe 和 actual 云(大概)的副产品。

虽然苹果技术栈的许多方面都是如此,但进程间通信却是一个明显的反例。

在每个关键时刻,解决方案不是取其精华,而是堆积如山。因此,一些相互重叠、互不兼容的 IPC 技术分散在不同的抽象层中。OS X 上可以使用所有这些技术,而 iOS 上只能使用 Grand Central Dispatch 和 Pasteboard(尽管程度较低)[^1]。

  • Mach端口
  • 分布式通知
  • 分布式对象
  • AppleEvents & AppleScript
  • 粘贴板
  • XPC

从低层次的内核抽象到高层次的面向对象 API,它们都具有特定的性能和安全特性。但从根本上说,它们都是从上下文边界之外传输和接收数据的机制。

Mach 端口

所有进程间通信最终都依赖于 Mach 内核 API 提供的功能。

马赫端口是轻量级的,功能强大,但文档较少。

通过给定的 Mach 端口发送消息只需调用一次 mach_msg_send 即可,但需要进行一些配置才能创建要发送的消息:

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) {
    …
}

在接收端,事情就简单多了,因为消息只需要声明,而不需要初始化:

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 = message.type.pad1;
    …
}

幸运的是,Core Foundation 和 Foundation 为 Mach 端口提供了更高级别的 API。CFMachPort / NSMachPort 是内核 API 的封装器,可以用作运行循环源代码,而 CFMessagePort / NSMessagePort 则方便两个端口之间的同步通信。

对于简单的一对一通信来说,CFMessagePort其实非常不错。只需几行代码,就可以将一个本地命名的端口作为运行循环源连接起来,以便在每次收到消息时运行回调:

static CFDataRef Callback(CFMessagePortRef port,
                          SInt32 messageID,
                          CFDataRef data,
                          void *info)
{
    …
}

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

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

CFRunLoopAddSource(CFRunLoopGetCurrent(),
                   runLoopSource,
                   kCFRunLoopCommonModes);

发送数据也很简单。只需指定远程端口、消息有效载荷以及发送和接收超时。其余的工作由 CFMessagePortSendRequest 处理:

CFDataRef data;
SInt32 messageID = 0x1111; // Arbitrary
CFTimeInterval timeout = 10.0;

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

SInt32 status =
    CFMessagePortSendRequest(remotePort,
                             messageID,
                             data,
                             timeout,
                             timeout,
                             NULL,
                             NULL);
if (status == kCFMessagePortSuccess) {
    …
}

Distributed Notifications

在 Cocoa 中,对象之间有许多通信方式:

当然,还有直接发送消息。此外,还有 target-action、delegate 和回调,这些都是松散耦合、一对一的设计模式。KVO 允许多个对象订阅事件,但它将这些对象强耦合在一起。另一方面,通知允许在全局范围内广播消息,并被任何知道监听对象的对象拦截。

每个应用程序都管理着自己的 "NSNotificationCenter "实例,以实现应用程序间的 "pub-sub"。但还有一个鲜为人知的核心基础 API,即 "CFNotificationCenterGetDistributedCenter",它也允许在全系统范围内发送通知。

要监听通知,可通过指定要监听的通知名称和每次收到通知时要执行的函数指针,向分布式通知中心添加一个观察者:

static void Callback(CFNotificationCenterRef center,
                     void *observer,
                     CFStringRef name,
                     const void *object,
                     CFDictionaryRef userInfo)
{
    …
}

CFNotificationCenterRef distributedCenter =
    CFNotificationCenterGetDistributedCenter();

CFNotificationSuspensionBehavior behavior =
        CFNotificationSuspensionBehaviorDeliverImmediately;

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

发送分布式通知更简单,只需发布标识符、对象和用户信息即可:

void *object;
CFDictionaryRef userInfo;

CFNotificationCenterRef distributedCenter =
    CFNotificationCenterGetDistributedCenter();

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

在连接两个应用程序的所有方法中,分布式通知是迄今为止最简单的一种。用它来发送大型有效载荷并不是一个好主意,但对于同步首选项或触发数据获取等简单任务来说,分布式通知是再好不过的了。

Distributed Objects(分布式对象

分布式对象(DO)是 Cocoa 的一项远程消息功能,早在上世纪 90 年代中期的 NeXT 时代就曾风靡一时。虽然现在它已不再被广泛使用,但在我们的现代技术栈中,完全无摩擦 IPC 的梦想仍未实现。

使用 DO 发送对象只需设置一个 NSConnection 并以特定名称注册即可:

@protocol Protocol;

id <Protocol> vendedObject;

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

然后,另一个应用程序将创建一个以相同注册名注册的连接,并立即获得一个原子代理,其功能与该原始对象相同:

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

每次向分布式对象代理发送消息时,都会通过 NSConnection 进行远程过程调用 (RPC),以根据所提供的对象评估消息,并将结果返回给代理。

分布式对象简单、透明、强大。如果 Cocoa 的任何功能都能像宣传的那样正常工作,那么分布式对象就会成为 Cocoa 的旗杆功能。

实际上,分布式对象并不能像本地对象那样使用,这仅仅是因为发送到代理的任何消息都可能导致异常抛出。与其他语言不同,Objective-C 在控制流中不使用异常。因此,用 @try/@catch来封装一切并不符合 Cocoa 的习惯。

DO的尴尬还有其他原因。在尝试跨连接调用值时,对象与基元之间的鸿沟尤为明显。此外,连接是完全不加密的,而且底层通信通道缺乏可扩展性,这对大多数严肃的使用来说都是一个障碍。

现在只剩下 Distributed Objects 用于指定属性和方法参数代理行为的注解痕迹了:

  • in: 参数被用作输入,但以后不会被引用
  • out: 参数用于通过引用返回值
  • inout: 参数被用作输入并通过引用返回值
  • const: 参数是常量
  • oneway: 返回结果时不阻塞
  • bycopy: 返回对象的副本
  • byref: 返回对象的代理

AppleEvents & AppleScript

AppleEvents 是经典 Macintosh 操作系统最持久的遗产。AppleEvents 在 System 7 中引入,允许使用 AppleScript 在本地控制应用程序,或使用名为 Program Linking 的功能远程控制应用程序。时至今日,使用 Cocoa Scripting Bridge 的 AppleScript 仍是与 OS X 应用程序进行编程交互的最直接方式。

尽管如此,它还是最难使用的技术之一。

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

为了更好地了解这只野兽的本质,下面是如何告诉 Safari 在最前端窗口的活动标签页中打开 URL 的方法:

tell application "Safari"
  set the URL of the front document to "https://nshipster.com"
end tell

在许多方面,AppleScript 的自然语言语法与其说是一种资产,不如说是一种负担。英语与其他口语一样,在正常结构中存在大量冗余和歧义。虽然这对人类来说完全可以接受,但计算机却很难解决所有这些问题。

即使是经验丰富的 Objective-C 开发人员,如果不经常参考文档或示例代码,也几乎无法编写 AppleScript。

幸运的是,脚本桥为 Cocoa 应用程序提供了一个合适的编程接口。

Cocoa 脚本桥接器

要通过脚本桥与应用程序交互,首先必须生成一个编程接口:

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

sdef 生成应用程序的脚本定义文件。然后,这些文件可以通过管道输送到 sdp,转换成另一种格式--本例中是 C 头文件。生成的 .h 文件可以添加和 #import 到项目中,以获得该应用程序的一流对象接口。

下面是使用 Cocoa 脚本桥表达的相同示例:

#import "Safari.h"

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

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

虽然比 AppleScript 略显冗长,但将其集成到现有代码库中要容易得多。同样的代码如何改编成略有不同的行为,也更容易理解(当然,这可能只是因为更熟悉 Objective-C 的缘故)。

可惜的是,AppleScript 的星光似乎正在陨落,因为最近发布的 OS X 和 iWork 应用程序大大削弱了其脚本功能。在这一点上,在你自己的应用程序中添加支持不太值得。

Pasteboard

Pasteboard 是 OS X 和 iOS 上最明显的进程间通信机制。每当用户在应用程序之间复制或粘贴一段文本、图片或文档时,数据就会通过 com.apple.pboard 服务从一个进程通过 mach 端口交换到另一个进程。

OS X 上有 "NSPasteboard",iOS 上有 "UIPasteboard"。它们的功能大同小异,不过和大多数同类产品一样,iOS 提供了一套更简洁、更现代的 API,功能略逊于 OS X。

以编程方式写入 "粘贴板 "几乎与在图形用户界面应用程序中调用 "编辑 > 复制 "一样简单:

NSImage *image;

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

对等粘贴操作比较复杂,需要迭代粘贴板的内容:

NSPasteboard *pasteboard = [NSPasteboard generalPasteboard];

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

作为一种传输数据的机制,Pasteboard 特别引人注目的地方在于它可以同时为复制到粘贴板上的内容提供多种表示方法。例如,文本选段可同时以富文本 (RTF) 和纯文本 (TXT) 的形式复制,从而允许所见即所得编辑器通过抓取富文本表示法来保留样式信息,而代码编辑器则只使用纯文本表示法。

这些表示法甚至可以通过符合 "NSPasteboardItemDataProvider "协议按需提供。这样就可以根据需要生成衍生表示法,例如从富文本生成纯文本。

每个表示法都由唯一类型标识符 (UTI) 标识,下一章将详细讨论这一概念。

XPC

XPC 是 SDK 中最先进的进程间通信方式。其架构目标是避免进程长期运行,适应可用资源,并尽可能懒散地初始化。将 XPC 集成到应用程序中的动机并不是要做那些在其他情况下不可能做到的事情,而是要为进程间通信提供更好的权限分离和故障隔离。

它是 "NSTask "的替代品,而且功能更多。

XPC 于 2011 年推出,为 OS X 上的应用程序沙盒、iOS 上的远程视图控制器以及这两种系统上的应用程序扩展提供了基础架构。它还被系统框架和第一方应用程序广泛使用:

$ find /Applications -name \*.xpc

通过调查野生 XPC 服务的清单,我们可以更好地了解在自己的应用程序中使用 XPC 的机会。应用程序中出现的共同主题包括图像和视频转换服务、系统调用、网络服务集成和第三方身份验证。

XPC负责进程间通信和服务生命周期管理。从注册服务、让服务运行到与其他服务通信的所有工作都由 "launchd "负责。XPC 服务可以按需启动,或在崩溃时重启,或在空闲时终止。因此,服务应被设计为完全无状态,以便在执行的任何时候突然终止。

作为 iOS 采用并在 OS X 中移植的新安全模型的一部分,XPC 服务默认在最受限制的环境中运行:禁止文件系统访问、禁止网络访问、禁止 root 权限升级。任何功能都必须由一组权限列入白名单。

可通过 libxpc C API 或 NSXPCConnection Objective-C API 访问 XPC。

XPC 服务要么位于应用程序捆绑包内,要么使用 launchd 在后台运行。

服务调用带有事件处理程序的 xpc_main 来接收新的 XPC 连接:

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 连接都是一对一的,这意味着服务在不同的连接上运行,每次调用 xpc_connection_create 都会创建一个新的对等连接:

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 连接发送消息时,消息会被自动分派到运行时管理的队列中。一旦远程端打开连接,消息就会被重新排序并发送。

每条消息都是一个字典,包含字符串键和强类型值:

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 对象对以下原始类型进行操作:

  • 数据
  • 布尔
  • 字符串
  • 有符号整数
  • 无符号整数
  • 日期
  • UUID
  • 数组
  • 字典

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

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);

注册服务

XPC 也可以注册为 launchd 作业,配置为在匹配 IOKit 事件、BSD 通知或 CFDistributedNotifications 时自动启动。这些标准在服务的 launchd.plist 文件中指定:

.launchd.plist

<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 "属性列表中的一个新内容,它在高层次上描述了启动代理的预期目的。根据规定的争用行为,操作系统会自动相应地节流 CPU 和 I/O 带宽。

进程类型和争用行为

进程类型争用行为
标准默认值
适应在代表应用程序执行工作时与应用程序争用
后台从不与应用程序发生冲突
交互式始终与应用程序发生冲突

要将服务注册为大约每 5 分钟运行一次(在以更高的优先级进行调度之前为系统资源变得更加可用留出宽限期),需要将一组标准传入 xpc_activity_register

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);
    });
});