任意方法的Swizzle的应用之一AOP(续2)

呃,又要出续集了…真不是故意的-_-!!,之前写的时候,只是为了实现功能,而这次是为了优化。初版花了两天完成,优化却前前后后花了两周。

目前我优化出了两个版本,一个正常版本,其基于常规的办法,效率大约是Aspect的20+倍,另一个基于非常规的手段,效率接近Aspect的100倍,当然都是大概测量。

常规优化版本

先说一说要面对的情况,AOP作为函数级别的外挂,如果一个函数加了2个AOP切面,那么每次调用都要附带调用这俩AOP调用的开销,如果调用频繁的话,对性能消耗是很大的。

给出如下测试数据:iPhone6s,空调用的情况下,Aspect 十万次调用大概在1.6-2.0s。正常情况下,如果延长0.1秒,人就可以感觉出来,如果要不影响App效率,最好在0.05s以内,也就是说每秒的计算量只能有1/20分配给Aspect本身作为成本,而这样的计算量只能调用2000-3000次,否则Aspect调度挤占大量运算量明显得不偿失。当然每秒调度2000-3000次一般也够用了,特别是业务级,但是如果对频繁调用的方法加AOP或者大面积使用,比如给Foundation框架的常用方法加AOP,那就不一样了,这个次数还是少了点。我之前用我写的JOBridge,用JS写了一个中下复杂的页面,然后统计页面渲染完成调用原生方法次数2000+。说这么多,就是表明大量使用时Aspect性能还不够。而初版的ZWAop,性能大概不到Aspect的10倍,已经快太多了,但还有很大的优化空间。

最开始我主要用以下办法来优化:

1、利用objc_class继承自objc_object的特性,其二,Class是运行时常量,可以直接作为字典的key,不用区分元类和非元类,可以不用创建字符串。

2、利用Tagged Pointer对象,大幅降低key的创建开销,具体说来,就是以前是用selector创建字符串来作为key存入字典的,后来发现每次创建字符串开销很大,而selector的性质之一也是运行时常量,地址唯一,也不用区分元类和非元类,所以可以将其创建为NSNumber,这是个Tagged Pointer对象,我记得有资料上说,其是普通对象创建销毁速度的100倍,这里使用恰到好处。

3、大量使用__unsafe_unretained,在不需要持有强引用的时候,绝不强引用。比如:字典里面已经保存了一个强引用,每次读取出来的对象是不需要再强引用的。可别小看这个优化,在这种每秒十万级调用时,开销很明显的。

拓展一下:研究苹果Runtime代码就知道,retain和release使用的是ldxr stxr stlxr汇编命令,它们是exclusive的,本身性能开销就相对不小,其能保证指令级修改互斥,但不会像互斥锁一样陷入等待,等下一次再尝试直到成功,这些指令只会返回非独占时修改失败,所以retain和release使用了类似于spin_lock的机制(当然spin_lock在单核CPU只需要关闭内核抢占,最多再关中断,就可以实现,而不需要控制变量)——循环,直到独占内存修改成功。说这么多就是想说retain和release的开销在性能要求很高的时候是不能忽视的,当然一般最少都是每秒上万乃至于十万再考虑吧,否则没意义。

4、减少堆上的对象创建。对象创建开销也不小,但也只有在大量创建时才会凸显出来,比如每秒上万。

__autoreleasing NSArray *arr = @[obj, [NSValue valueWithPointer:sel], @(ZWAopOptionReplace)];

我之前用NSArray来传以下调用数据,这导致,每次AOP,都有好几个对象创建,数量多了,开销剧增。所以我将其改成结构体,并且分配在栈上,可以极大降低开销。

5、减少字典的查询。这里就只修改了一个,就是frameLength的查询,以前每次AOP和原始函数调用都要查,现在只查第一个,然后缓存起来,以后都可以使用。当然其实我最想把_ZWBeforeIMP_ZWOriginIMP_ZWAfterIMP合并为一个,这样也可以缓存,能大幅减少查询次数和加解锁次数。

