美文网首页程序员
编写高质量iOS与OSX代码的52个有效方法-第六章:大中枢派发

编写高质量iOS与OSX代码的52个有效方法-第六章:大中枢派发

作者: 竹与豆 | 来源:发表于2018-07-25 15:36 被阅读9次

    41、多用派发队列,少用同步锁

    OC中,如果有多个线程执行同一份代码,有时可能会出问题。通常情况下,使用锁来实现某种同步机制。

    GCD之前有两种方法

    • 1、内置的同步块(synchronization block)
    - (void)synchronizeMethod {
        @synchronized(self) {
            //
        }
    }
    

    根据给定对象,自动创建一个锁,并等待块中农代码执行完毕。执行到折断代码结尾处,锁就释放了。

    优点:同步行为针对self,保证每个对象实例都能不受干扰地运行方法synchronizeMethod

    缺点:滥用会降低代码效率,共用同一个锁的那些同步块,都必须按顺序执行。若是self对象上频繁加锁,程序可能要等另一端无关的代码执行完毕,才能执行当前代码。

    • 2、NSLock/NSRecursiveLock
    _lock = [[NSLock alloc] init];
    
    - (void)synchronizeMethod {    
        [_lock lock];
        //
        [_lock unlock];
    }
    

    也可以使用NSRecursiveLock,线程能够多次持有该锁,不会出现死锁(deadlock)现象。

    两种方法都很好,也有缺陷。比方说,在极端情况下,同步块会导致死锁,另外效率也不见得很高,而如果直接使用锁对象的话,遇到死锁,就很麻烦。

    GCD实现

    • 1、串行同步队列(serial synchronization queue)

    将读取操作及写入操作都安排在同一个队列里,保证数据同步。

    _syncQueue = dispatch_queue_create("com.effectiveOC.syncQueue", NULL);
    
    - (NSString *)someString {
        __block NSString *localString;
        //为使块代码能够设置局部变量,使用__block语法。
        dispatch_sync(_syncQueue, ^{
            localString = self.someString;
        });
        return localString;
    }
    
    - (void)setSomeString:(NSString *)someString {
        dispatch_sync(_syncQueue, ^{
            self.someString = someString;
        });
    }
    

    把设置操作和获取操作都安排在序列化的队列里执行,这样的话,所有针对属性的访问操作都同步了。

    全部加锁任务都在GCD中处理。

    继续优化,设置方法并不一定非得同步,设置实例变量所用的块,并不需要向设置方法返回什么值。

    - (void)setSomeString:(NSString *)someString {
        dispatch_async(_syncQueue, ^{
            self.someString = someString;
        });
    }
    

    同步派发改成异步派发,可以提升设置方法的执行速度,而读取操作与写入操作依然会按顺序执行。

    执行异步派发时,需要拷贝块。如果拷贝所用的时间明显超过执行块所用的时间,则这种方法比原来慢。但是,若是派发给队列的块要执行更为繁重的任务,那么仍然可以考虑这种备选方案。

    并发队列(concurrent queue)

    多个获取方法可以并发执行,但获取方法与设置方法之间不能并发执行。

    栅栏(barrier),在队列中,栅栏必须单独执行,不能与其它块并行。下面方法可以像对立中派发块,将其作为栅栏使用。

    dispatch_barrier_async(dispatch_queue_t queue, dispatch_block_t block);
    
    dispatch_barrier_sync(dispatch_queue_t queue,
            DISPATCH_NOESCAPE dispatch_block_t block);
    
    

    只对并发队列有意义,因为串联队列中的块总是安顺序逐个执行的,并发队列如果发现接下来要处理的是栅栏块,那么就一直要等当前的所有并发块都执行完毕,才会单独执行这个栅栏块。待栅栏块执行过后,再按正常方式继续向下处理。

    例子中,可以用栅栏块来实现属性的设置方法,在设置方法中使用了栅栏块之后,对属性的读取操作依然可以并发执行,但是写入操作就必须单独执行了。

    _syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    
    - (NSString *)someString {
        __block NSString *localString;
        dispatch_sync(_syncQueue, ^{
            localString = self.someString;
        });
        return localString;
    }
    
    - (void)setSomeString:(NSString *)someString {
        dispatch_barrier_async(_syncQueue, ^{
            self.someString = someString;
        });
    }
    

    设置函数也可以改用同步的栅栏块(synchronous barrier)来实现。测试性能之后,选择最适合当前场景的方案。


    • 派发队列可用来表述同步语义(synchronization semantic),这种做法比使用@synchronized或NSLock对象更简单。
    • 将同步与异步派发结合起来,可以实现与普通加锁机制一样的同步行为,而这么做却不会阻塞执行异步派发的线程。
    • 使用同步队列栅栏,可以令同步队列更加高效。

    42、多用GCD,少用performSelector系列方法

    OC是一门非常动态的语言,NSObject定义了几个方法,开发者可以随意调用任意方法。

    performSelector系列方法

    - (id)performSelector:(SEL)aSelector;
    

    如果选择子是在运行期决定的,这种方式就很强大。

    SEL selector;
    if (index == 2) {
        selector = @selector(newObject);
    } else if (index == 1) {
        selector = @selector(copy);
    } else {
        selector = @selector(someProperty);
    }
    id ret = [objct performSelector:selector];
    
    

    有两个问题:

    • 1、ARC下可能会有内存泄露问题。编译器不知道将要调用的选择子是什么,不了解其方法签名及返回值,设置不知道是否有返回值。所以,ARC选择不添加释放操作,就可能导致内存泄露,因为方法可能在返回对象时已经将其保留了。
    • 2、返回值只能是void或对象类型。performSelector返回的类型是id,指向任意的OC对象指针。如果想返回一些整数或浮点数等类型的值,就需要执行一些复杂的转换,而这种转换容易出错。若返回值是C语言结构体,则不可使用performSelector方法。

    其他可传参数版本:

    - (id)performSelector:(SEL)aSelector withObject:(id)object;
    - (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;
    

    传参类型是id,另外选择子最多只能接受两个参数。

    可以延后执行选择子,或将其放在另一个线程执行。

    - (void)performSelector:(SEL)aSelector withObject:(nullable id)anArgument afterDelay:(NSTimeInterval)delay;
    
    - (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
    
    - (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait;
    

    这些方法太过局限。如具备延后执行的方法无法处理两个参数的选择子。能够指定执行线程的方法,也不能传多个参数。

    GCD实现相同功能

    performSelector系列方法所提供的线程功能,都可以通过在大中枢派发机制中使用块来实现,延后执行可使用dispatch_after来实现,另一个线程执行任务则可通过dispatch_sync及dispatch_async来实现。

    例如:

    dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0*NSEC_PER_SEC));
    dispatch_after(time, dispatch_get_main_queue(), ^{
        [self doSomethingElse];
    });    
    
    dispatch_async(dispatch_get_main_queue(), ^{
        [self doSomethingElse];
    });
    

    • performSelector系列方法在内存管理方面容易有疏失,他无法确定将要执行的选择子具体是什么,因而ARC编译器无法插入适当的内存管理方法。
    • performSelector系列方法所能处理的选择子泰国局限,选择子的返回值类型及发送给方法的参数个数都收到限制。
    • 如果想把任务放到另一个线程上执行,那么最好不要用performSelector系列方法,而是应该把任务封装到块里,然后调用大中枢派发机制的相关方法来实现。

    43、掌握GCD及操作队列的使用时机

    很少有其他技术能与GCD的同步机制相媲美,对于那些只需执行一次的代码来说,也是如此,使用GCD的dispatch_once最为方便。然而在执行后台任务时,GCD不一定是最佳方式。

    还有一种技术叫做NSOperationQueue,操作队列(operation queue)。它虽然与GCD不同,却与之相关,可以把操作以及NSOperation子类的形式放在队列中,而这些操作也能够并发执行。

    区别:GCD是纯C的API,而操作队列则是OC的对象。GCD中,任务用块来表示,而块是个轻量级数据结构,与之相反,操作时更为重量级的OC对象。

    优点:

    • 1、取消某个操作。使用操作队列,取消操作很容易。在运行任务之前,在NSOperation对象上调用cancel方法,设置对象内的标志位,表明此任务不需要执行。已经启动的任务无法取消。若是不通过操作队列,而是把块安排到GCD队列,就无法取消了。
    • 2、指定操作间的依赖关系。一个操作可以依赖其他多个操作。能够指定操作之间的依赖关系,使特定的操作必须在另一个操作顺利执行完毕后方可执行。
    • 3、通过键值观察机制监控NSOperation对象的属性。NSOperation许多属性都可以通过KVO来监听,如通过isCancelled属性判断任务是否已经取消,通过isFinished判断是否已经完成。如果想在某个任务变更状态时收到通知,或想要比用GCD更精细的方式控制所要执行的任务,KVO会很有用。
    • 4、指定操作优先级。操作优先级表示此操作与队列中其他操作之间的优先关系。优先级高限制性,优先级低后执行。GCD没有直接实现此功能的办法。GCD有优先级,不过是针对整个队列来说,而不是针对每个块来说的。 NSOperation对象也有线程优先级,决定运行此操作的线程处于何种优先级上。GCD可以实现此功能,采用操作队列更简单,只需设置一个属性。
    • 5、重用NSOperation对象。

    操作队列提供了多种执行任务的方式,而且都是写好的,直接就能使用。不需要编写复杂的调度器,也不用自己实现取消操作或者指定操作优先级的功能。

    NSNotificationCenter选用操作队列而非派发队列。可以通过其中的方法来注册监听器,一般发生相关事件时得到通知,这个方法接受的参数是块,不是选择子。

    - (id <NSObject>)addObserverForName:(nullable NSNotificationName)name
                                 object:(nullable id)obj
                                  queue:(nullable NSOperationQueue *)queue
                             usingBlock:(void (^)(NSNotification *note))block;
    

    尽可能使用高层API,只有在确有必要时才求助于底层。不过某些功能缺失可以使用高层OC的方法来做,但并不等于它就一定比底层实现方案好,具体看性能。


    • 在解决多线程与任务管理问题时,派发队列并非唯一方案。
    • 操作队列提供了一套高层OC API,能实现纯GCD所具备的绝大部分功能,而且还能完成一些更为复杂的操作,那些操作若改用GCD实现,需要另外编写代码。

    44、通过Dispatch Group机制,根据系统资源状况来执行任务

    dispatch group是GCD的一项特性,能够把任务分组。这个功能有很多用途,最重要、最值得注意的用法就是把将要执行的多个任务合为一个组,于是调用者就可以知道这些任务何时才能执行完毕。

    创建dispatch group

    dispatch_group_t dispatch_group_create(void);
    

    dispatch group就是个简单的数据结构,这种结构彼此之间没什么区别,它不像派发队列,后者还有个用来区分的标识符。

    • 把任务编组方法:
    void
    dispatch_group_async(dispatch_group_t group,
        dispatch_queue_t queue,
        dispatch_block_t block);
    

    就是普通的dispatch_async函数的辩题,比原来多一个参数,用于表示待执行的块所属的组。

    • 指定任务所属的dispatch group
    void
    dispatch_group_enter(dispatch_group_t group);
    //使分组中正要执行的任务数递增
    
    void
    dispatch_group_leave(dispatch_group_t group);
    //使分组中正要执行的任务数递减
    

    调用dispatch_group_enter必须有与之对应的dispatch_group_leave才行。与引用计数相似,要使用引用计数,必须令保留操作与释放操作彼此对应,以防内存泄漏。

    使用dispatch group如果调用enter之后,没有响应的leave操作,这一组任务就永远执行不完。多调用leave会崩溃。

    dispatch_group_t group = dispatch_group_create();
    
    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
        
    //添加任务1
    dispatch_group_enter(group);
        
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW,(int64_t)(3*NSEC_PER_SEC)), queue, ^{
        NSLog(@"11111");
        dispatch_group_leave(group);
    });
    
    //添加任务2
    dispatch_group_enter(group);
        
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW,(int64_t)(4*NSEC_PER_SEC)), queue, ^{
        NSLog(@"2222");
        dispatch_group_leave(group);
    });
        
    // 添加任务3
    dispatch_group_enter(group);
    NSLog(@"third");
    dispatch_group_leave(group);
    
    // 以不阻塞当前线程方式执行group
    dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        NSLog(@"执行完所有任务:%@",[NSThread currentThread]);
    });
    
    NSLog(@"方法结束");
    

    打印结果:

    third
    方法结束
    11111
    2222
    执行完所有任务:<NSThread: 0x60800006fb40>{number = 1, name = main}
    

    dispatch group执行函数

    • 1、dispatch_group_wait --阻塞所在线程
    long
    dispatch_group_wait(dispatch_group_t group, dispatch_time_t timeout);
    

    此函数接受两个参数,第一个是要等待的group,第二个是代表等待时间的timeout值。timeout表示函数等待dispatch group执行完毕时,应该阻塞多久。如果执行dispatch group所需的时间小于timeout,则返回0,否则返回非0值。此参数可以取常量DISPATCH_TIME_FOREVER,这表示函数会一直等着dispatch group执行完毕,不会超时。

    long num = dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, (3*NSEC_PER_SEC)));
    NSLog(@"%ld",num);
    
    • 2、dispatch_group_notify --不阻塞所在线程
    void
    dispatch_group_notify(dispatch_group_t group,
        dispatch_queue_t queue,
        dispatch_block_t block);
    

    这个方法可以向此函数传入块,等待dispatch group执行完毕之后,块会在特定的线程上执行。如果当前线程不应阻塞,又想在任务全部完成时得到通知,那么此做法就很有必要。第二个参数queue即是想要回调的线程。

    dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        NSLog(@"执行完所有任务:%@",[NSThread currentThread]);
    });
    

    示例

    创建两个级别线程队列,分别创建任务添加到group,最后并发执行。

    dispatch_group_t group = dispatch_group_create();
        
    // 创建优先级低的线程队列
    dispatch_queue_t lowPriorityQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
    // 创建优先级高的线程队列
    dispatch_queue_t highPriorityQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
        
    dispatch_group_enter(group);
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW,(int64_t)(2*NSEC_PER_SEC)), lowPriorityQueue, ^{
        NSLog(@">>>任务1-low<<<");
        dispatch_group_leave(group);
    });
        
    dispatch_group_enter(group);
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW,(int64_t)(2*NSEC_PER_SEC)), lowPriorityQueue, ^{
        NSLog(@">>>任务2-low<<<");
        dispatch_group_leave(group);
    });
        
    dispatch_group_enter(group);
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW,(int64_t)(2*NSEC_PER_SEC)), highPriorityQueue, ^{
        NSLog(@">>>任务3-high<<<");
        dispatch_group_leave(group);
    });
        
    dispatch_group_enter(group);
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW,(int64_t)(2*NSEC_PER_SEC)), highPriorityQueue, ^{
        NSLog(@">>>任务4-high<<<");
        dispatch_group_leave(group);
    });
    
    dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        NSLog(@"执行完所有任务:%@",[NSThread currentThread]);
    });
        
    NSLog(@"方法结束");
    

    最后中间打印的顺序是不固定的,原因是,虽然设置了线程的优先级别,但是这个顺序是由系统决定的,并不保证首先执行。同时,这里的任务提交到并发队列,优先级问题效果不明显。

    除了将任务提交到并发队列之外,还可以把任务提交到串行队列中。但是这种情况下,所有任务都排在同一个串行队列里,dispatch group用处就不大了。因为此时任务总要逐个执行,秩序在提交完玩不任务之后再提交一个块即可。所以未必总需要使用dispatch group,有时采用单个队列搭配标准的异步派发,也可以实现相同效果。

    GCD有并发队列机制,所以能够根据可用的系统资源状况来并发执行任务。通过dispatch group,既可以并发执行一系列给定的任务,又能在全部任务结束时得到通知。


    • 一系列任务可归入一个dispatch group中,开发者可以再这组任务执行完毕时获得通知。
    • 通过dispatch group,可以在并发式派发队列里同时执行多项任务。此时GCD会根据系统资源装快来调度这些并发任务。

    45、使用dispatch_once执行只需运行一次的线程安全代码

    dispatch_once()函数接受类型为dispatch_once_t的特殊参数(标记token),此外还接受块参数。对于给定的标记来说,该函数保证相关的块必须执行,切仅执行一次。首次调用该函数时,必要会执行块中的代码,最重要的一点在于,此操作完全是线程安全的。对于只执行一次的块来说,每次调用函数时传入的标记都必须完全相同。因此,开发者通常将标记变量声明在static或global作用域里。

    #import "ZYDUserManager.h"
    
    @implementation ZYDUserManager
    
    + (id)sharedInstance {
        static ZYDUserManager *sharedInstance = nil;
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            sharedInstance = [[ZYDUserManager alloc] init];
        });
        return sharedInstance;
    }
    @end
    

    使用dispatch_once可以简化代码并且彻底保证线程安全,无需但系加锁或同步。所有问题有GCD的底层实现。

    另外dispatch_once更高效,它没有使用重量级的同步机制,若是那样做的话,每次运行代码前都要获取锁,想法,此函数采用原子访问来查询标记,以判断其所对应的代码原来是否已经执行过。


    • 经常需要编写只需要执行一次的安全代码(thread-safe single-code execution)。通过GCD提供的dispatch_once函数,很容易实现此功能。
    • 标记应该声明在staticglobal作用域中,这样,在把只需要执行一次的块传递给dispatch_once函数时,传进去的标记也是相同的。

    46、不要使用dispatch_get_current_queue

    dispatch_queue_t dispatch_get_current_queue(void);此方法已经被弃用。


    • dispatch_get_current_queue函数的行为常常与开发者所预期的不同,此函数已经废弃,只应做调试之用。
    • 由于派发队列是按成绩来组织的,所以无法单用某个队列对象来描述“当前队列”这一概念。
    • dispatch_get_current_queue函数用于解决由不可重入的代码所引发的死锁,然而能用此函数解决的问题,通常也能改用队列特定数据来解决。

    相关文章

      网友评论

        本文标题:编写高质量iOS与OSX代码的52个有效方法-第六章:大中枢派发

        本文链接:https://www.haomeiwen.com/subject/nugpmftx.html