الگویی برای مدیریت دسترسی همزمان به ConcurrentDictionary
اندازه‌ی قلم متن
تخمین مدت زمان مطالعه‌ی مطلب: شش دقیقه

ConcurrentDictionary، ساختار داده‌ای است که امکان افزودن، دریافت و حذف عناصری را به آن به صورت thread-safe میسر می‌کند. اگر در برنامه‌ای نیاز به کار با یک دیکشنری توسط چندین thread وجود داشته باشد، ConcurrentDictionary راه‌حل مناسبی برای آن است.
اکثر متدهای این کلاس 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);
در این مثال زمانیکه اولین GetOrAdd فراخوانی می‌شود، مقدار item 1 بازگشت داده خواهد شد و همچنین این مقدار را در مجموعه‌ی جاری، به کلید key1 انتساب می‌دهد. در دومین فراخوانی، چون key1 در دیکشنری، دارای مقدار است، همان را بازگشت می‌دهد و دیگر به value factory ارائه شده مراجعه نخواهد کرد. بنابراین خروجی این مثال به صورت ذیل است:
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
در اینجا هر چند 10 آیتم در دیکشنری ذخیره شده‌اند، اما عملیاتی که در value factory متد GetOrAdd آن صورت گرفته، 13 بار اجرا شده‌است (بجای 10 بار).
علت اینجا است که در این بین، متد 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>>();
در اینجا با استفاده از کلاس Lazy، از ایجاد چندین pipeline به ازای یک key مشخص جلوگیری شده‌است.
یک مثال:
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
اینبار، هم 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 را نیز به صورت ذیل طراحی کرد:
    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;
        }
    }
در اینجا ممکن است چندین ترد همزمان متد GetOrAdd را دقیقا با یک کلید مشخص فراخوانی کنند؛ اما تنها چندین شیء Lazy بسیار سبک که هنوز اطلاعات محصور شده‌ی توسط آن‌ها اجرا نشده‌است، ایجاد خواهند شد. اولین تردی که به خاصیت Value آن دسترسی پیدا کند، سبب اجرای delegate زمانبر و سنگین آن شده و مابقی تردها مجبور به منتظر ماندن جهت بازگشت این نتیجه از دیکشنری خواهند شد (و نه اجرای مجدد delegate).
در مثال فوق، به صورت صریحی پارامتر LazyThreadSafetyMode نیز مقدار دهی شده‌است. هدف از آن اطمینان حاصل کردن از آغاز این شیء Lazy با دسترسی به خاصیت Value آن، تنها توسط یک ترد است.

