37、保障接口响应性的多线程编程要点

多线程编程核心要点解析

保障接口响应性的多线程编程要点

1. 竞态条件

在多线程编程中,由于每个线程都可以访问相同的内存,若编程时未意识到这一点,可能会引发诸多问题。当程序因多个线程同时访问共享数据而未给出预期结果时,就会出现竞态条件。

1.1 静态变量引发的竞态条件

看下面这段代码:

static int i;
for (i = 0; i < 25; i++) {
    NSLog(@"i = %d", i);
}

在这个例子中,将 i 声明为静态变量可能没有实际意义,但它展示了一种典型的竞态条件。当声明一个变量为静态时,它会成为一个单一的共享变量,无论哪个对象调用该方法都会使用它。

如果这段代码在单线程程序中运行,一切正常。因为只有一个变量 i 被多个对象共享,且在循环期间,没有其他代码会改变 i 的值。

但一旦引入并发,情况就不同了。若这段代码在多个线程中运行,所有线程都会共享 i 的同一个副本。当一个线程增加 i 的值时,所有线程的 i 值都会增加。原本每个线程可能打算循环 25 次,但所有线程组合起来总共只会循环 25 次。输出可能如下:

Thread 1:
Thread 2:
Thread 3:
i = 0
i = 2
i = 5
i = 1
i = 3
i = 10
i = 4
i = 6
i = 13
i = 7
i = 8
i = 18
i = 9
i = 11
i = 19
i = 12
i = 14
i = 24
i = 15
i = 17
i = 16
i = 21
i = 20
i = 22
i = 23

显然,这不是预期的行为。解决方法很简单:去掉 i static 修饰符。

1.2 访问器和修改器引发的竞态条件

考虑一个表示人的对象 Person ,有 firstName lastName 两个属性:

@implementation Person : NSObject

@property (nonatomic, strong) NSString *firstName;
@property (nonatomic, strong) NSString *lastName;

@end

如果一个 Person 实例被多个线程访问,就可能出现问题。假设在一个线程中更新对象,在另一个线程中读取对象。第一个线程将 firstName lastName 都改为新值:

person.firstName = @"Manny";
person.lastName = @"Sullivan";

与此同时,另一个线程读取 person 的值:

NSLog(@"Now processing %@ %@.", person.firstName, person.lastName);

如果第二个线程的 NSLog() 语句在第一个线程的两次赋值之间执行,结果可能是:

Now processing Manny Washington.

这显然不符合预期,因为不存在 “Manny Washington” 这个人。

操作队列并不能消除竞态条件问题,因此要格外注意。有时可以为每个线程提供共享资源的副本,而不是让多个线程访问同一个共享资源,这样能确保一个线程不会在另一个线程使用资源时改变它。但复制数据会带来一些开销,而且很多时候复制资源不可行,因为需要知道资源的当前值。在这种情况下,需要采取额外措施确保数据完整性,避免竞态条件,主要工具是互斥锁。

1.3 竞态条件的解决思路总结

场景 问题表现 解决方法
静态变量 多个线程共享静态变量,导致循环次数不符合预期 去掉静态修饰符
访问器和修改器 多线程访问和修改对象属性时出现数据不一致 使用互斥锁或为线程提供资源副本

2. 互斥锁与 @synchronized

互斥锁是一种机制,用于确保一段代码执行时,其他线程不能执行相同或相关的代码。“mutex” 是 “mutual” 和 “exclusion” 的组合词,意味着锁本质上是一种指定只有一个线程能在特定时间执行特定代码段的方式。

最初,锁通常使用 NSLock 类实现。现在,有了语言级别的特性 @synchronized 块来锁定代码段。

2.1 @synchronized 块示例

@synchronized(self) {
    person.firstName = @"Samantha";
    person.lastName = @"Stephens";
}

@synchronized 关键字后面的括号里有一个值 self ,这个参数称为互斥信号量或互斥锁。可以将其类比为小加油站的卫生间钥匙,只有拿到钥匙的人才能使用卫生间,即同一时间只有一个人能进入卫生间。

当一个线程到达 @synchronized 代码块时,它会检查是否有其他线程正在使用该互斥锁,即是否有其他使用相同信号量的 @synchronized 代码块正在执行。如果有,线程会阻塞,直到没有其他代码使用该信号量。阻塞的线程不会执行任何代码,当互斥锁可用时,线程会解除阻塞并执行同步代码。

