多线程同步
同步的概念
举一个例子,假设有一个公厕,一堆人正在排队等着,在公厕里面的人正在使用,使用完毕之后刚出门,结果又进去了,在待一会,刚出来又进去了,那么后面的人怎么办呢?
在操作系统的线程当中也是的,如果多个线程同时在等待锁,那么刚刚释放完锁的那个线程竞争力非常强,如果这个线程释放锁之后没有别的任务,那么每次都会是固定的线程抢到锁。(线程饥饿问题)
这里如果释放锁没有除抢锁以外的时间消耗,如果想让这个线程去后面排队的方法,就是线程同步,也是让线程等待,刚刚释放完锁的线程会去后面等待,下一个线程会拿这个锁。
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
条件变量解决死锁问题
拿到锁的线程如何去告诉下一个线程我使用完锁了,让下一个线程过来拿锁?
这里有一个叫条件变量的东西,他相当于一个铃铛,使用完毕就敲响这个铃铛,通知队列当中的线程。
条件变量函数 初始化:
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);
那么调教变量如何使用呢?
cpp
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;
int count = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//初始化锁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//初始化条件变量
void* Count(void* args)
{
pthread_detach(pthread_self());
uint64_t number = (uint64_t)args;
while(1)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond,&mutex);//让线程等待的时候会自动释放锁,如果不释放锁,lock外面的进不来
cout << "pthread:" << number << "count:" << count++ << endl;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
for(int i = 0;i < 5;i++)
{
pthread_t tid;
pthread_create(&tid,nullptr,Count,(void*)i);
}
while(1)
{
sleep(1);
pthread_cond_signal(&cond);//主线程唤醒等待队列中的其他线程,默认唤醒第一个
cout << "signal one pthread" << endl;
}
return 0;
}

那么我们如何知道要让一个线程去休眠呢?一定是临界资源未就绪,临界资源也是有状态的。
那么如果知道是否就绪?当然是去判断,判断也是访问临界资源,所以锁要加在判断条件的前面。
生产消费者模型(CP问题)
CP概念(consumer producter)
举个例子:我们的各种物品或食品的生产商家每次生产的东西都会放到超市里,顾客买东西也很少去厂家买,因为顾客需要买不同的商品,可能需要去各种厂家来回跑,并且厂家也不知道有多少人能买他的东西,只能来个人生产一份来一个人生产一份,很麻烦,所以就有了超市,超市的货架就是厂家生产商品放的地方,也是顾客购买商品的地方。(超市提高了所有人的效率)
生产者不用考虑消费者如何消费货品,消费者也不用考虑生产者需要生产什么货品,生产者与消费者的行为,进行了一定程度的解耦。
那么超市的货架就可以理解为一个超大号的缓存空间,是特定结构的内存。(共享资源,会有并发问题)
生产者与消费者都是线程,放到"货架"上的都是数据,也就是交换数据,进行一定程度的通信。
也就是说,如果会有并发问题,那么各个身份之间的关系就会有特殊的关系。(3种关系)
消费者与消费者:互斥(防止抢到一个东西)
生产者与生产者:互斥(防止"货架"满了,生产溢出)
消费者与生产者:互斥,同步(互斥是因为要确保数据的安全性,如果生产者生产的过程中,消费者过来拿数据,我们不确定数据有没有被拿走,有可能刚刚放上数据就被拿走了,内存不清楚,因为有不确定性,所以要生产和消费要保证原子性。同步是因为生产者会放满"货架"上的数据,消费者会拿光"货架"上的数据,只有一方满了或者另一方空对方才能知道要去生产or消费)
通过上述得知,满足以下条件才是生产消费者模型:
3种关系
2种角色(生产者与消费者)
1个交易场所(特定结构的内存空间)
代码实现CP模型
cpp
//test.hpp
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<queue>
using namespace std;
template<class T>
class BlockQueue
{
static const int defalutnum = 5;
public:
BlockQueue(int maxcap = defalutnum):maxcap_(maxcap)
{
pthread_mutex_init(&mutex_,nullptr);
pthread_cond_init(&c_,nullptr);
pthread_cond_init(&p_,nullptr);
}
const T pop()//消费
{
pthread_mutex_lock(&mutex_);
if(q_.size() == 0)
{
pthread_cond_wait(&c_,&mutex_);//想消费需要满足条件,如果没有资源,就不满足,需要进入队列等待资源补充
}
T out = q_.front();
q_.pop();
pthread_cond_signal(&p_);//顺利消费一个说明队列不为满,通知生产者进行生产
pthread_mutex_unlock(&mutex_);
return out;
}
void push(const T &in)//生产
{
pthread_mutex_lock(&mutex_);
if(q_.size() == maxcap_)//
{
pthread_cond_wait(&p_,&mutex_);//想生产需要满足条件,如果资源已满,就不满足,需要进入队列等待资源被拿走
}
q_.push(in);
pthread_cond_signal(&c_);//顺利生产完一个说明队列里面有资源,通知消费者消费
pthread_mutex_unlock(&mutex_);
}
~BlockQueue()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&c_);
pthread_cond_destroy(&p_);
}
private:
queue<T> q_;//共享资源
int maxcap_;//资源数量极值
pthread_mutex_t mutex_;
pthread_cond_t c_;
pthread_cond_t p_;
};
cpp
//main.cc
#include "test.hpp"
void* Consunmer(void* args)
{
BlockQueue<int> *bq = static_cast<BlockQueue<int>*>(args);
while(1)//消费
{
int data = bq->pop();
cout << "消费了一个数据" << data << endl;
}
}
void* Productor(void* args)
{
BlockQueue<int> *bq = static_cast<BlockQueue<int>*>(args);
int data = 0;
while(1)//生产
{
sleep(1);
data++;
bq->push(data);
cout << "生产了一个数据" << data << endl;
}
}
int main()
{
BlockQueue<int> *bq = new BlockQueue<int>();
pthread_t c,p;
pthread_create(&c,nullptr,Consunmer,bq);
pthread_create(&p,nullptr,Productor,bq);
pthread_join(c,nullptr);
pthread_join(p,nullptr);
delete bq;
return 0;
}

