02-联机数据逻辑需求分析与多线程编程基础
【从零开始的C++游戏开发】联机数据逻辑需求分析与多线程编程基础 | EasyX制作哈基米大冒险_哔哩哔哩_bilibili
1 需求分析
首先我们先根据游戏玩法进行需求分析。如果在还没有完全理清项目所需的功能时,把所有精力都放在代码上,会导致代码编写时目标模糊,接二连三的重构。
所以我们应该养成在动手前先动脑规划设计的习惯。
在本项目的计划中,我们的目标是实现联机打字游戏。那么从玩家角度讲,绘制客户端的画面需要哪些数据呢?
- 首先是需要两位玩家在地图世界中的实时坐标
- 然后是两位玩家当前的打字进度
那么我们应该考虑的是,这些数据应该通过什么样的结构进行表达和传输。
联机游戏的关键在于世界状态的拷贝。一种简单粗暴的设计,也是主流游戏引擎中十分流行的思想。那就是序列化场景中的游戏对象。将序列化后的数据,通过网络传输给其他设备后,在进行反序列化为另一台设备上的对象。从而实现游戏内容的复制。
1.1 序列化与反序列化
通俗一点讲,这两个过程可以看作是打包和解包的过程。举一个简单的例子,玩家类的对象主要由两个字段构成,当前的状态和当前的坐标.可以用这样的代码简化表示
class Player
{
public:
Player() = default;
~Player() = default;
private:
Status status;
Vector2 position;
};
那么序列化成JSON格式的字符串可能就是这个样子
{
"status": "walk",
"position": {
"x": 100,
"y": 200
}
}
那么这个字符串在传输给其他玩家之后,他们只需要解析这段字符串,将它赋值给本地对应的玩家对象,就可以完成不同设备的状态同步。
这个解析字符串到游戏对象的过程就是反序列化。
我们可以很清晰的看出,通过这种方式的网络同步还是需要依赖复杂的通信规则。
譬如需要生成注入JSON格式的字符串并高效地解析他。
所以我们还需要在HTTP层等网络协议基础之上,封装更复杂的应用层协议。
考虑到实现难度,在本项目中我们暂时不适用这种“完整对象”的网络拷贝。而是尽可能地避开协议的封装,考虑设计一下更简单轻量的数据通信方法。
我们注意到,游戏过程依赖于固定的文本内容。且不同客户端玩家的地图路径内容是完全相同的。那么我们便可以避开拷贝实际的游戏对象数据。而是同步更上层,更抽象的“游戏进度”。玩家输入文本的完成度与游戏场景中角色坐标是一一对应的关系,也就是说我们只需要在不同玩家的设备间同步这个进度值即可。
非常完美的是,游戏的胜负条件也是由进度决定的。所以对于服务器或者客户端彼此来说,我们仅需要知道两位玩家完成的字符数,这个int类型的值。至于玩家当前的位置和状态,这些数据可以交付给各个客户端在本地计算得到。
2 互斥锁
理清了游戏联机通信的数据需求。下面我们便可以开始从代码相对较少,逻辑简单的服务端代码开始分析。
我们先把之前的代码进行稍微的修改,将回调函数单独写出来
#include "../thirdparty/httplib.h"
void on_hello(const httplib::Request& req, httplib::Response& res)
{
std::cout << "Hello from Client!" << std::endl;
res.set_content("Hello From Server!", "text/plain");
}
int main(int argc, char** argv)
{
httplib::Server server;
server.Post("/hello", on_hello);
server.listen("localhost", 25565);
return 0;
}
我们来看on_hello这个函数的两个参数req(request)和res(response),这两个函数分别存储了客户端发送的请求数据和需要返回给客户端的响应数据。
我们可以在函数体内通过set_content等方法修改Response对象的值。当函数执行完毕后,response内的数据便会发送给客户端。
如果同一时刻有多个客户端访问了同一个服务器的hello路由,会发生什么呢?
cpp-httplib使用的时阻塞的socket IO,借助多线程实现多个客户端同时访问的并发操作。通俗理解,on_hello可能在同一时刻以你为多个客户端的同步访问而被执行多次。这就会引发一个问题,如果我们在函数内修改了变量的值。
像下面这样,on_hello函数可能被同时执行多次,导致g_str的值实际上是不确定的。这就是多线程编程中最经典的数据竞争问题。
on_hello内部的代码因为多线程的存在而形成了“临界区”。在访问时,如果不加以控制就很可能会出现读写冲突。
为了避免数据竞争的出现,我们就需要对临界区进行保护。让同一时刻只有一个线程进入临界区。
std::string g_str;
void on_hello(const httplib::Request& req, httplib::Response& res)
{
g_str = req.body;
std::cout << "Hello From Client!" << std::endl;
res.set_content("Hello From Server!", "text/plain");
}
这里就不得不再引入一个概念,“互斥锁”。互斥锁在STL中提供了mutex对象的实现。我们可以对一个std::mutex进行lock()和unlock(),也就是说当一个线程进入临界区时,编队临界区进行了锁定。在自己逻辑执行结束后再解锁。如果在锁定期间,其他线程需要访问同一个临界区。那么就需要等待mutex对象解锁后才能进入。
我们只需要把代码变成这样就可以了。
首先定义全局的互斥锁对象,在on_hello逻辑开始和退出时分别调用lock和unlock。这样我们就能实现安全的数据修改了。
std::string g_str;
std::mutex g_mutex;
void on_hello(const httplib::Request& req, httplib::Response& res)
{
g_mutex.lock();
g_str = req.body;
std::cout << "Hello from Client!" << std::endl;
res.set_content("Hello From Server!", "text/plain");
g_mutex.unlock();
}
如果不小心忘记调用unlock,会导致死锁。临界区的代码在某条线程执行一次后,便再也无法被进入。我们如何通过代码调优避免人为因素导致的死锁呢?
这里我们引入一个新的概念,“RAII”。全称叫做资源获取即初始化,Resource Acquisition Is Initialization。
我们知道在C++中对象在实例化时会调用其构造函数,在被删除时会调用其析构函数,那么我们便可以把加锁和解锁这两个步骤分别放到某个对象的构造函数和析构函数内部。只需要这个对象能够被正确构造和成功析构,那么对于锁的操作便能够更正确无误。比如下面这个SmartMutex类。
class SmartMutex
{
public:
SmartMutex()
{
_mutex.lock();
}
~SmartMutex()
{
_mutex.unlock();
}
private:
std::mutex _mutex;
};
不过所幸,STL为我们提供了这样的实现。也就是std::lock_guard模板类。
我们便可以使用下面这样的代码,当on_hello函数开始执行时,使用全局锁构造lock对象,而当函数返回时,lock对象离开作用域被销毁。调用它的析构函数完成解锁操作。
这样便可以避免认为一楼解锁代码调用导致的死锁发生了。
std::string g_str;
std::mutex g_mutex;
void on_hello(const httplib::Request& req, httplib::Response& res)
{
std::lock_guard<std::mutex> lock(g_mutex);
g_str = req.body;
std::cout << "Hello from Client!" << std::endl;
res.set_content("Hello From Server!", "text/plain");
}
评论
如果你已登录 GitHub,就可以直接在这里评论。