前面我们讲了线程的概念以及如何创建与控制线程,接下来我们来对线程的细节与线程之间的问题进行一些讲解;
1.线程的互斥
互斥就是相互排斥,我们可以理解为对立竞争不相容;线程的互斥则是线程之间在对于临界资源竞争时相互排斥的情况,通过这种情况可以处理一些对于临界资源访问所出现的问题,互斥是一种解决问题的方式
既然互斥是一种解决方案,那么它是用来解决什么样的问题的方案呢?
1.1需要互斥的场景
我们通过带入出现问题的场景来理解互斥:
假设我们设计了一个抢票系统,这个系统中有很多个线程,这些线程并发的进行抢票,直到票被抢完时停止抢票;
下面是代码示例:
#include <iostream>
#include <vector>
#include <pthread.h>
#include <string>
#include <unistd.h>
#include <sys/types.h>
using namespace std;
int ticket = 50;
//pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
struct threadData
{
string threadName;
threadData(int num)
{
threadName = "thread" + to_string(num);
}
};
void *getTicket(void *args)
{
threadData *data = (threadData *)args;
while (true)
{
// pthread_mutex_lock(&lock);
if (ticket > 0)
{
usleep(10);//增加抢票的过程使得现象明显
printf("%s get ticket sucess! remain ticket: %d\n", data->threadName.c_str(), --ticket);
// pthread_mutex_unlock(&lock);
}
else
{
printf("%s quit!\n", data->threadName.c_str());
// pthread_mutex_unlock(&lock);
break;
}
// usleep(10);//抢完票的后序工作
}
return (void *)0;
}
int main()
{
vector<pthread_t> tids;
for (int i = 0; i < 3; i++)
{
pthread_t tid;
threadData *data = new threadData(i);
pthread_create(&tid, nullptr, getTicket, data);
tids.push_back(tid);
}
for (auto tid : tids)
{
void *retData;
pthread_join(tid, &retData);
}
return 0;
}
上面的代码中,我们创建了三个线程,每个线程都执行同一个函数进行抢票,当票数为0时就退出抢票循环;
现象:
这个就是问题所出现的场景,为什么会出现这样的错误呢?
其实早就在我们前面的进程间通信篇的最后部分就谈论过这样的问题;这是非原子性行为访问临界资源时产生的错误;由于我们抢票时的操作不是原子的,--ticket操作其实是由多条汇编指令所组成的;在线程运行到票数为1的时候,线程上下文不断切换,使得线程0,1,2都进入了循环中,此时票数只有最后一张,而三个线程都进入循环进行--,使得ticket的数量变为了0,-1,-2;
可以通过我之前画的这张图理解:
所以如果想要解决这样的问题就得在某个线程抢票时不会被其他线程所影响,这个时候互斥的方法就闪亮登场了;
1.2通过互斥的方式解决问题
如何使用互斥解决问题呢?我们首先得直到互斥的原理;
其实互斥就是通过给临界资源加锁来使得临界资源一次只能被一个线程所访问,从而使得让线程运行环境变安全;(用时间换安全)
第一步
所以我们想要通过互斥解决上面的问题,我们首先需要创建互斥量;
1.2.1互斥量
这个锁我们也可以称它为互斥量,互斥量是pthread原生线程库中的数据结构;我们只需要使用接口就可以创建;
互斥量的类型是pthread_mutex_t
我们可以通过下面两种方式创建:
1.静态方式创建:
创建全局或者静态变量:
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER
PTHREAD_MUTEX_INITIALIZER是一个宏变量,这个变量是专门用来初始化这样的全局mutex的;
2.动态方式创建
首先声明变量:
pthread_mutex_t mutex;
再初始化变量:
第一个参数mutex为需要初始化的mutex互斥量,传入前面声明的数据的地址即可;
第二个参数attr,是用来传一个设置互斥量属性的结构体的指针的,我们一般使用默认的,一般设置为NULL即可;
最后进行使用完后销毁即可:
参数与上面函数第一个参数相同,传mutex地址即可;
第二步
创建好了mutex后,我们线程在访问临界资源时申请锁,申请成功后,其他线程申请锁时申请失败阻塞在lock代码处等待锁申请,这样也就自然不会出现多个线程同时访问同一临界资源的情况,当申请锁成功的线程访问完成临界资源后unlock解锁,允许其他线程申请锁,其他线程申请成功,解除阻塞;
互斥量的lock与unlock
申请锁与释放锁的函数;
其实互斥量就是一个结构体的数据结构
1.3互斥的代码实现
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
while (true)
{
pthread_mutex_lock(&lock);
if (ticket > 0)
{
usleep(10);//增加抢票的过程使得现象明显
printf("%s get ticket sucess! remain ticket: %d\n", data->threadName.c_str(), --ticket);
pthread_mutex_unlock(&lock);
}
else
{
printf("%s quit!\n", data->threadName.c_str());
pthread_mutex_unlock(&lock);
break;
}
usleep(10);//抢完票的后序工作
}
将第一份代码的注释代码取消注释后;我们看到我们在ticket判断和ticket--的地方都使用了lock与unlock包含;
现象:
1.4互斥特点的总结
1.互斥是一种解决由于线程并发访问临界资源时锁产生问题的一种解决方式
2.互斥是一种用时间换安全的方式
3.互斥使得临界资源的使用代码串行执行
4.lock与unlock之间的代码越少越好,因为它们之间的代码是串行执行的,这里的太多会影响线程的效率
5.线程在执行lock与unlock之间的代码时是可以进行上下文切换的,因为线程线程的切换是持有锁切换的
1.5互斥原理总结
互斥是通过创建一个互斥锁,锁住我们的临界区代码,当线程执行临界区代码之前,需要先申请锁,锁申请成功时才可以访问临界区代码,而其他线程在这个时候是无法访问临界区的,因为此时的锁被占用了,其他线程会阻塞在lock处,当线程直线完了临界区代码时,unlock释放锁,让其他线程获取锁,从而执行它的临界区代码;这样的操作使得,线程访问临界区代码的动作在其他线程看来是原子的;使得临界资源的访问变得安全;
lock与ulock的底层:
lock底层:
先将0写入到寄存器al中,再将互斥量与al的值进行交换(互斥量一开始是需要被初始化,初始化为1)再进行判断al寄存器中的值,进行阻塞和申请成功的操作;
这个过程中mutex中的1只有一个,多个线程进行lock操作时,都会与al中的数据进行交换,al的数据一开始都是0,只有第一个执行xchgb的线程可以成功的将mutex中的1与寄存器中的0进行交换,使得al中的数据为1,从而满足申请条件,此时无论如何进行上下文交换,都无法让其他线程满足申请条件,因为唯一的mutex被交换到了某个线程的上下文数据的寄存器中;也正是有了这样的操作使得lock函数在线程角度是原子的;
ulock底层:
unlock操作就是只需要将mutex重新置为1即可让正在阻塞中的线程可以获取到这个1从而使得满足申请锁的条件;
1.6对锁的封装
C++中RAII风格的锁:
代码实现:
头文件
class Mutex
{
private:
pthread_mutex_t _mutex;
public:
Mutex()
{
pthread_mutex_init(&_mutex, nullptr);
}
~Mutex()
{
pthread_mutex_destroy(&_mutex);
}
void lock()
{
pthread_mutex_lock(&_mutex);
}
void unlock()
{
pthread_mutex_unlock(&_mutex);
}
};
class lockGuard
{
public:
lockGuard(Mutex *mutex)
: _mutex(mutex)
{
_mutex->lock();
}
~lockGuard()
{
_mutex->unlock();
}
private:
Mutex *_mutex;
};
.c文件
struct threadData
{
string _threadName;
Mutex *_mutex;
threadData(int num, Mutex *mutex)
: _threadName("thread" + to_string(num)), _mutex(mutex)
{
}
threadData() = default;
};
void *routine(void *args)
{
threadData *data = static_cast<threadData *>(args);
{
lockGuard lock(data->_mutex);
for (int i = 0; i < 5; i++)
{
printf("I am %s\n", data->_threadName.c_str());
}
sleep(1);
}
}
int main()
{
pthread_t tid;
Mutex mutex;
for(int i=0;i<3;i++)
{
threadData *data = new threadData(i, &mutex);
pthread_create(&tid, nullptr, routine, data);
}
void *retData;
pthread_join(tid, &retData);
return 0;
}
这里我将init函数也封装进去了,这样可以Mutex类的创建也不需要手动的init和destroy了;
并且通过{}来限制了blockguard的生命周期:
现象:
2.死锁
这种情况,其实就是线程1申请的锁是线程2所持有的,而线程2申请的锁是线程1所持有的,它们两个锁互相持有对方的资源,但是又不释放自己的资源导致的,程序一直处于阻塞状态的情况,这就叫做死锁;
一个线程死锁的情况:一次进行了两次lock;
多个线程死锁:
3.互斥场景引发的问题——饥饿
我们将互斥代码实现的最后部分的usleep(10)注释掉
我们会看到这样一个现象:
而0,1线程完全阻塞在了lock位置没有任何的操作;这就是由于某个线程0在unlock锁后在上下文交换之前又马上回到循环开头申请锁,其他线程的竞争不过线程0,从而使得抢票过程只由线程0单独完成的情况;
由于某个线程的竞争能力太强而导致其他线程饥饿的问题;
为了解决这种情况产生的问题;又引出了新的解决方案——同步;
4.线程的同步
同步也是一个为了解决互斥问题不足的解决方案;
4.1同步的过程(概念)
同步是如何做到解决饥饿问题的呢?同步通过让线程申请锁具有一定的顺序从而保证了所有线程可以依此访问临界资源,从而解决饥饿问题;那么如何让线程的申请变得有序呢?我们之间看下面的图片辅助理解吧:
什么是条件变量呢?在代码中的体现就是一个pthread_cond_t类型的变量我们可以叫他cond,这个变量也是一个结构体,这个条件变量可以辅助线程有序的申请临界资源;其实cond条件变量和我们的互斥量mutex(锁)的使用是非常相似的;
4.2条件变量
声明条件变量:
pthread_cond_t cond;
初始化与销毁:
它初始化的调用与mutex的十分相似,这里就不做过多讲解了;
将线程放入等待队列中:
pthread_cond_wait函数
这个函数可以将线程放入等待队列中等待,第一个参数是cond变量的地址,第二个参数是锁的地址;
唤醒函数:
pthread_cond_signal与pthread_cond_broadcast;
上面先通过图片示例讲解了同步的过程,接下来我们用代码的方式实现以下同步:
4.3同步的代码实现
#include<pthread.h>
#include<unistd.h>
#include<vector>
using namespace std;
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond=PTHREAD_COND_INITIALIZER;
struct threadData
{
string _threadName;
threadData(int num)
: _threadName("thread" + to_string(num))
{
}
threadData() = default;
};
void *routine(void *args)
{
threadData *data = static_cast<threadData *>(args);
{
for (int i = 0; i < 5; i++)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond,&mutex);
printf("I am %s\n", data->_threadName.c_str());
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond);
}
}
}
int main()
{
vector<pthread_t> tids;
for(int i=0;i<3;i++)
{
pthread_t tid;
threadData *data = new threadData(i);
pthread_create(&tid, nullptr, routine, data);
tids.push_back(tid);
}
sleep(1);
pthread_cond_signal(&cond);
void *retData;
for(auto it:tids)
{
void *retData;
pthread_join(it,&retData);
}
return 0;
}
上面的代码,我们可以看到我们让3个线程运行同一个函数,都进行打印自己线程的名字,我们通过同步后产生的现象:
如果我们不进行上面的同步,将同步的代码注释掉:
我们现在再来解释一下代码:
4.4同步实现的讲解
我们的代码做了什么呢;我们上面的代码中我们既有mutex又有cond,它们两个的存在帮助我们的代码实现了同步;首先在我们先对线程访问临界区代码(假设routine中的printf是访问临界资源的动作)上锁,之后我们的线程会先释放锁,然后再进入等待队列;
这一步做了什么呢呢?为什么我们的线程需要释放锁?为什么这一步需要再申请锁的后面?
1.因为我们线程进入进入等待队列时就会被阻塞住,如果此时不释放锁,线程携带锁进入阻塞队列一定会使得所有其他的线程无法获得锁也一直阻塞,从而导致死锁的发生,所以线程在进入等待队列前是会释放锁的;
2.为什么进入等待队列这个动作一定要在申请锁之后呢,因为临界区的进入一次只会有一个线程,而进入等待队列一般是因为临界区中的某个临界资源满足了进入等待队列的条件,从而线程判断出此条件后才会进入等待队列的,所以进入等待队列前一定会先访问临界资源,而为了保证安全访问临界资源的动作是一定要上锁后进行的,所以进入等待队列的动作一定要在申请锁之后(这需要通过后面的生产消费模型讲解之后才能更好的理解),其次在上面那点中,我们进入等待队列前需要先释放锁,而如果代码顺序改变,我们都没有申请锁,又怎么可以释放锁呢,所以这也是代码顺序是如此的原因;
知道了上面这些之后,我们再来理一理上面代码的思路,首先多个线程都需要访问临界资源,为了保证安全临界资源被上了锁(临界区代码使用lock和unlock包住),其中一个线程成功申请到了锁,这个线程之间就开始执行wait代码了,也就是说它马上释放了锁,进入了等待队列中,等待被唤醒,而其他的线程原本在lock中阻塞突发发现锁空闲了,又马上竞争锁,之后又有一个线程成功竞争到了锁,然后再进入等待队列排在前面那个线程的后面,就这样依此类推所有的线程都进入了等待队列;我们再主线程中等待了1秒(sleep(1))让所有线程进入等待队列,之后再唤醒其中一个线程;当队头的线程被唤醒时,它在进入等待队列前先释放了锁,所以现在它退出等待队列也要再重新获得锁(如果锁被占用它会等待锁空闲再退出),然后再执行printf函数行为,所有动作执行完后,唤醒下一个线程,并释放锁;这就是上面代码的完整思路;
接下来我们将上面同步的场景增强一些,我们来介绍一个新的模型: