Lua中如何實現類似gdb的斷點調試–04優化鉤子事件處理

在第一篇的01最小實現中,我們實現了一個斷點調試的最小實現,在設置鉤子函數時只加了line事件,顯然這會對性能有很大的影響。而後來兩篇02通用變數列印03通用變數修改及調用棧回溯則是提供了一些輔助的調試介面,並沒有對鉤子函數進行修改。

我們本篇將在鉤子中引入call和return事件的處理,嘗試對性能進行優化。

源碼已經上傳Github,歡迎watch/star😘。

本部落格已遷移至CatBro’s Blog,那裡是我自己搭建的個人部落格,歡迎關注。

實現分析

當前的實現因為只加了line事件,執行每一行程式碼都會執行鉤子函數去查看是否有斷點,這是沒有必要的。我們可以在call事件時檢查當前函數是否有斷點,只有當有斷點的時候才加入line事件。那我們什麼時候去掉line事件呢?是不是遇到return事件就去掉呢?

考慮如下場景

考慮如下場景:假設f1調用f2,f2又調用f3。f1中有斷點,f2沒有斷點,f3有斷點。如果遇到return就去掉line事件,那麼從f2返回到f1之後,就無法再停到f1後面的斷點上了。

b             b
f1 --> f2 --> f3
   <--    <--

正確的做法

所以正確的做法應該是:call事件時,根據被調函數是否有斷點,決定是否加line事件;return事件時,則根據主調函數是否有斷點,決定是否加line事件。那麼return時如何獲取到主調函數的資訊呢?我們就需要在call的時候保存函數的相關資訊,組成一個鏈表。call的時候在尾部增加一個節點,return的時候則去掉一個節點。

數據結構

首先,在status數據結構中增加3個成員,stackinfos相當於我們前面提到的鏈表(只不過這裡以數組的形式實現),維護了調用棧中每個函數的資訊,記錄其中是否有斷點,stackdepth記錄了鏈表的長度,或者說棧的深度。funcinfos用於快取一些函數調試資訊,不用每次都調用debug.getinfo去獲取。

status.stackinfos = {}  -- table for saving stack infos
status.stackdepth = 0   -- the depth of stack
status.funcinfos = {}   -- table for caching func infos

鉤子函數

我們本篇最主要的改動是鉤子函數,除了line事件,我們還增加了call(或tail call)事件和return(或tail return)事件的處理。為了程式碼的簡潔,用局部變數s來表示status。接下來我們分別來看這三個部分。

local function hook (event, line)
    local s = status
    if event == "call" or event == "tail call" then
        -- 省略
    elseif event == "return" or event == "tail return" then
        -- 省略
    elseif event == "line" then
        -- 省略
    end
end

call事件

如果是call事件(或tail call事件),那麼先獲取當前函數,查看是否在斷點表中有斷點。

如果有則在s.stackinfos表尾部插入一個元素,其中hasbreak欄位為true指示該函數有斷點。注意,這裡我們對tail call進行了一個優化,直接覆蓋上一層的節點,在遞歸尾調用時可以防止空間無限膨脹。(Lua5.1上因為沒有tail call就無能為力了:)。然後重新設置鉤子函數的事件,將call、return和line事件全加上了。

如果當前函數沒有斷點,同樣在s.stackinfos表尾部插入一個節點,不過其中hasbreak欄位為false指示該層沒有斷點。在設置的鉤子事件中,則只保留了cr,將line事件移除了。這樣就只有斷點所在的函數內才會觸發line事件,可以大幅提升性能。

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
            -- 當前函數中有斷點
            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
            s.stackdepth = s.stackdepth + 1
        end
        s.stackinfos[s.stackdepth] = {func = func, hasbreak = false}
        debug.sethook(hook, "cr")   -- 移除"line"事件
    elseif event == "return" or event == "tail return" then
        -- 省略
end

return事件

接下來,我們來看return事件的處理。它首先刪除s.stackinfos表尾部的節點,然後檢查前一個節點的函數是否有斷點,如果有則恢復line事件,否則移除line事件。

local function hook (event, line)
    local s = status
    if event == "call" or event == "tail call" then
    	-- 省略
    elseif event == "return" or event == "tail return" then
        s.stackinfos[s.stackdepth] = nil
        s.stackdepth = s.stackdepth - 1
        -- 如果上一層的函數有斷點
        if s.stackdepth > 0 and s.stackinfos[s.stackdepth].hasbreak then
            debug.sethook(hook, "crl")  -- 恢復"line"事件
        else
            debug.sethook(hook, "cr")   -- 移除"line"事件
        end
    elseif event == "line" then
        -- 省略
    end
end

line事件

最後一部分是line事件的處理,跟之前沒有太大的變化。它遍歷斷點表,如果匹配到斷點則列印提示資訊,然後進入用戶交互模式。不過這裡也做了一個小優化,將debug.getinfo獲取的函數資訊快取到了status.funcinfos中,下一次就可以直接從快取中獲取到該函數的資訊。

local function hook (event, line)
    local s = status
    if event == "call" or event == "tail call" then
    	-- 省略
    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
end

初始事件設置

hook函數已經修改好了,我們再調整一下setbreakpoint函數中第一次設置鉤子時的行為。初始只設置call事件。

local function setbreakpoint(func, line)
    -- 省略
    if s.bpnum == 1 then                -- 只有一個斷點
        debug.sethook(hook, "c")        -- 設置call事件
    end
    return s.bpid
end

複雜度分析

我們假設程式碼執行的總行數為L,斷點數N=n*b,其中n為有斷點的函數個數,b為平均每個函數的斷點數,斷點所在函數平均行數為l,斷點所在函數平均調用次數為c,總的函數調用次數C

那麼優化前複雜度為O(L*N),優化後的複雜度為O(C*N+c*l*N)

一般情況下(C+c*I) << L,因為右邊L程式碼執行總行數可以分成有斷點的函數執行總行數+沒有斷點的函數執行總行數,而左邊的c*I就是有斷點的函數執行總行數,C為函數調用總次數。正常情況下函數調用總次數肯定是遠遠小於沒有斷點的函數執行的總行數的,有斷點的函數執行總行數也是遠遠小於沒有斷點的函數執行總行數的。

測試斷點是否正常

我們編寫如下測試腳本,來測試下之前提到的那種場景:f1調用f2,f2又調用f3,f1中加了兩個斷點,在調用f2前後各有一個,f2沒有斷點,f3有斷點。

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

local function f3()
end

local function f2()
    f3()
end

local function f1()
    f2()
end

-- f3中加斷點
local id1 = setbp(f3, 9)
-- f2不加斷點

-- f1中在調用f2前後各加一個斷點
local id2 = setbp(f1, 16)
local id3 = setbp(f1, 17)

f1()

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

然後來運行測試腳本驗證一下。首先停在了f1函數第16行(調用f2之前),然後cont繼續執行,停在了函數f3的斷點處,再次cont繼續,函數停在了f1函數第17行(調用f2之後)。可見斷點能正常工作

$ lua test.lua
Lua (local)f1 test.lua:16
lua_debug> cont
Lua (upvalue)f3 test.lua:9
lua_debug> cont
Lua (local)f1 test.lua:17
lua_debug> cont

測試tail call優化

我們再來測試下tail call的優化,編寫如下測試腳本。我們定義了一個尾調用遞歸的函數foo,然後再其他函數上隨便加了一個斷點(為了設置hook)。然後我們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(n)
    if n == 0 then
        return 0
    end
    return foo(n-1)
end

local function bar()
end

-- add a break in bar
local id1 = setbp(bar, 16)

foo(100000000000)

rmbp(id1)

Lua5.1測試

$ lua5.1 test2.lua

用Lua5.1運行上面的測試腳本,記憶體佔用一直在飆升,我只測試了一小會,就已經飆到8G了。

lua5.1-call

Lua5.3測試

$ lua5.3 test2.lua

用Lua5.3運行上面的測試腳本,因為有尾調用的優化,記憶體佔用一直保持在720KB。

lua5.3-tail-call

細心的同學可能已經發現了,我們的hook函數中call事件和line都需要對整個斷點表進行遍歷,這其中其實是存在著一些冗餘的。因為篇幅原因,我們放到下回分解。