1.相关概念
临界资源:多线程执行流共享的资源就叫做临界资源
临界区:每个线程内部,访问临界资源的代码,就叫做临界区
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
2. 使用多线程模拟抢票(问题引出)
在日常生活中,有很多关于抢票的时候,那么这时候票就是共享资源,我们去抢这些票,我们就相当于是一个一个的线程需要去抢这些票,对于有限的资源来说,线程之间也是有竞争存在的。
代码示例:设置线程为10个,每个线程都在0.01秒后就对ticket--,当票对于0的时候就线程就退出。
cpp
#include<iostream>
#include<cstdlib>
#include<cstring>
#include<vector>
#include<unistd.h>
#include<pthread.h>
using namespace std;
#define NUM 10
int ticket=1000;
struct ThreadData
{
public:
ThreadData(int num)
{
threadname="thread-"+to_string(num);
}
public:
string threadname;
};
void*GetTicket(void*args)
{
ThreadData*td=static_cast<ThreadData*>(args);
const char*name=td->threadname.c_str();
while(true)
{
if(ticket>0)
{
usleep(10000);
printf("who=%s,get a ticket%d\n",name,ticket);
ticket--;
}
else
{
break;
}
}
delete td;
return nullptr;
}
int main()
{
//创建多线程
vector<pthread_t> tids;
for(int i=0;i<NUM;i++)
{
pthread_t tid;
ThreadData*td=new ThreadData(i);
pthread_create(&tid,nullptr,GetTicket,td);
tids.push_back(tid);
}
for(auto i:tids)
{
pthread_join(i,nullptr);
}
return 0;
}
运行结果:

奇怪的事情发生了,不应该啊,票对于0的时候,就不应该在执行ticket--的操作了啊,为什么ticket最后成了负数,怎么还是有线程在ticket等于0的时候执行了ticket--的操作。
2.1 解答问题(提出互斥)
1.首先我们知道这10个线程都处于一个进程中,所以对于ticket这个变量,所有的线程都看得到的,也可以对变量进行操作,这个变量就是被多个线程共享的共享资源。
2.每一个线程都是有时间片的,当时间片到了就会从cpu上面剥离下来,保存上下文,将其他线程放入到cpu上面执行。
3.变量的++,--操作在代码上面是好像是只有一行代码,但是在cpu的眼里面,要执行对变量++或者--的操作是3次操作的。

也就是如上的3步。
所以当线程将一个变量--,首先需要从内存中读取读取这个内存的值,也就是1000,然后放入到cpu上面执行的时候,将这个变量加载到寄存器上面,如果这个时候这线程的时间片到了,那么这线程就需要将寄存器上面的数据,也就是自己的上下文数据(执行到哪一步,变量的值是多少这些)带走,可以理解为就是从寄存器上面拷贝了一份带走。
下一个线程(线程2)过来了,照样从物理内存中读取数据,要注意,线程1是没有完成到第三步,也就是说物理内存里面的值还是1000,所以线程2读取到的还是1000,继续执行这三步,但是线程2的运气很好,并没有被打断,执行了完整的三步。

假设线程2执行了2次完整的三步,将物理内存里面的变量减到了998,之后时间片到达了。这个时候线程1继续来执行了,线程1需要恢复自己的上下文,将变量1000重新加载到寄存器上面。

之后将1000进行--操作了之后,将物理内存中存储改为1000,这个时候线程1和线程2就会出现了数据不一致问题,就会出现线程2都抢了2张票了,过一会(线程1执行完了),票还有999张?所以多线程的并发是存在问题的。
回归到代码中来:为什么会出现0和负数的情况。
cpp
void*GetTicket(void*args)
{
ThreadData*td=static_cast<ThreadData*>(args);
const char*name=td->threadname.c_str();
while(true)
{
if(ticket>0)
{
usleep(10000);
printf("who=%s,get a ticket%d\n",name,ticket);
ticket--;
}
else
{
break;
}
}
delete td;
return nullptr;
}
代码中存在10个线程,一个线程进入到while循环中,这个时候俄国ticket刚刚好为1的时候,进入了if判断,这个时候需要调用sleep系统调用,休眠0.01秒,这个时候线程被切换走了,其他的线程访问到的ticket还是1,都可以通过if的判断对ticket--,printf和ticket--都是需要重新从物理内存中来获取ticket变量的值的,这个时候获取到的很有可能就是别的线程已经完成了ticket--之后的值了,线程2对ticket--,执行完成后ticket为0,线程3 if判断的时候还是1,printf打印出来的已经是0了,继续进行--,变成-1,线程4打印出来-1,继续--......。
所以就出现了0和负数的情况,要如何解决多个执行流的并发问题呢?---锁!!!
3.线程互斥
3.1 锁的初始化和销毁

