今天在做 Code Review,發現一段代碼,總感覺怪怪的。
就此開啟了邁向 Thread Safe 這條路....
先講功能的目的,再來看程式。
實際場景:在 .NET Core 中使用 Entity Framework Core 透過自訂的 LogProvider 進而去擷取特定範圍的 SQL 執行語法。
我自己模擬的場景:
- 我有一個 DemoService
- 在執行 DemoService.Log 的動作我會去設定 靜態類別(QuerySetting)的屬性(IsEnabled)為 True
- 接著會執行到另外一個類別(DbLogger)的 Log 方法,裡面會去
- 靜態類別(QuerySetting)的屬性(IsEnabled)是否為 True
- 成立時設定 靜態類別(QuerySetting)的屬性(Content)
- 透過 Console.Write 來輸出
預期要得到的成果:
在高併發的場景下,每條執行續間不得互相干擾,導致記錄的內容不同
我第一次看到同事寫的代碼我用以下代碼模擬出來:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace TestThread
{
public class Program
{
public static QuerySetting Setting = new QuerySetting();
public static List<Result> Results = new List<Result>();
public static async Task Main(string[] args)
{
var tasks = new List<Task>();
for (var i = 0; i < 100; i++)
{
tasks.Add(Task.Run(() => { new DemoService(Setting).Log(Guid.NewGuid().ToString("N")); }));
}
await Task.WhenAll(tasks.ToArray());
Console.WriteLine("=======================================");
Console.WriteLine("Pass Count : " + Results.Count(x => x.IsEqual));
Console.WriteLine("Fail Count : " + Results.Count(x => !x.IsEqual));
foreach (var result in Results.Where(x => !x.IsEqual))
{
Console.WriteLine("Guid1: " + result.Guid1 + "Guid2: " + result.Guid2);
}
}
public class DemoService
{
private readonly QuerySetting _setting;
public DemoService(QuerySetting setting)
{
_setting = setting;
}
public void Log(string gId)
{
_setting.IsEnabled = true;
new DbLogger(_setting).Log(gId);
var result = new Result()
{
ThreadId = Thread.CurrentThread.ManagedThreadId.ToString(),
Guid1 = gId,
Guid2 = _setting.Content
};
Results.Add(result);
Console.WriteLine("Thread Id: " +
result.ThreadId +
" GUID1: " + result.Guid1 +
" GUID2: " + result.Guid2 +
" IsSame: " + result.IsEqual);
_setting.ReLoad();
}
}
}
public class DbLogger
{
private readonly QuerySetting _setting;
public DbLogger(QuerySetting setting)
{
_setting = setting;
}
public void Log(string gId)
{
if (!_setting.IsEnabled)
return;
_setting.Content = gId;
Thread.Sleep(50);
}
}
public class QuerySetting
{
/// <summary>
/// 是否啟用
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// 內容
/// </summary>
public string Content { get; set; }
/// <summary>
/// 還原設定
/// </summary>
public void ReLoad()
{
if (!IsEnabled)
{
return;
}
IsEnabled = false;
Content = null;
}
}
public class Result
{
public string ThreadId { get; set; }
public string Guid1 { get; set; }
public string Guid2 { get; set; }
public bool IsEqual => Guid1.Equals(Guid2);
}
}
我透過 Task.Run 執行 100 次,並且等 100 次結果都回來,但是,100 次結果得到通過率並非 100%
不難理解,因為我們一開始宣告 QuerySetting 這個物件為靜態的,所以,在整個應用程式執行期間,大家都會共用到同一個實體。
導致,QuerySetting的 IsEnabled 和 Content 這兩個屬性都面臨了 Race Conditions 的問題。
以前這方面功力不夠,找了一找資料都理解概念,但不太確定怎麼實做。
包含 Lock 、Monitor 兩種解決機制。
當下只跟同事說這 Code Review 過不了,這問題一定要被解決才能過。
後來自己起了這個模擬的 Lab 來做一遍。
也看到今天的文章重點, ThreadStatic Attribute
它可以保護我們的 靜態 Field 在每個執行續中都是唯一的
所以,我改寫了 QuerySetting , 將 IsEnabled 和 Content 這兩個屬性都改為 Field ,並且標住 ThreadStatic ,再提供方法讓外界存取。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace TestThread
{
public class Program
{
public static QuerySetting Setting = new QuerySetting();
public static List<Result> Results = new List<Result>();
public static async Task Main(string[] args)
{
new DemoService(Setting).Log(Guid.NewGuid().ToString("N"));
var tasks = new List<Task>();
for (var i = 0; i < 100; i++)
{
tasks.Add(Task.Run(() => { RunTask(); }));
}
await Task.WhenAll(tasks.ToArray());
Console.WriteLine("=======================================");
Console.WriteLine("Pass Count : " + Results.Count(x => x.IsEqual));
Console.WriteLine("Fail Count : " + Results.Count(x => !x.IsEqual));
foreach (var result in Results.Where(x => !x.IsEqual))
{
Console.WriteLine("Guid1: " + result.Guid1 + "Guid2: " + result.Guid2);
}
}
private static void RunTask()
{
new DemoService(Setting).Log(Guid.NewGuid().ToString("N"));
}
public class DemoService
{
private readonly QuerySetting _setting;
public DemoService(QuerySetting setting)
{
_setting = setting;
}
public void Log(string gId)
{
_setting.SetEnabled();
new DbLogger(_setting).Log(gId);
var result = new Result()
{
ThreadId = Thread.CurrentThread.ManagedThreadId.ToString(),
Guid1 = gId,
Guid2 = _setting.GetContent()
};
Results.Add(result);
Console.WriteLine("Thread Id: " +
result.ThreadId +
" GUID1: " + result.Guid1 +
" GUID2: " + result.Guid2 +
" IsSame: " + result.IsEqual);
_setting.ReLoad();
}
}
}
public class Result
{
public string ThreadId { get; set; }
public string Guid1 { get; set; }
public string Guid2 { get; set; }
public bool IsEqual => Guid1.Equals(Guid2);
}
public class DbLogger
{
private readonly QuerySetting _setting;
public DbLogger(QuerySetting setting)
{
_setting = setting;
}
public void Log(string gId)
{
if (!_setting.GetEnabled())
return;
_setting.SetContent(gId);
Thread.Sleep(50);
}
}
public class QuerySetting
{
[ThreadStatic] private static string _content;
[ThreadStatic] private static bool _isEnable;
public void SetEnabled()
{
_isEnable = true;
}
public bool GetEnabled()
{
return _isEnable;
}
public void SetContent(string content)
{
_content = content;
}
public string GetContent()
{
return _content;
}
/// <summary>
/// 還原設定
/// </summary>
public void ReLoad()
{
if (!_isEnable)
{
return;
}
_isEnable = false;
_content = null;
}
}
}
結果,執行的最後完全如我預期。
這樣的做法搬到實際的功能上,也沒有發生原先的錯誤。
暫時算結案了,還要後續觀察。
如果各位先進有看出哪裡不妥或是有更好的建議,也歡迎隨時提出!!非常感謝!!