【c++重写Skynet底层02】skynet实战---模仿skynet写服务类,多线程下的对象管理,自旋锁互斥锁程序编写,哈希表管理对象,总结程序运行步骤

系列文章目录

【c++重写Skynet底层01】skynet实战—sunnet基本框架,创建,开启,等待线程退出方法,模仿skynet写消息类
【c++重写Skynet底层02】skynet实战—模仿skynet写服务类,多线程下的对象管理,自旋锁互斥锁程序编写,哈希表管理对象,总结程序运行步骤
【c++重写Skynet底层03】skynet实战—全局消息队列的插入与弹出,模仿skynet发送消息,服务间的消息传送和消息处理
【c++重写Skynet底层04】skynet实战—演示程序PingPong,工作线程的等待与唤醒,改进版调度功能



前言

本章中继续对skynet的底层Sunnet进行c++重写。主要实现模仿skynet写服务类程序,并进行自选锁和读写锁等相关程序编写,
以及多线成中队列的插入和取出元素操作。第二节中介绍了多线程下的对象管理,如何使用哈希表管理对象和读写锁如何锁住哈希表,并进行新建,查找删除服务程序编写。最后综合第一章对全部工程进行编译运行,总结出整体流程和相应的功能。


提示:以下是本篇文章正文内容,下面案例可供参考

一、队列与锁(模仿skynet写服务类)

首先简单介绍下队列和锁。多线程编程中会广泛使用队列和锁,如果多条线程同时操作一个队列,将会造成难以预估的后果,因此需要给队列加锁。为了提高运行效率,系统会开启多条工作线程(如worker[1],worker[2]),且会同时处理不同的服务,如果服务1正在读取消息,服务2正在发送消息,那么两条线程同时操作消息队列, 可能就会发生资源竞争,因此在操作队列前必须加锁。

读取消息
发送消息
服务1/线程1
消息队列
服务2/线程2

在本节中,将会创建Sunnet系统的服务类,以展示消息队列,锁的各种用法。

1.1 编写服务类

新建文件Service.h Service.cpp,编写服务类,有点长,可以先关注成员变量部分
其中:
id是uint32_t(32为无符号整数),代表服务的编号,每个服务的id是唯一的;
type是一个智能指针,指向一串字符串,代表服务类型;
isExiting代表服务是否正在退出;
msgQueue代表消息队列,队列中的元素类型是shared_ptr,是服务类最重要的成员;
queueLock是pthread_spinlock_t 类型变量,代表自旋锁变量,用于锁住msgQueue;

//Service.h
#pragma once
#include <queue>
#include <thread>
#include "Msg.h"

using namespace std;

class Service {
public:
    //为效率灵活性放在public

    //唯一id
    uint32_t id;
    //类型
    shared_ptr<string> type;
    // 是否正在退出
    bool isExiting = false;
    //消息列表和锁
    queue<shared_ptr<BaseMsg>> msgQueue;
    pthread_spinlock_t queueLock;
    // //标记是否在全局队列  true:在队列中,或正在处理
    // bool inGlobal = false;
    // pthread_spinlock_t inGlobalLock;
public:       
    //构造和析构函数
    Service();
    ~Service();
    //回调函数(编写服务逻辑)
    void OnInit();
    void OnMsg(shared_ptr<BaseMsg> msg);
    void OnExit();
    //插入消息
    void PushMsg(shared_ptr<BaseMsg> msg);
    //执行消息
    bool ProcessMsg();
    void ProcessMsgs(int max);  
    //全局队列
    void SetInGlobal(bool isIn);
private:
    //取出一条消息
    shared_ptr<BaseMsg> PopMsg();
};

1.2 锁的初始化

接下来就要对服务类的成员函数进行定义,首先是服务类的构造函数(开始调用构造)和析构函数(完成调用销毁)。常有的锁有三种,详细介绍可以参考这篇文章,
c++多线程编程——常见锁的定义与linux环境下c++程序代码编写(目前更新至互斥锁,自旋锁,读写锁)
在这里我们使用自旋锁。
自旋锁(spinlock)是指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,直到其他线程释放锁,才能往下执行。因此使用锁之前必须先初始化,使用完后必须销毁,否则不能正常工作,因此根据这个特性可以在服务类的构造函数和析构函数进行相应的处理。

