线程互斥:并发编程中的互斥量(Mutex)与RAII风格锁管理机制

目录

一、进程与线程间的互斥相关背景概念

1、共享资源

2、临界资源

3、临界区

4、互斥

互斥锁

信号量

5、原子性(后续将详细讨论如何实现)

二、互斥量(Mutex)的引入

1、线程局部变量与共享变量

2、并发访问共享变量的问题

[1. 执行结果示例](#1. 执行结果示例)

[问题1: 重复售票](#问题1: 重复售票)

[问题2: 票号不连续且重复](#问题2: 票号不连续且重复)

[问题3: 票数出现负数](#问题3: 票数出现负数)

[问题4: 数据竞争导致的数据不一致](#问题4: 数据竞争导致的数据不一致)

典型的竞态条件场景

[2. 问题分析](#2. 问题分析)

[3. 使用 objdump 查看反汇编](#3. 使用 objdump 查看反汇编)

[4. 预期的汇编代码分析](#4. 预期的汇编代码分析)

[5. 竞态条件场景示例](#5. 竞态条件场景示例)

[6. 完整的执行流程分析](#6. 完整的执行流程分析)

[7. 存在的问题](#7. 存在的问题)

3、互斥量的引入

三、互斥量的接口

[1、引入锁:pthread_mutex_t 互斥锁/互斥量](#1、引入锁:pthread_mutex_t 互斥锁/互斥量)

[1. pthread_mutex_t 类型](#1. pthread_mutex_t 类型)

[pthread_mutex_t 的定义](#pthread_mutex_t 的定义)

关键特性

[pthread_mutex_t 的内部结构(glibc 实现示例)](#pthread_mutex_t 的内部结构(glibc 实现示例))

[2. 头文件](#2. 头文件)

标准化需求

模块化设计

编译依赖管理

跨平台兼容性

历史与生态原因

2、初始化互斥量的两种方法

[1. 静态初始化](#1. 静态初始化)

代码示例

特点

注意事项

关于PTHREAD_MUTEX_INITIALIZER

宏的作用

宏的展开形式

不同变体的含义

实际使用方式

底层实现(glibc)

总结

[2. 动态初始化](#2. 动态初始化)

函数原型

参数

返回值

[(1) 成功 (0)](#(1) 成功 (0))

[(2) 失败(非零错误码)](#(2) 失败(非零错误码))

代码示例

特点

[pthread_mutex_t 的锁类型(了解)](#pthread_mutex_t 的锁类型(了解))

自定义属性示例(了解)

注意事项

[3. 两种方法的对比](#3. 两种方法的对比)

[4. 总结](#4. 总结)

3、销毁互斥量(pthread_mutex_destroy)

[1. 函数原型](#1. 函数原型)

[2. 销毁互斥量的作用](#2. 销毁互斥量的作用)

[3. 何时销毁互斥量?](#3. 何时销毁互斥量?)

[4. 销毁互斥量的前提条件](#4. 销毁互斥量的前提条件)

[5. 销毁互斥量的步骤](#5. 销毁互斥量的步骤)

[(1) 确保互斥量未被使用](#(1) 确保互斥量未被使用)

[(2) 调用 pthread_mutex_destroy](#(2) 调用 pthread_mutex_destroy)

[(3) 后续不再使用该互斥量](#(3) 后续不再使用该互斥量)

[6. 示例代码](#6. 示例代码)

[7. 注意事项](#7. 注意事项)

[8. 销毁互斥量的底层实现(glibc 示例)](#8. 销毁互斥量的底层实现(glibc 示例))

[9. 总结](#9. 总结)

4、互斥量加锁(pthread_mutex_lock)与解锁(pthread_mutex_unlock)

[1. 加锁:pthread_mutex_lock](#1. 加锁:pthread_mutex_lock)

函数原型

行为

示例

[2. 非阻塞加锁:pthread_mutex_trylock](#2. 非阻塞加锁:pthread_mutex_trylock)

函数原型

适用场景

示例

[3. 解锁:pthread_mutex_unlock](#3. 解锁:pthread_mutex_unlock)

函数原型

行为

示例

[4. 加锁与解锁的配对使用](#4. 加锁与解锁的配对使用)

基本规则

递归锁的特殊情况

示例(递归锁)(了解)

[5. 注意事项](#5. 注意事项)

[(1) 死锁预防](#(1) 死锁预防)

[(2) 性能优化](#(2) 性能优化)

[(3) 错误处理](#(3) 错误处理)

[6. 底层实现(简化)](#6. 底层实现(简化))

[7. 总结](#7. 总结)

加锁(pthread_mutex_lock)

解锁(pthread_mutex_unlock)

最佳实践

5、改进后的售票系统

[1. 改进后输出结果的特点](#1. 改进后输出结果的特点)

[2. 根本原因分析](#2. 根本原因分析)

[3. 总结](#3. 总结)

[4. 改进点](#4. 改进点)

四、线程切换的时间点及机制

1、线程切换的时间点

[1. 时间片耗尽](#1. 时间片耗尽)

[2. 线程阻塞](#2. 线程阻塞)

[3. 线程主动调用睡眠函数](#3. 线程主动调用睡眠函数)

[4. 中断或异常](#4. 中断或异常)

[5. 优先级调整](#5. 优先级调整)

2、线程切换的机制

[1. 陷入内核态](#1. 陷入内核态)

[2. 选择新线程并恢复上下文](#2. 选择新线程并恢复上下文)

[3. 返回用户态](#3. 返回用户态)

[4. 进行检查和调度](#4. 进行检查和调度)

3、多线程中的并发与切换

制造更多的并发

更多的切换

4、原子性与线程安全

原子操作的重要性

CPU与汇编语言

5、总结

五、锁的核心能力与临界区保护机制

1、锁的核心能力本质

[1. 串行化执行](#1. 串行化执行)

[2. 变相的原子性](#2. 变相的原子性)

[3. 对临界资源的保护](#3. 对临界资源的保护)

2、临界区与非临界区

3、线程遵守规则的重要性

4、临界区内的线程切换问题

5、锁的保护机制总结

[1. 锁的核心作用](#1. 锁的核心作用)

[2. 临界区与非临界区的区分](#2. 临界区与非临界区的区分)

[3. 线程切换的影响](#3. 线程切换的影响)

[4. 规则的重要性](#4. 规则的重要性)

6、锁是否需要被保护?

7、操作系统的工作原理

8、示例代码(正确使用锁)

9、总结

六、互斥量(Mutex)的作用及其优点

1、互斥量的核心作用

[1. 互斥访问共享资源](#1. 互斥访问共享资源)

[2. 防止数据不一致](#2. 防止数据不一致)

[3. 同步线程执行顺序](#3. 同步线程执行顺序)

2、互斥量的工作原理

加锁(Lock)与解锁(Unlock)

所有权语义

3、互斥量的优点

4、互斥量的局限性及注意事项

5、总结

七、互斥量实现原理探究

1、背景引入

2、原子操作与硬件支持

3、锁的原理

4、互斥量的基本实现原理

[1. 初始状态](#1. 初始状态)

[2. 加锁操作(lock)](#2. 加锁操作(lock))

[3. 解锁操作(unlock)](#3. 解锁操作(unlock))

[4. 实际实现中的优化](#4. 实际实现中的优化)

[5. 注意](#5. 注意)

5、临界区与非临界区、锁的实现前提

6、总结

八、互斥量的封装与RAII风格锁管理

[回顾:RAII 原则(Resource Acquisition Is Initialization)](#回顾:RAII 原则(Resource Acquisition Is Initialization))

[1. RAII 的核心思想](#1. RAII 的核心思想)

[2. RAII 的工作原理](#2. RAII 的工作原理)

1、互斥量封装 (Mutex 类)

重点:禁用拷贝构造和赋值操作符(必须要注意!!!)

设计意图与优势

2、RAII风格锁管理 (LockGuard 类)

[1. 核心机制](#1. 核心机制)

[(1) 构造函数:自动加锁](#(1) 构造函数:自动加锁)

[(2) 析构函数:自动解锁](#(2) 析构函数:自动解锁)

[(3) 私有成员:持有 Mutex 引用](#(3) 私有成员:持有 Mutex 引用)

[2. RAII 原则的应用](#2. RAII 原则的应用)

[3. 使用示例](#3. 使用示例)

[4. 优势](#4. 优势)

[5. 注意事项](#5. 注意事项)

[6. 改进建议](#6. 改进建议)

[7. 总结](#7. 总结)

3、使用示例:抢票系统

线程函数:route

[1. LockGuard 的构造时机](#1. LockGuard 的构造时机)

代码位置

构造时机

关键点

[2. LockGuard 的析构时机](#2. LockGuard 的析构时机)

代码位置

析构时机

关键点

[3. 构造与析构的完整流程](#3. 构造与析构的完整流程)

单次循环的流程

多线程竞争示例

[4. 为什么这样设计?](#4. 为什么这样设计?)

[RAII 原则的核心](#RAII 原则的核心)

适用场景

[5. 对比手动管理锁](#5. 对比手动管理锁)

手动管理锁的代码

[RAII 风格的优势](#RAII 风格的优势)

[6. 总结](#6. 总结)

4、C++11中的互斥量与锁管理

5、总结

[6、ThreadData 类封装线程数据:基于 RAII 风格的多线程抢票系统(改进版)](#6、ThreadData 类封装线程数据:基于 RAII 风格的多线程抢票系统(改进版))

[1. 代码结构](#1. 代码结构)

[2. 核心组件解析](#2. 核心组件解析)

[(1) ThreadData 类](#(1) ThreadData 类)

[(2) route 函数(线程函数)](#(2) route 函数(线程函数))

[(3) main 函数](#(3) main 函数)

[3. RAII 风格锁管理的应用](#3. RAII 风格锁管理的应用)

[(1) LockGuard 的作用](#(1) LockGuard 的作用)

[(2) 锁的粒度控制](#(2) 锁的粒度控制)

[4. 执行流程](#4. 执行流程)

[5. 关键点解析](#5. 关键点解析)

[(1) 为什么使用 ThreadData?](#(1) 为什么使用 ThreadData?)

[(2) 为什么锁的粒度要细?](#(2) 为什么锁的粒度要细?)

[(3) 为什么使用 RAII 风格?](#(3) 为什么使用 RAII 风格?)

[6. 潜在问题与改进](#6. 潜在问题与改进)

[(1) 动态内存管理](#(1) 动态内存管理)

[(2) 互斥量传递](#(2) 互斥量传递)

[(3) C++11 线程库](#(3) C++11 线程库)

[7. 总结](#7. 总结)


一、进程与线程间的互斥相关背景概念

在计算机系统中,进程和线程作为基本的执行单元,经常需要访问共享资源。为了确保系统的稳定性和数据的正确性,必须引入一系列机制来管理这些共享资源的访问,其中互斥是核心概念之一。

1、共享资源

  • 共享资源指的是在计算机系统中,可以被多个进程或线程同时访问(或尝试访问)的数据、设备或其他系统资源。

  • 这些资源可能是内存中的某个变量、文件、数据库记录,或者是硬件设备如打印机、磁盘等。

  • 由于多个执行单元可能同时尝试修改或读取这些资源,因此必须采取适当的同步与互斥机制来避免数据不一致或系统错误。

2、临界资源

  • 临界资源是共享资源的一个特定子集,特指那些在多线程执行环境中需要被特别保护的共享资源。

  • 当多个线程尝试同时访问或修改这类资源时,如果不加以控制,可能会导致数据损坏、结果不可预测或系统崩溃。

  • 因此,临界资源通常需要通过互斥机制来确保在任何给定时刻,只有一个线程能够访问它。

3、临界区

  • 临界区是指每个线程内部,用于访问临界资源的代码段。

  • 这段代码在执行过程中,必须保证对临界资源的独占访问,以防止其他线程同时进入并修改该资源。

  • 临界区的定义和实现是互斥机制的关键部分,它决定了哪些代码需要受到保护,以及如何保护。

4、互斥

  • 互斥是一种同步机制,用于确保在任何时刻,有且仅有一个执行流(无论是进程还是线程)能够进入临界区,访问临界资源。

  • 互斥的实现通常依赖于锁(如互斥锁、信号量等)或其他同步原语,这些机制能够在多线程或进程环境中提供必要的同步和协调。

  • 互斥的主要作用是保护临界资源,防止因并发访问而导致的数据不一致或系统错误。

互斥锁

  • 是最基本的互斥实现方式之一。

  • 当一个线程想要进入临界区时,它必须首先获取互斥锁。

  • 如果锁已被其他线程持有,则当前线程将被阻塞,直到锁被释放。

信号量

  • 是一种更通用的同步机制,可以用于实现互斥,也可以用于控制对有限资源的访问。

  • 在互斥场景中,信号量的值通常被初始化为1,表示资源可用。

  • 当线程进入临界区时,它"消耗"一个信号量(将值减1),离开时"释放"一个信号量(将值加1)。

5、原子性(后续将详细讨论如何实现)

**原子性是指一个操作在执行过程中不会被任何调度机制打断的特性。这意味着该操作要么完全执行成功,要么完全不执行,不存在部分执行的情况。在并发编程中,原子性操作是构建互斥和其他同步机制的基础。**例如,对共享变量的读写操作如果能够保证原子性,那么就可以避免因并发访问而导致的数据不一致问题。

  • 实现原子性的方法:包括使用硬件提供的原子指令(如CAS,Compare-And-Swap)、锁机制、以及某些编程语言或框架提供的原子类或原子操作。

  • 原子性与互斥的关系:原子性操作本身可以看作是一种轻量级的互斥实现,它适用于对单个变量的简单操作。而对于更复杂的临界区保护,通常需要结合互斥锁或其他同步机制来实现。

综上所述,进程与线程间的互斥相关背景概念涵盖了共享资源、临界资源、临界区、互斥以及原子性等多个方面。这些概念共同构成了并发编程中同步与协调的基础框架,对于确保系统的稳定性和数据的正确性至关重要。

比如,我们可以回想线程的性质来分析可能出现问题的过程:

  1. 线程是共享地址空间的:同一进程中的多个线程共享相同的地址空间。这意味着它们可以访问进程内的全局变量、堆内存等。

  2. 线程会共享大部分资源:除了共享地址空间,线程还共享许多其他资源,如代码段、数据段、打开的文件描述符、信号处理程序等。这种共享使得线程之间的通信和数据交换更加高效。

  3. 公共资源:由于线程共享资源,这些资源被称为"公共资源"。公共资源的存在使得线程可以协同工作,但也可能导致数据竞争和不一致的问题。

  4. 各种情况的数据不一致问题:当多个线程同时访问和修改共享资源时,如果没有适当的同步机制,可能会导致数据不一致的问题。例如,一个线程可能在读取数据的同时,另一个线程正在修改该数据,从而导致读取到的数据是部分更新或无效的。

  5. 解决这些问题:同步和互斥:为了解决数据不一致的问题,需要使用同步和互斥机制。同步机制确保线程按照特定的顺序访问共享资源,而互斥机制(如互斥量)则确保在任何时刻只有一个线程能够访问特定的共享资源,从而避免数据竞争。

总结来说,线程共享地址空间和资源,这虽然提高了效率,但也带来了数据不一致的风险。为了确保数据的完整性和一致性,必须使用适当的同步和互斥机制。


二、互斥量(Mutex)的引入

在并发编程中,线程间的数据共享和交互是常见的需求。然而,当多个线程并发地操作共享变量时,可能会引发一系列问题,如数据竞争、不一致性和系统错误。为了解决这些问题,互斥量(Mutex)作为一种重要的同步机制被引入。

1、线程局部变量与共享变量

  • 线程局部变量:大部分情况下,线程使用的数据都是局部变量,这些变量的地址空间位于线程的栈空间内。因此,这些变量归属于单个线程,其他线程无法直接访问。

  • 共享变量:然而,有时线程间需要共享数据以完成交互。这样的变量称为共享变量。共享变量的使用虽然方便了线程间的通信,但也带来了并发访问的问题。

2、并发访问共享变量的问题

以下是一个存在问题的售票系统代码示例,展示了多个线程并发操作共享变量时可能引发的问题:

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

int ticket = 50;

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

1. 执行结果示例

问题1: 重复售票
bash 复制代码
thread 1 sells ticket:50
thread 2 sells ticket:50  
thread 3 sells ticket:50
  • 三个线程都卖出了第50号票

  • 这是典型的"超卖"问题,同一张票被多个线程卖出

问题2: 票号不连续且重复
bash 复制代码
thread 4 sells ticket:44
thread 1 sells ticket:42
thread 2 sells ticket:42  # 重复的42号票
thread 3 sells ticket:40
thread 4 sells ticket:39
thread 1 sells ticket:38
thread 3 sells ticket:37
thread 2 sells ticket:36
thread 4 sells ticket:35
thread 1 sells ticket:34
thread 3 sells ticket:34  # 重复的34号票
  • 票号顺序混乱:44 → 42 → 42 → 40 → 39 → 38 → 37 → 36 → 35 → 34 → 34

  • 多次出现重复票号(42号、34号等)

问题3: 票数出现负数
bash 复制代码
thread 3 sells ticket:0
thread 4 sells ticket:-1
thread 1 sells ticket:-2
  • 这是最严重的问题,票数变成了负数

  • 说明在票数为0或1时,多个线程同时通过了 if (ticket > 0) 的判断

问题4: 数据竞争导致的数据不一致
bash 复制代码
thread 1 sells ticket:18
thread 2 sells ticket:17
thread 3 sells ticket:17  # 重复
thread 4 sells ticket:18  # 重复且倒序
  • 出现了18 → 17 → 17 → 18的异常序列

  • 说明线程间的执行顺序完全混乱

典型的竞态条件场景
cpp 复制代码
// 线程A和B同时执行到这里
if (ticket > 0) {  // 假设ticket=1,两个线程都通过检查
    usleep(1000);  // 在此期间两个线程都认为ticket>0
    printf(...);   // 两个线程都打印"卖出票"
    ticket--;      // 执行两次减1,结果ticket=-1
}

2. 问题分析

  • 条件判断与上下文切换 :在if (ticket > 0)判断为真后,代码可能会切换到其他线程执行,导致多个线程同时进入临界区。

  • 长业务过程usleep(1000)模拟了一个漫长的业务过程,在这个过程中,可能有多个线程进入该代码段。

  • 非原子操作ticket--操作本身不是原子的。通过反汇编可以看到,它对应三个步骤的汇编指令:(如下查看反汇编分析)

    1. load:将共享变量ticket从内存加载到寄存器中。

    2. update:更新寄存器里面的值,执行-1操作。

    3. store:将新值从寄存器写回共享变量ticket的内存地址。

3. 使用 objdump 查看反汇编

bash 复制代码
objdump -d -S demo_10_31 | grep -C 10 -i ticket

4. 预期的汇编代码分析

根据输出提供的反汇编代码,可以清楚地分析 ticket-- 的原子性问题。让我们重点关注关键部分:

ticket-- 对应的汇编代码:

bash 复制代码
1213:	8b 05 f7 2d 00 00    	mov    0x2df7(%rip),%eax   # 1. 读取ticket到eax
1219:	83 e8 01             	sub    $0x1,%eax           # 2. eax减1
121c:	89 05 ee 2d 00 00    	mov    %eax,0x2dee(%rip)   # 3. 将结果写回ticket

原子性分析:结论:不是原子操作

ticket-- 被编译成了三个独立的指令

  • 读取阶段mov 0x2df7(%rip),%eax,即从内存地址 0x4010 (ticket变量) 读取值到 eax 寄存器

  • 计算阶段sub $0x1,%eax,即在寄存器中执行减1操作

  • 写入阶段mov %eax,0x2dee(%rip),即将结果写回内存中的 ticket 变量

5. 竞态条件场景示例

假设初始 ticket = 2,两个线程同时执行:

bash 复制代码
线程A (时刻1): mov 0x2df7(%rip),%eax    # eax_A = 2
线程B (时刻2): mov 0x2df7(%rip),%eax    # eax_B = 2  
线程A (时刻3): sub $0x1,%eax            # eax_A = 1
线程B (时刻4): sub $0x1,%eax            # eax_B = 1
线程A (时刻5): mov %eax,0x2dee(%rip)    # ticket = 1
线程B (时刻6): mov %eax,0x2dee(%rip)    # ticket = 1 (应该是0!)

结果:票数从2变成了1,而不是预期的0,少卖了一张票。

6. 完整的执行流程分析

从反汇编可以看到完整逻辑:

bash 复制代码
11e1:	mov    0x2e29(%rip),%eax        # 读取ticket到eax (if判断)
11e7:	test   %eax,%eax               # 测试是否大于0
11e9:	jle    1224                    # 如果<=0就跳转到退出

... # 这里执行usleep和printf

1213:	mov    0x2df7(%rip),%eax        # 再次读取ticket (!问题所在!)
1219:	sub    $0x1,%eax               # 减1
121c:	mov    %eax,0x2dee(%rip)        # 写回

7. 存在的问题

  • 重复读取问题 :在判断 if (ticket > 0) 和实际 ticket-- 之间,ticket被读取了两次**(判断是否大于0也是在访问共享变量ticket!!!)**

  • 非原子操作:减操作需要三个步骤完成

  • 没有同步机制:多个线程可以同时进入临界区

3、互斥量的引入

为了解决上述问题,需要引入互斥量(Mutex)来确保在任何时刻只有一个线程能够访问临界区。互斥量本质上是一把锁(所以互斥量也叫做互斥锁!!!),它满足以下三点要求:

  • 互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。

  • 单一线程进入:如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。

  • 非阻塞其他线程:如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。


三、互斥量的接口

1、引入锁:pthread_mutex_t 互斥锁/互斥量

1. pthread_mutex_t 类型

pthread_mutex_t 是 POSIX 线程库(pthread)中用于实现**互斥锁(mutex)**的核心数据类型。它用于保护共享资源,确保多线程环境下对临界区的独占访问,避免数据竞争(data race)。

pthread_mutex_t 的定义

pthread_mutex_t 是一个透明的数据类型(指不可见) ,其具体实现由 pthread 库内部定义,对用户隐藏。通常在 /usr/include/pthread.h 中声明:

cpp 复制代码
typedef struct {
    // 具体实现由 pthread 库内部定义,用户无需关心
    // 通常包含锁的状态、所有者线程 ID、递归计数等信息
} pthread_mutex_t;
关键特性
  • 不透明性 :用户不应直接操作其内部成员,而应通过 pthread 提供的 API(如 pthread_mutex_lockpthread_mutex_unlock)进行管理。

  • 可移植性:不同系统(如 Linux、macOS)的底层实现可能不同,但 API 保持一致。

pthread_mutex_t 的内部结构(glibc 实现示例)

在 glibc 中,pthread_mutex_t 的典型实现如下(简化版):

cpp 复制代码
struct __pthread_mutex_s {
    int lock;          // 锁状态(0=未锁定,1=锁定)
    unsigned int flags; // 锁属性(如递归、错误检查等)
    pid_t owner;       // 持有锁的线程 ID(递归锁有用)
    int count;         // 递归锁的加锁次数
    // ... 其他内部字段
};
  • lock:表示锁是否被占用(0 或 1)。

  • flags:存储锁的类型(如 PTHREAD_MUTEX_NORMALPTHREAD_MUTEX_RECURSIVE)。

  • owner:记录当前持有锁的线程 ID(用于递归锁或调试)。

  • count:递归锁的加锁次数(同一线程多次加锁时递增)。

注意:实际实现可能更复杂,包含更多字段(如等待队列、自旋锁优化等)。

2. 头文件

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

互斥锁的相关定义和函数声明都包含在pthread.h头文件中!!!

bash 复制代码
man pthread.h

互斥锁的相关定义和函数声明包含在 pthread.h 头文件中的原因主要与 标准化、模块化设计、编译依赖管理 以及 跨平台兼容性 有关。以下是详细解释:

标准化需求
  • POSIX 标准pthread.h 是 POSIX 线程(pthread)库的标准头文件,定义了线程、互斥锁、条件变量等并发编程相关的接口。互斥锁作为线程同步的核心工具,其声明和类型定义必须遵循 POSIX 标准,以确保不同系统上的兼容性。

  • 统一接口 :将互斥锁的声明集中在 pthread.h 中,为开发者提供了统一的接口规范。无论底层实现如何变化(如不同操作系统的线程库),用户代码只需包含此头文件即可使用标准化的互斥锁功能。

模块化设计

功能隔离pthread.h 将线程相关的所有功能(创建线程、互斥锁、条件变量等)封装在一个模块中,避免了代码耦合。这种设计使得:

  • 开发者可以仅引入需要的模块(如仅使用互斥锁时,无需包含其他无关头文件)。

  • 维护和扩展更方便,例如新增同步原语时,只需修改 pthread.h 和相关实现文件。

避免命名冲突 :通过集中定义类型(如 pthread_mutex_t)和函数名,减少了与其他库的命名冲突风险。

编译依赖管理

声明与实现分离 :头文件负责声明函数和类型,而具体实现在动态库(如 libpthread.so)中。这种分离:

  • 允许编译器在编译阶段仅检查接口是否正确,无需关心实现细节。

  • 链接阶段再动态加载库,提高了编译效率。

减少编译时间 :如果互斥锁的声明分散在多个头文件中,每次修改实现都需要重新编译依赖它的所有文件。集中到 pthread.h 后,只需重新编译直接包含该头文件的源文件。

跨平台兼容性

操作系统抽象 :不同操作系统(如 Linux、macOS)的线程实现可能不同,但通过 pthread.h 提供的标准接口,开发者可以编写与系统无关的代码。例如:

  • Linux 使用 Native POSIX Thread Library (NPTL)。

  • 其他系统可能使用不同的底层实现,但均通过 pthread.h 暴露统一接口。

移植性 :代码只需包含 pthread.h 即可使用互斥锁,无需针对不同平台重写同步逻辑。

历史与生态原因
  • 历史延续性 :POSIX 线程库自 1995 年发布以来,pthread.h 已成为线程编程的事实标准。后续扩展(如互斥锁属性、自适应锁等)均在此框架内演进。

  • 工具链支持 :编译器、调试器、静态分析工具等均围绕 pthread.h 提供了专门支持。例如,GCC 会检查 pthread.h 中的函数调用是否符合规范。

2、初始化互斥量的两种方法

在 POSIX 线程库中,互斥量(mutex)的初始化有两种主要方法:静态初始化动态初始化。这两种方法分别适用于不同的场景,下面将详细讲解它们的用法、特点及注意事项。

1. 静态初始化

方法 :使用宏 PTHREAD_MUTEX_INITIALIZER 直接初始化互斥量。

适用场景:互斥量是全局变量或静态变量,且使用默认属性。

代码示例
cpp 复制代码
#include <pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

int main() {
    // 直接使用已初始化的 mutex
    pthread_mutex_lock(&mutex);
    // 临界区代码
    pthread_mutex_unlock(&mutex);
    return 0;
}
特点
  • 简单快捷

    • 无需调用初始化函数,直接赋值即可。

    • 适合全局或静态互斥量,减少代码量。

  • 默认属性 :使用 PTHREAD_MUTEX_INITIALIZER 初始化的互斥量具有默认属性(如快速锁、非递归等)。

  • 自动销毁 :程序结束时,静态初始化的互斥量会自动释放资源,无需手动调用 pthread_mutex_destroy

注意事项
  • 仅适用于全局/静态变量PTHREAD_MUTEX_INITIALIZER 不能用于局部变量或动态分配的互斥量。

  • 不可重复初始化 :如果互斥量已经被静态初始化,再次调用 pthread_mutex_init 可能导致未定义行为。

关于PTHREAD_MUTEX_INITIALIZER

使用 grep 快速定位

cpp 复制代码
grep "PTHREAD_MUTEX_INITIALIZER" /usr/include/pthread.h
宏的作用

PTHREAD_MUTEX_INITIALIZER 是 POSIX 线程库(pthread)提供的一个宏,用于静态初始化互斥锁 。它允许你在定义互斥锁时直接赋值,而无需调用 pthread_mutex_init() 函数。

宏的展开形式

从输出可以看出,PTHREAD_MUTEX_INITIALIZER 实际上是一个复合字面量 (compound literal),用于初始化 pthread_mutex_t 结构体。

  • 它调用了内部宏 __PTHREAD_MUTEX_INITIALIZER,并传入了不同的互斥锁类型(如 PTHREAD_MUTEX_TIMED_NPPTHREAD_MUTEX_RECURSIVE_NP 等)。

  • __PTHREAD_MUTEX_INITIALIZER 是 glibc 内部的一个宏,用于生成特定类型的互斥锁的初始值。

不同变体的含义

PTHREAD_MUTEX_INITIALIZER 可能有多个变体,对应不同的互斥锁类型:

  • PTHREAD_MUTEX_TIMED_NP:普通锁(默认),其他线程在争用时会阻塞。

  • PTHREAD_MUTEX_RECURSIVE_NP:递归锁,允许同一线程多次加锁而不死锁。

  • PTHREAD_MUTEX_ERRORCHECK_NP:错误检查锁,如果线程重复加锁或未加锁时解锁,会返回错误。

  • PTHREAD_MUTEX_ADAPTIVE_NP:自适应锁(某些系统支持),根据争用情况自动调整行为。

但通常,PTHREAD_MUTEX_INITIALIZER 默认指代普通锁(TIMED)的初始化方式 ,其他类型可能需要显式调用 pthread_mutex_init() 并指定属性。

实际使用方式

静态初始化(普通锁)

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

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 默认普通锁
  • 这种方式适用于全局或静态变量,且使用默认属性(普通锁)。

动态初始化(自定义类型)

如果需要递归锁或其他类型,必须使用 pthread_mutex_init()

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

int main() {
    pthread_mutex_t mutex;
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE); // 设置为递归锁
    pthread_mutex_init(&mutex, &attr);
    // ...
    pthread_mutex_destroy(&mutex);
    pthread_mutexattr_destroy(&attr);
    return 0;
}
底层实现(glibc)

在 glibc 中,__PTHREAD_MUTEX_INITIALIZER 可能会展开为一个结构体初始值,例如:

cpp 复制代码
#define __PTHREAD_MUTEX_INITIALIZER(type) \
    { 0, 0, 0, type, 0, { 0 } } // 具体字段因版本而异
  • 最终,PTHREAD_MUTEX_INITIALIZER 会生成一个符合 pthread_mutex_t 结构的初始值。
总结
  • PTHREAD_MUTEX_INITIALIZER 是一个静态初始化宏,用于快速初始化互斥锁,无需调用函数。

  • 它默认生成普通锁(TIMED,其他类型(如递归锁)需要动态初始化。

  • 底层实现:它展开为一个结构体初始值,由 glibc 内部定义。

  • 适用场景:全局/静态变量,且使用默认属性时,优先使用静态初始化。

如果你只需要普通锁,直接使用 PTHREAD_MUTEX_INITIALIZER 即可;如果需要特殊行为(如递归锁),则必须使用 pthread_mutex_init() 并配置属性。

2. 动态初始化

方法 :调用 pthread_mutex_init 函数动态初始化互斥量。

适用场景:互斥量是局部变量、动态分配的内存,或需要自定义属性。

函数原型
cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex, 
                       const pthread_mutexattr_t *restrict attr);
参数
  • mutex:指向要初始化的互斥量的指针。

  • attr:指向互斥量属性的指针。若为 NULL,则使用默认属性。

返回值
(1) 成功 (0)
  • 互斥量已成功初始化,可以用于后续的加锁(pthread_mutex_lock)和解锁(pthread_mutex_unlock)操作。
(2) 失败(非零错误码)
  • EAGAIN:系统缺乏必要的资源(如内存不足)来初始化互斥量。

  • EINVAL:传入的 attr 参数无效(例如,属性对象未初始化或包含非法值)。

  • ENOMEM:内存不足,无法分配互斥量所需的内部资源。

  • EBUSY(某些实现可能返回):尝试重新初始化一个已经初始化的互斥量(未销毁直接重新初始化)。

  • EFAULTmutexattr 指针指向的地址无效(如空指针或不可访问的内存)。

代码示例
cpp 复制代码
#include <pthread.h>
#include <stdio.h>

void* thread_func(void* arg) {
    pthread_mutex_t *mutex = (pthread_mutex_t*)arg;
    pthread_mutex_lock(mutex);
    // 临界区代码
    printf("Thread entered critical section.\n");
    pthread_mutex_unlock(mutex);
    return NULL;
}

int main() {
    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, NULL); // 使用默认属性初始化

    pthread_t thread;
    pthread_create(&thread, NULL, thread_func, &mutex);
    pthread_join(thread, NULL);

    pthread_mutex_destroy(&mutex); // 销毁互斥量
    return 0;
}
特点
  • 灵活性高

    • 可以初始化局部变量或动态分配的互斥量。

    • 允许通过 attr 参数自定义互斥量行为(如递归锁、进程共享等)。

  • 显式销毁 :动态初始化的互斥量必须手动调用 pthread_mutex_destroy 释放资源,否则可能导致内存泄漏。

pthread_mutex_t 的锁类型(了解)

通过属性对象(pthread_mutexattr_t)可以配置锁的行为:

类型 宏定义 行为
普通锁 PTHREAD_MUTEX_NORMAL 默认类型,争用时阻塞,无额外检查。
递归锁 PTHREAD_MUTEX_RECURSIVE 允许同一线程多次加锁,需对应次数的解锁。
错误检查锁 PTHREAD_MUTEX_ERRORCHECK 如果线程重复加锁或未加锁时解锁,返回错误。
自适应锁 PTHREAD_MUTEX_ADAPTIVE(非标准) 某些系统支持,根据争用情况自动优化。
自定义属性示例(了解)
cpp 复制代码
#include <pthread.h>

int main() {
    pthread_mutex_t mutex;
    pthread_mutexattr_t attr;

    // 初始化属性对象
    pthread_mutexattr_init(&attr);
    // 设置为递归锁(允许同一线程多次加锁)
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);

    // 使用自定义属性初始化互斥量
    pthread_mutex_init(&mutex, &attr);

    // 销毁属性对象和互斥量
    pthread_mutexattr_destroy(&attr);
    pthread_mutex_destroy(&mutex);
    return 0;
}
注意事项
  • 属性对象的生命周期 :如果使用了自定义属性(attrNULL),需确保属性对象在互斥量初始化期间有效。

  • 销毁顺序:先销毁互斥量,再销毁属性对象,避免悬空指针。

3. 两种方法的对比

特性 静态初始化 动态初始化
初始化方式 使用宏 PTHREAD_MUTEX_INITIALIZER 调用 pthread_mutex_init 函数
适用变量类型 全局/静态变量 局部变量、动态分配内存
属性 默认属性 可自定义属性(如递归锁)
销毁 程序结束时自动销毁 必须手动调用 pthread_mutex_destroy
灵活性

4. 总结

静态初始化

  • 适合简单场景,尤其是全局或静态互斥量。

  • 无需手动销毁,代码简洁。

动态初始化

  • 适合复杂场景,如局部变量、动态内存或需要自定义行为。

  • 需手动管理生命周期,但灵活性更高。

根据实际需求选择合适的初始化方法,可以平衡代码简洁性与功能灵活性。

3、销毁互斥量(pthread_mutex_destroy

pthread_mutex_destroy 是 POSIX 线程库中用于销毁互斥量的函数。它释放互斥量占用的资源,使其不再可用于同步操作。

1. 函数原型

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

int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数mutex:指向要销毁的互斥量的指针。

返回值

  • 成功时返回 0

  • 失败时返回错误码(如 EINVAL 表示无效的互斥量指针,EBUSY 表示互斥量正在被使用)。

2. 销毁互斥量的作用

  • 释放资源 :互斥量在初始化时可能分配内部资源(如等待队列、状态标志等)。pthread_mutex_destroy 会释放这些资源,避免内存泄漏。

  • 标记为无效:销毁后,互斥量不再可用。任何后续的加锁或解锁操作都会导致未定义行为。

3. 何时销毁互斥量?

  • 动态初始化的互斥量 :通过 pthread_mutex_init 动态初始化的互斥量必须显式销毁。

  • 不再使用的互斥量:如果互斥量的生命周期结束(如局部变量超出作用域、动态分配的内存被释放),应销毁它。

  • 程序退出时 :对于静态初始化的互斥量(PTHREAD_MUTEX_INITIALIZER),程序退出时会自动销毁,无需手动调用。但对于动态初始化的互斥量,仍需手动销毁。

4. 销毁互斥量的前提条件

  • 互斥量必须处于未锁定状态 :如果互斥量被某个线程持有(即已加锁但未解锁),销毁操作会失败并返回 EBUSY

  • 无线程在等待该互斥量 :如果有线程阻塞在 pthread_mutex_lockpthread_mutex_timedlock 上,销毁操作可能导致未定义行为(具体实现可能不同)。

5. 销毁互斥量的步骤

(1) 确保互斥量未被使用
  • 确保所有线程已解锁该互斥量。

  • 确保无线程正在尝试获取该互斥量。

(2) 调用 pthread_mutex_destroy
cpp 复制代码
pthread_mutex_t mutex;
// ... 初始化并使用互斥量 ...
int ret = pthread_mutex_destroy(&mutex);
if (ret != 0) {
    // 错误处理(如打印错误信息)
}
(3) 后续不再使用该互斥量
  • 销毁后,互斥量指针不应再被传递给任何 pthread 函数。

6. 示例代码

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

pthread_mutex_t mutex;

void* thread_func(void* arg) {
    pthread_mutex_lock(&mutex);
    // 临界区操作
    pthread_mutex_unlock(&mutex);
    return NULL;
}

int main() {
    // 动态初始化互斥量
    if (pthread_mutex_init(&mutex, NULL) != 0) {
        perror("Failed to initialize mutex");
        return 1;
    }

    pthread_t thread;
    pthread_create(&thread, NULL, thread_func, NULL);
    pthread_join(thread, NULL); // 等待线程结束

    // 确保互斥量未被锁定后销毁
    if (pthread_mutex_destroy(&mutex) != 0) {
        perror("Failed to destroy mutex");
        return 1;
    }

    return 0;
}

7. 注意事项

  • 避免重复销毁 :对同一个互斥量多次调用 pthread_mutex_destroy 是未定义行为。

  • 静态初始化的互斥量 :使用 PTHREAD_MUTEX_INITIALIZER 静态初始化的互斥量不需要手动销毁,程序退出时会自动释放资源。

  • 错误处理 :始终检查 pthread_mutex_destroy 的返回值,确保销毁成功。

  • 与动态内存结合使用 :如果互斥量是动态分配的(如 malloc 分配的内存中包含 pthread_mutex_t),应先销毁互斥量,再释放内存:(重点注意!!!)

    cpp 复制代码
    pthread_mutex_t *mutex = malloc(sizeof(pthread_mutex_t));
    pthread_mutex_init(mutex, NULL);
    // ... 使用互斥量 ...
    pthread_mutex_destroy(mutex);
    free(mutex);

8. 销毁互斥量的底层实现(glibc 示例)

在 glibc 中,pthread_mutex_destroy 的实现可能如下:

  • 检查互斥量状态

    • 验证互斥量指针是否有效。

    • 检查互斥量是否处于未锁定状态。

  • 释放内部资源:如果互斥量有等待队列或其他内部结构,释放相关内存。

  • 标记为无效:修改互斥量内部状态,标记为已销毁。

9. 总结

  • pthread_mutex_destroy 用于释放互斥量占用的资源,避免内存泄漏。

  • 适用场景:动态初始化的互斥量、不再使用的互斥量。

  • 前提条件:互斥量必须处于未锁定状态,且无线程在等待。

  • 最佳实践

    • 确保所有线程已解锁互斥量后再销毁。

    • 检查返回值,处理可能的错误。

    • 静态初始化的互斥量无需手动销毁。

通过正确销毁互斥量,可以确保程序的资源管理高效且安全。

4、互斥量加锁(pthread_mutex_lock)与解锁(pthread_mutex_unlock

互斥量(mutex)是 POSIX 线程库中用于实现线程同步的核心机制,确保多线程环境下对共享资源的独占访问。pthread_mutex_lockpthread_mutex_unlock 是操作互斥量的两个核心函数,下面详细讲解它们的用法、行为及注意事项。

1. 加锁:pthread_mutex_lock

函数原型
cpp 复制代码
#include <pthread.h>

int pthread_mutex_lock(pthread_mutex_t *mutex);

参数mutex:指向已初始化的互斥量的指针。

返回值

  • 成功时返回 0

  • 失败时返回错误码(如 EINVAL 表示无效的互斥量指针,EDEADLK 表示检测到死锁)。

行为
  • 尝试获取锁

    • 如果互斥量未被锁定(即处于未占用状态),当前线程会立即获得锁,并继续执行。

    • 如果互斥量已被其他线程锁定,当前线程会阻塞,进入等待状态,直到锁被释放。

  • 死锁检测 (某些实现):如果线程尝试对已持有的普通锁(非递归锁)重复加锁,可能导致死锁。某些实现会返回 EDEADLK 错误。

示例
cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void* thread_func(void* arg) {
    pthread_mutex_lock(&mutex);  // 尝试加锁
    // 临界区代码(独占访问共享资源)
    pthread_mutex_unlock(&mutex); // 解锁
    return NULL;
}

2. 非阻塞加锁:pthread_mutex_trylock

函数原型
cpp 复制代码
int pthread_mutex_trylock(pthread_mutex_t *mutex);

行为

  • 如果互斥量未被锁定,当前线程获得锁并返回 0

  • 如果互斥量已被锁定,函数立即返回 EBUSY不会阻塞当前线程。

适用场景
  • 避免线程长时间阻塞,提高响应性。

  • 结合循环或条件变量实现自旋锁或超时机制。

示例
cpp 复制代码
if (pthread_mutex_trylock(&mutex) == 0) {
    // 成功获得锁,执行临界区代码
    pthread_mutex_unlock(&mutex);
} else {
    // 锁被占用,执行其他逻辑
}

3. 解锁:pthread_mutex_unlock

函数原型
cpp 复制代码
int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数mutex:指向已初始化的互斥量的指针。

返回值

  • 成功时返回 0

  • 失败时返回错误码(如 EINVAL 无效指针,EPERM 当前线程未持有锁)。

行为
  • 释放锁

    • 如果当前线程持有该锁,释放锁并唤醒一个等待该锁的线程(如果有)。

    • 如果当前线程未持有该锁,行为未定义(通常返回 EPERM 错误)。

  • 唤醒等待线程:如果有多个线程在等待该锁,具体唤醒哪个线程由调度策略决定(通常是 FIFO 或优先级顺序)。

示例
cpp 复制代码
pthread_mutex_unlock(&mutex); // 释放锁

4. 加锁与解锁的配对使用

基本规则

加锁和解锁必须成对出现,否则可能导致:

  • 其他线程永远无法获取锁(死锁)。

  • 未定义行为(如数据竞争或程序崩溃)。

递归锁的特殊情况
  • 如果互斥量是递归锁(PTHREAD_MUTEX_RECURSIVE),同一线程可以多次加锁,但必须对应相同次数的解锁。
示例(递归锁)(了解)
cpp 复制代码
pthread_mutex_t recursive_mutex;
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
pthread_mutex_init(&recursive_mutex, &attr);

void recursive_function(int count) {
    pthread_mutex_lock(&recursive_mutex);
    if (count > 0) {
        recursive_function(count - 1); // 递归调用
    }
    pthread_mutex_unlock(&recursive_mutex);
}

5. 注意事项

(1) 死锁预防
  • 避免嵌套锁的顺序不一致

    • 如果线程 A 先锁 X 再锁 Y,线程 B 先锁 Y 再锁 X,可能形成死锁。

    • 解决方案:按固定顺序获取锁,或使用超时机制(pthread_mutex_timedlock)。

  • 检查返回值 :始终检查 pthread_mutex_lockpthread_mutex_unlock 的返回值,确保操作成功。

(2) 性能优化
  • 减少临界区大小

    • 临界区代码应尽可能短,避免长时间持有锁。

    • 长时间运行的任务应移出临界区。

  • 避免虚假唤醒 :如果使用条件变量(pthread_cond_wait),需在循环中检查条件,避免虚假唤醒导致逻辑错误。

(3) 错误处理
  • EINVAL:互斥量指针无效(未初始化或已销毁)。

  • EPERM:当前线程未持有锁时尝试解锁。

  • EDEADLK:检测到潜在死锁(如线程试图重复加锁普通锁)。

6. 底层实现(简化)

在 glibc 中,pthread_mutex_lockpthread_mutex_unlock 的典型实现如下:

加锁:

  • 使用原子操作(如 CAS)检查锁状态。

  • 如果锁未被占用,直接占用。

  • 如果锁被占用,将线程加入等待队列并阻塞。

解锁:

  • 清除锁的占用标志。

  • 从等待队列中唤醒一个线程。

7. 总结

加锁(pthread_mutex_lock
  • 用于获取互斥量,确保独占访问共享资源。

  • 如果锁被占用,线程阻塞等待。

  • 返回 0 表示成功,其他值表示错误。

解锁(pthread_mutex_unlock
  • 用于释放互斥量,允许其他线程获取锁。

  • 必须由锁的持有者调用,否则行为未定义。

  • 返回 0 表示成功,其他值表示错误。

最佳实践
  • 确保加锁和解锁成对出现。

  • 减少临界区代码量,提高并发性能。

  • 检查返回值,处理潜在错误。

  • 避免死锁,按固定顺序获取多把锁。

通过正确使用 pthread_mutex_lockpthread_mutex_unlock,可以确保多线程程序的数据一致性和线程安全性。

5、改进后的售票系统

以下是使用互斥量改进后的售票系统代码:

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

int ticket = 50;
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. 改进后输出结果的特点

改进后的输出显示:

  • 顺序正确:50 → 49 → 48 → ... → 1,严格递减

  • 无重复票号:每个票号只出现一次

  • 无负数票:在票数为1时正常停止

  • 单线程执行:看起来只有thread 1在执行,说明锁有效地序列化了线程访问

2. 根本原因分析

根据之前的汇编分析,问题出现在:

  • 非原子操作ticket-- 需要三个步骤

  • 检查后使用 :在 if (ticket > 0) 和实际减操作之间有时间窗口

  • 内存可见性:线程间的修改对其他线程不可见

3. 总结

改进前的输出结果清楚地展示了多线程编程中缺乏同步机制导致的典型问题:

  • ✅ 改进后:线程安全,数据一致

  • ❌ 改进前:数据竞争、重复售票、负数票等严重问题

这证明了在多线程环境中访问共享变量时,必须使用适当的同步机制(如互斥锁)来保证数据的一致性。

4. 改进点

  • 在访问共享变量ticket之前,先获取互斥量mutex

  • 在完成对ticket的操作后,释放互斥量mutex

  • 确保在任何时候只有一个线程能够访问和修改ticket,从而避免了数据竞争和不一致性问题。


四、线程切换的时间点及机制

**在多线程环境中,线程切换是操作系统调度的重要环节,它允许系统在多个线程之间高效地分配CPU时间。**以下是关于线程切换时间点及相关机制的详细整理和补充:

1、线程切换的时间点

线程切换通常发生在以下几种情况:

1. 时间片耗尽

  • 每个线程在运行时都会被分配一个时间片,当这个时间片用完时,操作系统会触发线程切换,让其他线程有机会运行。

  • 时间片的长度通常由操作系统的调度策略决定,可以是固定的,也可以是根据系统负载动态调整的。

2. 线程阻塞

  • 当线程遇到I/O操作(如文件读写、网络通信等)或需要等待某个条件成立时,它会主动放弃CPU,进入阻塞状态。

  • 此时,操作系统会选择另一个可运行的线程来执行。

3. 线程主动调用睡眠函数

  • 线程可以通过调用如sleep()等函数,主动要求暂停执行一段时间。

  • 在这段时间内,线程不会占用CPU资源,操作系统会安排其他线程运行。

4. 中断或异常

  • 当发生硬件中断或软件异常时,当前线程的执行会被暂时中断,操作系统会处理中断或异常,并可能决定进行线程切换。

5. 优先级调整

  • 如果系统的调度策略是基于优先级的,那么当高优先级线程变为可运行状态时,操作系统可能会抢占当前正在运行的低优先级线程,进行线程切换。

2、线程切换的机制

1. 陷入内核态

  • 线程切换通常需要操作系统内核的介入。当发生上述任一切换条件时,当前线程的执行会被暂停,并陷入内核态。

  • 在内核态中,操作系统会保存当前线程的上下文(如寄存器状态、程序计数器等),并选择下一个要运行的线程。

2. 选择新线程并恢复上下文

  • 操作系统根据调度策略选择一个新的线程来运行。

  • 然后,操作系统会恢复该线程的上下文,包括寄存器状态、程序计数器等,使其能够从上次暂停的地方继续执行。

3. 返回用户态

  • 在新线程的上下文恢复完成后,操作系统会从内核态返回用户态,让新线程开始执行。

4. 进行检查和调度

  • 在从内核态返回用户态之前,操作系统会进行一系列的检查,如验证线程的权限、更新调度信息等。

  • 这些检查确保系统的安全性和稳定性,同时也有助于优化调度策略。

3、多线程中的并发与切换

制造更多的并发

  • 多线程编程的主要目的之一是提高系统的并发性,即同时处理多个任务的能力。

  • 通过创建更多的线程,系统可以在同一时间内执行更多的操作,从而提高整体性能。

更多的切换

  • 随着线程数量的增加,线程之间的切换也会变得更加频繁。

  • 高效的线程切换机制对于保证系统的响应速度和吞吐量至关重要。

4、原子性与线程安全

原子操作的重要性

  • 在多线程环境中,确保操作的原子性对于维护数据的一致性和完整性至关重要。

  • 例如,对共享变量的修改操作应该是原子的,以避免数据竞争和不一致的问题。

CPU与汇编语言

  • CPU执行的是汇编语言指令,每条汇编指令在正常情况下都是原子的。

  • 然而,当多个汇编指令组合在一起形成一个复杂的操作时(如ticket--可能涉及读取、修改、写入三个步骤),这个操作就不再是原子的。

  • 因此,在多线程编程中,需要使用同步机制(如互斥量、信号量等)来确保复杂操作的原子性。

5、总结

  • 线程切换是多线程编程中的核心机制之一,它允许系统在多个线程之间高效地分配CPU时间。

  • 线程切换通常发生在时间片耗尽、线程阻塞、线程主动调用睡眠函数、中断或异常以及优先级调整等情况下。

  • 操作系统通过陷入内核态、选择新线程并恢复上下文、返回用户态以及进行检查和调度等步骤来完成线程切换。

  • 在多线程编程中,需要特别注意操作的原子性,以确保数据的一致性和完整性。


五、锁的核心能力与临界区保护机制

1、锁的核心能力本质

锁(如 pthread_mutex_t)的核心能力在于将并行执行的代码段强制转换为串行执行 ,从而确保临界区代码的原子性。具体表现如下:

1. 串行化执行

  • 锁通过强制多个线程按顺序(这个顺序是不按预期的,所以后面才会引入线程的同步机制)访问临界区,将原本可以并行执行的代码段变为串行执行。

  • 这种机制确保了临界区内的操作不会被其他线程干扰,从而避免了数据竞争(data race)。

2. 变相的原子性

  • 锁的持有者在临界区内执行代码时,其他线程必须等待锁的释放。

  • 这种"独占执行"的效果类似于原子操作,即临界区内的代码要么完全执行,要么不执行,不会被中断或部分执行。

3. 对临界资源的保护

  • 临界资源(如共享变量、文件、设备等)需要通过锁来保护。

  • 锁通过控制对临界区的访问,间接保护了临界资源,确保其状态的一致性。

2、临界区与非临界区

临界区(Critical Section)

  • 需要互斥访问的代码段,通常涉及对共享资源的操作。

  • 例如:修改全局变量、写入文件、操作硬件设备等。

非临界区

  • 不需要互斥访问的代码段,通常不涉及共享资源或只读操作。

  • 例如:局部变量的计算、独立的 I/O 操作等。

3、线程遵守规则的重要性

问题1:如果有一个线程不遵守规则(写 Bug),会发生什么?

规则的核心:所有线程在访问临界区前必须获取锁,退出临界区后必须释放锁。

不遵守规则的后果

  • 数据竞争

    • 如果某个线程未加锁直接访问临界区,可能导致多个线程同时修改共享资源,引发数据不一致。

    • 例如:两个线程同时修改同一个全局变量,结果可能不可预测。

  • 死锁 :如果线程未正确释放锁(如忘记调用 pthread_mutex_unlock),其他线程将永远无法获取锁,导致程序挂起。

  • 性能问题:即使不直接引发错误,不遵守规则的线程可能导致其他线程频繁阻塞,降低整体性能。

解决方案

  • 严格遵循加锁/解锁的配对使用。

  • 使用工具(如 Helgrind、TSan)检测数据竞争和死锁。(了解)

  • 代码审查时重点关注临界区的访问逻辑。

4、临界区内的线程切换问题

问题2:加锁之后,在临界区内部,允许线程切换吗?切换了会怎么样?

允许线程切换

  • 操作系统调度器并不知道某个线程正在临界区内执行,因此可能随时切换线程。

  • 线程切换的触发条件包括时间片用完、高优先级线程就绪、I/O 操作完成等。

切换后的行为

锁的持有状态不变

  • 即使当前线程被切换出去,它仍然持有锁。

  • 其他线程在尝试获取锁时会被阻塞,直到当前线程重新运行并释放锁。

对程序的影响

  • 性能下降:如果临界区执行时间较长,其他线程可能长时间阻塞,导致响应性降低。

  • 无功能性问题:锁的机制确保了即使发生线程切换,临界区的独占性仍然得到保证。

  • 潜在风险 :如果临界区内部调用了可能阻塞的操作(如 sleepread),会延长锁的持有时间,增加其他线程的等待时间。

最佳实践

  • 减少临界区大小:尽量缩短临界区的执行时间,避免长时间持有锁。

  • 避免在临界区内调用阻塞操作:如必须调用,需评估对性能的影响。

  • 使用超时机制 :对于可能长时间等待的锁,可以使用 pthread_mutex_timedlock 设置超时,避免无限阻塞。

5、锁的保护机制总结

1. 锁的核心作用

  • 通过强制串行化访问临界区,保护共享资源的一致性。

  • 提供变相的原子性,确保临界区内的操作不会被中断。

2. 临界区与非临界区的区分

  • 临界区必须加锁,非临界区无需加锁。

  • 合理划分临界区可以提升并发性能。

3. 线程切换的影响

  • 线程切换不会破坏锁的独占性,但可能影响性能。

  • 需通过优化临界区代码减少切换带来的负面影响。

4. 规则的重要性

  • 所有线程必须遵守加锁/解锁的规则,否则可能导致数据竞争或死锁。

  • 工具和代码审查是确保规则遵守的有效手段。

6、锁是否需要被保护?

  • 多个执行流共享的资源称为临界资源,而访问临界资源的代码段则被称为临界区。

  • 所有线程在进入临界区前都必须以竞争方式申请锁,因此锁同样属于被多个执行流共享的资源,即锁本身也是一种临界资源。

  • 既然锁属于临界资源,那么它也需要被保护。然而锁本身的作用就是保护其他临界资源,这就产生了"谁来保护锁"的问题。

  • 实际上,锁是通过自我保护机制来实现安全的。关键在于确保申请锁的过程具备原子性,只要满足这个条件,锁的安全性就能得到保障。

7、操作系统的工作原理

  • 操作系统启动后会进入一个持续运行的循环状态。

  • 计算机内部设有硬件时钟,它会定时向操作系统发送时钟中断信号。操作系统接收到中断后,会根据中断向量表来执行相应的操作程序。这个中断向量表本质上是一个函数指针数组,其中包含了磁盘刷新、网卡检测、数据更新等各种功能函数。通过不断接收和处理时钟中断,操作系统得以持续执行各种任务。

  • 在硬件架构方面,CPU与内存通过系统总线相连,而内存则通过I/O总线与外部设备连接。虽然计算机可能配备多个CPU,但系统总线只有一条。CPU访问内存的目的可能各不相同:有时是为了获取指令,有时则是为了读取数据。为了区分这些不同的操作类型,计算机采用总线周期机制来识别当前总线传输的资源类型。

8、示例代码(正确使用锁)

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

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int shared_data = 0;

void* thread_func(void* arg) {
    pthread_mutex_lock(&mutex);  // 加锁

    // 临界区开始
    printf("Thread entered critical section. Shared data: %d\n", shared_data);
    shared_data++;
    sleep(1);  // 模拟耗时操作(可能触发线程切换)
    printf("Thread exiting critical section. Shared data: %d\n", shared_data);
    // 临界区结束

    pthread_mutex_unlock(&mutex);  // 解锁
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, thread_func, NULL);
    pthread_create(&t2, NULL, thread_func, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_mutex_destroy(&mutex);
    return 0;
}

输出示例

即使 sleep(1) 触发了线程切换,锁的机制仍确保了 shared_data 的修改是原子的。

9、总结

锁通过强制串行化访问临界区,保护了共享资源的一致性,提供了变相的原子性。所有线程必须遵守加锁/解锁的规则,否则可能导致数据竞争或死锁。虽然临界区内允许线程切换,但需通过优化代码减少其对性能的影响。合理使用锁是编写高效、正确并发程序的关键。


六、互斥量(Mutex)的作用及其优点

**互斥量(Mutex,Mutual Exclusion的缩写)是操作系统和并发编程中用于实现线程或进程间同步的核心机制,其主要作用是确保多线程环境下对共享资源的独占访问,防止数据竞争和不一致问题。**以下是其详细作用和优点:

1、互斥量的核心作用

1. 互斥访问共享资源

  • 当多个线程需要同时访问同一资源(如全局变量、文件、内存区域等)时,互斥量通过加锁机制保证同一时刻只有一个线程能持有锁,其他线程必须等待锁释放后才能访问资源。

  • 这避免了多个线程同时修改数据导致的竞态条件(Race Condition)。

2. 防止数据不一致

在无同步机制的情况下,多线程对共享资源的并发修改可能导致数据损坏或逻辑错误。例如:

  • 两个线程同时对计数器加1,可能因指令重排序导致结果错误。

  • 线程A读取数据时,线程B正在修改数据,导致A读取到中间状态。

互斥量通过强制串行化访问,确保数据的原子性和一致性。

3. 同步线程执行顺序

结合条件变量(Condition Variable),互斥量可以实现更复杂的线程同步,例如生产者-消费者模型中,消费者线程需等待生产者提供数据后才能执行。

2、互斥量的工作原理

加锁(Lock)与解锁(Unlock)

  • 线程在访问共享资源前调用lock()尝试获取互斥量。

  • 如果互斥量未被占用,当前线程获得锁并继续执行;否则,线程被阻塞,进入等待队列。

  • 线程完成资源访问后,调用unlock()释放锁,唤醒等待队列中的一个线程。

所有权语义

  • 互斥量通常与线程绑定,即只有持有锁的线程才能释放它,避免其他线程误释放导致的逻辑错误。

3、互斥量的优点

  • 简单高效: 互斥量的实现通常基于原子操作或硬件指令(如x86的LOCK前缀),开销较小,适合高频访问的场景。

  • **可重入性(递归锁):**部分互斥量(如递归互斥量)允许同一线程多次加锁,避免线程因重复访问自身持有的资源而死锁。

  • 避免死锁的辅助机制: 通过配合超时机制(如try_lock_for())或锁的层级策略,可以减少死锁风险。

  • 跨平台兼容性: 大多数编程语言(C++、Java、Python等)和操作系统(Linux、Windows)都提供了互斥量的标准实现(如pthread_mutexstd::mutex),便于移植。

  • **与条件变量结合:**互斥量常与条件变量配合使用,实现高效的线程间通信(如等待某个条件满足后再执行)。

4、互斥量的局限性及注意事项

  • **性能开销:**频繁的锁竞争会导致线程阻塞和上下文切换,影响性能。需通过减少锁粒度(如分段锁)或使用无锁编程优化。

  • **死锁风险:**不正确的加锁顺序(如线程A持有锁1后请求锁2,线程B持有锁2后请求锁1)会导致死锁。需遵循固定顺序或使用超时机制。

  • 优先级反转: 高优先级线程可能因等待低优先级线程持有的锁而被阻塞,需通过优先级继承协议(如PTHREAD_PRIO_INHERIT)解决。

  • **不适用于所有场景:**对于读多写少的场景,读写锁(RWLock)可能更高效;对于无状态操作,无锁数据结构(如原子变量)可能更合适。

5、总结

互斥量是多线程编程中保护共享资源的基础工具,其核心价值在于通过简单的锁机制解决复杂的并发问题。尽管存在性能和死锁等挑战,但通过合理设计(如缩小临界区、使用RAII封装锁),可以充分发挥其优势,构建高效、安全的并发程序。


七、互斥量实现原理探究

1、背景引入

  • 在前面的售票系统示例中,我们已经意识到,即使是看似简单的 i++--i 操作,在多线程环境下也可能引发数据一致性问题。

  • 这是因为这些操作并非原子操作,它们可能被拆分为多个步骤(如读取、修改、写入),在并发执行时,这些步骤可能交错进行,导致不可预测的结果。

  • 为了解决这个问题,我们需要一种机制来确保在任意时刻,只有一个线程能够执行对共享资源的修改操作。这就是互斥量(Mutex)的核心作用。

2、原子操作与硬件支持

  • 为了实现互斥锁操作,大多数计算机体系结构都提供了特殊的原子指令,如 swapexchange

  • 这些指令的作用是在单个、不可中断的步骤中交换寄存器和内存单元的数据。

  • 由于这些指令是原子的,即使在多处理器平台上,也能保证操作的完整性。

  • 当一个处理器执行交换指令时,其他处理器的相同指令必须等待,从而避免了并发访问的冲突。

3、锁的原理

  • 硬件级实现:关闭时钟中断:通过禁用中断来确保操作的原子性。

  • 软件级实现 :使用 swapexchange 指令:这些指令用于交换寄存器和内存单元的数据,以实现互斥锁操作。

4、互斥量的基本实现原理

互斥量的实现通常依赖于这些原子指令。下面,我们将通过伪代码来展示互斥量的基本加锁和解锁过程,并对其进行详细解释。我们下面使用的伪代码的机器是在一个单片机上的原子指令,本质还是一样的,不过这个简单易懂些,便于分析:

1. 初始状态

  • 假设我们有一个互斥量 mutex,其初始值为 1,表示锁是可用的(即没有线程持有该锁)。

2. 加锁操作(lock)

cpp 复制代码
lock:
    movb $0, %al        ; 将AL寄存器清零,用于尝试获取锁
    xchgb %al, mutex    ; 原子地交换AL寄存器和mutex的值
    if (al寄存器的内容 > 0) {  ; 判断是否成功获取锁
        return 0;       ; 如果AL > 0(实际上,由于初始为1,交换后AL为1表示成功,但此处逻辑以>0为成功条件,可理解为非零即成功)
    } else {
        挂起等待;       ; 如果AL为0,表示锁已被其他线程持有,当前线程挂起等待
        goto lock;      ; 尝试重新获取锁(在实际实现中,通常会使用更高效的等待机制,如条件变量)
    }

解释

  1. 清零寄存器 :首先,将 AL 寄存器清零,准备尝试获取锁。

  2. 原子交换:使用 xchgb 指令原子地交换 AL 寄存器和 mutex 的值。这一步是关键,因为它保证了操作的原子性。

  3. 判断结果 :检查 AL 寄存器的值。如果 AL 大于 0(在初始状态下,mutex 为 1,交换后 AL 为 1),则表示成功获取了锁。

  4. 处理失败 :如果 AL 为 0,则表示锁已被其他线程持有,当前线程需要挂起等待,并尝试重新获取锁(在实际代码中,通常会使用循环或条件变量来避免忙等待)。

3. 解锁操作(unlock)

cpp 复制代码
unlock:
    movb $1, mutex      ; 将mutex的值设置为1,表示锁已释放
    唤醒等待Mutex的线程; ; 如果有线程在等待锁,唤醒其中一个
    return 0;           ; 解锁操作成功

解释

  1. 设置锁状态 :将 mutex 的值设置为 1,表示锁已释放,可以被其他线程获取。

  2. 唤醒等待线程:如果有线程在等待该锁,则唤醒其中一个线程,使其有机会获取锁。

  3. 返回成功:解锁操作成功完成。

4. 实际实现中的优化

在实际的操作系统或库实现中,互斥量的实现会更加复杂和高效。例如:

  • 避免忙等待:使用条件变量或信号量来避免线程在等待锁时占用 CPU 资源。

  • 公平性:确保锁的获取按照请求的顺序进行,避免某些线程长时间等待。

  • 性能优化:使用更高效的同步机制,如自旋锁(在短时间等待时)或读写锁(在读多写少的场景下)。

5. 注意

  • 在锁申请过程中,决定哪个线程能成功获取锁的关键在于哪个线程先执行了交换指令。当线程执行该指令后,其al寄存器会变为1,表示锁申请成功。由于交换指令是单条汇编指令,执行具有原子性,确保了锁申请的原子操作。

  • 关于锁释放机制,即便线程释放锁时未将al寄存器清零也不会产生影响。这是因为每个线程在申请锁时都会主动将al寄存器清零,然后再执行交换指令。(重点!!!)

  • 需要注意的是,CPU寄存器并非线程间共享资源,每个线程都拥有独立的寄存器组,而内存数据才是线程共享的区域。获取锁的本质是通过交换指令,将内存中的mutex值原子性地交换到线程自己的al寄存器中。

5、临界区与非临界区、锁的实现前提

  • 临界区:只允许一个线程执行,不允许多个线程同时执行。

  • 非临界区:可以并发执行代码。

  • 进程/线程切换:CPU 内的寄存器硬件只有一套,但 CPU 寄存器内的数据可以有多份,各自一份,当前执行流的上下文!

  • 数据交换 :通过 swapexchange 指令将内存中的**变量交换(不是拷贝!!!本质是交换!!!)**到 CPU 的寄存器中。

    • 本质是当前线程/进程在获取锁时,将变量的内容获取到当前执行流的硬件上下文中。

    • 当前 CPU 寄存器的硬件上下文(其实就是各个寄存器的内容)属于进程/线程私有的。

  • CPU 和内存的交互mutex 位于内存中,CPU 通过寄存器 %almutex 进行数据交换。

  • 线程执行流程

    • 线程 A 和线程 B 对 mutex 的操作,谁交换成功,谁就持有锁。

    • 线程 A 切走时,%al 的值为 1,表示锁被持有。

6、总结

互斥量是多线程编程中保护共享资源的基本机制,其实现依赖于硬件提供的原子指令。通过加锁和解锁操作,互斥量确保了多线程环境下对共享资源的独占访问,从而避免了数据竞争和不一致问题。在实际应用中,我们需要根据具体场景选择合适的同步机制,并考虑性能、公平性和实现复杂度等因素。


八、互斥量的封装与RAII风格锁管理

在多线程编程中,互斥量(mutex)是保护共享资源、避免数据竞争的重要工具。为了简化互斥量的使用并提高代码的安全性,我们可以对其进行封装,并采用RAII(Resource Acquisition Is Initialization)风格进行锁管理。

回顾:RAII 原则(Resource Acquisition Is Initialization)

RAII(资源获取即初始化)是 C++ 中一种重要的资源管理范式,其核心思想是:将资源的生命周期与对象的生命周期绑定,通过对象的构造和析构来自动管理资源,确保资源的正确获取和释放。

1. RAII 的核心思想

  • 资源绑定到对象

    • 资源(如内存、文件句柄、锁、网络连接等)在对象构造时获取(初始化)。

    • 资源在对象析构时自动释放。

  • 异常安全:即使发生异常,对象的析构函数仍会被调用,确保资源不会泄漏。

  • 简化代码:避免手动管理资源的繁琐和潜在错误(如忘记释放资源)。

2. RAII 的工作原理

  • 构造函数:在对象构造时获取资源(如分配内存、打开文件、加锁等)。

  • 析构函数:在对象析构时自动释放资源(如释放内存、关闭文件、解锁等)。

  • 作用域控制:对象的生命周期由作用域决定,资源在对象离开作用域时自动释放。

1、互斥量封装 (Mutex 类)

这段代码定义了一个名为 Mutex 的类,封装了 POSIX 线程库(pthread)中的互斥量(pthread_mutex_t),提供了更高级别的抽象和更安全的接口。将 Mutex 类封装在 LockModule 命名空间中,避免命名冲突。私有成员_mutex是底层互斥量,这个类封装实际的 POSIX 互斥量,对外隐藏实现细节。

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

namespace LockModule {

class Mutex {
public:
    // 禁用拷贝构造和赋值操作符
    Mutex(const Mutex&) = delete;
    const Mutex& operator=(const Mutex&) = delete;

    Mutex() {
        int n = pthread_mutex_init(&_mutex, nullptr);
        (void)n; // 避免未使用变量的警告
    }

    void Lock() {
        int n = pthread_mutex_lock(&_mutex);
        (void)n;
    }

    void Unlock() {
        int n = pthread_mutex_unlock(&_mutex);
        (void)n;
    }

    pthread_mutex_t* GetMutexOriginal() { // 获取原始指针
        return &_mutex;
    }

    ~Mutex() {
        int n = pthread_mutex_destroy(&_mutex);
        (void)n;
    }

private:
    pthread_mutex_t _mutex;
};

} // namespace LockModule

重点:禁用拷贝构造和赋值操作符(必须要注意!!!)

cpp 复制代码
Mutex(const Mutex&) = delete;
const Mutex& operator=(const Mutex&) = delete;
  • 目的 :防止 Mutex 对象被拷贝或赋值。

  • 原因互斥量是独占资源,拷贝或赋值可能导致多个对象共享同一个底层互斥量,引发未定义行为(如双重释放或竞争条件)。(这是要时刻记住的点!!!)

  • 实现方式 :通过 = delete 显式删除拷贝构造函数和赋值操作符。

说明:

  • 禁用拷贝和赋值:通过删除拷贝构造函数和赋值操作符,防止互斥量被意外复制,确保每个互斥量实例的唯一性。

  • 初始化与销毁:在构造函数中初始化互斥量,在析构函数中销毁互斥量,确保资源的正确管理。

  • 加锁与解锁 :提供 Lock()Unlock() 方法,用于手动控制锁的获取和释放。

  • 获取原始指针GetMutexOriginal() 方法允许在需要时获取原始的 pthread_mutex_t 指针,以便与C语言风格的线程库兼容。

设计意图与优势

安全性

  • 禁用拷贝和赋值,避免互斥量被意外共享。

  • 封装底层实现,防止直接操作 pthread_mutex_t 导致的错误。

易用性

  • 提供简单的 Lock()Unlock() 接口,简化互斥量使用。

  • 通过 GetMutexOriginal() 保留与底层 API 的兼容性。

资源管理:构造函数和析构函数自动初始化和销毁互斥量,遵循 RAII 原则。

2、RAII风格锁管理 (LockGuard 类)

这段代码定义了一个 LockGuard 类,用于实现 RAII(Resource Acquisition Is Initialization) 风格的锁管理。它通过绑定互斥量(Mutex)的生命周期,确保锁的自动获取和释放,避免手动管理锁的繁琐和潜在错误(如忘记解锁导致的死锁)。

cpp 复制代码
namespace LockModule {

class LockGuard {
public:
    LockGuard(Mutex& mutex) : _mutex(mutex) {  // 构造函数:绑定 Mutex 对象并加锁
        _mutex.Lock();
    }

    ~LockGuard() {  // 析构函数:自动解锁
        _mutex.Unlock();
    }

private:
    Mutex& _mutex;  // 引用方式持有 Mutex 对象,避免拷贝
};

} // namespace LockModule

说明:

  • RAII风格LockGuard 类在构造时自动加锁,在析构时自动解锁,确保锁的生命周期与作用域绑定,避免忘记解锁导致的死锁问题。

  • 简化代码 :使用 LockGuard 可以简化代码,使锁的管理更加直观和安全。

1. 核心机制

(1) 构造函数:自动加锁
cpp 复制代码
LockGuard(Mutex& mutex) : _mutex(mutex) {
    _mutex.Lock();
}

绑定 Mutex 对象

  • 通过构造函数参数接收一个 Mutex 对象的引用 _mutex

  • 使用引用(Mutex&)而非拷贝,避免不必要的对象复制。

自动加锁

  • 调用 _mutex.Lock(),在 LockGuard 对象构造时立即加锁。

  • 确保在进入临界区前,锁已被当前线程持有。

(2) 析构函数:自动解锁
cpp 复制代码
~LockGuard() {
    _mutex.Unlock();
}

自动解锁

  • LockGuard 对象析构时调用 _mutex.Unlock(),释放锁。

  • 无论函数是正常返回还是因异常退出,析构函数都会被调用,确保锁一定会被释放。

(3) 私有成员:持有 Mutex 引用
cpp 复制代码
private:
    Mutex& _mutex;
  • 引用而非指针 :使用引用(Mutex&)确保 LockGuard 必须绑定到一个有效的 Mutex 对象,避免空指针问题。

  • 生命周期绑定LockGuard 的生命周期与绑定的 Mutex 对象的作用域一致,确保锁的及时释放。

2. RAII 原则的应用

  • 资源绑定

    • 锁(Mutex)的获取(Lock())在构造函数中完成。

    • 锁的释放(Unlock())在析构函数中完成。

  • 异常安全 :即使临界区代码抛出异常,LockGuard 的析构函数仍会被调用,确保锁被释放,避免死锁。

  • 简化代码 :开发者无需手动调用 Lock()Unlock(),减少出错概率。

3. 使用示例

cpp 复制代码
#include "Lock.hpp"  // 假设 Mutex 类定义在此头文件中

LockModule::Mutex global_mutex;  // 全局互斥量

void CriticalSection() {
    LockModule::LockGuard lock(global_mutex);  // 构造时加锁
    // 临界区代码
    // 无需手动解锁,离开作用域时自动调用析构函数解锁
}

int main() {
    CriticalSection();
    return 0;
}

流程:

  1. 进入 CriticalSection 时,LockGuard 对象 lock 被构造,调用 global_mutex.Lock() 加锁。

  2. 执行临界区代码。

  3. 离开 CriticalSection 时,lock 对象析构,调用 global_mutex.Unlock() 解锁。

4. 优势

  • 自动管理锁 :锁的获取和释放由 LockGuard 的生命周期自动管理,避免手动操作。

  • 防止死锁:即使临界区代码抛出异常,锁仍会被释放。

  • 代码简洁 :减少 try-finallygoto cleanup 之类的冗余代码。

  • 线程安全:确保对共享资源的访问是互斥的,避免数据竞争。

5. 注意事项

  • 作用域控制LockGuard 对象的作用域应与临界区一致,避免过早或过晚释放锁。

  • 避免拷贝

    • LockGuard 对象不应被拷贝或移动,否则可能导致锁被多次释放。

    • 可以通过 = delete 禁用拷贝构造和赋值操作符(类似 Mutex 类的设计)。

  • Mutex 配合使用LockGuard 必须与 Mutex 类配合使用,确保 Mutex 提供 Lock()Unlock() 方法。

6. 改进建议

  • 禁用拷贝和赋值

    cpp 复制代码
    LockGuard(const LockGuard&) = delete;
    LockGuard& operator=(const LockGuard&) = delete;
  • 支持移动语义(可选):如果需要转移锁的所有权,可以实现移动构造函数和移动赋值操作符。

7. 总结

  • LockGuard 是 RAII 风格锁管理的典型实现 ,通过绑定 Mutex 的生命周期,确保锁的自动获取和释放。

  • 核心优势:异常安全、代码简洁、防止死锁。

  • 适用场景:任何需要保护临界区的多线程程序,如共享资源访问、线程同步等。

通过 LockGuard,开发者可以更安全、更高效地管理锁,避免手动管理带来的潜在错误。

3、使用示例:抢票系统

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include "Lock.hpp"  // 引入自定义的 Mutex 和 LockGuard

using namespace LockModule;  // 使用 LockModule 命名空间

int ticket = 50;  // 共享资源:剩余票数
Mutex mutex;        // 互斥量,用于保护共享资源

// 线程函数:模拟抢票过程
void* route(void* arg) {
    char* id = (char*)arg;  // 线程标识(如 "thread 1")
    while (1) {
        LockGuard lockguard(mutex);  // RAII 风格加锁
        if (ticket > 0) {
            usleep(1000);  // 模拟耗时操作(如网络延迟)
            printf("%s sells ticket: %d\n", id, ticket);
            ticket--;  // 卖票
        } else {
            break;  // 票卖完,退出循环
        }
    }
    return nullptr;
}

int main() {
    pthread_t t1, t2, t3, t4;  // 定义 4 个线程

    // 创建 4 个线程,分别执行 route 函数
    pthread_create(&t1, nullptr, route, (void*)"thread 1");
    pthread_create(&t2, nullptr, route, (void*)"thread 2");
    pthread_create(&t3, nullptr, route, (void*)"thread 3");
    pthread_create(&t4, nullptr, route, (void*)"thread 4");

    // 等待所有线程执行完毕
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);

    return 0;
}

说明:

  • 线程安全 :通过 LockGuard 确保对共享变量 ticket 的访问是线程安全的。

  • 自动管理锁 :每个线程在进入 route 函数时自动加锁,退出时自动解锁,避免手动管理锁的繁琐和潜在错误。

线程函数:route

参数void* arg 接收线程标识(如 "thread 1")。

流程

  1. 加锁LockGuard lockguard(mutex);构造 LockGuard 对象时自动加锁,确保后续操作是线程安全的。

  2. 检查票数if (ticket > 0)如果还有票,模拟耗时操作(usleep(1000)),然后卖票(ticket--)。

  3. 解锁LockGuard 析构时自动解锁,无需手动调用。

  4. 退出条件 :票卖完后(ticket <= 0),线程退出循环。

**在上面的抢票代码中,LockGuard 的生命周期(构造和析构)直接决定了锁的获取和释放时机。**以下是详细解析:

1. LockGuard 的构造时机

代码位置
cpp 复制代码
void* route(void* arg) {
    char* id = (char*)arg;
    while (1) {
        LockGuard lockguard(mutex);  // 构造 LockGuard 对象
        // ... 临界区代码 ...
    }
    return nullptr;
}
构造时机

每次循环开始时

  • LockGuard lockguard(mutex);while 循环的每一次迭代开始时构造。

  • 构造时会自动调用 mutex.Lock(),获取锁,确保当前线程独占访问共享资源(ticket)。

关键点

作用域绑定

  • LockGuard 对象 lockguard 的作用域仅限于 while 循环的当前迭代。

  • 每次循环都会创建一个新的 LockGuard 对象,确保锁的重新获取。

2. LockGuard 的析构时机

代码位置
cpp 复制代码
while (1) {
    LockGuard lockguard(mutex);  // 构造
    if (ticket > 0) {
        // ... 卖票操作 ...
    } else {
        break;  // 票卖完,退出循环
    }
    // LockGuard 析构发生在这里(每次循环结束时)
}
析构时机

每次循环结束时

  • while 循环的当前迭代结束(即执行到循环末尾或 break 退出时),lockguard 对象离开作用域。

  • 此时,LockGuard 的析构函数被自动调用,释放锁(mutex.Unlock())。

关键点
  • 自动释放锁 :无论循环是正常结束还是因 break 提前退出,LockGuard 的析构函数都会被调用,确保锁一定被释放。

  • 防止死锁 :即使临界区代码抛出异常,LockGuard 的析构函数仍会被调用(栈展开机制),避免锁泄漏。

3. 构造与析构的完整流程

单次循环的流程
  1. 构造 LockGuard

    • 调用 mutex.Lock(),获取锁。

    • 如果锁已被其他线程持有,当前线程阻塞等待。

  2. 执行临界区代码

    • 检查 ticket > 0,如果成立,卖票(ticket--)。

    • 否则,break 退出循环。

  3. 析构 LockGuard

    • 调用 mutex.Unlock(),释放锁。

    • 其他等待线程可以竞争锁。

多线程竞争示例

线程 A 和线程 B 同时进入 route 函数

  • 线程 A 构造 LockGuard,获取锁,进入临界区。

  • 线程 B 尝试构造 LockGuard,但锁已被持有,阻塞等待。

  • 线程 A 执行完临界区代码,析构 LockGuard,释放锁。

  • 线程 B 被唤醒,获取锁,进入临界区。

4. 为什么这样设计?

RAII 原则的核心

资源绑定到对象生命周期

  • 锁的获取(Lock())在构造函数中完成。

  • 锁的释放(Unlock())在析构函数中完成。

自动管理资源

  • 无需手动调用 Lock()Unlock(),减少出错概率。

  • 即使发生异常,资源仍会被正确释放。

适用场景
  • 短生命周期的锁需求 :如果锁需要长期持有,可以调整 LockGuard 的作用域(例如,将 LockGuard 的定义移到函数外部)。

  • 灵活控制锁的粒度 :通过调整 LockGuard 的作用域,可以精确控制锁的持有时间。

5. 对比手动管理锁

手动管理锁的代码
cpp 复制代码
void* route(void* arg) {
    char* id = (char*)arg;
    while (1) {
        mutex.Lock();  // 手动加锁
        if (ticket > 0) {
            usleep(1000);
            printf("%s sells ticket: %d\n", id, ticket);
            ticket--;
        } else {
            mutex.Unlock();  // 必须手动解锁,否则死锁
            break;
        }
        mutex.Unlock();  // 手动解锁
    }
    return nullptr;
}

问题

  • 如果 if (ticket > 0) 分支中抛出异常,Unlock() 不会被调用,导致死锁。

  • 代码冗长,容易遗漏解锁逻辑。

RAII 风格的优势
  • 自动解锁:无论代码如何退出(正常返回或异常),锁都会被释放。

  • 代码简洁:只需关注业务逻辑,无需手动管理锁。

6. 总结

  • 构造时机LockGuard 在每次 while 循环开始时构造,自动加锁。

  • 析构时机LockGuard 在每次循环结束时析构,自动解锁。

  • 优势

    • 遵循 RAII 原则,确保锁的自动管理。

    • 防止死锁和资源泄漏,提高代码的健壮性。

  • 适用场景:需要精确控制锁的持有时间,且希望代码简洁、异常安全。

通过 LockGuard,锁的生命周期与代码作用域绑定,使得多线程编程更加安全和高效。

4、C++11中的互斥量与锁管理

在C++11及更高版本中,标准库提供了 std::mutexstd::lock_guard,用法与封装的 MutexLockGuard 类似:

cpp 复制代码
#include <mutex>
#include <iostream>

std::mutex mtx;

void ThreadSafeFunction() {
    std::lock_guard<std::mutex> guard(mtx);
    // 临界区代码
}

说明:

  • 标准库支持 :C++11引入的 std::mutexstd::lock_guard 提供了更现代化和类型安全的锁管理方式。

  • 推荐使用:在支持C++11及更高版本的项目中,建议直接使用标准库提供的互斥量和锁管理工具。

5、总结

  • 互斥量封装 :通过封装 pthread_mutex_t,提供更安全、更易用的接口,禁用拷贝和赋值,确保资源的正确管理。

  • RAII风格锁管理 :通过 LockGuard 类实现锁的自动管理,简化代码,避免死锁问题。

  • 使用示例:抢票系统展示了如何在实际项目中应用封装的互斥量和RAII风格的锁管理。

  • C++11支持 :在支持C++11及更高版本的项目中,可以直接使用标准库提供的 std::mutexstd::lock_guard

6、ThreadData 类封装线程数据:基于 RAII 风格的多线程抢票系统(改进版)

这段代码实现了一个多线程抢票系统,使用 RAII 风格的锁管理(LockGuard) 来保护共享资源(ticket),并引入了 ThreadData 类来封装线程相关的数据。

1. 代码结构

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"  // 引入自定义的 Mutex 和 LockGuard

using namespace MutexModule;  // 使用 MutexModule 命名空间

int ticket = 50;  // 共享资源:剩余票数

// 线程数据封装类
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) {
    Mutex lock;  // 定义互斥量

    // 创建 4 个线程
    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);

    return 0;
}

2. 核心组件解析

(1) ThreadData
  • 作用:封装线程相关的数据,包括线程名称和指向互斥量的指针。

  • 成员变量

    • std::string name:线程名称,用于标识不同线程。

    • Mutex* lockp:指向互斥量的指针,用于线程同步。

  • 构造函数:接收线程名称和互斥量引用,初始化成员变量。

  • 设计意图:将线程相关的数据封装在一个类中,便于传递和管理。

(2) route 函数(线程函数)

参数void* arg,接收 ThreadData 对象指针。

流程

1. 获取线程数据ThreadData* td = static_cast<ThreadData*>(arg);将传入的 void* 转换为 ThreadData*,获取线程名称和互斥量指针。

2. 加锁与临界区LockGuard guard(*td->lockp);构造 LockGuard 对象,自动加锁,确保后续操作是线程安全的。

  1. 先执行 td->lockp

    1. td 是一个指针(ThreadData*),-> 用于访问 td 指向的对象的成员 lockp

    2. 结果:获取 td 对象的 lockp 成员(类型为 Mutex*)。

  2. 再执行解引用 *

    1. td->lockp 的结果(Mutex*)进行解引用,得到 Mutex 对象。

    2. 结果:*td->lockp 等价于 *(td->lockp),即获取 Mutex 对象本身。

  3. 构造 LockGuard 对象

    • 将解引用后的 Mutex 对象传递给 LockGuard 的构造函数。

    • LockGuard 内部会调用该 Mutex 对象的 Lock() 方法。

3. 临界区代码 :检查 ticket > 0,如果成立,模拟耗时操作(usleep(1000)),然后卖票(ticket--)。否则,退出循环。

4. 非临界区代码 (可选):usleep(123);模拟非临界区的其他操作,不影响线程安全。

5. 自动解锁LockGuard 析构时自动解锁,无需手动调用。

(3) main 函数
  • 初始化互斥量Mutex lock;定义一个互斥量,用于保护共享资源 ticket

  • 创建线程

    • 使用 pthread_create 创建 4 个线程,每个线程执行 route 函数。

    • 每个线程接收一个 ThreadData 对象,包含线程名称和互斥量指针。

  • 等待线程结束pthread_join 确保主线程等待所有子线程执行完毕。

3. RAII 风格锁管理的应用

(1) LockGuard 的作用
  • 自动加锁/解锁 :在 route 函数中,LockGuard guard(*td->lockp); 构造时自动加锁,析构时自动解锁。

  • 异常安全 :即使 route 函数抛出异常,LockGuard 的析构函数仍会被调用,确保锁被释放。

  • 代码简洁 :无需手动调用 Lock()Unlock(),减少出错概率。

(2) 锁的粒度控制
  • 细粒度锁 :锁的范围仅限于临界区(修改 ticket 的代码),非临界区代码(如 usleep(123))不在锁的保护范围内。

  • 优势:减少锁的持有时间,提高并发性能。

4. 执行流程

  1. 初始化ticket = 50Mutex lock 初始化。

  2. 线程启动 :4 个线程分别创建,每个线程接收一个 ThreadData 对象。

  3. 抢票过程

    • 每个线程尝试获取锁,修改 ticket

    • 由于锁的存在,同一时间只有一个线程能卖票,确保 ticket-- 是原子操作。

  4. 卖完票 :当 ticket 减到 0 时,所有线程陆续退出。

  5. 主线程结束pthread_join 确保所有子线程执行完毕,主线程退出。

5. 关键点解析

(1) 为什么使用 ThreadData
  • 数据封装:将线程名称和互斥量指针封装在一个类中,便于传递和管理。

  • 灵活性 :如果需要扩展线程数据(如线程 ID、状态等),可以直接在 ThreadData 中添加。

(2) 为什么锁的粒度要细?

性能优化

  • 锁的持有时间越短,并发性能越高。

  • 非临界区代码(如 usleep(123))不应被锁保护,否则会降低并发性。

(3) 为什么使用 RAII 风格?
  • 自动管理资源 :锁的获取和释放由 LockGuard 的生命周期自动管理,避免手动操作。

  • 防止死锁:避免因忘记解锁导致的死锁问题。

6. 潜在问题与改进

(1) 动态内存管理
  • 问题ThreadData 对象使用 new 动态分配,但未在 main 函数中释放,可能导致内存泄漏。

  • 改进 :使用智能指针(如 std::unique_ptr)管理 ThreadData 对象。

(2) 互斥量传递
  • 问题ThreadData 中存储的是互斥量的指针,如果互斥量生命周期结束,指针可能悬空。

  • 改进:使用引用或智能指针确保互斥量的生命周期覆盖线程的执行周期。

(3) C++11 线程库
  • 改进 :可以使用 std::thread 替代 pthread,更现代化且类型安全。

7. 总结

  • 功能 :这段代码实现了一个线程安全的抢票系统,4 个线程竞争卖票,确保 ticket 的正确修改。

  • 核心机制

    • ThreadData:封装线程相关的数据,便于传递和管理。

    • Mutex + LockGuard:实现 RAII 风格的锁管理,确保线程安全。

    • 细粒度锁:减少锁的持有时间,提高并发性能。

  • 优势

    • 代码简洁,避免手动管理锁。

    • 异常安全,防止死锁。

    • 易于扩展,适合更复杂的多线程场景。

通过 ThreadDataLockGuard 的结合,这段代码在保证线程安全的同时,也提高了代码的可维护性和可扩展性。

通过封装和RAII风格的管理,可以显著提高多线程编程的安全性和可维护性。

相关推荐
牛奶咖啡135 小时前
Linux中安装部署Hadoop集群的保姆级安装配置教程
linux·hadoop·openjdk21安装配置·openjre21安装配置·hadoop集群安装配置·linux的ssh配置·linux实现免密登录配置
dessler5 小时前
MYSQL-数据库介绍
linux·运维·mysql
Garc6 小时前
linux Debian 12 安装 Docker(手动)
linux·docker·debian
kaoa0006 小时前
Linux入门攻坚——52、drbd - Distribute Replicated Block Device,分布式复制块设备-1
linux·运维·服务器
Kay_Liang6 小时前
【Hive 踩坑实录】从元数据库初始化到 HiveServer2 启动的全流程问题解决
大数据·linux·hive·hadoop·笔记·mysql·ubuntu
NiKo_W6 小时前
Linux Socket网络编程基础
linux·服务器·网络
啊略略wxx7 小时前
嵌入式Linux面试题目
linux·运维·服务器
半桔7 小时前
【IO多路转接】深入解析 poll:从接口到服务器实现
linux·运维·服务器·php
Dovis(誓平步青云)7 小时前
《静态库与动态库:从编译原理到实战调用,一篇文章讲透》
linux·运维·开发语言