【Linux】线程互斥与同步_线程互斥

hello~ 很高兴见到大家! 这次带来的是Linux系统中关于线程这部分的一些知识点,如果对你有所帮助的话,可否留下你宝贵的三连呢?
个 人 主 页 : 默|笙


文章目录

  • 一、线程互斥
    • [1.1 相关概念](#1.1 相关概念)
    • [1.2 互斥量 mutex](#1.2 互斥量 mutex)
      • [1. 互斥量引出及介绍](#1. 互斥量引出及介绍)
      • [2. 互斥量接口及使用](#2. 互斥量接口及使用)
      • [3. 互斥量实现原理](#3. 互斥量实现原理)
      • [4. 互斥量封装使用](#4. 互斥量封装使用)

一、线程互斥

1.1 相关概念

  1. 互斥 :多个执行流(进程)能够同时看到并访问的资源叫做共享资源,而任何时刻都只能有一个流访问这个共享资源,这是互斥,是保护共享资源的一个手段。
  2. 临界区与非临界区这种需要被保护的共享资源,也叫做临界资源访问临界资源的代码片段称为临界区,其余代码则称为非临界区。对共享资源进行保护实质上就是对共享资源的代码进行保护。
  3. 原子性:原子性就是指一个操作是原子的,即该操作在执行过程中不会被打断,不可被分割。它只有两种结果,要么从一开始就不执行,要么就完整执行完毕,不存在执行一半的中间状态。这类操作在底层通常对应单条汇编 / CPU 指令。

1.2 互斥量 mutex

1. 互斥量引出及介绍

  1. 一个进程里的大部分资源都是被该进程内的线程共享的,而如果一个共享资源同时被多个线程并发访问,一个线程还没修改完毕,另一个线程就过来修改或读取,数据岂不是会乱套?比如下面的代码:
cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

int ticket = 100;
void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        if (ticket > 0)
        {
            usleep(1000);//模拟漫长业务,让多个线程进入同一代码段
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
        }
        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. 可以看到,票的剩余量出现了负数,而按照我们的代码逻辑,票数本不应该出现负数。出现负数的根本原因,就是多个线程同时竞争访问、修改共享资源 ticket,导致数据不一致。这里有两处代码会引发并发问题:一处是判断条件 if (ticket > 0),另一处是票数递减 ticket--;这两条语句都直接访问了共享变量 ticket。接下来我们就具体分析这两处问题分别会导致怎样的异常情况。
  1. 判断条件 if (ticket > 0)(逻辑运算):线程 1 运行时,把 ticket=1 读到了 CPU 硬件 eax 寄存器中。此后无论判断逻辑是否执行完毕,只要发生线程切换,OS 就会把 eax、eip 等所有寄存器的值,保存到线程 1 的 task_struct 里。之后线程 2、3 运行时,同样把内存中 ticket=1 读到同一套硬件 eax 中,再被切走时,也各自把 eax=1 存到自己的 task_struct 里。等到线程 1、2、3 再次被调度执行时,会按照保存的 eip 继续执行:未完成的判断会接着做完,已完成的则直接走后续逻辑。但它们恢复的依旧是旧值 1,都会判定满足 ticket > 0,并依次执行递减操作,最终导致票数出现小于 0 的异常情况。
  2. 票数递减 ticket--(算术运算):ticket-- 这个操作本身就不是原子的,也就是在执行的过程中能够被打断。它一共有三个动作:先把数据从内存拷贝到 CPU 硬件 eax 寄存器,再完成 ticket 递减操作,之后把递减之后的值重新拷贝回内存。如果线程在这三个步骤的中间过程被切走,就会将寄存器中的旧值保存到 task_struct 中,再次切回来执行时,会把这个未更新的旧值直接写回内存,覆盖掉其他线程已经修改好的新值,最终造成数据错乱。造成票数小于 0 的情况与它无关。

它们对应的汇编指令都是多条,而只有单条 CPU 汇编指令的操作才具备天然原子性。

  1. 为防止多线程并发访问共享资源引发的问题,就必须保证互斥性,即任一时刻仅允许一个线程进入临界区。而保护临界资源的核心在于管控临界区,以规范线程对共享数据的访问行为。具体而言,当某线程正在执行临界区代码时,其他试图进入的线程将被阻塞等待。为此需引入锁机制,Linux/POSIX 环境中提供的此类同步原语称为互斥量(mutex)。
  1. 非临界区的代码可以并发执行,而临界区的代码则必须串行执行。线程在进入临界区之前加锁,阻止其他线程进入;退出临界区后释放锁,让其他需要访问临界区的线程去竞争这把锁。这样就能保证任何时刻只有一个线程能够执行临界区中的代码。

2. 互斥量接口及使用

互斥量初始化与销毁
  1. 互斥量的初始化分为静态初始化与动态初始化两种方式。静态初始化适用于具有静态存储期的变量(如全局变量或 static 局部变量),直接使用宏 PTHREAD_MUTEX_INITIALIZER 赋值即可;动态初始化则通过调用 pthread_mutex_init() 函数在运行时完成。静态初始化的互斥量由系统自动管理,生命周期通常随进程结束自动回收,无需手动销毁;而动态初始化的互斥量在使用完毕后,必须显式调用 pthread_mutex_destroy() 进行清理,以释放内核同步资源
  2. 静态初始化代码演示:
cpp 复制代码
pthread_mutex_t _lock = PTHREAD_ADAPTIVE_MUTEX_INITIALIZER_NP;
  1. 动态初始化及其销毁代码演示:
cpp 复制代码
pthread_mutex_t _lock;
pthread_mutex_init(&_lock, nullptr);
...···
pthread_mutex_destroy(&_lock);
  1. 互斥量(mutex)销毁前,必须确保没有任何线程持有该锁,也没有任何线程正在等待该锁(例如阻塞在 pthread_mutex_lock 上)。
互斥量加锁与解锁接口
  1. pthread_mutex_lock 和 pthread_mutex_trylock 都是用来加锁的接口,其中前者没有抢到锁就阻塞线程并等待,后者没有抢到锁不会阻塞,而是立即返回,由程序决定去执行别的任务;两者只要抢到锁,都会进入临界区执行代码。这里我们只考虑 pthread_mutex_lock 接口。
  2. pthread_mutex_unlock 是用来解锁的接口,当一个线程执行完临界区代码后,就需要释放锁,从而让其他正在等待这把锁的线程可以重新竞争获取该锁
  3. 下面是用两种互斥量初始方法进行修改后的代码:
cpp 复制代码
//静态初始化
int ticket = 100;
pthread_mutex_t _lock = PTHREAD_ADAPTIVE_MUTEX_INITIALIZER_NP;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        pthread_mutex_lock(&_lock);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            pthread_mutex_unlock(&_lock);
        }
        else
        {
            pthread_mutex_unlock(&_lock);
            break;
        }
    }
    return nullptr;
}
cpp 复制代码
//动态初始化
#include <iostream>
#include <pthread.h>
#include <unistd.h>

int ticket = 100;
pthread_mutex_t _lock;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        pthread_mutex_lock(&_lock);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            pthread_mutex_unlock(&_lock);
        }
        else
        {
            pthread_mutex_unlock(&_lock);
            break;
        }
    }
    return nullptr;
}
int main(void)
{
    pthread_mutex_t _lock;
    pthread_mutex_init(&_lock, nullptr);
    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(&_lock);

}


}
  1. 不能在子线程的执行函数内部定义互斥锁;互斥锁可以定义为全局变量并在主线程中完成动态初始化,也可以直接在主线程(main 函数)内定义并动态初始化。推荐使用前者,后者定义的锁属于主线程局部变量,子线程使用时必须将其传递给线程执行函数。
  2. 加锁会导致执行效率降低,因此锁覆盖的代码范围,也就是锁粒度需要尽可能小。加锁为什么会拉低效率?这就像走大路和窄路:大路可以让多个线程并行通过,而被锁保护的窄路同一时刻只能有一个线程通行。和全程走大路相比,部分路段只能串行通过,效率自然会更低一些。
  3. 锁本身同样是共享资源,它负责保护临界资源;可锁自身又该由谁来保护?其实锁并不需要额外保护,因为加锁 lock、解锁 unlock 这两个操作的底层本身就被设计成原子操作。
  4. 所有线程在访问临界资源时,加锁与解锁的规则必须被所有线程严格遵守,不能有任何例外。不允许出现一部分线程访问临界资源时遵循加锁、解锁流程,而另一部分线程直接访问临界资源的情况。