//Service.cpp
#include "Service.h"
#include "Sunnet.h"
#include <iostream>

//构造函数
Service::Service() {
    //初始化锁
    pthread_spin_init(&queueLock, PTHREAD_PROCESS_PRIVATE);//看看参数有什么区别,Skynet怎么用
}

//析构函数
Service::~Service(){
    pthread_spin_destroy(&queueLock);
}

自旋锁常用的API:

API说明
int pthread_spin_init(pthread_spinlock_t * ,int)初始化锁,第二个参数暂不需要关注
int pthread_spin_lock(pthread_spinlock_t * )加锁
int pthread_spin_unlock(pthread_spinlock_t * )解锁
int pthread_spin_destroy(pthread_spinlock_t * )销毁锁

1.3 多线程队列插入

插入操作很简单,调用msgQueue.push即可,但为了线程安全,在操作队列前需要加锁,操作完成后解锁,代码中用{}扩起来,是为了提醒中间代码段处于锁中,称之为临界区,当然也可以去掉。
(注意:多线程编程需要注意一点,临界区必须很小,不然影响程序效率,如线程2的服务正在往队列里插入数据,线程1请求读取数据,因此线程2加锁,它需要等待时间,线程1和线程2都在操作同一个队列,在线程2解锁之前,线程1处于等待状态u,临界区越大,意味着等待时间越长,影响效率)

//Service.cpp
//插入消息
void Service::PushMsg(shared_ptr<BaseMsg> msg) {
    pthread_spin_lock(&queueLock);
    {
        msgQueue.push(msg);
    }
    pthread_spin_unlock(&queueLock);
}

1.4 多线程队取出元素

临界区中由三行代码,分别是:判断队列是否为空;指向队列的第一个元素和弹出第一个元素,如果队列不为空,PopMsg()将返回队列中的第一个元素,否则否为null。

//Service.cpp
//取出消息
shared_ptr<BaseMsg> Service::PopMsg() {
    shared_ptr<BaseMsg> msg = NULL;
    //取一条消息
    pthread_spin_lock(&queueLock);
    {
        if (!msgQueue.empty()) { 
            msg =  msgQueue.front();
            msgQueue.pop();
        }
    }
    pthread_spin_unlock(&queueLock);
    return msg;
}

1.5 三个回调方法

服务的生命周期基本是这样的:

Service构造函数
OnInit初始化
OnMsg收到消息
OnMsg
...
Onexit退出
析构函数

因此分别定义OnInit(),OnMsg(),Onexit(),三个回调函数:

//Service.cpp
//创建服务后触发
void Service::OnInit() {
    cout << "[" << id <<"] OnInit"  << endl;
} 
//收到消息时触发
void Service::OnMsg(shared_ptr<BaseMsg> msg) {
    //测试用
   cout << "[" << id <<"] OnMsg"  << endl;
    
}
//退出服务时触发
void Service::OnExit() {
    cout << "[" << id <<"] OnExit"  << endl;
}

目前它们不具备任何功能,只是简单的打印。在后续的创建服务功能中调用OnInit,删除服务中调用Onexit,消息处理中调用OnMsg。

1.6 分析临界区

为了进一步分析临界区,先编写代码ProcessMsg方法,该方法会从消息队列中弹出一条消息,如果队列不为空,则调用回调函数OnMsg。

//Service.cpp
//处理一条消息,返回值代表是否处理
bool Service::ProcessMsg() {
    shared_ptr<BaseMsg> msg = PopMsg();
    if(msg) {
        OnMsg(msg);
        return true;
    }
    else {
        return false;
    }
} 

下面是ProcessMsg()的拓展代码,是一个辅助方法,用于处理多条消息,在后面我们会用到这个函数。程序会处理参数max指定的消息数,或处理完全部消息。

//Service.cpp
//处理N条消息,返回值代表是否处理
void Service::ProcessMsgs(int max) {
    for(int i=0; i<max; i++){
        bool succ = ProcessMsg();
        if(!succ){
            break;
        }
    }
}

二、多线程下的对象管理

对象管理指的是新建,获取,删除对象,Sunnet系统要管理多个服务,本节中将以新建和删除他们为例,介绍多线程对象管理方法。

2.1 使用哈希表

