本文翻译自mikeash的博客,原文:ConcurrentMemoryDeallocationintheObjective-CRuntime译者:lynulzy(社区ID,博客)校对:哔哔哔失(博客)Objective-C的Runtime机制是Mac和iOS程序的核心,objc_msgSend函数是Runtime的核心。也就是说,这个功能的核心就是方法缓存。今天我将带领大家一起探讨Apple是如何在不影响程序性能的情况下,以线程安全的方式调整分配方法缓存使用的内存的。使用的技术可能永远不会用在其他线程安全材料中。消息转发的概念Objc_msgSend方法的工作原理是为发送的方法找到合适的方法实现并跳转到实现。官方的说法是,查找方法的过程是这样的:=c->methods[i];if(m.selector==selector){returnm.imp;}}c=c->superclass;}return_objc_msgForward;}【注意】代码中部分变量名已替换,如果如果你对原代码感兴趣,你可以下载一个Objective-C运行时的[源代码](http://www.opensource.apple.com/source/objc4/)。方法缓存在Objective-C程序中,消息到处发送。可以看出,对每条消息执行全消息搜索会使程序极其缓慢。解决方案是缓存,每个类都有一个与之关联的哈希表,用于将选择器映射到方法实现。使用哈希表的初衷是为了最大化读取速度。同时,objc_msgSend使用极其详尽高效的汇编源码,快速进行哈希表的检索,使得缓存方式下的消息发送仅维持在个位数纳秒量级上。当然,第一次使用时速度慢的任何消息以后都会很快。我们提到的缓存是用来提高最近使用资源的多次读取速度的,它通常是有大小限制的。例如,您可以缓存从网络加载的图像,这样2次连续的图像加载就不需要向网络发出2次请求。但是,您不想使用太多内存,因此您可以为缓存图片的数量设置一个最大值。当达到最大值并且有新图片进来时,可以删除最旧的图片。失去。在大多数情况下这是一个很好的解决方案,但在某些隐藏情况下可能性能不佳。比如你设置图片缓存个数为40,但是应用程序以41张为一组循环图片,你会突然发现你的缓存策略失效了。对于我们自己的应用,我们可以通过测试和调整缓存的大小来避免这种情况,但是Objective-C运行时机制不具备这种情况。因为方法缓存对性能极其关键,而且每个条目都比较小。运行时不会强制限制缓冲区的大小。相反,它会在需要保存所有已发送消息时扩展缓冲区。请注意,有时会刷新缓存;对于导致缓存数据过期的操作,例如在处理过程中加载更多代码,或者更改类的方法列表,适当的缓存被销毁并允许再次填充。更改缓存大小、内存分配和线程问题从概念上讲,更改缓存大小就像bucket_t*newCache=malloc(newSize);copyEntries(newCache,class->cache);free(class->cache);class->缓存=新缓存;Objective-C运行时实际上在这里采用了一些快捷方式,但不会将旧条目复制到新缓存!毕竟只是一个缓存,不需要保存数据,发送消息的时候会重新填充这些条目,所以,真实情况是这样的:free(class->cache);class->cache=malloc(新尺寸);在单线程环境下,你需要做的就是这个,所以这篇文章应该很短。当然,Objective-C运行时也必须支持多线程,这意味着所有这些代码都必须是线程安全的。任何给定类的缓存都可以从多个线程并发访问,因此代码必须小心以确保它可以处理这种情况。此处编写的代码无法处理多线程。在释放旧缓存和分配新缓存之前的这段时间里,其他线程可能会访问这些无效的缓存指针,这将导致它使用的数据是垃圾数据,或者因为指定的内存没有反映出物理地址出来并立即坠毁。我们如何解决这个问题?典型的保存共享数据的方式是加锁,代码如下:lock(class->lock);free(class->cache);class->cache=malloc(newSize);unlock(class->lock);因此,包括读取在内的所有访问都将由这把锁控制。也就是说*Objc_msgSend*方法需要获取锁,查找缓存,释放锁。每次加锁和解锁操作都会增加很多开销。考虑到缓存每次只需要几纳秒来检索自身,这对性能有很大的影响。我们可能会尝试通过其他方式关闭此时间窗口(*从释放旧缓存到分配新缓存的时间窗口*)。比如给缓存分配地址赋值,然后释放旧的缓存呢?bucket_t*oldCache=class->cache;class->cache=malloc(newSize);free(oldCache);这会有所帮助,但不能解决问题。另一个线程可能会检索旧的缓存指针,然后在它可以访问内容之前通过系统抢占缓存。旧的缓存在其他线程再次运行之前就被销毁了,之前的问题又出现了。添加这样的延迟怎么样?bucket_t*oldCache=class->cache;class->cache=malloc(newSize);after(5/*seconds*/,^{free(oldCache);});这几乎有效。但是还有下面这种情况,一个线程刚好被系统抢占,而且抢占的时间足够长,导致延迟5秒的释放先被触发。这使得崩溃的可能性很小,但不能完全保证不会发生。与其使用随机的延迟时间,不如等到时间窗口完全空闲呢?我们给Objc_msgSend添加一个计数器:gInMsgSend++;lookUpCache(类->缓存);gInMsgSend--;适当的线程安全版本需要使用计数器的原子性,以及适当的内存“防护”以确保相关加载/存储正确显示。本文的目的不是讨论这些,只是想象它们已经存在。在计数器的帮助下,缓存重新分配看起来像这样:bucket_t*oldCache=class->cache;class->cache=malloc(newSize);while(gInMsgSend);//spinfree(oldCache);请注意,这不是必需的objc_msgSend方法的阻塞执行工作正常。一旦释放缓存的代码确定在替换缓存指针后objc_msgSend中没有任何内容,代码将继续执行,释放旧缓冲区。其他线程可能会在释放旧缓冲区指针时调用Objc_msgSend方法,但是这个比较新的调用将不能再使用旧指针,所以在这种情况下是线程安全的。常量循环效率低下且不优雅。释放缓存并不那么紧急。释放内存是好的,如果需要一段时间也没关系。让我们保存一个未释放缓存的列表,而不是一个低效的循环。每次缓存被释放,所有等待的操作都会被执行。上面的代码:bucket_t*oldCache=class->cache;class->cache=malloc(newSize);append(gOldCachesList,oldCache);if(!gInMsgSend){for(cacheingOldCachesList){free(cache);}gOldCachesList.clear();}在处理新消息时,该操作不会立即释放旧缓存,但这不是问题。当再次访问、稍后访问或将来某个时候访问时,它将被释放。这个版本非常接近Objective-CRuntime机制的实际运行原理。#p#零成本标志着两个相互作用部分之间的巨大不对称。在Objc_msgSend端,它可能每秒运行数百万次,它确实需要尽可能快。最好的情况是单个调用只需几纳秒即可运行。另一方面,改变缓冲区的大小是一个相对较小的操作,并且会随着应用程序的继续运行而变得越来越少。一旦应用程序达到稳定状态,没有新代码正在加载,或者正在编辑消息列表,并且缓存已经足够大以满足需求,缓存块大小的重新计算将不再发生。但在此之前,随着缓冲区增长到所需的大小,此操作可能会发生数百或数千次,但与Objc_msgSend相比,它非常小且性能敏感度较低。由于这种不对称性,应尽可能少地在消息发送方进行工作,即使这会使缓冲区释放部分变慢。在objc_msgSend特殊的CPU周期中,每减少一个CPU运行周期所带来的累积优势和释放操作,都是净赢,优势巨大。即使是全球柜台也太贵了。objc_msgSend方法中的这两个额外的内存访问操作仍然会产生很大的开销。它们需要是原子的,使用内存隔离会使情况变得更糟。幸运的是,Objective-C运行时机制有一种技术,可以通过牺牲缓存释放的速度来将objc_msgSend的开销降低到0。假设全局计数器的目的是跟踪特定代码块中的任何线程。这些线程已经有一些可以监视它们当前在哪一段代码中执行,也就是程序计数器(programcounter)。这是一个CPU内部寄存器,其作用是记录当前指令的内存地址。对比全局计数器,我们可以通过查看每个线程的程序计数器来确认是否正在执行objc_msgSend。如果所有线程都没有执行objc_msgSend方法,那么释放缓存是安全的。代码实现如下:BOOLThreadsInMsgSend(void){for(threadinGetAllThreads()){uintptr_tpc=thread.GetPC();if(pc>=objc_msgSend_startAddress&&pccache;class->cache=malloc(newSize);append(gOldCachesList,oldCache);if(!ThreadsInMsgSend()){for(cacheingOldCachesList){free(cache);}gOldCachesList.clear();}那么objc_msgSend就不用做任何其他事情了,直接访问缓存区就可以了,不用加flag到读取,像这样:lookUpCache(class->cache);由于缓存释放需要检查进程中每个线程的状态,所以效率比较低。但是如果objc_msgSend只考虑单线程环境,它的执行效率会很高。值得做出权衡。这基本上就是Apple的运行时机制的工作原理。Apple如何实现上述技术的实际代码可以在运行时实现文件[objc-cache.mm]中的函数_collection_in_critical中找到。关键PC位置存储在全局变量中:OBJC_EXPORTuintptr_tobjc_entryPoints[];OBJC_EXPORTuintptr_tobjc_exitPoints[];其实objc_msgSend有多种实现方式(比如返回结构版本),内部的cache_getImp函数也会直接访问缓存。需要检查这些以确保释放缓存的安全性。函数本身不需要参数,返回值是**int**类型,像标志一样使用,用来标识关键函数中是否有多个线程:staticint_collectiong_in_critical(void){为了更好的关注我我打算跳过这个函数中一些无聊的代码。如果您想查看完整代码,可以在[此处](http://www.opensource.apple.com/source/objc4/objc4-646/runtime/objc-cache.mm)找到。获取线程信息的API是mach级别的。task_threads获取给定任务中所有线程的线程列表,这些代码使用它来获取它所在进程中的其他线程。ret=task_threads(mach_task_self(),&threads,&number);返回一组包含多个thread_t值的threads数组,可以得到数组元素个数,然后遍历这些元素for(count=0;count
