又一个lua调试器

最初,我并没有打算为Silly提供一个lua调试器,因为我本人不是一个重度调试器使用者。在开发期间出bug,看一下代码,打几条log可能比使用调试器会更快的找到问题,尤其是服务端程序,在很多时候其实只有log这一原始工具可以使用。但就我周围的人来推断,应该还是有很多重度调试器使用者。

因此,最终还是计划为Silly增加一个lua调试器作为基本组件存在。但是这个调试器到底以哪种方式提供调试功能,我一直在纠结中。

就我个人而言,我用过至少两种完全不同的调试器,gdb和dtrace(严格来讲dtrace可能不算是调试器)。

gdb主要用于开发期间进行阻塞式调试,虽然也可以写调试脚本用在生产环境中调试,但是在gdb加载符号文件时,延迟比较明显。

dtrace提供的是动态跟踪手段,在程序中放置相应的探针,当程序执行到探针的位置时,就执行这个探针上的函数。在放置探针时几乎没有卡顿延迟的现象,而且在放置探针后其对程序的整体执行性能影响甚小。因此在特殊时期可以用于生产环境调试使用。

最开始我觉得也许应该为Silly增加一个类似dtrace的调试器。这个调试器只提供一个probe接口,probe有两个参数,参数1是代码位置(文件名与行号),参数2是一个探针函数。当调用probe注册一个探针之后,代码执行到指定行时,就会执行调用probe时注册的探针函数。这样当线上出问题时,就可以像dtrace一样编写一段代码注入到程序中,将我们所关心的信息全部dump出来,而不会影响程序的正常执行。

但是,最近重新仔细思考发现,dtrace之所以可以如此高效,是因为他可以有各种加速手段。比如在增加探针时,可以通过调试信息快速定位到要增加的探针在CPU指令的哪个位置,还可以通过int 3中断被动触发探针函数等(这些只是大概的猜测,具体实现也许不同,但是至少可以证明的是,他可以非常高效的确定探针函数是否应该被执行)。而在lua中,即使仅仅确定什么时间探针函数需要被执行,也至少需要在全局hook事件”call”,在每次触发”call”事件时,遍历检测是否有探针触发。这会极大的拖慢程序的整体性能。因此即使实现了探针机制也不适合在生产环境做调试使用。

既然不可以在生产环境使用,那么做成类dtrace的方式也就没有必要了。如果是在开发期间使用,还是Gdb那种阻塞交互式调试最方便易用。

这不是第一次实现lua调试器了,两年前我就实现了一个非常简易并且低效的阻塞式调试器

当时我们的客户端代码采用lua编写,偶尔会出现一些偶发性bug,并且出错的地方总是一些没有提前预料到的地方,因此没有任何log。而当我们在出错的地方加上详细的log再重启客户端后,问题又不能被重现了。

这个调试器就是这种情况下产生的,他的作用仅仅是当出现偶发性bug时,能够在不重启客户端的情况下,打印出我们想要的上下文。因此在实现时一切从简,只有s(单步步入)、c(继续运行)、 b(设置断点)和 d(删除断点功能)的功能,并且没有考虑整个调试器的效率。


这次实现,希望除了要具备上一个调试器所有功能以外,还要支持gdb中命令n(单步步过)的功能。此外,它还应该是高效的。

在Silly中,所有的代码都是被包在某一个coroutine中被执行的,整个程序运行期间,可能会有数十或上百个coroutine并发执行。因此在调试某一个coroutine中运行的代码时,应该尽可能的保证其他coroutine应该可以不受调试器的干扰而正常执行。

这样就不能使用之前调试器中的方式(直接阻塞等待命令输入)来实现阻塞式调试器。因为那样会塞住整个Silly进程,所有的coroutine都会被卡住。因此,整个lua调试器的命令输入均来源于socket, 这样可以充分利用Silly自身的高并发能力而减少阻塞。

我把整个调试器实现成了一个调试状态机,共分为三个状态,分别是:checkcall, checkline、checkbreak。

程序运行时,整个调试器处于checkcall状态。在checkcall状态,调试器会检测所有的coroutine的”call”事件。只检测“call”事件可以最大程度保证程序的运行效率,而之所以会检测所有的coroutine,是因为这时还不知道被断点的代码将会在哪个coroutine上被执行。

每次触发”call”事件后,都会检测此函数中是否被设置断点。如果被设置断点,则进入checkline状态。

进入checkline后,将程序中所有的coroutine的事件检测取消,只保留当前coroutine的”call/return/line”事件,并且此后,所有针对事件检测范围的修改均只影响此coroutine。

在每个line事件被触发后,都要检测此行代码是否被设置断点,如果有则进入checkbreak状态。

需要说明的这里有一个可能存在的优化,比如下面代码。

function foo(x)
...
end

function bar()
local x = 3
...
foo(x)
local y = 10
...
end

