1.线程互斥
下面来谈一下线程互斥:前面说如果我们定义一个全局变量,每个线程都能看到全局变量并能修改,我改的时候它也再改,这个全局变量我们把它叫做共享资源。这里有个问题,这个共享资源在被多线程并发访问的时候有没有可能会出现一个线程正在访问另一个线程就来读或写呢?进而因另一个线程读写修改会影响到我呢?这会造成因为共享而导致的数据不一致问题。下面写一份代码引入互斥的概念和必要性(假设电影院有1000张票,用多线程模拟一轮抢票):

(把threadname封装成类可方便后面做扩展)相当于创建了一批线程,每个线程都有自己数据,用完后回收。下面写getTicket(有票才抢,usleep模拟抢票花的时间):

现在相当于有四个线程并发访问抢票逻辑,下面测试一下:

看到抢到了0号,-1号、-2号票,总共1000张票大于0才能抢,并且小于0应该不能抢票,因此当前抢票是有问题的。那么为什么会有这样的问题?在多线程并发执行getTicket时,tickets是共享数据,上面情况是导致了数据不一致问题。根本原因肯定和多线程并发访问有关系,可能一个再–tickets时另一个也来访问了,这样会造成数据不一致问题。那么:

对一个全局变量进行多线程并发–或++操作是否安全的?不是,问题是什么呢?看下图:

这是对应的CPU和内存,不管怎样定义全局变量它一定是在内存中的。tickets–操作本质是对数据做计算,要对数据做计算必须得在CPU内做计算。所以ticket–的第一步是先将tickets读入到CPU的寄存器中,然后CPU内做计算,操作完后把计算结果写回到内存中:

其中这三条的每一步都会对应一条汇编操作,也都是c语言看到的一条语句最终会变为三条汇编。现在假设有两线程,线程1现在要执行它所对应的代码,–时先进行第一步读取变量的值,任何一个线程在执行时都可能被切换,线程1正准备执行第二步被切换了。线程在执行的时候,将共享数据加载到CPU寄存器的本质,是把数据内容变成了自己上下文,也就是以拷贝方式给自己单独拿了一份,切换时把上下文保存起来。此时线程2来了,它也要执行–操作,它执行完了3步,tickets变成了999。假设线程2运气好没人打扰,它一直执行3步,最终票数变成了10张:

此时线程2又完成了第一步把10读到寄存器里,正准备做第二步时时间片到了,于是此时上下文保存后走了。这时线程1被切回来了,它先恢复上下文,此时它认为数据是100(走之前保存的上下文是100),然后开始第2步,第3步:

这样内存里变成了999,这样导致了tickets数据不一致问题。抢票代码里我们还在做判断(逻辑运算),也要读到CPU寄存器运算。假设tickets已经为1了,可能线程1刚判断条件成立被切换出去了,线程2刚判完也被切走了……此时每个线程上下文都认为tickets是1。第1个线程再进来减到0,第2个线程来了(上一个已做完3步骤写到内存里是0)做减减做3步骤,此时在0的基础上–,这样就出现负数了(每次在开始计算的时候要重新在内存中读数据)。那怎么解决?做到对共享数据的任何访问,保证任何时候只有一个执行流访问,这就要通过互斥来完成。怎样互斥呢?我们要引入原生线程库提供的锁的概念。
2.锁的概念
为了解决上述问题,我们引入一个叫互斥锁的东西man pthread_mutex_init:

正式谈它之前再说个问题:tickets–本来是一步,经过汇编是三条语句,这样多线程并发访问时会出错,所以tickets这种行为叫不具有原子性(有了执行中的概念,这样中间过程可能被切走)。继续来说:pthread_mutex_t是库给我们提供的一种数据类型,使用最重要的是初始化和释放。我们定义这把锁有两种方案:1.把这把锁定义成全局的,直接用PT_MU_IN来初始化,定义成全局的后面可以不需要destroy释放,当然定义成全局的用init创建和destroy释放也是可以的。2.如果我们定义的锁是常见的锁,不是全局的锁,必须用init函数对锁初始化。第二个参数是锁的属性,我们不管默认设为nullptr就行,我们用完后把对应的锁释放了就行。
先谈一般用法:我们想让对应的线程加锁,对区间进行保护,我们每个人访问这部分区域时必须得先申请锁,这样得保证线程在并发访问某种资源的时候首先得保证它们访问同一把锁。怎么做呢?定义一把锁,把这把锁初始化:

这样就定义好了一把锁,最后不要这把锁时对刚才定义的锁进行释放:

接下来要让每个线程使用这把锁,怎么做到让所有线程看到同一把锁呢:

这样每个线程都拿到刚才的锁了(指针指向外部的锁)。锁有了怎么加锁呢?man pthread_muxtex_lock:

参数是锁的地址,还可unlock解锁。那在哪里加锁呢?一个tickets全局变量叫共享变量,所有线程共享式的访问这个全局变量,我们不想让它们并发访问时出现问题,所以对tickets访问的地方进行加锁。如果成功加锁了,我们把tickets曾经我们共享的这个全局变量叫做临界资源。整个代码编写中是不是所有代码都在访问临界资源呢?并不是,只有一小块代码在使用临界资源,我们把一小块访问临界资源代码叫临界区:

加锁本质其实是让被加锁的代码区域我们要让多线程串行访问,任何一个时刻只允许一个线程访问小代码区,所以加锁是用时间来换安全。因此加锁有个原则,尽量的要保证临界区代码越少越好。代码中判断、打印、减减都是访问临界资源,所以对临界区加锁:

未来也要解锁。在加锁和解锁之间的代码我们称为临界区,加锁后tickets可称为临界资源。可以这样加锁吗:

编码上可以,逻辑上不对,这样一个线程把锁抢完才能到下一个线程去抢,而且限定区域越少越好,所以while循环里每个人想抢票,每个人先申请锁,谁拿到了锁才能进入临界区访问,没有拿到锁的线程就在加锁那里阻塞等待。按照系统观点,其实是当线程申请锁申请失败了或锁没准备好被其他人拿走了,当前线程阻塞等待的本质是线程等锁资源,锁资源没就绪,调度器把当前线程tcb状态设为非R状态,然后到锁的队列中等待。但这份代码中有break,我们这份代码是加完锁后进去查有没有票,可能进来票没了,这就走else那里break了,此时解锁代码没有被执行,这样把锁自始至终没有被释放,这样其它线程因锁资源没就绪一直卡在加锁那里,所以这份代码有问题,因此这样写:

下面验证一下:

解决了出现负数问题,但是看着一个线程把它抢完了,这样改一下:

发现都在抢了。
下面来谈一谈:我们前面没在最后加usleep时一个线程一直在抢,这种情况是正常的。因为可能这个线程一释放锁又立马跑过去申请(加)锁了,所以发现线程对于锁的竞争能力可能会不同。加了usleep让这个线程先别着急申请锁,先等一下,这样其它线程有机会拿到锁去申请。说明我们对锁的访问一定是并发的,只不过一个线程竞争能力强其它人没有抢到。这份代码编码没问题但逻辑上有问题:我们抢到了票,我们会立马抢下一张吗?其实多线程还要执行得到票之后的后续动作(如信息合验等),这里有就用usleep模拟了。所以实际上一个线程一直申请锁在现实中几乎不存在,我们代码没后续才存在,竞争能力强也是60和40的关系,不是100和0的关系。下面说个故事,再来理解一下:

有个VIP自习室,只允许一个人进来自习。门口墙上有一把钥匙,竞争原则是先到先得。当你早上来的时候拿上墙上的钥匙把门一反锁,开始自习了。此时陆续有人来了,一看门当前关着门口没钥匙,大家就在门口等着。你自习一会饿了想出去买饭,把门打开钥匙放墙上,正走时一看门外站了一大堆人,担心下次回来别人进去了,于是就转身拿上钥匙又开始自习了(因为离墙近每次可先拿到钥匙)。你反复这样,外面这批人长时间得不到锁资源进而导致了饥饿问题(纯互斥环境,如果锁分配不够合理,容易导致其它线程的饥饿问题)。但不是说只要有互斥,必有饥饿,把它用到适合场景就没有。那确实存在饥饿问题,怎么解决呢?VIP自习室有个观察员,看到你的上述独占自习室现象后制定了两个规则:1.外面来的人必须排队。2.出来的人不能立马重新申请锁,必须排到队列的尾部。有了这样设定,可让所有的线程(人)获得锁(钥匙),按照一定的顺序申请,按照一定的顺序获取资源,我们把它称为同步。
下面继续,每一个线程进入临界区访问临界资源的时候,它的第一个件事情是先申请一把锁,所以锁本身也是临界资源或共享资源。申请锁是保护临界资源安全的,那么谁来保证锁的安全呢?所以申请锁和释放锁本身就被设计成了原子性操作。下面再来个问题:

这部分区域是我们临界区,线程申请锁成功进入临界区了,其它线程申请锁时锁没就绪就被阻塞了,那在临界区中线程可以被切换吗?可以切换,但是当前线程并不怕切换,因为当前线程并没有执行解锁。在线程被切换出去的时候,是持有锁被切走的,这样还是任何一个线程都进不去。我不在期间,照样没有人能进入临界区访问临界资源。因此对于其他线程来讲只关注我这个线程执行时要么我没申请锁,要么我把临界资源使用完了这两种状态,不关心中间干什么,所以通过加锁可保证我当前线程访问临界区期间对于其它线程来讲是原子的。我们今天这为啥要加锁呢?因为有并发问题。为啥有并发问题?因为我们使用了多线程访问了全局变量。为啥用多线程?因为想提高代码并发度。再把这个线逆向推回去,当到单纯互斥锁时发现可能引发饥饿问题。我们会发现,任何解决方案出来的同时也会伴生新问题。除了前面那样,还可定义全局锁或者函数中定义为静态的(静态也是全局的),这样不用再使用init和destroy:

加锁解锁用全局地址(以上就是互斥锁)。
3.锁的原理
下面来理解锁的原理:我们前面说tickets不是原子的,会变成3条汇编语句。从底层理解原子性可认为:一条汇编语句就是原子的。为了实现互斥操作,大部分体系结构(cpu架构)都提供了swap或exchange指令,作用是用一条指令把寄存器和内存单元的值做互换,这样保证交换动作是原子的。下面看看加锁和解锁的伪代码:

整个这一段汇编对应的是pthread_mutex_lock调这个函数,那它做了什么呢:

其实锁就是一个简单的变量,比如它就是一个整型变量,默认里面值设为1表示当前锁存在申请锁(把al当成eax)。一个线程进来后要执行第一条语句,把这个寄存器清零,然后把寄存器里的内容和mutex里面的内容做了一次交换。接下来判断寄存器里边的值是不是大于0,大于0表示申请锁成功;不大于0挂起等待,也就是申请锁没有成功。现在线程1可能刚执行完第一条语句就被切换走了,但是CPU寄存器只有一套,但CPU寄存器里的内容是每个线程都要有自己内容的,寄存器不等于寄存器内容。所以线程1执行完第一条语句把0放进了寄存器,线程1要被切换走,它会把自己对应的上下文带走并记录自己执行到什么位置了,回来时候执行交换。线程2来了,它也要执行所有语句,当它准备做判断时被切换走了,首先它要把寄存器里的内容都带走并记录自己执行位置。线程1再回来时先恢复上下文,然后执行交换,执行判断是为0所以挂起等待,申请锁失败了它被阻塞了,唤醒后再goto重新申请。线程2再回来恢复上下文,判断大于0申请锁成功。上述最核心的是交换,交换本质是把内存中的数据交换到CPU的寄存器中。内存中数据是共享的,线程执行交换,所以本质是把数据交换到线程的硬件上下文中。线程硬件上下文是线程私有的,把内存数据交换到寄存器本质是把一个共享的锁,让一个线程以一条汇编的方式交换到自己的上下文中。所有线程申请锁都是拿0和内存里的1换的,整个交换过程中1只有1个,1会在CPU和内存之间来回跳转,跳到自己上下文中代表这个锁被特定线程持有了。因此竞争锁是看谁运气好,先把exchange执行完。
下面来看看解锁:

