Linux系统——线程同步与互斥

💁‍♂️个人主页:进击的荆棘

👇作者其它专栏:

|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 🧮数据结构与算法 | 《数据结构与算法》 |
| 算法 | 《算法》 |
| C++ | 《C++》 |
| 🐧Linux | 《Linux系统》 |
| MySQL | 《MySQL》 |


目录

1.线程互斥

2.线程同步

3.线程池

4.线程安全和重入问题

5.常见锁概念

6.STL,智能指针和线程安全

7.其它常见的各种锁


1.线程互斥

1.1进程线程间的互斥相关概念

●共享资源

●临界资源:多线程执行流被保护的共享的资源就叫临界资源

●临界区:每个线程内部,访问临界资源的代码,叫做临界区

●互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用

●原子性:不会被任何调度机制打断的操作,改操作只有两态,要么完成,要么未完成

1.2互斥量mutex

●大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其它线程无法获取这种变量

●但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互

●多个线程并发的操作共享变量,会带来一些问题

cpp 复制代码
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 100;

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;
        }
    }
}

int main(void)
{
    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 4 sells ticket:100
...
thread 4 sells ticket:1
thread 2 sells ticket:0
thread 1 sells ticket:-1
thread 3 sells ticket:-2

为什么可能无法获取正确结果?

●if语句判断条件为真以后,代码可以并发的切换到其它线程

●usleep(单位微秒)这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段

●ticket--操作本身就不一个原子操作

bash 复制代码
取出ticket--部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax #
600b34 <ticket>
153 400651: 83 e8 01          sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) #
600b34 <ticket>

--操作并不是原子操作,而是对应三条汇编指令:

●load:将共享变量ticket从内存加载到寄存器中

●update:更新寄存器里面的值,执行-1操作

●store:将新值,从寄存器写回共享变量ticket的内存地址

要解决上面问题,需要做到三点:

●代码必须要有互斥行为:当代码进入临界区执行时,不允许其它线程进入该临界区

●若多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区

●若线程不在临界区中执行,那么该线程不能阻止其它线程进入临界区

要做到这三点,本质上就需要一把锁。Linux上提供的这把锁叫互斥量

​

互斥量的接口

初始化互斥量

初始化互斥量有两种方法:

●方法一,静态分配:

cpp 复制代码
 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

●方法2,动态分配:

cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const
        pthread_mutexattr_t *restrict attr);
参数:
    mutex:要初始化的互斥量
    attr:NULL

销毁互斥量

销毁互斥量需要注意:

●使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁

●不要销毁一个已经加锁的互斥量

●已经销毁的互斥量,要确保后面不会有线程在尝试加锁

cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);

互斥量加锁和解锁

cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号

调用pthread_lock时,可能会遇到一下情况:

●互斥量处于未锁状态,该函数会经互斥量锁定,同时返回成功

●发起函数调用时,其它线程已经锁定互斥量,或者存在其它线程同时申请互斥量,但没有竞争到互斥量,那么pthread_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁

改写上述代码:

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

int ticket = 100;
pthread_mutex_t mutex;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        pthread_mutex_lock(&mutex);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
    return nullptr;
}
int main(void)
{
    pthread_t t1, t2, t3, t4;
    pthread_mutex_init(&mutex, NULL);
    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);
    pthread_mutex_destroy(&mutex);
}

1.3互斥量实现原理探究

●经过上面的例子,我们知道单纯的i++或++i都不是原子的,有可能会有数据一致性问题

●为实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

1.4互斥量的封装

Mutex.hpp

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

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex(){
            pthread_mutex_init(&_mutex,nullptr);
        }
        void Lock(){
            pthread_mutex_lock(&_mutex);
        }
        void UnLock(){
            pthread_mutex_unlock(&_mutex);
        }
        ~Mutex(){
            pthread_mutex_destroy(&_mutex);
        }
    private:
        pthread_mutex_t _mutex;
    };

    class LockGuard{
    public:
        LockGuard(Mutex &mutex):_mutex(mutex){
            _mutex.Lock();
        }
        ~LockGuard(){
            _mutex.UnLock();
        }
    private:
        Mutex &_mutex;
    };
}
cpp 复制代码
// 抢票的代码就可以更新成为
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include "Mutex.hpp"

