多线程与线程安全

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

推荐阅读更多精彩内容