【C#】 關於 ThreadStatic 和 Thread Safe 的實驗

今天在做 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;
        }
    }
}

結果,執行的最後完全如我預期。

這樣的做法搬到實際的功能上,也沒有發生原先的錯誤。

暫時算結案了,還要後續觀察。

如果各位先進有看出哪裡不妥或是有更好的建議,也歡迎隨時提出!!非常感謝!!