目录
一、线程不安全
1.线程不安全现象
2.线程不安全程序的特质
3.线程不安全程序的原因
二、线程互斥
1.基本概念
2.锁
(1)认识锁
(2)互斥锁的使用
(3)代码的改造
3.锁的本质
(1)加锁对线程的影响
(2)锁的原理
4.封装锁
三、重入和线程安全的理解
1.正确认识重入
(1)认识重入
(2)认识可重入
2.正确认识线程安全
3.可重入与线程安全的联系
四、死锁
1.四个必要条件
2.避免死锁
一、线程不安全
1.线程不安全现象
我们都有在12306上抢票的经历吧,毕竟一打开满眼的候补着实是血压高了。
那我们也编写一个简单的抢票程序,设置全局变量tickets=10表示一共有十张票。创建五个线程每一个线程代表一个抢票者。五个抢票者不断抢票,抢到票后tickets减一并显示当前余票。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
#include<vector>
using namespace std;#define NUM 5int tickets = 10;class pthread_data
{
public:pthread_t tid;char buffer[64];
};void* start_routine(void* args)
{pthread_data* p = (pthread_data*)args;string s;s += p->buffer;s += "Remaining tickets:";while(1){if(tickets > 0){sleep(1);--tickets;printf("%s%d\n", s.c_str(), tickets);}elsebreak;}pthread_exit(nullptr);
}int main()
{vector<pthread_data*> vpd;for(int i = 0; i<NUM; ++i){pthread_data* pd = new pthread_data;snprintf(pd->buffer, sizeof(pd->buffer), "thread:%d buy ticket:",i+1);pthread_create(&(pd->tid), nullptr, start_routine, (void*)pd);vpd.push_back(pd);}for(auto& e : vpd){pthread_join(e->tid, nullptr);delete e;}return 0;
}
运行结果:
按道理说票减到0线程就都应该退出了,不应该出现余票为负的情况。
2.线程不安全程序的特质
线程不安全的程序一般都有这个特质:多个线程交叉执行,换句话说就是调度器频繁发生线程调度与切换。
虽然我们看上去所有线程都在同时执行,其实线程也是同时只能执行一个(单核),只是因为CPU运行速度太快,人是观察不到的。
对于线程切换有以下细节:
- 线程达到时间片,更高优先级线程需要执行,线程等待时会发生线程切换。
- 线程切换的检测是以内核态身份进行的,访问的是地址空间的内核部分,本质是操作系统在检测。
- 线程在从内核态转为用户态时,会检测是否达到线程切换条件。
CPU负责调度这些执行流,在一个线程达到被切换的条件时,CPU就会与该线程分离并执行另一个线程,当再次轮到这个被切走的线程后才会继续执行。
如果CPU不停切换线程,一个线程执行一半就接着执行下一个去了,这样的交叉执行就会导致线程不安全的问题。
3.线程不安全程序的原因
首先,语句的执行都需要先把变量从内存读取到寄存器,然后在寄存器内进行处理,最后再覆盖到内存中。根据这样的思想我们就可以试着解释上面的票数为什么会出现负数。
我们假设ticket=1依旧有五个线程抢票。
第一阶段
首先,内存中储存的ticket为1。第一个线程thread1开始执行,判断ticket>0。CPU从内存中将tickets的数值1读取到ebx寄存器内,tickets确实大于0,将1写回内存,执行内部语句。
当线程要执行sleep时,线程1会被切走。由于CPU和寄存器从只有一套,所以它的上下文数据会被保存起来。
接着,第二个线程thread2同样从内存中读取到tickets为1,判断为真,再次被切走。
当然还有thread3、thread4、thread5都会经历这样的过程。
第二阶段
我们首先要知道,--tickets需要三步才能完成,包括读取数据到寄存器,寄存器数据减一,将寄存器数据写回内存。
此时每一个线程都进入了if语句框,线程thread1再次被CPU执行。此时内存中的tickets为1,寄存器ebx读取数据变为1。此时执行--tickets,寄存器内数据变为0,最后将0写回到内存的tickets中。
thread2线程也被再次唤醒,再次读取tickets为0,减一得到-1,再将-1写回内存中。
后面的thread3、thread4、thread5也是这样的流程,最后内存中的tickets经过五次减一变成了-4,这就出现了负数。
二、线程互斥
1.基本概念
- 临界资源:多个执行流进行安全访问的共享资源。
上面的tickets就不是临界资源,因为多线程对它的访问出现了问题。
- 临界区:多个执行流中,访问临界资源的代码。
上面只有部分代码属于一部分临界区,对tickets进行if判断,打印,减一的那部分代码属于临界区。
- 互斥:让多个线程串行访问共享资源,任何时候只有一个执行流在访问共享资源。
上面的代码如果将多进程交叉并行变为串行,就不会出现进程不安全的情况。让共享资源变成临界资源其实就是实现互斥。
- 原子性:对一个资源进行访问的时候,要么不做,要么就做完。
在前面也说过像加加(++)和减减(--)这样的操作,看似只有一条代码,但是它对应的汇编指令有3条,也就是说这个操作不能一次完成。
现在的我们可以认为:对资源进行操作,如果只用一条汇编就能完成,那么就说该操作具有原子性。(这只是原子性表述的其中一种)
2.锁
(1)认识锁
要想解决多线程的数据不一致问题需要做到以下几点:
- 代码必须有互斥行为,当一个线程进入临界区执行代码时,不允许其他线程进入该临界区。
- 如果有多个线程同时请求执行临界区代码,并且临界区没有线程在执行代码,那么只允许一个线程进入该临界区。
- 如果线程不在临界区中执行代码,那么该线程也不能阻止其他线程进入临界区。
其实做到上面三点只需要一把互斥锁,你可以将锁看作一个通行证,持有锁的线程才能进入临界区中执行代码,其他线程不持有锁,无法进入该临界区。
加锁本质就是让共享资源临界资源化,多个线程串行访问共享资源,从而保护共享资源的安全。
互斥锁本质上就是一个类(class pthread_mutex_t),可以构造对象pthread_mutex_t mutx,mutx就是互斥锁对象。
(2)互斥锁的使用
以下是锁的一些成员函数和使用代码:
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
头文件:pthread.h
功能:初始化互斥锁。
参数:pthread_mutex_t *restrict mutex表示需要被初始化的锁的地址,const pthread_mutexattr_t *restrict attr表示锁的属性,一般都为nullptr。
返回值:取消成功返回0,取消失败返回错误码。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
头文件:pthread.h
功能:销毁互斥锁。
参数:pthread_mutex_t *mutex表示需要被销毁的锁的地址。
返回值:销毁成功返回0,失败返回错误码。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
如果是全局或static修饰的锁,使用上面语句初始化锁。
int pthread_mutex_lock(pthread_mutex_t *mutex);
头文件:pthread.h
功能:对lock到unlock的部分代码加锁(仅允许线程串行)。
参数:pthread_mutex_t *mutex表示需要加锁的锁指针。
返回值:加锁成功返回0,失败返回错误码。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
头文件:pthread.h
功能:标识走出lock到unlock的部分代码解锁(恢复并发)。
参数:pthread_mutex_t *mutex表示需要解锁的锁指针。
返回值:加锁成功返回0,失败返回错误码。
其实加锁和解锁可以圈定临界区的范围,临界区内的代码只允许同一时间有一个线程执行内部代码,只有该线程退出后才允许另一个线程执行该部分代码,外部的代码依旧允许并行。
我打个比方的话就像只容许一个人公共厕所,厕所只能进一个人,必须等里面的人使用完毕后另一个人才能进去,而外面的公共空间不受管制。
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(lock);
//临界区
//...
pthread_mutex_unlock(lock);
(3)代码的改造
对线程加锁需要做两件事:让所有线程都看到同一把锁,所有线程都使用同一把锁。
我们如果将锁定义在main函数内,那么只有主线程能看到该锁。所以我们在Thread类中增加一个锁指针,这时所有的线程就能使用同一把锁了。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<stdio.h>
#include<vector>
using namespace std;#define NUM 5int tickets = 10;class pthread_data
{
public:pthread_t tid;char buffer[64];pthread_mutex_t* pmtx;//锁指针
};void* start_routine(void* args)
{pthread_data* p = (pthread_data*)args;string s;s += p->buffer;s += "Remaining tickets:";while(1){pthread_mutex_lock(p->pmtx);//加锁if(tickets > 0){sleep(1);--tickets;pthread_mutex_unlock(p->pmtx);//解锁printf("%s%d\n", s.c_str(), tickets);//不修改临界资源,可以不包含在内}else{pthread_mutex_unlock(p->pmtx);//解锁break;}}pthread_exit(nullptr);
}int main()
{vector<pthread_data*> vpd;pthread_mutex_t mutx;//创建锁pthread_mutex_init(&mutx, nullptr);//初始化for(int i = 0; i<NUM; ++i){pthread_data* pd = new pthread_data;pd->pmtx = &mutx;snprintf(pd->buffer, sizeof(pd->buffer), "thread%d buy ticket:",i+1);pthread_create(&(pd->tid), nullptr, start_routine, (void*)pd);vpd.push_back(pd);}for(auto& e : vpd){pthread_join(e->tid, nullptr);delete e;}return 0;
}
注意加锁和解锁的区域只需要覆盖住对临界资源进行操作的代码,不要覆盖太大。
还有一定要注意解锁一定要覆盖到代码的执行路径,比如抢票代码else中如果没有解锁,那下面的所有执行就都加锁了,会对代码运行造成巨大影响。
再次运行,程序正常运行并退出,只是由于串行,程序的运行时间加长了。
因为正常的抢票往往是很短时间内就会有许多人访问,我们将sleep(1)换成usleep(1000)缩短睡眠时间。
我们会发现,大部分的票都被一个线程抢走了。
实际上,锁只保证互斥访问,不管执行线程的顺序。
thread5抢的多,说明该线程的竞争能力强,别的线程打不过它。
现在的抢票逻辑是抢到票解锁以后,该线程又直接去申请锁,这就导致了之前持有锁的线程更加容易再次申请到锁。
咱们再12306抢票成功后也不可能立刻再去抢,程序还需要做打印订单等等工作,所以我们在线程执行最后也睡眠一会儿。
这次就是正常的你来我往了。
3.锁的本质
(1)加锁对线程的影响
锁必须让所有线程都看到,所以锁本身就是共享资源。那谁来保护锁的安全呢?
锁是通过加锁和解锁操作的原子性来保证自身的安全的。
一个线程,如果成功申请锁,那么它就会继续向下执行,如果申请不成功呢?
我们发现进程线程都还在,但线程卡住了。
一个锁只能被申请一次,只有锁被释放才能再次申请。当一个线程申请锁失败,它就会阻塞不动。
所以我们此时就能理解CPU排队处理线程和串行的关系了:
- 当一个线程申请锁成功,进入临界区访问临界资源,其他线程要想进入临界区只能阻塞等待,等待该进程将锁释放。
- 当一个线程申请锁成功,进入临界区访问临界资源,在满足条件时也是可以被换下CPU的。而且锁还在该线程的受力,其他线程仍然无法申请锁成功。
- 操作系统内不存在锁的概念,所以调度器在调度轻量级进程的时候并不会考虑是否有锁。如果调度到了没有锁的进程,不进行处理就可以了。
所以站在其他线程的角度,锁只有两种状态:申请锁前和申请锁后。
站在其他线程的角度,当前持有锁的过程就是原子的。
(2)锁的原理
为了保证加锁的原子性,在大多数体系结构都提供了swap或者xchange汇编指令,保证加锁只需要一条汇编指令。
下面是加锁和解锁的伪代码(xchange是原子的):
lock:movb %al, $0//将0写入al寄存器中xchange %al, mutex//将al寄存器的内容与锁的成员变量1交换if(al寄存器的内容 > 0){return 0;}else{挂起等待; }goto lock;unlock:movb mutex, $1唤醒等待mutex的线程;return 0;
在CPU中有一个al寄存器,它也是锁的能正常运行的保证之一。
假设有两个线程,每个线程中都有加锁的代码。
首先CPU开始处理线程thread1,thread2等待被处理。由于thread1是第一次被处理,此时需要向al寄存器内写入0。
当线程thread1执行到加锁代码时,由于内存中的锁变量储存了一个变量1,所以al寄存器会与内存中的这个变量进行数据交换。
在执行临界区代码时,很可能thread1还没有解锁,该线程就被换下去了。但是CPU和寄存器只有一套,那么上下文数据就必须保存后由线程带走,同样al寄存器里的1就也被带走了。
当thread2也是初次执行,需要在al寄存器写入0。然后同样将al寄存器会与内存中的这个变量进行数据交换,但此时锁变量也是0,0和0交换完还是0。
由于交换后al寄存器内容不大于0,所以该线程申请不到锁,只能挂起等待。
由于thread2被挂起,所以thread1再次被执行,此时它的上下文数据被加载回寄存器,al寄存器数据为1,线程继续运行。
当thread1完成了临界区代码执行就需要将al寄存器的1还回给锁变量,thread1的al寄存器重新变回0。然后,唤醒等待锁的线程thread2,thread1又被挂起。
经过上面过程的描述,我们不难发现发现:
- 锁只能被一个线程持有,而且由于加锁是一条xchange汇编代码,操作是原子性的,也不需要担心线程切换的事情。
- 一旦一个线程申请到锁,因为即使该线程被切走,锁还是在它的上下文数据中。所以,其他线程无法拿到锁,只能挂起等待,只有等锁被释放时才能申请。
- 锁的工作本质上就是锁类变量中的一个标志位1在不同进程间传递的过程,只有申请到该标志位,或者说持有锁的线程才能执行。形象地说,利用锁达到线程串行类似于很多人抢一张入场券。
- 释放锁的过程对原子性的要求不高,因为只有持有锁的线程才能释放锁,未申请到锁的线程都在挂起。
4.封装锁
锁的成员函数名普遍偏长,也不方便使用,不如我们自己将锁封装,自己使用也方便。
mutex.h
#include<pthread.h>
class mutex
{
public://构造函数mutex(pthread_mutex_t* p = nullptr):_pmutx(p){}//加锁void lock(){pthread_mutex_lock(_pmutx);}//解锁void unlock(){pthread_mutex_unlock(_pmutx);}
private:pthread_mutex_t* _pmutx;
};class LockGuard//这个类型变量的构造和销毁就可以执行加解锁
{
public:LockGuard(pthread_mutex_t* lock_p):_mutex(lock_p){_mutex.lock();//构造函数内加锁}~LockGuard(){_mutex.unlock();//析构函数内解锁}
private:mutex _mutex;
};
我们使用这个封装的锁修改代码。
#include<iostream>
#include"mutex.h"
#include<unistd.h>
#include<stdio.h>
#include<vector>
using namespace std;#define NUM 5pthread_mutex_t mutx = PTHREAD_MUTEX_INITIALIZER;//构建一个全局锁
int tickets = 10;class pthread_data
{
public:pthread_t tid;char buffer[64];
};void* start_routine(void* args)
{pthread_data* p = (pthread_data*)args;string s;s += p->buffer;s += "Remaining tickets:";while(1){{LockGuard lock(&mutx);//左侧的语句块标识这个lock变量的生命周期//构造函数加锁,走出代码块时,该变量的声明周期结束,执行析构函数解锁//这样的加锁模式也叫做RAII加锁if(tickets > 0){usleep(1000);--tickets;printf("%s%d\n", s.c_str(), tickets);//不修改临界资源,可以不包含在内}else{break;}}usleep(1000);}pthread_exit(nullptr);
}int main()
{vector<pthread_data*> vpd;for(int i = 0; i<NUM; ++i){pthread_data* pd = new pthread_data;snprintf(pd->buffer, sizeof(pd->buffer), "thread%d buy ticket:",i+1);pthread_create(&(pd->tid), nullptr, start_routine, (void*)pd);vpd.push_back(pd);}for(auto& e : vpd){pthread_join(e->tid, nullptr);delete e;}return 0;
}
正确运行
三、重入和线程安全的理解
1.正确认识重入
(1)认识重入
同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。
在信号部分就有重入,比如说进程执行一个函数,还没执行完就收到了一个信号,另一个执行流执行的还是这个函数。而在多线程这里就更好理解了,我们上面写的多线程代码都是重入的。
(2)认识可重入
一个函数在重入的情况下,对程序的运行过程和结果没有影响,则该函数被称为可重入函数,反之是不可重入函数。
常见的可重入情况:
- 不使用全局变量或静态变量。
- 不使用malloc或者new开辟出的空间。
- 不返回静态或全局数据,所有数据都有函数的调用者提供。
常见的不可重入情况:
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的函数。
- 可重入函数体内使用了静态的数据结构。
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
总之,函数如果使用了全局数据、静态数据和堆区的数据,就是不可重入的,反之就是可重入的。
2.正确认识线程安全
线程安全是指多个线程并发同一段代码时,会出现相同的结果。不加锁对全局变量或者静态变量操作时一般会出现线程安全问题。
常见线程安全情况:
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
- 多线程共同执行的代码段中,如果有全局变量或者静态变量并且没有保护,那么就是线程不安全的。
常见线程不安全情况:
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
3.可重入与线程安全的联系
可重入与线程安全有以下关系:
- 函数可重入,就是线程安全的。这样的代码没有全局或静态变量,不会产生数据不一致的问题。
- 函数不可重入,如果多个线程并发,就有可能引发线程安全问题。对不可重入函数的全局变量需要加锁保护。
- 如果一个函数中有不加锁保护的全局变量或静态变量,那这个函数既不可重入,多线程并发也不能保证线程安全。
可重入与线程安全的区别:
- 可重入说的是函数的中性属性,而线程安全说的是线程并发是否会出问题。
- 可重入函数是线程安全函数的一种,因为不存在全局或者静态变量。
- 线程安全不一定保证函数可重入的,而可重入函数又一定是线程安全的。因为线程安全的情况可能是对全局变量等进行了加锁。
- 由于线程安全可以通过加锁实现,所以线程安全的情况比可重入要多。
四、死锁
1.四个必要条件
死锁形成的四个必要条件:互斥、请求与保持、不剥夺、环路等待。
- 互斥:只要用到锁就必定有互斥。
- 请求与保持:请求指一个执行流申请其他锁,保持指不释放自己已经持有的锁。
- 不剥夺:已经持有锁的执行流,在不主动释放锁前,不能强行剥夺它的锁。
- 环路等待:线程A,B,C都持有一把锁,并且不释放。
下图中,线程A持有线程B的锁,线程B持有线程C的锁,线程C持有线程A的锁。这就是一个典型的环路等待,ABC都互相等待,哪个线程都不运行,构成死锁。
2.避免死锁
四个必要条件中只有第一个不能破坏,改变后三个任何一个都能避免死锁。
- 破坏请求与等待——避免锁位释放
当一个执行流在申请另一个锁的时候,要先释放已经有的锁再申请新锁。
- 破坏不剥夺——加锁顺序一致
注意加锁顺序不要构成环路。
- 避免死锁的建议——资源一次性分配
临界资源尽量一次性分配好,不要分散在太多的地方加锁。
避免死锁的算法,有兴趣可以了解:
- 死锁检测算法
- 银行家算法
采用算法避免死锁一半都会有一个执行流专门监测其他执行流的状态,一旦发现某个执行流长时间不执行,就代替它释放锁(本质是将那个线程间传递的1再送回到共享区的锁变量中)。
总之,互斥锁虽然帮助我们实现了线程安全,但不合理使用会造成巨大的问题,所以我们再以后的代码中尽量少用互斥锁。