iOS 线程同步

4,976 阅读12分钟

多线程相关的概念

  • 时间片轮转调度算法:是目前操作系统中大量使用的线程管理方式,大致就是操作系统会给每个线程分配一段时间片(通常 100 ms 左右),这些线程都被放在一个队列中,CPU 只需要维护这个队列,当队首的线程时间片耗尽就会被强制放到队尾等待,然后提取下一个队首线程执行
  • 原子操作:“原子”一般指最小粒度,不可分割;原子操作也就是不可分割,不可中断的操作
  • 临界区 :每个进程中访问临界资源的那段代码称为临界区(Critical Section)(临界资源是一次仅允许一个进程使用的共享资源)。每次只准许一个进程进入临界区,进入后不允许其他进程进入。不论是硬件临界资源,还是软件临界资源,多个进程必须互斥地对它进行访问
  • 忙等(busy-waiting): 试图进入临界区的线程,占着 CPU 而不释放的状态
  • 睡眠(sleep-waiting):试图进入临界区的线程,会进入睡眠状态,主动让出时间片,不会再占着 CPU 而不释放
  • 上下文切换(Context Switch):当线程进入睡眠(sleep-waiting)的时候,cpu的核心会进行上下文切换,将该线程置于等待队列中,而其他线程就会继续执行任务,上下文切换需要花费时间
  • 锁的拥有者(Lock Ownership):如果锁没有拥有者,则当它被某一条线程获取时,其他任意一条线程都可以对它进行解锁;如果锁只能有单一的拥有者,则当它被某一条线程获取时,只有这条线程可以对它进行解锁;如果锁可以有多个拥有者,则它可以同时被某多条线程获取
  • 死锁:指两个或两个以上的进程(线程)在运行过程中因争夺资源而造成的一种僵局(Deadly-Embrace) ) ,若无外力作用,这些进程(线程)都将无法向前推进。一般在获得锁的线程中再次进行加锁就会发生死锁
  • 饥饿(Starvation):指一个进程一直得不到资源

Lock Ownership

线程同步方案

要保证线程安全,就必须要线程同步,而在iOS中线程同步的方案有:

  • 原子操作
  • 信号量
  • GCD串行队列

原子操作

在 iOS 中,原子操作可以保证属性在单独的 setter 或者 getter 方法中是线程安全的,但是不能保证多个线程对同一个属性进行读写操作时,可以得到预期的值,也就是原子操作不保证线程安全,例如:

// 共享资源name
@property (copy, atomic) NSString *name;
// 初始化
self.name = @"A";

// 线程2进行写操作,是原子操作,不可以分割的
self.name = @"B";

// 线程3进行写操作,是原子操作,不可以分割的
self.name = @"C";

// 线程4进行读操作,是原子操作,不可以分割的,但这时候存在三种可能
self.name == @"A";
self.name == @"B";
self.name == @"C";

Objective-C 的原子操作

在 Objective-C 中,可以在设置属性的时候,使用 atomic 来设置原子属性,保证属性 settergetter 的原子性操作,底层是在 gettersetter 内部使用 os_unfair_lock 加锁

@property (copy, atomic) NSString *name;

Swift 的原子操作

在 Swift 中,原生没有提供原子操作,可以使用 DispatchQueue 的同步函数来达到同样的效果

class Person {
  // 创建一个队列
  let queue = DispatchQueue(label: "Person")

  // 私有化需要原子操作的属性
  private var _name: String = ""

  // 向外界暴露的属性,把它的 get 和 set 方法都设置为同步操作,实际上是对 _name 进行操作,这样就可以间接的对 name 进行原子操作
  var name: String {
      get {
          return queue.sync {
              _name
          }
      }
      set {
          return queue.sync {
              _name = newValue
          }
      }
  }
}

