1.一些基本概念
1.临界资源:凡是被线程共享访问的资源都是临界资源(多线程、多进程打印数据到显示器,显示器就是临界资源)
2.临界区:代码中访问临界资源的代码(在代码中,不是所有的代码都是进行访问临界资源的。而访问临界资源的代码区域我们称之为临界区)
3.对临界区进行保护的功能,本质就是对临界资源的保护。方式:互斥或者同步
4.互斥:在任意时刻,只允许一个执行流访问某段代码(访问某部分资源〉,就可以称之为互斥
5.同步:一般而言,让访问临界资源的过程在安全的前提下(一般都是互斥and原子的),让访问资源具有一定的顺序性(具有合理性,比如可以防止饥饿问题)
2.多线程不安全的情况
造成线程并不安全的因素有很多,这里讨论非原子性的情况,可以通过加锁来解决该问题
比如我们对一个变量进行--操作,a--并不是原子的
如果有一个线程要执行a--操作,需要经过下面三步
- 1.load :将共享变量a从内存加载到寄存器中
- 2.update : 更新寄存器里面的值,执行-1操作
- 3.store :将新值,从寄存器写回共享变量a的内存地址
假设a的初始值是1000,线程A执行a--操作
如果线程A执行完第二步,此时线程A时间片到了,线程A处理完的寄存器中的数据999保存在它自己的上下文中,然后在等待队列里面等待调度。
此时线程B来了,把三步操作全部完成,让a减少了900,此时内存中的a被修改为100,然后线程A再继续完成最后一步操作,把999写回了内存中,此时内存中的a值又变成了999,这就导致了结果错误,也就是线程不安全的情况。
3.互斥锁pthread_mutex的使用
- 定义锁
pthread_mutex_t mutex;
- 初始化锁
方法一:静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法二:动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
mutex:要初始化的互斥量
attr:NULL
- 释放锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
1.使用 PTHREAD_ MUTEX_ INITIALIZER静态分配初始化的互斥量不需要销毁
2.不要销毁一个已经加锁的互斥量
3.已经销毁的互斥量,要确保后面不会有线程再尝试加锁
- 加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
- 解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
实现线程安全的抢票程序
主要是对上述几个方法的使用
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
const int N = 1000;
class Tickets
{
private:
int tickets; //当前票的数量
pthread_mutex_t mtx; //定义互斥锁mtx
//这里可以将pthread_mutex互斥锁定义为成员变量,或者之后用到的地方定义为成员变量都可以
public:
Tickets():tickets(N)
{
pthread_mutex_init(&mtx,nullptr); //使用动态初始化的方式初始化互斥锁
}
~Tickets()
{
pthread_mutex_destroy(&mtx); //释放锁
}
bool getTicket() //返回值为bool类型,后续让多个线程抢票,如果没有抢到票就需要停下来
{
bool res = true;
//如果pthread_mutex互斥锁不定义为成员变量,可以在这里定义成静态局部变量
pthread_mutex_lock(&mtx); //加锁
if(tickets > 0)
{
usleep(10000); //休眠1000us,模拟抢票时间
cout << "我的线程id是: " << pthread_self() << ",我抢到的票是第" << N-tickets+1 <<"张" << endl;
tickets--;
}
else
{
cout << "我的线程id是: " << pthread_self() << ",我发现票已经被抢完了" << endl;
res = false;
}
tickets--;
pthread_mutex_unlock(&mtx); //解锁
return res;
}
};
void *threadRoutine(void *args)
{
pthread_detach(pthread_self()); //主线程中不关注新线程的返回值,所以可以detach分离线程
Tickets *it = (Tickets*)args; //注意需要将参数强转成Tickets*类型
cout << "tests" << endl;
while(true)
{
bool res = it->getTicket();
if(res == false)
{
break;
}
}
}
int main()
{
int n = 5;
pthread_t tid[n];
Tickets *ticket = new Tickets(); //ticket对象之后要作为参数传入线程执行函数中,所以这里定义为指针
for(int i = 0; i < n; i++)
{
pthread_create(&tid[i],nullptr,threadRoutine,ticket);
}
pthread_exit(nullptr); //新线程全部detach,主线程要先退出,否则会执行到return语句,从而进程被终止,其他新线程也就会被终止
return 0;
}
4.互斥锁pthread_mutex的原理
该原理主要是为了证明mutex锁本身是线程安全的,也就是lock和unlock操作是原子的。
有一行代码底层操作的时候只有一行汇编的时候,这行代码就是原子的。
原理分析:
1.mutex这个互斥量首先被保存在内存中,初始值是1,表示可以锁没有被占用。
2.此时线程A和线程B竞争锁,线程A竞争成功,首先执行mov指令,将0值设置到寄存器中,此时寄存器中的数值就是线程A的上下文数据,就算mov完线程A被切走,回来的时候还是会恢复当前的上下文继续执行;
3.第二步线程A执行xchgb指令,将寄存器中的值(0)和内存中mutex的值(1)进行交换,mutex互斥锁保证自己线程安全的核心就在这里,用一条汇编指令,将CPU寄存器中的内容与内存中的内容完成了交换。此时就算A被切走,其他线程获取内存中mutex的值的时候获取到的都是0,没有办法拿到锁,只能等待A解锁。
4.第三步线程A判断寄存器中的内容是否>0,交换完以后,线程A相当于把获取到的互斥量(互斥锁)mutex保存在自己的上下文数据中了,也就是此时该寄存器中的内容,如果从内存中获取的mutex值>0,那就进入临界区,访问临界资源。
5.如果线程A占用着锁,其他线程获取内存中mutex值的时候,会进入else逻辑,挂起等待,直到线程A解锁的时候,才会被唤醒,从而go to lock继续竞争锁。
6.当线程A在执行临界区内的代码时,线程A可能会被切走,进入等待队列,此时内存中mutex值并不会发生变化。线程A被切走的时候,它的上下文数据也会被保护起来一起切走,此时锁的数据是保存在上下文中的,所以线程A相当于是抱着锁走的。在这个期间,其他线程不可能成功竞争到锁。
7.站在其他线程的视角,线程A访问临界区就是原子性的了