如何讓 lua 做盡量正確的熱更新

JunBoucicau 8年前發布 | 70K 次閱讀 Lua Lua開發

很多項目采用 lua 的一大原因是 lua 可以方便的做熱更新。

你可以在不中斷進程運行的情況下,把修改過的代碼塞到進程中,讓隨后的過程運行新版本的代碼。這得益于 lua 的 function 是 first class 對象,換掉代碼不過是在讓相應的變量指向新的 function 對象而已。

但也正因為 lua 的這種靈活性,想把熱更新代碼這件事做的通用,且 100% 做對,又幾乎是不太可能的。

首先,你很難準確的定義出,什么叫做更新,哪些數據需要保留,哪些需要替換成新版本。光從源代碼和運行時的元信息上去分析是遠遠不夠的。

lua 只有一種通用數據結構 table ,這方便了我們做數據更新;但同時也制造了一些模糊性難題。比如,如果在代碼中有一些常量配置數據表,寫死在源代碼中,通常你是希望跟著新版本一起更新的;而有一些表,記錄著運行時的狀態,你又不希望在代碼更新后狀態清空。

所以一般做熱更新方案的時候,都會人為加一些約束,在遵循約束條件的前提上,盡量讓更新符合預期。

最近在給公司的項目做一些技術指導,同時也做一些工具來提高開發效率。對于開發期(而不是生產環境),或許提供一個更靈活的熱更新方案更方便一些。我們不太需要結果 100% 正確,但是需要減少一些約束。

在開發期,最好就是改兩行代碼,能立刻讓進程刷新成新版本的代碼,并不中斷運行。如果更新結果和預期不符,最壞的后果也不過是關掉程序重新來而已。但是如果僅僅是改兩行代碼,或加幾行 log ,則基本不會出錯,但開發效率卻可以極大的提高。

下面來討論一下,在約束條件足夠少的情況下,如何設計一個盡量完備的熱更新方案。

熱更新的關鍵是:找到更新的代碼模塊和在內存中運行的對應模塊,到底有什么差異,以及如何處理這些差異。

如果我們以模塊為單位來更新,第一步就是要把要處理的數據約束在一個足夠小的范疇,而不能擴大到整個虛擬機。

所以我先實現了一個沙盒,讓 require 新模塊的過程在沙盒中運行。我們只給沙盒注入有限的幾個不會產生副作用的函數,比如 print ,require,pairs 等;只允許在模塊初始化流程中調用這些無副作用的函數。那么,當模塊初始化好之后,整個模塊內部的數據(函數也是數據),都不會溢出沙盒(不會有引用指向沙盒外)。

由于 lua 的數據結構很簡單,所以我們可以認為沙盒中放著一張只有 function 和 table 兩種復雜數據類型構成的圖。這是因為 coroutine.* setmetatable io.* 等這些可能產生其它類型數據的函數都不能在初始化階段調用,這是一個合理的限制條件。

這個限制條件同樣也能規范模塊的開發方法。比如若有復雜的初始化流程必須提供一個模塊的初始化函數,由外部驅動,而不能直接寫在模塊的加載流程中,這也回避了更新模塊代碼時的重復初始化過程。

在制作沙盒時,可以建立一個訪問全局變量和其它模塊內容的 dummy 方法。一些 lua 常用寫法就可以支持,比如:

local debug = require "debug"
local tinsert = table.insert
local getinfo = debug.getinfo

這類常見的寫法是可以支持的,只不過這些 local 變量在沙盒中運行時,指向的是一個 dummy 對象;當更新模塊成功后,可以后續替換成真正的變量。但是,在模塊初始化過程調用它們會失敗。

第二步,我們可以分析沙盒中的數據圖。為了簡化實現,我們要求數據圖的初始狀態 key 都必須是 string 或 number 等值類型。這應該也算一個合理的要求。雖然 key 是 table 或 function 也能實現,但代碼會復雜很多,不值得放寬這個限制。

接下來,沙盒中每個 table 和 function 都可以表達為一個簡單值類型序列索引到的東西。

我們可以根據每條路徑去對比內存中同樣路徑上的對象,找到它的老版本。對于 function 變成 table 或 table 變成 function 這種情況,通通認為是有二義性的,可以簡單拒絕熱更新。我們的原則是,在約束條件下盡量不出錯,如果做就做對。

這樣,問題就變簡單了:找到了對象之間的新老版本對后,就是怎么替換的問題。如果新版本中有對象不存在,那么不用刪除老版本,因為如果老版本無人使用,那么就隨它去好了;如果新版本對老版本有新增,直接加入新對象。

對于同類對象替換:函數當然是用新版本替換老版本,但是要小心的處理 upvalue ,這個下面展開說;對于 table ,我的建議是直接把新版本的 k/v 插入老版本的 table ,取并集。這種合并 table 的規則最為簡單。

upvalue 如何合并呢?

upvalue 其實是 lua 中的一個隱式類型,大致相當于 C++ 的引用類。比如:

local a = {}

function foo()
  return a
end

a 是 foo 的一個 upvalue ,在 lua 中 a 的類型是 table ,但 upvalue 的實際類型卻不是。因為你可以在別處修改 a ,foo 返回值也會跟著改變。

lua 提供了兩個 api :debug.upvalueid 和 debug.upvaluejoin 來操作 upvalue 。

我建議的規則是,當我們需要把 f1 替換成 f2 時,應該取出 f2 的所有 upvalue ,按名字 join 給 f1 。不過這里存在的問題是, f1 如果新增了 upvalue 該怎么辦?。

我們不能簡單的保留 f1 新增的 upvalue ,比如:

--老版本
local a,b

function foo()
  return a
end

function foo2()
  return b
end

-- 新版本
local a,b

function foo()
  return a,b
end

function foo2()
  return b
end

這里老版 foo 只有一個 upvalue a ,但新版 foo 有兩個 a 和 b 。我們在處理 foo 更新的時候,可以把老版的 a 關聯給新版的 foo ;但在老版的 foo 中,卻無法找到 b 。

如果我們不理會新增的 b 讓其保留在 foo 上,那么接下來就會出現 foo2 的 b 被關聯走;結果在新版本中, foo 和 foo2 原本共享的 b 變成了兩個。

所以正確的做法是,把一個模塊中所有的函數對象一起處理,所有處理過的 upvalue 以upvalueid 為索引記錄在一起,這樣在推導 upvalue 的歸屬時,可以保持同一版本中的關聯性。

如果推導產生了歧義,例如新版本中兩個函數共享一個 upvalue ,而老版中是分離的,那么都認為代碼有二義性,拒絕更新。

由于我們可以先在沙盒中盡量把有效性檢查全部做完,所以不會出現更新了一半的狀態出錯,讓內存中的狀態處于中間狀態的情況。

最后,我們只需要遍歷整個 VM ,把所有引用老版本函數的地方,修改為新版本對應函數。整個工作就完成了。

說起來容易,做起來還是很麻煩的。我花了兩天的時間才把實現基本完成。 

目前還沒有仔細測試,有時間我會再 review 一遍。正如文章前面所說,在我們的應用場合(用于開發期調試),正確性暫時沒有太高的要求,所以可以先將就著用用吧。 :) 如果你想在你的項目利用它做更多的事情,歡迎使用,但請留意可能出現的 bug 。歡迎修正 bug 后提交一個 pull request 。

 

來自:http://blog.codingnow.com/2016/11/lua_update.html

 

 本文由用戶 JunBoucicau 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!