进程和线程创建销毁时mutex死锁问题分析

发布于:2025-09-06 ⋅ 阅读:(16) ⋅ 点赞:(0)

一、问题说明

linux下C语言多线程编程,通常使用mutex互斥锁解决竞争问题,但是在进程和线程创建销毁时,可能导致意外的死锁问题。

  • 进程

linux下父子进程地址空间是独立的,采用写时复制(COW)的原则,使用fork进程创建时,子进程地址空间内容与父进程完全一致,若父进程已持锁,子进程会继承锁的状态,后续子进程再访问此锁时,会出现死锁。

而子进程销毁时,因为与父进程地址空间已完全独立,子进程销毁不会影响父进程的锁的状态。

父进程 子进程 mutex_lock fork mutex_lock -死锁 父进程 子进程
  • 线程

linux下线程pthread地址空间,父子线程是共用的,所以创建后子线程可以正常持锁。就算父线程此时已持锁,子线程再持锁,也不会导致死锁,等待父线程释放锁,子线程就可以正常获取锁,mutex本身设计之初就是解决线程之间互斥的。

线程销毁时,由于父子线程地址空间共用,若线程退出时已经持锁,线程使用pthread_cancel退出后,父线程再获取此锁,会导致死锁问题。

线程1 线程2 mutex_lock pthread_cancel mutex_lock -死锁 线程1 线程2
  • 汇总
对象 创建 销毁
进程 继承父进程锁状态,可能死锁。 无问题
线程 无问题 子线程可能持锁后退出,可能死锁

本质上,都是由创建销毁新上下文时,锁的状态无法自动释放锁,还是维持之前错误的状态,从设计上其实不难做到,不理解为何有这个历史遗留问题。

二、进程创建时死锁问题

2.1 锁的状态与风险

  • 锁状态复制:如果父进程中某个线程正持有锁,那么在子进程中,该锁会表现为已被持有的状态(即使实际持有它的那个父进程线程并不存在于子进程中)。
  • 未定义行为风险:子进程尝试操作(例如锁定或解锁)这个从父进程继承而来的、状态可能不一致的互斥锁,可能导致未定义行为,常见的是死锁

2.2 底层处理与解决方案

互斥锁的状态管理通常由 Pthreads 库(C 库,如 glibc) 实现,但库的实现会依赖 Linux 内核提供的底层同步机制(如 futex)来保证原子性和阻塞/唤醒操作。

为了避免问题,POSIX 标准提供了 pthread_atfork()函数来帮助安全地处理 fork 与互斥锁的交互:

#include <pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

// 在fork()之前调用pthread_atfork()来注册处理函数
void pre_fork() { pthread_mutex_lock(&mutex); }
void post_fork_parent() { pthread_mutex_unlock(&mutex); }
void post_fork_child() { 
    // 在子进程中,可能需要将锁重置到未锁定状态,或进行其他初始化
    // 但直接解锁可能不安全,更常见的做法是避免在子进程使用父进程的锁,或者使用其他同步原语
    pthread_mutex_unlock(&mutex); 
    // 或者更安全的方法:在子进程中立即调用exec系列函数,不再依赖父进程的状态
}

int main() {
    pthread_atfork(pre_fork, post_fork_parent, post_fork_child);
    // ... 其他代码,包括fork() ...
}
  • pthread_atfork()注册的三个处理函数:
    • pre_fork:在 fork 之前调用。通常用于锁定所有父进程中的互斥锁,确保 fork 发生时父进程处于一个确定的、稳定的状态。
    • post_fork_parent:在 fork 之后,在父进程中调用。用于释放pre_fork中锁定的所有互斥锁。
    • post_fork_child:在 fork 之后,在子进程中调用。这是最关键的环节。子进程需要谨慎处理继承来的锁状态。有时会选择直接解锁,但更安全和常见的做法是:
      • 立即调用 exec系列函数:如果子进程计划调用 exec来执行一个新程序,那么父进程地址空间(包括那些锁)会被完全替换,这就自动避免了继承锁状态的问题。这是最推荐和最简单的做法。
      • 避免使用继承的锁:如果子进程不调用 exec,则需要非常小心地处理所有从父进程继承的同步原语。有时可能需要子进程完全重新初始化自己的同步环境。

最佳实践:如果子进程在 fork 后立即调用 exec()执行新程序,那么继承的锁状态会被新程序覆盖,无需特殊处理。若不调用 exec(),则需通过 pthread_atfork()等方式确保锁在子进程中的状态安全,或考虑使用其他进程间同步机制(如信号量、文件锁等)。

2.3 典型案例-popen引发的血案

在多线程架构中,使用popen执行shell命令,导致的死锁。某些glibc版本中,popen函数中vfork后可能会访问fd list进行持锁,然后再支持exec,在执行exec就持锁,导致出现死锁问题。

vfork
fd lock
exec

尽量少使用进程和线程混搭的情况。

三、线程销毁时死锁问题