3. 互斥量实现原理

  1. 上面是 lock 与 unlock 接口对应的底层汇编指令。为了实现互斥锁操作,大多数体系结构都提供了 exchange 或 swap 指令,该指令的作用是将寄存器中的值与内存单元中的值进行互换。由于它是单条硬件指令,因此保证了操作的原子性。接下来我们就来研究操作系统是如何通过这条指令实现加锁与解锁,从而保证临界区互斥执行的:
  1. 我们结合 lock 对应的底层汇编指令来分析上图:假如线程 1、2 都要执行临界区代码,线程 1 先竞争到锁。线程执行加锁逻辑时,会先将 al 寄存器的值置为 0,再通过原子交换指令与内存中互斥锁 mutex 的值进行互换。执行后,al 寄存器的值变为 1,即便此时线程 1 被系统切换下线,这个值为 1 的标记也会随线程上下文被保存下来。只有拿到 al=1 的线程,才算加锁成功,可以进入临界区执行后续代码。

  2. 我们模拟线程 2 执行 lock 函数的过程:线程 2 同样先将 al 寄存器置为 0,再与内存中的 mutex 进行原子交换。此时内存中 mutex 的值已经被线程 1 交换为 0,因此线程 2 交换后,al 寄存器的值仍然是 0。拿到 al=0 代表加锁失败,线程 2 无法进入临界区,会进入阻塞等待状态。只有线程 1 持有有效标记(值为 1),可以正常执行临界区代码。而 unlock 解锁的本质,就是将数值 1 重新写回内存中的 mutex,并唤醒阻塞等待的线程,让它们重新竞争这把锁。

  3. 加锁本质就是抢内存 mutex 里的 1,解锁就是还这个 1 *。mutex 本来是共享数据,交换的本质就是将这个共享资源变成某个线程的私有数据

  4. 我们可以把互斥量理解成一种特殊的二元信号量,它的计数值固定为 1,同一时刻只允许一个线程访问对应的共享资源