6、将os_unfair_lock_t替换为pthread_rwlock_t。这里字典读取情况远多于写入,所以互斥锁在出现大量竞争时性能表现下降比读写锁差很多,我测试来看可能下降到1/3,特别是非主线程获取锁不易。读写锁读取可以并发,开销可以大幅减少,大概减少一半吧。

优化成果

优化后,开销减少到之前1/3以下,效率是Aspect的20+倍。同一方法重复百万次调用成本0.9s左右(iPhone6s,iOS12,iPhone Xs Max一下也只快50%,主要是锁快不起来了),单次成本不到1微秒,第一次调用没有缓存开销是后续有缓存的50倍左右。目前AOP调用过程的主要开销为字典的查询(查询frameLength,查询AOP调用),单次成本百万分之一秒,让AOP在大规模使用的时候也不会成为性能瓶颈。内存开销比较小,除了存储AOP,就只有frameLength缓存,最多也就几十KB。

非常规优化版

优化无止尽,只要你有心。

常规版本只是在保证基本原理不动的基础上,通过减少对象创建,使用Tagged Pointer对象,重复利用中间数据等等手段,将效率优化到3倍。说白了还是因为懒,动原理层就意味着改变大量改动和汇编改动,比较麻烦,后面勤快了一下,又优化了一个版本,更新了部分实现方案原理,效率又提升了几倍了。虽然OC已经比较快了,但和C比起来还是差一些,而OC的一些方法调用还会附带一些多余的意义不大的操作。只不过这次使用的一些非常规手段目前业内还没有见过,比较激进,风险性未知,虽然原理和个人测试结果来看,问题不大,但毕竟是首次使用。

这次优化办法包括以下内容:
1、自定义切面结构体ZWAopIMPList,统一位置的切面调用全部存储在该自定义的数组中,减少数组创建和访问带来的额外操作。其默认初始最大长度为1,大部分情况下只会有一个,如果有多个话,可以尝试修改默认最大长度,这个最大长度是按指数级增长的,每次增长会新分配内存。

自定义ZWAopIMPAll,其存储某一方法调用的所有切面和frameLength等信息。每次调用只需要在字典中查询一次,将本结构体查询出来然后缓存起来给后面所有调用使用。

2、使用OC拟对象。OC拟对象是我定义的概念,ZWAopIMPList和ZWAopIMPAll都是这种拟对象。这种拟对象的是没有对应的OC的class模板的,只有对应的结构体。它通过malloc来分配内存来创建实例,通过指示结构体填充成员数据。同时其具备isa指针,并强行将isa指向NSObject,因为没有class模板,所有对应的方法列表属性列表都是NSObject的。所以无法通过NSObject的alloc来创建,也无法通过其访问成员变量。这就需要自定义函数来操作数据,同时需要手动管理内存。有人会问,搞这么复杂有啥好处呢?好处是我们主要把它当做结构体使用,必要的时候也可以当做OC对象使用。比如这里我希望达到的效果:A、可以使用__bridge id转换成id类型,这样可以放入NSDictionary,同时触发ARC机制。B、通过malloc直接分配内存(这会比alloc+init快),直接赋值,MRC,不触发autorelease机制。C、为RCU提供支持(这个后面单独说明)。当然使用OC对象也可以,这里对性能要求比较高,直接操作结构体数据会快一些。

需要注意的是修改isa指针的时候需要注意,isa指针可能不是纯粹的指针,可能包含更多的信息,这些bit位不要随便乱改,我只修改nonpointer值,其表示当前的isa指针不是一个纯粹的指针。为啥要改成这样呢?撸苹果objc源码,参考对象的创建过程,可以了解不修改该值,isa指针就会被当做纯粹的指针,调用retain时,会触发sidetable_retain来记录reference count,比较耗费性能,如果nonpointer=1,就会直接在isa指针里面记录reference count,而后者效率比前者高太多了。release同理。其他的bit位最好不要动。

