Linux系统编程:线程互斥

目录

一. 与线程互斥相关的概念

二. 线程安全问题

2.1 多个执行流访问临界区资源引发线程安全问题

2.2 可重入函数和线程安全的关系

三. 互斥锁 mutex

3.1 互斥锁功能

3.2 互斥锁的使用 

3.3 互斥锁的实现原理

四. 死锁问题 

四. 总结


一. 与线程互斥相关的概念

  • 临界资源:被多个执行流共享的那部分资源,称为临界资源。
  • 临界区:每个线程内部访问临界资源的那部分代码,称为临界区。
  • 互斥:互斥保证任何时刻只有一个执行流进入临界区访问临界资源,用于对临界区资源进行保护,从而保证线程安全。
  • 原子性:一个只有两种状态的操作,要么不执行操作,执行就一定全部完成,不具有中间状态,这样的操作我们说它具有原子性。

二. 线程安全问题

2.1 多个执行流访问临界资源引发线程安全问题

如代码2.1所示,就是一个典型的线程不安全代码,代码2.1为一个多线程抢票程序,这段代码的目的是让多个线程并发执行,从而提高抢票的效率,我们希望tickets为0的时候就不再继续抢票。但是,如图2.1,编译并运行代码,我们发现,tickets最后居然 < 0 了,这就是不安全造成的问题。

代码2.1:线程不安全的抢票程序

#include 
#include 
#include 
#include 

int g_tickets = 100;   // 全局变量,表示剩余票数

void *getTickets(void *args)
{
    char *para = (char*)args;  
    while(true)
    {
        if(g_tickets > 0)
        {
            usleep(1000);
            printf("%s, tickets:%d\n", para, g_tickets);
            --g_tickets;
        }
        else
        {
            break;
        }
    }

    return nullptr;
}

int main()
{
    pthread_t tid[4];

    // 创建线程1-4
    pthread_create(tid, nullptr, getTickets, (char*)"thread 1");
    pthread_create(tid + 1, nullptr, getTickets, (char*)"thread 2");
    pthread_create(tid + 2, nullptr, getTickets, (char*)"thread 3");
    pthread_create(tid + 3, nullptr, getTickets, (char*)"thread 4");

    // 等待线程
    for(int i = 0; i < 4; ++i)
    {
        pthread_join(tid[i], nullptr);
    }
    std::cout << "main thread over" << std::endl;

    return 0;
}
Linux系统编程:线程互斥_第1张图片 图2.1 代码2,1运行结果(部分)

造成上述错误的问题,正是由多个线程同时进入getTickets函数中的临界区引发的。

首先来分析,为什么会出现tickets <= 0,但 if(g_tickets > 0) 内部的代码还在被运行的情况,可以按照这样的链路来理解:

  • getTickets函数中,if内部为临界区,访问临界资源g_tickets,假设有2个线程(称为线程A和线程B)判断if成立,进入了临界区。
  • 假如线程A的运气很不好,在进入if后,马上就被CPU给切换走了,线程B开始被调度,在线程B中执行了g_tickets--操作,将g_tickets的值减到了0,此时线程B终止,换上线程A继续执行。
  • 此时的线程A执行流已经进入到了if内部,虽然g_tickets已经变为了0,但依旧不影响if内部的代码执行,这样就输出了tickets:0。
  • 如果线程A执行g_tickets--将g_tickets的值变为负数,那么其他已经进入到了if内部的线程,就会读到负数并输出,这样就造成了输出tickets<=0的问题。
Linux系统编程:线程互斥_第2张图片 图2.2 线程安全引发输出ticket小于0问题图解

