Linux线程同步与互斥
1、线程互斥——互斥量
1.1、多线程模拟抢票
前置知识回顾:
1、临界资源:多个执行流共享的资源加以保护就叫做临界资源。
2、临界区:每个线程内部访问临界资源的代码就叫做临界区。
3、互斥:任何时刻,保证只有一个执行流访问临界资源,称之为互斥。
4、原子性:一件事要么没做,要么做完了——两态的。没有正在做的概念。
下面使用多线程模拟抢票的过程:我们直接把上一篇线程封装的代码拿过来用了,当然你也可以直接使用pthread库。
下面这份代码是使用了线程封装的:
#include <iostream>
#include <pthread.h>
#include <vector>
#include "Thread.hpp"
using namespace ThreadModule;
#define NUM 4
int ticketnum = 10000;
struct ThreadData
{
std::string name;
};
void GetTicket(ThreadData* td)
{
while (1)
{
if (ticketnum > 0)
{
usleep(1000);
printf("%s[%ld]get a ticket: %d\n", td->name.c_str(), pthread_self(), ticketnum--);
}
else
{
break;
}
}
}
int main()
{
std::vector<Thread<ThreadData>> v;
for (int i = 0; i < NUM; i++)
{
ThreadData* td = new ThreadData;
v.push_back(Thread<ThreadData>(GetTicket, td));
td->name = v[i].Name();
}
for (int i = 0; i < NUM; i++)
{
v[i].Start();
}
for (int i = 0; i < NUM; i++)
{
v[i].Join();
}
return 0;
}
下面这份代码是直接使用原生线程库的:
#include <iostream>
#include <pthread.h>
#include <vector>
#include <unistd.h>
#define NUM 4
int ticketnum = 10000;
struct ThreadData
{
std::string name;
pthread_t tid;
};
void* GetTicket(void* args)
{
ThreadData* td = static_cast<ThreadData*>(args);
while (1)
{
if (ticketnum > 0)
{
usleep(1000);
printf("%s[%ld]get a ticket: %d\n", td->name.c_str(), pthread_self(), ticketnum--);
}
else
{
break;
}
}
return nullptr;
}
int main()
{
std::vector<ThreadData> thds(NUM);
for (int i = 0; i < NUM; i++)
{
std::string name = "thread" + std::to_string(i + 1);
thds[i].name = name;
pthread_create(&thds[i].tid, nullptr, GetTicket, &thds[i]);
}
for (int i = 0; i < NUM; i++)
{
pthread_join(thds[i].tid, nullptr);
}
return 0;
}

实测这两份代码的运行结果都是一样的。
在上面的代码中我们定义了一个全局变量ticketnum表示票数,然后创建4个线程来抢票,我们发现最后票数竟然抢到了0、-1、-2。
基于前面的知识,我们知道ticketnum是被所有线程共享的,所以是共享资源,多线程访问共享资源如果不加以保护,就会出现上面这种情况。
1、ticketnum--是不是线程原子的?
ticketnum--是一条代码,但是变成汇编有三条语句,分别为:
1、将ticketnum的值放入CPU寄存器中。
2、CPU中进行--操作。
3、将计算结果写回内存。
我们认为一条汇编语句是原子的,所以ticketnum--不是原子的。
而我们的执行流调度在执行这三条指令的时候随时可能被切换,那么就会出问题。
下面进行分析:
我们以两个线程为例,假设现在ticketnum的值为10000,线程一开始调度执行,将ticketnum放入寄存器中,然后进行--操作变成了9999,这时候发生了线程切换,所以线程需要保存上下文,将9999带走。现在换线程二上来,把ticketnum放入寄存器,然后进行--操作变成9999,然后再将结果写回内存,现在内存中的ticketnum变成了9999,继续重复这些步骤,线程二跑得很欢,最后ticketnum减到了1,然后写回内存,这时候内存中的值为1,然后发生线程切换,线程二切走换线程一,线程一刚才还差第三步,所以这时候把9999写回内存。那么内存中的值就从1直接变成了9999。线程二刚才全白忙活了。
1、把数据从内存中搬到CPU寄存器,本质就是将数据从共享转换为线程私有。
2、寄存器的内容本质就是执行流的硬件上下文。
所以多线程对同一个变量进行++或--都不是线程安全的,需要使用互斥锁加以保护。
但是这个并不是引起我们抢票抢到负数的主要原因,这个只是间接相关。

重点在if判断这里。这里进行逻辑判断也不是原子的,首先需要将ticketnum放入寄存器,然后CPU进行逻辑判断,然后再将结果返回。假设现在ticketnum的值为1,线程一将ticketnum放入寄存器中就进行了线程切换,然后线程二来了,也是将ticketnum放入寄存器中就切走了,很不巧线程三和线程四也是如此。那么最后四个线程进行逻辑判断都为真,所以四个执行流都会进入if语句内。然后线程一再从内存获取ticketnum,然后进行--,再把结果写回去,此时ticketnum=0。现在换线程二,线程二也是从内存将ticketnum读入寄存器中,然后--写回内存,现在内存中的ticketnum=-1。那么后面线程三和线程四也是如此,所以最后结果ticketnum会变成-3。我们上面打印的结果是-2是因为是后置--。所以引起抢票抢到负数的主要原因是逻辑判断。
那么凭什么ticketnum会减到负数?
1、if(xxx)不是原子操作。
2、我要让所有的线程尽可能多的进行调度切换执行。
那线程或进程什么时候切换?
a、时间片耗尽。
b、更高优先级的进程要调度——优先级抢占。
c、通过sleep,然后从内核返回用户时,会进行时间片是否到达的检测,进而导致切换。
1.2、互斥锁
解决方案:使用互斥锁pthread_mutex_t
默认ubuntu下查不到互斥量相关接口,可以使用sudo apt install manpages-posix-dev进行安装。

我们可以使用pthread_mutex_t定义一个互斥锁,如果定义成全局的可以使用宏:PTHREAD_MUTEX_INITIALIZER来初始化,不需要再销毁。如果定义成局部的,我们需要使用pthread_mutex_init来初始化,使用pthread_mutex_destroy来销毁。初始化函数有两个参数,第一个就是互斥锁的地址,第二个表示属性我们设置为nullptr就好。销毁函数直接传入互斥锁地址即可。

