[操作系统] 线程互斥

文章目录

背景概念

  • 临界资源:多线程执行流共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有⼀个执⾏流进⼊临界区,访问临界资源,通常对临界资源起保护作⽤
  • 原⼦性(后⾯讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成,不会在执行期间进行中断。

线程互斥的引出

以下代码模拟一个售票系统,有一个全局变量tickled,所有的线程进行抢票,每次抢后进行--

cpp 复制代码
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

int ticket = 1000;

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);
}

正常情况下每个线程会去抢夺票,然后--,但是发现当票数为0的时候并没有停止,而是结果如下:

出现了负数。

我们将聚焦点放在每个线程的抢票的代码逻辑部分:

cpp 复制代码
if (ticket > 0) // 1. 判断
{
    usleep(1000);                               // 模拟抢票花的时间
    printf("%s sells ticket:%d\n", id, ticket); // 2. 抢到了票
    ticket--;                                   // 3. 票数--
}
else
{
    break;
}

首先可以明确,该代码中的临界资源是ticket,因为ticket是线程之间所需要共享进行--的变量。而线程内部进行访问临界资源的代码是:

cpp 复制代码
if (ticket > 0) // 1. 判断
{
    usleep(1000);                               // 模拟抢票花的时间
    printf("%s sells ticket:%d\n", id, ticket); // 2. 抢到了票
    ticket--;                                   // 3. 票数--
}

所以说会出现负数结果,问题一定是在临界区,说明临界区没有进行保护,也就是互斥!


那么这段临界区为什么没有保护好临界资源,到底发生了什么?

线程切换的时间点:1️⃣时间片到期 2️⃣阻塞式IO 3️⃣sleep等系统调用陷入内核

好的,我们来用一个更简化的方式,只关注寄存器的存取过程,模拟 ticket 从一个小正数变成负数的情况。

假设 ticket 变量当前的内存值为 **1,**还剩一张票。

现在,有两个线程,Thread A 和 Thread B,几乎同时执行到 if (ticket > 0) 这个判断。

  1. Thread A 执行 if (ticket > 0)
    • Thread A 读取内存中的 ticket 值,发现是 1。
    • 1 大于 0,条件为真。Thread A 准备 进入 if 块内的代码。
  2. Thread B 执行 if (ticket > 0)
    • 就在 Thread A 进入 if 块之后(可能被usleep卡住),操作系统切换到 Thread B。
    • Thread B 也读取内存中的 ticket 值,此时内存中的 ticket 仍然是 1(因为 Thread A 还没修改它)。
    • 1 大于 0,条件为真。Thread B 也****进入 if 块内的代码。

现在,两个线程都通过了 if (ticket > 0) 的检查,都认为自己可以售票。它们将相继准备执行抢票。

假设接下来 Thread A 先执行 ticket-- 的过程:

  1. Thread A 执行 ticket-- :
    • Thread A 执行ticket--,现在ticket变为0
  2. 操作系统切换到 Thread B。
  3. Thread B 执行 ticket--:
    • Thread B 执行ticket--,现在ticket变为-1

这个简化的例子说明了,由于线程切换可能发生在代码的任何地方,多个线程可能读取到同一个旧的 ticket 值,全部进入临界区,各自进行减一操作,然后将减一后的值写回内存。最终的结果是 ticket 被"超卖"了,卖出的总票数超过了初始值 100,从而出现了负数。

从代码表面理解如此,实际上在汇编实现上也会进行线程的切换,不同的是每条汇编指令是原子的

这就是为什么对共享变量的非原子操作在多线程环境下需要同步控制,以确保同一时间只有一个线程能够完成整个操作序列,避免这种错误的交错执行。


解决临界区问题,本质上就是需要一个锁将该区域锁起来,Linux提供的锁叫互斥量

互斥量

  • 互斥量 (Mutex): 一把用于多线程同步的锁,确保在任何时刻只有一个线程可以访问被保护的共享资源。
  • 临界区 (Critical Section): 访问共享资源的代码段,需要用互斥量来保护。

锁的操作

互斥量的主要操作:

  1. 初始化 (Initialization)
  2. 加锁 (Locking)
  3. 解锁 (Unlocking)
  4. 销毁 (Destruction)
  5. 设置属性 (Setting Attributes - 影响互斥量行为)

初始化 (Initialization)

在使用互斥量之前,必须先进行初始化。有两种主要的初始化方式:

静态初始化
  • 操作: 使用宏 PTHREAD_MUTEX_INITIALIZERpthread_mutex_t 变量进行赋值。
  • 代码示例:
c 复制代码
pthread_mutex_t my_mutex = PTHREAD_MUTEX_INITIALIZER;
  • 讲解:
    • 这种方式主要用于全局或静态存储期的互斥量
    • 它在程序启动时自动完成初始化。
    • 使用这种方式初始化的互斥量,通常不需要显式调用 pthread_mutex_destroy() 进行销毁,系统会在程序结束时自动清理。
    • 这种方式初始化的互斥量通常是默认类型(Normal 或 Fast 互斥量),不支持特殊属性(如递归)。
动态初始化
  • 操作: 调用 pthread_mutex_init() 函数。
  • 函数原型: int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
  • 代码示例:
c 复制代码
pthread_mutex_t my_mutex;
// ... 可能设置属性 ...
int ret = pthread_mutex_init(&my_mutex, NULL); // 使用默认属性初始化
if (ret != 0) {
    // 处理错误
}
  • 讲解:
    • 这种方式用于局部变量或通过 malloc 等动态分配的互斥量
    • 需要在代码中显式调用此函数进行初始化。
    • mutex: 指向要初始化的 pthread_mutex_t 变量的指针。
    • attr: 指向互斥量属性对象的指针。如果为 NULL,则使用默认属性。可以通过创建并设置 pthread_mutexattr_t 对象来指定互斥量类型(如递归、错误检查)或进程共享属性。
    • 使用动态初始化方式的互斥量,在不再需要时必须显式调用 pthread_mutex_destroy() 进行销毁,以释放其占用的系统资源。

加锁 (Locking)

加锁是为了获取对共享资源的独占访问权,进入临界区。有两种主要的加锁操作:

阻塞式加锁
  • 操作: 调用 pthread_mutex_lock() 函数。
  • 函数原型: int pthread_mutex_lock(pthread_mutex_t *mutex);
  • 代码示例:
c 复制代码
// 尝试获取锁
int ret = pthread_mutex_lock(&my_mutex);
if (ret == 0) {
    // 成功获取锁,进入临界区
    // ... 访问共享资源 ...
    // 释放锁
    pthread_mutex_unlock(&my_mutex);
} else {
    // 处理错误
}
  • 讲解:
    • 这是最常用的加锁方式。
    • mutex: 指向要加锁的互斥量的指针。
    • 如果互斥量当前没有被任何线程持有,调用线程将立即成功获取锁,函数返回 0。
    • 如果互斥量已经被其他线程持有,调用线程将被阻塞 (暂停执行),直到持有锁的线程调用 pthread_mutex_unlock() 释放锁。一旦锁被释放,被阻塞的线程之一(具体哪个取决于调度策略)将被唤醒并获得锁。
    • 原子性: 加锁操作本身是原子的,即检查锁状态并获取锁的过程是不可分割的。
非阻塞式加锁 (尝试加锁/一般不考虑)
  • 操作: 调用 pthread_mutex_trylock() 函数。
  • 函数原型: int pthread_mutex_trylock(pthread_mutex_t *mutex);
  • 代码示例:
c 复制代码
// 尝试获取锁,如果获取不到立即返回
int ret = pthread_mutex_trylock(&my_mutex);
if (ret == 0) {
    // 成功获取锁,进入临界区
    // ... 访问共享资源 ...
    // 释放锁
    pthread_mutex_unlock(&my_mutex);
} else if (ret == EBUSY) {
    // 锁当前被其他线程持有,未获取到锁
    // ... 执行其他非临界区任务或稍后重试 ...
} else {
    // 处理其他错误
}
  • 讲解:
    • mutex: 指向要尝试加锁的互斥量的指针。
    • 如果互斥量当前没有被任何线程持有,调用线程将成功获取锁,函数返回 0。
    • 如果互斥量已经被其他线程持有,调用线程将立即返回 错误码 EBUSY,而不会阻塞。
    • 这种方式适用于希望在无法立即获取锁时避免线程阻塞,从而保持程序的响应性或执行其他并行任务的场景。

解锁 (Unlocking)

解锁是释放对共享资源的独占访问权,离开临界区。

  • 操作: 调用 pthread_mutex_unlock() 函数。
  • 函数原型: int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 代码示例:
c 复制代码
// ... 访问共享资源 ...
// 释放锁
int ret = pthread_mutex_unlock(&my_mutex);
if (ret != 0) {
    // 处理错误(例如,尝试解锁非自己持有的锁)
}
  • 讲解:
    • mutex: 指向要解锁的互斥量的指针。
    • 通常,**只有持有互斥量的线程才能成功调用 **pthread_mutex_unlock()。尝试解锁一个未加锁或由其他线程持有的互斥量是错误的行为(对于默认类型的互斥量,这会导致未定义行为;对于错误检查类型的互斥量,会返回错误)。
    • 解锁后,如果之前有线程因为尝试获取该互斥量而被阻塞,其中一个线程将被唤醒并获得锁。
    • 原子性: 解锁操作本身也是原子的。

销毁 (Destruction)

销毁互斥量是释放其占用的系统资源。

  • 操作: 调用 pthread_mutex_destroy() 函数。
  • 函数原型: int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • 代码示例:
c 复制代码
// ... 使用完互斥量后 ...
int ret = pthread_mutex_destroy(&my_mutex);
if (ret != 0) {
    // 处理错误
}
  • 讲解:
    • mutex: 指向要销毁的互斥量的指针。
    • 只应该销毁通过 pthread_mutex_init() 动态初始化的互斥量。 对于通过 PTHREAD_MUTEX_INITIALIZER 静态初始化的互斥量,通常不需要手动销毁。
    • 销毁一个已经被加锁的互斥量或有线程正在等待的互斥量会导致未定义行为,所以在销毁前必须确保互斥量处于未加锁状态且没有线程在等待它

设置属性 (Setting Attributes - 通过 pthread_mutex_init)

虽然不是直接的锁操作,但互斥量的行为可以通过初始化时的属性来控制。这适用于动态初始化。

  • 相关函数: pthread_mutexattr_init(), pthread_mutexattr_settype(), pthread_mutexattr_destroy(), 等。
  • 讲解:
    • 可以通过创建并设置 pthread_mutexattr_t 对象来指定互斥量的类型。常见的类型有:
      • PTHREAD_MUTEX_NORMAL** (或默认)😗* 标准互斥量。如果同一个线程多次加锁会导致死锁。尝试解锁非自己持有的锁是未定义行为。
      • PTHREAD_MUTEX_RECURSIVE** (递归)😗* 允许同一个线程多次加锁,需要进行相同次数的解锁。适用于函数内部调用需要加锁的另一个函数的情况。
      • PTHREAD_MUTEX_ERRORCHECK** (错误检查)😗* 对使用错误(如重复加锁、解锁非自己持有的锁)进行检查并返回错误码,有助于调试。
    • 还可以设置进程共享属性,使得互斥量可以在不同进程之间使用。

锁本身的保护

当申请竞争锁的时候,申请锁的过程本身就是临界的,所以该过程也需要被保护起来,该过程必须为原子的。

怎么理解"申请锁的过程本身就是临界的"?

当我们调用 pthread_mutex_lock() 函数时,底层会发生一系列操作来尝试获取这把锁。这个过程大致可以概括为:

  • 检查锁的状态: 查看互斥量当前是空闲(unlocked)还是已被占用(locked)。
  • 如果锁是空闲的: 将锁的状态设置为已占用,表示当前线程成功获得了锁。
  • 如果锁已被占用: 线程进入等待状态(通常会被放入一个等待队列),直到锁被释放。

想象一下,如果有两个线程(Thread A 和 Thread B)同时调用 pthread_mutex_lock(&mutex),并且此时 mutex 正好是空闲的。

  • Thread A 读取 mutex 的状态,发现是空闲。
  • 操作系统发生线程切换。
  • Thread B 读取 mutex 的状态,发现仍然是空闲(因为 Thread A 还没来得及将状态设为已占用)。
  • Thread B 将 mutex 的状态设置为已占用,认为自己获取了锁。
  • 操作系统切换回 Thread A。
  • Thread A 将 mutex 的状态设置为已占用,也认为自己获取了锁。

这样,两个线程都错误地认为自己成功获取了锁,都可以进入它们试图保护的应用程序层面的临界区,这就会导致数据竞争和不一致问题。

