【Linux笔记】线程同步与互斥

一、前置知识

1.1 共享资源

共享资源 :在多线程或多进程环境中(即多个执行流),共同访问的数据或设备资源。

共享资源本身不是问题,问题出在多个执行流同时、无协调地访问它时,会产生数据不一致、结果错误等并发问题。

复制代码
举例说明:
​
1.多个线程同时读写同一个全局变量 int counter = 0;
​
2.多个线程同时访问同一个文件
​
3.多个线程同时向同一块内存区域写入数据
​
4.多个线程竞争同一台打印机

1.2 临界资源

临界资源:在多线程执行流中,需要被保护的共享资源。

关键点在于"需要被保护",并非所有共享资源都是临界资源,只有那些一旦被并发访问就会出错的资源才是临界资源。

复制代码
举例说明:
    
int g_count = 0;  // 全局计数器,多线程共享
​
// 线程A
g_count++;
​
// 线程B
g_count++;

1.3 临界区

临界区:每个线程内部,访问临界资源的那段代码。

注意:临界资源是"资源",临界区是"代码",临界区是保护临界资源的代码区域。

复制代码
// 线程函数
void* thread_func(void* arg) 
{
    printf("进入线程\n");       // 非临界区,随便执行
​
    // ---- 临界区开始 ----
    int temp = g_count;         // 读取临界资源
    temp = temp + 1;            // 修改
    g_count = temp;             // 写回临界资源
    // ---- 临界区结束 ----
​
    printf("退出线程\n");       // 非临界区,随便执行
    return NULL;
}

1.4 原子性

原子性: 指一个操作不会被任何调度机制打断,该操作只有两种状态------要么完成,要么未完成,不存在中间状态。

代码示例:需要原子性的原因。

复制代码
// 线程函数
void* thread_func(void* arg) 
{
    printf("进入线程\n");       // 非临界区,随便执行
​
    // ---- 临界区开始 ----
    g_count++;
    // ---- 临界区结束 ----
​
    printf("退出线程\n");       // 非临界区,随便执行
    return NULL;
}

对于 g_count++ 这一行代码在CPU层面实际上是三条指令:

复制代码
1. 从内存读取 g_count 到寄存器(Load)
​
2. 寄存器中的值加一(Add)
​
3. 将结果写回内存中的 g_count(Store)

非原子操作带来的问题:

如果线程A执行完第1步后被调度走,线程B完整地执行了三步,然后线程A继续执行第2、3步,就会丢失线程B的修改。

二、线程互斥

2.1 互斥的引入

互斥: 在任何时刻,互斥机制保证有且只有一个执行流进入临界区访问临界资源,从而对临界资源起到保护作用。

类比理解:假设一个房间有ATM机,有且只能一个人进入该房间。

复制代码
一个人进去后锁上房间门
     ↓
其他人只能在门外等待
     ↓
里面的人出来并开门
     ↓
下一个等待者才能进入

2.2 场景分析

2.2.1 场景引入:多线程进行抢票

对于一个共享资源ticket,如果没有对共享资源进行保护,当多个线程需要修改它时会导致该共享资源的数据混乱。

复制代码
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
​
​
int ticket = 10;
void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
        }
        else
        {
            break;
        }
    }
    return NULL;
}
​
int main()
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, route, (void *)"thread 1");
    pthread_create(&t2, NULL, route, (void *)"thread 2");
    pthread_create(&t3, NULL, route, (void *)"thread 3");
    pthread_create(&t4, NULL, route, (void *)"thread 4");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
}

输出结果:

复制代码
thread 1 sells ticket:10
thread 3 sells ticket:10      ← 同一张票被卖了两次
thread 2 sells ticket:9
thread 4 sells ticket:9      ← 又重复了
thread 1 sells ticket:8
...
thread 3 sells ticket:1
thread 2 sells ticket:0      ← 非法:0号票不存在
thread 4 sells ticket:-1     ← 非法:超卖

2.2.2 核心并发bug:临界区未保护

临界区未进行保护:从 if (ticket > 0) 到 ticket-- 这整段代码就是临界区,ticket 就是临界资源。

