多线程与线程安全

1. 进程、线程、任务

进程(process),指的是一个正在运行中的可执行文件。每一个进程都拥有独立的虚拟内存空间和系统资源,包括端口权限等,且至少包含一个主线程和任意数量的辅助线程。另外,当一个进程的主线程退出时,这个进程就结束了;
线程(thread),指的是一个独立的代码执行路径,也就是说线程是代码执行路径的最小分支。在 iOS 中,线程的底层实现是基于 POSIX threads API 的,也就是我们常说的 pthreads ;
任务(task),指的是我们需要执行的工作,是一个抽象的概念,用通俗的话说,就是一段代码。

串行 vs. 并发

从本质上来说,串行和并发的主要区别在于允许同时执行的任务数量。串行,指的是一次只能执行一个任务,必须等一个任务执行完成后才能执行下一个任务;并发,则指的是允许多个任务同时执行。

同步 vs. 异步

同样的,同步和异步操作的主要区别在于是否等待操作执行完成,亦即是否阻塞当前任务。
同步操作会阻塞当前任务,等待操作执行完成后再继续执行接下来的代码,而异步操作则恰好相反,它会在调用后立即返回,不会等待操作的执行结果。

2. Operation Queues vs. Grand Central Dispatch (GCD)

简单来说,GCD 是苹果基于 C 语言开发的,一个用于多核编程的解决方案,主要用于优化应用程序以支持多核处理器以及其他对称多处理系统。而 Operation Queues 则是一个建立在 GCD 的基础之上的,面向对象的解决方案。它使用起来比 GCD 更加灵活,功能也更加强大。

Operation Queues :相对 GCD 来说,使用 Operation 和 Operation Queues 会增加一点点额外的开销,但是我们却换来了非常强大的灵活性和功能,我们可以给 operation 之间添加依赖关系、取消一个正在执行的 operation 、暂停和恢复 operation queue 、设置Operation优先级等;GCD也可以进行suspend/resume,但不能取消。NSOperation作为OC对象,还支持KVO,这也是GCD所没有的。

GCD :则是一种更轻量级的,以 FIFO 的顺序执行并发任务的方式,使用 GCD 时我们并不关心任务的调度情况,而让系统帮我们自动处理。但是 GCD 的短板也是非常明显的,比如我们想要给任务之间添加依赖关系、取消或者暂停一个正在执行的任务时就会变得非常棘手。

NSOperation,被添加到队列中后-(void)start方法会被调用,方法内会检查和设置op的状态,之后调用-(void)main方法。如果一个op不打算放入队列,也可以手动调用开始方法,但是对于已经在队列中的op,再手动调用开始方法是错误的。

GCD使用注意事项

2.1 dispatch_once_t必须是全局或static变量,非全局或非static的dispatch_once_t变量在使用时可能会导致非常不好排查的bug。

2.2 创建队列dispatch_queue_t dispatch_queue_create ( const char *label, dispatch_queue_attr_t attr );
第二个参数dispatch_queue_attr_t在网上教程中常用NULL,实际提供了更清晰、严谨的参数DISPATCH_QUEUE_SERIAL、DISPATCH_QUEUE_CONCURRENT

2.3 dispatch_after是延迟提交,而非延迟运行。需要延迟运行可以使用定时器。

2.4 dispatch_suspend并非立即停止队列的运行,而是在当前block任务执行完成后,暂停后续的block执行。

2.5 dispatch_apply会“等待”其所有的循环运行完毕才往下执行,也就是会阻塞外部的线程。嵌套使用也会造成死锁。

dispatch_queue_t queue = dispatch_queue_create("com.my.testQueue", DISPATCH_QUEUE_SERIAL);
dispatch_apply(5, queue, ^(size_t i) {
    NSLog(@"outter loop: %zu", i);
    // 此处造成死锁
    dispatch_apply(3, queue, ^(size_t j) {
        NSLog(@"inner loop: %zu", j);
    });
});

2.6 dispatchbarrier\(a)sync作用就是向某个队列插入一个block,当目前正在执行的block运行完成后,阻塞这个block后面添加的block,只运行这个block直到完成,然后再继续后续的任务。这个效果只在自己创建的并发队列上有效,使用其他队列效果则与dispatch_(a)sync一样。

2.7 dispatch_set_context可以为队列添加上下文数据,但是因为GCD是C语言接口形式的,所以其context参数类型是“void *”。如果参数用Objective-C的对象,但是要用__bridge等关键字转为Core Foundation对象,同时注意在dispatch_set_finalizer_f对应的函数中释放,避免内存泄露。

