一般来说,iOS开发者只要会GCD、@synchronized、NSLock等几个简单的API就可以搞定大部分的多线程开发,但是这个真的是多线程安全的,真的是充分利用了多线程的效率优势吗?看看下面这些容易被忽视的细节。读写器问题(Readers-writersproblem)先看读写器问题的描述:读写器有两个并发线程,共享同一份数据,当两个或多个读线程同时访问共享数据时时间,没有副作用,但是如果一个写线程和其他线程(读线程或写线程)同时访问共享数据,可能会导致数据不一致错误。因此,需要:允许多个读者同时对共享数据进行读操作;只允许一个写入者写入共享数据;在写入操作完成之前,任何写入器都不允许其他读者或写入器工作;在写者执行写操作之前,应该允许所有现有的读者和写者退出。从上面的描述我们可以知道,所谓的“读写器问题”是指同步问题,保证一个写线程必须访问与其他线程互斥的共享对象,允许并发读操作,但写操作必须与其他读写操作互斥。训斥。大多数客户端应用程序所做的无非是从网络中提取***数据,处理数据并显示列表。在这个过程中,有获取***数据后写入本地数据的操作,也有上层业务对本地数据的操作。因此读操作会涉及到大量的多线程读写操作。显然,这些基本上都属于读写问题的范畴[1]。不过笔者注意到,在遇到多线程读写问题时,大部分iOS开发者都会第一时间想到加锁,或者干脆避免使用多线程,但很少有人会尝试使用读写器问题的思路以进一步提高效率。下面是实现简单缓存的示例代码://实现简单缓存-(void)setCache:(id)cacheObjectforKey:(NSString*)key{if(key.length==0){return;}[_cacheLocklock];self.cacheDic[key]=cacheObject;...[_cacheLockunlock];}-(id)cacheForKey:(NSString*key){if(key.length==0){returnnil;}[_cacheLocklock];idcacheObject=self.cacheDic[key];...[_cacheLockunlock];returncacheObject;}上面的代码使用互斥量实现了多线程读写,实现了数据的安全读写,但是效率并不完美,因为这个这样的话,虽然写操作和其他操作是互斥的,但是读操作也是互斥的,会浪费cpu资源。如何改进?不难发现,这其实是一道典型的读写题。先看解决reader和writer问题的伪代码:semaphoreReaderWriterMutex=1;//实现读写互斥intRcount=0;//reader数量semaphoreCountMutex=1;//reader修改计数互斥writer(){while(true){P(ReaderWriterMutex);write;V(ReaderWriterMutex);}}reader(){while(true){P(CountMutex);if(Rcount==0)//当第一个读者进来时,blockthewriterP(ReaderWriterMutex);++Rcount;V(CountMutex);read;P(CountMutex);--Rcount;if(Rcount==0)V(ReaderWriterMutex);//当最后一个reader离开时,释放writerwriteorV(CountMutex);}}在iOS中,上面代码中的PV原语可以用GCD中的信号量API,dispatch_semaphore_t代替,但是需要维护一个readerCount和一个实现readerCount互斥访问的信号量,手动吧实现起来比较麻烦,很难封装成统一的接口。好在iOS开发中可以找到现成的读写锁:pthread_rwlock_t这是一个古老的C语言函数,用法如下://Protectingreadsection:pthread_rwlock_rdlock(&lock)//Readsharedresourcepthread_rwlock_unlock(&lock)//Protectingwritesection:pthread_rwlock_wrlock(&lock)//Writesharedresourcepthread_rwlock_unlock(&lock)//Cleanuppthread_rwlock_destroy(&lock)接口类型简单但不友好wlock的赋值用_tthread_会直接复制过来,一不小心就会浪费内存。另外,使用后需要记得销毁,容易出错。有没有更高级更易用的API?GCDbarrierdispatch_barrier_async/dispatch_barrier_sync并不是专门用来解决读写问题的,barrier主要用在以下场景:当执行某个任务A时,需要执行之前加入队列的所有操作,而后面添加的任务需要等待任务A执行完成后才能执行,从而隔离任务A。具体过程如下图所示:如果把barrier任务前后的并发任务换成读操作,而屏障任务本身被写操作代替,dispatch_barrier_async/dispatch_barrier_sync可以作为读写锁。将文章开头使用普通锁实现的缓存代码改写为dispatch_barrier_async进行对比://实现一个简单的缓存(使用普通锁)-(void)setCache:(id)cacheObjectforKey:(NSString*)key{if(key.length==0){return;}[_cacheLocklock];self.cacheDic[key]=cacheObject;...[_cacheLockunlock];}-(id)cacheForKey:(NSString*key){if(key.length==0){returnnil;}[_cacheLocklock];idcacheObject=self.cacheDic[key];...[_cacheLockunlock];returncacheObject;}//实现一个简单的缓存(使用readerWriter锁)staticdispatch_queue_tqueue=dispatch_queue_create("com.gfzq.testQueue",DISPATCH_QUEUE_CONCURRENT);-(void)setCache:(id)cacheObjectforKey:(NSString*)key{if(key.length==0){return;}dispatch_barrier_async(队列,^{self.cacheDic[key]=cacheObject;...});}-(id)cacheForKey:(NSString*key){if(key.length==0){returnnil;}__blockidcacheObject=nil;dispatch_sync(queue,^{cacheObject=self.cacheDic[key];...});returncacheObject;}这种方式实现的缓存可以并发执行读操作,同时有效隔离写操作,兼顾安全性和效率对于声明为原子并手动实现getter或setter的属性,也可以使用屏障来改进:@property(atomic,copy)NSString*someString;-(NSString*)someString{__blockNSString*tempString;dispatch_sync(_syncQueue,^{tempString=_someString;});returntempString;}-(void)setSomeString:(NSString*)someString{dispatch_barrier_async(_syncQueue,^{_someString=someString...}}原子的同时getter也可以并发执行,效率更高比起直接把setter和getter放在串行队列或者加普通锁,读写锁能提高多少效率?使用读写锁肯定比锁定所有读写和使用串行队列要快,但是能快多少呢??DmytroAnokhin在[3]中做了一个实验对比,分别测量了使用NSLock、GCDbarrier和pthread_rwlock时获取锁的平均时间,实验样本数在100到1000之间,去掉10%的***and***,结果如下图所示:3writers/10readers1writer/10readers5writers/5readers10writers/1reader分析表明:使用读写锁(GCDbarrier,pthread_rwlock),与单纯使用普通锁(NSLock),效率明显提升;读者越多,写者越少,使用读写锁的效率优势越明显;使用GCDbarrier和pthread_rwlock的效率差别不大。由于pthread_rwlock不好用且容易出错,而GCDbarrier和pthread_rwlock的性能不相上下,所以推荐使用GCDbarrier来解决iOS开发中遇到的读写问题。此外,使用GCD还有一个潜在的优势:GCD是面向队列而不是线程的。分派到某个队列的任务可以在任何线程上执行。这些对开发人员是透明的。这种设计的好处是显而易见的。GCD可根据实际情况而定。情境从自己管理的线程池中选择开销最小的线程来执行任务,尽量减少上下文切换的次数。在使用读写锁的时候需要注意的是,并不是所有的多线程读写场景都一定是读写锁的问题,使用的时候要注意区分。例如下面YYCache的代码://读cache-(id)objectForKey:(id)key{if(!key)returnnil;pthread_mutex_lock(&_lock);_YYLinkedMapNode*node=CFDictionaryGetValue(_lru->_dic,(__bridgeconstvoid*)(key));if(node){node->_time=CACurrentMediaTime();[_lrubringNodeToHead:node];}pthread_mutex_unlock(&_lock);returnnode?node->_value:nil;}//写cache-(void)setObject:(id)objectforKey:(id)keywithCost:(NSUInteger)cost{if(!key)return;if(!object){[selfremoveObjectForKey:key];return;}pthread_mutex_lock(&_lock);_YYLinkedMapNode*node=CFDictionaryGetValue(_lru->_dic,(__bridgeconstvoid*)(key));NSTimeIntervalnow=CACurrentMediaTime();if(node){_lru->_totalCost-=node->_cost;_lru->_totalCost+=cost;node->_cost=cost;node->_time=now;node->_value=object;[_lrubringNodeToHead:node];}else{node=[_YYLinkedMapNodenew];node->_cost=cost;node->_time=now;node->_key=key;node->_value=object;[_lruinsertNodeAtHead:node];}if(_lru->_totalCost>_costLimit){dispatch_async(_queue,^{[selftrimToCost:_costLimit];});}if(_lru->_totalCount>_countLimit){_YYLinkedMapNode*node=[_lruremoveTailNode];if(_lru->_releaseAsynchronously){dispatch_queue_tqueue=_lru->_releaseOnqueMainThread?dispatch_get_main():YYMemoryCacheGetReleaseQueue();dispatch_async(queue,^{[nodeclass];//holdandreleaseinqueue});}elseif(_lru->_releaseOnMainThread&&!pthread_main_np()){dispatch_async(dispatch_get_main_queue(),^{[nodeclass];//holdandreleaseinqueue});}}pthread_mutex_unlock(&_lock);}这里的缓存采用了LRU淘汰策略。每次读取缓存时,都会将缓存放在数据结构的最前面,从而延迟最近使用过的缓存被删除。淘汰的时机,因为每次读操作也和写操作同时发生,所以这里直接使用pthread_mutex互斥量,而不是读写锁。综上所述,如果遇到的多线程读写场景满足:有纯读操作(即读任务不同时包含写操作);读者多,写者少。都应该考虑使用读写锁来进一步提高并发率。注:(1)读写器问题包括“读者优先”和“写者优先”两种:前者是指读线程只要看到其他读线程正在访问,就可以继续读取和访问文件文件,而写线程必须等待所有的文件只有在读线程不访问它时才能写入,即使写线程可能比某些读线程更早申请;而writerpriority是写线程只需要申请,后面的读线程必须等待写线程完成。GCD的barrier是writer-first实现。详情请参考文献[2]。(2)串行队列上不需要使用GCD屏障,应该使用dispatch_queue_create创建的并发队列;由于dispatch_get_global_queue是全局共享队列,使用barrier无法达到隔离当前任务的效果,会自动降级为dispatch_sync/dispatch_async。[5]锁的粒度(Granularity)先看两段代码:代码段1@property(atomic,copy)NSString*atomicStr;//threadAatomicSr=@"amonthreadA";NSLog(@"%@",atomicStr);//threadBatomicSr=@"amonthreadB";NSLog(@"%@",atomicStr);代码段2-(void)synchronizedAMethod{@synchronized(self){...}}-(void)synchronizedBMethod{@synchronized(self){...}}-(void)synchronizedCMethod{@synchronized(self){...}}粒度太小无法执行代码段1,但是线程A打印的字符串可能是"amonthreadB",原因是虽然atomicStr是一个原子操作,但是atomicStr被取出后,atomicStr在NSLog执行之前仍然有可能被线程B修改。所以atomic声明的属性只能保证属性的get和set是完整的,而不能保证get和set之后对属性的操作是多线程安全的。这意味着aomic声明的属性可能无法保证线程安全的多个原因。同样的,不仅是atomic声明的属性,如果开发时自己加的锁太少,线程安全也得不到保障。代码段1实际上与下面的代码具有相同的效果:@property(nonatomic,strong)NSLock*lock;@property(nonatomic,copy)NSString*atomicStr;//线程A[_locklock];atomicSr=@"amonthreadA";[_lockunlock];NSLog(@"%@",atomicStr);//线程B[_locklock];atomicSr=@"amonthreadB";[_lockunlock];NSLog(@"%@",atomicStr);如果想让程序按照我们的初衷,设置atomicStr后,打印出来的值就是设置的值,就需要增加锁的范围,设置NSLog也包含在临界区://threadA[_locklock];atomicSr=@"amonthreadA";NSLog(@"%@",atomicStr);[_lockunlock];//threadB[_locklock];atomicSr=@"amonthreadB";NSLog(@"%@",atomicStr);[_lockunlock];示例代码很简单,很容易看出问题,但是在实际开发中遇到比较复杂的代码块时,可能会不小心踩到里面的坑。因此,在设计多线程代码时,要特别注意代码之间的逻辑关系。如果后续代码依赖于被加锁部分的代码,那么这些后续代码也应该加锁。@synchronized关键字会自动创建一个与传入对象关联的锁,在代码块开始时自动加锁,代码块结束后自动解锁。语法简单明了,使用起来非常方便,但也因此导致一些开发者过度依赖@synchronized关键字,滥用@synchronized(self)。如上面代码段2中所写,在整个class文件中,所有加锁的地方都使用了@synchronized(self),这可能会导致不相关的线程在执行时互相等待,这可能是并发执行的任务不得不执行串行地。另外,使用@synchronized(self)也可能造成死锁://classA@synchronized(self){[_sharedLocklock];NSLog(@"codeinclassA");[_sharedLockunlock];}//classB[_sharedLocklock];@synchronized(objectA){NSLog(@"codeinclassB");}[_sharedLockunlock];原因是self很可能被外部对象访问并作为key产生锁,类似于上面代码中的@synchronized(objectA)。两个公用锁交替使用的场景,容易出现死锁。所以正确的做法是传入一个类内部维护的NSObject对象,这个对象对外是不可见的[2]。因此,对于不相关的多线程代码,必须设置不同的锁,一把锁只覆盖一个临界区。另外,还有一个常见的错误做法会导致并发效率下降://threadA[_locklock];atomicSr=@"amonthreadA";NSLog(@"%@",atomicStr);//dosomeothertaskswhicharenoneofbusinesswithatomicStr;for(inti=0;i<100000;i++){sleep(5);}[_lockunlock];//threadB[_locklock];atomicSr=@"amonthreadB";NSLog(@"%@",atomicStr);//dosomeothertaskswitcharerenoneofbusinesswithatomicStr;对于(inti=0;i<100000;i++){sleep(5);}[_lockunlock];也就是说,临界区包含与当前锁定对象无关的任务。在实际应用中,我们需要特别注意临界区中任务的各个函数,因为其内部实现可能会调用耗时且无关的任务。递归锁(Recursivelock)相对于上面提到的@synchronized(self),以下情况导致的死锁更常见:@property(nonatomic,strong)NSLock*lock;_lock=[[NSLockalloc]init];-(void)synchronizedAMethod{[_locklock];//dosometasks[selfsynchronizedBMethod];[_lockunlock];}-(void)synchronizedAMethod{[_locklock];//dosometasks[_lockunlock];}A方法获取到锁后,调用B方法,会触发死锁,B方法只有等待A方法执行完毕释放锁后才能继续执行,而A方法执行完成的前提是B方法执行完毕。在实际开发中,可能发生死锁的情况往往隐藏在方法的层层调用中。因此,在不确定是否会发生死锁的情况下,推荐使用递归锁。更保守的做法是尽可能使用递归锁,因为很难保证以后的代码不会在同一个线程上多次加锁。递归锁允许同一个线程在释放自己拥有的锁之前重复加锁,这是通过一个计数器在内部实现的。除了NSRecursiveLock,也可以使用性能更佳的pthread_mutex_lock,初始化时参数设置为PTHREAD_MUTEX_RECURSIVE即可:pthread_mutexattr_tattr;pthread_mutexattr_init(&attr);pthread_mutexattr_settype(&attr,PTHREAD_MUTEX_RECURSIVE);pthread_mutex_init(&_lock,&attr);pthread_mutexattr_destroy(&attr);值得注意的是的,@synchronized也在内部使用递归锁://Beginsynchronizingon'obj'.//Allocatesrecursivemutexassociatedwith'obj'ifneeded.//ReturnsOBJC_SYNC_SUCCESSOncelockisacquired.intobjc_sync_enter(idobj){intresult=OBJC_SYNC_SUCCESS(dataobj){data(obj,ACQUIRE);assert(data);data->mutex.lock();}else{//@synchronized(nil)doesnothingif(DebugNilSync){_objc_inform("NILSYNCDEBUG:@synchronized(nil);setabreakpointonobjc_sync_niltodebug");}objc_sync_nil();}returnresult;}总结如果你想写出高效安全的多线程代码,仅仅熟悉GCD、@synchronized、NSLock的API是不够的。还需要多了解API背后的知识,深入理解临界区,理清各个任务的概念,理清各个任务之间的时序关系,这是必要的条件。
