谈谈游戏服务器代码抽象

在我刚开始写代码时,许多书籍不断教导我遵循正交性DRY原则(Don’t Repeat Yourself)和SPOT原则(Single Point of Truth)等编程准则。

这些原则确实能够显著提高代码质量和可维护性,我也乐此不疲地在日常开发中应用这些原则。

然而,当我转向编写游戏服务器代码时,却发现自己常常难以严格执行这些原则。

在游戏服务器的开发中,各个模块通常独立存储它们所需的数据。

用关系型数据库的术语来说,每个模块可能会使用独立的table。不过,游戏服务器的存储方式通常并不是按字段构建table,而是将结构体(struct)序列化为blob格式后进行存储。

模块之间很少通过外键进行约束,数据一致性和容错性完全依赖于代码的逻辑处理。

通常,模块内部负责将数据存储到数据库。如果多个模块共享一个table,由于各模块落地时机问题,如果不妥善处理,会大大增加数据不一致的风险。

此外,加上强类型语言的约束以及各模块之间相似却微妙的逻辑差异,迫使开发者反复编写相似但不完全相同的代码。

这些年,我尝试了不同的方法来增加代码的复用。

  1. 抽象中间结构:当我们需要使用某段代码时,可以将DB结构转换成抽象的内存结构,处理完业务逻辑后再将内存结构转换回DB结构进行存储。
  2. 复用设计好的DB结构:在设计模块数据结构时,复用一些通用的DB结构,并直接调用相同的算法代码。

然而,这两种方式都有很大的局限性。

例如,方式1可能会转换1000个数据,但最终只修改其中的1个数据。不进行转换是不行的,因为修改一个元素时可能会读取其他数据。一个典型的例子就是抽卡。

方式2的复用性更差,举例说明:

struct common {
    // 一些公共字段
}
struct entry_a {
  struct common c;
  // 其他字段
}
struct entry_b {
  struct common c;
  // 其他字段
}
struct module_a {
  struct entry_a[] list;
}
struct module_b {
  struct entry_b[] list;
}

由于强类型语言的限制,几乎不可能直接用相同的代码同时处理module_a.listmodule_b.list

现实中的情况更复杂,比如entry_?.other_fields往往会以某种方式影响struct common[] list的逻辑。


在我以前的游戏服务器开发中,我一直被数据库落地给禁锢了。

我一直认为数据库落地的不一致是无法解决的,因此也就不可能抽象出公共模块来存储并处理不同模块中相同的逻辑。

这就导致了,在进行代码设计时,无法自由的进行抽象。从而在方式1方式2之间徘徊。

直到最近我终于找到了部分答案。

解决方案其实也很简单:模块不再负责数据的落地,而是通知落地框架需要落地的数据。

落地框架将在合适的时机(如每秒, 或每条协议之后)统一处理所有脏数据的落地。

如果在落地之前服务器崩溃,丢失的也仅仅是最后一秒的修改,相当于游戏回档到1秒前。

这对玩家几乎没有影响(充值问题可以通过掉单处理,充值服务器只需重新补发1秒前的订单即可)。

为强类型语言的游戏服务器设计这个落地框架会比较麻烦,但收益是巨大的。

从此在进行数据库抽象时,我们不必再有数据库的心智负担。可以像普通应用程序那样进行自由的抽象。

最近碰到的一个分布式一致性问题

在之前的游戏设计中,好友功能只在同一服务器内生效,涉及到以下几个操作:

  • A玩家想加B玩家为好友,首先需要向B玩家发出好友申请
  • B玩家可以选择删除申请,也可以同意。一旦同意,AB的好友列表中都会同时显示对方。
  • 成为好友后,A每天可以向B送花,AB的关系会因此增加友情值。当B收到A的花时,双方的友情值也会增加。每日送花的次数会在0点重置,但累计的友情值不会清空。
  • 根据友情值的累积,AB可以领取不同档次的奖励,每个档次的奖励只能领取一次。

最近策划提出了一个新需求,要求去掉服务器之间的限制,使所有服务器的玩家都可以互加好友,且在操作上与同服好友无异。

面对这个需求,我有两个选择:

  1. 单独做一个服务,将所有好友关系集中管理。这样可以避免数据一致性问题。
  2. 扩展现有的本服好友系统,将跨服好友纳入其中,但会涉及到同步问题。

虽然单独做一个服务能够较轻松地解决数据一致性问题,但也带来了新的挑战:

  • 其他模块可能依赖好友关系,如果把好友关系放在一个单独的服务中,依赖此关系的业务需要异步查询,增加了不确定性。
  • 当玩家数量和在线人数达到一定规模时,如果需要进行水平扩展,数据一致性问题仍然会重新浮现。
  • 由于游戏已上线,如果跨服好友和本服好友分开存储,两个系统的融合也会带来不小的复杂度。

因此,我最终选择了扩展本服好友系统,让它能够接管所有好友关系,而不仅仅是本服好友。毕竟,“复杂度留给自己,为他人提供简洁的接口”才是最佳选择。


好友关系是强双向的,因此将好友关系存储在各自玩家的服务器中必然涉及数据一致性问题。

在处理好友申请同意申请时问题不大,这些操作可以很容易地设计为幂等操作。比如,B同意A的好友申请时,可以先向A所在服务器添加好友关系,成功后再从B的申请列表中删除关于A的申请。即便在中途协议超时,也可以通过重试机制来解决。

然而,送花收花友情值的处理让我陷入了困境。

因为送花只能每天进行一次,而收花必须在送花之后才能进行。这两个操作都和每日的0点相关联,意味着送花操作无法保证幂等。

不过这也不是很重要,因为其最终的导向是友情值,而友情值才和奖励挂勾。

我最初的想法是,送花收花完成时,先在本地计算出最新的友情值,然后将这个值同步给好友的服务器。

但我很快发现这个策略存在问题:AB送的花会增加AB之间的友情值,而BA的花也会增加同样的友情值。如果双方同时进行送花收花操作,友情值可能会被相互覆盖。


这就像是数据库的双主机制,问题开始变得棘手。

经过与同事讨论。他建议不再进行数据同步,当需要使用友情值时,直接从对方服务器获取对方操作产生的友情值,然后与本地的友情值相加。

我很快否定了这个方案。

因为玩家登录时,客户端需要拉取好友列表进行红点提示或其他展示。

如果按照此方案,我必须查询所有好友所在的服务器。

最坏的情况是,有50个好友分别在50个不同的服务器,这会导致协议请求数量放大50倍。

尤其是停服维护再开服时,这个瞬间的消息量是不可接受的。

这些年我刻意对抗性能强迫症时养成了一个习惯,就算一个方案在性能上不可行,我也会忽略性能来思考他的结果究竟是不是对的,有没有可能达成更简洁的抽象。

经过仔细的思考之后发现,虽然性能问题让这个方案行不通,但它揭示了友情值机制的底层逻辑。

通过简化,我们可以将操作归纳为以下公式:假设玩家AB友情值F

  • A一次送花产生的友情值为a1,一次收花产生的友情值为a2
  • B一次送花产生的友情值为b1,一次收花产生的友情值为b2

那么,F的最终值就是a1 + a2 + b1 + b2

从公式中不难看出,F是由AB各自操作产生的友情值之和。

因此,我们无需在用到友情值时查询对方服务器,而是将友情值拆分为F_AF_B,在需要时将F视为F_A + F_B

这样,A送花时可以本地更新F_A,并尝试将F_A同步到B所在的服务器。B的操作也是如此。

这解决了双主问题,两人分别同步自己所有权的数据,即使同时操作也不会产生冲突。


ps. 这篇博客之所以值得一写,是因为在扩展已有代码时,很容易陷入旧有的思维框架,难以跳脱出来。当发现数据所有权冲突时,如果我们仔细思考一下,这块数据中,到底哪部分该由管理,也许就可以解决双主问题。

pps. 解决完所有权问题,之后的解决方案又回到了我之前提到的本地事务 + 重试的分布式编程范式。

ppps. 在游戏分布式编程中,强一致性几乎是不可能的,最终一致性才是我们追求的目标。

谈谈游戏服务器的自动化测试

当我们要测试一个’算法’是否正确时,常常会打开代码编辑器为其编写测试代码。

这种测试往往被称为单元测试,即测试相对独立的最小单元。

由于算法往往都是一个独立的个体,没有依赖,因此很容易为其编写测试代码。

这里的“容易”指的是,我们除了设计testcase之外,不会有额外的心智负担。

当我们开始为业务逻辑编写测试代码时,其复杂程度往往会是我们放弃的开始。

举个例子,class A依赖class Bclass C

当我们为class A编写测试代码时,为了解决class Bclass C对测试结果的干扰,或者验证class A执行结果的正确性,往往需要为class Bclass C编写mock class。

这意味着我们在每写一个class的同时,也需要维护至少一个对应的mock class,以方便依赖的模块来编写测试代码。

在一些编程语言中,还意味着我们需要为每个class提供一个interface的定义。即,class A依赖的是interface Binterface C,而class Bclass C只是interface Binterface C的实现。不然无法实现测试时注入mock class的需求。

我简单尝试了一下,很快便放弃了。

一是因为它对业务有侵入性,需要强制定义interface;二是因为我认为,为每个class维护一个mock class,工作量太大了。

后来我尝试对这种测试思路做出一些简化。

在编写测试代码之前,我会先人工分析出这些class之间的依赖关系。先为依赖链中的末端class编写测试代码。

当某个class的测试代码成功执行后,就认为这个模块是正确的。然后再依次向上进行测试,直到测试完依赖链的顶端。

在这个过程中,我不会提供mock class,而是直接读取到所需要的class中的内部状态来验证结果是否正确。

以上面的依赖关系为例,我会先为class Bclass C编写测试代码。

当我认为class Bclass C没有bug时,就会开始为class A编写测试代码。

为了验证class A结果是否正确,我会直接读取class Bclass C的内部状态来验证。

但这依然不足以让我为游戏服务器编写测试代码,因为游戏服务器是有状态的,而且依赖关系远比我想象的更复杂。

有一天,我突然灵光一闪,想看看luaredis是怎么来做测试的。

