1.同步问题
下面来谈谈同步问题:同步是一个线程频繁申请锁释放锁只有一个执行流,导致其它线程饥饿问题。为了避免问题我们定个原则:1.外面的人排队。2.你出来后不能立马去申请锁,必须排队列尾部(解决方案之一)。同步问题是保证数据安全情况下,让我们线程访问资源时具有一定的顺序性。那既然大家都排队为啥还要申请锁呢?可以这样理解,线程一进来就要排队吗?它是被迫排队的,所有线程一来都要试着访问资源,它是访问资源失败了才去进行排队。所以加锁防的不是已经排队的人,而是突然来的人。这里我们不要认为互斥是一种问题,而要认为互斥是一种解决方案,只不过这个解决方案有适用场景,这个适用场景不满足我们在引入新的话题。那如何做到让线程同步呢?分为以下几步来说明:1.快速提出解决方案。2.来谈CP问题(生产者消费者理论)。3.快速实现CP。
1.1快速提出解决方案
下面进入第一部分:我们前面锁是没有排队的,纯纯的锁只是让线程去阻塞了并没有排队,怎样做到按一定顺序去访问资源呢?我们要提出一个解决方案叫linux中的条件变量。什么是条件变量呢,下面举个例子:还是一个大VIP自习室,墙上有钥匙,以前所有线程来申请锁时一旦申请成功就进去了,申请失败的自己阻塞住:

卡住也不管,一旦线程出来后把特定线程唤醒就行了,这是纯互斥,不满足我们需求,我们如何做呢:

提供这样一些东西,一个线程申请资源失败就把它放等待队列里。铃铛作用是历史上进去自习的线程现在出来了钥匙放墙上,我们不允许它立马申请锁并且它去排队前要想办法把铃铛敲一下,敲完后再去后面排队或退出。铃铛响了系统会把一个线程唤醒,然后这个线程自己就去申请锁了,当它再出来后也要先敲一下铃铛再去做后续工作。其中我们把铃铛和队列称为条件变量:

所以条件变量在我们认识里要提供两个东西:1.要给我们提供一种简单的通知机制,这个通知机制是要想办法帮我可以把线程唤醒。2.提供一个队列,让所有线程去等待队列中排队。怎么理解条件变量呢?其实在线程库里你可创建条件变量,它可创建条件变量;你可创建对应锁,它也可创建对应锁。所以像锁、条件变量这样东西都是要被线程库管理起来的,先描述再组织,所以锁和条件变量必定是个结构体。定义条件变量相当于在库里定义一个对象,释放条件变量就是把这个对象释放了,然后对条件变量管理变成了对数据对象结构的增删查改。反正认为条件变量里有个队列字段和模拟响的字段,定义bool或整型就能判断响没响。在条件变量队列中去排队的一定是先去申请锁了,申请失败了才去条件变量下队列中排队。所以条件变量要配合锁去使用,因为同步是保证数据安全的前提下去让访问资源有顺序性(条件变量必须依赖于锁的使用)。
下面看看条件变量接口初始化:

条件变量类型是原生线程库提供的pthread_cond_t,第二个参数是属性,一般设为空。销毁时的参数,也是传条件变量地址。继续看:

如果定义的条件变量是全局的,用P_C_I初始化,静态/全局可用P_C_I初始化,局部可用接口初始化。根据我们对条件变量理解,线程中申请锁失败时是知道自己申请失败了,失败后把自己放到等待队列,怎么放呢:

有pthread_cond_wait这样接口。一个线程把锁用完了它要去唤醒队列上的线程,用broadcast/signals:

一个是唤醒所有条件变量下等的线程,另一个是唤醒一个。下面用一下,整数是4字节,64位平台下8字节,为了防止警告用uint64_t(无符号长整数):

这里没有这样写,因为这样新主线程访问同一个i,有并发问题:

这样相当于拷贝没啥影响。继续看:

我们场景是定义一个全局变量,让多线程并发访问这个全局变量,并发访问时一人做一次++。下面完善:

