Linux_15(多线程)线程安全+线程互斥(加锁)+死锁

1.线程安全

基于上一篇线程控制,这里创建个linux_15文件,在里面写代码,先看一段模拟抢票的代码:

Makefile:

cpp 复制代码
mythread:mythread.cc
	g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
	rm -f mythread

mythread.cc:(创建了三个新线程抢票)

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <thread>
#include <unistd.h>
#include <pthread.h>
using namespace std;
 
// 如果多线程访问同一个全局变量,并对它进行数据计算,多线程会互相影响吗
int tickets = 10000; // 在并发访问的时候,导致了我们数据不一致的问题
 
void *getTickets(void *args)
{
    (void)args;
    while(true)
    {
        if(tickets > 0)
        {
            usleep(1000);
            printf("%p: %d\n", pthread_self(), tickets);
            tickets--;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}
 
int main()
{
    pthread_t t1,t2,t3,t4;
    // 多线程抢票的逻辑
    pthread_create(&t1, nullptr, getTickets, (void*)"user1");
    pthread_create(&t2, nullptr, getTickets, (void*)"user2");
    pthread_create(&t3, nullptr, getTickets, (void*)"user3");
    pthread_create(&t4, nullptr, getTickets, (void*)"user4");
 
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
}

编译运行:

运行以后,发现出现了负数票,这不合理,票抢完就应该停止了,包括我们的代码逻辑都是这样写的,但是此时就出现了这种情况。

  • 上面现象的原因是发生了线程不安全问题。

为什么产生了线程不安全现象:

上面现象故意是弄出来的,涉及到了线程调度,利用了线程调度的特性造出了一个这样的现象。要想出现上面的现象,就需要尽可能让多个线程交叉执行。多个线程交叉执行的本质:就是让调度器尽可能的频繁发生线程调度与切换。

虽然看起来是多个线程在同时运行,但这是由于CPU运行速度太快导致的,实际上,CPU是一个线程一个线程执行的。现在就是要让CPU频繁调度,不停的切换线程,一个线程还没有执行完就再执行下一个,每个线程都执行一点,这样交叉执行。

当一个线程进行延时的时候,CPU并不会等它,而是会将它放在等待队列里,然后去执行另一个线程,等延时线程醒来以后才会接着执行。

线程在时间片到来,更高优先级线程到来,线程等待的时候会发生线程切换。

线程是在从内核态转换成用户态的时候检测是否达到线程切换的条件的。

线程检测是否切换是以内核态的身份去检测的,执行的是3~4G内核空间中的代码,本质上是操作系统在检测。


1.1 线程不安全前期

假设tickets已经只剩一张了,即全局变量tickets = 1。

主线程创建好4个新线程以后,4个新线程便开始执行了,在执行到延时的时候,新线程就会被放在等待队列里。看CPU及内核:

**if(tickets > 0)**判断的本质逻辑: 从内存中读取数据到CPU寄存器 -> 进行判断。

在线程user1执行到if判断时,CPU从内存中将tickets变量中的数据1拿到了CPU的寄存器ebx中。

CPU进行判断后,发现符合大于0的条件。

当线程user1符合条件继续向下执行延时代码时,CPU将线程user1切走了,换上了user2。

在线程user1被切走的时候,它的上下文数据也会被切走。

所以ebx寄存器中的1也会跟着user1的PCB被切走。

user2被调度时仍然重复user1的过程,执行延时被切走,再换上user3,以此类推,直到user4被切走。四个线程都拿到了tickets=1,所以符合条件,都能向下执行。

当user4被挂起后,user1差不多就该醒来了。user1唤醒以后接着被切走的位置继续执行:

执行**tickets - -**的本质:

  • 从内存中读取数据到CPU的寄存器
  • 更改数据
  • 写回数据到内存中

虽然C/C++代码只有一条语句,但是汇编后至少有3条语句。

user1执行tickets--以后,抢票成功了,并且将抢票后的tickets=0写回到了内存中。

此时user2醒来了,同样接着它被切走的位置继续执行,此时user2回来后认为tickets=1,所以就向下执行了:

当执行tickets减减时,仍然需要三步:

  • 从内存中读取tickets=0到CPU寄存器ebx中。
  • 修改值,从0变成-1。
  • 将-1写回内存中。

当user2执行完后,user3和user4醒来同样继续向下执行,重复上面的过程,仍然对tickets减一,所以导致结果不合理。


1.2 线程不安全原因

只存在两个线程,对全局变量tickets仅作减减操作:

线程A先被CPU调度,进行减减操作。

  • 从内存中将tickets=1000取到寄存器ebx中。
  • 进行减减操作,tickets变成了999。
  • 在执行第三步写回数据之前,线程A被切走了。

线程A切走的同时,它的上下文,也就是tickets=999也被切走了。

线程B此时被调度,线程A在等待队列。

  • 线程B先从内存中读取tickets = 1000到寄存器ebx中。
  • 进行减减操作。
  • 将减减后的值写回到内存中。
  • 线程B将减减操作完整的执行了很多遍,直到tickets=200时才被切下去。

线程B被切走以后,线程A又接着被调度。

线程A接着被切走的位置开始执行,也就是执行减减的第三步操作一一写回。

  • 线程A被调度后,先恢复上下文,将被切走时的tickets=999恢复到了ebx寄存器中。
  • 然后执行第三步,将tickets=999写回到了内存中。

线程B辛辛苦苦将tickets从1000减到了200,线程A重新被调度后,直接将tickets又从200写回到了999。上面这种现象被叫做数据不一致问题。

导致数据不一致问题的原因:共享资源没有被保护,多线程对该资源进行了交叉访问。

而解决数据不一致问题的办法就是对共享资源加锁。


2.线程互斥

看看几个基本概念:

临界资源:多个执行流进行安全访问的共享资源。

上面现象中的tickets很显然就不是临界资源,因为多线程对它的访问并不安全,存在数据不一致问题。
临界区:多个执行流中,访问临界资源的代码。

假设上面例子中的是临界资源,那么每个线程都存在一部分临界区,就是对tickets进行判断,打印,减减部分的代码。多个线程中的这部分代码属于临界区。
线程互斥:让多个线程串行访问共享资源,任何时候只有一个执行流在访问共享资源。

上面例子中如果多个线程能够串行访问tickets,而不是交叉访问,也不会产生数据不一致问题。而让共享资源变成临界资源就是为了实现互斥,也就是让多个线程串行访问原本的共享资源。
原子性:对一个资源进行访问的时候,要么不做,要么就做完。

在C/C++中的减减和加加操作,看似是一句代码,但是对应着三条汇编指令,上面例子中,线程A在执行第三步之前被切走了,导致减减操作没有完成,这种行为就不具有原子性,因为对共享资源的操作没有做完。

对一个资源进行操作,如果只用一条汇编就能完成,那么就具有原子性,反之就不具有原子性。(这是当前的一种理解,这种理解只能算原子性中的一个子集,是为了方便表述。)


2.1 加锁保护

要想解决多线程的数据不一致问题,就需要做到以下几点:

  • 代码必须要有互斥行为,当一个线程进入临界区执行代码时,不允许其他线程进入该临界区。
  • 如果有多个线程同时请求执行临界区代码,并且临界区没有线程在执行代码,那么只允许一个线程进入该临界区。
  • 如果线程不在临界区中执行代码,那么该线程不能阻止其他线程进入临界区。

要做到上面三点,只需要一把锁就可以,持有锁的线程才能进入临界区中执行代码,并且其他线程无法进入该临界区。

锁:就是互斥量,也叫互斥锁。

锁相关的系统调用:

cpp 复制代码
pthread_mutex_t lock; // 定义一把锁

和创建线程一样,锁也需要创建,POSIX提供了锁的变量类型,如上面代码所示,其中mutext是互斥量的意思。

初始化锁:man pthread_mutex_init:

cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
  • 形参1:创建的互斥锁指针
  • 形参2:锁的属性,一般情况下设为nullptr
  • 返回值:初始化成功返回0,失败返回错误码
  • 作用:将创建的锁初始化。

销毁锁:

cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • 形参:创建的互斥锁指针
  • 返回值:销毁成功返回0,失败返回错误码
  • 作用:当锁使用完后,必须进行销毁

全局或者静态锁初始化:

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

如果锁是全局的或者被static修饰的静态锁,只需要使用上面语句初始化锁即可。

加锁:man pthread_mutex_lock:

cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);
  • 形参:创建的互斥锁指针
  • 返回值:加锁成功返回0,失败返回错误码
  • 作用:给临界区加锁,让多线程串行访问临界资源

解锁:

cpp 复制代码
int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 形参:创建的互斥锁指针
  • 返回值:解锁成功返回0,失败返回错误码
  • 作用:解锁,让多线程恢复并发执行

锁其实起一个区间划分的作用,在加锁和解锁之间的代码就是临界区,多个执行流只能串行执行临界区代码,从而保护公共资源,使之成为临界资源。

cpp 复制代码
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(lock);
//临界区
//...
pthread_mutex_unlock(lock);

加锁和解锁两句代码圈定了临界区的范围。

现在将抢票代码加上锁,看看是否还会出现多线程数据不一致问题:

Makefile:

cpp 复制代码
mythread:mythread.cc
	g++ -o $@ $^ -g -std=c++11 -lpthread
.PHONY:clean
clean:
	rm -f mythread

mythread.cc:

在主线程中创建一个互斥锁,并且初始化,在所有新线程等待成功后将锁释放。

但是此时的锁是存在于主线程的栈结构中,需要让所有新线程看到这把锁。(创建成全局就不用)

在线程数据类中再增加一个锁指针,此时所有线程就都能看到这把锁了。

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <cassert>
#include <thread>
#include <unistd.h>
#include <pthread.h>
using namespace std;
 
int tickets = 10000; // 在并发访问的时候,导致了我们数据不一致的问题 -> 临界资源
 
#define THREAD_NUM 10
 
class ThreadData
{
public:
    ThreadData(const std::string &n,pthread_mutex_t *pm):tname(n), pmtx(pm)
    {}
public:
    std::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--; // 也可能出现问题
            n = pthread_mutex_unlock(td->pmtx); // 解锁
            assert(n == 0);
        }
        else
        {
            n = pthread_mutex_unlock(td->pmtx);  // break之前解锁
            assert(n == 0);
            break;
        }
        
        usleep(rand()%2000); // 抢完票,其实还需要后续的动作
    }
    delete td;
    return nullptr;
}
 