我发现它们也没有mock class(这里的class仅代表某个代码单元,不局限于语法)。

但是作为互联网开源软件,它们的测试是非常到位的,不然无法保证质量。

它们采用的方式都很类似,都是针对特性进行测试,而不是某个函数或class

lua作为语言虚拟机,它是没有API的,但为了帮助测试,它会在测试模式下导出一些API用于感知当前luaVM的内部状态。

redis作为一个单独的服务,它是从协议层面去测试每一个特性的。

这给了我很大的启发。我想游戏服务器也是需要从协议层面去测试所有功能的,虽然这样代码覆盖率没有单元测试那么高,但想必对代码质量也是有足够帮助的。

既然通过协议来对游戏服务器进行测试,那就势必要启动真正的游戏服务器,这就会导致更多的外部依赖:时间配置数据库

  • 时间会要求我们在测试某个功能时,服务器时间必须处于某个指定的时间区间。

  • 配置会要求我们在测试某个功能时,某个配置必须包含多少条数据或什么数值,以便测试一些corner case

  • 数据库会有持久化问题,因此某个功能测过一遍之后,状态就被保存下来,下次就无法再次触发了。

这些外部依赖无一不在揭示着“测试代码不能重复执行”这一问题。

为了解决这个问题,我当时的设想是,每测试一个功能就将数据库清空,然后时间修改到本功能所需要的时间,同时为每一个testcase保留一份配置用于测试。

但随之而来的问题就是,因为清档了,所以每执行一个测试用例,都必须要创建新号。

而测试A模块时,它可能对B模块的数据有依赖,否则协议就无法成功执行。

我最初的想法是,在测试代码库中提供很多辅助条件函数。

随着testcase的增加,需要新增的辅助函数将会越来越少。

比如我要测试A模块,它需要玩家等级达到35级,那么就有一个辅助函数,它可以将玩家升到指定等级(是通过正常协议途径,而不是GM直接修改,因为升级有可能还会触发连带效应)。

后面再依赖玩家等级的testcase就不需要再编写这个辅助函数了。

这虽然很麻烦,但理论上它是完备的,可以测试任意代码。

为了验证这一想法,我挑了一个SLG的核心地图玩法,为其编写了测试代码,来证明它的可行性。

事实证明,这确实是可行的,但也确实是麻烦的。

因为我不仅遇到了为玩家升级,还碰到了为英雄升级,为技能升级,装备技能,操作队伍等一系列的测试辅助需求。

我想朝着这个方向继续简化,却一直没有头绪。


随着这两年我对游戏服务器理解的加深,以及见到了不一样的GM创号思路,我发现我终于可以简化这一流程了。

首先,我之前为每个testcase保存一份配置表是没有必要的。

一般来讲,每个模块只会需要有限的几个表或几行数据。

我们只需要在测试框架中提供修改配置表的能力即可。

在运行测试代码前,可以直接将配置改成测试代码需要的数据即可。

其次,为了解决模块间的数据依赖,使用辅助函数达到本模块要求是可行的,但不是必要的。

据我这两年的观察和反思,模块间的依赖其实没有那么紧密,大部分情况下只要分为达到达不到即可。

比如我需要玩家等级为30级,那么100级也是满足的。

因此提供一个GM指令,创建一个足够高级的号就足够了。

如果恰好我们需要测试的模块也被高级号给初始化了,我们的testcase代码可以直接操作数据库,清空这部分数据,然后重新登录即可。

lua在测试模式下导出一些API用于感知当前luaVM的内部状态,也给了我一些启示。

我们可以增加一个指定GM指令用来获取玩家或全服的状态,用于我们在测试代码中感知游戏服务器进程中的准确状态。

如果游戏服务器采用了分布式技术,还需要提供一个透传GM指令,用于获取分布式系统中某个服务进程中的状态。

除此之外,测试框架还需要提供重启集群和动态修改时间的能力。

总结如下,测试框架只需要提供如下能力即可满足绝大部分测试需求:

  1. 创建高级号的能力

  2. 直接访问数据库的能力

  3. 修改配置文件的能力

  4. 修改服务器时间的能力(包括进程运行时修改)

  5. 重启集群的能力,用于每测试一个用例,都清档重启

  6. 感知集群内进程状态的能力

至此,经历七年的思考,我认为这个测试方案终于达到了可以实践的阶段。

ps. 本文是我在实践2个月之后写出的,基本上可以确定心智负担极小。

pps. 让我们将视野拉高,由于每次测试都将数据库清空,因此数据库中的数据也可以等价于内存结构。与此同时,很多单元测试的技巧也可以拿来使用。比如要保持测试用例尽可能的小(一旦测试用例过于复杂,测试用例的正确性就不能得到保证)等。

对Raft协议的一点理解

最近终于给silly实现了etcd driver

下一步就要基于etcd来实现我去年提到的分布式框架了

但在此之前,我需要知道etcd的边界在哪里,他是如何保证一致的,是否有幻读等问题。

由于etcd采用了raft共识算法,所以我需要了解raft的一些基本概念。

最近懒得阅读英文文章,因此在学习raft之前,我先查了一大堆中文资源,最后我都不太满意。

最终只能去啃CONSENSUS: BRIDGING THEORY AND PRACTICE, 于是就有本文。

CONSENSUS: BRIDGING THEORY AND PRACTICE中几乎介绍了需要实现raft中的方方面面,但我并没有看完,因为我只是想了解raft算法的边界。

因此本文只是按我的理解,从leader选举日志复制安全性三个方面来描述一下raft算法,其中我会着重描述安全性

因为我查到的所有中文资料中,对安全性的描述,我最不满意。


raft使用心跳机来触发leader选举,选举机制如下:

进程状态分为followercandidateleader三种。

所有服务器启动时都默认是follower状态。

follower接收到leadercandidate进程的有效RPC请求时,会保持在follower状态。

leader会定时向所有follower发送心跳包(没有日志条目数据的AppendEntries RPCs即为心跳包)来保持他的权威性。

如果followerelection timeout时间内没有收到心跳包,那么follower就会发起一次选举,来选择新的leader

整个选举流程如下:

  1. 一个folllower增加它的当前term(任期)并转换为candidate状态。
  2. candidate给自己投票,并向所有其他服务器发送RequestVote RPCs
  3. candidate 一直会保持在candidate状态,直到以下三种情况之一发生:
    • 得到了超过一半的服务器的投票,此时candidate会转换为leader状态。
    • 另一个candidate赢得了选举,此时candidate会转换为follower状态。
    • 选举超时,此时candidate会增加term并重新发起选举。

每个服务器在给定的term(任期)内按照先到先得(安全性部分会增加一些额外的限制)的原则, 只能投出一票。

超过一半原则会保证在一个特定的term内,只有一个candidate会胜出成为leader

一旦一个candidate胜出选举,他就会成为leader,并向所有服务器发送心跳包,来宣布他的权威性,并阻止新的选择。

在等待投票的过程中,一个candidate可能会收到其他服务器S发送AppendEntries RPC消息来声明服务器S(自己)成为leadercandidate就会检查服务器S发送的term是大于等于自己的term

如果是,那么candidate就会转换为follower状态,否则就拒绝这个RPC请求,然后继续保持在candidate状态。

可以看到整个选举过程,本质上就是使用paxos算法就term的值达成一致的过程

正如paxos一样,raft的选举过程也会产生活锁, 即永远都无法选出leader

raft为了解决这一问题,引入了随机化选举超时时间,这样就最大话限度地避免活锁


再来看看日志复制是怎么进行的,自此raft就和paxos没有任何关系了。

raft日志复制是基于quorum来保证可靠性的。

一旦一个leader被选出,他就会开始接受客户端的请求。

每个客户端请求包含了一个由复制状态机执行的command

复制状态机可以按日志写入顺序,从前往后执行日志中记录的command,以得到最终的结果。

例如:复制状态机(set x=1),(set x=5)的顺序执行完command, 那么x的最终值就是5。这也意味着只要集群中各节点中日志一致,各节点中关于某个变量的值都是一致的。

leader先将这个command作为一条新的日志(log entry)追加(append)到自己的日志中。然后对其他服务器并行发出AppendEntries RPCs,要求他们也将这条日志追加到自己的日志中。

当有超过半数的服务器都成功的将这条日志追加到自己的日志中,那么leader就会将这个日志条目应用到自己的状态机中(这就是大名鼎鼎的quorum)。

此时这条日志就处于committed状态, leader已经可以将状态机中的结果返回给客户端了。

在将结果返回给客户端之后,leader会不停的使用AppendEntries RPCs来重试直到所有followers最终都保存了所有的日志。

需要强调的是,这个committed状态是针对leader来说的,当一条日志处于commited状态,就意味着这条日志产生的结果可能已经被返回给客户端了,因此不能丢失。这一点对于后面理解安全性部分很重要。

raft保证所有处于commited的日志都会持久化,并被所有可用的状态机被执行。

整个log的组织方式如下图所示,每条日志(log entry)都会有一个惟一id, 这个idtermlog index组成,其中term就是产生本条日志的,当期leaderterm值。

当一条日志处于commited状态时,leader会将这条日志之前的所有日志都commit, 包括前任(整个集群中所有之前的)leader所产生的日志。

leader会始终记录当前已经处于committed中日志最大log index(max_log_index),这个索引会通过下一次AppendEntries RPCs(包括心跳)发送给followers

当一个follower知道一条日志处于committed状态时,他就会顺序的将这条日志之前的所有日志都应用到本地复制状态机

这条规则对于一致性没有任何帮助,只是为了防止当leader宕机时,follower成为leader后,follower能够快速的将自己的状态机和leader的状态机保持一致。

raft的日志机制会使得不同服务器间的日志保持了高度一致,这不仅会简化系统行为,而且会使结果更加可预测,也是确保安全性的重要组成部分。

raft日志有如下属性:

  1. 如果在两个服务器中的日志有相同的log indexterm,那么这两个日志包含相同的command
  2. 如果在两个服务器中的日志有相同的log indexterm, 那么这两个服务器中,这两条日志之前的所有日志,都是相同的。

第一个属性来源于这样的一个事实:leader在一个term内,对于给定的log index最多只能产生一条日志。

