β

Objective-C Runtime 中内存释放的并发问题

SwiftGG 22 阅读
iOS

作者:Mike Ash, 原文链接 ,原文日期:2015-06-05
译者: 阳仔 ;校对: numbbbbb liberalism ;定稿: CMB

Objective-C Runtime 是绝大多数 Mac 和 iOS 程序代码的核心。Runtime 的核心就是 objc_msgSend 函数,这个函数最关键的就是方法缓存。我在这篇文章中将会阐述一下,Apple 是如何在不影响性能的情况下,以线程安全的方式来重新分配缓存大小、释放方法缓存。

消息传递的概念

objc_msgSend 会查找被调用的方法的实现,然后去执行。从概念上讲,查找方法的过程如下:

bucket_t *newCache = malloc(newSize);
copyEntries(newCache, class->cache);
free(class->cache);
class->cache = newCache;

实际上,Objective-C runtime 在这个基础上又对代码进行了精简:旧的缓存数据并没有被复制到新的缓存空间中!毕竟,这只是一块缓存空间而已,并没有要求一定要保留其中的数据。在消息发送的时候,新的数据自然又会被缓存下来。因此,事实上,代码是这样:

lock(class->lock);
free(class->cache);
class->cache = malloc(newSize);
unlock(class->lock);

所有访问都必须由锁控制,包括读操作。这样就意味着, objc_msgSend 方法可能需要获取线程锁,访问缓存空间,然后释放锁。考虑到缓存的查找本身只会占用几纳秒的时间,每次获取、释放锁会增加很多时间的损耗,对性能的影响太大了。

我们也可以尝试用另外的方法去解决“窗口时间”,比如先分配和赋值新的内存空间,再销毁旧的:

bucket_t *oldCache = class->cache;
class->cache = malloc(newSize);
after(5 /* seconds */, ^{
free(oldCache);
});

这似乎是可行的,但还是可以想到一种情况,就是一个线程刚好被中断足够久,以至于五秒的延迟结束了才重新启动。虽然这样的情况及其罕见,但并不是毫无可能。

如果不是设置一个固定的延迟时间,而是确定等到“窗口时间”结束呢。我们可以给 objc_msgSend 函数增加一个计数器,就像这样:

bucket_t *oldCache = class->cache;
class->cache = malloc(newSize);
while(gInMsgSend)
; // spin
free(oldCache);

注意,我们并不需要阻塞 objc_msgSend 执行,就能让这段代码正确工作。在给缓存的指针重新赋值后,一旦某一时刻,确认没有方法在调用 objc_msgSend 了,就可以将旧的缓存空间释放。另一个线程有可能会在旧缓存空间被释放的时候调用 objc_msgSend ,但这个新的调用不会访问到旧的缓存的指针,因此是安全的。

然而,轮询操作效率较低,且不优雅。事实上,释放旧的缓存空间并不是十分要紧的一件事。内存能够正确释放当然是好的,但晚点再释放也没有什么大不了的。因此,我们可以不使用轮询,而是持有一份未释放的缓存的记录表。每次需要释放缓存时,就清空所有待释放的缓存:

BOOL ThreadsInMsgSend(void) {
for(thread in GetAllThreads()) {
uintptr_t pc = thread.GetPC();
if(pc >= objc_msgSend_startAddress && pc <= objc_msgSend_endAddress) {
return YES;
}
}
return NO;
}

bucket_t *oldCache = class->cache;
class->cache = malloc(newSize);

append(gOldCachesList, oldCache);
if(!ThreadsInMsgSend()) {
for(cache in gOldCachesList) {
free(cache);
}
gOldCachesList.clear();
}

objc_msgSend 并不需要额外做任何事情,它可以不用考虑设置标志位,直接访问缓存:

OBJC_EXPORT uintptr_t objc_entryPoints[];
OBJC_EXPORT uintptr_t objc_exitPoints[];

事实上, objc_msgSend 有多种实现方式(比如返回 struct 类型),内部的 cache_getImp 也会直接访问缓存。这些都需要在缓存释放的时候被检查。

