死锁以及预防

什么是死锁

死锁是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的互相等待现象。若无外力干涉,这些进程都无法向前推进。

经典比喻:哲学家就餐问题

  • 五位哲学家围坐在圆桌旁,仅做思考和吃饭两件事。
  • 桌上有五根筷子,每两位哲学家之间放一根。
  • 哲学家需拿起左右两边的筷子才能吃饭。
  • 若所有哲学家同时拿起左边的筷子,会永远等待右边筷子被放下,最终陷入死锁。

死锁产生的四个必要条件

四个条件必须同时满足,才会发生死锁,缺一不可。

  1. 互斥条件:一个资源每次只能被一个进程使用。
    • 示例:打印机、锁,一次只能被一个线程持有。
  2. 请求与保持条件:进程因请求资源阻塞时,对已获得的资源保持不放。
    • 示例:线程A持有锁1,申请锁2时不释放锁1。
  3. 不剥夺条件:进程已获得的资源,未使用完前不能被强行剥夺。
    • 示例:不能强行将线程A持有的锁1抢给线程B。
  4. 循环等待条件:若干进程形成头尾相接的循环等待资源关系。
    • 示例:线程A持有锁1、等待锁2;线程B持有锁2、等待锁1。

解决和预防死锁的策略

核心思路:破坏上述四个必要条件中的至少一个,即可避免死锁。

策略1:预防死锁(设计时杜绝死锁)

最有效、最根本的方法,通过规则设计提前规避死锁可能。

  1. 破坏“请求与保持”条件
    • 一次性申请所有资源:进程运行前,需申请所需全部资源。
    • 若无法满足所有需求,则不分配任何资源,让进程等待。
    • 特点:简单易实现,但资源利用率低、浪费严重。
  2. 破坏“不剥夺”条件
    • 允许抢占:进程持有部分资源却无法获取新资源时,需释放所有已持资源,后续重新申请。
    • 实操方式:通过超时阻塞获取新资源,超时未获取则释放已有资源,重新发起申请。
    • 特点:实现复杂,会增加上下文切换开销。
  3. 破坏“循环等待”条件(最常用、最实用)
    • 资源有序分配法:给所有资源类型定义统一顺序,进程必须按递增顺序申请资源。
    • 原理:强制申请顺序后,无法形成“A等B、B等A”的环路。
    • 示例:锁L1、L2、L3的申请顺序规定为L1→L2→L3
      • 合法:线程A(L1→L2)、线程B(L1→L3)
      • 非法:线程C(先L3→再L1)
    • 拓展代码示例:
      // 按顺序申请锁的正确示范
      pthread_mutex_lock(&L1);  // 先申请低序锁
      pthread_mutex_lock(&L2);  // 再申请高序锁
      // 业务逻辑处理
      pthread_mutex_unlock(&L2);  // 释放顺序与申请相反
      pthread_mutex_unlock(&L1);
      

策略2:避免死锁(运行时动态防止)

系统分配资源前,先判断分配后是否进入“不安全状态”(可能发生死锁的状态),若会则拒绝分配。

  • 经典算法:银行家算法
    • 核心思想:模拟银行放贷逻辑,确保资源分配后系统仍能满足所有进程的最大需求,避免“坏账”(死锁)。
    • 优点:资源利用率高于预防策略。
    • 缺点:需提前预知进程最大资源需求(实际编程难实现)、算法本身有性能开销,仅用于教学或特定系统设计。

策略3:检测与恢复(放任死锁后处理)

不预防死锁,定期检测系统是否发生死锁,若发生则采取恢复措施。

  1. 死锁检测
    • 系统维护“资源分配图”,记录所有资源的分配与请求情况。
    • 定期调用算法检查图中是否存在环路,有环路则表明发生死锁。
  2. 死锁恢复
    • 进程终止:
      • 终止所有死锁进程:简单粗暴,但代价极高。
      • 逐个终止进程:每次终止一个(优先级最低、代价最小的),释放资源直到打破死锁环。
    • 资源抢占:
      • 从死锁进程中抢占资源,分配给其他进程直到死锁解除。
      • 需解决三个问题:选择牺牲哪个进程、如何回滚进程到安全状态、防止同一进程反复被牺牲(饥饿)。

线程并发中的死锁实例与解决方案

典型场景

线程持有一个或多个资源时被取消,且未释放资源,可能导致死锁。

方案1:使用取消处理函数

