【Linux线程】Linux系统多线程(五):<线程同步与互斥>线程互斥

🎬 个人主页艾莉丝努力练剑
专栏传送门 :《C语言》《数据结构与算法》《C/C++干货分享&学习过程记录
Linux操作系统编程详解》《笔试/面试常见算法:从基础到进阶》《Python干货分享

⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平


🎬 艾莉丝的简介:


文章目录

  • 前言
  • [1 ~> 认知的起点:被"降维打击"的共享资源](#1 ~> 认知的起点:被“降维打击”的共享资源)
    • [1.1 为什么会出现"负数"?------ 拆解非原子性的灾难在](#1.1 为什么会出现“负数”?—— 拆解非原子性的灾难在)
    • [1.2 "滞后写回"导致的降维打击:](#1.2 “滞后写回”导致的降维打击:)
  • [2 ~> 锁的哲学悖论:申请的是"执行许可"](#2 ~> 锁的哲学悖论:申请的是“执行许可”)
  • [3 ~> 深度解析:原子性(Atomicity)的硬件根基](#3 ~> 深度解析:原子性(Atomicity)的硬件根基)
    • [3.1 锁的底层推演(伪代码还原)](#3.1 锁的底层推演(伪代码还原))
    • [3.2 深度剖析一下](#3.2 深度剖析一下)
  • [4 ~> 实践:Mutex 操作规范与约定](#4 ~> 实践:Mutex 操作规范与约定)
    • [4.1 代码验证](#4.1 代码验证)
    • [4.2 架构师的避坑指南](#4.2 架构师的避坑指南)
  • [5 ~> 性能权衡:临界区粒度的艺术](#5 ~> 性能权衡:临界区粒度的艺术)
  • [6 ~> 干货整理](#6 ~> 干货整理)
  • [7 ~> 线程互斥收尾](#7 ~> 线程互斥收尾)
    • [7.1 互斥量的封装](#7.1 互斥量的封装)
      • [7.1.1 抢票逻辑实现(Main.cc)](#7.1.1 抢票逻辑实现(Main.cc))
      • [7.1.2 补充与严谨性检查](#7.1.2 补充与严谨性检查)
      • [7.1.3 自己来封装一个互斥量](#7.1.3 自己来封装一个互斥量)
    • [7.2 补充](#7.2 补充)
      • [7.2.1 互斥量(Mutex)的底层封装](#7.2.1 互斥量(Mutex)的底层封装)
      • [7.2.2 C++11 标准库互斥量的应用](#7.2.2 C++11 标准库互斥量的应用)
      • [7.2.3 RAII 风格锁管理:LockGuard](#7.2.3 RAII 风格锁管理:LockGuard)
      • [7.2.4 临界区逻辑与线程睡眠](#7.2.4 临界区逻辑与线程睡眠)
      • [7.2.5 补充:关于"锁的理论收尾"](#7.2.5 补充:关于“锁的理论收尾”)
  • 结尾

前言

在系统级编程的宏大架构中,并发往往伴随着最隐蔽、最致命的数据灾难。多线程虽然能通过并行计算极大提升程序的吞吐量,但如果缺乏严谨的同步与互斥机制,原本高效的 C/C++ 代码就会沦为制造薛定谔 Bug 的温床。今天,我们将穿透高级语言的迷雾,直击 CPU 体系结构与硬件上下文,深度剖析 Linux 线程互斥与锁的本质。


1 ~> 认知的起点:被"降维打击"的共享资源

在多线程环境中,线程默认会共享进程的文件描 述符表以及全局变量等资源 。这种物理上的共享性,正是导致并发访问出现线程安全问题的根源。

以经典的"售票系统"为例,假设我们有 1000 张票,代码逻辑是 if (tickets > 0) { usleep(1000); tickets--; }。运行结果往往令人大跌眼镜:票数不仅会错乱,甚至会被抢到负数(如 -1-2) 。

1.1 为什么会出现"负数"?------ 拆解非原子性的灾难在

C/C++ 中看似极简的一行 tickets--,在底层汇编(x86 架构)实际上会被拆解为三个不可分割的步骤:

  • 1、Load (读取)mov eax, [tickets],将内存中的全局变量加载到 CPU 的私有寄存器(如 eax)中 。
  • 2、Update (更新)sub eax, 1,在 CPU 内部完成减法计算 。
  • 3、Store (写回)mov [tickets], eax,将计算后的新值写回主存 。

1.2 "滞后写回"导致的降维打击:

全局变量存在于共享的数据段,而寄存器和栈是每个线程私有的硬件上下文 。假设当前 tickets = 100

  • 线程 A 执行了 Load 操作,其私有寄存器 eax 拿到了 100。就在此时,时钟中断到来,线程 A 被 OS 强行剥夺 CPU,进入等待队列。OS 会保存 A 的硬件上下文(记住它手里捏着 100) 。
  • 线程 B 被调度上台,它执行极快,一口气完成了 Load-Update-Store,将内存中的票数改为了 99。随后其他线程继续疯狂抢票,将内存中的票数减到了 1
  • 此时,线程 A 恢复执行!它根本不知道外界发生了什么,依然从上次中断的地方继续:100 - 1 = 99,然后执行 Store 操作,将 99 强行覆盖回内存 。
  • 结局:线程 B 和其他线程辛辛苦苦扣减的票数瞬间蒸发,已经被卖出的 98 张票在内存中"诡异复活" 。

为了防止这种微观层面的数据篡改,我们必须将被多线程并发访问的共享资源保护起来,这部分被保护的资源被称为临界资源 。而代码中访问这些资源的那段逻辑,则被称为临界区


2 ~> 锁的哲学悖论:申请的是"执行许可"

要保护临界区,我们需要引入"互斥"(Mutex)机制。互斥的理念非常霸道:它保证在任何时刻,有且只有一个执行流能够进入临界区,对其他线程形成一堵物理高墙 。

但在实现这把锁时,我们会遇到一个极致的逻辑悖论:

大家都必须先去申请锁,前提是所有线程都必须先"看到"同一把锁 。这意味着,锁本身也是一个被多线程共享的临界资源

既然锁是临界资源,那"申请锁"这个动作本身由谁来保护?如果申请锁的操作也不是原子的,那么多线程并发申请锁时就会直接导致锁机制崩溃。因此,申请锁的过程,必须是原子的 。在这个意义上,申请互斥锁的本质,就是在向系统申请唯一的**"执行许可"** 。


3 ~> 深度解析:原子性(Atomicity)的硬件根基

在并发语境下,原子性意味着一个操作不会被任何调度机制打断,该操作只有两态:要么彻底完成,要么完全未完成 。

为了打破上述的"锁悖论",现代 CPU 直接在硬件层面提供了降维打击般的支持。大部分体系结构都提供了一条特殊的汇编指令:swapexchange(如 x86 的 xchgb) 。

这条指令的作用是:一步到位地将内存单元与 CPU 寄存器的数据进行交换,且在硬件总线级别保证不可中断

3.1 锁的底层推演(伪代码还原)

假设内存中有一把锁 mutex = 1(1 代表锁可用,0 代表锁被占用)。

bash 复制代码
lock:
    movb $0, %al        ; 将线程私有寄存器 al 初始化为 0
    xchgb %al, mutex    ; 【神来之笔】:原子交换寄存器 al 和内存 mutex 的值
    if (al 寄存器的内容 > 0) {
        return 0;       ; 加锁成功,进入临界区
    } else {
        挂起等待;        ; 加锁失败,让出 CPU
        goto lock;
    }

3.2 深度剖析一下

1、线程 A 执行 xchgb 后,内存的 mutex 变成了 0,而 A 的私有寄存器 al 拿到了 1。

2、exchange 指令的绝妙之处在于:它以原子的方式,把一个共享的数据内容,变成了一个线程私有的内容

3、此时线程 B 再来执行 xchgb,它只能用自己的 0 换回内存里的 0,从而陷入阻塞。只有当线程 A 退出临界区,执行 unlock(将内存 mutex 置回 1)时,其他线程才有机会抢到这把锁 。

注:在早期的单核时代,内核甚至可以通过直接"关闭时钟中断"来拒绝上下文切换 ,从而简单粗暴地实现系统级原子操作


4 ~> 实践:Mutex 操作规范与约定

4.1 代码验证

在 Linux 环境下使用 pthread 库进行系统编程时,锁就是一个类型为 pthread_mutex_t 的变量。

c 复制代码
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

// 静态初始化全局锁
pthread_mutex_t glock = PTHREAD_MUTEX_INITIALIZER; 
int tickets = 1000;

void* thread_run(void* arg) {
    while (1) {
        // 原子操作:申请执行许可,失败则当前线程挂起等待
        pthread_mutex_lock(&glock); 
        
        // ============ 临界区开始 ============
        if (tickets > 0) {
            usleep(1000); // 模拟耗时操作,但在锁的保护下绝对安全
            printf("Selling ticket: %d\n", tickets);
            tickets--;
            pthread_mutex_unlock(&glock); // 释放锁
        } else {
            pthread_mutex_unlock(&glock); // 异常或分支退出前,必须释放锁!
            break;
        }
        // ============ 临界区结束 ============
    }
    return NULL;
}

4.2 架构师的避坑指南

1、局部锁的生命周期 :全局锁可以使用宏静态分配 。但如果是局部锁,必须使用 pthread_mutex_init 动态分配,且在线程结束前严格调用 pthread_mutex_destroy 销毁以防止内存泄漏 。更重要的是,局部锁必须通过指针传参(如放入结构体中),确保所有线程竞争的是同一块物理内存上的锁 。

2、防君子不防小人 :加锁保护本质上是一种程序员之间的 "代码约定" 。如果线程 A 规规矩矩地调用 lock(),而线程 B 耍小聪明直接绕过锁去强改 tickets,互斥机制依然会土崩瓦解。不守规矩的并发不是在利用机制,而是在写 Bug 。


5 ~> 性能权衡:临界区粒度的艺术

并发编程的核心矛盾,就是用性能换取安全性

一旦加锁,原本并行的多线程就在临界区前变成了排队串行执行 ,这必然会引起效率的骤降 。因此,高级系统程序员必须恪守一条铁律:临界区粒度最小化

只将不可分割的核心共享资源访问逻辑(如 if (tickets > 0)tickets--)放在锁的内部。任何耗时的非共享操作(如无关的局部变量计算、终端日志打印 printf 等)都应毫不留情地移出临界区。串行比重越小,多核 CPU 的并发天花板才越高。


6 ~> 干货整理

  • "临界资源:多线程执行流被保护的共享的资源就叫做临界资源;临界区:每个线程内部,访问临界资源的代码,就叫做临界区。"
  • "原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。"
  • "大家都必须先申请的前提是必须先看到锁。所以锁本身也是临界资源!这就要求申请锁的过程,必须是原子的。申请锁的本质是在申请执行许可!"
  • " 交换指令(exchange) 的本质不就是:把一个共享的数据内容,变成一个线程私有的内容!"
  • "加锁保护,是一种约定,大家都要遵守!"

7 ~> 线程互斥收尾

7.1 互斥量的封装

  • 我们写一个C++11的关于全局变量抢票demo
    在代码中去除了冗余逻辑,并遵循了 RAII风格锁管理,确保在异常或逻辑跳转时不会发生死锁。

7.1.1 抢票逻辑实现(Main.cc

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
#include <chrono>

/**
 * 全局共享资源
 * (1)g_tickets: 剩余票数 [cite: 18, 41]
 * (2)g_mtx: 保护票数的互斥锁 [cite: 20]
 */
int g_tickets = 1000; 
std::mutex g_mtx;

/**
 * 抢票执行函数
 * @param thread_id 线程编号,用于区分不同线程输出 [cite: 22, 69]
 */
void grabTicket(int thread_id) {
    while (true) {
        // 临界区范围限制
        {
            // 使用 std::lock_guard 实现 RAII 风格加锁 [cite: 74, 84]
            // 构造时自动加锁,作用域结束析构时自动解锁
            std::lock_guard<std::mutex> lock(g_mtx);

            if (g_tickets > 0) {
                // 模拟业务处理耗时,增加并发冲突概率 [cite: 28, 77]
                std::this_thread::sleep_for(std::chrono::milliseconds(1));
                
                --g_tickets; [cite: 29, 78]
                std::cout << "Thread [" << thread_id << "] grabbed a ticket. Remaining: " 
                          << g_tickets << std::endl; [cite: 30, 78]
            } else {
                // 票已抢完,退出循环 [cite: 83]
                break;
            }
        }
        // 适当在锁外短暂休眠,模拟线程处理其他逻辑,防止单线程"饥饿"式霸占锁
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
    }
}

int main() 
{
    const int thread_count = 4; // 启动 4 个线程进行抢票 [cite: 40, 42]
    std::vector<std::thread> workers;

    // 创建线程 [cite: 41]
    for (int i = 1; i <= thread_count; ++i) {
        workers.emplace_back(grabTicket, i);
    }

    // 等待所有线程完成 [cite: 13]
    for (auto& t : workers) {
        t.join();
    }

    std::cout << "All tickets are sold out." << std::endl;
    return 0;
}

7.1.2 补充与严谨性检查

以下几点在实际工程中需要优化的细节:

(1) 锁的粒度控制 在你的代码片段中,std::cout 位于锁内。在 Linux 内核开发中,std::cout(底层调用 write 系统调用)是相对沉重的 I/O 操作。如果并发量极高,将打印操作放在锁内会导致严重的性能瓶颈

  • 优化建议:在高性能场景下,应先在锁内拷贝剩余票数,解锁后再进行打印。

(2) RAII 锁的禁用拷贝语义 正如文档中提到的"锁一般是禁止被拷贝的"。在 C++11 中,std::mutex 原生就通过以下方式禁用了拷贝,你在封装自己的 Mutex 类时也应效仿:

cpp 复制代码
// 在你的 Mutex 类中加入:
Mutex(const Mutex&) = delete;
Mutex& operator=(const Mutex&) = delete;

(3) 虚假唤醒与原子性 ,使用 if (tickets > 0) 进行判断。

1、因此,我们一般不喜欢写if判断,而是改成while条件(也有判断能力),做了一次可靠性的保证。

2、用while条件语句会多次检查,增强了代码的健壮性。

这在简单的抢票场景中是足够的,但如果是更复杂的生产环境(涉及等待队列),通常需要配合 std::condition_variable 并使用 while 循环检查条件,以防止系统层面的 "虚假唤醒"

  • 我们下一篇博客会介绍"伪唤醒(虚假唤醒)",这里算是提前预告一下哇!

  • 后面推荐用语言的锁。

7.1.3 自己来封装一个互斥量

  • 我们自己来封装一个互斥量

对锁进行完整的封装------特别简单:

加锁的逻辑,未来也可以自己封装一个。

今天不带加锁减锁,直接四个进程:

我们再对这个锁进行一下改写,如果再加上一个锁的守卫呢?

为了方便我们理解和看可以用花括号括起来这个区域(可以不带看自己)。

这个区域就是临界区,我们把这种风格的加锁逻辑就叫做RAII风格的加锁逻辑。(9:50~10:01睡着了)

  • 锁一般是禁止被拷贝、赋值的,可以把构造函数私有化或delete掉。

可以把锁改成不允许拷贝的,但是这样可能会复杂点不方便使用。

cpp 复制代码
#include <iostream>
#include <thread>
#include <unistd.h>
#include "Mutex.hpp"    // 假设内部包含了 Mutex 类
#include "LockGuard.hpp" // 假设内部包含了图片中的 LockGuard 类

int tickets = 1000; // 共享资源:总票数
Mutex lock;         // 全局互斥锁

void buyTicket(int id) {
    while (true) {
        // 使用花括号 {} 显式定义一个局部作用域
        // 这个区域就是所谓的"临界区"
        {
            // RAII 风格加锁:
            // 创建 lockguard 对象时,构造函数自动调用 lock.Lock()
            LockGuard lockguard(&lock); 

            if (tickets > 0) {
                usleep(1000); // 模拟购票耗时
                std::cout << "Thread " << id << " bought ticket, remaining: " << --tickets << std::endl;
                
                // 离开这个花括号时,lockguard 声明周期结束
                // 析构函数会被自动调用,执行 lock.Unlock()
            } 
            else {
                // 同样的,即便在这里 break 掉循环
                // 只要出了这个花括号的作用域,锁就会被自动释放
                break; 
            }
        } 
        // 临界区结束,锁已释放,其他线程可以竞争了
    }
}

int main() {
    std::thread threads[4];
    
    // 创建4个线程模拟抢票
    for (int i = 0; i < 4; ++i) {
        threads[i] = std::thread(buyTicket, i + 1);
    }

    // 等待所有线程结束
    for (auto& t : threads) {
        t.join();
    }

    std::cout << "All tickets sold out!" << std::endl;
    return 0;
}

展示了多线程编程中一个非常重要的改进:使用 RAII(资源获取即初始化)风格来管理互斥锁

相比于你之前那张手动调用 Lock() 和 Unlock() 的代码,这里引入了一个 LockGuard 对象。它的核心逻辑是:构造时自动加锁,析构(即超出作用域)时自动解锁。这样可以有效防止因忘记手动解锁或代码提前 break/return 导致的死锁问题。

运行:

理解一下这种改进的好处:

7.2 补充

7.2.1 互斥量(Mutex)的底层封装

文档中展示了对 pthread_mutex_t 的类封装。在内核视角下,互斥锁不仅仅是一个变量,它涉及到原子操作(Atomic Ops)和进程的状态切换(挂起与唤醒)。

  • 构造与析构 :利用 pthread_mutex_initpthread_mutex_destroy 管理锁的生命周期。
  • 行为封装 :将 Lock()Unlock() 封装为成员函数,隐藏了 pthread 原生 API 的繁琐细节。
  • 严谨性补充
    • 拷贝控制 :互斥锁在逻辑上是独占的,物理上绑定了特定的内存地址。严禁拷贝或者赋值 。在 C++ 中应显式使用 = delete 禁用拷贝构造和赋值运算符,防止因浅拷贝导致多个对象尝试销毁同一个内核互斥量。
    • 返回值检查 :原生的 pthread 函数会返回错误码(如 EBUSYEDEADLK)。在封装时,应当通过 assert 或异常机制处理这些错误,而非保持静默。

7.2.2 C++11 标准库互斥量的应用

文档提到了 C++11 的 std::mutex 抢票 Demo。

  • 核心逻辑 :定义全局 std::mutex g_mutex和共享资源 g_tickets。在操作资源前调用 lock(),操作完成后调用 unlock()
  • 严谨性补充
    • 临界区粒度 :文档中在 if (g_tickets > 0) 之前加锁,直到票数递减并打印后才解锁。在真实高并发环境下,std::cout 属于慢速 I/O 操作。如果将其放在锁内,会大幅度降低并发性能。原则:锁的粒度越小越好
    • 线程安全陷阱 :单纯依靠锁保护 g_tickets 递减是不够的,如果后续逻辑依赖该值进行判断,必须确保整个 "检查-决策-执行" 过程是原子的。

7.2.3 RAII 风格锁管理:LockGuard

这是文档中最重要的进阶内容。LockGuard 类通过构造函数加锁,析构函数自动解锁。

  • 自动化优势 :它解决了由于 if 分支提前返回、break 跳出循环或函数抛出异常导致的 死锁(Deadlock)风险
  • 作用域控制 :文档提到可以使用 {} 花括号人为控制局部作用域。当 LockGuard 对象超出花括号范围时,析构函数自动触发,释放锁。
  • 内核级视角补充
    • 性能开销:RAII 风格在 C++ 中几乎是零开销的(Zero-overhead),编译器会将其优化为直接的加解锁指令。
    • LockGuard vs UniqueLock:虽然文档实现了简易的 LockGuard,但工业级代码通常会考虑 std::lock_guard(轻量)或 std::unique_lock(支持条件变量、手动提前解锁)。

7.2.4 临界区逻辑与线程睡眠

在抢票代码中,使用了 usleepsleep_for 来模拟业务处理。

  • 意图分析:通过睡眠让出 CPU 时间片,故意暴露多线程在没有锁保护时可能产生的并发问题(如票数变为负数)。
  • 内核逻辑补充
    • 在 Linux 内核中,当一个线程在持有锁的情况下进入睡眠,这是极其危险的行为(除非是可睡眠锁如 mutex)。虽然在用户态这只是效率问题,但会导致其他竞争该锁的线程长时间在内核态处于 TASK_UNINTERRUPTIBLETASK_INTERRUPTIBLE 状态。
    • 建议:在生产环境中,严禁在临界区内进行不必要的阻塞操作。

7.2.5 补充:关于"锁的理论收尾"

以下是内核开发中必须知道的两个进阶概念:

(1)原子性(Atomicity) :加锁的本质是保证操作的原子性。在 x86 架构下,锁的底层通常对应着 lock 前缀的汇编指令,确保对总线或缓存行的锁定。
(2)公平性(Fairness) :原生互斥锁通常是不保证公平的(Non-fair) ,即等待最久的线程不一定最先拿到锁。如果你的业务对顺序有严格要求,需要结合 信号量(Semaphore) 或特定的调度策略。


结尾

uu们,本文的内容到这里就全部结束了,艾莉丝在这里再次感谢您的阅读!

|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ### 艾莉丝努力练剑 C/C++ & Linux 底层探索者 | 一个正在努力练剑的技术博主 *** ** * ** *** 👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。 ❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。 ⭐ 【收藏】 把核心知识点存好,在需要时随时查、随时用。 💬 【评论】 分享你的经验或疑问,评论区一起交流避坑! 不要忘记给博主"一键四连"哦! "今日练剑达成!" "技术之路难免有困惑,但同行的人会让前进更有方向。" |

结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主"一键四连"哦!

往期回顾

【Linux线程】Linux系统多线程(四):线程ID及进程地址空间布局,线程封装

🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡 ૮₍ ˶ ˊ ᴥ ˋ˶₎ა

相关推荐
南無忘码至尊2 小时前
Unity学习90天 - 第4天 - 认识物理系统基础并实现物体碰撞反弹
学习·unity·游戏引擎
亚空间仓鼠2 小时前
Python学习日志(二):基础语法
windows·python·学习
百结2142 小时前
keepalived高可用与负载均衡
运维·负载均衡
Yeats_Liao2 小时前
混合部署架构:CPU+GPU协同推理的任务调度策略
服务器·arm开发·人工智能·架构·边缘计算
kyle~2 小时前
FANUC机械臂---R寄存器
开发语言·c++·机器人·fanuc
weixin_457260502 小时前
Linux 命令精讲(博客案例)
linux·运维·服务器
南無忘码至尊2 小时前
Unity学习90天 - 第4天 - 学习预制体 Prefab + 实例化并实现按鼠标生成子弹
学习·unity·游戏引擎
AnalogElectronic2 小时前
PHP学习02,PHP + jQuery + HTML + MySQL+nginx 做一个多用户云笔记项目
学习·php·jquery
听风lighting2 小时前
RTT-SMART学习 (二):启动过程
linux·c·rtt·rtos·rtt-smart