击水湘江

Born To Fight!


  • Home

  • About

  • Tags

  • Archives

聊聊OC中静态库的链接

Posted on 2019-10-30

本文源于开发中的一个困惑:经历了较大的重构,项目中有两个相同的类文件,竟然可以编译并链接通过。经过反思,回顾,整理了关于静态库的相关技术才解决了此疑问。接下来将聊聊静态库出现的原因,链接的原理及其容易令人困惑的点,同时简要说明OC中方法和类链接的特点。

问题由来

问题一

在iOS开发中,如果项目中有两个相同的类,那么在链接的时候一般会报错:

image.png

可是在我开发的项目,为什么不同目录下有两个相同的类确不会报错,能够正常链接通过。

问题二

在C语言中,如果某个函数没有被定义,那么在链接时候会报符号找不到的错误,OC中如果某个类的方法只写了声明,为什么可以链接通过呢?

问题三

如果在项目中引入的静态库里含有只有分类的文件,为什么会在运行时会报错:unrecognized selector sent to instance?

这里这几个问题其实都和静态库有关,我们先看看静态库解决了什么问题,以及静态库的链接是怎样的。

静态库解决了什么问题?

如果没有静态库,我们开发一个项目,会怎样?为了开发一个功能,我们要去某个公共的地方找到实现了相应功能的文件,以及这个文件依赖的文件,然后引入到项目中去,这样做显然太复杂了。后来出现了静态库,它其实是将上述动作交给了链接器,从而节省了人力。总体来说它可以方便引入外部功能,不泄露源码,节省编译时间等几个优点。

方便开发者使用

如果没有静态库,我们该怎样调用某个公共方法?我们首先需要去某个地方查找这个方法的定义,然后将这个方法的.h和.c文件引入到我们的工程,这时如果这个.c文件中还引入了其它的方法,那么我们需要一直寻找下去,这样就会让开发流程及其复杂。有了静态库,我们只需要导入一个库文件即可,链接器会决定究竟需要引入哪些目标文件。

方便编译器开发

有些人可能会感觉,既然程序员逐个来找所需要的源文件这么复杂,为什么编译器不内置所有的文件?我们使用的时候自动给我们链接所需要的目标文件不就可以了?这样做确实是可以,但是这样做其实大大增大了编译器的开发成本和维护成本,它和静态库耦合了,如果某个静态库升级了那么编译器也需要升级。

节省磁盘和内存空间

如果将所有的功能文件都打进一个.o文件,然后链接一次不就解决了开发者的问题,也解决了编译器的问题了吗?是,这样是解决了两者的问题,但是又引入了另外的问题:可执行文件的变得很大,因为它引入了不需要的功能,这占用了磁盘空间,同时在程序运行的时候也会占用内存空间。

不泄露源码

某些框架可能是作为商用产品提供给开发者的,不能直接将代码提供给开发者,这时将源代码编译之后提供给开发者更加安全。其实如果开发者知道了源码的实现逻辑,就可能会依赖没有在.h中暴露出来的代码,这样不规范的用法就导致依赖了不稳定的东西,结果一旦库升级,内部实现逻辑改变,那么上层的使用方就会出bug。

节省编译时间

在大型工程的开发过程中,某个开发者仅仅关注一个模块的功能,如果所有模块都是源码,那么每次运行项目就需要全部编译一遍,项目越大越耗时,所以在很多大型软件开发过程中都会拆开各个模块给不同的团队开发,自己业务不依赖的模块是个静态库,这样就在很大程度上节约开发的时间。

静态库的链接过程

知道了静态库出现的原因,下面说说Unix标准下静态库的链接步骤。将静态库和源文件链接的形式如下:

1
1linux> gcc main.c /usr/lib/libm.a /usr/lib/libc.a

链接器的工作方式是这样的:在链接器收到这个指令之后,会对创建三个集合E,D,U,它们的作用分别是:

E:存放最终要链接成可执行文件的所有目标文件。

D:存放放入E中的目标文件中定义的符号。

U:未被解析的符号引用。

链接器根据传入参数中从前往后**的顺序解析所有的输入项,**如果输入项为.c文件,那么就会被转化成.o文件然后放到E中,如果是.o文件则直接放到E中,同时更新D和U,如果其输入的是静态库,那么它会逐个遍历静态库中的目标文件,以查找U中符号的定义,如果找到了某个目标文件定义了U中的符号,那么就将其加入到E中,并更新D,一直到U和D中的符号稳定(这时说明静态库中所有的目标文件都被处理完了,U和D都不再变化了)。最后链接时候只会链接E中的目标文件,静态库中不在E中的目标文件将会被忽略,从而不占可执行文件的空间。处理到最后,如果U中有符号未被定义,那么就会报符号找不到的错误:

1
1linux> undefined reference to 'some_func'

具体流程如下:

静态库的链接流程.png

静态库链接注意点

从上面链接器对静态库处理链接的描述中,我们会发现几个问题:

链接的顺序很重要

如果某个文件引用了静态库中的函数,然而将静态库的链接放在了引用文件的前面,将会出现链接失败。为什么?因为引用符号的文件出现在了静态库的后面,那么,在静态库链接的时候没有被放到U中,所以静态库中需要被加入链接的目标文件被直接舍弃了。例如在main.c中使用了sumlib.a中的sum方法,而在链接的时候使用:

1
1linux>  gcc sumlib.a main.c

这时会报符号未定义的链接错误,但是这样链接就没有问题:

1
1linux>  gcc main.c sumlib.a

这是因为如果先处理sumlib.a的话,因为没有未引用的符号放到U中,所以这个静态库就直接被忽略了,之后再链接main.o的时候就会出错,关于静态库和.o之间链接可能引起的问题,StackOverflow上的这个回答给了五个Case可供学习 很多现代的链接器优化了删除静态库中无用文件的逻辑,不是在处理完某个静态库就将其中不需要的文件移除,而是在所有输入项中未定义符号处理完之后才会决定是否舍弃静态库中未放入E中的文件(这点是在Mac上使用gcc和clang试验结合参考资料5得来的)。

静态库中可以出现重复的符号

因为静态库在生成时候是没有进行链接的,所以其不会检查是否有符号重复,而在链接时候,只要链接器找到了一个U中符号定义的目标文件,就将其放入到E中,并更新D,因此,之后那个重复的文件没有被链接的可能。所以链接过程中不会有重复目标文件带来的错误,无论这个文件是出现在一个静态库中,还是出现在多个静态库之间(在Xcode中,如果某个静态库的不同问价夹下出现两个相同的类文件,那么编译之后会在类名以某种规则加上字符串,以做区分)如果两个静态库中有相同的类,那么先被用来解引用的类将会被链接,而后被解引用的那个静态库中的文件,因为U中没有了该符号,所以就不会再被链接。

不在静态库中的目标文件都会被放到E中

如果某个源文件是直接链接,而没有打包进静态库,那么即使该文件没有被引用,它还是会被加入到可执行文件,从而增加了应用程序包的大小。

源文件可以替换掉静态库中的文件

如果源文件放到静态库中的前面进行链接,那么最终链接的时候会用源文件,而不会使用静态库中编译好的目标文件,这个方法可以用在静态库的调试过程中。比如我们的项目用到了某个静态库,发现库中有一个bug,如果每修改一次都重新打包同时加入到项目中就会变得很繁琐(静态库有时候不好作为project直接引入到项目中),可以将某个文件直接引入到被使用的项目中去,这样就可以调试,调试完成之后再将源文件编译打包进静态库中就可以了。

本文开头提出的问题原因在哪?

问题一

细心的读者可能已经发现,原因就是我上文说的静态库链接机制,由于我们的开发是在静态库中的,所以可以有两个相同的类被放到不同的目录下,至于哪个类被链接取决于哪个类先被链接器处理。

问题二

问题二的原因在于,在OC中方法是不会被生成符号的,可以生成符号的只有类名和全局变量,所以如果某个类没有.m文件,直接链接错误的。而方法不会,在OC中方法调用,在编译之后会转换成objc_msgSend函数的调用,方法名作为该函数的一个字符串参数被传入,而函数的具体地址是在运行时决定的,这一点和C语言不一样,这也就是OC作为一门动态语言的特点。

问题三

问题三的原因和问题二很像,因为OC不将方法作为符号,所以就不会将其放入U中,因为U中没有静态库中的符号,所以就不会将静态库中相应的目标文件链接进可执行文件,就会在运行的时候报错。关于这个问题及其解决方案,请看苹果的官方解答:https://developer.apple.com/library/archive/qa/qa1490/_index.html

备注:

上面的问题三解决方案是通过配置Xcode中Build Setting,在Other Linker Flags中添加ObjC标识,填了此标识就会加载静态库的所有成员,无论是C还是C++还OC,但它对同一个静态库中的相同类是不会同时链接的,还是遵守上文中链接流程的描述(链接其中先链接到的符号文件),但是对于不同静态库中的文件不会因为其中已经没有未引用的符号而不链接该文件。同时现在绝大多数的项目都会使用CocoaPod,而CocoaPod默认是添加了ObjC标识的,这也就是为什么很多时候两个不同的静态库中有相同的符号会报链接错误。

解决方案

既然在使用静态库的过程中可能会有这些问题,应该如何避免呢?解决某个静态库中相同文件,其实可以在提交前写个脚本执行下,如果发现相同的文件,则不能提交git仓库。然而对于不同静态库中相同类名等问题就需要在持续集成打包的机器上做比价合适,因为其比较耗时,可以使用ar -t或者其它指令来找出所有的.o文件,对比其是否有相同的目标文件,并给出总后的结果(如果开启了ObjC标识,那么其实无需关心不同静态库中相同目标文件的问题,因为链接器会直接报错的)。

参考资料

  1. 深入理解计算机系统: 7.6.3
  2. https://developer.apple.com/library/archive/qa/qa1490/_index.html
  3. https://stackoverflow.com/questions/50292838/multiple-symbol-definitions-and-static-libraries
  4. https://stackoverflow.com/questions/37490157/linker-does-not-emit-multiple-definition-error-when-same-symbol-coexists-in-obje
  5. https://blog.csdn.net/weiyuefei/article/details/76169772
  6. https://segmentfault.com/a/1190000005859469
  7. https://pewpewthespells.com/blog/objc_linker_flags.html

影响正交性的常见因素

Posted on 2018-11-21

什么是正交性?

正交性是从几何学中借鉴过来的,比如上图中的X轴和Y轴,它们就是正交的。这里的X轴和Y轴的发展是完全独立的,X轴的伸展不会影响到其投影到Y轴的内容。从软件开发的角度来看,就是一个方法,类,模块的改动不对另一个方法,类,模块造成影响,那么它们就是正交的。比方说你改了数据库的表结构但是不影响到UI,改了UI层的展示方式不能要求数据库schema跟着变更,那么这二者就是正交的。

影响正交性的危害有哪些?

如果缺少正交性,就会严重影响到软件的维护性,这种危害将会随着项目的迭代而越来越严重。比方说你移动了一个方法调用的位置,可能就会造成严重的bug,因为各个方法相互依赖,你就必须理清楚所有的方法,才可以增加一个小功能。再比如说模块A依赖了模块B,那么模块B的改动就需要模块A重新加载模块B,这样它两者之间其实就缺失了模块的概念了。比如下面这个图:

