为什么 Objective-C 的属性还需要 atomic

使用过 Objective-C 的朋友们应该都知道,将一个属性声明为 atomic 并不能解决可变对象的多线程问题。既然如此,那么这个属性存在的意义是什么呢?本文将对比多个支持引用计数的编程语言,聊聊这个古老话题的“底层逻辑”。

我们知道,atomicnonatomic 主要针对对象类型的属性,对于原始类型没有影响。而对于对象类型的属性,在使用 nonatomic 的情况下,需要保证不存在多个线程同时读写这个属性,否则就会产生 crash。对象类型和原始类型在属性读写上有什么区别呢?答案就是引用计数。对于下面的代码:

@interface SomeObject : NSObject

@property (nonatomic, strong) NSObject *someProperty;

@end

我们来看看编译器为其生成的 setter 方法(由于是生成方法,这里只会有汇编代码):

-[SomeObject setSomeProperty:]:
    pushq  %rbp
    movq   %rsp, %rbp
    subq   $0x20, %rsp
    movq   %rdi, -0x8(%rbp)
    movq   %rsi, -0x10(%rbp)
    movq   %rdx, -0x18(%rbp)
    movq   -0x18(%rbp), %rsi
    movq   -0x8(%rbp), %rdi
    addq   $0x8, %rdi
    callq  objc_storeStrong  ; 关键函数
    addq   $0x20, %rsp
    popq   %rbp
    retq

我们注意到,对于 nonatomic 属性来说,编译器生成的代码与栈上变量赋值相同,都是 objc_storeStrong 这个 runtime 函数。我们在 objc 源码中可以找到这个函数的实现:

void objc_storeStrong(id *location, id obj)
{
    id prev = *location;
    if (obj == prev) {
        return;
    }
    objc_retain(obj);
    *location = obj;
    objc_release(prev);
}

这段代码中包含了多处操作,包括内存读写和应用计数操作,多线程执行过程中的交错点也是十分多。最典型的例子就是:两个线程均读取 locationprev,然后分别进行后续操作,导致的结果就是同一个对象被释放多次,从而产生悬挂指针。

接下来我们来看看相同的场景,将属性换成 atomic,在生成代码上会有什么变化:

-[SomeObject setSomeProperty:]:
    pushq  %rbp
    movq   %rsp, %rbp
    subq   $0x20, %rsp
    movq   %rdi, -0x8(%rbp)
    movq   %rsi, -0x10(%rbp)
    movq   %rdx, -0x18(%rbp)
    movq   -0x10(%rbp), %rsi
    movq   -0x8(%rbp), %rdi
    movq   -0x18(%rbp), %rdx
    movl   $0x8, %ecx
    callq  objc_setProperty_atomic  ; 关键函数
    addq   $0x20, %rsp
    popq   %rbp
    retq

可以看到,关键函数变成了 objc_setProperty_atomic,这个函数的实现同样可以在源码中找到:

void objc_setProperty_atomic(id self, SEL _cmd, id newValue, ptrdiff_t offset)
{
    reallySetProperty(self, _cmd, newValue, offset, true, false, false);
}

static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
    if (offset == 0) {
        object_setClass(self, newValue);
        return;
    }

    id oldValue;
    id *slot = (id*) ((char*)self + offset);

    if (copy) {
        newValue = [newValue copyWithZone:nil];
    } else if (mutableCopy) {
        newValue = [newValue mutableCopyWithZone:nil];
    } else {
        if (*slot == newValue) return;
        newValue = objc_retain(newValue);
    }

    if (!atomic) {
        oldValue = *slot;
        *slot = newValue;
    } else {
        spinlock_t& slotlock = PropertyLocks[slot];
        slotlock.lock();
        oldValue = *slot;
        *slot = newValue;
        slotlock.unlock();
    }

    objc_release(oldValue);
}

Runtime 在解决这个问题上也十分简单,我们只需要保证属性指针的修改和旧值的获取是一个原子操作即可。这里的原子化使用的是自旋锁,同时为了避免并发度较高的情况下,锁竞争严重,使用了一个全局的 StripedMap 来优化,这个也是很常见的优化手段了。这里其实也可以使用 CAS 操作来取代加锁操作,不过性能上是否真的有提升还需要验证。

为什么最后的 objc_release 不需要在锁的临界区呢?我们知道 nonatomic 产生问题的原因是多个线程同时获取到属性旧值并进行 release;而在 atomic 下,属性旧值获取的同时,新值就被设置了,不存在两个线程获取到同一个旧值的情况。而引用计数的增减,本身也是原子操作,所以对于所有权明确的场景下不需要额外加锁。

其他语言中的引用计数支持

C++ 中的情况 (clang STL)

既然 Objective-C 通过 atomic 属性完美解决了这个问题,C++ 中是否会存在类似的问题呢?我们也使用如下的代码来验证一下:

struct SomeObject {
    std::shared_ptr<std::string> someProperty;
};

在多线程下同时读写 someProperty 字段也发生了 crash,这也就是说 Objective-C 中的 nonatomic 并不是一种性能优化。正如 @synchronized 一样,atomic 实际上是 Objective-C 提供给我们的额外能力,方便处理这种多线程场景。

在 C++ 中的 crash 原因与 Objective-C 中的 nonatomic 非常相似,我们也来看一下对 someProperty 赋值的过程发生了什么。我这里写了一个赋值函数:

void writeProperty(SomeObject *obj, std::shared_ptr<std::string> &&val) {
    obj->someProperty = std::move(val);
}

其汇编如下:

writeProperty:
    pushq  %rbp
    movq   %rsp, %rbp
    subq   $0x10, %rsp
    movq   %rdi, -0x8(%rbp)
    movq   %rsi, -0x10(%rbp)
    movq   -0x10(%rbp), %rdi
    callq  std::__1::move<std::__1::shared_ptr<std::__1::basic_string<char, std::__1::char_traits<char>, <char> > >&> at move.h:27
    movq   %rax, %rsi
    movq   -0x8(%rbp), %rdi
    ; 关键方法:
    callq  std::__1::shared_ptr<std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > red_ptr.h:989
    addq   $0x10, %rsp
    popq   %rbp
    retq

由于 C++ 支持 operator= 来自定义对象赋值操作,因此简单的赋值表达式其实也是一个函数调用,这里展示的是 inline 之后的结果。而 std::move 是一个 cast 操作,对值内容其实没有任何影响,我们可以直接分析其中的关键方法。这个方法的符号在编译时被模板展开了,实际上对应的是 std::shared_ptr 如下方法:

template<class _Tp>
inline
shared_ptr<_Tp>&
shared_ptr<_Tp>::operator=(shared_ptr&& __r) _NOEXCEPT
{
    shared_ptr(_VSTD::move(__r)).swap(*this);
    return *this;
}

这段代码看起来做了很多操作,但我们只需要关注一个地方就可以了,那就是 this 指针。与文章开头所讲的一样,两个线程同时执行这个操作,唯一可能相同的就是变量旧值的 this 指针。我们顺着调用链路继续往下:

template<class _Tp>
inline
void
shared_ptr<_Tp>::swap(shared_ptr& __r) _NOEXCEPT
{
    _VSTD::swap(__ptr_, __r.__ptr_);
    _VSTD::swap(__cntrl_, __r.__cntrl_);
}

这里有两个 swap 操作,其实都是对指针的平凡交换操作,但不是原子的:

template <class _Tp>
inline _LIBCPP_INLINE_VISIBILITY __swap_result_t<_Tp> _LIBCPP_CONSTEXPR_AFTER_CXX17 swap(_Tp& __x, _Tp& __y)
    _NOEXCEPT_(is_nothrow_move_constructible<_Tp>::value&& is_nothrow_move_assignable<_Tp>::value) {
  _Tp __t(_VSTD::move(__x));
  __x = _VSTD::move(__y);
  __y = _VSTD::move(__t);
}

我们考虑有两个线程同时调用到上述方法,__x 是新值,__y 是旧值,那么 __x = __y 这一步操作就有可能让两个线程都获取到同一个旧值。接下来,调用栈退出,在这段代码中会由于 RAII 释放两次:

template<class _Tp>
inline
shared_ptr<_Tp>&
shared_ptr<_Tp>::operator=(shared_ptr&& __r) _NOEXCEPT
{
    shared_ptr(_VSTD::move(__r)).swap(*this);
    return *this;
    // 临时变量退出作用域,其表示的旧值被释放
}

由此我们可以得知,虽然 C++ 在这个变量交换的过程中,由于语法特性的原因,具体的操作并不与 Objective-C 相同。但根本问题还是出在对同一个对象多次释放的问题上,因为旧值获取与新值写入不是原子操作。

如何修复

Attempt 1

比较容易想到的方法就是使用 std::mutex 将属性赋值操作保护起来:

struct SomeObject {
    std::mutex fieldLock;
    std::shared_ptr<std::string> someProperty;
};

void writeProperty(SomeObject *obj, std::shared_ptr<std::string> &&val) {
    std::unique_lock<std::mutex> lock(obj->fieldLock);
    obj->someProperty = std::move(val);
}

不过这会导致一个不大不小的性能问题,如果 someProperty 旧值是唯一引用的,那么在赋值之后,旧值的释放就会在锁作用域中。

Attempt 2

如果我们先构造一个临时变量承接旧值,在锁外销毁临时变量就可以优化这个潜在的性能问题。我们这里也可以通过 swap 的方式来实现这个操作:

void writeProperty(SomeObject *obj, std::shared_ptr<std::string> &&val) {
    std::shared_ptr<std::string> temp(std::move(val));

    std::unique_lock<std::mutex> lock(obj->fieldLock);
    temp.swap(obj->someProperty);
    lock.unlock();
}

通过这种方式可以实现与 Objective-C atomic 类似的效果,首先原子地交换新值与旧值,然后在锁外释放旧值。值得注意的是,C++ 存在移动语义,第一行的临时变量其实也是与 val 做了一次 swap,交换之后的 temp 内容为 val 之前的内容,而 val 会变成一个无效对象。函数作用域退出后,tempval 都会析构,但 val 的析构会是一个 no-op。如果开编译优化的话,shared_ptr 很多操作会被 inline,性能上还会更好一些。

Rust 中的情况

为了更好地回答文章标题的问题,我们这里引入 Rust 的对比,来看看相同的场景在 Rust 中是如何处理的。

首先我们构造相同逻辑的代码:

use std::sync::Arc;
use std::thread;

struct SomeObject {
    some_field: Arc<String>,
}

fn make_shared_string() -> Arc<String> {
    Arc::new("this is a string".to_owned())
}

#[test]
fn test() {
    let mut obj = SomeObject {some_field: make_shared_string()};
    thread::scope(|s| {
        for _ in 0..12 {
            s.spawn(|| {
                obj.some_field = make_shared_string();
            });
        }
    });
}

编译之后我们会得到一个错误:obj 被多次可变引用了,这在 Rust 中是不允许的。

编译器如何判断闭包在结束之后仍然捕获外部变量呢?我们看到标准库中对于 Scope spawn 的实现:

#[stable(feature = "scoped_threads", since = "1.63.0")]
pub fn spawn<F, T>(&'scope self, f: F) -> ScopedJoinHandle<'scope, T>
where
    F: FnOnce() -> T + Send + 'scope,
    T: Send + 'scope,
{
    Builder::new().spawn_scoped(self, f).expect("failed to spawn thread")
}

可以看到,闭包 F 的生命周期与 Scope 本身相同,意味着里面的被捕获变量也将持续至 Scope 的销毁。单一可变引用又是 Rust 的重要原则之一,通过这种限制阻止竞态访问和一些其他问题

既然不能存在多份可变引用,只构造多份不可变引用总可以吧。我们可以使用 "Interior Mutability" 来实现我们的需求吗:

struct SomeObject {
    some_field: Cell<Arc<String>>,
}

答案是不可以,因为 Cell 没有实现 Sync,因此包含 Cell 引用的类型将不会实现 Send,这些变量自然就不能跨越线程边界。很有趣的是,当我们观察 Cell::set 的实现可以发现:

impl<T> Cell<T> {
    // ...

    #[inline]
    #[stable(feature = "rust1", since = "1.0.0")]
    pub fn set(&self, val: T) {
        let old = self.replace(val);
        drop(old);
    }

    #[stable(feature = "move_cell", since = "1.17.0")]
    pub fn replace(&self, val: T) -> T {
        // SAFETY: This can cause data races if called from a separate thread,
        // but `Cell` is `!Sync` so this won't happen.
        mem::replace(unsafe { &mut *self.value.get() }, val)
    }

    // ...
}

这个实现与 C++ 里 shared_ptr 交换的实现一致:都是获取旧值,设置新值,销毁旧值。在没有锁保护的情况下,旧值会被释放两次。

如何修复

方法其实也非常简单,多线程场景直接使用 Mutex 就可以了,我们修改字段类型:

struct SomeObject {
    some_field: Mutex<Arc<String>>,
}

更新字段的操作也该为锁内 swap + 锁外 drop:

// ...
s.spawn(|| loop {
    let mut new_value = make_shared_string();
    {
        let mut some_field_guard = obj.some_field.lock().unwrap();
        std::mem::swap(&mut new_value, &mut *some_field_guard);
    }
});

Rust 对 Mutex 的设计非常优秀,每个 Mutex 都显式绑定了一个值。值要在多线程中被读写,就一定要被 Mutex 保护。所有实现 Send 的类型,都可以在套上 Mutex 后变成 Sync。而对于具有内部可变性的对象(例如 Arc),在多线程中使用时可以不被保护,但实际上线程安全要由对象自己负责。

为什么 Mutex 不能让所有的对象都变成 Sync

对于 !Send 类型(例如 Rc),他们一般会表示某些共享资源,而类型没有考虑多线程场景下的处理。例如当 Rc 被移动到不同的线程,很有可能出现两个线程同时 drop Rc 导致的引用计数不一致。

除此之外,mem::swap 与单一可变借用原则也可以保证,在能够执行 swap 的上下文中,线程安全是一定会被保证的,我们无法写出不安全的 swap 操作。

所以通过 Rust,我们能够更好地理解文章标题所提的这个问题。拆解一下,Mutex<Arc<T>> 涉及了两个线程安全的保证:

  1. Arc 自身对于引用计数的原子性修改的保证,这里采用 Atomic 操作实现;
  2. Mutex 对于 Arc 指针修改的保护,防止多线程操作中,由于脏值的存在,多次释放 Arc

也就是说,引用计数机制本身是否为线程安全,与在多线程操作同一个对象的同一个属性并无关系。

Wrap Up

文章看似分析了几个系统编程语言(严格说 Objective-C 不算)中的引用计数机制在多线程下的表现,但其实解释了线程安全的本质:在对象模型中,一个对象的线程安全,不意味着所有使用这个对象的场景都是线程安全的。外部对象如果不是线程安全的,即使操作了一个线程安全的对象,也有可能出现逻辑错误。本文中的引用计数只是一个例子,而恰恰这个例子涉及内存操作,很容易出现明显的 segfault。我们在日常开发中可能还会遇到其他的多线程场景,缺乏线程安全保护的逻辑问题更加不容易察觉,因此也更值得我们注意。

Swift Package Plugin 和 Sandbox 的那些事

前言

Apple 今年在 WWDC 22 上推出了 Swift Package Plugin 这个全新的 SPM 特性。通过 Swift Package Plugin,开发者可以扩展 Xcode 中的菜单项和构建流程,从而实现一些研发流程上的定制化、自动化。

我们知道,Apple 在 Xcode 8 中采用 Xcode Extensions 的全新扩展机制 deprecated 了以往没有约束的第三方插件机制。所有的 extensions 都运行在自己的独立进程中,无法随意篡改 Xcode 主程序的行为。这可以说是 Apple 的祖传艺能了,当然也极大地提升了第三方扩展的安全性。

而今年的 Swift Package Plugin 又给 Xcode 提供了一个不一样的扩展点,因此我也非常好奇这次的限制在哪里,开发者到底可以通过它做什么,本文会就 Command Plugin 展开讨论。

第一个 Swift Package 插件

工程配置

要在现有的 package 中增加一个插件非常简单,首先创建一个 Plugins 目录,并再在其中创建与插件同名的目录,然后就可以编写具体的代码文件了。此时目录结构如下:

MyAwesomePackage
├── Package.swift
├── Plugins
│   └── Test
│       └── plugin.swift
├── README.md
├── Sources
│   └── MyLibrary
│       └── MyLibrary.swift
└── Tests
    └── MyLibraryTests
        └── MyLibraryTests.swif

然后修改 Package.swift,在 targets 数组中增加:

let package = Package(
    // ...
    targets: [
        .plugin(
            name: "Test",
            capability: .command(
                intent: .custom(verb: "test", description: "My first plugin"),
                permissions: []))
    ]
)

至此就配置完毕了,等待 package 重新 resolve,之后便可在工程的右键菜单中看到我们的插件了。

代码编写

Swift Package Plugin 与普通的 CLI 程序没有太大的区别,我们需要为插件声明入口函数。这里我们要用到 PackagePlugin 这个 module,并实现 CommandPlugin 协议,它符合 Type-Based Program Entry Points。代码如下:

import PackagePlugin

@main
struct Test: CommandPlugin {
    func performCommand(context: PluginContext, arguments: [String]) async throws {
        // Do your work here...
    }
}

插件运行之后,arguments 便是 Xcode 调用我们时传入的命令行参数,在 context 中我们则可以拿到完整的、解析好的 package 信息,以及插件当前运行的工作目录。我们这里试着往插件工作目录中写入一个临时文件:

func performCommand(context: PluginContext, arguments: [String]) async throws {
    let temporaryFilePath = context.pluginWorkDirectory.appending(subpath: "test.txt")
    try! "hello".write(toFile: temporaryFilePath.string, atomically: true, encoding: .utf8)
}

运行之后便可看到 test.txt 已经创建。当我们修改目标路径,向桌面目录中写入一个文件时,插件运行就直接报错了:

Test/plugin.swift:20: Fatal error: 'try!' expression unexpectedly raised an error: Error Domain=NSCocoaErrorDomain Code=513 "You don’t have permission to save the file “test.txt” in the folder “tmp”." UserInfo={NSFilePath=/var/tmp/test.txt, NSUnderlyingError=0x6000004f9d10 {Error Domain=NSPOSIXErrorDomain Code=1 "Operation not permitted"}}

到这里,我们其实可以初步得出结论:Swift Package Plugin 是运行在沙盒环境下的,文件读写会受控。后来我在 Activity Monitor 中也验证的确如此。

验证 Swift Package 插件的可用权限

网络访问

使用 Network 框架访问 localhost:3000 的一个本地服务:

func performCommand(context: PluginContext, arguments: [String]) async throws {
    let conn = NWConnection(to: .hostPort(host: .ipv4(.loopback), port: 3000), using: .tcp)
    conn.stateUpdateHandler = {
        print($0)
    }
    conn.start(queue: .main)
    let _: Void = try await withUnsafeThrowingContinuation { cont in
        conn.send(content: "hello".data(using: .utf8), completion: .contentProcessed({ error in
            if let error {
                cont.resume(with: .failure(error))
            } else {
                cont.resume()
            }
        }))
    }
}

输出 POSIXErrorCode: Operation not permitted,因此验证无法访问网络。

派生子进程

使用 Process 运行 git 命令:

func performCommand(context: PluginContext, arguments: [String]) async throws {
    let process = Process()
    process.executableURL = .init(FilePath("/usr/local/bin/git"))
    process.arguments = ["--version"]

    let pipe = Pipe()
    let stdout = pipe.fileHandleForReading
    process.standardOutput = pipe

    try! process.run()
    process.waitUntilExit()
    print(String(data: try! stdout.readToEnd()!, encoding: .utf8)!)
}

输出 git version 2.36.1,因此验证可以派生子进程。

系统服务访问

func performCommand(context: PluginContext, arguments: [String]) async throws {
    let pboard = NSPasteboard.general
    pboard.clearContents()
    pboard.setString(context.pluginWorkDirectory.string, forType: .string)

    let workspace = NSWorkspace.shared
    print(workspace.runningApplications)
    print(workspace.open(.init(string: "https://apple.com")!))
}

简单测试了几个基本服务,以上操作均失败。

GUI

使用派生子进程的方式启动本地的计算器 app,计算器进程被 SIGILL 信号杀死(运行时发生 crash)。

macOS Sandbox 的机制

sandbox-exec(1)

与 iOS 类似,macOS 也在内核层面提供了进程沙盒的支持,可以精准控制每个沙盒进程的权限(例如文件访问、Mach IPC 等系统调用)。与 iOS 不同的是,macOS 提供了一个命令行工具 sandbox-exec,来将沙盒的能力暴露给用户。我们可以通过 sandbox-exec 并配合上一个用来描述权限的 profile 文件,就可以在一个自定义的沙盒环境中执行任意进程了。

sandbox-exec 使用相当广泛,例如 Bazel 就通过它来实现沙盒构建,以确保构建产物的稳定性和输入输出依赖的确定性。

当然,在用户态除了可以使用 sandbox-exec 以外,我们还可以使用 Sandbox API(sandbox.h)来执行沙盒相关的操作,sandbox-exec 在实现上也是基于 Sandbox API + execvp

Sandbox Profile

这里我们重点看一下 sandbox-exec 需要的那个 profile 文件。在系统目录 /System/Library/Sandbox/Profiles 下可以看到很多 *.sb 文件,这些都是 Sandbox Profile。我们随便来看一个文件:

(version 1)

(debug deny)

(import "system.sb")

;; allow processes to traverse symlinks
(allow file-read-metadata)

(allow file-read-data file-write-data
  (regex
    ; Allow files accessed by system dylibs and frameworks
    #"/\.CFUserTextEncoding$"
    #"^/usr/share/nls/"
    #"^/usr/share/zoneinfo /var/db/timezone/zoneinfo/"
  ))

(allow ipc-posix-shm (ipc-posix-name "apple.shm.notification_center")) ; Libnotify

(allow signal (target self))

Sandbox Profile 由 SBPL 语言编写,它的语法非常类似 Lisp,也比较容易阅读。关于 Sandbox Profile 的语法和 API,可以参考这篇 PDF,介绍得非常完整。

Sandbox Profile 的核心操作就是 allowdeny,这是两个方法,参数均为操作和过滤器(可选)。例如 (allow signal (target self)) 这个语句表达的意思就是:对于发送信号且信号的目标是自己的操作,允许执行。对于某些严格的运行环境,我们还可以使用 (deny default) 禁用掉所有操作,然后使用 allow 方法白名单开启需要的操作。

我们也可以使用通配符来对一组操作进行控制,例如 (deny file-write*) 这个语句会禁用以 file-write 为前缀的所有操作。

进程模型

值得注意的是,Sandbox 在进程上具有继承性,即父进程会将自身的 Sandbox 状态传递给所有由它派生的子进程。这个特性也非常好理解,如果一个进程派生的子进程可以逃逸沙盒,那父进程也相当于间接逃逸沙盒了。如果这样,父进程通过管道控制沙盒外的子进程,这个机制的作用就完全失效了。

而 macOS 中,一个沙盒应用却可以通过 open(1) 或者 NSWorkspace.open(_:) 来以非沙盒模式启动另一个应用。这其实是系统故意留的一个“后门”,因为 Apple 理解这种情况是可控的,毕竟 Mac 作为桌面设备在权限上就会比 iPhone 这样的移动设备宽松。那这个现象是不是违背了 Sandbox 的进程模型呢,其实并没有。open(1) 或其他类似的启动应用方式借助的是 Launch Services 这个系统服务,它由 launchd 进程提供,应用通过 Mach IPC 与 launchd 交互,并最终由 launchd 启动应用,即可“逃逸沙盒”(其实在进程关系上,这个“子进程”的父进程是 launchd,与 Sandbox 的进程模型并不冲突)。

Swift Package 插件启动过程分析

我们现在知道 Swift Package Plugin 是运行在沙盒环境中,但是对其具体的 profile 尚不清楚。所以这里我会通过逆向分析 Swift Package Plugin 的启动过程,来提取其运行时的 Sandbox Profile。

首先需要找到一个切入点。由于 Xcode 代码日益庞大,单靠静态分析很难快速定位启动 Swift Package Plugin 的逻辑,因此这里我打算采用动态分析的方法。首先一个进程要想启动,一般会通过 fork + exec* 或者 posix_spawn 这几个系统调用来实现。所以这里我们先用 dtrace 对这几个 syscall 进行拦截(我尝试后发现是 posix_spawn,这里省略其他试验过程了):

sudo dtrace -n 'syscall::posix_spawn:entry/pid == 79228/ { ustack(); }'

得到堆栈:

CPU     ID                    FUNCTION:NAME
  6    655                posix_spawn:entry
              libsystem_kernel.dylib`__posix_spawn+0xa
              Foundation`-[NSConcreteTask launchWithDictionary:error:]+0xe97
              SwiftPM`specialized DefaultPluginScriptRunner.invoke(compiledExec:workingDirectory:writableDirectories:readOnlyDirectories:initialMessage:observabilityScope:callbackQueue:delegate:completion:)+0xb1f
              SwiftPM`closure #1 in DefaultPluginScriptRunner.runPluginScript(sourceFiles:pluginName:initialMessage:toolsVersion:workingDirectory:writableDirectories:readOnlyDirectories:fileSystem:observabilityScope:callbackQueue:delegate:completion:)+0x431
              SwiftPM`partial apply for closure #1 in DefaultPluginScriptRunner.runPluginScript(sourceFiles:pluginName:initialMessage:toolsVersion:workingDirectory:writableDirectories:readOnlyDirectories:fileSystem:observabilityScope:callbackQueue:delegate:completion:)+0x52
              SwiftPM`partial apply for closure #4 in DefaultPluginScriptRunner.compilePluginScript(sourceFiles:pluginName:toolsVersion:observabilityScope:callbackQueue:completion:)+0x59
              SwiftPM`thunk for @escaping @callee_guaranteed () -> ()+0x19
              libdispatch.dylib`_dispatch_call_block_and_release+0xc
              libdispatch.dylib`_dispatch_client_callout+0x8
              libdispatch.dylib`_dispatch_continuation_pop+0x1cc
              libdispatch.dylib`_dispatch_async_redirect_invoke+0x2cc
              libdispatch.dylib`_dispatch_root_queue_drain+0x157
              libdispatch.dylib`_dispatch_worker_thread2+0xa0
              libsystem_pthread.dylib`_pthread_wqthread+0x100
              libsystem_pthread.dylib`start_wqthread+0xf

这里我们就得到了 Swift Package Plugin 的启动逻辑,看起来上层 API 使用的是 NSTask。对于提取 Sandbox Profile 的工作,我们只需拿到 sandbox-exec 的启动参数即可。

在 LLDB 中下断点:

breakpoint set -n "-[NSConcreteTask launchWithDictionary:error:]"

断住后检查运行变量:

* thread #22, queue = 'swift.org.swiftpm.shared.concurrent', stop reason = breakpoint 3.1
    frame #0: 0x00007ff81b68763c Foundation`-[NSConcreteTask launchWithDictionary:error:]
Foundation`-[NSConcreteTask launchWithDictionary:error:]:
->  0x7ff81b68763c <+0>: pushq  %rbp
    0x7ff81b68763d <+1>: movq   %rsp, %rbp
    0x7ff81b687640 <+4>: pushq  %r15
    0x7ff81b687642 <+6>: pushq  %r14
Target 0: (Xcode) stopped.

(lldb) po $arg1
<NSConcreteTask: 0x600027b5e990>

(lldb) po [$arg1 arguments]
<Swift.__SwiftDeferredNSArray 0x600009f8c580>(
-p,
(version 1)
(deny default)
(import "system.sb")
(allow file-read*)
(allow process*)
(allow file-write*
    (subpath "/private/tmp")
    (subpath "/private/var/folders/18/rdgw2vgx4g3g_1qvr7fwfhwh0000gp/T")
)
(deny file-write*
    (subpath "/private/var/tmp/redacted/MyLibrary")
)
(allow file-write*
    (subpath "/Users/redacted/Library/Developer/Xcode/DerivedData/MyLibrary-dmdwxwcobwwasxgztgazmbufnwcy/SourcePackages/plugins/Test.output")
    (subpath "/Users/redacted/Library/Developer/Xcode/DerivedData/MyLibrary-dmdwxwcobwwasxgztgazmbufnwcy/SourcePackages/plugins")
)
,
/Users/redacted/Library/Developer/Xcode/DerivedData/MyLibrary-dmdwxwcobwwasxgztgazmbufnwcy/SourcePackages/plugins/Test
)

可以看到插件的运行环境默认禁用了所有权限,而 (import "system.sb") 只会开启几个系统进程必备的权限,其中不包括任意文件读写和任意 namespace 的 Mach IPC。后面紧接着增加了几个有限制的文件读写操作以及进程操作,方便我们在插件中对文件进行修改,或者使用子进程(如 Git 这种某些操作只有文件 I/O 的工具)。

上文中尝试启动计算器之所以失败,并不是因为无法派生进程,而是因为计算器进程无法创建 NSWindow,这个过程需要与 WindowServer 建立 CGSConnectionID,由于插件进程没有 lookup 其 namespace 的权限,因此也无法找到 Mach Port 从而进行通讯。那其他的系统服务无法使用也是同理,大部分系统服务都由名为 xxxxxxd 的 daemon 进程提供,clients 与服务通过 Mach Port 通讯来使用其提供的能力,系统的 frameworks 其实也只是将这些通讯封装成 High-Level APIs 提供给开发者。

小结

本文简单介绍了 Swift Package Plugin 并探索了它都可以做什么。可以发现,由于沙盒环境的限制,插件可以做的事情还是非常有限的。不过这确实符合 Apple 一贯的做事风格:在一个受限可控的环境下为系统或一方应用提供扩展能力。今年 iPadOS 也很意外的获得了加载三方驱动的能力,但可想而知的是,这个驱动也是基于 DriverKit 的受限环境,并没有与内核直接交互的能力。

不过相信今年看到的 Swift Package Plugin 一定不是它的终极形态,就像 SwiftUI 一样,我们可以看到它一点点变得开放和灵活。