复制代码
if (ticket > 0)          // 步骤A:检查
{
    usleep(1000);        // 步骤B:故意延时,放大问题
    printf("%s sells ticket:%d\n", id, ticket);  // 步骤C:读取
    ticket--;            // 步骤D:修改
}
A. 错误类型一:重复售卖同一张票
复制代码
时刻T1: thread1 执行 if(ticket>0),此时ticket=10,条件成立
时刻T2:thread1 打印 "thread 1 sells ticket:10"
时刻T3: thread1 被调度走(usleep让出CPU)

时刻T4: thread2 执行 if(ticket>0),此时ticket仍=10,条件也成立
时刻T5: thread2 打印 "thread 2 sells ticket:10",然后 ticket-- → ticket=9

时刻T5: thread1 恢复,然后 ticket-- → ticket=8
B. 错误类型二:超卖 (ticket变为负数)
复制代码
时刻T1: thread1 执行 if(ticket>0),此时ticket=1,条件成立
时刻T2: thread1 被调度走(usleep让出CPU)

时刻T3: thread2 执行 if(ticket>0),此时ticket仍=1,条件也成立
时刻T4: thread2 打印 "thread 2 sells ticket:1",然后 ticket-- → ticket=0

时刻T5: thread1 恢复,打印 "thread 1 sells ticket:0",然后 ticket-- → ticket=-1
C. 错误类型三:数据竞争(Data Race)
复制代码
ticket-- 本身不是原子操作(读-改-写三步),多个线程同时对同一个内存地址执行非原子写操作

2.2.3 剖析非原子写操作: ticket--

对于这一行代码 ticket-- 而言,在CPU执行时分为三条指令:

复制代码
LOAD   R1, [ticket]    ; // 第1条:从内存读取 ticket 的值到寄存器 R1
SUB    R1, R1, 1       ; // 第2条:寄存器 R1 中的值减 1
STORE  [ticket], R1    ; // 第3条:将 R1 的新值写回内存中的 ticket


1. 从内存读取 ticket 到寄存器(Load)

2. 寄存器中的值减一(Sub)

3. 将结果写回内存中的 ticket(Store)

CPU的调度器可以在这三条指令之间的任意间隙打断当前线程,切换到另一个线程,从而导致了线程读到了旧值:

  • 切换点1:线程A执行完LOAD后被切走

  • 切换点2:线程A执行完SUB后被切走

  • 切换点3:线程A执行完STORE后被切走

A. 切换点1:线程A执行完LOAD后被切走
复制代码
线程A: LOAD R1, [ticket]         → R1 = 5
        
        *** 被切走 ***
         
线程B: LOAD R1, [ticket]        → R1 = 5
       SUB  R1, R1, 1           → R1 = 4
       STORE [ticket], R1       → 内存中 ticket = 4
       
         *** 被切走 ***
         
线程A: SUB  R1, R1, 1           → R1 = 4  (注意:R1仍是旧值5,每个线程有独立的上下文,即有独立的寄存器)
       STORE [ticket], R1       → 内存中 ticket = 4

结果:ticket 从 5 变成了 4,而不是预期的 3,两个线程各卖了一张票,但只扣了一次,丢失了一次更新。

B. 切换点2:线程A执行完SUB后被切走
复制代码
线程A: LOAD R1, [ticket]        → R1 = 5
       SUB  R1, R1, 1           → R1 = 4
       
         *** 被切走 ***
         
线程B: LOAD R1, [ticket]        → R1 = 5  (内存中还是5!)
       SUB  R1, R1, 1           → R1 = 4
       STORE [ticket], R1       → 内存中 ticket = 4
       
         *** 被切走 ***
         
线程A: STORE [ticket], R1       → 内存中 ticket = 4

结果:同样是 ticket=4 而非 3。本质原因相同------线程B在线程A写回之前读取了旧值。

C. 切换点3:线程A执行完STORE后被切走
复制代码
线程A: LOAD R1, [ticket]        → R1 = 5
       SUB  R1, R1, 1           → R1 = 4
       STORE [ticket], R1       → 内存中 ticket = 4
       
         *** 被切走 ***
         
线程B: LOAD R1, [ticket]        → R1 = 4  (读到的是正确的新值)
       SUB  R1, R1, 1           → R1 = 3
       STORE [ticket], R1       → 内存中 ticket = 3

结果:ticket=3,正确, 因为线程A完整执行了三条指令后才被切走,线程B读到的是已经更新的值。

2.2.4 解决非原子操作

A. 互斥性