使用 pthread_cancel取消一个正在持有互斥锁的线程是危险的,如果取消请求恰好在线程持有锁时发生,并且该线程在被取消前没有机会释放锁,那么这个锁就会永远处于锁定状态,导致其他等待该锁的线程死锁

3.1 问题根源

线程的取消点(Cancellation Points)是线程检查是否被取消并响应的位置。许多系统调用和库函数(如 pthread_cond_wait, read, write, sleep)都是取消点。如果取消请求发生在线程进入取消点之后但尚未释放锁之前,就可能引发问题。

3.2 解决方案

🔒 使用线程清理处理程序(Cleanup Handlers)

这是最直接和推荐的方法。pthreads 库提供了 pthread_cleanup_push()pthread_cleanup_pop()宏,用于注册和移除清理函数。这些函数在线程被取消或通过 pthread_exit()退出时自动执行,用于释放资源(如互斥锁)。

#include <pthread.h>
#include <stdio.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

// 清理函数,用于解锁
void cleanup_handler(void *arg) {
    pthread_mutex_unlock((pthread_mutex_t *)arg);
    printf("Cleanup handler: mutex unlocked\n");
}

void *thread_func(void *arg) {
    // 将清理处理程序压栈
    pthread_cleanup_push(cleanup_handler, &mutex); 

    pthread_mutex_lock(&mutex); // 加锁
    // 临界区操作...
    printf("Thread is working in critical section...\n");
    // 假设这里是一个取消点(如某些系统调用),或者循环检查取消请求
    sleep(5); // sleep是一个取消点

    // 正常解锁并弹出清理处理程序(参数非0表示执行清理函数,0表示不执行)
    pthread_mutex_unlock(&mutex);
    pthread_cleanup_pop(0); // 正常执行到此处,弹出清理函数但不执行它

    return NULL;
}

int main() {
    pthread_t tid;
    pthread_create(&tid, NULL, thread_func, NULL);

    sleep(2); // 让子线程运行并获取锁
    pthread_cancel(tid); // 发送取消请求

    pthread_join(tid, NULL); // 等待线程结束
    printf("Main thread: joined successfully.\n");
    return 0;
}

在这个例子中,即使 thread_funcsleep(一个取消点)时被取消,cleanup_handler也会被调用并释放互斥锁,从而防止死锁。

🏁 禁用取消或使用延迟取消
  • 禁用取消:在线程进入临界区前,使用 pthread_setcancelstate()临时禁用取消功能。

    pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &old_state);
    pthread_mutex_lock(&mutex);
    // 临界区操作
    pthread_mutex_unlock(&mutex);
    pthread_setcancelstate(old_state, NULL);
    

    这可以确保线程在持有锁时不会被取消,但需谨慎使用,因为可能导致取消请求响应不及时。

  • 使用延迟取消(Deferred Cancellation):这是默认的取消类型。线程只会在到达取消点时才会响应取消请求。你可以确保在临界区内没有取消点(注意:某些函数可能是隐藏的取消点),或者在临界区内使用 pthread_testcancel()手动创建取消点,但这需要精心设计。

🚩 使用标志位安全终止线程(推荐替代方法)

避免使用 pthread_cancel,而是采用协作式取消。在线程函数内周期性地检查一个全局或传入的标志位,当该标志位被设置时,线程主动清理资源并退出。

#include <stdatomic.h>
// 或使用 volatile 和互斥锁保护

atomic_bool stop_requested = ATOMIC_VAR_INIT(false); // C11 原子变量
// 或者 volatile bool stop_requested = false; 并结合互斥锁确保可见性

void *thread_func(void *arg) {
    while (!atomic_load(&stop_requested)) { // 检查停止请求
        pthread_mutex_lock(&mutex);
        // 临界区工作
        pthread_mutex_unlock(&mutex);
        // ... 其他工作
    }
    // 线程安全地清理资源后退出
    return NULL;
}

// 在另一个线程中请求该线程停止:
atomic_store(&stop_requested, true);

这种方法完全避免了异步取消带来的不确定性,是最安全、最可控的线程终止方式。

3.3 典型案例

线程退出点通常是一些睡眠的API,使用这些API时如果有加锁则更容易引发死锁问题,比如发包函数等。

线程1 线程2 mutex_lock send(线程退出点) pthread_cancel mutex_lock -死锁 线程1 线程2

3.4 参考资料

线程退出点、退出点等介绍参考如下文章。

https://blog.csdn.net/chengf223/article/details/117999110

四、实践建议与总结

场景 关键问题 推荐解决方案
fork() 与互斥锁 子进程继承锁状态可能导致死锁 子进程后立即调用 exec();或使用 pthread_atfork()管理锁状态
pthread_cancel 与持锁线程 线程被取消时锁未释放导致死锁 首选:使用 pthread_cleanup_push/pop注册清理函数释放锁 更安全选择:使用协作式取消(标志位)替代 pthread_cancel


网站公告

今日签到

点亮在社区的每一天
去签到