_collecting_in_critical 函数没有入参,返回一个 int 类型,被当做一个布尔类型的标志位,指明是否有线程处于关键的函数中:

ret = task_threads(mach_task_self(), &threads, &number);

函数会在 threads 中保存 thread_t 数组,在 number 中保存线程的数量。然后会遍历所有线程:

pc = _get_pc_for_thread (threads[count]);

然后,程序会遍历所有的入口和出口,并逐个进行判断:

return result;
}

_get_pc_for_thread 函数是怎么工作的呢?它只是简单地调用 thread_get_state 函数来获得目标线程的寄存器状态。之所以要放到一个单独的函数中,是因为寄存器状态的结构体是与具体架构相关的,不同架构都有不同的寄存器。也就是说,这个函数需要对每个支持的架构有一套单独的实现,尽管这些实现差别不大。下面是 x86-64 下的实现:

.private_extern _objc_entryPoints
_objc_entryPoints:
.quad _cache_getImp
.quad _objc_msgSend
.quad _objc_msgSend_fpret
.quad _objc_msgSend_fp2ret
.quad _objc_msgSend_stret
.quad _objc_msgSendSuper
.quad _objc_msgSendSuper_stret
.quad _objc_msgSendSuper2
.quad _objc_msgSendSuper2_stret
.quad 0

.private_extern _objc_exitPoints
_objc_exitPoints:
.quad LExit_cache_getImp
.quad LExit_objc_msgSend
.quad LExit_objc_msgSend_fpret
.quad LExit_objc_msgSend_fp2ret
.quad LExit_objc_msgSend_stret
.quad LExit_objc_msgSendSuper
.quad LExit_objc_msgSendSuper_stret
.quad LExit_objc_msgSendSuper2
.quad LExit_objc_msgSendSuper2_stret
.quad 0

_collecting_in_critical 的用法和上面我们假设的例子很相似。它在释放缓存之前被调用。事实上,runtime 有两种工作模式:一种是如果其他线程正在调用相关函数的话,就把垃圾内存的清理工作留到下一次调用;另一种是一直轮询,直到确认没有线程正在调用,然后再进行销毁:

// Synchronize collection with objc_msgSend and other cache readers
if (!collectALot) {
if (_collecting_in_critical ()) {
// objc_msgSend (or other cache reader) is currently looking in
// the cache and might still be using some garbage.
if (PrintCaches) {
_objc_inform ("CACHES: not collecting; "
"objc_msgSend in progress");
}
return;
}
}
else {
// No excuses.
while (_collecting_in_critical())
;
}

// free garbage here

第一种将垃圾内存留到下一次调用时清理的模式,是在正常的重新分配缓存大小时采用;第二种始终清理垃圾内存的模式,是在需要刷新所有类的所有缓存时使用,因为这样往往会产生大量的垃圾内存。以我阅读代码来看,这种情况只会在开启一项日志调试功能时发生。日志调试会将所有的消息发送记录到文件中,消息缓存会影响这一日志,因此需要全部刷新。

总结

性能和线程安全经常会互相冲突。不同部分的代码对同一块内存的访问方式往往不同,也就允许我们以更加有效率的方式来实现线程安全。方式之一是用一个全局标志位或者计数器来指明对内存的改动操作是否安全。在 Objective-C runtime 中,Apple 更进一步,使用了各个线程的程序计数器来判断线程是否正在进行不安全的操作。这是一个很专业的案例,这种做法想要用到其他地方也不是很有用,但研究它的原理本身就是一件很有意思的事情。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

作者:Mike Ash, 原文链接 ,原文日期:2015-06-05
译者: 阳仔 ;校对: numbbbbb liberalism ;定稿: CMB

Objective-C Runtime 是绝大多数 Mac 和 iOS 程序代码的核心。Runtime 的核心就是 objc_msgSend 函数,这个函数最关键的就是方法缓存。我在这篇文章中将会阐述一下,Apple 是如何在不影响性能的情况下,以线程安全的方式来重新分配缓存大小、释放方法缓存。

iOS
作者:SwiftGG
走心的 Swift 翻译组
原文地址:Objective-C Runtime 中内存释放的并发问题, 感谢原作者分享。

发表评论