线程同步与互斥

1.线程互斥

线程间通信需要看到同一份资源,由于线程共享虚拟地址空间如果对共享资源不加以保护的话就会出现数据不一致、脏读、丢失更新等问题。被多线程访问保护的共享资源就叫做临界资源,而访问临界资源的代码段就叫做临界区。互斥就是任何时候只允许一个执行流进程临界区,访问临界资源通常对临界资源起到保护作用。

1.1互斥量mutex

线程间同时申请锁资源,得到锁的线程才能继续访问临界资源没有得到的线程会被阻塞找到申请到锁资源。线程间竞争同一把锁就实现了互斥,但是锁既然能被所以线程访问就说明锁也是一种共享资源,锁是用来保证临界资源的安全的那么谁来保障锁的安全呢?答案是锁自己保证自身安全。多线程同时访问共享资源之所以会出现问题关键在于一个线程在使用临界资源时还没有完成对资源的访问就因为各种原因轮到别的线程执行了,这是问题的关键。那么锁是怎么保证自身安全的呢?答案就是保证获取锁的操作是原子性的。什么是原子性呢?原子性就是一个操作是不可分割、不可中断、无中间状态,要么完全执行要么完全不执行

互斥锁的初始化有两种方法,一种是全局的锁使用静态分配:pthread_mutex_t mutex =PTHREAD_MUTEX_INITIALIZER;另一种就是动态分配需要使用pthread_mutex_init函数,函数原型为int pthread_mutex_init(pthread_mutex_t *restrict mutex, constpthread_mutexattr_t *restrict attr);mutex:要初始化的互斥量,attr:锁的属性,一般使用默认的就可以了所以一般传NULL。销毁锁使用int pthread_mutex_destroy(pthread_mutex_t *mutex);值得注意的是使用PTHREAD_MUTEX_INITIALIZER初始化的锁不需要销毁,不要销毁一个已经加锁的互斥量,也不要再对销毁的互斥量进行加锁。互斥量的加锁和解锁分别使用int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_unlock(pthread_mutex_t *mutex); 返回值:成功返回0,失败返回错误号。在使用pthread_mutex_lock函数时会出现两种情况:1.互斥量没被加锁,函数成功锁定互斥量返回0。2.互斥量已经被加锁,这是调用函数的线程会被阻塞,等待互斥锁资源。unlock处理对互斥量进行解锁以外,还会唤醒等待锁资源的线程。

1.2互斥量的原理

加锁过程依赖于底层硬件的原子指令 (如 test-and-setcompare-and-swap)。这些指令在硬件层面保证"读-改-写"操作不可分割。锁的本质是一个共享变量,加锁就是原子地把这个变量从"未锁"状态改为"已锁"状态,并返回旧值。如果旧值是"已锁",说明锁被占用,当前线程就会被挂起。这样就保证了锁的互斥性。

2.线程同步

2.1条件变量

当一个线程互斥的访问某个变量时,他可能在别的线程改变这个变量之前它都不能干什么。比如说一个线程访问队列时,发现队列为空,这时他就只能等但是它又持有锁别的线程又不能改变队列。这时就需要使用条件变量。

2.2同步与竞争条件

同步就是在保证数据安全的情况下,让线程按照一定的顺序执行,从而有效避免线程饥饿问题。竞态条件:因为时序问题,⽽导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。

2.3条件变量函数

初始化函数为int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t

*restrict attr);cond:要初始化的条件变量,attr:条件变量的属性,一般使用默认属性。也可以使用PTHREAD_COND_INITIALIZER初始化全局的条件变量。销毁函数为int pthread_cond_destroy(pthread_cond_t *cond);当条件不满足时使用int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex); 参数: cond:要在这个条件变量上等待 ,mutex:互斥量。因为条件判断都是在获取锁之后,这时等待就必须释放线程获得的锁了,否则会导致其他线程页无法访问临界资源从而造成效率低下。等到条件满足后线程会重新竞争锁资源,得到锁资源后才会执行后续代码。唤醒线程有两个函数int pthread_cond_broadcast(pthread_cond_t *cond); 和int pthread_cond_signal(pthread_cond_t *cond);pthread_cond_broadcast函数唤醒所有阻塞的线程,pthread_cond_signal唤醒一个线程。