所以,"申请锁的过程"------即检查锁状态并根据状态决定是否获取锁并修改锁状态的这个过程------本身就是一个对互斥量这个"共享资源"(互斥量的数据结构本身)的访问和修改过程。多个线程同时进行这个过程,同样面临竞态条件。因此,申请锁的过程本身就是一个需要被保护的临界区。

怎么解决"该过程必须为原子的"?

要解决申请锁过程的竞态问题,就需要保证检查锁状态并设置锁状态 (或者更一般地,测试并设置 )这个操作是原子的。这意味着,当一个线程正在执行这个"测试并设置"操作时,其他线程不能打断它,也不能同时执行这个操作。这个操作要么完全成功,要么完全失败,不会出现执行到一半被切换的情况。

这个问题不是通过在应用程序层面再加一个锁来解决的(那样会导致无限套娃),而是通过依赖底层硬件提供的原子操作指令来解决。现代CPU提供了一些特殊的指令,这些指令能够在一个不可中断的步骤中完成对内存位置的读取、修改和写回操作。

常见的硬件原子操作指令包括:

  • Test-and-Set (测试并设置): 读取内存位置的值,并将其设置为某个新值(通常是 1),整个过程是原子的。该指令通常会返回内存位置的旧值,调用者可以根据返回的旧值来判断是否成功获取了锁。
  • Compare-and-Swap (CAS, 比较并交换): 原子地比较内存位置的当前值与一个期望值,如果相等,则将该内存位置的值更新为一个新值。该指令通常也会返回内存位置的当前值,调用者可以通过比较返回的值与期望值来判断操作是否成功。

下文会对互斥量原理进行详细讲解。


互斥锁的应用

将上述代码的问题用锁来解决,代码如下:

cpp 复制代码
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <vector>
#include <iostream>

int ticket = 1000;
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);
}

细节补充

如果对一个临界区加锁之后,在临界区内部执行时允许线程切换吗?切换后会怎么样?

允许切换!并且不会对造成影响。

因为当前进程尚未释放锁,锁仍然被当前进程持有,具体持有逻辑会在下文互斥量的实现原理中理解。反正,只要该线程持有锁,其他线程只能等待线程执行完临界区后解锁,才能申请加锁。

即使可以切换,每一次切换后要申请锁的时候会检测到该锁已经被申请走,然后就会将该线程阻塞挂起。

这是每个线程都要遵守的规则!!


互斥量的实现原理

实现锁有两种方法:

  1. 硬件实现:只要关闭时钟中断就可以避免线程切换,从而保护临界区;
  2. 软件实现:该章节讲解重点。

执行流的上下文

CPU内只有一套寄存器,但是可以有多套数据。

每个进程/线程都有一套自己的上下文数据,每次在线程切换的时候就会将当前寄存器的数据打包带走,调度到下一个线程的时候,这个线程会将自己的上下文数据更新到CPU寄存器内。

上下文数据属于线程私有的,寄存器只是临时存储!

我们使用swap,exchange将内存中的互斥锁数据(变量)交换到CPU的寄存器中,本质就是当前线程在申请锁,因为锁的数据是唯一的,谁占有这个锁的数据,谁就拥有锁,当切换线程后发现锁的数据已经不在了,就是已经被占用,也就会执行挂起阻塞!

下文详细讲解该过程。

互斥锁的实现

互斥锁操作的实现,大多数体系结构都提供了swap或者exchange指令,该指令的作用是把寄存器和内存单元的数据相交换。

lock 伪代码:

plain 复制代码
lock:
    movb $0, %al          ; 1. 准备寄存器:将 al 寄存器清零
    xchgb %al, mutex      ; 2. 原子交换:将 al 寄存器的值与内存中的 mutex 值进行交换
    if(al寄存器的内容 > 0){ ; 3. 检查旧值:判断交换前 mutex 的值(现在在 al 中)是否大于 0
        return 0;         ;    如果大于 0 (锁之前是空闲的),加锁成功
    } else {
        挂起等待;         ;    如果小于等于 0 (锁之前是占用的),挂起等待
        goto lock;        ;    继续尝试获取锁 (这里简化为自旋或等待后重试)
    }

unlock 伪代码:

plain 复制代码
unlock:
    movb $1, mutex        ; 释放锁:将内存中的 mutex 值设为 1 (表示空闲)
    唤醒等待Mutex的线程;  ; 唤醒:通知等待在该互斥量上的线程
    return 0;

假设 mutex 变量在内存中,初始值为 1 (代表锁是空闲的)。