为了实现服务管理,需要为Sunnet添加成员,可以先关注成员变量部分:
代码中最重要的一个成员是services,用于存放系统中的所有服务,是一个哈希表(unordered_map,需包含头文件.h),哈希表是一种特殊数组,能够快速找到与键(服务id)对应的元素(服务对象)。哈希表的键值是uint32_t类型,代表服务id,值是shared_ptr类型,是服务对象的智能指针。

//Sunnet.h
#pragma once
#include <vector>
#include "Worker.h"
#include "Service.h" //新增
#include <unordered_map> //新增
using namespace std;
class Worker;

class Sunnet {
public:
    //单例
    static Sunnet* inst;
public:
    //构造函数
    Sunnet();
    //初始化并开始
    void Start();
    //等待运行
    void Wait();

     // 新增增删服务
    uint32_t NewService(shared_ptr<string> type); //string还是service?>?>
    void KillService(uint32_t id);     //仅限服务自己调用
 
private:
    //工作线程
    int WORKER_NUM = 3;              //工作线程数(配置)
    vector<Worker*> workers;         //worker对象
    vector<thread*> workerThreads;   //线程

    // 新增服务列表
    unordered_map<uint32_t, shared_ptr<Service>> services;
    uint32_t maxId = 0;              //最大ID
    pthread_rwlock_t servicesLock;   //读写锁


private:
    //开启工作线程
    void StartWorker();
    
    //新增获取服务
    shared_ptr<Service> GetService(uint32_t id);

};

2.2 读写锁锁住哈希表

正如第一节中的队列操作,哈希表的操作也需要加锁。尽管自旋锁,互斥锁,读写锁都能实现线程安全,但为了效率考虑,这里选用读写锁来锁住哈希表。
读写锁是一种特殊类型的自选锁,它把对共享资源的访问者划分为读者和写者,读者只对共享资源进行访问,写者需要对共享资源进行写操作。读写锁允许同时又多个线程来读取共享资源,不允许多线程同时写入。一个读写锁在同一时间只能有一个写者或者多个读者,不能又写又读。

//Sunnet.cpp
//开启系统
void Sunnet::Start() {
    cout << "Hello Sunnet" << endl;
    //锁
    pthread_rwlock_init(&servicesLock, NULL);
    //开启Worker
    StartWorker();
}

和自旋锁一样,也需要进行初始化,除了API不同,它与自旋锁的初始化没有区别:
读写锁常用的API:

API说明
int pthread_rwlock_init(pthread_rwlock_t *restrict relock, const pthread_rwlockattr_t *restrict attr)初始化锁,第二个参数一般填NULL
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock)加读锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock )加写锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock )解锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock )销毁锁

2.3 新建服务

管理对象,可以理解为“增删改查”。先用make_shared创建服务对象,然后锁住临界区,将新服务插入哈希表services,最后调用服务的初始化方法Oninit:

//Sunnet.cpp
//新建服务
uint32_t Sunnet::NewService(shared_ptr<string> type) {
    auto srv = make_shared<Service>();
    srv->type = type;
    pthread_rwlock_wrlock(&servicesLock);
    {
        srv->id = maxId; 
        maxId++;
        services.emplace(srv->id, srv);
    }
    pthread_rwlock_unlock(&servicesLock);
    srv->OnInit(); //初始化
    return srv->id;
}

Oninit方法由用户自定义。临界区的代码只有三行,对全局的变量maxId和services的读写操作。

2.4 查找服务

哈希表查找操作的时间复杂度为O(1),查找速度快,查找表是个读操作,因此也需要加上读锁:

//Sunnet.cpp
//由id查找服务
shared_ptr<Service> Sunnet::GetService(uint32_t id) {
    shared_ptr<Service> srv = NULL;
    pthread_rwlock_rdlock(&servicesLock);
    {
        unordered_map<uint32_t, shared_ptr<Service>>::iterator iter = services.find (id);
        if (iter != services.end()){
            srv = iter->second;
        }
    }
    pthread_rwlock_unlock(&servicesLock);
    return srv;
}

2.5 删除服务

