提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
前言
在多线程中,每个线程的执行顺序以及对共享资源的安全访问有着不确定性,而同步与互斥的概念便是解决以上问题的方法
一、线程互斥
线程互斥目的:确保同一时刻仅有一个线程访问共享资源,防止数据竞争(Data Race)。
1.1 进程线程间的互斥相关背景概念
- 临界资源:任何一个时刻,都只允许一个执行流进行访问的共享资源。
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。比如对变量val实现++操作,转换成汇编实际上是三行代码:把变量加载到寄存器,进行++,把结果返回变量,原子性就是这三行代码要么一个都不做,要么都做完。
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量,会带来数据上达不到理想结果的问题。
为了避免这种问题,需要做到以下三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,需要运用一种锁的概念。Linux上提供的这把锁叫互斥锁 / 互斥量。 |
1.2 互斥量
1.2.1 申请互斥量
#include <pthread.h>
//动态分配//局部变量使用
int pthread_mutex_destroy(pthread_mutex_t *mutex);//不要销毁⼀个已经加锁的互斥量 已经销毁的互斥量,要确保后⾯不会有线程再尝试加锁
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
//参数: mutex:要初始化的互斥量 attr:NULL
//静态分配//全局变量使用
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
函数参数列表中的类型 pthread_mutex_t 称为互斥锁。
局部变量定义完互斥锁后,使用函数 pthread_mutex_init 对互斥锁初始化,使锁变为可工作状态。锁用完之后,需要使用 pthread_mutex_destroy 函数销毁锁。
全局变量使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁。
1.2.2 加锁与解锁
当申请完互斥量后,便可以用加锁和解锁让代码分为临界区和非临界区
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
//返回值:成功返回0,失败返回错误号
调⽤ pthread_ lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调⽤时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_lock调⽤会陷⼊阻塞(执⾏流被挂起),等待互斥量解锁。
锁提供的能力本质:使代码由并行改为串行,在执行期间不会被打扰也是一种原子性的表现 |
具体用法如下,用一个模拟抢票函数来实现:
#include <iostream>
#include <cstring>
#include <cstdio>
#include <string>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 1000; //加锁保护
class TData
{
public:
TData(const string &name, pthread_mutex_t* mutex)
:_name(name)
,_pmutex(mutex)
{}
~TData()
{}
public:
string _name;
pthread_mutex_t* _pmutex;
};
void* threadRoutine(void* args)
{
TData* td = static_cast<TData*>(args);
while(1)
{
pthread_mutex_lock(td->_pmutex); // 所有线程都要遵守这个规则
if(tickets > 0)
{
usleep(2000);//模拟抢票花费的时间
cout << td->_name << " get a tickets: " << tickets-- << endl;
pthread_mutex_unlock(td->_pmutex);
}
else
{
pthread_mutex_unlock(td->_pmutex);
break;
}
usleep(13);
}
return nullptr;
}
int main()
{
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, nullptr);
pthread_t tids[4];
int n = sizeof(tids)/sizeof(tids[0]);
for(int i = 0; i < n; ++i)
{
char name[64];
snprintf(name, 64, "thread-%d", i + 1);
TData* td = new TData(name, &mutex);
pthread_create(tids + i, nullptr, threadRoutine, td);
}
for(int i = 0; i < n; ++i)
{
pthread_join(tids[i], nullptr);
}
pthread_mutex_destroy(&mutex);
return 0;
}
加锁操作有几点注意事项:
- 凡是访问同一个临界资源的线程,都要进行加锁保护,并且必须加同一把锁,这是规则,不能有例外。
- 在给执行流加锁时,只需要给临界区加锁就可以了。加锁的本质是让代码串行化,因此让临界区的代码越少越好,这样比较节省运行时间。
- 线程访问临界区的时候,需要先加锁,这意味着所有的线程都可以先看到同一把锁,因此锁本身就是一个公共资源,锁需要保证自己的安全。所以加锁和解锁本身就是原子性的。
- 临界区可以是一行代码,也可以是一批代码。 在执行临界区代码的时候,线程有可能会被切换,但是因为锁并没有被释放,所以其他线程都无法成功的申请到锁,会被阻塞起来。因此线程被切换不会导致资源被别的执行流更新替换。这也是互斥带来的串行化的体现。
- 对于线程而言,有意义的状态只有两种:持有锁、不持有锁,不存在其他中间状态。只有当线程的工作做完之后,才会归还锁。原子性就体现在这里。
1.3 互斥量原理
以下使锁原理实现的伪代码:
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
CPU内部只有一套寄存器设备,但每个线程都有自己的TCB代码块交给CPU执行,执行过程中会对数据进行处理,我们可以先将互斥量mutex的值视为1,而在寄存器al中的值被置为0,此时某个线程率先执行到加锁的代码的时候,会将这个值通过exhgb进行交换,至此互斥量的值1便被率先执行这段代码的线程保存到自己的上下文中了,并且mutex的值被置为0,而当其它线程再做交换时只能拿0进行交换了,而解锁便是重新将mutex置为1,继续让其它线程持有锁 |
二、线程同步
2.1 同步与竞态
- 饥饿:一个线程频繁连续的申请锁,但是申请到锁之后什么都不做。导致其他线程无法申请到锁,其他线程就处于饥饿状态。(这种线程的纯互斥行为是没有错的,但它不高效,不太公平)
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
2.2 条件变量
条件变量(Condition Variable)是多线程编程中用于线程间同步的核心机制,主要用于协调线程在特定条件满足时的唤醒与等待。它常与互斥锁(Mutex)配合使用,解决线程间对共享资源的依赖关系,避免忙等待(Busy Waiting),提升效率。
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。条件变量是一个数据类型,其中有一个队列,供线程等待使用。
2.3 条件变量的接口
条件变量初始化(POSIX的pthread_cond_t需要初始化后,内核才会为其分配资源(等待队列、同步状态等)。):
//pthread_cond_t条件变量类型,和互斥量相似,也可以通过全局或者静态直接定义/* pthread_cond_t cond = PTHREAD_COND_INITIALIZER;*/
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
参数:
cond:要初始化的条件变量
attr:NULL
销毁(显式销毁会通知操作系统或库释放这些资源。若未销毁,可能导致内存泄漏或内核对象泄漏):
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);
使用案例:
#include <stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<pthread.h>
pthread_cond_t cond;
pthread_mutex_t mutex;
void *r1(void *arg)
{
while(1)
{
pthread_cond_wait(&cond,&mutex);
printf("hello world\n");
}
}
void *r2(void *arg)
{
while(1)
{
pthread_cond_signal(&cond);
sleep(1);
}
}
int main()
{
pthread_t t1,t2;
pthread_cond_init(&cond,NULL);
pthread_mutex_init(&mutex,NULL);
pthread_create(&t1,NULL,r1,NULL);
pthread_create(&t2,NULL,r2,NULL);
pthread_join(t1,NULL);
pthread_join(t2,NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
}
每隔一秒打印hello world,首先创建线程t1,执行函数r1,由于条件变量cond,这个线程会在mutex锁上等待并不会打印hello world,线程t2创建并执行r2,唤醒条件变量队列中等待的线程t1,因此r1可以继续往下执行函数打印,然后继续循环以上过程
2.4 生产者与消费者模型
基本理解上面含有三种要素:
- 生产者S(多线程or单线程)
- 消费者S(多线程or单线程)
- 一个交易场所(容器,就是一块“内存”空间),用于提高整体生产消费的速率
理论化理解:
三种关系:
- 生产者和生产者之间:竞争关系,互斥关系
- 消费者和消费者之间:互斥关系
- 生产者和消费者之间:互斥和同步
两种角色 :
- 生产者(由线程承担 )
- 消费者(由线程承担 )
一种交易场所:
- 由特定结构组成的一种“内存空间”
2.4.1 生产者消费者模型的优势
⽣产者消费者模式就是通过⼀个容器来解决⽣产者和消费者的强耦合问题。⽣产者和消费者彼此之间不直接通讯,⽽通过阻塞队列来进⾏通讯,所以⽣产者⽣产完数据之后不⽤等待消费者处理,直接扔给阻塞队列,消费者不找⽣产者要数据,⽽是直接从阻塞队列⾥取,阻塞队列就相当于⼀个缓冲区,平衡了⽣产者和消费者的处理能⼒。这个阻塞队列就是⽤来给⽣产者和消费者解耦的
- 解耦
- ⽀持并发
- 支持忙闲不均
2.4.2 基于BlockingQueue的⽣产者消费者模型
在多线程编程中阻塞队列(Blocking_Queue)是⼀种常⽤于实现⽣产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放⼊了元素;当队列满时,往队列⾥存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
+-------------------+ +----------------------+
| Producer Thread | | Consumer Thread |
| - 生产数据 Data1,2| | - 消费数据 |
+-------------------+ +----------------------+
| ^
v |
+-----------------------------------------------+
| BlockingQueue (容量: N) |
| +---------+ +---------+ +---------+ |
| | Data1 | | Data2 | | ... | <--- 生产者写入 |
| +---------+ +---------+ +---------+ |
| 互斥锁 (Mutex) 保护队列操作 |
| 条件变量: |
| - Wait: 队列满时阻塞生产者 (Not Full) |
| - Notify: 队列非空时唤醒消费者 (Not Empty) |
+-----------------------------------------------+
| ^
v |
+-------------------+ +----------------------+
| 若队列满,生产者等待 | | 若队列空,消费者等待 |
+-------------------+ +----------------------+
2.4.3 C++ queue模拟阻塞队列的⽣产消费模型
- 单⽣产者,单消费者,来进行模拟
• 刚开始写,采⽤原始接⼝
•先写单⽣产,单消费。然后改成多⽣产,多消费(这⾥代码其实不变)。
//BlockQueue.hpp
#pragma once
#include<iostream>
#include<string>
#include<pthread.h>
#include<unistd.h>
const int defaultcount=5;
template<typename T>
class BlockQueue
{
private:
bool isFull()
{
return _q.size()>=_cout;
}
bool isEmpty()
{
return _q.empty();
}
public:
BlockQueue(int count=defaultcount):
_cout(count)
,csleep_num(0)
,psleep_num(0)
{
pthread_mutex_init(&_mutex,nullptr);
pthread_cond_init(&_full_cond,nullptr);
pthread_cond_init(&_empty_cond,nullptr);
};
void Equeue(const T& in)
{
pthread_mutex_lock(&_mutex);
while(isFull())/*pthread_cond_wait是函数吗?有没有可能失败?pthread_cond_wait立即返回了
pthread_cond_wait可能会因为,条件其实不满足,pthread_cond_wait 伪唤醒
使用while增加代码健壮性*/
{
//pthread_cond_wait调用成功,挂起当前线程,要先自动释放锁
//被唤醒后,默认在临界区被唤醒,成功返回则需要重新申请锁
psleep_num++;
pthread_cond_wait(&_full_cond,&_mutex);
psleep_num--;
}
_q.push(in);
if(csleep_num>0)
{
pthread_cond_signal(&_empty_cond);
std::cout<<"唤醒消费者..."<<std::endl;
}
pthread_mutex_unlock(&_mutex);
}
T pop()
{
pthread_mutex_lock(&_mutex);
while(isEmpty())
{
csleep_num++;
pthread_cond_wait(&_empty_cond,&_mutex);
csleep_num--;
}
T data=_q.front();
_q.pop();
if(psleep_num>0)
{
pthread_cond_signal(&_full_cond);
std::cout<<"唤醒生产者..."<<std::endl;
}
pthread_mutex_unlock(&_mutex);
return data;
}
~BlockQueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_full_cond);
pthread_cond_destroy(&_empty_cond);
};
private:
std::queue<T> _q;//临界资源
int _cout;//容器大小
pthread_mutex_t _mutex;
pthread_cond_t _full_cond;
pthread_cond_t _empty_cond;
int csleep_num;// 消费者休眠的个数
int psleep_num;// 生产者休眠的个数
};
//main.cc
#include <string>
#include <pthread.h>
#include<unistd.h>
#include "mutex.hpp"
#include "BlockQueue.hpp"
using namespace MutexModule;
void *consumer(void *args)
{
BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
while (true)
{
sleep(1);
int data=bq->pop();
std::cout << "消费了一个数据" << data << std::endl;
}
}
void *producter(void *args)
{
int data=1;
BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
while (true)
{
//sleep(1);
std::cout << "生产了一个数据" << data << std::endl;
bq->Equeue(data);
data++;
}
}
int main()
{
BlockQueue<int> *bq = new BlockQueue<int>();
// 构建生产者消费者
pthread_t c, p;
pthread_create(&c, nullptr, consumer, bq);
pthread_create(&p, nullptr, producter, bq);
pthread_join(c, nullptr);
pthread_join(p, nullptr);
return 0;
}
//Makefile
mutex:main.cpp
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -f mutex
以上是生产快而消费慢,代码运行结果:
三、POSIX信号量
POSIX信号量本质是一个计数器,是对特定资源的预定机制 |
初始化信号量:
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
/*参数:
pshared:0表⽰线程间共享,⾮零表⽰进程间共享
value:信号量初始值
销毁信号量
int sem_destroy(sem_t *sem);
等待信号量
//功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()
发布信号量
//功能:发布信号量,表⽰资源使⽤完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()
多线程使用资源的场景:
- 将目标资源整体使用【_mutex+二元信号量】
- 将目标资源分成不同的“块”,分批使用【信号量】
- 由于每个线程都需要先看到sem,因此信号量也是临界资源
-P:-- 原子的
-V:++原子的
3.1 基于循环队列的消费者生产者模型
//环形队列结构图
环形队列(容量=5)
┌───┬───┬───┬───┬───┐
│ │ │ │ │ │
├───┼───┼───┼───┼───┤
│ C │ D │ │ A │ B │
└───┴───┴───┴───┴───┘
↑ ↑
Consumer Producer
Front Rear
/*队列状态:当前有4个元素(A、B、C、D),1个空位。
指针规则:
Producer (Rear):指向下一个可插入位置。
Consumer (Front):指向下一个待消费位置。
环形特性:当指针到达末尾时,回到起始位置(如索引4的下一位是0)。*/
//同步机制示意图
信号量控制流
┌───────────────┐
│ empty=1 │ ← Producer等待可用空间
└───────────────┘
↓
Producer写入数据 → Rear++
┌───────────────┐
│ full=4 │ ← Consumer等待可用数据
└───────────────┘
↓
Consumer读取数据 → Front++
基于以上结构,我们可以使用数组对其进行模拟,并总结出以下四个约定
- 约定1:空,生产者先运行
- 约定2:满,消费者先运行
- 约定3:生产者不能生产超过消费者一个圈以上的资源
- 约定4:消费者不能超过生产者
//生产者操作流程
5. 等待empty > 0(有空位)
6. 获取互斥锁(Mutex)
7. 写入数据到Rear位置
8. Rear = (Rear + 1) % 容量
9. 释放互斥锁(Mutex)
10. 增加full信号量(通知消费者)
图示:
原队列:Rear在索引4(指向空位)
┌───┬───┬───┬───┬───┐
│ C │ D │ │ A │ B │ ← 写入新数据E
└───┴───┴───┴───┴───┘
↑
Rear
写入后:
Rear移动到索引0,empty减1,full加1
┌───┬───┬───┬───┬───┐
│ C │ D │ │ A │ B │
└───┴───┴───┴───┴───┘
↑
Rear(新位置)
//消费者操作流程
1. 等待full > 0(有数据)
2. 获取互斥锁(Mutex)
3. 读取Front位置的数据
4. Front = (Front + 1) % 容量
5. 释放互斥锁(Mutex)
6. 增加empty信号量(通知生产者)
图示:
原队列:Front在索引2(指向数据C)
┌───┬───┬───┬───┬───┐
│ C │ D │ │ A │ B │ ← 消费数据C
└───┴───┴───┴───┴───┘
↑
Front
消费后:
Front移动到索引3,full减1,empty加1
┌───┬───┬───┬───┬───┐
│ │ D │ │ A │ B │
└───┴───┴───┴───┴───┘
↑
Front(新位置)
//sem封装
#include <iostream>
#include <semaphore.h>
#include <pthread.h>
namespace SemModule
{
const int defaultvalue = 1;
class Sem
{
public:
Sem(unsigned int sem_value = defaultvalue)
{
sem_init(&_sem, 0, sem_value);
}
void P()
{
int n = sem_wait(&_sem); // 原子的
(void)n;
}
void V()
{
int n = sem_post(&_sem); // 原子的
}
~Sem()
{
sem_destroy(&_sem);
}
private:
sem_t _sem;
};
}
//锁的封装
#pragma once
#include <iostream>
#include <pthread.h>
namespace MutexModule
{
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&_mutex, nullptr);
}
void Lock()
{
int n = pthread_mutex_lock(&_mutex);
(void)n;
}
void Unlock()
{
int n = pthread_mutex_unlock(&_mutex);
(void)n;
}
~Mutex()
{
pthread_mutex_destroy(&_mutex);
}
pthread_mutex_t *Get()
{
return &_mutex;
}
private:
pthread_mutex_t _mutex;
};
class LockGuard
{
public:
LockGuard(Mutex &mutex):_mutex(mutex)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex &_mutex;
};
}
//基于循环队列的生产者消费者模型
#pragma once
#include <iostream>
#include <vector>
#include "Sem.hpp"
#include "Mutex.hpp"
static const int gcap = 5; // for debug
using namespace SemModule;
using namespace MutexModule;
template <typename T>
class RingQueue
{
public:
RingQueue(int cap = gcap)
: _cap(cap),
_rq(cap),
_blank_sem(cap),
_p_step(0),
_data_sem(0),
_c_step(0)
{
}
void Equeue(const T &in)
{
// 生产者
// 1. 申请信号量,空位置信号量--,信号量为0就会阻塞,直到消费者执行_blank_sem.V()
_blank_sem.P();
{
LockGuard lockguard(_pmutex);
// 2. 生产
_rq[_p_step] = in;
// 3. 更新下标
++_p_step;
// 4. 维持环形特性
_p_step %= _cap;
}
_data_sem.V();
}
void Pop(T *out)
{
// 消费者
// 1. 申请信号量,数据信号量
_data_sem.P();
{
//在信号量之前申请锁的话只能有一个线程执行,其余线程要等到锁释放,
///而在申请信号量之后申请锁,信号量的申请也是原子性的,一个线程申请完之后继续申请锁,其余线程也能同时申请信号量,因此效率会高一点
LockGuard lockguard(_cmutex);
// 2. 消费
*out = _rq[_c_step];
// 3. 更新下标
++_c_step;
// 4. 维持环形特性
_c_step %= _cap;
}
_blank_sem.V();
}
private:
std::vector<T> _rq;
int _cap;
// 生产者
Sem _blank_sem; // 空位置
int _p_step;
// 消费者
Sem _data_sem; // 数据
int _c_step;
// 维护多生产,多消费, 2把锁
Mutex _cmutex;
Mutex _pmutex;
};