Linux -- 线程互斥

目录

一、线程互斥

[1. 知识回顾](#1. 知识回顾)

[2. 问题引入](#2. 问题引入)

[3. 互斥量(锁)](#3. 互斥量(锁))

[4. 互斥量的接口](#4. 互斥量的接口)

[4.1 初始化互斥量](#4.1 初始化互斥量)

[4.2 销毁互斥量](#4.2 销毁互斥量)

[4.3 加锁互斥量](#4.3 加锁互斥量)

[4.4 解锁互斥量](#4.4 解锁互斥量)

5、抢票系统优化

[6. 互斥量的原理](#6. 互斥量的原理)

[7. 互斥量的封装](#7. 互斥量的封装)


一、线程互斥

1. 知识回顾

在前面章节介绍System 信号量时,我们介绍了共享资源、临界资源、互斥等概念,下面我们再来回顾一下:

共享资源

多线程环境下被多个执行流共同访问的资源称为共享资源

临界资源

在多线程执行过程中需要被保护的共享资源称为临界资源

• 临界区

线程中访问临界资源的那段代码区域称为临界区

• 互斥机制

确保在任意时刻最多只有一个执行流能够进入临界区访问临界资源,从而实现对临界资源的保护

• 原子性操作

指不可被中断的操作,该类操作只有两种状态:要么完全执行完毕,要么尚未开始执行

进程之间进行通信需要先创建第三方资源,使得不同的进程能够看到同一份资源。由于这份第三方资源可以由操作系统中的不同模块提供,所以进程间通信的方式有很多种。在进程间通信中,这个第三方资源被称为临界资源,而访问第三方资源的代码则被称为临界区。

与之不同的是,多线程的大部分资源都是共享的。因此,线程之间进行通信并不需要像进程那样费力地去创建第三方资源。

2. 问题引入

例如,我们在代码中只需要在全局区定义一个 count 变量,新线程可以每隔一秒对该变量进行加一操作,主线程也可以每隔一秒获取 count 变量的值并进行打印。

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
using namespace std;
int count = 0;
void *Routine(void *args)
{
    while (true)
    {
        count++;
        sleep(1);
    }
    pthread_exit((void *)0);
}
int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, Routine, nullptr);
    while (true)
    {
        cout << "The value of count is " << count << endl;
        sleep(1);
    }
    pthread_join(tid, nullptr);
    return 0;
}

在当前情境下,我们相当于实现了主线程和新线程之间的通信。其中,全局变量 count 起着关键作用,它被称为为临界资源 ,原因在于它被多个执行流所共享。而主线程中的 cout 操作以及新线程中的 count++ 操作,被称作临界区。这是因为这些代码片段对临界资源进行了访问。

但是我们同样观察到打印数据并没有 6,这就是多执行流对临界资源操作常引发的数据不一致问题。

在多线程编程中,线程使用的数据主要有两种类型:

  1. 局部变量

    • 存储在各自的线程栈空间中
    • 每个线程拥有独立的变量副本
    • 其他线程无法直接访问这些变量
  2. 共享变量

    • 存储在全局数据区或堆区
    • 所有线程都可以访问和修改
    • 用于线程间的数据交互和通信

当多个线程并发操作共享变量时,会导致**数据竞争(Data Race)**问题,表现为:

  • 读取脏数据
  • 数据不一致
  • 程序行为不可预测

同样我们也可以下面抢票程序的实现,具体演示如果不对临界资源进行限制,可能会出现的危害。

cpp 复制代码
// 操作共享变量会有问题的售票系统代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 100;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        if (ticket > 0) // 1.判断
        {
            usleep(1000); // 模拟抢票花的时间
            printf("%s sells ticket:%d\n", id, ticket); // 2. 抢到了票
            ticket--; // 3.票数--
        }
        else
        {
            break;
        }
    }

    return nullptr;
}

int main(void)
{
    pthread_t t1, t2, t3, t4;

    pthread_create(&t1, NULL, route, (void *)"thread 1");
    pthread_create(&t2, NULL, route, (void *)"thread 2");
    pthread_create(&t3, NULL, route, (void *)"thread 3");
    pthread_create(&t4, NULL, route, (void *)"thread 4");

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    return 0;
}

剩余票数出现负数,这明显不符合我们的常识与预期。之所以出现这种情况,本质就是 tickets 就是我们的临界资源,tickets-- 也并不是原子的,在多执行流同时执行时就可能会发生这种问题。

那么要怎么解决这种问题呢?加锁

cpp 复制代码
// 操作共享变量会有问题的售票系统代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 100;

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        pthread_mutex_lock(&lock);
        if (ticket > 0) // 1.判断
        {
            usleep(1000); // 模拟抢票花的时间
            printf("%s sells ticket:%d\n", id, ticket); // 2. 抢到了票
            ticket--; // 3.票数--
            pthread_mutex_unlock(&lock);
        }
        else
        {
            pthread_mutex_unlock(&lock);
            break;
        }
    }

    return nullptr;
}

int main(void)
{
    pthread_t t1, t2, t3, t4;

    pthread_create(&t1, NULL, route, (void *)"thread 1");
    pthread_create(&t2, NULL, route, (void *)"thread 2");
    pthread_create(&t3, NULL, route, (void *)"thread 3");
    pthread_create(&t4, NULL, route, (void *)"thread 4");

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    return 0;
}

结论 :一个全局资源没有加保护,多线程执行时可能会出现并发问题,多线程在整个代码层面出现并发问题,例如票抢到负数,叫做线程安全问题

如果 ticket 是指针,此时就可能出现野指针问题,而 routine 函数被多个执行流重入并且他有全局资源,此时他就是不可被重入函数!

所以我们为了复现抢票为负数这样的数据不一致问题,就要制造更多的并发、更多的切换。

3. 互斥量(锁)

为了解决线程安全 的问题我们就引入了互斥量/互斥锁,保证一次只有一个执行流访问临界资源,而为了实现互斥,我们就需要保证临界区的原子性,即临界区的资源要么被执行完成,要么不执行,只存在这两态。

要做到这些,本质就是需要一把锁,所以 Linux 就引入一个锁,并将其称为互斥量。

4. 互斥量的接口

4.1 初始化互斥量

初始化互斥量有两种方法:

  • 方法1,静态分配:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

特点

  • 在编译时初始化互斥量

  • 只能用于全局或静态互斥量

  • 使用默认属性初始化

  • 不需要显式销毁

  • 方法2,动态分配:
  • 函数原型:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
  • 参数:
    1. mutex:需要初始化的互斥量。
    2. attr:初始化互斥量的属性,一般设置为 nullptr 即可。
    3. 返回值:互斥量初始化成功返回0,失败返回错误码。
  • 特点

    • 在运行时初始化互斥量

    • 可以用于堆上或栈上分配的互斥量

    • 可以使用自定义属性

    • 需要显式销毁

4.2 销毁互斥量

  1. 函数原型:int pthread_mutex_destroy(pthread_mutex_t *mutex);
  2. 参数:mutex:需要销毁的互斥量。
  3. 返回值:成功返回 0,失败返回错误码。

其中销毁互斥量,需要注意以下几点:

  • 使用 PTHREAD_MUTEX_INITIALIZER静态初始化的互斥量不需要销毁。
  • 不能销毁一个已经加锁的互斥量。
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。

4.3 加锁互斥量

加锁本质就是让被加锁区域的代码具有原子性,只能同时被一个线程访问。

  1. 函数原型:int pthread_mutex_lock(pthread_mutex_t *mutex);
  2. 参数:mutex:需要加锁的互斥量。
  3. 返回值:成功返回 0,失败返回错误码。

如果一个线程在执行过程中,遇见该接口,并且该锁已被其他线程申请,那么该线程此时就会陷入阻塞状态,等待其解锁。

4.4 解锁互斥量

在加完锁之后,我们不可能让所有代码只被一个执行流访问,所以我们需要合适的地方解锁。

  1. 函数原型:int pthread_mutex_unlock(pthread_mutex_t *mutex);
  2. 参数:mutex:需要解锁的互斥量。
  3. 返回值:成功返回 0,失败返回错误码。

5、抢票系统优化

静态初始化:

cpp 复制代码
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
 
int ticket = 100;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 静态初始化
void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        pthread_mutex_lock(&lock);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            pthread_mutex_unlock(&lock);
        }
        else
        {
            pthread_mutex_unlock(&lock);
            break;
        }
    }
    return nullptr;
}
int main(void)
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, route, (void *)"thread 1");
    pthread_create(&t2, NULL, route, (void *)"thread 2");
    pthread_create(&t3, NULL, route, (void *)"thread 3");
    pthread_create(&t4, NULL, route, (void *)"thread 4");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
}