dependent_cycle

比如上图中的Interactors,Authorizer和Entities就因为出现了依赖环而导致正交性缺失。这就导致这三个模块的发布必须依赖考虑到和其它两个模块之间的兼容性,任何依赖这三者之一的模块都必须同时兼容其余的两个模块(比如要考虑到其它模块是不是最新版本,因为在老版本中出现了Bug),如果一个模块出了问题就会很难定位,因为它们之间是一个环形的结构,很难确定是Interactors调用的Entities出了问题,还是其自身出了问题(因为Entities调用Authorizer而Authorizer又会调用Interactor)。这种维护的成本会严重影响到软件的开发效率。曾经在YouTube上看到过一篇演讲给出这样一个结论:

程序员写代码的平均时间不超过10%,其它90%的时间在看代码。

起初还不认同,但是随着所做项目越来越大,迭代次数越来越多,项目的年代越来越久远,这种感觉就越强烈。90%的时间用于熟悉所有的业务逻辑,熟悉层层嵌套的if else,熟悉各个方法调用之后产生的副作用。而真正需要加的功能更或许也就几行代码而已。既然正交性这么重要,下面就来聊聊影响正交性的常见因素有哪些。

影响正交性的因素有哪些?

不必要的属性

属性是我们在保存某个数值以便在某个时刻使用的常用方式,它往往和所属对象有相同的生命周期。但是属性的使用同时却带来了麻烦:

使用属性的方法会产生副作用,而副作用是让方法之间缺少正交性的关键因素。

考虑如下的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@interface MFPeopertyShowController ()
@property (nonatomic, assign) CGFloat level;
@end

@implementation MFPeopertyShowController

- (void)viewDidLoad {
[super viewDidLoad];
...
NSDictionary *fullData = @{
...
@"level":@(10.0),
@"name":@"NoBody",
...
};
...
[self p_a:fullData];
...
[self p_b];
...
[self p_c];
}

- (void)p_a:(NSDictionary *)data {
self.level = [data[@"level"] floatValue];
//其它业务逻辑
}

- (void)p_b {
if (self.level < 2) {
// ...
}else if (self.level < 4) {
// ...
}else if (self.level < 6) {
// ...
}else {
// ...
}
...
}

- (void)p_c {
//用到level做了其它的并且赋值
...
}

这个例子中我们为级别作为属性,在p_a中对其赋值,之后调用了p_b方法,这个方法中用到了level属性。到这里p_a和p_b方法就缺少了正交性。我们必须先调用p_a,然后再调用p_b,并且任何对属性level产生的影响都将影响到p_b和p_c。再加上很多人会对这个p_a这个方法命名不规范,导致不熟悉该业务的人在解决bug时发现把p_c移到最上面好像可以解决,试了之后发现又引入了新的bug,仔细研究才发现是以为level的值设置错了。然后就他需要全局去搜索这个level,看看都有哪些地方用对它设置了值,最后发现搜索到了N个……。这里举得例子可能不太贴切,但是最终的结果是相同的属性的使用导致了各个方法之间没有了正交性,每个方法之后的重构或者新增功能都必须考虑到这个属性值。

怎么做才可以尽量减少属性的使用呢?在这个方法中,我们其实可以让p_a方法返回level,然后将它做为参数传入p_b,最后p_c传入一个level,并且返回一个新的level。这样做有以下几个优点:

  1. 各个方法之间的关系很清晰,如果改动了本来的顺序,编译器就直接警告你了。
  2. 一个方法不依赖其它方法了,它所依赖的只是一个输入的值。
  3. 有一天如果这个方法在另外一个项目中能用,这时,只需要做很少的改动就行了。
  4. 在多线程中,如果是属性则需要利用加锁等手段来保证线程安全。但是如果用了局部变量,则就不会出现竞态条件了,也就无需加锁。也就是说我们使用了全局变量让方法变得不可重入了。
  5. 利用局部变量往往可以提高程序的性能,因为编译器会将某些基本数据类型的局部变量(OC对象除外)直接存储在CPU的寄存器堆中,而不是在内存中(参考CSAPP第3章,第5章)。

但是有时候使用属性是不好避免的,比如我们给一个组件传递了一个数据,想在组件被用户点击的时候将数值传递出去,因为在iOS中这是基于Target-Action实现的,除了属性,我们没有办法来保存数据以便在Action的方法中传递。如果这里是一个Block能够捕获住局部变量,我们就可以少写个属性了,其实在Android开发中事件的回调用的是匿名内部类,刚好用的就是这种思想。

生命周期越长的变量或者实例使用过程中越需要注意。

单例模式的滥用

在一个应用程序中如果某个对象应该是唯一的,那么需要用到单例模式,比如UIApplication对象,每个应用对应一个。可能是因为单例模式实现简单的缘故,导致它很容易被滥用。比如很多人用单例模式来传值。 单例传值确实很简单,一个单例能够解决需要将参数层层传递到目标对象的繁琐工作。但简单实现之后,它却带来了维护的灾难。 因为单例严重影响了各个类之间的正交性,页面A正在是用着这个值,然后跳转到页面B,页面B改了之后页面A的值就变了。试想下,如果这个是单例是公司级的工具,每个业务线都在用,你根本看不到其它业务线的代码,独立测试顺利通过了,集成之后出现Bug(集成之后代码的测试程度往往会小于独立测试的强度,结果导致线上Bug)。除此之外单例更容易出现线程安全的问题,我就曾经见到过因为单例的非线程安全而造成难以排查的线上Crash。

单例模式不是用来传值的,用单例传值往往会造成维护的灾难。

违背最小知道原则

最小知道原则告诉我们:一个类对其它类知道的越少越好。还用一个中比较有趣说法是:编写害羞的代码,让一个类暴露的越少越好。这样两个类之间就越正交,一个类的变动对另一个类的影响也就最少。比如下面这个例子:

1
2
3
4
- (void)processDate(NSDate aData, MFSelection aSelection) {
TimeZone tz = aSelection.getRecorder().getLocation().getTimeZone();
...
}

在这个方法中我们需要的是一个TimeZone的对象,然而我们需要层层寻找,在这个过程中我们不经意间依赖了Recorder和Location这两个本来没有必要依赖的类,忽然有一天发现从Recoder中获取时区的方式会有问题,那么我们就需要查找到整个项目改动所有的方法。应该怎样解决呢?给MFSelection添加一个getLocationTimeZone的方法,将上文获取时区的方法放到里面即可。这样processDate所在的类就只知道了一个MFSelection类,而不知道其内部的其它类。再举个例子:

1
2
3
4
@interface MFPerson : NSObject
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@end

这是一个Person类,它有一个firstName和lastName,这时服务端返回的数据,可是有一个场景需要展示fullName。大多数人会在调用Person类的地方自己做个字符拼接来得到所需要的fullName。后来这种场景越来越多,你就拼接的地方也会越来越多。再后来用设计师发现fullName的展示可以优化下,在firstName和lastName中间加上一个特殊符号会更好。这时需要改的地方就会很多。在这里,Person类是无需外界知道其fullName的拼接过程的,所以我们应该给Person添加一个属性:

1
@property (nonatomic, copy, readonly) NSString *fullName;

其实这里还有一点需要注意,如果没有对Person属性进行写的需求,要将其变成readonly,这样它就更好的保证了自身的封装性。

滥用继承

利用继承,实现多态,然后将各个相似的功能实现在不同的类中,依赖抽象而不是具体的实现,这可能是面向对象给我们提供的利器之一。然而继承却成为了很多人用来实现复用的手段,同时它会严重影响到程序的正交性。在继承中,子类在开发新功能时要考虑到父类的代码逻辑,父类变动更会影响到很多子类。除此之外因为父类往往会加一些模板方法,而模板方法的逻辑在父类中。这就导致新人在熟悉代码的时候要将父类也熟悉一遍。父类的方法调用依赖子类的实现,子类又天生的依赖了父类,这就导致了环形的依赖,容易产生难以排查的Bug:子类调用了方法A,但是莫名其妙得又触发了方法N,调试了很久就才发现是父类的方法A调用了B,B又调用了C…最后调用到了N。所以能不用继承的时候就尽量不用继承,改用组合。

小结

为了让写的代码保持正交性,就要尽力避免和其它方法或者类持有相同的对象,要尽量避免使用继承。同时要满足最小知道原则,减少它暴露的信息。

参考资料:

《程序员的修炼之道–从小工到专家》
《深入理解计算机系统》
《Clean Architecture》

执行rm -rf后的一种恢复方案

Posted on 2018-08-02

如果使用rm -rf指令删除了某个问价夹,比如:

1
rm -rf importantFile

删完之后发现这个文件夹里面的内容都是有用的,这时候先不要慌张,可以使用lsof | grep FileName指令查看是否有进程在使用这个文件,如果可以找到,就可以使用如下的解决方案,如果没有找到,那么就需要借助于其它的工具解决了。

比如我的操作:

1
2
3
4
5
6
7
8
9
[work(mike)@tjtxvm-224-15 web]$ lsof | grep importantFile
java 16941 work cwd DIR 252,2 0 4718641 /opt/web/importantFile (deleted)
java 16941 work DEL REG 252,2 4718775 /opt/web/importantFile/temp/snappy-unknown-02c7c6de-6fa4-4f3b-b699-ac56a9d6c78a-libsnappyjava.so(deleted)
java 16941 work DEL REG 252,2 5112728 /opt/web/importantFile/webapps/WEB-INF/lib/xercesImpl-2.6.2.jar(deleted)
java 16941 work DEL REG 252,2 5112601 /opt/web/importantFile/webapps/WEB-INF/lib/xalan-2.6.0.jar(deleted)
java 16941 work DEL REG 252,2 5112699 /opt/web/importantFile/webapps/WEB-INF/lib/wredis-udp-1.0.2.jar(deleted)
java 16941 work DEL REG 252,2 5112594 /opt/web/importantFile/webapps/WEB-INF/lib/wredis-client-redis-2.0.4.jar(deleted)
java 16941 work DEL REG 252,2 5112564 /opt/web/importantFile/webapps/WEB-INF/lib/wredis-client-basic-2.0.4.jar(deleted)
// ....

从中我们可以看出java进程在使用这些文件,并且这个进程的进程号是:16941。我们可以进入该进程的文件描述符目录中查看它都使用了哪些文件描述符(fd是file descriptor的缩写):

