【C++面试题】多线程不加锁消息队列

76 篇文章 1 订阅

前文跟大家一起赏析了大师作——redis的事件机制,今献丑将自己常用的一个消息循环分享给大家。

笔者认为没有任何算法能完美的适配所有场景,就像前文提到的redis事件机制,它其实在处理就绪事件时是阻塞执行的。如果同时就绪的多个事件中有比较耗时的运算,那等待其他事件完成的用户体验就不太好了。

本文设计的环形队列是多线程并发执行时可用的,一次往队列中写入一个事件,队列只记录事件相关数据的指针,另外使用原子操作来记录读取这个指针,迅速、安全。因为指针占空间小而且一致,所以直接用数组来保存它们。

简单原理

如下图所示:

假设数组大小为8,只有0-7八个存储位置,用蓝色表示有数据存储的位置:当前数组索引4、5、6有数据,那么当前的读取位置就应该是4,如果现在要插入数据那么就应该插到索引7的位置;再次插入就应该插到索引8(真实索引其实是0),以此类推。

为保证读取位置和写入位置不混乱,每次读写都要保证写位置索引大于读位置索引,所以当循环使用该数组时,如下图:

用红色表示有数据存储的位置:当前的读取位置是6,写入位置真实索引是3,为了保证写入索引大于读取索引,写入位置我们用11表示,插入时取模操作即可。

多线程安全

为保证多线程使用安全,插入和读取时要保证原子性,笔者采用的是atomic_flag来模拟自旋锁的形式。环形队列的结构体很简单:

//定义一个环形队列结构体
typedef struct tthoop
{
    int tlen;//消息环其实是个数组,这个字段为数组的真实长度
    atomic_uint tread; //用来记录最后一次读取位置
    atomic_uint twrite;//最后一次写入的位置
    atomic_flag taflag;  //原子操作标志,类似于锁.用于每次写入和读取时锁住tread和twrite
    void** tqueue;  //用来储存消息指针的数组
} tthoop;

读写时使用如下代码获取原子标志,来达到自旋锁的效果:

while (atomic_flag_test_and_set(&th->taflag)) ;

然后是读写逻辑,因为本消息环只是记录一个指针,所以读写操作很快就只是简单的赋值。

读写完毕,立即将标志清零,以让其他线程获得:

atomic_flag_clear(&th->taflag); //原子标志清零,类似于解锁

下面是这个环形队列的全部实现源码非常简单。

首先定义了一些常量,因为这个代码是在笔者自用的代码中拿出来的,所以单独放在这里有些常量貌似没啥必要,但如果结合更多功能时这些定义是有意义的,所以我未做修改直接贴出来:

//tsrc/tops.h
#ifndef __TOPS_H
#define __TOPS_H

#define TOPS_NULL ((void*)0) //空指针
//下面几个常数用于位运算,适用于用标志位记录发生的故障等信息的场景
#define TOPS_B1 1   //1<<0:0000 0001
#define TOPS_B2 2   //1<<1:0000 0010
#define TOPS_B3 4   //1<<2:0000 0100
#define TOPS_B4 8   //1<<4:0000 1000
#define TOPS_B5 16  //1<<5:0001 0000
#define TOPS_B6 32  //1<<6:0010 0000
#define TOPS_B7 64  //1<<7:0100 0000
#define TOPS_B8 128 //1<<8:1000 0000

/*
自定义一些数据类型,以方便以后扩展,比如上面的位运算目前只定义了8个变量,用一个unsigned char就能处理,所以用ttbit来表示unsigned char
如果以后扩展成16个,那就需要unsigned short才能处理,这样只需要修改下面的自定义类型ttbit为unsigned short类型就行了
*/
typedef unsigned char ttbit; //自定义类型全部以tt开头(tops type)


#endif  /* __TOPS_H */

下面是环形队列的头文件,本队列就只对外提供了读和写两个方法。

//tsrc/tshoop.h
#ifndef __TSHOOP_H
#define __TSHOOP_H

#ifdef __STDC_NO_ATOMICS__ //原子操作 是新c标准的可选项,凡是不支持原子操作的实现都声明了__STDC_NO_ATOMICS__宏
#error “本程序依赖c11标准的原子操作,您的编译器不支持此标准”//......
#endif //结束对__STDC_NO_ATOMICS__宏(是否支持c标准的原子操作)的检测判断

