IT技术博客大学习 共学习 共进步

无锁消息队列

云风的 BLOG 2012-06-20 22:53:28 浏览 3,982 次

    最近三周按计划在做第一里程碑的发布工作,几乎所有新特性都冻结了。大家都在改 bug 和完善细节。

    服务器的性能还有不小的问题,压力测试的结果不能满意。原本我希望可以轻松实现 40 人对 40 人的战场。现在看起来在目前台式机上还有困难,虽然换上高配置的服务器可以达到,但会增加不少成本。我们决定动手做一些优化。

    固然过早优化的必要性不大,但早期发现的性能问题有可能是设计原因造成的。尽早发现设计中的错误,早点改正,比后期在低层次优化要省力的多。

    我们采用的是大量进程(非 OS 进程,这里指 Erlang 进程)协作工作的模式。可以充分利用多核的优势,但却对内部通讯的数据交换产生的极大的压力。初步发现在多人对战时,90% 的内部通讯包是状态包的同步 。虽然我们的框架实现,会让单台机器上的 Erlang 进程间通讯,变成同一进程内的简单函数参数传递。但数据列集和内存复制还是会带来一些负荷。

    目前还不太确定内部数据包传递的边际成本到底是否影响到整体性能,我打算从优化这一部分的设计和实现入手,确定问题所在。

    状态同步,简单说就是一个玩家的 Agent 在做一个动作时,它需要把这个行为通知所有在虚拟场景中他附近的玩家。当很多人(超过 50 人)在一起时,就有大量的数据包需要广播出去。我们目前的做法是基于这样一个假设:服务器内部数据包的传递非常廉价。广播包比逐个发送更加廉价,这是因为,单机内部广播,可以避免大量的数据复制。所以,在同一张地图上,我们会简单的把任意一个 Agent 的状态改变信息广播给同张地图的所有其他人。这样就不需要动态维护分组信息。当每个 Agent 收到广播包后,再根据自身的逻辑进行过滤,再发给对应的客户端。

    或许我们需要一个更为高效的广播方案,避免一些无谓的包复制。

    我想实现这样一个数据结构:

    有很多写入者,可以并发的向一个消息队列写入信息包。同时有很多读取者,可以并发的从这个队列读取这些信息包。

    假设内存无限大,队列可以无限长。那么队列无需清除永远不会被读到的数据。写指针可以被共享,任何人写入都向前移动写指针(队列尾)即可。每个读取者各自维护一个读指针(队列头),可以并发读取,互不影响。

    此数据结构可以被简单的实现,并做到无锁的并发安全。

    我是这样实现的:

    用两块连续内存,一个保存索引 INDEX_QUEUE,一个保存信息包的实际数据 DATA_QUEUE。

    对于进入队列,大概是这样的流程:

  1. OFFSET = SYNC_FETCH_AND_ADD(DATA_QUEUE.TAIL , SIZE)
  2. WRITE_DATA(DATA_QUEUE , OFFSET, DATA , SIZE)
  3. INDEX = SYNC_FETCH_AND_ADD(INDEX_QUEUE.WTAIL, 1)
  4. INDEX_QUEUE[INDEX] = OFFSET
  5. WHILE NOT SYNC_BOOL_COMPARE_AND_SWAP(INDEX_QUEUE.RTAIL , INDEX, INDEX+1)

    下面解释一下:

    第一步,我们增加数据队列的尾指针,空出足够的空间。第二步将数据写入准备好的内存中。第一步的原子性保证了第二步的数据准备工作可以并行进行。

    在读队列中的数据时,我们依据的是索引队列的指针位置,而不是数据队列。这样,不会读到没有准备好的数据。

    索引队列有两个尾指针,以保证第四步的安全写入。

    第三步,原子递增索引队列的 WTAIL ,分配出一个索引空间,供第四步原子写入索引。

    第五步,将 RTAIL 递增。注意这里,不能简单的加一。而是要在 WTAIL 的原有值的基础上加一。这是因为,第四步有可能被并发,而我们必须保证并发时,较小的 INDEX 值先被加一推进 RTAIL 。

    读队列的流程简单的多,每个线程独立维护各自的队列头指针,所以不再需要原子操作,每次仅需要从队列头读到 RTAIL 处即可。


    由于队列内存不可能无限大,所以我们需要实现成循环队列,在内存块满的时候,回转一下。这会使实际的实现代码比上面的伪代码更复杂一些。不过还是可以实现成无锁的数据结构。

    在我们的实际应用中,队列维护者不需要了解所有的读取者。每个队列的订阅者,在订阅的那一刻,重置读指针到队列尾。然后以一定的频率(目前是 0.1s),每次都处理完队列里的所有数据。而队列按照估计值,大约可以保存至少 1 秒甚至更多的数据包(大约有 3 万个数据包的余量,即使有上千人拥挤在同一个地图上,也需要花上远大于 0.1 秒的很长时间才会填满)。

    这样,每个 Agent 需要向地图广播数据的时候,只需要把数据压入地图广播消息队列。然后定期从广播消息队列读取这个心跳的所有广播包就可以了。它甚至不需要从队列中复制出广播包来做过滤和转发处理,而用指针指向队列上的数据区直接处理就够了。


    我只花了半天时间,200 多行 C + Lua 封装代码就实现了这个数据结构。但是多线程程序果然到处都是坑,又花了一天时间才解决掉其中的并行问题的 bug 。

    我们成功的把内部通讯包降低到原有的 10% 。整体性能有所提升(大约 10% 到 20%),没有我预想的效果那么明显。接下来还需要更多的统计信息,找到下一个热点。

建议继续学习

  1. 无锁消息队列 (阅读 14,125)
  2. 多线程队列的算法优化 (阅读 7,604)
  3. TSQ 的原理 (阅读 6,884)
  4. 各消息队列软件产品大比拼 (阅读 6,084)
  5. Gearman Server 使用 MySQL UDFs 来管理和保持队列 (阅读 5,763)
  6. RabbitMQ与Redis队列对比 (阅读 4,226)
  7. 一些队列理论 吞吐量、延迟和带宽 (阅读 4,163)
  8. 实现多线程对队列的读写操作(封装类) (阅读 4,085)
  9. Feed消息队列架构分析 (阅读 3,723)
  10. 一淘网offline系统简介 (阅读 3,221)