Linux系统:线程的互斥和安全

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


前言

学线程互斥的用处在于:在多线程程序里,很多数据和资源是共享的(比如全局变量、文件、socket、内存池)。如果不加限制,多个线程可能会在同一时间修改同一份数据,导致结果错误或者程序崩溃。互斥锁的作用就是保证某一段代码在同一时刻只能被一个线程执行,从而避免数据竞争,让结果稳定可靠。


一、线程竞争案例

我们来编写一个线程竞争资源的代码:

#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <vector>
using namespace std;

class customer {
public:
    int _ticket_num = 0;   // 该顾客买到的票数
    pthread_t _tid;        // 线程ID
    string _name;          // 顾客名字
};

int g_ticket = 10000;      // 总票数

void* buyTicket(void* args) {
    customer* cust = (customer*)args;

    while (true) {
        if (g_ticket > 0) {
            usleep(1000);  // 模拟出票耗时
            cout << cust->_name << " get ticket: " << g_ticket << endl;
            g_ticket--;
            cust->_ticket_num++;
        } else {
            break;
        }
    }

    return nullptr;
}

int main() {
    vector<customer> custs(5);

    // 创建 5 个顾客线程
    for (int i = 0; i < 5; i++) {
        custs[i]._name = "customer-" + to_string(i + 1);
        pthread_create(&custs[i]._tid, nullptr, buyTicket, &custs[i]);
    }

    // 等待所有顾客线程结束
    for (int i = 0; i < 5; i++) {
        pthread_join(custs[i]._tid, nullptr);
    }

    // 打印每个顾客买到的票数
    for (int i = 0; i < 5; i++) {
        cout << custs[i]._name 
             << " get tickets: " << custs[i]._ticket_num << endl;
    }

    return 0;
}

我们先从自定义函数和自定义类开始讲解,再到main函数的讲解:

class customer:有三个参数,都是顾客自身的信息,在整体程序当中,每一个顾客对应一个线程

void* buyTicket:用于给顾客售票,顾客每增加一张票,则g_ticket减1

main:创建五个进程,并且相继竞争购票

演示结果:

gch@hcss-ecs-f59a:/gch/code/HaoHao/learn2/day6$ ./exe
......
customer-3 get ticket: 0
customer-4 get ticket: -1
customer-1 get ticket: -2
customer-5 get ticket: -3
customer-1 get tickets: 2000
customer-2 get tickets: 2001
customer-3 get tickets: 2000
customer-4 get tickets: 2001
customer-5 get tickets: 2002

那么这里就有问题了,明明只有1000张票,为什么五个人加起来的数量是1004张呢

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程
  • --ticket 操作本身就不是一个原子操作

这个ticket在线程当中属于共享资源,因为只有一个CUP并且它是单核的,一次只能执行一个进程,为了实现进程的同步,操作系统内核会在线程还没执行完函数时打断线程,让CUP运行其它的线程,但是上一个线程还没执行玩ticket,下一个进程就已经进入if函数了,所以会导致--ticket被运行多次

  • g_ticket 是共享资源,因为所有线程都可以访问和修改它。多个线程同时访问它,就有可能出现冲突。

  • 虽然单核 CPU 在 同一时刻 只能执行一个线程,但操作系统会通过 时间片轮转 或 线程调度 不断切换线程。

    • 一个线程可能执行到一半(比如刚判断 g_ticket > 0)就被操作系统挂起。
    • CPU 会切换给另一个线程去执行。

假设线程 A 执行到:

if (g_ticket > 0) // 假设 g_ticket = 1

此时线程 A 还没来得及执行 g_ticket--。操作系统把 CPU 切给线程 B。线程 B 也执行到同样的 if (g_ticket > 0) 判断,此时它看到 g_ticket 还是 1,于是也进入 if。最终,两个线程都执行了 g_ticket--,但是 g_ticket 只应该被减一次。这就是 竞态条件:多个线程同时操作共享资源,导致结果错误


二、互斥,锁

要解决上述共享资源冲突问题,需要满足三点条件:

  • 互斥执行:当某个线程进入临界区执行代码时,其他线程不能同时进入该临界区。
  • 公平进入:如果多个线程同时请求进入临界区,而此时没有线程在执行,只允许其中一个线程进入。
  • 非阻塞退出:线程在临界区外时,不得阻止其他线程进入临界区

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量
在这里插入图片描述


演示代码:

#include <iostream>
#include <string>
#include <vector>
#include <unistd.h>
#include <pthread.h>
#include <cstdio>
#include <cstring>
using namespace std;
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
class customer
{
public:
    int _ticket_num = 0;
    pthread_t _tid;
    string _name;
};

int g_ticket = 10000;

void* buyTicket(void* args)
{
    customer* cust = (customer*)args;

    while(true)
    {
        pthread_mutex_lock(&mutex);
        if(g_ticket > 0)
        {
            usleep(1000);
            cout << cust->_name << " get ticket: " << g_ticket << endl;
            g_ticket--;
            cust->_ticket_num++;
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }

    return nullptr;
}

int main()
{
    vector<customer> custs(5);

    for(int i = 0; i < 5; i++)
    {
        custs[i]._name= "customer-" + to_string(i + 1);
        pthread_create(&custs[i]._tid, nullptr, buyTicket, &custs[i]);
    }

    for(int i = 0; i < 5; i++)
    {
        pthread_join(custs[i]._tid, nullptr);
    }

    for(int i = 0; i < 5; i++)
    {
        cout << custs[i]._name << " get tickets: " << custs[i]._ticket_num << endl;
    }

    return 0;
}

在这段代码里:

  • pthread_mutex_lock(&mutex) 让线程尝试获取锁,如果别的线程已经持有锁,它就会阻塞等待。
  • 当线程获得锁后,它才能进入临界区操作共享资源 g_ticket
  • 执行完之后,调用 pthread_mutex_unlock(&mutex) 释放锁,这样别的线程才能继续进入临界区。

所以,锁的作用就是:

  • 保证同一时刻只有一个线程能修改 g_ticket,避免出现多个线程同时减票而导致的数据错误。

演示结果:

gch@hcss-ecs-f59a:/gch/code/HaoHao/learn2/day6$ ./exe
......
customer-1 get tickets: 2214
customer-2 get tickets: 2391
customer-3 get tickets: 1761
customer-4 get tickets: 1806
customer-5 get tickets: 1828

上述是全局静态锁,如果是局部锁需要在使用完之后用pthread_mutex_destroy进行销毁
互斥锁初始化方式主要有三种

  • 静态初始化(全局/静态作用域)
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
  • 在 编译期 就完成初始化。

  • 适合全局变量、静态变量。

  • 生命周期随进程结束自动回收,不需要显式销毁

  • 动态初始化

pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
pthread_mutex_destroy(&mutex);
  • 在 运行时 用 pthread_mutex_init 初始化。
  • 适合函数内的局部变量,或需要 动态分配的结构体里的成员。
  • 用完必须 pthread_mutex_destroy,否则可能导致资源泄漏。

三,线程安全

线程安全指的是:当多个线程同时访问某个函数、数据结构或代码片段时,程序的行为依然是正确的、可预期的,不会出现数据错乱或未定义行为

换句话说:

  • 线程安全:多个线程并发访问 → 程序结果仍然正确。
  • 线程不安全:多个线程并发访问 → 可能导致错误(数据竞争、死锁、崩溃)。

3-1 为什么会线程不安全

根本原因是:多线程共享内存,但调度是抢占式的。
假设我们有一个全局变量:

int counter = 0;

void *worker(void *arg) {
    for (int i = 0; i < 10000; i++) {
        counter++;  // 不是原子操作!
    }
    return NULL;
}

如果两个线程同时执行 counter++:

实际会分解成三步:读 countercounter + 1写回 counter
假如两个线程交叉执行,就可能丢失更新(最终结果小于 20000)。
这就是 数据竞争,典型的线程不安全,所以我们在这种线程不安全的情况,我们可以使用锁来实现线程安全。
线程安全 = 在多线程并发情况下,程序逻辑和数据结果依旧正确,不会出现竞态问题。


四,重入函数

重入的核心是同一个函数被多个执行流交错执行。想象一个函数正在执行到一半,突然被打断(可能是另一个线程开始执行,或者信号处理函数触发),而这个打断它的执行流也调用了同一个函数,这就发生了重入


4-1 两种重入场景的具体分析

  1. 多线程重入(并发重入)
    当多个线程同时调用同一个函数时,就可能发生重入

可重入示例

// 可重入函数:只使用局部变量
int add(int a, int b) {
    int temp;  // 局部变量,每个线程有独立副本
    temp = a + b;
    return temp;
}

每个线程调用add()时,局部变量temp是线程私有,不会互相干扰。

不可重入示例

// 不可重入函数:使用全局变量
int global_num = 0;
int increment() {
    global_num++;  // 读取-修改-写入三步操作,可能被打断
    return global_num;
}

  1. 信号导致的重入(异步重入)
    当程序正在执行函数 A 时,突然收到信号,系统会暂停当前执行流,转去执行信号处理函数。如果信号处理函数也调用了函数 A,就会发生重入。

危险示例

#include <signal.h>
#include <stdio.h>

FILE *file;

void signal_handler(int signum) {
    // 信号处理函数也操作file,导致重入
    fputs("Signal handled\n", file);
}

int main() {
    file = fopen("test.txt", "w");
    signal(SIGINT, signal_handler);  // 注册Ctrl+C的处理函数
    
    // 主程序正在操作file时,若收到信号会导致重入
    fputs("Main writing\n", file);
    // ... 其他操作
    fclose(file);
    return 0;
}
  • 主程序正在执行fputs()(操作全局变量file)时,若按下 Ctrl+C 触发信号
  • 信号处理函数也调用fputs()操作同一个file
  • 可能导致文件缓冲区数据混乱,甚至程序崩溃

3、可重入函数的判定准则
一个函数要成为可重入函数,必须满足:

  • 不使用全局变量或静态变量,或对其访问进行特殊保护
  • 不使用 malloc/free(会操作全局内存管理结构)
  • 不调用其他不可重入函数(如标准库中的printffputsI/O函数)
  • 不依赖硬件资源的状态(如不直接操作硬件寄存器)

五,死锁

死锁是并发编程中一种常见且危险的状态,指两个或多个执行流(线程、进程)相互等待对方持有的资源,且彼此都无法继续推进的僵局


5-1 死锁的核心概念

当多个执行执行流同时竞争有限的共享资源时,若每个执行流都持有一部分资源,同时又等待其他执行流释放所需资源,就会形成循环等待,导致所有执行流都无法继续执行,这种状态称为死锁。


5-2 死锁产生的四大必要条件

  • 互斥条件:资源具有排他性,同一时间只能被一个执行流使用(如一把锁只能被一个线程持有)。
  • 持有并等待条件:执行流已经持有至少一个资源,同时又在等待获取其他执行流持有的资源。
  • 不可剥夺条件:已获取的资源不能被强制剥夺,只能由持有者主动释放(如线程持有的锁不能被其他线程强制释放)。
  • 循环等待条件:存在执行流的循环链,每个执行流都在等待下一个执行流持有的资源(如线程 A 等线程 B 的资源,线程 B 等线程 A 的资源)。

演示代码:

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

// 定义两个全局锁(资源)
pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock2 = PTHREAD_MUTEX_INITIALIZER;

// 线程1的执行函数:先锁lock1,再等lock2
void *thread1(void *arg) {
    pthread_mutex_lock(&lock1);
    printf("线程1持有lock1,等待lock2...\n");
    // 模拟处理时间,增加死锁概率
    sleep(1);
    pthread_mutex_lock(&lock2);  // 等待线程2释放lock2
    
    // 业务操作(实际中不会执行到)
    pthread_mutex_unlock(&lock2);
    pthread_mutex_unlock(&lock1);
    return NULL;
}

// 线程2的执行函数:先锁lock2,再等lock1
void *thread2(void *arg) {
    pthread_mutex_lock(&lock2);
    printf("线程2持有lock2,等待lock1...\n");
    // 模拟处理时间,增加死锁概率
    sleep(1);
    pthread_mutex_lock(&lock1);  // 等待线程1释放lock1
    
    // 业务操作(实际中不会执行到)
    pthread_mutex_unlock(&lock1);
    pthread_mutex_unlock(&lock2);
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, thread1, NULL);
    pthread_create(&t2, NULL, thread2, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

执行结果:

线程 1 持有 lock1 并等待 lock2线程 2 持有 lock2 并等待 lock1,形成循环等待,程序永远卡在等待状态,即死锁。


5-3 死锁的危害

  • 程序卡住,无法继续执行,需要强制终止
  • 资源被永久占用,无法释放
  • 难以调试,死锁可能在特定 timing 下才触发,复现困难

5-4 避免死锁的常用方法

  • 破坏循环等待条件:对所有资源按固定顺序获取(如规定必须先获取 lock1,再获取 lock2)。
    破坏持有并等待条件:一次性获取所有所需资源,获取不到则释放已持有的资源并重试。
    使用带超时的锁:如pthread_mutex_timedlock,超时后释放资源并重新尝试。
    定期检测死锁:通过工具(如pstackgdb)或自定义算法检测死锁,发现后强制释放资源。

网站公告

今日签到

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