线程

文章目录

  • 虚拟进程地址空间
    • 进程的运行时内存映像
  • 分页 && 页表
    • 多级页表映射
  • 什么是线程
    • 线程和进程的区别
    • 如何描述一个线程
    • LWP
    • 线程的私有结构
    • 多线程相比于多进程的优点
    • 线程的缺点
    • 查看线程
  • pthread库
    • 编译 && 头文件
    • pthread_t
    • pthread库的加载
    • 线程创建
      • `pthread_self`
    • 线程退出
      • `pthread_exit`
      • `pthread_cancel`
      • 线程退出VS进程退出
    • 线程控制
      • `pthread_join`
    • 分离线程
      • `pthread_detach`
  • 线程安全
    • 如何保证加锁和解锁的原子性
  • 死锁
  • 条件变量
    • 接口介绍
  • 生产消费之模型
    • 阻塞队列
    • 环形队列
      • 信号量

虚拟进程地址空间

进程的运行时内存映像

下面这张图就是进程看待内存的方式(地址空间为64位):
线程_第1张图片
这是进程看待内存的方式,所以我们虚拟地址和实际数据存储的地址是不同的,那么如何实现虚拟地址到物理地址的转换?

分页 && 页表

如果直接将整个程序从磁盘加载到内存中:首先会遇到内存可能不够的问题,其次多个进程同时被加载到内存时会产生碎片问题。这两个问题会严重影响程序的运行。解决方法是局部性!

首先我们会对物理内存分页,一页大小一般是4KB,分成若干个页,每个页有唯一的索引。每一个页叫做页框

记录上面页框的索引的表就叫做页表了!每条记录的索引叫做页表项,这里的索引可以是物理内存分页的地址也可以是页表地址!!!

多级页表映射

多级页表的映射过程如下:
线程_第2张图片

  • 首先我们通过CPU中一个专门的寄存器拿到一级页表的地址
  • 通过虚拟地址的1~10位作为索引找到一级页表中的页表项,该页表项存的是二级页表地址
  • 通过上面找到二级页表的地址,在通过虚拟地址的11~20位作为索引找到对应的二级页表的页表项,该页表项存储的是物理内存某一页的起始物理地址
  • 通过上面找到的物理页的起始地址,根据虚拟地址的21~32位作为索引,找到对应的物理内存

什么是线程

线程总体来说就是一个执行流,是CPU调度的最小单位

线程和进程的区别

说到线程不得不提他的爸爸进程,进程和线程是一个十分相关的概念。但是我们在下面的学习中始终要记住一下两点:

  • 进程是OS分配资源(内存)的基本单位
  • 线程是CPU调度的基本单位

进程在学完线程之后实际上指的是所有执行流所共有的OS资源(PCB、内存),但是你创建进程必然会有一个执行流,一般把这个执行流叫做主线程!

这例做一个比喻:进程就像是一个房子,线程就像房子里面的家庭成员。

如何描述一个线程

我们回忆一下OS是如何描述进程——PCB结构体!这个结构体里面有描述进程的所有信息(上下文、页表、寄存器)。
线程是一个进程的执行流之一,那么是否需要维护一个单独的结构体来描述线程呢?
windows操作系统其实为线程创建了单独的结构体,但是Linux操作系统却直接将描述线程的结构体复用进程的PCB结构体。本文将重点介绍Linux操作系统中的线程


在上面的地址空间是一个进程的地址空间,由于线程是进程的一个执行流,所以一个进程的线程地址空间应该与进程的地址空间理论上是一模一样。
如果我们重新为线程创建一个结构体,那么一个新的问题就出现了:

新的线程的结构体是有维护成本的,这无疑给CPU增加了很大的负担。而我们知道一个执行流在执行时顶多需要一些栈空间来维护临时变量,其他的地址空间对应的内存并不需要拷贝,直接所有执行流共用就可以了,所以Linux认为线程可以完全复用进程的PCB的地址空间,但是需要单独维护一下栈空间,并在PCB中建立一些标识位来标识线程即可。


这样做的好处

  • 很显而易见,CPU只需要维护一个PCB既可以调度线程成本降低,使得操作系统变得高效
  • 由于共享进程的大部分地址空间,所有线程会共享很多资源,方便线程间的通信

LWP

lwp(light weight process)是操作系统标识

线程的私有结构

  • 栈(存储运行时的临时变量)
  • 上下文(是执行流都需要自己维护)
  • PCB属性(标识线程的唯一ID等)

多线程相比于多进程的优点

  • 进程切换:

    • 上下文
    • PCB
    • 页表
  • 线程切换

    • 上下文
    • PCB

同时线程在执行的时候会在cache里面缓存很多热点数据,而这些数据在线程切换中并不会切出,所以线程间的切换要比进程间的切换速度要快很多。

线程的缺点

  • 性能损失:线程之间来回切换
  • 健壮性降低:一个线程崩溃,其他线程也会跟着崩溃。因为多个线程会共用一个pid,而操作系统是以pid为依据发送信号,所以一个线程崩了,所有线程都会崩了。

查看线程

我们可以输入ps -aL来查看进程和线程,现在我们假设我们有一个test进程,他在执行的过程中创建了两个子进程,现在我们调用指令就可以看见:
线程_第3张图片
注意

  • pid和LWP相同的那个执行流是主执行流——也就是main函数的执行流
  • LWP是OS唯一标识线程的记号

pthread库

如果你想在Linux代码中创建一个线程。那么一定离不开pthread库(也可以使用C++11跨平台的线程库,后面我会专门开一个博客),pthread库实际上是对操作系提供的接口实现了再封装,这是为什么呢?
上面提到了,Linux中线程是复用进程的PCB,所以操作系统在调度的时候是不会区分你到底是线程还是进程,所以操作系统提供了一个接口clone用来创建线程和进程:
线程_第4张图片
你需要输入一大堆参数来控制这个进程/线程 具体如何实现,这个需要你对OS底层非常了解,但是我只是想创建一个线程这个结果,所以封装成了一个第三方库供用户创建线程时更加方便
线程_第5张图片
下面就让我们了解一下pthread库

编译 && 头文件

在正式了解之前还是要提一下如果要使用pthread库需要包含头文件:
#include
其次就是由于pthread是第三方库,所以在编译的时候要加上-lpthread

pthread_t

这个是pthread库用来标识线程的ID,它的数值不同代表线程不同。但是他与我们上面的LWP并不是很一样。

  • LWP是系统层面用来标识线程的ID的唯一标识符
  • pthread_t是pthread库用来标识线程ID的唯一标识符

pthread库的加载

pthread库是一个动态库,所以它是运行时加载到进程/线程地址空间堆栈中间的共享区,在pthread的动态库里面会为你开辟的线程创建一个结构体来维护线程的私有结构,而这里标识每一个线程的标识符就是这些结构体在地址空间中的地址。
注意:主线程(main函数)的栈使用的是地址空间的栈,不用在动态库中创建私有栈
线程_第6张图片
我们可以做一个实验来证明这个:

void *fun_test3(void *)
{
}

int main()
{
    pthread_t t1, t2;
    pthread_create(&t1, nullptr, fun_test3, nullptr);     //创建线程1
    pthread_create(&t2, nullptr, fun_test3, nullptr);     //创建线程2
    printf("0x%x\n", t1);    //将t1用16进制打印出来
    printf("0x%x\n", t2);   
    pthread_join(t1, nullptr);     //等待线程1
    pthread_join(t2, nullptr);     //等待线程2

    return 0;
}

输出结果
在这里插入图片描述

线程创建

线程_第7张图片

  • thread:返回线程ID(输出型参数)
  • attr:设置线程的属性(输入nullptr为设置成默认属性)
  • start_routine:函数指针 返回值和参数都为void*,线程启动之后要执行的函数
  • arg:传入start_routine的函数参数,也就是向线程传递的参数

pthread_self

线程_第8张图片
返回当前线程的pthread_t类型的线程ID,与pthread_create中第一个参数一样

线程退出

线程退出的三种方式:

  • 从线程函数return——但是对于主线程相当于进程退出
  • 调用pthread_exit函数退出当前线程
  • 调用pthread_cancel函数来退出指定线程

pthread_exit

线程_第9张图片
退出当前线程

  • retval:是线程的返回值

pthread_cancel

线程_第10张图片
该函数退出输入pthread_t对应线程

线程退出VS进程退出

进程退出是依据pid来退出的,所以pid相同的线程都会退出。
假如我们某个线程调用了进程退出函数exit函数,则所有线程都会退出。

但是线程退出就不一样了,来看下面一种情况:

void *fun_test3(void *)
{
    // exit(1);
    while (1)
    {
        cout << "hello " << endl;
    }
}
void test3() // 博客 测试代码
{
    pthread_t t3 = pthread_self();
    pthread_t t1, t2;
    pthread_create(&t1, nullptr, fun_test3, nullptr);
    pthread_create(&t2, nullptr, fun_test3, nullptr);

    pthread_exit(nullptr);   //退出主线程
    // return;        //下面两种方法不能使用因为是退出进程
    // exit(1);
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);

    return;
}

主线程创建了两个子线程,在子线程运行未结束的时候退出。我们在执行的时候打开监事窗口:
在这里插入图片描述
主线程退出后会进入僵尸状态,等待子线程退出并回收

线程控制

pthread_join

在这里插入图片描述

  • thread:为等待线程的ID
  • retval:当前线程拿到子线程的返回值

为什么第二个参数是void
这里的是一个输出型参数,输出的是一个void *指针,但是输出型参数必须传入地址,所以理所应当应该传入void **

分离线程

pthread_detach

在这里插入图片描述
一个线程默认是joinable,那么什么是joinable呢?
joinable就是可以被回收的意思,如果一个线程是joinable那么创建他的父线程必须回收他(也就是拿到线程运行的结果)。但是有些线程我们并不关心她的运行结果,那就可以分离该线程。
pthread_detach(pthread_self()); 此为分离当前线程
但是注意如果主线程已经join了某个子线程,那么这个子线程即使detach也是没有用的,例如:

void *fun_test3(void *)
{
    sleep(3);
    pthread_detach(pthread_self());
    int *p = new int[5]{1, 2, 3, 4, 5};
    return (void *)p;
}
int main() // 博客 测试代码
{
    pthread_t t3 = pthread_self();
    pthread_t t1, t2;
    pthread_create(&t1, nullptr, fun_test3, nullptr);
    void *ret = nullptr;
    pthread_join(t1, &ret);
    for (int i = 0; i < 5; i++)
    {
        cout << ((int *)ret)[i] << endl;
    }
    return 0;
}

结果如下:
线程_第11张图片
虽然子线程分离了自己,但是父线程在此之前join了子线程,所以退出后依然拿到了子线程的结果!
而如果子线程先分离,那么父线程在join就没有用了

线程安全

线程虽然共享的空间变多了,但是随之而来的问题就是如何管理这些公共资源?
我没来看一个抢票的逻辑:

#include "Thread.hpp"
#include 
#include 
#include 
#include 
int ticket_number = 1000;   //设置了1000张票
using namespace std;
void *getticket(void *args)
{
    while (true)
    {
        if (ticket_number > 0)
        {
            usleep(9000);
            cout << (const char *)args << " 剩余票数:" << ticket_number << endl;
            --ticket_number;
        }
        else
            break;
    }

    return 0;
}
int main()
{
    thread t[10];
    for (int i = 0; i < 10; i++)    //设置10个线程同时抢票
    {
        char *p = new char[64];
        snprintf(p, 64, "线程编号:%d 正在抢票", i + 1);
        thread tmp(getticket, (void *)p);
        t[i] = move(tmp);
    }

    for (int i = 0; i < 10; i++)
    {
        t[i].join();
    }
    return 0;
}

运行结果如下:
线程_第12张图片

居然把票数抢成了负数,我们把票数定义成了一个全局对象,所有线程都可以访问是一个公共资源,但是这时也就会出现所谓的线程安全问题
为什么会造成上述现象?
假设我们现在只有一张票,这时线程1进入判断并开始休眠,此时OS将线程1挂起,切换别的线程
线程_第13张图片

由于线程1并未对票数做操作,线程2依然可以进入判断并开始休眠
线程_第14张图片
直到线程1休眠的时间到被唤醒,这时他醒来判断里面居然有一堆线程在休眠,这时已经完蛋了,票只有一张但是减减n次,所以就会被减成负数
线程_第15张图片

造成线程安全的主要原因就是多个线程同时访问了同一个公共资源。所以我们必须让同时访问同一个公共资源的所有线程有序的进行访问,否则就会造成线程安全的问题。而我们的解决方法就是加锁

加锁
加锁就是给临界资源上了一把锁,要想访问临界资源必须向操作系统申请一把钥匙,一个锁对应一把钥匙,要想访问这个公共资源就必须有钥匙,每个线程拿到钥匙进入临界资源之后反手就把门锁上,访问完临界资源之后把锁交给操作系统。由操作系统再次重新分配

线程_第16张图片

  • 加锁和解锁都是面向操作系统的
  • 锁也是一个临界资源,因为所有线程要访问一个临界资源就要向OS申请锁,而申请锁的前提是看到同一把锁
    所以加锁和解锁的过程必须是原子的!