动态初始化:

cpp 复制代码
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
 
int ticket = 100;
pthread_mutex_t lock;
void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        pthread_mutex_lock(&lock);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            pthread_mutex_unlock(&lock);
        }
        else
        {
            pthread_mutex_unlock(&lock);
            break;
        }
    }
    return nullptr;
}
int main(void)
{
    pthread_mutex_init(&lock, nullptr); // 动态初始化
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, route, (void *)"thread 1");
    pthread_create(&t2, NULL, route, (void *)"thread 2");
    pthread_create(&t3, NULL, route, (void *)"thread 3");
    pthread_create(&t4, NULL, route, (void *)"thread 4");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    pthread_mutex_destroy(&lock); // 动态初始化需要显式销毁
}

6. 互斥量的原理

经过上面多个线程并发执行 i++ 或 ++i 操作的例子,大家已经清楚地意识到这些看似简单的自增操作实际上并非原子操作。在并发环境下,由于这些操作包含读取、修改和写入三个步骤,中间可能被其他线程打断,因此会导致数据一致性问题。

为了实现互斥锁操作,确保临界区代码的原子性执行,大多数现代计算机体系结构都提供了特殊的硬件指令。其中最常见的是 swap 或 exchange 指令(在 x86 架构中称为 XCHG 指令),这条指令的作用是将寄存器中的数据和内存单元的数据进行原子性交换。由于这个操作在硬件层面被设计为单条不可分割的指令,因此可以保证其原子性。

