SSI ļʱ
您的位置: 微软IT学院 >> 新闻资讯 >> 行业信息2 >> StarCraft开发:如何避免链表引起的游戏崩溃

StarCraft开发:如何避免链表引起的游戏崩溃

  • 文章来源:微软IT学院
  • 浏览次数:
  • 发布时间:2012-9-22 14:02:53
文章摘要:本文是PatrickWyatt讲解StarCraft开发经验的第二篇文章。本文中,作者给我们解答了上篇文章中遗留的问题——为什么很多人都不会正确地使用链表,并在本文中给出了详细的证明和解决方案,相信你一定能够受益匪浅!

std::list有很多问题

如果你是一个使用STL std::list的C++程序员,那么你肯定做错了。但是,只要使用了本文提到的技术,相信你也能跻身精英程序员俱乐部,比同等级的程序员写出更好的代码!

有点垃圾广告的意思?我并不这么认为。根据观察StarCraft和Guild Wars开发人员的成败经历,我开始相信一些没有被广泛接受的的优秀编程实践确实能够对产品质量产生深远影响,下面将给你证明。

起因:他做错了

我正在看一篇Martin Sústrik的文章,他是ZeroMQ(一个我非常推荐的网络编程库)的首席开发工程师之一。ZeroMQ主要用来处理服务器间的网络交互,包括Mongrel2等优秀作品都在使用它。

Martin最近写了一篇文章来解释他为什么使用C而不是C++来开发ZeroMQ,这也是我为什么写这系列文章的原因。他在第二篇文章里主要说C++标准模板库中的链表是多么的失败(STL std::list library),所以用C语言能够做得更好。

虽然我非常尊敬他在ZeroMQ上的工作,但我认为下面这个方法比用C重新构架这些库更好。

使用std::list

下面是一个使用STL声明链表的方式(代码来自上面提到的文章):

  1. struct person { 
  2.     unsigned age; 
  3.     unsigned weight; 
  4. }; 
  5.  
  6. std::list <person*> people; 

在向上述链表中添加一些成员后,内存中会出现如下内容:

一个使用 C++ std::list创建的双向链表