这是在 Cocoa Touch 中避免竞态条件、使对象线程安全的主要机制。

2.2 原子性与线程安全

在之前的编程中,声明属性时通常使用 nonatomic 关键字。其实,未指定 nonatomic 时,访问器和修改器会像使用 @synchronized 关键字并以 self 为互斥锁那样创建。

2.2.1 非原子访问器示例
- (NSMutableString *)foo {
    return foo;
}
2.2.2 原子访问器示例
- (NSMutableString *)foo {
    NSString *ret;
    @synchronized(self) {
        ret = foo;
    }
    return ret;
}

原子版本与非原子版本的区别在于,原子版本使用 self 作为互斥锁,除了返回语句和变量声明外,其他代码都被包裹在 @synchronized 块中。这意味着当同一对象的其他原子访问器或修改器在另一个线程中执行时,所有原子访问器和修改器都会阻塞,有助于确保数据完整性。

声明属性为 nonatomic 时,会移除这些保护机制,因为可能认为不需要它们。到目前为止,由于只在主线程中访问和设置对象属性,这样做没问题。对于出口(outlets),通常也可以声明为 nonatomic ,因为不应该在主线程之外的线程中使用它们。大多数 UIKit 不是线程安全的,在主线程之外的线程中设置或检索值通常不安全。

但如果创建的对象要在多线程或操作队列中使用,很可能需要去掉 nonatomic 关键字,因为原子属性的保护作用足以抵消少量的开销。

需要注意的是,原子性和线程安全是不同的概念。使用原子属性并不意味着类就是线程安全的。在简单情况下,原子属性可能足以使对象线程安全,但线程安全是对象级别的特性。以 Person 对象为例,去掉两个属性的 nonatomic 关键字并不能使对象线程安全,因为之前提到的问题仍然可能发生。要使对象真正线程安全,不仅要同步单个访问器和修改器,还要同步涉及相关数据的任何事务。

2.2.3 同步事务示例
- (void)setFirstName:(NSString *)inFirst lastName:(NSString *)inLast {
    @synchronized (self) {
        self.firstName = inFirst;
        self.lastName = inLast;
    }
}

在这个例子中,使用修改器方法设置名字,即使这些修改器是原子的,代码也会被同步。这是因为 @synchronized 是递归互斥锁,只要两个同步块使用相同的互斥锁,一个同步块就可以安全地调用另一个同步块。但绝不要在一个同步块中调用使用不同互斥锁的另一个同步块,否则可能导致死锁。

2.3 原子性与线程安全的关系总结

graph LR
    A[原子性] -->|不保证| B[线程安全]
    C[线程安全] -->|需要| D[同步相关事务]
    E[原子属性] -->|部分作用| A
    F[同步块] -->|实现| D

3. 死锁

互斥锁是解决并发中竞态条件的主要方法,但它也有一个大问题——死锁。当一个线程阻塞并等待一个永远无法满足的条件时,就会发生死锁。例如,两个线程都有同步代码,且这些代码会调用对方线程的同步代码。如果两个线程都使用一个互斥锁并等待对方线程持有的互斥锁,那么两个线程都无法继续执行,会永远阻塞。

避免死锁没有简单的解决方案,但有一个很好的经验法则:永远不要让一个同步代码块调用使用不同互斥锁的另一个同步代码块。如果需要调用包含同步代码的方法或函数,可能需要在同步块内复制该方法的代码,而不是直接调用该方法。虽然这似乎违背了代码不应该不必要复制的原则,但如果不这样做,可能会陷入死锁。

4. 线程休眠

如果有太多线程同时执行,系统可能会变得缓慢,尤其是在只有单核 CPU 的旧款 iPhone 上。即使使用线程,如果在太多线程中执行过多任务,用户界面也可能会卡顿或响应缓慢。一种解决方案是减少线程的创建, NSOperationQueue 可以帮助实现这一点。

线程(以及操作)还可以通过休眠来保持应用程序的响应性。线程可以选择休眠一段时间或直到某个特定时间点。线程休眠时会阻塞,直到休眠结束,这样可以将处理器周期让给其他线程。在线程或操作中添加休眠调用可以限制其执行速度,确保主线程有足够的处理器时间。

4.1 线程休眠方法

  • 使用 sleepForTimeInterval: 方法让线程休眠指定的秒数,例如:
[NSThread sleepForTimeInterval:2.5];
  • 使用 sleepUntilDate: 方法让线程休眠到指定日期和时间,例如:
[NSThread sleepUntilDate:[NSDate dateWithTimeIntervalSinceNow:2.5]];

需要注意的是,永远不要在主线程上使用这些休眠方法,因为主线程是唯一处理事件和更新用户界面的线程,如果让主线程休眠,界面会停止响应。

5. 操作

在了解操作队列之前,需要先了解操作。操作是包含操作队列管理的指令集的对象,通常是 NSOperation 的自定义子类。可以编写子类并在其中放入需要并发运行的代码。

5.1 NSOperation 的子类

有两个 NSOperation 的子类 NSInvocationOperation NSBlockOperation ,可以在不创建自定义子类的情况下并发运行代码。
- NSInvocationOperation 允许指定一个对象和选择器作为操作的基础。
- NSBlockOperation 允许指定一个或多个块作为操作的基础。

但在大多数情况下,建议子类化 NSOperation ,因为这样能对操作过程有更多控制。

5.2 实现操作的步骤

  1. 创建子类并定义属性 :创建 NSOperation 的子类,并定义操作所需的输入或输出属性。例如,在计算平方根的示例中,可以创建一个子类并定义 current max 属性。
  2. 重写 main 方法 :在 main 方法中编写操作的代码。需要注意以下两点:
    • 将所有逻辑包裹在 @try 块中,捕获并处理任何异常,因为操作的 main 方法不能抛出异常,未捕获的异常会导致应用程序崩溃。
    • 创建一个新的自动释放池,因为不同线程不能共享同一个自动释放池,操作在单独的线程中运行,需要有自己的自动释放池。
5.2.1 示例代码
- (void)main {
    @try {
        @autoreleasepool {
            // Do work here...
        }
    }
    @catch (NSException * e) {
        // Important that we don't re-throw exception here
        NSLog(@"Exception: %@", e);
    }
}

5.3 操作的依赖关系

任何操作都可以有一个或多个依赖项。依赖项是另一个 NSOperation 实例,在该操作执行之前,依赖项必须先完成。操作队列会知道不运行有未完成依赖项的操作。

可以使用 addDependency: 方法为操作添加依赖项,例如:

MyOperation *firstOperation = [[MyOperation alloc] init];
MyOperation *secondOperation = [[MyOperation alloc] init];
[secondOperation addDependency:firstOperation];

在这个例子中,如果 firstOperation secondOperation 同时添加到队列中,即使队列有空闲线程,它们也不会并发运行。因为 secondOperation 依赖于 firstOperation ,所以 secondOperation 要等 firstOperation 完成后才会开始执行。

可以使用 dependencies 方法获取操作的依赖项数组,使用 removeDependency: 方法移除依赖项。

5.4 操作的优先级

每个操作都有一个优先级,队列根据优先级决定何时运行操作以及该操作可以使用多少可用处理资源。可以使用 setQueuePriority: 方法设置操作的优先级,可传入以下值:
- NSOperationQueuePriorityVeryLow
- NSOperationQueuePriorityLow
- NSOperationQueuePriorityNormal

5.5 操作相关要点总结

要点 说明
子类选择 多数情况子类化 NSOperation 以获得更多控制
main 方法 包裹逻辑在 @try 块,创建自动释放池
依赖关系 使用 addDependency: 添加, removeDependency: 移除
优先级设置 使用 setQueuePriority: 方法

6. 操作队列

操作队列是管理操作的强大工具,它可以自动调度操作的执行,根据操作的依赖关系和优先级来决定操作的执行顺序。

6.1 操作队列的基本概念

操作队列负责管理一组操作,确保操作按照正确的顺序和优先级执行。它可以并发执行多个操作,也可以串行执行操作,具体取决于队列的配置和操作的依赖关系。

6.2 创建和使用操作队列

创建操作队列非常简单,只需要实例化 NSOperationQueue 对象即可:

NSOperationQueue *operationQueue = [[NSOperationQueue alloc] init];

将操作添加到队列中,队列会自动调度操作的执行:

MyOperation *operation = [[MyOperation alloc] init];
[operationQueue addOperation:operation];

6.3 操作队列的调度规则

操作队列的调度规则如下:
- 操作队列会优先执行没有依赖项的操作。
- 如果多个操作没有依赖项,队列会根据操作的优先级来决定执行顺序,优先级高的操作先执行。
- 当一个操作完成后,队列会检查其依赖的操作是否都已完成,如果完成,则调度该操作执行。