1
2
3
4
5
6
7
[work(mike)@tjtxvm-224-15 web]$ cd /proc/16941/fd
[work(mike)@tjtxvm-224-15 fd]$ ls
0 102 107 111 142 157 183 20 222 238 247 258 262 267 271 276 280 285 29 294 299 302 307 311 316 34 40 45 5 54 59 63 68 72 77 81 86 90 95
1 103 108 112 143 16 184 21 225 24 248 259 263 268 272 277 281 286 290 295 3 303 308 312 32 36 41 46 50 55 6 64 69 73 78 82 87 91 96
10 104 109 12 15 167 19 219 226 241 25 26 264 269 273 278 282 287 291 296 30 304 309 313 324 38 42 47 51 56 60 65 7 74 79 83 88 92 97
100 105 11 13 153 17 196 22 23 242 256 260 265 27 274 279 283 288 292 297 300 305 31 314 325 39 43 48 52 57 61 66 70 75 8 84 89 93 98
101 106 110 14 156 18 2 220 232 244 257 261 266 270 275 28 284 289 293 298 301 306 310 315 33 4 44 49 53 58 62 67 71 76 80 85 9 94 99

这些就是文件描述符,但是从中我们看不到其所指的具体文件,可以使用ll指令查看描述符的详细信息,同时,如果想要过滤出和某个目录相关的信息可以结合grep指令。

查询所有被删除的文件:

1
2
3
4
5
6
7
8
9
10
11
[work(mike)@tjtxvm-224-15 fd]$ ll | grep importantFile
l-wx------ 1 work work 64 8月 2 10:41 1 -> /opt/web/importantFile/logs/catalina.out (deleted)
lr-x------ 1 work work 64 8月 2 10:41 100 -> /opt/web/importantFile/webapps/WEB-INF/lib/com.me.vip.scf_service.utils-2.2.0-SNAPSHOT.jar (deleted)
lr-x------ 1 work work 64 8月 2 10:41 101 -> /opt/web/importantFile/webapps/WEB-INF/lib/com.me.vip.sns.contract-1.3.8.jar (deleted)
lr-x------ 1 work work 64 8月 2 10:41 102 -> /opt/web/importantFile/webapps/WEB-INF/lib/com.me.vip.wlt.contract-4.5.7.jar (deleted)
lr-x------ 1 work work 64 8月 2 10:41 103 -> /opt/web/importantFile/webapps/WEB-INF/lib/com.me.wf.core-1.2.7.jar (deleted)
lr-x------ 1 work work 64 8月 2 10:41 104 -> /opt/web/importantFile/webapps/WEB-INF/lib/com.me.wf.mvc-1.2.17.jar (deleted)
lr-x------ 1 work work 64 8月 2 10:41 143 -> /opt/web/importantFile/webapps/WEB-INF/lib/dionaea-common-0.0.1-SNAPSHOT.jar (deleted)
lr-x------ 1 work work 64 8月 2 10:41 222 -> /opt/web/importantFile/webapps/WEB-INF/lib/snappy-java-1.1.1.6.jar (deleted)
lr-x------ 1 work work 64 8月 2 10:41 225 -> /opt/web/importantFile/webapps/WEB-INF/lib/spring-beans-3.0.5.RELEASE.jar (deleted)
// .......

从中可以看到文件最后的括号中有一个deleted的标示。其实我们只要使用cp指令把相应的描述符拷贝到相应的文件即可:

1
cp FdNumber DestinationFile

但因为这时DestinationFile这个文件的所有目录都被我们删除了,所以不能用直接用cp指令来做这件事,需要使用如下的脚本:

1
test -d "$des" || mkdir -p "$des" && cp $src $des

这里第一个参数是原文件,第二个参数是目标文件以及其路径。这样我们就可以通过循环来获取每个文件的描述符和其目标地址来进行恢复,所有的代码如下(脚本的地址):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#
# 使用方法:./rmrf_recover.sh pid deletedFillName
#

#
# 判断传入的参数是否正确
#

if [ "$#" -ne 2 ]
then
echo "Incorrect number of argumments."
echo "Usage:./rmrf_recover.sh pid DeletedFillName"
exit 1
fi

#
# 进入到需要应用删除文件进程的文件描述符目录
#

fdDir="/proc/""$1""/fd/"
cd $fdDir

temp_deleted_listPath=/tmp/temp_deleted_list

#
# 获取被删除文件的详细信息
#

ls -l | grep "$2" > $temp_deleted_listPath


echo "Process Id: ""$1"
echo "Deleted Filename: ""$2"
echo "Deleted List:"
## 输出删除的列表
cat $temp_deleted_listPath

#
# 获取所有的文件描述符
# 这里的11和13是以空格为分割符的,需要根据自己的情况做调整
#

fileDeses=$(cut -d' ' -f11 $temp_deleted_listPath)
fileDires=$(cut -d' ' -f13 $temp_deleted_listPath)

## 输出最终的值

echo $fileDes
echo $fileDires

#
# 将被删除文件的全目录存储到fileArray中
#

fileNum=0
for fileDir in $fileDires
do
fileArray[$fileNum]=$fileDir
fileNum=$((fileNum + 1))
done

#
# 遍历所有的文件描述符并且恢复
#

num=0
for fileDes in $fileDeses
do
echo " DeletedFile:---------"$fileDes" "
echo " DestinationFile:-----""${fileArray[num]}"
test -d ${fileArray[num]} || mkdir -p ${fileArray[num]} && cp $fdDir$fileDes ${fileArray[num]}
num=$((num + 1))
done

rm -rf $temp_deleted_listPath

Aspects框架中Block的使用

Posted on 2018-07-04

前言

Block算是OC语言中比较经典的语法,对其讲解的文章也不少,但大多讲的是其实现原理,而没有将其原理应用于实际中。本文将从实践出发,从Aspects框架中分析Block的本质,以及如何从Block中获取方法签名和参数列表。在分析之前我们先来看下Aspects为什么要使用Block来实现。

Aspects为什么要使用Block?

我们知道在objc中使用MethodSwizzle来实现AOP是很简单的,具体做法可以参考这两篇:method-swizzling 详解和使用,Objective-C的hook方案文中讲解了怎样在方法中注入相应的代码,但是这样做有以下两个问题:

  1. 需要为每个被Hook的类新建一个分类。
  2. 对于每一个需要Hook的方法,我们都需要重写一个和它参数列表一样的方法。

这些问题就会让AOP变得复杂,并且Hook方法不能统一添加。比如有这样一个需求:一个数组,数组里面配置了每个类需要Hook的N个方法,这些方法中需要注入相同的代码。这时总不能给每个类都添加一个方法,然后在里面新加一段统一的代码吧?怎样做才更优雅呢?更好的方法是将需要注入或新加的方法实现写在Block中,使用这个Block作为上文中提到的被插入的方法,这样就避免创建一个分类文件,同时再对原方法写一个相同参数的方法。但是使用Block又有以下两个问题:

  1. 因为MethodSwizzle最终切换的是方法,而我们写的是Block,Block怎样和方法之间产生关联?
  2. 如何获取Bolck的参数列表以便给它传递原方法相应的形参?

对于第一问题,Aspects框架是这样做的:

  1. 获取Block的方法签名。
  2. 调用class_replaceMethod替换掉需要被Hook的方法。
  3. 调用class_replaceMethod替换掉系统
1
- (void)forwardInvocation:(NSInvocation *)anInvocation;
  1. 在自己的forwardInvocation:方法中,利用1中获取的方法签名来生成新的NSInvocation,利用NSInvocation调用Block。

这样问题的关键就在于获取Block的方法签名了。

获取Block的Signature

怎样从block中获取其方法签名呢?我们从Block的语法中是得不到任何信息的。只有看看编译器对我们的Block语法做了哪些“手脚”才可以看到,Block编译后的代码在苹果开源的LLVM镜像中有提及:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
enum {
BLOCK_REFCOUNT_MASK = (0xffff),
BLOCK_NEEDS_FREE = (1 << 24),
BLOCK_HAS_COPY_DISPOSE = (1 << 25),
BLOCK_HAS_CTOR = (1 << 26), /* Helpers have C++ code. */
BLOCK_IS_GC = (1 << 27),
BLOCK_IS_GLOBAL = (1 << 28),
BLOCK_HAS_DESCRIPTOR = (1 << 29)
};

/* Revised new layout. */
struct Block_descriptor {
unsigned long int reserved;
unsigned long int size;
void (*copy)(void *dst, void *src);
void (*dispose)(void *);
};

struct Block_layout {
void *isa;
int flags;
int reserved;
void (*invoke)(void *, ...);
struct Block_descriptor *descriptor;
/* Imported variables. */
};

从中我们可以看出Block其实是一个对象,我们来分析下这个对象:

void *isa:指向该Block所属类的指针

在Block中,这个指针可能的值为:

1
2
3
4
5
1. _NSConcreteStackBlock
2. _NSConcreteGlobalBlock
3. _NSConcreteMallocBlock
4. _NSConcreteAutoBlock
5. _NSConcreteFinalizingBlock

这五种类型的Block都继承自_NSAbstractBlock。其具体的类型是根据Block创建的位置决定的,如果Block被定义在一个方法中做为一个局部变量,则其创建出来为_NSConcreteStackBlock(如果我们调用copy方法将其复制到上时,其类型将变为_NSConcreteMallocBlock),如果定义为全局变量,那么它就是_NSConcreteGlobalBlock。至于什么场景会产生什么Block对象不在本文的讨论范围,可以从本文的参考资料中获取。

int flags: Block属性的标识符

我们可以通过该属性来对Block是否含有某种信息做以判断,我们来详细说明这些标识符:

  • BLOCK_REFCOUNT_MASK,BLOCK_NEEDS_FREE,BLOCK_IS_GC:这三个与引用计数和GC相关的标识符是在运行时,当Block被拷贝时设定的。
  • BLOCK_IS_GLOBAL:对于全局存储的Block,这个属性是在编译期被设定的,对这种类型的Block执行Copy和Releas是不起作用的,因为它存储在应用程序的数据区。
  • BLOCK_HAS_DESCRIPTOR:这个标示总是会被设置,因为在Mac OS的Snow Leopard版本前后Block的实现是不同的,为了区分之后的实现,所以总是要被设置。
  • BLOCK_HAS_COPY_DISPOSE:如果Block捕获了某个变量,那么就需要将实现copy和dispose方法,这时就会设置该位。
  • BLOCK_HAS_CTOR:如果Block中含有C++的构造解释器,那么就会设置该位。

void (*invoke)(void *, …): 函数指针

这个指针就是我们写Block时候的实现,编译器会给我们创建一个C语言的函数,这个函数的指针被赋值给invoke指针。这时我们就可以通过函数调用来调用Block中函数的实现,请看下文的使用函数指针调用Block。

struct Block_descriptor *descriptor:Block的描述信息

这个结构体里面保存着Block的大小:size,Block捕获__block类型变量被Copy时调用的void (*copy)(void *dst, const void *src);以及被销毁时候调用的void (*dispose)(void *);方法。