/*
__bridge:         只做了类型转换,不修改内存管理权;
__bridge_retained(即CFBridgingRetain)转换类型,同时将内存管理权从ARC中移除,后面需要使用CFRelease来释放对象;
__bridge_transfer(即CFBridgingRelease)将Core Foundation的对象转换为Objective-C的对象,同时将内存管理权交给ARC。
*/

void cleanStaff(void *context) {
    //这里用__bridge转换,不改变内存管理权
    Data *data = (__bridge Data *)(context);
    NSLog(@"In clean, context number: %d", data.number);
    //释放context的内存!
    CFRelease(context);
}
- (void)testBody {
    //创建队列
    dispatch_queue_t queue = dispatch_queue_create("me.tutuge.test.gcd", DISPATCH_QUEUE_SERIAL);
    //创建自定义Data类型context数据并初始化
    Data *myData = [Data new];
    myData.number = 10;
    //绑定context
    //这里用__bridge_retained转换,将context的内存管理权从ARC移除,交由我们自己手动释放!
    dispatch_set_context(queue, (__bridge_retained void *)(myData));
    //设置finalizer函数,用于在队列执行完成后释放对应context内存
    dispatch_set_finalizer_f(queue, cleanStaff);
    dispatch_async(queue, ^{
        //获取队列的context数据
        //这里用__bridge转换,不改变内存管理权
        Data *data = (__bridge Data *)(dispatch_get_context(queue));
        //打印
        NSLog(@"1: context number: %d", data.number);
        //修改context保存的数据
        data.number = 20;
    });
}

2.8 在当前队列中使用sync提交任务到当前队列,造成死锁。需要注意的是,队列和线程并非同一个概念,每个队列放的Block任务会在线程中执行,可能是主线程或子线程。

// 代码在ViewDidLoad方法,即主线程中
/*
 Calling 'dispatch_sync' function and targeting the current queue results in deadlock.
 1. dispatch_sync将block提交到main queue
 2. dispatch_sync在阻塞当前队列任务的执行,直到Block执行完成
 3. dispatch_sync死锁,因为要执行Block的队列被阻塞,Block无法完成
 */
dispatch_sync(dispatch_get_main_queue(), ^{
    NSLog(@"main");
});

// 不会死锁
/* As an optimization, this function invokes the block on the current thread when possible.
 1. dispatch_sync将block提交到自定义队列queue
 2. dispatch_sync阻塞当前队列任务的执行,直到Block执行完成
 3. queue在当前线程(主线程)执行Block,log输出主线程信息
 */
dispatch_queue_t queue = dispatch_queue_create("com.myQueue", NULL);
dispatch_sync(queue, ^{
    NSLog(@"queue:%@",[NSThread currentThread]); // log main thread info
});

上面自定义队列的例子,输出了主线程的信息,因此网上其他博客中常说到的dispatch_sync会阻塞当前线程的说法是错误的,毕竟queue中的任务在主线程执行了,因此应该是“阻塞了队列或者队列当前任务”更加准确。

2.9 GCD只能suspend和resume队列,并不能cancel。

3. 多线程安全

多线程的安全,一方面是防止一个进程在写时,另一个进程也在写入或者读取;另一方面是保证一个代码片段内对内存的连续多次访问结果一致。
线程安全是有粒度大小的,可能是一个model,可能是model中的一个数组,可能是一段代码或一个方法。
Apple的多线程编码文档: Threading Programming Guide。

3.1 @synchronized

@synchronized和其他互斥锁一样,它防止不同的线程在同一时间获取相同的锁,而且不需要使用代码直接创建互斥锁对象,而是直接使用OC对象作为一个lock token。@synchronized 隐式添加了异常处理代码,如果代码块中抛出异常会自动释放互斥锁。

@synchronized(obj) {
    // do work
}

// 上面代码实际上为,objc_sync_enter会创建一个与obj关联的互斥锁
@try {
    objc_sync_enter(obj);
    // do work
} @finally {
    objc_sync_exit(obj);    
}

如果传入的是nil对象,则会是一个空操作,失去了线程安全的功能,应该避免这种情况发生。
在SDWebImage中也有使用@synchronized实现线程安全的情况:

// SDWebImageDownloaderOperation
@synchronized(self) {...}

// SDWebImageManager.m
@synchronized(self.failedURLs) {...}
@synchronized(self.runningOpeations) {...}

3.2 atomic关键字

