عنوان:

‫بررسی شیء جدید Lock‌ در C# 13 و دات‌نت 9


نویسنده: وحید نصیری
تاریخ: ۱۴۰۳/۰۹/۰۱ ۲۱:۵۰
آدرس: www.dntips.ir
روش متداول قفل کردن اجرای قطعه‌ای از کد، تا پیش از دات‌نت 9

تا پیش از دانت 9، روش متداول قفل کردن اجرای قطعه‌ای از کد به صورت زیر است:
private readonly object _syncRoot = new();
public void DoSomething()
{
   lock (_syncRoot)
   {
      // Do something
   }
}
برای مثال فرض کنید قصد دارید یک سیستم logging را طراحی کنید که قرار است اطلاعات نهایی را در یک فایل متنی ذخیره کند. در این سیستم، چندین ترد کار تهیه‌ی اطلاعات قابل ثبت را انجام می‌دهند. بدون قفل کردن قطعه کدی که کار ثبت اطلاعات نهایی را در فایل انجام می‌دهد، تردهای مختلف، اطلاعات را بر روی یکدیگر ثبت کرده و یا در نهایت سبب تخریب فایل می‌شوند.
هدف از تعریف یک lock statement، اطمینان حاصل کردن از این مورد است که کدهای قطعه‌ی محصور شده‌ی توسط آن، فقط توسط یک ترد در هر لحظه قابل فراخوانی و پردازش است و سایر تردهای فراخوان تا پایان کار ترد اول، باید منتظر باقی بمانند تا یک race condition رخ ندهد و عموما برای تعریف آن، از یک شیء از نوع object استفاده می‌شود.

Low-Level C# code این قطعه کد (کدی که کامپایلر در پشت صحنه تولید می‌کند)، به صورت زیر است:
using System.Runtime.CompilerServices;
using System.Threading;

namespace CS13Tests
{
  public class NewLockObjectTests
  {
    [Nullable(1)]
    private readonly object _syncRoot;

    public void DoSomething()
    {
      object syncRoot = this._syncRoot;
      bool lockTaken = false;
      try
      {
        Monitor.Enter(syncRoot, ref lockTaken);
      }
      finally
      {
        if (lockTaken)
          Monitor.Exit(syncRoot);
      }
    }

    public NewLockObjectTests()
    {
      this._syncRoot = new object();
      base..ctor();
    }
  }
}
این نوع خروجی را برای مثال از طریق منوی Tools -> IL Viewer مخصوص Rider می‌توانید مشاهده کنید.

همانطور که مشخص است، این قطعه از کد و یک lock statement، به نحو فوق توسط کامپایلر در پشت صحنه به کمک Monitor.Enter پیاده سازی می‌شود.


روش جدید قفل کردن اجرای قطعه‌ای از کد در دات‌نت 9

اکنون در دات‌نت 9 که به همراه نوع جدید System.Threading.Lock است، می‌توان بجای شیء object، از شیء مخصوص Lock استفاده کرد:
private readonly Lock _syncRoot = new();
public void DoSomething()
{
   lock (_syncRoot)
   {
      // Do something
   }
}
با این تغییر کوچک و استفاده از شیء جدید Lock، اینبار Low-Level C# code قطعه کد فوق، به صورت زیر است:
using System.Runtime.CompilerServices;
using System.Threading;

namespace CS13Tests
{
  public class NewLockObjectTests
  {
    [Nullable(1)]
    private readonly Lock _syncRoot;

    public void DoSomething()
    {
      Lock.Scope scope = this._syncRoot.EnterScope();
      try
      {
      }
      finally
      {
        scope.Dispose();
      }
    }

    public NewLockObjectTests()
    {
      this._syncRoot = new Lock();
      base..ctor();
    }
  }
}
باتوجه به کدهایی که کامپایلر C# 13 برای پیاده سازی این نوع جدید ارائه می‌کند (رفتار کامپایلر بر اساس نوع شیء قفل شده، به صورت خودکار مدیریت می‌شود)، اینبار قفل‌گذاری و رفع آن، توسط شیء Lock.Scope انجام می‌شود. مزیت آن، رهایش امن این قفل به کمک متد Dispose، حتی در صورت بروز استثنائی در این میان است.