第二个属性则被AppendEntries中执行的一致性检查来保证。当发送一个AppendEntries RPCs时,leader会将紧接着本次新增日志(可能有多条)的上一条日志的log indexterm,发送给所有follower,如果follower发现自己不存在同样的日志,则他会拒绝这条新增日志。

举个例子,当leader发送了一个(last_term=2, last_log_index=1, entries=((term=2, log_index=2, command="x=1"),(term=2, log_index=3, command="x=2")))AppendEntries RPCs时,follower会检查自己的日志列表,如果发现自己的日志列表中不存在(term=2, log_index=1)的日志时,follower就会拒绝这个RPC请求。

正常情况下,raftleaderfollower之间的日志列表是一致的,但是当leaderfollower之间产生一系列的crash之后,日志列表可能就会变得不一致。

下图展示了一些leaderfollower之间的日志列表不一致的情况, 一个follower可能会丢失已经存在于leader日志列表中的日志,也可能会拥有不存在于leader日志列表中的日志。

raft中,leader会通过强制所有follower来复制自己的日志列表来保证一致性。

leaderfollower中的AppendEntries被拒绝时,leader会不停的向历史日志回退,直到找到第一个和follower相同的日志(term=T,log_index=L),然后使follower删除日志(term=T,log_index=L)之后的所有日志,并复制从leader中日志(term=T,log_index=L)之后的所有日志。

leader会通过找到和被复制的follower中最新一条相同的日志,然他开始往后复制,直到复制完leader中所有的日志,至此leaderfollower的日志列表就保持了一致。


上面的日志机制只保证了可以leaderfollower之间的日志列表保持一致,但是并不能保证已经处于committed的日志不会丢失。

下面来看看安全性是怎么做到这一点的。

raft规定,如果一个caididate没有包含所有已经处于committed的日志,那它就不可能在选举中胜出。

一个candidate为了被选举成功,必须要联系超过半数的服务器,这意味着,所有已经处于committed的日志至少会存在于被联系的一台服务器之中(同样基于quorum)。

这里需要重申一下,这个处于committed的日志仅仅指,他被前任leader已经应用到状态机并将结果返回给了客户端的日志。

并不是leader通过AppendEntries RPCs同步过的,已经处于committed中日志最大log index(max_log_index)

这一点需要思考一下才能得出结论,也是所有中文资料最模糊的地方,也是很关键的地方。


我们来看一个反例

假如所有的follower都维护了一个committed index, 这个committed index来源于每次leader发送的AppendEntries RPCs中的最大的已提交日志的log index

整个集群中日志内容如下, S1leader, 他已经将(term=1, log_index=2)复制到了大多数服务器中,因此S1已经将(term=1, log_index=2)标记为committed,并且为客户端返回了结果。

S1 (term=1, log_index=1, command="x=1"),(term=1, log_index=2, command="x=2")
S2 (term=1, log_index=1, command="x=1"),(term=1, log_index=2, command="x=2")
S3 (term=1, log_index=1, command="x=1"),(term=1, log_index=2, command="x=2")
S4 (term=1, log_index=1, command="x=1"),
S5 (term=1, log_index=1, command="x=1"),

根据规则,本次提交的log_index将会通过下次AppendEntries RPCs同步给所有follower

因此S2,S3,S4,S5服务中,此时leader已提交的最大的id(term=1,log_index=1)

如果此时S1 crash, S5当前持续的最大日志id(term=1,log_index=1),是大于等于S2,S3,S4的最大日志id的,因此S5有资格成为leader

这种情况下已经处于committed状态下的日志(term=1, log_index=2, command="x=2")就会丢失。

因此,如果一个caididate没有包含所有已经处于committed的日志,那它就不可能在选举中胜出只是一个必要条件,并不是用来实现的充分条件。


事实上,当一个candidate发出一个RequestVote RPC时,他会带上自己日志列表中最新日志的(term,log_index), 而集群中别的服务器收到RequestVote RPC时,会拿这个(term,log_index)和自己的最新日志(term,log_index)进行比较,如果发现自己的日志比candidate的日志(通过比较(term,log_index)二元组来确定),那么就会拒绝这个RequestVote RPC

由于RequestVote RPC会发给半数以上的服务器,因此当candidate胜出时,他必然会已经包含了所有已经处于committed的日志,不然他就收不到超过半数的投票。

之所以上面说是一个必要条件,是因为candidate也有可能包含超过已经处理committed的之外的日志,这些日志是否需要恢复会产生一个更微妙的问题。

这就需要再次增加一个限制条件来达成一致性,leader只能通过当前日志是否已经在集群中被复制超过半数来判断是否应该提交自己的当期日志,而不能用来判断是不是应该提交前任的日志。

下图展示了为何leader只能主动提交自己当期的日志。

假如在图(c)中,S1过度到term=4,并且继续提交(term=2,log_index=2),此时(term=2,log_index=2)在整个集群中的复制数量已经超过半数,因此S1可以将这条日志标记为committed

然后S1生成了一条新的日志(term=4,log_index=3), 在还没有来得及复制给follower之前crash掉了,此时S5term=3的情况成为leader, 由于他本地最新的日志为(term=3,log_index=2), 因此他可以胜出获得超过半数的投票。

此时他会强制所有follower来与自己保持一致,会覆盖掉已经处理committed状态下的日志(term=2,log_index=2), 这违反了raft的保证。

这里其实还隐含了一些更微妙的问题。在刚开始阅读原文时我没有注意到。

从图(c)之后,假设S1接受了(term=4, log_index=3)的请求并开始复制,他会先复制(term=2, log_index=2),然后再复制(term=4, log_index=3)

如果在集群中机器超过半数都已经复制到了(term=4, log_index=2), 但是在复制(term=4, log_index=3)时,S1再次crash了。

虽然(term=2, log_index=2)在集群中的复制数量已经超过了半数,但是由于(term=4, log_index=3)还没有处于committed

因此(term=2, log_index=2)还不能被标记为committed, 因此(term=2, log_index=2)是可以被别的leader覆盖掉的。

至此,raftleader选举日志复制安全性三个部分算法总算描述完了。

使用mmap来学习/proc/pid/smaps

在很多年前我就断断续续使用过/proc/pid/smaps观测过进程的状态。

当时主要用于观测栈大小和一些文件映射的信息。

当需要特定信息时,会现查文档,因此虽然对于/proc/pid/smaps有模糊的认识,但是对于具体字段的含义认识并不深刻。

纸上得来终觉浅,最终我还是打算花一些时间来写几段代码来印证/proc/pid/smaps中文档的描述。

针对某映射地址范围08048000-080bc000(例如 /bin/bash),/proc/pid/smaps的一个典型条目如下:

08048000-080bc000 r-xp 00000000 03:02 13130      /bin/bash
Size:               1084 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                 892 kB
Pss:                 374 kB
Pss_Dirty:             0 kB
Shared_Clean:        892 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:          892 kB
Anonymous:             0 kB
KSM:                   0 kB
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
SwapPss:               0 kB
Locked:                0 kB
THPeligible:           0
VmFlags: rd ex mr mw me dw

根据文档,这些字段的含义如下:

  • Size: 映射区域的大小(即虚拟内存大小)。
  • KernelPageSize: 每个VMA分配的页面大小,通常与页表条目中的大小相同。
  • MMUPageSize: MMU使用的页面大小(在大多数情况下与KernelPageSize相同)。
  • Resident Set Size (RSS): 进程驻留内存大小,指的是进程使用的物理内存大小
  • Proportional Set Size (PSS): 是指进程在内存中的页面数量,其中每个页面都按其共享的进程数进行划分。因此,如果一个进程有独占的1000页,和另一个进程共享1000页,它的PSS将为1500。
  • PSS_Dirty: 是由脏页组成的PSS的部分, Pss_Clean不包括在内,但可以通过从Pss中减去Pss_Dirty来计算
  • Shared_Clean: 共享干净页大小,指的是共享内存中未被修改的页大小
  • Shared_Dirty: 共享脏页大小,指的是共享内存中被修改的页大小
  • Private_Clean: 私有干净页大小,指的是私有内存中未被修改的页大小
  • Private_Dirty: 私有脏页大小,指的是私有内存中被修改的页大小
  • Referenced: 当前标记为已引用或访问的内存量
  • Anonymous: 不属于任何文件的内存量。即使与文件关联的映射也可能包含匿名页:当使用 MAP_PRIVATE 并且修改页面时,文件页面将被替换为私有的匿名副本。
  • LazyFree: 使用 madvise(MADV_FREE) 标记的内存量。内存不会立即使用 madvise() 释放。如果内存是干净的,它将在内存压力下释放。请注意,由于当前实现中使用的优化,打印的值可能低于实际值。如果不希望这样,请提交错误报告。
  • KSM: 其中有多少页面是 KSM 页面。请注意,KSM 放置的零页面不包括在内,只包括实际的 KSM 页面。
  • AnonHugePages: 由透明大页面支持的内存数量。
  • ShmemPmdMapped: 由大页面支持的共享 (shmem/tmpfs) 内存数量。
  • Shared_Hugetlb: 和 PrivateHugetlb: 显示历史上原因未计入 "RSS" 或 "PSS" 字段的由 hugetlbfs 页面支持的内存量。它们也不包含在 {Shared,Private}{Clean,Dirty} 字段中。
  • Swap: 显示有多少非匿名内存也使用,但已交换出去。
  • SwapPss: 此映射的比例交换份额。与 "Swap" 不同,这不考虑底层 shmem 对象的交换出页面。
  • Locked: 指示映射是否锁定在内存中。
  • THPeligible: 指示映射是否有资格分配任何当前启用的尺寸的自然对齐 THP 页面。如果是 1,否则为 0。
  • VmFlags: 该成员以两位字母编码方式表示与特定虚拟内存区域关联的内核标志。

文档还给了两点提示:

  • 读取 /proc/pid/maps or /proc/pid/smaps 是存在竞争的。在内存映射修改的同时做部分读取可能会遇到不一致性,但是Linux内核保证了部分输出的正确性。
  • 由于KERNEL配置和内核版本迭代,/proc/pid/smaps的输出以及字段可能会发生变化。各项内存配置可能会是依赖特定版本。

