【Linux系统编程】(四十七)线程安全与线程锁深度解析:从概念到实战,避坑指南全掌握


前言

在多线程编程的世界里,线程安全 是永恒的核心话题,而线程锁则是保障线程安全的核心武器。当多个线程并发访问共享资源时,稍不注意就会出现数据竞争、结果错乱甚至程序崩溃的问题;而锁的不当使用又会引发死锁、性能损耗等新问题。除此之外,函数重入、STL 容器和智能指针的线程安全特性,也是多线程开发中极易踩坑的点。

本文将从线程安全与可重入的核心概念出发,深入剖析死锁的成因与解决办法,再结合 STL 和智能指针的线程安全特性展开讲解,全程搭配 C/C++ 实战代码,让你从原理到实战彻底掌握线程安全与线程锁的核心知识,告别多线程编程的各种坑!下面就让我们正式开始吧!


一、线程安全与可重入:分清概念,避免混淆

很多开发者在接触多线程编程时,会把线程安全可重入混为一谈,认为二者是同一个概念。实际上,二者既有紧密联系,又有本质区别。理解这两个概念,是编写安全的多线程代码的基础。

1.1 核心概念定义

(1)线程安全

线程安全指的是多个线程并发访问同一段代码或共享资源时,程序能够正确执行,不会出现结果错乱、数据损坏或执行流程异常的情况

简单来说,线程安全的代码在多线程环境下的执行结果,和单线程环境下的执行结果完全一致。反之,如果多线程并发执行时,对全局变量、静态变量等共享资源的操作没有任何保护措施,就极易出现线程不安全的问题。

线程不安全的典型场景

  • 未加锁保护的共享变量操作(如多线程抢票案例中的ticket--);
  • 函数状态会随调用发生变化的情况;
  • 返回指向静态变量指针的函数(多个线程会访问同一个静态变量);
  • 调用了其他线程不安全函数的函数。

线程安全的典型场景

  • 代码只使用局部变量(局部变量存储在线程栈中,属于线程私有);
  • 对全局 / 静态变量只有读权限,无写权限;
  • 对共享资源的操作都是原子操作;
  • 多个线程的切换不会导致接口执行结果出现二义性。

(2)可重入

重入指的是同一个函数被不同的执行流调用,当前一个执行流还未执行完该函数时,另一个执行流又再次进入该函数

可重入函数 则是指函数在重入的情况下,运行结果不会出现任何异常或不同;反之则为不可重入函数

重入主要分为两种场景:

  1. 多线程重入:多个线程同时调用同一个函数;
  2. 信号导致的重入:程序在执行函数的过程中,被信号中断,信号处理函数又调用了原函数。

不可重入的典型场景

  • 函数内部调用了**malloc/free**:malloc通过全局链表管理堆内存,并发调用会导致链表错乱;
  • 函数内部调用了标准 I/O 库函数:标准 I/O 库的很多实现使用了全局数据结构,是非重入的;
  • 函数体内使用了静态 / 全局数据结构:多个执行流会同时修改该数据结构;
  • 函数未释放锁就再次进入:会导致死锁(后续重点讲解)。

可重入的典型场景

  • 不使用全局变量、静态变量,仅使用局部变量;
  • 不调用**malloc/newfree/delete**,避免操作全局堆管理结构;
  • 不返回静态 / 全局数据,所有数据由函数调用者提供;
  • 不调用任何不可重入的函数;
  • 通过制作全局数据的本地拷贝来保护全局数据。

1.2 线程安全与可重入的联系与区别

(1)核心联系

可重入函数一定是线程安全的,这是二者最核心的关联。因为可重入函数的设计本身就保证了多个执行流同时调用时不会出现资源竞争,自然能在多线程环境下安全执行。

反之,如果一个函数是不可重入的,那么它一定不能被多个线程并发调用,否则大概率会引发线程安全问题。

另外,如果一个函数中有全局 / 静态变量且未做任何保护,那么这个函数既不是线程安全的,也不是可重入的

(2)本质区别

二者的核心区别在于关注的侧重点不同

  • 线程安全 :侧重描述多个线程并发访问公共资源时的安全状态 ,是针对多线程并发场景的特性,关注的是线程之间的资源竞争
  • 可重入 :侧重描述一个函数本身的设计特性 ,关注的是函数是否能被多个执行流重复进入,其适用场景不仅包括多线程,还包括信号中断等单进程内的重入场景。