بنابراین تبدیل کدهای قدیمی به جدید در این حالت بسیار ساده بوده و فقط کافی است در حین تعریفsyncRoot_، بجای نوع object، از نوع جدید Lock استفاده شود تا علاوه بر مشخص شدن بهتر هدف از تعریف شیء lock، از مزایای کارآیی بیشتر این نوع جدید نیز بهره‌مند شویم.


نوع جدید Lock دات‌نت 9 را می‌توان Dispose کرد

این نوع Lock جدید را به صورت تک سطر زیر هم می‌توان فراخوانی کرد (یعنی الزامی به استفاده از یک lock statement برای کار با آن وجود ندارد):
using var scope = _syncRoot.EnterScope();
و یا به این صورت:
using (_syncRoot.EnterScope())
{
    // ...
}
متد EnterScope، یک ref struct را ارائه می‌دهد که با الگوی Dispose، سازگاری کاملی دارد و توسط using blocks قابل استفاده‌است.

روش دیگر فراخوانی شیء جدید Lock، بدون استفاده از یک using statement به صورت زیر است:
public class NewLockObjectTests
{
    private readonly Lock _lock = new();

    private void Do()
    {
        _lock.Enter();

        try
        {
            // ....
        }
        finally
        {
            if (_lock.IsHeldByCurrentThread)
            {
                _lock.Exit();
            }
        }
    }


بررسی کارآیی شیء Lock جدید در دات‌نت 9

در کدهای ذیل، کارآیی روش سنتی قفل‌گذاری و استفاده از شیء جدید Lock، بررسی و مقایسه شده‌اند:
using BenchmarkDotNet.Attributes;

namespace CS13Tests;

public class NewLockObjectBenchmarks
{
    private const decimal Amount = 100m;
    private const int TaskCount = 1000;
    private const int OperationsPerTask = 1000;

    [Benchmark]
    public decimal TestTraditionalLock()
    {
        var account = new TraditionalLockAccount();

        Parallel.For(fromInclusive: 0, TaskCount, _ =>
        {
            for (var i = 0; i < OperationsPerTask; i++)
            {
                account.Deposit(Amount);
            }
        });

        return account.GetBalance();
    }

    [Benchmark]
    public decimal TestNewLock()
    {
        var account = new NewLockAccount();

        Parallel.For(fromInclusive: 0, TaskCount, _ =>
        {
            for (var i = 0; i < OperationsPerTask; i++)
            {
                account.Deposit(Amount);
            }
        });

        return account.GetBalance();
    }

    public class TraditionalLockAccount
    {
        private readonly object _syncRoot = new();
        private decimal _balance;

        public void Deposit(decimal amount)
        {
            lock (_syncRoot)
            {
                _balance += amount;
            }
        }

        public decimal GetBalance() => _balance;
    }

    public class NewLockAccount
    {
        private readonly Lock _syncRoot = new();
        private decimal _balance;

        public void Deposit(decimal amount)
        {
            using var scope = _syncRoot.EnterScope();
            _balance += amount;
        }

        public decimal GetBalance() => _balance;
    }
}

/*
| Method              | Mean     | Error   | StdDev   |
|-------------------- |---------:|--------:|---------:|
| TestTraditionalLock | 137.3 ms | 3.82 ms | 11.28 ms |
| TestNewLock         | 115.8 ms | 1.50 ms |  1.41 ms |
*/
استفاده از شیء Lock جدید، کارآیی بیشتری (حداقل 20 درصدی) را نسبت به قفل کردن یک object، به همراه دارد.


محدودیت شیء جدید Lock در دات‌نت 9

این نوع lock را نیز در متدهای async نمی‌توان بکار گرفت و در صورت استفاده، با خطای زیر متوقف خواهیم شد:
Error CS1996 : Cannot await in the body of a lock statement
بنابراین هنوز هم استفاده از SemaphoreSlim، روش اصلی پیاده سازی asynchronous locks در دات‌نت است.