编写网络程序应该注意的几个问题060714.docx
- 文档编号:3860245
- 上传时间:2022-11-25
- 格式:DOCX
- 页数:15
- 大小:29.92KB
编写网络程序应该注意的几个问题060714.docx
《编写网络程序应该注意的几个问题060714.docx》由会员分享,可在线阅读,更多相关《编写网络程序应该注意的几个问题060714.docx(15页珍藏版)》请在冰豆网上搜索。
编写网络程序应该注意的几个问题060714
编写网络程序应该注意的几个问题
刘刚
2003年9月19日星期五
2006年7月14日星期五批注
经过一年多来《天骄》网络游戏的编程工作,有了一些经验和教训,希望在这里跟大家分享。
注:
后来我和我的同事编写了天骄II,增加了不少经验,在这里补上。
以前我们得出的一些结论到了现在发生了变化,在这里也进行了修正和补充。
毕竟我离开目标软件和网络游戏行业一年了,有些经验可以结合着具体的游戏表达出来。
网络程序与单机程序相比难度是成倍增加的
网络程序的编程和单机版相比,难度增加了许多,而且往往是超乎我们的想象的。
对稳定性需求增加
一台游戏的服务器程序需要持续不断的运行,至少要在24小时内不会发生任何问题。
这与单机游戏相比,难度增加了。
如果希望这款游戏的规模再大些,要求就更高了。
基本上要达到电信级的水平。
注:
在后来的天骄II里面,我们终于基本上做到无严重BUG,正式运营的游戏服务器可以正常运行一周没有问题,在我们自己的测试服务器上运行时间超过49天。
当然这个成绩在我离开目标的时候还没有达到,还存在一个BUG不定期的会导致崩溃,这时候是去年的7月份,天骄II收费2个月。
后来在去年年底,这个BUG由接替我工作的同事们找到,值得庆幸的是,导致BUG的代码并不是我们编写的,而是只作网络底层的程序员编写的。
这说明我们在防止BUG方面,做得很不错。
对效率要求增加
一台服务器支持的游戏人数是非常多的,每个用户的操作也是多种多样的。
中间会有慢速操作和快速操作。
就需要我们分离这些操作,提高快速操作的效率,而不能让这些操作为了等一些无关紧要的东西而降低效率。
对这部分内容进行优化势必会增加程序的复杂性和出现问题的可能性。
注:
一般我们会用多线程来充分利用服务器上的多个CPU。
程序复杂度增加
游戏的用户操作比起那些电信的操作来说要复杂的多了。
所可能导致的问题也要多很多。
服务器结构的扩充,程序内部模块的划分,模块和线程之间的交互都会使得程序更加容易出现BUG,而且这些BUG更加隐蔽。
注:
服务器程序的复杂度,不在于代码量的多少,而在于代码的BUG数量和优化的程度。
版本更新频繁
天骄的服务器程序从2003年2月14日至今就更新了123个版本,平均不到每两天就有一个版本。
每次更新都可能包含新的BUG,新的问题。
长时间的维护,同一段代码由不同的人维护,也会增加代码中的隐患。
注:
所谓的至今是只截止到我写这篇文字的时候,2003年9月,大约7个月的时间。
天骄I至今还在运营,后来更新了多少版我没有统计过。
对BUG的监测
那么如何加强对如此复杂的程序的监控呢?
下面有一些方法。
这些方法对于单机程序而言,可能并不重要,但是对于网络程序而言,就显得比较重要了。
这个时候我们写代码,其主要作用就不仅仅是实现游戏的功能,更重要的是如何使我们的代码错误更少,出了错之后更容易被找到。
注:
因为网络游戏的特殊性,我们必须不断地更新游戏,所以游戏基本上不可能出现一个阶段,在这个阶段里是完全BUGFree的。
很多修改都要求我们在没有完全进行测试的情况下就必须上线,实际上进行所谓的完全测试也基本上是不可能的。
所以,这要求我们必须很快的定位错误,并且在错误一开始发生的时候就尽量的收到报警并找到它。
Assert
在每一个模块,线程,函数的参数输入部分,增加Assert()进行参数合法性判定。
但是要注意,这个函数的作用仅仅限于Debug版,对于查找那些非常隐蔽的错误用处是不大的。
所以一般仅仅在程序开发的前期使用。
到了后期,基本上要以后后面的解决办法来查找问题。
assert(strlen(szMsg) 注: 在天骄II的时候,因为ASSERT的作用实在是太大了,我们又增加了一个编译选项: Final版。 也就是说除了普通的Debug和Release编译选项以外,又增加了一个Final。 Final才是最终服务器会用到的版本。 而我们在Release版里,重新定义了ASSERT(),而不是原来的简单的把代码设置为NULL。 重新定义以后,ASSERT()就不再报对话框,而是直接写log文件,这在公测期间尤为重要。 可以随时帮助我们防范错误。 Log文件 因为游戏的服务器经常需要长时间运行,而且大多是Release版的情况下,所以非常有必要对那些出现错误的地方进行随时的记录。 对错误的输入要有记录。 主要需要记录的内容有: ●正常错误。 程序中可能出现的正常错误,比如资源没有找到,读写磁盘失败,登陆失败等等。 ●非正常错误。 不应该出现的错误,比如人物没有找到,道具属性没有找到,怀疑是作弊的行为等等。 对于网络程序,几乎要在任何我们认为可能出现错误的地方写下Log记录。 但是因为Log记录的操作非常消耗时间,所以对于那些调用非常频繁的地方不适合用Log来记录,那就需要下面的方法。 WriteLogFile("easyrpg.log","Herecannotbearoom! \n"); 注: 因为log比较慢,所以在天骄I的后期,和天骄II,我们将这些信息输出给另外一个线程,后者是另外一台计算机,然后再保存。 这里的用途除了查找BUG以外,另外一个主要的用途是记录玩家的操作,这是为了追查复制等玩家作弊行为,监控游戏的公平性,防止玩家刷钱,洗钱。 Try和Catch 网络服务器程序经常需要运行较长的时间,而且一旦出现错误,无法得知到底是在哪里出现的。 Try和catch就帮助我们解决这个问题。 Try的作用就是保证如果这部分代码出现的了异常,程序会自动返回到一个catch中的指令里继续执行。 而不会出现非法指令的问题。 我们的全部代码都应该被保护在try和catch里,层层嵌套。 在现实情况下,错误往往会出现在程序的任何一个地方,可能会在任何时候出现,而且这些错误往往是不能立刻再现的。 我们的程序员已经不可能针对每一个错误长时间的进行“再现”尝试和跟踪。 这时候,往往需要我们一边用try先把错误跳过去,让程序可以继续运行,另一方面缩小包围圈,逐步找到问题的真正所在。 Try和catch的最大作用是当程序发生了错误,我就可以立刻知道在哪个范围里,哪个函数里发生了错误。 虽然发生错误的地方和产生错误的地方可能并不一定是一个地方,但是至少可以帮助我们尽快的确定到底是哪里出了错,并且不让这里继续出错。 在天骄里经常可以遇到几个月以后才最终找到BUG的情况。 往往当一个错误发生了,我们并不能很快定位出错的原因。 但是如果我们的程序内部有这样一个由log和try这样的纠错机制所组成的“网”的话,会非常有助于我们尽快解决问题。 实际上一般,try和写log都在一起使用,一旦发生了异常,被catch住,就应该写log把相关的信息打印出来。 intaaa=0; #ifndef_DEBUG try { #endif …(实际的代码1) aaa=1; …(实际的代码2) aaa=2; …(实际的代码3) aaa=3; … #ifndef_DEBUG } catch(...) { charszMsg[ER_MESSAGE_SIZE]; sprintf(szMsg,"newpersonex()Error: %d<%s%d%d>\n",aaa,szType,nLevel,nSLevel); WriteLogFile("easyrpg.log",szMsg); } #endif 这里要说明一下,变量aaa的作用就是帮助我们定位到底是在执行哪段代码,哪个函数的时候发生了异常。 以便于我们下次在该函数内部写入这样的try和catch的代码,继续跟踪。 注意: 对于还没有发布的程序,尤其是单机版游戏,草率的使用try反而是有害的(假如try不能很快定位错误的话)。 因为这样会徒然导致许多很奇怪的错误的产生,会掩盖真正出现错误的地方。 注: Try和Catch不能滥用,这里再次强调,尤其在客户端程序,我们不建议使用。 甚至我们在天骄II里,基本上很少使用Try和Catch了。 一个主要原因是,因为吸取了天骄I的经验,所以天骄II崩溃的机会比天骄I要少了很多。 我们基本上没有必要随时保证服务器的稳定运行。 另外一个主要原因是我们找到了一个更好的办法。 以前,我们最头疼的问题是,一旦程序崩溃,我们无法知道它是在什么地方崩溃的,函数调用的嵌套关系是怎样的。 但是我们利用了Windows提供的dbghelp.dll的功能,可以使我们随时发现相关的代码,这大大提供了我们查找错误的速度,所以Try和Catch的主要作用都可以被替代了。 当然,如果我们遇到的这个BUG,短时间内找不到,利用Try和Catch维持一下服务器的稳定还是必要的。 程序注释 程序注释的重要性不仅仅是在解决BUG方面,在大家一起维护同一段代码和自己长时间维护同一段代码的过程中,都会帮助我们的记忆。 一般我们都会要求: 在每修改一个BUG的时候,要写上修改人,日期,BUG的原因以及是怎么改正的。 在每增加一项功能的时候,要写上是什么功能,和什么部分配合。 //BUGFIX: Apr.14.2003,对中毒以后的人,必须记住是谁下的毒。 //中毒时间结束以后,要删除这个变量 注: 这个要求尤其重要。 在我们培训新程序员的时候,这是一个基本要求,在写任何代码的时候我们都要求必须这样。 因为这是目前我们认为的最有效的程序文档。 必须强制执行。 相信程序员自己会逐渐认同的,毕竟这对于需要不停阅读代码的他们,有着实际上的好处。 消息和指令要有文档 服务器和客户端之间,甚至服务器内部不同模块之间的交互,往往是通过消息的形式来实现的。 模块之间交互往往牵扯到不同的程序员之间,不同的模块之间的程序。 所以这些接口部分的内容我们建议用文档来将其规范化。 每增加一个消息,都应当在文档上有所体现。 文档上的主要内容是这个消息的作用,消息的参数,以及发出了这个消息后我们希望会收到什么样的消息。 如果每两个模块之间的交互都有这样的文档,那么模块与模块之间的接口就清晰了,发生了问题也比较容易分清责任。 我们在设计游戏中某一个功能的时候也能够有一个依托。 打开赛程安排界面 94 TDS_PLAN %d 此人ID %d 场次1-4 %d 是否可以参加 %s 比赛信息 最大人数,时间信息,比赛方1,比赛方2 注: 这相当于是制定网络协议。 这是软件工程里,拆分模块的最有效的方法,这就相当于是模块之间的接口。 这个接口定义的越清晰,模块之间的调用和访问就会越简单。 在我们的天骄I和天骄II里,我们甚至把服务器端程序都拆分成两个部分: 文字服务器和图形服务器,这样大大简化了单个模块的复杂度。 尽管这增加了消息通讯之间的复杂度,有些程序员认为效率不高,但是经过了长时间的使用,我相信他们会觉得,简单的程序对他们来讲更重要。 局域网的局限性 我们测试一般都是用公司内部的局域网络进行测试。 但是,我们必须要知道,有些错误和问题在局域网上是不能检测出来的。 比如天骄的卡机问题。 还有在内测时候出现的服务器停止响应的问题,一些利用网络反应迟缓作弊的问题等等。 都是我们在局域网无法预先得知的。 所以在最终的测试之前,一定要经过Internet的实际检验。 注: 必须要在真正的Internet环境下真实的测试。 测试的问题,一直被认为是最让人头疼的问题之一。 在盛大,每次服务器更新都是一场恶仗,由陈天桥带队,所有人员(开发、运营)必须在场,更新以后,必须等待一段时间以后,游戏稳定了,没有玩家抱怨了,所有人才可以撤离。 但是我更欣赏九城的方法: 建立免费测试区。 目标软件也采取的同样的方法,我们建立专门的免费测试区,这个区一般是由我们的内测演变而来的——这样就不必删除玩家的存档了。 新版内容先放在这个区里测试1-3天,确定没有问题了,再扩展到全部服务器。 但是在现在免费时代,免费测试区存在的意义似乎就不大了,因为没有了免费的优势。 同时,战区的结构需要更复杂些。 对内存使用的关注 服务器要想稳定运行,其内存和Windows资源的占用也是很重要的。 一定要注意内存的泄漏(MemoryLeak)。 尽管Windows提供了很方便的内存管理方式,但是有时候我们也需要自己来管理动态内存。 因为这时候我们更加容易检测到内存使用的非法情况。 再提一次天骄II最后的那个BUG,为了查找到底是谁把内存写出界了,我们把所有模块的动态内存分配都放入了一个自己写的内存池(MemoryPool),但是很遗憾的是因为当时我们采用了公司另外一个部门写的网络底层,这部分代码并没有放入pool中,所以这个问题一直没有被发现。 后来还是这个底层代码被使用在另外的一个游戏中之后,才被发现的。 所以,我们必须要时刻关注服务器程序的内存占用,这是服务器程序想要稳定运行的必不可少的条件。 运行超过49天的BUG 在Windows系统里,VC提供一个函数timeGetTime(),得到系统启动累计时间的毫秒数。 这个数值是32位的,所以基本上每隔49天就会复位一次。 而我们游戏的BUG恰巧在这个时候出现了。 这种情况按例说是非常难以出现的,因为大多数正式运行的服务器,都会每周重新启动一次。 出现异常要立即做些什么 尽量要把BUG消灭在刚刚写完的时候。 所以一旦发现问题,尽量立即就进行查找。 而对于那些一时无法定位的问题,我们就应该增加log和try这样的调试代码。 以期望下次这个问题出现的时候,我们距离错误能够更进一步。 注: 用一句通俗的话讲,这叫做“贼不走空”。 每次我们发现了问题,必须写一些代码更详细的跟踪代码。 这样这次服务器的“当机”才更有意义。 如果你发现你对这样的当机无计可施,这只能说明你应该换程序员了。 提升效率要注意的地方 服务器程序里有一些操作是必须进行序列化的。 比如: 对客户端发过来的指令的执行,我们一般都只能采用一条流水线来处理(为了减少程序的复杂度)。 而这些操作里往往既有很快就可以执行完的操作,比如修改一个属性,也有需要较长时间才能执行完的,比如读取一个场景。 如果这些慢速指令影响到那些快速指令,则会导致服务器的效率降低。 读写磁盘的操作 这些操作包括: 对图片等资源的读取,对角色档案的读取和写入,对log文件的写入等等。 磁盘操作所消耗的时间一般是内存操作的1000倍以上,所以宁肯减少一次读写盘的操作,也比增加一些程序代码要好。 当然,具体问题需要具体进行测试和分析。 如果是读写其他计算机上的磁盘的操作,比如通过网络共享来读写,速度就更慢了,这一点我们必须注意。 注: 在天骄II,我们用数据库的操作更多的来代替磁盘文件操作,减少了不少出现BUG的可能性,同时性能也有所提升。 调用硬件的次数 对网络,显示的硬件调用次数,会直接影响到程序效率。 消息队列的排队情况 模块之间的调用我们一般用消息(指令)的形式实现。 这就需要我们时刻关注这些消息队列的排队情况。 如果某个队列排队情况比较严重。 则说明有效率低下的情况。 我们就可以针对这些情况进行优化。 注: 消息的堆积这个问题在天骄II里非常突出。 因为天骄II的服务器程序的效率大幅度的提升,我们内部测试的数据可以达到2000人/服务器。 但是,因为人数太多,消息的数量曾经在公测期间造成大面积的堆积。 这需要我们花费更多的精力来进行监控的优化。 我们制作了专门的代码来检查消息队列的长度,以及他们消长的情况。 一般我们会测试出一个消息量的最大值,比如每秒处理5万条消息,一旦超过这个数量,消息就会堆积,这时候我们的服务器必须丢弃一些消息,或者减缓游戏的刷新频率(比如敌人的刷新频率),这样来缓解小溪的压力。 同时,我们也获得了一台服务器最大能够支持的用户的数量。 帧速率的平稳 理想的情况下,服务器的帧速率应该是平稳的,即便是每秒12帧,帧与帧之间的间隔应该也是平稳的。 而如果我们不能把慢速操作分离出来,就很难做到这种效果。 注: 在我们的游戏程序里,为了防止外挂,很多操作是服务器同步模拟的,并且一切都以服务器的为准。 所以这时候,服务器一般都会有一个固定的刷新频率。 比如刷新敌人,记录人物身上状态的时间,记录技能火球的移动等等。 我们在优化的时候应该尽量减少服务器每秒刷新的内容。 多线程的使用 单机游戏可以不使用多线程,但是网络方面的程序一般就都需要多线程技术了。 而多线程技术对程序员的水平提出了更高的要求。 最容易出BUG和死锁的情况 多线程的问题最主要的就是资源共享的问题。 如果一个资源同时被多个线程修改和访问,就会造成错误。 而关键区的嵌套则很容易造成死锁。 要想避免或者解决这样的问题,一般可以遵循以下的规则: ●搞清楚那些内容是要被其他线程共享的,它们被称作资源。 尽量减少这些资源。 ●尽量不要用指针来访问那些共享的资源。 对于那些可能被释放的动态资源尤其如此,这是非常危险的。 ●可以把资源拷贝一份出来,然后释放关键区,减少关键区包含的范围。 关键区包含的范围越大,越会降低多线程的效果,也容易不小心包含了不该包含的内容。 ●关键区内尽量不要包含慢速操作,因为这样会拖住其它的线程。 ●尽量不要嵌套使用关键区。 ●把所有同一个线程的操作写在同一个文件里。 这样会帮助你理清头绪。 写线程最省事的办法 一个10分钟就可以为你的程序增加一个线程,却不会出任何关键区错误的方法是这样的: 1.这个线程和其它线程通讯的唯一途径就是消息。 通过一个接受消息链表和一个发送消息链表,来进行本线程和其它线程的通讯。 2.在这两个链表的添加消息和删除消息的函数里分别设置关键区。 3.保证只有这两个消息链表才是线程之间的共享资源,其它的所有内容都不是。 CDMList CCriticalSectiongCS_SRBN; intSRBN_AddRecvMsg(intnIDNet,intnKey,LPCTSTRszCmd,LPCTSTRszParam) { intnCount=gSRBN_RecvList.GetCount(); if(nCount>=1000&&((nCount%1000)==0)) { charszMsg[NET_MESSAGE_SIZE]; sprintf(szMsg,"SRBN_AddRecvMsg(): TooMuch<%d>\n",nCount); WriteLogFile("easyrpg.log",szMsg); } gCS_SRBN.Lock(); CSRBN_MsgItem*pmsg=gSRBNPool.NewPtr(); if(pmsg) { assert(strlen(szParam)<(int)sizeof(pmsg->m_szMsg)); sprintf(pmsg->m_szMsg,"%s%s",szCmd,szParam); pmsg->m_nMsgSize=strlen(pmsg->m_szMsg); assert(pmsg->m_nMsgSize<(int)sizeof(pmsg->m_szMsg)); pmsg->m_nIDNet=nIDNet; pmsg->m_nKey=nKey; gSRBN_RecvList.AddTail(pmsg); } gCS_SRBN.Unlock(); gSRBN_Sema.Unlock (1);//接收线程可以接收 return1; } intSRBN_RemoveRecvMsg(int*pnIDNet,int*pnKey,char*szMsg,intnSize) { intret=0; gCS_SRBN.Lock(); if(! gSRBN_RecvList.IsEmpty()) { CSRBN_MsgItem*pmsg=gSRBN_RecvList.RemoveHead(); if(nSize>pmsg->m_nMsgSize) { memcpy(szMsg,pmsg->m_szMsg,pmsg->m_nMsgSize); assert(pmsg->m_nMsgSize szMsg[pmsg->m_nMsgSize]=0; *pnIDNet=pmsg->m_nIDNet; *pnKey=pmsg->m_nKey; assert(pmsg->m_nKey! =-1); } else { assert(0); } gSRBNPool.DeletePtr(pmsg); staticintnloop=0; if(nloop%101==0) { gSRBNPool.Fix(100); } nloop++; ret=1; } else { ret=0; *pnIDNet=-1; *pnKey=-1; } gCS_SRBN.Unlock(); returnret; } 当然,这样的写法仅仅对一部分线程有效,对于那些还需要共享其它资源的线程是没有太大用处的。 注: 要用这里的算法请注意,消息的拷贝将可能是你的程序消耗CPU的罪魁祸首。 硬件环境 在编写和测试多线程程序的时候,一个双CPU的计算机对你会有非常大的帮助。 一个在单CPU计算机运行一宿才可能出现的问题,在一个双CPU的计算机上一个小时就可能出现了。 这样可以节省你大量的测试时间。 注: 值得庆幸的是,在Windows系统下,你不用自己去管你的线程应该会用在哪个CPU上。 文件操作中容易出错的地方 如果程序对一个文件既有读操作,又有写操作。 那么对这个文件和整个程序而言是极为危险的。 因为如果一旦写入文件失败,则会影响到读的操作,而读操作的失败则可能导致整个程序的崩溃。 如果这个文件是玩家的存档,则很容易造成档案数据的永久性损坏。 注: 还是那个建议: 尽量用数据库来代替文件系统,又快又不容易出错,更好查询和修改。 天骄I的问题是因为这是一个从单机游戏改装过来的网络游戏,你总不能让用户在玩一款单机游戏的时候还要再安装一个数据库吧? 多个服务器共享文件 在天骄中,会出现多台服务器程序读写同一个文件的情况。 比如门派系统的存盘文件。 读文件无所谓,但是对于写文件,如果出现多个进程同时写入一个文件,则会导致这个文件的数据混乱。 这时候我们必须自己建立一个跨计算机的“关键区”。 目前我们所采取的方法是建立“文件锁”的方法。 在写操作开始前,判断是否有文件锁,如果没有则创建一个文件锁,然后写入,写入结束以后,删除这个文件锁。 写盘操作最不容易出错的写法 在读写玩家存档这样的操作中。 为了防止在写入文件时失败导致的文件不可读。 我们一般采取下面的做法: 先把文件写入到一个临时文件中。 如果最后成功,再把这个文件改名成为正式文件。 当然,文件的结构也是非常重要的,如何既可以随时修改和扩充,又可以随时校验读写的正确与否,都是我们要考虑的。 最后,我们必须判断所有的读写函数的返回值。 如果出现错误必须能够及时发现,及时处理,以确保如果读取了出了错的文件不会对整个系统造成致命的影响。 因为这毕竟不同于单机游戏,网络游戏里即使读了错误的文件也不能使系统崩溃。 用数据库代替文件操作
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 编写 网络 程序 应该 注意 几个问题 060714