013 Linux_互斥
前言
本文将会向你介绍互斥的概念,如何加锁与解锁,互斥锁的底层原理是什么
线程ID及其地址空间布局
每个线程拥有独立的线程上下文:一个唯一的整数线程ID, 独立的栈和栈指针,程序计数器,通用的寄存器和条件码。
和其他线程共享的进程上下文的剩余部分:整个用户虚拟地址空间,那就是上图的数据段,堆以及所有的共享库代码和数据区域,也共享所有打开文件的集合。
pthread_create函数会产生一个线程id,存放到第一个参数指向的地址中,如果你将这个id打印出来会发现特别大,其实这串数字是一个地址,这个地址就是一个虚拟地址,这样在主线程产生的临时数据都压在系统栈上,而其他线程则存储在pthread库提供的栈内。
这里要注意的是线程的寄存器的内容是不共享的,通常栈区是被相应线程独立访问的,但是还是可能出现一个线程去访问另一个线程中的栈区的情况。如果这个线程获得了指向另一个线程栈区的指针,那么它就可以读写这个栈的任何部分。
互斥相关概念
临界资源:多线程执行流共享的资源就叫做临界资源 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
互斥量: 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个 线程,其他线程无法获得这种变量。
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之 间的交互。
多个线程并发的操作共享变量,会带来一些问题。
模拟抢票
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
using namespace std;
#define NUM 4
class threadData
{
public:
//构造函数
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname; //线程名
};
int tickets = 1000;
void* getTicket(void *args)
{
//安全类型转换
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while(tickets)
{
if(tickets > 0)
{
usleep(10000);
printf("who=%s, get a ticket: %d\n", name, tickets);
tickets--;
}
else break;
}
printf("%s ... quit\n", name);
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData *> thread_datas;
for(int i = 1; i <= NUM; i++)
{
pthread_t tid;
//构造一个对象指针
threadData *td = new threadData(i);
//存放对象指针
thread_datas.push_back(td);
//创建线程,将对象指针作为参数传递给getTicket
pthread_create(&tid, nullptr, getTicket, thread_datas[i-1]);
//管理线程id
tids.push_back(tid);
}
//线程等待
for(auto thread : tids)
{
pthread_join(thread, nullptr);
}
for(auto td : thread_datas)
{
delete td;
}
return 0;
}
现象:明明存在对tickets的条件判断,可是票数依旧被抢到了负数
原因是:多个线程可能同时检查tickets大于0,然后同时减少tickets的值。这可能导致tickets的值减少到负数,因为每个线程都可以执行tickets–操作,即使tickets已经为0。
从底层来看:
Ticket–这一步骤在汇编上是三条代码
1、先将tickets读入到cpu的寄存器中
2、cpu内部进行–操作
3、将计算结果的数据写回内存
举个例子
引入一个概念:线程在执行的时候,将共享数据,加载到CPU寄存器的本质:把数据的内容,变成了自己的上下文—以拷贝的方式,给自己单独拿了一份(也就是说会将数据保存到自己的上下文当中)
1、当执行线程一的时候,倘若线程一刚执行完第一步就被切走
2、假设线程2一直能执行完3个步骤,重复执行,没有被中断,最终票数被减到10了,当刚要执行第一步骤的时候此时被切换,此时将减到10的数据保存到自己的上下文当中
3、切换到线程一,并不是紧接着执行第二步,而是恢复自己的上下文数据1000到CPU当中,它认为数据是1000,最后将计算出来的999写回内存,此时线程二先前将票数减到10的工作白做了
4、而此时如果再次切换到线程二,线程二再将内存中的tickets读取到寄存器当中(票数就又变成了999),因此可以看出:多个线程并发的操作共享变量,会带来一些问题,是线程切换导致的
我们也可以看出无论是–还是++其实都不是原子性的,因为它们会在cpu调度是被打断。
互斥锁
为了解决上述抢票的问题(共享数据被读哦现场并发访问造成数据不一致的问题)
需要做到三点:
代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
加锁:模拟抢票
引入函数接口
互斥锁
方法1,静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法2,动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
参数:
mutex:要初始化的互斥量
attr:NULL
销毁互斥量需要注意:
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误码
调用 pthread_ lock 时,可能会遇到以下情况: 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
using namespace std;
#define NUM 4
class threadData
{
public:
//构造函数
threadData(int number, pthread_mutex_t *mutex)
{
threadname = "thread-" + to_string(number);
lock = mutex;
}
public:
string threadname; //线程名
pthread_mutex_t *lock;
};
int tickets = 500;
void* getTicket(void *args)
{
//安全类型转换
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while(true)
{
//上锁
pthread_mutex_lock(td->lock);
if(tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket: %d\n", name, tickets);
tickets--;
pthread_mutex_unlock(td->lock);
}
else
{
//解锁
pthread_mutex_unlock(td->lock);
break;
}
}
printf("%s ... quit\n", name);
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData *> thread_datas;
pthread_mutex_t lock;
//初始化锁
pthread_mutex_init(&lock, nullptr);
for(int i = 1; i <= NUM; i++)
{
pthread_t tid;
//构造一个对象指针
threadData *td = new threadData(i, &lock);
//存放对象指针
thread_datas.push_back(td);
//创建线程,将对象指针作为参数传递给getTicket
pthread_create(&tid, nullptr, getTicket, thread_datas[i-1]);
//管理线程id
tids.push_back(tid);
}
//线程等待
for(auto thread : tids)
{
pthread_join(thread, nullptr);
}
for(auto td : thread_datas)
{
delete td;
}
return 0;
}
现象:上锁了,但是票数都是由一个线程抢走了
为什么会有这样的现象呢?
故事时间
在纯互斥环境里,如果锁分配不够合理,容易导致其它线程的饥饿问题
比如:存在一个独立自习室,规矩:出去后,必须把钥匙放到指定的位置。倘若自习室里的人需要去吃饭,然后出门,看到一大堆人在等他出来,然后他又进去了,因为他不想失去这把🔑,但是由于他距离门更近一些,因此他对钥匙的竞争更强一些
我们应该对此再加一个规矩:出来的人,不能立马重新申请锁,想要继续申请,必须排到队列的最后面,外面来的,必须排队
了解上述的故事之后,我们可以对代码进行如下修改:
互斥量实现原理探究
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下
1、当线程切换时,线程要把它的上下文数据带走(即把在寄存器中的值拷贝一份),还要记录执行到哪一个位置了
2、当线程一执行完第一步就被切换,首先把0保存到自己的上下文当中,回来的时候要执行xchgb
3、 线程二来了:把0mov到寄存器里,让后与内存中的mutex=1作交换(此时内存中的值为0,cpu寄存器的值为1)当正要做判断的时候,被切换了,线程二要把寄存器中的内容带走,并记录即将执行if语句
4、线程一回来了:首先要恢复上下文数据,将0又恢复到寄存器里,然后执行交换,发现 跟内存交换完后,依旧是0
5、原因是线程二已经拿走了1,线程一申请锁失败,不会被调度,线程二再恢复寄存器中的数据,继续进行判断大于0,申请锁成功
锁本身就是共享资源,放在内存里这个数据(仅有一把锁)就是被所有线程共享的
此时持有锁的线程再将mutex中的值(此时为0)与1交换
小结
今日的分享就到这里啦,如果本文存在疏漏或错误的地方,还请您能够指出!