2.4生产者消费者模型

生产者消费者模型有一个321原则,即三种关系、两个角色、一个缓冲区。三种关系分别为生产者&生产者、消费者&消费者、生产者&消费者。生产者&生产者是互斥关系,消费者&消费者也是互斥关系、生产者&消费者是互斥+同步关系。两个角色就是生产者和消费者,一个缓冲区通常是一个容器例如阻塞队列。生产者和消费者不直接通讯,而是通过阻塞队列来进行通讯。生产者消费者模型的优点是让生产者和消费者解耦,支持生产者和消费者并发运行,还支持忙闲不均的问题。

2.5基于阻塞队列的生产者消费者模型

cpp 复制代码
#include<iostream>
#include<pthread.h>
#include<queue>
const static int a=5;
#include <unistd.h>
template<class T>
class Thread
{
private:
    bool A()
    {
        return _q.size()==_cap;
    } 
    bool B()
    {
        return _q.empty();
    }
public:
    Thread(int cap=5):_cap(cap),_c_wait_num(0),_p_wait_num(0)
    {
        pthread_mutex_init(&_lock,nullptr);
        pthread_cond_init(&_c,nullptr);
        pthread_cond_init(&_p,nullptr);
    }
    void Enqueue(const T &a)
    {
        pthread_mutex_lock(&_lock);
        while(A())
        {
            _p_wait_num++;
            pthread_cond_wait(&_p,&_lock);
            _p_wait_num--;
        }
        _q.push(a);
        if(_c_wait_num>0)
            pthread_cond_signal(&_c);
        pthread_mutex_unlock(&_lock);
    }
    void Pop(T* a)
    {
        pthread_mutex_lock(&_lock);
        while(B())
        {
            _c_wait_num++;
            pthread_cond_wait(&_c,&_lock);
            _c_wait_num--;
        }
        *a=_q.front();
        _q.pop();
        if(_p_wait_num>0)
            pthread_cond_signal(&_p);
        pthread_mutex_unlock(&_lock);
    }
    ~Thread()
    {
        pthread_mutex_destroy(&_lock);
        pthread_cond_destroy(&_c);
        pthread_cond_destroy(&_p);
    }
private:
    pthread_mutex_t _lock;
    pthread_cond_t _c;
    pthread_cond_t _p;
    std::queue<T> _q;
    int _cap;
    int _c_wait_num;
    int _p_wait_num;
};


pthread_mutex_t M = PTHREAD_MUTEX_INITIALIZER;
void *p(void *args)
{
    Thread<int> *x = static_cast<Thread<int> *>(args);
    int a = 0;
    while (true)
    {
        x->Enqueue(a);
        pthread_mutex_lock(&M);
        std::cout << "生产者生产了一个数据:" << a << std::endl;
        pthread_mutex_unlock(&M);
        a++;
        sleep(1);
    }
    return (void *)0;
}
void *c(void *args)
{
    Thread<int> *x = static_cast<Thread<int> *>(args);

    while (true)
    {
        int a = 0;
        x->Pop(&a);
        pthread_mutex_lock(&M);
        std::cout << "消费者消费了一个数据:" << a << std::endl;
        pthread_mutex_unlock(&M);
        sleep(1);
    }
    return (void *)0;
}
int main()
{
    Thread<int> a;
    pthread_t x, y;
    pthread_create(&x, nullptr, p, &a);
    pthread_create(&y, nullptr, c, &a);
    pthread_join(x, nullptr);
    pthread_join(y, nullptr);
    return 0;
}