假设在checkcall状态下触发”call”事件的是bar函数,并且有断点设置在bar函数内。这时调试状态机会进入checkline状态,如果foo函数中没有断点的存在,理论上我们只需要检测bar函数中所有行是否触发断点即可,也就是在执行foo函数时,不必触发”line”事件。这也是在checkline状态,我们需要检测”call”和”return”事件的原因。

checkline状态中,如果触发了断点,则挂起当前coroutine并进入checkbreak状态。

checkbreak状态主要处理用户命令输入比如b(设置断点)、d(删除断点)、s(单步步入),n(单步步过)、c(继续运行)等命令。

b/d/c命令都没什么好说的,无非就是向断点列表中增加删除断点信息,及将因为触发断点而被挂起的coroutine唤醒。

命令s的实现是参照Intel x86 CPU架构中的TF标志的思路,即如果单步标志位被置起,则每执行一行都会有触发断点的效果(当前coroutine被挂起等待,用户下一次命令输入)。

命令n在实现时,其实费了颇多周折。如果这一行的代码是调用一个函数,命令s会直接进入到这个被调用的函数的上下文并暂停执行并等待用户输入命令。而命令n则是直到这个被调用的函数返回才会暂停执行。

最开始是通过设置临时断点的方式来实现的,每执行一行,就为下一行设置一个临时断点,后来发现当函数执行到最后一行时,下一行的临时断点就失效了。

经过多次分析总结之后,发现命令n和s惟一的差别仅在于函数调用这里,其余情况下,n与s的作用一模一样。

有了这个关系,问题就简单多了。引入一个变量calllevel代表调用层数,每当call事件触发时,calllevel加1,每当return事件触发时,calllevel则减1。这样calllevel为0时,命令n就可以和命令s的处理方式相同。

这里同样有优化的空间,在“call”事件响应中,如果calllevel大于1,说明我只关心”return”事件,就可以把“line”事件的检测取消。

虽然我前面说要做一个高效的调试器,然而这个版本其实还是有很多可以优化的空间,比如可以重新设置断点列表的数据结构,以使在checkcall状态时可以尽快检测当前函数中是否有断点等。不过,至少这个版本,在使用lua debug hook上,我已经做到了尽可能的高效。

客户端缓存落地方案

先大致描述一下场景,客户端按登陆流程成功登陆后,开始按各个模块加载所需数据,待收到消息返回后cache在内存中,只有当所有模块全部加载完成之后,客户端才算真正登陆成功,这时玩家才可以进行各种操作。在玩家操作过程中,客户端不断与服务器进行消息交互,并始终与服务器数据保持一致(这里是指状态一致,不是指所有数据全部一致,服务端存储的数据与客户端所需要的数据并不完全相同)。

由于模块过多,每个模块都是分别独立加载,因此从用户点击登陆按钮开始到真正可以操作所经过的时间显得有些漫长。客户端同学提出,由于客户端内存中的数据与服务器保持一致,那么在客户端退出时将cache中的模块数据存储为文件。下次登陆直接跳过模块的网络数据加载过程,直接从本地加载。

刚听到这个想法,我被其惊艳到了,随后心里却隐隐感觉不妥,但是并没有发现有什么问题。

仔细思考后,终于知道哪里不妥了,玩家的某一个ID可能同时会登陆多个客户端,那么理论上每一个客户端都会有一份这个ID的cache,就需要所有客户端与服务器在玩家数据上保持一致。这明明是一个典型的分布式一致性问题,那么由CAP定理,问题不可能这么容易被解决。

后来他们又围绕着这个机制进行了进一步加强,服务器为每个玩家ID的cache落地后数据维护一个版本号,这个ID每登陆一次,客户端就对其自增并发往服务器进行存储。这确实会把问题概率降低好几个数量级,但是我觉得可靠性依然不够。

比如客户端将数据写入文件的过程中crash了,服务器在向数据库记录版本号时crash了等等都有可能导致数据的不一致性。虽然这个概率很低,但并不是没有可能。

最近受paxos影响颇深,就想着有没有办法在任意糟糕的网络状况下和数据落地可能出错(比如写文件或DB时crash了)的情况下保证数据的一致性。


先说一下登陆流程。

客户端持帐户名向LoginServer进行帐户认证,如果认证成功LoginServer会拿着帐号ID向GameServer索取一个session。当LoginServer成功获取session之后将uid和session一同返回给客户端。客户端再持uid和session到GameServer端进行登陆。其中session在GameServer端分配,规则为一直自增。

大致的想法是:

在GameServer登陆之后,服务端向客户端返回GameServer的启动时间戳StartTime和下次从LoginServer登陆时将会拿到的session(这里称为next_session),next_session在GameServer整个启动周期内(从启动服务器进程到进程退出)有效。客户端在收到登陆成功消息之后将next_session和StartTime存入内存,当进程退出时首先所有数据落地完成之后,再将next_session和StarTime字段一起进行落地。

下次在从LoginServer认证成功,获取到session后,与本地存储的next_session进行比较,如果一致则说明中间没有人登陆过,那么数据一定是一致的。由于session是在GameServer整个启动周期内有效,所以如果中间服务器重启session会0开始分配。因此除了需要比较LoginServer返回的session与上次获取到的next_session是否一致之外还需要比较本次GameServer返回的StartTime与上次落地的StarTime是否一致。