smaps中的字段过多,而且有一些内核和透明大页相关字段。

这里仅测试常归内存分配和非透明大页应用程序对smaps中字段的影响。

现代Linux系统都会使用ASLR技术来随机化进程空间的地址空间布局,这会使得恶意攻击者难以准确预测代码和数据在内存中的位置,从而增加了系统的安全性。

从另一个角度看,ASLR会干扰我们对smaps文件的分析。

让我们先使用sudo sh -c 'echo 0 > /proc/sys/kernel/randomize_va_space'来临时禁用掉ASLR


先来编写一段简单分配8M内存的代码,但并不对内存进行赋值,代码如下:

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
void main(int argc, char *argv[])
{
        char *x = (char *)mmap(0, 8*1024*1024, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
        printf("%p\n", x);
        for (;;) {
                usleep(1000);
        }
        munmap(x, 1000);
}

需要说明的是,这段代码并没有使用malloc,是因为malloc会重复使用分配过但是又释放的内存。

有可能使用malloc分配出来的内存,是之前代码已经使用过的,这会干扰我们的判断(比如虽然从本次分配开始,我们一直没有访问过这版内存,但是有可能在上一次分配时已经被进程访问过了)。

来看一下/proc/pid/smaps的输出(这里仅列出与上述代码相关的字段,无关字段已经省略):

...
7ffff75d1000-7ffff7dd4000 rw-p 00000000 00:00 0
Size:               8204 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                   8 kB
Pss:                   8 kB
Pss_Dirty:             8 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         8 kB
Referenced:            8 kB
Anonymous:             8 kB
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
FilePmdMapped:         0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
SwapPss:               0 kB
Locked:                0 kB
THPeligible:    1
VmFlags: rd wr mr mw me ac
...

smaps的输出可以看出Size字段正是我们分配的8M虚拟内存。

由于我们并没有对分配出来的内存有任何访问,所以RssPss都应该为0,这里却显示为8K

这可能是因为我这里的测试环境并不是标准的Linux系统,而是WSL

我在标准的Linux系统中,RssPss都会为0KB,这并不会影响我们接下来的分析。


接下来,我们读取一下使用mmap出来的内存, 再来观察smaps的输出:

diff --git a/a.c b/a.c
index 555efe5..bffd055 100644
--- a/a.c
+++ b/a.c
@@ -7,6 +7,7 @@ void main(int argc, char *argv[])
 {
         char *x = (char *)mmap(0, 8*1024*1024, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
         printf("%p\n", x);
+        printf("%d\n", x[8*1024*1024-1]);
         for (;;) {
                 usleep(1000);
         }
...
7ffff75d1000-7ffff7dd4000 rw-p 00000000 00:00 0
Size:               8204 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                   8 kB
Pss:                   8 kB
Pss_Dirty:             8 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         8 kB
Referenced:            8 kB
Anonymous:             8 kB
...

smaps的输出可以看出,所有的值都没有增加。

这并不符合我们学习的操作系统知识,从MMU的角度来看,只要我们访问了一个不存的的虚拟内存地址,就必然会产生一个minor page fault, 进而造成操作系统为这个虚拟内存地址所在的虚拟页分配物理内存。

带着疑惑,我们去查看一下/proc/pid/pagemap, 这个虚拟文件可以给出某个进程的详细内存映射信息,由于信息量过大,这个文件内容是以二进制提供的。

GitHub随便找了个解析器, 输出结果如下:

...
0x7ffff7dd0000     : pfn 0                soft-dirty 0 file/shared 0 swapped 0 present 1 library
...

/proc/pid/pagemap输出上看,操作系统已经为我们访问的内存分配的物理内存,但是/proc/pid/smaps中却并没有体现。

其根本原因就是,当我们使用mmap分配一个匿名页(MAP_ANONYMOUS)时,操作系统会保证分配出来的内存全部由0填充。

为了优化填充效率,Linux中会维护一个ZeroPage, 这个内存页是由操作系统管理的一个由0填充的只读内存页。

当我们试图对这个ZeroPage进行写操作时,就会触发copy-on-write机制,这时操作系统才会真正为当前进程分配物理内存。

为了验证这一点,让我们将代码修改如下:

diff --git a/a.c b/a.c
index 4f7b123..f1cf401 100644
--- a/a.c
+++ b/a.c
@@ -8,6 +8,7 @@ void main(int argc, char *argv[])
         char *x = (char *)mmap(0, 8*1024*1024, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
        printf("%p\n", x);
        printf("%d\n", x[8*1024*1024-1]);
+        x[8*1024*1024-1] = 1;
         for (;;) {
                 usleep(1000);
         }

smaps的输出如下:

...
7ffff75d1000-7ffff7dd4000 rw-p 00000000 00:00 0
Size:               8204 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  12 kB
Pss:                  12 kB
Pss_Dirty:            12 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:        12 kB
Referenced:           12 kB
Anonymous:            12 kB
...

果然RssPss都如预期增加了一个PageSize


到目前为止RssPss总是一样的,让我们来尝试一下两个进程共享内存,看看有什么不同。

我们先使用一个mmapMAP_PRIVATE的方式将一个8m.bin的文件映射到进程的虚拟空间,并启动一个进程, 代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
void main(int argc, char *argv[])
{
    int fd;
    char *x;
    struct stat file_info;
    // 打开文件
    fd = open("8m.bin", O_RDWR);
    if (fd == -1) {
        perror("open");
        return ;
    }
    // 获取文件信息
    if (fstat(fd, &file_info) == -1) {
        perror("fstat");
        close(fd);
        return ;
    }
    // 映射文件至内存
    x = mmap(0, file_info.st_size, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
    if (x == NULL) {
        perror("mmap");
        close(fd);
        return ;
    }
    printf("%p\n", x);
    for (;;) {
                usleep(1000);
    }
    munmap(x, 1000);
    }

这时smaps的输出如下:

7ffff7400000-7ffff7c00000 rw-p 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                   0 kB
Pss:                   0 kB
Pss_Dirty:             0 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:            0 kB
Anonymous:             0 kB
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
FilePmdMapped:         0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
SwapPss:               0 kB
Locked:                0 kB
THPeligible:    0
VmFlags: rd wr mr mw me ac

由于我们并没有读写过mmap后的内存,并没有触发minor page fault,因此RssPss都为0KB


我们来读取一下文件的内容,将代码修改如下:

diff --git a/a.c b/a.c
index d356103..b3bbbe6 100644
--- a/a.c
+++ b/a.c
@@ -29,6 +29,7 @@ void main(int argc, char *argv[])
         return ;
     }
     printf("%p\n", x);
+    printf("%d\n", x[8*1024*1024-1]);
     for (;;) {
                 usleep(1000);
     }

这时smaps的输出如下:

...
$ cat /proc/59838/smaps  | grep -A30 7ffff74
7ffff7400000-7ffff7c00000 rw-p 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  64 kB
Pss:                  64 kB
Pss_Dirty:             0 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:        64 kB
Private_Dirty:         0 kB
Referenced:           64 kB
Anonymous:             0 kB
...

Rss的字段可以看出,操作系统已经为这块虚拟内存分配了64kB物理内存。

由于我们只启动了一个进程来打开文件,所以PssRss的值是一样的。

由于我们只有一个进程来独占这块内存并且没有对文件内容进行过修改,所以分配的物理内存页被统计入了Private_Clean,而Private_Dirty为0。

我们来启动2个相同进程,smaps的输出如下:

7ffff7400000-7ffff7c00000 rw-p 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  64 kB
Pss:                  32 kB
Pss_Dirty:             0 kB
Shared_Clean:         64 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:           64 kB
Anonymous:             0 kB

由于我们启动了2个相同的进程并执行了相同的逻辑,因此Pss的值为Rss除以2

需要说明的是,RssPss并不总是存在比例关系

我们将上面的程序称为A, 然后A的基础上做出如下修改,得到程序B:

diff --git a/a.c b/a.c
index b3bbbe6..73315f5 100644
--- a/b.c
+++ b/b.c
@@ -29,7 +29,7 @@ void main(int argc, char *argv[])
         return ;
     }
     printf("%p\n", x);
-    printf("%d\n", x[8*1024*1024-1]);
+    printf("%d\n", x[8*1024*1024-1 - 128*1024]);
     for (;;) {
                 usleep(1000);
     }

同时运行程序AB, 再来看进程Asmaps文件内容:

...
7ffff7400000-7ffff7c00000 rw-p 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  64 kB
Pss:                  64 kB
Pss_Dirty:             0 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:        64 kB
Private_Dirty:         0 kB
Referenced:           64 kB
Anonymous:             0 kB
...

可以看到虽然进程AB共享了同一个文件,但是此时Pss竟然等于Rss

这是因为Pss的统计粒度是Page

比如进程A被分配了3个物理页A,B,C。这三个物理页的共享进程数分别为A:1,B:2,C:3, 那么Pss的最终值就是PageSize*(1/1 +1/2+1/3)。

而当前进程AB并没有共享任何内存页。


让我们为程序A增加文件代码,并启动两个相同的程序,修改如下:

diff --git a/a.c b/a.c
index b3bbbe6..34c8a42 100644
--- a/a.c
+++ b/a.c
@@ -30,6 +30,7 @@ void main(int argc, char *argv[])
     }
     printf("%p\n", x);
     printf("%d\n", x[8*1024*1024-1]);
+    x[8*1024*1024-1] = 1;
     for (;;) {
                 usleep(1000);
     }

smaps的输出如下:

...
7ffff7400000-7ffff7c00000 rw-p 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  64 kB
Pss:                  34 kB
Pss_Dirty:             4 kB
Shared_Clean:         60 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         4 kB
Referenced:           64 kB
Anonymous:             4 kB
...

这次的输出是目前为止信息量最大的一次。

由于我们对内存的写入,导致Anonymous的内存增加了4kB,这是由于MAP_PRIVATE参数导致的。

当我们使用MAP_PRIVATE来打开文件时,我们所有对文件的修改都会触发copy-on-write, 为当前进程分配独占物理内存。

可想而知,以MAP_PRIVATE打开的文件,也不可能真的被修改。

下面让我们来手工模拟如何计算出上述参数的值。

Rss的值代表操作系统为我们的虚拟内存映射了64kB物理内存,Anonymous的值操作系统为我们的虚拟内存映射了4kB独占物理内存。

也就是说当前两个进程的共享内存一共为60kB,字段Shared_Clean也可以印证这一点。

由于60kB内存是由两个进程共享, 并且还有4kB的独占内存,因此Pss等于60/2+4=34kB

由于修改过的脏页是私有页(自己独占), 因此Private_Dirty的值为4kB, Pss_Dirty的值为4kB/1=4kB


让我们将MAP_PRIVATE换为MAP_SHARED再来启动两个相同进程,看看有什么不同。

diff --git a/a.c b/a.c
index 34c8a42..1493c60 100644
--- a/a.c
+++ b/a.c
@@ -22,7 +22,7 @@ void main(int argc, char *argv[])
         return ;
     }
     // 映射文件至内存
-    x = mmap(0, file_info.st_size, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
+    x = mmap(0, file_info.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
     if (x == NULL) {
         perror("mmap");
         close(fd);

smaps的输出如下:

....
7ffff7400000-7ffff7c00000 rw-s 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  60 kB
Pss:                  30 kB
Pss_Dirty:             2 kB
Shared_Clean:         56 kB
Shared_Dirty:          4 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:           60 kB
Anonymous:             0 kB
...

这次操作系统已经不再为我们分配匿名内存了,整个文件映射内存全部是两进程共享,就连Pss_Dirty都变为了2kB

为了难证Pss_Dirty的值是怎么计算的,我们不再启动两个相同的进程,而是分别让两个进程写入两个不同的内存位置。

在上面代码的基础上,做出如下修改:

diff --git a/a.c b/a.c
index 1493c60..c8ebe28 100644
--- a/a.c
+++ b/a.c
@@ -29,8 +29,8 @@ void main(int argc, char *argv[])
         return ;
     }
     printf("%p\n", x);
-    printf("%d\n", x[8*1024*1024-1]);
-    x[8*1024*1024-1] = 1;
+    printf("%d\n", x[8*1024*1024-1 - 128*1024]);
+    x[8*1024*1024-1 - 128*1024] = 1;
     for (;;) {
                 usleep(1000);
     }

smaps中的输出如下:

7ffff7400000-7ffff7c00000 rw-s 00000000 08:20 65306                      /home/dev/foo/8m.bin
Size:               8192 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  60 kB
Pss:                  60 kB
Pss_Dirty:             4 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:        56 kB
Private_Dirty:         4 kB
Referenced:           60 kB
Anonymous:             0 kB

可以看到Pss_Dirty的值和和Pss的值没有直接比例关系,但是其计算方式很像。

Pss_Dirty只统计当前的共享脏页, 其中每个页面都按其共享的进程数按比例时计算。

需要说明的是,这种共享并不是说别的进程也修改了这个内存页,而是指这块内存被映射到了几个进程中去。

2023(完)

2023年又已经走完了最后一秒。相比2022年,技术上的收获更少了,令我醍醐灌顶的突破已经很久没有出现过了。

回顾历年来的blog,我发现这几年的技术增长趋势越发的平缓,2023年更是几乎趋近于直线。

虽然技术突破几乎没有,经验总算还有一点点增加。

虽然现在工作语言已经切换为Go语言,但是我依然没有放弃对Silly的开发。

我常常反思并比较,,探究使用silly来实现工作中的业务逻辑的优势和劣势。

在这个过程中,我发现即使Silly框架只存在竞争而非并发问题,抽象出一个mutex也能大幅减轻业务逻辑的抽象负担

在为Silly增加对prometheus的支持时,我也发现了自己的一个盲点

尽管我对操作系统和硬件电路的宏观运作流程有清晰的认识,但对具体细节却知之甚少。

这个缺点或许与我多年来的学习习惯有关,我总习惯于先了解全局再深入细节,但现在看来应该适时深挖一下操作系统相关的知识了。

在过去一年的业务开发中,收获不多,能够抽象成通用模式的更是只有一种而已,那是一种在分布式中如何稳定同步数据源的思路。

我继续对silly增加etcd支持的过程中,我下定决心为timer增加了取消功能。值得一提的是,增加这个取消功能在实现上并没有花费太大代价。

在编写Go语言代码的过程中,我最羡慕的不是他的各种特性,而是其便捷的性能分析器。

有了这个性能分析器,我可以更从容地遵循过早的优化是万恶之源这一箴言。

因此,我非常期待也能在编写Lua代码时也可以Don't do it yet.

正巧我也有学习eBPF和Rust的需求。因此,我决定使用Rust编写eBPF代码,以实现Lua性能分析器的功能。 当然,同时学习两种未知领域的过程必然充满挑战,但痛苦终究是短暂的。

除了上述稍显具体的收获外,还有一种无法言喻的成长,那就是我的“性能追求症”得到了极大改善。

在工作中,由于一些特殊限制,我经常会编写那些以前因时间复杂度较高而避免的代码。

然而这些代码上线之后并没有出现性能问题。这大大增加了我对过早的优化是万恶之源的信心。

除此之外,我对游戏服务器框架有了新的理解。

我认为整个游戏服务器其实只需要分为Role ServerGlobal Server就够了。

Role ServerGlobal Server只是代表数据的职责,并不局限于某种具体的业务逻辑。

Role Server只用来处理所有和玩家自身相关的数据,而Global Server用来处理玩家和玩家之间的交互数据。

当然这只是一种猜想,如果有可能我2024年会实现出来以做验证。

虽然过去任务总是没法完全完成,但是这并不能阻止我定下2024的目标:

  1. silly增加完etcd相关的支持。
  2. 实现并验证上述服务器框架的猜想。
  3. 探索如何将这套框架实施在K8S上,以学习K8S。
  4. 继续优化silly的log模块,学习ELK,尽量能实现建议的分布式追踪功能。
  5. 如果还有时间,继续学习ECS,研究ECS在网络同步和服务器端的应用。

再次实现了一个Lua性能分析器

去年学习Go语言时,有位同学说了一句让我至今仍深刻记忆的话:“我们有足够多的工具来进行性能分析,以找出性能问题的根源”。

后来我发现,Go语言的性能分析工具确实非常强大。更重要的是,它被设计成可以直接在生产环境中采样线上数据。

然而,当我写Lua代码时,我并没有自信能说出同样的话。尽管我之前曾多次实现Lua性能分析器。

这些分析器的实现原理与gprof类似,只是细节略有不同。在代码块进入时记录函数的进入时间,在退出时统计函数的执行时间和执行次数。

为了准确评估rpc:call等函数的CPU时间,还添加了一个选项用于去除coroutine的让出时间。

然而,这些性能分析器存在一些缺点:

首先,它们对宿主程序的性能影响很大。在以函数为区间进行耗时统计时,甚至可能达到1000%的性能影响。因此,不能在线上环境中使用,只能在开发期进行自测。

其次,它们只能统计Lua函数(包括C编写的闭包和lightCFunction),无法统计C模块内部的C函数开销。而使用其他C性能分析工具时,也无法分析与Lua函数相关的耗时。这在进行性能分析时会导致非常不连贯的感觉。

此外,当使用C的性能分析器进行分析时,我们会失去上下文信息。由于Lua是用C语言编写的虚拟机,当我们发现某个C函数的耗时很高时,无法确定是哪段Lua代码导致的。例如,当发现tremove函数的CPU使用率很高时,无法知道是哪段Lua代码引起的。

最后,这些性能分析器是实现在宿主进程中的。如果宿主进程陷入死循环,将无法获取任何性能分析数据。

但当时我并没有找到解决以上问题的好办法,直到最近我开始研究eBPF,我终于觉得自己可以解决这些缺点,并且实现一个和Go语言类似的性能分析器。

现在回想起来,已经过去一年了。


新的性能分析器和Go的性能分析器一样基于栈采样技术,这样可以做到对目标程序的性能影响最小。

和Go不同的是,我这次实现的Lua性能分析器和linux下的perf一样,是一个独立的程序。

这样可以做到对目标程序无侵入,并且在目标程序死循环的情况下,依然可以正常运行。

按照最初的想法,这并不是一件太困难的事情。只需要在bpf程序中获取CcallstackLuacallstack,然后在用户空间将它们合并。

最后,按照火焰图的格式进行输出并生成火焰图。

整个过程并不复杂。

然而,当我开始实际实现时,事情的发展远远超出了我的预期,整个过程触及了我知识的盲区。

我本以为eBPF发展了近9年,在内核空间获取Ccallstack应该只是一个API的事情。然而,现实却给了我一个沉重的打击。

现代编译器只要开启优化,默认情况下会抹去栈帧指针。而bpf中的内置API只能在栈帧指针保留的情况下轻易获取整个callstack

我面临两个选择:要求被性能分析的进程在编译时必须使用-fomit-frame-pointer编译选项,或者我必须手动进行栈回溯。

我的目标是对目标程序进行无侵入性能分析,我认为强制要求目标程序使用-fomit-frame-pointer也是一种形式的侵入。

目标程序需要不断忍受-fomit-frame-pointer带来的性能损失。

而且,我无法要求像libc等系统提供的so文件必须保留栈帧指针。

于是,我只剩下一种方案,就是手动进行栈回溯。

手动进行栈回溯也有两种方案。

一种是在bpf程序中将目标进程的完整栈数据复制到用户空间,然后使用libunwind进行栈回溯。

另一种是直接在bpf程序中进行栈回溯,并统计调用栈的出现次数,然后只将统计结果发送回用户空间。

很快,我否定了第一种方案。

bpf最初的目的是用于过滤网络数据包,因此eBPF程序应该基于此设计思路。

即在bpf程序中处理和加工所需的数据,然后只将需要的数据传回用户空间。

而且,如果我们在用户空间进行栈回溯,由于ringbuffer的异步性,我们无法及时采样到与收集到的栈数据相匹配的Lua调用栈(因为我们需要先回溯完Ccallstack才能获取L指针,然后再对L进行栈回溯,而这期间目标程序的Lua调用栈早已经发生变化)。

对已经抹去栈帧指针的callstack进行手动回溯,完全触及了我知识的盲区。

最初,我考虑仿照gdb的方案,通过调试信息进行栈回溯。

但是,调试信息的数据量太大,不方便传送到内核。而且,解析调试信息这样的任务也不太适合由bpf完成。

一个偶然的机会,我发现了elf文件中的一个名为eh_framesection,全名为exception handling frame。它被设计为在发生异常时为栈回溯提供完整的信息,这恰好符合我们的需求。

eh_frame是由一系列CFI指令组成的,用于为栈回溯提供指导信息。这些CFI指令按函数顺序执行,即程序执行到某一行代码时,要回溯所有寄存器的状态,需要执行函数开始到该行代码之前的所有CFI指令。

幸运的是,在回溯时我们只需要获取callerEIP和包含luaState *L变量的寄存器的值,因此可以忽略大多数寄存器的回溯信息。

为了加快bpf程序的执行速度,我们可以在将eh_frame数据发送给bpf之前进行预处理。

通过模拟CFI指令的执行,我们可以获得每行汇编对应的所有寄存器的回溯信息。

这样当我们在bpf中获取到对应的EIP时,可以使用二分查找快速获取所有寄存器的回溯规则。

为了更好地利用缓存,我们还可以生成一个类似于eh_frame_header的数组,只包含EIP,专门供bpf程序进行二分查找。

一旦获取到索引,我们可以再查询真正的eh_frame信息。


尽管上述方案没有问题,但它忽略了一个条件。

我们从elf文件读取的是相对虚拟地址(PC),而在bpf程序中获取的是经过内核映射后的绝对虚拟地址(VA)。

在对eh_frame进行预处理时,我们需要将其中的PC转换为VA。这就需要使用到elf文件的Program Header Table信息。

Program Header Table提供了整个elf文件在进程空间中映射的分段情况。根据/proc/<pid>/smaps中的映射信息,我们可以将PCVA进行转换。

具体的转换逻辑是,当<program Header Table>.p_offset/proc/<pid>/smaps中的offset相同时,表示它们属于文件的同一映射区域。

一旦我们获得了eh_frame中的PC,只需计算其在ELF文件映射块中的偏移量,加上/proc/</pid><pid>/smaps中的映射基地址,即可得到PC在进程空间中的绝对虚拟地址(VA)。

现在,我们终于可以在bpf程序中进行C的栈回溯了。


Lua的调用栈相对简单,只需要遍历整个L->ci链表即可。

但是有一个特别的问题,由于Lua中的函数都是动态的,有可能某个函数在当前分析的时刻存在,但过一会就被垃圾回收(GC)掉了。

因此,在回溯Lua的调用栈时,我们需要保留当前的所有文件信息,否则稍后可能就无法获取它们了。

然而,直接在Lua的调用栈中存储文件路径和行号会浪费大量空间。

简单计算一下,如果我们要支持的最大Lua调用栈深度为128,并且每个文件路径的最大长度为64字节,那么每个调用栈就需要浪费128 * 64 + 4个字节的存储空间。

这种存储量级是不可接受的,并且在对调用栈进行计数时,也会导致性能严重损失。

为了简化设计,我在bpf程序中创建了一个字符串映射表strings

在回溯Lua调用栈时,我们通过strings将字符串转换为uint32_t类型的id,然后使用id << 32 | line_num来构建一个虚拟地址。

为了标记这是一个合成地址而不是真实的虚拟地址,需要将即最终结果修改为(uint64)id << 32 | line_num | (1 << 63)

这种方法的之所以有效,是因为在于用户空间,地址的bit63永远为0

值得注意的是,strings是一个哈希表,因此无法保证冲突永不发生。

当字符串冲突时,我们将旧字符串和对应的id发送回用户空间,让用户空间进行存储,并为该槽位分配一个新的id

我们利用了一个事实,Lua中的大部分函数都是常驻的,因此它们的源文件TString指针很可能是相同的。

尽管冲突存在,但我们并不太关心它们。因此,我们可以将源文件TString的指针视为该字符串的哈希值,当哈希值不同时,我们直接认为这是两个不同的字符串。


bpf_helper中,有一个辅助函数bpf_get_stackid可将整个callstack映射为一个id。这对于生成火焰图非常有用。

由于我们正在手动进行栈回溯,我们需要自己实现该功能。然而,这也带来了一些好处。

通过与用户空间进行通信,我们可以利用用户空间的大内存来支持我们做一些bpf_get_stackid做不到的事情。

首先,我们需要定义一个名为stacks的哈希表。

当我们获取到一个栈回溯数据时,我们同时计算内核空间调用栈用户空间调用栈Lua调用栈的哈希值。然后,根据哈希值来确定stacks中对应的槽位。

如果槽位上已经有值,我们将比较它是否与当前的callstack相同,如果相同则数量加一。

如果不同,bpf_get_stackid将选择要么丢弃当前槽位上的旧callstack,要么丢弃新插入的callstack

由于我们可以与用户空间进行通信,我们可以选择将旧的callstack发送回用户空间,并让新的callstack占据槽位。


Lua调用栈和C调用栈也不是一帆风顺的。

Lua 5.4版本开始,Lua支持在C函数中使用yield功能。

这可能导致在L->ciLua调用信息链表)中出现某个C函数或C闭包,但在C调用栈中并不存在相应的信息。

目前的解决方案是采用一种启发式的匹配策略。

L->ci链表中的C函数与C调用栈中的C函数匹配时,我们认为从Lua调用栈的栈顶到当前C函数位置的部分是由当前C调用栈中的C函数产生的,并进行合并。


一些旁支末节。

在我最初学习eBPF程序时,我听说内核有一个bpf校验器,可以确保你编写的bpf程序永远不会损坏内核数据。

我一直觉得这很神奇,当时我在思考如果将这种技术应用于应用程序的检查中,会不会无敌。

最后才了解到,原来bpf校验器是采用宁可错杀一千也不可漏掉一人的方式进行检查的,各种报错会让人感到困惑和沮丧。

一个非常重要的知识点是,bpf校验器是在编译之后才开始校验的。

如果你写了相应的if检查,但校验器仍然报告你没有进行检查,那可能是因为你的检查被编译器优化掉了,你需要采用各种非常归的方法来阻止编译器的优化(我在这个问题上花了整整一个周末的时间来解决)。

最后源码在这里

终于给Silly的定时器增加了取消功能

silly是一个基于Lua的高并发网络框架,它的定时器是采用类似Linux内核时间轮的方式实现的。

数据结构大致如下:

struct node {
    uint32_t expire;
    uint32_t session;
    struct node *next;
};
struct slot_root {
    struct node *slot[SR_SIZE];
};
struct slot_level {
    struct node *slot[SL_SIZE];
};
struct silly_timer {
    struct slot_root root;
    struct slot_level level[4];
};

一直一来,我都没有给定时器增加取消功能。一是因为我在写业务逻辑时,对定时器取消需求不强烈。二是因为要想实现定时器取消功能,需要有一个数据结构根据session找到对应的node指针。并且可以O(1)的时间复杂度,将node从链表上删除,这会大大增加定时器的实现复杂度。

最近,当我在为silly增加etcd支持时,发现如果定时器支持取消功能,就能够以较小的开销实现各种超时逻辑,而无需构建特定的超时数据结构。这种方法会简化诸如HTTP读超时和RPC调用超时等各种超时逻辑的实现过程。

从实现上来讲,要想实现链表的O(1)删除,惟一的解决方案只能是双向链表,这几乎没有什么可以改进的余地。

但是根据session来找到对应的node指针,我一直在纠结要不要用hash表。

要实现一个可用的hash表,不可避免的需要处理冲突,理负载因子,rehash等各种情况。即使如此,也不能保证冲突不会发生。

而我加定时器取消功能的初衷是为了能在高频次场合使用,比如每秒10W次的rpc调用。

而RPC调用超时的实现一般如下:

local timer = core.timeout(5000, rpc_timer)
...
local ack, cmd = rpc:call()
if ack then
    core.timercancel(timer)
end

也就是说,在正常情况下,每秒可能会调用10W次timeout和timercancel函数。

这要求session到node指针的映射必须始终保持高效。我考察了各种哈希表实现方式,包括Lua的,但都不太满意。

最近,我突然想到了一个完美的解决方案:利用内存池来完美解决这个问题。这个想法让我感到非常惊讶,因为我从未想到过内存池还可以这样用。

由于silly主要使用Lua作为业务逻辑开发语言,底层C代码通常通过id与Lua层进行通信。

每个定时器事件,C语言层都会返回给Lua层一个session,以代表该定时器事件。也就是说只要这个session和node有惟一的双向关系,那么怎么分配session其实并不重要。

在之前的实现中,我使用了递增分配session并强行绑定给node指针的方式。这种方式必须要使用哈希表才能实现session到node指针的映射。

现在我反过来思考,先分配一个node指针,然后根据该node指针的特征生成一个唯一的session。

这样,拿到这个session后就可以反向推理出对应的node指针的值。这种实现方式其实是得到了Linux中的syn cookies机制的启示。

通过实现一个内存池,当分配一个node指针后,计算该node指针到内存池首地址的偏移量,将其作为session来使用。

由于偏移量和session_id之间存在双向映射关系,因此可以通过session_id反向计算出node指针的地址。

这种实现方式完全避免了使用hash表的各种复杂度,同时大幅降低了内存分配的开销。


有了这个想法之后,剩下的就是一些更细致的优化了。

即然是内存池,就肯定需要扩容,选择哪种扩容方式也会影响到实现的复杂度。

如果使用realloc扩容的话,内存池首地址可能会变,势必会影响到时间轮中还未超时的node节点,这就需要改变时间论中链表的实现方式。

我认为这种实现复杂度太高了。所以我换了一种思路,将内存池抽象成一个page数组,每个page的大小为4K(保持与linux内存页大小一致,可以节省不必要的page fault开销,并且cache更友好)。

当我们需要扩容时,只需要malloc一个page并将其加入到page数组中,然后做一些必要的初始化即可。

在malloc一个page时分配一个page_id, 这个page_id即为page在page数组中的索引,相应的session计算公式修改为page_id * PAGE_SIZE + (node - page)

之前递增分配session的方案中,session会过很久才会复用,可能是几个月也可能是几年。

这会对业务逻辑产生极大的容忍度,可以避免很多bug。这是内存池方案所不具备的。

幸运的是,lua中的Integer全是64bit, 即使我们的session是uint32_t, 到lua层之后依然会变成64 bit。即然如此,为什么要浪费那多出来的32bit呢。

因此我给node增加了一个version字段,每当node被释放过一次之后,就将version++。

相应的session计算公式改为version << 32 | page_id * PAGE_SIZE + (node - page)

至此,我觉得整解决方案已经符合我预期了:以不高的复杂度实现了定时器取消功能。

整个数据结构大致如下:

struct node {
    uint32_t expire;
    uint32_t version;
    uint32_t cookie;    //page_id * PAGE_SIZE + page_offset
    struct node *next;
    struct node **prev;
};
#define PAGE_SIZE   (4096/sizeof(struct node))
struct page {
    struct node buf[PAGE_SIZE];
};
struct pool {
    uint32_t cap;
    uint32_t count;
    struct node *free;
    struct page **buf;
};
struct slot_root {
    struct node *slot[SR_SIZE];
};
struct slot_level {
    struct node *slot[SL_SIZE];
};

实现完成之后,我发现由于结构对齐,strcut node有4个字节的浪费。总想用这4个字节做点什么优化。

我会在timeout函数中返回一个session, 方便超时逻辑根据session来关联一些数据,以达到减少闭包的创建的目的。

大致的使用方式如下:

local user_data = {}
local function timer(session)
    local ud = data[session]
    data[session] = nil
    --do some thing with ud
end
local session = core.timeout(1000, timer)
user_data[session] = ud

每个timer函数几乎都需要写这几行相似的代码,并且由于lua中table的特性,频繁的添加删除不重复key, 会频繁触发rehash。

虽然大概率这些rehash操作并不会成为瓶颈。但是,即可以利用一个浪费的4字节,又可以降低代码的重复度,还可以优化性能,何乐而不为呢:D。

优化之后,业务逻辑的代码就可以改为如下方式:

local function timer(ud)
end
local session = core.timeout(1000, timer, ud)

我在lua底层维护了一个timer_user_data数组。

当执行timeout时,从timer_user_data中找到一个空闲的位置(不一定是数组的末尾), 将user_data和这个位置进行绑定。

再将这个位置和C层的node进行绑定。当这个定时器超时后,将session和这个user_data所在的位置一起传入lua层。

由于timer_user_data是一个数组,因此他的key总是在1~#timer_user_data中循环使用,当timer_user_data扩容到一定程度后,再也不会触发rehash了。同样这个思路借鉴致于luaL_ref函数。

一次虚拟内存排查经历

事情的起因是这样的,我最近在给silly增加prometheus数据库支持。

在测试过程中发现,在docker中,silly刚起动就占了将近110MiB虚拟内存。

我将相同的代码在宿主机直接直行,虚拟内存只有48.32MiB。

与此同时, silly暴露给prometheus数据库的指标显示,应用程序分配了3.7MiB内存,而jemalloc一共给应用程序分配了9.38 MiB内存。

我打算先来看看这48.32MiB内存是不是合理的。

通过cat /proc/[pid]/smaps查看了一下虚拟内存的大致分配,下面是一些比较大的匿名内存段。

7ffff587e000-7ffff607e000 rw-p 00000000 00:00 0
Size:               8192 kB
...
7ffff607f000-7ffff687f000 rw-p 00000000 00:00 0
Size:               8192 kB
...
7ffff6880000-7ffff7080000 rw-p 00000000 00:00 0
Size:               8192 kB
...
7ffff7080000-7ffff7c00000 rw-p 00000000 00:00 0
Size:              11776 kB

其中三个8192KiB内存块肯定是三个线程栈所分配的虚拟内存,一共24MiB。

而11776KiB这块内存大小和jemalloc向应用程序分配的内存大小非常接近(稍大一些), 有理由怀疑这块内存其实就是jemalloc所使用的虚拟内存大小,毕竟jemalloc本身的元数据也是需要一些内存消耗的。

为了确认这一怀疑,我使用strace -f -k -e mmap ./silly/silly server-src/config来确认这一点(事实上这一步我遇到了困境,因为我没有加-f标志,导致strace不能跟踪所有线程的系统调用,以致于浪费了很多时间)。

下面是截取的一段jemalloc相关的mmap系统调用,可以看到这块内存刚好就是jemalloc所用掉的。

mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7ffff7fcf000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_boot+0x1c7) [0xb0877]
 > /home/silly/silly(malloc_init_hard_a0_locked+0x12d) [0x4f20d]
 > /home/silly/silly(malloc_init_hard+0x79) [0x4f4d9]
 > /home/silly/silly(__libc_csu_init+0x45) [0xc3895]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0x7a) [0x2402a]
 > /home/silly/silly(_start+0x2a) [0x1dcea]
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7fcf000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_boot+0x2b7) [0xb0967]
 > /home/silly/silly(malloc_init_hard_a0_locked+0x12d) [0x4f20d]
 > /home/silly/silly(malloc_init_hard+0x79) [0x4f4d9]
 > /home/silly/silly(__libc_csu_init+0x45) [0xc3895]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0x7a) [0x2402a]
 > /home/silly/silly(_start+0x2a) [0x1dcea]