2.6POSIX信号量

POSIX信号量也是用于同步操作,达到无冲突的访问临界资源的目的。信号量更像是对资源的一种预定。初始化信号量的函数为set_init函数int sem_init(sem_t *sem, int pshared, unsigned int value); 参数: pshared:0表⽰线程间共享,⾮零表⽰进程间共享。pshared:信号量的共享范围,0代表信号量在线程间共享,1表示进程间共享。value:信号量初始值,也就是可以预定资源的数量。销毁信号量可以使用int sem_destroy(sem_t *sem);等待信号量使用int sem_wait(sem_t *sem);会将信号量的值减一,如果信号量为0则会阻塞改线程。发布信号量使用int sem_post(sem_t *sem);表示使用资源完毕归还资源,信号量的值会加一还会唤醒等待信号量的线程。

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

我对锁和信号量进行了简单的面向对象的封装,并用其完成了基于环形队列的生产者消费者模型如下。

cpp 复制代码
#pragma once
#include "Lock.hpp"
#include "Semaphore.hpp"
#include <vector>

const static int a = 10;
template <class T>
class PCM
{
public:
    PCM(int set = a, int x = a) : _set(set), _c_Sem(0), _p_Sem(x),_c_in(0),_p_in(0)
    {
        _queue.resize(_set);
    }
    void Envector(const T &a)
    {
        _p_Sem.Wait();
        _p_lock.Lock();
        _queue[_p_in++] = a;
        _p_in %= _set;
        _p_lock.Unlock();
        _c_Sem.post();
    }
    void Pop(T *out)
    {
        _c_Sem.Wait();
        _c_lock.Lock();
        *out = _queue[_c_in++];
        _c_in %= _set;
        _c_lock.Unlock();
        _p_Sem.post();
    }
    ~PCM()
    {
    }

private:
    int _set;
    Semaphore _c_Sem;
    Semaphore _p_Sem;
    lock _c_lock;
    lock _p_lock;
    std::vector<T> _queue;
    int _c_in;
    int _p_in;
};

3.线程池

3.1日志与策略模式

计算机中的⽇志是记录系统和软件运⾏中发⽣事件的⽂件,主要作⽤是监控运⾏状态、记录异常信 息,帮助快速定位问题并⽀持程序员进⾏问题修复。它是系统维护、故障排查和安全管理的重要⼯具。日志格式必须有一下几个指标:日志等级、时间戳、日志内容,还有一些可选的指标:文件名、行号、进程线程相关的id信息等。

3.2线程池设计

⼀种线程使⽤模式。线程过多会带来调度开销,进⽽影响缓存局部性和整体性能。⽽线程池维护着多 个线程,等待着监督管理者分配可并发执⾏的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利⽤,还能防⽌过分调度。可⽤线程数量应该取决于可⽤的并发处理器、处理器内核、内存、⽹络sockets等的数量。线程池的应⽤场景:需要⼤量的线程来完成任务,且完成任务的时间⽐较短。 ⽐如WEB服务器完成⽹⻚请求这样的任务,使⽤线程池技术是⾮常合适的。因为单个任务⼩,⽽任务数量巨⼤,你可以想象⼀个热⻔⽹站的点击次数。 但对于⻓时间的任务,⽐如⼀个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间⽐线程的创建时间⼤多了。对性能要求苛刻的应⽤,⽐如要求服务器迅速响应客⼾请求接受突发性的⼤量请求,但不⾄于使服务器因此产⽣⼤量线程的应⽤。突发性⼤量客⼾请求,在没有线程池情况下,将产⽣⼤量线程,虽然理论上⼤部分操作系统线程数⽬最⼤值不是问题,短时间内产⽣⼤量线程可能使内存到达极限,出现错误.

线程池的种类 :a. 创建固定数量线程池,循环从任务队列中获取任务对象,获取到任务对象后,执⾏任务对象中的任务接⼝b. 浮动线程池,其他同上。此处,我们选择固定线程个数的线程池。

