前天提到 JScript.NET 跑 Eval() 在多緒執行出錯崩潰的案例,問題根源在當初覺得反覆編譯 JScript 建立組件並建立物件個體會拖累效能,故寫成只建一個靜態物件共用,但因 eval() 並非 Thread-Safe 方法(指被多條執行緒同時執行也不會有問題),於是線上大量使用下有微小機率會同一微秒兩個 Client 同時呼叫 eval(),轟! JScript 物件完全崩壞,要重啟 AppPool 才能重生。

確定是 Thread-Safe 問題,直覺想法是加上 lock 防止多緒執行,而問題也在加入 lock 後立即消失,似乎已塵埃落定。

貼文後讀者 Sean 與 wooo 留言問到 static 與 lock 的事,我回覆了我的看法:static 是為了避免反覆編譯浪費資源拖慢效能,而 lock 是保護非 Thread-Safe 方法的必要之惡。

回完留言出門晨跑,半路上再想起這事兒(分享個訣竅 - 慢跑時大腦格外清明,很適合想事情,把難解問題吐出來反芻,常有意外收獲),心中浮出兩個新疑問:

  • 建立物件有效能代價,但 lock 也有,豈可主觀判定 lock + 共用靜態物件一定比每次新物件來得快?
  • 建立 JScript.NET 物件的過程分成兩段:將 JScript 程式字串編譯成組件、用組件中的型別建立新物件。編譯程序複雜,速度慢無庸置疑,但用型別建立物件倒未必,說不定比 lock 快呢。

空想永遠不會有答案,動手寫程式驗證吧!

我的測試構想如下,用亂數產生 8192 組兩位整數相加的數學題,以三種方式跑 Parallel.ForEach 計算數學題,由耗費時間比較效能:

  1. EvalLock
    只建一個 JSEvaluator JScript.NET 物件,以靜態成員方式共用,使用 lock 限定 Eval() 方法只能單緒存取
  2. EvalRecompile
    每次重新編譯產生組件再建立 JSEvaluator JScript.NET 物件叫用 Eval() (實測速度實在太慢了,8192 筆要算上數分鐘,超過我的耐性上限(沒辦法,誰叫我是王藍田),故優待它只算 64 題就好,故其秒數要乘 128 再跟其他做法相比)
  3. EvalIntance
    重新編譯產生組件只做一次,每次計算時建立新的 JSEvaluator JScript.NET 物件叫用 Eval()

完整程式碼如下:(最前方額外加了一小段驗證 EvalLock 與 EvalIntance 算出結果是否一致)

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System;
using System.CodeDom.Compiler;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;

namespace JSEval
{
    class Program
    {
        static void Main(string[] args)
        {
            //驗算 EvalLock 與 EvalInstance 結果一致
            var resLock =
                Runner.Expressions.ToDictionary(o => o, o => JSEvaluator.EvalLock(o));
            var resInstance =
                Runner.Expressions.ToDictionary(o => o, o => JSEvaluator.EvalInstance(o));
            var chk = resLock.Where(o => o.Value != resInstance[o.Key]);
            Console.WriteLine($"結果不一致筆數 = {chk.Count()}");
            BenchmarkRunner.Run<Runner>();
        }
    }

    public class Runner
    {
        static Random rnd = new Random(9527);
        public static string[] Expressions =
            Enumerable.Range(1, 8192)
            .Select(o => $"{rnd.Next(100)} + {rnd.Next(100)} //{Guid.NewGuid()}")
            .ToArray();

        [Benchmark]
        public void TestEvalLock()
        {
            Parallel.ForEach(Expressions, e =>
            {
                JSEvaluator.EvalLock(e);
            });
        }
        [Benchmark]
        public void TestEvalRecompile()
        {
            //太慢了,只跑1/128的數量
            Parallel.ForEach(Expressions.Take(8192 / 128), e =>
            {
                JSEvaluator.EvalRecompile(e);
            });
        }
        [Benchmark]
        public void TestEvalInstance()
        {
            Parallel.ForEach(Expressions, e =>
            {
                JSEvaluator.EvalInstance(e);
            });
        }
    }

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

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

        public static string EvalRecompile(string expr)
        {
            var compResult = provider.CompileAssemblyFromSource(
                new CompilerParameters { GenerateInMemory = true },
                jScriptCode);
            var jseType = compResult.CompiledAssembly.GetType("JSEvaluator");
            var jse = Activator.CreateInstance(jseType);
            return jseType.InvokeMember("Eval", BindingFlags.InvokeMethod,
                null, jse, new object[] { expr }).ToString();
        }

        public static string EvalInstance(string expr)
        {
            var jse = Activator.CreateInstance(_jseType);
            return _jseType.InvokeMember("Eval", BindingFlags.InvokeMethod,
                null, jse, new object[] { expr }).ToString();
        }
    }
}

BenchmarkDotNet 測試過程有個小插曲,EvalRecompile 編譯 JScript.NET 程式碼的過程會引來 Windows Denfender 即時防毒程序的注意,出現 MsMpEng.exe 耗用 CPU 比壓測程式 (08025a3d-5797-...) 還高的狀況:

對照 EvalLock() 與 EvalInstance() 測試期間,壓測程式的 CPU 應該要在 80% 以上才合理:

為公平起見,我暫時關掉 Defender 的即時保護,請它不要喧賓奪主。關閉後 MsMpEng.exe CPU 下降到 10% 以下,而 EvalRecompile 的時間由 1.3 秒縮短到 520ms:

實測結果如下,結果出乎我意料。

每次重建物件比靠 lock 共用靜態物件快了 2.4 倍左右 (18.64ms vs 44.58ms),而每次重新編譯的速度爆慢在預期之下,算 64 筆就花了 520ms,還要乘 128 倍是 66.5 秒,比 EvalInstance 慢了 3,568 倍。

由這次測試,我獲得一些新知與體會:

  • 編譯程式碼行為有時會被防毒軟體判定為可疑活動
    猜想某些惡意軟體會使用動態編譯技巧現場打造武器(像電影裡,通過安檢門再從公事包、手機裡抽出零件組成手槍),因此防毒軟體會加強監控。在 stackoverflow 也有 MingGW 編譯 C 語言 Hello World 被 Defender 當成木馬的案例
  • 建立新物件的成本沒有我想像高
    過去我有個迷思 - 建立新物件要耗費資源,故共用靜態物件效能較好。但如果靜態物件的方法非 Thread-Safe,加上 lock 也會有成本。當物件的建構與初始化程序單純,新建物件的成本未必比 lock 高,還有利於單元測試,不該因錯誤印象一昧避用。

Locking an not thread-safe static method is faster than create instance each time? I designed a benchmark to find the answer.


Comments

# by Huang

建立物件算空間換時間的例子?用static有風險,不小心會掉入陷阱有靈異事件XD

Post a comment