【译】关于iOS/OS X线程安全的基础知识

jopen 9年前

 

【译】关于iOS/OS X线程安全的基础知识

处理多并发和可重入性问题,是每个库发展过程中面临的比较困难的挑战之一。在Parse平台上,我们尽最大的努力保证你在使用我的SDKs时所做的操作都是线程安全的,保证不会出现性能问题。

在这篇文章中我们将会复习一些关于如何以简洁、安全、干净的方式处理多并发和竞争条件下的基本概念。

首先,在进入细节讨论之前,我们先定义以下概念:

  • 线程:它是操作系统执行的一个上下文程序,并且可以同时 存在多个线程。

  • 并发性:在程序运行过程中,多个线程执行时共享同一资源的现象。

  • 可重入性: 通过显式递归,软件/硬件中断,或者其他方法,可以重新进入执行状态下的函数的现行。

  • 操作的原子性:一个保证操作完成或者失败的属性,这个属性永远不会产生一个中间状态或者一个无效的状态。

  • 线程安全:一个函数如果是线程安全的,就是说它不会产生无效状态,也不能被观察,同时也不能进入并发态。

  • 重入样式:一个函数如果是可重入的,就是说它不会产生无效状态,也不能被观察,同时也不能进入并发态。

谈到线程安全我们经常讨论的首要事情是线程安全的是天生难以实现的。 由于线程调度方式、内存垃圾回收,缓存错误,分支预测等等复杂的工作,与线程有关的问题很难被记录下来,也很难修复。鉴于这些因素,无论何时只要有可能,不要写可能陷入多线程环境的代码。如果你遵守下面的指导原则,避免多线程环境就会相当容易:

  • 如果可能的话,不要有可变状态。

  • 你的代码有竞争条件。

  • 必要的时候使用线程本地存储而不是全局状态。

  • 困惑的时候,使用线程锁。

  • 最后确保你的代码有竞争条件(尽管你认为那是不可能的)。

竞争条件

竞争状态是多线程系统的克星。当你不直接控制调度(如发生在单个线程的事情),你怎么能确保事情发生的顺序符合你的预期?网上有很多好的 关于如何追踪竞态条件的建议 ,但很少有关于如何避免它们的介绍。

大多数竞争状态是由共享可变状态引起的,如以下事例:

void thread1() {      _sharedState = 1;      // Do stuff         if (someCondition) {          _sharedState = 0;      }      // Do stuff      _sharedState = 1;  }    void thread2() {      // Do stuff      if (_sharedState == 0) {          _sharedState = 1;      }      // Do stuff  }

如果线程1的someCondition变量的值为true ,_shareState的值是0还是1?这取决于线程2的状态,无论线程1是否有条件和指定值。

可变状态并不一定意味着变量。包括文件系统、网络、系统调用,等等的状态可能在你的应用程序之外被改变。

States and Copying

避免可变状态的最好方法之一是有一个严格的关于如何把管理的状态作为一个整体的指导方针。在Parse库中,我们坚持一下三个规则:

  1. 把状态和能改变状态的代码相分离。这可以让你把阅读和状态突变的关注点相分离,并允许你在线程的代码中实现更好的逻辑。

  2. 通过mutable copy传递任意对象。通过引用传递对象可能会产生并发资源的改变,为了防止这种情况的发生,你需要某种形式的资源同步。

  3. 每当遇到困惑的时间,就使用线程锁(lock).这可能会使应用程序变慢,但是比在1000个选出一个的竞争环境下从而使应用崩溃的情况要好。

记住全局状态是不好的(包括单例),尽可能的避免使用它。在Parse库中,我们更喜欢使用依赖注入(也称控制反转)设计模式而不是单例(例 如:-initWithObjectController: vs [ObjectController shareController]),原因是它帮助我们一直记录对象的用法,同时也加强我们对线程的推理能力,如果必要的话,可以使用本地线程存储替代全 局变量。

正如上面提到的,可变的状态(以及全局变量)使处理并发性更难。所以不惜一切代价避免它。

原子性

正如上文中所说的那样,原子性的定义如下:

一个保证操作完成或者失败的属性,这个属性永远不会产生一个中间状态或者一个无效的状态。

这个定义看起来很神秘,有点难以理解。但是,它在实践中这意味着什么呢?

假如你有一个计数变量y,它需要在多线程里被更新。解决这问题比较天真的方法是让y直接增加,例如y++。然而,这种做法有一个重要缺陷,就是如果有两个线程同时增加y,那怎么办。这就迫使你去找其他解决方案。

有一个解决方案是在计数变量上附加一个锁,但这将显著降低性能。另一个解决方案(根据情况)可能是在每一个单独的线程的上使用各子的计数器,但这增加了程序的内存使用和认知负荷。

但是,我们还有更好的方法。使用指示器的某些特殊指令,这些指令是从 中分离出来的功能,他们能确保在一个内存地址上所有的操作都是正确同步的。这些操作是指示器发出的,而不是系统操作。那些创建无锁数据结构的基础理论是很实用的,但是不在本文的讨论范围中。

一般来说,如果你在一个指定的地址上操作是原子性的,那么没有读取那个地址不可能使你的应用处于无效状态。当这些参数一旦和原子性属性联合,就能 确保单个参数不能处于无效状态。注意作为一个整体的对象仍然可能处于无效状态,原因是每个原子性操作的表现是完全独立于其他正在另外那些内存地址上执行的 原子性操作的。

当原子性不能满足你的目的时,在锁定线程安全方面,你的确有很多传统的方法。锁存在多种形式,问题是要在众多的形式中找到一个最好的方式,来解决许多矛盾重生的困境。下面我们将讨论iOS/OS中一些默认的情况。

在讨论锁之前,我们首先要知道什么时候需要锁。在线程安全开发时最大的错误之一是轻易的大量使用锁。当然,如果你你锁定每一个调用对象的方法,那是不可能有竞争条件的。但是,如果你在获取可变状态的时候,将状态和线程分离,这样会更好。

下面,我们将演示几种一下几种锁的,一下面的例子开始

这简单的函数,看起来是完全没有问题,但是它既不是线程安全的也不是可重入的。使用者段代码的时候,会出现很多问题。

在并发的实际使用实例中,操作符*=不是原子性的。这就意味着如果有两个线程同时调用incrementFooBy:方法,我们最终会得到一个中间值,并且它不代表任何有效的状态。

在可重入的实际使用实例中,如果在上面例子中的乘法和赋值中间引起了一个中断,我们会遇到和上面相似的问题,就是我们会得到一个奇怪的中间值。

所有上面的代码不能正常工作,我们需要做一些改变使它更好。

方法1:使用 @synchronized 关键字

这解决了并发问题和可重入问题,但是也产生了几个新问题。第一,很明显的是我们通过同步对象本身,限制了其他线程对该对象的同步,如果大量使用这个函数,将会出现很糟糕的情况。

第二问题是由@synchronized带来的,众数周知,@synchronized的在性能方面的表现是很糟糕的。但是,在Objective - C 中,它是创建锁的一个最简单的方法。这并不意味着不存在更好的方法,创建锁。

方法2:串行队列

从某种意义上说,在你的Cocoa/Cocoa Touch编程生涯,你一定能接触到串行队列中的一个,那就是主线程。一个串行的调度队列是一个以线性方式执行的任务列表,这些任务都是来自OS系统的线 程。然而,调度队列有一些独特的特性使它比@synchronized更适合创建线程锁。

@implementation SomeObject() {     dispatch_queue_t _barQueue; // = dispatch_queue_create("com.parse.example.queue", DISPATCH_QUEUE_SERIAL);  }    - (NSInteger)foo {      __block NSInteger result = 0;      dispatch_sync(_barQueue, ^{          result = _foo;      });      return result;  }    - (void)incrementFooBy:(NSInteger) x {      dispatch_sync(_barQueue, ^{          _foo += x;      });  }    @end

  • 除了主队列,所有的调度队列将会忽略信号中断,这就使得可重入资源更加明显的复合逻辑。

  • 通过他们的QoS系统,调度队列不受优先级反转的控制。

  • 可以通过设置延迟执行,而不破坏同步模型。

然而,当你的资源是相互排斥的时候,使用调度队列会产生以下缺点包括:

  • 所有的调度队列都是不可重入的,这就意味着如果你在当前队列同步就会产生死锁现象。

  • 调度队列对象与一个简单的OSSpinLock相比占内存容量比较大,最短仅约128字节(加上额外的空间内部指针),OSSpinLock只有4个字节。

  • 由于需要__block变量接收,dispatch_sync块返回值有时候变得有点令人讨厌。

  • 串行队列不能很好地处理异常调度队列。

在大多数场景下这些性能优势得权衡是值得的,并且要广泛应用在SDK中。

方法3:并行队列

在读写平衡的场景中(例如相同数量的get和set方法),方法2是很好的。但是,在实际生活中,那种情况是很少出现的。你经常遇到的情况是多次读取某个数据,只是偶尔去写数据。

调度以并行对列的形式建立在支持所谓的读写锁的基础之上。但是,他们的工作和其他大多数队列一样,他们试图让更多的执行人尽可能的单独访问dispatch_barrier块。这就允许队列在并行队列的上下文中单独运行,并帮助我们加速无竞争条件下得用例。

@implementation SomeObject() {      dispatch_queue_t _barQueue; // = dispatch_queue_create("com.parse.example.queue", DISPATCH_QUEUE_CONCURRENT);  }    - (NSInteger)foo {      __block NSInteger result = 0;      dispatch_sync(_barQueue, ^{          result = _foo;      });      return result;  }    - (void)incrementFooBy:(NSInteger)x {      dispatch_barrier_sync(_barQueue, ^{          _foo += x;      });  }    @end

上面代码的另一个优点是,它使我们更清楚的知道那些函数更新实例变量,而那些函数没有。

知道并行队列的性能开销比串行队列的开销要大得多时很重要的。在竞争环境下(例如dispatch_barrier_sync的多次调用),有一个显而易见的基准就是一个并行队列

在其内部旋转锁上花费的时间比一个串行队列多的多。

结论

在Parse库中,我们努力创造最好的APIs接口,最好的线程支持。我们在这个SDK内部使用的大量机制,对任何一个移动应用和都是最好的。请继续关注我们,未来几周我们会继续发布类似的文章。我们会分享更多关于测试理念,知识等等。