3.3线程安全的单例模式

单例模式就是一个进程只能有一个对象,单例模式的实现有两种方式:懒汉实现方式、饿汉实现方式。饿汉式 在类加载时直接创建实例(线程安全但可能浪费内存),懒汉式在首次调用时才创建实例(需处理线程安全问题,推荐使用静态内部类或双重检查锁实现)。

cpp 复制代码
#include <vector>
#include <queue>
#include <string>
#include <functional>
#include "task.hpp"
#include "thread.hpp"
#include "Cond.hpp"

const static int default_thread_num = 5;
template <class T>
class threadpool
{
private:
    threadpool(int threadnum = default_thread_num) : _threadnum(threadnum), _waitnum(0), _state(false)
    {
        {
            LockGuard x(_lock);
            std::string name;
            for (int i = 0; i < _threadnum; i++)
            {
                name = "thread-" + std::to_string(i + 1);
                _threads.emplace_back(name, [this](std::string name)
                                      { this->task(name); });
                LOG(LogLevel::INFO) << "init thread " << _threads.back().Name()
                                    << " done";
            }
        }
    }
    threadpool<T> operator=(const threadpool<T> &a) = delete;
    threadpool(const threadpool<T> &a) = delete;
    bool X()
    {
        return _tasks.empty();
    }

public:
    void task(std::string name)
    {
        while (true)
        {
            T t;
            std::string Name;
            {
                LockGuard x(_lock);
                while (X() && _state)
                {
                    _waitnum++;
                    _cond.Wait(_lock.Get_lock());
                    _waitnum--;
                }
                if (X() && !_state)
                {
                    break;
                }
                t = _tasks.front();
                Name = _tasks.front().Name();
                _tasks.pop();
            }
            t();
            LOG(LogLevel::INFO) << name << "线程成功处理了一个任务 " << Name;
        }
    }
    static threadpool<T> *GetInstance()
    {
        if (!_instance)
        {
            LockGuard x(_mutex);
            if (!_instance)
            {
                _instance = new threadpool<T>();
                LOG(LogLevel::INFO) << " 成功创建线程池";
                return _instance;
            }
        }
        LOG(LogLevel::INFO) << " 成功获取线程池";
        return _instance;
    }
    void Start()
    {
        _state = true;
        for (auto &ch : _threads)
        {
            ch.Start();
            LOG(LogLevel::INFO) << " 启动线程:" << ch.Name();
        }
    }
    void Stop()
    {
        {
            LockGuard x(_lock);
            _state = false;
            _cond.notify_all();
        }
        LOG(LogLevel::INFO) << " 线程池退出";
    }
    void wait()
    {
        for (auto &ch : _threads)
        {
            ch.Join();
            LOG(LogLevel::INFO) << " 线程" << ch.Name() << "退出 ";
        }
    }
    void Enqueue(const T &a)
    {
        {
            LockGuard x(_lock);
            _tasks.push(a);
            if (_waitnum)
            {
                _cond.notify_one();
            }
        }
    }
    ~threadpool()
    {
    }

private:
    std::queue<T> _tasks;
    int _threadnum;
    bool _state;
    int _waitnum;
    cond _cond;
    std::vector<mythread> _threads;
    static threadpool<T> *_instance;
    lock _lock;
    static lock _mutex;
};
template <class T>
threadpool<T> *threadpool<T>::_instance = nullptr;
template <class T>
lock threadpool<T>::_mutex;

4.线程安全和重入问题

线程安全就是多个线程在访问共享资源时,能够正确执行,不会互相干扰结果。重入是同一个函数在当前执行流还没有指向完就被其他执行流再次执行。如果一个函数被重入结果不会出现问题就被称为可重入函数,反之称为不可重入函数。重入其实又分为两种情况:1.多线程重入函数、2.信号导致函数同一执行流重入。线程安全和重入有很多类似的情况,但是线程安全并不代表可重入,如一个函数申请锁之后收到信号然后重新执行该函数,该函数又会重新申请锁就会出现死锁问题。所以函数可重入那么就是线程安全的。

