Linux多线程互斥锁:从现象到原理解析

文章目录

  • [1. 由现象出发:多线程抢票问题](#1. 由现象出发:多线程抢票问题)
    • [1.1 问题代码演示](#1.1 问题代码演示)
    • [1.2 现象:票数出现负数](#1.2 现象:票数出现负数)
    • [1.3 问题本质:多线程并发访问共享资源](#1.3 问题本质:多线程并发访问共享资源)
  • [2. 问题分析:数据为什么不一致?](#2. 问题分析:数据为什么不一致?)
    • [2.1 核心概念](#2.1 核心概念)
    • [2.2 根因分析](#2.2 根因分析)
      • [2.2.1 判断条件(tickets > 0)不是原子的](#2.2.1 判断条件(tickets > 0)不是原子的)
      • [2.2.2 自减操作(tickets--)不是原子的](#2.2.2 自减操作(tickets--)不是原子的)
  • [3. 解决方案:互斥锁](#3. 解决方案:互斥锁)
    • [3.1 锁的核心思想](#3.1 锁的核心思想)
    • [3.2 互斥量接口](#3.2 互斥量接口)
      • [3.2.1 初始化(静态/动态)](#3.2.1 初始化(静态/动态))
      • [3.2.2 加锁与解锁](#3.2.2 加锁与解锁)
      • [3.2.3 销毁](#3.2.3 销毁)
    • [3.3 代码改造:加锁版抢票](#3.3 代码改造:加锁版抢票)
  • [4. 锁的底层原理](#4. 锁的底层原理)
    • [4.1 硬件实现:关中断](#4.1 硬件实现:关中断)
    • [4.2 软件实现:原子交换(xchgb)](#4.2 软件实现:原子交换(xchgb))
      • [4.2.1 核心伪代码](#4.2.1 核心伪代码)
      • [4.2.2 时序图解析](#4.2.2 时序图解析)
    • [4.3 锁的本质:独占资源](#4.3 锁的本质:独占资源)
  • [5. RAII 封装:C++ 风格的锁管理](#5. RAII 封装:C++ 风格的锁管理)
    • [5.1 封装 Mutex 类](#5.1 封装 Mutex 类)
    • [5.2 LockGuard 类(RAII)](#5.2 LockGuard 类(RAII))
    • [5.3 改造后的代码](#5.3 改造后的代码)
  • [6. 总结](#6. 总结)
    • [6.1 锁解决了什么问题](#6.1 锁解决了什么问题)
    • [6.2 锁带来了什么代价](#6.2 锁带来了什么代价)
    • [6.3 下一步:条件变量](#6.3 下一步:条件变量)

1. 由现象出发:多线程抢票问题

1.1 问题代码演示

我们有如下一段代码,操作变量的售票系统代码:

cpp 复制代码
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <vector>
int tickets = 100; // 共享资源,尝试对一个共享资源进行更新

void* route(void* args)
{
    char* id = (char*)args;
    while (1) {
        if (tickets > 0) {
            usleep(1000); // 模拟具体抢票花的时间
            printf("%s get tickets: %d\n", id, tickets);
            tickets--;
        } else {
            break;
        }
    }
    return nullptr;
}

int main()
{
    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;
}

1.2 现象:票数出现负数

我们抢票正常不是抢完就完了,但是却出现了如下现象:

抢票怎么抢出了负数?很奇怪吧,就像你原本只有100张电影票却卖出了102张票一样。这是为什么呢?当然这么说不专业。

1.3 问题本质:多线程并发访问共享资源

问题:一个进程内部的多个线程中,因为所有的线程共享地址空间,所以进程资源的大部分都会被线程共享。如果多个线程同时访问共享资源呢?

现象:多个线程向显示器打印,我们知道Linux下一切皆文件,所有线程都向fd->1中写入数据,也就是同时访问共享资源,就会导致数据不一致问题,也就是我们所看到的现象。

那么我们如何解决这个问题呢?同步与互斥!

2. 问题分析:数据为什么不一致?

2.1 核心概念

在深入分析之前,我们先补充几个概念:

  • 临界资源:多线程执行流被保护的共享资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  • 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

2.2 根因分析

2.2.1 判断条件(tickets > 0)不是原子的

实际上,可能导致内存数据被修改的地方也就两个部分:

  1. tickets > 0,因为对票数做判断,本身也是一种运算
  2. tickets--

问题的导致实际上与 tickets > 0 关系较大,因为你对条件判断没有加任何保护,进程进来的时候会被切换走。并发判断的会让多个线程同时进入到if判断里,你将线程唤醒的时候依次执行就会导致数据一直减少。

所以问题的关键在于判断临界区大于0被重复载入不同数据的寄存器中,所以解决问题的关键就在于访问临界区时不准其它线程的进入。

2.2.2 自减操作(tickets--)不是原子的

然而对于 tickets--,对于一个变量做修改,汇编之后至少是三条语句,恢复上下文数据会导致数据被覆盖。对全局变量的 ++-- 操作对于多线程也是导致数据不一致问题而引发线程安全问题的关键。

实际上,也就相当于:

assembly 复制代码
movl    -8(%rbp), %eax    # 1. 把 tickets 的值加载到 eax 寄存器
leal    -1(%rax), %edx    # 2. 把 eax-1 的结果存入 edx
movl    %edx, -8(%rbp)    # 3. 把 edx 写回内存,即 tickets = tickets - 1
  • load:将共享变量 ticket 从内存加载到寄存器中
  • update:更新寄存器里面的值,执行-1操作
  • store:将新值,从寄存器写回共享变量 ticket 的内存地址

usleep(1000) 的存在是为了尽可能的让执行流进行切换,执行流切换的原因本质上是时间片到了。所以 usleep 的本质实际上是让线程去阻塞,线程去阻塞就必然要将当前PCB从运行队列转移到我们某种等待队列,并且将R状态设置为S,当我们在唤醒的时候,修改内容数据结构必然会进入内核态。

3. 解决方案:互斥锁

3.1 锁的核心思想

上述问题应该如何解决:想办法保护共享资源→临界资源,访问临界资源的代码→临界区

保护临界资源的本质:保护临界区

如何保护?让线程互斥:在任一时刻,不允许多线程同时执行这部分代码,让代码串行执行。

要解决以上问题就必须要做到上面这三点:

  1. 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  2. 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  3. 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
    要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。

多线程保护并共享资源,本质就是将临界资源的代码保护起来

3.2 互斥量接口

3.2.1 初始化(静态/动态)

初始化锁:

  1. 静态分配
cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
  1. 动态分配
cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutex_t *restrict attr);

参数:

  • mutex:要初始化的互斥量
  • attr:NULL

3.2.2 加锁与解锁

加锁:

cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

调用 pthread_mutex_lock 时,可能会遇到如下情况:

  • 互斥量处于未锁状态,该函数将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但是没有竞争到互斥量,那么 pthread_mutex_lock 调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

3.2.3 销毁

销毁锁:

销毁互斥量需要注意:

  • 使用 PTHREAD_MUTEX_INITIALIZER 初始化的互斥量不需要销毁(编译时完成的静态初始化,对应的互斥量在静态存储区,生命周期随进程不需要手动销毁。)
  • 不要销毁一个已经加锁的互斥量(锁还在用但是锁本身没有了,会导致严重的线程安全问题)
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁(因为互斥量被销毁后,它内部的内存和内核资源已经被释放了。如果还有线程去访问它,就会发生「内存访问已释放对象」的错误)
cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);

全局静态的锁→ pthread_mutex_t(用宏初始化)

栈上的锁→ pthread_mutex_init or pthread_mutex_lock

3.3 代码改造:加锁版抢票

静态分配版本:

cpp 复制代码
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <vector>

int tickets = 1000; // 共享资源,尝试对一个共享资源进行更新
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void* route(void* args)
{
    char* id = (char*)args;
    while (1) {
        pthread_mutex_lock(&mutex); // 要放到循环里面,不然只会加一次锁
        if (tickets > 0) {
            usleep(1000); // 模拟具体抢票花的时间
            printf("%s get tickets: %d\n", id, tickets);
            tickets--;
            pthread_mutex_unlock(&mutex);
        } else {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
    return nullptr;
}

int main()
{
    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;
}

动态分配版本:

cpp 复制代码
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <vector>

int tickets = 1000; // 共享资源,尝试对一个共享资源进行更新
pthread_mutex_t mutex; // 动态的话就不需要初始化了

void* route(void* args)
{
    char* id = (char*)args;
    while (1) {
        pthread_mutex_lock(&mutex); // 要放到循环里面,不然只会加一次锁
        if (tickets > 0) {
            usleep(1000); // 模拟具体抢票花的时间
            printf("%s get tickets: %d\n", id, tickets);
            tickets--;
            pthread_mutex_unlock(&mutex);
        } else {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_mutex_init(&mutex, NULL);
    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(&mutex);

    return 0;
}

4. 锁的底层原理

4.1 硬件实现:关中断

锁的实现本质上就是让线程不要发生切换。

时间片到了,在线程访问临界区的时候,将时钟中断关闭,OS就不会执行调度,也就不会发生切换了。关中断的实现是属于内核级别的操作,而OS一般不敢关,怕导致整个进程无法调度。

4.2 软件实现:原子交换(xchgb)

回忆:

  1. CPU在调度的线程,以线程为载体执行加锁逻辑
  2. CPU寄存器只有一套,但是寄存器内部的数据可以有多份
  3. 内存中的变量,被线程共享的,只要拿到地址就可以访问

把内存中的变量,交换到CPU内部的寄存器中,本质是什么?把共享数据变为某个线程的私有数据

4.2.1 核心伪代码

cpp 复制代码
// mutex = 0 表示锁是空闲的(未加锁)
// mutex = 1 表示锁已被占用(已加锁)

lock:
    movb $1, %al            // 把 1 放入 al 寄存器(表示"我要拿锁")
    xchgb %al, mutex        // 原子交换 al 和 mutex
    if (al寄存器内容 > 0) {  // al 拿到的是 0 → 原来 mutex 是 0(未加锁)
        return 0;           // 加锁成功!当前线程持有锁(mutex已经变为1)
    } else {                // al 拿到的是 1 → 原来 mutex 是 1(已被占用)
        挂起等待;            // 阻塞,等锁释放
        goto lock;          // 被唤醒后重试
    }

unlock:
    movb $1, mutex          // 把 mutex 设为 0(释放锁,归还"1")
    唤醒等待 mutex 的线程;
    return 0;

4.2.2 时序图解析

接着我们又可以提出问题:线程在临界区执行时,可以切换吗?可以的,你在切换的时候不把锁还回去不就好了?你不还回去,其他线程是不可能切换成功的。此时切换是不影响执行的,这个可以再次解释效率低的问题。

站在CPU的角度,他自己是不知道我让一个线程加了锁的,在CPU看来,这段语句可能就只是一段普通的汇编。但是只有这一条汇编就可以保证临界区加锁的安全。

4.3 锁的本质:独占资源

输出一个结论:互斥锁的本质是什么?互斥锁本质上是独占,独占的本质是我们认为临界资源只有一份,把互斥锁理解成一个信号量,只不过信号量的值为1,表示一份资源。

为什么申请资源要加锁?因为申请资源就相当于买票,其本质是对资源的预定机制。

5. RAII 封装:C++ 风格的锁管理

5.1 封装 Mutex 类

Thread.hpp

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

class Mutex {
public:
    Mutex() { pthread_mutex_init(&_lock, nullptr); }
    void lock() { pthread_mutex_lock(&_lock); }
    void unlock() { pthread_mutex_unlock(&_lock); }
    ~Mutex() { pthread_mutex_destroy(&_lock); }

private:
    pthread_mutex_t _lock;
};

5.2 LockGuard 类(RAII)

cpp 复制代码
class grouplock { // RAII风格代码
public:
    grouplock(Mutex& lock) : _lockref(lock) { _lockref.lock(); }
    ~grouplock() { _lockref.unlock(); }

private:
    Mutex &_lockref;
};

5.3 改造后的代码

main.cc

cpp 复制代码
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <vector>
#include "Thread.hpp"

int tickets = 1000; // 共享资源,尝试对一个共享资源进行更新
Mutex mutex;

void* route(void* args)
{
    char* id = (char*)args;
    while (1) {
        grouplock lock(mutex); // 要放到循环里面,不然只会加一次锁
        if (tickets > 0) {
            usleep(1000); // 模拟具体抢票花的时间
            printf("%s get tickets: %d\n", id, tickets);
            tickets--;
        } else {
            break;
        }
    }
    return nullptr;
}

int main()
{
    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;
}

6. 总结

6.1 锁解决了什么问题

多线程并发访问共享资源时,由于判断条件和自减操作都不是原子的,会导致数据不一致问题(如票数变成负数)。互斥锁通过保证临界区代码的串行执行,解决了这个问题。

6.2 锁带来了什么代价

引入锁会带来性能损失,因为临界区代码被串行化了。所以加锁的粒度必须足够细,区域的减小有助于减少效率损失。

6.3 下一步:条件变量

锁解决了互斥问题,但线程之间还需要协调执行顺序(同步)。条件变量(Condition Variable)是下一步要学习的内容,它可以让线程在某个条件满足时才继续执行。


互斥锁本质上是独占,独占的本质是我们认为临界资源只有一份,把互斥锁理解成一个信号量,只不过信号量的值为1。

相关推荐
KaiwuDB1 小时前
KaiwuDB 运维实战 03:双副本仲裁节点部署与高可用实践
运维·时序数据库·raft·kaiwudb·数据库运维·双副本高可用·仲裁节点
ambition202424 小时前
操作系统同步:读者-写者问题与读写公平法详解(附每个 PV 操作含义)
linux·开发语言·数据结构·unix
zmzmzmalo4 小时前
Linux ELF文件加载与内存管理揭秘
linux·网络·数据库
三言老师10 小时前
K8s集群运行时自动化运维全覆盖落地实操(下)
linux·运维·服务器·网络
ltl11 小时前
HAProxy HTX 与 HTTP 路径:内部表示、改写落点与协议分叉
linux
ltl11 小时前
可拆分 OS:CXL 内存池化如何动摇本地 DRAM 假设
linux
80s77711 小时前
住宅代理解析:动态住宅IP与静态住宅IP如何选型?
网络·网络协议·tcp/ip
ltl11 小时前
Tetragon 强制执行:Override 与 SIGKILL 各自保证什么
linux
lengjingzju12 小时前
编译与调试完全指南—第6章 性能分析工具
linux