目录
0.前言
1.相关概念
2.互斥量(mutex)
2.1 代码引入
2.2为什么需要互斥量
2.3互斥量的接口
2.3.1 初始化互斥量
2.3.2 销毁互斥量
2.3.3 互斥量加锁和解锁
2.4改写代码
3.互斥量的封装
4.小结
(图像由AI生成)
0.前言
在多线程编程中,线程之间的并发操作可能会导致共享资源的竞争问题,如数据不一致、状态紊乱等。为了保证程序的正确性和稳定性,必须引入线程同步机制,其中互斥量(mutex)是解决线程互斥的核心工具。本篇博客承接前文关于进程的讨论,深入介绍线程互斥的相关概念、实现方法以及代码实例,帮助理解如何在 Linux 环境下有效避免线程竞争问题。
1.相关概念
- 临界资源:指多个线程需要共享访问的资源,例如全局变量、文件或数据库连接等。如果多个线程同时操作临界资源,可能会导致数据不一致或冲突。
- 临界区:指访问临界资源的代码片段。为防止多个线程同时进入临界区,需要对其进行保护,确保同一时刻只有一个线程可以执行临界区代码。
- 互斥:一种线程同步机制,用于确保多个线程对临界资源的访问是互斥的,即同一时间仅允许一个线程访问共享资源。互斥量(mutex)是实现互斥的常用工具。
- 原子性:指某个操作不可被中断,要么完全执行完毕,要么完全不执行。在多线程环境下,原子性是实现线程安全的基本要求之一。
2.互斥量(mutex)
互斥量是一种线程同步机制,用于解决多线程并发访问共享资源时的冲突问题。在多线程编程中,互斥量通过对临界区的加锁和解锁,确保同一时刻只有一个线程可以访问共享资源,从而避免数据竞争。
2.1 代码引入
在大多数情况下,线程使用的数据是局部变量,变量的地址空间位于线程栈空间内,仅属于单个线程,其他线程无法访问。但在某些场景下,线程之间需要共享数据,这些变量称为共享变量,通过它们可以完成线程间的交互。
然而,当多个线程并发操作共享变量时,会导致数据不一致等问题。例如,一个典型的问题是多个线程争夺共享资源时的竞争。以下以“抢票”为例,展示未加锁的多线程争夺资源代码:
未加锁的多线程代码示例:
#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;
}
程序输出(部分):
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为什么需要互斥量
在多线程编程中,当多个线程并发访问共享资源时,如果没有同步机制进行保护,就会导致数据竞争和资源冲突等问题。以下是未加锁情况下上面的代码出现的问题:
-
票号重复销售:
多个线程同时读取共享变量ticket
的值,导致同一票号被多个线程同时销售。例如:thread 2 sells ticket: 100 thread 1 sells ticket: 100 thread 3 sells ticket: 100
这是因为
ticket
的读取和更新是分步骤完成的,线程在切换时导致了数据的不一致。 -
超卖现象:
由于多个线程同时修改ticket
的值,可能导致最终结果错误,甚至出现负值。例如:thread 1 sells ticket: -1 thread 3 sells ticket: -2
这种现象表明线程在操作过程中缺乏有效的同步机制,无法确保共享变量的正确性。
-
数据竞争:
ticket--
是非原子操作,分为读取值、修改值和写回值三个步骤。在多线程环境下,这些步骤可能被其他线程的操作打断,导致多个线程同时更新变量的值,破坏数据一致性。
多线程编程中的共享资源竞争是导致数据不一致的主要原因。以抢票系统中的 --ticket
操作为例,尽管它看似简单,但实际上并非原子操作,而是由多条汇编指令组成的复杂过程。
--ticket 的汇编代码
通过 objdump
工具反汇编程序,我们可以看到 --ticket
的具体汇编指令:
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) # 将新值写回共享变量的内存地址
这三条指令的含义分别是:
- load: 将共享变量
ticket
的值从内存加载到寄存器。 - update: 在寄存器中执行
-1
操作,更新值。 - store: 将更新后的值写回共享变量的内存地址。
问题所在:
由于 --ticket
涉及三步操作,如果线程在任意步骤被中断,另一个线程可能会修改 ticket
,导致数据竞争。例如:
- 线程 A 从内存读取
ticket = 100
,还未更新,线程 B 也读取了ticket = 100
。 - 两个线程都执行了
ticket--
操作,结果是ticket = 99
,实际减少了一张票而非两张。
这种数据不一致问题会引发票号重复销售和超卖现象,根本原因是 --ticket
不是原子操作。
如何解决这些问题?
为了解决共享资源的竞争问题,需要满足以下三点:
- 互斥行为: 当一个线程进入临界区执行代码时,其他线程必须被阻止进入临界区。
- 独占访问: 如果多个线程同时请求进入临界区,且临界区没有线程在执行,则仅允许一个线程进入。
- 非阻塞: 如果某线程不在临界区内执行,则不能阻止其他线程进入临界区。
这些条件的核心要求是一把锁,而 Linux 系统中提供的这把锁就是互斥量(mutex)。
互斥量的作用:
互斥量通过加锁(pthread_mutex_lock
)和解锁(pthread_mutex_unlock
),实现对临界区的独占访问:
- 加锁: 线程在访问共享资源前需要获得锁,如果其他线程已经持有锁,则当前线程会阻塞。
- 解锁: 线程在完成共享资源操作后释放锁,其他阻塞线程才可以继续执行。
通过互斥量,--ticket
的多条汇编指令可以被视为一个原子操作,从而避免数据竞争,确保程序的正确性和线程安全。
2.3互斥量的接口
在多线程编程中,互斥量(mutex
)提供了一种机制来确保共享资源的安全访问。以下介绍互斥量的核心操作接口。
2.3.1 初始化互斥量
互斥量在使用前需要进行初始化,主要有两种方法:
方法1:静态分配
通过宏 PTHREAD_MUTEX_INITIALIZER
初始化互斥量,适用于全局或静态互斥量:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
这种方式简单直接,适合在程序启动时确定的互斥量。
方法2:动态分配
通过函数 pthread_mutex_init
动态初始化互斥量,适用于动态创建的互斥量:
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL); // 动态初始化
- 参数说明:
mutex
:指向需要初始化的互斥量。attr
:互斥量属性,一般传NULL
表示使用默认属性。
示例代码:
pthread_mutex_t mutex; pthread_mutex_init(&mutex, NULL); // 动态初始化
2.3.2 销毁互斥量
互斥量使用完成后,需通过 pthread_mutex_destroy
释放资源:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 注意事项:
- 使用
PTHREAD_MUTEX_INITIALIZER
初始化的互斥量不需要显式销毁。 - **不要销毁一个已经加锁的互斥量,**否则可能导致程序崩溃或行为异常。
- **确保销毁后的互斥量不再被使用,**避免线程尝试加锁销毁的互斥量。
- 使用
示例代码:
pthread_mutex_destroy(&mutex);
2.3.3 互斥量加锁和解锁
加锁
使用 pthread_mutex_lock
对互斥量加锁:
int pthread_mutex_lock(pthread_mutex_t *mutex);
- 行为:
- 如果互斥量处于未锁状态,调用线程会成功加锁并继续执行。
- 如果互斥量已被其他线程锁定,调用线程会阻塞,等待互斥量解锁。
解锁
使用 pthread_mutex_unlock
对互斥量解锁:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 解锁后,其他等待的线程将有机会获得锁。
返回值:
- 成功返回
0
。 - 失败返回错误号(例如尝试解锁未加锁的互斥量)。
示例代码:
pthread_mutex_lock(&mutex);
// 临界区代码
pthread_mutex_unlock(&mutex);
2.4改写代码
在 2.1 的示例代码中,由于 ticket--
操作不是原子操作,导致出现数据竞争和不一致的问题。通过引入互斥量(mutex),可以确保对共享资源 ticket
的访问具有互斥性,从而解决上述问题。
以下是改写后的代码,使用互斥量实现线程安全:
#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 风格实现自动化管理。
#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; // 引用封装的互斥量
};
}
封装的核心思想
-
Mutex
类:- 封装了
pthread_mutex_t
的操作,包括初始化、加锁、解锁和销毁。 - 禁止拷贝构造和赋值,避免多次操作同一个互斥量。
- 提供获取原始互斥量指针的方法,以便在某些特殊场景中直接操作底层互斥量。
- 封装了
-
LockGuard
类:- 采用 RAII(Resource Acquisition Is Initialization)风格,通过构造函数加锁,析构函数解锁,实现自动化管理。
- 避免手动解锁可能导致的遗漏问题。
4.小结
线程间的共享资源竞争是多线程编程中的核心问题,互斥量(mutex)提供了一种高效的解决方案。通过本篇博客,我们从互斥量的基础概念入手,详细介绍了其初始化、加锁解锁操作,以及如何通过封装实现更安全和高效的资源管理。通过互斥量,我们可以确保临界区操作的线程安全性,避免数据竞争和资源冲突,为构建健壮的多线程应用奠定基础。