5.常见锁概念

5.1死锁

死锁指的是一组线程的各个线程均占据一些不会释放的资源,而这些线程又需要其他线程的资源却永远得不到而处在一种永久等待的状态。比如两线程A和B必须同时拥有锁1和2,线程A先申请了锁1然后时间片到了被调走线程B申请锁2之后再申请锁1,因为锁1已经被A拥有且没被释放,因此B陷入等待A再去申请锁2也陷入等待,除非A或B释放自己的锁资源否则将陷入永久等待。

5.2死锁的四个必要条件

死锁出现实现要满足互斥条件 ,即一个资源每次只能被一个执行流使用。其次是请求和保持条件 :一个执行流必须使用某些资源就是请求条件,而一个执行流在得到一定资源后在申请别的资源阻塞时不会自动释放自己获得的资源。还有就是不剥夺条件 :一个执行流已经获得的资源在未使用完之前不能被其他执行流剥夺。最后就是循环等待条件:若干执行流之间形成一个头尾相接的循环等待资源的关系。还是以之前的例子来说,1.A线程已经拥有了锁1并不会放手,需要从B那里获得锁2但是又不能剥夺B的资源只能干等。2.B线程也想要A的锁1,也是不能剥夺资源也只能干等。这两条一条循环等待资源。

5.3死锁的避免方式

想要避免死锁无非就是想办法破坏死锁的4个必要条件即可,但为了线程安全互斥条件不能破坏,请求条件不能被破坏。破坏循环等待条件可以让资源一次性分配好,也可以使用超时机制、加锁顺序一致等。也可以允许线程剥夺其他线程的资源,或者让线程在申请资源失败后先释放拥有的资源再进行等待。

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

6-1 STL中的容器是否是线程安全的?

不是,原因是, STL 的设计初衷是将性能挖掘到极致, ⽽⼀旦涉及到加锁保证线程安全, 会对性能造成巨⼤的影响⽽且对于不同的容器, 加锁⽅式的不同, 性能可能也不同(例如hash表的锁表和锁桶)因此 STL 默认不是线程安全. 如果需要在多线程环境下使⽤, 往往需要调⽤者⾃⾏保证线程安全.

6-2 智能指针是否是线程安全的?

对于 unique_ptr, 由于只是在当前代码块范围内⽣效, 因此不涉及线程安全问题对于 shared_ptr, 多个对象需要共⽤⼀个引⽤计数变量, 所以会存在线程安全问题. 但是标准库实现的时 候考虑到了这个问题, 基于原⼦操作(CAS)的⽅式保证 shared_ptr 能够⾼效, 原⼦的操作引⽤计数.

相关推荐
牛马鸡niumasi4 小时前
C/C++ 程序编译过程、静态/动态链接、静态/动态库
linux
捧月华如5 小时前
Linux 系统性能压测工具全景指南(含工程实战)
linux·运维·服务器
YMWM_5 小时前
export MPLBACKEND=Agg命令使用
linux·python
想唱rap5 小时前
线程的同步与互斥
linux·运维·服务器·数据库·mysql
格林威6 小时前
SSD 写入速度测试命令(Linux)(基于工业相机高速存储)
linux·运维·开发语言·人工智能·数码相机·计算机视觉·工业相机
勇闯逆流河6 小时前
【LInux】linux控制(进程替换,自主shell的实现详解)
linux·运维·服务器
IMPYLH6 小时前
Linux 的 ls 命令
linux·运维·服务器·bash
笨笨饿7 小时前
33_顺序表(待完善)
linux·服务器·c语言·嵌入式硬件·算法·学习方法
wwj888wwj7 小时前
Ansible基础(复习1)
linux·运维·ansible