这些Block的基本知识了解之后,我们看本小节的主旨:如何通过Block获取方法的签名?其实在上面官方提供的文档中看,早期的Block实现中没有提供方法签名,后来在flags的枚举中又新加了一个BLOCK_HAS_SIGNATURE = (1 << 30)这个枚举项,通过对这个枚举项的判断我们可以确定一个Block中是否含有方法的签名。方法签名的标识有了,那么这个方法签名存放在哪里呢?它存放在Block_descriptor结构体中dispose下面,我们来看Aspects框架是怎样将Block转换为方法签名的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Block internals.
typedef NS_OPTIONS(int, AspectBlockFlags) {
AspectBlockFlagsHasCopyDisposeHelpers = (1 << 25),
AspectBlockFlagsHasSignature = (1 << 30)
};
typedef struct _AspectBlock {
__unused Class isa;
AspectBlockFlags flags;
__unused int reserved;
void (__unused *invoke)(struct _AspectBlock *block, ...);
struct {
unsigned long int reserved;
unsigned long int size;
// requires AspectBlockFlagsHasCopyDisposeHelpers
void (*copy)(void *dst, const void *src);
void (*dispose)(const void *);
// requires AspectBlockFlagsHasSignature
const char *signature;
const char *layout;
} *descriptor;
// imported variables
} *AspectBlockRef;

static NSMethodSignature *aspect_blockMethodSignature(id block, NSError **error) {
AspectBlockRef layout = (__bridge void *)block;
if (!(layout->flags & AspectBlockFlagsHasSignature)) {
NSString *description = [NSString stringWithFormat:@"The block %@ doesn't contain a type signature.", block];
AspectError(AspectErrorMissingBlockSignature, description);
return nil;
}
void *desc = layout->descriptor;
desc += 2 * sizeof(unsigned long int);
if (layout->flags & AspectBlockFlagsHasCopyDisposeHelpers) {
desc += 2 * sizeof(void *);
}
if (!desc) {
NSString *description = [NSString stringWithFormat:@"The block %@ doesn't has a type signature.", block];
AspectError(AspectErrorMissingBlockSignature, description);
return nil;
}
const char *signature = (*(const char **)desc);
return [NSMethodSignature signatureWithObjCTypes:signature];
}

从中我们可以看出首先判断是否含有方法签名,如果没有则直接返回,如果有则通过指针的移动来指向方法签名所在的位置,并调用[NSMethodSignature signatureWithObjCTypes:signature];来生成方法签名。

获取Block的参数列表

有了方法签名之后获取参数列表是相对简单的,其关键代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
static BOOL aspect_isCompatibleBlockSignature(NSMethodSignature *blockSignature, id object, SEL selector, NSError **error) {
//...
BOOL signaturesMatch = YES;
NSMethodSignature *methodSignature = [[object class] instanceMethodSignatureForSelector:selector];
if (blockSignature.numberOfArguments > methodSignature.numberOfArguments) {
signaturesMatch = NO;
}else {
if (blockSignature.numberOfArguments > 1) {
const char *blockType = [blockSignature getArgumentTypeAtIndex:1];
if (blockType[0] != '@') {
signaturesMatch = NO;
}
}
// Argument 0 is self/block, argument 1 is SEL or id<AspectInfo>. We start comparing at argument 2.
// The block can have less arguments than the method, that's ok.
// 这里获取Block中的参数列表以便和原方法的参数列表进行比较,看其是否匹配
if (signaturesMatch) {
for (NSUInteger idx = 2; idx < blockSignature.numberOfArguments; idx++) {
const char *methodType = [methodSignature getArgumentTypeAtIndex:idx];
const char *blockType = [blockSignature getArgumentTypeAtIndex:idx];
// Only compare parameter, not the optional type data.
if (!methodType || !blockType || methodType[0] != blockType[0]) {
signaturesMatch = NO;
break;
}
}
}
}

if (!signaturesMatch) {
// ...
return NO;
}
return YES;
}

这里主要做了原始方法和新方法之间参数的比较,如果参数不对应则注入方法之后再重新调用原始方法时会出错,所以这里获取了Block的参数列表。主要用到的是NSMethodSignature的两个方法:

1
2
@property (readonly) NSUInteger numberOfArguments;
- (const char *)getArgumentTypeAtIndex:(NSUInteger)idx NS_RETURNS_INNER_POINTER;

使用函数指针调用Block

我们可以通过定义自己的Block结构体,然后将Block变量强制转化,之后调用上文中提到的invoke函数指针来实现对Block结构体的调用,运行下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
struct MyBlockDescriptor {
unsigned long int reserved;
unsigned long int size;
void (*copy)(void *dst, const void *src);
void (*dispose)(const void *);
};

struct MyBlockLayout {

void *isa;
int flags;
int reserved;
void (* invoke)(void *, ...);
struct MyBlockDescriptor *descripter;
};

- (void)callBlockByFuncPointer {

//简单Block的调用
void (^block)() = ^{
NSLog(@"Block called");
};
struct MyBlockLayout *block1 = (struct MyBlockLayout *)(__bridge void *)block;
block1->invoke(block1);

//含有参数的Block调用
int (^returnBlock)(int, int ) = ^int(int a, int b) {
return a + b;
};

struct MyBlockLayout *block2 = (struct MyBlockLayout *)(__bridge void *)returnBlock;
//这是将void * 指针转换为 (int (*)(void *, int a, ...))类型的指针
int result = ( (int (*)(void *, int a, ...)) (block2->invoke) ) (block2, 3, 4);
NSLog(@"result == %d",result);
}

这里我们将Block强制转换为我们自定义的MyBlockLayout,然后将void *类型的指针转换为Block相应实现的指针,这时候就可以实现以函数指针的形式调用Block了。

小结

本文小结了Block的本质,以及如何利用自定义的Block结构体,通过桥接将系统的Block转化成我们的Block结构体,从而获取其函数指针和方法签名。同时说明了标示block特性的flags,这个标示在使用Block结构体的时候往往很有用。其实FaceBook开源的框架:FBRetainCycleDetector中用到了相似的手段,感兴趣的可以对比看下。

参考资料:

Objective-C高级编程
https://stackoverflow.com/questions/13006685/is-there-a-way-to-wrap-an-objectivec-block-into-function-pointer?utm_medium=organic&utm_source=google_rich_qa&utm_campaign=google_rich_qa

http://www.galloway.me.uk/2013/05/a-look-inside-blocks-episode-3-block-copy/

Advanced Mac OS X Programming: The Big Nerd Ranch Guide

http://www.galloway.me.uk/2012/10/a-look-inside-blocks-episode-1/

http://blog.devtang.com/2013/07/28/a-look-inside-blocks/

https://github.com/llvm-mirror/compiler-rt/blob/master/lib/BlocksRuntime/Block_private.h

AOP实践小结

Posted on 2018-06-05

AOPLogo
Objective-C中利用Method Swizzle实现AOP编程是相对简单的,只需要调用runtime框架的method_exchangeImplementations方法,将相应方法的实现进行调换即可。然而在实际的使用中可能会遇到一些问题,本文总结了在项目中使用AOP时遇到的问题,并分析了常用框架中AOP的处理方法。

为什么要使用AOP?

需求背景:在某版的开发中Listing页面的改版较大,产品将UI分为A,B,C三个版本,需要根据后续的数据分析来对A,B,C三个版本进行相应的取舍。所以就需要我们在之前Listing页的所有埋点中都加入一个版本号。因为Listing经过了很多的迭代,总共统计下来有100+的埋点,很难将每个埋点中都加入一个字段。基于此,采用了面向切面的思想,Hook住公共埋点最终要调用的方法,然后在这个方法的参数中添加一个版本的字段,这种方式可谓“一劳永逸”,只需要在一个一个地方加一段代码就可以解决Listing页面的所有埋点问题。

遇到的问题

问题一、添加和移除

因为主App涉及到很多的业务线,所有的业务线最终都要调用这个方法来进行埋点。如果我Hook住了这个方法,那么其它业务线的埋点最终也会调用我写的埋点方法,这显然是不好的,同时如果我们的页面从Listing页进入Detail页面就就不需要这个版本号,这时也需要这个Hook移除。也就是说要调用两次method_exchangeImplementations,第一次用来添加注入代码,第二用来移除注入的代码。那么接口就变成了这样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// YPDataLog+YPAddition.m
- (void)yp_addAspect {
SEL orignSelector = //;
SEL newSelector = //;
BOOL responsed = [self respondsToSelector:orignSelector];
NSAssert(responsed = YES, @"The log method has been changed");
if (responsed) {
[self p_swizzleWithClass:[self class]
originalSelector:orignSelector
swizzleSelector:newSelector];
}
}
- (void)yp_removeAspect {
[self yp_addAspect];
}

这时问题就出现了,因为这个方法是有副作用的:用户在调用yp_addAspect和yp_removeAspect时候必须要保证是一一对应。也就是说要调用一个yp_addAspect,然后调用yp_removeAspect,如果连续调用了两次yp_addAspect,再接着调用yp_removeAspect那么,就会造成错误,这就给用户使用这个方法带来了麻烦。

问题二、非线程安全

比如我们注入的方法是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)yp_logPage   :   (NSString *)pagetype
logAction : (NSString *)actionType
params : (NSArray *)paramtes {

//步骤1. 添加相应的参数
NSMutableArray *mutableArray = [NSMutableArray arrayWithArray:paramtes];
NSString *version // 获取相应的A,B,C版本号;
[mutableArray addObject:version ?: @""];

//步骤2. 调用之前的方法
[self yp_logPage:pageType
logAciton:actionType
params:mutableArray];
}

在线程A调用这个方法,执行到步骤1的时候,线程B调用了- (void)yp_removeAspect,这时方法已经被调换回来了。这时线程A仍然会调用:

1
2
3
[self    yp_logPage:pageType
logAciton:actionType
params:paramtes];

这时就发生循环调用,因为在这个循环中会调用步骤一并创建相应的NSMutableArray,所有会造成栈溢出并最终崩溃。

解决方案

为了解决问题一,我们需要给这个hook添加一个标示,用来标注该方法是否已经被hook,如果已经被hook,那么再调用yp_addAspect就直接返回,如果没有调用yp_addAspect方法而先调用了yp_removeAspect方法,我们直接使用断言提醒用户就可以达到相应的目的。

对于问题二,由于调用我们没有办法限定调用yp_logPage:logAciton:params是在主线程中还是在子线程中,所以要保证该方法的调用和yp_removeAspect之间的互斥该怎么做到呢?。

方案一:加锁

为了保证互斥加锁不就行了?加锁只能保证yp_removeAspect和yp_logPage:logAction:params:中的执行是互斥的,这同样是非线程安全的。考虑下面的执行路径:

  1. 如果线程A调用了yp_removeAspect,同时线程B进入了yp_logPage:logAction:params:
  2. 因为线程A持有锁,所以线程B被阻塞
  3. 线程A执行完removeAspect,线程B获取锁被唤醒
  4. 线程B调用yp_logPage:logAction:params:,进入死循环

所以在这两个方法上加锁解决不了非线程安全问题。

方案二:全局标识

利用标识是否可以解决呢?如果在调用yp_logPage:logAction:params:中我们发现swizzle切换了,那么就调用该类原来的方法logPage:logAction:params:。来看看伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
- (void)yp_removeAspect {

SEL orignSelector = //;
SEL newSelector = //;
BOOL responsed = [self respondsToSelector:orignSelector];
NSAssert(responsed = YES, @"The log method has been changed");
if (responsed) {
[self p_swizzleWithClass:[self class]
originalSelector:orignSelector
swizzleSelector:newSelector];
self.isHooked = NO;
}
}