就是把1写到mutex里面(并不是我们想着交换回去,说明其它线程也能解锁)。代码现在创建了4个线程,每个线程都是申请锁释放锁,那我可不可以单独拿一个线程出来让它别加锁:

直接去访问,上图这个规则让另三个去玩,这样可以吗?写代码角度可以,但一旦有了临界资源规则,我们必须让每个执行流都遵守,否则是有bug的。
下面来说说锁的应用,做一下分装:

现在有了分装,怎么用它呢?现在有个全局的被定义好的锁:

接下来对整个区域加锁怎么办呢:

从此在while花括号内定义对象会自动调构造方法加锁,区间结束临时对象被自动释放解锁(在临界区把对象放上,用对象生命周期管理加锁和解锁,这种风格称为RAII风格的锁)。目前代码里usleep是抢票后的动作,把usleep这13毫秒放临界区这也是持有锁期间,可以这样做:

这样这个锁属于一个临时的局部对象,进入代码块形成变量,调用构造形成加锁,结束后锁自动被释放,这样usleep不在临界区了。我们创建的4个线程每个将来都会执行getTicket,所以多线程场景中重入非常常见,也就是一个函数被多个线程同时调用。因为线程间大部分资源共享,若多个线程并发访问一段代码时不会出现不同结果,此时称之为多线程运行过程中整个运行动作是线程安全的。但并发访问时导致互相有了问题,这种情况称为线程不安全(比如前面抢票例子)。常见对全局或静态变量进行操作,且没有锁保护情况下会出现线程安全问题。重入是:

重入/不重入描述的是函数特点,线程安全/不安全描述多线程并发的问题,重入和多线程安全的不同概念。总之只要这个函数不可重入,多线程将用时可能出现问题;若这个函数可被重复,那么多线程访问一定不会出问题。
4.相关代码
//LockGuard.hpp
#pragma once
#include <pthread.h>
class Mutex
{
public:
Mutex(pthread_mutex_t *lock):lock_(lock)
{}
void Lock()
{
pthread_mutex_lock(lock_);
}
void Unlock()
{
pthread_mutex_unlock(lock_);
}
~Mutex()
{}
private:
pthread_mutex_t *lock_;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t *lock):mutex_(lock)
{
mutex_.Lock();
}
~LockGuard()
{
mutex_.Unlock();
}
private:
Mutex mutex_;
};
//mythread.cc
#include <iostream>
#include <vector>
#include <sys/types.h>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include "LockGuard.hpp"
using namespace std;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
#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 = 3000; //用多线程,模拟一轮抢票
// void* getTicket(void*args)
// {
// threadData *td = static_cast<threadData*>(args);
// const char *name = td->threadname.c_str();
// while (true)
// {
// //pthread_mutex_lock(td->lock); //申请锁成功,才能往后执行,不成功,阻塞等待
// pthread_mutex_lock(&lock);
// if (tickets > 0)
// {
// usleep(1000);
// printf("who=%s, get a ticket: %d\\n", name, tickets);
// tickets–;
// //pthread_mutex_unlock(td->lock);
// pthread_mutex_unlock(&lock);
// }
// else{
// //pthread_mutex_unlock(td->lock);
// pthread_mutex_unlock(&lock);
// break;
// }
// usleep(13);
// }
// printf("%s…quit\\n", name);
// return nullptr;
// }
void* getTicket(void*args)
{
threadData *td = static_cast<threadData*>(args);
const char *name = td->threadname.c_str();
while (true)
{
{
LockGuard lockguard(&lock); //临时的LockGuard对象
if (tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket: %d\\n", name, tickets);
tickets–;
}
else
break;
}
usleep(13);
}
printf("%s…quit\\n", name);
return nullptr;
}
int main()
{
//pthread_mutex_t lock;
//pthread_mutex_init(&lock, nullptr);
vector<pthread_t> tids;
vector<threadData*> thread_datas;
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData* td = new threadData(i/*, &lock*/);
thread_datas.push_back(td);
pthread_create(&tid, nullptr, getTicket, thread_datas[i-1]);
tids.push_back(tid);
}
for (auto thread : tids)
{
pthread_join(thread, nullptr);
}
for (auto td : thread_datas)
{
delete td;
}
//pthread_mutex_destroy(&lock);
return 0;
}
5.死锁的概念
下面再说个概念叫死锁,通俗说多线程代码里因为锁的使用导致多线程代码都不往后执行了。那一把锁可能产生死锁吗:

多线程跑时同一个锁申请了两次,运行一下:

发现当前线程卡住了。因为已经申请过了,第2次申请时不成功当前线程就被挂起了。然后第一个线程拿着锁被挂起了,后面几个线程来了在第一个申请就失败了,这样整个线程被卡住了,这就是死锁问题。
再来谈谈死锁概念:我们在进行并发编程的时候锁可能不止一个,当我们进行编程时各个线程由调度器自由去调度,自由去申请锁,此时可能会出现这样的问题:一个线程持有一把锁,另一个线程持有另一把锁,但它们两个却互相申请对方的锁会导致一种永久等待的状态,这种永久等待的状态我们称之为死锁。如果是只有一个执行流,只有一把锁也能产生死锁。多线程里会产生死锁其实有4个必要条件,谈具体必要条件时先来聊聊什么是必要条件:意思是只要你产生了死锁,一定这4个条件都要满足,其中有一个条件不满足就不会产生死锁。现在谈谈:1.互斥条件。一个资源每次只能被一个执行流使用,也就是我们用了锁,因为我们要起保护作用。2.请求与保持条件。线程申请对方线程锁是请求,它把自己的锁也不释放是保持。3.不剥夺条件。一个线程拿着自己锁,还去要别人的锁,别人不给也没办法,不能去抢。4.循环等待条件。线程1要线程2的锁,线程2也要线程1的锁(同时满足上述条件),这样形成环路等待资源关系:

一旦产生死锁,这4个条件必须同时满足。下面再说说:比如现有3个线程,第1个线程要第2个线程锁,2要3,3要1:

这样也能形成环路问题,同时每个人遵守请求与保持和不剥夺条件也就产生了死锁。其中互斥条件是前提,没有互斥也就是自己没用锁就不可能产生死锁;请求与保持条件和不剥夺条件,是每个线程遵守原则(我不释放我的,我也不抢你的);循环是最终一个重要条件。那如何解决死锁问题呢?先谈谈理念后谈方法:因为产生死锁必要条件就上述四个,因此想办法破坏四个必要条件,只需要一个不满足就可以了。用什么方法?想破坏互斥条件就能不用锁就别用锁,重新写一下代码。破坏请求与保持就申请对方锁时,申请失败就把自己锁释放了。也就是申请与不保持。怎么操作呢:

除了lock加锁外还有个trylock,lock是申请锁时当前锁被别人拿走了,申请锁失败后的动作是把自己阻塞住,trylock是申请锁失败立马返回。也就是线程1申请锁采用trylock,申请第一把失败了继续申请,成功了后再申请第2把锁,失败了返回后把第一把锁释放了再重新开始,线程2也做同样规则。破坏不剥夺条件,剥夺本质是释放对方的锁,所以要剥夺直接释放锁。破坏环路问题不是通过接口,而是通过编码完成:也就是要问时持有两把锁必须按顺序申请,线程1先申请第一把锁再申请第二把锁,线程2也一样。不要一个线程先申1再申2,另一个线程先申2再申1。




