跳到主要内容Linux 互斥锁原理与 C++ RAII 封装实战 | 极客日志C++算法
Linux 互斥锁原理与 C++ RAII 封装实战
本文介绍了 Linux 多线程环境下共享资源的互斥机制。阐述了临界区、原子性及数据不一致问题的成因,通过代码示例对比了未加锁与加锁后的线程行为。深入解析了互斥锁的软件与硬件实现原理,包括 swap 指令与寄存器交换过程。最后展示了如何使用 pthread 接口及 C++ RAII 风格封装互斥锁,利用 LockGuard 类简化资源管理,避免手动解锁导致的死锁风险,提升代码安全性与可维护性。

一、互斥概念
共享资源:多个线程共有的资源。
临界资源:多线程执行流被保护的共享资源,称为临界资源。
临界区:每个线程内部,访问临界资源的代码,称为临界区。
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
问题:一个进程内部的多个线程中,因为所有的线程共享地址空间,进程资源大部分都会被线程共享!如果多个线程同时访问共享资源呢?
答:首先多个线程访问的共享资源如果是栈资源,那么不会出现什么问题,因为各个线程都有各自的线程栈。但是如果这个资源不是栈资源,例如显示屏,多个线程向显示屏打印数据,那么一定会导致打印的数据错乱,这就是数据不一致问题,解决办法是线程同步与互斥。
数据不一致问题的本质就是多个执行流(线程或者进程)访问共享资源导致的。为了解决这个问题,我们就得想办法把共享资源变成临界资源,而把共享资源变成临界资源的本质就是保护临界区(保护访问共享资源的代码)。怎么保护?使用互斥来保护。互斥就是多个执行流访问共享资源时,只让一个线程去访问,其他线程阻塞等待,当前面的线程访问结束,再让下一个线程来访问,这个过程就跟去银行排队取钱一样。那么要实现互斥的效果,就得使用到锁。

