Linux 线程互斥

目录

0.前言

1.相关概念

2.互斥量(mutex)

[2.1 代码引入](#2.1 代码引入)

2.2为什么需要互斥量

2.3互斥量的接口

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

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

[2.3.3 互斥量加锁和解锁](#2.3.3 互斥量加锁和解锁)

2.4改写代码

3.互斥量的封装

4.小结


(图像由AI生成)

0.前言

在多线程编程中,线程之间的并发操作可能会导致共享资源的竞争问题,如数据不一致、状态紊乱等。为了保证程序的正确性和稳定性,必须引入线程同步机制,其中互斥量(mutex)是解决线程互斥的核心工具。本篇博客承接前文关于进程的讨论,深入介绍线程互斥的相关概念、实现方法以及代码实例,帮助理解如何在 Linux 环境下有效避免线程竞争问题。

1.相关概念

  • 临界资源:指多个线程需要共享访问的资源,例如全局变量、文件或数据库连接等。如果多个线程同时操作临界资源,可能会导致数据不一致或冲突。
  • 临界区:指访问临界资源的代码片段。为防止多个线程同时进入临界区,需要对其进行保护,确保同一时刻只有一个线程可以执行临界区代码。
  • 互斥:一种线程同步机制,用于确保多个线程对临界资源的访问是互斥的,即同一时间仅允许一个线程访问共享资源。互斥量(mutex)是实现互斥的常用工具。
  • 原子性:指某个操作不可被中断,要么完全执行完毕,要么完全不执行。在多线程环境下,原子性是实现线程安全的基本要求之一。

2.互斥量(mutex)

互斥量是一种线程同步机制,用于解决多线程并发访问共享资源时的冲突问题。在多线程编程中,互斥量通过对临界区的加锁和解锁,确保同一时刻只有一个线程可以访问共享资源,从而避免数据竞争。

2.1 代码引入

在大多数情况下,线程使用的数据是局部变量,变量的地址空间位于线程栈空间内,仅属于单个线程,其他线程无法访问。但在某些场景下,线程之间需要共享数据,这些变量称为共享变量,通过它们可以完成线程间的交互。

然而,当多个线程并发操作共享变量时,会导致数据不一致等问题。例如,一个典型的问题是多个线程争夺共享资源时的竞争。以下以"抢票"为例,展示未加锁的多线程争夺资源代码:

未加锁的多线程代码示例:

cpp 复制代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h> // usleep 函数

int ticket = 100; // 共享资源

void* sell_tickets(void* arg) {
    char* id = (char*)arg; // 将 void* 转为 char*
    while (1) {
        if (ticket > 0) { // 检查是否还有票
            usleep(1000); // 模拟售票的延迟
            printf("%s sells ticket: %d\n", id, ticket);
            ticket--; // 执行 -- 操作,存在数据竞争
        } else {
            break; // 没有票时退出
        }
    }
    return NULL;
}

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

    // 创建四个线程,并显式转换字符串为 void*
    pthread_create(&t1, NULL, sell_tickets, (void*)"thread 1");
    pthread_create(&t2, NULL, sell_tickets, (void*)"thread 2");
    pthread_create(&t3, NULL, sell_tickets, (void*)"thread 3");
    pthread_create(&t4, NULL, sell_tickets, (void*)"thread 4");

    // 等待线程结束
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    return 0;
}

程序输出(部分):

bash 复制代码
thread 2 sells ticket: 100
thread 1 sells ticket: 100
thread 3 sells ticket: 100
thread 4 sells ticket: 100
...
thread 2 sells ticket: 3
thread 3 sells ticket: 3
thread 4 sells ticket: 1
thread 2 sells ticket: 0
thread 1 sells ticket: -1
thread 3 sells ticket: -2

2.2为什么需要互斥量

在多线程编程中,当多个线程并发访问共享资源时,如果没有同步机制进行保护,就会导致数据竞争和资源冲突等问题。以下是未加锁情况下上面的代码出现的问题:

  1. 票号重复销售:

    多个线程同时读取共享变量 ticket 的值,导致同一票号被多个线程同时销售。例如:

    bash 复制代码
    thread 2 sells ticket: 100
    thread 1 sells ticket: 100
    thread 3 sells ticket: 100

    这是因为 ticket 的读取和更新是分步骤完成的,线程在切换时导致了数据的不一致。

  2. 超卖现象:

    由于多个线程同时修改 ticket 的值,可能导致最终结果错误,甚至出现负值。例如:

    bash 复制代码
    thread 1 sells ticket: -1
    thread 3 sells ticket: -2

    这种现象表明线程在操作过程中缺乏有效的同步机制,无法确保共享变量的正确性。

  3. 数据竞争:
    ticket-- 是非原子操作,分为读取值、修改值和写回值三个步骤。在多线程环境下,这些步骤可能被其他线程的操作打断,导致多个线程同时更新变量的值,破坏数据一致性。

多线程编程中的共享资源竞争 是导致数据不一致的主要原因。以抢票系统中的 --ticket 操作为例,尽管它看似简单,但实际上并非原子操作,而是由多条汇编指令组成的复杂过程。

--ticket 的汇编代码

通过 objdump 工具反汇编程序,我们可以看到 --ticket 的具体汇编指令:

bash 复制代码
152 40064b: 8b 05 e3 04 20 00    mov 0x2004e3(%rip),%eax   # 将共享变量加载到寄存器
153 400651: 83 e8 01             sub $0x1,%eax             # 更新寄存器中的值,执行 -1 操作
154 400654: 89 05 da 04 20 00    mov %eax,0x2004da(%rip)   # 将新值写回共享变量的内存地址

这三条指令的含义分别是:

  1. load: 将共享变量 ticket 的值从内存加载到寄存器。
  2. update: 在寄存器中执行 -1 操作,更新值。
  3. store: 将更新后的值写回共享变量的内存地址。

问题所在:

由于 --ticket 涉及三步操作,如果线程在任意步骤被中断,另一个线程可能会修改 ticket,导致数据竞争。例如:

  • 线程 A 从内存读取 ticket = 100,还未更新,线程 B 也读取了 ticket = 100
  • 两个线程都执行了 ticket-- 操作,结果是 ticket = 99,实际减少了一张票而非两张。

这种数据不一致问题会引发票号重复销售超卖现象 ,根本原因是 --ticket 不是原子操作。

如何解决这些问题?

为了解决共享资源的竞争问题,需要满足以下三点:

  1. 互斥行为: 当一个线程进入临界区执行代码时,其他线程必须被阻止进入临界区。
  2. 独占访问: 如果多个线程同时请求进入临界区,且临界区没有线程在执行,则仅允许一个线程进入。
  3. 非阻塞: 如果某线程不在临界区内执行,则不能阻止其他线程进入临界区。

这些条件的核心要求是一把锁,而 Linux 系统中提供的这把锁就是互斥量(mutex)

互斥量的作用:

互斥量通过加锁(pthread_mutex_lock)和解锁(pthread_mutex_unlock),实现对临界区的独占访问:

  • 加锁: 线程在访问共享资源前需要获得锁,如果其他线程已经持有锁,则当前线程会阻塞。
  • 解锁: 线程在完成共享资源操作后释放锁,其他阻塞线程才可以继续执行。

通过互斥量,--ticket 的多条汇编指令可以被视为一个原子操作,从而避免数据竞争,确保程序的正确性和线程安全。

2.3互斥量的接口

在多线程编程中,互斥量(mutex)提供了一种机制来确保共享资源的安全访问。以下介绍互斥量的核心操作接口。

2.3.1 初始化互斥量

互斥量在使用前需要进行初始化,主要有两种方法:

方法1:静态分配

通过宏 PTHREAD_MUTEX_INITIALIZER 初始化互斥量,适用于全局或静态互斥量:

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

这种方式简单直接,适合在程序启动时确定的互斥量。

方法2:动态分配

通过函数 pthread_mutex_init 动态初始化互斥量,适用于动态创建的互斥量:

cpp 复制代码
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL); // 动态初始化
  • 参数说明:
    • mutex:指向需要初始化的互斥量。
    • attr:互斥量属性,一般传 NULL 表示使用默认属性。

