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

文章目录
- 一、线程互斥
-
- [1.1 相关概念](#1.1 相关概念)
- [1.2 互斥量 mutex](#1.2 互斥量 mutex)
-
- [1. 互斥量引出及介绍](#1. 互斥量引出及介绍)
- [2. 互斥量接口及使用](#2. 互斥量接口及使用)
- [3. 互斥量实现原理](#3. 互斥量实现原理)
- [4. 互斥量封装使用](#4. 互斥量封装使用)
一、线程互斥
1.1 相关概念
- 互斥 :多个执行流(进程)能够同时看到并访问的资源叫做共享资源,而任何时刻都只能有一个流访问这个共享资源,这是互斥,是保护共享资源的一个手段。
- 临界区与非临界区 :这种需要被保护的共享资源,也叫做临界资源 ;访问临界资源的代码片段称为临界区,其余代码则称为非临界区。对共享资源进行保护实质上就是对共享资源的代码进行保护。
- 原子性:原子性就是指一个操作是原子的,即该操作在执行过程中不会被打断,不可被分割。它只有两种结果,要么从一开始就不执行,要么就完整执行完毕,不存在执行一半的中间状态。这类操作在底层通常对应单条汇编 / CPU 指令。
1.2 互斥量 mutex
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);
}

- 可以看到,票的剩余量出现了负数,而按照我们的代码逻辑,票数本不应该出现负数。出现负数的根本原因,就是多个线程同时竞争访问、修改共享资源 ticket,导致数据不一致。这里有两处代码会引发并发问题:一处是判断条件 if (ticket > 0),另一处是票数递减 ticket--;这两条语句都直接访问了共享变量 ticket。接下来我们就具体分析这两处问题分别会导致怎样的异常情况。

- 判断条件 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 的异常情况。
- 票数递减 ticket--(算术运算):ticket-- 这个操作本身就不是原子的,也就是在执行的过程中能够被打断。它一共有三个动作:先把数据从内存拷贝到 CPU 硬件 eax 寄存器,再完成 ticket 递减操作,之后把递减之后的值重新拷贝回内存。如果线程在这三个步骤的中间过程被切走,就会将寄存器中的旧值保存到 task_struct 中,再次切回来执行时,会把这个未更新的旧值直接写回内存,覆盖掉其他线程已经修改好的新值,最终造成数据错乱。造成票数小于 0 的情况与它无关。
它们对应的汇编指令都是多条,而只有单条 CPU 汇编指令的操作才具备天然原子性。

- 为防止多线程并发访问共享资源引发的问题,就必须保证互斥性,即任一时刻仅允许一个线程进入临界区。而保护临界资源的核心在于管控临界区,以规范线程对共享数据的访问行为。具体而言,当某线程正在执行临界区代码时,其他试图进入的线程将被阻塞等待。为此需引入锁机制,Linux/POSIX 环境中提供的此类同步原语称为互斥量(mutex)。

- 非临界区的代码可以并发执行,而临界区的代码则必须串行执行。线程在进入临界区之前加锁,阻止其他线程进入;退出临界区后释放锁,让其他需要访问临界区的线程去竞争这把锁。这样就能保证任何时刻只有一个线程能够执行临界区中的代码。
2. 互斥量接口及使用
互斥量初始化与销毁

- 互斥量的初始化分为静态初始化与动态初始化两种方式。静态初始化适用于具有静态存储期的变量(如全局变量或 static 局部变量),直接使用宏 PTHREAD_MUTEX_INITIALIZER 赋值即可;动态初始化则通过调用 pthread_mutex_init() 函数在运行时完成。静态初始化的互斥量由系统自动管理,生命周期通常随进程结束自动回收,无需手动销毁;而动态初始化的互斥量在使用完毕后,必须显式调用 pthread_mutex_destroy() 进行清理,以释放内核同步资源。
- 静态初始化代码演示:
cpp
pthread_mutex_t _lock = PTHREAD_ADAPTIVE_MUTEX_INITIALIZER_NP;
- 动态初始化及其销毁代码演示:
cpp
pthread_mutex_t _lock;
pthread_mutex_init(&_lock, nullptr);
...···
pthread_mutex_destroy(&_lock);
- 互斥量(mutex)销毁前,必须确保没有任何线程持有该锁,也没有任何线程正在等待该锁(例如阻塞在 pthread_mutex_lock 上)。
互斥量加锁与解锁接口

- pthread_mutex_lock 和 pthread_mutex_trylock 都是用来加锁的接口,其中前者没有抢到锁就阻塞线程并等待,后者没有抢到锁不会阻塞,而是立即返回,由程序决定去执行别的任务;两者只要抢到锁,都会进入临界区执行代码。这里我们只考虑 pthread_mutex_lock 接口。
- pthread_mutex_unlock 是用来解锁的接口,当一个线程执行完临界区代码后,就需要释放锁,从而让其他正在等待这把锁的线程可以重新竞争获取该锁。
- 下面是用两种互斥量初始方法进行修改后的代码:
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);
}
}
- 不能在子线程的执行函数内部定义互斥锁;互斥锁可以定义为全局变量并在主线程中完成动态初始化,也可以直接在主线程(main 函数)内定义并动态初始化。推荐使用前者,后者定义的锁属于主线程局部变量,子线程使用时必须将其传递给线程执行函数。
- 加锁会导致执行效率降低,因此锁覆盖的代码范围,也就是锁粒度需要尽可能小。加锁为什么会拉低效率?这就像走大路和窄路:大路可以让多个线程并行通过,而被锁保护的窄路同一时刻只能有一个线程通行。和全程走大路相比,部分路段只能串行通过,效率自然会更低一些。
- 锁本身同样是共享资源,它负责保护临界资源;可锁自身又该由谁来保护?其实锁并不需要额外保护,因为加锁 lock、解锁 unlock 这两个操作的底层本身就被设计成原子操作。
- 所有线程在访问临界资源时,加锁与解锁的规则必须被所有线程严格遵守,不能有任何例外。不允许出现一部分线程访问临界资源时遵循加锁、解锁流程,而另一部分线程直接访问临界资源的情况。
3. 互斥量实现原理

- 上面是 lock 与 unlock 接口对应的底层汇编指令。为了实现互斥锁操作,大多数体系结构都提供了 exchange 或 swap 指令,该指令的作用是将寄存器中的值与内存单元中的值进行互换。由于它是单条硬件指令,因此保证了操作的原子性。接下来我们就来研究操作系统是如何通过这条指令实现加锁与解锁,从而保证临界区互斥执行的:

-
我们结合 lock 对应的底层汇编指令来分析上图:假如线程 1、2 都要执行临界区代码,线程 1 先竞争到锁。线程执行加锁逻辑时,会先将 al 寄存器的值置为 0,再通过原子交换指令与内存中互斥锁 mutex 的值进行互换。执行后,al 寄存器的值变为 1,即便此时线程 1 被系统切换下线,这个值为 1 的标记也会随线程上下文被保存下来。只有拿到 al=1 的线程,才算加锁成功,可以进入临界区执行后续代码。
-
我们模拟线程 2 执行 lock 函数的过程:线程 2 同样先将 al 寄存器置为 0,再与内存中的 mutex 进行原子交换。此时内存中 mutex 的值已经被线程 1 交换为 0,因此线程 2 交换后,al 寄存器的值仍然是 0。拿到 al=0 代表加锁失败,线程 2 无法进入临界区,会进入阻塞等待状态。只有线程 1 持有有效标记(值为 1),可以正常执行临界区代码。而 unlock 解锁的本质,就是将数值 1 重新写回内存中的 mutex,并唤醒阻塞等待的线程,让它们重新竞争这把锁。
-
加锁本质就是抢内存 mutex 里的 1,解锁就是还这个 1 *。mutex 本来是共享数据,交换的本质就是将这个共享资源变成某个线程的私有数据。
-
我们可以把互斥量理解成一种特殊的二元信号量,它的计数值固定为 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);
}
- 非常的方便,我们只需要在临界区开头创建 LockGuard 对象就好,构造函数会自动调用加锁;当函数退出、对象生命周期结束时,析构函数会自动调用解锁,解锁它会自动完成,完全不需要手动写 unlock。Mutex 类:对原生 pthread_mutex 进行封装,提供加锁、解锁接口。LockGuard 类:利用 C++ 自动析构机制,创建时自动加锁,销毁时自动解锁。
今天的分享就到此结束啦,如果对读者朋友们有所帮助的话,可否留下宝贵的三连呢~~
让我们共同努力, 一起走下去!