未对共享资源进行保护的情况:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 1000;
void *route(void *arg) {
char *id = (char *)arg;
while () {
(ticket > ) {
();
(, id, ticket);
ticket--;
}
{
;
}
}
;
}
{
t1, t2, t3, t4;
(&t1, , route, ( *));
(&t2, , route, ( *));
(&t3, , route, ( *));
(&t4, , route, ( *));
(t1, );
(t2, );
(t3, );
(t4, );
;
}
微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown 转 HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
- HTML 转 Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
1
if
0
usleep
1000
printf
"%s sells ticket:%d\n"
else
break
return
nullptr
int main()
pthread_t
pthread_create
NULL
void
"thread 1"
pthread_create
NULL
void
"thread 2"
pthread_create
NULL
void
"thread 3"
pthread_create
NULL
void
"thread 4"
pthread_join
NULL
pthread_join
NULL
pthread_join
NULL
pthread_join
NULL
return
0
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 1000;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *route(void *arg) {
char *id = (char *)arg;
while (1) {
pthread_mutex_lock(&mutex);
if (ticket > 0) {
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
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;
}
问题:加锁的原则问题(就是在哪个位置加锁,哪个位置解锁)
答:首先如果没有锁,多个线程并发执行,但是加锁之后,只允许一个一个的线程执行,所以加锁必然会导致效率的降低,进而我们加锁的粒度(位置),必须要足够细!
问题:在上面的代码中,mutex 是一个共享资源,它保护别人,谁来保护它?
问题:有 10 个进程,6 个进程加锁访问共享资源,其他 4 不加锁访问共享资源,这样行吗?
答:访问临界资源,所有的线程必须遵守加锁和解锁规则,不能有例外。
答:10 个线程去访问临界资源,只有一把锁,10 个线程去抢这一把锁。例如:1 号线程拿到这把锁,它就能去访问临界资源,其余没抢到这把锁的线程,就在锁上阻塞等待,等到 1 号线程访问临界结束之后,解锁之后,其余的线程再抢这把锁。因为没有抢到这把锁的线程只关心抢到这把锁的线程的加锁前、解锁后,不关心抢到这把锁的线程干了些什么,所以加锁是会保证原子性的。其实原子也体现到汇编语句上,站在 CPU 的视角,要么执行完了这条语句,要么没执行。
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
mutex:指向待初始化的互斥锁对象指针
attr:互斥锁属性(如共享属性、类型等),传入 NULL 表示使用默认属性
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数 mutex:指向待销毁的互斥锁对象指针
返回值:成功返回 0,失败返回非 0 :错误码
注意:销毁前需确保互斥锁处于解锁状态,且无线程正在等待该锁,否则会导致未定义行为。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <iostream>
#include <string>
#include <pthread.h>
int ticket = 1000;
class thread_data {
public:
thread_data(const std::string& n, pthread_mutex_t* p) : name(n), pmutex(p) {}
public:
std::string name;
pthread_mutex_t* pmutex;
};
void *route(void *arg) {
thread_data* td = static_cast<thread_data*>(arg);
while (1) {
pthread_mutex_lock(td->pmutex);
if (ticket > 0) {
usleep(1000);
printf("%s sells ticket:%d\n", td->name.c_str(), ticket);
ticket--;
pthread_mutex_unlock(td->pmutex);
}
else {
pthread_mutex_unlock(td->pmutex);
break;
}
}
return nullptr;
}
int main() {
pthread_t t1, t2, t3, t4;
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, nullptr);
thread_data m1("thread 1", &mutex), m2("thread 2", &mutex), m3("thread 3", &mutex), m4("thread 4", &mutex);
pthread_create(&t1, NULL, route, (void *)&m1);
pthread_create(&t2, NULL, route, (void *)&m2);
pthread_create(&t3, NULL, route, (void *)&m3);
pthread_create(&t4, NULL, route, (void *)&m4);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}

二、互斥锁的原理
为了更好的理解软件实现锁的原理,我们先聊一下硬件实现锁的原理:首先,多线程出现并发访问共享资源导致数据不一致的问题,本质是线程切换导致。那么如果我们不让线程切换,让当前线程跑完,就能实现锁,所以在硬件层面,我们只要屏蔽时钟中断,让线程跑完,跑完之后再恢复时钟中断就行,这样就在硬件层面上实现了锁。
经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的(i++ 或者 ++i 的汇编语句至少三条,不能保证该操作是原子,进而有可能出现数据不一致问题),有可能会有数据一致性问题。
为了实现互斥锁操作,大多数体系结构都提供了 swap 或 exchange 指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。现在我们把 lock 和 unlock 的伪代码改一下。
我们定义的锁的本质就是一个变量,它里面存储的就是 1;我们一般的寄存器以 32 位系统为例,刚开始是例如英特尔一般都是 16 位的,后来要把寄存器扩展到 32 位,同时要兼容之前的 16 位,那么他把寄存器分成两部分:低 16 位叫 al,高 16 位叫 aH。
就把 al 这个寄存器的值和内存里 mutex 的值进行交换:
此时执行线程的 CPU 里面的 al 寄存器里面的值为 1,那么表示加锁成功,否则就挂起等待;直到有线程解锁,然后重复上面的工作。
1)CPU 在调度线程时,是以线程为载体执行的加载逻辑
2)CPU 内的寄存器只有一套,但是寄存器里面的数据,可以有多份;也就是说:寄存器 != 寄存器里面的数据
3)内存中的变量,被线程共享,只要拿到地址。
结论:那么把内存中变量的值交换到 CPU 的内部中,本质就是把这个共享数据变成某个线程的私有数据。
问题:在加锁的过程中会造成多线程的并发问题,进而导致数据不一致问题吗?
答:假设有两个线程:A,B,无论哪个线程执行加锁的第一行汇编时,都不会有什么并发问题,所以我们主要讨论的是第二行汇编。当线程 A 执行加锁汇编,执行到:
如果此时线程 A 被切走时,因为线程调度是要保留上下文数据的,而 al 寄存器里面的内容也属于线程 A 的数据所以,线程 A 就要带走他的上下文数据:
此时轮到线程 B 执行,那么线程 B 进行加锁汇编时:
因为内存里面的 mutex 的值以及被线程 A 带走了,所以线程 B 执行这个汇编,无法获取锁,例如:
上面这个图片是线程 B 执行加锁的第一行汇编,此时再执行第二行汇编:
交换寄存器 al 的内容和 mutex 的值,因为此时 al 寄存器里面的内容和 mutex 的值都为 0,所以执行这个汇编无实际效果:
所以线程 B 只能挂起等待。等到线程 A 解锁之后,线程 B 才能执行。
结论:锁就是内存中 mutex 里面的值:1,因为 exchange 方式,没有拷贝,所以至始至终只有一个 1,谁有这个 1,谁就拥有锁。所谓竞争锁就是竞争执行 exchange。
接下来讨论一下解锁过程,一句话搞定:既然要解锁,那肯定是拥有锁的线程进行解锁操作:
解锁就把寄存器 al 里面的 1,还给内存中的 mutex:
问题:如果 CPU 执行到某个线程的加锁之后,解锁之前的代码,此时这个线程会被切换吗?切换了会不会造成数据不一致问题?
答:可以被切换,不会造成数据不一致问题;拥有既然没有解锁,那么这个线程被切换,那么 CPU 里面的寄存器 al 里面的值:1,就会被带走,所以只要这个线程不解锁,那么其他线程永远都不能访问临界资源。
互斥锁的本质就是 mutex 的值由 1 变成了 0,互斥的本质就是独占,独占的本质就是我们可以认为临界资源只有一份,谁拿到这个 1 谁就能拥有这份资源。我们可以把互斥锁理解成一个信号量,只不过这个信号量的值为 1,表示一个资源。加锁的本质就是对资源的预订,不过该线程有没有访问这个临界资源,其他线程就是不能访问这份临界资源,直到该线程解锁。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <iostream>
#include <string>
#include <pthread.h>
#include <mutex>
int ticket = 1000;
std::mutex lock;
class thread_data {
public:
thread_data(const std::string& n, pthread_mutex_t* p) : name(n), pmutex(p) {}
public:
std::string name;
pthread_mutex_t* pmutex;
};
void *route(void *arg) {
while (1) {
lock.lock();
if (ticket > 0) {
usleep(1000);
printf("sells ticket:%d\n", ticket);
ticket--;
lock.unlock();
}
else {
lock.unlock();
break;
}
}
return nullptr;
}
int main() {
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void *)nullptr);
pthread_create(&t2, NULL, route, (void *)nullptr);
pthread_create(&t3, NULL, route, (void *)nullptr);
pthread_create(&t4, NULL, route, (void *)nullptr);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
return 0;
}
三、使用 C++ 封装 Linux 的锁、RAII 风格的锁
#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 {
public:
LockGuard(Mutex& lock) : _lockref(lock) {
_lockref.Lock();
}
~LockGuard() {
_lockref.Unlock();
}
private:
Mutex& _lockref;
};
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>
#include "Mutex.hpp"
int ticket = 1000;
Mutex lock;
void *route(void *arg) {
char *id = (char *)arg;
while (1) {
{
LockGuard lockguard(lock);
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;
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;
}