浅谈一种解决多线程野指针的新思路

无论是xx还是xx,对于整个App的稳定性要求都非常之高。因此,那些前辈大牛们为了解决一些常见的问题,比如空指针、数组越界等等,开发了xxxxxx这样的底层SDK,用于解决问题。

但是随着业务逐渐的复杂化以及愈发严格的性能要求,xxApp绝大多数的Crash开始往野指针方面靠拢。这些野指针的问题,除了一些iOS7上delegate是assign声明导致的历史遗留问题以外,绝大多数都是多线程的赋值导致的野指针问题。

而这些多线程的野指针问题,至今仍未有一个比较好的统一解决方案。因此,今天就想稍微聊下我自身研究的一个方案。

什么是多线程的野指针问题

之前在《浅谈多线程编程误区》一文中,曾经举过如下这样的多线程setter例子:

for (int i = 0; i < 10000; i++) {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        self.data = [[NSMutableData alloc] init];
    });
}

如果这个self.data是个nonatomic的属性的话,就会造成在多次释放导致的野指针问题。(具体可以见《浅谈多线程编程误区》的原理解释)。

从原理解释中不难发现,本质上会产生野指针的场景是由于我们没有对临界区进行保护。导致赋值替换的操作不是原子性的。

有些人会说,例子中你刻意构建了一万个线程才会导致Crash。而我们平时就用用几个线程,不会有问题的。
理论上一万个线程只不过是把两个线程中可能出现问题的概率放大了而已。在一万个线程中会出现的多线程野指针问题在两个线程中一定也会发生。

传统业界方案:赋值加锁

既然原子性是导致野指针的罪魁祸首,那么我们只要在对应可能产生冲突的临界区内加锁就好了,比如:

[lock lock];
self.data = [[NSMutableData alloc] init];
[lock unlock]

按照这样的做法,同一时间不管有多少线程试图对self.data进行赋值,最终都只有一个线程能够抢到锁对其赋值。

但是这样的做法从安全性角度来说是解决了原子赋值的问题。但是这样的做法却对开发要求比较严格,因为任意非基础类型的对象(Int, Bool)都有可能产生多线程赋值的野指针,所以开发需要牢记自身的属性变量究竟有哪些会在多线程场景中被使用到。

而且,这样的方案还有一个非常大的不确定性!

当你开发了一个底层SDK,对外暴露了一些公共的readwrite的Property。别人对你的property赋值的时候,你怎么确定他们一定会做到线程安全?

我的方案:runtime追踪对象初始化的GCD Queue

我们都知道,在Objective-C中,对于一个property的赋值最终都会转化成对于ivar的setter方法。所以,如果我们能确保setter方法的线程安全性,就能确保多线程赋值不会产生野指针。

好,按照这个思路进行操作的话,我们大致需要如下几个步骤:

  1. 获取第一次setter调用的时机及对应的线程。
  2. 将这个线程记录下来。
  3. 后续调用setter的时候,判断当前setter调用的线程是不是我们之前记录的线程,如果是,直接赋值。如果不是,派发到对应的线程进行调用。
  4. 获取所有的setter,重复实现上述步骤。

看起来思路很简单,具体实现起来却有一定的难度,容我由浅入深慢慢道来:

1. 获取第一次赋值的线程并记录

由于我们不能通过成员变量就记录每个ivar对应的setter的初始化线程(这样setter的个数就无限增长了),因此本质上我们只有通过局部静态变量的方式来作为存储。同时由于我们只需要在初次执行时进行记录,所以很理所当然就想到了dispatch_once

具体代码如下:

static dispatch_queue_t initQueue;
static void* initQueueKey;
static void* initQueueContext;

static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{

    // 1. 主队列
    if ([UIApplication isMainQueue]) {
        initQueue = dispatch_get_main_queue();
        initQueueKey = [UIApplication mainQueueKey];
        initQueueContext = [UIApplication mainQueueContext];
    } else {
        // 2. 非主队列
        const char *label = [NSStringFromSelector(_cmd) UTF8String];
        initQueueKey = &initQueueKey;
        initQueueContext = &initQueueContext;
        initQueue = dispatch_queue_create(label, nil);
        dispatch_queue_set_specific(initQueue, initQueueKey, initQueueContext, nil);
    }
});

从代码中不难发现,由于主队列是全局共用的,所以如果这次setter的赋值是在主队列进行的,那么就直接复用主队列即可;而如果当前的队列我们自身都不确定的话,那么就干脆开辟一个串行的队列用语这个setter的后续赋值,并将其记录下来。

细心的读者可能会发现,我们标题里写的是线程,但是在代码中记录的却是GCD的队列(Queue)。而且,我们判断的是主队列而不是主线程。这是为什么呢?

嘿嘿,容我卖个关子,文章最后会有详细的阐述。

2. 判断后续赋值是否是记录的线程

由于我们之前记录的是队列,所以我们是无法直接使用诸如如下代码的方式进行是否是同一个线程的判断