3、一次查询多次使用,在ZWBeforeInvocation调用时将字典中拟对象查出后传递给后面所有函数复用,这里需要改变汇编实现。

4、frameLength处理,其需要缓存没什么说的,否则每次计算量太大了。但第一次获取还是有优化的空间,这里有几种处理方法:A、参数少于6个,可以直接认为frameLength=0xe0(至于为什么几句话说不清楚);B、写一个frameLength预估函数,通过签名预估是否frameLength=0xe0,预估可能超出寄存器传参范围,再使用耗时NSMethodSignature来精确获取;C、将问题抛给使用者。使用第一种最简单,但是效果不太好。优化后使用第二种,其参考objc的method_getNumberOfArguments的实现来计算栈参大小,如果发现参数中有结构体,寄存器参数长度大于64Byte或者浮点寄存器长度大于64Byte,在使用NSMethodSignature精确计算。

5、使用RCU机制替代部分锁。之前将os_unfair_lock_t替换为pthread_rwlock_t,读写锁在读远多于写的情况下,通过读的并发能够更好的减少开销,这样的性能已经很好了。但这里既然使用了拟对象,RCU实现起来就容易多了。其次,读写锁中读操作虽然没有进入临界区限制,但是读锁本身是原子性的,是有性能开销的,略微了解计算机的就知道,CPU频率比内存频率快太多,所以才有CPU一级二级甚至三级缓存,而读锁的原子性在这种特殊情况下也有不可忽视的性能开销(有兴趣的话,可以去看看pthread_rwlock_t在arm下的实现,其就是使用arm的ldaxr,stxr这种独占原语,循环尝试修改同一变量实现,不过其同时引入wfe指令,必要时让CPU可以进入低功耗待命状态,虽然能节能,但是还是快不起来)。鉴于这种情况,如果不需要加锁是最好的,但是写是需要互斥的,而读和读,读和写能并发,且没有额外开销就行了。RCU应用前提是修改指针或者基本数据类型的指令本身就是原子的。比如指针p=0x104010300,赋值一次性完成,不会存在赋值完成一半时线程时间片用完了的情况。RCU应用的条件:A、读取性能要求苛刻。B、读远多于写。C、替换操作拷贝的内存开销不大。D、实时性要求不高(原数据使用不受修改数据影响)。我这里主要在ZWAopIMPListAdd和ZWAopIMPListRemove中完成。基本过程是,将原始数据做一个拷贝,并在这个拷贝上修改,最后替换原始数据指针,这其中拷贝数据开销还是挺大的,只不过写入量少的话就会好很多,而且不要求实时性。这里需要说明的是,注意所有的数据拷贝完成后再替换原指针,而且这里有可能需要引入内存屏障技术。

内存屏障是指内存的访问顺序和指令的顺序可能不一致。产生原因:编译器优化重排指令;运行是多核CPU交叉访问。绝大部分情况这都不会出问题,一因为大部分逻辑并不强依赖实时性;二是编译重排一般也会保证顺序;三是就算是出问题,其时间区间很短,一般就几个指令周期。但是当程序逻辑强依赖内存访问顺序或者频繁读写共享的时候,这个问题就不能忽略了。dmb,dsb或者isb都可以来确保内存访问顺序,严格性一次增加。在苹果开源的runtime源码中有这种情况,在objc-cache.mm文件中。当我们调用method_setImplementation改变了方法的实现,需要刷新方法imp缓存buckets,保证数据的一致性。(setBucketsAndMask函数中,有注释ensure other threads see new buckets before new mask,其实应该这么写,这里的逻辑希望其他线程在看到新的mask之时,buckets同样也是和其配套的新缓存空间。其使用了dsb命令,表示在该存储指令完成后,其后面的指令才能执行)。说说我这里RCU的情况,我需要保证的是,拷贝修改的内存写入指令完成后,才能将自己的地址交给外部访问,在此之前还有原来内容可以访问。所以这里并不要求这么严格,dmb就足够了,其表示在该存储指令执行完成后,后面的内存访问指令才会被提交。至于更加严格isb,它属于不正常人类的使用范畴,其表示它执行完后,还需要刷新流水线。

