第 0 章 [ 快速入门 ] 网络游戏编程:网络和游戏编程的技术基础

第 0 章 [快速入门]网络游戏编程:网络和游戏编程的技术基础

开发网络游戏必须掌握以下两大块技术。即使所处的团队实行分工制度,开发人员也需要对这两方面有所了解。

  • 网络编程(Network Programming)

  • 游戏编程(Game Programming)

为了便于读者更容易理解之后的内容,本章首先介绍一下网络编程和游戏编程的基本概念,对开发网络游戏来说,它们是非常重要的。本章将按照如下顺序进行讲解。

  • 网络游戏开发者所需了解的网络编程基础

  • 套接字编程

  • RPC 指南

  • 游戏编程基础

首先,0.1~ 0.3 节将介绍网络编程。这一部分的要点如图 0.A 所示,请先掌握以下重点,然后我们马上进入正题。

图 0.A 网络游戏的层次体系

0.1 网络游戏开发者所需了解的网络编程基础

首先,我们来学习网络游戏编程所需的网络编程基础知识。

0.1.1 网络编程是必需的

在实现网络游戏的过程中,网络上各个主机之间的数据传输(发送数据、接收数据)极其重要,因此,为了处理通信(也就是与远程终端进行数据的输入输出),必须掌握网络编程。

利用网络游戏的通信中间件(网络中间件)可以在很大程度上隐藏复杂的处理过程,但是如果不理解网络通信的内部机制,就很难有效运用中间件的功能,调试效率也会变低。因此,虽然不用亲自从底层开始编写所有的代码来实现网络通信,但是理解其中的机制还是很有必要的。此外,理解了本书中所介绍的这方面内容,也有助于理解网络通信中的基本技术。

网络游戏与网络游戏相关的网络编程基础知识并不多,本章将重点介绍那些网络游戏所需的网络编程基础知识。理解了这一块内容才能有效地加以运用,所以一起努力吧!

0.1.2 网络编程与互联网编程

为了使运行在两台以上机器的相关进程能够协调工作,必须在它们之间实现一些必要的通信功能,网络编程就是指实现进程间通信所需的编程技术。

网络编程的范畴非常广泛。比如,在使用 USB 接口将外置 HDD(Hard Disk Drive,硬盘驱动器)连接到 PC 的情况下需要网络编程,因为要使 PC 上运行的进程与 HDD 控制器上运行的进程进行通信。再有,SCSI(Small Computer System Interface)、红外线通信,以至空调的遥控器等都离不开网络编程。

除了任天堂 DS 之间使用有线连接的情况之外,网络游戏中的网络编程一般只使用与互联网有关的技术。这种互联网方面的通信编程称为“互联网编程”(Internet Programming),在国外有很多这方面的参考资料。

实现多人网络游戏的前提就是使用互联网,因此本书只讨论以互联网编程为主的网络编程技术。

0.1.3 互联网编程的历史和思想

在互联网通信的事实标准中,IP(Internet Protocol,网际协议)、TCP(Transmission Control Protocol,传输控制协议)、UDP(User Datagram Protocol,用户数据报协议)等网络通信协议自定义以来的三十几年里,基本的通信方式并没有发生什么变化。IP 协议等以安全、舒适地使用互联网服务为目的而产生的网络协议,被技术标准化组织 IETF(Internet Engineering Task Force,互联网工程任务组)作为基础资料收录在 RFC(Request For Comments1)中。RFC 并不具有法律上的强制力,但是遵守这些标准可以带来经济上的利益,所以很多人都以此为准。

1RFC 是一系列以编号排定的文件。文件收集了有关互联网相关信息,以及 UNIX 和互联网社区的软件文件。——译者注。

RFC 以编号排定,比如,TCP 是在 RFC 793 中定义的,DNS(Domain Name System,域名系统)的实现是在 RFC 1123 中,HTTP(HyperText Transfer Protocol,超文本传输协议)的 HTTP 1.0 则是在 RFC 1945 中定义的。每次版本更新后,RFC 的编号也会随之更新。截至本书撰写时(2010 年 9 月),RFC 的文档总数已经超过了 6000 份 2