不光是多线程进入if内部会引发线程安全问题,--g_tickets也会存在线程不安全,按照下面假设的逻辑链,来理解--g_tickets为什么会存在线程安全问题:

  • --/++指令,翻译成汇编代码后,有三条汇编指令会被先后执行:(1). 从物理内存中读取g_tickets到CPU寄存器中   (2). 执行--/++运算   (3). 将--/++后的g_tickets值写回内存。
  • --g_tickets的三条汇编指令在运行的过程中,在任何位置都可能被打断。
  • 假设线程A在第二条汇编指令执行完成后就被切换了,此时CPU寄存器中记录g_tickets的值为99,这个99被存储到线程A的PCB中,线程B被换上运行。
  • 再假设线程B执行多次--g_tickets后才被切换,线程B被切换走的时候,已经将g_tickets减到了10并写回了内存。
  • 当内存中记录g_tickets=10时,将此前被切换走的线程A拿到CPU从上次中断的位置开始运行,然而,由于进程A的PCB中记录了g_tickets的值为上次--g_tickets但还没来的及写入内存的99,g_tickets=99被放入CPU寄存器,--g_tickets的第三条汇编指令将99写入了物理内存,前面线程B将g_tickets减到10的工作白做了!!

上面就是线程不安全的现象,在项目开发过程中,应当采取编写可重入函数、加锁等多线程编程思想,来保证线程安全。

2.2 可重入函数和线程安全的关系

可重入函数、不可重入函数和线程安全的概念:

  • 可重入函数:多个执行流同时进入函数,不会影响运行结果的函数,称为可重入函数。
  • 不可重入函数:多个执行流同时进入函数,可能引发异常的函数,称为不可重入函数。
  • 线程安全:多个执行流并发执行同一段代码,不会出现运行结果不同的代码,我们称其是线程安全的。

常见的不可重入函数的:

  • 调用了 malloc / new 动态申请内存资源的函数(内存资源是由双链表管理的)。
  • 进行了IO操作的函数(涉及缓冲区,有可能存在无序输入/输出)。
  • 内部更改了全局变量或者静态变量的函数。

函数可重入与线程安全的关系:

  • 如果一个函数是可重入的,那么它一定是线程安全的。
  • 一个线程安全的程序,可以存在不可重入的函数(使用互斥锁避免多执行流访问临界资源)

三. 互斥锁 mutex

3.1 互斥锁功能

如代码2.1,多个线程同时进入临界区运行,会引发线程安全问题,那么,为了避免这样的问题,引入了互斥锁,通过将临界区锁死,来避免多执行流进入。

一块被锁死的临界区,就只允许一个执行流进入,其他执行流若想进入临界区,那么就必须阻塞等待,直到解锁。

这样,被加锁的区域,就由多线程下的并发执行,变为只能串行执行

由于互斥锁避免了多执行流进入临界区,对临界区资源进行了保护,从而避免了线程安全问题。

如3.1为互斥锁实现的功能,执行流在进入到临界区之前为并行执行,由于临界区上锁,临界区内只能串行执行,出了临界区就解锁,从而恢复并行执行。

Linux系统编程:线程互斥_第3张图片 图3.1 互斥锁功能图解

3.2 互斥锁的使用 

创建互斥锁pthread_mutex_t:

  • 在全局或者局部,定义 pthread_mutex_t 类型的对象,就完成了互斥锁的创建。
  • 创建名为mtx的互斥锁语法:pthread_mutex_t mtx

互斥锁的初始化:

  • 有两种方式可以实现对互斥锁的初始化
  • 对于全局的互斥锁,可以直接将互斥锁对象赋值为PTHREAD_MUTEX_INITIALIZER,实现的语法为:pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER。
  • 对于局部互斥锁,可以使用pthread_mutex_init函数来初始化。

pthread_mutex_init -- 初始化互斥锁

函数原型:int pthread_mutex_init(pthread_mutex_t *mutex,  const pthread_mutexattr_t *restrict attr)

函数参数:

  • mutex -- 指向被初始化的互斥锁的指针。
  • attr -- 初始化互斥锁的属性,一般传nullptr表示默认属性