6.4 操作队列与线程的关系

操作队列会自动管理线程的创建和销毁,它会根据操作的数量和系统资源情况来决定使用多少线程。一般情况下,操作队列会使用线程池来管理线程,避免频繁创建和销毁线程带来的开销。

6.5 操作队列相关要点总结

要点 说明
创建 实例化 NSOperationQueue 对象
添加操作 使用 addOperation: 方法
调度规则 优先执行无依赖项、优先级高的操作
线程管理 自动管理线程,使用线程池

7. 线程安全的综合应用

在实际开发中,需要综合考虑竞态条件、互斥锁、死锁、线程休眠、操作和操作队列等因素,以确保应用程序的线程安全和性能。

7.1 线程安全的设计原则

  • 避免共享资源 :尽量为每个线程提供独立的资源副本,减少多个线程对共享资源的访问。
  • 使用互斥锁 :在需要访问共享资源时,使用互斥锁确保同一时间只有一个线程可以访问该资源。
  • 合理设置操作优先级和依赖关系 :通过设置操作的优先级和依赖关系,确保操作按照正确的顺序执行,避免死锁和数据不一致问题。
  • 控制线程数量 :避免创建过多的线程,使用操作队列来管理线程的创建和销毁。

7.2 线程安全的示例代码

以下是一个综合应用的示例代码,展示了如何创建操作、设置依赖关系和优先级,并使用互斥锁确保线程安全:

// 创建操作队列
NSOperationQueue *operationQueue = [[NSOperationQueue alloc] init];

// 创建操作
MyOperation *operation1 = [[MyOperation alloc] init];
MyOperation *operation2 = [[MyOperation alloc] init];
MyOperation *operation3 = [[MyOperation alloc] init];

// 设置操作优先级
[operation1 setQueuePriority:NSOperationQueuePriorityHigh];
[operation2 setQueuePriority:NSOperationQueuePriorityNormal];
[operation3 setQueuePriority:NSOperationQueuePriorityLow];

// 设置操作依赖关系
[operation2 addDependency:operation1];
[operation3 addDependency:operation2];

// 添加操作到队列
[operationQueue addOperation:operation1];
[operationQueue addOperation:operation2];
[operationQueue addOperation:operation3];

7.3 线程安全的开发流程

graph LR
    A[需求分析] --> B[设计线程安全方案]
    B --> C[创建操作和操作队列]
    C --> D[设置操作优先级和依赖关系]
    D --> E[使用互斥锁保护共享资源]
    E --> F[测试和优化]

8. 总结

本文详细介绍了多线程编程中的竞态条件、互斥锁、死锁、线程休眠、操作和操作队列等重要概念,并通过示例代码和流程图展示了如何应用这些概念来确保应用程序的线程安全和性能。

8.1 关键知识点回顾

  • 竞态条件 :多个线程访问共享资源时可能导致数据不一致,需要使用互斥锁或为线程提供资源副本。
  • 互斥锁 :使用 @synchronized 块可以确保同一时间只有一个线程可以执行特定代码段。
  • 死锁 :避免让同步代码块调用使用不同互斥锁的另一个同步代码块,防止死锁发生。
  • 线程休眠 :使用 sleepForTimeInterval: sleepUntilDate: 方法让线程休眠,避免系统过载。
  • 操作和操作队列 :创建 NSOperation 子类,设置操作的依赖关系和优先级,使用操作队列管理操作的执行。

8.2 未来展望

多线程编程是一个复杂而重要的领域,随着硬件性能的提升和应用程序需求的增加,多线程编程的应用场景会越来越广泛。未来,我们需要不断学习和掌握新的多线程编程技术,以应对更加复杂的并发问题。同时,也要注意多线程编程带来的性能开销和调试难度,合理使用多线程技术,确保应用程序的稳定性和性能。

8.3 建议和最佳实践

  • 在开发过程中,始终牢记线程安全的重要性,从设计阶段就考虑如何避免竞态条件和死锁问题。
  • 尽量使用操作和操作队列来管理线程,避免直接操作线程,减少线程管理的复杂性。
  • 在使用互斥锁时,要确保锁的粒度适中,避免锁的范围过大导致性能下降,也避免锁的范围过小无法保证数据的一致性。
  • 对于复杂的多线程场景,可以使用调试工具来帮助定位和解决问题,如 Xcode 的调试器和性能分析工具。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值