2顺带一提,RFC 1(http://www.rfc-editor.org/rfc/rfc1.txt)描述了使用串行电缆将两台主机进行连接的过程,这实在是很简单的一件事情,令人不禁感慨互联网黎明期的艰辛。

IETF 的 RFC 是为了共享通信形式和协议而产生的,它里面并没有定义实际的编程接口,因此要开始网络编程,还必须进一步掌握一些基础知识。

0.1.4 OSI 参考模型——透明地处理标准和硬件的变化

如果每次出现新标准时,比如出现了更高速的 1Tbit 以太网,都要修改程序,那将非常麻烦,而且网络设备类型多种多样,所以由此产生了希望能尽可能不依赖于这些设备,来透明地处理网络通信问题的要求。

在过去,每次为了应对设备更新都不得不修改程序,而随着网络中主机数量的不断增加,为了降低由此产生的成本,20 世纪 80 年代,美国发起了制定标准化模型的运动。

其研究成果就是 OSI 参考模型(OSI Reference Model)。OSI 参考模型的优点为:如果基于该模型进行设计,那么即使标准和硬件发生了变更,位于其上方的层次也不需要做过多的改动。通信系统是一种“设备与设备互连”的系统,所以应该尽可能不要对相连的设备造成影响,而是对其本身进行修改,这才是我们期望的设计方式。

OSI 参考模型提出了 7 层结构,以下介绍各个层次的功能。

  • 第 7 层:应用层

    提供具体的通信服务,比如文件和邮件的传送,访问远程数据库等。这一层的协议包括 HTTP、FTP(File Transfer Protocol,文件传输协议)等。

  • 第 6 层:表示层

    规定数据的表现形式,比如将以 EBCDIC3 表示的文本文件转换为以 ASCII 码表示的文件

  • 第 5 层:会话层

    规定应用程序之间的通信从开始到结束之间的顺序(在连接中断的情况下,尝试恢复连接)

  • 第 4 层:传输层

    实行网络中应用程序进程之间端到端的通信管理,如差错恢复、重发控制等

  • 第 3 层:网络层

    对网络中的通信链路进行选择(路由选择)、中继

  • 第 2 层:数据链路层

    控制直接相连(相邻)的通信设备之间的信号收发

  • 第 1 层:物理层

    规定物理连接,包括连接器的引脚数、连接器形状等,以及铜缆与光纤之间电气信号的转换等

3广义二进制编码的十进制交换码(Extended Binary Coded Decimal Interchange Code)。在主机中使用的字符编码。

OSI 参考模型并不是一种非遵守不可的技术标准,它是一种有助于技术人员理解并记忆的概念性指导方针。对于各类技术人员和企业来说,遵循 OSI 参考模型来设计产品可以获得不少好处,所以这一模型自然而然地得到了普及。

只要在 OSI 参考模型的上层与下层之间提供相同的接口,即使任意一层中的组成要素发生了变更,系统仍能照常运作。

0.1.5 网络游戏系统及其层次结构

现在,请回看一下本章开头介绍的图 0.A。根据 OSI 参考模型的层次结构,对网络游戏的系统进行总结之后,我们划分出了如图 0.A 所示的分层。

第 4 层大多使用 TCP 协议、不需要直接操纵第 3 层以下的分层

在为了让应用程序能够在互联网上进行广泛通信的第 4 层(传输层)协议中,TCP 和 UDP 这两种协议是事实上的标准。如果要求收发信号按顺序、可靠地进行传输,就要使用 TCP 协议;如果对此不作要求,就可以使用 UDP。

另一方面,网络游戏要求只有在必要的情况下才使用 UDP,除此之外一概用 TCP。只要具有了 TCP 和 UDP 的功能,就基本不需要再进行更进一步的细微调整了,因此,没有必要直接操纵第 4 层以下的分层(第 3 层至第 1 层),将它们交给操作系统来处理就可以了。

但是,为了最大限度地提升性能,在第 3 层以下也有几个需要注意的地方,这几点我将在下一节的后半部分予以说明。

第 5 层以上的分层需要在游戏中予以实现

如图 0.A 所示,网络游戏中尚不存在能作为第 5 层、第 6 层事实标准的协议。因此,一般来说,第 5 层以上的功能都需要由网络游戏的开发人员来实现。这是因为根据游戏的类型和策划内容,在要求上有些微妙的差异,因而无法做到统一。但是在使用针对网络游戏的通信中间件的情况下,可以避免从零开始进行开发,从而能够大幅减少开发量。

接下来,我将介绍一下套接字 API 和套接字编程基础,以及使用套接字 API 的针对游戏的通信中间件的实现。

0.1.6 套接字 API 的基础知识

BSD 套接字 API4 是为了实现互联网连接而开发的 API,它是在所有操作系统(包括嵌入式系统)上进行网络开发的首选。使用 TCP / IP(不是 BSD 套接字)开发的 API 不胜枚举,但是如今在广泛用于网络游戏的环境上(包括游戏机)全都可以使用套接字 API。

4Berkley Socket,也叫做 Socket。

标准化的 C、Java 和 Ruby、Perl、C# 等几乎所有的编程语言都能使用这一 API5。套接字 API 在 20 世纪 80 年代开始普及,此后基本没有进行过变更,因此当时开发的程序有很多至今仍在照常运行。

5C 以外的编程语言(Java 和 ActionScript 等)不能直接调用 Socket API,但是可以使用在一定程度上仿效 Socket 行为的 API。

由于篇幅有限,本书对套接字 API 的介绍仅限于最基本的内容,网络上有大量学习资料可供参考,请读者务必进行查阅 6。此外,前文提到过的 IETF 的互联网方法、OSI 参考模型以及套接字 API 等从互联网开创时期开始的历史和通信方面的基础理论,在《UNIX 网络编程》系列图书(该系列是网络编程领域的圣经)中也有介绍。该书中,套接字 API 的示例丰富且全面,其中的内容在所有网络游戏中都有用到。即使说网络游戏开发团队的书架上都有这本书也不为过。

6网络游戏也是同样,可以参考以下网站:
http://x68000.q-e-d.net/~68user/net
http://www.few.vu.nl/~jms/socket-info.html

0.1.7 网络游戏和套接字 API——使用第 4 层的套接字 API

使用套接字 API 可以控制位于第 1 层、第 2 层(物理层和数据链路层)之上的第 3 层的 IP 协议、第 4 层的 UDP、TCP、ICMP(Internet Control Message Protocol,Internet 控制报文协议)等协议。套接字 API 中包括直接控制 IP 协议的第 3 层的 API 和使用 TCP 等协议的第 4 层的 API。网络游戏只使用“第 4 层的套接字 API”。

面向连接(流式)和无连接(数据报式)

数据包会在网络的各条路径中传输,在这个过程中,数据包可能会因为路由器的处理能力不足或者通信链路拥堵等原因而丢失。在这种情况下,作为互联网基础的第 3 层 IP 协议并不会重发数据包,所以它是不可靠的。此外,数据包的到达顺序也无法保证。但是一旦收取成功,其内容不会发生改变 7

7错误的修正由第 2 层以下的层次来保证。

如果使用第 4 层的套接字 API,就可以在不具可靠性的 IP 协议之上实现两种类型的通信:第一种是面向连接的通信(流式,STREAM),在建立了连接的两台主机之间维持通信线路畅通,保证通信持续进行;另一种是无连接的通信(数据报式,DGRAM),只进行一次数据包的交换,不维持各主机之间的通信线路。

在 IP 上层实现的面向连接的协议是 TCP 协议,使用 TCP 的典型代表是 HTTP 和 SSH(Secure Shell)。HTTP 和 SSH 对长时间传输、保证传输顺序一致、可靠性高的数据通信来说,是很有必要的。8

8如果 HTML(HyperText Markup Language)的顺序改变了,标记的前后关系就会完全混乱。

另一方面,无连接的代表协议是 UDP,该协议将 IP 数据包进行分割后发送出去。接收端只具有将其复原的功能,并不能保证数据包的到达顺序和可靠性,这一点与 IP 协议区别不大。使用 UDP 协议的典型代表是 DNS 查询。

* * *

之前提到过,网络游戏中都会用到 TCP 和 UDP,但是为了让游戏中的主机能够持续进行通信,基本上都使用面向连接的 TCP 协议 9。在下一节中,我们将介绍一个以 TCP 为前提的套接字编程示例。

9除了网络地址转换(NAT Traversal,将在 5.6 节介绍)等特殊情况。

专栏 网络编程的特性和游戏架构的关系——服务器、客户端所需具备的性能和功能

那么网络游戏到底需要哪些网络编程技术呢?为了回答这一问题,我们先来简单了解一下网络编程的特性与游戏架构之间的关系。网络编程的特性根据游戏架构的不同而有所差异,作为铺垫,我们以第 2 章中会讲到的 C/S MMO、C/S MO、P2P MO 这几种游戏架构为例(有关上述分类请参见表 2.4)10,看一下它们之间的区别。

10后面将会详细讲述,C/S 架构游戏是指客户端 / 服务器(Client/Server)模式的游戏,P2P MO 指利用 P2P(Peer to Peer)通信的游戏。MMO(Massively Multiplayer Online)指容纳大量用户的多人网络游戏,而 MO(Multiplayer Online)则是指玩家数相对较少的多人游戏。

  • C/S 架构的游戏(C/S MMO、C/S MO)

    →高性能、功能强大的服务器端编程 × 一般的客户端编程

     所有的处理都在服务器进行,每台服务器要容纳尽可能多的用户。另一方面,客户端的通信相对比较简单。

  • P2P 架构的游戏(P2P MO)

    →一般程度的(Web)服务器端编程 × 高性能、功能强大的客户端编程

进行游戏处理的服务器只起辅助作用,由于客户端也要扮演服务器的角色,为此需要在客户端实现支持大量通信量的功能。

总而言之,C/S 架构的游戏要求编程结构满足“服务器端具有高性能”,而 P2P 架构的游戏则要求“客户端具有高性能”。

尽管如此,本书中涉及的一些实时游戏,不管在服务器端还是客户端,都要求高性能、高功能的网络编程。

高性能、高功能服务器的特性——网络游戏所需具备的要素

C/S MMO、C/S MO 游戏所要求的高性能、高功能服务器需要具备以下这些特性。

小带宽

  每秒几次至 20 几次,达到几百位通信量的持续连接。

极高的连接数

  每台服务器需要维持数千至数万个连接。

低延迟

  处理、结果返回的延迟只能在几毫秒至 20 毫秒以内。

稳定

  服务器端保持游戏状态(Stateful),敌人等可以移动的物体实时地持续行动。

Web 服务器的特性与此截然不同。所以一般来说,Web 系统中使用的编程技术在其他的网络游戏中是不使用的。本书稍后也会谈及其中的差异。

高性能、高功能客户端的特性——网络游戏客户端所需具备的要素

另一方面,P2P 架构的游戏要求高性能、高功能客户端具备以下特性。

小带宽

  每秒几次至 20 几次,达到几百位通信量的持续连接。

连接数少

  每个客户端只连接几台机器。

低延迟

  处理、结果返回的延迟只能在几毫秒至 20 毫秒以内。

稳定

  服务器端保持游戏状态,敌人等可以移动的物体实时地持续行动,此外,画面渲染等非常重要的处理也要同时进行。

多样性

  必须应对客户端的各种网络状况。

与服务器端相比,客户端的连接数较少,但是在进行渲染等重要处理的同时,必须在延迟很低的情况下进行通信,还要应对网络状况的多样性 11,不管是性能上还是功能上,都需要具备一般的 Web 服务所不具有的要素。

11包括防火墙、各个 ISP(Internet Service Provider)的策略之间的差异等。

0.2 套接字编程入门——处理多个并发连接、追求性能

本节使用 C 语言风格的伪代码,对基于第 4 层的 TCP 的套接字 API 进行进一步的说明。首先,我们从作为通信起点的“通信链路的确定”(复习)开始,依次介绍“套接字 API 基础”、“处理多个并发连接的策略”(同步连接、异步连接),以及“与性能和开发效率相关的关键问题”。

0.2.1 通信链路的确定(复习)

TCP 通信链路(面向连接的通信链路)并不是自然发生的。这是建立在一方提出“想要进行连接”,而一方接受这一连接请求的基础上的。提出“想要进行连接”的这一方称为客户端(client),接受方则称为服务器(server)。服务器在接受请求之前,还需要做一些准备工作。

IP 是由位于通信链路端点的一个 IP 地址(32 位)和一个端口号(16 位)来指定的 12。IP 地址和端口号一共有 48 位,位于通信链路两端的 IP 信息作为一组,根据这总共 96 位的信息,可以指定互联网上任意一条通信链路(参见图 0.1 ❶)。

12本书以 32 位的 IPv4 为例进行说明。但是现在面临着 IP 地址即将消耗殆尽的问题,为此,采用 IPv6 将 IP 地址扩展至 128 位是非常必要的,IPv6 在技术上已经基本准备妥当了。

IP 地址是一组 32 位的数据,使用十进制值来表示就是 0.0.0.0~255.255.255.255(参见图 0.1 ❷),接入互联网的 Web 服务器需要一个固定的 IP 地址,这是在国际规定下获取的。

图 0.1 根据 IP 地址和端口号所指定的通信链路

另一方面,16 位的端口号可以由服务器的实现者酌情决定。但是有些端口号已经被使用了,比如 HTTP 所使用的 80 端口号这种公认端口号(WELL KNOWN PORT NUMBER,经常被使用的端口号)和注册端口号(REGISTERED PORT NUMBERS,已经登记过的端口号),这些端口号都可以在 IANA(Internet Assigned Numbers Authority) 的端口表中进行确认。此外,在 Linux 和 FreeBSD、Mac OS X 等基于 UNIX 的系统中,在 /etx/services 文件里包含这些端口号的子集。根据这一点,我们可以使用如下命令来指定端口号:

shell> telnet localhost http  ←与 telnet localhost 80 相同

用这一方式可以实现一些简单命令。

顺带一提,前文所述的 IANA 的端口表还记录了大型多人在线 RPG 游戏《魔兽世界》(WoW,后面会讲到)的端口号。

blizwow        3724/tcp  World of Warcraft
blizwow        3724/udp  World of Warcraft

0.2.2 套接字 API 基础——一个简单的 ECHO 服务器、ECHO 客户端示例

套接字 API 的行为在服务器和客户端是不同的。我们将假想的 ECHO 服务器 13 作为一个具体应用来加以说明。

13ECHO 是一个“只要接收到了数据,就原样返回给发送端”的服务器,这是在讲解套接字 API 时广泛使用的一种服务器。

首先来看看代码清单 0.1 所示的 ECHO 服务器端的行为。这段代码很简单,各函数的含义都给出了注释。唯一比较难理解的是代码清单 0.1 中 ❶ 所示的内容,sock 传给 accept 函数以生成一个新的套接字 new_sock14。本来,在代码一开始的地方使用 socket() 函数生成的套接字 sock,在严格意义上并没有建立通信链路,但这个函数却使用了与套接字(socket)相同的名字。为了让代码清单 0.1 中的服务器运行起来,使用 telnet 命令(客户端)以设置的端口号进行连接,同时发送一些合适的数据,这样可以确认数据是否从服务器端返回,请读者予以尝试。

14新的套接字(也叫连接套接字)new_sock 拥有客户端的连接信息,而原始套接字(也叫监听套接字)sock 只有协议簇等一些基本信息,用于监听。——译者注

代码清单 0.1 ECHO 服务器的行为

int sock = socket(PF_INET, SOCK_STREAM); ←指定类型 17
                                           生成等待使用的套接字(文件描述符)
bind(sock, addr);     ←设置监听端口号 18
listen(sock);         ←监听开始。待机中
while (1) {
  int new_sock = accept(sock, &addr);  ← ① 在新的连接请求到来之前一直“等待”(阻塞)
                      当连接请求来到后,返回新的套接字,建立连接 19
  char buf[100];
  size_t size = read(new_sock, buf, 100);    ←在读满最大的100 字节之前一直等待
                     ( 在数据到达前或是连接中断时阻塞) 20
  if(size == 0){      ←如果read 函数返回了0,意味着接收到了 EOF 21
    close(new_sock);      ←收到EOF 后关闭连接
  } else {
    write(new_sock, buf, size);      ←没有收到EOF 的话就写入 size 个字节的数据
  }
}

15在这里指定网络接口 / 协议族的类型(本例中设为“PF_INET”,表示 IPv4,写成 AF_INET 在行为上也是相同的。IPv6 要写成 PF_INET6)和套接字类型(本例中设为“SOCK_STREAM”,表示面向连接的流式 TCP/IP。如果设为 SOCK_DGRAM 就表示指定的是 UDP 协议)。

16将地址绑定到套接字中。设置监听用的端口号。

17针对这个套接字进行输入输出,从而与客户端进行消息的收发。

18这里为了方便起见,使用固定的 100 个字节。

19EOF 是 End OF File 的缩写,表示数据流的结束(数据不会再发送过来了)。

代码清单 0.2 所示的 ECHO 客户端比服务器端更简单。使用 socket() 函数生成套接字之后,针对指定的地址使用 connect() 函数进行连接,使用 write() 函数写入数据,等收到数据时使用 read() 函数来进行读取。最后的 read() 和 write() 函数会永远循环执行。

代码清单 0.2 ECHO 客户端

int sock = socket(PF_INET, SOCK_STREAM);    ←生成新的套接字(文件描述符)
connect(sock, addr);      ←使用生成的套接字,
                          向指定的地址和端口(持有这些信息的服务器)进行连接。
                          在连接终止前等待
while(1) {
  write(sock, “ping”);  ←写入数据(这里是“ping”)
  char buf[100];
  read(sock, buf, 100);   ←等待读取数据。期待数据(“ping”)的返回
}                                ←永远往返通信

通过上面这个例子可以知道,为了生成作为通信链路的新套接字(确立连接)要用到 socket() 和 accept() 这两个函数。

0.2.3 TCP 通信链路的状态迁移和套接字 API

现在我们来仔细了解一下代码清单 0.1、0.2 与 TCP 通信链路的关系。TCP 的通信链路的状态变化如图 0.2 所示 20

20每一条通信链路的状态都可以使用 netstat 命令来确认。

图 0.2 TCP 通信链路的状态迁移

出处:Digital Advantage 刊登的《连载:从基础开始学习 Windows 网络 -2. TCP 的状态迁移图》

http://www.atmarkit.co.jp/fwin2k/network/baswinlan016/baswinlan016_03.html

上述 TCP 状态迁移图中对各个状态进行了分组,非常容易理解。

“需要调用套接字 API 中的哪个函数、将会转变为哪个状态”是理解这一问题的关键。这里不可能对每一点都面面俱到地加以说明,因为这与 TCP 协议对此进行操作的 API 是完全独立的。本书将以套接字 API 为中心来加以解释,请好好掌握以下这些套接字 API 的函数与 TCP 通信链路的状态之间的对应关系。

  • socket()

    →因为还不会生成新的 TCP 连接,所以还不存在 TCP 连接状态。

  • connect()

    →connect() 函数开始进行如图 0.2 ❷ 所示的“主动打开”(active open)。由客户端调用 connect() 函数主动发起连接称为“主动打开”,而接收到这一请求的服务器被动建立连接则称为“被动打开”(passive open)。客户端在调用 connect() 的瞬间会发送 SYN 消息,此时客户端处于 SYN_SENT 状态,而服务器则处于 SYN_RECEIVED(SYN_RCVD)状态。服务器端的操作系统在收到了这个消息后立刻返回 SYN/ACK 消息,然后客户端在收到这个 SYN/ACK 消息后返回 ACK 消息,此时客户端处于 ESTABLISHED(连接建立)状态,服务器收到 ACK 消息后也将处于 ESTABLISHED 状态。由此可见,客户端会经过 SYN → SYN/ACK → ACK 三次消息收发,因此称为“三次握手”(Three-way handshake)。

  • bind()

    →不会生成新的 TCP 连接,只是设置本地生成的套接字的监听端口号,所以没有 TCP 连接状态。

  • listen()

    →开始进行如图 0.2 ❶ 所示的“被动打开”。被动打开就是从服务器端角度看到的主动打开,实际上这里的数据包流动顺序与主动打开的顺序是完全一致的。被动打开后,服务器进入 LISTEN(待机)状态。如果服务器处于这一状态,收到客户端发来的 SYN 数据包后就会开始生成新的套接字。

  • accept()

    →在操作系统(UNIX 内核)建立了 TCP 连接(处于 ESTABLISHED 状态下的新通信链路)的情况下,我们在应用程序中将其作为新的套接字获取下来。虽然这里 TCP 状态没有变化,但是之后使用 read() 和 write() 函数通过连接套接字来进行数据的收发时需要一个文件描述符,accept() 函数就是获取这个文件描述符的必不可少的函数。

  • read()/write()/send()/recv()/sendto()/recvfrom()

    →这些函数用来进行实际的数据收发,必须在处于 ESTABLISHED 状态时调用,否则会报错。

  • shutdown()

    →通知操作系统不要再进行数据的写入和读取了。当在参数中指定了 SHUT_RD 停止数据读取时,本地的状态就发生了变化,不再是可读取的状态了,所以会话状态也就不会变化了;而在指定 SHUT_WR 通知操作系统不再向其发送数据(写入数据)的情况下,就会开始关闭套接字 21,这样,如图 0.2 ❹ 所示的“主动关闭”流程就开始了。主动关闭与主动连接不同,服务器端和客户端都可以发起。如果再次进入 ESTABLISHED 状态,客户端和服务器的处理都是相同的。

    在主动关闭的过程中,首先从 SHUT_WR 侧发送 FIN 数据包。接收方(被动关闭的一方)会立刻返回 FIN,然后进入 CLOSE_WAIT 状态。SHUT_WR 侧一旦接收到这个 FIN 消息就立刻发送 FIN/ACK 消息,然后释放通信链路。被动侧接收到 FIN/ACK 后也同样关闭通信链路。这个关闭过程也需要三次握手。

  • close()

    →等同于调用 shutdown(SHUT_RD | SHUT_WR) 来同时关闭读写双方。

21单单指定 SHUT_RD 或者 SHUT_WR 的话并不会关闭套接字,两个都指定了才会关闭。另外还有一个参数是 SHUT_RDWR,表示先 SHUT_RD 再 SHUT_WR。——译者注

影响 TCP 状态转变的套接字 API 函数就是如上所示的这些了。

0.2.4 处理多个并发连接——通向异步套接字 API 之路

从现在开始就要进入本章的核心内容了。代码清单 0.1 所示的 ECHO 服务器存在一个很大的缺陷。由于 accept() 函数在“新的连接请求到来前一直等待着”,所以 read() 函数在接收新的连接请求前不会再被第 2 次调用。这就导致为了调用 read() 函数,必须每次接收新的连接 22

22一般来说,向 ECHO 服务器进行了一次连接后,应该可以多次收发消息。比如,一般的 ECHO 服务器,在 accept 之后,会持续进行 read → write → read → write…的过程。但是在代码清单 0.1 所示的情况中,在 accept 之后,进行 read → write,之后因为已经调用了 accept,在客户端进行连接后,只能收发一次消息。这不能说是一般意义上的 ECHO 服务器。

此外还有一个问题。read() 函数在客户端发来数据之前也会“等待”,所以在开始读取数据前,accept() 函数也不会调用第 2 次。也就是说,在接收了客户端发来的一次新的连接请求后,在数据到达之前无法再接收其他连接请求。

为多个客户端同时提供服务的网络游戏在这种情况下是不可能实现的。为了解决这个问题,必须要处理多个并发连接,为此,需要同时控制多个套接字。方法大致有如下这些。

每次连接时启动一个进程

实行异步的多重输入输出(多重 I/O)

使用线程并行进行同步处理

虽然在 inetd(Internet 超级服务器)和从很早开始就在 Web 中使用的 CGI(Common Gateway Interface,通用网关接口)中都采用了方法 ❶,但是在网络游戏中,需要多个用户(连接)实时共享同一个游戏状态,所以不能使用这种方式。可以在方法 ❷ 和方法 ❸ 中选择。

0.2.5 同步调用(阻塞)和线程

套接字 API 的 connect()、accept()、read() 函数在处理成功之前会一直处于等待状态,而其他函数则不会等待,而是立刻返回(在几微秒时间内)。通常,像这种同步调用 connect()、accept() 和 read() 这类“永远等待着”的函数称为“阻塞”(Blocking)。

处理这种情况的方法一般是使用线程(Thread)。使用线程的示例如代码清单 0.3 所示。它与代码清单 0.1 的不同之处在于,read() 和 write() 函数的反复调用是在 create_thread 中并行执行的 23

23C 语言与 Ruby 等有所不同,不能像代码清单 0.3 中那样以函数参数的方式来编写,这里所示的代码只是一种直观表示。请考虑将给出的代码段放在其他的线程中执行。在实际情况下,在 UNIX 内核的操作系统上,使用 fork 函数可以启动一个进程,也可以使用 pthread_create 等线程库,总之有很多种选择。

代码清单 0.3 ECHO 服务器(线程版,阻塞)

int sock = socket(PF_INET, SOCK_STREAM);
bind(sock, addr);
listen(sock);
while(1) {
  int new_sock = accept(sock, &addr);
  create_thread( {        ← ① 启动并行处理
    char buf[100];
    size_t size = read(new_sock, buf, 100);
    if (size == 0) {
      close(new_sock);
    } else {
      write(new_sock, buf, size);
    }
  } );
}

根据代码清单 0.3 中的多线程,我们可以实现向多个客户端同时提供服务(这里是返回数据)。使用线程可以同时处理多个“等待”场景。从结构上来看,同步调用是在多个线程的内部并行执行的。

线程方式下的负载处理问题

上述的线程方式在网络游戏的服务器中存在“负载处理”的问题。在每次创建线程时都会启动 1 个线程和进程,如果同时连接数为 3000,就会同时启动 3000 个线程,对现在的机器来说,3000 个并行处理数实在太过庞大,服务器的性能会大幅下降。

活跃进程一般要控制在操作系统能够同时执行的进程数或线程数的 4 到 10 倍以内。如果超出了这一范围,操作系统内部进行线程切换的开销就会变得很大。比如,4 核处理器下的最佳线程数是十几个。3000 个线程实在太多了,但是考虑到服务器成本,每台机器又不得不处理 3000 个左右的并发连接。

0.2.6 单线程、非阻塞、事件驱动——使用 select 函数进行轮询

在实际调用 read 函数和 accept 函数之前,我们可以使用 select 函数 24 事先查询一下这些函数所等待的消息(数据以及连接请求)是否已经到达了。这种事先询问的方式称为轮询(Polling)。根据操作系统的版本,使用 poll 函数及更高速的 epoll 函数等多种接口都能实现同样的功能。

24select 是等待输入输出完成(在读写完成之前等待着)的函数。由于是异步处理,可以实现输入输出的多重化。在前述的《UNIX 网络编程》一书中有详细介绍。

代码清单 0.4 展示了使用 select 函数的服务器端代码。在实际的服务器上运行的代码需要进一步设置标志和定义结构体,等等,但是基本的逻辑结构是相同的。

代码清单 0.4 在调用 accept() 和 read() 之前,为了确定该套接字所需处理的事件(数据)是否到达而调用了 select() 函数。这样,read() 和 accept() 函数就可以在几微秒内返回并且获取到数据。实际上,select() 函数不会像这样调用多次,调用一次就能一下子确认几千个套接字。

代码清单 0.4 中的代码与代码清单 0.3 的差别就是没有创建线程(单线程)就向多个客户端多重化地提供了服务。这种实现方式称为异步调用、非阻塞(Non-blocking)方式。另外,因为这种方式会事先查询数据到达这一事件是否发生,然后再调用相关函数,所以也叫做事件驱动(Event-driven)。

代码清单 0.4 ECHO 服务器(select 版本,非阻塞、事件驱动)

int sock = socket(PF_INET, SOCK_STREAM);
bind(sock, addr);
listen(sock);
allsock.add(sock); ←向allsock 队列注册sock
while (1) {
  result = select(sock);  ←插入select 函数进行事先检测
  if(result > 0) {
    int new_sock = accept(sock, &addr);
    allsock.add(new_sock); ←向allsock 注册新的连接
  }
  foreach(sock = allsock) {
    result = select(sock); ←插入select 函数进行事先检测
    if(result > 0) {
      char buf[100];
      size_t size = read(new_sock, buf, 100);
      if(size == 0) {
        close(new_sock);
      } else {
        write(new_sock, buf, size);
      }
    }
  }
}

0.2.7 网络游戏输入输出的特点——单线程、事件驱动、非阻塞

在游戏编程中,同时处理数千个可移动物体是很平常的,这与“使用 1 个线程处理数千个套接字”类似。为此,在网络游戏中,客户端和服务器端通常都使用 select 函数(或者 poll/epoll 函数)在单线程中实现非常简单的事件驱动的非阻塞方式。

0.2.8 网络游戏和实现语言

网络游戏中最常用的编程语言是 C/C++,其次是 Java,此外也有一些其他语言。轻量级语言可以嵌入服务器的实现代码中与其他语言并存。轻量级语言以 Lua 和 Squirrel 为代表,最近 Ruby 和 JavaScript 也在崛起。由于篇幅有限,本书无法对此进行详细介绍,但是嵌入式语言 Lua 和 Squirrel 所用的内存很少,初期成本很低,所以在游戏开发中很受欢迎。

服务器端使用的语言以 C、C++、Java、Ruby、Python、Perl 为代表。在这些语言中都可以使用 select 函数来实现事件驱动、非阻塞方式的套接字。

客户端所使用的代表语言有 C、C++、Java、Objective-C、Flash /ActionScript3、JavaScript。在 Flash/ActionScript3 和 JavaScript 中使用事件驱动的套接字是基础,它们与 RPC(Remote Procedure Call,远程过程调用,后面将会介绍)服务也能很好地协作。

0.2.9 充分发挥性能和提高开发效率——从实现语言到底层结构

现在,我们来看一下如何充分发挥性能,提高开发效率。首先从实现语言开始,然后再看看第 3 层以下的底层部分的注意点。

如今的网络游戏大多使用 C/C++,而现状是大家迫切需要缩短游戏开发周期,由于开发成本很高,各公司都在讨论是否不能使用具有垃圾回收特性的 Java 和 C#,或者轻量级语言。笔者自己也在想如果能用 Ruby 语言来编写具有 C++ 执行速度的服务器就好了。

就现状来看,如果牺牲服务器的最高性能,就能提高开发效率。这是种此消彼长的关系。表 0.1 以一些主要的编程语言为例进行了简单的总结。

从表 0.1 可以看到,C/C++ 与 Ruby 在吞吐量性能上相差了 1000 倍。服务器数量与处理性能是成反比的,所以 1000 倍的差异所带来的服务器数量的差异也是非常惊人的。

表 0.1 主要的编程语言与吞吐量

语言

吞吐量

特性

C/C++

100

静态语言、本地代码

Java/C#

1~10

静态语言、VM、字节码

Ruby/Python

0.1~1

动态语言

现在,拥有大量用户的游戏都是用 C/C++ 来编写的,内容相似的游戏纷纷涌入市场,如果不使用 C/C++ 的话就无法在价格战中获胜。

网络游戏的特殊性所造成的编程语言性能差异

表 0.1 中,Java 的吞吐量比 C/C++ 低了 10 倍,这是由于网络游戏的特殊性所造成的。一般在配备了 JIT(Just In Time)编译器 25 的虚拟机(Virtual Machine,VM)26 中,Java 的运行速度会因 JIT 编译的效果变得很快,某些情况甚至会比 C 语言更快。

25在 Java 编程语言和环境中,即时编译器是一个把 Java 的字节码(包括需要被解释的指令的程序)转换成可以直接发送给处理器的指令的程序。——译者注

26可以说这是全部的了。

但是这种效果只发生在以 CPU 为中心 27 的应用程序中,而在那些与操作系统频繁进行输入输出操作的应用程序中无效。比如,在一个对 100MB 的文件进行读取,每次读取 1KB 并对行数进行计数的程序中,C 语言要比 Java 快上 10 倍左右的情况也是常有的。这是因为 Java VM 在系统调用前后,每次都会进行缓存溢出和异常对象的处理。这是无法省去的处理过程,所以使用 VM 的处理系统存在一定的局限性。网络游戏的服务器每秒会进行数万次输入输出,这是 Java 和 C 语言产生速度差异的典型例子。Apache 和 MySQL 等服务器软件都用 C/C++ 编写也是基于同样的原因。

27指主要使用 CPU 来进行处理。另外以 I/O 为中心是指输入输出占了较大比重的处理方式。

其次,动态语言的吞吐量比起 Java 更是低了 10~100 倍,为什么会这样呢?这是因为每次进行一些处理时,对象调用的方法可能会发生变化,所以每次都必须进行检查确认。

顺带提一下,Google 的 Go 语言是一种静态的、本地执行的语言,它具有垃圾回收机制,程序员可以在代码的不同部分中选择类型化的强度,既不牺牲服务器的性能又可以提高开发效率,笔者对此十分期待。让人不禁感叹 Google 对服务器开发确实颇为了解。

0.2.10 发挥多核服务器的性能

通过单线程、事件驱动和非阻塞的实现,就可以充分发挥出多核服务器的性能。

举例来讲,在 CPU 只有一个处理核心的情况下,不可能同时执行多个线程或进程,而是为每个线程或者进程划分一小段时间片,轮流执行。例如,如下所示暂停 1 毫秒的程序:

while(1) {
  usleep(1000);
}

如果有两个这样的程序同时运行,那么 1 秒内要切换 1000 次。由于要从进程 A 切换到进程 B,又要从进程 B 切换到进程 A,每秒总共要切换 2000 次。这里的切换次数可以在 Linux 上用 vmstat 命令来确认 28

28vmstat 的相关内容会在第 7 章进行说明。

专栏 输入输出的实现方针和未来提高性能的可能性

网络编程中输入输出的实现是一个非常深奥的问题,为了充分发挥设备的性能,开发人员提出了各种各样的方案。

比如,如果存储多个线程与事件驱动一起使用,是否会提高性能?围绕这一问题展开了诸多争论 29。将来 Apple 的 Grand Central Dispatch30 这种并行执行机制、Google 的 Go 语言的 goroutine特性等是否能减少工作量并且带来高性能,笔者对此也充满期待。

29可以参考以下网站:http://d.hatena.ne.jp/sdyuki/20090624/1245845216

30Mac OS X 10.6(Snow Leopard)搭载的新功能之一,简化多核并行编程的一种机制。

上下文切换——保存 CPU 的设置状态

上文提到的“切换”称为“上下文切换”(Context Switch),在进行上下文切换时,CPU 核心内部将执行注册(Register)、保存虚拟内存(Virtual Storage)的管理表以及安全性设置的切换等。至笔者撰稿时,在 Linux 上,平均每个 CPU 内核一秒内可以执行 10 万~20 万次上下文切换。这也与 CPU 的高速缓存内容等应用程序的执行情况有关,这一点需要注意,但是,比如前文中提到过的那个暂停 1 毫秒的程序,如果运行了 200 个左右这样的程序,仅仅是上下文切换大约就会使当前时间 CPU 的系统占用率(由 vmstat 命令获得当前时间的 CPU 统计信息)达到 100%。

上下文切换要处理 CPU 设置状态的保存工作,CPU 内核多的话,整个系统每秒能执行的次数也会增加。比如,在拥有 10 个 CPU 内核的机器上执行同样的任务,基本上可以进行 100 万~200 万次上下文切换。31

31笔者没有接触过拥有 100 个内核、1000 个内核的这种庞大内核数的机器,也许这种机器的行为会有所不同。

因为一个内核的处理能力已经达到极限,而内核的处理也已达到了最优化,所以在今后的机器中,一个内核所能进行的上下文切换次数与上面给出的值相比,很有可能不会发生很大的变化了。

多核处理器上不要运行过多的服务器进程

在网络游戏的服务器上实现单线程、事件驱动、非阻塞的情况下,如果服务器的每个内核运行 1 个进程,就能充分利用多核处理器的性能。

上下文切换在针对网络的输入输出中也是必须的。假设 1 个内核可以执行 10 万次上下文切换,那就可以进行 10 万次网络输入输出。平均每个内核能有 1000 个同时连接数的话,每秒就可以进行 100 次输入输出,通常,每秒的输入输出只有几次的程度,可以说非常宽裕。因此,多核服务器中,服务器进程数只要不增长过多的话就不会有问题。

0.2.11 多核处理器与网络吞吐量——网络游戏与小数据包

服务器通常使用以太网 [ 或许是千兆以太网(1 Gbit Ethernet)] 连接至数据中心内的网络中。现在在 Linux 中,以太网在基础设施层次中可达到其名所示的速度。也就是说,千兆以太网的通信速度就是 1Gbit。交换集线器也可以应对这一速度。

但是,网络游戏中发送大量小数据包的情况下,有时也会无法达到预期的通信速度。

以太网帧

首先,以太网在发送 IP 数据包时,会向数据包中添加 IP 数据以外的信息一起发送。包括这些附加信息在内的总带宽是 1Gbit/s,实际上应用程序能够使用的带宽比这要小。

比如,在 Windows 中使用 telnet 命令连接 TCP 服务器,按下 A 键就会发送 "a" 这个 1 字节的数据。此时,以太网实际消耗的带宽是多少呢?在最典型的以太网中,发送数据包时会发送图 0.3 所示的信息。

在图 0.3 中,❶ 中的(7)表示 7 个 octet。octet 的意思是“8 位位组”。因为有一小部分机器“一个字节(byte)不等于 8 位(bit)”。这样就不能用 bit 来进行划分,所以在严格定义通信方法的情况下都使用 octet 这个单位。

图 0.3 最典型的以太网帧(IEEE 802.3)

{%}

"a" 这样的一个字符可以用 1octet 来表示,用 16 进制来表示就是 0x61,2 进制表示就是 8 位的 01100001。

图 0.3 中 ❶❷ 所 示的开头 7 octet 的“前导码信号”(Preamble,同步信号)和其后 1 octet 的 SFD(帧首定界符,Start Frame Delimiter)总是固定的(最后的 1 连续重复了 2 次):

1010101010101010101010101010101010101010101010101010101010101011

这是一种为了从流入电缆的噪声中找出通信信号的固定信号。这会持续长达 64bit 的时间(虽说这是微秒以下的单位),所以以太网的转换器可以由此分辨出噪声和信号。

在图 0.3 中 ❸❹ 中,接收方和发送方的 MAC 地址各有 6 octet,总共 12 octet。MAC 地址是分配给与以太网连接在一起的所有设备的一个数值,由每个设备厂商进行分配。比如在 Windows 中,使用 ipconfig/all 命令 32 可以看到如 04-A3-43-5F-43-23 这样的表示。

32在 Linux 中,可以使用 ifconfig 命令。

接下来,❺ 用 2 octet 来指定数据的长度和类型,❻ 是数据本身,最后 ❼ 是 4 octet 的 FCS(Frame Check Sequence,帧检验序列,用于修正信号错误的总和检验码)。

各个网络层的头信息

使用 TCP 协议发送数据时,也会同时发送以太网帧头信息以外的一些头信息。在使用 TCP 发送 "a" 这样 1 octer 数据时,在 OSI 参考模型的层次结构中会用到以下 4 个下层系统。

  • 第 4 层(传输层):TCP

  • 第 3 层(网络层):IP

  • 第 2 层(数据链路层):以太网协议

  • 第 1 层(物理层):双绞线电缆

因为采用了层次结构这种方式而不是直接利用以太网,即使更上层的系统要使用不同的物理媒介(比如 Wi-Fi 和 3G 网络 33 等)来进行通信,也不需要修改程序。为了享受到这一优势,必须要向各个层添加必要的头信息。

333rd Feneration Network。第 3 代移动通信网络。

具体来说就是加上第 4 层 TCP 的头信息(20 octet)、第 3 层 IP 的头信息(20 octet)和以太网的帧头信息和帧尾信息(22 octet)。

TCP 头如图 0.4 所示,必须包括源端口号(16 位,2 octet)、目的端口号(16 位,2 octet)、序列号(32 位,4 octet)、ACK 序列号(32 位,4 octet)、标志(代码位)、窗口大小、(数据的)校验位、紧急标志等 20 octet。

图 0.4 TCP 头

{%}

IP 头如图 0.5 所示。图 0.5 的前 3 行(×4 = 12 octet)包括版本号(4 位)、头部长度(4 位)、服务类型(8 位)、数据包长度(16 位)等各种通信设置。紧随其后的是 32 位的源 IP 地址和目的 IP 地址。至此为止的 5 行 20 octet 是 IP 报文必须包含的内容。图中 IP 报文的“数据”部分放入了 TCP 头所包含的数据,而 TCP 的“数据”部分则放入了 "a" 这个应用想要发送的数据。

图 0.5 IP 头(IPv4)

由此可见,为了发送 "a" 这个 1 octet 的数据,需要用掉如下这些 octet。

  • TCP:总共 21 octet:

    • 头:20 octet

    • 数据 "a":1 octet

  • IP:总共 41 octet

    • 头:20 octet

    • 数据(TCP 的部分):21 octet

  • Ethernet:总共 67 octet

    • 前同步信号:7 octet

    • SFD:1 octet

    • 接受方:6 octet

    • 发送方:6 octet

    • 长度:2 octet

    • 数据:41 octet(IP 的部分)

    • FCS:4 octet

可以看到,传输 "a" 总共需要耗费 67 octet。假设使用 1Gbit/s 以太网,应用程序每次发送 1 字节的数据,实际可能用到的带宽为 1Gbit/s 的 1/67,大约是 1.5Mbit/s。

多核处理器的数据传输能力

如上所述,使用 OSI 参考模型时,如果将数据分成非常小的数据块来发送,头信息就会占据很大一部分,对物理层来说负担非常大。相反,如果以 1400 字节的大数据块为单位来发送,各个头信息所占的比重就会降低,这样基本上可以达到理论上的通信速度。

网络游戏中,应用程序的数据单位有时不得不只有 20 个字节左右,所以很多情况下只能达到以太网理论速度的几分之一。一般来说,使用 10Mbit/s 以太网发送最小的数据时可以达到每秒 14881 次,100Mbit/s 以太网的话可以达到 148810 次,而 1Gbit/s 以太网则可以达到 1488100 次。

根据经验,将理论值的 1/10 作为基准,1Gibt/s 以太网每秒可以发送 100MB 的数据,能够发送的数据包数最好以每秒 10 万~15 万为上限(Linux 的情况下)。

如果在有 10 个内核的机器上使用 1Gbit/s 以太网,每个内核可以处理大约 1 万个数据包,如果同时连接数为每个内核 1000 个连接的话,或许每个连接必须设计为将发送频率限制在每秒 10 次以内。或者,如果服务器可以安装多个网络适配器(NIC,Network Interface Card),那么可以连接 4 根 LAN(Local Area Network)电缆,以实现 4 倍的吞吐量。

此外,10Gbit/s 以太网的交换集线器价格仍然很高,在必须降低成本的网络游戏中还不能使用。如果将来价格便宜的话,就可以选择它了。

0.2.12 简化服务器实现——libevent

最后,我们来了解一下服务器实现的简化。在“单线程 + 事件驱动 + 非阻塞调用” 模式下,实现服务器的最佳程序库是 libevent顺带一提,有个可以替代 libevent 的更为高速的程序库,名为 libev:http://software.schmorp.de/pkg/libev.html]}。libevent 是从文件共享软件 Tor派生出来的库,在 memcached34 等系统中也有使用,在追求与网络游戏的服务器和客户端系统同等服务性能的网络服务器软件中,它被持续使用长达 5 年以上。

34memcached(http://memcached.org)常用于 Web 服务中,在 mixi 和 livedoor 等所有大规模服务中,为了提高服务器的处理性能而使用 memcached。2010 年夏天在 mixi 中使用的 memcached 虽然情况不佳,但是 mixi Engineers'Blog 立刻进行了公开、详细的解释。就是在特定硬件设置的状态下只有在发生大量连接时才会产生 bug。
http://alpha.mixi.co.jp/blog/?cat=34

libevent 在全世界的网站中都有运用,不管是性能方面还是稳定性方面都很成熟。尽管如此,libevent 在实际用于商业服务时,在嵌入到游戏服务中后,应该进行并入单独的游戏内容中的负载测试。

libevent 的特点

使用 libevent 时需要注意以下关键点。

  • 如果套接字处于某个指定状态时(可以 write、可以 read、可以 accept),调用事先指定的函数。

  • libevent 库会自动选择各个 OS 中最高效的方法(比如,在 Linux 中当套接字数量很多时,选择 epoll 函数等)来轮询套接字的当前状态。

  • 应用程序用事先设置的函数调用(称为回调函数)来获取这一结果,在这回调函数中实际执行 read、accept 等本来应该在等待的函数。

libevent 最大的特点就是:它的工作方式并非创建大量线程然后等待 read 等系统调用,而是“不创建线程,为每一个想要事先通知的事件注册回调函数,当事件发生时,只进行一次函数调用”。

因为完全不创建线程,可以执行非常轻量且高速的行为,所以它面向的是实现以通信处理等数据的输入输出为主的并行处理。

libevent 非常简单且高速,所以在很多系统中都有使用。笔者也在实现网络游戏的服务器端时实际使用过,获得了相当出色的性能。在 Linux、Mac OS X、Solaris、Windows 等主要的操作系统都能使用,它的跨平台特性也非常出色。

使用 libevent 就可以简化采用单线程+事件驱动+非阻塞调用这种模式的系统的开发工作。

0.3 RPC 指南——最简单的通信中间件

本节将要介绍远程过程调用协议 RPC。RPC 将与通信有关的一些复杂细节封装起来,与一般的函数调用形式相同,它是确保与远程主机进行简单、安全的通信的一种方法。使用 RPC 就不用直接使用 BSD 套接字的 API 也可以进行通信程序的开发了。

0.3.1 通信库的必要性

上一节介绍了第 4 层(TCP)的套接字编程的基础知识。但是在实际的游戏中,应该避免直接调用(BSD)套接字 API 的函数,而使用更上层 API 封装了的通信库来开发应用。这是因为 BSD 套接字库会根据网络状况产生如下问题。

  • 不一定能成功收发期望数据,所以之后需要再次调用。

  • 可能会发生错误。

  • 发送缓存满了的话,write() 函数会等待 35

35后面会讲到,在库的内部缓存,在可以写入数据的阶段再次发送,这样可以解决这个问题。

与向文件写数据相比,向网络写数据可能会碰到目标主机离线的情况,无法获知准确的状态,即使当前发送失败,之后也可能会成功,所以不能仅仅作为出错来处理。网络的基本性质就是“状态是不定的”。如果是文件,就算磁盘满了立刻写入失败也没什么关系。

现在来看看一段不甚理想的客户端代码(代码清单 0.5)。send 根据网络状况可能会产生如下问题。

可能等待。

可能发送失败。

在希望发送 4 个字节时,可能只发送了 1 个字节。

在连续敲击键盘的情况下,可能会有 3 次没有发送。

代码清单 0.5 不甚理想的客户端代码示例

void keyDownHandler(KeyboardEvent e) {       ←客户端按下某个键盘按键
  send(sock, e.keycode);
}
void mouseDownHandler(MouseEvent e) {        ←客户端按下某个鼠标按键
  send(sock, e.buttonCode);
}

但是这些函数中的 send 在发送成功前不会阻塞,每次编写错误处理造成的代码重复也是引起很多错误的根源。

通常,需要有一个能独自负责这些工作的程序库。这个通信程序库应该首先将针对网络的输入输出要求装入缓存中,接着准确地加以执行。然后再准确地将数据发送出去,直到发送完成。如果在一段时间内无法发送出去则返回错误消息,像这样,用“时间”这个要素来区分成功和失败。

代码清单 0.6 中用调用 wrapperSend 函数来替换了 send() 函数。wrapperSend 函数不会阻塞,除了内存不足不能运行之外不会返回错误,这样来确保成功。在通信库中有一个有名的库——boost::asio,能与这里实现同等内容。

代码清单 0.6 使用 wrapperSend 函数

void keyDownHandler(KeyboardEvent e) {       ←客户端按下某个键盘按键
  wrapperSend(sock, e.keycode);      ← ①
}
void mouseDownHandler(MouseEvent e) {        ←客户端按下某个鼠标按键
  wrapperSend (sock, e.buttonCode);
}

确定数据格式来进行数据的收发

在这里,我们不对上述代码清单 0.6 作详细分析,但会仔细了解一下实际的数据发送方式。在其中的①处,将按下的按键以调用 wrapperSend(sock, e.keycode) 函数的方式发送出去,这些代码是很抽象的,那么具体发送的是什么样的数据位呢?

比如按下了 x 键,该键码值用十六进制来表示就是 0x78。TCP 是面向连接的流式协议,发送时不会间断。因此,①处代码会发送 0x78 这样一个字节,但是这里并不知道这个字节是否与前面发送的数据是连在一起的。

再比如,想要在鼠标移动时发送对应的坐标,这时调用 wrapperSend(sock, e.mouseX)函数来发送。如果 x 坐标用十六进制表示也是 0x78 的话,接收方就无法判断到底是按下了键盘按键还是移动了鼠标。

因此,很有必要指定如下这样的数据格式,然后再进行数据的收发。

[ 数据的类型代码 1 字节 ] [ 数据内容 ]

0.3.2 网络游戏中使用的 RPC 的整体结构

上述的数据格式经过一番发展之后,产生了一个称为 RPC 的概念。这是在本地模拟远程主机(其他的进程)中的函数调用,将数据流进行编码后发送出去,远程主机接收这些数据并将其解码,然后调用相应的函数。除了指针以外的数据都能顺利发送。

网络游戏中使用的 RPC 模式如图 0.6 所示。最下面的是物理层,向上依次对应七层模型。

图 0.6 左侧是调用 RPC 的一方,右侧则是接收方。不管是哪一侧都可以是客户端或者服务器端。

首先,图 0.6 ❶ 处调用侧的应用程序在程序内部调用了名为 attackAtEnemy 的函数。该函数的定义在源文件“RPC 存根代码”中。因为逐个手工编写函数非常麻烦,所以这个 RPC 存根代码是用工具自动生成的。在这里就是用套接字库调用 send() 函数发送两个字节。第 1 个字节表示想调用的函数是“attackAtEnemy 函数”,所以写入 123 这个固定值。下一个字节表示攻击对象的 ID 值,在这个例子中写入的是 99。这是通过 attackAtEnemy 函数的参数传入的。

虽然图中省略了错误处理,但其实也是同时进行的。

图 0.6 网络游戏使用的 RPC 模式图

{%}

图 0.6 ❷ 处,在之前调用的套接字库的 send 函数内部进行了 OS 的系统调用(图 0.6 ❸),OS 让硬件开始运作,而硬件则发出电气信号(图 0.6 ❹),随后电气信号就在互联网中传输(图 0.6 ❺)。

在接收侧则将收到的电气信号从硬件传送给 OS(图 0.6 ❻),将其处理成程序可以使用的状态。OS 再将这些数据发送给套接字库(图 0.6 ❼)。

接收侧的应用程序在主循环内执行 doPolling 函数(图 0.6 ❽),在里面接收从套接字发来的数据。

由于接收到的第 1 个字节是表示函数类型的固定值 123,所以在条件分支中调用实际在接收侧应用程序中定义的 attackAtEnemy 函数。然后从第 2 个字节中取出在调用侧作为参数传入的值 99,将其赋给 attackAtEnemy 函数的参数。

* * *

按照以上的方式来处理时,就不用关心与通信相关的任何细节内容,通过使用函数调用的方式,就能安全地编写通信程序。在有 2 个进程执行复杂通信的应用程序中,使用作为结构方法的 RPC 也是很普遍的。

自动生成 RPC 存根代码的 RPC 工具

RPC 存根代码文件中调用方的函数参数列表必须和被调用方的函数参数列表完全一致,但是如果有大量函数,全部手工编写这些函数的定义难保不会出错,还有可能产生极难修复的 bug。一般我们会使用一些用于省去手工编写函数定义这项工作的专用程序,来自动生成调用方和接收方的函数定义。在这里,我们将这种专用程序称为 RPC 工具。

通常,使用 Ruby 和 Python 等很容易进行 DSL(Domain Specific Language,领域特定语言)定义的语言来设计 IDL(Interface Description Language,接口描述语言),然后执行脚本,通过这种方式来自动生成发送侧函数和接收侧函数的存根函数的源代码和头文件,然后在项目中进行链接后加以使用。

为了让两个程序可以进行“对话”,必须定义一些必要的接口,这种定义接口的语言就称为 IDL。当两个以上的程序需要进行“对话”而又不能使用常规的函数调用时,IDL 是十分必要的。比如,这些程序各自使用不同的语言来编写,或者运行在不同的机器上,又或者在不同的时间内运行,等等。因为在这些特殊的情况下,必须具有因机器不同而不同的二进制格式的转换和持久化等功能。

IDL 一般与想要“对话”的应用程序的编程语言相同,或者使用可读性很高的第三方编程语言,或者配置文件等来描述。现在,如果想自己编写 IDL,可以利用 Ruby 的 ERB 和 Python 的 Django 等非常优秀的常用于 Web 的模板引擎,使用这些引擎可以很方便地生成源代码。

使用 RPC 工具自动生成源代码的话,那些与各类开发工作相关的费时任务都可以在 RPC 工具中自动完成,包括:直接发送整数、字符串、数组、列表、结构体和类等;在发送 2 个字节以上可能随机器的不同而不同的数据时,进行字节顺序的处理;针对两种以上的编程语言输出源代码,等等。

但是拥有复杂的功能后处理速度就会变慢,无法在游戏中使用。比如,以名为 XMLRPC 的 XML(Extensible Markup Language,可扩展标记语言)为基础的 RPC 结构,因为使用的是收发 XML 的形式,所以具有良好的可读性和调试效率,但是在网络游戏中,一个内核每秒要处理数万个 RPC,实在太过繁重。

网络游戏和二进制数据交换格式/库

在网络游戏中,不需要以二进制格式发送具有非常复杂的层次结构的数据,简单的数据结构就已经足够了。Google 公司由于其内部的需要,将名为 Protocol Buffers 的数据交换格式作为开源项目对外公布。Facebook 公司也同样发布了名为 Thrift 的库,现在该项目已经入驻 Apache Software Foundation(Apache 软件基金会)。笔者虽然没有用过日本开发的 MessagePack 程序库,但是知道它的处理速度非常快。

Protocol Buffers、Thrift、MessagePack 都能针对结构体和枚举进行处理。在定义文件与输出代码的对应关系时,请参考各自的说明文档。这些来自 Web 企业的开发工具都用 Python 等轻量级语言来实现,也同样适应易于使用的二进制格式,所以也可以用在网络游戏中。说不定全世界很多网络游戏都在使用这些工具。此外,如果觉得这些开发工具拥有太多不需要的功能以致进行了很多无用的处理,可以以此为参考自己来开发,想必不会太难。

* * *

如果将这一节所述的这类“RPC 功能”与之前介绍的 libevent 这样的“实现套接字 API 非阻塞化的 API”结合起来,就能具备作为网络游戏通信中间件的最基本的功能了。

0.3.3 [补充]UDP 的使用

上一节和这一节以 TCP 为例进行了讲解,UDP 会在接下来的章节中介绍。目前在网络游戏中使用 UDP 主要有以下两个原因。

发送那些与可靠性相比到达速度更为重要的数据。

为了实现 NAT 遍历功能。

对于第 ❶ 点,比如在 FPS(First Person Shooter)中为了以最快的速度发送角色的移动信息而使用 UDP。在使用 TCP 的时候,数据包如果在发送过程中丢失了,就会再次发送。由于在该数据发送完毕之前下一个数据无法送达,所以会造成相当大的通信延迟。但是在使用 UDP 的情况下,不会对丢失的数据包进行重新发送,而是继续发送后面的数据包。在 Windows 和游戏机等非 UNIX 系统的主机上运行的软件,尤其是那些很占 CPU 的软件,由于经常会丢失数据包(在 Windows 内部),在这种情况下使用 UDP 可以将影响降到最低。

第 ❷ 点是在 P2P MO、C/S MO 架构的游戏中使用的技术,将会在第 5 章中详细介绍。

0.4 游戏编程基础

介绍完网络编程之后,这一节我们再来学习一下一般的游戏编程基础。

0.4.1 游戏编程的历史

自从 20 世纪 70 年代 Taito 公司的《太空入侵者》(Space Invaders)开始,游戏编程方法基本上没有发生过变化。

使用的编程语言从当时的汇编语言,到之后的 C、C++、Objective-C,也没有发生根本性变化。与互联网编程的历史相同,几乎所有的游戏基本上都是以同样的方式开发的。

因为游戏编程是在一台机器上完成的,所以在处理流程方面并没有进行国际标准化,也没有像套接字 API 这样的事实标准库。现实情况就是,各种各样特定于游戏类型、特定于硬件平台的工具鱼龙混杂,各个企业各自使用不同的工具 36。为了理解网络游戏的实现方法,下面我们将以游戏编程的基本处理逻辑为中心加以说明。

36也有例外,比如 3D 多边形(在 3D 计算机图形中,为了表示立体形状而使用的多边形)的文件保存形式需要在各组织、各企业之间频繁交换,就此发起了 COLLADA(http://www.khronos.org/collada/)等标准。

0.4.2 采用“只要能画点就能做出游戏”的方针来开发入侵者游戏

对网络游戏来说,GPU(Graphics Processing Unit)和高速的渲染库等并非是必需的,如果将这些考虑在内的话就复杂了,所以在讲解游戏开发的基础知识时,我们以“只要能在屏幕上画点就能做出游戏”37 的方针来说明。下面列出了一些需要用到的内容。

37在《ゲームプログラマになる前に覚えておきたい技術》(中文译名:成为程序员之前需要了解的技术)(平山尚著,秀和システム出版,2008 年)一书中有详细说明,请务必参阅。

  • 调用 getKey() 函数可以知道按下了键盘上的某个键

    返回值的第 1 个字节表示←(左方向键)、第 2 个字节表示→(右方向键),第 3 个字节表示空格键,由此判断按下了哪个键。

  • 画面大小为 256 × 256 像素

  • 使用函数 point(10,10,3) 可以在画面的 (10,10) 处绘制“3”所代表的颜色

    颜色值:0:黑色;1:白色;2:红色;3:绿色。

有了以上这些信息就可以制作出如图 0.7 这样的入侵者风格的游戏。看起来这与现在的游戏开发相距甚远,实际上并非如此。在能够实现像 STAR STRIKE HD38 这样的游戏的 PlayStation 3 上,也只是借助 GPU 和多核处理等手段来高速进行上述处理。首先我们忽略处理速度来加以考虑。

38高速渲染大量物体的 360°射击游戏的代表之一。

图 0.7 《太空入侵者》(入侵者游戏的例子)

{%}

图像由 Taito 公司提供。《太空入侵者》的第一代是在 1978 年发行的,它是入侵者类游戏的鼻祖。上图是截至本书撰稿时的最新版 Space Invaders Infinity Genehttp://infinitygene.net/)的截图。画面上方的是入侵者(敌对角 色),下面的是移动炮台(己方)。画面中央的短直线表示用子弹攻击入侵者,画面中锯齿状的格子是敌人的子弹。此外,本节之后给出的入侵者游戏示例的画面大小是 256×256 像素的。

0.4.3 游戏编程的基本剖析

前面所述的游戏整体结构,全部包含起来也就几个画面的程度。下面我们以 C 语言风格的伪代码来演示一下。话虽如此,这里的伪代码与实际运行的代码非常相近。

那么我们这就来看一下。MYSHIP 表示己方战机,INVADER 表示敌对的入侵者,MISSILE 表示己方导弹,将向画面上方飞去,BULLET 表示敌人攻击的子弹,将向画面下方飞来。

↓定义画面大小
#define WIDTH 256
#define HEIGHT 256
↓定义出场的图像编号(类别ID)
enum {
  MYSHIP = 0,        ←己方战机
  INVADER = 1,       ←敌对的入侵者
  MISSILE = 2,       ←己方子弹
  BULLET = 3,        ←敌方子弹
};    →(以下待续…)

接下来,我们再针对存在于画面上的物体(可移动物体)定义一个名为 Sprite 的类。Sprite 拥有图像编号 (img)、坐标 (x, y)、行进方向 (dx, dy) 这几个参数,move() 函数用于向行进方向前进一步。用 new 创建的对象初始状态下行进方向为 (0, 0),所以不会移动。在 hit() 函数中传入其他的 Sprite 对象,判断是否发生碰撞(在 8 像素矩形范围内是否有重叠)。

class Sprite
{
 public:
  int img;      ←图像编号(MYSHIP、INVADER、MISSILE、BULLET 中的哪一个)
  int x,y;      ←当前位置的坐标(单位为像素)
  int dx,dy;    ←行进方向,前进 1 帧(单位为像素)
  ↓构造函数
  Sprite(int x, int y, int img) {
    this->x = x;
    this->y = y;
    this->img = img;
    this->dx = this->dy = 0;
  }
  ↓移动 1 步
  void move() {
    this->x += this->dy;
    this->y += this->dy;
  }
  ↓碰撞检测,范围为 8 像素
  bool hit(Sprite *sp){
    if(!sp) return false;
    return (this->x + 8 > sp->x && this->y + 8 > sp->y
            && sp->x + 8 > this->x && sp->y + 8 > this->y);
  }
};    →(以下待续…)

上面这段代码只是 Sprite 类,下面将展示 main 例程。main 例程由以下两部分组成。

初始化

无限循环

在正式的游戏程序中也是如此。

初始化

首先来看一下初始化。在下面这段代码中,❶、❶' 创建己方战机,将其置于画面下方。传入 MYSHIP 作为参数指定图像编号。游戏开始时,尚未发射导弹,所以不使用 new 创建(❷)。

接着 ❸ 处,因为游戏开始时敌方入侵者总共是 60 个(12 列 ×5 行),所以全部用 new 来创建。Sprite 类的实例数一下子增加了 60 个。创建好的 Sprite 指针全部存入名为 invaders 的数组中。

❹ 中,虽然入侵者也会发射子弹,但是在游戏开始时还没有发射,所以全部初始化为 0。敌方子弹也同样是同时存在多个,所以都存入了名为 bullets 的数组中。

int main()
{
  int i,j;
  ↓①己方战机出场
  Sprite *myship = new Sprite(WIDTH/2, HEIGHT*0.8, MYSHIP); ← ①′位于画面下方正中央
  Sprite *missile = 0    ←②己方子弹
  ↓ ③ 入侵者出场
#define NUM_INVADERS(12 * 5)
  Sprite *invaders[NUM_INVADERS];
  for(i=0; i<5; i++) {
    for(j=0; j<12; j++) {
      invaders[i*12+j] = new Sprite((WIDTH / 12) * j, (HEIGHT / 10) * i);
    }
  }
  ↓ ④ 入侵者同时发射的子弹最多为10 个
#define NUM_BULLETS 10
  Sprite *bullets[NUM_BULLETS];
  for(i=0; i<NUM_BULLETS; i++) {
    bullets[i] = 0;
  }
}    →(以下待续…)

以上就是全部的初始化内容。

无限循环

接着来看一下无限循环部分。❶ 处虽然用了 while(1) 这种没有循环条件的写法,但是在 PC 游戏中可以用 Esc 键等方式来终止游戏。

❷ 以下的部分在循环开始时获取按键输入。❷ 这一行调用 getKey 函数获取键盘状态。对于 Windows 应用程序来说,在消息循环中从 Windows 系统获取消息,然后进入条件分支。游戏机中经常调用这种简单的函数。

在这个入侵者风格的示例中,玩家操纵的只有己方战机,所以判断了按键后就能直接修改 myship 的 dx 值(行进方向)。在这个游戏中对行进方向的修改是不断累加的,所以如果一直按住相应的键,就会一直往那个方向移动。

按下空格键时,己方战机就会发射导弹,因此这时就用 new 来创建这个对象,将其图像编号设为 MISSILE,坐标与己方战机的当前坐标相同。将 dy 设为表示向画面上方前进的值:-1。

while (1) {    ← ①
  int key = getKey();    ← ②
  if (key & 0x1) {       ←右方向键
    myship->dx = 1;
  } else if (key & 0x2) {      ←左方向键
    myship->dx = -1;
  } else if (key & 0x4) {      ←空格键
    if(missile == 0) {
      missile = new Sprite(myship->x, myship->y, MISSILE);
      missile->dy = -1;
    } else {    ←没有按下任何键
      myship->dx = 0;
      myship->dy = 0;
    }
  }   →(以下待续…)

各个 Sprite 的行为——游戏逻辑主体

当主循环中按键输入完成之后,各个 Sprite 就要开始行动了。这部分是实现游戏策划内容的逻辑主体,所以相对比较长。

代码 ❶ 处,调用 move 函数将己方战机向行进方向移动 1 步,由于在之前的代码中指定了 1 或者 -1,因此每次循环移动 1 个像素。在每秒 60 帧的情况下就会移动 60 个像素。在商业游戏中,如果游戏速度过快,就会追加一些处理,比如使用空循环来加以调整,或者使用硬件的计时功能来等待一段时间等。

代码块 ❷ 中,如果己方战机发射了导弹,就调用 move 函数使其移动起来。与 myship 不同,子弹可能还不存在,所以必须使用 if(missile) 来进行判断。子弹飞出画面顶部后,就将其删除以释放内存。

继续,代码块 ❸ 中,调用 move() 函数使所有的敌方子弹都行动起来。这里最重要的就是碰撞检测。如果敌方的子弹击中了己方战机,那么游戏就结束了,所以这里以 myship 作为参数对所有的敌方子弹调用 hit() 函数,碰到了的话就调用 exit() 函数(很粗暴的方式呢!)。同样,当子弹飞出画面下方时也将其删除以释放内存,同时,将数组中的相应元素设为 0,重新初始化。

代码块 ❹ 是入侵者的行动逻辑。在商业化的入侵者游戏中,入侵者一般都按照波浪式等一些特殊方式移动,但是因为比较复杂,这里就省略了。入侵者在被己方战机发射的导弹击中后会消失,所以在 hit(missile) 的返回值为真时就将其删除。此外这里还使用了随机数以一定的概率使敌方发射子弹,发射子弹是通过创建(new)一个 Sprite 来实现的,在 for 循环中检测 bullets 数组中是否有空值(值为 0 的元素),如果元素为空则使用 new 创建。如果画面中已经存在了 NUM_BULLETS(NUM_BULLETS 是敌弹的最大个数)个子弹,那么这个循环就相当于什么也没有做。如果不这样事先设置上限值的话,就有可能发生内存不足。

↓移动己方战机和导弹、敌方子弹、敌人,碰撞检测
myship->move();      ← ①

if(missile) {        ← ②
  missile->move();
  if(missile->y < 0) { ←子弹飞出画面的话就将其释放
    delete missile;
    missile = 0;
  }
}

for(i=0; i<NUM_BULLETS; i++) { ← ③ 移动敌方子弹
  if(bullets[i]) {
    bullets[i]->move();
    if(bullets[i]->hit(myship)) exit(0);    ←被击中的话游戏结束
    if(bullets[i]->y > HEIGHT) {            ←飞离画面时将其释放
      delete bullets[i];
      bullets[i] = 0;
    }
  }
}

for(i=0; i<NUM_INVADERS; i++) { ← ④ 移动入侵者
  if(invaders[i]) {
    invaders[i]->move();
    if(invaders[i]->hit(missile)) {     ←打倒入侵者
      delete invaders[i];
      invaders[i] = 0;
    }
    if((random() % 10000) == 0) {       ←敌人偶尔会发射子弹
      for(int k=0; k<NUM_BULLETS; k++) {
        if(bullets[k] == 0) {
          bullets[k] = new Sprite(invaders[i]->x, invaders[i]->y, BULLET);

          bullets[k]->dy = 1;
          break;
        }
      }
    }
  }
}    →(以下待续…)

绘制

主循环中各个可移动物体的行动完成了,就要进行最后的绘制工作了。在下面的代码段 ❶ 处首先清除画面上的所有物体。这是因为利用了眼睛的感知特点:“在描绘物体时,全部清除之后在稍微偏移的位置上进行绘制,就会感觉物体在动。”不过这与动画一样,重绘频率必须达到一定程度才能实现。

❷ 以下的绘制代码中,将 Sprite 类的实例变量作为参数传给 drawSprite 函数,分别绘制了 myship、missile、bullets 和 invaders。改变绘制顺序可以对将哪个物体绘制在最上方进行调整。击毁己方战机的只有敌弹,所以它们最重要,因此在最后绘制它们。

    ↓全部清除后进行绘制
    clearScreen();        ← ①
    drawSprite(myship);   ← ②
    drawSprite(missile);
    for(i=0; i<NUM_INVADERS; i++) {
      drawSprite(invaders[i]);
    }
    for(i=0; i<NUM_BULLETS; i++) {
      drawSprite(bullets[i]);
    }
  }        ← while 无限循环结束
}          ← main 函数结束

至此,主循环(while 无限循环)和 main 函数都结束了。

子过程

下面来看一些子过程。首先要定义绘制的图像。每个 Sprite 由 64 个像素组成,比如子弹就是如下所示的形式。循环 8×8 次就能在画面上显示出类似子弹的图像。

00000000
00011000
00011000
00011000
00011000
00011000
00011000
00000000

首先在 imageData 中定义 4 个字符串(myship、invader、missile、bullet),实际中如何使其更具魅力是图形设计人员的工作。此外在 imageColor 中定义各个图像的绘制颜色。

↓ Sprite 的大小为 8×8。用 64 个字符来定义图像
char imaged[BULLET + 1][] =
  {
  “0001010010010101010101100001000010111110101110110101011010101111”, ← myship
  “1111010101110101111010010100010101010001010101010100101110101010”, ← invader
  “0000000000011000000110000001100000011000000110000001100000000000”, ← missile
  “0000000000011000000110000001100000011000000110000001100000000000”, ← bullet
  };
int imageColor[BULLET + 1] =
  {
   3,      ← myship 为绿色
   1,      ← invader 为白色
   1,      ← missile 为白色
   1,      ← bullet 为白色
  }

实现这一逻辑的是下面这段代码的代码块 ❶ 处的 drawSprite 函数。首先根据 Sprite 类的 img 成员变量来决定图像和颜色。

然后在 ❷ 的部分中循环 8×8 次,在循环体中调用 point() 函数。这里的要点就是根据各个 Sprite 的坐标以相对坐标来绘制。

❸ 处的 clearScreen 函数将整个画面全部涂成黑色。

void drawSprite(Sprite *sp)    ← ①
{
  int i,j;

  if(!sp) return;

  char *toDraw = imageData[sp->img];    ←确定绘制的图像
  int col = imageColor[sp->img];        ←确定绘制的颜色

  for(i=0, i<8; i++) {         ← ②
    for(j=0; j<8; j++) {
      point(sp->x + j, sp->y + i, toDraw[i*8 + j] * col);   ←以相应的颜色绘制每个点
    }
  }
}

void clearScreen()             ← ③
{
  int i,j;
  for(i=0; i<WIDTH; i++) {
    for(j=0; j<HEIGHT; j++) {
      point(i,j,0);
    }
  }
}

至此,这个入侵者游戏的实现就完成了。

0.4.4 游戏编程精粹——不使用线程的“任务系统”

这里所要说明的实现方法从 30 年前的入侵者游戏到现在的 iPhone 游戏、MMORPG 游戏,几乎没有发生过变化。

从上面这个例子中我们可以看到,最重要的就是“许多物体在同一时刻都在各自运动,但实际上并没有用线程来实现”。在游戏逻辑中,所有的内容都是依次处理的。

在这个入侵者游戏的示例中,如果使用线程来实现多个物体又会如何呢?考虑一下每个 Sprite 使用 1 个线程的情况,如果就这样使用操作系统提供的线程的话就会陷入困境。

  • 所有的敌方子弹可能不会完全以相同的速度移动,这样游戏的平衡性就会被破坏了。

  • 己方的子弹与敌人进行碰撞检测时,可能会发生两个以上的物体同时碰撞的情况,所以必须具有排他机制。

  • 比 CPU 内核数多得多的线程数会导致性能变差。

综上,要严格实现游戏的策划内容,就会产生线程的运行计时和调度、正确控制排他机制等各种各样的问题。随着线程数的增加,处理负荷会变得相当高。

所以在网络游戏编程中一般不会使用多线程来控制多个敌方子弹的行为。这在业界术语中称为“任务系统”(Task system)。任务系统就是像 libevent 部分的“回调函数”所介绍的那样,以“1个导弹每次只前进 1 帧”这种非常小的单位来进行处理,将此作为一个函数来定义。这样,对所有的导弹在 1 帧内依次调用该函数就能实现实质上的并行处理。由于不使用操作系统的本地线程来切换处理,运行速度就会很高。

但是为了充分发挥如今的 CPU 的能力,根据音效处理、AI、网络、主循环、渲染等方面,有时会用到 3~5 个线程。今后多核处理器得以普及后,可以更有针对性地使用多个内核来处理物理仿真和图形绘制等方面。使用多个线程来实现游戏的处理逻辑是不太合乎常理的。

0.4.5 两种编程方法的相似性——不使用线程

对于上述不使用线程的这一点,读者是否注意到,这与之前网络编程中的服务器端编程方法是相同的。将这两者进行比较,可以看到它们有如下这些相似之处。

  • 网络编程

    对所有的套接字调用 select() 函数进行轮询,对于需要处理的内容执行 read 和 write 操作,调用回调函数来逐个处理。

  • 游戏编程

    对所有的可移动物体(这个例子中是 Sprite 类的实例)以帧为单位进行轮询,对于需要进行处理的物体调用回调函数来使其行动(进行 move 和 hit 处理)。

当并行运行的物体数达到处理器内核的数十倍以上,需要严格控制处理顺序时,使用多线程恐怕是不可能实现的。

0.5 小结

本章介绍了一般的游戏编程和网络编程的基础,读者对需要什么程度的基本技术已经有了一些认识了吧?在掌握了本章的基础知识之后,从下一章开始,我们将逐步进入网络游戏编程的核心。

专栏 确保开发效率和各平台之间的可移植性

网络游戏的开发中,也有以 Linux 服务器作为主要目标的云基础服务(IaaS,Infrastructure as a Service),正式服务器的运行环境平台集中在 Linux 的 Red Hat Linux CentOS 系统 。另一方面,客户端的平台环境则非常多样,包括 iPhone、Web 浏览器、Windows、Android、移动电话、游戏机等。

提高开发效率

为了提高开发效率,这里在平台方面提出两点主要要求。

  • 正式服务器采用 Linux 操作系统,但开发环境则是在 Windows 下使用 Visual Studio 以高效地进行开发。

  • 服务器端和客户端在碰撞检测等方面使用相同的游戏处理代码。

这两点都与“同一个程序要在不同的操作系统上运行,即确保可移植性”这一要求联系在一起。

同时,这也要求在 C/S MMO 中,服务器端和客户端使用相同的编程语言。也就是说,客户端和服务器端双方都要使用 C、C++ 或者 Java 来编写。顺带一提,现在这种情况下能够选择的编程语言只有 C、C++ 或者 Java,但是将来,C#、Objective-C 和 Go 语言等也极有可能加入这一行列,轻量级语言和 node.js等也是有力的候选语言。

P2P MO 游戏不包含服务器,所以一般不需要满足这些要求。

使用封装保持源代码级的兼容性

除了为了实现画面渲染、声音输出、键盘和鼠标输入、触屏、视频输出等客户端用户体验的功能,在使用 C/C++ 的情况下,现在通过简单的封装就能保证源代码级的兼容性。这种封装自然是在通信中间件的层次上实现的。

降低 OS 差异性的封装工作

为了降低 OS 的差异性,需要对以下这些方面进行封装。

  • 内存管理

    malloc 几乎在所有的系统中都会使用,所以很容易进行封装。

  • 套接字 API

    Windows 与 UNIX 系统(包括 iOS)中,套接字 API 的方法有所不同,所以需要将函数全部封装。

  • 线程

    将 pthread 的基本 API 进行封装就足够了。线程的使用部分限定在客户端中。调度标志等细微部分在每个操作系统中都不兼容,就像本书建议的那样,不要使用多个线程,使用单线程来实现服务器端就不会有问题。在完全不使用线程的情况下,不需要与服务器端共享源代码,所以也需要进行封装。

  • 信号

    远程管理服务器的情况下需要使用信号,而这是一种可移植性很低的方法,所以并不推荐。实现工作在 TCP 之上的 HTTP 服务,再通过套接字来实现服务的停止具有更高的可移植性。

  • 事件与计时

    使用本书介绍的 libevent,可以有效地进行封装,性能非常高。

基本上以接近 POSIX(Portable Operating System Interface of Unix,可移植操作系统接口)标准来进行封装,可以降低整体的工作量。

习惯用 QtBoost 之类通用封装库的技术人员有很多。像 CAPCOM 公司这样使用公司内部独立开发的封装库的企业也有很多。如果可以在发售游戏的平台上使用自己用得惯的程序库,当然就选择这种库了。

目录

  • 版权声明
  • 前言
  • 专业术语
  • 第 0 章 [ 快速入门 ] 网络游戏编程:网络和游戏编程的技术基础
  • 第 1 章 网络游戏的历史和演化:游戏进入了网络世界
  • 第 2 章 何为网络游戏:网络游戏面面观
  • 第 3 章 网络游戏的架构:挑战游戏的可玩性和技术限制
  • 第 4 章 [ 实践 ] C/S MMO 游戏开发:长期运行的游戏服务器
  • 第 5 章 [ 实践 ] P2P MO 游戏开发:没有专用服务器的动作类游戏的实现
  • 第 6 章 网络游戏的辅助系统:完善游戏服务的必要机制
  • 第 7 章 支持网络游戏运营的基础设施:架构、负荷测试和运营
  • 第 8 章 网络游戏的开发体制:团队管理的挑战