使用pthread_mutex_lock来加锁,使用pthread_mutex_unlock来解锁。pthread_mutex_trylock是尝试进行加锁,如果申请不到锁就直接返回。
下面对代码进行修改,使用全局互斥锁。
#include <iostream>
#include <pthread.h>
#include <vector>
#include "Thread.hpp"
using namespace ThreadModule;
#define NUM 4
int ticketnum = 10000;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
struct ThreadData
{
std::string name;
};
void GetTicket(ThreadData* td)
{
while (1)
{
pthread_mutex_lock(&mutex);
if (ticketnum > 0)
{
usleep(1000);
printf("%s[%ld]get a ticket: %d\n", td->name.c_str(), pthread_self(), ticketnum--);
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
}
int main()
{
std::vector<Thread<ThreadData>> v;
for (int i = 0; i < NUM; i++)
{
ThreadData* td = new ThreadData;
v.push_back(Thread<ThreadData>(GetTicket, td));
td->name = v[i].Name();
}
for (int i = 0; i < NUM; i++)
{
v[i].Start();
}
for (int i = 0; i < NUM; i++)
{
v[i].Join();
}
return 0;
}

对共享资源加锁保护之后,就不会出问题了。

注意,不能这么解锁,如果走else直接退出之后锁就没有释放,其他线程就拿不到锁一直阻塞住。
并且我们注意到加锁后抢票的速度比不加锁要慢,所以加锁必定会影响性能。
1、所有对资源的保护,都是对临界区代码的保护,因为资源是通过代码访问的。
2、加锁一定不能大块代码加锁,要保证细粒度。
3、加锁就是找到临界区,对临界区进行加锁。
细节一:锁本身是全局的,那么锁也是共享资源,锁要保证别人的安全,那谁来保证锁的安全?
pthread库中加锁和解锁已经被设计成原子的了。
细节二:如何看待锁呢?
1、加锁本质就是对资源的预定机制。加锁并不是直接访问资源,加锁才有访问资源的权限。
2、加锁保证了任何时刻只有一个执行流访问临界资源,所以是整体使用资源的。
之前进程间通信信号量我们说过一个超级VIP电影院,只有一个人能进入放映厅,所以信号量为1,谁先申请到信号量谁就能进入看电影。这也是把资源整体来用的。
所以二元信号量就是锁。
细节三:如果申请锁的时候,锁被别人拿走了怎么办?
其他线程要阻塞等待,默认使用pthread_mutex_lock就是阻塞等待,如果使用pthread_mutex_try申请失败会返回。
细节四:线程拿到锁,在访问临界区代码的时候,也会进行线程切换,如果进行线程切换其他线程能进来吗?
不行,线程是拿着锁走的,所还没有释放,其他线程申请不到锁,所以进不来。所以多线程访问临界区代码是串行的,线程切走了其他线程也只能阻塞着等,这就是效率低的原因。
下面我们定义一个局部的锁,使用pthread_mutex_init和pthread_mutex_destroy进行初始化和销毁,将锁传给新线程使用。
#include <iostream>
#include <pthread.h>
#include <vector>
#include "Thread.hpp"
using namespace ThreadModule;
#define NUM 4
int ticketnum = 10000;
// pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
struct ThreadData
{
std::string name;
pthread_mutex_t* pmtx;
};
void GetTicket(ThreadData* td)
{
while (1)
{
pthread_mutex_lock(td->pmtx);
if (ticketnum > 0)
{
usleep(1000);
printf("%s[%ld]get a ticket: %d\n", td->name.c_str(), pthread_self(), ticketnum--);
pthread_mutex_unlock(td->pmtx);
}
else
{
pthread_mutex_unlock(td->pmtx);
break;
}
}
}
int main()
{
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, nullptr);
std::vector<Thread<ThreadData>> v;
for (int i = 0; i < NUM; i++)
{
ThreadData* td = new ThreadData;
v.push_back(Thread<ThreadData>(GetTicket, td));
td->name = v[i].Name();
td->pmtx = &mutex;
}
for (int i = 0; i < NUM; i++)
{
v[i].Start();
}
for (int i = 0; i < NUM; i++)
{
v[i].Join();
}
pthread_mutex_destroy(&mutex);
return 0;
}

但是我们会发现不够随机,虽然线程1-4都会切换,但是有一部分时间都是同一线程在抢票。
这是因为我们这里不够完善,因为正常抢到票会有个加入数据库的操作,我们可以在解锁后usleep(50)模拟入库让多线程抢票更随机:

1.3、互斥锁实现原理
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。

上图左侧为互斥锁的伪代码,我们具体分析一下:先来看加锁
互斥锁实际上就是一个值为1的变量。首先线程一开始调度执行,第一步将0放到寄存器eax中,然后执行第二步,交换内存中mutex和寄存器eax中的值,交换后eax寄存器中值为1,物理内存中mutex值为0。现在进行线程切换,换线程二上来,线程二也是先把0放到eax寄存器中,然后交换eax和mutex的值,接着进入判断发现eax的值不大于0,说明线程二没有获取到锁,线程二就阻塞住了。再换线程一上来,进行if判断,eax中的值为1大于0,所以申请锁成功直接返回,执行临界区的代码。
在这个过程中线程一执行完第二步eax的值为1,这本质就是获取到锁,数据从内存中搬到CPU内部不就是将共享数据变成线程私有的数据,这时候物理内存mutex的值为0,所以这个锁就变成了线程私有的数据了。而线程一进行线程切换的时候要把自己的上下文带走,也就是要把寄存器eax值带走,因为寄存器本质属于进程的硬件上下文,所以线程一就把锁带走了,后面哪怕再来很多个线程也申请不到锁,因为物理内存中的mutex是0。
再来看释放的时候,线程一释放锁将1写入mutex,然后唤醒阻塞的线程,那么阻塞的线程就会goto lock继续竞争锁,哪个线程先执行第二步将mutex的值1换入寄存器,谁就拿到锁。
1.4、互斥锁封装
// Mutex.hpp
#pragma once
#include <iostream>
#include <pthread.h>
namespace LockModule
{
class Mutex
{
public:
Mutex(const Mutex&) = delete;
const Mutex& operator=(const Mutex&) = delete;
Mutex()
{
int n = ::pthread_mutex_init(&_lock, nullptr);
(void)n;
}
~Mutex()
{
int n = ::pthread_mutex_destroy(&_lock);
(void)n;
}
pthread_mutex_t* LockPtr() { return &_lock; }
void Lock()
{
int n = ::pthread_mutex_lock(&_lock);
(void)n;
}
void Unlock()
{
int n = ::pthread_mutex_unlock(&_lock);
(void)n;
}
private:
pthread_mutex_t _lock;
};
class LockGuard
{
public:
LockGuard(Mutex& mtx)
:_mtx(mtx)
{
_mtx.Lock();
}
~LockGuard()
{
_mtx.Unlock();
}
private:
Mutex& _mtx;
};
}
// ticket.cc
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>
#include "Mutex.hpp"
#define NUM 4
using namespace LockModule;
int ticketnum = 10000;
Mutex mutex;
void* GetTicket(void* args)
{
char* name = static_cast<char*>(args);
while (1)
{
{
LockGuard LockGuard(mutex);
if (ticketnum > 0)
{
usleep(1000);
printf("%s[%ld] get a ticket: %d\n", name, pthread_self(), ticketnum--);
usleep(50);
}
else break;
}
}
return nullptr;
}
int main()
{
pthread_t tid1, tid2, tid3, tid4;
pthread_create(&tid1, nullptr, GetTicket, (void*)"thread-1");
pthread_create(&tid2, nullptr, GetTicket, (void*)"thread-2");
pthread_create(&tid3, nullptr, GetTicket, (void*)"thread-3");
pthread_create(&tid4, nullptr, GetTicket, (void*)"thread-4");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
pthread_join(tid4, nullptr);
return 0;
}

上面使用LockGuard,初始化自动调用构造函数加锁,出了作用域自动调用析构函数解锁。我们把这种称为RAII风格的锁。
2、线程同步——条件变量
2.1、承上启下
抢票我们使用互斥能解决问题,但是使用纯互斥并不能解决所有问题。

假设学校里有一个超级自习室,但是这个自习室一次只能有一个人在里面自习。所以你为了抢到这个自习室,四点你就冲过来,然后在这个自习室外面墙上挂着一把钥匙,你拿着钥匙就进去自习然后把门锁上了。然后到早上八九点了,陆陆续续来了很多人,在外面等着,因为自习室里面有人,门被锁着,所以一群人就在外面混乱的等着。然后你刚好也饿了,这时候你就打算出来吃个早餐,你打开门,把钥匙挂墙上,然后看到这么多人你突然想到如果我现在去吃饭等下回来这么多人就很难再抢到钥匙进去自习了,所以你马上从墙上拿下钥匙然后又进去锁门自习。过了一会你又想出来,然后刚把钥匙挂墙上,然后看到这么多人又马上拿到钥匙进去自习。那么你自习的效率就很低,因为一直在开门出来放钥匙,然后又拿钥匙进自习室。所以导致你效率低,其他人也不能进自习室自习。
外面这群人就是线程,这些线程长时间获取不到锁资源,我们称为线程的饥饿问题。
那么你错了吗?并没有错,因为这个自习室只能有一个人自习,所以互斥锁是对的,但是效率不高。

现在学校就规定,外面等的人必须排好队,里面自习完的人归还钥匙后不能马上申请,必须排到队列的尾部去。
那么现在多个线程访问临界资源还是互斥的,并且多线程访问临界资源具有顺序性,我们称之为同步。
互斥保证了安全性,但是安全不一定合理或高效!
同步主要是在保证安全的前提下,让系统变得更加合理和高效。
2.2、条件变量
下面先来看条件变量的接口:

我们可以使用pthread_cond_t定义一个条件变量,如果定义成全局的可以使用宏:PTHREAD_COND_INITIALZER进行初始化。如果定义成局部的需要调用pthread_cond_init和pthread_cond_destroy进行初始化和销毁。初始化两个参数分别是:条件变量和属性,属性我们默认设置为nullptr即可。销毁只要传条件变量即可。

pthread_cond_wait是让线程到某个条件变量去等待,有两个参数,第一个是去哪个条件变量下等待,第二个是锁。

pthread_cond_signal用于唤醒条件变量下等待的一个线程,pthread_cond_broadcast用于唤醒条件变量下等待的所有线程。
理解条件变量:

假设现在有两个人在玩一个放苹果和拿苹果的游戏,左边的人放苹果,右边的人拿苹果,但是右边的人眼睛是蒙上的。那么左边的人放苹果的时候右边的人就不能来拿,所以需要有一个锁。左边的人要放苹果之前要先拿到锁,然后才能放苹果,放完苹果释放锁。右边的人也是如此。现在右边的人来拿苹果,他先申请到锁,然后判断一下盘子里面有没有苹果,发现有,所以他把苹果拿走,然后释放锁。然后他又马上申请锁,他也不确定盘子里到底还有没有苹果,然后又继续判断,发现没有苹果然后释放锁。然后又继续申请锁,判断有没有苹果,没有继续释放锁。这样右边的人一直在做无意义的事情,左边的人也没法拿到锁放苹果。

那么现在就有了一个铃铛和等待队列,这个就是条件变量。右边的人申请锁然后判断盘子里有苹果,所以拿走苹果然后释放锁。接着他又继续申请锁,然后判断盘子里没有苹果,这时候就让这个人去等待队列里,然后将锁释放。那么左边的人就可以拿到锁,然后放苹果,放完苹果释放锁,然后他就敲一下铃铛,唤醒这个人,这个人就去申请锁,然后拿到锁判断盘子里有苹果,所以他就拿走苹果然后释放锁,接着他又申请锁,判断盘子为空,所以他又去等待队列里面等。那么现在这样就不会导致拿苹果的人一直在拿锁,然后判断盘子为空,又释放锁,一直做无意义的事情,提高效率。

现在拿苹果的人多了几个,刚开始盘子里没有苹果,这些人全都去申请锁,然后判断发现盘子里没有苹果,所以就去等待队列里面等,然后把锁释放了。所以现在拿苹果的人全都在等待队列里。然后放苹果的人拿到锁,往里面放苹果,敲一下铃铛唤醒一个人,那么队列头部的人就被唤醒了,他就去申请锁然后拿苹果,拿完释放锁。接着他不确定盘子里有没有苹果,所以他又申请锁,然后发现没苹果,他就去队列的尾部排队,然后释放锁。然后等左边的人放完苹果,敲一下铃铛,下一个人被唤醒他就可以继续拿苹果,拿完再去尾部排队。
在上面的例子中,这个锁就是互斥量,盘子就是临界资源,苹果就是数据,铃铛+等待队列就是条件变量,然后这些人就是线程,这些人判断盘子里没有苹果去条件变量下等待并释放锁就是调用了pthread_cond_wait。然后左边线程放完苹果后唤醒一线程就是调用了pthread_cond_signal。
现在左边的人放完苹果后他不唤醒一个人,敲三下铃铛把所有人都唤醒,那么这时候所有人就都会去竞争锁,这就是调用了pthread_cond_broadcast。
有了上面的理解,我们写一个demo代码:主线程创建三个新线程,每个线程申请锁然后去条件变量下等待并把锁释放掉,然后主线程两秒后开始控制,一次唤醒一个线程打印消息。在这个过程中线程访问的临界资源就是显示器文件。
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void* Routine(void* args)
{
std::string name = static_cast<char*>(args);
while (1)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond, &mutex);
printf("%s[%ld] is active!\n", name.c_str(), pthread_self());
pthread_mutex_unlock(&mutex);
}
}
int main()
{
pthread_t tid1, tid2, tid3;
pthread_create(&tid1, nullptr, Routine, (void*)"thread-1");
pthread_create(&tid2, nullptr, Routine, (void*)"thread-2");
pthread_create(&tid3, nullptr, Routine, (void*)"thread-3");
sleep(2);
std::cout << "main thread begin control..." << std::endl;
while (1)
{
printf("main thread wakeup thread\n");
pthread_cond_signal(&cond);
sleep(1);
}
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
return 0;
}

可以看到,上面的运行结果线程1、2、3按照顺序打印。
注意:pthread_cond_wait第二个参数传入锁,线程去对应条件变量下等待的时候会自动释放锁。后面会具体分析。
下面我们把pthread_cond_signal换成pthread_cond_broadcast唤醒所有线程。
pthread_cond_broadcast(&cond);

这时候它们的顺序就不确定了,因为唤醒了所有线程,所有线程都会去竞争锁。
2.3、生产者消费者模型