[NSThread currentThread] == xxxThread

在iOS7之前,苹果提供了dispatch_get_current_queue()用于获取当前正在执行的队列,如果有这个方法,我们就可以很容易判断这个队列和我们记录的队列是否是同一个了。但是很不幸的是,该方法已经被从GCD的Public API中移除了,一时间研究陷入了僵局。

不过好在libdispatch是开源的,经过一段时间的摸索,我发现了这个方法dispatch_get_specific,其自身实现如下:

DISPATCH_NOINLINE
void *
dispatch_get_specific(const void *key)
{
    if (slowpath(!key)) {
        return NULL;
    }
    void *ctxt = NULL;
    // 1. 获取当前线程的执行队列
    dispatch_queue_t dq = _dispatch_queue_get_current();

    while (slowpath(dq)) {
        // 2. 如果进行过标记
        if (slowpath(dq->dq_specific_q)) {
            ctxt = (void *)key;
            dispatch_sync_f(dq->dq_specific_q, &ctxt,
                    _dispatch_queue_get_specific);
            if (ctxt) break;
        }
        // 3. 向上传递至target Queue
        dq = dq->do_targetq;
    }
    return ctxt;
}

通过上述代码不难理解,系统会自动获取当前线程正在执行的队列的。如果进行该队列进行过标记,就根据我们传入的key去获取key对应的value(ctxt)。如果查询到了,就返回。否则按照目标队列层层上查,直至root_queue也没找到为止。(关于libdispatch的具体原理,我下周还会专门写篇细细分析的文章)。

通过这个方法,我们可以在直接记录初始化队列的时候对其进行特殊的标定:

dispatch_queue_set_specific(initQueue, initQueueKey, initQueueContext, nil);

随后在后续setter执行的时候通过如下代码进行判断并进行相应的直接赋值或者队列重新派发:

// 如果是当前队列
if (dispatch_get_specific(initQueueKey) == initQueueContext) {
    _threadSafeArray = threadSafeArray;
} else {
     // 不是当前队列
    dispatch_sync(initQueue, ^{
        _threadSafeArray = threadSafeArray;
    });
}

3. 遍历所有的setter,重复上述过程

由于我们的目的是减轻其他开发的负担,所以不得不借助了runtime的Method Swizzling技术。但是传统的Method Swizzling技术是将函数实现两两交换。如果按照这个思路,我们就需要为每一个setter编写一个对应的hook_setter,这工作量无疑太巨大了。

所以,在这里我们需要的一个中心重定向的过程:即,将所有的setter都转移到一个hook_proxy中。代码如下:

- (void)hookAllPropertiesSetter
{
    unsigned int outCount;
    objc_property_t *properties = class_copyPropertyList([self class], &outCount);

    NSMutableArray *readWriteProperties = [[NSMutableArray alloc] initWithCapacity:outCount];
    for (unsigned int i = 0; i < outCount; i++) {
        objc_property_t property = properties[i];
        NSString *propertyName = [[NSString alloc] initWithCString:property_getName(property) encoding:NSUTF8StringEncoding];

        unsigned int attrCount;
        objc_property_attribute_t *attrs = property_copyAttributeList(property, &attrCount);

        // !!!!!!!!!!!!!!!!!!特别注意!!!!!!!!!!!!!!!!!!
        // !!!!!!!!!!!!!!!!!!特别注意!!!!!!!!!!!!!!!!!!
        BOOL isReadOnlyProperty = NO;
        for (unsigned int j = 0; j < attrCount; j++) {
            if (attrs[j].name[0] == 'R') {
                isReadOnlyProperty = YES;
                break;
            }
        }
        free(attrs);

        if (!isReadOnlyProperty) {
            [readWriteProperties addObject:propertyName];
        }
    }
    free(properties);

    for (NSString *propertyName in readWriteProperties) {

        NSString *setterName = [NSString stringWithFormat:@"set%@%@:", [propertyName substringToIndex:1].uppercaseString, [propertyName substringFromIndex:1]];

        // !!!!!!!!!!!!!!!!!!特别注意!!!!!!!!!!!!!!!!!!
        // !!!!!!!!!!!!!!!!!!特别注意!!!!!!!!!!!!!!!!!!
        NSString *hookSetterName = [NSString stringWithFormat:@"hook_set%@:", propertyName];

        SEL originSetter = NSSelectorFromString(setterName);
        SEL newSetter = NSSelectorFromString(hookSetterName);

        swizzleMethod([self class], originSetter, newSetter);
    }
}

在这里有两点需要注意的地方:

  1. readonly的property是不具备setter功能的,所以将其过滤。
  2. 将每个setter,比如setThreadSafeArrayswizzle成了hook__setThreadSafeArray。即为每一个setter都定制了一个对应的hook_setter。

哎,有人会问,你刚刚不才说为每一个setter编写对应的hook_setter是费时费力的吗?怎么自己打自己脸啊?