信号量(Semaphore)

  • 信号量(semaphore)是非负整型变量,在初始化时设置一个值 value,用来控制线程并发访问的最大数量,当 value == 1 的时候,就可以实现线程同步
  • 信号量有两个原子操作:wait()signal()
    • wait():当 value > 0,就将 value 减 1 并马上返回;当 value == 0,那当前线程就会睡眠,直到其他线程调用 signal() 把 value 加 1,当前线程恢复,然后将 value 减 1 并返回
    • signal() :将 value 加 1
    • 如果初始化的时候 value 为 0, 那么调用 wait() 方法就会马上挂起当前线程,直到别的线程调用了 signal() 方法,才会恢复
  • 被阻塞线程会进入睡眠状态
  • 信号量不支持递归
  • 信号量没有拥有者(Owner),意味着可以在一条线程进行 wait() 操作,在另外一条线程进行 signal() 操作
  • 在 iOS 中用 dispatch_semaphore 来使用信号量,也是 GCD 用来同步的一种方式
// 初始化一个值为 5 的信号量,可以同时有 5 条线程访问临界区,其他线程则进入睡眠状态
dispatch_semaphore_t semaphore = dispatch_semaphore_create(5);


// wait
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);

// 临界区...

// signal
dispatch_semaphore_signal(semaphore);

GCD串行队列

  • 使用 GCD 串行队列也可以达到同步的效果,配合 sync 函数就是在当前线程执行任务
  • GCD 串行队列有单一的拥有者,就是一个串行队列有对应的线程
dispatch_queue_t queue = dispatch_queue_create("serialQueue", DISPATCH_QUEUE_SERIAL);
dispatch_sync(queue, ^{
    // 临界区...
});

OSSpinLock

  • OSSpinLock 是一种"自旋锁"。自旋锁是一种特殊互斥锁,当一个线程需要获取自旋锁时,如果该锁已经被其他线程占用,那么会一直去请求锁,进入 忙等(busy-waiting) 状态,所以会一直占用 CPU
  • 由于自旋锁在等待锁的时候线程一直处于忙等状态,而不用进入睡眠,所以不用进行上下文切换,自旋锁的效率远高于互斥锁
  • 自旋锁适用于
    • 预计线程等待锁的时间很短
    • 临界区经常访问,但竞争情况很少发生
  • 自旋锁不安全,会出现优先级反转问题:如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,它会处于 忙等 状态从而占用大量 CPU 时间片。此时低优先级线程无法与高优先级线程争夺 CPU 时间片,从而导致完成任务而无法释放锁
  • 在 iOS 10 及以上被废弃
#import <libkern/OSAtomic.h>

OSSpinLock lock = OS_SPINLOCK_INIT;

// 加锁
OSSpinLockLock(&lock);

// 临界区...

// 解锁
OSSpinLockUnlock(&lock);

os_unfair_lock

  • os_unfair_lock 用于取代不安全的 OSSpinLock ,iOS 10 开始支持,当一条线程等待锁的时候会进入睡眠,不再消耗 CPU 时间,当其他线程解锁以后,操作系统会激活线程
  • os_unfair_lock 有单一的拥有者
  • 这是一种不公平锁。在公平锁中,多个线程同时竞争这个锁的时候, 会考虑公平性尽可能的让不同的线程获得锁,这样会频繁进行上下文切换,牺牲性能。而在不公平锁中,系统为了减少上下文切换,当前拥有锁的线程有可能会再次获得锁,但这样做可能会让其他线程等待更长时间,造成饥饿
#import <os/lock.h>

os_unfair_lock lock = OS_UNFAIR_LOCK_INIT;


// 加锁
os_unfair_lock_lock(&lock);

// 临界区...

// 解锁
os_unfair_lock_unlock(&lock);

互斥锁

  • 互斥锁是可以看作是一种特殊的信号量,当一条线程等待锁的时候会进入睡眠状态
  • 互斥锁阻塞的过程分两个阶段,第一阶段是会先空转,可以理解成跑一个 while 循环,不断地去申请加锁,在空转一定时间之后,线程会进入睡眠状态,让出时间片,此时线程就不占用 CPU 时间片,等锁可用的时候,这个线程会立即被唤醒

pthread_mutex

pthread 表示 POSIX thread,是 POSIX 标准的 unix 多线程库,定义了一组跨平台的线程相关的API。pthread_mutex 是一种用 C 语言实现的互斥锁,有单一的拥有者

#import <pthread.h>

// 静态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;


// 动态初始化
// 初始化属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_DEFAULT);
// 初始化锁
pthread_mutex_t mutex;
pthread_mutex_init(mutex, &attr);
// 销毁属性
pthread_mutexattr_destroy(&attr);