线程被取消时,先释放所有资源再退出,通过“压栈-出栈”机制实现。

  • 核心函数:
    #include <pthread.h>
    // 压栈:将取消处理函数存入栈中
    void pthread_cleanup_push(void (*routine)(void *), void *arg);
    // routine:取消处理函数地址(函数指针)
    // arg:传递给取消处理函数的参数地址
    
    // 出栈:弹出取消处理函数
    void pthread_cleanup_pop(int execute);
    // execute:0=只弹出不执行,非0=弹出并执行
    
  • 注意事项:
    • 压栈与出栈函数必须配套使用。
    • 压栈与出栈需在同一层代码块中。
  • 完整示例:
    #include <stdio.h>
    #include <pthread.h>
    #include <unistd.h>
    
    void Func1(void *arg) {
        printf("[1]执行取消处理函数,释放资源...\n");
    }
    
    void Func2(void *arg) {
        printf("[2]执行取消处理函数,释放资源...\n");
    }
    
    void Func3(void *arg) {
        printf("[3]执行取消处理函数,释放资源...\n");
    }
    
    void *TaskFunc(void *arg) {
        // 压入取消处理函数(栈结构,先进后出)
        pthread_cleanup_push(Func1, NULL);
        pthread_cleanup_push(Func2, NULL);
        pthread_cleanup_push(Func3, NULL);
    
        while (1) {
            printf("线程运行中...\n");
            sleep(1);
        }
    
        // 出栈(0=只弹出不执行,因while循环不会正常退出)
        pthread_cleanup_pop(0);
        pthread_cleanup_pop(0);
        pthread_cleanup_pop(0);
    }
    
    int main(int argc, char const *argv[]) {
        pthread_t tid;
        pthread_create(&tid, NULL, TaskFunc, NULL);
    
        sleep(3);  // 让线程运行3秒
        pthread_cancel(tid);  // 发送取消请求
        pthread_join(tid, NULL);  // 等待线程退出
        printf("目标线程已退出\n");
    
        return 0;
    }
    

方案2:设置取消响应状态与类型

通过控制线程对取消请求的响应行为,避免资源未释放的情况。

  • 核心函数:
    #include <pthread.h>
    // 设置取消状态
    int pthread_setcancelstate(int state, int *oldstate);
    // state:PTHREAD_CANCEL_ENABLE(允许取消,默认)、PTHREAD_CANCEL_DISABLE(禁止取消)
    
    // 设置取消类型
    int pthread_setcanceltype(int type, int *oldtype);
    // type:PTHREAD_CANCEL_DEFERRED(延迟响应,默认)、PTHREAD_CANCEL_ASYNCHRONOUS(立即响应)
    
  • 关键代码示例:
    void *TaskFunc(void *arg) {
        // 禁止接收取消请求,确保资源操作完成
        pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
    
        int Num = 5;
        while (Num--) {  // 关键业务逻辑(持有资源期间)
            printf("线程运行中...\n");
            sleep(1);
        }
    
        // 完成资源操作后,允许接收取消请求
        pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
    
        while (1) {  // 后续循环,可响应取消
            printf("[2]线程运行中...\n");
        }
    }
    

补充:取消点(Cancelation Points)

POSIX.1 规定的函数,若线程可取消且类型为延迟响应,收到取消请求后会在调用这些函数时触发取消。man 7 pthreads

  • 核心取消点函数(部分):
    accept()、aio_suspend()、clock_nanosleep()、close()、connect()、creat()、fcntl()(F_SETLKW)、fdatasync()、fsync()、getmsg()、getpmsg()、lockf()(F_LOCK)、mq_receive()、mq_send()、mq_timedreceive()、mq_timedsend()、msgrcv()、msgsnd()
  • 注意:立即响应类型(PTHREAD_CANCEL_ASYNCHRONOUS)无需等待取消点,收到请求后立即执行取消。
示例图

示例代码

void * TaskFunc( void *  arg )
{
    // 把取消类型设置为立即响应
    // 立即响应 = 不需要等待取消点函数被调用
    pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS , NULL );

    int Num = 5 ;
    while(Num)
    {
        // 没有取消点
    }
}


拓展知识:死锁常见场景与避坑技巧

常见死锁场景

  1. 嵌套锁使用不当:线程A持有锁1申请锁2,线程B持有锁2申请锁1。
  2. 资源释放顺序错误:申请顺序为L1→L2,释放顺序却为L2→L1(虽不必然死锁,但增加风险)。
  3. 线程池任务依赖:线程池中的任务互相等待对方持有的资源。

实际开发避坑技巧

  1. 使用智能锁:如C++的std::lock_guardstd::unique_lock,自动释放资源,避免手动解锁遗漏。
  2. 限制锁持有时间:给锁申请设置超时时间,超时未获取则释放已有资源并重试。
  3. 减少锁粒度:将大锁拆分为多个小锁,降低资源竞争概率。
  4. 日志监控:记录锁的申请、持有、释放日志,便于排查潜在死锁问题。

posted @ 2025-12-05 08:23  Jaklin  阅读(102)  评论(0)    收藏  举报