今天解掉一個糾纏十餘年的老茶包,有治好數十年痼疾的痛快,特 PO 文紀念。

某個古老系統有個進階客製需求,允許設計者在定義作業流程時可以撰寫自訂規則,用預先定義好的資料變數符號(例如:$FieldA、$Now...)配合大於、小於、AND、OR 寫出複雜的判斷條件式,還要能支援括號優先順序。當年的我找到一個取巧解法,將條件式中的系統變數展開翻譯成 JavaScript if 條件式,再丟給 JavaScript 引擎運算傳回 true 或 false。如此就算寫出 ($FieldA % 4 > 0 && ($FieldB == 'A' || $FieldC.indexOf('I') == 0)) 這種鬼算式,展開成 ( 140 % 4 == 0 && ('A' == 'A' || 'IIS'.indexOf('I') == 0)) 丟給 JavaScript 引擎照樣瞬間得到答案。 (資安提醒:大家如果想仿效應用,務必要留意程式碼注入風險)

實作原理是借用被遺忘的 JScript.NET 語言,寫一段 JScript 函式傳回 eval() 結果編譯成 .NET 組件,便可以 .NET 中跑 JavaScript 指令做運算,以下是簡單示範:

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine(JSEvaluator.Eval("1+1"));
        Console.WriteLine(JSEvaluator.Eval("1 == 1 || 4 > 5"));
        Console.WriteLine(
            JSEvaluator.Eval("(140 % 4 == 0 && ('A' == 'A' || 'IIS'.indexOf('I') == 0))");
    }
}

public class JSEvaluator
{
    static Type _jseType = null;
    static object _jse = null;
    static JSEvaluator()
    {
        var provider = CodeDomProvider.CreateProvider("JScript");
        var p = new CompilerParameters();
        p.GenerateInMemory = true;
        CompilerResults r = provider.CompileAssemblyFromSource(p,
@"class JSEvaluator {  
public function Eval(expr : String) : String { return eval(expr); } 
}");
        Assembly asm = r.CompiledAssembly;
        _jseType = asm.GetType("JSEvaluator");
        _jse = Activator.CreateInstance(_jseType);
    }

    public static string Eval(string expr)
    {
        return _jseType.InvokeMember("Eval", BindingFlags.InvokeMethod,
            null, _jse, new object[] { expr }).ToString();
    }
}

不過呢,這個系統跑了十幾年,一年會出現幾次異常,JSEvaluator.Eval 運算出現 "'Exception has been thrown by the target of an invocation" 或 "Object reference not set to an instance of an object" 錯誤,之後無法執行 JavaScript 運算式,直到重啟 ASP.NET 網站恢復。但由於發生頻率很低又不知如何重現,再加上它是被世人遺忘的 JScript.NET,我很鄉愿地做出粗暴結論 - 這是 JScript.NET 不定期發生的 Bug,微軟也不會再更新了,既然頻率不高,遇到重啟 AppPool 便是。

最近,異常發生頻率提高到兩三個月一次,讓人無法繼續忽視,我開始思索解法。已做好改借用 Node.js 的心理準備,但工程有點大,且系統架構會變複雜,當涉及元件愈多,故障機率愈高,亦是隱憂。

無法下決心砍掉重練,再爬文碰碰運氣,幸運地在 MSDN 論壇找到幾乎一模一樣的案例。啊! 是 Thread-Safe 問題!

這樣就能解釋為什麼測試環境沒遇到,線上環境也久久才會發生一次。該判斷邏輯執行的頻率不算高,一整天才幾千次,每次執行時間又小於 1ms,剛好兩個使用者在同一毫秒執行很需要運氣。

依此原理,將程式小改為 Parallel.ForEach 多緒執行,立刻成功重現問題:

知道原因根源,解決只在彈指之間。用 lock (_jseType) 把 _jseType.InvokeMember("Eval"...) 包起來,問題瞬間消除:

    public static string Eval(string expr)
    {
        lock (_jseType)
        {
            return _jseType.InvokeMember("Eval", BindingFlags.InvokeMethod,
                null, _jse, new object[] { expr }).ToString();
        }
    }

事後自我檢討,為什麼放任這個 Bug 存在如此多年?出現頻率不高影響不大,而且重啟一下就能解決是主要原因,因為不太痛也就不會積極追查;第二點則是因為 JScript.NET 屬古老技術,我先入為主下了「很久沒更新 + 很少人用 + 有 Bug 也沒人發現 + 找到 Bug 也不會修正」的草率結論,說穿了是欠缺實事求是精神,有違茶包射手的專業,未來應以此為戒。最後還有一點,我對 Thread-Safe 問題的 Sense 還不夠強,壓根沒想過它是兇手,下回遇到測試環境 OK,上線久久才會炸一次的問題,應該要優先排除多緒環境因素。

Experience of solving a JScript.NET thread-safe issue.


Comments

# by Sean

是不是不要把_jseType宣告成static就沒事?

# by wooo

重點是lock, 將多緒「變成」單緒執行

# by Jeffrey

to Sean, 取消 static 改成每次重新動態編譯產生新組件是解法,但會耗用較多資源且效能不佳。 to wooo, 若方法非 Thread-Safe,多緒執行可能引起災難,lock 是必要的保護手段。

# by Greg Yu

十幾年的老系統,出錯的次數頻率提高, 解決了這個 Bug 固然令人欣喜, 可我個人還會好奇,發生次數增加的原因, 該業務突然爆紅 ? 某個新系統上線,跑來調用相關功能 ? 機器過於老舊,即將 [罷工] ? 跑在 VM 裡面,相關資源被刪減 ? 會不會在不久的將來要求以新技術重建該系統 ??

# by Jeffrey

to Greg Yu, 我的看法,一年幾次跟兩三個月一次都在機率常態分配的中間區,還不足以成為客觀環境有變的明確證據。但因為該頻率接近使用者、長官會關心的門檻,處理一下比較好,哈。

Post a comment