Linux

关注公众号 jb51net

关闭
首页 > 网站技巧 > 服务器 > Linux > Linux线程同步/互斥

Linux线程同步/互斥过程详解

作者:QQ_437664314

文章讲解多线程并发访问导致竞态条件,需通过互斥锁、原子操作和条件变量实现线程安全与同步,分析死锁条件及避免方法,并介绍RAII封装技术提升资源管理效率

01. 资源共享问题

1.1 多线程并发访问

例: 初始状态counter=0,线程 1 和 2 各自都执行counter++操作

要想对counter++做修改,在底层被编译成三条机器指令:

  1. 从内存加载counter的值到寄存器(LOAD)
  2. 寄存器中的值加1(ADD)
  3. 将寄存器的值写回内存(STORE)

假设counter初始值为0,在两个线程同时执行的时候,可能出现下面这种情况。以至于多线程场景中对全局变量并发访问不是 100%可靠的。

线程 1 执行

线程 2 执行

线程 1 恢复执行

最终结果counter=1(预期应为 2)。

1.2 临界区与临界资源

1.3 锁的引入

对于临界资源访问时的安全问题,也可以通过加锁来保证,实现多线程间的互斥访问,互斥锁就是解决多线程并发访问方法之一。

我们可以在线程1进入临界区之前加锁,出临界区之后解锁, 这样可以确保并发访问临界资源时的线性进行,若线程1在对共享资源进行操作时被切换成线程2,线程2也只能阻塞等待解锁。

注:

02. 多线程案例

2.1 为什么线程需要互斥?

当多个线程同时访问共享资源时,可能导致竞态条件,造成数据不一致或程序异常。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。而多个线程并发的操作共享变量,会带来一些问题。线程互斥机制确保在任何时刻只有一个线程能访问共享资源。

#include <stdio.h>
#include <pthread.h>
int counter = 0;
void* increment(void* arg) {
    for (int i = 0; i < 100000; i++) {
        counter++; // 非原子操作}
    return NULL;}
int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    // 理论结果为:200000
    printf("Final counter: %d\n", counter); // 实际输出通常小于200000
    return 0;
}

在上面代码里面,我们知道counter是临界资源,而increment函数是访问临界资源的代码,亦称为临界区。

理想状态下是希望两个线程分别对counter100000次。但是由由于非原子操作内存可见性问题,当两个线程同时执行这些指令,可能会出现指令交错,导致最终结果通常会小于预期200000

要解决以上问题,需要做到三点:

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量

2.2 线程或进程切换时机?

  1. 时间片耗尽时
  2. 有更高优先级的进程要调度时
  3. 通过sleep,从内核返回用户时,会进行时间片是否到达的检测,进而导致切换

如果锁对象是全局的或静态的,可以用宏:PTHREAD_MUTEX_INITIALIZER初始化,并且不用我们主动destroy;如果锁对象是局部的,需要用pthread_mutex_init初始化,用pthread_mutex_destroy释放。

  1. 所有对资源的保护,都是对临界区代码的访问,因为资源都是通过代码访问的。
  2. 要保证加锁的细粒度。
  3. 加锁就是找到临界区,对临界区进行加锁。

那么相应的又有一些问题:

03. 线程互斥

3.1 互斥锁操作

有以下特点

// 初始化(静态)
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 初始化(动态)
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
// 加锁/解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
// 销毁
int pthread_mutex_destroy(pthread_mutex_t *mutex);

3.2代码互斥问题优化

通过对上面代码进行改进,我们便可以得到正确的结果。

细节: 互斥会给其他线程带来影响