现在虽然安全了,但运行发现其他线程没有机会抢,怎么按我们想的顺序访问呢?每个线程一进来先去等待队列中等待。为啥等待放加锁里面?首先pthread_cond_wait让线程等待的时候会自动释放锁,这样5个线程都去队列休眠了。我们让主线程唤醒一个个线程,这用signal,唤醒在指定条件变量下去等待的一个线程:

下面测一下:

为了更凸显顺序,让创建线程错开一些:

再用用唤醒所有线程:

就是把所有唤醒了,现在我们可以让线程去条件变量下等并且唤醒它。我们要让一个线程去休眠那为啥要让它休眠呢?因为一定是临界资源不就绪,所以临界资源也有状态的。那怎么知道临界资源是就绪还是不就绪的?比如下图:

比如抢票一轮一千张票,我会派五轮,一轮完了线程别退去等,等新一轮来了再去抢。这是怎么知道是去抢还是去等呢?通过判断。所以通过自己判断来看临界资源是否就绪。那么判断是访问临界资源吗?必须是的,因此判断必须在加在锁之后,所以决定线程后续休眠还是干嘛要先判断,所以等待/执行在加锁和解锁之间。
1.2cp问题
下面进入第二部分,谈谈CP问题(生产者消费者模型),它叫做consumer producter问题。现实中有个符合生产者消费者模型的例子是超市:

我们每个人去超市买东西,我们都是典型的消费者,所以我们每个人是线程,超市是可以让我们拿东西的一个场所(超市只卖方便面)。超市不是生产者,超市背后有很多供货商,每个供货商把自己品牌的商品放超市,消费者就可在超市获取各种商品,供货商是生产者,这就是我们要研究的模型。那先说个题外话,为啥要有超市呢(用户可以去生产者那里拿)?因为效率高,生产者不需要用户需要一包它就产生一包,而是超市需要1w包供货商一次消费一万包。对于消费者来说不用去供货商那里等,直接拿现呈的,所以超市存在对供货商/消费者来讲都是可以提高效率的,其实超市本质就是一个大号的缓存。比如过年时供货商要放假,所以年前供货商提前把超市塞满,等过年消费者买东西时就有充足物品。这说明有缓存的存在可以调整供货商和消费者因为速度不一致而带来的效率问题,所以超市可做到支持忙闲不均。供货商生产方便面和消费者没关系,消费者买走方便面吃也和供货商没关系,所以超市可做到让生产和消费的行为,进行一定程度的解耦。那计算机中如何理解生产者消费者模型呢?其实生产者是一个个线程承担,消费者也是一个个线程承担,超市是特定结构的内存空间,而生产者生产的商品和消费者消费商品其实是对应数据。因此供货商是要把商品交给消费者,计算机里是一个线程把数据交给另一个线程,背后本质是执行流在做通信,重点在如何安全高效的通信。所有供货商和消费者都会访问超市,所以超市是一种共享资源。而共享资源被生产者同时访问、被消费者同时访问会有并发问题,那会有什么具体并发问题呢?要谈并发问题本质在研究生产者和生产者之间,消费者和消费者之间,生产者和消费者之间三者的关系。下面来想想生产者和生产者之间什么关系呢?竞争关系,其实也就是互斥关系,我在供货时你不能供货,需要等一下。生产者和消费者什么关系?比如生产者往货物柜上正放商品时消费者要拿商品,那有没有拿到?这是不确定的。所以生产者要么已经放好了,要么没放好,不要说什么正在放的情况,因此也是互斥关系。但单纯互斥是不行的,比如超市一天只能接100次电话,供货商一直打电话问超市要不要供货,超市说不要,这样重复了100次,顾客就打不进去电话了。只有互斥供应商会频繁访问超市导致消费者饥饿问题,所以生产者和消费者也要保持同步关系(生产一部分,消费完后再来生产)。那消费者和消费者之间什么关系?互斥关系。
上述信息我们发现CP问题里有3种关系、2种角色(生/消)、1个交易场所(特定结构内存空间),我们把它叫生产者消费者模型的321原则。带来的优点是:1.支持忙闲不均。2.生产和消费进行解耦。下面快速实现一个CP:

今天实现一个基于阻塞队列的生产者消费者模型(为了方便描述,我们先设计一个生产者,未来改成多个生产者;一个消费者,未来我们改成多个消费者)。一个生产,一个消费,它们之间通信采用BlockingQueue,阻塞队列说明这个队列一定要有上限,也可为空。如果生产者往队列放满了就不能放了,消费者把队列消费空了就不能来消费了,必须得去休眠。
1.3快速实现cp
下面来实现:

比如这个阻塞队列已经写完了,怎样让多线程基于阻塞队列进行生产和消费呢(先以单线程为例):

两线程一旦跑起来会各自执行自己的代码,怎样让生产者和消费者看到同一份资源呢:

未来消费和生产都是队列里拿数据。下面来设计阻塞队列:首先有个队列,不知道未来队列里数据类型是什么,所以给个模板再加个maxcap_表示极值,最多可放多少数据。还需要一把锁保护临界资源,再引入一个条件变量控制同步问题:

方法中有构造、析构,最起码有入队列和出队列:

下面来设计构造函数和析构:

那怎么去push?push时可能其它人在pop,要先保证队列安全:

这里阻塞队列,不是说想生产就可以直接生产的,得先确保生产条件满足。所以判断一下,如果不能生产了在条件变量下等:

这是持有锁期间让它去等了,所以pthread_cond_wait会自动释放锁,所以它的参数里有把锁。再次走到后面时有两种情况:1.队列没满 2.被唤醒了,此时在push后unlock就是安全的。有个细节:

为啥不把判断放外面,来看是满的直接就return了。还是这一条逻辑链:

消费也是先加锁,消费完再解锁。消费也不是想消就能消的,也要判断能否消费:

不满足让消费者去队列里等,但这里生产者和消费者不能在同一个条件变量下等,否则后面唤醒不确定了,所以还需要增加:

相当于我们用同一把锁但用不同的条件变量,这样可区分是生产者等待队列还是消费者等待队列。
那么谁来唤醒呢?可想想只有谁最清楚队列有没有满/还有没有空间?生产者只要成功push了,就可保证本次队列里一定是有数据,此时就可以唤醒消费者来消费。消费者pop了一定保证有消费空间,就可唤醒生产者来进行生产:

下面简单测试一下,生产者要一直生产数据,让data不断++,消费者消费慢一些,生产者生产很快:

运行一下:

看到一瞬间生产了很多数据,消费者消费1个再生产1个,说明生产者和消费者有一定同步互斥关系。同理让生产者慢一些,变成了生产一个,消费一个:

现在把缓冲区调为20:

能否生产一批消费一批,不要生一个销一个?可以:

定义两个水位线。意思是这样:

在阻塞队列中形成一个区间,如果将来剩的数据低于low_water,就赶紧让生产者来生产;如果水位线高于high_water,就让消费者赶紧来消费。在low和high之间我们不管,让它们自由运行。继续看:

让low_water占1/3,high_water占2/3。pop时看如果当前元素数量小于低水平了,就通知生产者赶快来生产;push时看队列元素超过了high_water,就快速通知消费者来进行消费:

下面再测试一下(有休眠所以不太明显):

未来BlockQueue内部是可以传对象的,比如任务:

封装一个简单的任务试一试:

这样生产者生产个任务,把构造好的任务扔到任务队列里,然后消费者获得了一个任务,拿到任务后运行任务。此时一个线程可给另一个线程去指派任务了,可以运行看看:

cp模型是为了让多线程并发来协作的。
下面继续,为了方便演示改动一下代码:

现在是构建了一个阻塞队列,里面只能放整数,我们生产一个整数,由对应线程把生产的整数拿走。我们是生产一个数据,消费一个数据,下面让生产慢一些,消费不管:

运行一下:

一切符合我们预期,生产者生产一个数据,消费者很快就能把它消费完了。但生产者过1秒后生产第二个,消费者消费时执行pop,发现条件为0就先去休眠了。发现谁慢另一个得跟着慢,因此一个线程会跟另一个线程进行步调协同的。继续看:

对于生产者来讲,生产者把数据放阻塞队列叫生产了一个数据,那生产者对应的数据从哪来?用户、网络等,所以生产者生产的数据也是要花时间获取的。不要只认为消费者把数据从仓库拿到线程中的行为就是消费了,还不够。消费者拿到数据要不要做数据加工处理?这也要花时间。所以生产者的工作是:1.获取数据 2.生产数据到队列。消费者的工作是:1.消费数据 2.加工处理数据。我们常听说生产消费模型是高效的,怎么理解这句话呢?生产过程是要加锁的,消费过程也是要加锁的,整个访问仓库过程中生产和消费本身就是串行的,这里并没有高效。如果我们对应的消费者当前正在仓库消费,生产者虽然没办法生产到仓库,但它可以正在获取数据。假设生产者持有锁正在仓库放数据,消费者不消费但它可以正在加工处理数据。那么上面可以达到生产和消费是并发访问的,一个正在访问临界区代码,一个正在访问非临界区代码,这样两线程就高效并发的调度运行起来了。目前没有场景让生产者获取数据,所以我们模拟一下:

usleep模拟获取一个数据要花时间,然后把获取到的数据插入到阻塞队列里。再然后:

消费者拿到数据后有个处理过程(我们这暂时没有)。难道每次生产消费时只能拿一个整数来玩吗?我们也可以放对象进去,生产者可通过某种方式获取到任务,把任务交给消费者来进行对应的计算处理。所以我们要引入任务的概念:1.我们阻塞队列设计时用的模拟。2.我们也设计了一个Task任务。这个任务我们当前做了一个简单的计算器:

构建了一些字段,op表示想让a b做什么操作,res表示结果,exitcode表明结果正确性。run中实现操作:

再来个获取结果的接口:

现在有了一个大任务,未来让生产者生产对应任务,由消费者处理消费任务:

继续看:

生产了一个任务后让用户看看生产的什么任务。然后:

消费者拿到任务后处理任务。至此可让一个线程不断创作任务把它生产到阻塞队列里,另一个线程消费任务并把任务做计算。下面测试一下(当前生产者是慢的):

这样一个线程可给另一个线程派发任务了。调用实现这可弄成仿函数,处理任务时可像调函数一样使用:

所以不要只看到向仓库放数据和拿数据的过程,也要看到任务被放到任务队列之前也要有很大时间获取数据以及拿到任务后处理任务的事情。
现在回归到阻塞队列继续谈一些问题,我们之前写的抢票代码:

抢票时先加锁,判断有票就抢,没有票就解锁。再来看:

生产代码先加锁,判断生产条件,然后解锁,pop也是一样的。那么我们发现操作临界资源时,都有判断资源是否满足的动作,这个判断动作为啥在加锁解锁之间?我今天想抢票,不是说抢到了锁就得到了票,必须是既抢到了锁而且票还有才能抢到票。你今天想消费一个数据,不是说拿到了锁就能消费,而是必须拿到锁且队列里还有任务才能消费。所以生产者消费者这些发现除了持有锁,还要在持有锁期间先对条件做判断,而判断必须在加锁和解锁之间,因为判断临界资源条件是否满足也是在访问临界资源。判断会出现失败情况,所以要等待,等待时当前线程是持有锁的,因此调wait时会自动释放锁。当因为唤醒而返回的时候,要重新持有锁。所以这个函数调用时释放锁,返回时让该线程重新持有锁再向后运行。那如果线程wait时被误唤醒了呢?假设现在是多个生产者多个消费者(以生产为例),生产时一个线程持有了锁,不满足后去条件变量下等待了,也释放锁了,这都正常。然后:

现在有多个生产者线程去条件变量下等待了,假设消费者消费了一条任务,消费者在这不是调用的signal,而是调的broadcast把所有线程唤醒了。一旦唤醒了还要重新持有锁,那么这三个线程会对锁展开竞争。假设第一个线程把锁持有了,就继续往后生产执行。当它释放锁后可能没被消费者抢到锁,而被第二个线程抢到了锁。假设最开始阻塞队列满的,前面消费者消费了一个任务,第一个线程又补满了,这样第二个线程往后执行时队列已经满了,这样这就产生了一种伪/误唤醒的情况,这样会多push数据进而产生问题。再详细说说:

阻塞队列现在已经被写满了,然后消费者正在运行,消费一个后就唤醒生产者,最后解锁,现在阻塞队列里有一个位置空出来了。但消费者唤醒时采用的是pthread_cond_broadcast,意味着条件变量下等的多个线程都被唤醒了。唤醒后三个线程为了持有锁都去竞争锁,假设第一个线程竞争成功了,它往后走把阻塞队列填满了,唤醒消费者并释放锁。但不一定消费者拿到了锁,可能是曾经竞争锁失败的生产者拿到了,被唤醒后再往满的push就出问题了,这个线程就被称为伪唤醒状态,所以等待被唤醒可能出现伪唤醒。所以条件变量那要做到防止线程被伪唤醒:

不用if用while循环来判断。这样不管是生产还是消费,一个线程只要被唤醒了先不要着急往后走,而是重复的判断再次确认一下。伪唤醒时重新再做一次判断,条件不满足重新把自己投入到休眠状态。目前我们是一个生产者一个消费者,前面情况不存在。如果是对多生产单消费或单生产多消费,这样单的可能会唤醒多的去竞争锁,从而产生伪唤醒情况。
那怎么改代码为多生产多消费呢:

下面测试一下:

可以做到多生产多消费同时跑。那为啥那样一改成多生产多消费了呢?因为321原则遵守。即便是多生产多消费,任何一个时刻只允许1个人进入临界资源访问,那为啥要改成多生产多消费呢?获取数据和处理任务都要花时间,这样有的线程在竞争,有的在做自己事情,可以并发进行。
1.4完整代码
//BlockQueue.hpp
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
#include <unistd.h>
template <class T>
class BlockQueue
{
static const int defaultnum = 20;
public:
BlockQueue(int maxcap = defaultnum):maxcap_(maxcap)
{
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&c_cond_, nullptr);
pthread_cond_init(&p_cond_, nullptr);
//low_water_ = maxcap_/3;
//high_water_ = (maxcap_*2)/3;
}
T pop()
{
pthread_mutex_lock(&mutex_);
while (q_.size() == 0)
{
pthread_cond_wait(&c_cond_, &mutex_);
}
T out = q_.front(); //你想消费就能直接消费吗?不一定,得确保消费条件满足
q_.pop();
//if (q_.size() < low_water_) pthread_cond_signal(&p_cond_);
pthread_cond_signal(&p_cond_);
pthread_mutex_unlock(&mutex_);
return out;
}
void push(const T& in)
{
pthread_mutex_lock(&mutex_);
while (q_.size() == maxcap_)
{
pthread_cond_wait(&p_cond_, &mutex_); //调用的时候自动释放锁
}
//1.队列没满 2.被唤醒
q_.push(in);
//if (q_.size() > high_water_) pthread_cond_signal(&c_cond_);
pthread_cond_signal(&c_cond_);
pthread_mutex_unlock(&mutex_);
}
~BlockQueue()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&c_cond_);
pthread_cond_destroy(&p_cond_);
}
private:
std::queue<T> q_; //共享资源
int maxcap_; //极值
pthread_mutex_t mutex_;
pthread_cond_t c_cond_;
pthread_cond_t p_cond_;
//int low_water_;
//int high_water_;
};
//main.cc
#include "BlockQueue.hpp"
#include "Task.hpp"
#include <unistd.h>
#include <ctime>
void* Consumer(void* args)
{
BlockQueue<Task> *bq = static_cast<BlockQueue<Task>*>(args);
while (true)
{
//消费
Task t = bq->pop();
//计算
//t.run();
t();
std::cout << "处理任务:" << t.GetTask() << "运算结果是:" << t.Getresult() << " thread id: " << pthread_self() << std::endl;
}
}
void* Productor(void* args)
{
int len = opers.size();
BlockQueue<Task>* bq = static_cast<BlockQueue<Task>*>(args);
//int data = 0;
while (true)
{
//模拟生产者生产数据
int data1 = rand() % 10 + 1; //[1,10]
usleep(10);
int data2 = rand() % 10 + 1;
char op = opers[rand() % len];
Task t(data1, data2, op);
//生产
bq->push(t);
std::cout << "生产了一个任务:" << t.GetTask() << " thread id: " << pthread_self <<std::endl;
sleep(1);
}
}
int main()
{
srand(time(nullptr));
BlockQueue<Task> *bq = new BlockQueue<Task>();
pthread_t c[3],p[5];
for (int i = 0; i < 3; i++)
{
pthread_create(c + i, nullptr, Consumer, bq);
}
for (int i = 0; i < 5; i++)
{
pthread_create(p + i, nullptr, Productor, bq);
}
for (int i = 0; i < 3; i++)
{
pthread_join(c[i], nullptr);
}
for (int i = 0; i < 5; i++)
{
pthread_join(p[i], nullptr);
}
delete bq;
return 0;
}
//Task.hpp
#pragma once
#include <iostream>
#include <string>
std::string opers = "+-*/%";
enum
{
DivZeor = 1,
ModZeor,
UnKnow
};
class Task
{
public:
Task(int x, int y, char op):data1_(x), data2_(y), oper_(op), result_(0), exitcode_(0)
{
}
void run()
{
switch (oper_)
{
case '+':
result_ = data1_ + data2_;
break;
case '-':
result_ = data1_ - data2_;
break;
case '*':
result_ = data1_ * data2_;
break;
case '/':
{
if (data2_ == 0) exitcode_ = DivZeor;
else result_ = data1_ / data2_;
}
break;
case '%':
{
if (data2_ == 0) exitcode_ = ModZeor;
else result_ = data1_ % data2_;
}
break;
default:
exitcode_ = UnKnow;
break;
}
}
void operator()()
{
run();
}
std::string Getresult()
{
std::string r = std::to_string(data1_);
r += oper_;
r += std::to_string(data2_);
r += "=";
r += std::to_string(result_);
r += "[code: ";
r += std::to_string(exitcode_);
r += "]";
return r;
}
std::string GetTask()
{
std::string r = std::to_string(data1_);
r += oper_;
r += std::to_string(data2_);
r += "=?";
return r;
}
~Task()
{
}
private:
int data1_;
int data2_;
char oper_;
int result_;
int exitcode_;
};
2.POSIX信号量
下面进入下一个话题posix信号量:目前为止,我们维护的阻塞队列queue(共享资源)我们是把它当做一个整体使用的,q只有1份,我们通过加锁保证这一份资源的安全性。但其实共享资源也可以被看成多份,比如有个全局数组大小为300,一共有3个线程,第一个线程只能访问前100个,以此类推。这样虽然这个数组是全局资源,但多线程访问的是全局资源不同的区域,所以多线程可以并发访问数组不同区域吗?可以的。那要是再来第4个线程呢?数组只分了3份,现在有了第4个线程,怎样保证多线程访问并发度和数据安全呢?所以4个线程只能放3个进来访问数组不同区域就行。因此如果把数组资源当成一个整体来用,直接加锁只有一个线程可以进来;如果资源允许被多线程并发访问不同区域,能被拆成多少份资源就最多允许多少个线程进入。为了更好的保护临界资源(说放3个进来,凭什么保证?)所以需要有一个信号量的概念被引入进来了。
什么是信号量呢?信号量也叫做信号灯,本质是一把计数器,用来描述临界资源中资源数量的多少。比如之前举过电影院的例子,电影院本身是被大家共享的,只要大家抢的是不同位置,那么大家都可以看电影。这是在说一份大的临界资源也可被看作多份,每一份是一个个的座位,只要限定进来的线程个数不要超过总的座位数,合理的把资源分配给不同线程(别把一个资源分配给两线程),此时可保证多线程并发访问这份临界资源。买票的本质,是对电影院资源的预定机制。所以我们将来要访问一个临界资源,我们定义个信号量,所有线程想访问临界资源某一个,每个线程先去抢占信号量,申请到信号量的进程将来一定有一个临界资源会给它,没申请到就不能进入临界资源访问。那信号量本质是一把计数器,那这把计数器的本质是什么?临界资源数量。来看:

先p操作,再访问资源,再v操作。那一旦p操作成功后在pv之间还用作判断资源是否就绪吗?不需要,因为只要申请成功就一定有你的那份资源,申请不成功的就去信号量下等待了。所以本质中还有把资源是否就绪放在了临界区之外,原来加锁进来了还要判断资源是否就绪,若没有则去等;现在只要p()完成后一定有,内部不用做判断了。所以申请信号量时,其实就间接的已经做判断了。下面过一下信号量的接口(后面详细说):