一个重要的结论:线程安全不一定是可重入的。例如,一个函数通过加锁保护共享资源,实现了线程安全,但如果该函数在持有锁的情况下被重入(如信号处理函数调用该函数),就会因为锁未释放而导致死锁,此时该函数是线程安全的,但不可重入。

1.3 实战代码:线程安全与可重入的对比

(1)线程不安全的函数示例

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

// 全局变量,无锁保护
int g_count = 0;

// 线程不安全的计数函数
void count_add() {
    for (int i = 0; i < 10000; ++i) {
        g_count++; // 非原子操作,多线程并发会导致计数错乱
    }
}

// 线程回调函数
void* thread_func(void* arg) {
    count_add();
    return nullptr;
}

int main() {
    pthread_t t1, t2;
    // 创建两个线程并发执行count_add
    pthread_create(&t1, nullptr, thread_func, nullptr);
    pthread_create(&t2, nullptr, thread_func, nullptr);
    // 等待线程执行完成
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    // 预期结果20000,实际结果远小于20000
    std::cout << "g_count = " << g_count << std::endl;
    return 0;
}

编译运行

bash 复制代码
g++ -o unsafe unsafe.cpp -lpthread
./unsafe
# 输出示例:g_count = 12345(每次运行结果不同)

问题分析g_count++是由加载、加 1、写回三条汇编指令组成的非原子操作,两个线程并发执行时会相互覆盖,导致计数错乱。

(2)线程安全但不可重入的函数示例

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <signal.h>
#include <unistd.h>

int g_count = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

// 加锁实现线程安全,但不可重入
void count_add_safe() {
    pthread_mutex_lock(&mutex); // 加锁
    for (int i = 0; i < 1000; ++i) {
        g_count++;
    }
    // 模拟信号中断,触发重入
    raise(SIGUSR1);
    pthread_mutex_unlock(&mutex); // 解锁
}

// 信号处理函数,重入count_add_safe
void sig_handler(int sig) {
    std::cout << "signal " << sig << " caught, reenter count_add_safe" << std::endl;
    count_add_safe();
}

void* thread_func(void* arg) {
    count_add_safe();
    return nullptr;
}

int main() {
    // 注册信号处理函数
    signal(SIGUSR1, sig_handler);
    pthread_t t1;
    pthread_create(&t1, nullptr, thread_func, nullptr);
    pthread_join(t1, nullptr);
    std::cout << "g_count = " << g_count << std::endl;
    return 0;
}

编译运行

bash 复制代码
g++ -o reenter reenter.cpp -lpthread
./reenter
# 程序死锁,无输出

问题分析 :函数**count_add_safe通过加锁实现了线程安全,但在持有锁的情况下触发了信号,信号处理函数又重入了该函数,导致二次加锁,最终引发死锁。该函数是线程安全但不可重入**的典型案例。

(3)可重入函数示例

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

// 可重入函数:仅使用局部变量,无全局/静态资源,无锁
int count_add_reentrant(int init, int num) {
    int count = init; // 局部变量,线程私有
    for (int i = 0; i < num; ++i) {
        count++;
    }
    return count;
}

// 线程回调函数
void* thread_func(void* arg) {
    // 每个线程传入不同的初始值,结果独立
    int res = count_add_reentrant(*(int*)arg, 10000);
    std::cout << "thread " << pthread_self() << " res = " << res << std::endl;
    return nullptr;
}

int main() {
    pthread_t t1, t2;
    int a = 0, b = 10000;
    // 创建两个线程并发调用可重入函数
    pthread_create(&t1, nullptr, thread_func, &a);
    pthread_create(&t2, nullptr, thread_func, &b);
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    return 0;
}

编译运行

bash 复制代码
g++ -o reentrant reentrant.cpp -lpthread
./reentrant
# 输出:
# thread 140709260797696 res = 10000
# thread 140709252404992 res = 20000

结果分析:可重入函数仅使用局部变量,每个线程的调用都是独立的,不存在资源竞争,既保证了线程安全,又支持重入。

二、常见锁概念:死锁是头号敌人,避坑是核心

在多线程编程中,我们通过 来保护共享资源,实现线程互斥与同步。但锁并不是万能的,死锁是锁使用过程中最常见也最致命的问题。除此之外,我们还需要了解锁的核心特性和各类锁的设计思想,才能做到合理使用锁。