示例代码:

cpp 复制代码
pthread_mutex_t mutex; pthread_mutex_init(&mutex, NULL); // 动态初始化

2.3.2 销毁互斥量

互斥量使用完成后,需通过 pthread_mutex_destroy 释放资源:

cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • 注意事项:
    1. 使用 PTHREAD_MUTEX_INITIALIZER 初始化的互斥量不需要显式销毁。
    2. **不要销毁一个已经加锁的互斥量,**否则可能导致程序崩溃或行为异常。
    3. **确保销毁后的互斥量不再被使用,**避免线程尝试加锁销毁的互斥量。

示例代码:

cpp 复制代码
pthread_mutex_destroy(&mutex);

2.3.3 互斥量加锁和解锁

加锁

使用 pthread_mutex_lock 对互斥量加锁:

cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);
  • 行为:
    1. 如果互斥量处于未锁状态,调用线程会成功加锁并继续执行。
    2. 如果互斥量已被其他线程锁定,调用线程会阻塞,等待互斥量解锁。

解锁

使用 pthread_mutex_unlock 对互斥量解锁:

cpp 复制代码
int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 解锁后,其他等待的线程将有机会获得锁。

返回值:

  • 成功返回 0
  • 失败返回错误号(例如尝试解锁未加锁的互斥量)。

示例代码:

cpp 复制代码
pthread_mutex_lock(&mutex);
// 临界区代码
pthread_mutex_unlock(&mutex);

2.4改写代码

在 2.1 的示例代码中,由于 ticket-- 操作不是原子操作,导致出现数据竞争和不一致的问题。通过引入互斥量(mutex) ,可以确保对共享资源 ticket 的访问具有互斥性,从而解决上述问题。

以下是改写后的代码,使用互斥量实现线程安全:

cpp 复制代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h> // usleep 函数

int ticket = 100; // 共享资源
pthread_mutex_t mutex; // 定义互斥量

void* sell_tickets(void* arg) {
    char* id = (char*)arg; // 将 void* 转为 char*
    while (1) {
        pthread_mutex_lock(&mutex); // 加锁,保护共享资源
        if (ticket > 0) { // 检查是否还有票
            usleep(1000); // 模拟售票的延迟
            printf("%s sells ticket: %d\n", id, ticket);
            ticket--; // 执行 -- 操作,已被互斥量保护
        } else {
            pthread_mutex_unlock(&mutex); // 解锁,退出循环前释放锁
            break;
        }
        pthread_mutex_unlock(&mutex); // 解锁,允许其他线程访问共享资源
    }
    return NULL;
}

int main(void) {
    pthread_mutex_init(&mutex, NULL); // 初始化互斥量

    pthread_t t1, t2, t3, t4;

    // 创建四个线程,并显式转换字符串为 void*
    pthread_create(&t1, NULL, sell_tickets, (void*)"thread 1");
    pthread_create(&t2, NULL, sell_tickets, (void*)"thread 2");
    pthread_create(&t3, NULL, sell_tickets, (void*)"thread 3");
    pthread_create(&t4, NULL, sell_tickets, (void*)"thread 4");

    // 等待线程结束
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    pthread_mutex_destroy(&mutex); // 销毁互斥量
    return 0;
}

3.互斥量的封装

在实际开发中,直接操作互斥量可能会导致代码冗长且容易出错。通过对互斥量的封装,可以简化使用流程并提高代码的可维护性。以下通过 Lock.hpp 文件展示如何封装互斥量,并采用 RAII 风格实现自动化管理。

cpp 复制代码
#pragma once
#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; // 封装的互斥量
};

// RAII 风格的锁管理器
class LockGuard {
public:
    // 构造函数,自动加锁
    LockGuard(Mutex &mutex) : _mutex(mutex) {
        _mutex.Lock();
    }

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

private:
    Mutex &_mutex; // 引用封装的互斥量
};

}

封装的核心思想

  1. Mutex 类:

    • 封装了 pthread_mutex_t 的操作,包括初始化、加锁、解锁和销毁。
    • 禁止拷贝构造和赋值,避免多次操作同一个互斥量。
    • 提供获取原始互斥量指针的方法,以便在某些特殊场景中直接操作底层互斥量。
  2. LockGuard 类:

    • 采用 RAII(Resource Acquisition Is Initialization)风格,通过构造函数加锁,析构函数解锁,实现自动化管理。
    • 避免手动解锁可能导致的遗漏问题。

4.小结

线程间的共享资源竞争是多线程编程中的核心问题,互斥量(mutex)提供了一种高效的解决方案。通过本篇博客,我们从互斥量的基础概念入手,详细介绍了其初始化、加锁解锁操作,以及如何通过封装实现更安全和高效的资源管理。通过互斥量,我们可以确保临界区操作的线程安全性,避免数据竞争和资源冲突,为构建健壮的多线程应用奠定基础。

相关推荐
还有醒着的咩4 分钟前
SElinux和防火墙
运维·服务器·网络
daizikui9 分钟前
内网穿透产品 frp ngrok FastTunnel
linux·运维·网络·nginx
HealthScience16 分钟前
如何正确书写sh文件/sh任务?bash任务
linux·运维·服务器
木子Linux19 分钟前
【Linux打怪升级记 | 报错02】-bash: 警告:setlocale: LC_TIME: 无法改变区域选项 (zh_CN.UTF-8)
linux·运维·服务器·centos·ssh
LinuxST21 分钟前
30、Firefly-rk3399定时器
linux·windows·stm32·嵌入式硬件·ubuntu
^毛小兔^21 分钟前
[Bash]遍历文件夹下的动态库文件,并为每个so文件创建一个软连接(symbolic link)
开发语言·bash
Cedric_Anik26 分钟前
Swift——类与结构体
开发语言·ios·swift
0110编程之路27 分钟前
ubuntu20.04安装docker compose
运维·docker·容器
叫我:松哥32 分钟前
经典游戏:飞机大战游戏python设计与实现
开发语言·python·游戏·pygame
fancybit32 分钟前
ubuntu 20 桌面版安装备忘
linux·运维·ubuntu