我们平时会去超市买东西,那么我们买的比如水果、零食,这些是由超市制造出来的吗?并不是,这些是由工厂生产出来的,然后工厂卖给超市,接着超市再卖给我们,超市本身是没有制造能力的。这种模式能提高效率,比如你要买方便面,你可能就买一包两包,再不济你买个一箱,你找工厂买,工厂要把机器启动起来制造,结果你就买一包两包,或者一两箱,这样效率就很低。所以工厂是先生产出一批,然后将它们卖给超市,超市再卖给你,你直接找超市买就行。这样就能提高效率。
超市在计算机视角来看就是一个缓存,对于工厂就是写缓存,对于消费者就是读缓存。
假设今天超市只卖方便面,那么对于同一款方便面可能有很多工厂生产,超市今天觉得这个工厂生产的质量不高,然后换了个工厂进货,那么对于右侧的消费者来说,消费者不会关心是哪个工厂生产的,消费者只会关心是不是他要买的方便面,所以左侧生产厂商的更换并不会影响右侧的消费者。那么右侧消费者来买,不管是大学生、上班的、工人,对于工厂来说,超市想卖给谁就卖给谁,跟工厂没有关系。所以实现了生产和消费的解耦。
现在要过年了,超市对工厂说,你们马上要停止生产了,到时候我方便面卖完了没地方进货,所以你们先生产一大批方便面给我吧。所以工厂就先生产了一大批方便面卖给超市,先把数据存在超市这里。或者说最近消费者消费的比较多,方便面都快卖完了,超市就可以给工厂打电话让工厂多生产一些。对于消费者来说,最近消费者买的比较少,超市里方便面还比较多,那么超市就可以给工厂打电话说,最近你们不用给我供货了,超市里还有很多方便面。那么这样就可以平衡生产和消费的速率。所以支持忙闲不均。
所以对于生产者消费者模型:
1、提高效率。2、生产和消费进行解耦。3、支持忙闲不均。
那么工厂就是生产者线程,消费者就是消费者线程,超市就是某种结构组织的一段内存区域,方便面就是数据。
1、超市就是共享或临界资源,所以要保护起来。
2、我们要研究生产者消费者模型,就需要研究清楚多个生产者、多个消费者的同步互斥关系。
生产者和生产者:互斥关系
我在给超市里供货放东西的时候,你就不能供货。我在超市里生产的时候别人就不能来生产。可以理解为同行是冤家。生产者线程在往某个位置生产数据的时候,其他生产者线程就不能进来生产,否则可能导致某个生产者线程生产的数据被覆盖,那么就需要互斥锁保护。所以生产者和生产者之间是互斥关系。
消费者和消费者:互斥关系
今天展架上有一包方便面,你要买别人也要买,那么你刚伸手抓住方便面,别人也伸手去拿,那这包方便面算谁的呢?消费者线程过来消费数据,其他消费者线程也可能过来消费,那么就可能导致两个消费者线程消费了同一个数据,那么就需要互斥锁保护。所以消费者和消费者是互斥关系。
生产者和消费者:互斥和同步关系
今天生产者来供货往展架上放方便面,消费者过来就直接从展架上拿,那么生产者放没放好都不清楚。换句话说生产者线程往里面写数据,数据还不一定写入了消费者线程就过来取数据了,那么就会取到垃圾数据。所以生产者和消费者是互斥关系。
那么今天消费者来超市买方便面,很不巧超市方便面卖完了,然后过一段时间你又过来,超市还是没有方便面,这样就很浪费时间,这其实就是消费者线程在不断申请锁然后释放锁。可能你一周跑了十几趟,结果都没有方便面。这虽然没错,但是不合理。那么对于工厂生产者来说也是一样的,工厂今天刚供完货,过一段时间又来超市供货,结果超市没有位置可以生产,如果工厂每隔一段时间来一下结果都没有位置生产,那么也是效率低下。对于超市有方便面谁最清楚?当然是生产者,生产者刚给超市供货,供完货走了,所以生产者最清楚有数据,就可以通知消费者来消费。对于超市没有方便面谁最清楚?当然是消费者,消费者今天来买,发现超市没有方便面,就可以通知生产者来生产。所以生产者和消费者不能是单纯的互斥关系,否则它们会不断轮询,申请锁然后释放锁,浪费锁资源。所以生产者和消费者还应该是同步关系。
总结:
三种关系:生产者和生产者、消费者和消费者、生产者和消费者。
两种角色:生产者和消费者。
一个交易场所。
我们把它称为321原则方便记忆。
2.4、基于BlockingQueue的生产者消费者模型

在多线程编程中阻塞队列(BlockingQueue)是一种常用于实现生产者和消费者模型的数据结构。
当队列里面的数据满了,生产者就不能再往队列里生产数据,直到队列里面有空间可以生产。当队列里面的数据被消费完了,队列为空,那么消费者就不能再消费了,直到队列里面有数据可以消费。
下面我们先编写单生产者单消费者的BlockingQueue:
// BlockQueue.hpp
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
#include <unistd.h>
namespace BlockQueueModule
{
static const int gcap = 5;
template<typename T>
class BlockQueue
{
bool IsFull() { return _q.size() == _cap; }
bool IsEmpty() { return _q.empty(); }
public:
BlockQueue(int cap = gcap)
:_cap(cap)
,_pwait_num(0)
,_cwait_num(0)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_productor_cond, nullptr);
pthread_cond_init(&_consumer_cond, nullptr);
}
void Equeue(const T& in)
{
pthread_mutex_lock(&_mutex);
if (IsFull())
{
_pwait_num++;
pthread_cond_wait(&_productor_cond, &_mutex);
_pwait_num--;
}
_q.push(in);
if (_cwait_num)
{
pthread_cond_signal(&_consumer_cond);
}
pthread_mutex_unlock(&_mutex);
}
void Pop(T* out)
{
pthread_mutex_lock(&_mutex);
if (IsEmpty())
{
_cwait_num++;
pthread_cond_wait(&_consumer_cond, &_mutex);
_cwait_num--;
}
*out = _q.front();
_q.pop();
if (_pwait_num)
{
pthread_cond_signal(&_productor_cond);
}
pthread_mutex_unlock(&_mutex);
}
~BlockQueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_productor_cond);
pthread_cond_destroy(&_consumer_cond);
}
private:
std::queue<T> _q; // 保存数据的容器,临界资源
int _cap; // bq最大容量
pthread_mutex_t _mutex; // 互斥量
pthread_cond_t _productor_cond; // 生产者条件变量
pthread_cond_t _consumer_cond; // 消费者条件变量
int _pwait_num;
int _cwait_num;
};
}
// Main.cc
#include "BlockQueue.hpp"
using namespace BlockQueueModule;
void* Productor(void* args)
{
BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(args);
int data = 1;
while (1)
{
// 1.获取数据
// 2.生产数据
bq->Equeue(data);
printf("Productor product a data: %d\n", data);
data++;
}
}
void* Consumer(void* args)
{
BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(args);
while (1)
{
sleep(2);
// 1.获取数据
int data;
bq->Pop(&data);
// 2.处理数据
printf("Consumer consume a data: %d\n", data);
}
}
int main()
{
BlockQueue<int>* bq = new BlockQueue<int>();
pthread_t p, c;
pthread_create(&p, nullptr, Productor, bq);
pthread_create(&c, nullptr, Consumer, bq);
pthread_join(p, nullptr);
pthread_join(c, nullptr);
delete bq;
return 0;
}

我们让消费者每隔两秒消费一次数据,让消费者慢一点。所以刚开始生产者就生产满,然后消费者消费一个数据,生产者生产一个数据,这样不断循环。

现在我们让生产者慢一点,所以消费者刚开始要等,等生产者生产了唤醒消费者,消费者再消费,所以就是生产一个消费一个,这样不断循环。

当我们把sleep都去掉,那么就会比较随机了。
看完现象,再来谈细节:

我们以生产者生产数据来分析:
1、为什么在临界区进行等待?
首先生产者线程要生产数据,就得先判断队列是否满了,而判断队列是否满了就是在访问临界资源,所以必须加以保护不然会出问题。所以判断队列是否满了在临界区中,如果队列满了就需要去生产者条件变量下等待,这也就注定了等待必定在临界区内部(目前)。
2、为什么pthread_cond_wait需要第二个锁参数?
现在队列满了,生产者线程要去生产者条件变量下等待,而生产者线程现在是在临界区中去等待的,说明生产者线程现在持有锁,如果拿着锁去等待,那么消费者线程就无法获取到锁,消费者线程获取不到锁就不能消费数据,那么队列就一直是满的,那么就会造成死锁。所以生产者线程去生产者条件变量下等待的时候调用wait需要把锁传过去,把生产者线程放到生产者条件变量下的等待队列并把锁释放掉。这样消费者线程才能拿到锁消费数据。
3、pthread_cond_wait返回时有两个条件:线程被唤醒 && 重新申请到锁
当一个线程被唤醒了,并不意味着它能从pthread_cond_wait后面开始执行,这个线程还要再去申请锁,获取到锁才能从pthread_cond_wait后面开始执行,获取不到锁就要一直卡住直到申请到锁。所以pthread_cond_wait传锁有两个意义,上半部分线程去对应条件变量下等待的时候释放掉锁,下半部分当线程醒来再去竞争锁资源。
4、走到_q.push说明if条件不满足,或者线程被唤醒了并且重新申请到锁。
我们通过两个变量_pwait_num和_cwat_num来判断是否有生产者线程和消费者线程在对应条件变量下等待,如果有就调用pthread_cond_signal唤醒一个线程。由于我们需要判断这两个变量,这两个变量也是共享的,所以需要在临界区唤醒线程,也就是解锁之前。
5、唤醒是在解锁之前还是解锁之后呢?——都可以
在解锁之前唤醒:消费者线程醒来就会去申请锁,而生产者还没释放锁,所以消费者线程要在锁上等,然后生产者线程释放掉锁,消费者线程就能拿到锁然后消费。
在解锁之后唤醒:生产者线程会先释放掉锁,然后唤醒消费者线程,消费者线程就会获取到锁,然后往后走,现在是单生产单消费,所以没有问题。那如果是多个消费者呢,其中有两个消费者在竞争锁,生产者把锁释放掉了再唤醒线程可能导致这两个消费者先申请到锁,而被唤醒的线程没有获取到锁,但是没有关系,唤醒的线程会卡住直到申请到锁才能向后走,因为唤醒的线程需要重新申请锁。
所以唤醒在解锁前还是解锁后都能保证是互斥的。
6、对条件进行判断,为了防止伪唤醒,通常使用while进行判断。
上面的代码使用if判断是有问题的,在多个生产多个消费者的情况下,假设当前生产者生产完数据唤醒了所有在消费者条件变量下等待的线程,或者是由于某种原因导致唤醒了多个消费者线程。这些消费者线程醒来全部需要重新申请锁,当生产者线程释放掉锁之后,有一个消费者线程获取到锁,其他消费者线程就卡住在锁上等,现在假设队列只剩下一个数据,这个消费者线程消费之后队列为空了,然后它释放锁。那么那些在锁上等的线程就可能获取到锁然后消费数据,但是现在队列已经为空了,所以就会出问题。
线程本来在条件变量下等待,但是由于某种原因被唤醒,但是条件并不满足,我们称之为伪唤醒。
因此我们对条件判断需要改成while