4. 互斥量封装使用

cpp 复制代码
//Mutex.hpp
#pragma once

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

class LockGuard //RAII风格代码(资源获取即初始化)
{
public:
    LockGuard(Mutex& lock) : _lockref(lock)
    {
        _lockref.Lock();
    }
    ~LockGuard()
    {
        _lockref.Unlock();
    }
private:
    Mutex& _lockref;
};
cpp 复制代码
//Main.cc
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include "Mutex.hpp"

int ticket = 100;
Mutex lock;

void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        //lock.Lock();
        LockGuard lockguard(lock);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            //lock.Unlock();
        }
        else
        {
            //lock.Unlock();
            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. 非常的方便,我们只需要在临界区开头创建 LockGuard 对象就好,构造函数会自动调用加锁;当函数退出、对象生命周期结束时,析构函数会自动调用解锁,解锁它会自动完成,完全不需要手动写 unlock。Mutex 类:对原生 pthread_mutex 进行封装,提供加锁、解锁接口。LockGuard 类:利用 C++ 自动析构机制,创建时自动加锁,销毁时自动解锁

今天的分享就到此结束啦,如果对读者朋友们有所帮助的话,可否留下宝贵的三连呢~~
让我们共同努力, 一起走下去!

相关推荐
A小辣椒9 小时前
TShark:Wireshark CLI 功能
linux
A小辣椒13 小时前
TShark:基础知识
linux
AlfredZhao15 小时前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao1 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334661 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪2 天前
linux 拷贝文件或目录到指定的位置
linux
摇滚侠2 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
bush42 天前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5202 天前
Linux 11 动态监控指令top
linux
不会C语言的男孩2 天前
Linux 系统编程 · 第 8 章:进程基础
linux·c语言