以下就是实现加锁 lock 与解锁 unlock 的伪代码:

我们首先可以认为锁是一个标记位,例如一个整数1就是一个锁,我们让 mutex 的初始值为1,al 是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:

  1. 先将 al 寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的 al 寄存器清0。
  2. 然后交换 al 寄存器和 mutex 中的值。xchgb 是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。
  3. 最后判断 al 寄存器中的值是否大于0。若大于0,则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。

我们需要注意的是 CPU 内的寄存器不是被所有的线程共享的,每个线程都有独自的一组寄存器,所以改变当前线程 al 寄存器的值并不会影响其他线程的 al 寄存器, 当然内存中的数据因为属于同一个进程,所以各个线程是共享的。

而当线程释放锁时,需要执行以下步骤:

  1. 将内存中的 mutex 置回1,使得下一个申请锁的线程在执行交换指令后能够得到1。
  2. 唤醒等待 mutex 的线程,让它们继续竞争申请锁。

在线程释放锁的过程中,并没有将当前线程的 al 寄存器中的值清0,这不会造成任何影响,因为每次线程在申请锁时都会先将自己 al 寄存器中的值清0,再执行交换指令。

所以我们申请锁的本质就是执行 xchgb 这一条汇编指令,因为只有一条,所以只有已执行与未执行两种状态,具有原子性。

7. 互斥量的封装

下面我们来模拟封装一个简易版本的互斥量:

cpp 复制代码
namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            int n = pthread_mutex_init(&_mutex, nullptr);
            if(n != 0)
            {
                perror("init failed");
            }
        }
 
        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            if(n != 0)
            {
                perror("lock failed");
            }
        }
 
        void Unlock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            if(n != 0)
            {
                perror("unlock failed");
            }
        }
 
        ~Mutex()
        {
            int n = pthread_mutex_destroy(&_mutex);
            if(n != 0)
            {
                perror("destroy failed");
            }
        }
    private:
        pthread_mutex_t _mutex;
    };
}

我们还可以使用 RAII 的方式,也就是和智能指针一样,来帮我们管理锁。

cpp 复制代码
namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            int n = pthread_mutex_init(&_mutex, nullptr);
            if (n != 0)
            {
                perror("init failed");
            }
        }
 
        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            if (n != 0)
            {
                perror("lock failed");
            }
        }
 
        void Unlock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            if (n != 0)
            {
                perror("unlock failed");
            }
        }
 
        ~Mutex()
        {
            int n = pthread_mutex_destroy(&_mutex);
            if (n != 0)
            {
                perror("destroy failed");
            }
        }
 
    private:
        pthread_mutex_t _mutex;
    };
 
    // 采用RAII风格,进行管理
    class LockGuard
    {
    public:
        LockGuard(Mutex& mutex)
            :_mutex(mutex)
        {
            _mutex.Lock();
        }
 
        ~LockGuard()
        {
            _mutex.Unlock();
        }
    private:
        Mutex& _mutex;
    };
}

下面我们来测试一下:

cpp 复制代码
#include "Mutex.hpp"
#include <unistd.h>
using namespace MutexModule;
 
int ticket = 100;
Mutex mutex;
void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        LockGuard lock(mutex);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}
int main(void)
{
 
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, route, (void *)"thread 1");
    pthread_create(&t2, NULL, route, (void *)"thread 2");
    pthread_create(&t3, NULL, route, (void *)"thread 3");
    pthread_create(&t4, NULL, route, (void *)"thread 4");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    return 0;
}

运行结果:

相关推荐
Broken Arrows2 小时前
排查网络问题的一些工具的作用和常用使用方法
linux·网络·学习
撒币使我快乐2 小时前
Windows安装Claude Code全流程
linux·windows·claude
longerxin20203 小时前
ubuntu所有版本镜像下载链接
linux·运维·ubuntu
数据雕塑家3 小时前
Linux下的花式「隔空」文件传输魔法
linux·运维·服务器
uoscn3 小时前
链接脚本(Linker Scripts)
linux·arm开发·arm
橘子真甜~4 小时前
C/C++ Linux网络编程2 - Socket编程与简单UDP服务器客户端
linux·运维·服务器·网络编程api·udp协议·udp通信
QT 小鲜肉5 小时前
【QT/C++】Qt样式设置之CSS知识(系统性概括)
linux·开发语言·css·c++·笔记·qt
Elias不吃糖5 小时前
NebulaChat 框架学习笔记:深入理解 Reactor 与多线程同步机制
linux·c++·笔记·多线程
洋哥网络科技5 小时前
centos 7.9搭建安装confluence7
linux·centos·知识图谱