一.sem_信号量
我们之前学的生产消费者模型里面,超市作为临界区是被当作一个整体的资源使用的(整个资源只允许一个线程访问,但只对部分资源做出改 eg.队列有5个数据但只对1个数据进行获取) 我们可不可以把整个资源分成n份,一个线程访问一份。整个资源内可以有多个线程,共同访问共享资源,有一定并发性。
这不就是我们之前讲的信号量吗?
现在我们把整个资源分成多份,并用循环队列来表示,循环队列每个元素都可以进入一个线程,用信号量来表示,进入一个线程 信号量--(P操作) 出去一个线程 信号量++(V操作)
把它代替生产消费者模型中只允许一个线程进入的超市,现在我们讨论一下一个生产者线程和一个消费者线程的场景。
任何线程在访问临界资源时,都要先申请信号量。资源的数量用信号量表示。
但对于生产者和消费者而言,它们需求的资源不同
生产者的资源:空间
消费者的资源:数据
数据+空间=整个资源分成的个数N
因为生产者和消费者需要的资源不同,需要两种信号量来表示。 空间信号量 数据信号量
一开始为空没有数据,空间信号量=N 数据信号量=0
生产者先申请信号量1.P(空间信号量)-- 2.再进入生产数据 3.V(数据信号量)++
消费者先申请信号量1.P(数据信号量)-- 2.再进入消费数据 3.V(空间信号量)++
一个生产者和一个消费者在临界区内可能会出现的情况:
1.同时访问同一个位置:
1.1为空的情况 此时数据信号量==0消费者不能进入 生产者进入再进行原子性生产1.2为满的情况 此时空间信号量==0生产者不能进入 消费者进入再进行原子性消费
这就体现出了消费者线程和生产者线程的同步性 互斥性
2.访问不同位置
并行访问 同时存入数据 获取数据
结论:同一个位置 互斥同步 不同位置 并发
1.sem_init() 初始化
该函数用于初始化一个信号量。它为信号量分配内存并设置初始值。
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
sem:指向信号量变量的指针。
pshared:如果是 0,表示信号量是线程间共享的;如果是非零,表示信号量是进程间共享的。
value:初始化信号量的值。这个值通常是 1(表示互斥锁),也可以是更大的值,表示多个线程或进程可以同时访问共享资源。
返回值:如果成功,返回 0;失败时返回 -1,并设置 errno。
2.sem_wait() 等待信号量
该函数用于执行 P 操作,即等待信号量。如果信号量的值大于 0,信号量值减 1;如果信号量的值为 0,调用线程将被阻塞直到信号量值大于 0。
int sem_wait(sem_t *sem);
3.sem_post()释放信号量
该函数用于执行 V 操作,即释放信号量,增加信号量的值。如果有其他线程由于调用 sem_wait() 被阻塞,可能会有线程被唤醒。
int sem_post(sem_t *sem);
4.sem_getvalue() 获取信号量的值
该函数返回信号量的当前值。此操作不会改变信号量的值,仅用于检查信号量的当前状态。
int sem_getvalue(sem_t *sem, int *sval);
sem:指向信号量变量的指针。
sval:指向整型变量的指针,用于存储信号量的当前值。
5.sem_destroy() 销毁信号量
int sem_destroy(sem_t *sem);
代码模拟
下面我们用代码模拟生产者和消费者模型,先讨论下多生产者和多消费者的场景。
1.整个资源分为多份,一个消费者和一个生产者可以在里面实现并行。要有两个信号量分别记录对于生产者/消费者而言资源还剩多少份。
2.多个消费者可以同时申请资源P操作,但进入资源里面只能有一个。要有一个锁
3.多个生产者可以同时申请资源P操作,但进入资源里面只能有一个。要有一个锁
所以需要两个锁 两个信号量。(一个锁也可以实现功能,但进入资源里面的只能有一个线程,不能实现生产者和消费者的并行)
#pragma once
#include<iostream>
// #include<pthread.h>
#include "Mutex.hpp"
#include "Sem.hpp"
#include<vector>
using namespace SemMoudle;
using namespace LockModule;
namespace RingBufferModule
{
template<typename T>
class RingBuffer
{
public:
RingBuffer(){}
RingBuffer(int cap)
: _ring(cap),
_cap(cap),
_p_step(0),
_c_step(0)
{
// sem_init(&_spacesem,0,_cap);
// sem_init(&_datasem,0,0);
// pthread_mutex_init(&_c_lock);
// pthread_mutex_init(&_p_lock);
}
//为什么不进行判断?怎么知道里面还有多少份资源?
//信号量本身就代表资源数目,只要成功就一定有资源。失败表示没有资源,会被阻塞 直到有资源
void Equeue(const T &in)
{
_spacesem.P();//对空间信号量P操作
{
//phtread_mutex_lock(&_p_lock);
LockGuard Lock(_p_lock); // 加锁为什么要在P()后面?
// 在P操作前也可以,都可以保证生产者间的互斥。但P申请资源是原子性的,生产者可以并行。
// 但在P操作前加锁,就会变为串行 降低了效率。
// 就像买票进场,买票可以同时买,但进场只能一个一个进。因此提前买票 提前进行P操作
_ring[_p_step]=in;//存入数据
_p_step++;
_p_step%=_cap; //维持环形队列
}
//phtread_mutex_unlock(&_p_lock);
_datasem.V();//对数据信号量V操作
}
void Pop(T*out)
{
_datasem.P();//对数据信号量P操作
{
LockGuard Lock(_c_lock);
// phtread_mutex_lock(&_c_lock);
*out=_ring[_c_step];//获取数据
_c_step++;
_c_step%=_cap;
//phtread_mutex_unlock(&_c_lock);
}
_spacesem.V();//对空间信号量V操作
}
~RingBuffer()
{
// sem_destory(&_spacesem);
// sem_destory(&_datasem);
// pthread_mutex_destory(&_c_lock);
// pthread_mutex_destory(&_p_lock);
}
private:
std::vector<int> _ring;//环 临界资源
int _cap;//总容量
int _p_step;//生产者位置
int _c_step;//消费者位置
Sem _datasem; //数据信号量
Sem _spacesem;//空间信号量
// pthread_t _p_lock; //生产者锁
// pthread_t _c_lock; //消费者锁
Mutex _p_lock;
Mutex _c_lock;
};
} // namespace RingBufferModule
对信号量封装
#pragma once
#include <semaphore.h>
namespace SemMoudle
{
int gval=1;
class Sem
{
public:
Sem(int val=gval):_val(val)
{
sem_init(&_sem,0,val);
}
void P()
{
sem_wait(&_sem);
}
void V()
{
sem_post(&_sem);
}
~Sem()
{
sem_destroy(&_sem);
}
private:
sem_t _sem;
int _val;
};
}
锁封装
#pragma once
#include <iostream>
#include <pthread.h>
namespace LockModule
{
class Mutex
{
public:
Mutex(const Mutex&) = delete;
const Mutex& operator = (const Mutex&) = delete;
Mutex()
{
int n = ::pthread_mutex_init(&_lock, nullptr);
(void)n;
}
~Mutex()
{
int n = ::pthread_mutex_destroy(&_lock);
(void)n;
}
void Lock()
{
int n = ::pthread_mutex_lock(&_lock);
(void)n;
}
pthread_mutex_t *LockPtr()
{
return &_lock;
}
void Unlock()
{
int n = ::pthread_mutex_unlock(&_lock);
(void)n;
}
private:
pthread_mutex_t _lock;
};
class LockGuard
{
public:
LockGuard(Mutex &mtx):_mtx(mtx)
{
_mtx.Lock();
}
~LockGuard()
{
_mtx.Unlock();
}
private:
Mutex &_mtx;
};
}
main
#include"RingBuffer.hpp"
#include<pthread.h>
#include<unistd.h>
using namespace RingBufferModule;
void* Consumer(void*args)
{
RingBuffer<int>*br=static_cast<RingBuffer<int>*>(args);
int data;
while (true)
{
sleep(2);
//1.从队列中获取数据
br->Pop(&data);
//2.处理数据
printf("消费者获取数据:%d\n",data);
}
}
void* Productor(void*args)
{
RingBuffer<int>*br=static_cast<RingBuffer<int>*>(args);
//1.从外部获取数据
int data=10;
while(true)
{
//2.把数据输入队列中
br->Equeue(data);
printf("生产者存入数据:%d\n",data);
data++;
}
}
int main()
{
RingBuffer<int>* br=new RingBuffer<int>(10);
pthread_t c,p;
pthread_create(&c,nullptr,Consumer,(void*)br);
pthread_create(&p,nullptr,Productor,(void*)br);
pthread_join(p,nullptr);
pthread_join(c,nullptr);
delete br;
}
二.可重入和线程安全
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
可重入与线程安全联系:
函数是可重入的,那就是线程安全的。
线程安全不一定是可重入的。
(通过锁来确保多个线程对共享资源的访问是有序的,但它可能不可重入,因为在函数内部如果需要再次调用同一个函数时,可能会由于锁的持有而发生死锁。)
线程安全通过同步(例如加锁、原子操作)确保并发访问不会出错,而可重入性则强调函数在中断后能够继续安全执行。
三.饿汉实现方式和懒汉实现方式
饿汉式
饿汉式是指在类加载时就创建单例对象。(要用到的时候立刻就可以用)也就是说,无论你是否使用这个对象,它都会在程序启动时就实例化。这种方式相对简单,线程安全,但也可能造成不必要的资源浪费,因为即使没有使用这个单例对象,它也会被创建。
class Singleton {
private:
// 静态成员变量,在类加载时即初始化
static Singleton instance;
// 构造函数私有化,防止外部直接实例化
Singleton() {}
public:
// 获取单例对象的接口
static Singleton& getInstance() {
return instance;
}
void doSomething() {
// 业务逻辑
}
};
// 静态成员变量的定义(只会创建一次实例)
Singleton Singleton::instance;
优点:
1.线程安全。由于静态变量是在程序启动时就初始化的,且是静态存储期的变量,因此不需要额外的同步机制,线程安全。
缺点:
1.浪费资源:即使没有使用单例对象,它也会在程序启动时就创建,可能导致不必要的内存开销。
2.不可延迟初始化:初次加载初始化时间长
懒汉式
懒汉式是指单例对象在第一次使用时才创建。(要用到的时候初始化后才能用)也就是说,只有当需要用到这个对象时,才会去创建它。懒汉式更节省资源,但在多线程环境下需要小心处理,避免竞态条件。
class Singleton {
private:
// 静态指针,初始为nullptr
static Singleton* instance;
// 构造函数私有化
Singleton() {}
public:
// 获取单例对象的接口
static Singleton* getInstance() {
if (instance == nullptr) {
instance = new Singleton();
}
return instance;
}
void doSomething() {
// 业务逻辑
}
};
// 静态成员变量的初始化
Singleton* Singleton::instance = nullptr;
1.类中有对象的静态指针(类外定义静态变量,类内声明静态变量)
2.构造函数私有化,不能直接访问。
3.拷贝 赋值函数=delete 禁止
4.公有静态函数getInstance(),可以在类外直接调用它(Singleton::getInstance()),访问到唯一的单例实例。
优点:
线程安全:通过互斥锁确保多线程下的安全。
延迟初始化:仅在需要时才创建实例。
缺点:
性能开销:锁的使用可能会引入一定的性能损耗。
多线程环境下的懒汉式(线程安全实现)
在创建单例对象时,如果多个线程同时调用该函数,多个线程可能会同时进入临界区,导致创建多个实例。
// 获取单例对象的接口
static Singleton* getInstance() {
if (instance == nullptr) {
instance = new Singleton();
}
return instance;
}
因此我们需要对临界区进行加锁,保证只有一个线程进入临界区,创建一个实例。
static Singleton* getInstance() {
std::lock_guard<std::mutex> lock(mtx); // 上锁
if (instance == nullptr) {
instance = new Singleton();
}
return instance;
}
但这样每个线程进入该函数,想获取单例,必须先申请锁,再判断,不满足最后再返回单例。效率太低。
能不能先申请锁前就可以进行判断,如果已经创建完了,就直接返回单例。
可以双重检查
static Singleton* getInstance() {
if (instance == nullptr) {
std::lock_guard<std::mutex> lock(mtx); // 上锁
if (instance == nullptr) { // 第二次检查
instance = new Singleton();
}
}
return instance;
}
#include <mutex>
class Singleton {
private:
static Singleton* instance;
static std::mutex mtx; // 互斥锁
Singleton() {}
public:
static Singleton* getInstance() {
if (instance == nullptr) {
std::lock_guard<std::mutex> lock(mtx); // 上锁
if (instance == nullptr) {
instance = new Singleton();
}
}
return instance;
}
void doSomething() {
// 业务逻辑
}
};
// 静态成员变量初始化
Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mtx;
定义的锁必须也是静态的。
1.std::mutex 保证线程对单例的访问是同步的,避免多个线程同时创建或访问单例实例。为了实现这一点,互斥锁必须在多个线程之间是共享的,确保无论多少个线程试图访问 getInstance(),都能够通过同一个锁来同步它们的访问。
2.生命周期和同步:静态成员的生命周期与程序运行的生命周期一致,保证了锁的持久性,避免了锁被销毁或重建的风险。
四.死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
eg,访问临界资源需要申请锁1和锁2,A线程申请了锁1 B线程申请了锁2,它们都占有锁并不释放,互相申请,导致一直在阻塞。
单线程会出现死锁问题吗?
有可能,对锁的操作不当。申请了锁却没有正确释放
死锁的4个必要条件
互斥条件:
资源不能共享,某个时刻只能被一个线程或进程占用。例如,如果两个线程都试图获取同一个锁,而这个锁只能被一个线程持有,则发生了互斥。请求与保持条件:(持有锁并不释放 同时申请其它被其它线程持有的锁)
线程已经持有至少一个资源,并且在请求其他资源时不会释放已持有的资源。例如,一个线程持有锁A并请求锁B,但此时锁B已被其他线程占用,线程不释放锁A,等待锁B。不剥夺条件:(不能强行获取其它线程持有的锁)
线程已经获得的资源在没有完成任务之前,不能强行剥夺。即使其他线程请求该资源,不能从当前线程手中夺走该资源。循环等待条件:
存在一个线程等待的资源链,其中每个线程都在等待下一个线程持有的资源。形成一个闭环,例如线程1等待锁2,线程2等待锁3,线程3等待锁1。
五.STL,智能指针和线程安全
STL 中的容器是否是线程安全的?
不是.
原因是, STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响.
而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如 hash 表的锁表和锁桶).
因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证
线程安全.
智能指针是否是线程安全的?
对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题.(std::unique_ptr 是独占所有权的智能指针,意味着它的所有权在任何时候只能被一个 unique_ptr 持有。)
对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是
标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够
高效, 原子的操作引用计数.(虽然 shared_ptr 本身是线程安全的(对于引用计数的操作),但对其所指向的对象的访问不一定是安全的,尤其是在多个线程中同时修改对象时,可能需要额外的同步措施。)