返回值:如果成功返回0,如果失败返回非0错误码。

互斥锁的销毁:

  • 可调用pthread_mutex_destroy函数销毁指定互斥锁。
  • 对于局部互斥锁才需要人工销毁,全局互斥锁进程结束便会自动销毁。

pthread_mutex_destroy -- 销毁互斥锁

函数原型:int pthread_mutex_destroy(pthread_mutex_t *mutex);

函数参数:mutex -- 指向被销毁的互斥锁的指针。

返回值:如果成功返回0,如果失败返回非0错误码。

对临界区代码上锁:

  • 使用pthread_mutex_lock函数可以实现上锁
  • 从pthread_mutex_lock函数被调用开始直到解锁,它们之间的代码都只允许一个执行流进入。
  • 如果pthread_mutex_lock没有申请到锁,那么就会阻塞等待,直到申请到锁才继续运行。
  • 对临界区才需要上锁,如果对非临界区上锁,会降低效率。
  • pthread_mutex_trylock的功能为尝试申请锁,如果申请锁成功,那么该函数的行为与pthread_mutex_lock完全相同,但是如果申请锁失败,pthread_mutex_trylock函数并不会阻塞等待,而是会直接返回。

pthread_mutex_lock -- 临界区代码上锁

函数原型:int pthread_mutex_lock(pthread_mutex_t *mutex)

函数参数:mutex -- 指向使用的锁的指针。

函数行为:成功申请到锁就进入临界区执行,否则阻塞等待,直到申请到锁。

返回值:如果成功返回0,如果失败返回非0错误码。

pthread_mutex_trylock -- 尝试对临界区代码上锁

函数原型:int pthread_mutex_trylock(pthread_mutex_t *mutex);

函数参数:mutex -- 执行使用的锁的指针。

函数行为:如果申请到锁与pthread_mutex_lock函数的行为一致,进入临界区执行,如果申请锁失败,那么pthread_mutex_trylock直接返回,而pthread_mutex_lock会阻塞等待。

返回值:成功申请到锁返回0,否则返回非0错误码。

解锁:

  • 使用函数pthread_mutex_unlock函数解锁。
  • 一旦离开临界区,应当马上解锁,避免降低效率。

pthread_mutex_unlock -- 解锁

函数原型:int pthread_mutex_unlock(pthread_mutex_t *mutex)

函数参数:mutex -- 指向被解开的锁的指针。

返回值:如果成功返回0,如果失败返回非0错误码。

代码3.1在2.1的基础之上,定义了全局互斥锁,在进入getTicket函数内的if条件判断式之前,调用pthread_mutex_lock函数对临界区上锁,当进行完IO操作、访问完临界资源g_tickets后,马上进行解锁,这样就保证了线程安全性,不会出现输出ticket为0或为负的情况。

代码3.1:使用全局互斥锁对临界区上锁

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define THREAD_NUM 5   // 子线程数量
int g_tickets = 100;   // 全局变量,表示剩余票数
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;   // 定义并初始化全局互斥锁

void *getTickets(void *args)
{ 
    std::cout << (char*)(args) << std::endl;
    while(true)
    {
        // 加锁
        int n = pthread_mutex_lock(&mutex);
        assert(n == 0);
        if(g_tickets > 0)
        {
            usleep(1000);
            printf("%s, tickets:%d\n", (char*)args, g_tickets);
            --g_tickets;
            n = pthread_mutex_unlock(&mutex);  // 解锁
            assert(n == 0);
        }
        else
        {
            n = pthread_mutex_unlock(&mutex);  // 解锁
            assert(n == 0);
            break;
        }

        usleep(rand() % 2000);
    }

    return nullptr;
}