一种设计锁的思路:
设置一个变量int lock 如果lock ==1 就代表锁可以被申请,如果lock==0代表锁已经被申请走了,申请锁的逻辑为:
线程_第17张图片
运算符--的在汇编上分为三步:

  • 内存读入寄存器
  • 寄存器中值减1
  • 寄存器中的值重新写入内存

首先线程1执行--操作,但是线程1执行完上述三步第一步之后,突然时间片到了触发中断,被操作系统挂起——具体操作就是CPU里寄存器的数据(上下文)都会存储到线程1的PCB中
线程_第18张图片


这时线程2上来执行,他将内存中的数值拷贝到寄存器,先进行判断,符合条件。到这一步就已经证明这种方法的错误了,因为有两个线程同时在访问临界区的代码中了,这样就可能发生线程安全问题。

线程_第19张图片

如何保证加锁和解锁的原子性

要想实现锁的原子性就必须保证加锁的动作是一步汇编,否则加锁的过程中线程切换就会造成线程安全问题
那么加锁的动作实际上是使用了swap指令

  1. 我们首先在内存中定义一个锁,他的值为1
  2. 然后来了一个线程来申请锁,他先判断锁的值是否为1(代表有没有锁),使用swap汇编指令将锁中的值1换到寄存器中,同时寄存器中的0被还如内存中的锁上
  3. 这时后面的进程再来判断时,锁在内存中的值为1,代表锁已经被拿走,顾申请也就失败了
  4. 解锁的步骤就是将1通过swap指令和内存中的锁进行交换

注意:
我们发现哪个线程持有锁,实际上就是该线程的寄存器中存储着锁,那么当持有锁的线程在访问临界区代码时候被CPU切走会有线程安全问题吗?

不会!,线程被切走的时候上下文(也就是寄存器中的值)会被存起来,通俗的说也就是线程抱着锁休眠了。别的线程依然无法申请到锁。

死锁

  • 死锁举个最简单的例子:一个线程申请到了一把锁,我们就把他叫做锁1,申请到之后,该线程又向操作系统再次申请锁1。这就造成了死锁,线程再次申请锁1需要该线程交出锁1,线程想要交出锁1就必须申请到锁1 。这就形成了一个逻辑闭环。
  • 在举一个例子:
    线程_第20张图片
    如上图线程A、B各自持有一把锁,然后互相申请对方的锁,但是申请成功锁的条件必要条件是先交出自己手上的锁。举个生活中的例子:两个互不信任的人做交易,买家对卖家说你先交货我再给钱,卖家对买家说你先给钱我再发货,和上面的情况是一致的

死锁的四个必要条件

  1. 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
  2. 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
  3. 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
  4. 循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。

OK,那么死锁问题怎么解决呢?只要破坏四个必要条件中的一个即可将死锁破坏了

  • 资源一次性分配:一次性分配所有资源,这样就不会再有请求了:(破坏请求条件)
  • 只要有一个资源得不到分配,也不给这个进程分配其他的资源:(破坏请保持条件)
  • 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
  • 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)

条件变量

复习一下概念:
线程互斥: 指的是让线程按照串行的顺序去执行临界区域

引入一个概念:
线程同步: 是让线程按照一定的顺序去访问临界资源 why?—— 因为可能一个线程竞争锁的能力太强了,别的线程都抢不过他,会导致其他线程一直申请不到锁也就访问不到临界资源(线程的饥饿问题)

接口介绍

条件变量创建

  • int pthread_cond_init (pthread_cond_t *__restrict __cond , const pthread_condattr_t *__restrict __cond_attr)
    • __restrict __cond :是一个pthread_cond_t类型变量,改变了是条件变量的类型
    • __restrict __cond_attr:设置条件变量的属性,一般填的都是nullptr

等待条件满足
int pthread_cond_wait (pthread_cond_t *__restrict __cond , pthread_mutex_t *__restrict __mutex)

  • __restrict __cond :指明条件变量,哪个线程调用这个函数就会去名为__restrict __cond 的条件变量下等待
  • __restrict __mutex:线程等待之前必须把锁交了,否则就会造成死锁问题

在线程被唤醒之后会接着该函数之后继续执行(可能依然在临界区内),所以该函数依然会贴心的位该线程申请锁