细节1:CP模型是高效的
为什么说是高效的呢?
因为生产者要获取数据才能放进队列,消费者取到数据还有处理数据的过程,这个过程可以让生产者拿数据放数据,很少产生产生队列满or消费者等待的情况。
细节2:条件变量被误唤醒
比如你想唤醒一个线程,但是不小心把队列中的消息全都唤醒了,这就是误唤醒,也叫做伪唤醒。
这时等待队列全都被唤醒,有一个线程竞争成功了,往队列中添加一个数据,剩下竞争失败的线程会一直在外面等待,等到唤醒消费者的时候,消费者要和同时被误唤醒的生产者去竞争锁,万一生产者竞争成功了,那么就会往已满的队列当中继续添加数据:
那么如何避免这种情况呢?很简单,将if改成while()就可以了,多判断几次。
细节3:并发
CP模型是多个生产者与消费者在运行,很可能一个消费者正在拿,另一个消费者正在处理数据,并不会产生互斥,生产者也是同理,所以CP非常高效。
并且因为公用同一把锁,消费者和生产者用的也不是同一个等待队列,所以支持多个生产者消费者活动。(满足CP模型的关系)
POSIX信号量的另一个角度
在一个电影院中,很多人都在抢电影票,但是票数有限,也等于座位数,不可能让票数为0的时候还能被购买,信号量也是这样的,比如一个全局的计数器,加上锁之后,多个线程就可以安全的访问,虽然这个计数器是一份,但是可以被多个线程看成不同的一份,在某种意义上也是多份。
其实信号量的本质也就是计数器,那么这把计数器的本质是什么?
信号量有PV操作(P申请资源,V释放资源),也就是加加减减,并且这个操作是原子的,只要拿到了信号量就说明一定有这种资源,没拿到就一定没有。
所以信号量就是描述资源数目的,也就是资源是否就绪。
申请信号量也就间接的等于判断资源是否就绪了。
初始化信号量
#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()
环形CP模型队列

这里空和满CP都是在同一个位置的。
我们可以加入信号量来讨论一下。
当P生产满了之后,P就会被挂起,这就是为什么生产者无法把消费者套一个圈。
然后让C进行消费,当资源数为0的时候,C挂起。
模拟实现一个单生产者单生产者的代码:
cpp
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<queue>
#include<semaphore.h>
#include<ctime>
using namespace std;
const static int defaultcap = 5;
template<class T>
class RingQueue
{
private:
void P(sem_t &sem)
{
sem_wait(&sem);
}
void V(sem_t &sem)
{
sem_post(&sem);
}
public:
RingQueue(int cap = defaultcap)
:ringqueue_(cap),cap_(cap),c_step_(0),p_step_(0)
{
sem_init(&cdata_sem,0,0);
sem_init(&pspace_sem,0,cap);
}
void push(const T& val)
{
P(pspace_sem);
ringqueue_[p_step_] = val;
p_step_++;
p_step_ %= cap_;
V(cdata_sem);
}
void pop(T *out)
{
P(cdata_sem);
*out = ringqueue_[c_step_];
c_step_++;
c_step_ %= cap_;
V(pspace_sem);
}
~RingQueue()
{
sem_destroy(&cdata_sem);
sem_destroy(&pspace_sem);
}
private:
vector<T> ringqueue_;
int cap_;
int c_step_;//消费者下标
int p_step_;//生产者下标
sem_t cdata_sem;//消费者关注的数据资源
sem_t pspace_sem;//生产者关注的空间资源
};
cpp
#include "RingQueue.hpp"
void* Consunmer(void* args)
{
RingQueue<int> *rq = static_cast<RingQueue<int>*>(args);
int data = 0;
while(1)//消费
{
rq->pop(&data);
cout << "Consunmer..." << data << endl;
sleep(1);
}
}
void* Productor(void* args)
{
RingQueue<int> *rq = static_cast<RingQueue<int>*>(args);
int data = 0;
sleep(3);//这里停顿3S,消费者3S中内也并没有运行
while(1)//生产
{
data = rand() %10;
rq->push(data);
cout << "Productor..." << data << endl;
sleep(1);
}
}
int main()
{
srand(time(nullptr)^getpid());
RingQueue<int>* rq = new RingQueue<int>();
pthread_t c,p;
pthread_create(&c,nullptr,Consunmer,rq);
pthread_create(&p,nullptr,Productor,rq);
pthread_join(c,nullptr);
pthread_join(p,nullptr);
delete rq;
return 0;
}