using namespace MutexModule;

int ticket = 1000;
Mutex mutex;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        LockGuard lockguard(mutex); // 使⽤RAII⻛格的锁
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}
int main(void)
{
    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);
}

RAII风格的互斥锁,C++11也有,比如:

std::mutex mtx;

std::lock_guard<std::mutex> guard(mtx);

2.线程同步

2.1条件变量

●当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了

●例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量

2.1.1同步概念与竞态条件

●同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步

●竟态条件:因为时序问题(哪一个线程先执行,哪一个线程后执行,哪一步被打断都不确定),而导致程序异常,我们称之为竟态条件

2.1.2条件变量函数

初始化

cpp 复制代码
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t
    *restrict attr);
参数:
    cond:要初始化的条件变量
    attr:NULL

销毁

cpp 复制代码
int pthread_cond_destroy(pthread_cond_t *cond)

等待条件满足

cpp 复制代码
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict
    mutex);
参数:
    cond:要在这个条件变量上等待
    mutex:互斥量,当前线程用来保护共享资源的那把锁,而且所有在这个条件变量上
等待的线程,必须传入同一把互斥锁

唤醒等待

cpp 复制代码
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

案例:

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

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void *active(void *arg)
{
    std::string name = static_cast<const char *>(arg);
    while (true)
    {
        pthread_mutex_lock(&mutex);
        pthread_cond_wait(&cond, &mutex);
        std::cout << name << " 活动..." << std::endl;
        pthread_mutex_unlock(&mutex);
    }
}
int main(void)
{
    pthread_t t1, t2;
    pthread_create(&t1, NULL, active, (void *)"thread-1");
    pthread_create(&t2, NULL, active, (void *)"thread-2");

    sleep(3); // 可有可⽆,这⾥确保两个线程已经在运⾏

    while (true)
    {
        // 对⽐测试
        // pthread_cond_signal(&cond); // 唤醒⼀个线程
        pthread_cond_broadcast(&cond); // 唤醒所有线程
        sleep(1);
    }
    
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
}
cpp 复制代码
$ ./cond
thread-1 活动...
thread-2 活动...
thread-1 活动...
thread-1 活动...
thread-2 活动...

2.1.3生产者消费者模型

●321原则(用于记忆,实际不存在)

2.1.3.1为什么要使用生产者消费者模型

生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的

2.1.3.2生产者消费者模型优点

●解耦

●支持并发

●支持忙闲不均

2.1.4基于BlockingQueue的生产者消费者模型

2.1.4.1BlockingQueue

在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)

2.1.4.2C++ queue模拟阻塞队列的生产消费模型

代码:

●为便于理解,先以单生产者,单消费者进行写

●先写单生产,单消费。然后改成多生产,多消费(代码其实不变)

BlockQueue.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <queue>
#include <pthread.h>
#include <unistd.h>

const int defaultcap = 5;

template <class T>
class BlockQueue
{
private:
    bool IsFull() { return _queue.size() >= _cap; }
    bool IsEmpty() { return _queue.size() == 0; }

public:
    BlockQueue(int cap = defaultcap) : _cap(cap), _csleep_num(0), _psleep_num(0)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_full_cond, nullptr);
        pthread_cond_init(&_empty_cond, nullptr);
    }

    void EnQueue(const T &data)
    {
        pthread_mutex_lock(&_mutex);

        // 若队列满了,就不再添加数据,使生产者休眠
        // 为什么要while,1.虚假唤醒 2.竞争
        while (IsFull())
        {
            _psleep_num++;
            pthread_cond_wait(&_full_cond, &_mutex);
            _psleep_num--;
        }

        // 此时,队列一定不是满的
        _queue.push(data);
        if (_csleep_num > 0)
        {
            // 实际上singal函数可以放在unlock前,也可以放在unlock后
            pthread_cond_signal(&_empty_cond);
            std::cout << "唤醒消费者" << std::endl;
        }
        pthread_mutex_unlock(&_mutex);
    }
    T PopQueue()
    {
        pthread_mutex_lock(&_mutex);

        while (IsEmpty())
        {
            _csleep_num++;
            pthread_cond_wait(&_empty_cond, &_mutex);
            _csleep_num--;
        }

        T t = _queue.front();
        _queue.pop();

        if (_psleep_num > 0)
        {
            pthread_cond_signal(&_full_cond);
            std::cout << "唤醒生产者" << std::endl;
        }

        pthread_mutex_unlock(&_mutex);

        return t;
    }
    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_full_cond);
        pthread_cond_destroy(&_empty_cond);
    }