#include <stdatomic.h>
#include "tops.h"
//定义一个环形队列结构体
typedef struct tthoop
{
    int tlen;//消息环其实是个数组,这个字段为数组的真实长度
    atomic_uint tread; //用来记录最后一次读取位置
    atomic_uint twrite;//最后一次写入的位置
    atomic_flag taflag;  //原子操作标志,类似于锁.用于每次写入和读取时锁住tread和twrite
    void** tqueue;  //用来储存消息指针的数组
} tthoop;

//结构体初始化
void tf_hoop_init(tthoop*th,int len);
//结构体中的tqueue释放
void tf_hoop_free(tthoop*th);

/*
从消息队列中读出一条消息,并将消息指针记录在arg所保存的指针中
*/
ttbit tf_hoop_read(tthoop*th,void** arg,int*ind);

/*
向消息队列添加一条消息,arg为要记录消息数据的指针
*/
ttbit tf_hoop_write(tthoop*th,void* arg,int*ind);

#endif

下面是实现源码,笔者都加了详细的注释。

//tsrc/tshoop.c
#include <stdatomic.h>
#include "tshoop.h"
#include <stdlib.h>
/*
读取和写入操作都会用到的功能,定义为static,所以这个函数只能在本文件中被调用
*/
static int tf_atomic_check_fun(ttbit *res,tthoop *th)
{
    if (th->tread > th->twrite)
    {
        *res |= TOPS_B1; //读位置索引大于写位置索引理论上不应该出现,若有这类情况,说明代码逻辑有问题
        return 1;
    }
    if ((th->twrite - th->tread) > th->tlen)
    {
        *res |= TOPS_B2; //写位置索引位置与读位置索引间距大于队列长度了,这也不应该出现
        return 1;
    }
    /*记录的读写位置索引都大于等于数组长度以后取模调整一下索引数字
    这是将这个消息数组当成环形队列的办法
    比如数组长度是32,当前读出了位置11,则允许写到43,其实43取模后就是11,
    为了方便判断写位置一定要大于读位置,所以记录的写位置仍是43,而不是11,
    等读写位置都大于32时,统一做个取模计算
    */
    if ((th->tread >= th->tlen) && (th->twrite >= th->tlen))
    {
        th->tread %= th->tlen;
        th->twrite %= th->tlen;
    }

    return 0; //0表示没有出错
}

//消息环结构体初始化
void tf_hoop_init(tthoop*th,int len){
    th->tlen=len;//数组长度
    //为数组申请内存,暂时使用malloc,因为malloc、calloc和free会引起内存碎片,所以后期这里可能会优化
    //不过消息环在程序运行过程中应该不会频繁申请释放,所以这个影响不大.
    do
    {
        th->tqueue=(void**)calloc(sizeof(void*),len);
    }
    while(th->tqueue==NULL);
    
    th->tread=0;//读取位置
    th->twrite=0;//写入位置
    //下面这句用于初始化atomic_flag,一般的写法:atomic_flag taflag=c但这里不能给这样写,因为ATOMIC_FLAG_INIT其实是{0}
    atomic_flag_clear(&th->taflag);
}

//消息环数组申请的内存释放,注意释放的只是数组,内部的消息所占内存由调用者自己负责清理
void tf_hoop_free(tthoop*th){
    if(th->tqueue){
        free(th->tqueue);//跟初始化一样,暂时使用free,后期如果优化内存管理时,可能会重写这里.
        th->tqueue=NULL;
    }
}

/*
向消息队列添加一条消息或者读取一条消息,
arg为要记录消息数据的指针,或者读出的数据指针。
用ttbit(tops.h中定义)记录出错信息,成功返回0
可能的错误包含在ttbit的每个位中
TOPS_B1:0000 0001  读位置索引大于写位置索引理论上不应该出现,若有这类情况,说明代码逻辑有问题
TOPS_B2:0000 0010  写位置索引位置与读位置索引间距大于队列长度了,这也不应该出现
TOPS_B3:0000 0100  消息队列为空
TOPS_B4:0000 1000  消息队列已满,暂时不能添加
TOPS_B5:0001 0000  读取位置指针为空,应该是写入操作未完成。
TOPS_B6:0010 0000  写入的位置不为空,表示此标志位未被读取,或重复写入

消息队列,只是将每个消息的数据指针存入 数组tqueue中
用两个整数记下最近一次读取和写入的索引位置,循环使用这个数组,类似一个环形队列,每次读取和写入都要保证索引位置相关操作的原子性
如果不能使用原子操作,就不得不对这些操作进行加锁了
*/


