Linux 线程同步

Linux 线程同步

概念

使用同步机制来协调线程之间的执行顺序和访问共享资源的方式,以避免竞态条件和数据不一致性。常用的线程同步机制包括互斥锁、条件变量、信号量和屏障等。

为什么要有线程同步

线程同步保证了各个线程对临界资源的有序访问,如果没有线程同步,可能产生以下问题:

  • 竞态条件
  • 数据不一致
  • 死锁
  • 线程饥饿

竞态条件(Race Condition)是指在多线程或多进程环境下,多个线程或进程对共享资源的访问产生的不确定性和不可预测性结果。竞态条件会导致程序的执行结果与预期不符,可能引发各种问题,包括数据损坏、死锁、无限循环等。

条件变量机制

条件变量(Condition Variable)用于线程之间的等待和通知机制。它允许一个或多个线程在满足特定条件之前等待,而不是忙等待。当条件满足时,其他线程可以通过发送信号来通知等待的线程继续执行。

在 C 语言中,pthread_cond_t 是用于线程间条件变量的类型,它是 POSIX 线程库中的一部分。条件变量用于线程间的等待和通知机制,允许线程在特定条件下等待,并在条件满足时被通知继续执行。

相关接口
pthread_cond_init

一个条件变量在使用前必须初始化

pthread_cond_init 函数用于初始化条件变量,创建一个可用的条件变量对象。初始化后的条件变量可用于线程同步和线程间的等待/通知操作。

函数原型:

int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);

参数:

  • cond:指向条件变量的指针,用于初始化。
  • attr:指向条件变量属性的指针,通常设为NULL以使用默认属性。

返回值:

  • 成功:0
  • 失败:错误码
pthread_cond_wait

pthread_cond_wait 函数用于在条件变量上等待。

函数原型:

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

参数:

  • cond:指向条件变量的指针,用于等待和唤醒线程。
  • mutex:指向互斥锁的指针,用于保护共享资源的访问。

返回值:

  • 成功时,返回0。
  • 失败时,返回一个非零的错误码,表示出现了错误。

注意:

  • 在调用该函数之前,线程必须先获取互斥锁,然后将互斥锁传递给 pthread_cond_wait
  • 在等待条件变量时,线程会自动释放互斥锁,并在 被唤醒后重新获取互斥锁。
  • 在等待条件变量时,线程可能会出现 虚假唤醒 的情况,因此需要 在循环中检查条件是否满足 ,以防止出现竞态条件。
  • 线程在等待条件变量时可能会被信号中断,需要根据需要处理信号中断的情况。
pthread_cond_signal

pthread_cond_signal 函数用于向等待在条件变量上的一个线程发送信号,唤醒其中一个线程继续执行。它通常与 pthread_cond_wait 配合使用,用于实现线程间的同步。

函数原型:

int pthread_cond_signal(pthread_cond_t *cond);

参数、返回值与 pthread_cond_wait 一致,这里不再赘述

pthread_cond_boardcast

pthread_cond_signal 不同的是,发送广播通知,pthread_cond_boardcast 会唤醒所有等待在条件变量上的线程。

函数原型:

int pthread_cond_broadcast(pthread_cond_t *cond);

参数、返回值与 pthread_cond_wait 一致,这里不再赘述

pthread_cond_destroy

pthread_cond_destroy 函数用于销毁条件变量。在 使用完条件变量后,应该调用该函数进行清理工作,释放相关的资源。

函数原型:

int pthread_cond_destroy(pthread_cond_t *cond);

参数、返回值与 pthread_cond_wait 一致,这里不再赘述

注意:

  • 调用 pthread_cond_destroy 之前,应该 确保没有线程在等待条件变量上。否则,销毁条件变量将导致未定义的行为。
  • 销毁条件变量后,不能再使用已销毁的条件变量。
实例
pthread_mutex_t mutex;
pthread_cond_t cond;
int flag = 0;
char *shareResorces = NULL; // 共享资源

void *thread0(void *arg)
{
    printf("Now thread0 is running...\n");
    pthread_mutex_lock(&mutex); // 先上锁

    while (!flag) // 当条件不满足
    {
        // 循环等待其它线程唤醒(循环目的?防止假唤醒,因为 wait 可能失败提前返回)
        // 会释放锁
        pthread_cond_wait(&cond, &mutex);
    }

    printf("Thread0: Access shareResorces: %s\n", shareResorces);
    free(shareResorces);
    pthread_mutex_unlock(&mutex);

    printf("Now thread0 is about quitting...\n");
    return NULL;
}