改成while之后,现在假设队列只有一个数据,生产者唤醒了一批消费者线程,然后释放锁,这批消费者线程会去竞争锁,其中某个消费者线程拿到锁就会消费然后释放锁,释放锁后很不巧又被这些在锁上等的消费者线程申请到了,那么由于while循环所以需要重新进行判断,判断队列为空不满足条件所以继续去条件变量下等待。
下面我们将代码改成多个生产者多个消费者:我们已经实现了生产者和消费者的互斥同步关系,仔细分析两外两种关系我们发现生产者和生产者也是互斥的,消费者和消费者也是互斥的,所以已经支持多生产多消费了。我们只需要多创建几个生产者消费者线程即可。
#include "BlockQueue.hpp"
using namespace BlockQueueModule;
void* Productor(void* args)
{
BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(args);
int data = 1;
while (1)
{
sleep(2);
// 1.获取数据
// 2.生产数据
bq->Equeue(data);
printf("Productor product a data: %d\n", data);
data++;
}
}
void* Consumer(void* args)
{
BlockQueue<int>* bq = static_cast<BlockQueue<int>*>(args);
while (1)
{
// sleep(2);
// 1.获取数据
int data;
bq->Pop(&data);
// 2.处理数据
printf("Consumer consume a data: %d\n", data);
}
}
int main()
{
BlockQueue<int>* bq = new BlockQueue<int>();
pthread_t p1, p2, p3, c1, c2;
pthread_create(&p1, nullptr, Productor, bq);
pthread_create(&p2, nullptr, Productor, bq);
pthread_create(&p3, nullptr, Productor, bq);
pthread_create(&c1, nullptr, Consumer, bq);
pthread_create(&c2, nullptr, Consumer, bq);
pthread_join(p1, nullptr);
pthread_join(p2, nullptr);
pthread_join(p3, nullptr);
pthread_join(c1, nullptr);
pthread_join(c2, nullptr);
delete bq;
return 0;
}

2.5、条件变量封装
等待的时候需要释放锁,我们就直接把前面写的互斥锁封装拿过来用了。
// Cond.hpp
#pragma once
#include <iostream>
#include <pthread.h>
#include "Mutex.hpp"
namespace CondModule
{
using namespace LockModule;
class Cond
{
public:
Cond()
{
int n = ::pthread_cond_init(&_cond, nullptr);
(void)n;
}
void Wait(Mutex& mutex)
{
int n = ::pthread_cond_wait(&_cond, mutex.LockPtr());
(void)n;
}
void Notify()
{
int n = ::pthread_cond_signal(&_cond);
(void)n;
}
void NotifyAll()
{
int n = ::pthread_cond_broadcast(&_cond);
(void)n;
}
~Cond()
{
int n = ::pthread_cond_destroy(&_cond);
(void)n;
}
private:
pthread_cond_t _cond;
};
}
下面我们修改BlockingQueue代码,使用我们自己封装的互斥量和条件变量。
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
#include <unistd.h>
#include "Cond.hpp"
#include "Mutex.hpp"
namespace BlockQueueModule
{
using namespace LockModule;
using namespace CondModule;
static const int gcap = 10;
template<typename T>
class BlockQueue
{
bool IsFull() { return _q.size() == _cap; }
bool IsEmpty() { return _q.empty(); }
public:
BlockQueue(int cap = gcap)
:_cap(cap)
,_pwait_num(0)
,_cwait_num(0)
{ }
void Equeue(const T& in)
{
LockGuard lockguard(_mutex);
while (IsFull())
{
_pwait_num++;
_productor_cond.Wait(_mutex);
_pwait_num--;
}
_q.push(in);
if (_cwait_num)
{
_consumer_cond.Notify();
}
}
void Pop(T* out)
{
LockGuard lockguard(_mutex);
while (IsEmpty())
{
_cwait_num++;
_consumer_cond.Wait(_mutex);
_cwait_num--;
}
*out = _q.front();
_q.pop();
if (_pwait_num)
{
_productor_cond.Notify();
}
}
~BlockQueue()
{}
private:
std::queue<T> _q; // 保存数据的容器,临界资源
int _cap; // bq最大容量
Mutex _mutex; // 互斥量
Cond _productor_cond; // 生产者条件变量
Cond _consumer_cond; // 消费者条件变量
int _pwait_num;
int _cwait_num;
};
// static const int gcap = 10;
// template<typename T>
// class BlockQueue
// {
// bool IsFull() { return _q.size() == _cap; }
// bool IsEmpty() { return _q.empty(); }
// public:
// BlockQueue(int cap = gcap)
// :_cap(cap)
// ,_pwait_num(0)
// ,_cwait_num(0)
// {
// pthread_mutex_init(&_mutex, nullptr);
// pthread_cond_init(&_productor_cond, nullptr);
// pthread_cond_init(&_consumer_cond, nullptr);
// }
// void Equeue(const T& in)
// {
// pthread_mutex_lock(&_mutex);
// while (IsFull())
// {
// _pwait_num++;
// pthread_cond_wait(&_productor_cond, &_mutex);
// _pwait_num--;
// }
// _q.push(in);
// if (_cwait_num)
// {
// pthread_cond_signal(&_consumer_cond);
// }
// pthread_mutex_unlock(&_mutex);
// }
// void Pop(T* out)
// {
// pthread_mutex_lock(&_mutex);
// while (IsEmpty())
// {
// _cwait_num++;
// pthread_cond_wait(&_consumer_cond, &_mutex);
// _cwait_num--;
// }
// *out = _q.front();
// _q.pop();
// if (_pwait_num)
// {
// pthread_cond_signal(&_productor_cond);
// }
// pthread_mutex_unlock(&_mutex);
// }
// ~BlockQueue()
// {
// pthread_mutex_destroy(&_mutex);
// pthread_cond_destroy(&_productor_cond);
// pthread_cond_destroy(&_consumer_cond);
// }
// private:
// std::queue<T> _q; // 保存数据的容器,临界资源
// int _cap; // bq最大容量
// pthread_mutex_t _mutex; // 互斥量
// pthread_cond_t _productor_cond; // 生产者条件变量
// pthread_cond_t _consumer_cond; // 消费者条件变量
// int _pwait_num;
// int _cwait_num;
// };
}
2.6、代码和理论拓展
下面实现一个Task类,生产者线程生产任务,消费者线程中任务队列中获取任务并进行处理。
#pragma once
#include <unistd.h>
namespace TaskModule
{
class Task
{
public:
Task(){}
Task(int a, int b)
:x(a)
,y(b)
{}
void Excute()
{
sleep(1); // 用1s来模拟任务处理时长
result = x + y;
}
int X() {return x;}
int Y() {return y;}
int Result() {return result;}
~Task()
{}
private:
int x;
int y;
int result;
};
}
#include "BlockQueue.hpp"
#include <time.h>
#include "Task.hpp"
using namespace BlockQueueModule;
using namespace TaskModule;
void* Productor(void* args)
{
BlockQueue<Task>* bq = static_cast< BlockQueue<Task>*>(args);
while (1)
{
// sleep(2);
// 1.获取数据
int a = rand() % 10 + 1;
int b = rand() % 20 + 1;
Task t(a, b);
// 2.生产数据
bq->Equeue(t);
printf("生产了一个任务: %d+%d=?\n", t.X(), t.Y());
}
}
void* Consumer(void* args)
{
BlockQueue<Task>* bq = static_cast< BlockQueue<Task>*>(args);
while (1)
{
//sleep(1);
// 1.获取数据
Task t;
bq->Pop(&t);
// 2.处理数据
t.Excute();
printf("处理了一个任务: %d+%d=%d\n", t.X(), t.Y(), t.Result());
}
}
int main()
{
srand(time(nullptr)^ getpid());
BlockQueue<Task>* bq = new BlockQueue<Task>();
pthread_t p1, p2, p3, c1, c2;
pthread_create(&p1, nullptr, Productor, bq);
// pthread_create(&p2, nullptr, Productor, bq);
// pthread_create(&p3, nullptr, Productor, bq);
pthread_create(&c1, nullptr, Consumer, bq);
// pthread_create(&c2, nullptr, Consumer, bq);
pthread_join(p1, nullptr);
// pthread_join(p2, nullptr);
// pthread_join(p3, nullptr);
pthread_join(c1, nullptr);
// pthread_join(c2, nullptr);
delete bq;
return 0;
}

所以阻塞队列不仅仅可以用来传递数据,还可以用来处理某种任务。比如数据入库、访问缓存、访问网络、打印日志等等。

生产者消费中模型中,在访问某种结构的内存区域时,生产者和消费者是互斥访问的。内存区域是整个锁起来的,只能有一个执行流在里面,所以临界区是串行的,那么高效从何谈起?
我们不能只注意到生产者消费者和内存区域,我们还要注意到生产者要获取数据,生产者先获取数据再生产,消费者消费数据后还要进行数据的处理。那么现在生产者生产满了等消费者来消费,生产者可以先获取数据,然后消费者来消费,生产者获取到数据后消费者消费了一部分数据,那么生产者就可以继续生产数据。同理,消费者拿光了数据,消费者要进行数据的处理,生产者来生产数据,等消费者处理好数据就可以继续来拿数据。从这个角度来看它们就是并行的,放数据和拿数据实际上是比较快的,而获取数据和处理数据是比较费时间的。
3、线程同步——信号量
3.1、信号量
前置知识回顾:
进程间通信中信号量我们举过一个例子,超级VIP电影院。只能有一个人进去看电影,计数器为1,谁先申请到计数器资源谁就能进去看电影。这实际上就是把资源整体使用,我们前面的抢票、阻塞队列就是把资源整体使用的。所以任何时刻只有一个执行流访问临界资源,这就是互斥。而这个计数器值为1、0两态,我们称为二元信号量,本质上就是一个锁。
然后现在我们把资源分成很多块,同时使用信号量来保证不会有过量的线程访问临界资源,每个线程要访问共享资源首先要申请计数器资源。比如现在数组有10个元素,我们让线程先申请计数器资源,通过某种算法让线程访问不同的子区域,这样就可以让多个执行流并发访问多个子区域,所以资源也可以单独来使用。
1、信号量的本质就是一把计数器。
2、申请信号量本质是对资源的预定机制。
3、申请信号量,计数器--,P操作。释放信号量,计数器++,V操作。申请和释放信号量PV操作。
4、信号量要被所有线程看到并访问,并且我们知道计数器++/--不是原子的,你要保证别人的安全首先得保证自己的安全,所以PV操作被设计成原子的。
互斥锁是对资源的预定机制吗?加锁也是对资源的预定机制,对整个资源预定。加锁解锁也必须是原子的,加锁解锁在我看来就是对一个值为1的变量进行PV操作。所以加锁就是信号量的特殊情况。
现在假设信号量是10,然后十一个线程来申请信号量,那么有十个线程申请成功了,还有一个线程申请失败,那么这个线程也要去等待队列等,所以信号量也要有等待队列。当有线程释放信号量了,再去唤醒等待队列中的线程
所以信号量如何实现?不就是维护一个计数器变量,然后要保证这个计数器++/--是原子的,还要维护一个等待队列。这不就是互斥锁加条件变量吗?
也就是:

