MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Objective-C中的死锁与线程饥饿问题避免

2021-12-312.8k 阅读

死锁的概念与原理

在多线程编程中,死锁是一个常见且棘手的问题。当两个或多个线程相互等待对方释放资源,而导致所有线程都无法继续执行时,就发生了死锁。

从本质上讲,死锁的产生需要满足四个必要条件,这被称为死锁的四个条件:

  1. 互斥条件:资源在某一时刻只能被一个线程所占有。例如,有一把锁,在同一时间只能有一个线程持有它。在Objective-C中,NSLock就是一个典型的互斥锁,同一时间只有一个线程能够成功获取该锁。
NSLock *lock = [[NSLock alloc] init];
[lock lock];
// 执行需要保护的代码
[lock unlock];
  1. 占有并等待条件:一个线程持有了至少一个资源,同时又在等待获取其他线程所持有的资源。例如,线程A持有资源R1,并且正在等待资源R2,而资源R2被线程B持有,同时线程B又在等待线程A持有的资源R1,这就形成了占有并等待的情况。
  2. 不可剥夺条件:资源一旦被一个线程占有,在该线程主动释放之前,其他线程不能强行剥夺。在Objective-C的多线程编程中,大多数锁机制都遵循这个原则,一旦一个线程获取了锁,其他线程只能等待该线程释放锁。
  3. 循环等待条件:存在一个线程集合{T1, T2, ..., Tn},其中T1等待T2持有的资源,T2等待T3持有的资源,以此类推,Tn等待T1持有的资源,形成一个循环等待的链。

Objective-C中死锁的常见场景

  1. 嵌套锁死锁:当多个线程以不同顺序获取多个锁时,很容易出现死锁。例如,假设有两个锁lockAlockB,线程1先获取lockA,然后尝试获取lockB;而线程2先获取lockB,然后尝试获取lockA。如果这两个操作同时进行,就会导致死锁。
NSLock *lockA = [[NSLock alloc] init];
NSLock *lockB = [[NSLock alloc] init];

dispatch_queue_t queue1 = dispatch_queue_create("com.example.queue1", DISPATCH_QUEUE_CONCURRENT);
dispatch_queue_t queue2 = dispatch_queue_create("com.example.queue2", DISPATCH_QUEUE_CONCURRENT);

dispatch_async(queue1, ^{
    [lockA lock];
    NSLog(@"Thread 1: Locked A");
    [NSThread sleepForTimeInterval:1];
    [lockB lock];
    NSLog(@"Thread 1: Locked B");
    [lockB unlock];
    [lockA unlock];
});

dispatch_async(queue2, ^{
    [lockB lock];
    NSLog(@"Thread 2: Locked B");
    [NSThread sleepForTimeInterval:1];
    [lockA lock];
    NSLog(@"Thread 2: Locked A");
    [lockA unlock];
    [lockB unlock];
});

在上述代码中,线程1和线程2分别在不同的队列中异步执行。线程1先获取lockA,睡眠1秒后尝试获取lockB;线程2先获取lockB,睡眠1秒后尝试获取lockA。由于线程1持有lockA时等待lockB,而线程2持有lockB时等待lockA,就会导致死锁。 2. 递归锁使用不当:递归锁允许同一个线程多次获取锁而不会造成死锁。但是,如果使用不当,例如在递归函数中错误地处理锁的获取和释放,也可能导致死锁。在Objective-C中,NSRecursiveLock是递归锁。

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

void recursiveFunction() {
    [recursiveLock lock];
    NSLog(@"Entered recursive function");
    // 一些逻辑
    recursiveFunction();
    [recursiveLock unlock];
}

在上述代码中,如果recursiveFunction没有正确的终止条件,会导致递归调用无限进行,最终耗尽系统资源。虽然递归锁允许同一个线程多次获取锁,但这种无限递归调用会导致死锁。 3. 同步块死锁:在Objective-C中,@synchronized块也可能导致死锁。@synchronized会自动为传入的对象创建一个锁,并在块结束时释放锁。如果多个线程以不同顺序进入@synchronized块,并且使用相同的锁对象,就可能发生死锁。

id lockObject = [[NSObject alloc] init];

dispatch_queue_t queue3 = dispatch_queue_create("com.example.queue3", DISPATCH_QUEUE_CONCURRENT);
dispatch_queue_t queue4 = dispatch_queue_create("com.example.queue4", DISPATCH_QUEUE_CONCURRENT);

dispatch_async(queue3, ^{
    @synchronized(lockObject) {
        NSLog(@"Thread 3: Entered synchronized block");
        [NSThread sleepForTimeInterval:1];
        @synchronized(lockObject) {
            NSLog(@"Thread 3: Entered nested synchronized block");
        }
    }
});