نمونه‌ی دیگر کار با خاصیت ویژه‌ی Value شیء Lazy را در مطلب «پشتیبانی توکار از ایجاد کلاس‌های Singleton از دات نت 4 به بعد» پیشتر در این سایت مطالعه کرده‌اید.
  • #
    ‫۵ سال و ۶ ماه قبل، سه‌شنبه ۲۱ اسفند ۱۳۹۷، ساعت ۱۵:۳۱
    یک فیلتر رو به صورت زیر نوشتم و در آن از این دیکشنری استفاده کردم وقتی به صورت parallel اجرا می‌کنم متد AddOrUpdate  کلیدهایی که تکراری باشند را به جای اینکه مقدار آن را ویرایش کند یک کلید دیگر با همون مقدار اضافه می‌کند لطفا راهنمایی کنید مشکل کار از کجاست؟ 
     public class LockFilter : ActionFilterAttribute
        {
            static ConcurrentDictionary<StringBuilder, int> _properties;
            static LockFilter()
            {
                _properties = new ConcurrentDictionary<StringBuilder, int>();
            }
    
            public  int Duration { get; set; }
            public string VaryByParam { get; set; }
    
            public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
            {
                var actionArguments = context.ActionArguments.Values.Single();
                var properties = VaryByParam.Split(",").ToList();
    
                StringBuilder key = new StringBuilder();
                foreach (var actionArgument in actionArguments.GetType().GetProperties())
                {
                    if (!properties.Any(t => t.Trim().ToLower() == actionArgument.Name.ToLower()))
                        continue;
                    var value = actionArguments.GetType().GetProperty(actionArgument.Name).GetValue(actionArguments, null).ToString();
                    key.Append(value);
                }
    
                _properties.AddOrUpdate(key, 1, (x, y) => y + 1);
    
                // rest of code 
            }
        }

    • #
      ‫۵ سال و ۶ ماه قبل، سه‌شنبه ۲۱ اسفند ۱۳۹۷، ساعت ۱۵:۴۰
      واژه‌ی valueFactory را در بحث جاری جستجو کنید. valueFactory متد AddOrUpdate هم همان مشکل GetOrAdd را دارد (قسمت remarks مستندات رسمی آن) و برای آن راه حلی در اینجا ارائه شده ...
      • #
        ‫۵ سال و ۶ ماه قبل، سه‌شنبه ۲۱ اسفند ۱۳۹۷، ساعت ۱۸:۵۲
        با وجود lazy بودن هم چندین بار امتحان کردم قسمت آپدیت آن فر اخوانی نمی‌شود و همچنان مثل قبل کلیدهای مشابه و تکراری را تولید می‌کند.
         var result = _properties.AddOrUpdate(key
                          k =>
                          {
                              Console.WriteLine("AddValueFactory called for " + k);
                              return new Lazy<int>(() => 1);
                          },
                          (x, y) =>
                          {
                              Console.WriteLine("updateValueFactory called for " + key);
                              return new Lazy<int>(() => y.Value + 1);
                          }).Value;

        • #
          ‫۵ سال و ۶ ماه قبل، سه‌شنبه ۲۱ اسفند ۱۳۹۷، ساعت ۲۳:۱۲
          Lazy را به قسمتی از درون Func اعمال کردید. کل Func باید Lazy شود تا درست کار کند.
          using System;
          using System.Collections.Concurrent;
          using System.Threading;
          
          namespace LazyDic
          {
              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;
                  }
          
                  public TValue AddOrUpdate(TKey key, TValue addValue, Func<TKey, TValue, TValue> updateValueFactory)
                  {
                      var lazyResult = _concurrentDictionary.AddOrUpdate(
                          key,
                          new Lazy<TValue>(() => addValue),
                          (k, currentValue) => new Lazy<TValue>(() => updateValueFactory(k, currentValue.Value),
                                                                LazyThreadSafetyMode.ExecutionAndPublication));
                      return lazyResult.Value;
                  }
          
                  public TValue AddOrUpdate(TKey key, Func<TKey, TValue> addValueFactory, Func<TKey, TValue, TValue> updateValueFactory)
                  {
                      var lazyResult = _concurrentDictionary.AddOrUpdate(
                          key,
                          k => new Lazy<TValue>(() => addValueFactory(k)),
                          (k, currentValue) => new Lazy<TValue>(() => updateValueFactory(k, currentValue.Value),
                                                                LazyThreadSafetyMode.ExecutionAndPublication));
                      return lazyResult.Value;
                  }
          
                  public int Count => _concurrentDictionary.Count;
              }
          }
          • #
            ‫۵ سال و ۶ ماه قبل، چهارشنبه ۲۲ اسفند ۱۳۹۷، ساعت ۱۲:۴۶
            با اعمال کلاس فوق هم همان طور که در تصویر ملاحظه می‌فرمایید کلید‌های مشابه و تکراری تولید می‌شود:

            • #
              ‫۵ سال و ۶ ماه قبل، چهارشنبه ۲۲ اسفند ۱۳۹۷، ساعت ۱۳:۰۰
              از آنجایی که StringBuilder  را به عنوان کلید در نظر گرفته بودم با تبدیل به string مشکل حل شد. 
            • #
              ‫۵ سال و ۶ ماه قبل، چهارشنبه ۲۲ اسفند ۱۳۹۷، ساعت ۱۳:۰۱
              غیرممکن هست که در یک دیکشنری کلید تکراری ثبت شود؛ چون استثناء تولید می‌کند و هویت آن به این صورت است. مشکلی که در طراحی فوق دارید، استفاده از StringBuilder به عنوان کلید هست:
              var sb1 = new StringBuilder("Food");
              var sb2 = new StringBuilder("Food");
              Console.WriteLine(sb1 == sb2);
              خروجی مقایسه‌ی فوق، false است؛ چون این شیء Equals(object) را پیاده سازی نکرده و روش محاسبه‌ی یکی بودن آن‌ها به این صورت است:
              true if this instance and sb have equal string, Capacity, and MaxCapacity values; otherwise, false.
              آن‌را تبدیل کنید به رشته، مشکل حل می‌شود. ضمنا هدف از دیکشنری یافتن سریع کلیدها است و عموما از یک رشته‌ی بلند به عنوان کلید استفاده نمی‌شود. از هش کوتاه آن استفاده می‌شود.
  • #
    ‫۵ سال و ۶ ماه قبل، سه‌شنبه ۲۱ اسفند ۱۳۹۷، ساعت ۱۶:۲۴
    نمونه ای از ExtentionMethod‌های متد‌های ConcurrentDictionary:
    public static class ConcurrentDictionaryExtensions
        {
            public static TValue GetOrAdd<TKey, TValue>(
                this ConcurrentDictionary<TKey, Lazy<TValue>> @this,
                TKey key, Func<TKey, TValue> valueFactory
            )
            {
                return @this.GetOrAdd(key,
                    (k) => new Lazy<TValue>(() => valueFactory(k))
                ).Value;
            }
    
            public static TValue AddOrUpdate<TKey, TValue>(
                this ConcurrentDictionary<TKey, Lazy<TValue>> @this,
                TKey key, Func<TKey, TValue> addValueFactory,
                Func<TKey, TValue, TValue> updateValueFactory
            )
            {
                return @this.AddOrUpdate(key,
                    (k) => new Lazy<TValue>(() => addValueFactory(k)),
                    (k, currentValue) => new Lazy<TValue>(
                        () => updateValueFactory(k, currentValue.Value)
                    )
                ).Value;
            }
    
            public static bool TryGetValue<TKey, TValue>(
                this ConcurrentDictionary<TKey, Lazy<TValue>> @this,
                TKey key, out TValue value
            )
            {
                value = default(TValue);
    
                var result = @this.TryGetValue(key, out Lazy<TValue> v);
    
                if (result) value = v.Value;
    
                return result;
            }
    
            // this overload may not make sense to use when you want to avoid
            //  the construction of the value when it isn't needed
            public static bool TryAdd<TKey, TValue>(
                this ConcurrentDictionary<TKey, Lazy<TValue>> @this,
                TKey key, TValue value
            )
            {
                return @this.TryAdd(key, new Lazy<TValue>(() => value));
            }
    
            public static bool TryAdd<TKey, TValue>(
                this ConcurrentDictionary<TKey, Lazy<TValue>> @this,
                TKey key, Func<TKey, TValue> valueFactory
            )
            {
                return @this.TryAdd(key,
                    new Lazy<TValue>(() => valueFactory(key))
                );
            }
    
            public static bool TryRemove<TKey, TValue>(
                this ConcurrentDictionary<TKey, Lazy<TValue>> @this,
                TKey key, out TValue value
            )
            {
                value = default(TValue);
    
                if (@this.TryRemove(key, out Lazy<TValue> v))
                {
                    value = v.Value;
                    return true;
                }
                return false;
            }
    
            public static bool TryUpdate<TKey, TValue>(
                this ConcurrentDictionary<TKey, Lazy<TValue>> @this,
                TKey key, Func<TKey, TValue, TValue> updateValueFactory
            )
            {
                if (!@this.TryGetValue(key, out Lazy<TValue> existingValue))
                    return false;
    
                return @this.TryUpdate(key,
                    new Lazy<TValue>(
                        () => updateValueFactory(key, existingValue.Value)
                    ),
                    existingValue
                );
            }
        }

    • #
      ‫۵ سال و ۶ ماه قبل، سه‌شنبه ۲۱ اسفند ۱۳۹۷، ساعت ۱۶:۲۸
      مواردی که Func ندارند، thread-safe هستند و نیازی به نکته‌ی مطلب جاری ندارند.