int main()
{
    srand((unsigned int)time(nullptr) ^ getpid());  // 种下随机数种子

    pthread_t tid[THREAD_NUM];
    
    // 生成线程命名
    std::vector name(THREAD_NUM, "thread ");
    for(int i = 0; i < THREAD_NUM; ++i)
    {
        name[i] += std::to_string(i + 1);
    }

    // 创建线程
    for(int i = 0; i < THREAD_NUM; ++i)
    {
        int n = pthread_create(tid + i, nullptr, getTickets, (void*)name[i].c_str());
        assert(n == 0);
    }

    // 主线程等到子线程退出
    for(int i = 0; i < THREAD_NUM; ++i)
    {
        int n = pthread_join(tid[i], nullptr);
        assert(n == 0);
    }

    return 0;
}

代码3.2则使用了在main函数中定义局部互斥锁的方式,main函数中定义的局部互斥锁,需要传递给线程函数getTicket,但是,线程函数的参数为void*类型,我们不但希望传递互斥锁,还希望传入另一个char*类型的参数,这里采用定义struct结构体的方法,在struct threadData中定义string类型和pthread_mutex_t类型指针,将指向struct threadData类型对象的指针充当参数传给线程函数,这样就实现了在局部定义互斥锁并传给线程函数。

代码3.2:在局部(main函数)中定义互斥锁

#include 
#include 
#include 
#include 
#include 

#define THREAD_NUM 5   // 子线程数量
int g_tickets = 100;   // 全局变量,表示剩余票数

// 用于向线程函数传参的结构体
struct threadData
{
    std::string _name;
    pthread_mutex_t *_ptx;

    threadData(const std::string& name, pthread_mutex_t *ptx)
        : _name(name)
        , _ptx(ptx)
    { }
};

void *getTickets(void *args)
{ 
    threadData *pth = (threadData*)args;
    while(true)
    {
        // 加锁
        int n = pthread_mutex_lock(pth->_ptx);
        assert(n == 0);
        if(g_tickets > 0)
        {
            usleep(1000);
            printf("%s, tickets:%d\n", pth->_name.c_str(), g_tickets);
            --g_tickets;
            n = pthread_mutex_unlock(pth->_ptx);  // 解锁
            assert(n == 0);
        }
        else
        {
            n = pthread_mutex_unlock(pth->_ptx);  // 解锁
            assert(n == 0);
            break;
        }
    }

    delete pth;
    return nullptr;
}


int main()
{
    // 定义并初始化局部线程锁
    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, nullptr);

    pthread_t tid[THREAD_NUM];
    
    // 创建子线程
    for(int i = 0; i < THREAD_NUM; ++i)
    {
        std::string name = "thread ";
        name += std::to_string(i + 1);
        struct threadData* pth = new threadData(name, &mutex);
        int n = pthread_create(tid + i, nullptr, getTickets, (void*)pth);
        assert(n == 0);
    }

    // 等待子线程
    for(int i = 0; i < THREAD_NUM; ++i)
    {
        int n = pthread_join(tid[i], nullptr);
        assert(n == 0);
    }

    pthread_mutex_destroy(&mutex);

    return 0;
}

3.3 互斥锁的实现原理

如果线程函数对一个全局变量(临界资源)做修改,那么,我们就认为,这个线程函数在访问临界资源,如果不加锁,它就是线程不安全的。但是,互斥锁也是临界资源啊,为什么多个线程使用一个互斥锁,不会出现线程安全问题呢?

这就要涉及到互斥锁上锁的底层实现了,如果上锁和解锁的操作都是原子的,不会出现中间状态,那么,这个互斥锁就是线程安全的。

站在汇编的角度,如果只执行一条汇编指令,我们认为单条汇编指令是原子的。而swap或exchange指令,可以实现将CPU寄存器中的数据和内存中的数据做交换,这个交换指令是原子的,互斥锁正是运用了这种交换,来保证上锁和解锁操作的原子性。

