一、临界区和临界资源
多个执行流都能够看到并且能够访问的资源,我们称之为临界资源。多个执行流的代码中,访问了临界资源的代码,我们称之为临界区。这两个概念在代码上就能体现,我们举一个简单的例子来具体说明一下临界区和临界资源的概念:
下面的代码中,value是全局变量,它能够被多个执行流看到并访问,所以它属于临界资源;当我们访问临界资源的时候,该代码就属于临界区。
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
using namespace std;
// 这个全局变量能被多个执行流看到并访问
// 因此它是临界资源
int value = 100;
void *callBack(void *args)
{
// 这行代码访问了临界资源,因此属于临界区
cout << "thread: " << pthread_self() << " value: " << value
<< endl;
// 这行代码并没有访问临界资源,不属于临界区
return (void*)0;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, callBack, (void *)"thread1");
pthread_join(tid, nullptr);
return 0;
}
但是事实上,多线程同时访问临界资源是很有可能会出现问题的,这些问题都是因为线程缺少访问控制导致的,虽然这些问题不一定是百分百会出现,但我们也不能保证它的完全正确。我们可以写代码模拟一下多线程同时访问临界资源的场景:
我们写一个程序模拟实现多个线程同时抢票的场景,每个线程都去抢票,当还有票的时候线程就抢票,票数减一,直到票被抢完为止。
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 10000;// 总票数为10000张
// 执行抢票逻辑
void* getTicket(void* args)
{
const char* name = (char*)args;
while(true)
{
// 先判断是否还有票
if (tickets > 0)
{
cout << name << " 抢票成功, 票编号为: " << tickets << endl;
tickets--;
}
else
{
cout << name << " 抢票失败,票被抢完了......" << endl;
break;
}
}
return nullptr;
}
int main()
{
// 创建三个线程模拟抢票
pthread_t tid1, tid2, tid3;
pthread_create(&tid1, nullptr, getTicket, (void*)"thread1");
pthread_create(&tid2, nullptr, getTicket, (void*)"thread2");
pthread_create(&tid3, nullptr, getTicket, (void*)"thread3");
// 回收线程
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
return 0;
}
上面的代码可能会正常运行,各个线程都能成功抢到票不会出现什么问题,但也有可能会出现票数混乱问题。我们的抢票执行的逻辑就是tickets自减操作,这个操作在底层汇编代码中其实是分三步进行的,首先第一步是将内存中tickets的数据加载到CPU中,接着第二步是在CPU中对tickets进行运算,最后第三步是将运算结果写回到内存当中。
但是在多线程的背景下,有可能会出现下图中的这种问题,线程1先被调度进行抢票操作,此时tickets的值是10000,它先将内存中的tickets值加载到CPU中,在CPU中进行运算,运算结果是9999,最后当它正准备要将CPU中的运算结果返回的时候,线程1被切换走了。虽然CPU内的寄存器是被所有执行流共享的,但是寄存器里面的数据是属于当前执行流的上下文数据的。所以当线程1被切换走时,寄存器里的运算数据属于线程的上下文数据,会被保存在线程1当中。此时内存中tickets的值依旧是10000,紧接着线程2被调度执行,它的优先级比较高,它能够多次执行运算,当它运算了三次以后将tickets的值减到了9997并且将该值写回了内存当中,然后才被切换走。紧接着线程1继续运行,它将上一次没来得及写入内存的9999数据重新写入了内存,此时tickets的值就发生了混乱,最后的结果就会出错。
我们可以运行上面的程序查看一下结果:我们可以看到运行确实出现错误了,线程2已经将票抢完了,线程1还能抢到编号为9200的票,线程3还能抢到9817的票。原因就是因为线程2先运行,它一直在执行抢票操作,等它将票的编号减到了9220的时候并且将9220写回内存的时候,就被切换走了,紧接着线程1继续运行,它获取到的tickets的值就是9220,所以线程1抢到了9220的票,但是由于线程2的优先级比较高,线程1可能只是将tickets等于9220这个值加载到了CPU,都还没来得及做输出语句也没来得及做运算就被切换走了,而tickets等于9920这个数据就被保存到了线程1的上下文数据中。紧接着线程2继续运行,运行到将所有票抢完,线程1再继续执行就会出现抢票成功,票的编号为9920这样的错误情况,线程3也是同样的道理。
二、线程的互斥
1.线程互斥的概念
其实要解决上面多线程同时访问临界资源出现的问题,我们只需要保证线程的原子性即可。所谓原子性指的是一件事情要么不做,要么直接做完,不存在中间状态。也就是说不会在线程还没有完成抢票逻辑时就被切换走。我们要想实现每一个线程在执行临界区的代码时都不会被打扰,不会临时被切换走,我们就要对临界区代码进行加锁操作。加锁了以后,线程之间就是互斥的。所谓互斥指的是当我们访问某种资源的时候,任何时刻都只有一个执行流在访问。
2.互斥锁
Linux操作系统下的互斥锁是pthread_mutex_t类型的,只要我们加上了互斥锁,就可以保证多线程对临界资源的互斥访问。互斥锁可以定义为全局的也可以定义为局部的。全局的互斥锁是所有线程都可以使用的,定义全局互斥锁用 PTHREAD_MUTEX_INITIALIZER 宏来初始化,用这种方式初始化可以不用手动destroy来释放锁。
// 用宏初始化互斥锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
互斥锁的函数接口也设计得非常简单,使用起来很方便,下面分别介绍一下互斥锁函数接口的使用:
pthread_mutex_init函数:
pthread_mutex_init函数是互斥锁的初始化函数,一般用于定义局部互斥锁时的初始化。参数非常简单,第一个参数mutex传递进定义好的互斥锁,第二个参数attr设置互斥锁的状态,一般我们设置为nullptr代表默认状态即可。
pthread_mutex_destroy函数:
pthread_mutex_destroy函数是释放互斥锁的函数,在我们不再需要使用互斥锁的时候,调用该函数将指定的互斥锁释放,参数传递互斥锁即可。
pthread_mutex_lock函数:
pthread_mutex_lock函数是在指定的代码区域加锁函数,这种加锁方式是阻塞式加锁,也就是说如果当前申请的锁正在被别的线程使用,别的线程还没有解锁,那调用该函数的线程就会阻塞式地等待。参数传递互斥锁即可。
pthread_mutex_trylock函数:
pthread_mutex_trylock函数也是在指定的代码区域加锁函数,但这种加锁方式是非阻塞式加锁,如果申请的锁资源还没有被其它线程所释放,那么该函数会直接返回,调用该函数的线程会继续去执行其它任务。参数传递互斥锁即可。
pthread_mutex_unlock函数:
pthread_mutex_unlock函数是解锁函数,每个线程在使用完锁资源以后都应该要解锁,这样其它线程在申请锁资源的时候才可以正常使用,如果线程永远不解锁,其它线程又在阻塞式地申请锁资源,那就会形成死锁。参数也是传递互斥锁即可。
下面我们写一下代码演示一下这些互斥锁函数接口的具体使用方式:
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 10000; // 总票数为10000张
// 定义一个全局的互斥锁
pthread_mutex_t mutex;
// 执行抢票逻辑
void *getTicket(void *args)
{
const char *name = (char *)args;
while (true)
{
// 在进入循环以后加锁
// 保证其它申请锁的线程能阻塞在这个地方,不继续往下执行
pthread_mutex_lock(&mutex);
// 先判断是否还有票
if (tickets > 0)
{
cout << name << " 抢票成功, 票编号为: " << tickets << endl;
tickets--;
// 使用完以后解锁,让其它线程也可以使用
pthread_mutex_unlock(&mutex);
}
else
{
// 使用完以后解锁,让其它线程也可以使用
pthread_mutex_unlock(&mutex);
cout << name << " 抢票失败,票被抢完了......" << endl;
break;
}
}
return nullptr;
}
int main()
{
// 初始化互斥锁
pthread_mutex_init(&mutex, nullptr);
// 创建三个线程模拟抢票
pthread_t tid1, tid2, tid3;
pthread_create(&tid1, nullptr, getTicket, (void *)"thread1");
pthread_create(&tid2, nullptr, getTicket, (void *)"thread2");
pthread_create(&tid3, nullptr, getTicket, (void *)"thread3");
// 回收线程
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
// 释放锁资源
pthread_mutex_destroy(&mutex);
return 0;
}
上面的代码演示的是定义全局互斥锁,我们还可以定义局部的互斥锁,并且将其作为参数传递给线程运行函数:
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
using namespace std;
#define NAMESIZE 1024
// 将线程数据封装起来
class ThreadData
{
public:
ThreadData(char *name, pthread_mutex_t *mutex)
: mutex_p(mutex)
{
strcpy(thread_name, name);
}
char* getThreadName()
{
return thread_name;
}
pthread_mutex_t* getMutexP()
{
return mutex_p;
}
private:
char thread_name[NAMESIZE];
pthread_mutex_t *mutex_p;
};
int tickets = 1000;
void* getTickets(void* args)
{
ThreadData* threadData = (ThreadData*)args;
while(true)
{
// 加锁
pthread_mutex_lock(threadData->getMutexP());
if (tickets > 0)
{
cout << threadData->getThreadName() << " 抢票成功: " << tickets << endl;
tickets--;
usleep(500);
pthread_mutex_unlock(threadData->getMutexP());
}
else
{
cout << threadData->getThreadName() << " 抢票失败,票抢完了" << endl;
pthread_mutex_unlock(threadData->getMutexP());
break;
}
}
return nullptr;
}
int main()
{
// 定义一个局部互斥锁
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, nullptr);
pthread_t tid1, tid2;
ThreadData threadData1("thread1", &mutex);
pthread_create(&tid1, nullptr, getTickets, (void *)&threadData1);
ThreadData threadData2("thread2", &mutex);
pthread_create(&tid2, nullptr, getTickets, (void *)&threadData2);
// 回收线程
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
// 释放锁资源
pthread_mutex_destroy(&mutex);
return 0;
}
3.加锁的原理
线程加锁的原理其实是运用到了汇编的swap和exchange两条指令,这两条指令的作用是把寄存器和内存单元的数据相交换,保证只需一条指令便可完成交换操作,所以保证了原子性。因此加锁的汇编伪代码大致如下图所示:
但这个过程具体是如何保证原子性的呢?我们假设有两个线程A和线程B在申请锁资源,演示一下加锁的过程。假设首先是线程A开始申请锁,根据汇编指令,先将数字0写入到al寄存器中,然后再将寄存器中的值与内存中mutex的值交换,此时就表示线程A已经拿到锁了。
那上面提到的过程也是分了两步才完成的加锁,它到底是怎么保证原子性的呢?
首先,在只考虑单核单CPU的情况下,一个CPU在某一时刻只能执行一个线程,那么我们来看上面的两步操作:如果线程A在将数字0写入到al寄存器之后就立马被CPU剥离下来,线程B紧接着被调度,同样执行将数字0写入到al寄存器中,但这丝毫不影响线程A,因为寄存器中的数据属于线程的上下文数据,线程A在被剥离下来的同时也会将这个数据带走,所以线程B对al寄存器的写入并不会覆盖线程A写入的数字0;
如果线程A在执行完第二步以后,即已经将mutex的值与寄存器的数字0交换了以后,代表线程A已经拿到锁了。在完成这一步以后,线程A被剥离下来,线程B紧接着被调度,它同样执行着将数字0写入到al寄存器中,再将寄存器的值与内存中mutex的值做交换,但此时线程B是拿它自己写入的数字0(下图中蓝色的数字0)与线程A写入的数字0(下图中紫色的数字0)做交换,在汇编代码接下来的指令中,条件判断会不通过,因为线程B并没有拿到锁,所以就会挂起等待锁资源。这样就保证了锁申请的原子性。
所以加锁的本质就是:将锁从内存读入寄存器,也就是将锁从共享变成线程私有。
4.RAII风格的加锁方式
RAII风格是C++程序设计中一种设计的方式,它是Resource Acquisition Is Initialization的简称,翻译过来就是资源获取是初始化。意思就是说我们在初始化的时候就可以顺便获取锁资源。下面我们写一份代码来演示一下这种加锁方式。
Lock.hpp文件:
#pragma once
#include <pthread.h>
class Mutex
{
public:
// 构造函数用来初始化锁
Mutex()
{
pthread_mutex_init(&_mutex, nullptr);
}
// 加锁函数
void lock()
{
pthread_mutex_lock(&_mutex);
}
// 解锁函数
void unlock()
{
pthread_mutex_unlock(&_mutex);
}
// 析构函数用来释放锁资源
~Mutex()
{
pthread_mutex_destroy(&_mutex);
}
private:
pthread_mutex_t _mutex;
};
class LockGuard
{
public:
// 构造函数用来加锁
LockGuard(Mutex* mutex)
:_mutex(mutex)
{
_mutex->lock();
}
// 析构函数用来解锁
~LockGuard()
{
_mutex->unlock();
}
private:
Mutex* _mutex;
};
mythread.cc文件:
#include <iostream>
#include <unistd.h>
#include "Lock.hpp"
using namespace std;
int tickets = 1000;
Mutex mutex;
// 单次抢票的逻辑
bool getTickets()
{
bool ret = false;
LockGuard lockGuard(&mutex);
if (tickets > 0)
{
usleep(1001);
cout << "thread: " << pthread_self() << " get a ticket: " << tickets << endl;
tickets--;
ret = true;
}
return ret;
}
void *startRoutine(void *args)
{
const char *name = static_cast<const char *>(args);
while(true)
{
if(!getTickets())
{
break;
}
cout << name << " get tickets success" << endl;
//其他事情要做
usleep(100);
}
}
int main()
{
// 创建三个线程
pthread_t tid1, tid2, tid3;
pthread_create(&tid1, nullptr, startRoutine, (void *)"thread1");
pthread_create(&tid2, nullptr, startRoutine, (void *)"thread2");
pthread_create(&tid3, nullptr, startRoutine, (void *)"thread3");
// 回收新线程
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
return 0;
}
三、线程安全
线程安全指的是在多个线程并发同一段代码的时候,不会出现错误的结果。我们常见的对全局变量或者是静态变量进行操作,并且在没有锁保护的情况下,会出现错误的结果,这就是线程不安全的问题。
在同一个函数被不同的执行流调用的时候,如果当前的执行流还没有执行完毕,就被切换了有其它的执行流进入,这种现象称之为重入。如果一个函数在重入的情况下结果没有出现错误,则称该函数是可重入函数;否则称该函数为不可重入函数。例如,我们上面写的代码中getTickets就是可重入函数。
四、死锁
死锁指的是一组进程中各个进程均占有不会被释放的资源,但互相之间都在申请着这些被其它进程占用不释放的资源,从而导致处于一种永久的等待状态。
死锁其实是一种由于程序员对代码加锁解锁的操作不正确导致的程序错误。我们可以简单地模拟一下死锁的现象看一下:
我们创建两个线程分别为线程1和线程2,再创建两个锁分别为锁A和锁B,我们让线程1先申请锁A,sleep上1s以后再申请锁B。让线程2先申请锁B,sleep上1s以后再申请锁A。
#include <iostream>
#include <unistd.h>
using namespace std;
pthread_mutex_t mutexA = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutexB = PTHREAD_MUTEX_INITIALIZER;
void* startRoutine1(void* args)
{
while (true)
{
// 线程1先申请锁A,再申请锁B
pthread_mutex_lock(&mutexA);
sleep(1);
pthread_mutex_lock(&mutexB);
cout << "我是线程1,我的tid: " << pthread_self() << endl;
pthread_mutex_unlock(&mutexA);
pthread_mutex_unlock(&mutexB);
}
}
void* startRoutine2(void* args)
{
while (true)
{
// 线程2先申请锁B,再申请锁A
pthread_mutex_lock(&mutexB);
sleep(1);
pthread_mutex_lock(&mutexA);
cout << "我是线程2, 我的tid: " << pthread_self() << endl;
pthread_mutex_unlock(&mutexB);
pthread_mutex_unlock(&mutexA);
}
}
int main()
{
// 创建2个线程互相竞争对方的锁资源
pthread_t tid1, tid2;
pthread_create(&tid1, nullptr, startRoutine1, (void*)"thread1");
pthread_create(&tid2, nullptr, startRoutine2, (void*)"thread2");
// 回收线程
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
return 0;
}
运行程序查看结果,我们可以看到程序运行起来以后就卡住了,原因是两个线程都在互相申请着已经被对方占用的锁资源,从而导致阻塞式地等待对方释放,但对方都不会释放这个锁资源了因为两个线程都卡在了那个地方,所以这就是死锁的现象。