需要云服务器等云产品来学习Linux的同学可以移步/–>腾讯云<–/官网,轻量型云服务器低至112元/年,优惠多多。(联系我有折扣哦)
上节中我们学习了多线程的概念和控制,但是实际上多线程的程序会有很多问题的出现。
首先我们看一个多线程程序的例子,一个模拟抢票的程序
#include
#include
#include "Thread.hpp"
int ticket = 10000;
void *getTicket(void *arg) // 执行抢票的逻辑
{
while (true)
{
if (ticket > 0) // 当票量大于0的时候才能抢
{
usleep(1245); // 模拟抢票前执行的操作
std::cout << static_cast<const char *>(arg) << "正在抢票" << ticket-- << std::endl; // 抢票
}
else // 如果票量小于0就不抢了
{
break;
}
}
return nullptr;
}
int main()
{
// 创建4个线程用于抢票
Thread *thread1 = new Thread(getTicket, (void *)"thread-1");
Thread *thread2 = new Thread(getTicket, (void *)"thread-2");
Thread *thread3 = new Thread(getTicket, (void *)"thread-3");
Thread *thread4 = new Thread(getTicket, (void *)"thread-4");
thread1->join();
thread2->join();
thread3->join();
thread4->join();
return 0;
}
这里可以看出多线程的程序出现了一些问题,那么怎么解决呢?
临界资源:在计算机中存在着很多共享资源,这些资源是会被很多线程、进程共享的,为了保证这些资源的安全,所以需要被保护起来,这些被保护起来的共享资源就是临界资源
临界区:临界资源被访问总是存在访问这些临界资源的代码,这些访问临界资源的代码叫做临界区
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成,不会有中间状态
现在我们来分析引入中的问题产生的原因:
我们看上面的抢票逻辑的代码,在第11行的地方访问了共享资源ticket
线程1在执行了第11行的代码之后,线程可不可以被切换?当然可以
线程1在此时被切换之后,(我们把情况推向极端,此时ticket就是1),然后线程2被切换过来执行
此时ticket还是1然后执行11行代码进入if的代码块执行完本次循环之后再切回线程1
此时线程1可以直接执行14行代码,将ticket–,此时就出现了抢到-1的情况
这就是上述问题产生的原因,那么如何解决呢?
我们在上面说到了原子性,对于一件事,要么全做,要么不做,这里如果让11行到吗到14行代码变成原子性的,也就能够让这个问题得到解决
使用互斥量mutex就能完成这个任务
我们知道,一般来说,一条汇编指令就是原子的,但是像++i
或者i++
都不是原子的,有可能会有数据一致性问题
这是因为这种操作对应了三条汇编指令:
load
:将共享变量i从内存加载到寄存器中updata
:更新寄存器里面i的值store
:将新的值从寄存器协会i的内存地址
那如果要解决以上问题,就需要做到三点
mutex也是pthread库提供的。形象的来说,我们也可以把它叫做锁。所以也就有了互斥锁这种说法
1. 互斥锁的类型
2. 定义初始化与销毁
头文件:
#include
函数原型:
int pthread_mutex_destory(pthread_mutex_t *mutex); // 销毁
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); // 初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 全局变量的初始化方式
参数解释:
mutex:需要操作的锁
attr:是一个pthread_mutexattr_t类型的联合体,这里我们不关心,设为NULL即可
函数描述:
pthread_mutex_init:初始化一个互斥锁
pthread_mutex_destory:销毁一个互斥锁
锁有初始化就要有销毁
返回值:
成功返回0,失败返回错误码
注意:mutex的定义可以是全局的也可以是局部的,如果是全局的话,可以直接使用宏来初始化
PTHREAD_MUTEX_INITIALIZER
,同时也就不需要销毁了,使用init函数初始化的就一定要使用destory销毁
小tips:restrict
关键字表示mutex
和attr
指针不会被函数以外的方式访问或修改,可以提高编译器对互斥锁初始化的优化
3. 互斥锁的加锁和解锁
头文件:
#include
函数原型:
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数解释:
mutex:要操作的互斥锁
函数描述:
pthread_mutex_lock:加锁。如果互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功;发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
pthread_mutex_trylock:尝试给mutex加锁,如果此时互斥量未锁状态,或者竞争到互斥量,就加锁然后返回0,如果没有竞争到或者已经被锁定,就执行其他内容,不会被挂起
pthread_mutex_unlock:给传入的mutex解锁
返回值:
lock和unlock调用成功返回0否则返回错误码,trylock如果获取到这个互斥锁,就返回0,否则返回错误码
使用互斥量改进抢票代码:
#include
#include
#include "Thread.hpp"
int ticket = 10000;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *getTicket(void *arg)
{
while (true)
{
pthread_mutex_lock(&mutex); // 给mutex加锁
if (ticket > 0)
{
usleep(1245);
std::cout << static_cast<const char *>(arg) << "正在抢票" << ticket-- << std::endl;
pthread_mutex_unlock(&mutex); // 给mutex解锁
}
else
{
pthread_mutex_unlock(&mutex); // 给mutex解锁
break;
}
}
return nullptr;
}
int main()
{
Thread *thread1 = new Thread(getTicket, (void *)"thread-1");
Thread *thread2 = new Thread(getTicket, (void *)"thread-2");
Thread *thread3 = new Thread(getTicket, (void *)"thread-3");
Thread *thread4 = new Thread(getTicket, (void *)"thread-4");
thread1->join();
thread2->join();
thread3->join();
thread4->join();
return 0;
}
1. 如何看待锁
经过了前面的使用,我们发现**锁本身就要被多个线程共同看见,所以锁本身就是一个共享资源!!**我们知道锁是用来保护共享资源的,那锁的安全由谁来保护?
实际上我们对锁的操作pthread_mutex_lock
和pthread_mutex_unlock
本身就是原子的,所以加锁解锁的过程天生就是安全的。谁持有锁谁就进入临界区,否则就会在阻塞等待
2. mutex加锁解锁的汇编实现
事实上,为了实现互斥锁的操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据进行交换,由于只有一条指令,所以保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行期间另一个处理器的交换指令也只能等待总线周期
lock:
movb $0, %al
xchgb %al, mutex
if(%al > 0){
return 0;
} else
挂起等待;
goto lock;
unlock:
movb $1, mutex
唤醒等待Mutex的线程;
return 0;
汇编代码的分析:
加锁过程:
首先将寄存器al的内容清0,本质上是将该线程的上下文中对应的al的内容清零,(因为每个线程都有一个对应的al寄存器内容)
然后使用原子的指令xchgb将al寄存器和内存中mutex的值进行交换,如果当前锁没有被申请,那么可以理解成mutex中的值为1,如果被申请,那么就是0。
交换后检查al寄存器中的值,如果大于0,那么就说明当前线程竞争到了锁资源,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。
解锁过程
- 首先把内存中mutex的值置1,使得下一次使用xchgb指令能够交换到1进寄存器,表示获取锁
- 唤醒正在等待mutex的线程,让其竞争这个锁
当然,使用pthread版本的锁是C语言风格的,而且较为复杂,有可能会写出逻辑异常的代码,在C++的开发中,我们希望使用的还是一个C++式的锁,也就是面向对象的结构,那么接下来我们可以手动封装出一个C++版本的mutex。顺手实现成RAII风格的,帮助我们自动加锁和解锁
/*Mutex.hpp*/
#pragma once
#include
class Mutex
{
public:
Mutex(pthread_mutex_t *lock_p = nullptr) : _lock_p(lock_p) {} // 构造函数
void lock() // 加锁
{
if (_lock_p)
pthread_mutex_lock(_lock_p);
}
void unlock() // 解锁
{
if (_lock_p)
pthread_mutex_lock(_lock_p);
}
~Mutex() {} // 析构函数
private:
pthread_mutex_t *_lock_p;
};
class LockGuard // RAII风格的锁的实现
{
public:
LockGuard(pthread_mutex_t *mutex) : _mutex(mutex) // 构造函数
{
_mutex.lock(); // 在构造函数中加锁
}
~LockGuard() // 析构函数
{
_mutex.unlock(); // 在析构函数中解锁
}
private:
Mutex _mutex;
};
改写抢票程序:
#include
#include
#include "Thread.hpp"
#include "Mutex.hpp"
int ticket = 10000;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *getTicket(void *arg)
{
while (true)
{
LockGuard lockguard(&lock); // 这里在栈区创建Mutex对象并加锁,在这个代码块结束之后解锁,可以手动设置代码块,以达到在指定位置解锁的功能
{
if (ticket > 0)
{
std::cout << static_cast<const char *>(arg) << "正在抢票" << ticket-- << std::endl;
}
else
{
break;
}
}
usleep(1245);
}
return nullptr;
}
int main()
{
Thread *thread1 = new Thread(getTicket, (void *)"thread-1");
Thread *thread2 = new Thread(getTicket, (void *)"thread-2");
Thread *thread3 = new Thread(getTicket, (void *)"thread-3");
Thread *thread4 = new Thread(getTicket, (void *)"thread-4");
thread1->join();
thread2->join();
thread3->join();
thread4->join();
return 0;
}
一把锁可以造成死锁,比如说在抢票的时候,如果在申请锁之后再加申请一次锁导致死锁。
为什么会有死锁?一定是你用了锁,锁保证临界资源的安全
多线程访问我们可能出现数据不一致的问题 多线程共享全局资源全局资源
解决问题的同时带来了新的问题:死锁,任何技术都有自己的边界,在解决问题的同时一定可能会引入新的问题
死锁四个必要条件:
互斥:一个共享资源每次被一个执行流使用
请求与保持:一个执行流因请求资源而阻塞,对已有资源保持不放
不剥夺:一个执行流获得的资源在未使用完之前,不能强行剥夺
环路等待条件:执行流间形成环路问题,循环等待资源
避免死锁的方法:
也有一些算法可以避免死锁(了解):死锁检测算法、银行家算法
但是,在实际开发过程中,我们要尽可能少的使用锁
举一个例子:现在学校里面有一个VIP自习室,里面只有一个座位,统一时间容纳一个人自习,大家都想去这个自习室学习,其中某个人起床特别早,抢到了这个自习室的名额,然后就一直在里面学习,其他人想要使用这个自习室,就在外面排队,等这个人出来之后才能进去。中午这个人想出去吃饭,但是刚出门又犹豫了,好不容易抢到的自习室,一定要多学一会,此时这个人刚出门,所以离自习室最近,所以肯定能抢到这个自习室的名额。这个往复很多次,进行了很多次的资源争夺,但是每次都是这个人能抢到,其他人抢不到。这种情况显然是不符合逻辑的,所以需要采用一些措施来避免这种情况。
在回到上文,我们可以发现一个现象,当我们给抢票代码加锁之后,每次抢票的都是同一个线程,这样就造成了其他线程的饥饿现象。为了解决这个问题:我们在数据安全的情况下让这些线程按照一定的顺序进行访问,这就是线程同步
首先我们明确几个概念:
当一个线程互斥地访问某个变量时,它可能发现在其他线程改变状态之前,它什么也做不了(因为被阻塞)
例如一个线程访问队列时,发现队列为空,它只能等待,直到其他线程将一个节点添加到队列中。这种情况就需要用到条件变量
使用条件变量主要包括两个动作:
条件变量通常需要配合互斥锁一起使用。
1. 初始化和销毁
头文件:
#include
函数原型:
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr *restrict attr);//初始化
int pthread_cond_destory(pthread_cond_t cond);
参数解释:
cond:需要初始化的条件变量,类型是pthread_t
attr:这里我们不关心,设为NULL即可
函数描述:
初始化和销毁条件变量
返回值:
如果调用成功,函数返回0,否则返回错误码
当然,和mutex一样,如果创建全局的条件变量,那么可以直接使用PTHREAD_COND_INITIALIZER
初始化
2. 等待条件满足
头文件:
#include
函数原型:
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t mutex);
参数解释:
cond:在指定条件变量下等待
mutex:传入一个锁,这里我们后面在解释
返回值:
成功返回0,失败返回错误码
3. 唤醒等待
头文件:
#include
函数原型:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
参数解释:
cond:要操作的条件变量
函数描述:
pthread_cond_broadcast:唤醒等待队列中首个线程
pthread_cond_signal:唤醒等待队列中所有线程
返回值:
成功返回0,失败返回错误码
一个小例子:
#include
#include
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void *start_routine(void *args)
{
while (true)
{
pthread_mutex_lock(&mutex); // 假设这里是临界区,使用mutex保护
pthread_cond_wait(&cond, &mutex); // 判断条件变量条件
std::cout << static_cast<const char *>(args) << "is running" << std::endl;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
pthread_t t1, t2, t3;
pthread_create(&t1, nullptr, start_routine, (void *)"thread-1");
pthread_create(&t2, nullptr, start_routine, (void *)"thread-2");
pthread_create(&t3, nullptr, start_routine, (void *)"thread-3");
while (true)
{
getchar();
pthread_cond_signal(&cond);
std::cout << "main thread is waked up a thread" << std::endl;
}
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
return 0;
}
举个例子:某公司进行招聘:应聘者要面试,大家不能同时进入房间进行面试,但是没有由于没有组织,上一个人面试完之后,面试官打开门准备面试下一个,一群人在外面等待面试,但是有人抢不过别人,人太多了,面试官记不住谁面试过了,所以有可能一个人面试完之后又去面试了,造成其他人饥饿问题,这时候效率很低
后来HR重新进行管理:设立一个等待区,先排队去等待区进行等待面试,现在每个人都进行排队,都有机会面试了,而这个等待区就是条件变量,如果一个人想面试,先得去排队等待区等待,未来所有应聘者都要去条件变量等
条件不满足的时候,线程必须去某些定义好的条件变量上进行等待
所以我们可以知道,条件变量应该是一个结构体(struct cond)里面包含状态,队列,而我们定义好的条件变量包含一个队列,不满足条件的线程就链接在这个队列上进行等待。
本节完。。。