void *thread1(void *arg)
{
    printf("Now thread1 is running...\n");
    pthread_mutex_lock(&mutex);

    shareResorces = (char *)malloc(sizeof(char) * 100);
    strcpy(shareResorces, "Hello, pthread_cond_t!");
    flag = 1;

    pthread_cond_signal(&cond);   // 唤醒线程 0
    pthread_mutex_unlock(&mutex); // 解锁

    printf("Now thread1 is about quitting...\n");
    return NULL;
}

// 同步:先执行线程 1,再执行线程 0

int main(void)
{
    pthread_mutex_init(&mutex, NULL);
    pthread_cond_init(&cond, NULL);

    pthread_t tid0, tid1;
    pthread_create(&tid0, NULL, thread0, NULL);
    sleep(1);
    pthread_create(&tid1, NULL, thread1, NULL);

    pthread_join(tid0, NULL);
    pthread_join(tid1, NULL);

    pthread_cond_destroy(&cond);
    pthread_mutex_destroy(&mutex);
    printf("Now main thread is about quitting...\n");
}

输出:

在这里插入图片描述

可以发现,即使让线程 0 先执行 1s,线程 0 也必须等到线程 1 写入了资源后才能访问到资源,避免了空指针异常,这体现了线程同步的必要性

为什么 wait 操作需要用到互斥量 mutex

在前面的介绍中,可以发现 pthread_cond_wait 函数参数含有互斥量 mutex,这是为什么?

第一个原因:

在等待的过程中,共享数据可能发生变化,这就需要互斥锁来保护临界资源,有了互斥锁,就能保证在等待唤醒的过程中,对临界资源的访问是互斥的

第二个原因:

确保原子性。

例如,如果 pthread_cond_wait 函数参数 含有互斥量 mutex:

// 错误的设计
pthread_mutex_lock(&mutex); 
while (condition_is_false)
{
    pthread_mutex_unlock(&mutex);

    // 此处如果发生调度,想想会发生什么?

    pthread_cond_wait(&cond);
    pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);

如果这样写,在 解锁之后,等待之前 ,条件可能已经满足,信号已经发出,但是该信号可能就被 pthread_cond_wait(&cond) 错过了,导致该进程永远等待唤醒

生产者消费者问题

生产者消费者问题是一种经典的互斥、同步问题,涉及到多个生产者和消费者线程对共享资源的访问和操作。

生产者线程负责生产产品,并将其放入一个 有限大小的缓冲区 中,而消费者线程负责从缓冲区中取出产品进行消费。生产者和消费者之间通过共享缓冲区进行通信。

生产者和消费者之间需要协调合作,避免出现以下问题:

  • 缓冲区溢出:当缓冲区已满时,生产者必须等待,直到有空闲位置。
  • 缓冲区为空:当缓冲区为空时,消费者必须等待,直到有可用产品。

为了解决生产者消费者问题,需要用到同步机制,下面是以条件变量机制解决的生产者消费者问题的代码:

// BlockQueue.h
#pragma once

#include <iostream>
#include <queue>
#include <pthread.h>
#include <unistd.h>

class BlockQueue
{
    std::queue<int> data;
    size_t bufferSize;     // 阻塞队列缓冲区大小
    pthread_mutex_t mutex; // 互斥锁
    pthread_cond_t _full;  // 队列满
    pthread_cond_t _empty; // 队列空

    bool full(void) const;
   
    bool empty(void) const;

public:
    BlockQueue(size_t bufferSize = 6);

    ~BlockQueue();
    
    void push(int val);
    
    // 和 JAVA 的 pop 类似,返回队列首元素
    int pop(void);
};

// BlockQueue.cpp
#include "BlockQueue.h"

inline bool BlockQueue::full(void) const
{
    return data.size() == bufferSize;
}

inline bool BlockQueue::empty(void) const
{
    return data.empty();
}

BlockQueue::BlockQueue(size_t bufferSize)
    : bufferSize(bufferSize)
{
    pthread_mutex_init(&mutex, NULL);
    pthread_cond_init(&_full, NULL);
    pthread_cond_init(&_empty, NULL);
}

BlockQueue::~BlockQueue()
{
    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&_full);
    pthread_cond_destroy(&_empty);
}