// 加锁
pthread_mutex_lock(&mutex);

// 临界区...

// 解锁
pthread_mutex_unlock(&mutex);

// 销毁锁
pthread_mutex_destroy(&_mutex);

NSLock

  • NSLock 是以 Objective-C 对象的形式对 pthread_mutex 的封装,属性为 PTHREAD_MUTEX_ERRORCHECK,它会损失一定性能换来错误提示
  • NSLockpthread_mutex 略慢的原因在于它需要经过方法调用,同时由于缓存的存在,多次方法调用不会对性能产生太大的影响
  • NSLock 有单一的拥有者
NSLock *lock = [[NSLock alloc] init];

// 加锁
[lock lock];

// 临界区...

// 解锁
[lock unlock];

递归锁

递归锁是一种特殊互斥锁。递归锁允许单个线程在释放之前多次获取锁,其他线程保持睡眠状态,直到锁的所有者释放锁的次数与获取它的次数相同。递归锁主要在递归迭代中使用,但也可能在多个方法需要单独获取锁的情况下使用。

pthread_mutex(Recursive)

pthread_mutex 支持递归锁,只要把 attr 的类型改成 PTHREAD_MUTEX_RECURSIVE 即可,它有单一的拥有者

#import <pthread.h>

// 初始化属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
// 初始化锁
pthread_mutex_t mutex;
pthread_mutex_init(mutex, &attr);
// 销毁属性
pthread_mutexattr_destroy(&attr);


// 加锁
pthread_mutex_lock(&_mutex);


// 临界区...
// 在同一个线程中可以多次获取锁

// 解锁
pthread_mutex_unlock(&_mutex);


// 销毁锁
pthread_mutex_destroy(&_mutex);

NSRecursiveLock

NSRecursiveLock 是以 Objective-C 对象的形式对 pthread_mutex(Recursive) 的封装,它有单一的拥有者

NSRecursiveLock *lock = [[NSRecursiveLock alloc] init];

// 加锁
[lock lock];

// 临界区...
// 在同一个线程中可以多次获取锁

// 解锁
[lock unlock];

@synchronized

  • @synchronized 是对 pthread_mutex(Recursive) 的封装,所以它支持递归加锁
  • 需要传入一个 Objective-C 对象,可以理解为把这个对象当做锁来使用
  • 实际上它是用 objc_sync_enter(id obj)objc_sync_exit(id obj) 来进行加锁和解锁
  • 底层实现:在底层存在一个全局用来存放锁的哈希表(可以理解为锁池),对传入的对象地址的哈希值作为key,去查找对应的递归锁
  • @synchronized 额外还会设置异常处理机制,性能消耗较大
  • @synchronized 有单一的拥有者
@synchronized(lock) {
    // 临界区...
}

条件锁

条件锁是一种特殊互斥锁,需要条件变量(condition variable) 来配合。条件变量有点像信号量,提供了线程阻塞与信号机制,因此可以用来阻塞某个线程,并等待某个数据就绪,随后唤醒线程。条件锁是为了解决 生产者-消费者模型

pthread_mutex – 条件锁

pthread_mutex 配合 pthread_cond_t,可以实现条件锁,其中 pthread_cond_t 没有拥有者

#import <pthread.h>

// 初始化锁
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, &NULL);
// 销毁属性
pthread_mutexattr_destroy(&attr);

// 初始化条件变量
pthread_cond_t cond;
pthread_cond_init(&cond, NULL);

// 消费者
- (void)remove {
    // 加锁
    pthread_mutex_lock(&mutex);

    // 先判断某个条件
    if (self.data.count == 0) {
        // 如果不满足条件,则等待,具体是释放锁,用条件变量来阻塞当前线程
        // 当条件满足的时候,条件变量唤醒线程,再用原来的锁加锁
        pthread_cond_wait(&cond, &mutex);
    }

    [self.data removeLastObject];


    // 解锁
    pthread_mutex_unlock(&mutex);
}


// 生产者
- (void)add
{
    // 加锁
    pthread_mutex_lock(&mutex);
    

    [self.data addObject:@"Test"];
    
    // 信号
    // 条件变量唤醒阻塞的线程
    pthread_cond_signal(&cond);
    // 广播
    // pthread_cond_broadcast(&cond);
    
    // 解锁
    pthread_mutex_unlock(&mutex);
}