本质理解:当代码进入临界区执行时,不允许其他线程进入该临界区。

B. 空闲让进

本质理解:如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。

  1. 不能浪费机会:临界区空闲时,如果有线程想进入,就必须让其中一个进去,不能让临界区空着而所有申请者都在外面干等。

  2. 只能放一个进去:即使有多个线程同时申请,也只能选择其中一个放行,不能因为"大家都等了很久"就同时放进去多个。

C. 无关不阻

本质理解:如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

一个线程如果当前不在临界区内(包括从未进入、已经离开、或者正在执行非临界区代码),它对临界区的访问权就不应该对其他线程产生任何影响,

简而言之:不在临界区里的线程,无论它处于什么状态(运行、阻塞、挂起、甚至崩溃),都不应该成为其他线程进入临界区的障碍

2.3 互斥锁接口的认识

复制代码
关于锁相关的接口

//全局变量
pthread_mutex_t fastmutex = PTHREAD_MUTEX_INITIALIZER;

//初始化互斥锁
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);

//加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);

//解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);

//回收互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);

2.4.1 互斥锁的类型声明

pthread_mutex_t :是 POSIX 线程库中定义互斥锁的类型,本质是一个结构体(或联合体)。

内部封装了锁的状态、持有者、等待队列等信息。

复制代码
//互斥锁的类型

pthread_mutex_t fastmutex = PTHREAD_MUTEX_INITIALIZER;

初始化有两种方式:

复制代码
1. 静态初始化:PTHREAD_MUTEX_INITIALIZER  

适用场景: 全局变量、静态变量(编译期确定地址) 

2. 动态初始化:pthread_mutex_init()

适用场景:局部变量、堆上分配的锁(运行期创建) 

2.4.2 初始化互斥锁

复制代码
//初始化互斥锁

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);

2.4.3 加锁

复制代码
//加锁

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);

2.4.4 解锁

复制代码
//解锁

int pthread_mutex_unlock(pthread_mutex_t *mutex);

2.4.5 回收互斥锁

复制代码
//回收互斥锁

int pthread_mutex_destroy(pthread_mutex_t *mutex);

2.4 互斥锁的原理

2.5 Demo演示:互斥量的封装

复制代码
#pragma once
#include <iostream>
#include <pthread.h>

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            pthread_mutex_init(&_mutex, nullptr);
        }
        Mutex(const Mutex &) = delete;
        Mutex &operator=(const Mutex &) = delete;

        void lock()
        {
            pthread_mutex_lock(&_mutex);
        }

        void unlock()
        {
            pthread_mutex_unlock(&_mutex);
        }

        // 暴露底层句柄,供条件变量等需要原生 pthread_mutex_t* 的场景使用
        pthread_mutex_t *native_handle()
        {
            return &_mutex;
        }

        ~Mutex()
        {
            pthread_mutex_destroy(&_mutex);
        }

    private:
        pthread_mutex_t _mutex;
    };

    class LockGuard
    {
    public:
        LockGuard(Mutex &mutex)
            : _mutex(mutex)
        {
            _mutex.lock();
        }

        LockGuard(const LockGuard &) = delete;
        LockGuard &operator=(const LockGuard &) = delete;

        ~LockGuard()
        {
            _mutex.unlock();
        }

    private:
        Mutex &_mutex;
    };
}

三、线程同步

3.1 同步的引入

3.2 条件变量接口的认识

复制代码
#include <pthread.h>

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

//初始化条件变量
int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr);

//唤醒条件变量
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);

//等待条件变量
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, 
                           const struct timespec *abstime);													//回收条件变量		
int pthread_cond_destroy(pthread_cond_t *cond);	

3.3 生产者和消费者模型 -基于条件变量实现

复制代码
#pragma once
#include <iostream>
#include <pthread.h>
#include <string>
#include <vector>
#include <queue>
#include <unistd.h>
#include "myCond.hpp"
#include "myMutex.hpp"

namespace CvQueueModule
{
    const int defaultcap = 5; // 该容量仅用于测试

    template <typename T>
    class CvQueue
    {
    private:
        bool IsFull() { return (_p_step + 1) % _cap == _c_step; }
        bool IsEmpty() { return _p_step == _c_step; }

    public:
        CvQueue(int capacity = defaultcap)
            : _cap(capacity),
              _cv_q(capacity + 1),
              _p_step(0),
              _c_step(0)
        {}

