【网络多人游戏架构与编程1】
1、即使在今天,大多数的多人在线游戏在每个游戏会话中仍然限制玩家的数量 ,一般支持4~32个玩家。然而,在大规模多人在线游戏(massive multiplayer online gmme,MMO)中,成百上千的玩家将同时出现在同一个游戏会话中。
2、《星际围攻:部落》的开发者们最终将数据分为以下4种类型:
1)非保障数据。当带宽有限时,游戏选择首先丢弃这些数据。
2)保障数据。
3)最近状态的数据。只有最新玩家数据才是重要数据的场合。如游戏知道了玩家当前的生命值,那么他5秒之前的生命值就不重要了。
4)最快保障数据。如一个玩家的移动信息,在一个非常短的时间内极其重要,因此要忙传输。
3、对等网络模型需要O(n^2)的带宽,而C/S模型只需要O(n)带宽。
4、《星际围攻:部落》的网络模型:
1)平台数据包模块。标准套接字API的封装,可以构建和发送不同的数据包格式。
2)连接管理器。将网络中两台计算机之间的连接抽象化,连接管理器是不可靠的,它保证投递状态通知的正确传输。
3)流管理器。决定允许数据传输的最大速率。把请求按优先次序排列好,在带宽限制下,移动管理器、事件管理器、ghost管理器拥有最高优先级。
4)事件管理器。维持游戏模拟层产生的事件队列,这些事件可以看作是远程过程调用(remote procedure call, RPC)。
5)Ghost管理器。复制被认为与指定客户端相关的动态对象。这些信息按优先级分为“必须知道的”、“最好知道的”
6)移动管理器。当有移动数据可用时,流管理器总是给出站数据包添加所有的移动管理器数据。
5、在分组交换出现之前,长距离系统间传输信息使用电路交换。在传输的过程中,该电路要始终保持连通。一个时刻只能用于一个目的。
分组交换取消了电路交换一个时刻只能用于一个传输的限制,提供更高的可用性。它将传输的信息拆分为小块(数据包),基于一个存储转发的技术将他们发送到共享的线路中。
6、系统互联5层模型。
7、每种被选择作为物理层实现的物理介质,都有对应的协议或协议族来提供链路层所需要的服务。
8、网卡(Network Interface Controller,NIC)。
以太网链路层的帧格式,对于每一个数据包,其前导序列(preamble)、和帧开始标志(start frame delimiter,SFD)都是一样的,包含7个十六进制值 0x55 以及一个 0xD5。
以太网标准规定帧数数据最大长度为1500字节,称为最大传输单元(maximum transimission unit, MTU)。
帧检验序列(frame check sequences,FCS),用于保证收到的帧数据的正确性。显然,以太网帧只能保证如果收到的数据其数据肯定是对的,但并不保证一定能收到数据。
9、ARP映射表
10、IP路由表
11、IP数据包的长度比链路层的最大传输单元长怎么办?答案是分片(fragmentation)。
12、1024~49151称为用户端口(user port)或注册端口(registered port)。任何协议和应用开发者可以向IANA申请这个范围的端口号。
0~1023称为系统端口(system port)或预留端口(reversed port)。大部分操作系统,只允许 root级别的进程才能绑定系统端口。
49152~65535称为动态端口(dynamic port)。
13、TCP协议需要维持的状态变量:
14、带宽限制 = 接收窗口 / RTT。
15、socket,af参数为 AF_INET(IPv4)。
以下调用创建一个UDP socket:
SOCKET udpSocket = socket(AF_INET,SOCK_DGRAM,0);
以下调用创建一个TCP socket:
SOCKET tcpSocket = socket(AF_INET,SOCK_STREAM,0);
操作系统为每个数据创建IP头、传输层头。但是,通过创建type为SOCK_RAW和protocol为0的socket,可以直接写这两层头部的值。
16、socket库中大部分与平台无关的的函数使用写小字母,如socket。Windows下的winsock2函数以大写字母开头,有时使用 WSA前缀,来标记它们为非标准函数。
17、getaddrinfo() 执行DNS查询,会阻塞线程。Windows提供了GetAddrInfoEx函数,它允许无需手工创建的异步操作。
18、sockaddr 是通用地址,注意其成员以 sa_ 开头。
sockaddr_in 是IPv4地址,in指的是 internet。注意基成员以 sin_ 开头。
19、inet_pton()、InetPton()将字符串初始转为 in_addr。
20、socket 在用于发送和接收数据之前必须要bind。如果一个进程试图使用一个未 bind 的 socket 发送数据,网络库将自动为这个 socket 绑定一个可用的端口。
21、UDP Socket。sendto()的返回值,仅表示已经成功进入发送队列。
int sendto(SOCKET sock, const char *buf, int len, int flags, const sockaddr *to, int tolen);
recvfrom() 是接收数据,如果没能可读数据,线程将被阻塞,直到有数据到达。一理 recvfrom() 调用成功,socket 库将不再保存数据副本。如果 len 小于未读数据大小,则超过 len 的未读数据将被丢弃。
flags 的一个选项是 MSG_PEEK,意思不这次读取不删除缓冲区,以便下一次可以再读取。
一个常见的错误是,调用者希望通过设置这个参数来要求只接收来自特定地址的数据包,这是不可能的。所有数据报按序交付给recvfrom函数,
int recvfrom(SOCKET sock, char *buf, int len, int flags, sockaddr *from, int *fromlen);
22、UDP是无状态的、无连接的、不可靠的,所以每台主机只需要一个单独的socket来发送和接收数据。
但TCP是可靠的,需要发送数据前,在两台主机之间建立连接,此外必须维护和存储状态以重新发送丢失的数据包。因此针对每一个TCP连接,都需要一个额外的、单独的socket。
如果 accept 函数执行成功,将创建一个可以与远程主机通信的新socket。这个新socket被绑定到与监听socket相同的端口号上。当操作系统收到一个目的端口是该绑定端口的数据包时,它使用源地址、源端口来确定哪个socket应该接收这个数据。
监听 socket 没有连接任何主机,仅仅扮演调度者的角度。使用监听 socket 给远程主机发送数据,将会失败。
如果没有新连接,accept函数将阻塞,直到有新连接或超时。
23、TCP 中 Client 使用 connect() 函数连接服务器。connect() 函数将阻塞调用线程,直到连接被接受或超时。
int connect(SOCKET sock, const sockaddr *addr, int addrlen);
send() 函数,调用成功返回发送大小。如果缓冲区大小小于 len,则返回的值会比 len 小。如果缓冲区空间已满,则send()函数将阻塞,直到超时或有了空闲缓冲空间。注意,send()函数的返回成功,只表示数据已经插入队列等待发送,并不表示已经发送出去了。
int send(SOCKET sock, const char *buf, int len, int flags)
recv()函数,当len非0,而返回值为0时,说明对方主机发送了FIN包。当len为0,而返回值为0时,说明socket上有可读的数据。如果socket上没有数据可读,recv()函数将阻塞。
24、TCP、UDP socket 需要注意的地方。
可以在 tcp socket 上使用 sendto、recvfrom函数,但是地址参数将被忽略。在一些平台上,udp socket 上可以调用 connect 函数,以和远程地址绑定。
25、windows下使用 ioctrlsocket()设置 socket 选项。cmd的取值如 FIONBIO,argp任意非零值开启非阻塞,0将阻止开启。
int ioctrlsocket(SOCKET sock, long cmd, u_long* argp);
posix 兼容的操作系统下,使用 fcntl 函数。必须先用 cmd 为 F_GETFL 获取状态,将取到的状态与 O_NONBLOCK按位或运算,再使用 F_SETFL cmd 进行更新。
int fcntl(int sock, int cmd, ...);
26、Select > 非阻塞IO > 多线程
select函数如下:
27、一个简单的TCP服务器循环。
void DoTCPLoop() { // 1. 创建 TCPSocketPtr listenSocket = SocketUtil::CreateTCPSocket(INET); // 2. Bind SocketAddress receivingAddress(INADDR_ANY, 48000); if (listenSocket->Bind(receivingAddres)!=NO_ERROR) { return; } // 3. Read Pending Socket vector<TCPSocketPtr> readBlockSockets; readBlockSockets.push_back(listenSocket); vector<TCPSocketPtr> readableSockets; while(gIsGameRunning) { // 4. Select if (SocketUtil::Select(&readBlockSockets, &readableSockets, nullptr, nulllptr, nullptr, nullptr)) { // 5. 遍历 ReadableSockets for (const TCPSocketPtr& socket: readableSockets) { if (socket == listenSocket) { SocketAddress newClientAddress; auto newSocket = listenSocket->Accept(newClientAddress); // 6. 加入 Read Pending Socket readBlockSockets.push_back(newSocket); ProcessNewClient(newSocket, newClientAddress); } else { // it's a regular socket-process the data... char segment[GOOD_SEGMENT_SIZE]; // 7. Process Client Request int dataReceived = socket->Receive(segment, GOOD_SEGMENT_INT); if(dataReceived>0){ ProcessDataFromClient(socket, segment, dataReceived); } } } } } }
28、setsockopt
int setsockotp(SOCKET sock, int level, int optname, const char *optval, int optlen);
包括以下常用选项:
1)SO_REUSEADDR
2)SO_KEEPALIVE
3)TCP_NODELAY
29、压缩
1)稀疏数组压缩。
2)熵编码。用某个较短的值代替较长的值。
3)定点。用离散值代表连续值。
30、基本的反射系统。
1)先定义基本类型。
enum EPrimitiveType { EPT_Int, EPT_String, EPT_Float };
2)成员变量的封装。
class MemberVariable { public: MemberVariable(const char* inName, EPrimitiveType inPrimitiveType, uint32_t inOffset): mName(inName),mPrimitiveType(inPrimitiveType),mOffset(inOffset){} EPrimitiveType GetPrimitiveType() const {return mPrimitiveTYpe;} uint32_t GetOffset() const {return mOffset;} private: std::string mName; EPrimitiveType mPrimitiveType; uint32_t mOffset; }
3)成员变量容器。
class DataType { public: DataType(std::initializer_list<const MemberVariable&> inMVs): mMemberVariables(inMVs){} const std::vector<MemberVariable>& GetMemberVariables() const { return mMemberVariables; } private: std::vector<MemberVariable> mMemberVariables; }
31、基于基本的反射系统的简单序列化函数。
// inData: 对象指针 // inDataType: 对象成员列表 void Serialize(MemoryStream* inMemoryStream,const DataType* inDataType, uint8_t* inData) { for(auto& mv:inDataType->GetMemberVariables()) { void* mvData = inData + mv.GetOffset(); switch(mv.GetPrimitiveType()) { EPT_Int: inMemoryStream->Serialize(*(int*)mvData); break; EPT_String: inMemoryStream->Serialize(*(std::string*)mvData); break; EPT_Float: inMemoryStream->Serialize(*(float*)mvData); break; } } }
32、传输对象三步曲。从一台主机向另一台主机传输对象的行为称为复制(replication)。
1)对象ID。LinkingContext
2)类ID。ObjectCreationRegistry
3)对数数据的序列化。
33、游戏状态的增量更新,包含三种操作:增、改、删。
enum ReplicationAction { RA_Create, RA_Update, RA_Destroy, RA_MAX }
34、服务器客户端代码分离。
35、RPC框架中,每一个 RPC Function 都对应一个 RCPUnwrapFunc,如:
RPCManager 会使用到上面的 RCPUnwrapFunc。
class RPCManager { public: void RegisterUnwrapFunction(uint32_t inName, RPCUnwrapFunc inFunc) { assert(mNameToRPCTable, find(inName)==mNameToRPCTable.end()); mNameToRPCTable[inName]=inFunc; } void ProcessRPC(InputMemoryBitStream& inStream) { uint32_t name; inSteram.Read(name); mNameToRPCTable[name](inStream); } unordered_map<uint32_t, RPCUnwrapFunc> mNameToRPCTable; }
下面是一个 RCPUnwrapFUnc 的示例。
void UnwrapPlaySound(InputMemoryBitStream& inStream) { string soundName; Vector3 location; float volume; // 此处解参数 inStream.Read(soundName); inStream.Read(location); inStream.Read(volume); // 此处调用真正的函数 PlaySound(soundName, location, volume); } void RegisterRPCs(RPCManager* inRPCManager) { inRPCManager->RegisterUnwrapFunction('PSND', UnwrapPlaySound); }
37、对等网络中,主对等体的主要目的是提供游戏中已知的对等体的IP地址。除了这一个特例,主对等体与其他对等体行为一致。所以如果主对等体断开了,游戏仍然可以继续。
38、
39、
40、