int main()
{
    time_t start = time(nullptr);
    pthread_mutex_t mtx;
    pthread_mutex_init(&mtx, nullptr);
 
    pthread_t t[THREAD_NUM];
 
    for(int i = 0; i < THREAD_NUM; i++) // 多线程抢票的逻辑
    {
        std::string name = "thread ";
        name += std::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;
}

此时抢票的结果是正常了,最终抢到1结束,符合我们的预期。

但发现抢票的速度比以前慢了好多。

因为加锁和解锁的过程是多个线程串行执行的,并且临界区的代码也是串行执行的,所以速度就变慢了。
需要注意的是

  • 当一个线程从临界区中出来并且释放锁后,执行后续任务时,其他线程才有更大几率去竞争锁。
  • 加锁时,一定要保证临界区的粒度非常小。将那些不是必须放在临界区中的代码放在临界区外。
  • 加锁是程序员行为,要加锁就所有线程都加锁,否则就起不到保护共享资源的效果。

2.2 锁的本质

如何看待锁?

在上面代码中,一个锁必须让所有线程都看到,所以锁本身就是一个共享资源。既然是共享资源,锁也必须是安全的,那么是谁来保证锁的安全性呢?

锁是通过加锁和解锁是原子的来保证自身的安全的。
一个线程,如果申请成功锁,那么它就会继续向下执行,如果暂时申请不成功呢?此时就被阻塞住了,线程和进程都是存在的。

  • 一个锁只能被申请一次,只有锁被释放后才能再次申请。

当一个线程申请锁暂时失败以后,就会阻塞不动。

  • 当一个线程申请锁成功,进入临界区访问临界资源,其他线程要想进入临界区只能阻塞等待,等锁释放。

  • 当一个线程申请锁成功,进入临界区访问临界资源,同样是能被切走的,而且该线程是抱着锁走的,其他线程仍然无法申请锁成功。
    **操作系统内部并不存在锁的概念,**所以调度器在调度轻量级进程的时候并不会考虑是否有锁。所以站在其他线程的角度,锁只有两种状态:

  • 申请锁前

  • 申请锁后

站在其他线程的角度,看到当前持有锁的过程就是原子的。

加锁解锁的原理:

经过上面的例子,我们认识到一个事实,C/C++中加加和减减的操作并不是原子的,所以会导致多线程数据不一致的问题。

而为了能让加锁过程是原子的,在大多数体系结构了,都提供了swap或者xchange汇编指令,通过一条汇编指令来保证加锁的原子性。

加锁解锁的伪汇编代码:

cpp 复制代码
lock:
	movb %al, $0
	xchange %al, mutex
	if(al寄存器的内容 > 0)
	{
		return 0;
	}
	else
	{
		挂起等待;	
	}
	goto lock;
 
unlock:
	movb mutex, $1
	唤醒等待mutex的线程;
	return 0;

加锁过程中,xchange是原子的,可以保证锁的安全。

锁只能被一个线程持有,而且由于xchange汇编只有一条指令,即使申请锁的过程被切走也不怕。一旦一个线程通过xchage拿到了锁,即使它被切走,也是拿着锁走的,其他线程是无法拿到锁的,只有等它将锁释放。

只有持有锁的线程才能执行下去,锁相当于一张入场卷。

这样来看,释放锁的过程其实对原子性的要求并没有那么高,因为释放锁的线程必定是持有锁的线程,不持有锁的线程都不会执行到这里,都在阻塞等待。


3.可重入对比线程安全

重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。

之前在信号部分就提到过重入,进程在执行一个函数,收到某个信号在处理信号时又调用了这个函数。今天在多线程这里,理解重入更加容易,我们以前写的多线程代码都是可重入的。

可重入和不可重入:一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

常见可重入情况:

  • 不使用全局变量或静态变量。
  • 不使用malloc或者new开辟出的空间。
  • 不返回静态或全局数据,所有数据都由函数的调用者提供。

总的来说,一个函数中如果使用了全局数据,或者静态数据,以及堆区上的数据,就是不可重入的,反之就是可重入的。

线程安全:

多个线程并发同一段代码时,不会出现不同的结果(数据不一致)。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。

互斥锁就是让不安全的线程变安全,也就是前面我们所学习的内容。

常见线程安全情况:

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
  • 类或者接口对于线程来说都是原子操作。
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性。

多线程共同执行的代码段中,如果有全局变量或者静态变量没有被保护,那么就是线程不安全的。

常见线程不安全情况:

  • 不保护共享变量的函数。
  • 函数状态随着被调用,状态发生变化的函数。
  • 返回指向静态变量指针的函数。

可重入与线程安全的联系:

多线程是通过调用函数来实现的,所以线程安全和重入就存在一些联系:

  • 函数是可重入的,那就是线程安全的,因为没有全局或者静态变量,不会产生数据不一致问题。
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。出发对不可重入函数的全局变量进行保护。
  • 如果一个函数中有全局变量并且没有保护,那么这个函数既不是线程安全也不是可重入的。
    可重入与线程安全的区别:

可重入和线程安全是不同的两个东西,但是又存在一定的交集。

  • 可重入说的是函数。
  • 线程安全说的是线程。

可重入函数是线程安全函数的一种,因为不存在全局或者静态变量。

线程安全不一定是可重入的,而可重入函数则一定是线程安全的 。因为线程安全的情况可能是对全局变量进行了保护(加了锁)。

由于线程可以加锁,所以说线程安全的情况比可重入要多。


4.死锁

我们前面例子中写的都是只有一把锁的情况,在实际使用中有可能会存在多把锁,此时就可能造成死锁。

死锁: 一组执行流中的各个执行流均占有不会释放的锁资源,但因互相申请被其他进程所占用不会释放的锁资源而处于的一种永久等待状态。

通俗来说就是一个线程自己持有锁,并且不会释放, 但是**还要申请其他线程的锁,此时就容易造成死锁。**一把锁也是会有死锁的情况的,连续申请俩次就是死锁。

在上面演示一个线程暂时申请锁失败而阻塞时,就是死锁。


4.1 死锁的必要条件

死锁的四个必要条件:

① 互斥

这一点不用说,只要用到锁就会互斥。

② 请求与保持

请求就是指一个执行流申请其他锁,保持是指不释放自己已经持有的锁。

③ 不剥夺

一个执行流已经持有锁,在不主动释放前不能强行剥夺。

④ 环路等待

线程A,B,C都持有一把锁,并且不释放。

  • 线程A 申请 线程B持有的锁B
  • 线程B 申请 线程C持有的锁C
  • 线程C 申请 线程A持有的锁A

此时就构成了环路阻塞等待。

只有符合上面四个条件就会造成死锁。而要破坏死锁只要破坏其中一个条件即可。


4.2 避免死锁

① 上面四个必要条件中的第一个无法破坏,因为我们使用的就是锁,锁就具有互斥的性质。只能破坏其他三个条件。


② 加锁顺序一致

这是为了避免形成环路等待,只要不构成环路即可。


④ 避免锁未释放的场景

临界资源尽量一次性分配好,不要分布在太多的地方加锁,这样的话导致死锁的概率就会增加。


解决死锁的基本方法如下:

预防死锁、避免死锁、检测死锁、解除死锁。


解决死锁的常用策略如下:

  • **鸵鸟策略:**对可能出现的问题采取无视态度,前提是出现概率很低。
  • **预防策略:**破坏死锁产生的必要条件。
  • 避免策略: 死锁检测算法,银行家算法,分配资源前进行风险判断,避免风险的发生。
  • **检测与解除死锁:**分配资源时不采取措施,但是必须提供死锁的检测与解除手段。

可以避免(预防)死锁的算法(了解):

  • 死锁检测算法
  • 银行家算法(避免策略)

银行家算法的思想在于将系统运行分为两种状态:安全/非安全,有可能出现风险的都属于非安全。银行家算法的思想是为了避免出现"环路等待"条件


本章完

相关推荐
ajassi200016 分钟前
AI语音智能体开发日记(十八)智能体服务器xiaozhi-esp32-server源码部署指南
运维·服务器·人工智能·ai·ai编程
‎ദ്ദിᵔ.˛.ᵔ₎20 分钟前
linux 进程信号(下)
linux·运维·服务器
skywalk816337 分钟前
在FreeBSD系统的linux仿真环境下安装和使用kiro 这个亚马逊的AI agent
linux·人工智能·freebsd·kiro
Mikko739 分钟前
Linux 磁盘满了但 du 找不到?LVM 扩容 df 不变?fstab 写错开不了机?三台真机实测磁盘排查
linux·运维·centos
蓝胖的四次元口袋1 小时前
Jenkins知识梳理(2)
运维·jenkins
yumgpkpm1 小时前
Acceldata ODP 3.3.6.4 vs CDP Private Cloud Base 7.3.2 对比
大数据·运维·服务器·hadoop·华为·zookeeper·hbase
Madison-No71 小时前
多语言聊天大模型--测试报告
linux·git·python·selenium·jmeter·自动化·postman
Liuqy-051 小时前
特殊进程——孤儿、僵尸、守护进程
linux·运维·服务器
流浪0011 小时前
Linux系统篇47——线程(十二) POSIX 信号量与环形队列,把判空判满提前到访问之前
linux·操作系统·线程·信号量·环形队列
咖丨喱2 小时前
【MMC Core + hc16 Host(WiFi SDIO 如何落到控制器)】
linux