        void Enqueue(const T &in)
        {
            // 获取互斥锁,进入临界区。
            // pthread_mutex_lock(&_lock);
            {
                MutexModule::LockGuard lockguard(_mutex);

                // 为什么使用while,而不使用if条件判断?
                // 1.消费者线程可能被虚假唤醒(Spurious Wakeup),而导致生成数据时出现越界
                // 2.多个消费者线程都被唤醒 或 连续被唤醒,也会导致生成数据时出现越界
                while (IsFull())
                {
                    // 生产者线程等待队列
                    // 1.pthread_cond_wait调用成功,释放锁并睡眠,等待"缓冲区有空位"的通知进行唤醒。
                    // 2.当线程被唤醒时,默认在临界区内唤醒,需要pthread_cond_wait再次申请锁
                    // 3.若当前线程被唤醒时,但是申请锁失败,此时会在锁上阻塞等待。

                    // pthread_cond_wait(&_full_cond, &_lock);
                    _full_cond.Wait(_mutex);
                }

                // 向缓冲区队列中添加数据
                _cv_q[_p_step] = in;
                _p_step = (_p_step + 1) % _cap;

                // 运行到此,"缓冲区可能有数据" → "缓冲区含有数据",向消费者进行通知
                // 1.消费者在  _empty_cond 的阻塞队列中,唤醒消费者
                // 2.消费者不在_empty_cond 的阻塞队列中,signal 直接返回,通知丢失(条件变量不存储状态)。
                // pthread_cond_signal(&_empty_cond);

                _empty_cond.Signal(); // 唤醒消费者
            } //释放互斥锁
        }

        T Pop()
        {
            T out;
            {
                // 获取互斥锁,进入临界区。
                // pthread_mutex_lock(&_lock);
                MutexModule::LockGuard lockguard(_mutex);
                while (IsEmpty())
                {
                    // 消费者线程等待队列
                    // 1.pthread_cond_wait调用成功,释放锁并睡眠,等待"缓冲区是否有数据"的通知进行唤醒。
                    // 2.当线程被唤醒时,默认在临界区内唤醒,需要pthread_cond_wait再次申请锁
                    // 3.若当前线程被唤醒时,但是申请锁失败,此时会在锁上进行阻塞等待。
                    //  pthread_cond_wait(&_empty_cond, &_lock);
                    _empty_cond.Wait(_mutex);
                }

                // 向缓冲区队列中获取数据
                out = _cv_q[_c_step];
                _c_step = (_c_step + 1) % _cap;

                // 运行到此,"缓冲区数据可能满了" → "缓冲区数据一定不满",向生产者进行通知
                // 1.生产者在  _full_cond 的阻塞队列中,唤醒消费者
                // 2.生产者不在_full_cond 的阻塞队列中,signal 直接返回,通知丢失(条件变量不存储状态)。
                // pthread_cond_signal(&_full_cond);
                _full_cond.Signal();
                
            }//释放互斥锁
            
            return out;
        }

    private:
        std::vector<T> _cv_q; // 缓冲区
        int _cap;             // 缓冲区的容量大小

        int _p_step; // 生产者在缓冲区中的位置
        int _c_step; // 消费者在缓冲区中的位置

        MutexModule::Mutex _mutex;    // 互斥锁
        CondModule::Cond _full_cond;  //  生产者等待:缓冲区非满
        CondModule::Cond _empty_cond; // 消费者等待:缓冲区非空
    };
}

四、POSIX信号量

4.1 基本概念

信号量(Semaphore)本质上是一个非负整数计数器,用于控制多个执行流对共享资源的访问。

P操作 :尝试将信号量值减1。

  • P操作成功:若当前值大于0,则立即减1并返回;

  • P操作失败:若值当前值为0,则调用者阻塞等待,直到信号量变为正数。

V操作 :将信号量值加1。

若有其他执行流正在等待该信号量,则唤醒其中一个。

4.2 信号量接口的认识

复制代码
#include <semaphore.h>

//初始化信号量
int    sem_init(sem_t *, int, unsigned);

//回收信号量
int    sem_destroy(sem_t *);

//P操作:尝试将信号量值减1
int    sem_post(sem_t *);

//V操作:将信号量值加1。
int    sem_wait(sem_t *);

4.2.1 初始化信号量