void BlockQueue::push(int val)
{
    pthread_mutex_lock(&mutex);

    std::cout << "Check if the BlockQueue is full" << std::endl;
    while (full())
    {
        std::cout << "BlockQueue is full! Waiting for consumer..." << std::endl;
        pthread_cond_wait(&_full, &mutex); // 阻塞,等待消费者消费产品
    }
    data.push(val);
    std::cout << "Push success! The size of BlockQueue is " << data.size() << std::endl;

    pthread_mutex_unlock(&mutex);

    pthread_cond_signal(&_empty); // 通知可能正在等待 _empty 的消费者
}

int BlockQueue::pop(void)
{
    pthread_mutex_lock(&mutex);

    std::cout << "Check if the BlockQueue is empty" << std::endl;
    while (empty())
    {
        std::cout << "BlockQueue is empty! Waiting for producer..." << std::endl;
        pthread_cond_wait(&_empty, &mutex); // 阻塞,等待生产者生产产品
    }

    int ret = data.front();
    data.pop();
    std::cout << "Pop success! The size of BlockQueue is " << data.size() << std::endl;

    pthread_mutex_unlock(&mutex);

    pthread_cond_signal(&_full); // 通知可能正在等待 _full 的生产者
    return ret;
}

// main.cpp
#include "BlockQueue.h"
#include <random>

void *thread0(void *arg);
void *thread1(void *arg);

int main(void)
{
    BlockQueue blockQueue;

    pthread_t tid0, tid1;

    pthread_create(&tid0, NULL, thread0, &blockQueue);
    sleep(10); // 让队列满了再开始线程 1
    pthread_create(&tid1, NULL, thread1, &blockQueue);
    
    pthread_join(tid0, NULL);
    pthread_join(tid1, NULL);
}

// 生产者线程
void *thread0(void *arg)
{
    std::uniform_int_distribution<int> u(0, 9);
    std::default_random_engine e(time(NULL));

    BlockQueue *blockQueue = static_cast<BlockQueue*>(arg);

    for (int loop = 0; loop < 60; ++loop)
    {
        blockQueue->push(u(e));
        sleep(1);
    }

    return NULL;
}

// 消费者线程
void *thread1(void *arg)
{
    BlockQueue *blockQueue = static_cast<BlockQueue*>(arg); // 记得用指针,否则线程 0、线程 1 的阻塞队列都不同!

    for (int loop = 0; loop < 60; ++loop)
    {
        int val = blockQueue->pop();
        std::cout << "Thread1: received data from BlockQueue: " << val << std::endl;
        sleep(1);
    }

    return NULL;
}

某一次运行时输出:

在这里插入图片描述

注意:

  • 在使用条件变量时需要初始化,销毁
  • 循环等待其它线程唤醒,防止假唤醒
  • 在线程内操作队列时,为了保证两个线程操作相同的阻塞队列,需要使用指针,避免拷贝

POSIX 信号量

POSIX 信号量是一种用于进程间同步和互斥的机制。它是 POSIX 标准中定义的一组函数和数据类型,用于实现并发编程中的线程同步操作。

POSIX 信号量的特点和使用方法包括:

  • 信号量用于实现资源的同步访问和互斥操作,通过控制对共享资源的访问来避免竞态条件和数据不一致性问题。
  • 信号量可以通过 P(wait)和 V(post)操作来进行加锁和解锁操作,以控制对资源的访问。
  • 等待 P 操作的线程或进程会被阻塞,直到信号量的计数器满足条件,才能继续执行。
  • 执行 V 操作会增加信号量的计数器,并唤醒等待的线程或进程继续执行。
  • 信号量的计数器可以表示资源的数量,也可以表示互斥锁的状态。
  • POSIX 信号量提供了一组函数,如 sem_initsem_waitsem_postsem_destroy 等,用于初始化、操作和销毁信号量。
常用接口

要想使用 POSIX 信号量的接口,需要包含头文件 semaphore.h

sem_init

sem_init函数用于初始化一个POSIX信号量。它创建一个新的信号量,并设置初始值。

原型:

int sem_init(sem_t *sem, int pshared, unsigned int value)

参数:

  • sem: 指向要初始化的信号量的指针
  • pshared: 指定信号量的共享方式,取值为0或非零。
    • 如果pshared为0,则信号量被 线程内部 使用,只能在同一进程内的线程间共享。
    • 如果pshared非零,则信号量可以在多个 进程间 共享
  • value: 指定信号量的初始值,必须是非负数。

