文章目录
- 一、互斥的概念
- 二、线程互斥
-
- [1. 锁的相关接口使用](#1. 锁的相关接口使用)
- [2. 锁的原子性原理](#2. 锁的原子性原理)
- [3. 锁的封装](#3. 锁的封装)
- 三、线程同步
-
- [1. 条件变量的相关接口使用](#1. 条件变量的相关接口使用)
- [2. 条件变量的封装](#2. 条件变量的封装)
- 四、POSIX信号量
-
- [1. POSIX信号量相关接口使用](#1. POSIX信号量相关接口使用)
- [2. 信号量的封装](#2. 信号量的封装)
- 五、生产者消费者(cp)模型
-
- [1. 生产者消费者模式介绍](#1. 生产者消费者模式介绍)
- [2. 基于阻塞队列的cp模型](#2. 基于阻塞队列的cp模型)
- [3. 基于环形队列的cp模型](#3. 基于环形队列的cp模型)
一、互斥的概念
我们首先要明确一些进程线程间的概念:
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部访问临界资源的代码叫做临界区
- 互斥:任何时刻,必须要保证有且仅有一个执行流进入临界区,访问临界资源,称之为互斥。互斥通常对临界资源起保护作用。
- 原子性:不会被任何调度机制打断的操作。具有原子性的操作,只有两态,要么完成,要么未完成。
如果没有互斥,对临界资源的访问不加以保护,就会有各种的数据问题,有线程安全问题。
比如,全局变量是一种临界资源,全局变量的++和--运算并不是原子的。如果某种操作只有一行汇编指令,也认为他是原子的。
一个全局变量count,执行
count++,在 CPU 层面分为三步:读取 count 到寄存器、寄存器加 1、写回内存,这三步中间可被中断或线程切换打断,因此不是原子操作。以两个线程对 count=1 各执行一次
count++为例,预期结果为3,但可能发生如下错误:线程A完成读取(得到 1)后被切换,寄存器上下文数据被保存,内存中 count 仍为 1;线程 B完整执行三步,将 count 更新为 2;随后线程A恢复,基于寄存器中的旧值 1 继续执行加 1 和写回,将 count 覆写为 2,最终两次 ++ 只生效一次,丢失了一次更新。
二、线程互斥
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程的独立栈空间内,这种情况下别的线程无法访问到这种变量。但有时候,很多变量需要在线程之间共享,完成线程之间的交互,如上面的例子。
要解决如上问题,需要做到三点:
- 代码必须要有互斥行为:当一个线程代码进入临界区执行时,不允许其他线程进入该临界区。
- 同时只允许一个线程进入临界区。
- 如果线程不在临界区中执行,他不能阻止其他线程进入临界区。
要做到这三点,本质需要一把锁,Linux中称之为互斥量(mutex)!
想象共享资源是一个房间,只有一把锁和钥匙。此时来了一大堆线程进行竞争,不管怎样最终只会有一个线程得到钥匙并能开锁进入,其他的进程只能继续等待。等这个线程用完共享资源,开锁,归还钥匙,所有进程再竞争钥匙。如此,便能满足上述三点。
在代码层面,关键的就是lock(加锁)和unlock(解锁)两步操作!

为了保证我们的锁能完成线程互斥,还必须明确:
- 加锁会导致效率降低,加锁的粒度必须足够细(影响的代码越少越好)
- 所有线程能竞争锁,说明锁本身也是共享资源!但是锁的lock和unlock操作被设计称为了原子的,就不需要被额外保护了。
- 访问临界资源,所有线程都必须遵守加锁解锁规则,不能有例外!
- 没有竞争到锁的线程,都必须在Lock操作上阻塞等待!
1. 锁的相关接口使用
pthread线程库中为我们提供了锁类型 ( pthread_mutex_t )------互斥量,及其相关接口。
-
初始化:
当锁定义成静态、全局时,使用
PTHREAD_MUTEX_INITIALIZER标志为其初始化;当锁定义在局部时,使用pthread_mutex_init函数为其初始化:
第一个参数的要初始化的锁;第二个参数是相关权限,我们不必关心,填为NULL即可。 -
销毁锁:
使用
PTHREAD_MUTEX_INITIALIZER初始化的锁不需要手动销毁。除此之外,使用pthread_mutex_destroy函数销毁锁:
注意不要销毁一个已经加锁的互斥量;已经销毁的锁确保后面不会再加锁。
-
加锁:
使用
pthread_mutex_lock函数为一个互斥量加锁
如果调用lock时,该互斥量未锁,则该函数会将互斥量锁定,并且返回0表示成功;
如果调用lock时,该互斥量已被其他线程锁定,则lock函数会阻塞,执行流被挂起,等待互斥量解锁。
-
解锁:
使用
pthread_mutex_unlock函数为一个互斥量解锁
演示:
cpp
#include <iostream>
#include <pthread.h>
int count = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void* routine(void* args)
{
//加锁解锁保护临界资源
pthread_mutex_lock(&mutex);
//临界区
count++;
pthread_mutex_unlock(&mutex);
return nullptr;
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, nullptr, routine, nullptr);
pthread_create(&t2, nullptr, routine, nullptr);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
std::cout << count << std::endl;
return 0;
}

2. 锁的原子性原理
上面说到,i++的操作并不是原子的,因为它翻译为汇编指令并不是一步操作。
为了实现互斥锁操作,大多数体系结构提供了swap或exchange指令,作用是把寄存器和内存单元的数据相交换,由于这是一条指令,保证了开锁解锁的原子性。
mutex看做内存中的一个变量,一开始是1。锁,就是一开始的1。在整个lock和unlock操作中,没有拷贝,自始至终只有一个1,谁有这个1,谁就有锁!

竞争锁,本质就是竞争执行xchgb指令,而因为这是一条汇编指令,因此就具有原子性!
3. 锁的封装
这份我们自己封装的锁,后面都会继续用到。
cpp
// Mutex.hpp
#pragma once
#include<pthread.h>
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&_lock, nullptr);
}
~Mutex()
{
pthread_mutex_destroy(&_lock);
}
void Lock()
{
pthread_mutex_lock(&_lock);
}
void Unlock()
{
pthread_mutex_unlock(&_lock);
}
pthread_mutex_t* Ptr()
{
return &_lock;
}
private:
pthread_mutex_t _lock;
};
// RAII风格用法
class LockGuard
{
public:
LockGuard(Mutex& lock)
:_lockref(lock)
{
_lockref.Lock();
}
~LockGuard()
{
_lockref.Unlock();
}
private:
Mutex& _lockref;
};
/*
后续使用我们自己封装的锁时,可以写成:
{
LockGuard lock(mutex);
// 临界区
// ...
}
{}划定作用域, 利用LockGuard自动调用构造析构完成加锁和解锁
*/
测试:
cpp
#include "Mutex.hpp"
#include <iostream>
#include <pthread.h>
int count = 0;
Mutex mutex;
void* routine(void* args)
{
// 加锁解锁保护临界资源
{
// 临界区
LockGuard lg(mutex);
count++;
}
return nullptr;
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, nullptr, routine, nullptr);
pthread_create(&t2, nullptr, routine, nullptr);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
std::cout << count << std::endl;
return 0;
}

三、线程同步
回到一开始的例子,线程互斥是一大堆线程竞争唯一的一把房间钥匙。假如一个线程拿到钥匙,访问完毕,解锁后,立刻又抢到钥匙继续访问,别的线程能访问到的机会大大减少了,这也是不合理的。我们最好能让当前用完临界资源的线程,不要立刻再次访问,给别的线程机会。
所以,线程同步是指,在临界资源安全的前提下,让访问临界资源具有一定的顺序性。线程同步的完成需要条件变量(condition)。
1. 条件变量的相关接口使用
pthread线程库中为我们提供了条件变量类型 (pthread_cond_t),及其相关接口。
-
初始化:
当条件变量定义成静态、全局时,使用
PTHREAD_COND_INITIALIZER标志为其初始化;当定义在局部时,使用pthread_cond_init函数为其初始化:
第一个参数的要初始化的条件变量;第二个参数是相关权限,我们不必关心,填为NULL即可。 -
销毁:
使用
PTHREAD_COND_INITIALIZER的条件变量不需要手动销毁,除此之外使用pthread_cond_destroy函数
-
等待条件满足:
使用
pthread_cond_wait函数
第一个参数表示在哪个条件变量下等待,第二个参数为锁。
调用这个函数之前当前线程必须有锁。等待条件变量时,当前线程会自动解锁,并到这个条件变量下阻塞等待,直到被唤醒,又会自动参与竞争锁,拿到锁pthread_cond_wait函数才会返回。 -
唤醒条件变量下等待的线程:

pthread_cond_signal函数会唤醒这个条件变量下的一个线程,pthread_cond_broadcast函数会唤醒这个条件变量下的所有线程。
2. 条件变量的封装
cpp
// Cond.hpp
#pragma once
#include<pthread.h>
#include"Mutex.hpp"
class Cond
{
public:
Cond()
{
pthread_cond_init(&_cond, nullptr);
}
~Cond()
{
pthread_cond_destroy(&_cond);
}
void Wait(Mutex& mutex)
{
pthread_cond_wait(&_cond, mutex.Ptr());
}
void Signal()
{
pthread_cond_signal(&_cond);
}
void BroadCast()
{
pthread_cond_broadcast(&_cond);
}
private:
pthread_cond_t _cond;
};
四、POSIX信号量
POSIX信号量和我们之前提到的SystemV信号量作用相同,本质是记录共享资源数量的计数器,可以用于同步操作。
互斥的本质是"独占",独占的本质是我们认为临界资源只有一份,同一时间只能有一个人占有它。所以,我们也可以把锁理解为初始值为1的信号量(二元信号量)。加锁,等同于申请信号量。申请信号量,本质是对资源的预定机制!
申请资源,计数器- -,称为P操作;
释放资源,计数器++,称为V操作。
信号量本身也是共享资源,因此P操作和V操作一定也是原子的!
1. POSIX信号量相关接口使用
POSIX信号量并不属于pthread库,由POSIX标准定义,sem_t 类型与信号量相关接口定义在<semaphore.h>头文件下。
-
初始化信号量:
第一个参数是信号量id;第二个参数pshared,为0表示信号量在线程间共享,非0表示在进程间共享;
第三个参数为信号量的初始值。
-
销毁信号量:

-
申请信号量(P操作):

这个操作会将信号量的值 -1,如果信号量的值为0,则阻塞等待直到信号量变为正数。
-
释放信号量(V操作):
这个操作会将信号量的值 +1
2. 信号量的封装
cpp
// Sem.hpp
#pragma once
#include <semaphore.h>
class Sem
{
public:
Sem(int init_val) // 传递信号量初始值
{
if (init_val >= 0)
{
sem_init(&_sem, 0, init_val);
}
}
~Sem()
{
sem_destroy(&_sem);
}
void P()
{
sem_wait(&_sem);
}
void V()
{
sem_post(&_sem);
}
private:
sem_t _sem;
};
五、生产者消费者(cp)模型
1. 生产者消费者模式介绍
生产者消费者模式(Producer-Consumer)就是通过一个容器来解决生产者和消费者的强耦合关系。生产者和消费者彼此之间不直接通信,而是通过队列来进行通信。生产者生产完数据不必等待消费者处理,直接扔进队列中;消费者不用找生产者要数据,而是直接从队列中取。队列就相当于一个缓冲区,用来给生产者和消费者解耦。

显然,这种模型有两种角色:生产者和消费者。也就产生了代码中必须处理的三种关系:
- 生产者之间有互斥关系,假如队列只剩一个位置,只有一个生产者能放入数据;
- 消费者之间有互斥关系,假如队列只有一份数据,只有一个消费者能获取;
- 生产者与消费者之间,有同步关系,可能有互斥关系。队列满时,生产者必须等待消费者取走数据,队列空时,消费者必须等待生产者放入数据。
总结一下,生产者消费者模式有"321"原则:
- 3种关系(生产者之间,消费者之间,生产者消费者之间)
- 2种角色(生产者、消费者)
- 1个交易场所(内存空间的特定数据结构)
生产者消费者模式是多线程协同的一种模式,能提高协作效率,本质是一种通信工作。它的优点是:线程之间解耦合、支持并发操作、支持忙闲不均。
2. 基于阻塞队列的cp模型
在多线程编程中阻塞队列(Block Queue)是一种常用于实现生产者消费者模型的数据结构。当阻塞队列为空时,从队列中获取元素的操作将会被阻塞;当队列为满时,往队列中存放元素的操作会被阻塞。以上的操作是基于不同的线程来说的。
我们先使用原生接口实现一遍:
cpp
#pragam once
#include <pthread.h>
#include <queue>
const int default_cap = 5;
template <class T>
class BlockQueue
{
public:
BlockQueue(int cap = default_cap) : _cap(cap)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_c_cond, nullptr);
pthread_cond_init(&_p_cond, nullptr);
}
~BlockQueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_c_cond);
pthread_cond_destroy(&_p_cond);
}
// 放数据,生产者操作
void Enqueue(T in)
{
pthread_mutex_lock(&_mutex);
// 如果队列为满,此时不能生产,在条件变量下等待
// wait函数可能会调用失败或系统原因伪唤醒,这里用while判断
while (_queue.size() == _cap)
{
pthread_cond_wait(&_p_cond, &_mutex);
}
// 到这里队列一定有空位置
_queue.push(in);
// 已经生产了一个数据,可以唤醒消费者了
pthread_cond_signal(&_c_cond);
pthread_mutex_unlock(&_mutex);
}
// 消费数据,消费者操作
void Pop(T* out)
{
pthread_mutex_lock(&_mutex);
// 如果队列为空,此时不能消费,在条件变量下等待
// wait函数可能会调用失败或系统原因伪唤醒,这里用while判断
while (_queue.size() == 0)
{
pthread_cond_wait(&_c_cond, &_mutex);
}
// 到这里队列一定有数据
*out = _queue.front();
_queue.pop();
// 取了一个数据,队列一定有一个空位置,可以唤醒生产者了
pthread_cond_signal(&_p_cond);
pthread_mutex_unlock(&_mutex);
}
private:
std::queue<T> _queue;
int _cap;
pthread_mutex_t _mutex;
pthread_cond_t _c_cond;
pthread_cond_t _p_cond;
};
再使用上面我们自己封装的锁和条件变量实现一次:
cpp
// BlockQueue.hpp
#pragma once
#include "Cond.hpp"
#include "Mutex.hpp"
#include <queue>
const int default_cap = 5;
template <class T> class BlockQueue
{
public:
BlockQueue(int cap = default_cap) : _cap(cap)
{
}
~BlockQueue()
{
}
void Enqueue(T in)
{
// 加锁保护临界区
LockGuard lg(_mutex);
// 如果队列为满,此时不能生产,在条件变量下等待
// wait函数可能会调用失败或系统原因伪唤醒,这里用while判断
while (_queue.size() == _cap)
{
_p_cond.Wait(_mutex);
}
// 到这里队列一定有空位置
_queue.push(in);
// 现在一定至少有一个数据,可以唤醒消费者了
_c_cond.Signal();
}
void Pop(T* out)
{
// 加锁保护临界区
LockGuard lg(_mutex);
// wait函数可能会调用失败或系统原因伪唤醒,这里用while判断
while (_queue.size() == 0)
{
_c_cond.Wait(_mutex);
}
// 到这里队列一定有数据
*out = _queue.front();
_queue.pop();
// 取了一个数据,队列一定有一个空位置,可以唤醒生产者了
_p_cond.Signal();
}
private:
std::queue<T> _queue;
int _cap;
Mutex _mutex;
Cond _c_cond;
Cond _p_cond;
};
项目中包含上一篇文章我自己封装的线程类,如下:
cpp
// Thread.hpp
#ifndef _THREAD_
#define _THREAD_
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <functional>
static int gnumber = 1;
using callback_t = std::function<void()>;
class Thread
{
private:
static void* Thread_Routine(void* args)
{
Thread* self = static_cast<Thread*>(args);
pthread_setname_np(self->_tid, self->_name.c_str());
self->_task(); // 执行任务
return nullptr;
}
public:
Thread(callback_t task) : _task(task), _tid(-1), _joinable(true), _result(nullptr)
{
_name = "NewThread-" + std::to_string(gnumber++);
}
void Start()
{
pthread_create(&_tid, nullptr, Thread_Routine, this);
}
void Join()
{
if (_joinable)
{
pthread_join(_tid, &_result);
std::cout << "线程已回收" << std::endl;
}
else
{
std::cerr << "线程不可被等待" << std::endl;
}
}
void Detach()
{
if(_joinable)
{
pthread_detach(_tid);
_joinable = false;
}
else
{
std::cerr<<"线程已被分离" << std::endl;
}
}
~Thread()
{
}
private:
std::string _name;
pthread_t _tid;
callback_t _task;
void* _result;
bool _joinable;
};
#endif
测试:
cpp
#include "BlockQueue.hpp"
#include "Thread.hpp"
#include <iostream>
#include <unistd.h>
int num = 1;
Mutex lock;
BlockQueue<int> bq;
void ProductorRoutine()
{
while(1)
{
char name[16];
pthread_getname_np(pthread_self(), name, sizeof name);
int in;
{
LockGuard lg(lock);
in = num;
num++;
}
bq.Enqueue(in);
std::cout << "线程" << name << " 生产数据" << in << std::endl;
sleep(1);
}
}
void ConsumerRoutine()
{
while(1)
{
char name[16];
pthread_getname_np(pthread_self(), name, sizeof name);
int out;
bq.Pop(&out);
std::cout << "线程" << name << " 消费数据" << out << std::endl;
sleep(1);
}
}
int main()
{
Thread productor1(ProductorRoutine);
Thread productor2(ProductorRoutine);
Thread productor3(ProductorRoutine);
Thread consumer1(ConsumerRoutine);
Thread consumer2(ConsumerRoutine);
productor1.Start();
productor2.Start();
productor3.Start();
consumer1.Start();
consumer2.Start();
productor1.Join();
productor2.Join();
productor3.Join();
consumer1.Join();
consumer2.Join();
return 0;
}
虽然在BlockQueue中我们好像只处理了生产者和消费者的关系,但是在一个类对象中只有一把锁,所以多生产者之间、多消费者之间,也是天然满足互斥关系的!

因为打印操作我们没有加保护,可能会出现混乱,但是主要逻辑没有问题!
3. 基于环形队列的cp模型
环形队列(Ring Queue)是一种首尾相连的队列,他有一个队头head位置和队尾tail位置。

当队列为空、为满时,head和tail的位置相同,必须互斥访问 。队列为空不能取数据,队列为满不能存数据;
当队列有元素且不为满时,head和tail一定在不同的位置,可以并发访问环形队列。
我们可以用数组模拟,用模运算模拟环状特性。
在基于环形队列的生产者消费者模型中,tail位置代表生产者放入新数据的位置,head位置代表消费者取数据的位置。
我们可以用两个信号量分别代表生产者和消费者的资源数量。对于生产者,"空位置"是他需要的资源;对于消费者,"数据"是他需要的资源。只有能申请到信号量,预定到资源,才能进一步操作。
直接使用我们自己封装的信号量和锁来完成:
cpp
// RingQueue.hpp
#pragma once
#include "Mutex.hpp"
#include "Sem.hpp"
#include <vector>
const int default_cap = 5;
template <class T> class RingQueue
{
public:
RingQueue(int cap = default_cap)
: _cap(cap),
_rq(cap),
_c_step(0),
_p_step(0),
_data(0),
_blank(cap)
{}
~RingQueue()
{}
void Enqueue(T& in)
{
// 生产者放数据,需要申请一个空位置
_blank.P();
{
LockGuard lg(_p_mutex);
_rq[_p_step] = in;
_p_step++;
_p_step %= _cap;
}
// 此时多了一个数据,_data信号量可以释放
_data.V();
}
void Pop(T* out)
{
// 消费者取数据,需要申请一个数据
_data.P();
{
LockGuard lg(_c_mutex);
*out = _rq[_c_step];
_c_step++;
_c_step %= _cap;
}
// 此时多了一个空位置,_blank信号量可以释放
_blank.V();
}
private:
std::vector<T> _rq;
int _cap; // 最大容量
int _c_step; // 消费者取数据位置
int _p_step; // 生产者放数据位置
Sem _data; // 消费者需要的数据资源, 初始为0
Sem _blank; // 生产者需要的空格资源, 初始为cap
Mutex _c_mutex; // 维护消费者之间的互斥
Mutex _p_mutex; // 维护生产者之间的互斥
};
测试:
cpp
#include "RingQueue.hpp"
#include "Thread.hpp"
#include <iostream>
#include <unistd.h>
int num = 1;
Mutex lock;
RingQueue<int> rq;
void ProductorRoutine()
{
while(1)
{
char name[16];
pthread_getname_np(pthread_self(), name, sizeof name);
int in;
{
LockGuard lg(lock);
in = num;
num++;
}
rq.Enqueue(in);
std::cout << "线程" << name << " 生产数据" << in << std::endl;
sleep(1);
}
}
void ConsumerRoutine()
{
while(1)
{
char name[16];
pthread_getname_np(pthread_self(), name, sizeof name);
int out;
rq.Pop(&out);
std::cout << "线程" << name << " 消费数据" << out << std::endl;
sleep(1);
}
}
int main()
{
Thread productor1(ProductorRoutine);
Thread productor2(ProductorRoutine);
Thread productor3(ProductorRoutine);
Thread consumer1(ConsumerRoutine);
Thread consumer2(ConsumerRoutine);
productor1.Start();
productor2.Start();
productor3.Start();
consumer1.Start();
consumer2.Start();
productor1.Join();
productor2.Join();
productor3.Join();
consumer1.Join();
consumer2.Join();
return 0;
}

逻辑没有问题!还是一样的打印问题,也可以选择给打印语句加锁解决,就不再演示了
本文代码均提交在我的github仓库中。
本文完,感谢阅读!