复制代码
#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);

参数:
 pshared:0表示线程间共享,⾮零表示进程间共享
 value:信号量初始值

4.2.2 回收信号量

复制代码
int sem_destroy(sem_t *sem);

4.2.3 等待信号量 --P操作

复制代码
功能:等待信号量,会将信号量的值减1

int sem_wait(sem_t *sem); //P()

4.2.4 发布信号量 --V操作

复制代码
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。

int sem_post(sem_t *sem);//V()

4.3 生产者和消费者模型 -基于信号量实现

复制代码
#pragma once
#include "mySem.hpp"
#include "myMutex.hpp"
#include <vector>

namespace SemQueueModule
{
    const int defaultcap = 5;
    template <typename T>
    class SemQueue
    {
    public:
        SemQueue(int capacity = defaultcap)
            : _cap(capacity),
              _sem_q(capacity),
              _blank_sem(capacity),
              _p_step(0),
              _data_sem(0),
              _c_step(0)
        {
        }

        void Enqueue(const T &in)
        {
            // 生产者
            // 1.生产者查看是否有空位
            _blank_sem.P();

            {
                MutexModule::LockGuard lockguard(_mutex);
                // 2.生产资源
                _sem_q[_p_step] = in;

                // 3.更新生产者在环形队列中的位置
                _p_step = (_p_step + 1) % _cap;
            }
            // 4.通知消费者环形队列中增加了资源 => 环形队列增加了资源
            _data_sem.V();
        }

        T Pop()
        {
            T out;
            // 消费者

            // 1.查看环形队列中是否有资源
            _data_sem.P();

            {
                MutexModule::LockGuard lockguard(_mutex);
                // 2.获取环形队列中的资源
                out = _sem_q[_c_step];

                // 3.更新消费者在环形队列中的位置
                _c_step = (_c_step + 1) % _cap;
            }

            // 4.通知生产者环形队列中释放了资源 => 环形队列增加了空位
            _blank_sem.V();
            return out;
        }

        ~SemQueue() {}

    private:
        std::vector<T> _sem_q; // 模拟环形队列
        int _cap;              // 环形队列的有效长度

        // 生产者
        SemModule::Sem _blank_sem; // 生产者:环形队列中的空位置
        int _p_step;               // 生产者:在环形队列中的位置
        // 消费者
        SemModule::Sem _data_sem; // 消费者: 环形队列中的数据
        int _c_step;              // 消费者:在环形队列中的位置
        
        MutexModule::Mutex _mutex; //互锁
    };
}

五、线程安全

5.1 什么是线程安全

5.2 Linux 下常见的线程安全问题

5.2.3 死锁

死锁: 是指两个或多个执行单元(线程、进程、事务等)因争夺资源而造成的一种永久阻塞状态,每个单元都在等待另一个单元释放其所持有的资源,且没有任何外部干预能打破这种僵局。

A. 产生死锁的四个必要条件
B. 死锁预防的策略

5.3 线程安全的单例模式

5.3.1 单例模式介绍

5.3.2 实现单例模式 -基于饿汉方式

5.3.3 实现单例模式 -基于懒汉方式

5.3.4 实现线程安全的单例模式

5.4 "STL"与"线程安全"

5.5 "智能指针"与"线程安全"

相关推荐
星间都市山脉1 小时前
RecoveryUI 圆角屏安全边距配置及代码调用链
android·java·linux·windows·ubuntu
rest10241 小时前
对主从复的理解
linux
忆挽篱笙歌1 小时前
linux开发工具
linux·运维·ubuntu
奕鼎竜瑆1 小时前
GPT-6 Luna Batch API 调用实践笔记
笔记·gpt·batch
妄汐霜1 小时前
SSE,RocketMQ,MQTT不同之处
笔记·学习·rocketmq
東隅已逝,桑榆非晚2 小时前
c++模板进阶
开发语言·c++·笔记·学习
杨逢昌工厂6S管理2 小时前
107-杨逢昌钣金车间换型管控:从物料切换杂乱到有序换型的四步技术方案
经验分享·笔记·职场和发展·学习方法
左手の明天11 小时前
SLIP 协议封装与解析详解:原理、C 代码与实战案例
linux·c语言·开发语言·c++
꯭自꯭闭꯭11 小时前
达梦SQL优化相关
linux·运维·数据库·sql