Linux知识点 – Linux多线程(二)
文章目录
- Linux知识点 -- Linux多线程(二)
- 一、线程互斥
- 1.背景概念
- 2.多线程访问同一个全局变量
- 3.加锁保护
- 4.问题
- 5.锁的实现
- 二、线程安全
- 1.可重入与线程安全
- 2.常见情况
- 3.可重入与线程安全的联系
- 三、死锁
- 1.死锁概念
- 2.死锁的条件
- 3.避免死锁的方法
一、线程互斥
1.背景概念
- 临界资源:多线程执行流共享的资源就叫做临界资源;
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区;
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区, 访问临界资源,通常对临界资源起保护作用;
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成;
2.多线程访问同一个全局变量
下面实现一个抢票代码,多线程共同抢票,都访问同一个全局变量tickets,每次访问都 - -tickets:
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <cerrno>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 1000;
void *getTickets(void *args)
{
(void)args;
while (true)
{
if(tickets > 0)
{
usleep(1000);
printf("%p: %d\n", pthread_self(), tickets);
tickets--;
}
else
{
break;
}
}
}
int main()
{
pthread_t t1, t2, t3;
pthread_create(&t1, nullptr, getTickets, nullptr);
pthread_create(&t2, nullptr, getTickets, nullptr);
pthread_create(&t3, nullptr, getTickets, nullptr);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
return 0;
}
运行结果:
最终将tickets的数量减到了-1,但我们发现判断条件是tickets > 0才执行 - -操作;
并发访问的时候,导致了数据不一致的问题;
- 解释:
tickets - -这个操作翻译成汇编语句,一共有三步操作:
(1)读取内存数据到cpu的寄存器中;
(2)cpu内部进行计算 - -;
(3)将结果写回内存中
把数据读取到寄存器,就是将数据读取到执行流的上下文数据;
因为这个tickets - - 的运算过程不是原子的,线程在运行的任何时候都有可能被切换出去,因此会发生以下的情况:
当线程1执行完第二步的时候,被切走了,由线程2继续执行这个- - 操作;
线程2执行完后将数据写回内存,当线程2一直执行一定时间后,将最后结果(5000)写入内存;
此时切回了进程1,从第三步继续执行,将结果写入内存,内存中的结果又被写成了9999;
这样就引发了因为切换问题导致的数据不一致;
3.加锁保护
为了解决多线程引发的数据不一致问题,可以为临界区代码加锁:
- 锁的初始化:
自行初始化:
全局内定义的静态锁,使用宏初始化:
- 加锁与解锁:
在临界区加锁:加锁的意义在于,在一个时刻,只允许一个执行流访问加锁的代码,将这段代码变成串行运行的;
任何一个时刻,只允许一个线程获得这把锁,其他线程都在等待;
直到拿到锁的线程最终释放掉,其他线程才可以拿到;
相当于加锁和解锁之间的代码只可以串行通信,其他代码都可以并行;
全局静态的锁:
//全局静态的锁,使用宏初始化
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;//pthread_mutex_t就是原生线程库提供的一个数据类型
void *getTickets(void *args)
{
(void)args;
while (true)
{
pthread_mutex_lock(&mtx);//为临界区代码加锁
if(tickets > 0)
{
usleep(1000);
printf("%p: %d\n", pthread_self(), tickets);
tickets--;
}
else
{
break;
}
}
}
解锁:
不能在这里解锁,因为如果线程执行完break之后,就不会执行解锁代码,而这把锁是全局的,还处于被该线程修改的状态,其他线程就无法拿到锁了;
应该在下面的地方解锁:
//全局静态的锁,使用宏初始化
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;//pthread_mutex_t就是原生线程库提供的一个数据类型
void *getTickets(void *args)
{
(void)args;
while (true)
{
pthread_mutex_lock(&mtx);//为临界区代码加锁
if(tickets > 0)
{
usleep(1000);
printf("%p: %d\n", pthread_self(), tickets);
tickets--;
pthread_mutex_unlock(&mtx);//解锁
}
else
{
//如果线程加锁后直接运行到这里,在这里也可以解锁
pthread_mutex_unlock(&mtx);//解锁
break;
}
}
}
加锁和解锁之间的代码叫做临界区;
运行:
固定休眠时间可能会导致只有一个线程在跑,可以随即休眠时间;
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <cerrno>
#include <unistd.h>
#include <pthread.h>
#include <ctime>
using namespace std;
int tickets = 1000;
//全局静态的锁,使用宏初始化
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;//pthread_mutex_t就是原生线程库提供的一个数据类型
void *getTickets(void *args)
{
(void)args;
while (true)
{
pthread_mutex_lock(&mtx);//为临界区代码加锁
if(tickets > 0)
{
usleep(rand()%1500);
printf("%s: %d\n", (char*)args, tickets);
tickets--;
pthread_mutex_unlock(&mtx);//解锁
}
else
{
//如果线程加锁后直接运行到这里,在这里也可以解锁
pthread_mutex_unlock(&mtx);//解锁
break;
}
usleep(rand()%2000);
}
}
int main()
{
srand((unsigned long)time(nullptr) ^ getpid());
pthread_t t1, t2, t3;
pthread_create(&t1, nullptr, getTickets, (void*)"thread one");
pthread_create(&t2, nullptr, getTickets, (void*)"thread two");
pthread_create(&t3, nullptr, getTickets, (void*)"thread three");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
return 0;
}
结果:
注:加锁的时候,一定要保证加锁的粒度越小越好,因为加锁会导致进程互斥,造成临界区代码只能串行访问,影响效率;
局部的锁:
第一个参数是锁的地址,第二个是锁的属性;
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <cerrno>
#include <unistd.h>
#include <pthread.h>
#include <ctime>
using namespace std;
int tickets = 1000;
//全局静态的锁,使用宏初始化
//pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;//pthread_mutex_t就是原生线程库提供的一个数据类型
#define THREAD_NUM 5
class ThreadData
{
public:
ThreadData(const string &n, pthread_mutex_t *pm)
: tname(n),
pmtx(pm)
{}
public:
string tname;//线程名
pthread_mutex_t *pmtx;//锁
};
void *getTickets(void *args)
{
ThreadData* td = (ThreadData*)args;//接收对象
while (true)
{
pthread_mutex_lock(td->pmtx);//为临界区代码加锁
if(tickets > 0)
{
usleep(rand()%1500);
printf("%s: %d\n", td->tname.c_str(), tickets);
tickets--;
pthread_mutex_unlock(td->pmtx);//解锁
}
else
{
//如果线程加锁后直接运行到这里,在这里也可以解锁
pthread_mutex_unlock(td->pmtx);//解锁
break;
}
usleep(rand()%2000);
}
delete td;
return nullptr;
}
int main()
{
pthread_mutex_t mtx;
pthread_mutex_init(&mtx, nullptr);//局部锁初始化
srand((unsigned long)time(nullptr) ^ getpid());
pthread_t t[THREAD_NUM];
//多线程抢票逻辑
for(int i = 0; i < THREAD_NUM; i++)
{
string name = "thread ";
name += to_string(i + 1);
ThreadData *td = new ThreadData(name, &mtx);//创建对象
pthread_create(t + i, nullptr, getTickets, (void*)td);//创建线程的时候,穿的参数也可以是对象指针
}
for(int i = 0; i < THREAD_NUM; i++)
{
pthread_join(t[i], nullptr);
}
pthread_mutex_destroy(&mtx);//局部锁的销毁
return 0;
}
在上面的代码中,创建了一个类保存线程的信息和锁的指针,创建线程的时候,给回调函数传的参数也可以传这个对象,这样就把线程属性和局部锁的指针都传进去了,在回调函数中就可以进行使用;
运行结果:
4.问题
-
加锁之后,线程在临界区中是否会切换?
会被切换,但是不会出问题;因为该线程是持有锁被切换的,所以其他抢票线程要执行临界区代码,也必须先申请锁,但是锁已经被该线程申请了,其他线程就无法申请成功,因此,就不会让其他线程进入临界区,保证了临界区中数据的一致性; -
一个线程,不申请锁,就是单纯的访问临界资源,这是错误的编码方式;
-
当一个线程持有锁,在其他线程看来,该线程就是原子的;
-
所本身就是一种共享资源,那么谁来保证锁的安全呢?
为了保证锁的安全,申请和释放锁,必须是原子的;
5.锁的实现
-
exchange或swap汇编指令:
以一条汇编指令的方式,将内存和CPU寄存器的数据进行交换;站在汇编的角度,只有一条汇编语句,就认为该语句的执行是原子的; -
在执行流视角,是如何看待COU上面的寄存器的?
CPU内部的寄存器,本质叫做当前执行流的上下文,这些寄存器的空间是被所有执行流共享的,但是寄存器的内容,是被每一个执行流私有的,当执行流切换的时候,会将寄存器内的数据(上下文数据)一并带走; -
加锁和解锁的汇编代码:(伪代码)
核心的语句就是下面这句:
将寄存器的内容和锁的内容交换,这是一行汇编代码,是原子的;
多线程申请锁的可能的情况:
内存mutex中的1只能被一个线程交换,如果A线程已经执行了这一条指令,将al寄存器的值(0)和mutex的值(1)交换;
交换完成后,mutex的值就变为了0,相当于锁已经被A线程拿走了,此时线程A被切换了,连带着寄存器al中的值一起带走;
当另一个线程B来的时候,内存mutex中这个1已经被上一个线程交换了;
现在mutex中的值是0,第二个线程交换完后将0交换到了寄存器al中,因此只能等待;
释放锁就是再将线程寄存器al的内容和内存mutex的内容再交换回来;
二、线程安全
1.可重入与线程安全
- 可重入:
可重入是针对函数而言的; - 线程安全:
线程安全是用来描述线程的;
2.常见情况
-
线程不安全:
-
线程安全:
-
不可重入:
-
可重入:
3.可重入与线程安全的联系
- 函数是可重入的,那就是线程安全的;
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题;
- 如果一个函数中有全局变量,那么这函数既不是线程安全的也不是可重入的;
- 线程安全不一定是可重入的;
三、死锁
1.死锁概念
死锁:是指再一组线程中的各个线程均占有不会释放的资源,但因互相申请被其他进程所占的资源而初一的一种永久等待的状态;
- 两个线程同时申请对方已有的锁,形成互相申请对方资源的一种环路情况;
同一个线程反复申请同一把锁,也会造成死锁:
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <cerrno>
#include <unistd.h>
#include <pthread.h>
#include <ctime>
#include<cassert>
using namespace std;
int tickets = 1000;
#define THREAD_NUM 5
class ThreadData
{
public:
ThreadData(const string &n, pthread_mutex_t *pm)
: tname(n),
pmtx(pm)
{}
public:
string tname;//线程名
pthread_mutex_t *pmtx;//锁
};
void *getTickets(void *args)
{
ThreadData* td = (ThreadData*)args;//接收对象
while (true)
{
int n = pthread_mutex_lock(td->pmtx);//为临界区代码加锁
assert(n == 0);
if(tickets > 0)
{
usleep(rand()%1500);
printf("%s: %d\n", td->tname.c_str(), tickets);
tickets--;
int n = pthread_mutex_lock(td->pmtx);//听一个进程反复申请同一把锁,也会造成死锁
assert(n == 0);
}
else
{
int n = pthread_mutex_lock(td->pmtx);
assert(n == 0);
break;
}
usleep(rand()%2000);
}
delete td;
return nullptr;
}
int main()
{
pthread_mutex_t mtx;
pthread_mutex_init(&mtx, nullptr);//局部锁初始化
srand((unsigned long)time(nullptr) ^ getpid());
pthread_t t[THREAD_NUM];
//多线程抢票逻辑
for(int i = 0; i < THREAD_NUM; i++)
{
string name = "thread ";
name += to_string(i + 1);
ThreadData *td = new ThreadData(name, &mtx);//创建对象
pthread_create(t + i, nullptr, getTickets, (void*)td);//创建线程的时候,穿的参数也可以是对象指针
}
for(int i = 0; i < THREAD_NUM; i++)
{
pthread_join(t[i], nullptr);
}
pthread_mutex_destroy(&mtx);//局部锁的销毁
return 0;
}
运行结果:
该线程运行了一次就卡住不动了,进入了死锁状态;
2.死锁的条件
- 死锁的四个必要条件(全部满足即造成死锁):
(1)互斥条件:一个资源每次只能被一个执行流使用;
(2)请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放;
(3)不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺;
(4)循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系;
3.避免死锁的方法
(1)破坏死锁的四个必要条件的其中一个;
- 互斥:可不可以不加锁;
- 请求与保持:申请锁时可以使用trylock,如果锁被占有,就返回错误码,连续申请若干次,不成功,就把自己的锁释放掉,不会导致线程阻塞;
(2)加锁顺序一致;
(3)避免锁未释放的场景;
(4)资源一次性分配;