整个算法的一致性可以很容易进行证明。

因为每次登陆成功在GameServer端是一个原子操作,而session的分配是直接在内存完成的,不存在操作数据库的行为,也一定是原子的。登陆成功和分配next_session在一个消息中被处理,因此整个操作一定是原子的,这保证了session的有效性。即然保证了服务端session的正确性,那么客户端只要保证在所有模块数据落地之后再落地next_session和StartTime就可以保证数据的一致性。不管是服务器和客户端在哪一步crash其实都无关紧要。

正如CAP定理所说的一样,这个算法虽然保证了一致性,但是会有两个缺陷。

1. 由于session是个4字节无符号整型,因此如果每秒有1000个人登陆,24天后就会回绕。因此如果一个客户端上的数据24天内都没能动过,不管session是否一致,都需要在登陆时重新向服务器拉取新数据。

2. 为了保证session分配的原子性和有效性,session是不存DB的(如果对DB进行存储则有两种方案,一种是等DB成功返回之后, session才分配成功,这样会增加登陆时间,第二种是将存储session的消息发向DB直接返回分配session成功, 这样会有数据不一致的风险),因此每次停服维护之后,玩家第一次登陆一定是需要拉取全新数据。

理清上述算法逻辑之后,对比最初客户端同学提出的算法,其实只要将Server端版本号的改成纯内存版,就可以解决Server端crash引起的不一致问题,将客户端版本号在最后所有模块落地之后再落地,则可以解决客户端存储时crash数据不一致问题。那么看来我之所以没在原版基础上去思考改进,可能是因为我当时正在看登陆逻辑的代码:D


绕了一大圈,不管哪种算法都挺累的。

回过去再看看,最根本的问题其实是模块过多加载数据过慢,而缓存落地只是为了解决它,又引申出来的一系列问题。

那么我们真的非要落地才可以解决模块加载慢的问题么,我不这么认为,分布式数据同步一定是复杂的,所以如果有其他办法来解决加载慢的问题,分布式肯定不是首选。

站在服务端的角度,只要客户端登陆成功了,不管客户端是否需要发消息加载数据,服务端都必须要把这个玩家的所有数据加载入内存。因此玩家登陆慢与游戏服务器的数据加载没有一点关系。

再看一下加载过程可能出现的问题。

1. 频繁发包,导致系统调用过多,开销过大
2. 各模块数据加载之间可能有数据依赖,即必须等一个模块返回后,才能请求另一个模块
3. 模块加载的数据过多,流量过大,带宽不足,所以网络传输时间长

从之前的经验来看,一般客户端登陆时拉取的都是一些最基础的信息,这些信息每个模块都很小(几十到几百个字节不等)。只有真正点开模块的UI时才会加载更详细的数据。因此问题3不存在。

问题1肯定是存在的,至于到底对加载过慢产生了多少影响,需要经过仔细评测才能得出结论。

不管怎么样,先把所有模块的加载协议合并,对比一下最初的加载过程,看看有多大差距。

在游戏服务器判断,此连接有合法Session之后,收集所有模块的基础信息,将其打包与登陆成功消息一起返回到客户端。客户端不再为每个模块单独请求数据。

下面来估算一下修改前和修改后的开销。假设游戏有30个模块,每个模块的基础信息是100字节。

30个模块单独加载数据,请求数据和回应数据至少需要30次write和30次read,一共60次系统调用,同样会有30个请求包和30个回应包,一共60个包。(在没开启nagle算法的情况下)

所有模块数据在登陆成功之时,由服务器收集并将其跟随登陆成功消息一起返回给客户端,不需要再多调用一次系统调用。30个模块的数据总大小为30 * 100 = 3000字节,而一个MTU大概为1500个字节左右,估算下来大概只有3个包左右。

优化前后,系统调用个数比是30比0,数据包个数比是60比3。而数据包越多,在网络上迷路的概率就越大。

事情到这里还没有结束,再分析一下客户端从LoginServer认证到从GameServer的登陆过程。

客户端到LoginServer会经历一次公网Connect时间和一次公网RTT。到GameServer会再经历一次公网Connect时间和一次公网RTT。如果客户端从LoginServer认证,LoginServer到GameServer之间会再经历一次内网RTT。

如果客户端从LoginServer认证并在GameServer登陆成功之后将得到的session存储,下次启动时从外存中加载session并按某种约定算法(比如将session自增,GameServer每次认证成功后也将此uid所关联的session自增)生成新的session, 直接向GameServer认证。就可以省掉一次公网Connect时间一次公网RTT和一次内网RTT。

其中公网Connect是非常慢的,通过都会在几十到几百毫秒,而一次公网RTT也至少要10毫秒以上。在网络情况一般的情况下,就这一项优化都可以减少掉数百毫秒的开销。

相比那个分布式的方案,我个人更喜欢这个,简单而有效而不会引入过于复杂的问题。