当某个线程持有[锁资源】 时,对于其他线程的有意义的状态:在这两种状态的划分下,确保了多线程并发访问时的 原子性

3.3 互斥锁原理

lock是原子的,其他线程无法进入。 为了实现互斥锁操作,大多数体系结构都提供了swapexchange指令,该指令的作用是把寄存器和内存单元的数据交换(私有和共享),由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

3.4 多线程封装

着手编写一个小组件: Demo 版线程库目标:对 原生线程库 提供的接口进行封装,进一步提高对线程相关接口的熟练程度既然是封装,这里的类成员包括:

3.4.1 thread.hpp编写

#pragma once

#include <iostream>
#include <string>
#include <pthread.h>
#include <cassert>

// 参数、返回值为 void 的函数类型
typedef void *(*func_t)(void *);
const int num = 1024;
class Thread
{
public:
    Thread(func_t func, void *args = nullptr, int number = 0)
        : _func(func), _args(args)
    {
        // 根据编号写入名字
        char buf[128];
        snprintf(buf, sizeof buf, "thread-%d", num);
        _name = buf;
        int n = pthread_create(&_tid, nullptr, runHelper, this); // this->Thread*
        assert(n == 0);
        (void)n;
    }
    // 回调方法
    static void *runHelper(void *args)
    {
        Thread *_this = static_cast<Thread *>(args);
        return _this->callback();
    }
    // 获取 ID
    pthread_t getTID() const
    {
        return _tid;
    }
    // 获取线程名
    std::string getName() const
    {
        return _name;
    }
    // 启动线程
    void run()
    {
        int ret = pthread_create(&_tid, nullptr, runHelper, this );//this 是一个指向当前类类型的常量指针
        if (ret != 0)
        {
            std::cerr << "create thread fail!" << std::endl;
            exit(1); // 创建线程失败,直接退出
        }
    }
    // 线程等待
    void join()
    {
        int ret = pthread_join(_tid, nullptr);
        if (ret != 0)
        {
            std::cerr << "thread join fail!" << std::endl;
            exit(1); // 等待失败,直接退出
        }
    }
    void *callback()
    { // 亦指在外调用的线程处理函数,_args与是否返回值有关
        return _func(_args);
    }

private:
    pthread_t _tid;    // 线程 ID
    std::string _name; // 线程名
    func_t _func;      // 线程回调函数
    void *_args;       // 传递给回调函数的参数
};

测试代码:

#include "thread.hpp"
// 1:线程创建和运行
void *basic_task(void *arg){
    int *val = static_cast<int *>(arg);
    std::cout << "线程正在运行,初始值为: " << *val << std::endl;
    *val *= 2; // 修改传入的值
    return nullptr;}
// 2:带返回值
void *task_with_return(void *arg){
    std::string *msg = new std::string("Hello!");
    return msg;}
int main(){{
        int value = 42;
        Thread t1(basic_task, &value);
        t1.join();
        std::cout << "修改后旳值为: " << value << std::endl; // 应该输出84}
    std::cout << "---------------: " << std::endl;{
        Thread t2(task_with_return);
        void *ret_val = nullptr;
        pthread_join(t2.getTID(), &ret_val); // 直接使用pthread_join获取返回值
        if (ret_val){
            std::string *msg = static_cast<std::string *>(ret_val);
            std::cout << *msg << std::endl; // 输出线程返回的消息
            delete msg;                     // 记得释放内存
        }}  return 0;}

结果如下:

3.5 互斥锁封装

我们对锁进行封装,实现一个简单易用的小组件。利用创建对象时调用构造函数,对象生命周期结束时调用析构函数的特点,融入加锁、解锁等操作。更加方便

#pragma once
#include <iostream>
#include <pthread.h>
class Mutex
{
public:
    Mutex(const Mutex &) = delete;
    const Mutex &operator=(const Mutex &) = delete;
    Mutex(){
        int n = pthread_mutex_init(&_lock, nullptr);
    }
    void Lock(){
        int n = pthread_mutex_lock(&_lock);
    }
    void Unlock(){
        int n = pthread_mutex_unlock(&_lock);
    }
    pthread_mutex_t *LockPtr() { return &_lock; }
    ~Mutex(){
        int n = pthread_mutex_destroy(&_lock);
    }

private:
    pthread_mutex_t _lock;
};
class LockGuard{
public:
    LockGuard(Mutex &mutex)
        : _mutex(mutex){
        _mutex.Lock();
    }
    ~LockGuard(){
        _mutex.Unlock();
    }
private:
    Mutex &_mutex; // 在该类下面定义了一个Mutex类型的引用成员变量,_mutex为变量名
};

3.5.1 RAII风格

像这种获取资源即初始化的风格称为RAII风格,非常巧妙的运用了类和对象的特性,实现半自动化操作。

04. 线程同步

当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。例如:一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。

同步概念与竞态条件:

4.1 死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。

4.1.1 死锁四个必要条件

4.1.2 避免死锁

4.1.3 避免死锁算法

4.2 条件变量

条件变量是线程同步的高级机制,用于解决"等待特定条件成立"的场景。它总是与互斥锁配合使用,实现高效的线程等待-通知机制。有以下特点

操作代码:

// 初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

// 等待条件满足(自动释放关联互斥锁)
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

// 通知条件
int pthread_cond_signal(pthread_cond_t *cond);      // 唤醒一个线程
int pthread_cond_broadcast(pthread_cond_t *cond);   // 广播。。唤醒所有线程

可以把条件变量看作一个结构体,其中包含一个队列结构,用来存储正在排队等候的线程信息,当条件满足时,就会取 队头 线程进行操作,操作完成后重新进入队尾。后续基于此实现生产者-消费者模型。

简单使用示例:

#include <pthread.h>
#include <stdio.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int data_ready = 0;  // 共享条件

void* consumer(void* arg) {
    pthread_mutex_lock(&mutex);
    while (data_ready == 0) {
        printf("Consumer: Waiting...\n");
        pthread_cond_wait(&cond, &mutex); // 阻塞并释放锁
    }
    printf("Consumer: Processing data.\n");
    data_ready = 0;
    pthread_mutex_unlock(&mutex);
    return NULL;
}

void* producer(void* arg) {
    sleep(1); // 模拟数据准备时间
    pthread_mutex_lock(&mutex);
    printf("Producer: Data ready.\n");
    data_ready = 1;
    pthread_cond_signal(&cond); // 唤醒消费者
    pthread_mutex_unlock(&mutex);
    return NULL;
}

int main() {
    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, consumer, NULL);
    pthread_create(&tid2, NULL, producer, NULL);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    return 0;
}

总结

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

您可能感兴趣的文章:
阅读全文