
一. 同步与互斥
同步:
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步,这里的安全其实就算是这是临界资源。
1.1 饥饿问题
void* work(void*arg){
//假设某种场景下我们的所有线程都是在循环的执行某个任务
// 所有会有一直申请锁的场景
while(1){
lock(_mutex);
//...临界资源
unlock(_mutex);
}
}
在之前的文章提到过,mutex锁的目的是一种信号量机制,值为1获取到这个信号量的线程才允许访问该临界资源,但是多线程的场景当中,在cpu调度多执行流,有一个很有趣的现象就是饥饿问题。
因为cpu在调度的时候总喜欢调度刚刚用过的执行流效率更高嘛,所以如果我们只是普通的使用pthread_mutex没有设置属性,会出现一个线程一直拿着该信号量,其他线程永远都别想访问的问题。---这就是饥饿问题,其他线程因为在等待锁到这一直在申请锁,但是一直申请不到
1.2 公平锁--但不能保证绝对同步

这张我画的经典老图,再拿出来溜溜,所谓的公平锁就是我们在创建锁的开始就给锁设置一下属性表示这是一个公平锁,相关实现可以参考之前的博客: Linux: posix标准:线程互斥&& 互斥量的原理&&抢票问题-CSDN博客
但是它依旧不能保证1个事情就是绝对的同步。
公平锁的意思是: 保证之后如果cpu去等待队列中获取线程,它会FIFO的方式获取每一个线程,保证他们被调度,但是很遗憾的是什么,
一种极端场景假如我们的线程的时间片够长,每次线程还没还回去就立马又申请锁,好 欧克它的时间片到了,cpu去调度互斥锁下的等待队列的线程,但是没有锁啊,全部又呱呱的回到互斥锁的等待队列,我们拿着锁的线程呢,很快又被切换回来,继续执行,直到有一次,它真正的直接被放入锁的等待队列了,这个时候cpu才会逐个的去遍历我们信号量的等待队列当中获取新的线程。
所以说公平纵然好啊,但是关键就在于每次一个线程执行完之后cpu没有强制把它丢入等待队列,也就是没有人强制来阻塞它,这时候有人就说啦,那我就yiled它,主动放弃。
是的这是一种解决方式, 用 yield+ 公平锁,能尽量保持一种互斥和同步但是不够优雅哦!所以我们有 条件变量和信号量的机制来解决同步问题!!!
二. 同步-----linux的条件变量
角色策略的同步
Linux 线程同步
对于同步来说,仅仅是解决串行访问资源未免太low了,为什么呢,本质上同步是希望我们能够更为精准的控制线程,谁先执行然后是谁。
所以诞生出了如生产-消费模型的角色同步,控制思路就是,对于一个共享资源的访问,由生产者先访问共享资源,然后生成者线程来主动唤醒消费者线程,这个过程中始终只有一个执行流去访问了我们的共享资源,这是必须的但是我们很好的保证了角色之间的同步!!
注意我们始终这里保证的还是角色的同步,相同角色之间更多的是互斥而不是同步。
接下来提一下 信号量的同步策略和posix标准的条件变量 策略的同步
2.1 条件变量接口
cpp
// 条件变量静态初始化(默认属性)
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
// 互斥锁静态初始化(默认属性,配合条件变量使用)
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
动态初始化
#include <pthread.h>
#include <errno.h>
// 初始化条件变量(attr为NULL表示默认属性)
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
// 等待条件变量(核心!会自动释放mutex,被唤醒后重新获取mutex)
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
// 限时等待条件变量(超时返回ETIMEDOUT)
int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime);
// 唤醒一个等待该条件变量的线程
int pthread_cond_signal(pthread_cond_t *cond);
// 唤醒所有等待该条件变量的线程
int pthread_cond_broadcast(pthread_cond_t *cond);
// 销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
2.2 使用--- 生产消费模型--- 条件变量模板
cpp
void* produce_work(void*){
// 1. 加锁(保护共享条件和资源)
pthread_mutex_lock(&mutex);
// 2. 循环检查条件(必须用 while,避免虚假唤醒)
while (条件不满足) {
// 3. 等待条件变量,自动释放锁;被唤醒后自动重获锁
pthread_cond_wait(&cond, &mutex);
}
// 4. 条件满足,操作共享资源
// ... 业务逻辑 ...
// 5. 解锁
pthread_mutex_unlock(&mutex);
}
2.3 基于条件变量的单生产-单消费模型
cpp
#include <pthread.h>
#include <unistd.h>
#include <cstdlib>
#include <iostream>
#include <string>
// 单生产单消费模式
// 存放数据的缓冲区----共享资源
struct Buffer
{
// 假设只有一个数字器 将来生产者生产数字 消费者来消费数字
int _number;
bool isexit; // 判断当前数据是存在
};
// 保证互斥的锁
// 控制消费者角色的条件和控制生产者角色的线程
static pthread_mutex_t g_mtx;
static pthread_cond_t g_productor_cond = PTHREAD_COND_INITIALIZER;
static pthread_cond_t g_consumer_cond = PTHREAD_COND_INITIALIZER;
Buffer buffer = {-1, false};
// 生产者线程
void *p_productor(void *arg)
{
// 生产者 只有一个 假设我们这里是生产者
// 只生产五次数据
int count = 5;
while (count--)
{
// 生产者要进入临界区生产数据 先进行互斥
pthread_mutex_lock(&g_mtx);
// 开始生产数据前通过条件变量保证角色之间的同步
// 有意思的是这里要用循环表示避免意外被唤醒 但是缺没有数据 别用if!
while (buffer.isexit)
{
// 条件变量可以帮我们原子的释放锁和把当前线程放入自己的阻塞队列当中
pthread_cond_wait(&g_productor_cond, &g_mtx);
}
// 到这共享资源对生产者处于可以使用状态了 数据不存在
// 这里生产数据我们可以模拟网络发来的数据 就用sleep模拟是生产数据过程
usleep(100000); // 模拟获取数据的时间 但是我们这里具体是用随机数
buffer._number = rand() % 10000;
buffer.isexit = true;
std::cout << "生产者生产了一个数据: " << buffer._number << std::endl;
// 生产完数据先唤醒消费者线程 还是 先释放锁?
// 从效率来看先去释放锁 因为减少线程的无效阻塞 但是怎么选择都可以安全的
pthread_mutex_unlock(&g_mtx);
pthread_cond_signal(&g_consumer_cond); // 有单一唤醒 和全部唤醒
// pthread_cond_broadcast(&g_consumer_cond);
}
return nullptr; // 线程退出我们暂时不关心 返回一个null即可
}
// 消费者线程
void *p_consumer(void *arg)
{
// 消费者线程 就一直消费数据 但是一直不退出
while (1)
{
// 进来访问临界区前先申请锁
pthread_mutex_lock(&g_mtx);
// 控制角色之间同步
while (!buffer.isexit)
{
// 没数据就老老实实的释放锁 和等待
pthread_cond_wait(&g_consumer_cond, &g_mtx);
}
// 到这里就有资源了 就开始消费吧
// 模拟消费的时间
usleep(100000); // 模拟获取数据的时间
// 这里消费的行为就是打印了 一下数据
std::cout << "消费者消费一个数据: " << buffer._number << std::endl;
buffer.isexit = false;
// 消费完之后 释放锁 和唤醒生产者线程来生产
pthread_mutex_unlock(&g_mtx);
pthread_cond_broadcast(&g_productor_cond); // 反之也就一个 我们就为了熟悉接口吧
}
}
int main()
{
// 创建生产者 消费者线程 以及回收
pthread_t p_tid;
pthread_t c_tid;
pthread_create(&p_tid, nullptr, p_productor, nullptr);
pthread_create(&c_tid, nullptr, p_consumer, nullptr);
// 回收
pthread_join(p_tid, nullptr);
pthread_join(c_tid, nullptr);
srand(time(0));
return 0;
}