唤醒等待

  • int pthread_cond_broadcast (pthread_cond_t *__cond)
    唤醒所有在名为__cond条件变量下等待的线程
  • int pthread_cond_signal (pthread_cond_t *__cond)
    唤醒一个在名为__cond条件变量下等待的线程

那么如何使线程们“有序” 的访问临界资源呢?
实际上的条件变量都是这个逻辑

  • 线程1先申请锁,然后交锁、进入cond条件变量下等待,
    线程_第21张图片
  • 这时线程2过来重复上述步骤,并进入cond的队列中
    线程_第22张图片
  • 后面唤醒的过程也是按照队列的先后顺序来唤醒的!

这样我们就达成了一个顺序的执行

生产消费之模型

这就是我们生活中最常见的一个生产消费模型
线程_第23张图片

我们要来在计算机的角度来审视一下这个模型:

  1. 生产者:可以是多个线程,他们用来生产任务
  2. 消费者:也可以是多个线程,他们用来执行任务、
  3. 超市:用来将:生产者——消费者 关系,变成:生产者——超市、超市——消费者,实现了关系的解耦(这个需要重点理解)

关于生产者和消费者我们可以用“三二一”来记忆

  • 三种关系:
    • 消费之-消费者:互斥关系(竞争)
    • 生产者-生产者:互斥关系(竞争)
    • 生产者-消费者:同步&&互斥
  • 两种角色:
    • 生产者线程
    • 消费者线程
  • 一种交易场所:一段特定结构的缓冲区

阻塞队列

阻塞队列是一个任务队列(固定大小),生产者/消费者 互斥的向队列 放入任务/拿取任务执行,并使用条件变量来完成 生产者-生产者 / 消费者-消费者 的同步关系,但是唯一要注意的是 生产者的条件变量队列的唤醒条件的决定权是在消费者手上,同理 消费者的条件变量队列的唤醒条件的决定权是在生产者手上。

再看条件变量
我们先前像抢票模型,访问的逻辑都是申请锁、判断、执行逻辑、释放锁。但是如果判断条件不满足 就变成了申请锁、条件判断、释放锁。这样的空转实际上是资源的浪费
举个例子:超市里面没有货物,消费者在门口排起了长队(条件变量的队列),如果每个消费者都进去看看消费者有没有送货是一个非常低效的选择,高效的解决方法是消费者就在门口等待,生产者生产好货物送上超市的货架并通知消费者消费(pthread_cond_singal)。这也解释了上面生产者的条件变量队列的唤醒条件的决定权是在消费者手上,同理 消费者的条件变量队列的唤醒条件的决定权是在生产者手上。

代码

#pragma once
#include 
#include 
#include 
#include 
#include 

#define NUM 10

template <class T, size_t N = NUM>
class Blockqueue
{
public:
    Blockqueue()
    {
        pthread_cond_init(&_p_cond, nullptr);
        pthread_cond_init(&_c_cond, nullptr);
        pthread_mutex_init(&_mutex, nullptr);
    }

    void push(const T &x)
    {
        pthread_mutex_lock(&_mutex);
        while (is_full()) // 如果队列满了就进入该循环进行挂起
        {
            pthread_cond_wait(&_p_cond, &_mutex);
        }

        // 走到这里代表队列一定不是满的
        _q.push(x);

        // 走到这里代表队列里一定有元素,可以唤醒消费者
        pthread_cond_signal(&_c_cond);
        pthread_mutex_unlock(&_mutex);
    }

    void pop(T *x)
    {
        pthread_mutex_lock(&_mutex);
        while (is_empty()) // 如果队列为空 执行此逻辑
        {
            pthread_cond_wait(&_c_cond, &_mutex);
        }

        T top = _q.front();
        // 消费数据
        //....
        *x = top;
        _q.pop();
        // 走到这里队列一定不是满的可以通知生产者继续生产
        pthread_cond_signal(&_p_cond);
        pthread_mutex_unlock(&_mutex);
    }

    bool is_full() // 判断是否满了
    {
        return (_q.size() == N);
    }

    bool is_empty() // 判断是否为空
    {
        return _q.empty();
    }

private:
    std::queue<T> _q;
    pthread_cond_t _p_cond;
    pthread_cond_t _c_cond;
    pthread_mutex_t _mutex;
};

环形队列

阻塞队列的缺点: 实际上生产者和消费者在访问阻塞队列的时候还是一种互斥关系,有没有一种方法能完成生产者和消费者的解耦——生产者、消费者可以同时访问队列