mmap(NULL, 2097152, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7a49000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_map+0x4c) [0xb00cc]
 > /home/silly/silly(je_extent_alloc_mmap+0x14) [0xa56c4]
 > /home/silly/silly(base_block_alloc.isra.21+0x281) [0x63681]
 > /home/silly/silly(je_base_new+0x70) [0x64030]
 > /home/silly/silly(je_base_boot+0x17) [0x64b97]
 > /home/silly/silly(malloc_init_hard_a0_locked+0x138) [0x4f218]
 > /home/silly/silly(malloc_init_hard+0x79) [0x4f4d9]
 > /home/silly/silly(__libc_csu_init+0x45) [0xc3895]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0x7a) [0x2402a]
 > /home/silly/silly(_start+0x2a) [0x1dcea]
mmap(NULL, 4190208, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff784a000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_map+0x163) [0xb01e3]
 > /home/silly/silly(je_extent_alloc_mmap+0x14) [0xa56c4]
 > /home/silly/silly(base_block_alloc.isra.21+0x281) [0x63681]
 > /home/silly/silly(je_base_new+0x70) [0x64030]
 > /home/silly/silly(je_base_boot+0x17) [0x64b97]
 > /home/silly/silly(malloc_init_hard_a0_locked+0x138) [0x4f218]
 > /home/silly/silly(malloc_init_hard+0x79) [0x4f4d9]
 > /home/silly/silly(__libc_csu_init+0x45) [0xc3895]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0x7a) [0x2402a]
 > /home/silly/silly(_start+0x2a) [0x1dcea]