下面来看信号量的接口:

包含于头文件semaphore.h,但是也要指明链接的库文件pthread。
sem_init用来初始化信号量,使用sem_t定义一个对象,然后传给sem_init的第一个参数。
第二个参数pshared:0表示线程间共享,非零表示进程间共享。
第三个参数value:表示信号量初始值。

sem_destroy用来销毁信号量。

sem_wait用来申请信号量——P操作。sem_wait申请不到就会阻塞住,sem_trywait申请不到会返回。

sem_post用来释放信号量——V操作。
3.2、信号量封装
// Sem.hpp
#pragma once
#include <semaphore.h>
namespace SemModule
{
int defaultsemval = 1;
class Sem
{
public:
Sem(int value = defaultsemval)
:_init_value(value)
{
int n = ::sem_init(&_sem, 0, _init_value);
(void)n;
}
void P()
{
int n = ::sem_wait(&_sem);
(void)n;
}
void V()
{
int n = ::sem_post(&_sem);
(void)n;
}
~Sem()
{
int n = ::sem_destroy(&_sem);
(void)n;
}
private:
sem_t _sem;
int _init_value;
};
}
3.3、基于环形队列的生产者消费者模型

环形队列前置知识:逻辑上是环形的,但是物理结构上是一个数组。刚开始head和tail指向同一个位置,往里面放数据tail就++往后走,那么如何判断是否满了呢?有两种方式:1、空一个位置出来不放数据,当tail+1=head的时候说明队列满了。2、维护一个计数器,当计数器为N时说明容量满了。那么在物理结构上是一个数组,当走到数组结尾需要回到头部,我们只需要每次让pos++后%=N就可以保证了。
head是出队列所以代表消费者,tail是入队列所以代表生产者,那么现在这个环形队列为空为满我们可以直接使用信号量来表示,不需要再操心。任何人访问临界资源之前都必须申请信号量,信号量表示的就是资源数目。
对于生产者来说:关注的是剩余空间数目。
对于消费者来说:关注的是剩余数据的数目。
先定义int head = 0; int tail = 0;
起初空间为N,数据为0。
对于生产者:P(空间),ring[tail]=data,tail++,tail%=N, V(数据)。
对于消费者:P(数据),data=ring[head],head++,head%=N,V(空间)。
首先我们以单生产者单消费者进行分析:
1、当生产者和消费者访问同一个位置时:
1.1、此时队列为空:那么消费者P(数据)阻塞住,生产者P(空间)成功,然后生产数据,V(数据),那么消费者就可以申请成功。保证了生产者原子性先生产。
1.2、此时队列满了:那么生产者P(空间)阻塞住,消费者P(数据)成功,获取数据。保证了消费者原子性先消费。
为空为满保证了只有一个生产者或消费者可以进来,这就是互斥,这不是通过锁实现的,而是通过信号量实现的。为空时保证了生产者先生产,满了保证消费者先消费,有先后顺序,这就是同步。
所以情况一体现出了互斥和同步。
2、生产者和消费者访问不同位置:
这时候环形队列不为空也不为满,生产者和消费者访问不同位置,所以生产者和消费者可以并发访问。
基于以上分析,我们发现:
1、生产者无法把消费者套一个圈,因为环形队列满了生产者就不能继续生产了。
2、消费者无法超过生产者,当唤醒队列为空,消费者不能继续消费。
3、在同一个位置它们是互斥且同步的。
4、在不同位置生产者和消费者可以并发访问。
所以需要几个信号量?两个信号量,一个用来表示空间资源数目,一个用来表示数据资源数目。
下面基于我们封装的信号量实现单生产单消费的环形队列:
// Ringbuffer.hpp
#pragma once
#include <iostream>
#include <vector>
#include "Sem.hpp"
namespace RingBufferModule
{
using namespace SemModule;
template<typename T>
class RingBuffer
{
public:
RingBuffer(int cap)
:_ring(cap)
,_cap(cap)
,_p_step(0)
,_c_step(0)
,_datasem(0)
,_spacesem(cap)
{}
void Equeue(const T& in)
{
_spacesem.P();
_ring[_p_step] = in;
_p_step++;
_p_step %= _cap;
_datasem.V();
}
void Pop(T* out)
{
_datasem.P();
*out = _ring[_c_step];
_c_step++;
_c_step %= _cap;
_spacesem.V();
}
~RingBuffer()
{}
private:
std::vector<T> _ring; // 环,临界资源
int _cap; // 总容量
int _p_step; // 生产者位置
int _c_step; // 消费者位置
Sem _datasem; // 数据信号量
Sem _spacesem; // 空间信号量
};
}
// Main.cc
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include "RingBuffer.hpp"
using namespace RingBufferModule;
void* Productor(void* args)
{
RingBuffer<int>* rb = static_cast<RingBuffer<int>*>(args);
int data = 1;
while (true)
{
sleep(1);
rb->Equeue(data);
printf("生产了一个数据: %d\n", data);
data++;
}
}
void* Consumer(void* args)
{
RingBuffer<int>* rb = static_cast<RingBuffer<int>*>(args);
while (true)
{
// sleep(1);
int data;
rb->Pop(&data);
printf("消费了一个数据: %d\n", data);
}
}
int main()
{
RingBuffer<int>* rb = new RingBuffer<int>(5);
pthread_t p1, c1;
pthread_create(&p1, nullptr, Productor, rb);
pthread_create(&c1, nullptr, Consumer, rb);
pthread_join(p1, nullptr);
pthread_join(c1, nullptr);
return 0;
}
我们先让生产者慢一点,所以效果应该是生产一个消费一个:

下面我们让消费者慢一点,效果应该是先生产出一批数据,然后消费一个生产一个:

1、信号量这里为什么没有判断?
信号量本身就是表示资源数目,只要申请成功就一定有,不需要判断。抢票用的互斥量是整体申请整体使用。使用互斥量+条件变量实现的阻塞队列是整体申请局部使用。现在我们使用信号量实现的环形队列是局部申请局部使用。
2、如何实现多生产者多消费者呢?
我们现在已经实现了单生产者和单消费者的互斥同步关系,接下来根据三种关系我们还要考虑生产者和生产者之间的互斥关系,消费者和消费者的互斥关系。所以我们可以分别给生产者和消费者定义两把锁,这样就可以解决它们之间的互斥关系。
一把锁可以吗?答案是可以的,但是生产者和消费者就无法并发去跑了。因为当它们指向不同的位置时,只能有一个人能拿到锁。
下面使用pthread_mutex_t实现多生产者多消费者模型:

思考,方式一加锁对吗?
这样加锁就会导致每个线程进来必须申请锁,当某个线程在访问临界资源的时候,其他线程全部堵在锁这里了,当这个线程释放锁之后,其他线程才能申请锁进来再申请信号量。
如果我们在申请信号量之后加锁,那么当某个线程在访问临界资源的时候,其他线程也没闲着,其他线程先申请信号量资源。这时候尽可能减少锁持有时间,降低线程竞争提升并发性能。
还有个问题,当队列满了,生产者申请到锁,再进去P(空间)就会导致死锁。
因此我们需要把加锁放到申请信号量之后,同时解锁放在释放信号量之前,确保其他线程可以立即竞争锁。
下面使用我们自己封装的Mutex.hpp来实现多生产多消费:
// RingBuffer.hpp
#pragma once
#include <iostream>
#include <vector>
#include "Sem.hpp"
#include "Mutex.hpp"
namespace RingBufferModule
{
using namespace SemModule;
using namespace LockModule;
template<typename T>
class RingBuffer
{
public:
RingBuffer(int cap)
:_ring(cap)
,_cap(cap)
,_p_step(0)
,_c_step(0)
,_datasem(0)
,_spacesem(cap)
{}
void Equeue(const T& in)
{
_spacesem.P();
{
LockGuard lockguard(_p_lock);
_ring[_p_step] = in;
_p_step++;
_p_step %= _cap;
}
_datasem.V();
}
void Pop(T* out)
{
_datasem.P();
{
LockGuard lockguard(_c_lock);
*out = _ring[_c_step];
_c_step++;
_c_step %= _cap;
}
_spacesem.V();
}
~RingBuffer()
{}
private:
std::vector<T> _ring; // 环,临界资源
int _cap; // 总容量
int _p_step; // 生产者位置
int _c_step; // 消费者位置
Sem _datasem; // 数据信号量
Sem _spacesem; // 空间信号量
Mutex _p_lock; // 生产者互斥量
Mutex _c_lock; // 消费者互斥量
};
}
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include "RingBuffer.hpp"
using namespace RingBufferModule;
void* Productor(void* args)
{
RingBuffer<int>* rb = static_cast<RingBuffer<int>*>(args);
int data = 1;
while (true)
{
// sleep(1);
rb->Equeue(data);
printf("生产了一个数据: %d\n", data);
data++;
}
}
void* Consumer(void* args)
{
RingBuffer<int>* rb = static_cast<RingBuffer<int>*>(args);
while (true)
{
sleep(1);
int data;
rb->Pop(&data);
printf("消费了一个数据: %d\n", data);
}
}
int main()
{
RingBuffer<int>* rb = new RingBuffer<int>(5);
pthread_t p1, p2, p3, c1, c2;
pthread_create(&p1, nullptr, Productor, rb);
pthread_create(&p2, nullptr, Productor, rb);
pthread_create(&p3, nullptr, Productor, rb);
pthread_create(&c1, nullptr, Consumer, rb);
pthread_create(&c2, nullptr, Consumer, rb);
pthread_join(p1, nullptr);
pthread_join(p2, nullptr);
pthread_join(p3, nullptr);
pthread_join(c1, nullptr);
pthread_join(c2, nullptr);
delete rb;
return 0;
}