2.1 死锁:多线程编程的 "幽灵"

(1)死锁的定义

死锁是指在一组进程 / 线程中,每个进程 / 线程都占有其他进程 / 线程需要的资源,且不会主动释放自己占有的资源,最终导致所有进程 / 线程都处于永久等待的状态

简单来说,死锁就是 "互相拿捏":线程 A 持有锁 1,想要获取锁 2;线程 B 持有锁 2,想要获取锁 1,二者都不释放自己的锁,最终陷入无限等待。

(2)死锁的四个必要条件

死锁的发生必须同时满足四个必要条件,缺少任何一个,死锁都不会发生。这是我们解决死锁问题的核心依据:

  1. 互斥条件:一个资源每次只能被一个执行流使用(锁的核心特性,不可破坏);
  2. 请求与保持条件 :一个执行流因请求资源而阻塞时,对已获得的资源保持不放(线程持有已获取的锁,同时请求新的锁);
  3. 不剥夺条件 :一个执行流已获得的资源,在未使用完之前,不能被强行剥夺(操作系统不会主动抢占线程持有的锁);
  4. 循环等待条件 :若干执行流之间形成头尾相接的循环等待资源的关系(线程 A 等线程 B 的锁,线程 B 等线程 A 的锁)。

注意 :互斥条件是锁的本质,我们无法也不需要破坏;不剥夺条件由操作系统机制决定,也难以破坏。因此,解决死锁的核心思路是破坏 "请求与保持条件" 和 "循环等待条件"

(3)死锁的实战代码示例

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

// 定义两个互斥锁
pthread_mutex_t mtx1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mtx2 = PTHREAD_MUTEX_INITIALIZER;

// 线程1:先加锁1,再尝试加锁2
void* thread1(void* arg) {
    pthread_mutex_lock(&mtx1);
    std::cout << "thread1: lock mtx1 success" << std::endl;
    sleep(1); // 让线程2有时间获取锁2,制造死锁条件
    pthread_mutex_lock(&mtx2);
    std::cout << "thread1: lock mtx2 success" << std::endl;

    // 解锁(死锁后不会执行到这里)
    pthread_mutex_unlock(&mtx2);
    pthread_mutex_unlock(&mtx1);
    return nullptr;
}

// 线程2:先加锁2,再尝试加锁1
void* thread2(void* arg) {
    pthread_mutex_lock(&mtx2);
    std::cout << "thread2: lock mtx2 success" << std::endl;
    sleep(1); // 让线程1有时间获取锁1,制造死锁条件
    pthread_mutex_lock(&mtx1);
    std::cout << "thread2: lock mtx1 success" << std::endl;

    // 解锁(死锁后不会执行到这里)
    pthread_mutex_unlock(&mtx1);
    pthread_mutex_unlock(&mtx2);
    return nullptr;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, nullptr, thread1, nullptr);
    pthread_create(&t2, nullptr, thread2, nullptr);
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    // 销毁锁(死锁后不会执行到这里)
    pthread_mutex_destroy(&mtx1);
    pthread_mutex_destroy(&mtx2);
    return 0;
}

编译运行

bash 复制代码
g++ -o deadlock deadlock.cpp -lpthread
./deadlock
# 输出:
# thread1: lock mtx1 success
# thread2: lock mtx2 success
# 程序卡死,无后续输出

问题分析:线程 1 持有 mtx1,等待 mtx2;线程 2 持有 mtx2,等待 mtx1,满足死锁的四个必要条件,最终程序死锁。

2.2 避免死锁的核心方法

基于死锁的四个必要条件,我们总结了几种实用、易操作的死锁避免方法,这些方法在实际开发中能解决 99% 的死锁问题。

(1)保证加锁顺序一致(最常用)

这是破坏循环等待条件 的核心方法:所有线程在获取多个锁时,都按照固定的顺序加锁

例如,上述死锁案例中,让线程 1 和线程 2 都先加 mtx1,再加 mtx2,就不会出现循环等待的情况,死锁自然不会发生。

修改后的代码示例