返回值:

  • 成功时返回0,表示信号量初始化成功。
  • 失败时返回-1,表示初始化失败,具体错误信息保存在errno中。

注意:

  • 在调用sem_init之前,应确保sem指针是有效的,且未被初始化。
  • 对于线程内部使用的信号量,通常将pshared参数设置为0。
  • 信号量的初始值通过value参数指定,它 表示资源的初始数量
sem_wait(P 操作)

sem_wait函数用于获取(等待)一个信号量。如果信号量的值大于0,表示有可用资源,sem_wait会将信号量的值减1,并立即返回。如果信号量的值为0,表示没有可用资源,sem_wait将阻塞线程,直到有资源可用时才返回。

原型:

int sem_wait(sem_t *sem)

参数:

  • sem: 指向要操作的信号量的指针

返回值:

  • 成功时返回0,表示成功获取到信号量。
  • 失败时返回-1,表示出现错误,具体错误信息保存在errno中。

注意: sem_wait是一个阻塞操作,如果没有可用资源,它会一直等待,直到有资源可用或出现错误。

sem_post(V 操作)

sem_post函数用于释放一个信号量。它会将信号量的值加1,表示释放了一个资源。如果有其他线程正在等待该信号量,则其中一个线程将 被唤醒 ,可以获取到该信号量,并继续执行。

sem_post 与 sem_wait 的用法类似,这里不再赘述

sem_destroy

sem_destroy函数用于销毁一个信号量。它会释放信号量相关的资源,并将信号量重置为未初始化的状态。

原型:

int sem_destroy(sem_t *sem)

参数:

  • sem: 指向要销毁的信号量的指针

返回值:

  • 成功时返回0,表示成功销毁信号量。
  • 失败时返回-1,表示出现错误,具体错误信息保存在errno中。
生产者消费者模型再探

再理一下生产者与消费者关系:

  • 有空闲缓冲区 -> 生产者可以生产产品
  • 有非空闲缓冲区 -> 消费者可以消费产品
// CircularQueue.h
#pragma once
#include <vector>
#include <semaphore.h>
#include <pthread.h>
class CircularQueue
{
    std::vector<int> data;
    size_t bufferSize;
    size_t size;
    sem_t _full; // 记录当前缓冲区存放的数据个数
    sem_t _empty; // 记录当前缓冲区空闲个数
    pthread_mutex_t mutex;
    int front = 0;
    int rear = 0;
public:
    CircularQueue(int bufferSize = 6);

    ~CircularQueue();

    void push(int val);

    int pop(void);
};

// CircularQueue.cpp
#include <cstdio>
#include "CircularQueue.h"

CircularQueue::CircularQueue(int bufferSize)
    :bufferSize(bufferSize)
{
    pthread_mutex_init(&mutex, NULL); 
    sem_init(&_empty, 0, bufferSize); // 将 _empty 信号量的数量设置为 bufferSize
    sem_init(&_full, 0, 0);
    data.resize(bufferSize);
}

CircularQueue::~CircularQueue()
{
    sem_destroy(&_empty);
    sem_destroy(&_full);
    pthread_mutex_destroy(&mutex);
}

void CircularQueue::push(int val)
{
    sem_wait(&_empty); // P(_empty)

    printf("CircularQueue: pushed data %d.\n", val);
    fflush(stdout);
    data[rear] = val;
    rear = (rear + 1) % bufferSize;

    sem_post(&_full); // V(_full)
}

int CircularQueue::pop(void)
{
    sem_wait(&_full); // P(_full)

    int ret = data[front];
    front = (front + 1) % bufferSize;

    sem_post(&_empty); // V(_empty)

    printf("CircularQueue: poped data %d.\n", ret);
    return ret;
}

// main.cpp
#include "CircularQueue.h"
#include <iostream>
#include <random>
#include <pthread.h>
#include <unistd.h>

// 生产者线程
void *thread0(void *arg)
{
    std::uniform_int_distribution<int> u(0, 9);
    std::default_random_engine e(time(NULL));

    CircularQueue *circularQueue = static_cast<CircularQueue*>(arg);

    for (int loop = 0; loop < 60; ++loop)
    {
        int val = u(e);
        circularQueue->push(val);
        sleep(1);
    }

    return NULL;
}