那么如果想让支持多生产多消费该怎么办呢?
答案是加两把锁,生产者一把,消费者一把。
枷锁一定是在PV操作区间,这里注意,加锁最好实在P操作之前,因为信号量可以很多个线程申请成功,但是锁只能有一个线程竞争成功,假设A,B,C,D同时进入PV操作的区间,A的速度比较快,申请了信号量也申请了锁,然后B,C,D也来了,直接可以申请信号量,然后在申请锁的前面等待锁;如果是申请锁在申请信号量前面,只有申请锁才能够申请信号量,就导致只能一个一个去进入这个区间,效率会变慢。
池化技术
池化技术就是提前开辟好一块很大的空间,然后让线程在拿取空间的时候不需要在向操作系统申请,效率提高,操作系统开辟的空间全都放在线程池,线程一直去线程池拿空间,这其实也是CP模型。
线程安全的单例模式
单例模式是一种 "经典的, 常用的, 常考的" 设计模式.
什么是设计模式?
IT行业这么火, 涌入的人很多. 俗话说林子大了啥鸟都有. 大佬和菜鸡们两极分化的越来越严重. 为了让菜鸡们不太拖大佬的后腿, 于是大佬们针对一些经典的常见的场景, 给定了一些对应的解决方案, 这个就是 设计模式。
单例模式的特点
某些类, 只应该具有一个对象(实例), 就称之为单例。
饿汉模式
吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭。
伪代码:
cpp
template <typename T>
class Singleton {
static T data;
public:
static T* GetInstance() {
return &data;
}
};
只要通过 Singleton 这个包装类来使用 T 对象, 则一个进程中只有一个 T 对象的实例.
懒汉模式
吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式。
懒汉方式最核心的思想是 "延时加载". 从而能够优化服务器的启动速度。
因为很多大型模块加载很慢,也导致服务器启动也很慢,所以用到的时候在加载,服务器就会启动的很快。
伪代码:
cpp
template <typename T>
class Singleton {
static T* inst;
public:
static T* GetInstance() {
if (inst == NULL) {
inst = new T();
}
return inst;
}
};
存在一个严重的问题, 线程不安全.
第一次调用 GetInstance 的时候, 如果两个线程同时调用, 可能会创建出两份 T 对象的实例.
但是后续再次调用, 就没有问题了.
那么如何解决不安全的问题呢?
cpp
template <typename T>
class Singleton {
volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.
static std::mutex lock;
public:
static T* GetInstance() {
if (inst == NULL) { // 双重判定空指针, 降低锁冲突的概率, 提高性能.
lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new.
if (inst == NULL) {
inst = new T();
}
lock.unlock();
}
return inst;
}
};
注意事项:
- 加锁解锁的位置
- 双重 if 判定, 避免不必要的锁竞争
- volatile关键字防止过度优化
STL,智能指针和线程安全
STL中的容器是否是线程安全的?
不是的:
原因是, STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响.
而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶).
因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全.
智能指针是否是线程安全的?
对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题.
对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这
个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数.
其他常见的各种锁
悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,
会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
自旋锁:和悲观锁对比,自旋锁会一直循环的查看这个锁有没有准备就绪,并不会去阻塞等待。
这是自旋锁的接口:
加锁:
第一个是非阻塞,第二个是阻塞。
解锁:
初始化与销毁:
读者与写者问题
在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁。
同时读者与写者也遵守CP模型的3种关系,2种角色,1个交易场所。
读者与读者:共享(因为所有人读一篇公告不会产生冲突)
写者与写者:互斥竞争
读者与写者:互斥,同步(如果写者没写完内容,读者去读,得到的信息就不完整)
读写锁的接口:
销毁与初始化,初始化第一个为读写锁,第二个为属性。
读者用第一个,写者用第二个。
无论读者还是写者,释放锁用这个。
伪代码:
如果读者数量为1,直接给写者加锁,不让写者进来写,如果写者此时已经再写, 说明锁没了,只能在lock(&wlock)这里阻塞了。