cpp 复制代码
// 线程2:修改加锁顺序,先加mtx1,再加mtx2
void* thread2(void* arg) {
    pthread_mutex_lock(&mtx1); // 固定顺序,先加mtx1
    std::cout << "thread2: lock mtx1 success" << std::endl;
    sleep(1);
    pthread_mutex_lock(&mtx2);
    std::cout << "thread2: lock mtx2 success" << std::endl;

    pthread_mutex_unlock(&mtx2);
    pthread_mutex_unlock(&mtx1);
    return nullptr;
}

编译运行

bash 复制代码
g++ -o no_deadlock no_deadlock.cpp -lpthread
./no_deadlock
# 输出:
# thread1: lock mtx1 success
# thread1: lock mtx2 success
# thread2: lock mtx1 success
# thread2: lock mtx2 success
# 程序正常退出

结果分析:线程 1 先获取 mtx1,线程 2 等待 mtx1;线程 1 执行完成释放所有锁后,线程 2 才能获取 mtx1,进而获取 mtx2,无死锁。

(2)一次性获取所有需要的锁(破坏请求与保持条件)

让线程在执行任务前,一次性获取所有需要的锁,如果有任何一个锁获取失败,就释放已获取的所有锁,重新尝试。这种方法破坏了 "请求与保持条件",因为线程不会持有部分锁并请求其他锁。

在 C++11 及以上版本中,可以使用**std::lock**函数实现多个锁的一次性获取,该函数会保证原子性地获取所有锁,避免死锁。

代码示例

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

std::mutex mtx1, mtx2;

void func1() {
    // 一次性获取mtx1和mtx2,原子操作,避免死锁
    std::lock(mtx1, mtx2);
    // 用std::lock_guard管理锁的释放,RAII风格
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
    std::cout << "func1: get all locks success" << std::endl;
    sleep(1);
}

void func2() {
    // 同样一次性获取所有锁,顺序无关
    std::lock(mtx2, mtx1);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::cout << "func2: get all locks success" << std::endl;
    sleep(1);
}

int main() {
    std::thread t1(func1);
    std::thread t2(func2);
    t1.join();
    t2.join();
    return 0;
}

编译运行

bash 复制代码
g++ -o one_lock one_lock.cpp -lpthread -std=c++11
./one_lock
# 输出:
# func1: get all locks success
# func2: get all locks success
# 程序正常退出

关键说明

  • std::lock:原子性地获取多个互斥锁,避免死锁;
  • std::adopt_lock:表示锁已经被获取,**std::lock_guard**仅负责解锁,不负责加锁;
  • RAII 风格:利用对象的构造和析构自动管理锁的生命周期,避免手动解锁遗漏导致的死锁。

(3)给锁添加超时机制

让线程在获取锁时设置超时时间,如果在指定时间内没有获取到锁,就放弃获取,并释放已持有的所有锁,避免永久等待。

在 POSIX 线程中,可以使用**pthread_mutex_timedlock**函数实现带超时的加锁,该函数在超时后会返回错误码,不会一直阻塞。

代码示例

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <time.h>

pthread_mutex_t mtx1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mtx2 = PTHREAD_MUTEX_INITIALIZER;

void* thread1(void* arg) {
    pthread_mutex_lock(&mtx1);
    std::cout << "thread1: lock mtx1 success" << std::endl;

    // 设置超时时间:5秒后超时
    struct timespec ts;
    clock_gettime(CLOCK_REALTIME, &ts);
    ts.tv_sec += 5;
    // 尝试加锁mtx2,5秒超时
    int ret = pthread_mutex_timedlock(&mtx2, &ts);
    if (ret == 0) {
        std::cout << "thread1: lock mtx2 success" << std::endl;
        pthread_mutex_unlock(&mtx2);
    } else {
        std::cout << "thread1: lock mtx2 timeout, release mtx1" << std::endl;
    }
    // 释放已持有的锁
    pthread_mutex_unlock(&mtx1);
    return nullptr;
}

void* thread2(void* arg) {
    pthread_mutex_lock(&mtx2);
    std::cout << "thread2: lock mtx2 success" << std::endl;
    sleep(10); // 持有锁210秒,让线程1加锁2超时
    pthread_mutex_unlock(&mtx2);
    return nullptr;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, nullptr, thread1, nullptr);
    pthread_create(&t2, nullptr, thread2, nullptr);
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_mutex_destroy(&mtx1);
    pthread_mutex_destroy(&mtx2);
    return 0;
}

编译运行