图3.2为上锁操作的伪代码和保证互斥锁线程安全的原理图,结合伪代码,并想象下面的场景,来理解互斥锁能保证线程安全的原因:

  • 假设线程A正在被调度,线程A向寄存器中写入了0,然后寄存器数据与物理内存中mutex数据进行了交换,执行完swap后,线程A被切走,线程B被调度。
  • 线程A在被切换的时候,要带走CPU寄存器中的相关数据到其PCB中去。
  • 线程B先后执行move向寄存中写入0,执行swap交换寄存器和mutex的数据,此时寄存器中的数据变为了0,if条件判断不成立,线程B挂起等待,切换回线程A继续调度。
  • 线程A再次调度时要把PCB中的数据写回寄存器,此时if判断成立,线程A就可以执行临界区代码了。
  • 综上,我们发现,如果线程A拿到了锁(if判断寄存器数据 > 0 成立),那么,线程B及其他任意一个线程,都无法拿到锁,也就无法进入临界区执行代码。
  • 由于是通过从CPU中读取值来判断是否拿到锁,上锁是通过swap来完成的,因此最初的mutex=1中的那个1,被每个线程换来换去,但始终不会拷贝,这个1只有一份,保证了不会有两个线程同时拿到锁,也就保证了临界区在某一时刻只能有1个执行流进入。
Linux系统编程:线程互斥_第4张图片 图3.2 互斥锁保证线程安全的原因图解

解锁的原理也非常简单,只需要向物理内存存储mutex的区域写入1,就表示这个锁被释放掉了,再次swap的时候,调度优先级高的线程就可以拿到锁,再次进入临界区执行代码,图3.3为上锁和解锁的伪汇编代码。

Linux系统编程:线程互斥_第5张图片 图3.3 上锁和解锁的伪代码

四. 死锁问题 

在多线程中,如果每个线程都占用一定的资源,并且不释放资源,而每个线程都需要相互申请被其它线程所占用的资源,而造成整个进程处于永久等待状态的现象,叫做死锁。

图4.1展示了一种死锁的场景,线程1和线程2需要申请锁A和锁B,其中线程1先申请锁A再申请锁B,而线程2先申请锁B再申请锁A,如果线程1申请完锁A之后就被切换去执行线程2,而线程2就会先申请锁B,当线程2申请锁A的时候,由于线程1持有锁A,线程2不能申请成功,并且当线程1再次被调度想申请锁B的时候,由于线程2占有锁B,也无法成功申请。

这样就造成了线程1和线程2互相索要对方占有的资源,而使它们处于永久等待状态的问题,这种现象被称为死锁。

死锁问题,类似于智能指针shared_ptr的循环引用造成两处资源的释放互相依赖对方的释放,而谁也无法释放的问题。

Linux系统编程:线程互斥_第6张图片 图4.1 死锁问题

死锁的产生,必须要同时具备下面四个条件:

  • 互斥条件:每个资源都只能被一个执行流占用。
  • 申请与保持条件:一个执行流在申请某资源时,对已持有的资源不释放。
  • 不剥夺资源条件:一个执行流的资源在其使用完成之前,不会被强制剥夺。
  • 循环申请资源条件:每个执行流申请的资源首尾相连形成环状。

针对死锁产生的条件,为了避免死锁,可以采取以下措施:

  • 破坏死锁的四个必要条件之一。
  • 保证加锁顺序的一致性。
  • 确保解锁。
  • 尽量一次性申请全部资源。

五. 总结

  • 在多线程场景下,如果对临界区代码不予以加锁保护,会出现运行结果不可预期的线程安全问题,如果一个函数是可重入的,那么它一定是线程安全的。
  • 对临界区代码加锁,保证同一时刻只能有一个执行流进入执行,上锁是通过swap/exchange汇编指令,来保证原子性,从而保证上锁操作不会出现线程安全问题的。
  • 如果几个线程互相申请其他线程已经占用的资源,那么就会出现死锁问题,所有线程都会一直处于等待状态。

你可能感兴趣的:(Linux系统和网络,服务器,运维,linux)