4、日志与策略模式
计算机中的日志是记录系统和软件运行中发生事件的文件,主要作用是监控运行状态、记录异常信息,帮助快速定位问题并支持程序员进行问题修复。它是系统维护、故障排查和安全管理的重要工具。
日志通常含有以下信息:时间戳、日志等级、日志内容、文件名和行号、进程线程相关ID等。
下面我们采用设计模式中的策略模式实现一个日志,方便后续测试代码,我们要设计的日志输出信息格式如下:
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [16] - hello world
// Log.hpp
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <sstream>
#include <memory>
#include <filesystem> // C++17
#include <unistd.h>
#include "Mutex.hpp"
namespace LogModule
{
using namespace LockModule;
// 获取当前系统时间
std::string CurrentTime()
{
time_t time_stamp = ::time(nullptr);
struct tm curr;
localtime_r(&time_stamp, &curr);
char buffer[1024];
snprintf(buffer, sizeof(buffer), "%04d-%02d-%02d %02d:%02d:%02d",
curr.tm_year + 1900,
curr.tm_mon + 1,
curr.tm_mday,
curr.tm_hour,
curr.tm_min,
curr.tm_sec
);
return buffer;
}
// 日志的默认路径和默认文件名
const std::string defaultlogpath = "./log/";
const std::string defaultlogname = "log.txt";
// 日志等级
enum class LogLevel
{
DEBUG = 1,
INFO,
WARNING,
ERROR,
FATAL,
};
std::string Level2String(LogLevel level)
{
switch(level)
{
case LogLevel::DEBUG:
return "DEBUG";
case LogLevel::INFO:
return "INFO";
case LogLevel::WARNING:
return "WARNING";
case LogLevel::ERROR:
return "ERROR";
case LogLevel::FATAL:
return "FATAL";
default:
return "NONE";
}
}
// 设计模式——策略模式
class LogStrategy
{
public:
virtual ~LogStrategy() = default;
virtual void SyncLog(const std::string& message) = 0; // 纯虚函数
};
// 控制台策略
class ConsoleLogStrategy : public LogStrategy
{
public:
ConsoleLogStrategy(){}
~ConsoleLogStrategy(){}
virtual void SyncLog(const std::string& message)
{
LockGuard lockguard(_mutex);
std::cout << message << std::endl;
}
private:
Mutex _mutex;
};
// 文件级(磁盘)策略
class FileLogStrategy : public LogStrategy
{
public:
FileLogStrategy(const std::string& logpath = defaultlogpath, const std::string& logname = defaultlogname)
:_logpath(logpath)
,_logname(logname)
{
LockGuard lockguard(_mutex);
// 如果目录存在直接返回,不存在就创建
if (std::filesystem::exists(_logname))
{
return;
}
try
{
std::filesystem::create_directories(_logpath);
}
catch(const std::filesystem::filesystem_error& e)
{
std::cerr << e.what() << '\n';
}
}
~FileLogStrategy(){}
virtual void SyncLog(const std::string& message)
{
LockGuard lockguard(_mutex);
std::string log = _logpath + _logname;
std::ofstream out(log, std::ios::app); // 追加写入
if (!out.is_open())
{
return;
}
out << message << "\n";
out.close();
}
private:
std::string _logpath;
std::string _logname;
Mutex _mutex;
};
class Logger
{
public:
Logger()
{
// 默认采用控制台刷新策略
_strategy = std::make_shared<ConsoleLogStrategy>();
}
void EnableConsoleLog()
{
_strategy = std::make_shared<ConsoleLogStrategy>();
}
void EnableFileLog()
{
_strategy = std::make_shared<FileLogStrategy>();
}
~Logger(){}
// [2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [16] - hello world
class LogMessage
{
public:
LogMessage(LogLevel level, std::string filename, int line, Logger& logger)
:_currtime(CurrentTime())
,_level(level)
,_pid(::getpid())
,_filename(filename)
,_line(line)
,_logger(logger)
{
std::stringstream ss;
ss << "[" << _currtime << "] "
<< "[" << Level2String(_level) << "] "
<< "[" << _pid << "] "
<< "[" << _filename << "] "
<< "[" << _line << "] - ";
_loginfo = ss.str();
}
template<typename T>
LogMessage& operator<<(const T& info)
{
std::stringstream ss;
ss << info;
_loginfo += ss.str();
return *this;
}
~LogMessage()
{
if (_logger._strategy)
{
_logger._strategy->SyncLog(_loginfo);
}
}
private:
std::string _currtime; // 时间
LogLevel _level; // 日志等级
pid_t _pid; // 进程PID
std::string _filename; // 源文件名称
int _line; // 行号
Logger& _logger; // 根据不同刷新策略进行刷新
std::string _loginfo; // 一条完整的日志记录
};
LogMessage operator()(LogLevel level, const std::string& filename, int line)
{
return LogMessage(level, filename, line, *this);
}
private:
std::shared_ptr<LogStrategy> _strategy; // 日志刷新的策略方案
};
Logger logger;
#define LOG(Level) logger(Level, __FILE__, __LINE__)
#define ENABLE_CONSOLE_LOG() logger.EnableConsoleLog()
#define ENABLE_FILE_LOG() logger.EnableFileLog()
}
下面编写一个代码来测试:
#include "Log.hpp"
using namespace LogModule;
int main()
{
ENABLE_FILE_LOG();
LOG(LogLevel::DEBUG) << "hello file";
LOG(LogLevel::DEBUG) << "hello file";
LOG(LogLevel::DEBUG) << "hello file";
LOG(LogLevel::DEBUG) << "hello file";
ENABLE_CONSOLE_LOG();
LOG(LogLevel::DEBUG) << "hello world";
LOG(LogLevel::DEBUG) << "hello world";
LOG(LogLevel::DEBUG) << "hello world";
LOG(LogLevel::DEBUG) << "hello world";
return 0;
}


下面对调用链进行分析:我们定义了一个宏LOG(level),在main函数中LOG(LogLevel::DEBUG)在预处理阶段就会被替换成右边这样,__FILE__表示的是当前文件名称,__LINE__表示的当前行号,那么就会直接调用Logger中的operator()函数,然后给我们返回一个临时的LogMessage对象,而LogMessage重载了operator<<,所以可以像cout那样写,operator<<还写了模板参数,所以支持任意类型,它会把右边的信息拼接到_loginfo中,返回LogMessage&是为了支持连续调用operator<<,当这一行结束临时对象生命周期也就结束了,自动调用LogMessage的析构函数将数据刷新到磁盘或控制台。
对于前面等级时间等信息,我们在构造LogMessage对象的时候就先通过sringstream格式化到string字符串中,然后赋值给_loginfo。后面调用operator<<再拼接到_loginfo后面即可。
写入到磁盘我们使用了C++17的filesystem,使用filesystem::exists来判断文件是否创建,如果创建了就直接返回,如果没有创建就使用filesystem::create_derectories来创建文件。
写入磁盘如果在多线程情况下可能会出问题,输出到控制台在多线程下也可能会混乱,所以我们使用自己封装的互斥量来加锁。
5、线程池
5.1、线程池设计
线程池:一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。
线程池的应用场景:
1、需要大量线程完成任务,且完成任务的时间比较短。如WEB服务器完成网页请求。
2、对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
线程池的种类:
1、固定数量线程池,循环从任务队列中获取任务处理。
2、浮动线程池,动态创建和释放线程。
我们实现的是固定数量的线程池。
线程需要从任务队列中拿任务所以需要互斥,任务队列中并不一定时刻有任务所以需要条件变量,所以我们直接把之前线程封装ThreadModule、互斥封装LockModule、条件变量封装CondModule、日志设计LogModule拿过来用。
// ThreadPool.hpp
#pragma once
#include <iostream>
#include <string>
#include <queue>
#include <vector>
#include "Mutex.hpp"
#include "Cond.hpp"
#include "Thread.hpp"
#include "Log.hpp"
namespace ThreadPoolModule
{
using namespace LockModule;
using namespace CondModule;
using namespace ThreadModule;
using namespace LogModule;
using thread_t = std::shared_ptr<Thread>;
static const int defaultnum = 5;
void DefaultTest()
{
while (true)
{
LOG(LogLevel::DEBUG) << "我是一个测试方法";
sleep(1);
}
}
template<typename T>
class ThreadPool
{
void HandlerTask(std::string name)
{
LOG(LogLevel::INFO) << "线程:" << name << "进入HandlerTask逻辑";
while (true)
{
// 1.获取任务
T t;
{
LockGuard lockguard(_lock);
while (IsEmpty() && _isrunning)
{
_wait_num++;
_cond.Wait(_lock);
_wait_num--;
}
if (IsEmpty() && !_isrunning)
break;
t = _taskq.front();
_taskq.pop();
}
// 2.处理任务
t(name);
}
LOG(LogLevel::INFO) << "线程:" << name << "退出HandlerTask逻辑";
}
bool IsEmpty() { return _taskq.empty(); }
public:
ThreadPool(int num = defaultnum)
:_num(num)
,_wait_num(0)
,_isrunning(false)
{
for (int i = 0; i < _num; i++)
{
_threads.push_back(std::make_shared<Thread>(std::bind(&ThreadPool::HandlerTask, this, std::placeholders::_1)));
LOG(LogLevel::INFO) << "构建线程" << _threads[i]->Name() << "对象...成功";
}
}
void Start()
{
if (_isrunning) return;
_isrunning = true;
for (auto& thread_ptr : _threads)
{
thread_ptr->Start();
LOG(LogLevel::INFO) << "启动线程" << thread_ptr->Name() << "...成功";
}
}
void Wait()
{
for (auto& thread_ptr : _threads)
{
thread_ptr->Join();
LOG(LogLevel::INFO) << "回收线程" << thread_ptr->Name() << "...成功";
}
}
void Equeue(const T& in)
{
LockGuard lockguard(_lock);
if (!_isrunning) return;
_taskq.push(std::move(in));
if (_wait_num > 0)
_cond.Notify();
}
void Stop()
{
LockGuard lockguard(_lock);
if (_isrunning)
{
_isrunning = false;
if (_wait_num > 0)
_cond.NotifyAll();
}
}
~ThreadPool()
{}
private:
std::vector<thread_t> _threads; // 线程智能指针数组
int _num; // 线程数量
std::queue<T> _taskq; // 任务队列
Mutex _lock; // 互斥量
Cond _cond; // 条件变量
int _wait_num; // 在条件变量下等待的线程数
bool _isrunning; // 线程池是否启动
};
}
// Task.hpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include "Log.hpp"
using namespace LogModule;
using task_t = std::function<void(std::string)>;
void Push(std::string name)
{
LOG(LogLevel::DEBUG) << "我是一个推送数据到服务器的一个任务,我正在被执行" << "[" << name << "]";
}
// ThreadPool.cc
#include "ThreadPool.hpp"
#include "Task.hpp"
using namespace ThreadPoolModule;
int main()
{
ENABLE_CONSOLE_LOG();
// ENABLE_FILE_LOG();
std::unique_ptr<ThreadPool<task_t>> tp = std::make_unique<ThreadPool<task_t>>();
tp->Start();
int cnt = 10;
while (cnt--)
{
tp->Equeue(Push);
sleep(1);
}
tp->Stop();
tp->Wait();
return 0;
}
运行结果如图:

下面对ThreadPool代码继续分析:

1、我们vector里面保存的是智能指针shared_ptr对象,然后线程执行的是ThreadPool类内的HanderTask函数。HanderTask参数为string,无返回值。所以对于之前线程封装我们使用的是无参的V1版本,但是需要将func_t改为多一个string参数的类型。然后在Routine中调用_func函数需要传入名字,因为我们任务队列里入的任务实际上是一个一个的可执行对象,也就是Task.hpp里面的Push函数,而Push函数需要一个参数name来表示线程的名字。那么在使用make_shread构造的时候,由于执行的是类内的函数,所以需要使用bind将this指针作为固定的参数传过去,然后HanderTask里面还有个参数name直接写为placehoders::_1即可。

2、线程执行HandlerTask方法需要获取任务,然后再执行任务。获取任务的过程需要加锁,但是处理任务的时候就不需要加锁了。Equeue是用来入任务的,所以也需要加锁,添加任务后判断是否有线程在条件变量下等待,如果有就唤醒一个线程。
3、通过_isrunning来表示线程池是否还在运行,刚开始初始化为false。然后调用Start函数的时候需要先判断_isrunning是否为true,如果为true说明启动过了直接返回。如果为false就将_isrunning设置为true然后启动所有线程,此时所有线程就回去条件变量下等待。
4、如果要停止呢?1、我们要让线程自己退出。2、我们要把历史的所有任务处理完。3、我们不能再往任务队列里添加任务了。所以基于3,我们Equeue需要先判断线程池是否在运行,如果不在运行就不能再添加任务了。那么如何停止呢?停止需要访问_wait_num,所以也需要加锁。首先要保证线程池在运行,如果不在运行就什么也不做,如果在运行我们先把_isrunning设置为false,这样就无法再添加任务了。接着我们需要把历史任务全都处理掉,所以判断是否有线程在条件变量下等待,如果有就将线程全部唤醒,这样线程就会去取任务了。
注意HandlerTask里面的处理逻辑:如果队列为空并且线程池在运行,那么线程就需要到条件变量下等待。走到if这里有几种情况:1、队列不为空,线程池还在运行。2、队列不为空,线程池停止运行。3、线程池为空,线程池不在运行。对于情况1:线程池还在运行,if条件不成立,线程继续取任务去处理。对于情况2:队列不为空,if条件不成立,线程继续取任务处理,这时候就是处理历史任务了,那么当线程处理完任务后再次循环时,while条件永远不成立,所以不会再去条件变量下等,而当任务队列为空,所有任务被处理完了,if条件成立,线程退出。对于情况3:就是情况2最后的结果,线程直接退出。
通过Stop停止后所有线程处理完任务就会退出,然后再调用Wait将所有线程回收。
5.2、单例模式线程池
什么是单例模式:某些类只应该具有一个对象(实例), 就称之为单例。
单例有两种模式:懒汉模式和饿汉模式。
吃完饭,立刻洗碗,这种就是饿汉方式。因为下一顿吃的时候可以立刻拿着碗就能吃饭。
吃完饭,先把碗放下,然后下一顿饭用到这个碗了再洗碗,就是懒汉方式。
懒汉方式最核新的思想是 “延时加载”,从而能够优化服务器的启动速度。
下面使用懒汉模式实现单例线程池:
// ThreadPool.hpp
#pragma once
#include <iostream>
#include <string>
#include <queue>
#include <vector>
#include "Mutex.hpp"
#include "Cond.hpp"
#include "Thread.hpp"
#include "Log.hpp"
namespace ThreadPoolModule
{
using namespace LockModule;
using namespace CondModule;
using namespace ThreadModule;
using namespace LogModule;
using thread_t = std::shared_ptr<Thread>;
static const int defaultnum = 5;
void DefaultTest()
{
while (true)
{
LOG(LogLevel::DEBUG) << "我是一个测试方法";
sleep(1);
}
}
template<typename T>
class ThreadPool
{
void HandlerTask(std::string name)
{
LOG(LogLevel::INFO) << "线程:" << name << "进入HandlerTask逻辑";
while (true)
{
// 1.获取任务
T t;
{
LockGuard lockguard(_lock);
while (IsEmpty() && _isrunning)
{
_wait_num++;
_cond.Wait(_lock);
_wait_num--;
}
if (IsEmpty() && !_isrunning)
break;
t = _taskq.front();
_taskq.pop();
}
// 2.处理任务
t(name);
}
LOG(LogLevel::INFO) << "线程:" << name << "退出HandlerTask逻辑";
}
bool IsEmpty() { return _taskq.empty(); }
ThreadPool(int num = defaultnum)
:_num(num)
,_wait_num(0)
,_isrunning(false)
{
for (int i = 0; i < _num; i++)
{
_threads.push_back(std::make_shared<Thread>(std::bind(&ThreadPool::HandlerTask, this, std::placeholders::_1)));
LOG(LogLevel::INFO) << "构建线程" << _threads[i]->Name() << "对象...成功";
}
}
ThreadPool(const ThreadPool<T>&) = delete;
const ThreadPool<T>& operator=(const ThreadPool<T>&) = delete;
public:
static ThreadPool<T>* GetInstance()
{
if (instance == nullptr)
{
LockGuard lockguard(mutex);
if (instance == nullptr)
{
LOG(LogLevel::INFO) << "单例首次被执行,需要加载对象...";
instance = new ThreadPool<T>();
instance->Start();
}
}
return instance;
}
void Start()
{
if (_isrunning) return;
_isrunning = true;
for (auto& thread_ptr : _threads)
{
thread_ptr->Start();
LOG(LogLevel::INFO) << "启动线程" << thread_ptr->Name() << "...成功";
}
}
void Wait()
{
for (auto& thread_ptr : _threads)
{
thread_ptr->Join();
LOG(LogLevel::INFO) << "回收线程" << thread_ptr->Name() << "...成功";
}
}
void Equeue(const T& in)
{
LockGuard lockguard(_lock);
if (!_isrunning) return;
_taskq.push(std::move(in));
if (_wait_num > 0)
_cond.Notify();
}
void Stop()
{
LockGuard lockguard(_lock);
if (_isrunning)
{
_isrunning = false;
if (_wait_num > 0)
_cond.NotifyAll();
}
}
~ThreadPool()
{}
private:
std::vector<thread_t> _threads; // 线程智能指针数组
int _num; // 线程数量
std::queue<T> _taskq; // 任务队列
Mutex _lock; // 互斥量
Cond _cond; // 条件变量
int _wait_num; // 在条件变量下等待的线程数
bool _isrunning; // 线程池是否启动
static ThreadPool<T>* instance; // 线程池单例指针
static Mutex mutex; // 只用来保护第一次单例对象的创建
};
template<typename T>
ThreadPool<T>* ThreadPool<T>::instance = nullptr;
template<typename T>
Mutex ThreadPool<T>::mutex;
}
#include "ThreadPool.hpp"
#include "Task.hpp"
using namespace ThreadPoolModule;
int main()
{
ENABLE_CONSOLE_LOG();
// ENABLE_FILE_LOG();
// std::unique_ptr<ThreadPool<task_t>> tp = std::make_unique<ThreadPool<task_t>>();
ThreadPool<task_t>::GetInstance()->Start();
int cnt = 10;
while (cnt--)
{
ThreadPool<task_t>::GetInstance()->Equeue(Push);
sleep(1);
}
ThreadPool<task_t>::GetInstance()->Stop();
ThreadPool<task_t>::GetInstance()->Wait();
return 0;
}

多线程第一个获取单例对象的时候,可能导致单例对象被多次创建所以需要加锁。使用双重if判断+互斥锁。
6、线程安全和重入问题
线程安全:多个线程在访问共享资源时,能够正确地执行,不会相互干扰或破坏彼此的执行结果。⼀般而言,多个线程并发同一段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或者静态变量进行操作,并且没有锁保护的情况下,容易出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
目前我们遇到函数重入有两种情况:1、多线程重入函数。2、当进程调用该函数,收到信号后再次调用该函数导致重入。
常见不可重入的情况:
1、调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
2、调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
3、可重入函数体内使用了静态的数据结构。
函数是可重入的,那就是线程安全的。线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
比如抢票对共享资源加锁保护,如果在临界区中递归调用该函数就会导致死锁,所以线程安全不一定是可重入的。
7、常见锁概念
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
下面以两个线程一二和两把锁AB为例,线程一申请了A锁,线程二申请了B锁,然后线程一还要申请B锁,线程二还要申请A锁,线程一二对于自己持有的锁不释放,同时申请对方的锁,这样就会导致死锁,永久等待。
一个线程有没有可能死锁?有可能,比如在临界区线程再次申请锁,就会导致死锁。
死锁的四个必要条件:
1、互斥条件:资源每次只能被一个执行流访问。
2、请求与保持条件:一个执行流因请求资源而阻塞时,对已获取的资源不释放。
3、不剥夺条件:一个执行流已获取的资源,在未使用完之前,不能强行剥夺。
4、循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
如何避免死锁?破坏四个必要条件之一。
1、破坏互斥条件,说白了就是不使用互斥锁。
2、破坏请求与保持条件,比如线程当前申请了A锁,然后申请B锁失败了,那就把A锁释放了。
3、破坏不剥夺条件,线程当前申请了A锁,然后申请B锁阻塞,因为B锁被其他线程持有了,将持有B锁的线程拿的B锁直接释放掉,就是剥夺B锁。
4、破环循环等待条件,上面两个线程死锁的例子,线程一等线程二,线程二等线程一,所以形成了循环等待。那么我们不要让他们循环等待即可,比如原来线程一先申请A锁,然后申请B锁,线程二实现申请B锁然后再申请A锁。那么我们就让它们都先申请A锁,申请A锁成功再申请B锁。让他们加锁顺序一致。
其他常见的锁:
悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
自旋锁和读写锁,下面介绍。
8、STL、智能指针和线程安全
STL中的容器是否是线程安全的?
不是,STL设计初衷是将性能挖掘到极致,一旦涉及到加锁,那么就会对性能有很大影响。所以STL默认不是线程安全的,如果多线程环境下使用,使用者要自行保证线程安全。
智能指针是否是线程安全的?
unique_ptr禁止拷贝,所以无法被多线程共享,因此是线程安全的。
shared_ptr本身是线程安全的,对引用计数进行++/--是线程安全的,但是它所指向的对象不是线程安全的。
weak_ptr引用计数也是线程安全的。
9、自旋锁
自旋锁是一种多线程同步机制,用于保护共享资源免受并发访问的影响。在多个线程尝试获取锁时,它们会持续自旋(即在一个循环中不断检查锁是否可用)而不是立即进入休眠状态等待锁的释放。这种机制减少了线程切换的开销,适用于短时间内锁的竞争情况。但是不合理的使用,可能会造成 CPU 的浪费。