- (void)yp_logPage : (NSString *)pagetype
logAction : (NSString *)actionType
params : (NSArray *)paramtes {

//步骤1. 添加相应的参数
// ...
//步骤2. 调用之前的方法
if(self.isHooked) {
[self yp_logPage:pageType
logAciton:actionType
params:paramtes];
}else{
[self logPage:pagetType
logAciton:actionType
params:paramtes];
}
}

在多线程之间做标示时,要注意:

这个标示要被声明成volatile类型的,以确保其在各个线程之间是可见的。

这时仍然是非线程安全的,因为比如线程A执行完了swizzle之后时间片刚好到了,被操作系统换出,然后线程B执行yp_logPage:logAction:params的时候,if语句仍然是成立的。

方案三:方案一二结合

利用加锁和标示结合的方式可以解决问题,在方案一加锁的同时,在内部再利用标示进行判断。这种方式可以解决问题,但是开销太大,我们知道埋点方法的调用是很频繁的,这种频繁的加锁解锁会造成很大的上下文切换开销,同时绝大部分的埋点方法调用都是在主线程所以没有必要加锁解锁。同时,这种在直接在接口上加锁的方式太简单粗暴了,粒度太大。

方案四:GCD和标示

既然百分之九十以上的埋点都是在主线程中调用的,我们可以调用利用让在子线程的方法切换到主线程就行了,同时在内部加标示判断即可,这样既防止了开销,又保证了线程安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)yp_logPage   :   (NSString *)pagetype
logAction : (NSString *)actionType
params : (NSArray *)paramtes {

dispatch_async(dispatch_get_main_queue(), ^{
//....
if(self.isHooked){
//...
}else{
//...
}
}
);
}

我在2.7GHz,i5处理器的的Mac Pro上的6s模拟器上模拟了10000条多线程的日志输出,发现用Lock的形式和切换到主线程的形式,用时分别是:4.494608s和3.382740s,可以看出使用GCD进行切换的形式性能更优。这里模拟的每条日志都是在GCD的线程池中抽取的线程中执行的,而项目中的实际埋点大多都是在主线程中调用的,所以性能的提高会更高。这里要注意:

虽然埋点方法的调用是在主线程中的,但是最终将埋点写入文件(等到了时间阈值统一上传,以减少网络IO和流量损耗)时应该在子线程中,因为磁盘IO造成的性能损耗是很大的。

常用框架的处理

下面们说说常见的框架是如何来进行AOP的。

DZNEmptyDataSet中AOP的实现

DZNEmptyDataSet可以说是做空白页的鼻祖,它hook的是tableView和collectionView的reloadData方法,然后在这个方法内部去判断是否没有数据,如果没有就展示相应的空白页面。它其实没有调用method_exchangeImplementations方法,而是先将原来的方法的实现替换掉:

1
2
3
4
5
6
7
8
9
// Swizzle by injecting additional implementation
Method method = class_getInstanceMethod(baseClass, selector);
IMP dzn_newImplementation = method_setImplementation(method, (IMP)dzn_original_implementation);

// Store the new implementation in the lookup table
NSDictionary *swizzledInfo = @{DZNSwizzleInfoOwnerKey: baseClass,
DZNSwizzleInfoSelectorKey: NSStringFromSelector(selector),
DZNSwizzleInfoPointerKey: [NSValue valueWithPointer:dzn_newImplementation]};
[_impLookupTable setObject:swizzledInfo forKey:key];

这里在调用method_setImplementation是方法原来的实现(这也就决定了这个方法是不可重入的,因为再次调用,他就将返回之前注入的实现),它会将这个方法原来的实现的指针dzn_newImplementation以NSValue的形式存放到_impLookupTable这个字典中,在然后在新注入的方法执行完之后,以函数指针的形式调用原来的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void dzn_original_implementation(id self, SEL _cmd)
{
// Fetch original implementation from lookup table
Class baseClass = dzn_baseClassToSwizzleForTarget(self);
NSString *key = dzn_implementationKey(baseClass, _cmd);
NSDictionary *swizzleInfo = [_impLookupTable objectForKey:key];
NSValue *impValue = [swizzleInfo valueForKey:DZNSwizzleInfoPointerKey];

IMP impPointer = [impValue pointerValue];

// We then inject the additional implementation for reloading the empty dataset
// Doing it before calling the original implementation does update the 'isEmptyDataSetVisible' flag on time.
[self dzn_reloadEmptyDataSet];
// If found, call original implementation
if (impPointer) {
((void(*)(id,SEL))impPointer)(self,_cmd);
}
}

在if语句中就是利用((void(*)(id,SEL))impPointer)(self,_cmd);这个函数指针的形式调用回了原来的函数。从中可以看出,它其实也是利用字典来对这些不可重入的方法做以限制。

Aspects框架的实现

Aspects框架可以说是iOS中实现AOP的经典框架了。因为它要Hook住所有用户想要Hook住除了下面方法之外的所有方法:

1
2
3
4
retain
release
autorelease
forwardInvocation:

所以它采用了一种非常巧妙的方式:

  1. 调用class_replaceMethod替换掉原来需要被Hook的方法。
  2. 调用class_replaceMethod替换掉系统的- (void)forwardInvocation:(NSInvocation *)anInvocation方法。

因为我们在第一步替换掉了原来的方法,所以在runtime的时候系统会发现找不到原来的方法,这时系统会自动调用forwardInvocation这个消息转发的方法,因为它刚好被替换了,所以,无论你调用任何方法,到最后都会被Hook在forwardInvocation方法中,这也就解决了Hook住所有方法的目的,也正是Aspects框架的精巧所在,关于Aspects框架中Block的详细讲解请看在Aspects框架中Block的使用中的说明。

为什么不能直接利用Aspects框架?

问题在于我们的埋点最后传参的数组是NSArray,而不是NSMutableArray,这也就导致没有办法给它添加参数,为了说明白这一点,我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib.
NSArray *someArray = @[@"A",@"B",@"C"];
[self p_addPamas:someArray];
NSLog(@"someArray:%@",someArray);
}

- (void)p_addPamas:(NSArray*)params {
NSMutableArray *resultParams = [NSMutableArray arrayWithArray:params];
[resultParams addObject:@"E"];
[resultParams addObject:@"F"];
params = resultParams;
}

可能我们会采用这种方式来添加E,F来给NSArray添加两个元素,但是结果输出的却是:

1
2
3
4
5
someArray:(
A,
B,
C
)

这是为什么呢?因为我们p_addPamas:的入参是一个指针,对这个指针形参有如下的性质:

改变形参的数值本身不会对实参造成影响,然而如果我们想改变实参,那么可以改变形参指针所指的内容,而不是指针本身。

也就是说如果我们传入的pamas的数值是:0x600000244fb0,那么改变这个值是不能改变实参someArray的,除非我们改变了0x600000244fb0所指的内容,而此时这个NSArray又是不可变数组,所以不能往里面添加元素。试想下,如果是NSMutableArray,那么问题将会变得简单很多:

1
2
3
4
5
6
7
8
9
10
11
12
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib.
NSMutableArray *someArray = [NSMutableArray arrayWithArray:@[@"A",@"B",@"C"]];
[self p_addPamas:someArray];
NSLog(@"someArray:%@",someArray);
}

- (void)p_addPamas:(NSMutableArray*)params {
[params addObject:@"E"];
[params addObject:@"F"];
}

这时我们只需要改变指针所指的内容就可以了。输出的结果是:

1
2
3
4
5
6
7
someArray:(
A,
B,
C,
E,
F
)

所以直接使用Aspects框架不能给NSArray的形参中添加元素,因此需要自己写方法进行替换。

其它

如果在版本迭代中这种AB测的埋点很多,粒度很细怎么办?比如某个组件需要加AB测,可能涉及到两三个埋点。这时可以将将相应的埋点字段放到一个Array中,然后在注入的方法判断Array中是否有该埋点,如果有就添加版本,如果没有就不添加。

小结

在Objective-C中使用Method Swizzle实现AOP时一定要注意多线程的问题,同时要保证该方法的执行顺序(因为这个方法本身就是带有副作用的)否则就可能出现难以排查的bug。使用method_setImplementation和class_replaceMethod同样可以达到方法调换的效果。Aspects框架使用替换原方法和替换forwardInvocation:方法巧妙得达到了Hook所有方法的目的。最后,试图改变形参的指针本身是不起作用的,然而可以改变指针所指的内存空间。

缓冲区溢出实例解析

Posted on 2018-05-14

前言

缓冲区溢出攻击是黑客利用程序漏洞攻击系统的一种手段。1988年11月著名的Internet蠕虫病毒通过四种不同的方法获取对很多计算机的访问权限。其中有一种是对系统fingerd服务的缓冲区溢出攻击,通过一个特殊的字符串调用其FINGER函数,造成远程服务的缓冲区溢出并且执行一段异常代码,然后获得远程服务的执行权限,获得权限之后该蠕虫就会自我复制,从而消耗计算机资源,导致机器瘫痪。一直到今天,还有很多黑客在对有安全漏洞的系统进行缓冲区溢出攻击,所以理解缓冲区溢出的原因和掌握避免缓冲区溢出的技术是很有必要的。本文将从一个实际的例子出发,介绍缓冲区溢出的原因,以及一些避免缓冲区溢出的方法。

什么是缓冲区?

我们通常所说的缓冲区是指:在读取磁盘或者进行标准的IO操作时,为了解决输入输出设备和CPU之间的速度不匹配问题,通常会开辟一段内存空间来存储这些从输入设备读取的数据,这段在内存预留的存储空间叫做缓冲区。也就是说缓冲区就是一段存储输入数据的内存空间。缓冲区分为下面三类:

  1. 全缓冲
    在这种情况下,当填满标准I/O缓存后才进行实际I/O操作。全缓冲的典型代表是对磁盘文件的读写。
  2. 行缓冲
    在这种情况下,当在输入和输出中遇到换行符时,执行真正的I/O操作。这时,我们输入的字符先存放在缓冲区,等按下回车键换行时才进行实际的I/O操作。典型代表是标准输入(stdin)和标准输出(stdout)。
  3. 不带缓冲
    也就是不进行缓冲,标准出错情况stderr是典型代表,这使得出错信息可以直接尽快地显示出来。

本文所指的缓冲区是:在应用程序中使用到的一块用来存放用户输入数据的内存,这样的内存称作缓冲区。

缓冲区溢出攻击实例

我们用C语言来写一个简单的模拟用户登录的例子,在该例子中,让用户输入的字符和用户密码相比较,如果密码正确就输出Welcome!,如果输入的密码错误就输出Sorry, your password is wrong.:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//  main.c
#include <stdio.h>
#include <string.h>
int main(int argc, const char * argv[]) {

char passsword[8] = "secret", input[8];
while (1) {
printf("Enter your password:");
gets(input);
if (strcmp(input, passsword) == 0) {
printf("Welcome!\n");
break;
}else {
printf("Sorry,your password is wrong.\n");
}
}
return 0;
}

这是一个非常简单的例子。单纯从语言的层面上看没有什么错误,我们来编译执行这个程序会发生意想不到的事情:

缓冲区溢出输出

从操作中可以看到,第一次输入的内容是12345678ok,第二次输入的内容是ok,结果根据程序的判断,结果却是正确的。究竟问什么会出现这种现象呢?从C语言的表面,我们看不出任何的逻辑错误可以造成这种结果。我们再以一个简单的例子来说说函数调用的过程。

函数的调用过程

我们现在以一个简单的函数来说明函数的执行过程,这个函数是这样的:

1
2
3
4
5
void echo(){
char buf[8];
gets(buf);
puts(buf);
}

这个方法输入一个字符串再将这个字符串输出出来。为了将这个方法的详细执行过程我们利用gcc的编译指令将其生成汇编语言,以确定其究竟是怎样操作寄存器,来完成echo方法调用的。生成的汇编代码如下:

1
2
3
4
5
6
7
8
echo:
subq $24, %rsp
movq %rsp, %rdi
call gets
movq %rsp, %rdi
call puts
addq %24, %rsp
ret

其中:%rsp是栈指针寄存器,%rdi是第一个参数寄存器。然后我们来分析下这段程序。通过调用

1
subq  $24, %rsp

我们将栈指针减少了24个字节,也就是通常所说的压栈,因为我们的数组是char型的,buf的长度是8个字节(这就是我们所指的缓冲区),所以还有16个字节的空闲,因此在栈中内存分布是这样的:

echo的内存分布

为什么在调用gets和puts函数之前需要执行:

1
movq  %rsp, %rdi

这段代码呢?这里的意思是要将栈指针赋值给rdi寄存器,然后调用gets和pust函数的时候,这个函数就可以用rdi寄存器中取出栈指针,这也就完成了函数调用过程中的参数传递过程。

问题就出在这里,如果我们给定的数组长度大于8会怎样呢?会造成溢出,根据字符串的长度不同,造成的破坏性也不同:

输入的字符数量 破坏的状态
0~7 无
9~23 未被使用的栈空间
24~31 返回地址
32+ caller中保存的状态

从中我们可以看到如果输入的内容是小于23个字符长度的,那么造成的破坏相对较小,如果要是大于23个字符,那么程序的返回就可能不返回原来程序调用的地址了,可能就是黑客在输入的字符串中嵌入的可执行代码的字节编码,也就是攻击代码。

实例分析

在明白了缓冲区溢出的原因之后我们就来分析本文开头所举的例子。在这个例子中,我们的password在内存中占有八个字节,input数组也是八个字节,那么经过编译,运行以后内存是这样分布的:

7 6 5 4 3 2 1 0
- \0 t e r c e s
7 6 5 4 3 2 1 0

当我们输入12345678ok之后内存中的分布就会变成下面的样子:

7 6 5 4 3 2 1 0
\0 t e r \0 k o
7 6 5 4 3 2 1 0
8 7 6 5 4 3 2 1

为什么会在输入的ok后面加上”\0”呢?我们来看下C语言标注库给我们提供的gets函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
char *gets(char *s)
{
int c;
char *dest s;
while((c = getchar()) != '\n' && c != EOF)
*des ++ = c;

if(c == EOF && des == s)
return NULL;

*des ++= '\0'; //字符串结尾
return s;
}

从中我们可以看到该函数以从标准输入中读入一行,以换行符或者某个错误作为结束。然后将字符串复制到s指定的位置,同时在结尾加上’\0’,作为结束。这时问题就出现了。在C语言中’\0’是字符数组结束的标志,也就是说在第一次输入12345678ok之后,password数组变成了ok:缓冲区溢出覆盖掉了password原来的值,将其替换成了新的值。然后当用户再次输入ok的时候,比较输入的值和password中的值相同,于是就登录成功了。这样就利用缓冲区溢出就越过了登录的检测,成功进行了非法登录。

对抗缓冲区溢出的方法

从上面我们可以看到缓冲区溢出的原因就是C语言对数组越界没有做边界检查。如果我们对插入数组元素的长度做以限制就可以在一定程度上避免缓冲区溢出。通常所用的方法有以下几种:

  1. 对于C语言中不安全的函数我们要使用安全的函数来替代,用fgets()、strncpy()、strncat()来替代gets()、strcpy()、strcat()等不限制字符串长度,不检查数组越界的函数。实际上编译器在编译完代码之后就已经提示了一个警告warning, this program uses gets(), which is unsafe.,所以,我们应该重视编译器给我们的提示,这样往往能避免常见的错误。
  2. 在向一块内存中写入数据之前要确认这块内存是否可以写入,同时检查写入的数据是否超过这块内存的大小。
  3. 栈随机化法。也就是说让栈的位置在程序每次运行时都不一样,然后黑客将可执行代码插入内存之后就不容易找到指向该字符串的地址,也就不能执行插入的程序了。
  4. 栈破坏检测。也就是说在实际的缓冲区上面做个标记,保存这个标记,然后在函数返回之前检查这个标记,如果这个标记和函数调用之前不一样了,就说明在函数调用的过程中发生了溢出,这是就抛异常,让程序异常终止。
  5. 操作系统限制可执行代码的区域来阻止黑客代码的执行。

这里面1,2是业务开发人员需要注意的,3,4,5是操作系统或者编译,链接器的开发人员需要考虑的。

小结

C程序被编译链接之后,其各个变量在内存中的相对位置就已经确定了,然而C对数组越界等非法的内存访问并没有很好地限制,特别是其中某些不安全的函数调用,会引起缓冲区溢出,黑客可能会利用缓冲区溢出来破坏程序原来设想的执行逻辑,并且可能被黑客插入恶意代码来对系统进行攻击。我们可以通过良好的代码编程习惯,多加非法判断,多使用安全的库函数来在一定程度上避免缓冲区溢出攻击。

参考资料

深入理解计算机系统第三章
C语言程序设计
https://www.cnblogs.com/buyizhiyou/p/5505280.html

事件处理的背后--中断

Posted on 2018-05-05

前言

中断是计算机中的一种常用的机制,也是计算机中发展中里程碑式的一步,本文将以AT89S51单片机为例来讲解中断的产生和处理过程。下面以一个按键的处理程序为例来说明,在下面的例子中,我们要实现这样一个功能:当按下按钮S1时,我让红灯点亮100ms,然后让绿灯亮,如果没有按下S1,那么一直是绿灯亮。
单片机中断的例子

没有中断以前

如果没有中断,我们的做法应该是这样的:不停地扫描P3.3口,如果发现P3.3口是低电平,那么就让红灯亮100ms,然后熄灭红灯,让绿灯亮。在这个简单的功能里是没有什么问题的,但是如果单片机要处理的事情很多,而它为了处理S1按键这一个事情就被占据,不能脱身,对CPU来说就是非常浪费的,因为CPU的执行速度是非常之快的,而按键的按下和松开对CPU来说是非常慢的,也就是说再两次按键被按下的间隙,CPU可以执行非常多的指令,而这种轮询端口的方式大大降低了CPU的效率。

中断是什么?

在上面的例子中,我们看到,如果没有按钮事件到来,CPU的效率是非常低的。为了解放CPU,我们引入了中断的概念,那么究竟什么是中断?举个例子,下班回家我们想用微波炉做个鸡蛋羹,如果没有中断,我们就必须在微波炉旁边时刻等着,等到预计的时间到了,然后把微波炉关掉。在等待的过程中我们不能做其它的事情,因为超过了需要的时间,鸡蛋羹就会失去最佳味道。如果做鸡蛋羹需要5分钟的时间,那么我们(CPU)就傻等了五分钟时间,我们原本可以用这五分钟时间背几个单词或者看会儿书。我们知道CPU处理指令的速度非常之快,大约在10^9的数量级。等待的这五分钟时间原本可以用来处理很多指令。那么怎么办呢?这时我们可以给微波炉加一个定时的功能,有了这个功能,我们把打好的鸡蛋放到碗里,然后放到微波炉中就不用管了,这时我们可以去做其它的事情,在做事情的过程中,我们听到了微波炉发出了“嘀–嘀–嘀”的声音,我们知道是时间到了,我们关了微波炉,然后把做好的鸡蛋取出来。这个到了一定的条件发出“嘀–嘀–嘀”的声音就是产生了一个中断,我们去取出来鸡蛋就是对一个中断的处理。我们可以看到使用了中断可以提高CPU的效率,让它可以再外部设备进行某些操作的时候去做其它的事,而在触发中断时候再让CPU对中断进行相应的处理。

单片机的中断处理过程

通过上面的分析,我们知道中断可以提高CPU的执行效率,那么使用中断,我们可以怎样实现上图中所示实例的程序呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    ORG  00H ; 起始地址00H
; 主程序段,点亮绿色发光二极管D1

MAIN:
MOV IE, #84H ; 使能外部中断1
GREEN:
CLR P0.0 ; 点亮绿色发光二极管D1
JMP GREEN ; 循环
; 中断服务子程序,熄灭绿色发光二极管D1,点亮红色D2

ORG 13H ; 外部中断1的中断服务子程序起始地址为13H
EXT1_RED:
SETB P0.0 ; 熄灭绿色发光二极管D1
CLR P0.1 ; 点亮红色D2

D1:
MOV R4, #200; 延时100ms

D2: MOV R5, #248
DJNZ R5, $
DJNZ R4, D2
SETB P0.1 ;熄灭红色发光二极管D2
RET1 ;中断服务子程序结束

END ;程序结束

下面我们来详细分析下这段代码。ORG伪指令指明该段代码所在的内存地址是00,计算机执行到这里会将PC指针指向00处,这样就可以执行该段代码了。其中MOV IE, #84,将8:4赋值给IE寄存器,IE寄存器是一个中断使能寄存器,为什么要设置中断使能寄存器呢?在上图中我们可以看到P3.0–P3.5这六个端口后面都有个括号,括号中分别是(RXD,TXD,INT0,INT1,T0,T1),这说明这些端口要么作为普通的IO口,要么作为中断源。默认情况下,单片机上电以后是不会对任何中断产生响应的,相应的中断端口是做为普通的IO口使用的,只有使能了以后才可以接收中断。这六个端口对应五个中断源,RXD和TXD对应串行口中断,INT0,INT1,T0,T1分别对应于外部中断0,外部中断1,Timer0中断和Timer1中断。下面我们看下IE寄存器各个位的含义:
中断使能寄存器
从中我们可以看到IE的最高位是EA,它管理着所有的中断源,如果该为置零,那么所有的中断都不能响应了,其中第6位和第5位是保留位,第四位管理串行口中断,ET1管理Timer1中断,EX1管理外部中断1,第1位和第0位分别管理Timer0中断和外部中断0。我们写的84换算成十六进制刚好是10000100,对应于中断使能寄存器,也就是开启了外部中断1。而外部中断1的接口刚好和键盘S1的相连接。所以如果键盘被按下,就会触发单片机的一次中断。中断被使能以后,我们进入GREEN程序段,在这里面调用CLR P0.0;这条指令可以将P0.0口置成低电平,因为P0.0口上的绿色发光二极管的正极连接的是高电平,所以它会被点亮。然后调用JMP GREEN;指令一直循环。接下来就是中断服务子程序,也就是当中断发生的时候CPU需要执行的指令。ORG 13H;指明接下来的指令发到13H地址开始的指令处。为什么偏偏要是13H呢?因为单片机在设计生产出来的时候都有一个中断向量表,这个中断向量表就说明了每个中断如果发生那么CPU会去哪个地址处开始执行。AT89S51单片机的中断向量表如下:

AT89S51单片机的中断向量表
我们可以看到外部中断1的向量地址是0013H,也就是说CPU如果遇到了外部中断1就会去0013H地址处执行。我们可以看到每两个中断向量的地址相隔只有8位,所以通常情况下,从向量地址处开始的第一条指令是JMP 指令,让程序跳转到其它的地址处执行,不然会影响其后面中断向量的执行。因为我们这里只有一个中断,所以不存在相互影响。

在绿灯亮着的过程中,我们按下按键,这时就会触发一次中断,CPU找到13H开始的地址,然后取指,执行。执行EXT1_RED:,在这里先执行SETB P0.0;将P0.0口置为高电平,让绿灯熄灭,进而执行CLR P0.1;将P0.1口置为低电平然后点亮红色发光二极管。那么下面的延时程序段是怎么计算的呢?

1
2
3
4
5
6
7
8
D1:
MOV R4, #200; 延时100ms

D2: MOV R5, #248
DJNZ R5, $
DJNZ R4, D2
SETB P0.1 ;熄灭红色发光二极管D2
RET1 ;中断服务子程序结束

我们看上面单片机的电路,我们发现一个Y1,12MHz的元器件,这个器件就是晶振,一个机器周期等于晶振频率的倒数乘以12,所以一个机器周期的时间为:

$$
12 \times \frac{1}{12MHz} = 1 \mu s
$$

这个程序D1中先将200移动到寄存器R4中,D2中先将248移动到R5中,然后将R5减1,如果不等于0,则持续执行这条指令(DJNZ R5, $),所以:

1
2
MOV  R5,  #248
DJNZ R5, $

因为执行一个DJNZ指令需要花费两个时钟周期,所以内层循环总共的耗时是:

$$
1 + 2 \times 248 = 497 \mu s
$$

一条MOV指令花费一个时钟周期,所以加上外层循环总共的耗时时间是:

$$
200 \times (497 + 2) + 1 = 99801 \mu s \approx 100 ms
$$

到此为止一个没有操作系统的逻辑处理事件的流程已经结束了,那么操作系统,这个在硬件上面的第一层软件,它是怎样来管理中断的呢?

旁注

其实中断使能IE寄存器,在操作系统中也扮演者重要的角色,在进行临界区保护的时候,我们需要加锁,加锁可以有很多方式,比如用纯粹软件的方式:面包店算法。也可以使用硬件的方式,硬件的方式就是关闭中断,关闭中断之后,其它进程就不能进入操作系统内核了,不能进入操作系统内核,也就不会对其进行相应的调度,其它线程也就不能进入临界区了。通过这种硬件的方式,可以提高程序的执行速度,与此同时也可以减少软件方法书写的复杂性。在Linux中使用cli()和sti()的系统调用来关闭和打开中断。同时如果有了操作系统,那么响应的中断往往会和一个驱动相连接,比如:网卡的中断号为X,那么在中断处理程序中就会将中断号X和网卡驱动程序绑定起来。这样,如果遇到了中断X,就会调用响应的驱动程序执行其操作。

参考资料

《实例解读51单片机完全学习与应用》
操作系统:临界区保护

事件处理的背后--综述

Posted on 2018-05-05

前言

事件处理是每个计算机系统与外设进行交互都必须解决的问题,它存在于各种开发场景中,比如iOS开发中的RunLoop,Android开发中的Looper,前端开发中的EventLoop都是相似的事件处理库。因为偏向底层,同时又是开发中的关键概念,所以有很多人对它进行过阐述,但那些文章多是基于苹果的开发文档,或者开源的代码分析而来,很少有跳出这些具体的实现,站在整个计算机系统的层面来讨论的。最近在看CSAPP,回想学过的单片机知识,发现这些机制和中断之间存在某种联系,在和做嵌入式开发的同学交流之后更加坚定了我的判断。搜集整理了许多资料之后,说说我的个人理解。RunLoop,Looper或者EventLopp等等都是一种事件处理机制,这种事件处理机制是普遍存在的,我们使用的几乎所有应用程序,QQ,微信,Tomcat,Nginx等,它们之所以能在启动后一直运行,并且可以在单线程的情况下,异步去接收各种事件并处理,就因为有了这样一套机制。而事件可以产生于由处理器内(比如Timer)或者处理器外(比如点击屏幕),那么点击了触摸屏之后发生了什么?事件是怎样被处理器发现并处理的?操作系统怎么处理CPU发生的中断的?上层的应用程序是怎样获取这些中断事件的?应用程序获取了这些中断后怎样找到合适的处理者进行处理?。为了回答这些问题,我决定写一个系列的文章来探讨,本文将从不含操作系统的裸机–单片机中的键盘事件来介绍中断,然后介绍含有操作系统的中断处理,最后介绍事件处理机制,RunLoop和线程等内容,欢迎大家留言,讨论,指正。

综述

我们以触摸事件为例来谈谈事件的处理流程:

  1. 我们的手指点击屏幕,屏幕会产生高低电平
  2. 高低电平的连接线和微处理器的中断源相连接,这会产生一个中断
  3. 处理器内部有一个中断向量表,这个中断向量表会指向中断产生之后所执行代码的具体地址,然后CPU设置PC值跳转到相应的处理程序
  4. 操作系统收集这个中断放入一个event queue中(操作系统是面对硬件的第一层软件,所以它会提供一个中断的统一处理机制)
  5. 操作系统根据某中标标识找到处理事件的合适应用程序
  6. 操作系统利用IPC等机制将这个事件消息发送给想相应的应用程序(如果应用程序处理休眠状态,那么会将此程序唤醒)
  7. 应用程序会利用事件处理模型(EventLoop,RunLoop,Looper),通过系统调用从操作系统获取事件并处理
  8. 应用程序在收到事件之后会找到事件触发事件的源头和事件处理者。在iOS系统中会先传递给UIAppliction单例,然后再根据视图的层级关系利用hitTest:withEvent:和pointInside:withEvent:找到事件产生的视图
  9. 找到事件源视图之后就会根据Resoponse Chain来找到事件的处理者,对事件进行相应的处理

在接下来的系列文章里,将会从:中断,操作系统的中断处理,事件处理模型,RunLoop的实现原理等几方面做以论述。

Java多线程中的常见问题

Posted on 2018-02-02

long和double不具有顺序一致性

Java中的long和double无论在32位机器还是64位机器上都是8个字节,因为它需要JVM提供这种平台无关的抽象。那么,我们来看看为啥8个字节的读写操作在32位机器上不具有一致性。CPU和内存的通信是通过总线进行的,总线的宽度是固定的并且只有一条,如果有多个CPU同时请求总线进行读/写事务的话,会由总线仲裁(Bus Arbitration)进行裁决,获胜的CPU才能进行数据传递。并且如果某个CPU占用了总线,那么其它CPU要请求总线仲裁是要被拒的。这种机制就保证了CPU对内存的访问以串行的方式进行。如果有一个32位的处理器,但是要进行64位的写数据操作,这时CPU会将数据分为两个32位的写操作,并且这两个32位数据在请求总线仲裁的时候可能被分配到了不同的总线事务中,所以此时这个64位的写操作就不具有原子性了。这时如果处理器A写入了高32位,在写入低32位期间,处理器B对该数据进行了访问,那么就会产生错误的数据。(Java5之前读写都可以被拆分,Java5之后写操作可以被拆分,但是读操作必须是原子的)。如果要将变量前面加上volatile修饰符,那么对其操作将具有原子性,因为加上volatile之后,对其进行读写操作就像是“加锁”了一样。

ABA问题

乐观锁在实现的过程中利用到了CAS机制,这个机制是这样的:

有三个操作数:内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做或者使用新数值再次进行尝试。

这时就会出现一种问题:比如线程T1刚开始判断某个变量X,刚开始的数值是A,将要把它赋值为D。于此同时线程T2进来将X变为B,最后又变为A。那么线程T1在使用CAS判断的时候就会认为该数值没有变化(也就是说这个变化为B值的过程被忽略了),然后线程T1就把X的值赋成了D。在链表等场景下,这可能会引发问题。为了解决这个问题,从Java 1.5开始,JDK给我们提供了AtomicStampedReference,它的解决思路是这样的:给该类提供一个变量,每次数值变化的时候就将该变量加一,然后在利用CAS机制进行判断的时候不光判断X的值,还要判断该变量的值,这样就避免了该问题:

1
2
3
4
5
6
7
8
9
10
11
12
public boolean compareAndSet(V   expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}

双重锁定问题

在项目开发过程如果某个类很Expensive,我们希望创建一次,然后一直使用它,早期人们通常这样写:

1
2
3
4
5
6
7
8
private static ExpensiveObj instance;
public static ExpensiveObj getInstance() {

if (instance == null) { // 1
instance = new ExpensiveObj(); // 2
}
return instance;
}

显然这种方式非线程安全的,当线程A和线程B同时进入步骤1的时候,会发现instance没有被创建,这时它们同时创建instance。这时有人就提出了一种线程安全的写法:

1
2
3
4
5
6
7
private static ExpensiveObj instance;
public synchronized static ExpensiveObj getInstance() {
if (instance == null) {
instance = new ExpensiveObj();
}
return instance;
}

这种实现方法是线程安全的,但是因为使用synchronized加了锁,所以每次调用getInstance方法,不论instance已经被创建,都会加锁。这种频繁的加锁,解锁,会造成性能开销,因此有人提出了一种看似完美的解决方案:

1
2
3
4
5
6
7
8
9
10
11
private static ExpensiveObj instance;
public static ExpensiveObj getInstance() {
if (instance == null) {
synchronized (DoubleCheck2.class) {
if (instance == null) {
instance = new ExpensiveObj();
}
}
}
return instance;
}

在这个方案里,如果instance已经被创建,那么就不需要加锁了,直接返回。如果没有创建,那么再加锁创建对象,因为这个对象只被创建一次,所以这个锁只会使用一次。这个看似完美的解决方案,其实有一个致命的缺陷:因指令重排而造成未被初始化成功的对象逸出。我们先用伪代码来看下对象的创建过程:

1
2
3
memory = alloc(); //1: 开品内存空间
ctorInstance(memory); //2: 初始化对象
instance = memory; //3: 将instance指定为刚才分配的内存地址

但是在步骤2和步骤3之间可能会发生指令重排,从而造成如下的执行顺序:

1
2
3
memory = alloc(); //1: 开品内存空间
instance = memory; //3: 将instance指定为刚才分配的内存地址
ctorInstance(memory); //2: 初始化对象