/*
从消息队列中读出一条消息,并将消息指针记录在arg中
*/
ttbit tf_hoop_read(tthoop*th,void** arg,int*ind)
{
    *ind=-1;//记录成功读取的索引位置
    ttbit res = 0; //定义返回值,如果操作成功则返回0

    //使用while循环获得原子标志,类似于自旋锁
    while (atomic_flag_test_and_set(&th->taflag))
        ;
    //开始处理读位置索引
    //这个do-while不是为了循环,只是为了使用break语句跳到下面的清零操作,这样就不用写很多清零语句了
    do
    {
        if (tf_atomic_check_fun(&res,th))
            break;
        if (th->tread == th->twrite)
        {
            res |= TOPS_B3; //已读位置索引等于写位置索引,表示没有未读取的消息了
            break;
        }

        *ind = ++th->tread % th->tlen; //计算真实可读索引
        if(th->tqueue[*ind]==NULL)
        {
            res |= TOPS_B5; //读取的位置为空
            break;
        }
        *arg = th->tqueue[*ind];                      //读出消息
        th->tqueue[*ind]=NULL;
    } while (0);
    atomic_flag_clear(&th->taflag); //原子标志清零,类似于解锁
    return res;
}

/*
向消息队列中写入一条消息,arg为消息内容数据块指针
*/
ttbit tf_hoop_write(tthoop*th,void* arg,int*ind)
{
    *ind=-1;//用来记录成功写入的索引位置
    ttbit res = 0; //定义返回值,如果操作成功则返回0

    //使用while循环获得原子标志,类似于自旋锁
    while (atomic_flag_test_and_set(&th->taflag));
    
    //开始处理读位置索引
    //这个do-while不是为了循环,只是为了使用break语句跳到下面的清零操作,这样就不用写很多清零语句了
    do
    {
        if (tf_atomic_check_fun(&res,th))
            break;
        if ((th->twrite-th->tread) == th->tlen)
        {
            res |= TOPS_B4; //读写位置间距恰好等于队列长度,表示队列已满,不能再写了
            break;
        }

        *ind = ++th->twrite % th->tlen; //计算真实写入位置索引
        if(th->tqueue[*ind]!=NULL)
        {
            res |= TOPS_B5; //写入的位置不为空
            break;
        }
        th->tqueue[*ind]=arg;                      //写入消息
    } while (0);
    atomic_flag_clear(&th->taflag); //原子标志清零,类似于解锁
    return res;
}

最后用一个简单的线程池来检测下环形队列的功能:

//hooptest.c
#include <stdio.h>
#include <pthread.h>
#include "tsrc/tops.h"
#include "tsrc/tshoop.h"
#include <stdlib.h>
#include <stdatomic.h>

//消息环 全局变量
tthoop thobj;

atomic_int a=0;//已经写入的消息数,使用原子变量记录,防止紊乱
atomic_int c=0;//已经读出的消息数
pthread_cond_t cond_read;//读消息线程的条件变量
pthread_mutex_t mtx_read;//读消息条件变量使用的锁
pthread_cond_t cond_write;//写消息线程的条件变量
pthread_mutex_t mtx_write;//写消息条件变量使用的锁

pthread_t pt[6];//线程数组,算是个简单的线程池吧

//写消息函数
void* w_thrd_fun(){
    pthread_t selfid=pthread_self();//获取本线程id
    int pt_ind;//本线程在线程数组中的索引
    for(int i=0; i<6; i++){
        if(pthread_equal(pt[i],selfid)){
            pt_ind=i;
            break;
        }
    }
    while(a<100){//写入的消息数小于100的话,就继续执行写操作
        //arg就是要记入消息环的数据,本例中只是使用了一个简单的整数,其实他可以是任意类型,读者朋友可以自行设计自己的消息结构体,来替换这个整数
        int *arg = (int*)malloc(sizeof(int));  
        *arg=a;//消息内容是个整数
        int ind=-1;//用来获得消息记录位置索引的
        ttbit r=tf_hoop_write(&thobj,arg,&ind);//写入消息环
        if(!r){//写入成功了
            a++;
            printf("线程%d :: 第%d个消息成功记录在索引: %d\n",pt_ind,*(int*)arg,ind);
            //发送一个写入成功的信号,这是如果有等待读取消息的线程会被唤醒。
            pthread_mutex_lock(&mtx_read);
            pthread_cond_signal(&cond_read);
            pthread_mutex_unlock(&mtx_read);
        }
        //写入失败时显示失败原因
        if(r&TOPS_B1){
            printf("线程%d :: 第%d个消息记录因读位置索引大于写位置索引而操作失败!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n",pt_ind,*(int*)arg);
        }
        if(r&TOPS_B2){
            printf("线程%d :: 第%d个消息记录因写位置索引位置与读位置索引间距大于队列长度了而操作失败!!!!!!!!!!!!!!!!!!!!!!!\n",pt_ind,*(int*)arg);
        }
        
        if(r&TOPS_B4){//消息环被写满了
            printf("线程%d :: 第%d个消息记录时消息队列已满,线程即将挂起\n",pt_ind,*(int*)arg);
            //这时候就阻塞本线程,等待可写信号发生
            pthread_mutex_lock(&mtx_write);
            pthread_cond_wait(&cond_write,&mtx_write);
            pthread_mutex_unlock(&mtx_write);
            printf("线程%d :: 被唤醒\n",pt_ind);
            continue;
            
        }
        if(r&TOPS_B5){
            printf("线程%d :: 第%d个消息重复写入!!!!!!!!!!!!!!!!!!!!!!!!!!!\n",pt_ind,*(int*)arg);
        }
        
    }
    //执行到这一步说明消息环写够了100次了。线程退出
    printf("线程%d :: 退出\n",pt_ind);
    return NULL;
}