参数id代表着等待删除的编号。
删除对象并不是简单的把它从哈希表中移除,KillService是删除服务的一部分,它只是切断了Sunnet::inst与服务的连接,由于使用了智能指针,如果没有其他地方引用服务,服务将被自动释放。**但是,如果在删除服务3(假设)的同时,服务2正在向服务3发送消息,那么它也会找到服务3,往消息队列中插入数据。在这种情况下,就算切断Sunnet::inst与服务3的关联,服务3的引用计数也不为0,也不会马上释放,所以代码中把isExiting标注为True,便于后续处理。**另外,服务3释放之后,它所引用的消息计数也会减1,如果消息只被服务3引用,它们也会被释放。

//Sunnet.cpp
//删除服务
//只能service自己调自己,因为srv->OnExit、srv->isExiting不加锁
void Sunnet::KillService(uint32_t id) {
    shared_ptr<Service> srv = GetService(id);
    if(!srv){
        return;
    }
    //退出前
    srv->OnExit();
    srv->isExiting = true;
    //删列表
    pthread_rwlock_wrlock(&servicesLock);
    {
        services.erase(id);
    }
    pthread_rwlock_unlock(&servicesLock);
}

在多线程环境下,需要很谨慎的对待删除操作。Sunnet系统只能由服务在自己的消息处理函数中调用KillService删除自己。

三、程序开始运行

经历过前面的学习之后,声明了消息类(见上一篇博客),服务类,,还添加了创建,删除服务的方法,现在就可以来对程序进行调试:
在main函数中调用三次NewService,新建ping1 ,ping2,pong三个服务

//main.cpp
#include "Sunnet.h"
#include <string>
#include "Sunnet.h"
using namespace std;
int test() {
    auto pingType = make_shared<string>("ping");
    uint32_t ping1 =  Sunnet::inst->NewService(pingType);
    uint32_t ping2 = Sunnet::inst->NewService(pingType);
    uint32_t pong =  Sunnet::inst->NewService(pingType);

    return 0;
}

int main(){
  new Sunnet();
  Sunnet::inst->Start();
  test();
  Sunnet::inst->Wait();
    
  return 0;
}

执行cmake进行编译,然后再执行./sunnet运行,可以得到如图结果
在这里插入图片描述
此处创建了三个服务,尽管工作线程在空转,但组成Actor模型的关键元素——服务,消息,工作线程都已具备,worker线程不断循环,三个服务被创建,它们的初始化回调OnInit()方法会被调用。

总结

根据两篇博客的内容,基本上已经创建了Sunnet系统所需要的工作线程,消息类,服务类。接下来会从main函数出发,重新梳理一遍运行流程:

①:首先,main函数会new一个Sunnet()对象出来,然后先进行初始化 Sunnet::inst->Start();

②:进入 Sunnet::inst->Start()进行初始化会先打印出"Hello Sunnet",然后对读写锁进行初始化,再调用类成员函数 StartWorker()创建线程;

③: StartWorker()会根据WORKER_NUM(设置3)数量用for循环创建线程thread和线程对象
worker(new 一个 Worker对象),并将其添加到列表当中workers和workerThreads。每次循环都会打印start worker thread:";

④: Worker()每次都会打印出working id 然后阻塞0.1s。(有时候会看到working1在后面,可能是程序运行的时候前面的一个线程先运行完了才创建后面一个。)

⑤:Sunnet::inst->Start() 完成后会调用 test(),test中会创建三个服务ping1,ping2,ping0,调用Sunnet::inst->NewService进行服务创建。

⑥: Sunnet::inst->NewService中会在读写锁中对servicesLock进行锁定,以确保对services的线程安全访问,在临界区中会为对象分配一个指定id,即maxid当前值,然后将服务对象添加到services容器中,emplace 函数将 id 和服务对象作为键值对插入。最后释放锁,调用 srv->OnInit() 对服务对象进行初始化,并返回服务对象的 id。

⑦: OnInit()在serivce中,会打印出[2] OnInit信息。

⑧: main函数会调用Sunnet::inst->Wait();函数unnet::Wait(), 用于等待第一个工作线程执行完毕。在这个函数中,首先通过 workerThreads[0] 获取第一个工作线程对象的指针。接下来,使用 join() 方法等待该线程执行完毕。join() 方法会阻塞当前线程(即 Wait() 函数所在的线程),直到被调用的线程执行完毕。使线程进入循环,不断打印出working id。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值