bash 复制代码
g++ -o timeout_lock timeout_lock.cpp -lpthread
./timeout_lock
# 输出:
# thread1: lock mtx1 success
# thread2: lock mtx2 success
# thread1: lock mtx2 timeout, release mtx1
# 程序正常退出

结果分析:线程 1 获取 mtx1 后,尝试获取 mtx2 超时,主动释放 mtx1,避免了死锁。

(4)避免锁未释放的场景

锁的未释放是导致死锁的常见诱因,比如:

  • 程序在持有锁时抛出异常,未执行解锁操作;
  • 手动解锁时遗漏了某些分支的解锁;
  • 线程在持有锁时退出,未释放锁。

解决方法:使用 RAII 风格的锁管理 (如 C++ 的std::lock_guardstd::unique_lock,或自定义的 LockGuard),利用对象的析构函数自动解锁,无论程序正常执行还是抛出异常,都能保证锁被释放。

自定义 RAII 锁示例(基于 POSIX 线程):

cpp 复制代码
#include <pthread.h>

// 互斥锁封装
class Mutex {
public:
    Mutex() { pthread_mutex_init(&_mtx, nullptr); }
    ~Mutex() { pthread_mutex_destroy(&_mtx); }
    void lock() { pthread_mutex_lock(&_mtx); }
    void unlock() { pthread_mutex_unlock(&_mtx); }
    pthread_mutex_t* get() { return &_mtx; }

    // 禁止拷贝和赋值
    Mutex(const Mutex&) = delete;
    Mutex& operator=(const Mutex&) = delete;
private:
    pthread_mutex_t _mtx;
};

// RAII风格的锁守卫
class LockGuard {
public:
    explicit LockGuard(Mutex& mtx) : _mtx(mtx) { _mtx.lock(); }
    ~LockGuard() { _mtx.unlock(); } // 析构自动解锁

    // 禁止拷贝和赋值
    LockGuard(const LockGuard&) = delete;
    LockGuard& operator=(const LockGuard&) = delete;
private:
    Mutex& _mtx;
};

// 使用示例
Mutex mtx;
void func() {
    LockGuard lock(mtx); // 构造加锁
    // 业务逻辑,即使抛出异常,析构也会自动解锁
}

关键优势:RAII 风格的锁管理从根本上避免了手动解锁的遗漏,是多线程编程中推荐的锁使用方式。

2.3 其他常见锁概念:悲观锁、乐观锁、自旋锁

除了互斥锁,实际开发中还会接触到悲观锁、乐观锁、自旋锁 等概念,这些锁并非具体的锁实现,而是锁的设计思想,适用于不同的业务场景。

(1)悲观锁

悲观锁 的设计思想是:总是假设最坏的情况,认为数据一定会被其他线程修改,因此在每次访问数据前都先加锁,阻止其他线程访问

我们之前使用的互斥锁(pthread_mutex_tstd::mutex)都是典型的悲观锁。悲观锁的核心是阻塞等待,如果一个线程获取锁失败,就会被挂起,直到锁被释放。

适用场景写操作频繁的场景,比如多线程对共享资源的修改操作远多于读操作,悲观锁能有效避免数据竞争。

缺点:线程的挂起和唤醒会带来一定的系统开销,在高并发场景下可能会影响性能。

(2)乐观锁

乐观锁 的设计思想是:总是假设最好的情况,认为数据不会被其他线程修改,因此访问数据时不加锁,只在更新数据时判断数据是否被修改

乐观锁没有具体的锁实现,而是通过版本号机制CAS 操作实现。

  • 版本号机制:为数据添加一个版本号,每次更新数据时版本号加 1,更新前判断版本号是否与获取时一致,一致则更新,否则重试;
  • CAS 操作(Compare And Swap):原子操作,包含三个操作数 ------ 内存地址、旧值、新值。当内存地址中的实际值等于旧值时,将其修改为新值,否则操作失败,一般通过自旋重试。

适用场景读操作频繁,写操作稀少的场景,比如缓存、配置信息等,乐观锁避免了加锁的开销,提升了并发性能。

缺点

  • 存在ABA 问题:数据被修改为 B 后又改回 A,CAS 会认为数据未被修改,可能导致逻辑错误(解决方法:添加版本号或时间戳);
  • 自旋重试会占用 CPU 资源,若写操作频繁,会导致 CPU 利用率过高。