我们的做法也非常简单:因为我们发现每次消费者和生产者都是队列中的一个元素,只有生产者和消费者访问的不是一个元素就不会出现线程安全问题。阻塞队列的做法是给整个队列加上锁,而我们可以把临界资源切成若干份,只要生产者和消费者不同时访问同一个小份就不会出现线程安全问题!

信号量

这里我们就要引入信号量

信号量的本质: 我们把一个临界资源切成若干个小份,而信号量实际上就是一个计数器,用来代表这些可用的小份的个数。所以信号量本质是一个计数器!!!

注意

  • 信号量和条件变量都必须被所有线程看见,所以都是临界资源!
  • 所以这个信号量的所有操作都必须是原子性的!
  • 申请到信号量代表了你对这一小份资源的预定,但是你并不需要立刻访问这一块临界资源!

初始化信号量

线程_第24张图片

  • sem:信号量的名称
  • pshared:信号量是否被共享
  • value :信号量设置的计数器的值

信号量的PV操作

  • P操作:申请临界资源——实际上就是计数器-- 实际上的API接口为
    如果信号量的计数器为0,那么线程就会被阻塞
    线程_第25张图片

  • V操作:释放临界资源——实际上就是计数器++ 实际上的API接口为
    线程_第26张图片

那么环形队列是怎么用信号量来实现
下面是一个比较简单的环形队列,生产者和消费者都有一个指针:

  • 生产者:指向生产者下一个将要生产的位置
  • 消费者:指向消费者下一个将要消费的位置

线程_第27张图片

但是这样的环形队列有一个问题:
队列满的时候和队列空的时候,两个指正会指向同一块区域。我们并不能很好的区分这两种情况。但是引入信号量之后就不会出现问题。我们用一个信号量来表示 生产任务的个数 ,另一个信号量来表示 剩余空间大小。不论是你是消费者还是生产者你上来不管三七二十一先申请对应的信号量,生产者申请剩余空间的信号量,消费者申请任务数量的信号量。如果能申请到才加锁访问空间

#include 
#include 
#include 
#include 
#include 

// 要搞清楚 环形队列和阻塞队列 到底在哪里不同
// 阻塞队列 是 先加锁 在 进入临界资源进行判断,这样如果队列满了,碰巧这一短时间都是生产者线程竞争成功
// 那么CPU 就会一直在做无用功(加锁、判断不满足、进入条件变量下等待并解锁)

// 而环形队列 在生成对象之就对临界资源进行了划分 这样临界资源的粒度更小,生产者和消费者只要不是访问同一个
// 小块就不会出现线程安全问题,这样就可以实现生产者和消费者同时访问临界资源;而这一点是阻塞队列无法达到的

namespace sht
{
    template <class T, size_t N>
    class RingQueue
    {
    public:
        RingQueue(int sz = 0)
            : p_cur(0), c_cur(0)
        {
            // 初始化信号量
            std::cout << N << std::endl;
            _array.resize(N);
            sem_init(&p_sem, 0, 0);
            sem_init(&c_sem, 0, N);
            pthread_mutex_init(&p_mutex, nullptr);
            pthread_mutex_init(&c_mutex, nullptr);
        }

        // 对PV操作进行封装

        // P操作:申请资源 == 计数器--
        void P(sem_t &x)
        {
            // 阻塞式申请
            int ret = sem_wait(&x);
        }

        // V操作:释放资源 == 计数器++
        void V(sem_t &x)
        {
            int ret = sem_post(&x);
        }

        void push(T &x)
        {
            P(c_sem);
            // 为什么这里要加锁? ——申请到信号量只代表队列里面有空间,但是哪个空间有可能会冲突
            pthread_mutex_lock(&p_mutex);
            _array[p_cur++] = &x;
            p_cur %= N;
            pthread_mutex_unlock(&p_mutex);
            V(p_sem);
        }

        void pop(T **x)
        {
            P(p_sem);
            pthread_mutex_lock(&c_mutex);
            *x = _array[c_cur++];
            c_cur %= N;
            pthread_mutex_unlock(&c_mutex);
            V(c_sem);
        }

    private:
        std::vector<T *> _array;
        sem_t p_sem; // 队列中已经被占有的空间
        sem_t c_sem; // 队列中空余的空间
        int p_cur;
        int c_cur;
        pthread_mutex_t p_mutex; // 生产者的锁
        pthread_mutex_t c_mutex; // 消费者的锁
    };
}

你可能感兴趣的:(开发语言,c++,线程)