《从零入门Linux系统篇(五十八):线程篇·十一——线程安全与死锁详解:从可重入到多锁管理》

这篇,是Linux主线部分的最后一篇了。当然,日后有空,加餐内容还会继续补上,但主线走到这里,算是要画个句号。

废话不多说,今天把线程部分剩下的内容收个尾,重点就落在"线程安全"这四个字上。要聊的东西不少:STL里的线程安全、线程安全与重入问题、常见的锁,还有让人头疼的死锁。我们正式开始。

目录

一、线程安全与可重入:两个容易混淆的问题

[1.1 什么是线程安全,什么是可重入](#1.1 什么是线程安全,什么是可重入)

[1.2 线程安全与可重入的常见场景](#1.2 线程安全与可重入的常见场景)

[1.2.1 常见的线程不安全情况](#1.2.1 常见的线程不安全情况)

[1.2.2 常见的不可重入情况](#1.2.2 常见的不可重入情况)

[1.2.3 常见的线程安全情况](#1.2.3 常见的线程安全情况)

[1.2.4 常见的可重入情况](#1.2.4 常见的可重入情况)

[1.3 可重入与线程安全:联系与区别](#1.3 可重入与线程安全:联系与区别)

[1.3.1 可重入为什么通常更安全](#1.3.1 可重入为什么通常更安全)

[1.3.2 可重入与线程安全并不是一回事](#1.3.2 可重入与线程安全并不是一回事)

[1.3.3 实际编程时需要注意什么](#1.3.3 实际编程时需要注意什么)

二、死锁:多个线程为什么会互相等下去

[2.1 什么是死锁问题](#2.1 什么是死锁问题)

[2.2 死锁的四个必要条件](#2.2 死锁的四个必要条件)

三、如何避免与处理死锁

[3.1 trylock:如何避免一直持有资源等待](#3.1 trylock:如何避免一直持有资源等待)

[3.1.1 什么是trylock](#3.1.1 什么是trylock)

[3.1.2 trylock如何处理请求与保持问题](#3.1.2 trylock如何处理请求与保持问题)

[3.1.3 trylock与不可剥夺条件有什么关系](#3.1.3 trylock与不可剥夺条件有什么关系)

[3.2 一次性获取多个资源:std::lock的应用](#3.2 一次性获取多个资源:std::lock的应用)

[3.2.1 一次性分配多个资源的思路与问题](#3.2.1 一次性分配多个资源的思路与问题)

[3.2.2 C++11 std::lock:标准库提供的解决方案](#3.2.2 C++11 std::lock:标准库提供的解决方案)

[3.2.3 运行结果对比:方案到底有没有改善](#3.2.3 运行结果对比:方案到底有没有改善)

[3.3 继续了解:常见的死锁避免算法](#3.3 继续了解:常见的死锁避免算法)

四、STL与智能指针的线程安全边界

[4.1 STL容器到底是不是线程安全的](#4.1 STL容器到底是不是线程安全的)

[4.2 智能指针到底是不是线程安全的](#4.2 智能指针到底是不是线程安全的)

五、其他常见锁机制


一、线程安全与可重入:两个容易混淆的问题

1.1 什么是线程安全,什么是可重入

线程安全:多个线程访问共享资源时,能够正确地执行,不互相干扰,也不破坏彼此的执行结果。一般来说,多个线程并发跑一段只碰局部变量的代码,结果不会出岔子。可一旦对全局变量或者静态变量动手,又没锁保护,麻烦就容易找上门。

重入 :同一个函数,被不同的执行流调用。前一个流程还没跑完,另一个执行流又闯了进来,这就叫重入。一个函数在重入的情况下,运行结果依然正确、不出任何幺蛾子,那它就是可重入函数 ;否则,就是不可重入函数。

学到现在,重入其实已经能拆成两种情况来看了:

  • 多线程重入函数:多个线程同时闯进同一个函数。

  • 信号导致一个执行流重复进入函数:信号处理函数插队,把正在执行的流程又拉进同一个函数里。

1.2 线程安全与可重入的常见场景
1.2.1 常见的线程不安全情况
  • 函数里碰了共享变量,却没做保护。
  • 函数的状态会随着调用发生变化。
  • 函数返回一个指向静态变量的指针。
  • 函数内部调用了别的线程不安全函数。
1.2.2 常见的不可重入情况
  • 调用了malloc/free。因为malloc底层是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现,都以不可重入的方式使用全局数据结构。
  • 可重入函数体内使用了静态数据结构。
1.2.3 常见的线程安全情况
  • 每个线程对全局变量或静态变量只有读取权限,没有写入权限。只读不写,一般就是安全的。
  • 类或者接口对线程来说,都是原子操作。
  • 多个线程之间的切换,不会导致接口的执行结果出现二义性。
1.2.4 常见的可重入情况
  • 不使用全局变量或静态变量。
  • 不使用malloc或new开辟出来的空间。
  • 不调用不可重入函数。
  • 不返回静态或全局数据,所有数据都由函数调用者提供。
  • 使用本地数据,或者通过给全局数据做一份本地拷贝,来保护全局数据。

别被上面这一串看着像绕口令的话唬住。仔细一看,其实这些情况说的都是一回事:只要一个函数不碰共享状态、不依赖外部环境、每次调用都从零开始,它多半就既线程安全,又可重入。

1.3 可重入与线程安全:联系与区别
1.3.1 可重入为什么通常更安全
  • 函数是可重入的,那它就是线程安全的。其实知道这一句话,就够用了。
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
  • 如果一个函数里有全局变量,那它既不是线程安全的,也不是可重入的。
1.3.2 可重入与线程安全并不是一回事
  • 可重入函数,是线程安全函数的一种。
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  • 如果给临界资源的访问加上锁,那这个函数就是线程安全的;但如果这个重入函数在锁还没释放的时候又被重入,就会产生死锁,因此它是不可重入的。
1.3.3 实际编程时需要注意什么

如果不考虑"信号导致一个执行流重复进入函数"这种重入情况,线程安全和重入在安全角度上,其实不用刻意区分。

但两者的侧重点不同:

  • 线程安全,侧重说明线程访问公共资源时的安全情况,表现的是并发线程的特点。

  • 可重入,描述的是一个函数能否被重复进入,表示的是函数的特点。

同一个问题的两个侧面,像一枚硬币的两面,看你从哪个角度看。

Tips:为什么"线程安全不一定是可重入的,而可重入函数则一定是线程安全的"

上面那两句话,光看文字确实绕。我们拿单例模式里获取线程池实例的函数,来把这件事说透。

先看一个经典的双检锁单例实现:

cpp 复制代码
class ThreadPool {
public:
    static ThreadPool* getInstance() {
        if (_instance == nullptr) {           // 第一次检查
            _mutex.lock();
            if (_instance == nullptr) {        // 第二次检查
                _instance = new ThreadPool();
            }
            _mutex.unlock();
        }
        return _instance;
    }

private:
    static ThreadPool* _instance;
    static std::mutex _mutex;
};

这个getInstance()是线程安全的。

多个线程同时调用它,双检锁保证了只有一个线程能进入new ThreadPool()那段代码,其他线程要么被挡在锁外,要么在第二次检查时发现实例已经创建,直接返回。共享变量_instance被锁保护着,不会出现竞态条件。所以,它线程安全。但它不是可重入的。

设想一个场景:线程A正在执行getInstance(),它已经拿到了_mutex,正卡在new ThreadPool() 的过程中。这时候,一个信号打断了线程A,信号处理函数里又调用了 getInstance(),这就是重入。

信号处理函数跑在同一个线程的上下文里,它走到_mutex.lock()时,发现锁已经被自己(线程A)持有着。可线程A此刻正卡在信号处理函数里,根本没法往下跑去解锁。于是信号处理函数永远等在那里,线程A也永远回不去,死锁。同一个线程,自己等自己释放锁,永远等不到。

所以结论很清楚:加锁让函数线程安全,但锁本身是一种共享状态,导致函数不可重入。 线程安全,是用锁换来的;可重入,要求函数不依赖任何共享状态。加了锁,就丢了可重入。

反过来,可重入函数为什么一定是线程安全的?

因为可重入函数不依赖任何共享的可变状态,不用全局变量,不用静态变量,也不用锁。每次调用的数据都是局部的、独立的,多个线程同时调用,各算各的,互不干扰。没有共享状态,就没有竞态;没有竞态,自然线程安全。看个最简单的例子:

cpp 复制代码
int add(int a, int b) {
    int result = a + b;   // 只用局部变量
    return result;
}

多个线程同时调用add,各算各的,互不影响。它可重入,也线程安全。

再对比一个线程安全但不可重入的:

cpp 复制代码
std::mutex mtx;
int counter = 0;

void safe_increment() {
    std::lock_guard<std::mutex> lock(mtx);   // 加锁
    counter++;                                 // 操作共享变量
}

多个线程同时调用,锁保证了安全,所以它是线程安全的。但同一个线程在持有锁期间又重入它(比如信号打断),就会死锁。所以它不可重入。

最后看一个既不可重入也不线程安全的:

cpp 复制代码
int counter = 0;

void unsafe_increment() {
    counter++;   // 没有锁保护,多个线程会出问题
}

把它整理成一张表,对比更清楚:

函数类型 线程安全 可重入 原因
只用局部变量 ✅ ✅ 不依赖共享状态,天然安全
加锁保护共享数据 ✅ ❌ 锁是共享状态,重入会死锁
无保护地操作共享数据 ❌ ❌ 存在竞态条件,数据会被破坏

一句话收尾:可重入是"不碰共享状态",线程安全是"碰了共享状态但用锁兜住了"。前者更纯粹,后者更实用。两者重叠,但不重合。

二、死锁:多个线程为什么会互相等下去

2.1 什么是死锁问题

死锁这个话题,校招面试里考得还挺多,值得好好掰扯。

所谓死锁,就是一组进程各自占着不会释放的资源,同时又都伸手去要别人占着不放的资源。你等我,我等你,谁也不撒手,谁也动不了,就这么僵在原地,陷入一场永无止境的等待。

为了把话说清楚,假设现在有线程A和线程B,两个线程都必须同时持有锁1和锁2,才能继续往下访问资源。

讲个小故事:

一个小胖子和一个小男孩,各自兜里揣着五毛钱,跑去小卖部买棒棒糖。老板手一摊:"棒棒糖一块钱一个。"

胖子扭头对小男孩说:"我有五毛,现在要你手里那五毛,凑一块我就能买着棒棒糖了。"小男孩不甘示弱,原话奉还,也对胖子说了同样一句。

在老板眼里,这俩人各自攥着自己的五毛钱,死活不撒手,同时又都盯着对方那五毛。谁也不肯先松手,谁也不肯让一步。于是,胖子和小男孩就陷进了一场无穷无尽的争吵。

拿示意图一画,事情就清楚了:

A拿着锁1,想要锁2;B 拿着锁2,想要锁1。两人各自占着对方需要的那把锁,又都不肯先松手。僵局,就此形成。

造成的结果就是: 两个线程谁也不动,谁也不放,就这么永远卡在那里,直到天荒地老。程序死锁,整个进程跟着陪葬。

2.2 死锁的四个必要条件

死锁不是凭空冒出来的,它得同时凑齐四个条件。四个缺一个,死锁就立不住。

  • 互斥条件:一个资源,每次只能被一个执行流占用。资源的独占性,没什么好解释的。

  • 请求与保持条件:一个执行流因为申请新资源被阻塞时,手里已经攥着的资源,死死不放。一边等新的,一边占着旧的。

  • 不剥夺条件:已经拿到手的资源,在它自己用完之前,谁也不能强行抢走。只能等它自愿释放。
  • 循环等待条件:若干个执行流之间,形成一种头尾相接的等待环。A 等 B,B 等 C,C 又等 A,绕一圈回来,谁也出不去。

这四个条件同时成立,死锁才会发生。反过来说,只要打破其中任意一个,死锁就无从谈起。后面要讲的预防死锁,入手点就在这儿。

三、如何避免与处理死锁

解决死锁,说穿了就是一件事:破坏四个必要条件中的任意一个。 条件凑不齐,死锁就立不住。

3.1 trylock:如何避免一直持有资源等待
3.1.1 什么是trylock

tryLock是一种非阻塞的锁获取机制。它的工作机制很干脆:**尝试获取锁,成功就返回true;锁已经被别人占了,不阻塞、不等待,立刻返回false。**跟lock()那种"拿不到就死等"的脾气,完全是两码事。

3.1.2 trylock如何处理请求与保持问题

核心逻辑就四个字:拿不到就放手。

传统的lock(),在拿不到新锁的时候,会死等下去,同时死死攥着手里已经拿到的旧锁。旧的占着,新的等着,请求与保持条件就这么凑齐了。

tryLock()不一样。它去申请新锁,发现拿不到,立刻返回false。代码收到这个false,随即主动释放已经持有的旧锁,打破"占着不放"的保持状态。旧的让出去了,新的再慢慢想办法,请求与保持,被它拆开了。

3.1.3 trylock与不可剥夺条件有什么关系

核心逻辑是:逻辑上的自我剥夺。

传统意义上的"不可剥夺",说的是资源不能被外部强行抢走。而 tryLock() 实现的是一种主动剥夺当线程发现自己凑不齐全部资源时,主动"剥夺"自己对已有锁的占有权,把锁交出去,让资源恢复可分配的状态。

可能有人会犯嘀咕:剥夺,难道不是冲着别人去的动作吗?

其实,剥夺也可以指向自己。主动放手,也是一种剥夺------剥夺的是自己的占有权。 与其攥着旧锁跟别人死磕,不如先松开,给自己留一条活路。

3.2 一次性获取多个资源:std::lock的应用

除了tryLock这条思路,解决死锁还有另一个有效方向:破坏请求与保持条件,顺便把循环等待也一并拆了。 核心就一句话,要么一份资源都不占,要占就一次性把需要的全拿到手。这样,"攥着A去死等B"的困境,根本无从发生。

3.2.1 一次性分配多个资源的思路与问题

多线程开发里,假设一个线程需要同时访问资源1(mtx1)和资源2(mtx2)。传统的逐个加锁方式,简直是死锁的温床:

cpp 复制代码
// 传统加锁:极易产生死锁与资源竞争
mtx1.lock();
mtx2.lock(); // 若此时其他线程持有了 mtx2 并等待 mtx1,则直接死锁

加锁顺序稍有不慎,或者中途缺了防护机制,轻则线程卡死,重则数据脏读脏写,比如那个累加计数器,跑完之后根本到不了预期的数值。

3.2.2 C++11 std::lock:标准库提供的解决方案

C++11标准库直接给出了答案:std::lock。它的核心本事,就是**一次性同时锁定两个或多个互斥量。**内部藏着一套死锁避免算法,通常结合非阻塞尝试和退避策略,保证多个锁要么全拿到,要么一个都不占,绝不会卡在半路。

下面是配合std::unique_lock和std::defer_lock实现"资源一次性分配"的完整代码:

cpp 复制代码
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
#include <unistd.h>

// 两个共享资源和两把互斥锁
int shared_resource1 = 0;
int shared_resource2 = 0;
std::mutex mtx1, mtx2;

// 同时访问两个共享资源的线程函数
void access_shared_resources()
{
    // 1. 用 defer_lock 声明锁管理对象,此时先不加锁
    std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
    std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);

    // 2. 一次性安全加锁:同时锁住两把互斥锁,从根上破坏死锁条件
    std::lock(lock1, lock2);

    // 3. 两把锁都到手了,放心大胆地访问共享资源
    int cnt = 10000;
    while (cnt)
    {
        ++shared_resource1;
        ++shared_resource2;
        cnt--;
    }

    // 4. 函数结束时,lock1 和 lock2 的析构函数自动释放互斥锁
}

// 模拟多线程并发访问
void simulate_concurrent_access()
{
    std::vector<std::thread> threads;

    // 创建 10 个并发线程
    for (int i = 0; i < 10; ++i)
    {
        threads.emplace_back(access_shared_resources);
    }

    // 等所有线程跑完
    for (auto &thread : threads)
    {
        thread.join();
    }

    // 输出最终结果
    std::cout << "Shared Resource 1: " << shared_resource1 << std::endl;
    std::cout << "Shared Resource 2: " << shared_resource2 << std::endl;
}

int main()
{
    simulate_concurrent_access();
    return 0;
}
3.2.3 运行结果对比:方案到底有没有改善

资源到底有没有做到"一次性安全分配",结果一跑便知,天差地别。

非一次性安全申请(逐个加锁):竞争条件和锁等待冲突双双冒头,共享资源的值根本没法保证正确。10个线程各累加10000次,预期100000,实际跑出来只有94416和94536,甚至跑着跑着就一头栽进死锁里。

一次性申请(用std::lock):原子化地同时锁定多个资源,共享资源的计算结果精准落地,Shared Resource 1: 100000,Shared Resource 2: 100000,一个不多,一个不少。

差别就在这:要么全拿,要么不拿,绝不半途卡住。 死锁的根,从加锁方式上就被拔掉了。

3.3 继续了解:常见的死锁避免算法

死锁这块,还有两个经典算法。面试考得少,权当拓展。

死锁检测算法

它的思路是:不预防死锁,先让死锁发生,再回头检查系统当前状态。算法维护一张"资源分配图",哪个进程占着哪些资源、又在等哪些资源。然后周期性地扫一遍这张图,看有没有形成环。有环,就说明死锁已经出现。

一旦检测到死锁,系统再决定怎么收场:是剥夺某个进程的资源、回滚某个进程,还是干脆杀掉几个。检测算法适合那种"死锁偶尔发生,但不值得花大力气预防"的场景。

银行家算法

这个算法更主动,玩的是"事先评估"。名字来自银行放贷,银行家手上有多少资金,每个客户想借多少、最多借多少,他心里得有数。只有当一笔贷款放出去之后,系统还有余力满足其他客户的最大需求时,银行家才肯放款。

映射到操作系统里,就是:进程提出资源申请时,系统先假装分配一次,然后运行安全性检查。如果分配之后,系统依然存在一条能让所有进程都顺利跑完的"安全序列",那就真的分配;否则,申请被拒绝,进程等着。核心思想一句话:只做那些不会把系统带进死路的分配。

四、STL与智能指针的线程安全边界

4.1 STL容器到底是不是线程安全的

不是。原因也不难理解:STL的设计初衷,就是把性能挖到极致。一旦为了线程安全到处加锁,性能会付出巨大的代价。而且不同的容器,加锁方式还不一样,哈希表可以锁表,也可以锁桶,粒度不同,性能也不同。要统一处理,反而束手束脚。

所以,STL默认就不是线程安全的。想在多线程环境里用,得调用者自己保证安全,该加锁加锁,该隔离隔离。

4.2 智能指针到底是不是线程安全的

智能指针的线程安全问题,我们之前在C++11那部分聊过。这里把结论再拎一遍。

  • unique_ptr:不存在线程安全问题。它的生命周期只在当前代码块范围内,一个对象一个指针,独来独往,不跟别的线程共享,自然也没什么可竞争的。
  • shared_ptr:情况就不一样了。多个对象共用一个引用计数变量,这个计数就得被多个线程同时读写,线程安全问题随之而来。好在标准库实现的时候早就想到了这一层,它基于原子操作(CAS) 来保证引用计数的高效、原子更新。所以shared_ptr的引用计数操作本身是线程安全的,多个线程同时拷贝、析构shared_ptr,计数不会算乱。

但别高兴太早,引用计数安全,不代表它指向的对象也安全。 多个线程通过同一个shared_ptr去修改它管理的对象,那个对象本身的读写,还得你自己加锁保护。智能指针只管计数,不管内容。

五、其他常见锁机制

这块先不展开,后面会单独出一篇Linux加餐文章细讲。这里先把几个概念亮个相,混个脸熟。

悲观锁:每次取数据,总担心数据被别的线程改了,所以取之前先加锁,读锁、写锁、行锁,能上的都上。其他线程想访问?对不起,先阻塞挂起,等着。

乐观锁:每次取数据,心态很好,总觉得没人会改,所以不加锁。但更新数据之前,它会先检查一下,看看自己拿到的数据是不是还跟当初一致。主要靠两种手段:版本号机制和CAS操作。

CAS操作:更新数据时,先判断当前内存里的值,跟之前取到的值是否相等。相等,说明没人动过,就用新值更新;不等,说明数据被改过,本次更新失败,回头重试。通常是个自旋的过程,不断重试,直到成功为止。

自旋锁、读写锁这些,放到加餐里详细介绍,这里先按下不表。


如果这个系列对你有帮助,别忘了点个赞、点个收藏、点个关注。你的每一次反馈,都是我继续硬核输出的最大动力。加餐篇,我们不见不散。

相关推荐
郝学胜_神的一滴1 小时前
C++ Templates 05:那些容易踩坑的技巧性基础知识
c++·visual studio
用户5708462574401 小时前
AI 会话该什么时候重开?一份「上下文卫生」的判断清单
架构
黑妹天下第一乖1 小时前
第 05 讲:阿加犀 AidCV 图像处理加速与 OpenCV 一致开发实战
开发语言·图像处理·人工智能·嵌入式硬件·数码相机·opencv·计算机视觉
言乐61 小时前
Python基于关键词分拣快递(适用于电商与货运代理等)
开发语言·python·django·virtualenv·pygame
一条破秋裤1 小时前
01_字符设备基本概念_从分类到LED控制链路
开发语言·php
励志不掉头发的内向程序员1 小时前
从鼠标点击到画出一条线:CAD 交互层的状态机设计
后端·架构
朝朝辞暮i1 小时前
C++ 第 40 章:ROS2 Publisher + Timer + Subscriber + Callback + Executor 完整闭环
开发语言·c++·算法·ros2
ebiobiz1 小时前
Zig 工具链编译 STM32 开发指南
驱动开发·stm32·嵌入式硬件
bullkingluo1 小时前
从零到一搭建企业级智能问答系统:Ch14 · 三层记忆与断点续跑
架构·llm·agent