CAS 操作实战代码(基于 C++11 原子操作):

cpp 复制代码
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>

std::atomic<int> g_count(0); // 原子变量,支持CAS操作

void count_add() {
    for (int i = 0; i < 10000; ++i) {
        // CAS自旋更新:直到更新成功为止
        int old = g_count.load();
        while (!g_count.compare_exchange_weak(old, old + 1)) {
            // 旧值被修改,重新加载
        }
    }
}

int main() {
    std::vector<std::thread> threads;
    // 创建10个线程并发执行
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(count_add);
    }
    for (auto& t : threads) {
        t.join();
    }
    // 预期结果100000,实际结果准确
    std::cout << "g_count = " << g_count << std::endl;
    return 0;
}

编译运行

bash 复制代码
g++ -o cas cas.cpp -lpthread -std=c++11
./cas
# 输出:g_count = 100000

关键说明 :**std::atomic是 C++11 提供的原子类型,其compare_exchange_weak**方法实现了 CAS 操作,保证了更新的原子性,无需加锁即可实现线程安全。

(3)自旋锁

自旋锁 的设计思想是:当线程获取锁失败时,不会被挂起,而是通过循环不断尝试获取锁,直到获取成功

自旋锁的核心是忙等 ,而非阻塞等待,适用于锁持有时间极短的场景。因为线程的挂起和唤醒开销远大于自旋的 CPU 开销,此时自旋锁的性能更优。

与互斥锁的区别

  • 互斥锁:获取锁失败→线程挂起→CPU 调度其他线程;
  • 自旋锁:获取锁失败→自旋重试→不放弃 CPU。

适用场景:锁持有时间短、CPU 核心数充足的场景(避免单核心下自旋导致其他线程无法执行)。

缺点:若锁持有时间过长,自旋会占用大量 CPU 资源,导致系统性能下降。

注意 :POSIX 线程中没有直接提供自旋锁的接口,但 Linux 内核提供了**pthread_spinlock_t**实现自旋锁,使用方式与互斥锁类似。

三、STL、智能指针与线程安全:那些容易踩坑的点

在 C++ 多线程编程中,除了手动管理共享资源和锁,我们还会频繁使用 STL 容器和智能指针。很多开发者会想当然地认为这些标准库组件是线程安全的,实则不然 ------STL 和智能指针的线程安全特性有明确的边界,超出边界就会引发问题

3.1 STL 容器的线程安全:默认非线程安全,需手动保护

结论先行C++ 标准库中的 STL 容器默认都不是线程安全的

(1)为什么 STL 容器设计为非线程安全?

STL 的设计初衷是极致的性能,而加锁保证线程安全会带来显著的性能损耗。此外,不同容器的使用场景不同,加锁的方式(如整锁、细粒度锁)也不同,标准库无法为所有场景提供通用的加锁方案,因此将线程安全的责任交给了开发者。

例如,对于std::vector的遍历,加整锁会导致并发读的性能大幅下降;而对于std::unordered_map,可以采用桶级锁(对每个哈希桶单独加锁)提升并发性能,标准库无法兼顾所有情况。

(2)STL 容器的有限线程安全保证

虽然 STL 容器默认非线程安全,但 C++11 及以上版本对 STL 容器的线程安全做了有限的保证,在以下场景中,容器是线程安全的:

  1. 多个线程同时对容器进行读操作:无写操作时,多个线程并发读 STL 容器是安全的;
  2. 一个线程写,多个线程读:需手动加锁,无安全保证;
  3. 多个线程同时对容器进行写操作:必须手动加锁,否则会导致容器内部数据结构错乱(如链表断裂、哈希桶冲突)。

(3)STL 容器的线程安全实战:加锁保护

代码示例 :多线程对std::vector进行读写操作,通过互斥锁保证线程安全

cpp 复制代码
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>
#include "LockGuard.hpp" // 引入之前自定义的RAII锁

std::vector<int> g_vec;
Mutex g_mtx; // 全局互斥锁

// 写线程:向vector中添加元素
void* write_func(void* arg) {
    for (int i = 0; i < 1000; ++i) {
        LockGuard lock(g_mtx); // RAII加锁
        g_vec.push_back(i);
    }
    std::cout << "write thread " << pthread_self() << " done" << std::endl;
    return nullptr;
}

