当前位置: 首页 > ops >正文

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

一、问题说明

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

  • 进程

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

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

父进程子进程mutex_lockforkmutex_lock -死锁父进程子进程
  • 线程

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

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

线程1线程2mutex_lockpthread_cancelmutex_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线程2mutex_locksend(线程退出点)pthread_cancelmutex_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

http://www.xdnf.cn/news/19995.html

相关文章:

  • 神经网络之深入理解偏置
  • Go语言实战案例- 命令行参数解析器
  • Gin + Viper 实现配置读取与热加载
  • swing笔记
  • 【Flutter】flutter_local_notifications并发下载任务通知实践
  • 深度学习基础概念【持续更新】
  • 前端安全防护深度实践:从XSS到供应链攻击的全面防御
  • JAiRouter 配置文件重构纪实 ——基于单一职责原则的模块化拆分与内聚性提升
  • 消费品企业客户数据分散?CRM 系统来整合
  • Python包管理工具全对比:pip、conda、Poetry、uv、Flit深度解析
  • mac怎么安装uv工具
  • CT影像寻找皮肤轮廓预处理
  • 一天一个强大的黑科技网站第1期~一键抠图神器!设计师必备!分分钟扣100张图!
  • 基于STM32设计的激光充电控制系统(华为云IOT)_277
  • Flutter的三棵树
  • 【STM32外设】DAC
  • Big Data Analysis
  • 某头部能源集团“数据治理”到“数智应用”跃迁案例剖析
  • Ubuntu中使用nginx-rtmp-module实现视频点播
  • mac 安装 nginx
  • Day36 TCP客户端编程 HTTP协议解析 获取实时天气信息
  • 如何选择适合的实验室铸铁地板和铸铁试验平板?专业人士帮助指南
  • 【开题答辩全过程】以 基于Android的点餐系统为例,包含答辩的问题和答案
  • 《sklearn机器学习——多标签排序指标》
  • Conda 使用py环境隔离
  • 新后端漏洞(上)- H2 Database Console 未授权访问
  • 高级RAG策略学习(四)——上下文窗口增强检索RAG
  • 耐达讯自动化RS485与Profinet双向奔赴,伺服驱动器连接“稳稳拿捏”
  • 第24节:3D音频与空间音效实现
  • 如何使用宝塔API批量操作Windows目录文件:从获取文件列表到删除文件的完整示例