探索Linux互斥:线程安全与资源共享

发布于:2025-05-27 ⋅ 阅读:(45) ⋅ 点赞:(0)

个人主页:chian-ocean

文章专栏-Linux

前言:

互斥是并发编程中避免竞争条件和保护共享资源的核心技术。通过使用锁或信号量等机制,能够确保多线程或多进程环境下对共享资源的安全访问,避免数据不一致、死锁等问题。

在这里插入图片描述

竞争条件

竞争条件(Race Condition)是并发程序设计中的一个问题,指在多个线程或进程并发执行时,由于它们对共享资源的访问顺序不确定,可能导致程序的输出或行为依赖于执行的顺序,从而产生不一致或不可预测的结果。

例如:一个假脱机打印程序。当一个进程需要打印一个文件时,它将文件名放在一个特殊的假脱机目录**(spoalerdirectory)**下。另一个进程(打印机守护进程)则周期性地检查是否有文件需要打印,若有就打印并将该文件名从目录下删掉)

在这里插入图片描述

  • 理想情况

    1. 设想假脱机目录中有许多槽位,编号依次为0,1,2,……,每个槽位存放一个文件名。
    2. 同时假设有两个共享变量:out,指向下一个要打印的文件:in,指向目录中下一个空闲槽位。
    3. 可以把这两个变量保存在一个所有进程都out=4进程Ain=7能访问的文件中,该文件的长度为两个字。
    4. 在某一时刻,进程B号至3号槽位空(其中的文件已经打印完毕),4号至6号槽位被占用(其中存有排好队列的要打印的文件名)。几乎在同时刻,进程A进程B都决定将一个文件排队打印,这种情图两个进程同时想访问共享内存
  • 实际情况

    1. 进程A读到 in 的值为7,将7存在一个局部变量 next_free_slot中。
    2. 此时发生一次时钟中断,CPU认为进程A已运行了足够长的时间,决定切换到进程B。进程B也读取in,同样得到值为7,于是将7存在B的局部变量next_free_slot中。
    3. 在这一时刻两个进程都认为下一个可用槽位是7.进程B现在继续运行,它将其文件名存在槽位7中并将in的值更新为8。然后它离开,继续执行其他操作最后进程A接着从上次中断的地方再次运行。
    4. 它检查变量 next_free_slot,发现其值为7,于是将打印文作名存人7号槽位,这样就把进程B存在那里的文件名覆盖掉。然后它将 next_free_slot加1,得到值为8,就将8存到in中。
    5. 此时,假脱机目录内部是一致的,所以打印机守护进程发现不了任何错误,但进程B却永远得不到任何打印输出。类似这样的情况,即两个或多个进程读写某些共享数据,而最后的结果取决于进程运行的精确时序,称为竞争条件(race condition)。

实际抢票问题

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

简单描述:

  1. 线程数量和票数
    • 定义了一个全局变量 ticket,初始值为 1000,表示共有 1000 张票。
    • 程序创建了 10 个线程(NUM = 10),每个线程将尝试减少 ticket 的值,模拟每个线程购买一张票。
  2. 线程函数
    • 每个线程执行 mythread 函数,函数内部通过一个 while 循环不断检查 ticket 是否大于 0。如果 ticket 大于 0,则线程会输出剩余票数并减去一张票,模拟卖票操作。
    • 使用 usleep(1000) 模拟了一个小延迟,避免线程占用过多 CPU 资源,并且增加了另一个小的 usleep(20) 让线程执行有一定的间隔。
  3. 主线程
    • 主线程创建了 10 个线程,并且等待 5 秒后退出,给子线程一些时间执行任务。

在这里插入图片描述

潜在问题:

  1. 竞态条件(Race Condition)
    • 问题描述:多个线程同时访问并修改共享资源 ticket,可能会发生竞态条件。由于 ticket-- 操作并不是原子的(即分为读取、修改和写入三步),多个线程在同一时间访问 ticket 时,可能会同时读取到相同的值并同时更新,导致票数没有正确减少,可能会出现卖出同一张票的情况。
    • 解决方案:可以通过互斥锁(pthread_mutex_t)来保证每次只有一个线程能修改 ticket,避免并发写入导致的错误。

临界区

临界区(Critical Section) 是指在多线程或多进程程序中,共享资源被多个线程或进程同时访问和修改的代码区域。为了确保共享资源在多线程或多进程环境中的一致性和正确性,我们需要对访问临界区的操作进行同步控制,以避免发生竞争条件(Race Condition)。

临界区的特点:

  1. 共享资源访问:临界区中的代码通常会访问共享资源,例如共享内存、文件、全局变量、硬件资源等。
  2. 并发执行:多个线程或进程可能同时尝试进入临界区,并对共享资源进行修改。
  3. 资源竞争:如果多个线程/进程在同一时刻进入临界区并修改共享资源,就可能导致数据冲突、不一致或错误。

临界区的问题:

  • 数据一致性问题:多个线程或进程同时修改共享数据,可能导致数据不一致、错误或丢失。
  • 资源冲突:当多个线程或进程试图同时访问共享资源时,可能会引发系统资源竞争,影响程序的正确性和效率。

解决方案

  • 互斥锁(Mutex): 互斥锁用于确保在某一时刻只有一个线程能够访问临界区。当一个线程需要进入临界区时,它会获取互斥锁,其他线程必须等待该线程释放锁后才能进入临界区。
  • 信号量(Semaphore): 信号量可以控制对共享资源的并发访问。通过限制允许访问临界区的线程数量,可以避免过多的线程同时进入临界区。

这样尽管可以避免竞争条件,但是这样不能保证共享数据进行正确高效的协作,还要满足以下4个条件:

  1. 任何两个进程不能同时处于临界区。
  2. 不应该对CPU的数量和速度进行任何假设。
  3. 临界区外的进程不得阻塞其他进程。
  4. 不得使进程无期限等待进入临界区。

临界区的优化:

  1. 减少临界区的长度:尽量将临界区的代码量减少到最小,避免过长时间占用临界区。
  2. 避免不必要的锁:对于只读的共享资源,尽量避免加锁,减少锁带来的性能开销。
  3. 使用无锁编程(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;  // 定义线程 ID
        pthread_create(&tid, nullptr, mythread, (void*)i);  // 创建线程并传递参数(线程编号)
    }

    sleep(5);  // 主线程休眠 5 秒,确保所有线程执行一段时间
    cout <<"process quit ..." <<endl;  // 输出退出信息,表示主进程结束
    
    return 0;  // 返回 0,程序结束
}

代码详细注释解析:

  1. 全局变量

    • pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;:定义了一个全局互斥锁,初始化时就已经可用。这个锁是为了防止多个线程同时访问和修改 ticket 变量导致的并发问题。
    • #define NUM 10:定义了一个宏 NUM,表示需要创建的线程数量(此处为 10)。
    • int ticket = 1000;:全局变量 ticket 表示剩余票数,初始值为 1000。
  2. 线程函数 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); 解锁,允许其他线程访问共享资源。
  3. 主函数 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++ 中管理资源(如内存、文件句柄、互斥锁等)的一种设计模式。在该模式下,资源在对象的构造函数中获取,在对象的析构函数中释放,这样可以确保即使发生异常,也能正确释放资源,避免资源泄漏和死锁。