系列文章目录
【c++重写Skynet底层01】skynet实战—sunnet基本框架,创建,开启,等待线程退出方法,模仿skynet写消息类
【c++重写Skynet底层02】skynet实战—模仿skynet写服务类,多线程下的对象管理,自旋锁互斥锁程序编写,哈希表管理对象,总结程序运行步骤
【c++重写Skynet底层03】skynet实战—全局消息队列的插入与弹出,模仿skynet发送消息,服务间的消息传送和消息处理
【c++重写Skynet底层04】skynet实战—演示程序PingPong,工作线程的等待与唤醒,改进版调度功能
前言
本章会在之前的基础上编写全局消息队列的插入与弹出,使工作线程能够调度各种服务,然后模仿skynet发送消息,编写工作线程的调度代码。
一、全局消息队列
要完成服务间的消息传送和消息处理,就需要有一个全局队列来处理调度各个服务,工作线程必须知道哪些服务有消息需要处理,哪些服务可以暂时忽略。因此可以将待处理的服务存入到全局队列当中,由工作线程在全局队列中取出服务进行处理。
以下代码为sunnet.h新增的变量和方法:
globalQueue代表全局队列,使用智能指针引用着“存有待处理消息”的服务。由于多个线程可能同时读写全局队列,因此定义了自旋锁globalLocal进行加锁。
//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);
void KillService(uint32_t id); //仅限服务自己调用
//新增
//发送消息
void Send(uint32_t toId, shared_ptr<BaseMsg> msg);
//全局队列操作
shared_ptr<Service> PopGlobalQueue();
void PushGlobalQueue(shared_ptr<Service> srv);
private:
//工作线程
int WORKER_NUM = 3; //工作线程数(配置)
vector<Worker*> workers; //worker对象
vector<thread*> workerThreads; //线程
//服务列表
unordered_map<uint32_t, shared_ptr<Service>> services; //string还是service?>?>
uint32_t maxId = 0; //最大ID
pthread_rwlock_t servicesLock; //读写锁
//新增
//全局队列
queue<shared_ptr<Service>> globalQueue;
int globalLen = 0; //队列长度
pthread_spinlock_t globalLock; //锁
private:
//开启工作线程
void StartWorker();
//获取服务
shared_ptr<Service> GetService(uint32_t id);
};
既然定义了锁,就需要在初始化函数中初始化锁,目前sunnet类需要初始化两个锁:
//开启系统
void Sunnet::Start() {
cout << "Hello Sunnet" << endl;
//锁
pthread_rwlock_init(&servicesLock, NULL);
pthread_spin_init(&globalLock, PTHREAD_PROCESS_PRIVATE);
//开启Worker
StartWorker();
}
工作线程每次会处理一定数量的消息,再根据服务是否空闲,决定是否将它重新插会全局队列(后面实现)
二、全局队列的插入与弹出
2.1. 全局队列的弹出
由于涉及多线程操作,因此插入和弹出全局队列的操作必须加锁,以下代码展示了弹出队列的方法:
//Sunnet.cpp
//弹出全局队列
shared_ptr<Service> Sunnet::PopGlobalQueue(){
shared_ptr<Service> srv = NULL;
pthread_spin_lock(&globalLock);
{
if (!globalQueue.empty()) {
srv = globalQueue.front();
globalQueue.pop();
globalLen--;
}
}
pthread_spin_unlock(&globalLock);
return srv;
}
2.2 全局队列的插入
与弹出操作十分相似:
//Sunnet.cpp
//插入全局队列
void Sunnet::PushGlobalQueue(shared_ptr<Service> srv){
pthread_spin_lock(&globalLock);
{
globalQueue.push(srv);
globalLen++;
}
pthread_spin_unlock(&globalLock);
}
2.3 标志位
按照目前的设计,工作线程可以很轻易获取到"待处理的服务",但如果想要知道一个服务是否在全局队列中,只能遍历,很慢。因此给服务类添加一个成员变量inGlobal,用于标注服务是否在队列中。
由于inGlobal也有可能被多个线程同时访问,因此也需要定义自旋锁inGlobalLock,以锁住inGlobal:
//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();
};
千万不要忘记将锁进行初始化和销毁:
//Service.cpp
//构造函数
Service::Service() {
//初始化锁
pthread_spin_init(&queueLock, PTHREAD_PROCESS_PRIVATE);//看看参数有什么区别,Skynet怎么用的
//新增
pthread_spin_init(&inGlobalLock, PTHREAD_PROCESS_PRIVATE);
}
//析构函数
Service::~Service(){
pthread_spin_destroy(&queueLock);
//新增
pthread_spin_destroy(&inGlobalLock);
}
接下来实现方法,方法非常简单,就是将inGlobal放到锁中:
//Service.cpp
void Service::SetInGlobal(bool isIn) {
pthread_spin_lock(&inGlobalLock);
{
inGlobal = isIn;
}
pthread_spin_unlock(&inGlobalLock);
}
三、模仿skynet发送消息
服务间的发送消息的全过程:服务1向服务2发送消息,将消息插入到服务2的消息队列中,如果服务2不在全局队列中,将它插入到全局队列,使工作线程能处理它。
send方法的时序流程是这样的:
① GetService查找目标服务,GetService里面用到了读写锁,但速度很快
② 调用目标服务的PushMsg向消息队列中插入数据,期间也会用自旋锁锁住消息队列
③ 由于任务服务都有可能用send方法,程序先对服务的inGlobal 加锁,判断服务是否已经在全局队列之中,如果不在调用PushGlobalQueue添加到全局队列中并设置inGlobal的值。
//Sunnet.cpp
//发送消息
void Sunnet::Send(uint32_t toId, shared_ptr<BaseMsg> msg){
shared_ptr<Service> toSrv = GetService(toId);
if(!toSrv){
cout << "Send fail, toSrv not exist toId:" << toId << endl;
return;
}
toSrv->PushMsg(msg);
//检查并放入全局队列
//为缩小临界区灵活控制,破坏封装性
bool hasPush = false;
pthread_spin_lock(&toSrv->inGlobalLock);
{
if(!toSrv->inGlobal) {
PushGlobalQueue(toSrv);
toSrv->inGlobal = true;
hasPush = true;
}
}
pthread_spin_unlock(&toSrv->inGlobalLock);
}
临界去的代码很巧妙,先给inGlobal加锁,再给globalQueue(PushGlobalQueue方法中的锁),加锁
四、工作线程的调度
只差最后一步,即实现工作线程的调度,让它们读取全局队列中的服务,处理服务消息
以下代码重写Worker线程的线程函数:
//Worker.cpp
#include "Service.h"
//线程函数
void Worker::operator()() {
while(true) {
shared_ptr<Service> srv = Sunnet::inst->PopGlobalQueue();
if(!srv){
usleep(100); //0.1s
// Sunnet::inst->WorkerWait();
}
else{
srv->ProcessMsgs(eachNum);
CheckAndPutGlobal(srv);
}
//cout << "working id:" <<id <<endl;
}
}
①代码中先从sunnet全局队列中获取一个服务,调用ProcessMsgs方法处理eachNum条消息,
②处理完后,调用CheckAndPutGlobal(后面实现),它会判断服务是否还有未处理的消息,如果有,重新放回全局队列中等待下一次处理。
③如果队列为空,线程将会等待100微妙,然后进入下一次循环。
在开启worker线程的时候,会给不同的线程的eachNum 分别赋值id的几次方(0,1,2,4,8)等
目的是:由于从全局队列取出服务,把服务重新放回全局队列都用到了锁,因此也会有一定的时间开销,worker[0]表示每处理一条就会有一次损耗,worker[1]表示每处理两条才会有一次损耗,worker[2]表示每处理四条消息才会有一次损耗,因此worker[2]的。
但并不是eachNum越大越好,过大的eachNum会导致worker线程一直在处理某几个服务,那些在全局队列中等待的服务会得不到及时的处理,会有较高的延迟。
worker->eachNum = 2 << i;
以下是CheckAndPutGlobal的实现:
在工作线程处理完消息后,必要时把服务重新放回全局队列当中,判断服务是否还有为处理完的消息,如有,把它放回全局队列。
先在.h文件中声明:
//Worker.h
#pragma once
#include <thread>
#include "Sunnet.h"
#include "Service.h"
class Sunnet;
using namespace std;
class Worker {
public:
int id; //编号
int eachNum; //每次处理多少条消息
void operator()(); //线程函数
private:
//辅助函数
void CheckAndPutGlobal(shared_ptr<Service> srv);
};
//Worker.cpp
//那些调Sunnet的通过传参数解决
//状态是不在队列中,global=true
void Worker::CheckAndPutGlobal(shared_ptr<Service> srv) {
//退出中(只能自己调退出,isExiting不会线程冲突)
if(srv->isExiting){
return;
}
pthread_spin_lock(&srv->queueLock);
{
//重新放回全局队列
if(!srv->msgQueue.empty()) {
//此时srv->inGlobal一定是true
Sunnet::inst->PushGlobalQueue(srv);
}
//不在队列中,重设inGlobal
else {
srv->SetInGlobal(false);
}
}
pthread_spin_unlock(&srv->queueLock);
}
由于Worker和Sunnet是相互引用的,因此需要再Sunnet.h中添加前置声明:
class Worker;
需要注意两点:
①若服务处于退出状态(isExiting为True),工作线程将不把服务放入全局队列中,智能指针计数为0,系统会销毁它
②虽然把inGlobal称为服务是否在全局队列中,但它也包含了服务在全局队列中与正在处理消息两层含义。因此,在重新返回全局队列的过程中,如果尚有未处理的消息,服务的inGlobal一定为True,无需再次设置。
总结
本章在之前的基础上编写全局消息队列的插入与弹出,使工作线程能够调度各种服务,然后模仿skynet发送消息,编写工作线程的调度代码。