别急,容我慢慢道来。

在Method Swizzling的时候,我们需要调用class_getInstanceMethod来进行对应方法名的函数查找。整个过程简述如下:

method cache list -> method list -> 动态方法决议 -> 方法转交 (forward Invocation)

其中,在动态方法决议这步,如果我们添加了之前的没找到的方法,那么整个查找过程又会重新开始一遍。

由于那些hook_setter是压根不会存在于method list中的,所以在查找这些函数的时候,一定会走到动态决议这一步。

基于此,我实现了如下的动态决议函数:

+ (BOOL)resolveInstanceMethod:(SEL)sel
{
    NSString *selName = NSStringFromSelector(sel);

    if ([selName hasPrefix:@"hook_"]) {
        Method proxyMethod = class_getInstanceMethod([self class], @selector(hook_proxy:));
        class_addMethod([self class], sel, method_getImplementation(proxyMethod), method_getTypeEncoding(proxyMethod));
        return YES;
    }

    return [super resolveInstanceMethod:sel];
}

从代码中很容易发现,如果是之前那么hook_setter的函数名,我就讲这些方法的函数实现全部重定向到函数hook__proxy上。

4. 寻找上下文

在传统的Method Swizzling技术中,由于我们是两两交换,因此我们不需要上下文这一个步骤,直接调用hook_setter就可以重新返回对应的原setter方法。

可是在本文的实现中,由于我们将所有的setter都重定向到了hook__proxy中,所以我们需要在hook_proxy中寻找究竟是给哪个property赋值。

如果对Method Swizzling的理解只停留在表面,是很难想到后续步骤的。

Method Swizzling的原理是只是交换IMP,即函数实现。而我们在Objective-C的函数调用统统是通过objc_msgSend结合函数的Selector(可以简单理解为函数名)来找到真正的函数实现。

因此,swizzle后的Selector没变,变的是IMP。

有了这个理解,我们就可以在hook_proxy使用__cmd这个隐藏变量,它会指引我们究竟是哪个Setter当前正在被调用,具体代码如下:

- (void)hook_proxy:(NSObject *)proxyObject
{
    // 只是实现被换了,但是selector还是没变
    NSString *originSelector = NSStringFromSelector(_cmd);
    NSString *propertyName = [[originSelector stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@":"]] stringByReplacingOccurrencesOfString:@"set" withString:@""];
    if (propertyName.length <= 0) return;

    NSString *ivarName = [NSString stringWithFormat:@"_%@%@", [propertyName substringToIndex:1].lowercaseString, [propertyName substringFromIndex:1]];

    //NSLog(@"hook_proxy is %@ for property %@", proxyObject, propertyName);

    重复之前步骤即可。
}

5. 其他拓展

本文中只是探索了下没有重载setter的那些ivar,因此只需要简单对ivar进行赋值即可。
如果你碰到了大量自定义setter的ivar,那么也一样很简单,你只需要维护一个ivar 到对应自定义的setter的imp映射,在hook_proxy将setValue:ForKey:替换成直接的IMP调用即可。

一些额外细节

    1. 线程和GCD Queue并不是一一对应的关系。

前面提到了,我们要记录的是队列而不是线程。相信很多人可能一开始都不能理解,那么我用如下这样的代码进行解释:

if ([NSThread isMainThread]) {
    [self doSomeThing];
} else {
    dispatch_sync(dispatch_get_main_queue(), ^{
        [self doSomething];
    });
}

上述代码想必大家非常熟悉,就是全包在主线程执行一些操作,比如UI操作等等。但是事实上,这里有个误区:

主队列一定在主线程执行,而主线程不一定只执行主队列。

换句话说:上述代码的if 和 else是不等价的。

有时候,主线程有可能会被调度到执行其他队列(其他线程亦是如此),比如如下代码:

// 在主线程创建
dispatch\_queue\_t dq = dispatch\_queue\_create('com.mingyi.dashuaibi', NULL);
dispatch_sync(dq, ^{
    NSLog(@"current thread is %@", [NSThread currentThread]);
});

具体效果,大家可以自己尝试下,看看Log输出的结果是不是主线程。

    1. 为什么不能直接将所有的setter直接hook到hook_proxy,非要通过动态决议来进行。

我们举个简单的例子,假设我们有两个property,分别叫A和B。那么在执行下述代码的时候:

for (int i = 0; i < 2; i++) {
     SEL originSetter = NSSelectorFromString(setterName);
     SEL newSetter = NSSelectorFromString(hook_proxy);
     swizzleMethod([self class], originSetter, newSetter);
}

第一次交换的时候,Setter A的 IMP和 hook_proxy的 IMP进行了交换,这一步没问题。
第二次交换的时候,Setter B的 IMP和 hook_proxy的 IMP进行了交换,而此时hook_proxy的IMP已经指向了Setter A的IMP,因此导致的结果就是交换错乱了,调用setter B实质上是调用了setter A。