下面是从链表中安全删除一个person记录的代码:

							
  1. // 注意: O(N) 算法 
  2. // -- 需要列表扫描 
  3. // -- 需要内存回收 
  4. void erase_person (person *ptr) { 
  5.     std::list <person*>::iterator it; 
  6.     for (it = people.begin(); it != people.end(); ++it) { 
  7.         if (*it != ptr) 
  8.             continue
  9.         people.erase(it); 
  10.         break// 假设person仅被链接了一次 
  11.     } 

不幸的是,这里的解链代码非常糟糕:对于一个N条记录的列表,平均需要扫描N/2次才能找到我们想要删除的元素,这也就解释了为什么链表不适合存储需要经常随机访问的数据。

更重要的是,还需要写上面那样删除列表元素的函数,这不仅会占用程序员开发时间、降低编译速度,也更容易出现bug。但如果有一个优秀的链表库,上面的代码就毫无必要了。

使用intrusive list

下面我们来说说使用intrusive list代替std::list的方案。

intrusive list要求链接域直接嵌入被链接的结构体中。和外部链接表(一个单独的用来保存对象以及前后列表指针的对象)不同的是,intrusive list在结构体被链接时就已经公开其存在了。

以下是上例使用链表重新实现后的代码:

																				
  1. struct person { 
  2.     TLink link; // “intrusive”链接域 
  3.     unsigned age; 
  4.     unsigned weight;  
  5. }; 
  6.  
  7. TListDeclare<person, offsetof(person, link)> people; 

我使用了#define宏来避免代码重复和拼写错误,所以列表的声明代码就成了这样:

																												
  1. LIST_DECLARE(person, link) people; 

如果你看一下内存布局会发现,和std::list相比内存中分配的对象更少了:


一个双向链接的intrusive list(doubly-linked intrusive list)

此外,删除列表中的元素变得更加简单和快速,而且还不需要内存回收:

																												
  1. // 注意: O(1) 算法 
  2. // -- 没有代码遍历 
  3. // -- 没有内存回收 
  4. void erase_person (person *ptr) { 
  5.     ptr->link.Unlink(); // 嗯…这里肯定有什么魔法在作怪 

更好的是,如果你删除intrusive list中的一条记录,它会自动将自己从列表中解链:

																																			
  1. void delete_person (person *ptr) { 
  2.     // 无论如何它是在哪个链表中,都会自动解链person记录 
  3.     delete ptr; 

为什么说intrusive list更好?

做到这一步你可能会疑惑,为什么很多情况下intrusive list比外部链表更好?下面是我所想到的原因:

因为这个被用来包含一个带链表的对象的链接字段通常是嵌入在对象中的,所以它不需要为了将项目链接到列表中而分配内存,也不用在解链的时候回收内存。所以应用速度++、内存占用-。

当遍历存储在侵入式链表中的对象时,它只需要一个指针间接取值就可以获取到对象;相比于std::list使用两个指针间接取值,内存缓存超负荷的情况发生得更少,所以程序也就运行地更快了——特别是在存储器停顿非常之长的现代处理器上。所以应用速度++,缓存压力-。

我们减少了代码故障路径,因为在链接项目时没有必要处理内存外的异常。代码对象大小--,代码复杂度-。

最重要的是,对象会自动从它们链入的列表中删除,消除了很多常见的bug。应用可靠性++。

我是如何将一个对象链向多个列表的?

intrusive list的最大好处在于,它能立竿见影。下面来看看这样实现的原理:

																																								
  1. struct employee { 
  2.     TLink<employee> employeeLink; 
  3.     TLink<employee> managerLink; 
  4.     unsigned        salary; 
  5. }; 
  6.  
  7. struct manager : employee { 
  8.     LIST_DECLARE(employee, managerLink) staff; 
  9. }; 
  10.  
  11. LIST_DECLARE(employee, employeeLink) employees; 
  12.  
  13. void friday () { 
  14.     // 雇佣Joe,一个出纳员 
  15.     employee * joe  = new employee; 
  16.     joe->salary     = 50 * 1000; 
  17.     employees.LinkTail(joe); 
  18.  
  19.     // 雇佣Sally,一个轮班经理 
  20.     manager * sally = new manager; 
  21.     sally->salary   = 80 * 1000; 
  22.     employees.LinkTail(sally); 
  23.     sally->staff.LinkTail(joe); 
  24.  
  25.     // 啊哦,Joe偷出纳金被逮到了 
  26.     delete joe; 
  27.  
  28.     // 现在Joe被开除了,Sally没有报告了 
  29.     ASSERT(sally->staff.Empty()); 
  30.  
  31.     // 现在她成了光杆经理了 
  32.     ASSERT(employees.Head() == sally); 
  33.     ASSERT(employees.Tail() == sally); 

相当漂亮,是不是?使用自动化清理能够避免很多常见错误。

汤里的苍蝇

有的人可能会在意包含intrusive list域的对象,这里的intrusive list域跟对象应该包含的数据没有任何关系。person记录被外部的“stuff”“玷污”了。

这样的话,类似直接向磁盘写入记录(不能安全地写指针)、使用memcmp来比较对象(又是这些讨厌的指针)这样的底层功能( leet-programmer stuff)做起来就更难了。其实你完全不应该做这些事,可靠性比速度重要得多!如果你减少使用这些用来提升速度的hack技巧,你的程序会变得更好。不要忘了千年虫bug!

我的对象链接到哪儿了?

在使用intrusive list时,程序必须声明在声明结构体时声明记录和列表之间的嵌入关系。大多数情况下这并不重要,但某些情况下则刚好相反:

																																																																											
  1. // Some3rdPartyHeaderYouCannotChange.h 
  2. struct Some3rdPartyStruct { 
  3.     // 很多数据 
  4. }; 
  5.  
  6. // MyProgram.cpp 
  7. struct MyStruct : Some3rdPartyStruct { 
  8.     TLink<MyStruct> link; 
  9.  
  10. LIST_DECLARE(MyStruct, link) mylist; 

当然,如果你不想控制结构体定义,也不想管代码究竟被分配到哪儿,你还可以选择第三方的库来继续使用std::list。

链接代码(threaded code)的注意事项

写多线程代码时,你需要特别注意:一个删除操作会调用到所有侵入式的链接域的析构函数,来保证将该元素从列表中删除。

如果被删除的对象已经从所有列表中解链了,那当然没问题;但是如果对象仍然链接在某个列表上,这时就需要锁来防止竞争状态出现。下面是几个优化方案:

																																																																																							
  1. // 使用锁封装析构函数来防止竞争状态 
  2. // Downsides:
  3. //   在调用析构函数时上锁 
  4. //   在释放内存时上锁 
  5. void threadsafe_delete_person (person *ptr) { 
  6.     s_personlock.EnterWrite(); 
  7.     { 
  8.         delete ptr; 
  9.     } 
  10.     s_personlock.LeaveWrite(); 
  11.  
  12.  
  13. -- 或者 --  
  14.  
  15. // 使用锁封装解链函数来避免竞争状态
  16. // 避免上面解决方案的Downsides,但是会再一次安全地调用Unlock(),而不必在TLink析构函数中。
  17. // unnecessarily in the TLink destructor.
  18. void threadsafe_delete_person (person *ptr) {
  19.     s_personlock.EnterWrite(); 
  20.     { 
  21.         ptr->link.Unlink(); 
  22.     } 
  23.     s_personlock.LeaveWrite(); 
  24.     delete ptr; 
  25.  
  26. -- 或者 --  
  27.  
  28. // 和上面相同,但是更健壮;因为解链行为在析构函数中完成了,所以在删除的时候就不会出现忘记解链的情况了。
  29. person::~person () { 
  30.     s_personlock.EnterWrite(); 
  31.     { 
  32.         link.Unlink(); 
  33.     } 
  34.     s_personlock.LeaveWrite(); 
  35.  
  36. -- 或者 -- 
  37.  
  38. // 和上面相同,但是减少了解锁时的竞争 
  39. person::~person () { 
  40.     if (link.IsLinked()) { 
  41.         s_personlock.EnterWrite(); 
  42.         { 
  43.             link.Unlink(); 
  44.         } 
  45.         s_personlock.LeaveWrite(); 
  46.     } 

你可以看到我做了这么多工作,对吧?

链接、列表的分配和结构复制

给intrusive list分配对象链接或者复制其结构都是不可能的,同样也无法对列表进行上述操作。这也不是你会在实践中碰到的限制。对于需要将项目从一个列表移动到另一个的特殊情况,有必要专门写一个函数将两个列表中的元素拼接在一起。

为什么不使用boost intrusive list?

Boost intrusive list.hpp实现了一个类似于我上面所写的intrusive list,它可以解决所有你遇到过的链表问题,因此它也非常难以使用。它太过复杂了——我认为完全不必如此。

如果你看过了源代码,我希望你能立刻察觉这点。首先,整个instrusive linked-list(侵入式链表),包括注释和MIT许可证,只有不到500行。

boost instrusive list.hpp和它相比显然功能更多,即使除去11!个充斥着难以理解的的现代(modern)C++模板附属的头文件也有1500行。

std::list会在什么地方发生故障?

下面是我在ArenaSrv和StsSrv中实现的代码。我写的这个服务器框架几乎在所有Guild Wars系列(包括GW1和GW2)中都使用到了,但为了让代码更清晰、简介,又重写了一遍。

下面的代码是为了防止一个叫做Slowloris的特定网络服务攻击。Slowloris会逐步地把众多的套接字(socket)和一个网络服务联接起来,直到与服务器间的联接达到饱和状态,此时服务器将无法正常工作。虽然其它服务器也会有同样的问题,但Apache服务器尤其容易受到Slowloris攻击。

																																																																																																																																										
  1. //*********** SLOWLORIS PREVENTION FUNCTIONS *********** 
  2. // 记录这个联接,如果它不能在一段(短的)时间内接受一整条信息就关闭联接
  3.  
  4. void Slowloris_Add (Connection * c) { 
  5.     s_pendingCritsect.Enter();  
  6.     { 
  7.         // 列表按顺序排序;最新的在最后面
  8.         s_pendingList.LinkTail(c); 
  9.         c->disconnectTime = GetTime() + DEFEAT_SLOWLORIS_TIME; 
  10.     } 
  11.  
  12.     s_pendingCritsect.Leave(); 
  13.  
  14.  
  15. // 从"close-me-soon"列表中删除联接
  16.  
  17. void Slowloris_Remove (Connection * c) { 
  18.     s_pendingCritsect.Enter();
  19.     {  
  20.         s_pendingList.Unlink(c); 
  21.     } 
  22.  
  23.     s_pendingCritsect.Leave(); 
  24.  
  25. // 定期检查"close-me-soon"列表
  26.  
  27. void Slowloris_CheckAll () { 
  28.  
  29.     s_pendingCritsect.Enter();  
  30.     while (Connection * c = s_pendingList.Head()) { 
  31.         // 因为列表已经排好序了,所以我们可以在发现过期的条目时将它停止掉
  32.  
  33.         if (!TimeExpired(GetTime(), c->disconnectTime)) 
  34.             break
  35.         s_pendingList.Unlink(c); 
  36.         c->DisconnectSocket(); 
  37.     } 
  38.  
  39.     s_pendingCritsect.Leave(); 
  40.  
  41.  
  42. //*********** SOCKET FUNCTIONS *********** 
  43.  
  44. void OnSocketConnect (Connection * c) {  
  45.     Slowloris_Add(c);  
  46.  
  47. void OnSocketDisconnect (Connection * c) { 
  48.     Slowloris_Remove(c); 
  49.     delete c; 
  50.  
  51. void OnSocketReadData (Connection * c, Data * data) { 
  52.  
  53.     bool msgComplete = AddData(&c->msg, c->data); 
  54.  
  55.     if (msgComplete) { 
  56.         Slowloris_Add(c); 
  57.         ProcessMessageAndResetBuffer(&c->msg); 
  58.     } 
  59.  

在这种情况下,你不会想用std::list的,因为每次从“close-me-soon”中删除一个联接时,平均需要遍历列表的50%。在Guild Wars 1的某些服务器上,有的进程会同时创建超过20000条联接,所以需要遍历10000条列表项目——这可不是一个好主意!

Credit where credit is due

这种链表技术不是我发明的,我第一次碰到它是在Mike O’Brien为Diablo写的代码,也就是在上篇文章中提到的Storm.dll中。当我、Mike和Jeff Strain开始开发ArenaNet时,Mike首先开始的就是这个的链表优化版本。

快离开ArenaNet的时候,我发现在使用了intrusive list10年之后——一直没有给予这样愚蠢的链表bug足够重视——我发现需要重新实现它,因为现在还没有更好的替代方案(尽管有boost),但还是遇到了频繁的尝试和错误。

为了避免这些重复工作,我按照MIT协议开源了这些代码,所以你可以在非商业限制下使用它。

总结

所以本文是对于intrusive list的使用说明,它会在使用完后自动清理(注意:多线程代码),用它编写出来的代码非常可靠。

Guild Wars的编程团队中包括十余个刚毕业的大学生,如果我们放任他们使用std::list编写游戏引擎,恐怕会带来难以计数的bug——我并没有冒犯他们的意思,他们确实很不错。通过让他们使用这样的工具,我们写出了超过6500000行代码——一个大型游戏——并且异常稳定、可靠。

稳定性是一个非常关键的编程指标。通过创建不错的集合类(collection classes),我们终将能够达到更远大的目标。

StarCraft开发系列第二篇结束

在第三篇中,我将着重讲解关键问题:它是怎么工作的?

等不及的话,可以好好看看这里List.h的实现。

责任编辑:admin
关键字:
SSI ļʱ