小码哥iOS学习笔记第二十天: 多线程的安全隐患

2,394 阅读8分钟

一、多线程的安全隐患

  • 资源共享
    • 1块资源 可能会被多个线程共享,也就是多个线程可能会访问同一块资源
    • 比如多个线程访问同一个对象、同一个变量、同一个文件
  • 当多个线程访问同一块资源时,很容易引发数据错乱和数据安全问题

二、多线程安全隐患示例01 – 存钱取钱

  • 模拟代码如下

  • 运行程序, 结果如下

  • 正常情况, 应该存5000, 取2500, 所以应该剩3500, 但是结果剩了2500
  • 再次运行模拟

  • 可以看到只剩了2000, 这就是多线程的安全隐患问题, 是数据错乱

三、多线程安全隐患示例02 – 卖票

  • 代码模拟如下

  • 运行程序, 模拟卖票

  • 一共卖出10张, 应该剩余0张, 但是结果却剩余3张, 说明数据出现了错乱

四、多线程安全隐患分析和解决方案

1、多线程安全隐患分析

2、多线程安全隐患的解决方案

  • 解决方案:使用线程同步技术(同步,就是协同步调,按预定的先后次序进行)
  • 常见的线程同步技术是:加锁

五、iOS中的线程同步方案

  • iOS中线程加锁有以下几种方案
OSSpinLock
os_unfair_lock
pthread_mutex
dispatch_semaphore
dispatch_queue(DISPATCH_QUEUE_SERIAL)
NSLock
NSRecursiveLock
NSCondition
NSConditionLock
@synchronized

六、准备代码

  • 将上面的多线程安全隐患示例01 – 存钱取钱多线程安全隐患示例02 – 卖票代码封装到一个BaseDemo类中, 具体代码如下图

  • BaseDemo暴露出五个方法, 两个测试调用, 三个线程调用
  • 创建AddLockDemo继承自BaseDemo

  • ViewController中代码如下

七、OSSpinLock(自旋锁)

  • OSSpinLock叫做自旋锁,等待锁的线程会处于忙等(busy-wait)状态,一直占用着CPU资源

1、解决存钱取钱卖票的安全隐患

  • 存钱取钱卖票中加入OSSpinLock

  • 运行程序, 多次点击屏幕试验, 都可以发现结果正确

2、OSSpinLock目前已经不再安全,可能会出现优先级反转问题

  • 一个程序中可能会有多个线程, 但是只有一个CPU
  • CPU给线程分配资源, 让他们穿插的执行, 比如有三个线程thread1thread2thread3
  • CPU通过分配, 让thread1执行一段时间后, 接着让thread2执行一段时间, 然后再让thread3执行一段时间
  • 这样就给了我们有多个线程同时执行任务的错觉
  • 而线程是有优先级的
    • 如果优先级高, CPU会多分配资源, 就会有更多的时间执行
    • 如果优先级低, CPU会减少分配资源, 那么执行的就会慢
  • 那么就可能出现低优先级的线程先加锁,但是CPU更多的执行高优先级线程, 此时就会出现类似死锁的问题
假设通过OSSpinLock给两个线程`thread1`和`thread2`加锁
thread优先级高, thread2优先级低
如果thread2先加锁, 但是还没有解锁, 此时CPU切换到`thread1`
因为`thread1`的优先级高, 所以CPU会更多的给`thread1`分配资源, 这样每次`thread1`中遇到`OSSpinLock`都处于使用状态
此时`thread1`就会不停的检测`OSSpinLock`是否解锁, 就会长时间的占用CPU
这样就会出现类似于死锁的问题

八、os_unfair_lock(互斥锁)

  • os_unfair_lock用于取代不安全的OSSpinLock, 从iOS10开始才支持
  • 从底层调用看, 等待os_unfair_lock锁的线程会处于休眠状态, 并非忙等
  • 需要导入头文件#import <os/lock.h>
// 初始化
os_unfair_lock lock = OS_UNFAIR_LOCK_INIT;
// 尝试加锁, 如果lcok已经被使用, 加锁失败返回false, 如果加锁成功, 返回true
os_unfair_lock_trylock(&lock);
// 加锁
os_unfair_lock_lock(&lock);
// 解锁
os_unfair_lock_unlock(&lock);

解决存钱取钱卖票的安全隐患

  • 在存钱取钱和卖票中加入os_unfair_lock

  • 运行程序, 多次点击屏幕试验, 都可以发现结果正确

九、pthread_mutex

  • mutex叫做互斥锁,等待锁的线程会处于休眠状态
  • 需要导入头文件#import <pthread.h>
// 初始化属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_DEFAULT);
// 初始化锁
pthread_mutex_t pthread;
pthread_mutex_init(&pthread, &attr);
// 销毁属性
pthread_mutexattr_destroy(&attr);
// 销毁锁
pthread_mutex_destroy(&pthread);
  • 属性类型的取值
#define PTHREAD_MUTEX_NORMAL		0
#define PTHREAD_MUTEX_ERRORCHECK	1
#define PTHREAD_MUTEX_RECURSIVE		2
#define PTHREAD_MUTEX_DEFAULT		PTHREAD_MUTEX_NORMAL

1、解决存钱取钱卖票的安全隐患

  • 导入头文件, 创建锁, 加锁解锁

  • 运行程度, 多次点击屏幕试验, 都可以发现结果正确

2、递归锁

  • 定义PthreadTest类继承自NSObject, 其中recursive是一个递归方法

  • ViewController中代码如下, 点击屏幕后调用PthreadTestrecursive方法

  • 点击屏幕, 可以看到发生了死锁, 这是因为recursive中调用recursive, 此时还没有解锁, 再次进行加锁, 所以发生了死锁

  • 设置pthread初始化时的属性类型为PTHREAD_MUTEX_RECURSIVE, 这样pthread就是一把递归锁

  • 递归锁允许同一线程内, 对同一把锁进行重复加锁, 所以可以看到递归方法调用成功