void* r_thrd_fun(){
    //首先同样是获取本线程的索引,用于展示
    pthread_t selfid=pthread_self();
    int pt_ind;
    for(int i=0; i<6; i++){
        if(pthread_equal(pt[i],selfid)){
            pt_ind=i;
            break;
        }
    }

    while(c<100){//读出的消息总数不到100,继续读
        int b=0;//创建一个临时变量,只是为了初始化下面的arg
        int *pa=&b;
        int**arg=&pa;//将指向指针的指针传给read方法读取环中的消息
        int ind=-1;//用于获取消息记录位置索引的
        ttbit r=tf_hoop_read(&thobj,(void**)arg,&ind);//从消息环中读出消息
        if(!r&&arg){//读取成功
            c++;//读取次数加1
            printf("线程%d :: 第%d个消息读取成功------------%d 为它原来在数组中存储的索引位置-r:%d--w:%d-----\n",pt_ind,*(int*)*arg,ind,thobj.tread,thobj.twrite);
            //上面的展示语句就相当于实际操作中的消息处理了,处理结束,下面就清理这条消息占用的资源
            free(*arg);

            //读出一条消息后就至少会空一个位置出来,所以发送一个可写信号以唤醒等待写的线程
            pthread_mutex_lock(&mtx_write);
            pthread_cond_signal(&cond_write);
            pthread_mutex_unlock(&mtx_write);
        }
        //读消息失败展示错误信息
        if(r&TOPS_B1){
            printf("线程%d :: 因读位置索引大于写位置索引而操作失败!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n",pt_ind);
        }
        if(r&TOPS_B2){
            printf("线程%d :: 写位置索引位置与读位置索引间距大于队列长度了而操作失败!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n",pt_ind);
        }
        if(r&TOPS_B3){//消息环中无数据了,所以要等待写入才继续本线程
            printf("线程%d :: 消息队列为空了,即将挂起\n",pt_ind);
            //挂起线程等待写入线程发送可写信号
            pthread_mutex_lock(&mtx_read);
            pthread_cond_wait(&cond_read,&mtx_read);
            pthread_mutex_unlock(&mtx_read);
            printf("线程%d :: 被唤醒\n",pt_ind);
            
            continue;
        }
        
        if(r&TOPS_B5){
            printf("线程%d :: 第%d个消息重复读取或写入操作未成功!!!!!!!!!!!!!!!!!!!!!!!!!!!\n",pt_ind,*(int*)arg);
        }
        
    }
    //到这一步说明成功读取次数够100次了,线程退出
    printf("线程%d :: 退出\n",pt_ind);
    return NULL;
}

int main()
{
    tf_hoop_init(&thobj,32);//初始化消息环
    //创建六个线程
    int j=0;
    while(j <6){
        if(j<2) pthread_create(&pt[j],0,w_thrd_fun,NULL);//0和1线程是写消息线程
        else pthread_create(&pt[j],0,r_thrd_fun,NULL);//2-5是读线程
        j++;
    }
    
    while(--j >=0){
        pthread_join(pt[j],NULL);
    }
    return 0;
}
//

//苹果编译指令: cc -std=c1x -g -o main tsrc/tshoop.c hooptest.c -pthread
//linux编译指令: gcc -std=c1x -g -o main tsrc/tshoop.c hooptest.c -lpthread

/*
cd tops
gcc -std=c1x -g -o main tsrc/tshoop.c hooptest.c -lpthread
./main
*/

这个很小但也有用武之地的环,其实可以扩展出很多功能的,欢迎各位与笔者探讨。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值