ConcurrentDictionary، ساختار دادهای است که امکان افزودن، دریافت و حذف عناصری را به آن به صورت thread-safe میسر میکند. اگر در برنامهای نیاز به کار با یک دیکشنری توسط چندین thread وجود داشته باشد، ConcurrentDictionary راهحل مناسبی برای آن است.
اکثر متدهای این کلاس thread-safe طراحی شدهاند؛ اما با یک استثناء: متد GetOrAdd آن thread-safe نیست:
بررسی نحوهی کار با متد GetOrAdd
این متد یک کلید را دریافت کرده و سپس بررسی میکند که آیا این کلید در مجموعهی جاری وجود دارد یا خیر؟ اگر کلید وجود داشته باشد، مقدار متناظر با آن بازگشت داده میشود و اگر خیر، delegate ایی که به عنوان پارامتر دوم آن معرفی شدهاست، اجرا خواهد شد، سپس مقدار بازگشت داده شدهی توسط آن به مجموعه اضافه شده و در آخر این مقدار به فراخوان بازگشت داده میشود.
در این مثال زمانیکه اولین GetOrAdd فراخوانی میشود، مقدار item 1 بازگشت داده خواهد شد و همچنین این مقدار را در مجموعهی جاری، به کلید key1 انتساب میدهد. در دومین فراخوانی، چون key1 در دیکشنری، دارای مقدار است، همان را بازگشت میدهد و دیگر به value factory ارائه شده مراجعه نخواهد کرد. بنابراین خروجی این مثال به صورت ذیل است:
دسترسی همزمان به متد GetOrAdd امن نیست
ConcurrentDictionary برای اغلب متدهای آن به صورت توکار مباحث قفلگذاری چند ریسمانی را اعمال میکند؛ اما نه برای متد GetOrAdd. زمانیکه valueFactory آن در حال اجرا است، دسترسی همزمان به آن thread-safe نیست و ممکن است بیش از یکبار فراخوانی شود.
یک مثال:
یک نمونه خروجی این مثال میتواند به صورت ذیل باشد:
در اینجا هر چند 10 آیتم در دیکشنری ذخیره شدهاند، اما عملیاتی که در value factory متد GetOrAdd آن صورت گرفته، 13 بار اجرا شدهاست (بجای 10 بار).
علت اینجا است که در این بین، متد GetOrAdd توسط ترد A فراخوانی میشود، اما key را در دیکشنری جاری پیدا نمیکند. به همین جهت شروع به اجرای valueFactory آن خواهد کرد. در همین زمان ترد B نیز به دنبال همین key است. ترد قبلی هنوز به پایان کار خودش نرسیدهاست که مجددا valueFactory متعلق به همین key اجرا خواهد شد. به همین جهت است که در ConcurrentStack اجرا شدهی در valueFactory، بیش از 10 آیتم موجود هستند.
الگویی برای مدیریت دسترسی همزمان امن به متد GetOrAdd
یک روش برای دسترسی همزمان امن به متد GetOrAdd، توسط تیم ASP.NET Core به صورت ذیل ارائه شدهاست:
در اینجا با استفاده از کلاس Lazy، از ایجاد چندین pipeline به ازای یک key مشخص جلوگیری شدهاست.
یک مثال:
با این خروجی:
اینبار، هم dictionary و هم addStack دارای 10 عضو هستند که به معنای تنها اجرای 10 بار value factory است و نه بیشتر.
در این مثال دو تغییر صورت گرفتهاند:
الف) مقادیر ConcurrentDictionary به صورت Lazy معرفی شدهاند.
ب) متد GetOrAdd نیز یک مقدار Lazy را بازگشت میدهد.
زمانیکه از اشیاء Lazy استفاده میشود، خروجیهای بازگشتی از GetOrAdd، توسط این اشیاء Lazy محصور خواهند شد. اما نکتهی مهم اینجا است که هنوز value factory آنها فراخوانی نشدهاست. این فراخوانی تنها زمانی صورت میگیرد که به خاصیت Value یک شیء Lazy دسترسی پیدا کنیم و این دسترسی نیز به صورت thread-safe طراحی شدهاست. یعنی حتی اگر چند ترد new Lazy یک key مشخص را بازگشت دهند، تنها یکبار value factory متد GetOrAdd با دسترسی به خاصیت Value این اشیاء Lazy فراخوانی میشود و مابقی تردها منتظر مانده و تنها مقدار ذخیره شدهی در دیکشنری را دریافت میکنند و سبب اجرای مجدد value factory سنگین و زمانبر آن، نخواهند شد.
بر این مبنا میتوان یک LazyConcurrentDictionary را نیز به صورت ذیل طراحی کرد:
در اینجا ممکن است چندین ترد همزمان متد GetOrAdd را دقیقا با یک کلید مشخص فراخوانی کنند؛ اما تنها چندین شیء Lazy بسیار سبک که هنوز اطلاعات محصور شدهی توسط آنها اجرا نشدهاست، ایجاد خواهند شد. اولین تردی که به خاصیت Value آن دسترسی پیدا کند، سبب اجرای delegate زمانبر و سنگین آن شده و مابقی تردها مجبور به منتظر ماندن جهت بازگشت این نتیجه از دیکشنری خواهند شد (و نه اجرای مجدد delegate).
در مثال فوق، به صورت صریحی پارامتر LazyThreadSafetyMode نیز مقدار دهی شدهاست. هدف از آن اطمینان حاصل کردن از آغاز این شیء Lazy با دسترسی به خاصیت Value آن، تنها توسط یک ترد است.
نمونهی دیگر کار با خاصیت ویژهی Value شیء Lazy را در مطلب «پشتیبانی توکار از ایجاد کلاسهای Singleton از دات نت 4 به بعد» پیشتر در این سایت مطالعه کردهاید.
اکثر متدهای این کلاس thread-safe طراحی شدهاند؛ اما با یک استثناء: متد GetOrAdd آن thread-safe نیست:
TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory);
بررسی نحوهی کار با متد GetOrAdd
این متد یک کلید را دریافت کرده و سپس بررسی میکند که آیا این کلید در مجموعهی جاری وجود دارد یا خیر؟ اگر کلید وجود داشته باشد، مقدار متناظر با آن بازگشت داده میشود و اگر خیر، delegate ایی که به عنوان پارامتر دوم آن معرفی شدهاست، اجرا خواهد شد، سپس مقدار بازگشت داده شدهی توسط آن به مجموعه اضافه شده و در آخر این مقدار به فراخوان بازگشت داده میشود.
var dictionary = new ConcurrentDictionary<string, string>(); var value = dictionary.GetOrAdd("key1", x => "item 1"); Console.WriteLine(value); value = dictionary.GetOrAdd("key1", x => "item 2"); Console.WriteLine(value);
item 1 item 1
دسترسی همزمان به متد GetOrAdd امن نیست
ConcurrentDictionary برای اغلب متدهای آن به صورت توکار مباحث قفلگذاری چند ریسمانی را اعمال میکند؛ اما نه برای متد GetOrAdd. زمانیکه valueFactory آن در حال اجرا است، دسترسی همزمان به آن thread-safe نیست و ممکن است بیش از یکبار فراخوانی شود.
یک مثال:
using System; using System.Collections.Concurrent; using System.Threading.Tasks; namespace Sample { class Program { static void Main(string[] args) { var dictionary = new ConcurrentDictionary<int, int>(); var options = new ParallelOptions { MaxDegreeOfParallelism = 100 }; var addStack = new ConcurrentStack<int>(); Parallel.For(1, 1000, options, i => { var key = i % 10; dictionary.GetOrAdd(key, k => { addStack.Push(k); return i; }); }); Console.WriteLine($"dictionary.Count: {dictionary.Count}"); Console.WriteLine($"addStack.Count: {addStack.Count}"); } } }
dictionary.Count: 10 addStack.Count: 13
علت اینجا است که در این بین، متد GetOrAdd توسط ترد A فراخوانی میشود، اما key را در دیکشنری جاری پیدا نمیکند. به همین جهت شروع به اجرای valueFactory آن خواهد کرد. در همین زمان ترد B نیز به دنبال همین key است. ترد قبلی هنوز به پایان کار خودش نرسیدهاست که مجددا valueFactory متعلق به همین key اجرا خواهد شد. به همین جهت است که در ConcurrentStack اجرا شدهی در valueFactory، بیش از 10 آیتم موجود هستند.
الگویی برای مدیریت دسترسی همزمان امن به متد GetOrAdd
یک روش برای دسترسی همزمان امن به متد GetOrAdd، توسط تیم ASP.NET Core به صورت ذیل ارائه شدهاست:
// 'GetOrAdd' call on the dictionary is not thread safe and we might end up creating the pipeline more // once. To prevent this Lazy<> is used. In the worst case multiple Lazy<> objects are created for multiple // threads but only one of the objects succeeds in creating a pipeline. private readonly ConcurrentDictionary<Type, Lazy<RequestDelegate>> _pipelinesCache = new ConcurrentDictionary<Type, Lazy<RequestDelegate>>();
یک مثال:
namespace Sample { class Program { static void Main(string[] args) { var dictionary = new ConcurrentDictionary<int, Lazy<int>>(); var options = new ParallelOptions { MaxDegreeOfParallelism = 100 }; var addStack = new ConcurrentStack<int>(); Parallel.For(1, 1000, options, i => { var key = i % 10; dictionary.GetOrAdd(key, k => new Lazy<int>(() => { addStack.Push(k); return i; })); }); // Access the dictionary values to create lazy values. foreach (var pair in dictionary) Console.WriteLine(pair.Value.Value); Console.WriteLine($"dictionary.Count: {dictionary.Count}"); Console.WriteLine($"addStack.Count: {addStack.Count}"); } } }
10 1 2 3 4 5 6 7 8 9 dictionary.Count: 10 addStack.Count: 10
در این مثال دو تغییر صورت گرفتهاند:
الف) مقادیر ConcurrentDictionary به صورت Lazy معرفی شدهاند.
ب) متد GetOrAdd نیز یک مقدار Lazy را بازگشت میدهد.
زمانیکه از اشیاء Lazy استفاده میشود، خروجیهای بازگشتی از GetOrAdd، توسط این اشیاء Lazy محصور خواهند شد. اما نکتهی مهم اینجا است که هنوز value factory آنها فراخوانی نشدهاست. این فراخوانی تنها زمانی صورت میگیرد که به خاصیت Value یک شیء Lazy دسترسی پیدا کنیم و این دسترسی نیز به صورت thread-safe طراحی شدهاست. یعنی حتی اگر چند ترد new Lazy یک key مشخص را بازگشت دهند، تنها یکبار value factory متد GetOrAdd با دسترسی به خاصیت Value این اشیاء Lazy فراخوانی میشود و مابقی تردها منتظر مانده و تنها مقدار ذخیره شدهی در دیکشنری را دریافت میکنند و سبب اجرای مجدد value factory سنگین و زمانبر آن، نخواهند شد.
بر این مبنا میتوان یک LazyConcurrentDictionary را نیز به صورت ذیل طراحی کرد:
public class LazyConcurrentDictionary<TKey, TValue> { private readonly ConcurrentDictionary<TKey, Lazy<TValue>> _concurrentDictionary; public LazyConcurrentDictionary() { _concurrentDictionary = new ConcurrentDictionary<TKey, Lazy<TValue>>(); } public TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory) { var lazyResult = _concurrentDictionary.GetOrAdd(key, k => new Lazy<TValue>(() => valueFactory(k), LazyThreadSafetyMode.ExecutionAndPublication)); return lazyResult.Value; } }
در مثال فوق، به صورت صریحی پارامتر LazyThreadSafetyMode نیز مقدار دهی شدهاست. هدف از آن اطمینان حاصل کردن از آغاز این شیء Lazy با دسترسی به خاصیت Value آن، تنها توسط یک ترد است.
نمونهی دیگر کار با خاصیت ویژهی Value شیء Lazy را در مطلب «پشتیبانی توکار از ایجاد کلاسهای Singleton از دات نت 4 به بعد» پیشتر در این سایت مطالعه کردهاید.