dispatch_async(queue4, ^{
    @synchronized(lockObject) {
        NSLog(@"Thread 4: Entered synchronized block");
        [NSThread sleepForTimeInterval:1];
        @synchronized(lockObject) {
            NSLog(@"Thread 4: Entered nested synchronized block");
        }
    }
});

在这段代码中,线程3和线程4分别在不同队列中异步执行。线程3先进入@synchronized块,睡眠1秒后尝试进入嵌套的@synchronized块;线程4同样先进入@synchronized块,睡眠1秒后尝试进入嵌套的@synchronized块。由于两个线程都在等待对方释放锁,就会导致死锁。

避免死锁的方法

  1. 破坏死锁的四个条件
    • 破坏互斥条件:在一些情况下,可以使用资源的共享机制来避免互斥。例如,使用多读单写锁(NSReadWriteLock),允许多个线程同时进行读操作,只有写操作需要独占资源。
NSReadWriteLock *readWriteLock = [[NSReadWriteLock alloc] init];
// 读操作
[readWriteLock readLock];
// 执行读操作代码
[readWriteLock unlock];
// 写操作
[readWriteLock writeLock];
// 执行写操作代码
[readWriteLock unlock];
- **破坏占有并等待条件**:可以在一个线程开始执行时,一次性获取它所需要的所有资源,而不是先获取部分资源再等待其他资源。例如,在前面嵌套锁死锁的例子中,如果线程1和线程2都按照相同的顺序获取锁,就可以避免死锁。
NSLock *lockA = [[NSLock alloc] init];
NSLock *lockB = [[NSLock alloc] init];

dispatch_queue_t queue1 = dispatch_queue_create("com.example.queue1", DISPATCH_QUEUE_CONCURRENT);
dispatch_queue_t queue2 = dispatch_queue_create("com.example.queue2", DISPATCH_QUEUE_CONCURRENT);

dispatch_async(queue1, ^{
    [lockA lock];
    NSLog(@"Thread 1: Locked A");
    [lockB lock];
    NSLog(@"Thread 1: Locked B");
    [lockB unlock];
    [lockA unlock];
});

dispatch_async(queue2, ^{
    [lockA lock];
    NSLog(@"Thread 2: Locked A");
    [lockB lock];
    NSLog(@"Thread 2: Locked B");
    [lockB unlock];
    [lockA unlock];
});

在这段代码中,线程1和线程2都先获取lockA,再获取lockB,这样就避免了死锁。 - 破坏不可剥夺条件:可以实现一种机制,当一个线程等待资源超过一定时间时,自动释放它已经持有的资源。在Objective-C中,可以使用NSCondition来实现这种机制。

NSCondition *condition = [[NSCondition alloc] init];
[condition lock];
BOOL success = [condition waitUntilDate:[NSDate dateWithTimeIntervalSinceNow:5]];
if (success) {
    // 获取资源成功
    [condition unlock];
} else {
    // 等待超时,释放已经持有的资源
    [condition unlock];
}
- **破坏循环等待条件**:可以为资源分配一个唯一的序号,线程按照序号从小到大的顺序获取资源,这样就不会形成循环等待。例如,假设有三个锁`lock1`、`lock2`和`lock3`,线程获取锁时按照`lock1`、`lock2`、`lock3`的顺序获取,就可以避免循环等待。

2. 使用死锁检测工具:在开发过程中,可以使用一些工具来检测死锁。Xcode自带的 Instruments工具中的 Deadlock instrument可以帮助检测应用程序中的死锁。通过在应用程序运行时启用Deadlock instrument,可以捕获到死锁发生的线程和资源信息,从而帮助开发者定位和解决死锁问题。 3. 使用自动释放池:在多线程编程中,合理使用自动释放池可以减少内存压力,同时也有助于避免死锁。自动释放池会在其生命周期结束时自动释放池中的对象。在长时间运行的线程中,创建自动释放池可以防止大量对象在堆中堆积,从而避免因内存问题导致的死锁。

dispatch_queue_t longRunningQueue = dispatch_queue_create("com.example.longRunningQueue", DISPATCH_QUEUE_CONCURRENT);
dispatch_async(longRunningQueue, ^{
    @autoreleasepool {
        // 执行长时间运行的任务
    }
});

线程饥饿的概念与原理

线程饥饿是指在多线程环境中,某些线程由于一直无法获取所需资源而长时间处于等待状态,导致其无法执行的现象。线程饥饿通常是由于资源分配不均或者调度算法不合理引起的。