这时是如果线程A正在创建对象,同时线程B调用了getInstance方法,那么这时instance != null。所以就会直接使用这个对象,然而此时线程A可能没有完成对象的初始化,所以线程B使用的就是没有被完全初始化的对象,所以要想解决这个双重锁定问题只需要避免指令重排即可,我们将instance声明为volatile就行了:

1
private volatile static Instance instance;

关于指令重排,volatile关键字可以查看我的另外一篇文章:JMM。其实解决这种某个类只被创建一次的问题,也可以使用static来实现:

1
2
3
4
5
6
private static class ExpensiveObjHolder {
public static ExpensiveObj instance = new ExpensiveObj();
}
public static ExpensiveObj getInstance() {
return ExpensiveObjHolder.instance; // 这里将导致ExpensiveObjHolder类被实例化
}

这里在调用getInstance方法的时候,JVM会执行类的初始化,此时JVM回去获取一把锁,这个锁可以保证其它线程在此时不会使用该对象,其实发生了指令重拍,其它对象也不知道,因为它必须等JVM创建完成之后才可以使用。

对线程安全的类操作不一定都是线程安全的

比如AtomicInteger是线程安全的,但是下面的操作就不是线程安全了。

1
2
3
4
5
AtomicInteger atomicInteger = new AtomicInteger(3);
if (atomicInteger.get() == 3) { // 1
atomicInteger.set(4); // 2
//...
}

因为在线程A执行步骤1的时候,线程B也可能进入这个判断中,那么此时线程B也会再执行一次2,如果这个方法内部还有一些其它需要互斥的操作,那么就可能造成线程不安全的一系列问题。也是就是说:

线程安全操作 + 线程安全操作 ≠ 线程安全操作

增加CPU不能持续程序效率

根据Amdahl定律,增加处理器之后效率提升的最大幅度为:

串行执行代码所占百分比的倒数。

关于Amdahl定律的详细解释请看我的另外一篇文章,增加处理器的个数和资源利用率的关系如下:

amdahl_principle

从中我们可以看出,串行部分占比越多增加CPU的个数越没有用。同时CPU的利用率下降的越快,也造成了越大的费用支出。

多线程不一定快

越多的线程就可能造成越多的上下文切换,上下文切换消耗的时间在0.1毫秒到1毫秒之间,这对CPU来说已经是巨大的损耗了,频繁的上下文切换所造成的性能损耗可能不如单线程运行的程序,同时多线程可能还涉及到加锁的需要,这就操作成了更大的性能损耗(因为某些锁的底层实现是需要进行相应的系统调用)

参考资料

  1. https://www.cnblogs.com/549294286/p/3766717.html
  2. 《Java并发编程的艺术》
  3. 《Java并发编程实战》

SDWebImage学习笔记(三)

Posted on 2018-01-17

内存缓存大小的限制

在设计缓存框架是的时候,如果内存我们使用的类是NSDictionary,或则NSArray这种通用类的话,如果内存占用率过高,导致系统RAM中少于12M内存(这个数值可能会随着系统版本和手机机型的不同而不同),那么系统的看门狗(watch dog)会将我们的App杀死。这时,我们要限制占用内存的大小。获取系统内存大小的方案如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#import <mach/mach.h>
#import <mach/mach_host.h>
static natural_t minFreeMemLeft = 1024*1024*12; // reserve 12MB RAM
// inspired by http://stackoverflow.com/questions/5012886/knowing-available-ram-on-an-ios-device
static natural_t get_free_memory(void)
{
mach_port_t host_port;
mach_msg_type_number_t host_size;
vm_size_t pagesize;

host_port = mach_host_self();
host_size = sizeof(vm_statistics_data_t) / sizeof(integer_t);
host_page_size(host_port, &pagesize);

vm_statistics_data_t vm_stat;

if (host_statistics(host_port, HOST_VM_INFO, (host_info_t)&vm_stat, &host_size) != KERN_SUCCESS)
{
NSLog(@"Failed to fetch vm statistics");
return 0;
}

/* Stats in bytes */
natural_t mem_free = vm_stat.free_count * pagesize;
return mem_free;
}

这里我们定义了最小的内存空间12M,然后在框架中,如果我们使用get_free_memory获取可用内存小于minFreeMemLeft,那么我们就移除内存缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)storeImage:(UIImage *)image imageData:(NSData *)data forKey:(NSString *)key toDisk:(BOOL)toDisk
{
if (!image || !key)
{
return;
}
if (get_free_memory() < minFreeMemLeft)
{
[memCache removeAllObjects];
}
[memCache setObject:image forKey:key];
//.....
}

如果既想避免占用内存过高而被Kill掉,同时也想避免在每个方法中都去判断当前内存可用空间的大小,那么使用NSCache替代上文中提到的NSDictionary或者NSArray来做内存缓存的类,因为NSCache在内存吃紧的情况下会自动清除部分缓存。

关于图片的Alpha通道

图片的Alpha通道会造成离屏渲染从而带来FPS的下降,所以没有特别必要的情况下应该尽量避免使用Alpha通道,如果从网络上下载下来的图片含有Alpha通道该怎样处理呢?我们可以在强制解码阶段来将Alpha通道去除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
+ (UIImage *)decodedImageWithImage:(UIImage *)image
{
CGImageRef imageRef = image.CGImage;
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
// 使用kCGImageAlphaNoneSkipLast,去除了Alpha通道
CGContextRef context = CGBitmapContextCreate(NULL,
CGImageGetWidth(imageRef),
CGImageGetHeight(imageRef),
8,
kCGImageAlphaNoneSkipLast | kCGBitmapByteOrder32Little);
CGColorSpaceRelease(colorSpace);
if (!context) return nil;

CGRect rect = (CGRect){CGPointZero,{CGImageGetWidth(imageRef), CGImageGetHeight(imageRef)}};
CGContextDrawImage(context, rect, imageRef);
CGImageRef decompressedImageRef = CGBitmapContextCreateImage(context);
CGContextRelease(context);

UIImage *decompressedImage = [[UIImage alloc] initWithCGImage:decompressedImageRef scale:image.scale orientation:image.imageOrientation];
CGImageRelease(decompressedImageRef);
return SDWIReturnAutoreleased(decompressedImage);
}

如果想要保存原图片的Alpha通道,那么可以先获取原图片的Alpha通道信息,然后在调用CGBitmapContextCreate的时候将Alpha信息传递过去:

1
2
3
4
5
6
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef);
CGContextRef context = CGBitmapContextCreate(NULL,
CGImageGetWidth(imageRef),
CGImageGetHeight(imageRef),
8,
alphaInfo | kCGBitmapByteOrder32Little);

解除Block中的循环引用

在使用Block的时候我们要小心Block和对象之间的相互持有不能释放的问题,比如下面的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// MKAnnotationView+WebCache.m
- (void)setImageWithURL:(NSURL *)url placeholderImage:(UIImage *)placeholder options:(SDWebImageOptions)options completed:(SDWebImageCompletedBlock)completedBlock
{
[self cancelCurrentImageLoad];
self.image = placeholder;
if (url)
{
__weak MKAnnotationView *wself = self;
id<SDWebImageOperation> operation = [SDWebImageManager.sharedManager downloadWithURL:url options:options progress:nil completed:^(UIImage *image, NSError *error, BOOL fromCache, BOOL finished)
{
__strong MKAnnotationView *sself = wself;
if (!sself) return;
if (image)
{
sself.image = image;
}
if (completedBlock && finished)
{
completedBlock(image, error, fromCache);
}
}];
objc_setAssociatedObject(self, &operationKey, operation, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
}

在这里我们创建了一个operation,并且将其关联给了一个MKAnnotationView对象,而这个operation中的Block又持有了这个MKAnnotationView对象,这就造成了循环引用导致两者都不能释放。这时最好的做法就是先在Block外层声明一个__weak的引用,然后再Block内部重新将其变为__strong,这样就既能保证只在Block执行的范围内强引用了MKAnnotationView对象,随着Block执行完毕,这个强引用就被销毁(它是局部变量,存储在栈上,执行完毕系统自动将其释放),同时又能保证在Block内部使用该对象的过程中,对象不被意外销毁。

下面再看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//SDWebImageDownloaderOperation.m
- (id)initWithRequest:(NSURLRequest *)request queue:(dispatch_queue_t)queue options:(SDWebImageDownloaderOptions)options progress:(void (^)(NSUInteger, long long))progressBlock completed:(void (^)(UIImage *, NSData *, NSError *, BOOL))completedBlock cancelled:(void (^)())cancelBlock
{
if ((self = [super init]))
{
_queue = queue;
_request = request;
_options = options;
_progressBlock = [progressBlock copy];
_completedBlock = [completedBlock copy];
_cancelBlock = [cancelBlock copy];
_executing = NO;
_finished = NO;
_expectedSize = 0;
}
return self;
}

这里在初始化的时候传入了一个completedBlock,那么在执行完毕,调用完completedBlock的时候,要注意调用self.completionBlock = nil;来消除循环引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (void)connectionDidFinishLoading:(NSURLConnection *)aConnection
{
//....
SDWebImageDownloaderCompletedBlock completionBlock = self.completedBlock;
if (completionBlock)
{
dispatch_async(self.queue, ^
{
UIImage *image = [UIImage decodedImageWithImage:SDScaledImageForPath(self.request.URL.absoluteString, self.imageData)];
dispatch_async(dispatch_get_main_queue(), ^
{
completionBlock(image, self.imageData, nil, YES);
self.completionBlock = nil;
[self done];
});
});
}
//...
}

这里将completionBlock置为nil是为了防止客户端在这个block内部使用了SDWebImageDownloaderOperation对象,从而造成循环引用,导致对象无法正常销毁。相关内容在《Effective Objective-C 2.0》书中第二十条有较为详细的描述。

获取内存和磁盘中图片大小的方式

获取磁盘中文件大小的方式:

1
2
3
4
5
6
7
8
9
10
11
12
-(int)getSize
{
int size = 0;
NSDirectoryEnumerator *fileEnumerator = [[NSFileManager defaultManager] enumeratorAtPath:diskCachePath];
for (NSString *fileName in fileEnumerator)
{
NSString *filePath = [diskCachePath stringByAppendingPathComponent:fileName];
NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:filePath error:nil];
size += [attrs fileSize];
}
return size;
}

获取磁盘中文件个数的方式:

1
2
3
4
5
6
7
8
9
10
- (int)getDiskCount
{
int count = 0;
NSDirectoryEnumerator *fileEnumerator = [[NSFileManager defaultManager] enumeratorAtPath:diskCachePath];
for (NSString *fileName in fileEnumerator)
{
count += 1;
}
return count;
}

获取内存中图片大小的方式:

1
2
3
4
5
6
7
8
9
10
- (int)getMemorySize
{
int size = 0;
for(id key in [memCache allKeys])
{
UIImage *img = [memCache valueForKey:key];
size += [UIImageJPEGRepresentation(img, 0) length];
};
return size;
}

参考资料:

https://www.jianshu.com/p/404e0ea5f6d7

12…6
击水湘江

击水湘江

努力让明天的自己爱上今天的自己!

56 posts
10 tags
© 2019 击水湘江
Powered by Hexo
|
Theme — NexT.Muse v6.0.3