// 读线程:遍历vector并输出大小
void* read_func(void* arg) {
    for (int i = 0; i < 100; ++i) {
        LockGuard lock(g_mtx); // RAII加锁
        std::cout << "read thread " << pthread_self() << " vec size: " << g_vec.size() << std::endl;
        sleep(1);
    }
    std::cout << "read thread " << pthread_self() << " done" << std::endl;
    return nullptr;
}

int main() {
    pthread_t w1, w2, r1, r2;
    // 创建两个写线程,两个读线程
    pthread_create(&w1, nullptr, write_func, nullptr);
    pthread_create(&w2, nullptr, write_func, nullptr);
    pthread_create(&r1, nullptr, read_func, nullptr);
    pthread_create(&r2, nullptr, read_func, nullptr);
    // 等待所有线程完成
    pthread_join(w1, nullptr);
    pthread_join(w2, nullptr);
    pthread_join(r1, nullptr);
    pthread_join(r2, nullptr);
    // 最终vector大小应为2000
    std::cout << "final vec size: " << g_vec.size() << std::endl;
    return 0;
}

关键说明 :对 STL 容器的所有读写操作都必须加锁保护,即使是size()empty()等看似简单的操作,因为这些操作也可能涉及容器内部的全局状态修改,非原子操作。

3.2 智能指针的线程安全:unique_ptr 安全,shared_ptr 有限安全

C++ 的智能指针主要包括**unique_ptrshared_ptrweak_ptr,三者的线程安全特性差异较大,核心区别在于是否存在共享的状态**。

(1)unique_ptr:完全线程安全

unique_ptr独占式智能指针 ,其特性是一个unique_ptr对象独占一个资源,不支持拷贝,仅支 持移动

unique_ptr的所有操作都基于局部对象,存储在线程栈中,属于线程私有资源,不存在多个线程并发访问同一个**unique_ptr对象的情况,因此unique_ptr在多线程环境下是完全线程安全的**。

代码示例

cpp 复制代码
#include <iostream>
#include <memory>
#include <thread>

void func() {
    // unique_ptr是局部变量,线程私有
    std::unique_ptr<int> p(new int(10));
    std::cout << "thread " << std::this_thread::get_id() << " : " << *p << std::endl;
}

int main() {
    std::thread t1(func);
    std::thread t2(func);
    t1.join();
    t2.join();
    return 0;
}

结果分析 :两个线程的**unique_ptr**是独立的局部对象,无资源竞争,线程安全。

(2)shared_ptr:有限线程安全,引用计数原子操作

shared_ptr共享式智能指针 ,其核心特性是多个shared_ptr对象共享同一个资源,通过引用计数管理资源的生命周期

shared_ptr的线程安全特性分为两个层面:

  1. 引用计数的操作是原子的、线程安全的 :C++ 标准库规定,**shared_ptr**的引用计数增加(copy)和减少(destruct)操作是原子操作,由编译器通过 CAS 实现,无需开发者手动加锁;
  2. shared_ptr对象本身的操作是非线程安全的 :如果多个线程并发修改同一个**shared_ptr**对象(如resetassign),则需要手动加锁保护,否则会导致数据竞争。

简单来说多个线程并发拷贝同一个shared_ptr对象(共享资源)是安全的;多个线程并发修改同一个shared_ptr对象是不安全的

(3)shared_ptr 的线程安全实战示例

① 安全场景:多线程拷贝 shared_ptr(引用计数操作)
cpp 复制代码
#include <iostream>
#include <memory>
#include <thread>
#include <vector>

// 全局shared_ptr,多个线程拷贝
std::shared_ptr<int> g_sp(new int(10));

void copy_sp() {
    for (int i = 0; i < 10000; ++i) {
        std::shared_ptr<int> p = g_sp; // 拷贝,引用计数原子增加
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(copy_sp);
    }
    for (auto& t : threads) {
        t.join();
    }
    // 引用计数最终为1(所有临时对象析构,引用计数原子减少)
    std::cout << "g_sp use_count: " << g_sp.use_count() << std::endl;
    return 0;
}

编译运行

bash 复制代码
g++ -o shared_safe shared_safe.cpp -lpthread -std=c++11
./shared_safe
# 输出:g_sp use_count: 1

结果分析 :多线程拷贝**g_sp**,引用计数的增加和减少都是原子操作,线程安全。