3、条件

  • PthreadTest中代码如下

  • ViewController中代码如下

  • 当点击屏幕时, 会在array中移除最后一个元素添加一个新元素, 代码中可以看到, 使用不同线程调用__remove__add两个方法

  • 现在的需求是, 只有在array不为空的情况下, 才能执行删除操作, 如果直接运行, 那么可能会先调用__remove在调用__add, 那么就与需求相违背

  • 所以, 我们可以使用条件对两个方法进行优化

  • 创建cond

  • array.count == 0时, 是程序进入休眠, 只有当array中添加了新数据后在发起信号, 将休眠的线程唤醒

  • 运行程序, 点击屏幕, 可以看到程序先进入__remove方法, 但是却在__add中添加新元素之后再移除元素

十、NSLock、NSRecursiveLock、NSCondition、NSConditionLock

  • NSLockNSRecursiveLockNSConditionNSConditionLock是基于pthread封装的OC对象

1、NSLock

  • AddLockDemo中代码如下, 直接使用NSLock进行加锁

  • ViewController中点击屏幕时调用方法

  • 运行程序, 点击屏幕, 可以看到结果正确

  • 查看GNUStep中关于NSLock的底层代码, 可以看到NSLock是基础pthread封装的normal

2、NSRecursiveLock

  • PthreadTest中代码如下, 使用NSRecursiveLock递归函数加锁解锁

  • ViewController中, 当点击屏幕时调用recursive方法

  • 运行程序, 点击屏幕, 可以看到递归锁的结果

  • 查看GNUStep中关于NSRecursiveLock的底层代码

3、NSCondition

  • PthreadTest中代码如下, 使用NSCondition加锁解锁

  • ViewController中, 当点击屏幕时调用pthreadTest方法

  • 可以看到, 先调用了__remove方法, 但是却在__add中给array添加了新元素之后, 才删除一个元素

  • 查看GNUStep中关于NSCondition的底层代码

4、NSConditionLock

  • NSConditionLock是对NSCondition的进一步封装
@interface NSConditionLock : NSObject <NSLocking> {
@private
    void *_priv;
}

// 初始化, 同时设置 condition
- (instancetype)initWithCondition:(NSInteger)condition;

// condition值
@property (readonly) NSInteger condition;

// 只有NSConditionLock实例中的condition值与传入的condition值相等时, 才能加锁
- (void)lockWhenCondition:(NSInteger)condition;
// 尝试加锁
- (BOOL)tryLock;
// 尝试加锁, 只有NSConditionLock实例中的condition值与传入的condition值相等时, 才能加锁
- (BOOL)tryLockWhenCondition:(NSInteger)condition;
// 解锁, 同时设置NSConditionLock实例中的condition值
- (void)unlockWithCondition:(NSInteger)condition;
// 加锁, 如果锁已经使用, 那么一直等到limit为止, 如果过时, 不会加锁
- (BOOL)lockBeforeDate:(NSDate *)limit;
// 加锁, 只有NSConditionLock实例中的condition值与传入的condition值相等时, 才能加锁, 时间限制到limit, 超时加锁失败
- (BOOL)lockWhenCondition:(NSInteger)condition beforeDate:(NSDate *)limit;
// 锁的name
@property (nullable, copy) NSString *name;

@end
  • 可以使用NSConditionLock设置线程的执行顺序

  • 运行程序, 可以看到打印顺序

十一、同步队列解决多线程隐患

  • 使用同步队列, 代码如下图

  • ViewController代码如下

  • 点击屏幕, 可以看到结果正确

十二、dispatch_semaphore_t

  • 可以使用dispatch_semaphore_t设置信号量为1, 来控制同意之间只有一条线程能执行, 实际代码如下

  • 运行程序, 点击屏幕, 可以看到打印结果正确

十三、@synchronized

  • @synchronized是对mutex递归锁的封装
  • 源码查看:objc4中的objc-sync.mm文件
  • @synchronized(obj)内部会生成obj对应的递归锁,然后进行加锁、解锁操作

1、解决多线程的安全隐患

  • 使用@synchronized进行加锁

  • 执行代码, 点击屏幕, 效果如下

2、@synchronized底层原理

  • 找到objc_sync_enterobjc_sync_exit两个函数, 分别用于加锁和解锁

  • 查看SyncData

  • 通过所点进去, 找到recursive_mutex_tt

  • 查看recursive_mutex_tt, 可以看到底层是通过os_unfair_recursive_lock封装的锁

  • 接着查看通过对象获取锁的代码

  • 找到LIST_FOR_OBJ, 点击查看

  • 可以看到, 通过传入的对象, 会获取唯一标识所谓锁

十四、iOS线程同步方案性能比较

性能从高到低排序
os_unfair_lock
OSSpinLock
dispatch_semaphore
pthread_mutex
dispatch_queue(DISPATCH_QUEUE_SERIAL)
NSLock
NSCondition
pthread_mutex(recursive)
NSRecursiveLock
NSConditionLock
@synchronized

十五、自旋锁、互斥锁比较

  • 什么情况使用自旋锁比较划算?
    • 预计线程等待锁的时间很短
    • 加锁的代码(临界区)经常被调用,但竞争情况很少发生
    • CPU资源不紧张
    • 多核处理器
  • 什么情况使用互斥锁比较划算?
    • 预计线程等待锁的时间较长
    • 单核处理器
    • 临界区有IO操作
    • 临界区代码复杂或者循环量大
    • 临界区竞争非常激烈