RCU下,原始数据如果在使用中,就需要延迟释放。这也有不少办法来实现,比如将原始数据放入某个资源池,弄个线程啥的,等到其没有使用时再释放,这还需要一个数据位+原子操作来支持,还是挺麻烦的。但是RCU这里的数据是OC拟对象,是时候去掉“拟”字,那就是个OC对象,引用计数器可用,自然也就达到了延迟释放的目的,需要做的就是在使用前后调用一次retain或release。(之前我自己写了retain和release操作retainCount来替代,但实际上还是挺麻烦的,也快不了多少,还不如用Runtime提供的)接下来就是讨论retainCount=0时,系统能否释放我通过malloc分配的内存。这种我们只能撸苹果objc的源码了。

找到以下调用链dealloc->_objc_rootDealloc->rootDealloc

inline void
objc_object::rootDealloc()
{
    if (isTaggedPointer()) return;  // fixme necessary?

    if (fastpath(isa.nonpointer  &&  
                 !isa.weakly_referenced  &&  
                 !isa.has_assoc  &&  
                 !isa.has_cxx_dtor  &&  
                 !isa.has_sidetable_rc))
    {
        assert(!sidetable_present());
        free(this);
    } 
    else {
        object_dispose((id)this);
    }
}

看第二个if,如果isa.nonpointer=1,其他几个bit为0时,直接free内存就行了。看看之前创建实例的时候,我们只修改了nonpointer=1,正好符合当前情况。

顺便看一下object_dispose,追踪发现其调用了objc_destructInstance,之后就仅仅调用了一下free而已。而objc_destructInstance这个函数中只做了移除关联对象,调用C++析构函数,处理sidetable引用计数器操作。

我这里的ZWAopIMPList是内部对象,其使用范围是可控的,如果给外部使用,最好还是用OC的玩法,否则容易引起其他问题。

最后那该死的最耗性能的字典和加锁能用这种机制么?答案是字典这种数据结构是不能的!原因是字典访问有严格的上下文依赖,简单说就是有多个不可拆分的步骤,计算hash+取值不可拆分,试想一下,线程A计算了hash还没有取数据,线程B往其中添加或删除了数据,导致字典结构发生改变,线程A再用原来的hash取数据时就会出问题。我再想想能不能使用自定义的hash表替代,比如类似于java的ConcurrentHashMap就是一种不错的思路,减少锁碰撞。

优化成果

目前相较于初版性能提升10+倍,比Aspect高大概100倍。主要耗时在三个地方,字典查询(40%),加解锁(20%),retain+release操作(15%)。在多线程高并发的情况下,加解锁情况会恶化;如果要高并发调用同一切面(比如5个线程调用同一个函数及其切面一百万次),也会导致retain-release开销进一步恶化。如果想要再优化估计只能针对前两者入手了。当然已经算是快到飞起了,再优化的意义其实不大了,除非再有个两三倍的提高。目前这种性能使AOP大规模使用有了可能,iPhone6s百万次调用大约0.25s,可以每秒数十万次调用基本不影响性能(少于0.1s的卡顿,用户基本没有感知)。如果没有AOP,直接调用方法(空方法)的,大概是百万次0.0075,大约是AOP版本的30倍,这是只是一个参考值,不同的函数差距其实不小。

最后

AOP一种常规的设计手段来使用,一般情况下侵入性还是比较强的,但是我更希望是其可以作为非侵入的设计手段来大规模应用,并可以简单方便的实现。目前大概就这样子了,后续也就看看有没有什么bug啥的,啥时候想起来再看看还有没有优化的空间。

Github源码地址

性能高度优化版
Github源码地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容