5

Coding4Fun - 使用 lock 共用靜態物件 vs 每次新建物件之效能比較

 3 years ago
source link: https://blog.darkthread.net/blog/new-instance-vs-lock-perf-benchmark/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
使用 lock 共用靜態物件 vs 每次新建物件之效能比較-黑暗執行緒

前天提到 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 高,還有利於單元測試,不該因錯誤印象一昧避用。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK