Lua中如何实现类似gdb的断点调试–05优化断点信息数据结构

在上一篇04优化钩子事件处理中,我们在钩子函数中引入了call和return事件的处理,对性能进行了优化。

细心的同学可能已经发现了,我们的hook函数中call事件和line都需要对整个断点表进行遍历,这其中其实是存在着一些冗余的。因为call事件只关心函数是否有断点,而line事件则只关心本函数内有哪些断点。所以我们可以想办法优化一下断点信息的数据结构,进一步提升性能。

源码已经上传Github,欢迎watch/star😘。

本博客已迁移至CatBro’s Blog,那里是我自己搭建的个人博客,欢迎关注。

实现分析

原来的断点表status.bptable是以断点id为键的数组,我们需要通过断点id来快速删除断点,所以status.bptable表还是保留。

但是在钩子函数中,我们并不关心断点的id,相反我们关心断点属于哪个函数。只有当程序执行到有断点的函数时,我们才需要处理line事件。所以,很自然地可以想到,我们应该新增一个以函数为键的断点表。我们把这个新表定义如下:

status.funcbpt = {}     -- 以函数为键的断点表

那么新表的值应该是什么呢?因为一个函数中可能有多个断点,而在line事件中我们又需要比较当前行是否是断点行,所以我们把新表status.funcbpt的值也设计成一个表,该表的键是断点行号,值为断点id。因为断点行号并不连续,所以status.funcbpt[func]表不是一个序列,为了快速获取断点个数,我们在表中额外加了一个特殊的num字段保存该函数中的断点个数。

这样我们就可以在call事件中快速地判断当前函数是否有断点,在line事件中快速地判断当前行是否是断点行。

另外,之所以将断点id作为status.funcbpt[func]表的值,则是为了保留从status.funcbpt访问status.bptable中对应断点的能力(status.bptable表中元素因为包含了funcline字段,所以也可以访问到status.funcbpt对应断点)。

这么说可能不直观,我们来看个例子。

假设我们的bptable表中func函数添加了两个断点如下:

bptable[1] = {func = func, line = 10}
bptable[2] = {func = func, line = 20}

对应的在funcbpt表中的操作如下:

funcbpt[func] = {}          -- 构造表
funcbpt[func][10] = 1	    -- 函数func,行号10,断点id为1
funcbpt[func].num = 1       -- 该函数第一个断点
funcbpt[func][20] = 2       -- 函数func,行号20,断点id为2
funcbpt[func].num = funcbpt[func].num + 1	-- 断点个数+1

OK,理清楚了status.funcbpt的数据结构设计,实现起来就简单了。

添加断点

我们先从设置断点函数入手修改代码。新增的代码已经用中括号括起来了,有两个部分。其中前面的部分先根据函数和行号检查是否已经设置过同一个断点了,如果是的话,直接返回之前设置的断点id。

后面的部分,在断点保存到s.bptable表之后,也要保存到我们新增的表s.funcbpt表中,行号line作为键,新断点id作为值。如果设置的是该函数的第一个断点,需要先进行表的初始化构造,所以操作稍有不同。

local function setbreakpoint(func, line)
    local s = status
    if type(func) ~= "function" or type(line) ~= "number" then
        return nil
    end
    [[ 新增代码开始 ]]
    -- 已经设置了相同的断点
    if s.funcbpt[func] and s.funcbpt[func][line] then
        return s.funcbpt[func][line]
    end
    [[ 新增代码结束 ]]
    s.bpid = s.bpid + 1
    s.bpnum = s.bpnum + 1
    s.bptable[s.bpid] = {func = func, line = line}
    [[ 新增代码开始 ]]
    if s.funcbpt[func] then             -- 该函数已经有断点了
        s.funcbpt[func].num = s.funcbpt[func].num + 1
        s.funcbpt[func][line] = s.bpid
    else                                -- 该函数第一个断点
        s.funcbpt[func] = {}
        s.funcbpt[func].num = 1
        s.funcbpt[func][line] = s.bpid
    end
     [[ 新增代码结束 ]]
    if s.bpnum == 1 then                -- 全局第一个断点
        debug.sethook(hook, "c")        -- 设置钩子"call"事件
    end
    return s.bpid                       --> 返回断点id
end

删除断点

相应地,在删除断点的时候,我们也需要把s.funcbpt表中对应的断点删除。首先根据断点id,从s.bptable表中获取到断点的函数和行号,从而找到s.funcbpt表中对应的断点。现将断点个数减1,然后将对应断点删除(防止后续line事件找到该断点)。如果该函数已经没有断点了,那么将s.funcbpt[func]表本身也删除(防止后续call事件以为该函数还有断点)。

local function removebreakpoint(id)
    local s = status
    if s.bptable[id] == nil then
        return
    end
    [[ 新增代码开始 ]]
    local func = s.bptable[id].func
    local line = s.bptable[id].line
    s.funcbpt[func].num = s.funcbpt[func].num - 1
    s.funcbpt[func][line] = nil
    if s.funcbpt[func].num == 0 then
        s.funcbpt[func] = nil
    end
    [[ 新增代码结束 ]]
    s.bptable[id] = nil
    s.bpnum = s.bpnum - 1
    if s.bpnum == 0 then
        debug.sethook()                 -- 移除钩子
    end
end

钩子函数

然后来修改钩子函数的处理,钩子的函数的修改主要有两处,分别是call事件和line事件。我们先来看call事件的修改:

 local function hook (event, line)
     local s = status
     if event == "call" or event == "tail call" then
         local func = debug.getinfo(2, "f").func
-        for _, v in pairs(s.bptable) do
-            -- found breakpoint in current function
-            if v.func == func then
-                if event == "call" then
-                    s.stackdepth = s.stackdepth + 1
-                end
-                s.stackinfos[s.stackdepth] =
-                    {func = func, hasbreak = true}
-                debug.sethook(hook, "crl")     -- 添加"line"事件
-                return
-            end
-        end
-        -- 没有断点
-        if event == "call" then
+        if event == "call" then     -- 对于尾调用,直接覆盖
             s.stackdepth = s.stackdepth + 1
         end
-        s.stackinfos[s.stackdepth] = {func = func, hasbreak = false}
-        debug.sethook(hook, "cr")   -- 去掉"line"事件
+        -- found breakpoint in current function
+        if s.funcbpt[func] then
+            s.stackinfos[s.stackdepth] = {func = func, hasbreak = true}
+            debug.sethook(hook, "crl") -- 添加"line"事件
+        else        -- no breakpoints found
+            s.stackinfos[s.stackdepth] = {func = func, hasbreak = false}
+            debug.sethook(hook, "cr")   -- 去掉"line"事件
+        end
     elseif event == "return" or event == "tail return" then

主要的改动就是从原本的遍历s.bptable表来查找是否有断点,改成了直接通过检查s.funcbpt[func]是否不为nil来判断是否有断点。这里直接从遍历一个表,优化成了一个查表操作。

第二处是line事件的修改,下面是修改前的代码:

    -- 省略
    elseif event == "line" then
        for _, v in pairs(s.bptable) do
            if v.func == s.stackinfos[s.stackdepth].func
                and v.line == line then
                if not s.funcinfos[v.func] then
                    s.funcinfos[v.func] = debug.getinfo(2, "nS")
                end
                local info = s.funcinfos[v.func]
                local prompt = string.format("%s (%s)%s %s:%d\n",
                    info.what, info.namewhat, info.name, info.short_src, line)
                io.write(prompt)
                debug.debug()
            end
        end
    end
	-- 省略

下面是修改后的代码,除了增加了一些局部变量简化代码之外,也是将原本的遍历s.bptable表来判断当前行是否是断点行,改成了直接通过检查s.funcbpt[curfunc][line]是否不为nil来判断当前行是否是断点行。这里也将遍历一个表的动作,优化成了一个查表操作。

    elseif event == "line" then
        local curfunc = s.stackinfos[s.stackdepth].func
        local funcbp = s.funcbpt[curfunc]
        assert(funcbp)
        if funcbp[line] then
            if not s.funcinfos[curfunc] then
                s.funcinfos[curfunc] = debug.getinfo(2, "nS")
            end
            local info = s.funcinfos[curfunc]
            local prompt = string.format("%s (%s)%s %s:%d\n",
                info.what, info.namewhat, info.name, info.short_src, line)
            io.write(prompt)
            debug.debug()
        end
    end

复杂度分析

我们仍然假设代码执行的总行数为L,断点数N=n*b,其中n为有断点的函数个数,b为平均每个函数的断点数,断点所在函数平均行数为l,断点所在函数平均调用次数为c,总的函数调用次数C

完全没优化前复杂度为O(L*N),上一篇的事件处理优化后的复杂度为O(C*N+c*l*N),而本篇的数据结构优化之后复杂度进一步缩小为O(C+c*l)

测试多函数多断点

先来测试一下修改之后断点功能是否正常,编写一个测试脚本如下。我们对foo函数和bar函数分别添加了两个断点,其中foo函数第一个断点添加了两次用于测试重复添加的情况。

local ldb = require "luadebug"
local setbp = ldb.setbreakpoint
local rmbp = ldb.removebreakpoint
pv = ldb.printvarvalue
sv = ldb.setvarvalue
ptb = ldb.printtraceback

local function foo ()
    local a = 1
end

local function bar ()
    local b = 1
end

local id1 = setbp(foo, 9)
assert(id1 == 1)
local id1 = setbp(foo, 9)
assert(id1 == 1)
local id2 = setbp(foo, 10)

local id3 = setbp(bar, 13)
local id4 = setbp(bar, 14)

foo()
bar()

rmbp(id1)
rmbp(id2)
rmbp(id3)
rmbp(id4)

foo()
bar()

运行脚本,4个断点情况都能正常跑到。删除断点后再调用foo和bar函数,不再碰到断点。

$ lua test.lua
Lua (local)foo test.lua:9
lua_debug> cont
Lua (local)foo test.lua:10
lua_debug> cont
Lua (local)bar test.lua:13
lua_debug> cont
Lua (local)bar test.lua:14
lua_debug> cont
$

性能比对

我们再来做个简单的测试,看看我们优化的效果。编写如下测试脚本,foo函数模拟较长的程序,然后在另一个函数上加个断点(为了设置hook)。我们分别使用优化前的luadebug.lua和优化后的luadebug.lua进行测试。

local ldb = require "luadebug"
local setbp = ldb.setbreakpoint
local rmbp = ldb.removebreakpoint

local function foo ()
    local a = 1
    for i = 1, 10000000 do
        a = a + 1
    end
end

local function bar ()
end

local id1 = setbp(bar, 13)

foo()

优化前

使用优化前的实现,运行这个脚本用了40s

$ time lua test2.lua
lua test2.lua  39.54s user 0.16s system 99% cpu 39.957 total

优化后

使用优化后的实现,则只花了0.109s,相差了接近400倍,可见我们的优化效果还是很明显的。

$ time lua test2.lua
lua test2.lua  0.10s user 0.00s system 97% cpu 0.109 total