美文网首页夯实基础
iOS开发之多线程(6)—— 线程安全与各种锁

iOS开发之多线程(6)—— 线程安全与各种锁

作者: 看影成痴 | 来源:发表于2020-07-15 17:37 被阅读0次

    文集

    iOS开发之多线程(1)—— 概述
    iOS开发之多线程(2)—— Thread
    iOS开发之多线程(3)—— GCD
    iOS开发之多线程(4)—— Operation
    iOS开发之多线程(5)—— Pthreads
    iOS开发之多线程(6)—— 线程安全与各种锁

    目录

    1. 线程安全
      1.1 线程不安全示例
      1.2 线程安全
      1.3 互斥

    2. dispatch_semaphore 信号量
      OSSpinLock 自旋锁
      pthread_mutex 互斥锁
      pthread_mutex (RECURSIVE) 递归锁
      pthread_mutex + pthread_cond 条件锁
      NSLock 互斥锁
      NSRecursiveLock 递归锁
      NSCondition 条件锁
      NSConditionLock 条件锁
      @synchronized

    1. 线程安全

    1.1 线程不安全示例

    线程可与其同属一个进程的其他的线程共享进程所拥有的全部资源, 由于线程间是可以并发执行的, 这就可能导致多个线程同时访问(读或写)同一内存地址 (即数据竞赛), 从而导致一些不可预计的结果.
    示例
    模拟买票系统, 没有使用锁, 是线程不安全的:

    @property (nonatomic, assign)   int tickets;
    
    - (void)initData {
        
        self.tickets = 10;
    }
    
    - (void)sample {
        
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            while (self.tickets) {
                [self sellTickets];
            }
            NSLog(@"窗口A, 票卖完了");
        });
        
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            while (self.tickets) {
                [self sellTickets];
            }
            NSLog(@"窗口B, 票卖完了");
        });
    }
    
    
    - (void)sellTickets {
                
        if (self.tickets) {
            sleep(0.2);         // 出票中
            self.tickets--;
            NSLog(@"出票一张, 余票:%d", self.tickets);
        }
    }
    
    // ----------------------------------------------------------------------------------
    log:
    出票一张, 余票:9
    出票一张, 余票:9
    出票一张, 余票:8
    出票一张, 余票:8
    出票一张, 余票:7
    出票一张, 余票:7
    出票一张, 余票:6
    出票一张, 余票:5
    出票一张, 余票:4
    出票一张, 余票:3
    出票一张, 余票:2
    出票一张, 余票:1
    出票一张, 余票:0
    窗口A, 票卖完了
    窗口B, 票卖完了
    

    反复运行, 很容易出现问题. 结果显然不是我们想要的.
    另需注意, 这里没有在窗口A或者B中打印余票, 是因为某个窗口将要打印时, 有可能被另一窗口卖掉一张从而影响余票. 正确做法是在sellTickets里实时监听票数然后通知窗口A和B来刷新显示, 这里不做演示.

    这里只列举一个例子, 类似这样的案例很多, 但归根还是因多个线程同时访问同一内存地址导致的不可预计结果.
    为了解决这类问题, 我们需要引入线程安全机制.

    1.2 线程安全

    线程安全是一种适用于多线程代码的计算机编程概念。线程安全代码仅以确保所有线程正常运行并满足其设计规范的方式操作共享数据结构,而不会发生意外交互。

    建立线程安全数据结构的策略多种多样, 可分为两类方法:

    第一类方法着重于避免共享状态, 包括:

    1. 重入
    2. 线程本地存储
    3. 不变的对象

    第二类方法与同步相关, 并且在无法避免共享状态的情况下使用:

    1. 互斥
    2. 原子操作

    第一类方法着重于避免数据共享, 而我们要讨论的是如何在多线程间安全地进行数据共享, 故此类方法在这里不做讨论.
    第二类方法着重于同步机制 (线程同步和数据同步). 第一种互斥将在下文1.3小节单独介绍; 而第二种原子操作 (原子操作atomic是不可分割的操作, 在原子操作执行完毕之前, 其不会被任何其它任务或事件中断). atomic在iOS系统中比较耗费资源从而基本不用, 它比较适用于macOS中. 而且, 苹果开发文档已经明确指出: Atomic不能保证对象多线程的安全. 它只是能保证你访问的时候给你返回一个完好无损的Value而已.

    1.3 互斥

    wikipedia
    计算机科学中互斥并发控制的属性,它是为了防止竞争条件而建立的。它是要求,即一个执行线程不会进入其临界段在该另一同一时间的并发执行的线程进入其自己的关键部分,它是指一个时间间隔,在此期间执行的线程访问共享资源,如共享内存

    互斥解决的问题是资源共享的问题, 防止在同一时间有两个(或以上)线程争夺同一资源. 互斥也可以认为是一种同步机制.
    互斥的解决方案也有很多种:

    而我们要讨论的是在编码中常会用到的——锁.

    2. 锁

    锁是一种同步机制, 用于在存在许多执行线程的环境中强制限制对资源的访问.
    锁的分类五花八门, 各种名词层出不穷. 这里不打算花时间去做一个详细总结, 至少我不愿意那样做. 🤷‍♀️🤷‍♀️

    加锁势必会带来额外的开销, 以下是ibireme对各种锁所做的性能测试, 具有参考价值(图片来源):

    lock_benchmark.png

    OSSpinLock耗时最短, 故性能最好(可惜已不再安全); @synchronized耗时最长, 性能最差.

    为了便于讨论, 我们将介绍顺序重新整理如下:

    • dispatch_semaphore 信号量
    • OSSpinLock 自旋锁
    • pthread_mutex 互斥锁
    • pthread_mutex (RECURSIVE) 递归锁
    • pthread_mutex + pthread_cond 条件锁
    • NSLock 互斥锁 (封装pthread_mutex)
    • NSRecursiveLock 递归锁 (封装pthread_mutex (RECURSIVE))
    • NSCondition 条件锁 (封装pthread_mutex + pthread_cond)
    • NSConditionLock 互斥锁 (封装NSCondition + condition(NSInteger类型))
    • @synchronized

    dispatch_semaphore 信号量

    如果信号量是一个任意的整数,通常被称为计数信号量(Counting semaphore),或一般信号量(general semaphore);如果信号量只有二进制的0或1,称为二进制信号量(binary semaphore)。在linux系统中,二进制信号量(binary semaphore)又称互斥锁(Mutex)

    最简单的二进制信号量可以看做是一种互斥锁. 信号量可看成一种锁, 或者说是对锁的升级, 也是为了解决资源竞争问题.

    信号量和互斥锁的区别是:

    • 互斥锁: 提供对共享资源的互斥访问, 强调唯一性和排他性, 但是无法限制资源释放后其他线程申请的顺序问题. 比如说, 线程A正在占用资源, 同时线程B和线程C在等待者, 当A释放资源后, B和C谁先抢得资源是随机的.
    • 信号量: 在资源互斥的基础上, 实现了对线程的调度功能, 当然也保证了数据的同步. 比如上个例子, A释放资源后, 可指定先分配给B或者C.

    GCD中信号量只有三个方法:

    // 创建
    dispatch_semaphore_t dispatch_semaphore_create(long value);
    // 等待
    long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout);
    // 发送信号
    long dispatch_semaphore_signal(dispatch_semaphore_t dsema);
    
    • 创建: 传入一个非负long值, 返回一个dispatch_semaphore_t类型信号量. 当前信号值即为传入的long值, 一般传0. 如果传负数, 会返回nil, 创建失败.
    • 等待: 如果执行该操作将会对信号值减1, 但是要求减之后信号值不能小于0. 所以, 只有当前信号量大于等于1时, 才会执行该操作, 否则将一直等待, 直到time out.
    • 发送信号: 会使信号值加1. 多次调用则多次加1.

    一般用法:

        // 创建信号量 (此时信号值为0)
        dispatch_semaphore_t sem = dispatch_semaphore_create(0);
        
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            
            // do something
            
            // 发送信号 (信号值加1)
            dispatch_semaphore_signal(sem);
        });
        
        // 等待 (当信号值大于或等于1, 此时可减, 才结束等待)
        dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
    

    示例

    - (void)semaphore {
    
        // 创建信号量
        dispatch_semaphore_t sem = dispatch_semaphore_create(0);
    
        // 线程A
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            NSLog(@"线程A");
            dispatch_semaphore_signal(sem);
        });
        dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
    
        // 线程B
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            NSLog(@"线程B");
            dispatch_semaphore_signal(sem);
        });
        dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
    
        // 线程C
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            NSLog(@"线程C");
            dispatch_semaphore_signal(sem);
        });
        dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
    }
    
    // ----------------------------------------------------------------------------------
    log:
    线程A
    线程B
    线程C
    

    虽然都是异步执行, 但是由于使用信号量, 不管执行多少次, 结果都是按照线程A->B->C顺序执行的.

    OSSpinLock 自旋锁

    OSSpinLock属于自旋锁.
    它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。
    但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。
    由于不再安全, 这里不讨论用法了.

    pthread_mutex 互斥锁

    还是以卖票为例, 加锁后线程才是安全的:

    #import <pthread.h>
    
    @property (nonatomic, assign)   int globalInt;
    @property (nonatomic, assign)   pthread_mutex_t mutex;
    
    - (void)initData {
        
        self.tickets = 10;
        
        // 需将锁设为全局变量, 不然会有警告
        pthread_mutex_init(&self->_mutex, NULL);
    }
    
    
    - (void)dealloc
    {
        // 销毁锁
        pthread_mutex_destroy(&_mutex);
    }
    
    - (void)sample {
        
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            while (self.tickets) {
                [self sellTickets];
            }
            NSLog(@"窗口A, 票卖完了");
        });
        
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            while (self.tickets) {
                [self sellTickets];
            }
            NSLog(@"窗口B, 票卖完了");
        });
    }
    
    
    - (void)sellTickets {
            
        pthread_mutex_lock(&self->_mutex);      // 加锁
        
        if (self.tickets) {
            sleep(0.2);         // 出票中
            self.tickets--;
            NSLog(@"出票一张, 余票:%d", self.tickets);
        }
            
        pthread_mutex_unlock(&self->_mutex);    // 解锁
    }
    
    // ----------------------------------------------------------------------------------
    log:
    出票一张, 余票:9
    出票一张, 余票:8
    出票一张, 余票:7
    出票一张, 余票:6
    出票一张, 余票:5
    出票一张, 余票:4
    出票一张, 余票:3
    出票一张, 余票:2
    出票一张, 余票:1
    出票一张, 余票:0
    窗口A, 票卖完了
    窗口B, 票卖完了
    

    在上面例子中, 比如当线程A持有锁时, 线程B去获取锁, 线程B会被强制挂起等待, 直到线程A释放锁, 线程B才能获取锁继续运行.
    有时候我们不想挂起等待而去做些其他事情, 那么就可以使用pthread_mutex_trylock()尝试加锁:

    - (void)sellTickets {
            
        if (pthread_mutex_trylock(&self->_mutex) == 0) {    // 尝试加锁
            // 加锁成功
            if (self.tickets) {
                sleep(0.2);         // 出票中
                self.tickets--;
                NSLog(@"出票一张, 余票:%d", self.tickets);
            }
            
            pthread_mutex_unlock(&self->_mutex);            // 解锁
            
        }else {
            
            // 加锁失败
            NSLog(@"喝口水");
            sleep(0.5);
        }
    }
    

    pthread_mutex (RECURSIVE) 递归锁

    如果函数存在递归调用, 那么重复pthread_mutex_lock()加锁会导致死锁. 我们可以改变初始化时的属性, 将锁类型改为递归锁.

    - (void)initData {
        
        self.tickets = 10;
        
    //    // 需将锁设为全局变量, 不然会有警告
    //    pthread_mutex_init(&self->_mutex, NULL);
        
        // 初始化属性
        pthread_mutexattr_t attr;
        pthread_mutexattr_init(&attr);
        pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
        // 初始化锁
        pthread_mutex_init(&self->_mutex, &attr);
        // 销毁属性
        pthread_mutexattr_destroy(&attr);
    }
    
    
    - (void)sellTickets {
        
        pthread_mutex_lock(&self->_mutex);
        
        // 加锁成功
        if (self.tickets) {
            sleep(0.2);         // 出票中
            self.tickets--;
            NSLog(@"出票一张, 余票:%d", self.tickets);
            
            if (self.tickets) {
                [self sellTickets];
            }
        }
        
        pthread_mutex_unlock(&self->_mutex);            // 解锁
    }
    

    锁的默认属性是PTHREAD_MUTEX_NORMAL, 不能用于递归实现.
    将属性改成PTHREAD_MUTEX_RECURSIVE后称为递归锁.

    pthread_mutex + pthread_cond 条件锁

    还是卖票的例子, 我们加入一个退票窗口C. 如果票卖完了, 窗口A和B进入等待, 直到窗口C有人退票后, 窗口A和B继续卖票. 其实也就是生产者--消费者问题.

    #import "ViewController.h"
    #import <pthread.h>
    
    @interface ViewController ()
    
    @property (nonatomic, assign)   int tickets;
    @property (nonatomic, assign)   pthread_mutex_t mutex;
    @property (nonatomic, assign)   pthread_cond_t  cond;
    
    @end
    
    @implementation ViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        
        [self initData];
        
        [self sample];
        
        sleep(5);
        
        [self refundTicket];
    }
    
    
    - (void)initData {
        
        self.tickets = 10;
        
        // 需将锁设为全局变量, 不然会有警告
        pthread_mutex_init(&self->_mutex, NULL);
    
        // 初始化条件
        pthread_cond_init(&self->_cond, NULL);
    }
    
    
    - (void)dealloc
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_cond);
    }
    
    
    - (void)sample {
        
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            while (1) {
                [self sellTickets];
            }
        });
        
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            while (1) {
                [self sellTickets];
            }
        });
    }
    
    
    - (void)sellTickets {
        
        pthread_mutex_lock(&self->_mutex);
        
        if (self.tickets == 0) {
            NSLog(@"无票, 等待中");
            // 等待
            pthread_cond_wait(&self->_cond, &self->_mutex);
        }
        
        sleep(0.2);         // 出票中
        self.tickets--;
        NSLog(@"出票一张, 余票:%d", self.tickets);
    
        pthread_mutex_unlock(&self->_mutex);
    }
    
    
    - (void)refundTicket {
        
        pthread_mutex_lock(&self->_mutex);
        
        self.tickets++;
        NSLog(@"退票一张");
        
        // 激活一个等待该条件的线程
        pthread_cond_signal(&self->_cond);
    
        // 激活所有等待该条件的线程
    //    pthread_cond_broadcast(&self->_cond);
        
        pthread_mutex_unlock(&self->_mutex);
    }
    
    
    // ----------------------------------------------------------------------------------
    log:
    出票一张, 余票:9
    出票一张, 余票:8
    出票一张, 余票:7
    出票一张, 余票:6
    出票一张, 余票:5
    出票一张, 余票:4
    出票一张, 余票:3
    出票一张, 余票:2
    出票一张, 余票:1
    出票一张, 余票:0
    无票, 等待中
    无票, 等待中
    退票一张
    出票一张, 余票:0
    无票, 等待中
    

    NSLock 互斥锁

    Apple在GitHub上面开源了Swift版Foundation源码 (https://github.com/apple/swift-corelibs-foundation), 里面可以找到NSLock的定义实现.

    swift_fundation.png

    由此可知, NSLock内部封装了pthread_mutex. 类似的, NSCondition / NSConditionLock / NSRecursiveLock 都是基于pthread_mutex封装的.

    NSLock封装pthread_mutex的属性为PTHREAD_MUTEX_ERRORCHECK, 它会损失一定性能换来错误提示. NSLock比pthread_mutex略慢的原因在于它需要经过方法调用, 同时由于缓存的存在, 多次方法调用不会对性能产生太大的影响.

    NSLock特性:

    • lock和unlock必须在同一个线程完成;
    • 两次调用lock会造成死锁, 也就是不能用于递归实现;
    • 性能比pthread_mutex略慢;
    • 好用

    NSLock的四个方法:

    - (void)lock;
    - (void)unlock;
    - (BOOL)tryLock;
    - (BOOL)lockBeforeDate:(NSDate *)limit;  // 在指定Date之前一直尝试加锁, 如果失败, 则返回NO
    

    用法 (参照之前pthread_mutex例子):

    - (void)initData {
        
        self.tickets = 10;
        self.lock = [[NSLock alloc] init];
    }
    
    - (void)sellTickets {
            
        [self.lock lock];      // 加锁
        
        if (self.tickets) {
            sleep(0.2);         // 出票中
            self.tickets--;
            NSLog(@"出票一张, 余票:%d", self.tickets);
        }
            
        [self.lock unlock];    // 解锁
    }
    

    NSRecursiveLock 递归锁

    为了弥补NSLock不能用于递归的问题.
    用法 (参照之前pthread_mutex例子):

    - (void)initData {
        
        self.tickets = 10;
        self.recursiveLock = [[NSRecursiveLock alloc] init];
    }
    
    - (void)sellTickets {
        
        [self.recursiveLock lock];
        
        // 加锁成功
        if (self.tickets) {
            sleep(0.2);         // 出票中
            self.tickets--;
            NSLog(@"出票一张, 余票:%d", self.tickets);
            
            if (self.tickets) {
                [self sellTickets];
            }
        }
        
        [self.recursiveLock unlock];            // 解锁
    }
    

    NSCondition 条件锁

    NSCondition被NSConditionLock所封装, 但是只谈NSCondition的话, 两者就没有必然联系.
    虽然NSCondition不带lock字样, 但是他也是一种锁, 也遵循锁协议NSLocking.
    NSCondition封装了pthread_mutex + pthread_cond.

    用法 (参照之前pthread_mutex例子):

    - (void)initData {
        
        self.tickets = 10;
        self.condition = [[NSCondition alloc] init];
    }
    
    
    - (void)sellTickets {
        
    //    pthread_mutex_lock(&self->_mutex);
        [self.condition lock];
        
        if (self.tickets == 0) {
            NSLog(@"无票, 等待中");
            // 等待
    //        pthread_cond_wait(&self->_cond, &self->_mutex);
            [self.condition wait];
        }
        
        sleep(0.2);         // 出票中
        self.tickets--;
        NSLog(@"出票一张, 余票:%d", self.tickets);
    
    //    pthread_mutex_unlock(&self->_mutex);
        [self.condition unlock];
    }
    
    
    - (void)refundTicket {
        
    //    pthread_mutex_lock(&self->_mutex);
        [self.condition lock];
        
        self.tickets++;
        NSLog(@"退票一张");
        
        // 激活一个等待该条件的线程
    //    pthread_cond_signal(&self->_cond);
        [self.condition signal];
        
        // 激活所有等待该条件的线程
    //    pthread_cond_broadcast(&self->_cond);
    //    [self.condition broadcast];
        
    //    pthread_mutex_unlock(&self->_mutex);
        [self.condition unlock];
    }
    

    NSConditionLock 条件锁

    NSConditionLock 封装了 NSCondition 和 一个NSInteger类型的condition.
    NSConditionLock 可以称为条件锁, 只有 condition 参数与初始化时候的 condition 相等,lock 才能正确进行加锁操作.
    NSConditionLock 类似于信号量, 但不是对信号量的封装, 加入condition能实现线程间的依赖.

    🌰

    - (void)initData {
        
        self.conditionLock = [[NSConditionLock alloc] initWithCondition:0];
    }
    
    
    - (void)sample2 {
        
        // 线程1
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            [self.conditionLock lockWhenCondition:1];           // condition与init时设置的条件0不符, 故需等待
            NSLog(@"线程1");
            sleep(2);
            [self.conditionLock unlock];
        });
        
        // 线程2
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            sleep(1);   // 跑在线程1之后
            if ([self.conditionLock tryLockWhenCondition:0]) {  // 与init时条件0相符, 先持有锁, 执行
                NSLog(@"线程2");
                [self.conditionLock unlockWithCondition:2];     // 解锁后, 将条件值改为2, 让线程3和线程4先跑
                NSLog(@"线程2解锁成功");
            } else {
                NSLog(@"线程2尝试加锁失败");
            }
        });
        
        // 线程3
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            sleep(2);   // 跑在线程2之后
            if ([self.conditionLock tryLockWhenCondition:2]) {
                NSLog(@"线程3");
                [self.conditionLock unlock];
                NSLog(@"线程3解锁成功");
            } else {
                NSLog(@"线程3尝试加锁失败");
            }
        });
        
        //线程4
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            sleep(3);   // 跑在线程3之后
            if ([self.conditionLock tryLockWhenCondition:2]) {  // 此时条件还是2, 等线程3释放锁后才能持有锁
                NSLog(@"线程4");
                [self.conditionLock unlockWithCondition:1];     // 解锁后, 条件改为1, 让线程1跑起来
                NSLog(@"线程4解锁成功");
            } else {
                NSLog(@"线程4尝试加锁失败");
            }
        });
    }
    
    // ----------------------------------------------------------------------------------
    log:
    线程2
    线程2解锁成功
    线程3
    线程3解锁成功
    线程4
    线程4解锁成功
    线程1
    

    unlockWithCondition: 并不是当 condition 符合条件时才解锁, 而是解锁之后, 修改 condition 的值.

    @synchronized

    关于synchronized, 这篇文章讲得比较深入http://rykap.com/objective-c/2015/05/09/synchronized/

    一些注意事项:

    • @synchronized既是互斥锁也是递归锁
    • @synchronized(NSObject) 括号里可以是任意NSObject, 一般传入VC的self
    • @synchronized(NSObject) 只有当NSObject是同一个时, 才满足互斥
    • 如果在@synchronized(NSObject) 内部 NSObject 被释放或被设为 nil, 不影响结果; 但如果 NSObject 一开始就是 nil, 则失去了锁的功能
    • @synchronized会自动释放互斥锁
    • @synchronized很耗性能, 应谨慎使用

    用法:

    @synchronized (self) {
        ...
    }
    

    相关文章

      网友评论

        本文标题:iOS开发之多线程(6)—— 线程安全与各种锁

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