mmap(NULL, 2097152, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7800000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_map+0x4c) [0xb00cc]
 > /home/silly/silly(je_extent_alloc_mmap+0x14) [0xa56c4]
 > /home/silly/silly(je_ehooks_default_alloc_impl+0xd3) [0x9de03]
 > /home/silly/silly(je_ecache_alloc_grow+0x9b9) [0xa5039]
 > /home/silly/silly(pac_alloc_real+0x93) [0xaf3e3]
 > /home/silly/silly(pac_alloc_impl+0xe5) [0xaf525]
 > /home/silly/silly(je_pa_alloc+0x57) [0xae547]
 > /home/silly/silly(je_arena_extent_alloc_large+0x9f) [0x5b56f]
 > /home/silly/silly(je_large_palloc+0xcb) [0xac53b]
 > /home/silly/silly(je_arena_palloc+0xd5) [0x5ede5]
 > /home/silly/silly(je_tsd_tcache_data_init+0xe2) [0xbc2a2]
 > /home/silly/silly(je_tsd_tcache_enabled_data_init+0x28) [0xbc948]
 > /home/silly/silly(je_tsd_fetch_slow+0x114) [0xbe3d4]
 > /home/silly/silly(malloc_init_hard+0x9b) [0x4f4fb]
 > /home/silly/silly(__libc_csu_init+0x45) [0xc3895]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0x7a) [0x2402a]
 > /home/silly/silly(_start+0x2a) [0x1dcea]
