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内部使用的大量机制,对任何一个移动应用和都是最好的。请继续关注我们,未来几周我们会继续发布类似的文章。我们会分享更多关于测试理念,知识等等。

本文中的所有译文仅用于学习和交流目的,转载请注明文章译者、出处、和本文链接。

时间: 2025-01-31 07:31:12

iOS/OS X线程安全的基础知识的相关文章

OS的沙盒机制 --基础知识

/* iOS的沙盒机制,应用只能访问自己应用目录下的文件. iOS不像android,没有SD卡概念,不能直接访问图像.视频等内容. iOS应用产生的内容,如图像.文件.缓存内容等都必须存储在自己的沙盒内. 默认情况下,每个沙盒含有3个文件夹:Documents, Library 和 tmp.Library包含Caches.Preferences目录.  上面的完整路径为:用户->资源库->Application Support->iPhone Simulator->7.1->

IOS学习之路--OC的基础知识

运行过程 1.编写OC程序:.m源文件 2.编译.m文件为.o目标文件:cc -c xxxx.m 3.链接.o文件为a.out可执行文件:cc xxxx.o -framework Foundation 4.执行a.out文件:./a.out #import 的功能跟#include一样,只是更好用,他避免了头文件的多次包含 为了能使用OC的特性, 一定要引入#import <Foundation/Foundation.h> 类定义// @implementation 和 @end // 设计(

iOS开发系列--C语言之基础知识

概览 当前移动开发的趋势已经势不可挡,这个系列希望浅谈一下个人对IOS开发的一些见解,这个IOS系列计划从几个角度去说IOS开发: C语言 OC基础 IOS开发(iphone/ipad) Swift 这么看下去还有大量的内容需要持续补充,但是今天我们从最基础的C语言开始,C语言部分我将分成几个章节去说,今天我们简单看一下C的一些基础知识,更高级的内容我将放到后面的文章中. 今天基础知识分为以下几点内容(注意:循环.条件语句在此不再赘述): Hello World 运行过程 数据类型 运算符 常用

Inside Cisco IOS Software Architecture(第一章,系统基础知识)

由于本书写于1990年代,CEF还是cisco最新的黑科技. 所以其中很多关于操作系统的内容已经不太正确.Cisco的操作系统也从最开始的IOS一种形式到后来的Linux做control plane的IOS,IOS-XR,IOS-XE,NX-OS 等等等. 我相信书中提到的很多玩意已经不再使用,或者起码有所变化,但是由于没有更新的版本的书讲新的操作系统的内部,所以还是只能从这本书上了解.从学习的角度来看,从一个比较原始的形态学习也有助于一步步理解更复杂的系统. 所以不要过分纠结书的年代和细节内容

线程Thread的基础知识学习

一.线程的基本概念 1.线城市一个程序内部的顺序控制流. 2.Java的线程是通过java.lang.Thread类来实现的. 3.VM启动时会有一个由主方法{public static void main(Args[] String)}所定义的线程. 4.可以通过创建新的Thread实例来创建新的线程. 5.每个线程都是通过某个特定的Thread对象所对应的方法run()来完成其操作的,方法run()称为线程体. 6.通过调用Thread类的start()方法来启动一个线程. 注意:多进程(在

IOS-OC的基础知识

IOS学习之路--OC的基础知识 1.项目经验 2.基础问题 3.指南认识 4.解决思路 ios开发三大块: 1.Oc基础 2.CocoaTouch框架 3.Xcode使用 -------------------- CocoaTouch Media Core Services Core OS -------------------- System Framework OC的类声明,定义域 OC关键字定义为  @class O-C特有的语句for(..in ..)迭代循环,其他的条件和循环语句和c

iOS面试必备-iOS基础知识

近期为准备找工作面试,在网络上搜集了这些题,以备面试之用. 插一条广告:本人求职,2016级应届毕业生,有开发经验.可独立开发,低薪求职.QQ:895193543 1.简述OC中内存管理机制. 答:内存管理机制:使用引用计数管理,分为ARC和MRC,MRC需要程序员自己管理内存,ARC则不需要.但是并不是 所有对象在ARC环境下均不需要管理内存,子线程和循环引用并不是这样.与retain配对使用的是release,retain代表引用计 数+1,release代表引用计数-1,当引用计数减为0时

线程基础知识

什么是线程: 在一个程序里的一个执行路线就叫做线程(thread).更准确的定义是:线程是"一个进程内部的控制序列" 一切进程至少都有一个执行线程 进程与线程 进程是资源竞争的基本单位 线程是程序执行的最小单位 线程共享进程数据,但也拥有自己的一部分数据 线程ID 一组寄存器 栈 errno 信号状态 优先级 fork和创建新线程的区别 当一个进程执行一个fork调用的时候,会创建出进程的一个新拷贝,新进程将拥有它自己的变量和它自己的PID.这个新进程的运行时间是独立的,它在执行时几乎

iOS开发基础知识--碎片3

iOS开发基础知识--碎片3  iOS开发基础知识--碎片3 十二:判断设备 //设备名称 return [UIDevice currentDevice].name; //设备型号,只可得到是何设备,无法得到是第几代设备 return [UIDevice currentDevice].model; //系统版本型号,如iPhone OS return [UIDevice currentDevice].systemVersion; //系统版本名称,如6.1.3 return [UIDevice