Coding4Fun - 使用 lock 共用靜態物件 vs 每次新建物件之效能比較
1 |
前天提到 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 計算數學題,由耗費時間比較效能:
- EvalLock
只建一個 JSEvaluator JScript.NET 物件,以靜態成員方式共用,使用 lock 限定 Eval() 方法只能單緒存取 - EvalRecompile
每次重新編譯產生組件再建立 JSEvaluator JScript.NET 物件叫用 Eval() (實測速度實在太慢了,8192 筆要算上數分鐘,超過我的耐性上限(沒辦法,誰叫我是王藍田),故優待它只算 64 題就好,故其秒數要乘 128 再跟其他做法相比) - 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