使用 Rust 中的 Objective-C 时管理 cocoa 内存的正确方法

The correct way to manage cocoa memory when working with Objective-C from Rust

我正在努力解决一个与 cocoa 基础内存管理相关的问题。基本上我有一个用 Rust 编写的项目,我正在使用 cocoa-rsobjc-rs 与 Objective-C 进行交互。我熟悉 CoreFoundation 和 CocoaFoundation 中的内存管理(我已经阅读了文档中的相应文章)。当我使用 CoreFoundation 函数时我没有任何内存问题,但是当我使用 CocoaFoundation 相关的东西时我遇到了很多问题,似乎从 CocoaFoundation 获取任何对象都会泄漏内存。

这里是导致内存记忆的函数之一的简化​​版本:

pub fn enumerate_apps()-> Vec<Rc<AppInfo>> {
    let mut apps_list = Vec::new();
    unsafe {
        let shared_workspace: *mut Object = msg_send![class("NSWorkspace"), sharedWorkspace];
        let running_apps: *mut Object = msg_send![shared_workspace, runningApplications];

        let apps_count = msg_send![running_apps, count];
        for i in 0..apps_count {
            let app: *mut Object = msg_send![running_apps, objectAtIndex:i];

            // Those ones are not used at the moment, but I actually need them,
            // I just removed all business logic to keep the example simple and compilable
            // to demonstrate the problem.
            let bundle_url: *mut Object = msg_send![app, bundleURL];
            let app_bundle: *mut Object = msg_send![class("NSBundle"), bundleWithURL:bundle_url];
            let info_dict: *mut Object = msg_send![app_bundle, infoDictionary];

            apps_list.push(Rc::new(AppInfo {
                pid: msg_send![app, processIdentifier],
            }));
        }
    }
    apps_list
}

我试图在循环中调用此函数以使内存泄漏可见:

fn main() {
    loop {
        for i in 0..200 {
            enumerate_apps();
        }
        std::thread::sleep(std::time::Duration::from_millis(5000));
    }
}

当我 运行 应用程序时,我可以看到它随着时间的推移消耗越来越多的内存。

我的问题是:为什么?在这样的 FFI 代码中管理内存的正确方法是什么?如果我 运行 XCode 中的相同代码,使用普通的 Objective-C,它工作正常并且似乎没有泄漏内存。嗯,之所以在XCode中没有泄露内存,是因为默认开启了ARC。据我所知,当我们以这种方式使用 Rust 中的 Objective-C 时,ARC 不会启用,所以基本上这意味着我们必须自己管理内存。注释包含 bundle_urlapp_bundleinfo_dict 的 3 行会产生内存泄漏消失的错觉(如果不注释它们,进程每 2 秒就会泄漏几兆字节的内存),但实际上内存仍然泄漏,但没有那么快。

我尝试了什么:

  1. 我试图在函数的开头创建一个 NSAutoreleasePool,并在创建时为 bundle_urlapp_bundle 调用 autorelease()。没用,内存还是泄露了。
  2. 我尝试在 bundle_urlapp_bundle 上手动调用 release(),没有任何效果。
  3. 甚至尝试对它们调用 dealloc()(我认为这是错误的方法),这也无助于解决我的问题。

我做错了什么吗?还是 objc-rs 中的错误(我想这不太可能,但谁知道呢)?

由于Objective-C ARC 未在objc-rs/cocoa-rs 中实现,您需要遵循memory management rule,特别是对于此问题:您不得放弃不属于您的对象的所有权。也就是说,您不应该对任何返回的对象调用 autorelease()release()dealloc()

你应该做的是create an NSAutoreleasePool inside the function并且不要碰任何其他东西。池将在释放时释放所有这些对象。

pub fn enumerate_apps()-> Vec<Rc<AppInfo>> {
    let mut apps_list = Vec::new();
    unsafe {
        let autoreleasePool: *mut Object = msg_send![class("NSAutoreleasePool"), new];

        // ...
        // all code unchanged
        // ...

        msg_send![autoreleasePool, release];
    }
    apps_list
}

为什么在bundle_url/app_bundle/info_dict上调用autorelease()/release()/dealloc()不能减少内存?因为泄漏内存的不仅仅是这些对象。最大的消耗是 running_apps 对象。

为什么显式调用 autorelease()/release()/dealloc() 是错误的?让我们回顾一下 ObjC 的内存管理规则,并将其与普通的 Rust 代码进行比较(我假设你知道 Rc<T> 类型是如何工作的):

  1. 您拥有您创建的任何对象 — 您使用名称以“alloc”、“new”、“copy”开头的方法创建对象,或“mutableCopy”

    • 你可以这样想:

      // Objective-C code:
      NSMutableString* s = [NSMutableString new];
      NSMutableString* t = [s mutableCopy];
      
      // Similar to this in Rust:
      let s: Rc<NSMutableString> = Rc::new(NSMutableString::new());
      let t: Rc<NSMutableString> = Rc::new(s.mutableCopy());
      

      您的代码从未调用任何以 "alloc"、"new"、"copy" 或 "mutableCopy" 开头的方法,因此您不拥有它们中的任何一个。所有 ObjC API 都遵循此命名约定。

  2. 您可以使用 retain.

    获取对象的所有权
    • 这类似于拥有一个对象a: Rc<T>,然后通过调用b = Rc::clone(&a)获得一个新的引用。现在 b 也 "owns" 通过引用计数的原始对象:

      // Objective-C code:
      NSMutableString* u = [t retain];
      
      // Similar to this in Rust:
      let u: Rc<NSMutableString> = Rc::clone(&u);
      

      但是你从未调用过 retain,所以你仍然没有任何对象。

  3. 当您不再需要它时,您必须放弃您拥有的对象的所有权 — 您通过向对象发送 release 消息或 autorelease 消息。

    • 在 Rust 方面,发送 -release 消息等同于删除 Rc 对象。

      // Objective-C code:
      [u release];
      
      // Similar to this in Rust:
      drop(u);
      
    • -autorelease 将所有权转移到自动释放池。将找到最近分配的 NSAutoreleasePool,将对象的所有权移至该池中,我们只保留借用的引用(*).

      // Objective-C code:
      NSMutableString* v = [t autorelease];
      
      // Similar to this in Rust:
      let pool: &NSAutoreleasePool = find_top_autorelease_pool()?;
      let v: &NSMutableString = pool.add_object(t);
      // `t` is passed-by-value, so `pool` now owns `t`.
      // `pool` returns a borrowed reference, 
      // so that we can still access the memory pointed to by `t`,
      // but we no longer own it.
      
  4. 您不得放弃您不拥有的对象的所有权

    • 也就是说,你永远不能通过借用引用来删除内存。在 Rust 中这是不可能的,但是 Objective-C 没有借用检查器。
  5. 此外,调用 -dealloc 就像在 Rust 中通过 drop(*s) 显式调用析构函数一样。这绕过了引用计数机制和 is explicitly discouraged.

让我们回顾一下:

  • None 你调用的方法(sharedWorkspace/runningApplications/objectAtIndex:/bundleURL/bundleWithURL:/infoDictionary) 以 alloc/new/copy/mutableCopy.
  • 开头
  • 你从未打电话给 -retain
  • 这意味着根据规则 1 和 2,你所有的东西都是借来的
  • 这意味着根据规则 4,你永远不应该调用 release()autorelease()

对您不拥有的对象调用 -release-autorelease 会导致双重释放。这可能会导致 SEGFAULT、无操作或任何未定义的行为。

如果我们不提供 NSAutoreleasePool,为什么程序会像筛子一样泄漏? runningApplications/bundleWithURL: 方法确实会分配对象,但遵循 Cocoa 内存管理规则,它们会在内部调用 -autorelease 以确保您不会获得拥有的对象。但是如果我们不分配任何池,-autorelease 可以将所有权转移到任何地方,即那些自动释放的对象变得不属于任何人,没有人拥有释放它们的所有权,因此泄漏。


(*):这个类比并不完美,因为您可以通过 [[x autorelease] retain] 获得新的所有权。但是这个细节在这里并不重要。