如上图,我们的代码结构是先加锁,然后进入临界区,然后再解锁。其他的就是非临界区。
下面举个例子说明:你今天去找你同学,到你同学宿舍楼下,然后打电话叫他下来,你同学说我可能还需要一个小时才下来你再等等,那么这时候你会一直等吗?你可能会去外面网吧上个玩,玩两把游戏,然后一个小时后再来找你同学。那么你去网吧的路上和你从网吧回到你同学宿舍楼下,这也是需要时间的。那么如果今天你同学跟你说你等等,我还有一分钟就下来了,那么这时候你就不会走开再去干其他事情了,你就会在楼下等他下来。
所以我们平时等人的时候,等人的时长决定了我们等待的方式。
那么多线程加锁,只有一个线程能拿到锁,其他线程就要阻塞等待,阻塞等待不就是将线程状态由R改为S,并把线程从运行队列拿走放到等待队列中,那么把线程唤醒就需要将线程状态由S改为R,然后从等待队列拿走放到运行队列,这也是开销。换句话说,线程阻塞等待和被唤醒就相当于你去网吧再从网吧回来,这路上也需要花时间,也是开销。
如果今天线程在临界区里面执行的时间很短,那么你让其他线程都去阻塞等待了,之后再将它们唤醒,这样就需要不小的开销,所以你可以让线程申请失败不阻塞,继续不断申请,直到申请成功,这就是自旋锁。如果线程在临界区执行的时间比较长,那么就不能用自旋锁了,该让线程阻塞还是得让线程阻塞。
自旋锁原理:
自旋锁通常使用一个共享的标志位(如一个布尔值)来表示锁的状态。当标志位为true时,表示锁已被某个线程占用;当标志位为 false 时,表示锁可用。当一个线程尝试获取自旋锁时,它会不断检查标志位:
1、如果标志位为 false,表示锁可用,线程将设置标志位为 true,表示自己占用了锁,并进入临界区。
2、如果标志位为 true(即锁已被其他线程占用),线程会在一个循环中不断自旋等待,直到锁被释放。
优点:
1、低延迟:自旋锁适用于短时间内的锁竞争情况,因为它不会让线程进入休眠状态,从而避免了线程切换的开销,提高了锁操作的效率。
2、减少系统调度开销:等待锁的线程不会被阻塞,不需要上下文切换,从而减少了系统调度的开销。
缺点:
1、CPU 资源浪费:如果锁的持有时间较长,等待获取锁的线程会一直循环等待,导致 CPU 资源的浪费。
2、可能引起活锁:当多个线程同时自旋等待同一个锁时,如果没有适当的退避策略,可能会导致所有线程都在不断检查锁状态而无法进入临界区,形成活锁。
使用场景:
1、短暂等待的情况:适用于锁被占用时间很短的场景,如多线程对共享数据进行简单的读写操作。
2、多线程锁使用:通常用于系统底层,同步多个 CPU 对共享资源的访问。

atomic_flag_test_and_set 函数检查 atomic_flag 的当前状态。如果atomic_flag 之前没有被设置过(即其值为 false 或“未设置”状态),则函数会将其设置为 true(或“设置”状态),并返回先前的值(在这种情况下为false)。如果atomic_flag 之前已经被设置过(即其值为 true),则函数不会改变其状态,但会返回 true。
下面介绍pthread库的自旋锁接口:

可以使用phtread_spinlock_t定义一个自旋锁,然后通过pthread_spin_init初始化,通过pthread_spin_destroy销毁。

pthread_spin_init的第二个参数pthread,PTHREAD_PROCESS_PRIVATE表示同一个进程内多线程使用,PTHREAD_PROCESS_SHARED表示在任意进程多个线程使用。

pthread_spin_lock表示线程不断自旋申请锁,pthread_spin_unlock表示解锁。pthread_spin_trylock申请锁,申请失败返回,不会自旋。
我们以抢票为样例写一份代码:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <time.h>
const int N = 4;
pthread_spinlock_t spinlock;
int ticket = 10000;
void* GetTicket(void* args)
{
const char* name = static_cast<char*>(args);
while (true)
{
pthread_spin_lock(&spinlock);
if (ticket > 0)
{
usleep(1000);
printf("%s[%ld]get a ticket: %d\n", name, pthread_self(), ticket--);
usleep(50);
pthread_spin_unlock(&spinlock);
}
else
{
pthread_spin_unlock(&spinlock);
break;
}
}
return nullptr;
}
int main()
{
pthread_spin_init(&spinlock, PTHREAD_PROCESS_PRIVATE);
pthread_t tid1, tid2, tid3, tid4;
pthread_create(&tid1, nullptr, GetTicket, (void*)"thread-1");
pthread_create(&tid2, nullptr, GetTicket, (void*)"thread-2");
pthread_create(&tid3, nullptr, GetTicket, (void*)"thread-3");
pthread_create(&tid4, nullptr, GetTicket, (void*)"thread-4");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
pthread_join(tid4, nullptr);
pthread_spin_destroy(&spinlock);
return 0;
}
不加锁的情况下会导致抢票抢到负数:

加锁了就不会抢到负数了:

10、读写锁
读者写者问题:读者写者也需要遵循"321"原则,三种关系,两种角色,一个交易场所。
我们以出黑板报为例子,老师经常让写字好看的同学去出黑板报,出黑板报的同学就是写者,我们在底下看的人就是读者。我在看黑板报的时候其他同学也能看,黑板报被所有其他同学共享。而出黑板报的同学如果有一个人在写,那么另一个人就不能来写了,不然就会影响到别人。
写者和写者:互斥
有人在往交易场所写数据的时候,其他人就不能来写,不然可能就把别人写的覆盖了,所以写者和写者之间是互斥关系。
读者和写者:互斥&&同步
写者在写的时候读者不能来读,因为写者可能只写了一部分数据,读者来读就只读到一部分,这就是数据不一致问题,所以需要互斥。写者写完需要通知读者来读,不然写者写完了结果发现没有人来读,那么就没有意义。同时读者读完了就要通知写者来写。今天你看了出的黑板报,过了一段时间你再看发现还是没有更新,你就会让出黑板报的人赶紧去写去更新。那么今天黑板报出好了,结果没有人来读,过了一段时间又擦掉了,那么这黑板报出的就没有意义。所以读者和写者还需要有同步关系。
读者和读者:并发
读者读数据并不会将数据取走,所以不会出问题,它们可以并发读取。
读者写者VS生产消费
其他两种关系是类似的,关键在于读者不会取走数据,而消费者会将数据取走,所以需要互斥。

下面来看一段伪代码:
首先定义一个reader_count表示读者的数量,count_lock用来保护reader_count。
当读者要读的时候,先加count_lock锁,然后判断是不是第一个读者,如果是第一个读者也就是reader_count=0时,申请writer_lock。如果写者在写,那么第一个读者就会阻塞住,同时后续的读者会阻塞在count_lock处,所以读者全都进不来。当写者释放后,第一个读者就可以申请到writer_lock,然后++reader_count,释放count_lock,然后就可以读取数据了。那么当第一个读者在读的时候,其他读者就都可以申请count_lock进来然后由于不是第一个读者,所以不会走if,只进行读者数量++就解锁去读数据了。第一个读者读完走了对读者数量--,所以也需要加count_lock锁,同时需要判断是不是最后第一个读者,如果是最后一个读者就需要把writer_lock释放掉,这样写者才能进来写,如果不是最后第一个读者,说明还有读者在读,所以不能释放writer_lock。
下面介绍pthread库的读写锁接口:

使用pthread_rwlock_t定义一把读写锁,可以使用PTHREAD_RWLOCK_INITIALIZER初始化全局锁。
如果定义局部的读写锁,使用pthread_rwlock_init初始化,第二个参数属性设置为nullptr。使用pthread_rwlock_destroy销毁读写锁。

pthread_rwlock_rdlock用于读者加锁,tryrdlock申请不到就返回。

pthread_rwlock_wrlock用于写者加锁。

读者写者解锁统一使用pthread_rwlock_unlock。
下面实现一份代码:
#include <iostream>
#include <vector>
#include <pthread.h>
#include <time.h>
#include <unistd.h>
const int N1 = 5;
const int N2 = 5;
int share_data = 1;
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
void* Reader(void* args)
{
int* num = static_cast<int*>(args);
while (true)
{
pthread_rwlock_rdlock(&rwlock);
printf("reader thread-%d[%ld]read data: %d\n", *num, pthread_self(),share_data);
sleep(1); // 模拟读取数据
pthread_rwlock_unlock(&rwlock);
}
delete num;
return nullptr;
}
void* Writer(void* args)
{
int* num = static_cast<int*>(args);
while (true)
{
pthread_rwlock_wrlock(&rwlock);
int data = rand() % 100;
share_data = data;
printf("writer thread-%d[%ld]write data: %d\n", *num, pthread_self(), data );
sleep(1); // 模拟写入数据
pthread_rwlock_unlock(&rwlock);
}
delete num;
return nullptr;
}
int main()
{
srand(time(nullptr) ^ getpid());
std::vector<pthread_t> v;
for (int i = 0; i < N1; i++)
{
int* num = new int(i + 1);
pthread_t tid;
pthread_create(&tid, nullptr, Reader, (void*)num);
v.push_back(tid);
}
for (int i = 0; i < N2; i++)
{
int* num = new int(i + 1);
pthread_t tid;
pthread_create(&tid, nullptr, Writer, (void*)num);
v.push_back(tid);
}
for (const auto& e : v)
{
pthread_join(e, nullptr);
}
return 0;
}

我们发现一直是读者在读,写者根本就没机会写入。我们将N1改为1。这样读者和写者1:5

读者写者这里无法很好的观察现象。
但是我们发现刚开始那种情况,读者一直在读,写者就无法写,这样就会导致写者饥饿问题。
在读者写者这里有两种策略:
读者优先(Reader-Preference)
在这种策略中,系统会尽可能多地允许多个读者同时访问资源(比如共享文件或数据),而不会优先考虑写者。这意味着当有读者正在读取时,新到达的读者会立即被允许进入读取区,而写者则会被阻塞,直到所有读者都离开读取区。读者优先策略可能会导致写者饥饿(即写者长时间无法获得写入权限),特别是当读者频繁到达时。
写者优先(Writer-Preference)
在这种策略中,系统会优先考虑写者。当写者请求写入权限时,系统会尽快地让写者进入写入区,即使此时有读者正在读取。这通常意味着一旦有写者到达,所有后续的读者都会被阻塞,直到写者完成写入并离开写入区。写者优先策略可以减少写者等待的时间,但可能会导致读者饥饿(即读者长时间无法获得读取权限),特别是当写者频繁到达时。
在刚开始测试的时候读者写者5:5,我们发现读者一直在读,说明pthread库默认是读者优先的策略。

被折叠的 条评论
为什么被折叠?