// 销毁
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);

NSCondition

NSCondition 是以 Objective-C 对象的形式对 pthread_mutexpthread_cond_t 进行了封装,NSCondition 没有拥有者

NSCondition *condition = [[NSCondition alloc] init];

// 消费者
- (void)remove
{
    [condition lock];

    
    if (self.data.count == 0) {
        // 如果不满足条件,则等待,具体是释放锁,用条件变量来阻塞当前线程
        // 当条件满足的时候,条件变量唤醒线程,再用原来的锁加锁
        [condition wait];
    }
    
    [self.data removeLastObject];
    
    [condition unlock];
}


// 生产者
- (void)add
{
    [condition lock];
    
    
    [self.data addObject:@"Test"];
    
    // 信号
    // 条件变量唤醒阻塞的线程
    [condition signal];
    
    
    [condition unlock];
}

NSConditionLock

NSConditionLock 是对 NSCondition 的进一步封装,可以设置条件变量的值。通过改变条件变量的值,可以使任务之间产生依赖关系,达到使任务按照一定的顺序执行,它有单一的拥有者(不确定)

// 初始化设置条件变量的为1,如果不设置则默认为0
NSConditionLock *lock = [[NSConditionLock alloc] initWithCondition:1];


// 消费者
- (void)remove
{
    // 当条件变量为2的时候加锁,否则等待
    [lock lockWhenCondition:2];
    
    [self.data removeLastObject];
    
    // 直接解锁
    [lock unlock];
}


// 生产者
- (void)add
{
    // 直接加锁
    [lock lock];
    
    
    [self.data addObject:@"Test"];
    
    
    // 解锁并让条件变量为2
    [lock unlockWithCondition:2];
}

读写锁

读写锁是一种特殊互斥锁,提供"多读单写"的功能,多个线程可以同时对共享资源进行读取,但是同一时间只能有一条线程对共享资源进行写入

pthread_rwlock

pthread_rwlock 有多个拥有者

#import <pthread.h>

// 初始化
pthread_rwlock_t lock = PTHREAD_RWLOCK_INITIALIZER;


// 读操作
- (void)read {
    pthread_rwlock_rdlock(&lock);

    // 临界区...
  
    pthread_rwlock_unlock(&lock);
}

// 写操作
- (void)write
{
    pthread_rwlock_wrlock(&lock);
    
    // 临界区...
    
    pthread_rwlock_unlock(&lock);
}

// 销毁
- (void)dealloc
{
    pthread_rwlock_destroy(&lock);
}

GCD 的 Barrier函数

  • GCD 的 Barrier 函数也可以实现"多读单写"的功能
  • Barrier 函数的作用是:等其他任务执行完毕,才会执行任务自己的任务;会执行完毕自己的任务,才会继续执行其他任务
  • 这个函数传入的并发队列必须是自己通过 dispatch_queue_cretate 创建的,如果传入的是一个串行或是一个全局的并发队列,那这个函数便等同于 dispatch_async 函数的效果
dispatch_queue_t queue = dispatch_queue_create("rw_queue", DISPATCH_QUEUE_CONCURRENT);


dispatch_async(queue, ^{
    // 读
});

dispatch_async(queue, ^{
    // 读
});


dispatch_barrier_async(queue, ^{
    // 写
});

dispatch_async(queue, ^{
    // 读
});

性能

性能从高到底分别是:

  • os_unfair_lock
  • OSSpinLock
  • dispatch_semaphore
  • pthread_mutex
  • GCD 串行队列
  • NSLock
  • NSCondition
  • pthread_mutex(recursive)
  • NSRecursiveLock
  • NSConditionLock
  • @synchronized

总结:

  • OSSpinLockos_unfair_lock 性能很高,但是一个是已经废弃,一个是低级锁,苹果不建议使用低级锁
  • dispatch_semaphorepthread_mutex 也具有不错的性能,NSLockpthread_mutex 的封装,性能上接近
  • 个人建议在 Objective-C 中直接使用面向对象的 NSLock,而在 Swif t中使用 GCD 串行队列

参考文章

苹果官方文档

白夜追凶,揭开iOS锁的秘密

起底多线程同步锁(iOS)

深入理解 iOS 开发中的锁