线程 1 尝试加锁:

  1. 准备寄存器: 线程 1 的 CPU 执行 movb $0, %al,将线程 1 的 al 寄存器的值设置为 0。
  2. 原子交换 ( xchgb %al, mutex): 这是关键的原子操作。CPU 执行 xchgb 指令,它会在一个不可分割的步骤中完成两件事:
    • 将内存中 mutex 的当前值 (1) 读取到线程 1 的 al 寄存器中。
    • 将线程 1 的 al 寄存器中原来的值 (0) 写入到内存的 mutex 地址。
    • 重要: 这个读取和写入是作为一个整体完成的,期间不会被其他线程的 xchgb 操作打断。
    • 交换后: 线程 1 的 al 寄存器现在的值是 1,内存中 mutex 的值现在是 0。
  3. 检查旧值: 线程 1 接着执行 if(al寄存器的内容 > 0)。由于此时线程 1 的 al 寄存器值是 1 (这是交换前 mutex 的值),条件 1 > 0 为真。
  4. 加锁成功并返回: 条件为真,线程 1 认为自己成功获取了锁,执行 return 0,进入临界区。

线程切换发生,线程 1 的上下文保存:

  • 操作系统决定进行线程切换。线程 1 当前在 CPU 上的状态,包括所有寄存器(如 al,此时值为 1)和程序计数器等,都会被保存到线程 1 自己的上下文结构中。您说的"线程1会将自己所维护的上下文,也就是当前CPU寄存器的数据全部带走"是正确的,这些数据是线程私有的,在切换时会被保存。

线程 2 尝试加锁:

  • 操作系统将线程 2 的上下文加载到 CPU 的寄存器中。此时 CPU 的 al 寄存器和程序计数器等都变成了线程 2 的状态。
  1. 准备寄存器: 线程 2 的 CPU 执行 movb $0, %al,将线程 2 的 al 寄存器的值设置为 0。
  2. 原子交换 ( xchgb %al, mutex): 线程 2 执行 xchgb 指令。
    • 注意: 此时内存中 mutex 的值是 0 (因为之前线程 1 已经将其设为 0)。
    • CPU 执行原子交换:将内存中 mutex 的当前值 (0) 读取到线程 2 的 al 寄存器中,同时将线程 2 的 al 寄存器中原来的值 (0) 写入到内存的 mutex 地址。
    • 交换后: 线程 2 的 al 寄存器现在的值是 0,内存中 mutex 的值仍然是 0。
  3. 检查旧值: 线程 2 执行 if(al寄存器的内容 > 0)。由于此时线程 2 的 al 寄存器值是 0 (这是交换前 mutex 的值),条件 0 > 0 为假。
  4. 加锁失败并等待: 条件为假,线程 2 知道锁已经被占用了,执行 else 块中的"挂起等待"和 goto lock。在实际的互斥量实现中,这通常意味着线程 2 会被放入一个等待队列,并让出 CPU,进入睡眠状态,而不是像 goto lock 这样忙等(自旋)。

解锁:

即使加锁后线程执行的代码可能会影响al寄存器,但是最后的解锁操作,是直接将1写入mutex,而不是交换,所以无论怎样都不会影响解锁操作。

总结:

  • 锁的状态判断和修改是原子的: xchgb 指令保证了"读取旧值"和"写入新值"这两个步骤是捆绑在一起不可分割的。
  • 谁先到达谁成功: 多个线程同时执行 xchgb 时,只有一个线程能成功地将内存中原来的锁状态从"空闲"(1) 换到自己的寄存器中。
  • 通过检查寄存器的旧值判断是否获取锁: 哪个线程在 xchgb 后发现自己的 al 寄存器里是旧的"空闲"状态 (1),就说明它成功地将锁的状态改为了"占用" (0),从而获取了锁。
  • 其他线程等待: 其他线程在 xchgb 后发现自己的 al 寄存器里是旧的"占用"状态 (0),就知道锁已经被别人拿走了,只能等待。
  • 上下文保存和恢复: 线程切换时,寄存器状态等上下文信息确实会被保存和恢复,这是操作系统实现多任务的基础。这并不会影响锁机制的正确性,因为锁的状态是保存在所有线程共享的内存中的,而 xchgb 操作保证了对内存锁状态的修改是原子的。

加锁和解锁的形象图示如下:

互斥量的封装

Mutex.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <pthread.h>

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            pthread_mutex_init(&_mutex, nullptr);
        }
        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            (void)n;
        }
        void Unlock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            (void)n;
        }
        ~Mutex()
        {
            pthread_mutex_destroy(&_mutex);
        }

    private:
        pthread_mutex_t _mutex;
    };

    class LockGuard
    {
    public:
        LockGuard(Mutex &mutex):_mutex(mutex)
        {
            _mutex.Lock();
        }
        ~LockGuard()
        {
            _mutex.Unlock();
        }
    private:
        Mutex &_mutex;
    };
}

testMutex.cc

cpp 复制代码
#include <iostream>
#include <mutex>
#include <string>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include "Mutex.hpp"

using namespace MutexModule;

int ticket = 1000;
// pthread_mutex_t glock = PTHREAD_MUTEX_INITIALIZER;
// std::mutex cpp_lock;

class ThreadData
{
public:
    ThreadData(const std::string &n, Mutex &lock)
        : name(n),
          lockp(&lock)
    {}
    ~ThreadData() {}
    std::string name;
    Mutex *lockp;
};

// 加锁:尽量加锁的范围粒度要比较细,尽可能的不要包含太多的非临界区代码
void *route(void *arg)
{
    ThreadData *td = static_cast<ThreadData *>(arg);
    while (1)
    {
        LockGuard guard(*td->lockp); // 加锁完成, RAII风格的互斥锁的实现
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", td->name.c_str(), ticket);
            ticket--;
        }
        else
        {
            break;
        }

        usleep(123);
    }

    return nullptr;
}

int main(void)
{
    // pthread_mutex_t lock;
    // pthread_mutex_init(&lock, nullptr); // 初始化锁

    {
        int a = 10;
    }

    int a = 20;

    Mutex lock;
    pthread_t t1, t2, t3, t4;
    ThreadData *td1 = new ThreadData("thread 1", lock);
    pthread_create(&t1, NULL, route, td1);

    ThreadData *td2 = new ThreadData("thread 2", lock);
    pthread_create(&t2, NULL, route, td2);

    ThreadData *td3 = new ThreadData("thread 3", lock);
    pthread_create(&t3, NULL, route, td3);

    ThreadData *td4 = new ThreadData("thread 4", lock);
    pthread_create(&t4, NULL, route, td4);

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

    // pthread_mutex_destroy(&lock);
    return 0;
}

封装中有一个C++的RAII细节可以注意学习:

cpp 复制代码
class LockGuard
{
public:
    LockGuard(Mutex &mutex):_mutex(mutex)
    {
        _mutex.Lock();
    }
    ~LockGuard()
    {
        _mutex.Unlock();
    }
private:
    Mutex &_mutex;
};

通过LockGuard的封装,在使用Mutex的时候,使用局部的LockGuard

cpp 复制代码
while (1)
{
    LockGuard guard(*td->lockp); // 加锁完成, RAII风格的互斥锁的实现
    if (ticket > 0)
    {
        usleep(1000);
        printf("%s sells ticket:%d\n", td->name.c_str(), ticket);
        ticket--;
    }
    else
    {
        break;
    }

    usleep(123);
}

当一次循环结束后,guard的析构函数就会自动调用Mutex的析构函数,进而释放锁,到达自动关锁的目的,类似于智能指针,这就是RAII。

相关推荐
chuxinweihui19 分钟前
初识c++
开发语言·c++·学习
掘根1 小时前
【云备份】服务端工具类实现
运维·服务器
blueshaw2 小时前
CMake中的“包管理“模块FetchContent
c++·cmake
Johny_Zhao3 小时前
Ubuntu堡垒机搭建与设备管理指南
linux·网络·人工智能·信息安全·云计算·yum源·系统运维·teleport
程序员-King.3 小时前
【网络服务器】——回声服务器(echo)
linux·运维·服务器
LILI000003 小时前
C++静态编译标准库(libgcc、libstdc++)
开发语言·c++
华纳云IDC服务商4 小时前
华纳云:centos如何实现JSP页面的动态加载
java·linux·centos
云中飞鸿5 小时前
加载ko驱动模块:显示Arm版本问题解决!
linux·arm开发
孞㐑¥5 小时前
C++之特殊类设计及类型转换
开发语言·c++·经验分享·笔记
二进制coder5 小时前
ARM32静态交叉编译并使用pidstat教程
linux