private:
    std::queue<T> _queue; // 临界资源
    int _cap;

    pthread_mutex_t _mutex;
    pthread_cond_t _full_cond;
    pthread_cond_t _empty_cond;

    int _csleep_num;
    int _psleep_num;
};

注:

这使用模板,是因为队列中不止可以传内置类型,也可以传对象

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <functional>

// 任务类型1
// class Task
// {
// public:
//      Task() {}
//      Task(int a, int b) : _a(a), _b(b), _result(0)
//      {
//      }
//      void Excute()
//      {
//          _result = _a + _b;
//      }
//      std::string ResultToString()
//      {
//          return std::to_string(_a) + "+" + std::to_string(_b) + "=" +
//                  std::to_string(_result);
//      }
//      std::string DebugToString()
//      {
//          return std::to_string(_a) + "+" + std::to_string(_b) + "=?";
//      }
// private:
//      int _a;
//      int _b;
//      int _result;
// };

// 任务类型2
using Task = std::function<void()>;

2.1.5为什么pthread_cond_wait需要互斥量

●条件等待是线程间同步的一种手段,若只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,是原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程

●条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据

●所以,我们应该设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上

cpp 复制代码
// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false)
{
    pthread_mutex_unlock(&mutex);
    // 解锁之后,等待之前,条件可能已经满⾜,信号已经发出,但是该信号可能被错过
    pthread_cond_wait(&cond, &mutex);
    pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);

●由于解锁和等待不是原子操作。调用解锁之后,pthread_cond_wait之前,若已经有其它线程获取到互斥量,摈弃条件满足,发送了信号,那么pthread_cond_wait将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait。所以解锁和等待必须是一个原子操作

●int pthread_cond_wait(pthread_cond _t *cond,pthread_mutex_t * mutex); 进入该函数后,会取看条件量是否等于0,若等于,就把互斥量变为1,直到cond_wait返回,把条件量改成1,把互斥量恢复成原样

2.1.6条件变量使用规范

●等待条件代码

cpp 复制代码
pthread_mutex_lock(&mutex);

while (条件为假) //if??
    pthread_cond_wait(cond, mutex);

修改条件
pthread_mutex_unlock(&mutex);

●给条件发送信号代码

cpp 复制代码
pthread_mutex_lock(&mutex);

设置条件为真
pthread_cond_signal(cond);

pthread_mutex_unlock(&mutex);

2.1.7条件变量的封装

Cond.hpp

cpp 复制代码
#pragma once

#include <pthread.h>
#include "Mutex.hpp"

using namespace MutexModule;

class Cond{
public:
    Cond(){
        pthread_cond_init(&_cond,nullptr);
    }
    void Wait(MutexModule::Mutex &mutex){
        pthread_cond_wait(&_cond,&mutex.Get());
    }
    void Signal(){
        pthread_cond_signal(&_cond);
    }
    void BroadCast(){
        pthread_cond_broadcast(&_cond);
    }
    ~Cond(){
        pthread_cond_destroy(&_cond);
    }
private:
    pthread_cond_t _cond;
};

为让条件变量更具通用性,封装的时候,不要再Cond类内部引用对应的封装互斥量,不然后面组合的时候,会因为代码耦合的问题难以初始化,因为一般Mutex和Cond是一起创建的

2.2POSIX信号量

POSIX信号量和System V信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。但POSIX可以用于线程间同步