② 不安全场景:多线程修改同一个 shared_ptr 对象
cpp 复制代码
#include <iostream>
#include <memory>
#include <thread>
#include <vector>

std::shared_ptr<int> g_sp(new int(10));

// 多线程修改同一个g_sp对象,非线程安全
void modify_sp() {
    for (int i = 0; i < 1000; ++i) {
        g_sp.reset(new int(i)); // 修改g_sp,非原子操作
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(modify_sp);
    }
    for (auto& t : threads) {
        t.join();
    }
    std::cout << "*g_sp = " << *g_sp << std::endl;
    return 0;
}

问题分析 :多个线程并发调用**g_sp.reset()修改同一个shared_ptr**对象,reset操作包含多个步骤(减少旧引用计数、分配新资源、设置新引用计数),非原子操作,会导致数据竞争,程序可能崩溃或结果错乱。

③ 解决方法:加锁保护 shared_ptr 对象的修改
cpp 复制代码
#include <iostream>
#include <memory>
#include <thread>
#include <vector>
#include <mutex>

std::shared_ptr<int> g_sp(new int(10));
std::mutex g_mtx; // 互斥锁保护g_sp的修改

void modify_sp_safe() {
    for (int i = 0; i < 1000; ++i) {
        std::lock_guard<std::mutex> lock(g_mtx); // 加锁
        g_sp.reset(new int(i));
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(modify_sp_safe);
    }
    for (auto& t : threads) {
        t.join();
    }
    std::cout << "*g_sp = " << *g_sp << std::endl;
    return 0;
}

编译运行

bash 复制代码
g++ -o shared_unsafe_fix shared_unsafe_fix.cpp -lpthread -std=c++11
./shared_unsafe_fix
# 输出:*g_sp = 999(结果确定,线程安全)

结果分析 :通过加锁保护g_sp的修改操作,保证了原子性,解决了数据竞争问题。

(4)weak_ptr:依赖 shared_ptr 的线程安全

weak_ptr是为了解决**shared_ptr的循环引用问题而设计的,它不管理资源的生命周期,仅作为shared_ptr的观察者** ,其线程安全特性完全依赖于所指向的**shared_ptr**:

  • 对**weak_ptrlock()**操作(获取shared_ptr)是线程安全的,因为本质是对shared_ptr引用计数的原子操作;
  • 对**weak_ptr**对象本身的修改操作(如reset),若多个线程并发执行,需要手动加锁保护。

总结

多线程编程的核心是平衡线程安全与并发性能,线程锁是保障线程安全的重要工具,但锁的使用并非越多越好 ------ 过度加锁会导致并发性能下降,甚至引发死锁;不加锁则会导致数据竞争,程序执行错乱。

想要掌握多线程编程,不仅需要理解线程安全、锁、死锁等核心概念,更需要在实际开发中不断实践,积累避坑经验。希望本文能帮助你从原理到实战彻底掌握线程安全与线程锁的核心知识,让你在多线程编程的世界里游刃有余,告别各种坑!

如果本文对你有帮助,欢迎点赞、收藏、关注三连~ 后续会持续更新多线程编程的进阶内容,包括读写锁、条件变量、线程池的深度实战,敬请期待!

相关推荐
探序基因2 小时前
安装R包arrow
linux·运维·服务器
JiMoKuangXiangQu2 小时前
Linux 中断线程化
linux·中断线程化
AI+程序员在路上2 小时前
linux中bash与sh脚本区别
linux·运维·bash
路弥行至2 小时前
linux运行脚本出现错误信息 /bin/bash^M: bad interpreter解决方法
linux·运维·开发语言·经验分享·笔记·其他·bash
我爱学习好爱好爱2 小时前
Elasticsearch 7.17.10 双节点集群部署实战(基于 Rocky Linux 9.6)
大数据·linux·elasticsearch
豆浆煮粉2 小时前
基于 Linux+CMake 从零集成 Lua 脚本引擎 (附 Sol2 避坑指南)
linux·lua
NEAI_N2 小时前
离网设备的加密解密方案
linux·服务器·网络
左手の明天2 小时前
Linux内核裁剪深入浅出:从原理到实操,打造轻量化嵌入式内核
linux·arm开发·c++
萝卜白菜。2 小时前
annotation扫描引起的StackOverflowError问题
linux·运维·服务器