mmap(NULL, 4194304, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7400000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_map+0x4c) [0xb00cc]
 > /home/silly/silly(je_extent_alloc_mmap+0x14) [0xa56c4]
 > /home/silly/silly(base_block_alloc.isra.21+0x281) [0x63681]
 > /home/silly/silly(je_base_alloc+0x269) [0x644d9]
 > /home/silly/silly(je_rtree_leaf_elm_lookup_hard+0x100) [0xb5280]
 > /home/silly/silly(je_emap_register_boundary+0x357) [0x9ea17]
 > /home/silly/silly(je_ecache_alloc_grow+0x60b) [0xa4c8b]
 > /home/silly/silly(pac_alloc_real+0x93) [0xaf3e3]
 > /home/silly/silly(pac_alloc_impl+0xe5) [0xaf525]
 > /home/silly/silly(je_pa_alloc+0x57) [0xae547]
 > /home/silly/silly(je_arena_extent_alloc_large+0x9f) [0x5b56f]
 > /home/silly/silly(je_large_palloc+0xcb) [0xac53b]
 > /home/silly/silly(je_arena_palloc+0xd5) [0x5ede5]
 > /home/silly/silly(je_tsd_tcache_data_init+0xe2) [0xbc2a2]
 > /home/silly/silly(je_tsd_tcache_enabled_data_init+0x28) [0xbc948]
 > /home/silly/silly(je_tsd_fetch_slow+0x114) [0xbe3d4]
 > /home/silly/silly(malloc_init_hard+0x9b) [0x4f4fb]
 > /home/silly/silly(__libc_csu_init+0x45) [0xc3895]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0x7a) [0x2402a]
 > /home/silly/silly(_start+0x2a) [0x1dcea]