atomic的作用只是给getter和setter加了个锁,atomic只能保证代码进入getter或者setter函数内部时是安全的,一旦出了getter和setter,多线程安全只能靠程序员自己保障了。所以atomic属性和使用property的多线程安全并没什么直接的联系。另外,atomic由于加锁也会带来一些性能损耗,所以我们在编写iOS代码的时候,一般声明property为nonatomic,在需要做多线程安全的场景,自己去额外加锁做同步。

@property (atomic, strong) NSString* stringA;

//thread A
for (int i = 0; i < 100000; i ++) {
    if (i % 2 == 0) {
        self.stringA = @"a very long string";
    }
    else {
        self.stringA = @"string";
    }
    NSLog(@"Thread A: %@\n", self.stringA);
}

//thread B
for (int i = 0; i < 100000; i ++) {
    // getter 1
    if (self.stringA.length >= 10) {
        // getter 2
        NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)];
    }
    NSLog(@"Thread B: %@\n", self.stringA);
}

上面代码,在线程B中,getter2可能出现崩溃。原因是在getter1时,字符串长度大于10,而在getter2时字符串内容已经在线程A中被修改了,因此发生崩溃。
stringA属性是原子性的,它的set/get方法都是线程安全的,但是问题发生在set/get方法之外,两次对stringA内存区域的访问,内存内容已经发生了改变,因此需要加锁来保证这一段代码是原子性的。

//thread A
[_lock lock];
for (int i = 0; i < 100000; i ++) {
    if (i % 2 == 0) {
        self.stringA = @"a very long string";
    }
    else {
        self.stringA = @"string";
    }
    NSLog(@"Thread A: %@\n", self.stringA);
}
[_lock unlock];

//thread B
[_lock lock];
if (self.stringA.length >= 10) {
    NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)];
}
[_lock unlock];

NSLock 可以协调同一应用程序中多个线程。可以保护全局数据以原子方式访问或代码片段以原子方式运行。
调用NSLock的unlock方法时,必须确保和调用lock方法的线程为同一个,如果是在不同线程可能发生不可预期的效果。
不应该使用NSLock来实现递归锁,在同一个线程上调用lock两次,线程将永远被锁住。递归锁应该使用NSRecursiveLock。

OSSpinLock会出现优先级反转的情况,也会出现空转耗CPU的情况,不适用于较长时间的任务,而且在iOS 10和macOS10.12标记了Deprecated(没彻底移除)。引入了一个新的os_unfair_lock,也是忙等机制。

在使用NSThread、NSOperation等OC对象的过程中涉及到多线程安全,常常会使用NSLock、NSConditionLock、NSRecursiveLock、NSCondition等类。

3.3GCD线程安全

3.3.1 dispatch_semaphore信号量

dispatch_semaphore_t signal = dispatch_semaphore_create(1);
dispatch_time_t overTime = dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC);

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    dispatch_semaphore_wait(signal, overTime);
    NSLog(@"需要线程同步的操作1 开始");
    sleep(2);
    NSLog(@"需要线程同步的操作1 结束");
    dispatch_semaphore_signal(signal);
});

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    sleep(1);
    dispatch_semaphore_wait(signal, overTime);
    NSLog(@"需要线程同步的操作2");
    dispatch_semaphore_signal(signal);
});

3.3.2 dispatch_barrier_(a)sync + 自定义并发队列
以SDWebImage中的代码为例

// SDImageCache.m

_ioQueue = dispatch_queue_create("com.hackemist.SDWebImageCache", DISPATCH_QUEUE_SERIAL);

dispatch_sync(_ioQueue, ^{
    _fileManager = [NSFileManager new];
});

dispatch_async(self.ioQueue, ^{
    // 执行缓存图片的增删查等操作
});

3.3.3 dispatch_sync + 自定义串行队列

// SDWebImageDownloader.m
_barrierQueue = dispatch_queue_create("com.hackemist.SDWebImageDownloaderBarrierQueue", DISPATCH_QUEUE_CONCURRENT);

// 对self.URLOperations的增删
dispatch_barrier_async(self.barrierQueue, ^{
    SDWebImageDownloaderOperation *operation = self.URLOperations[token.url];
    BOOL canceled = [operation cancel:token.downloadOperationCancelToken];
    if (canceled) {
        [self.URLOperations removeObjectForKey:token.url];
    }
});



参考文章:

  1. GCD使用经验与技巧浅谈
  2. 为GCD队列绑定NSObject类型上下文数据
  3. iOS多线程到底不安全在哪里?
  4. iOS 并发编程之 Operation Queues

你可能感兴趣的:(多线程与线程安全)