// 消费者线程
void *thread1(void *arg)
{
    CircularQueue *circularQueue = static_cast<CircularQueue*>(arg);

    for (int loop = 0; loop < 60; ++loop)
    {
        int val = circularQueue->pop();
        sleep(1);
    }

    return NULL;
}

int main(void)
{
    CircularQueue circularQueue;

    pthread_t tid0, tid1;

    pthread_create(&tid0, NULL, thread0, &circularQueue);
    sleep(10); // 让队列满了再开始线程 1
    pthread_create(&tid1, NULL, thread1, &circularQueue);

    pthread_join(tid0, NULL);
    pthread_join(tid1, NULL);
}

输出:

在这里插入图片描述

读者写者问题

有读者和写者两组并发进程,共享一个文件,当两个或两个以上的读进程同时访问共享数据时不会产生副作用,但若某个写进程和其他进程(读进程或写进程)同时访问共享数据时则可能导致数据不一致的错误。因此要求:①允许多个读者可以同时对文件执行读操作;②只允许一个写者往文件中写信息;③任一写者在完成写操作之前不允许其他读者或写者工作;④写者执行写操作前,应让己有的读者和写者全部退出。

概括一下:

  • 允许多个读进程同时读数据
  • 只允许一个写进程写数据
  • 在写数据的过程中,不允许其它任何进程访问数据
semaphore rw = 1; // 信号量,用于实现文件的互斥访问
int count = 0;    // 记录读文件进程的数量,用于实现多个进程“同时”读数据
writer()
{
    while(1)
    {
        P(rw); // 上锁
        写文件;
        V(rw); // 解锁
    }   
}

reader()
{
    while(1)
    {
        if(count == 0) // 如果自己是第一个读文件的进程 1
            P(rw);     // 上锁 2
        ++count;
        读文件;
        --count;
        if(count == 0) // 如果自己是最后一个退出的进程
            V(rw);     // 解锁
    }   
}

上面的代码看起来没什么毛病嗷,但是,如果在 if(count == 0) 这句话发生了进程调度,切换到另一个读进程,即:

  • 第一个读进程执行到 1,进程调度,切换到第二个读进程
  • 第二个读进程执行到 1,进程调度,切换到第一个读进程
  • 第一个读进程上锁
  • 第二个读进程无法上锁,因为已经上锁,被阻塞

也就是说,此时并没有实现两个读进程同时访问文件的要求

如何解决?

很容易想到,出现上面的问题,根本原因是:

if(count == 0) // 如果自己是第一个读文件的进程 1
    P(rw);     // 上锁 2
++count;

这三句话不是原子操作,导致第二个进程以为 count == 0,进而发生阻塞

因此,再添加一个互斥信号量,以实现 “类原子操作”,即:

semaphore mutex = 1;

reader()
{
    ...
    P(mutex);
    if(count == 0) // 如果自己是第一个读文件的进程 1
        P(rw);     // 上锁 2
    ++count;
    V(mutex);

    读文件;

    P(mutex);
    --count;
    if(count == 0) // 如果自己是最后一个退出的进程
        V(rw);     // 解锁
    V(mutex);
    ...
}

无论是否修改,这个代码存在潜在的写进程饥饿问题,如果有源源不断的读进程,那么写进程将持续被阻塞(只有最后一个退出的读进程才能解锁 rw)

因此这种方式又叫读优先

要想实现写优先,需要再添加一个信号量:

semaphore w = 1;

writer()
{
    while(1)
    {
        P(w);
        P(rw); // 上锁
        写文件;
        V(rw); // 解锁
        V(w);
    }   
}

reader()
{
    ...
    P(w);
    P(mutex);
    if(count == 0) // 如果自己是第一个读文件的进程 1
        P(rw);     // 上锁 2
    ++count;
    V(mutex);
    V(w);
    ...
}

假设按照 读者 1 -> 写者 1 -> 读者 2 这种模式并发执行 P(w)

  • 读者 1 执行 P(w)
  • 写者 1 执行 P(w),失败,被阻塞,挂在阻塞队列的队头
  • 读者 2 执行 P(w),失败,被阻塞,挂在阻塞队列的队头后
  • 读者 1 执行 V(w),解锁,唤醒 写者 1 进程

可以发现,添加一个信号量 w,解决了写进程饥饿的问题,实现了 “写优先”

当然,这个 “写优先” 不是真正意义上的写优先,而是一种「先来先服务」的原则,体现在阻塞队列中

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值