个人主页:chian-ocean
文章专栏-Linux
前言:
互斥是并发编程中避免竞争条件和保护共享资源的核心技术。通过使用锁或信号量等机制,能够确保多线程或多进程环境下对共享资源的安全访问,避免数据不一致、死锁等问题。
竞争条件
竞争条件(Race Condition)是并发程序设计中的一个问题,指在多个线程或进程并发执行时,由于它们对共享资源的访问顺序不确定,可能导致程序的输出或行为依赖于执行的顺序,从而产生不一致或不可预测的结果。
例如:一个假脱机打印程序。当一个进程需要打印一个文件时,它将文件名放在一个特殊的假脱机目录**(spoalerdirectory)**下。另一个进程(打印机守护进程)则周期性地检查是否有文件需要打印,若有就打印并将该文件名从目录下删掉)
-
理想情况:
- 设想假脱机目录中有许多槽位,编号依次为
0,1,2,……
,每个槽位存放一个文件名。 - 同时假设有两个共享变量:
out
,指向下一个要打印的文件:in
,指向目录中下一个空闲槽位。 - 可以把这两个变量保存在一个所有进程都
out=4
进程Ain=7
能访问的文件中,该文件的长度为两个字。 - 在某一时刻,进程B号至3号槽位空(其中的文件已经打印完毕),4号至6号槽位被占用(其中存有排好队列的要打印的文件名)。几乎在同时刻,进程A和进程B都决定将一个文件排队打印,这种情图两个进程同时想访问共享内存
- 设想假脱机目录中有许多槽位,编号依次为
-
实际情况:
- 进程A读到
in
的值为7
,将7
存在一个局部变量next_free_slot
中。 - 此时发生一次时钟中断,CPU认为进程A已运行了足够长的时间,决定切换到进程B。进程B也读取
in
,同样得到值为7
,于是将7
存在B的局部变量next_free_slot
中。 - 在这一时刻两个进程都认为下一个可用槽位是
7
.进程B现在继续运行,它将其文件名存在槽位7中并将in
的值更新为8
。然后它离开,继续执行其他操作最后进程A接着从上次中断的地方再次运行。 - 它检查变量
next_free_slot
,发现其值为7,于是将打印文作名存人7号槽位,这样就把进程B存在那里的文件名覆盖掉。然后它将next_free_slot
加1,得到值为8,就将8存到in
中。 - 此时,假脱机目录内部是一致的,所以打印机守护进程发现不了任何错误,但进程B却永远得不到任何打印输出。类似这样的情况,即两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的精确时序,称为竞争条件(race condition)。
- 进程A读到
实际抢票问题:
#include<iostream>
#include<unistd.h>
#include<pthread.h>using namespace std;#define NUM 10 // 定义线程数量,这里创建 10 个线程
int ticket = 1000; // 票数从 1000 开始// 线程执行的函数
void* mythread(void* args)
{pthread_detach(pthread_self()); // 分离线程,线程结束后自动释放资源uint64_t number = (uint64_t)args; // 将传入的参数(线程编号)转换为 uint64_t 类型while(true){if(ticket > 0) // 如果还有票{usleep(1000); // 模拟一些延迟,减少系统负载cout <<"thread: " << number << " ticket: " << ticket << endl; // 打印线程编号和剩余票数ticket--; // 减少票数}else {break; // 如果没有票了,退出循环}usleep(20); // 再次暂停 20 微秒,模拟其他操作}return nullptr; // 线程结束时返回空指针
}int main()
{// 创建 NUM 个线程for(int i = 0; i < NUM; i++){pthread_t tid;pthread_create(&tid,nullptr,mythread,(void*)i); // 创建线程,传入线程编号}sleep(5); // 主线程等待 5 秒,确保子线程有足够的时间执行cout <<"process quit ..." <<endl; // 打印主线程退出消息return 0;
}
简单描述:
- 线程数量和票数:
- 定义了一个全局变量
ticket
,初始值为 1000,表示共有 1000 张票。 - 程序创建了 10 个线程(
NUM = 10
),每个线程将尝试减少ticket
的值,模拟每个线程购买一张票。
- 定义了一个全局变量
- 线程函数:
- 每个线程执行
mythread
函数,函数内部通过一个while
循环不断检查ticket
是否大于 0。如果ticket
大于 0,则线程会输出剩余票数并减去一张票,模拟卖票操作。 - 使用
usleep(1000)
模拟了一个小延迟,避免线程占用过多 CPU 资源,并且增加了另一个小的usleep(20)
让线程执行有一定的间隔。
- 每个线程执行
- 主线程:
- 主线程创建了 10 个线程,并且等待 5 秒后退出,给子线程一些时间执行任务。
潜在问题:
- 竞态条件(Race Condition):
- 问题描述:多个线程同时访问并修改共享资源
ticket
,可能会发生竞态条件。由于ticket--
操作并不是原子的(即分为读取、修改和写入三步),多个线程在同一时间访问ticket
时,可能会同时读取到相同的值并同时更新,导致票数没有正确减少,可能会出现卖出同一张票的情况。 - 解决方案:可以通过互斥锁(
pthread_mutex_t
)来保证每次只有一个线程能修改ticket
,避免并发写入导致的错误。
- 问题描述:多个线程同时访问并修改共享资源
临界区
临界区(Critical Section) 是指在多线程或多进程程序中,共享资源被多个线程或进程同时访问和修改的代码区域。为了确保共享资源在多线程或多进程环境中的一致性和正确性,我们需要对访问临界区的操作进行同步控制,以避免发生竞争条件(Race Condition)。
临界区的特点:
- 共享资源访问:临界区中的代码通常会访问共享资源,例如共享内存、文件、全局变量、硬件资源等。
- 并发执行:多个线程或进程可能同时尝试进入临界区,并对共享资源进行修改。
- 资源竞争:如果多个线程/进程在同一时刻进入临界区并修改共享资源,就可能导致数据冲突、不一致或错误。
临界区的问题:
- 数据一致性问题:多个线程或进程同时修改共享数据,可能导致数据不一致、错误或丢失。
- 资源冲突:当多个线程或进程试图同时访问共享资源时,可能会引发系统资源竞争,影响程序的正确性和效率。
解决方案
- 互斥锁(Mutex): 互斥锁用于确保在某一时刻只有一个线程能够访问临界区。当一个线程需要进入临界区时,它会获取互斥锁,其他线程必须等待该线程释放锁后才能进入临界区。
- 信号量(Semaphore): 信号量可以控制对共享资源的并发访问。通过限制允许访问临界区的线程数量,可以避免过多的线程同时进入临界区。
这样尽管可以避免竞争条件,但是这样不能保证共享数据进行正确高效的协作,还要满足以下4个条件:
- 任何两个进程不能同时处于临界区。
- 不应该对CPU的数量和速度进行任何假设。
- 临界区外的进程不得阻塞其他进程。
- 不得使进程无期限等待进入临界区。
临界区的优化:
- 减少临界区的长度:尽量将临界区的代码量减少到最小,避免过长时间占用临界区。
- 避免不必要的锁:对于只读的共享资源,尽量避免加锁,减少锁带来的性能开销。
- 使用无锁编程(Lock-Free Programming):通过原子操作(如
atomic
类型)和 CAS(Compare-And-Swap)等无锁技术,避免传统锁机制带来的性能瓶颈。
互斥锁
互斥锁(Mutex) 是一种用于多线程编程的同步机制,旨在防止多个线程同时访问和修改共享资源,从而确保数据的一致性和程序的正确性。
互斥锁初始化
- 全局域初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 使用默认属性初始化
- 局部区初始化
pthread_mutex_t mutex; // 定义一个互斥锁变量pthread_mutex_init(&mutex, NULL); // 初始化互斥锁。NULL表示使用默认的属性pthread_mutex_destroy(&mutex); // 销毁互斥锁,在不再使用锁时调用
加锁、解锁
-
pthread_mutex_lock:用于锁定一个互斥锁。若互斥锁已被其他线程锁定,则调用线程会阻塞,直到互斥锁被释放。
-
pthread_mutex_trylock:尝试锁定互斥锁。与
pthread_mutex_lock
不同的是,它不会阻塞线程。如果锁定成功,返回 0;如果锁定失败(即锁已经被其他线程持有),则返回一个非零值。 -
pthread_mutex_unlock:用于解锁一个已锁定的互斥锁。如果当前线程没有持有该锁,调用此函数将导致未定义的行为。
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL); // 初始化互斥锁pthread_mutex_lock(&mutex); // 锁定互斥锁
// 访问共享资源
pthread_mutex_unlock(&mutex); // 解锁互斥锁
优化抢票问题
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 定义并初始化一个互斥锁
#define NUM 10 // 定义创建的线程数量
int ticket = 1000; // 定义一个全局变量 ticket,初始为 1000,表示票的数量// 线程函数,用于模拟每个线程购买票
void* mythread(void* args)
{pthread_detach(pthread_self()); // 将当前线程设置为分离线程,结束后自动回收资源uint64_t number = (uint64_t)args; // 将传入的参数(线程编号)转换为 uint64_t 类型while(true) // 循环,直到票数为 0{{pthread_mutex_lock(&lock); // 锁定互斥锁,确保对 ticket 资源的互斥访问if(ticket > 0) // 如果还有票{usleep(1000); // 模拟工作延迟,单位为微秒(1 毫秒)cout <<"thread: " << number << " ticket: " << ticket << endl; // 输出当前线程编号和剩余票数ticket--; // 票数减少}else // 如果票数为 0,退出循环{break;}pthread_mutex_unlock(&lock); // 解锁,允许其他线程访问 ticket 资源}}return nullptr; // 返回空指针,结束线程
}int main()
{// 创建多个线程for(int i = 0; i < NUM; i++) // 创建 NUM 个线程{pthread_t tid; // 定义线程 IDpthread_create(&tid, nullptr, mythread, (void*)i); // 创建线程并传递参数(线程编号)}sleep(5); // 主线程休眠 5 秒,确保所有线程执行一段时间cout <<"process quit ..." <<endl; // 输出退出信息,表示主进程结束return 0; // 返回 0,程序结束
}
代码详细注释解析:
-
全局变量:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
:定义了一个全局互斥锁,初始化时就已经可用。这个锁是为了防止多个线程同时访问和修改ticket
变量导致的并发问题。#define NUM 10
:定义了一个宏NUM
,表示需要创建的线程数量(此处为 10)。int ticket = 1000;
:全局变量ticket
表示剩余票数,初始值为 1000。
-
线程函数
mythread
:-
pthread_detach(pthread_self());
:将当前线程设置为分离线程,这样线程结束时系统会自动回收资源,无需显式调用pthread_join
来等待线程结束。 -
uint64_t number = (uint64_t)args;
:将传递给线程函数的参数(线程编号)转换为uint64_t
类型,以便进行打印。 -
在
while(true)
循环中,线程将不断检查ticket
是否大于 0:
- 使用
pthread_mutex_lock(&lock);
上锁,防止多个线程同时修改ticket
变量,保证每次只有一个线程能访问和修改票数。 - 如果
ticket > 0
,则输出当前线程的编号和剩余票数,并将票数减 1。每次操作后调用usleep(1000);
来模拟工作延时。 - 如果
ticket
为 0,跳出循环。 - 最后,通过
pthread_mutex_unlock(&lock);
解锁,允许其他线程访问共享资源。
- 使用
-
-
主函数
main
:for
循环中,创建了 10 个线程,每个线程都会执行mythread
函数。线程编号(i
)被传递到每个线程中,作为其唯一标识。sleep(5);
:主线程休眠 5 秒,以确保创建的 10 个子线程有足够的时间执行完毕。cout <<"process quit ..." <<endl;
:输出程序退出信息,表示主程序结束。
打印:
互斥锁封装(RAII)
class mutex
{
public:
private:pthread_mutex_t * _mutex; // 互斥锁指针public:mutex(pthread_mutex_t* mutex):_mutex(mutex) // 构造函数,初始化互斥锁指针{//pthread_mutex_init(_mutex,nullptr); // 互斥锁的初始化被注释掉了}void lock(){pthread_mutex_lock(_mutex); // 锁定互斥锁}void unlock(){pthread_mutex_unlock(_mutex); // 解锁互斥锁}~mutex() {} // 析构函数,什么都不做
};class Guard
{
private:mutex _lock; // 使用上面定义的 mutex 类来管理锁public:Guard(pthread_mutex_t* lock):_lock(lock) // 构造函数中锁定互斥锁{_lock.lock(); // 自动锁定}~Guard() // 析构函数中解锁{_lock.unlock(); // 自动解锁}
};
这段代码的设计实现了一个典型的 RAII(资源获取即初始化) 模式,尤其是在 Guard
类中得到了完美的体现。RAII 是 C++ 中管理资源(如内存、文件句柄、互斥锁等)的一种设计模式。在该模式下,资源在对象的构造函数中获取,在对象的析构函数中释放,这样可以确保即使发生异常,也能正确释放资源,避免资源泄漏和死锁。
// 构造函数中锁定互斥锁
{
_lock.lock(); // 自动锁定
}
~Guard() // 析构函数中解锁
{_lock.unlock(); // 自动解锁
}
};
这段代码的设计实现了一个典型的 **RAII(资源获取即初始化)** 模式,尤其是在 `Guard` 类中得到了完美的体现。RAII 是 C++ 中管理资源(如内存、文件句柄、互斥锁等)的一种设计模式。在该模式下,资源在对象的构造函数中获取,在对象的析构函数中释放,这样可以确保即使发生异常,也能正确释放资源,避免资源泄漏和死锁。