美文网首页面试
iOS多线程编程(七) 同步机制与锁

iOS多线程编程(七) 同步机制与锁

作者: 卖馍工程师 | 来源:发表于2021-02-28 14:06 被阅读0次

    多线程系列篇章计划内容:
    iOS多线程编程(一) 多线程基础
    iOS多线程编程(二) Pthread
    iOS多线程编程(三) NSThread
    iOS多线程编程(四) GCD
    iOS多线程编程(五) GCD的底层原理
    iOS多线程编程(六) NSOperation
    iOS多线程编程(七) 同步机制与锁
    iOS多线程编程(八) RunLoop

    前言

    多线程编程在应用程序开发中扮演着十分重要的角色,使用多线程编程最显著的优势表现在:

    • 多线程可以提高应用程序的响应能力。
    • 多线程可以提高多核系统上应用程序的实时性能。

    多线程编程使得在单个应用程序内并发执行多个代码路径成为可能,随着多核计算机的普及,执行不同任务的线程可以在不同的处理器内核上同时执行,这使得应用程序可以在给定的时间内增加它所完成的工作量。我们可以利用多线程快速且高效地执行任务。

    然而,多线程编程在带来益处的同时,也给我们带来了不小的挑战;

    在应用程序中拥有多个执行路径会给代码增加相当大的复杂性。不得不考虑的就是多线程之间的资源争用导致的线程安全问题。

    要解决多线程安全问题,不妨先了解多线程不安全的原因。

    一、多线程不安全的原因

    在计算机中,CPU 是计算机系统的运算核心控制核心,每个CPU都有自己的 寄存器,用来暂存数据指令位址。寄存器拥有与CPU相当的读写速度,但数目有限。计算机做运算时,必须将数据从 主存(RAM) 读入寄存器。由于CPU的速度远高于主存,CPU直接从主存中存取数据要等待一定时间周期,这严重影响了CPU的执行效率。为了解决这一问题,CPU厂商设计了 高速缓冲存储器(Cache),简称缓存或高速缓存。高速缓存是主存的部分拷贝。当CPU需要从主存中读取数据时,会将该数据的副本写入高速缓存,当CPU再次使用该数据时可从高速缓存中直接读取,这样就减少了CPU的等待时间,提高了系统的效率。

    CPU加上高速缓存解决了处理器和主存的矛盾(一快一慢),同时也带来了新的问题: 缓存一致性或称缓存可见性

    在多核CPU中,每个处理器都有各自的高速缓存,而主存确只有一个。被不同CPU调度的线程只能操作各自工作区的副本数据,线程之间不能直接进行数据交互,线程只能与主存进行数据交互。待线程结束工作后(不可预期),更新共享数据到主存,另一线程从主存重新加载共享数据,从而达到两个线程数据共享的目的。在并发情况下,如果两个线程同时操作同一份共享数据时,很可能会出现A线程要读取的数据已被B线程改变,而A线程并不知晓,操作的还是旧数据。进而导致数据安全问题或其他危害程序运行的行为。问题的本质在于缓存之间的信息不同步,A线程无法及时地获知B线程对数据的更改。

    这是多线程编程不安全的原因之一:线程不可见

    另外,代码编写顺序代码实际执行的顺序不一定是相同的。为了提高程序整体的执行效率,可能会对代码进行优化,其中一项就是调整指令顺序,按照更高效的顺序执行。

    指令重排序 就是编译器或处理器为了提高程序性能而做出的优化。主要表现在:

    • 编译器优化的重排序(编译器优化,主要是单线程下,在保证执行结果正确的前提下,重新排序代码顺序让代码更符合机器执行)
    • 指令级并行重排序(处理器优化,主要为多核计算机同时执行做了优化)
    • 内存系统的重排序(处理器优化,主要是运行内存(如主内存、工作内存)进行重排序)

    只要指令不存在数据依赖,就可以对指令进行重排序。重排序会保证程序最终的执行结果和代码顺序执行时的结果一致,所以这并不会影响单线程程序执行。

    - (void)reorderSimulate {
        int a = 1;  // 假设对应“指令1”
        int b = 2;  // 假设对应"指令2"
        int c = a + b; // 假设对应"指令3"
    }
    

    对于上面代码,由于重排序的影响,可能是指令1先执行,也可能是指令2先执行,但是对于指令3,因存在与指令1指令2的数据依赖,所以指令3一定会在指令1指令2之后执行。

    指令重排序不影响单线程的正确性,但在多线程的复杂情况下,一旦线程间共享数据,调整指令顺序就有可能会导致可见性问题。

    指令重排序 是多线程编程不安全的原因之二。

    还有一个影响多线程安全的特性:线程执行的原子性。如果一个线程的操作可以被其他线程中断,那么该线程的数据就可能被其他线程污染,导致线程安全问题。

    综上,导致多线程不安全的主要原因有:

    • ① 线程的交叉执行( 原子性 )
    • ② 指令重排序( 顺序性 )
    • ③ 共享变量更新后的值没有在工作内存中与主内存之间及时更新( 可见性 )

    解决多线程安全问题,就是从一定程度上解决原子性顺序性可见性问题。同步机制是解决这些问题的方式之一。

    二、同步机制

    避免共享资源和尽量减少线程之间的交互可以减少线程相互干扰的可能性。然而,完全无干扰的设计并不总是可能的。在线程必须交互的情况下,可以使用同步机制来确保它们交互时的安全,常用的同步机制如下。

    2.1 原子操作

    原子操作,就是像原子一样不可再分割的操作。即一个操作(有可能包含多个子操作)只要开始执行,在执行完毕之前,不会被其他操作或指令中断。原子操作解决了多线程不安全问题中的原子性问题。没有原子操作的话,操作可能会因为中断异常等各种原因引起数据状态的不一致从而影响到程序的正确性。

    原子操作可以通过基于硬件层面的CPU指令 CAS 实现。

    CAS操作:Compare and Swap,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。它的语义是“我认为V的值应该为A,如果是,那么将V的值更新为B,否则不修改并告诉V的值实际为多少”,CAS是项乐观锁技术,允许多个线程进入,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,其它线程都失败,失败的线程并不会被挂起,而是被告知在这次竞争中失败,并可以再次尝试。

    原子操作的优点是它们不会阻塞相互竞争的线程。对于简单的操作,比如递增一个计数器变量,使用原子操作可以获得比使用锁更好的性能。另外,由于是CPU级别的指令,其开销比需要操作系统参与的锁的开销小。

    iOS中的atomic属性修饰符的语义就是原子操作。被atomic所修饰的属性,确保了setter与getter的原子性。然而atomic并不是线程安全的,且比nonatomic开销要大得多。

    2.2 Volatile

    Volatile 的两大作用:
    ①.保证线程可见性;
    ②.防止编译器层面优化的指令重排序;

    前面我们提到,对于多个线程共享的变量,原始数据存储在主存中。线程在使用这些共享变量时,是将主存中的数据复制到各自的工作内存。通过操作工作内存中的副本实现对数据的修改,操作完毕后再同步回主存中。由于同步的时间不可预期,导致线程之间的数据是不可见的。

    使用 Volatile 修饰的变量,在变量被修改时会立即同步到主存,并且使其他线程工作内存中的副本数据失效,当其他线程再使用该变量时,发现其是失效的,就会重新从主存中读取数据,这样就保证了线程的可见性(当一个线程修改了线程共享变量的值,其它线程在使用前,能够得到最新的修改值)。

    而对于防止指令重排序,是通过 内存屏障 实现的,实际上对于保证线程可见性,也有 内存屏障 的参与。

    那么Volatile可以确保多线程安全吗?

    不能,因为他并不保证线程执行的原子性

    线程要操作共享变量,需从主存中读取到工作内存,改变值后需从工作内存同步到主存中(遵循<同步交互协议>)。

    <同步交互协议>,规定了8种原子操作

    • 1、lock(锁定):将主内存中的变量锁定,为一个线程所独占;
    • 2、unclock(解锁):将lock加的锁定解除,此时其他的线程可以有机会访问此变量;
    • 3、read(读取):作用于主内存变量,将主内存中的变量值读到工作内存中;
    • 4、load(载入):作用于工作内存变量,将read读取的值保存到工作内存中的变量副本中;
    • 5、use(使用):作用于工作内存变量,将值传递给线程的代码执行引擎;
    • 6、assign(赋值):作用于工作内存变量,将执行引擎处理返回的值重新赋值给变量副本;
    • 7、store(存储):作用于工作内存变量,将变量副本的值传到主内存中;
    • 8、write(写入):作用于主内存变量,将store传送过来的值写入到主内存的共享变量中;

    以上的同步协议中,把主内存的变量读取到工作内存中需要3、4两步操作,同样把工作内存的变量同步到主内存中也需要7、8两步,两个原子操作就不是原子操作了!!!

    所以 Volatile 适用于不需要保证原子性,但却需要保证可见性的场景。一种典型的使用场景是用它修饰用于停止线程的状态标记。

    2.3 内存屏障

    内存屏障 也是一种非阻塞式同步工具,通常用于确保由一个线程(但对另一个线程可见)执行的内存操作始终按照预期的顺序进行。它的作用类似于栅栏,在允许处理器执行位于屏障之后的加载和存储操作之前,强制处理器完成位于屏障前面的任何加载和存储操作。

    内存屏障是硬件之上、操作系统之下,对并发作出的最后一层支持。Volatile保证了编译器层面的线程可见性和指令重排序,内存屏障保证了处理器层面的线程可见性和指令重排序。

    因为内存屏障Volatile变量都减少了编译器可以执行的优化次数,所以应该有节制地使用它们,并且只在需要确保正确性的地方使用。

    2.4 信号量

    信号量是一个特殊的变量,它的本质是计数器,信号量里面记录了临界资源的数量,有多少数量,信号量的值就为多少。与普通变量的区别是:它只能被两个标准的原语waitsignal访问,也可以记为“P操作”和“V操作”(PV操作,P:占用资源,V:释放资源,均是原子操作)。

    信号量机制的基本原理:两个或多个线程可以利用彼此间收发的简单的信号来实现“正确的”并发执行,一个线程在收到一个指定信号前,会被迫在一个确定的或者需要的地方停下来,从而保持同步或互斥。

    信号量的类型:

    • 整型信号量

    用一个整数型的变量作为信号量,用来表示某种资源的数量。

    int S = 1; // 整型信号量S,表示某种资源的数量
    
    void wait(int S) { // wait原语 == P操作:占用资源
        while(S <= 0); // 资源数不足,循环等待
        S = S - 1; //  资源充足,占用资源
    }
    
    void signal(int S) { // signal原语 == V操作:释放资源
        S = S + 1;  // 释放占用的资源
    }
    

    整型信号量的P操作中,只要信号量S<=0,就会不断地测试。因此,整型信号量并未遵循“让权等待”,而是使线程处于“忙等”状态。

    • 记录型信号量

    记录型信号量是一种不存在“忙等”现象的同步机制。但是采取了“让权等待”的策略后又会出现多个线程等待访问同一临界资源的情况。为此,除了需要一个用于表示资源数目的整型变量value外,还应增加一个链表指针*L,用于链接所有等待线程。

    // 记录型信号量,value用来记录信号量的值,*L用来记录所有等待的线程
    typedef struct {
        int value; // 某种资源的数量
        struct thread *L; // 等待队列
    } semaphore;
    
    void wait(semaphore S) { // wait原语==P操作
        S.value--;  // 占用一个资源,则系统资源数目少一个
        if(S.value < 0) {
            block(S.L); // 阻塞线程 
        }
    }
    
    void signal(semaphore S) { // signal原语== V操作
        S.value++; // 释放一个占用资源,则系统资源数目+1
        if(S.value <= 0) {
            wakeup(S.L); // 唤醒线程
        }
    }
    

    对记录型信号量的一次P操作意味着线程请求一个单位的该类资源,因此执行S.value--,当S.value<0时,意味着资源分配完毕,调用block原语进行自我阻塞(当前线程由运行态->就绪态),主动放弃处理机。并将当前线程插入该类资源的等待队列S.L中,此时S.value的绝对值表示该信号量链表中阻塞线程的数目;
    对记录型信号量的一次V操作意味着线程释放一个单位的该类资源,因此执行S.value++,如果加1后仍是S.value <= 0,表示依然有线程在等待此类资源,因此调用wakeup原语唤醒等待队列中的第一个线程(被唤醒的线程从阻塞态->运行态)。

    信号量机制是一种功能较强的机制,可用于解决互斥同步的问题。

    信号量实现线程互斥:互斥信号量mutex初始值为1,在临界区之前执行P(mutex),在临界区之后执行V(mutex)。

    信号量实现线程同步:同步信号量S初始值为0,在“前操作”之后执行V(S),在“后操作”之前执行P(S)。

    • 使用信号量解决互斥问题

    实现互斥,就是保证临界区同时只能有一个线程可以访问,可以把临界区理解成一种特殊的资源,该资源只有一个,只能被分配给一个线程使用,只有这个线程释放了资源,才能被其他线程使用。所以可以将信号量初始化为1,在访问临界区之前进行P操作,在访问临界区之后进行V操作。

    semaphore S.value = 1;
    T1() {
        P(S); //准备开始访问临界资源,加锁
        线程T1的临界区
        V(S); //访问结束,解锁
    }
    T2() {
        P(S); //准备开始访问临界资源,加锁
        线程T2的临界区
        V(S); //访问结束,解锁
    }
    
    
    • 使用信号量解决同步问题

    当两个线程中的任务存在依赖关系,在解决同步问题时,需要将信号量初始化为0,在“前操作”之后执行V操作,在“后操作”之前执行P操作。

    semaphore S.value = 0;    //初始化信号量
    T1() {
        x;    //语句x
        V(S); //告诉线程T2,语句x已经完成
    }
    T2() {
        P(S); //检查语句x是否运行完成
        y;    //检查无误,运行y语句
    }
    

    2.5 条件

    条件是另一种类型的信号量,条件通常用于指示资源的可用性或确保任务按特定顺序执行。当线程测试一个条件时,如果条件不为真,它会一直处于阻塞状态,直到其他线程显式更改并发出该条件的信号。条件和互斥锁的区别在于,可以允许多个线程同时访问条件。条件更像是一个看门人,它允许不同的线程通过这个门,但是前提是,某些指定的条件为真。

    使用条件的一种方式是管理挂起事件池。当队列中有事件时,事件队列将使用一个条件变量向等待线程发出信号。如果一个事件到达,队列将发出适当的条件信号。如果一个线程已经在等待,它将被唤醒,然后它将从队列中提取事件并处理它。如果两个事件大约在同一时间进入队列,队列将两次向条件发出信号,以唤醒两个线程。

    2.6 锁

    锁是开发中最常用的同步工具。通过锁来实现对临界资源的访问控制,从而使目标代码段同一时间只会被一个线程执行,这是一种以牺牲性能为代价的方法。

    锁的实现依赖于原子操作,不同的处理器(intel、arm),不同的架构(单核、多核)实现原子操作的方式不一样,有的是通过加锁封锁总线,有的是做成单指令,有的是依据标志位,有的是依据CPU相关的指令对,总之不同的机制可以实现原子操作,有性能上的区别,再依据原子操作,从而封装了信号量,锁等机制,用于不同的代码需求。

    虽然锁是同步两个线程的有效方法,但是获取锁是一个相对昂贵的操作,即使在无争用的情况下也是如此。相比之下,许多原子操作只需花费一小部分时间就可以完成,并且可以像锁一样有效。

    使用锁可以保证多线程操作共享数据时的安全问题,却也降低了程序的执行效率。
    锁这种机制无法彻底避免以下几点问题:
    ① 锁引起线程的阻塞,对于没有能占用到锁的线程或者进程将会一直等待锁的占有者释放资源后才能继续;
    ② 申请和释放锁的操作增加了很多访问共享资源的消耗;
    ③ 锁不能很好的避免编程开发者设计实现的程序出现死锁或者活锁可能;
    ④ 优先级反转和锁护送怪现象;
    ⑤ 难以调试。

    使用锁的注意事项:

    注意死锁问题:
    在多线程执行任务的时候,可能需要获取到多把锁才能去完成某个任务。Thread-0 、Thread-1它们要完成线程的任务,而它们都需要获取不同的锁才能完成。Thread-0 需要先获取A锁,再获取B锁;而Thread-1需要先获取B锁,再获取A锁。Thread-0 获取到了A锁,CPU切换到Thread-1上,Thread-1获取到了B锁。这时就出现了两个线程要执行任务都需要获取对方线程上的那个锁。这就会导致死锁的发生。结果是每个线程永久阻塞,因为它永远无法获得另一个锁。

    注意活锁问题:
    活锁类似于死锁,当两个线程竞争同一组资源时发生。在活锁情况下,一个线程放弃它的第一个锁,试图获得它的第二个锁。一旦它获得了第二个锁,它就会返回并再次尝试获得第一个锁。它锁定是因为它把所有时间都花在释放一个锁并试图获取另一个锁上,而不是做任何实际的工作。

    避免死锁和活锁的最好方法是一次只使用一个锁。如果必须一次获得多个锁,应该确保其他线程不会尝试做类似的事情。

    互斥锁和自旋锁:

    锁的分类多种多样,根据线程的状态可以分为:互斥锁自旋锁被互斥锁阻塞的线程处于休眠状态,被自旋锁阻塞的线程处于忙等状态。

    互斥锁:互斥锁充当资源周围的保护屏障,如果多个线程竞争同一个互斥锁,每次只允许一个线程访问。如果一个互斥锁正在使用中,另一个线程试图获取它,该线程就会阻塞,进入睡眠状态,直到该互斥锁被它的原始持有者释放再被唤醒。

    自旋锁:与互斥锁不同的是,如果一个自旋锁正在使用中,另一个线程试图获取它时,该线程不会进入休眠状态,而是反复轮询其锁条件,直到该条件变为真。(适用于竞争预期较低的情况)

    使线程进入睡眠状态,主动让出时间片并不代表效率高,这会导致操作系统切换到另一个线程。上下文切换通常需要10ms,而且需要切换两次。如果锁的预期等待时间很短,轮询通常比阻塞线程更有效,阻塞线程涉及上下文切换和线程数据结构的更新。

    三、iOS中的锁

    3.1 pthread_mutex 互斥锁

    互斥锁是一种用来防止多个线程同一时刻对共享资源进行访问的信号量,它的原子性确保了如果一个线程锁定了一个互斥量,将没有其他线程在同一时间可以锁定这个互斥量。它的唯一性确保了只有它解锁了这个互斥量,其他线程才可以对其进行锁定。当一个线程锁定一个资源的时候,其他对该资源进行访问的线程将会被挂起,直到该线程解锁了互斥量,其他线程才会被唤醒,进一步才能锁定该资源进行操作。

    pthread_mutex 是POSIX提供的互斥锁,基于C语言实现,可跨平台。基本上OC层面的互斥锁都是基于pthread_mutex实现的。主要的函数如下:

    // 宏定义。用于静态的mutex的初始化,采用默认的attr。
    PTHREAD_MUTEX_INITIALIZER 
    // 用于动态的mutex的初始化,第二个参数为mutex的属性attr
    int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); 
    // 请求获得锁,如果当前mutex未被持有,则加锁成功;
    // 如果当前mutex已被持有,那么请求加锁线程不会获得成功,并阻塞线程,直到mutex被释放
    int pthread_mutex_lock(pthread_mutex_t *mutex); 
    // 释放锁
    int pthread_mutex_unlock(pthread_mutex_t *mutex); 
    // 尝试获得锁,如果当前mutex已经被持有或者不可用,这个函数就直接return,不会阻塞当前线程
    int pthread_mutex_trylock(pthread_mutex_t *mutex); 
    // 销毁mutex锁,并且释放所有它所占有的资源
    int pthread_mutex_destroy(pthread_mutex_t *mutex); 
    

    使用pthread_mutex的主要过程为:
    ① 创建pthread_mutex;
    ② 使用pthread_mutex_lock加锁,使用pthread_mutex_unlock解锁;
    ③ 销毁pthread_mutex;

    创建pthread_mutex:

    初始化pthread_mutex有两种方式,一种是通过宏定义(PTHREAD_MUTEX_INITIALIZER) 获得默认的互斥锁,另一种是通过函数(pthread_mutex_init)创建锁。如果不需要自定义pthread_mutex的属性信息,使用宏定义的方式更快速便捷。

    使用pthread_mutex_lock加锁与pthread_mutex_unlock解锁:

    pthread_mutex(互斥锁)利用排他性来保证线程安全,在同一时刻只允许一个线程获得锁。如果一个线程已经获得互斥锁,另一个线程就无法访问,直到锁的持有者正确的释放了互斥锁,另一个线程才有机会获得锁。

    - (void)pthread_mutexDemo {
      
        // 创建mutex
        __block pthread_mutex_t mutex = (pthread_mutex_t)PTHREAD_MUTEX_INITIALIZER;
        // 线程1
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            pthread_mutex_lock(&mutex);
            NSLog(@"执行任务A---%@",[NSThread currentThread]);
            sleep(5);
            NSLog(@"任务A执行完毕---%@",[NSThread currentThread]);
            pthread_mutex_unlock(&mutex);
        });
        // 线程2
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            sleep(1);// 让线程1的任务先执行
            pthread_mutex_lock(&mutex);
            NSLog(@"执行任务B---%@",[NSThread currentThread]);
            pthread_mutex_unlock(&mutex);
        });
        
        // 销毁mutex:确保mutex使用完毕再销毁
    //    pthread_mutex_destroy(&mutex);
    }
    
    // 打印结果:
    2021-02-27 21:00:14.034887+0800 lockDemo[83241:6444414] 执行任务A---<NSThread: 0x600000fad000>{number = 5, name = (null)}
    2021-02-27 21:00:19.039718+0800 lockDemo[83241:6444414] 任务A执行完毕---<NSThread: 0x600000fad000>{number = 5, name = (null)}
    2021-02-27 21:00:19.040232+0800 lockDemo[83241:6444416] 执行任务B---<NSThread: 0x600000faee80>{number = 3, name = (null)}
    

    本例中,线程1先获得互斥锁,尽管线程2的异步任务在sleep(1)后就可执行,但此时线程1已持有互斥锁,所以再次遇到pthread_mutex_lock(&mutex)时,必须等待,此时线程2处于阻塞态,直到 sleep(5)后线程1释放互斥锁,线程2才被唤醒继续执行任务。

    使用pthread_mutex_trylock:

    除了pthread_mutex_lock函数外,pthread_mutex还提供了pthread_mutex_trylock函数,与pthread_mutex_lock不同的是,使用pthread_mutex_trylock函数来申请加锁,不管是否能获得锁都立即返回,并不阻塞线程。如果申请失败则返回错误:EBUSY(锁尚未解除)或者EINVAL(锁变量不可用)。一旦在trylock的时候有错误返回,那就把前面已经拿到的锁全部释放,然后过一段时间再来一遍。

    如果将上例中线程2的pthread_mutex_lock(&mutex)操作,换成pthread_mutex_trylock(&mutex)。则结果为

    2021-02-27 21:04:54.976015+0800 lockDemo[62208:9380951] 执行任务A---<NSThread: 0x6000017de040>{number = 6, name = (null)}
    2021-02-27 21:04:55.977173+0800 lockDemo[62208:9380952] 执行任务B---<NSThread: 0x6000017a5980>{number = 4, name = (null)}
    2021-02-27 21:04:59.980902+0800 lockDemo[62208:9380951] 任务A执行完毕---<NSThread: 0x6000017de040>{number = 6, name = (null)}
    

    使用注意事项:

    使用pthread_mutex时,pthread_mutex_lockpthread_mutex_unlock要成对使用,一般情况下,一个线程只能申请一次锁,也只能在获得锁的情况下才能释放锁,多次申请锁或释放未获得的锁都会导致异常。一定要确保在正确的时机获得锁和释放锁。

    • 避免阻塞
      假设在已经获得锁的情况下再次申请锁,线程会因为等待锁的释放而进入睡眠状态,同时也不可能释放锁。
    • 避免死锁
      如果两个线程存在互相等待释放锁的情况,也会导致死锁的发生。
    • 记得pthread_mutex_destroy销毁锁,但要确保pthread_mutex已使用完毕。

    3.2 pthread_mutex(recursive) 递归锁

    在实际开发中,有可能存在这样的需求,递归调用或需要重复的获得锁。这种情况下,如果使用pthread_mutex(互斥锁)就会阻塞线程,任务也就无法继续执行。这就需要使用递归锁来解决问题了。

    递归锁是互斥锁的变体。递归锁允许单个线程在释放锁之前多次获取该锁(可重入,保存了锁的次数信息)。而不会阻塞当前线程,其他线程仍然处于阻塞状态,直到锁的持有者以获得锁的相同次数释放锁。

    递归锁主要在递归迭代期间使用,也可以在多个方法分别需要获得锁的情况下使用。

    递归锁的使用:

    pthread_mutex维护了以下几种锁类型:

    /*
     * Mutex type attributes
     */
    #define PTHREAD_MUTEX_NORMAL        0     // 普通互斥锁
    #define PTHREAD_MUTEX_ERRORCHECK    1     // 检查锁
    #define PTHREAD_MUTEX_RECURSIVE     2     // 递归锁
    #define PTHREAD_MUTEX_DEFAULT       PTHREAD_MUTEX_NORMAL
    

    PTHREAD_MUTEX_NORMAL 是默认属性的互斥锁;与PTHREAD_MUTEX_DEFAULT等同。
    PTHREAD_MUTEX_ERRORCHECK 查错锁: 以损失些许性能的方式返回错误信息;
    PTHREAD_MUTEX_RECURSIVE 就是递归锁;

    可以通过pthread_mutexattr_t属性设置锁的类型,示例代码如下:

    - (void)pthread_mutex_recursiveDemo {
        
        // init attr
        pthread_mutexattr_t attr;
        pthread_mutexattr_init(&attr);
        pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
    //    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_DEFAULT);
    
        // init mutex
        __block pthread_mutex_t mutex_recursive;
        pthread_mutex_init(&mutex_recursive, &attr);
        pthread_mutexattr_destroy(&attr);
        
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
        
            static void (^RecursiveMethod)(int);
            RecursiveMethod = ^(int value) {
                // lock
                pthread_mutex_lock(&mutex_recursive);
                if (value > 0) {
                    NSLog(@"value = %d,thread = %@",value,[NSThread currentThread]);
                    RecursiveMethod(value - 1);
                }else{
                    pthread_mutex_destroy(&mutex_recursive);
                }
                // unlock
                pthread_mutex_unlock(&mutex_recursive);
            };
            
            RecursiveMethod(5);
        });
        //    使用完毕后,销毁
        //    pthread_mutex_destroy(& mutex_recursive);
    }
    
    // 打印结果:
    2021-02-27 21:18:33.418542+0800 lockDemo[83366:6460107] value = 5,thread = <NSThread: 0x6000037bd380>{number = 6, name = (null)}
    2021-02-27 21:18:33.418716+0800 lockDemo[83366:6460107] value = 4,thread = <NSThread: 0x6000037bd380>{number = 6, name = (null)}
    2021-02-27 21:18:33.418845+0800 lockDemo[83366:6460107] value = 3,thread = <NSThread: 0x6000037bd380>{number = 6, name = (null)}
    2021-02-27 21:18:33.419116+0800 lockDemo[83366:6460107] value = 2,thread = <NSThread: 0x6000037bd380>{number = 6, name = (null)}
    2021-02-27 21:18:33.419250+0800 lockDemo[83366:6460107] value = 1,thread = <NSThread: 0x6000037bd380>{number = 6, name = (null)}
    

    如果将此例中的PTHREAD_MUTEX_RECURSIVE 换成普通的互斥锁,那么在打印输出“value = 5 ...”之后便会进入阻塞状态。

    注意:
    pthread_mutex(recursive)只保证在单线程情况下可重入,当多个线程获取相同的pthread_mutex(recursive)锁会导致死锁的发生。

    3.3 pthread_rwlock(读写锁)

    基本上所有的问题都可以用互斥的方案去解决,但是可以解决并不代表适合。

    pthread_mutex(互斥锁)有个缺点,就是只要锁住了,不管其他线程要干什么,都不允许进入临界区。设想这样一种情况:临界区变量a正在被线程1读取,加了个mutex锁,线程2如果也要读变量a,因为被线程1加了个互斥锁,就只能等待线程1读取完毕。但事实情况是,读取数据并不影响数据内容本身,所以即便被1个线程读着,另外一个线程也应该被允许去读。除非另外一个线程是写操作,为了避免数据不一致的问题,写线程就需要等读线程都结束了再写。

    因此诞生了读写锁,有的地方也叫共享-独占锁。

    读写锁的特性是这样的,当一个线程加了读锁访问临界区,另外一个线程也想访问临界区读取数据的时候,也可以加一个读锁,这样另外一个线程就能够成功进入临界区进行读操作了。此时读锁线程有两个。当第三个线程需要进行写操作时,它需要加一个写锁,这个写锁只有在读锁的拥有者为0时才有效。也就是等前两个读线程都释放读锁之后,第三个线程就能进去写了。总结一下就是:

    • 当读写锁被一个线程以读模式占用的时候,写操作的其他线程会被阻塞读操作的其他线程还可以继续进行
    • 当读写锁被一个线程以写模式占用的时候,写操作的其他线程会被阻塞读操作的其他线程也被阻塞

    这样更精细的控制,就能减少mutex导致的阻塞延迟时间。如果受保护的数据结构经常被读取,并且只偶尔修改,则可以显著提高性能。虽然用mutex也能起作用,但这种场合,明显读写锁更好。

    pthread中读写锁主要函数如下:

    // 静态初始化方法
    PTHREAD_RWLOCK_INITIALIZER
    // 动态初始化,可传pthread_rwlockattr_t属性
    int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
    // 销毁 pthread_rwlock
    int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
    // 获得读锁
    int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
    // 尝试获得读锁
    int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
    // 获得写锁
    int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
    // 尝试获得写锁
    int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
    // 释放锁
    int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
    

    使用读写锁与pthread_mutex类似,都是通过初始化创建锁,之后根据读写不同场景进行加锁、解锁操作,在使用完毕后别忘了销毁锁。示例代码如下:

    - (void)pthread_rwlock_demo {
        pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
        _rwlock = rwlock;
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            // 读
            [self readWithTag:1];
        });
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            // 读
            [self readWithTag:2];
        });
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            // 写
            [self writeWithTag:3];
        });
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            // 写
            [self writeWithTag:4];
        });
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            // 读
            [self readWithTag:5];
        });
        //使用完毕后销毁锁:不可在未使用完毕前销毁
        //pthread_rwlock_destroy(&_rwlock);
    }
    
    - (void)readWithTag:(NSInteger )tag {
        pthread_rwlock_rdlock(&_rwlock);
        NSLog(@"start read ---- %ld",tag);
        self.path = [[NSBundle mainBundle] pathForResource:@"pthread_rwlock" ofType:@".txt"];
        self.content = [NSString stringWithContentsOfFile:self.path encoding:NSUTF8StringEncoding error:nil];
        NSLog(@"end   read ---- %ld",tag);
        pthread_rwlock_unlock(&_rwlock);
    }
    
    - (void) writeWithTag:(NSInteger)tag {
        pthread_rwlock_wrlock(&_rwlock);
        NSLog(@"start wirte ---- %ld",tag);
        [self.content writeToFile:self.path atomically:YES encoding:NSUTF8StringEncoding error:nil];
        NSLog(@"end   wirte ---- %ld",tag);
        pthread_rwlock_unlock(&_rwlock);
    }
    
    // 打印结果 :  读操作可共享,写操作互斥
    2021-02-27 21:29:44.081500+0800 lockDemo[82462:10201536] start read ---- 2
    2021-02-27 21:29:44.081500+0800 lockDemo[82462:10201541] start read ---- 1
    2021-02-27 21:29:44.081795+0800 lockDemo[82462:10201536] end   read ---- 2
    2021-02-27 21:29:44.081795+0800 lockDemo[82462:10201541] end   read ---- 1
    2021-02-27 21:29:44.082017+0800 lockDemo[82462:10201537] start wirte ---- 3
    2021-02-27 21:29:44.082182+0800 lockDemo[82462:10201537] end   wirte ---- 3
    2021-02-27 21:29:44.082351+0800 lockDemo[82462:10201535] start wirte ---- 4
    2021-02-27 21:29:44.082459+0800 lockDemo[82462:10201535] end   wirte ---- 4
    2021-02-27 21:29:44.082617+0800 lockDemo[82462:10201538] start read ---- 5
    2021-02-27 21:29:44.082799+0800 lockDemo[82462:10201538] end   read ---- 5
    
    • 注意事项: 避免写线程饥饿
      由于读写锁的性质,在默认情况下是很容易出现写线程饥饿的。因为它必须要等到所有读锁都释放之后,才能成功申请写锁。比如在写线程阻塞的时候,有很多读线程是可以一个接一个地在那儿插队的(在默认情况下,只要有读锁在,写锁就无法申请,然而读锁可以一直申请成功,就导致所谓的插队现象),那么写线程就不知道什么时候才能申请成功写锁了,然后它就饿死了。所以要注意锁建立后的优先级问题。不过不同系统的实现版本对写线程的优先级实现不同。Solaris下面就是写线程优先,其他系统默认读线程优先。

    3.4 pthread_cond (条件变量)

    当我们在使用多线程的时候,有时一把只会lock和unlock的锁未必就能完全满足我们的使用。因为普通的锁只能关心锁与不锁,而不在乎用什么钥匙(满足什么条件)才能开锁,而我们在处理资源共享的时候,有时候需要只有满足一定条件的情况下才能打开这把锁。

    这时候,POSIX提供的pthread_cond(条件变量)就派上了用场。主要的函数如下:

    // 静态初始化
    PTHREAD_COND_INITIALIZER
    // 动态初始化并允许设置属性
    int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
    // 销毁条件变量
    int pthread_cond_destroy(pthread_cond_t *cond);
    // 发送信号(给指定线程)
    int pthread_cond_signal(pthread_cond_t *cond);
    // 广播信号(给所有线程)
    int pthread_cond_broadcast(pthread_cond_t *cond);
    // 等待信号
    int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
    // 等待信号,如果在指定时间仍未收到信号,则返回
    int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mute
    

    条件变量可以做到让一个线程等待多个线程的结束,并在合适的时候唤醒正在等待的线程,具体是什么时候,取决于你设置的条件是否满足。

    设想这样一个场景:B线程和A线程之间有合作关系,A线程完成某件事情之前,B线程会等待。当A线程完成某件事情之后,需要让B线程知道,然后B线程从等待状态中被唤醒,继续做自己要做的事情。

    我们就可以在B线程任务执行前添加pthread_cond_wait(条件变量a)等待信号的发送,在A线程处理完事件后发送pthread_cond_signal(条件变量a),这样就可以实现上面的需求。

    大致的实现原理是:一个条件变量背后有一个池子,所有需要wait这个变量的线程都会进入这个池子。当有线程扔出这个条件变量的signal,系统就会把这个池子里面的线程挨个唤醒。

    示例代码如下:

    pthread_mutex_t mutex;
    pthread_cond_t condition;
    Boolean        ready_to_go = false;
     
    void MyCondInitFunction()
    {
        mutex =  (pthread_mutex_t)PTHREAD_MUTEX_INITIALIZER;
        pthread_cond_init(&condition, NULL);
    }
     
    void MyWaitOnConditionFunction()
    {
        // Lock the mutex.
        pthread_mutex_lock(&mutex);
        // If the predicate is already set, then the while loop is bypassed;
        // otherwise, the thread sleeps until the predicate is set.
        while(ready_to_go == false)
        {
            pthread_cond_wait(&condition, &mutex);
        }
        
        // Do work. (The mutex should stay locked.)
       
        // Reset the predicate and release the mutex.
        ready_to_go = false;
        pthread_mutex_unlock(&mutex);
    }
    
    void SignalThreadUsingCondition()
    {
        // At this point, there should be work for the other thread to do.
        pthread_mutex_lock(&mutex);
        
        ready_to_go = true;
        // Signal the other thread to begin work.
        pthread_cond_signal(&condition);
        pthread_mutex_unlock(&mutex);
    }
    
    - (void)pthread_cont_demo {
        MyCondInitFunction();
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            MyWaitOnConditionFunction();
        });
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            sleep(1);
            SignalThreadUsingCondition();
        });
    }
    

    注意事项

    一定要跟pthread_mutex配合使用

    void thread_function_1 ()
    {
        done = 1;
        pthread_cond_signal(&condition);
    }
    
    void thread_function_2 ()
    {
        while (done == 0) {
            pthread_cond_wait(&condition, NULL);
        }
    }
    

    这样行不行?当然不行。为什么不行?
    这里涉及一个非常精巧的情况:在thread_function_2发现done=0的时候,准备要进行下一步的wait操作。在具体开始下一步的wait操作之前,thread_function_1一口气完成了设置done,发送信号的事情。thread_function_2还没来得及waiting呢,thread_function_1就把信号发出去了,也没人接收这信号,thread_function_2继续执行waiting之后,就只能一直等待而无法被唤醒了。

    ② 一定要检测你要操作的内容

    dispatch_queue_t  operation_queue = NULL;
    void thread_function_1 ()
    {
        pthread_mutex_lock(&mutex);
        // 初始化operation_queue
        ...
        operation_queue = create_operation_queue();
        ...
    
        pthread_cond_signal(&condition_variable_signal);
        pthread_mutex_unlock(&mutex);
    }
    
    void thread_function_2 ()
    {
        pthread_mutex_lock(&mutex);
        ...
        pthread_cond_wait(&condition_variable_signal, &mutex);
        ...
        pthread_mutex_unlock(&mutex);
    }
    

    这样行不行?当然不行。为什么不行?

    比如thread_function_1一下子就跑完了,operation_queue也初始化好了,信号也扔出去了。这时候thread_function_2刚刚启动,由于它没有去先看一下operation_queue是否可用,直接就进入waiting状态。然而事实是operation_queue早已搞定,再也不会有人扔我已经搞定operation_queue啦的信号,thread_function_2也不知道operation_queue已经好了,就只能一直在那儿等待了...

    ③ 一定要用while来检测你要操作的内容而不是if

    void thread_function_1 ()
    {
        pthread_mutex_lock(&mutex);
        done = 1;
        pthread_cond_signal(&condition_variable_signal);
        pthread_mutex_unlock(&mutex);
    }
    
    void thread_function_2 ()
    {
        pthread_mutex_lock(&mutex);
        if (done == 0) {
            pthread_cond_wait(&condition_variable_signal, &mutex);
        }
        pthread_mutex_unlock(&mutex);
    }
    

    这样行不行?大多数情况行,但是用while更加安全。

    如果有别人写一个线程去把这个done搞成0了,期间没有申请mutex锁。那么这时用if去判断的话,由于线程已经从wait状态唤醒,它会直接做下面的事情,而全然不知done的值已经变了。

    如果这时用while去判断的话,在pthread_cond_wait解除wait状态之后,会再去while那边判断一次done的值,只有这次done的值对了,才不会进入wait。如果这期间done被别的不长眼的线程给改了,while补充的那一次判断就帮了你一把,能继续进入waiting。

    不过这解决不了根本问题哈,如果那个不长眼的线程在while的第二次判断之后改了done,那还是要悲剧。根本方案还是要在改done的时候加mutex锁。

    总而言之,用if也可以,毕竟不太容易出现不长眼的线程改done变量不申请加mutex锁的。用while的话就多了一次判断,安全了一点,即便有不长眼的线程干了这么龌龊的事情,也还能hold住。

    ④ 扔信号的时候,在临界区里面扔,不要在临界区外扔

    void thread_function_1 ()
    {
        pthread_mutex_lock(&mutex);
        done = 1;
        pthread_mutex_unlock(&mutex);
    
        pthread_cond_signal(&condition_variable_signal);
    }
    
    void thread_function_2 ()
    {
        pthread_mutex_lock(&mutex);
        if (done == 0) {
            pthread_cond_wait(&condition_variable_signal, &mutex);
        }
        pthread_mutex_unlock(&mutex);
    }
    

    补充一下,原则上pthread_cond_signal是只通知一个线程,pthread_cond_broadcast是用于通知很多线程。但POSIX标准也允许让pthread_cond_signal用于通知多个线程,不强制要求只允许通知一个线程。具体看各系统的实现。

    另外,在调用pthread_cond_wait之前,必须要申请互斥锁,当线程通过pthread_cond_wait进入waiting状态时,会释放传入的互斥锁。

    3.5 NSLock (互斥锁)

    NSLock 是Cocoa 基于pthread_mutex实现的一个基本的互斥锁。对应pthread_mutex的PTHREAD_MUTEX_ERRORCHECK的类型。
    遵循NSLocking协议,该协议定义了lock和unlock方法。通过 lock 和 unlock 来进行锁定和解锁。

    实际上,OC层面的基于pthread_mutex封装的锁对象都遵循NSLocking协议,这样设计的目的是因为,对于这些锁的锁定与解锁行为对于底层的操作是一致的。使用这些方法来获取和释放锁,就像使用任何pthread_mutex一样。

    除了NSLocking协议提供的标准锁定行为,NSLock类还添加了tryLocklockBeforeDate:方法。

    • tryLock方法尝试获取该锁,但如果该锁不可用,并不会阻塞,该方法只返回NO。
    • lockBeforeDate:方法尝试获取锁,但是如果在指定Date的时间限制内没有获得锁,则会解除线程阻塞(并返回NO)。
    - (void)nslock_demo {
        //主线程
        NSLock *lock = [[NSLock alloc] init];
        //线程1
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            [lock lock];
            NSLog(@"线程1任务 开始");
            sleep(2);
            NSLog(@"线程1任务 结束");
            [lock unlock];
        });
        //线程2
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            sleep(1);
            if ([lock tryLock]) {//尝试获取锁,如果获取不到返回NO,不会阻塞该线程
                NSLog(@"线程2尝试获取锁,锁可用");
                [lock unlock];
            }else{
                NSLog(@"线程2尝试获取锁,锁不可用");
            }
            
            NSDate *date = [[NSDate alloc] initWithTimeIntervalSinceNow:3];
            if ([lock lockBeforeDate:date]) {//尝试在未来的3s内获取锁,并阻塞该线程,如果3s内获取不到恢复线程, 返回NO,不会阻塞该线程
                NSLog(@"没有超时,线程2获得锁");
                [lock unlock];
            }else{
                NSLog(@"超时,线程2没有获得锁");
            }
        });
    }
    
    // 打印结果:
    2021-02-27 21:44:10.071157+0800 lockDemo[36464:983765] 线程1任务 开始
    2021-02-27 21:44:11.074331+0800 lockDemo[36464:983761] 线程2尝试获取锁,锁不可用
    2021-02-27 21:44:12.074832+0800 lockDemo[36464:983765] 线程1任务 结束
    2021-02-27 21:44:12.075065+0800 lockDemo[36464:983761] 没有超时,线程2获得锁
    

    NSLock基本与pthread_mutex特性一致,可以参照pthread_mutex的使用及注意事项。

    3.6 NSRecursiveLock (递归锁)

    NSRecursiveLock 是Cocoa对pthread_mutex互斥锁 PTHREAD_MUTEX_RECURSIVE 类型的封装。与pthread_mutex(递归锁)一样,主要是用在循环或递归操作中。该锁可以被同一个线程多次获取,而不会被阻塞。它记录了成功获得锁的次数,每一次成功的获得锁,都必须有一个配套的释放锁与其对应,只有当所有的加锁和解锁调用都被平衡后,锁才会被实际释放,以便其他线程能够获取它。

    除了实现NSLocking协议的方法外,NSRecursiveLock还提供了两个方法,分别如下:

    // 在给定的时间之前去尝试请求一个锁
    - (BOOL)lockBeforeDate:(NSDate *)limit
    
    // 尝试去请求一个锁,并会立即返回一个布尔值,表示尝试是否成功
    - (BOOL)tryLock
    

    使用示例如下:

    - (void)NSRecursiveLock_demo {
        //主线程
        NSRecursiveLock *recursiveLock = [[NSRecursiveLock alloc] init];
        //线程1
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            static void(^MyRecursiveFunction)(int);
            MyRecursiveFunction = ^(int value)
            {
                [recursiveLock lock];
                if (value > 0)
                {
                    NSLog(@"递归任务1--%d",value);
                    sleep(2);
                    --value;
                    MyRecursiveFunction(value);
                }
                [recursiveLock unlock];
            };
            MyRecursiveFunction(5);
        });
        //线程2
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            sleep(1);
            [recursiveLock lock];
            NSLog(@"任务2");
            [recursiveLock unlock];
        });
    }
    
    // 打印结果如下:
    2021-02-27 21:48:33.853605+0800 lockDemo[83298:10293307] 递归任务1--5
    2021-02-27 21:48:35.856179+0800 lockDemo[83298:10293307] 递归任务1--4
    2021-02-27 21:48:37.859868+0800 lockDemo[83298:10293307] 递归任务1--3
    2021-02-27 21:48:39.863572+0800 lockDemo[83298:10293307] 递归任务1--2
    2021-02-27 21:48:41.868646+0800 lockDemo[83298:10293307] 递归任务1--1
    2021-02-27 21:48:43.870858+0800 lockDemo[83298:10293303] 任务2
    

    注意:由于递归锁只有在所有锁操作与解锁操作得到平衡后才会被释放,长时间持有任何锁会导致其他线程阻塞,直到递归完成。如果可以通过重写代码来消除递归,或者消除使用递归锁的需要,那么可能会获得更好的性能。

    3.7 NSCondition (条件)

    NSCondition 是对POSIX条件pthread_cond的封装, 它将所需的锁和条件数据结构包装在一个对象中。使得开发者可以像锁定互斥锁一样锁定它,然后像等待条件一样等待它。

    NSCondition和NSLock、@synchronized等是不同的是,NSCondition可以给每个线程分别加锁,加锁后不影响其他线程进入临界区。其它线程也能上锁,而之后可以根据条件决定是否继续运行线程,即线程是否要进入 waiting 状态.

    除了实现NSLocking协议的方法外,NSCondition还提供了以下函数:

    - (void)wait;   // 等待信号
    - (BOOL)waitUntilDate:(NSDate *)limit;  // 等待信号,如果limit时间已到,则直接返回
    - (void)signal; // 发送信号
    - (void)broadcast; // 广播信号
    

    通过NSCondition可以实现不同线程的调度。一个线程被某一个条件所阻塞,直到另一个线程满足该条件从而发送信号给该线程使得该线程可以正确的执行。

    - (void)NSCondition_demo {
        __block NSInteger timeToDoWork = 0;
        NSCondition *cocoaCondition = [[NSCondition alloc] init];
        // 线程1
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            [cocoaCondition lock];
            while (timeToDoWork <= 0){
                [cocoaCondition wait];
            }
             
            timeToDoWork--;
             
            // Do real work here.
             
            [cocoaCondition unlock];
        });
        
        // 线程2
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            sleep(2);
            [cocoaCondition lock];
            timeToDoWork++;
            [cocoaCondition signal];
            [cocoaCondition unlock];
        });
    }
    

    3.8 NSConditionLock (条件锁)

    NSConditionLock 是对NSCondition的进一步封装,条件锁对象所定义的互斥锁可以用特定的值(某个条件)锁定和解锁。除了NSLocking协议外,NSConditionLock还提供如下函数与属性:

    - (instancetype)initWithCondition:(NSInteger)condition NS_DESIGNATED_INITIALIZER;
    
    @property (readonly) NSInteger condition;
    - (void)lockWhenCondition:(NSInteger)condition;  // 当condition的值满足条件时 获取锁
    - (BOOL)tryLock; // 尝试获得锁,不管是否获得成功都立即返回,不阻塞线程
    - (BOOL)tryLockWhenCondition:(NSInteger)condition; // 当condition的值满足条件时,尝试加锁
    - (void)unlockWithCondition:(NSInteger)condition; // 释放锁,并将condition的值修改为执行值
    - (BOOL)lockBeforeDate:(NSDate *)limit; // 在指定时间限制内获取锁,获取失败,返回NO
    // 在指定时间内,当condition的值满足条件时获取锁
    - (BOOL)lockWhenCondition:(NSInteger)condition beforeDate:(NSDate *)limit;
    

    通常,当线程需要以特定的顺序执行任务时,比如当一个线程生产数据另一个线程消耗数据时,可以使用NSConditionLock对象。在生产者执行时,可以通过特定的条件获得锁(条件本身只是定义的一个整数值),当生产者完成时,它将解锁,并将锁的条件设置为可以唤醒消费者线程的条件。

    下面的示例展示了如何使用条件锁处理生产者-消费者问题。假设一个应用程序包含一个数据队列。生产者线程向队列中添加数据,消费者线程从队列中提取数据。生成器不需要等待特定的条件,但是它必须等待锁可用,这样它才能安全地将数据添加到队列中。

    NSMutableArray *products = [NSMutableArray array];
    NSConditionLock *lock = [[NSConditionLock alloc] init];
    NSInteger HAS_DATA = 1;
    NSInteger NO_DATA = 0;
        
    dispatch_async(dispatch_get_global_queue(0, DISPATCH_QUEUE_PRIORITY_DEFAULT), ^{
           
        while (1) {
            [lock lockWhenCondition:NO_DATA];
            [products addObject:[[NSObject alloc] init]];
            NSLog(@"produce a product, 总量: %zi", products.count);
            [lock unlockWithCondition:HAS_DATA];
            sleep(1);
        }
    });
        
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            
        while (1) {
            NSLog(@"wait for product");
            [lock lockWhenCondition:HAS_DATA];
            [products removeObjectAtIndex:0];
            NSLog(@"custome a product");
            [lock unlockWithCondition:NO_DATA];
        }
    });
    

    当生产者释放锁的时候,把条件设置成了1。这样消费者可以获得该锁,进而执行程序,如果消费者获得锁的条件和生产者释放锁时给定的条件不一致,则消费者永远无法获得锁,也不能执行程序。同样,如果消费者释放锁给定的条件和生产者获得锁给定的条件不一致的话,则生产者也无法获得锁,程序也不能执行。

    注意unlockunlockWithCondition:(NSInteger)condition 的区别:
    unlock 释放锁但并不改变condition的值;
    unlockWithCondition 释放锁,并将condition的值修改为指定值。
    另:
    由于在实现操作系统时的细微参与,即使代码里没有实际发出信号,条件锁也允许以虚假的成功返回。为了避免由这些假信号引起的问题,您应该始终将谓词与条件锁结合使用。谓词是确定线程继续执行是否安全的更具体的方法。这个条件只是让线程处于休眠状态,直到发送信号的线程可以设置谓词。

    3.9 @sychronized

    @sychronized 是使用起来最简单的互斥锁,通常只需要@sychronized(obj)这样一个简单的指令就可以实现加/解锁操作。

    - (void)sychronized_demo {
        NSObject *obj = [[NSObject alloc] init];
        NSObject *obj1 = [[NSObject alloc] init];
        //线程1
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            @synchronized(obj){
                NSLog(@"任务1");
                sleep(5);
            }
        });
        //线程2
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            @synchronized(obj){
                NSLog(@"任务2");
            }
        });
    }
    // 打印结果:
    2021-02-27 22:08:25.288126+0800 lockDemo[83702:10333558] 任务1
    2021-02-27 22:08:30.291985+0800 lockDemo[83702:10333557] 任务2
    

    @synchronized指令使用传入的对象(obj)作为该锁的唯一标识,只有当标识相同时,才为满足互斥,如果线程2中的@synchronized(obj)改为@synchronized(obj1),线程2就不会被阻塞。

    // 如果将线程2的 @synchronized(obj)换成 @synchronized(obj1),则
    2021-02-27 22:09:42.831004+0800 lockDemo[83783:10344549] 任务1
    2021-02-27 22:09:42.831014+0800 lockDemo[83783:10344546] 任务2
    

    同时@synchronized还允许重入,前面提到的pthread_mutex(递归锁)和NSRecursiveLock也支持重入,但它们只允许在同一线程内多次重入,而@synchronized支持多线程重入。这是因为@sychronized内部,除了维护了同一线程的加锁次数lockCount外,还维护了使用唯一标识的线程数threadCount

    @synchronized指令实现锁的优点就是我们不需要在代码中显式的创建锁对象,便可以实现锁的机制,但作为一种预防措施,@synchronized块会隐式的添加一个异常处理例程来保护代码,该处理例程会在异常抛出的时候自动的释放互斥锁。所以如果不想让隐式的异常处理例程带来额外的开销,你可以考虑使用锁对象。

    注意事项:
    确保传入@synchronized的obj不为nil,因为如果传入的obj为nil的话,实际上并不会做任何实际的内容,也无法达到加锁的目的。

    3.10 dispatch_semaphore

    dispatch_semaphore 和 NSCondition 类似,都是一种基于信号的同步方式,但 NSCondition 信号只能发送,不能保存(如果没有线程在等待,则发送的信号会失效)。而 dispatch_semaphore 能保存发送的信号。dispatch_semaphore 的核心是 dispatch_semaphore_t 类型的信号量。

    dispatch_semaphore 是信号量,但当信号总量设为 1 时也可以当作锁来。在没有等待情况出现时,它的性能比 pthread_mutex 还要高,但一旦有等待情况出现时,性能就会下降许多。相对于 OSSpinLock 来说,它的优势在于等待时不会消耗 CPU 资源。

    与其相关的主要有三个函数:

    • dispatch_semaphore_t dispatch_semaphore_create(long value)
      输出一个dispatch_semaphore_t类型且值为value的信号量。值得注意的是,这里的传入的参数value必须大于或等于0,否则dispatch_semaphore_create会返回NULL。

    • long dispatch_semaphore_signal(dispatch_semaphore_t dsema)
      这个函数会使传入的信号量dsema的值加1;

    • long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout)
      这个函数会使传入的信号量dsema的值减1;
      这个函数的作用是这样的,如果dsema信号量的值大于0,该函数所处线程就继续执行下面的语句,并且将信号量的值减1;如果desema的值为0,那么这个函数就阻塞当前线程等待timeout(注意timeout的类型为dispatch_time_t,不能直接传入整形或float型数),如果等待的期间desema的值被dispatch_semaphore_signal函数加1了,且该函数(即dispatch_semaphore_wait)所处线程获得了信号量,那么就继续向下执行并将信号量减1。如果等待期间没有获取到信号量或者信号量的值一直为0,那么等到timeout时,其所处线程自动执行其后语句。

    示例代码如下:

        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);
        });
    

    如上的代码,如果超时时间overTime设置成>2,可完成同步操作。如果overTime<2的话,在线程1还没有执行完成的情况下,此时超时了,将自动执行下面的代码。

    上面代码的执行结果为:

    需要线程同步的操作1 开始
    需要线程同步的操作1 结束
    需要线程同步的操作2
    

    如果把超时时间设置为<2s的时候,执行的结果就是:

    需要线程同步的操作1 开始
    需要线程同步的操作2
    需要线程同步的操作1 结束
    

    3.11 OSSpinLock 与 os_unfair_lock

    互斥锁和读写锁在申请加锁的时候,会使得线程阻塞,阻塞的过程又分两个阶段,第一阶段是会先空转,可以理解成跑一个while循环,不断地去申请锁,在空转一定时间之后,线程会进入waiting状态,此时线程就不占用CPU资源了,等锁可用的时候,这个线程会被唤醒。

    为什么会有这两个阶段呢?主要还是出于效率因素。

    如果单纯在申请锁失败之后,立刻将线程状态挂起,会带来context切换的开销,但挂起之后就可以不占用CPU资源了,原属于这个线程的CPU时间就可以拿去做更加有意义的事情。假设锁在第一次申请失败之后就又可用了,那么短时间内进行context切换的开销就显得很没效率。

    如果单纯在申请锁失败之后,不断轮询申请加锁,那么可以在第一时间申请加锁成功,同时避免了context切换的开销,但是浪费了宝贵的CPU时间。假设锁在第一次申请失败之后,很久很久才能可用,那么CPU在这么长时间里都被这个线程拿来轮询了,也显得很没效率。

    于是就出现了两种方案结合的情况:在第一次申请加锁失败的时候,先不着急切换context,空转一段时间。如果锁在短时间内又可用了,那么就避免了context切换的开销,CPU浪费的时间也不多。空转一段时间之后发现还是不能申请加锁成功,那么就有很大概率在将来的不短的一段时间里面加锁也不成功,那么就把线程挂起,把轮询用的CPU时间释放出来给别的地方用。

    所以spin lock就是这样的一个锁:它在第一次申请加锁失败的时候,会不断轮询,直到申请加锁成功为止,期间不会进行线程context的切换。,《APUE》中原文是这样:A spin lock is like a mutex, except that instead of blocking a process by sleeping, the process is blocked by busy-waiting (spinning) until the lock can be acquired. 互斥锁和读写锁基于spin lock又多做了超时检查和切换context的操作,如此而已。事实上,spin lock在实现的时候,有一个__pthread_spin_count限制,如果空转次数超过这个限制,线程依旧会挂起(__shed_yield)。

    这里是spin lock申请加锁的实现:

    /////////////////////////////pthread_src/sysdeps/posix/pt-spin.c
    
    /* Lock the spin lock object LOCK.  If the lock is held by another
        thread spin until it becomes available.  */
    int
    _pthread_spin_lock (__pthread_spinlock_t *lock)
    {
      int I;
    
      while (1)
        {
          for (i = 0; i < __pthread_spin_count; I++)
        {
          if (__pthread_spin_trylock (lock) == 0)
            return 0;
        }
    
          __sched_yield ();
        }
    }
    
    • 注意事项:
      了解了自旋锁的特性,我们就发现这个锁其实非常适合临界区非常短的场合,或者实时性要求比较高的场合。
      由于临界区短,线程需要等待的时间也短,即便轮询浪费CPU资源,也浪费不了多少,还省了context切换的开销。 由于实时性要求比较高,来不及等待context切换的时间,那就只能浪费CPU资源在那儿轮询了。
      不过说实话,大部分情况你都不会直接用到空转锁,其他锁在申请不到加锁时也是会空转一定时间的,如果连这段时间都无法满足你的请求,那要么就是你扔的线程太多,或者你的临界区没你想象的那么短。

    OSSpinLock

    OSSpinLock 是一把自旋锁,性能很高。原理很简单,就是一直 do while 忙等。它的缺点是当等待时会消耗大量 CPU 资源,所以它不适用于较长时间的任务。

    OSSpinLock是整数类型。约定是解锁为零,锁定为非零。锁必须自然对齐,并且不能在缓存抑制的内存中。

    如果锁已经被持有,OSSpinLockLock()将自旋,但会使用各种各样的策略来后退,使其对大多数优先级反转活锁免疫。但因为它可以旋转,所以在某些情况下可能效率低下。

    如果锁被持有,OSSpinLockTry()立即返回false,如果它获得了锁,则返回true。它不自旋。
    OSSpinLockUnlock()通过置零无条件地解锁锁。

    - (void)osspinlock_demo {
        __block OSSpinLock theLock = OS_SPINLOCK_INIT;
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            OSSpinLockLock(&theLock);
            NSLog(@"线程1");
            sleep(5);
            OSSpinLockUnlock(&theLock);
            NSLog(@"线程1解锁成功");
        });
        
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            sleep(1);
            OSSpinLockLock(&theLock);
            NSLog(@"线程2");
            OSSpinLockUnlock(&theLock);
        });
    }
    // 打印结果
    2021-02-27 22:05:13.526 ThreadLockControlDemo[2856:316247] 线程1
    2021-02-27 22:05:23.528 ThreadLockControlDemo[2856:316247] 线程1解锁成功
    2021-02-27 22:05:23.529 ThreadLockControlDemo[2856:316260] 线程2
    

    OSSpinLock 的问题

    新版 iOS 中,系统维护了 5 个不同的线程优先级/QoS: background,utility,default,user-initiated,user-interactive。高优先级线程始终会在低优先级线程前执行,一个线程不会受到比它更低优先级线程的干扰。这种线程调度算法会产生潜在的优先级反转问题,从而破坏了 spin lock。

    具体来说,如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,它会处于 spin lock 的忙等状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock。

    所以从iOS10.0开始,苹果弃用了OSSpinLock,并用os_unfair_lock进行替代。

    // 初始化
    os_unfair_lock unfair_lock = OS_UNFAIR_LOCK_INIT;
    // 加锁
    os_unfair_lock_lock(&unfair_lock);
    // 解锁
    os_unfair_lock_unlock(&unfair_lock);
    // 尝试加锁,可以加锁则立即加锁并返回 YES,反之返回 NO
    os_unfair_lock_trylock(&unfair_lock);
    

    不过,os_unfair_lock的实现属于互斥锁,当锁被占用的时候,线程处于阻塞状态,而非忙等。

    文章参考链接:
    pthread的各种同步机制
    不再安全的OSSpinLock

    相关文章

      网友评论

        本文标题:iOS多线程编程(七) 同步机制与锁

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