初始化信号量

cpp 复制代码
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
    pshared:0表⽰线程间共享,⾮零表⽰进程间共享
    value:信号量初始值

销毁信号量

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

等待信号量

cpp 复制代码
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()

发布信号量

cpp 复制代码
功能:发布信号量,表⽰资源使⽤完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()

生产者消费者的例子是基于queue的,其空间可以动态分配,现在基于固定大小的环形队列重新这个程序(POSIX信号量)

2.2.1基于环形队列的生产消费模型

唤醒队列采用数组模拟,用模运算来模拟环状特征

环形结构起始状态和结束状态都是一样的,不好判断为空或为满,所以可以通过加计数器或标记位来判断满或空。另外也可以开辟空间时多开辟一个位置,作为满的状态

有了信号量这个计数器,就可以进行多线程的同步过程

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

class Sem
{
public:
    Sem(int n)
    {
        sem_init(&_sem, 0, n);
    }
    void P()
    {
        sem_wait(&_sem);
    }
    void V()
    {
        sem_post(&_sem);
    }
    ~Sem()
    {
        sem_destroy(&_sem);
    }

private:
    sem_t _sem;
};

注:

先单生产单消费,后面改成多生产多消费

cpp 复制代码
#pragma once

#include <vector>
#include "sem.hpp"
#include "Mutex.hpp"

using namespace MutexModule;

const int default_cap = 5;

template <class T>
class RingQueue
{
public:
    RingQueue(int cap = default_cap)
        : _queue(cap), _cap(cap), _p_sem(cap), _p_step(0), _c_sem(0), _c_step(0)
    {
    }
    void EnQueue(const T &data)
    {
        // 判断生产者是否符合条件,即信号是否大于0
        _p_sem.P();
        {
            //实际上,锁也可以放在_p_sem.P()前面,但这样可缩短持有锁的时间,优化效率,避免串行化其它生产者
            //所以P()必须放在锁外,即本来可以并发,若放在锁内,则硬生生的变为串行
            LockGuard lock(_pmutex);

            _queue[_p_step++] = data;
            _p_step %= _cap; // 防止越界
        }

        // 唤醒消费者
        _c_sem.V();
    }
    T PopQueue()
    {
        T data;

        _c_sem.P();
        {
            LockGuard lock(_cmutex);

            data = _queue[_c_step++];
            _c_step %= _cap;
        }

        _p_sem.V();
        return data;
    }
    ~RingQueue() {}

private:
    std::vector<T> _queue;
    int _cap;

    // 生产者
    Sem _p_sem; // 资源为空盘子,初始为数组大小
    int _p_step;

    // 消费者
    Sem _c_sem; // 资源为数据,初始为0
    int _c_step;

    Mutex _pmutex;
    Mutex _cmutex;
};
相关推荐
橙橙笔记23 分钟前
QT的学习第四部分
linux·qt·学习·音视频
.道阻且长.24 分钟前
Linux基本开发工具:gdb和cgdb的使用
linux·运维·服务器·开发语言
vipxieliang32 分钟前
可观测性与安全的融合:统一数据平台的建设实践
运维·openai·自动化运维
叼烟扛炮1 小时前
Linux 学习笔记(五十四):内网穿透实战——用 frp 让外网访问你的私人电脑
linux·服务器·网络·笔记·学习
Android系统攻城狮1 小时前
Linux FFmpeg深度解析之av_guess_format调用流程与实战(三十六)
linux·运维·服务器·ffmpeg·ffmpeg音视频进阶
进击的荆棘1 小时前
Linux系统——线程概念与控制之线程封装
linux·运维·服务器·线程
叼烟扛炮1 小时前
[LangChain] 学习笔记(05):提示词模板(Prompt)与少样本注入(Few-Shot)实战
linux·人工智能·笔记·学习·langchain·prompt
CodeYp20191 小时前
第5讲:Socket 编程 TCP
linux·socket·tcp
咔_2 小时前
有IPv6地址,为什么外面还是连不进来?让同一台服务分别卡在四个位置
linux·计算机网络