pthread_mutex_t 就是锁的类型。
对于pthread_mutex_init的第二个参数为锁的属性,不关心。
3.2 上锁和解锁

3.3 锁的介绍
故事1:假设有一个自习室,这个自习室里面有一把锁,谁先到了就可以从里面把门锁了,等我学习完了之后,别人才可以进来使用,没有抢到的同学只能在门外等着了。
对应上面的代码,那些线程就是一个一个的同学,它们谁先来了拿到锁就可以先进去学习,其他人就只能在门口等待,也就是没有拿到锁的线程会阻塞在加锁的那一行 ,那个自习室就相当于共享资源 ,锁的存在就很好的让只有一名同学可以访问到共享资源,来使临界区的代码串行运行。但是这样子也会导致其他同学的学习时间就减少了,所以加锁的本质就是一种用时间来换安全的做法。
故事2:这个时候有一些同学想要一直占着学习室,就想办法了,自己买了一把锁,从外面把学习室给锁上去了,别人来了也进不去。
所以加锁的时候就应该保证大家要竞争的是同一把锁,也就是要保证多个线程看到的是同一把锁,竞争的也是同一把锁。
局部锁:需要创建和销毁。
全局锁:整个进程运行期间都存在,使用宏 PTHREAD_MUTEX_INITIALIZER 来初始化,不需要 destroy,进程退出系统自动回收资源
3.4 锁的使用
代码1:使用锁来保护共享资源ticket
确保多线程看到的是同一把锁呢?很简单,在主线程中定义局部锁,将局部锁的地址传入给线程函数要接收的threadData类对象中,即在threadData类中添加类型为pthread_mutex_t*指针类型的成员变量lock_,并且在构造函数中对lock_进行初始化,这样多个线程拿到的一定是同一把锁,使用的是同一个锁初始化。
cpp
#include<iostream>
#include<cstdlib>
#include<cstring>
#include<vector>
#include<unistd.h>
#include<pthread.h>
using namespace std;
#define NUM 8
int ticket=10000;
// pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;
struct ThreadData
{
public:
ThreadData(int num,pthread_mutex_t *mutex)
{
threadname="thread-"+to_string(num);
lock=mutex;
}
public:
string threadname;
pthread_mutex_t *lock;
};
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(ticket>0)
{
printf("who=%s,get a ticket%d\n",name,ticket);
ticket--;
pthread_mutex_unlock(td->lock);
}
else
{
pthread_mutex_unlock(td->lock);
break;
}
}
delete td;
return nullptr;
}
int main()
{
//创建多线程
pthread_mutex_t lock;
pthread_mutex_init(&lock,nullptr);
vector<pthread_t> tids;
for(int i=0;i<NUM;i++)
{
pthread_t tid;
ThreadData*td=new ThreadData(i,&lock);
pthread_create(&tid,nullptr,GetTicket,td);
tids.push_back(tid);
}
for(auto i:tids)
{
pthread_join(i,nullptr);
}
pthread_mutex_destroy(&lock);
return 0;
}
小问题1:
cpp
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(ticket>0)
{
printf("who=%s,get a ticket%d\n",name,ticket);
ticket--;
// pthread_mutex_unlock(td->lock);
}
else
{
// pthread_mutex_unlock(td->lock);
break;
}
pthread_mutex_unlock(td->lock);
}
delete td;
return nullptr;
}
如果像上面这样子写行不行,还少写一行代码,是不可以的这个时候当有线程拿到锁,判断的时候票是等于0的时候,就会直接break,就执行不到释放锁的操作了,其他线程就会一直阻塞住,导致死锁。
**代码2:**也可以定义全局锁。
这时候锁成为一个全局变量,这时候锁在定义的时候采用上图传入宏PTHREAD_MUTEX_INITIALIZER进行初始化即可,即全局的锁不需要调用pthread_mutex_init进行初始化,并且全局的锁也不需要手动销毁,而是自动销毁,即不需要调用接口pthread_mutex_destroy
那么此时threadData中的成员变量就不需要pthread_mutex_t*类型的指针lock了,并且构造函数中也不再需要对这个指针进行初始化了
cpp
#include<iostream>
#include<cstdlib>
#include<cstring>
#include<vector>
#include<unistd.h>
#include<pthread.h>
using namespace std;
#define NUM 8
int ticket=10000;
pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;
struct ThreadData
{
public:
ThreadData(int num)
{
threadname="thread-"+to_string(num);
}
public:
string threadname;
};
void*GetTicket(void*args)
{
ThreadData*td=static_cast<ThreadData*>(args);
const char*name=td->threadname.c_str();
while(true)
{
pthread_mutex_lock(&lock);
if(ticket>0)
{
printf("who=%s,get a ticket%d\n",name,ticket);
ticket--;
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
break;
}
}
delete td;
return nullptr;
}
int main()
{
//创建多线程
vector<pthread_t> tids;
for(int i=0;i<NUM;i++)
{
pthread_t tid;
ThreadData*td=new ThreadData(i);
pthread_create(&tid,nullptr,GetTicket,td);
tids.push_back(tid);
}
for(auto i:tids)
{
pthread_join(i,nullptr);
}
return 0;
}
两段代码的运行结果:

可以看到果然没有出现了0和负数的情况了。
3.5 关于锁的其他理论
1.锁本身也是共享资源。因为锁也是需要被大家共同去竞争的,多个线程就是要去申请和释放同一个锁。
2.申请和释放锁的操作必须是原子的。
3.互斥:保证同一时刻只有一个线程进入临界区,访问共享资源。解决的是"竞争"问题,防止数据被并发修改导致错乱。
4.线程的"饥饿问题":但是如果锁分配不合理(如一个线程长时间持有锁),容易导致其他线程"饥饿"(一直拿不到锁,无法执行)。我们上面的代码中就出现了线程的"饥饿问题"。

可以看到都是线程6去执行抢票,其他线程就长期处于挂起的状态,为什么线程6可以一直抢到锁呢?因为线程6在抢到锁之后,释放了锁,它离锁更近,所以下一次去申请锁也就更加的方便了。
要注意:互斥不等于饥饿,饥饿是锁使用不当的结果,不是互斥的必然产物。在适合纯互斥的场景下,使用互斥是正确的选择。
- 同步:为了使线程可以平等的去执行,需要让多个线程(或进程)按照预定的顺序执行,协调它们的工作步调。
在申请锁的表现就是:
-
外面来的线程必须排队(按顺序申请锁)。
-
释放锁的线程不能立即重新申请,必须排到队尾。
6.临界区:访问共享资源的那段代码,同一时间只允许一个线程执行。
一定要知道:线程在临界区中是可以被切换的!!!线程切换是由操作系统调度器控制的,与是否在临界区无关。当持有锁的线程被切换出去时,它是带着锁一起被切走的。所以其他的线程也无法进入。
3.6 锁的原理
之间在ticket--时说到了,ticket--的操作是有三步的,之间的每一步都有可能被切换走,那么上锁的过程就不会被切换走吗?锁是怎么做到的?怎么实现原子性的?

首先要明白其实锁不是什么很复杂的对象,在内存中无非就是一个变量。
上锁的实现:首先上锁的实现大概是分为了3条汇编语句
第一句:将寄存器eax中的al的值置为0,al可以看作就是一个比特位,不是0就是1。
第二句:交换物理内存中锁与al的值进行交换
第三句:判断寄存器中al的值是否大于0,是的话就相当于获得了锁,返回,不会被挂起。
解锁的实现:将物理内存中的锁变量重新置为1。
谈谈场景1(上锁):
如果线程1想要获取锁的话,执行了第一行的代码,也就是把al存储的值置为了0,之后时间片到了,线程1就带走自己的上下文并且将al的值自己复制了一份,之后被切换走了。

之后线程2来了,但是它很幸运,执行完了完整的代码,执行了上步代码,将物理内中锁置为了0。也就相对于获得了锁,去执行临界区里面的代码,但是当线程的时间片到来,线程就会发生切换,不管是否存储与临界区内,所以线程2就被切换走了,也是复制了一份al的值1,带着自己的上下文被切换走了。

这个时候,线程1回来了,线程1把存储的al值返回到al上面,将上下文恢复,继续执行接下来的代码,将al的值与物理内存的锁变量交换值,注意这个时候锁已经是0了,所以0和0交换,al寄存器里面的值还是0,所以线程1就被挂起了,其他线程过来即使执行了完整代码,但是物理内存的锁变量已经为0了,与al交换也还是0,被挂起。
其实就关键的就是谁能够执行第二步的代码,也就是可以将物理内存的1置换,也就相对于获得了锁,就算执行完了第二步完了之后马上就被切换了,但是也拷贝了一份al的值,当继续执行的时候,al值恢复上来还是为1。
**谈谈场景2(解锁):**解锁只有一条汇编语句,也就是原子的,唤醒阻塞等待锁一般所有操作系统来进行的,这里的解锁操作是所有的线程都可以进行的,但是95%的情况都是拥有锁的线程解锁的,因为解锁的步骤是在加锁的代码后面的,基本上应该是拥有锁的线程更快的执行到。