2.4 条件变量保证同步原理--图解

三. 封装的环形缓冲区类 ---解决生产消费模型
3.1 环形缓冲区类设计思路 和类图设计


cpp
#pragma once
#include<iostream>
#include<cstdlib>
#include<pthread.h>
#include<unistd.h>
const int BUFFERSIZE = 100;// 缓冲区大小
template<class Data>
class CircularBuffer{
// 删拷贝构造 赋值重载 初始化构造
public:
CircularBuffer(): _front(0),_rear(0),_count(0){
pthread_mutex_init(&_mtx,nullptr);
pthread_cond_init(&_c_cond,nulltr);
pthread_cond_init(&_p_cond, nulltr);
}
CircularBuffer(const &CircularBuffer buffer) = delete;
CircularBuffer& oprator=(const &CircularBuffer buffer) = delete;
// 判空判满
bool is_empty(){
return _count == 0;
}
bool is_full(){
return _count == BUFFERSIZE;
}
public:
// 操作 push 和 pop
bool push(const Data&d){
// 生产者 push 那就得加锁 和控制同步
pthread_mutex_lock(&_mtx);
// 控制同步
while(is_full()){
// 满的就去等等
pthread_cond_wait(&_p_cond,&_mtx);
}
// 到这可以生产数据了 使用rear指针
_buffer[_rear] = d;
// 移动就是模拟环形缓冲区的关键
_rear = (_rear + 1) % BUFFERSIZE;
// 先释放锁在唤醒
pthread_mutex_unlock(&_mtx);
pthread_cond_signal(&_c_cond);
return true;
}
// 消费者每次都是从队头读取数据 我们用输出型参数 为了返回值对齐
bool pop(Data*d){
// 现在要进入临界区加锁
pthread_mutex_lock(&_mtx);
// 同步 不是空才能取数据
while(is_empty()){
pthread_cond_wait(&_c_cond,&_mtx);
}
// 到这才可以访问数据
*d = _buffer[_fornt];
// 移动指针
_front = (_front + 1) % BUFFERSIZE;//模拟环形缓冲
// 最后同步操作
pthread_mutex_unlokc(&_mtx);
pthread_cond_signal(&_p_cond);
}
private:
// 缓冲区 队头 队尾 互斥锁 生产者/消费者 角色的条件变量
Data _buffer[BUFFERSIZE]; // 用原生数组 自己来维护效率会高些
int _front;
int _rear;
int _count;
pthread_mutex_t _mtx;
pthread_cond_t _p_cond;
pthread_cond_t _c_cond;
};
3.2 再谈基于生产消费的缓冲区 多线程下的操作
我们已经把缓冲区封装成类,而且内部带有条件变量而且是分角色的, 生产者和消费者
以及加锁了,所以 是 单/多 --单/多 生产消费模型来说本质上还是两种角色之间的同步而已, 所以有了这个缓冲区,我们在将来的线程只需要调用缓冲区类的接口push 就是生产者 而 pop的线程就是消费者。
而且他们会自动归属到对应的条件变量下面等待和释放,所以对于外部是 什么样的单-多生产消费模型,我们在这里就不用在乎,只需要调用相关缓冲区的接口就欧克了
下面举例几个简单的例子来测试我们的缓冲区类:
测试: 多生产单消费 --- 单消费
首先是写一个生产者线程函数和消费者线程函数,其实,本质上就是要不要加其他控制了,角色已经确定了而且同步过程也交给了缓冲区,我们只需要创建线程 和实习线程函数即可。
还有一点就是如下我们的测试思路是:
创建10000条消息注意这里是严格控制了,所以肯定要用一个严格计数器:
当然对应生产者线程之间他们是互斥的访问这个计数器 你可以 互斥锁+计数器
方式
cpp
// 消费者线程就一直消费即可不退出
while (true)
{
// 0.模拟读取数据的耗时
// usleep(100);
int data = 0;
if (g_buffer.pop(&data))
{
// 模拟消费数据这里就是打印一下
std::cout << "消费者get a message: " << data << std::endl;
}
}
return nullptr;
}
int main()
{
// 获取到的数据实际上是随机数哈 这是为了模拟网络获取
srand(time(0));
// 现在开始 创建单消费 多生产线程 来看结果吧
// 创建一批生产者 假设是20个生产者
std::vector<pthread_t> p_tids;
for (int i = 0; i < 20; i++)
{
pthread_t p_tid;
pthread_create(&p_tid, nullptr, p_task, nullptr);
p_tids.push_back(p_tid);
}
// 消费者线程
pthread_t c_tid;
pthread_create(&c_tid, nullptr, c_task, nullptr);
// 等生产者生产完了指定数目 以及当前缓冲区空了就退出吧
while (true)
{
if (cnt <= 0 && g_buffer.is_empty())
{
std::cout << "生产完毕 消费者也消费完毕 即将消费者" << std::endl;
pthread_cancel(c_tid);
break;
}
}
// 回收线程
for (int i = 0; i < 20; i++)
{
pthread_join(p_tids[i], nullptr);
}
pthread_join(c_tid, nullptr);
return 0;
}
output:

原子: atomic计数器的使用
也可以用原子计数器 ,但是用原子计数器要注意使用方式 因为它只能保证: 当前操作是原子的 ; 比如你在现在判断>0 进入 后面又-- 可能会到这 多个线程都是1 进入了 然后-- 所以你可以先-- 在判断 然后回滚的
单原子+ 回滚策略
cpp
//全局的原子计数器 思路是 从大的数开始如 1000 开始--
atomic<int> cnt(1000)
// 错误demo:
{
if(cnt<0){...}
cnt--;// 到这里就可能多个线程都是1 因为cnt只是单个操作是原子的
}
// 思路demo:
{
// 先直接--
cnt--;// 到这里就可能多个线程都是1 因为cnt只是单个操作是原子的
// 再取出来
int load = cnt;
if(load<0){
cnt++;// 只对自己的--进行回滚就不会有问题了
}
}
// 加法计数
// 初始化为0
atomic<int> cnt(0)
//用不超过 1000
{
if(cnt<1000){
cnt++;// 这里也不能保证 如果进来的都是999呢
}
}
// 也需要回滚策略 先进行++ 然后取出来 再判断 以及回滚
// 原子计数在一种场景下很好用就是 你只是仅仅拿来计数 但是并不想限制计数 ?
//
由于编译限制 本文就暂时把 线程同步基于条件变量谈完啦 下一章节是基于信号量啦 ! 期待咯