mmap(NULL, 3670016, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7080000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_map+0x4c) [0xb00cc]
 > /home/silly/silly(je_extent_alloc_mmap+0x14) [0xa56c4]
 > /home/silly/silly(je_ehooks_default_alloc_impl+0xd3) [0x9de03]
 > /home/silly/silly(je_ecache_alloc_grow+0x9b9) [0xa5039]
 > /home/silly/silly(pac_alloc_real+0x93) [0xaf3e3]
 > /home/silly/silly(pac_alloc_impl+0xe5) [0xaf525]
 > /home/silly/silly(je_pa_alloc+0x57) [0xae547]
 > /home/silly/silly(je_arena_extent_alloc_large+0x9f) [0x5b56f]
 > /home/silly/silly(je_large_palloc+0xcb) [0xac53b]
 > /home/silly/silly(je_arena_malloc_hard+0x50d) [0x5ec0d]
 > /home/silly/silly(je_malloc_default+0x5a1) [0x52371]
 > /home/silly/silly(silly_malloc+0x6) [0x23356]
 > /home/silly/silly(silly_socket_init+0x57) [0x215e7]
 > /home/silly/silly(silly_run+0x88) [0x22cb8]
 > /home/silly/silly(main+0x468) [0x1d888]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0xeb) [0x2409b]
 > /home/silly/silly(_start+0x2a) [0x1dcea]

有了宿主机的这些经验后,再去查看docker中那110MiB内存来源时就轻车熟路了。

首先使用docker run --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined -it --entrypoint /bin/bash registry.cn-hangzhou.aliyuncs.com/findstr-vps/xxx来强行改写entrypoint为bash和添加strace的能力,之后所有的分析都和宿主机一样了。

通过cat /proc/[pid]/smaps发现了一大块内存一个字节都没有使用,他很可疑.

7ffff0021000-7ffff4000000 ---p 00000000 00:00 0
Size:              65404 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                   0 kB
Pss:                   0 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:            0 kB
Anonymous:             0 kB
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
SwapPss:               0 kB
Locked:                0 kB
THPeligible:    1
VmFlags: mr mw me nr sd

再使用strace -f -k -e mmap ./silly/silly server-conf/config, 可以找到相应的callstack如下:

[pid    34] mmap(NULL, 134217728, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7fffed87d000
 > /lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /lib/x86_64-linux-gnu/libc-2.28.so(_IO_str_seekoff+0xd87) [0x80127]
 > /lib/x86_64-linux-gnu/libc-2.28.so(_IO_str_seekoff+0x157c) [0x8091c]
 > /lib/x86_64-linux-gnu/libc-2.28.so(_IO_str_seekoff+0x44ad) [0x8384d]
 > /lib/x86_64-linux-gnu/libc-2.28.so(__libc_malloc+0x116) [0x84626]
 > /lib/x86_64-linux-gnu/libc-2.28.so(fgets+0x1bb) [0x7022b]
 > /silly/silly(luaL_loadfilex+0x4d) [0x3a35d]
 > /silly/silly(silly_worker_start+0x19b) [0x2200b]
 > /silly/silly(thread_worker+0xb) [0x228fb]
 > /lib/x86_64-linux-gnu/libpthread-2.28.so(start_thread+0xf3) [0x7fa3]
 > /lib/x86_64-linux-gnu/libc-2.28.so(clone+0x3f) [0xf906f]

可以看到是libc中的fgets函数导致的,当我使用gdb断这个函数时,相同的系统调用又会出现在别的线程中调用的glibc函数中。

我怀疑这是glibc的一个bug。报着试试看的态度google了一下,没想到这是一个Feature(glibc 2.10+版本的多线程程序,glibc会预分配很多虚拟内存, 用来提高性能)。

但是奇怪的时,我的宿主机和docker的glibc的版本都是2.28, 宿主机却没有虚拟内存问题。

ldd (Debian GLIBC 2.28-10+deb10u2) 2.28
Copyright (C) 2018 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.

7月30号补充:

在分析过程中,我还发现一个奇怪的现象。

mmap(NULL, 2097152, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7a49000 产出了块大内存,但是从smaps中发现,这块内存包含了文件/home/silly/silly/luaclib/sys.so的内存映射, 截取部分smaps如下:

7ffff7080000-7ffff7c00000 rw-p 00000000 00:00 0
Size:              11776 kB
7ffff7c23000-7ffff7c27000 r--p 00000000 08:00 253822                     /home/silly/silly/luaclib/sys.so
Size:                 16 kB
7ffff7c27000-7ffff7c3d000 r-xp 00004000 08:00 253822                     /home/silly/silly/luaclib/sys.so
Size:                 88 kB
7ffff7c3d000-7ffff7c40000 r--p 0001a000 08:00 253822                     /home/silly/silly/luaclib/sys.so
Size:                 12 kB
7ffff7c40000-7ffff7c41000 r--p 0001c000 08:00 253822                     /home/silly/silly/luaclib/sys.so
Size:                  4 kB
7ffff7c41000-7ffff7c42000 rw-p 0001d000 08:00 253822                     /home/silly/silly/luaclib/sys.so
Size:                  4 kB
7ffff7c42000-7ffff7c4e000 rw-p 00000000 00:00 0
Size:                 48 kB

理论上这是不可能的。我想了很久,觉得只有一种可能,那就是刚mmap之后,程序就使用munmap释放掉了这块地址空间。然后刚好这块地址空间就被OS拿来做sys.so文件的映射了。

为了验证这一猜想,我使用strace -f -e mmap,munmap ./silly/silly server-src/config确认了一下,果然就是这样,下面的strace的部分输出。

mmap(NULL, 2097152, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7a49000
munmap(0x7ffff7a49000, 2097152)         = 0

jemalloc在mmap分配了一块内存后,随即就使用munmap给释放了。

这也给我一个提示, 当我们监控内存分配时,只监控mmap是不够用。至少需要同时监控mmap,munmap一起配合分析才行。如果有必要我们还需要监控brk系统调用。

ps. 由于linux会地址随机化,可能会导致多出来很多匿名内存段,不便于排查问题。可以使用echo 0 > /proc/sys/kernel/randomize_va_space来临时关闭内存地址随机化。

游戏服务器分布式数据的一种同步的思路

在游戏服务器的分布式设计中,我们通常避免将密切交互或存在数据强耦合的两个模块分别实现在不同的进程中。

然而,在某些情况下,我们不得不通过RPC在不同进程之间进行通信。例如,在处理玩家请求时,A进程可能需要与B进程进行资源扣除,并继续执行逻辑。

由于网络的不稳定性,A进程向B进程发起的RPC可能会有三种可能的结果:成功、失败或超时。

在分布式设计中,超时问题是最具挑战性的,因为超时可能是由于B进程未收到请求,也可能是B进程执行后的响应未被A进程接收到。

因此,在重试超时的RPC时必须非常小心,因为有可能这个RPC已经执行成功了,只是响应包丢失了而已,这样就会多次扣除玩家资源。

幸运的是,同一个游戏服务器的一组进程通常位于同一个子网下,并且在停服维护期间也是同生共死的。因此,RPC超时的概率通常不会很大,除非服务器超载。

在这种前提下,针对RPC的超时情况,一般的做法是在调用方和被调用方打上详细的日志。如果真的发生了超时,可以根据日志给玩家进行相应的补偿。


最近我遇到了一个有点棘手的需求,经过简化后的内容是这样的:

我们需要在"跨服"给玩家定期(大概几十秒到几百秒不等)产出一定的资源到"本地服",这个产出的资源需要玩家手动领取。在玩家没有领取之前,只能暂存N个周期的资源产出,到达暂存上限后就停止产出,直到玩家手动进行领取之后,再继续产出。

这个需求的棘手之处在于,在维护时,"跨服"的生命周期很大程度上和"本地服"不一样,也就是说数据包丢失必然会发生,被丢包的玩家必然会丢失这一周期产出的资源。

这种情况下,就不能只打印日志了,因为这是一种必然会发生的事件,而且影响范围是所有玩家,每次维护后手动处理是不能接受的。

经过仔细思考后,我认为要将"跨服产出到本地服"这个行为拆成两部分来实现。

第一步是先将资源产出到"跨服",这一步在进程内完成,如果跨服进程被关闭,就直接不会产出,下次启动后根据时间戳可以自动恢复出相应需要产出的资源。

第二步是将"跨服"进程中暂存的所有资源同步到"本地服"。同步数据包可能会丢失,但是由于每次总是同步当前玩家暂存的所有资源,所以重试这个同步操作是幂等的。

如果仅仅是产出重试这一问题,到这里问题基本上已经解决了。但是,现实往往是复杂的。

玩家领取资源时,还需要将产出的资源扣除以便"跨服"可以继续产出,而只要行为是"扣除",无论怎么设计这个RPC请求都不可能被重试。

所以需要进一步改进"跨服"产出逻辑。

"跨服"记录的不再是暂存的资源数量,而是从玩家参与这个玩法时到现在一共产出的"总"产出周期(TotalCycle)和"总"产出资源(TotalResource),这两个值都是只增不减的。

"本地服"同样记录收从"跨服"收到的最新的TotalCycle和TotalResource。

"本地服"每次收到"同步"包之后,根据同步包中的TotalCycle和TotalResource减去"本地服"中的TotalCycle和TotalResource,就能得出本次同步一共产出了多少周期和多少资源。再将本次同步产出的周期和资源合并到"本地服"的暂存周期数(StashCycle)和暂存产出资源数(StashResource)。

当玩家领取奖励时,"本地服"只需要将存储的TotalCycle和StashCycle同步到"跨服",跨服就可以知道当前还有多少周期的产出资源未被领取。具体公式为:(StashCycle + 跨服.TotalCycle – 本地服.TotalCycle)。

这样在玩家领取奖励时,从扣除暂存资源到加入玩家背包又是在进程内完成的,通知跨服可以继续产出的行为又变成了幂等的。


在整个问题的解决思路中,其实无非就是一点。将一些分布式行为拆分成本地行为+ "同步"行为(同步是指幂等的行为),在拆分过程中,"单调"会是一个很有用的武器。