从本质上讲,线程饥饿与系统的资源调度策略密切相关。在一个多线程系统中,操作系统负责调度线程的执行。如果调度算法总是优先分配资源给某些线程,而忽略了其他线程,就会导致部分线程饥饿。例如,在一个使用优先级调度算法的系统中,如果高优先级线程持续占用资源,低优先级线程就可能长时间无法执行。

Objective-C中线程饥饿的常见场景

  1. 优先级倒置:当高优先级线程依赖于低优先级线程持有的资源时,可能会发生优先级倒置。例如,假设有三个线程:高优先级线程H、中优先级线程M和低优先级线程L。线程L持有资源R,线程H需要资源R来执行。但是,线程M在运行过程中,由于其优先级高于线程L,会抢占线程L的执行机会,导致线程L无法释放资源R,从而使线程H饥饿。
  2. 资源竞争过度:当多个线程竞争有限的资源时,如果资源分配不合理,也会导致线程饥饿。例如,在一个多线程下载任务的应用中,假设有10个线程同时下载文件,而网络带宽有限。如果某些线程一直占用大量的网络带宽,其他线程就可能因为无法获取足够的带宽而饥饿。
  3. 长时间阻塞:如果一个线程长时间占用一个资源,而其他线程需要这个资源才能执行,就会导致其他线程饥饿。例如,在一个多线程数据库访问应用中,一个线程长时间执行一个复杂的数据库查询,占用了数据库连接,其他线程就无法获取数据库连接来执行自己的查询,从而导致饥饿。

避免线程饥饿的方法

  1. 公平调度算法:在Objective-C中,可以使用一些调度机制来实现公平调度。例如,GCD(Grand Central Dispatch)提供了不同的队列优先级,并且在一定程度上保证了公平性。通过合理设置队列优先级,可以避免某些线程长时间饥饿。
dispatch_queue_t highPriorityQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_queue_t lowPriorityQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);

dispatch_async(highPriorityQueue, ^{
    // 高优先级任务
});

dispatch_async(lowPriorityQueue, ^{
    // 低优先级任务
});
  1. 资源分配策略优化:在资源竞争的场景中,优化资源分配策略可以避免线程饥饿。例如,在多线程下载任务中,可以采用轮询的方式分配网络带宽,确保每个线程都能获得一定的带宽资源。在Objective-C中,可以通过自定义资源分配算法来实现这一点。
  2. 设置合理的超时机制:为线程获取资源设置合理的超时机制。如果一个线程在规定时间内无法获取所需资源,可以释放已经持有的资源,或者尝试其他方式获取资源。这样可以避免线程长时间等待导致饥饿。例如,在前面使用NSCondition的例子中,通过设置等待超时时间,可以避免线程无限期等待。
  3. 避免长时间阻塞:尽量避免线程长时间占用资源。对于长时间运行的任务,可以将其分解为多个小任务,在适当的时候释放资源,让其他线程有机会执行。例如,在数据库访问中,可以将复杂的查询分解为多个简单的查询,每次查询完成后释放数据库连接,让其他线程可以使用数据库连接。

死锁与线程饥饿的综合防范策略

  1. 全面的代码审查:在开发过程中,对多线程相关的代码进行全面的审查是非常重要的。审查内容包括锁的使用顺序、资源的获取和释放逻辑、线程优先级设置等。通过代码审查,可以提前发现潜在的死锁和线程饥饿问题。
  2. 模拟高并发场景测试:在测试阶段,模拟高并发场景对应用程序进行测试。通过模拟大量线程同时运行的情况,可以更容易发现死锁和线程饥饿问题。可以使用一些工具来模拟高并发场景,如GCD的并发队列、NSOperationQueue等。
  3. 日志与监控:在应用程序中添加详细的日志记录,记录线程的执行状态、资源的获取和释放情况等。通过分析日志,可以在出现问题时快速定位死锁或线程饥饿的原因。同时,可以使用监控工具实时监控应用程序的线程状态,及时发现异常情况。
  4. 持续学习与更新知识:多线程编程是一个不断发展的领域,新的技术和方法不断涌现。开发者需要持续学习,关注最新的多线程编程技术,了解如何更好地避免死锁和线程饥饿问题。例如,学习新的锁机制、调度算法等,并将其应用到实际项目中。

总之,在Objective-C的多线程编程中,死锁和线程饥饿是常见的问题,但通过深入理解其原理,掌握常见的避免方法,并采取综合的防范策略,开发者可以有效地避免这些问题,提高应用程序的稳定性和性能。无论是在小型应用还是大型项目中,多线程编程的正确性和效率都是至关重要的,需要开发者投入足够的精力来确保其质量。