عنوان:

‫روش‌های مختلف انجام چند کار به صورت همزمان در C# .NET - قسمت دوم


نویسنده: یاسر مرادی
تاریخ: ۱۳۹۸/۰۸/۱۰ ۱۹:۱۵
آدرس: www.dntips.ir
در قسمت قبل دیدیم که انجام کارهای همزمان، با Objectهایی که به اصطلاح Thread Safe نیستند (مانند DbContext) خروجی چندان جالبی ندارد و برای مثال اگر در یک Service یک DbContext را Inject کنیم (مثلا با Constructor injection) و از آن در متدی استفاده کنیم که آن متد یا با TPL یا RX و ... به صورت چندتایی و همزمان اجرا شود، DbContext به مشکل می‌خورد؛ یعنی نمی‌توان یک وهله از DbContext را بین چند Thread همزمان پردازش موازی، به اشتراک گذاشت.
در کدهای فرضی مثال‌های قسمت قبل، متدی داشتیم با نام DoSomethingWithCustomer که مثلا همان متدی بود که قرار است همزمان اجرا شود. یکی از ساده‌ترین کارهایی که برای رفع این مشکل می‌توان انجام داد، نوشتن چنین کدی است:
public async Task DoSomethingWithCustomer(Customer customer)
{
    using var dbContext = new AppDbContext();

    // ...
}
در این حالت، اگر متد DoSomethingWithCustomer به صورت همزمان اجرا شود، به ازای هر بار اجرا، یک DbContext جدید ساخته میشود؛ پس از یک DbContext مشترک، به صورت همزمان توسط چندین Thread پردازش موازی، استفاده نخواهد شد. ولی مشکل اینجاست که از Dependency Injection برای ساختن DbContext استفاده نشده‌است و ما خودمان، هم new کرده‌ایم و هم Dispose!

این روش ابدا توصیه نمی‌شود؛ برای اینکه Dependency Injection این روزها مسائل خیلی زیادی را مدیریت می‌کند. مثلا DbContext در EF Core وقتی با Dependency Injection ساخته شود، Logging اش هم فعال می‌شود و یا مثلا اگر از متد زیر
services.AddDbContextPool<AppDbContext>();
برای Register کردن DbContext استفاده کرده باشیم، تعدادی DbContext ساخته می‌شود و در پروژه به صورت امن از همان چندتا استفاده می‌شود؛ بجای اینکه Objectها مدام ساخته و از بین برده شوند که این مهم، روی کارآیی تاثیر گذار است.
و یا مثلا برای HttpClient فقط در همین سایت نزدیک به یک دوجین مقاله توضیح داده‌اند که چرا new کردن و Dispose کردن HTTP Client مناسب نیست و بهتر است برای Register کردن HttpClient، از services.AddHttpClient استفاده و از IHttpClientFactory و سایر روشها برای Resolve کردن HttpClient استفاده کنیم که اینها نیز توسط Dependency Injection قابل استفاده هستند. از مسائلی مانند Polly و ... نیز می‌گذریم.
راه حل ایجاد یک Context جدید، تقریبا در تمامی کتابخانه‌های Dependency Injection دیده شده‌است و آن ساختن یک Child Scope است. در ادامه با Microsoft.Extensions.DependencyInjection یک پیاده سازی آن‌را خواهیم داشت؛ ولی مشابه این روش در سایر کتابخانه‌ها همچون Autofac نیز شدنی است.

برای شروع System.IServiceProvider را inject کنید. سپس کد قبلی را به این شکل بنویسید:
(نیاز به ;using Microsoft.Extensions.DependencyInjection در بالای فایل کد است)
public async Task DoSomethingWithCustomer(Customer customer)
{
    using var scope = _serviceProvider.CreateScope();
    var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    var httpClient = scope.ServiceProvider.GetRequiredService<IHttpClientFactory>().CreateClient();
    // ...
}
در این روش فقط نیاز به using نوشتن برای خط اول است؛ یعنی scope که Dispose شود، Objectهایی که به وسیله آن scope ساخته شده‌اند، آزاد خواهند شد. تمام آن چیزهایی را که قبلا با Constructor یا Property injection می‌توانستید بگیرید را اکنون می‌توانید با متد GetRequiredService بگیرید.
همچنین می‌توانید برای داشتن کدی بهتر، یک interface و class را ایجاد کنید و logic مربوطه را در آن سرویس قرار دهید و در آن سرویس با constructor یا property injection از DbContext و HttpClient و سایر سرویس‌ها استفاده کنید و در نهایت آن interface/class را رجیستر کنید و در DoSomethingWithCustomer به کمک child scope، یک object از آن سرویس بسازید و متدش را فراخوانی کنید. برای مثال اگر هدف ساختن Excel تاریخچه خریدهای مشتری است، داریم:
public interface IOrderHistoryService
{
    Task BuildCustomerHistory();
}

public class OrderHistoryService : IOrderHistoryService
{
    private AppDbContext _dbContext;

    public OrderHistoryService(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task BuildCustomerHistory()
    {
        // ...
    }
}
سپس اگر جایی لازم شد روی لیستی از مشتری‌ها این متد اجرا شود، از TPL استفاده می‌کنیم و متدی را فراخوانی می‌کنیم که در آن child scope می‌سازیم و scope.GetRequiredService را برای گرفتن IOrderHistoryService استفاده می‌کنیم. در این روش هم مشکلی در استفاده از Object هایی که thread safe نیستند (مانند DbContext) رخ نخواهد داد و از Dependency injection و مزیت‌های بی‌شمار آن نیز بهره برده‌ایم.

نظرات

  • وحید نصیری در ۱۴۰۴/۰۹/۲۲ ۰۸:۴۹
    DbContext ایمن در برابر نخ‌ها نیست: روش موازی‌سازی پرس‌وجوهای EF Core در نگارش‌های جدید آن

    توضیح مسئله
    تصور کنید endpointی دارید که باید داده‌هایی مثل ۵۰ سفارش اخیر کاربر، لاگ‌های سلامت سیستم، تنظیمات پروفایل کاربر و شاید تعداد نوتیفیکیشن‌ها را واکشی کند. این داده‌ها کاملاً نامرتبط هستند، اما در اجرای استاندارد، شما آن‌ها را به ترتیب (sequential) فراخوانی می‌کنید:
    var orders = await GetRecentOrdersAsync(userId);
    var logs = await GetSystemLogsAsync();
    var stats = await GetUserStatsAsync(userId);
    return new DashboardDto(orders, logs, stats);
    این کد تمیز و خوانا است، اما مشکل اینجاست: اگر GetRecentOrdersAsync، به میزان ۳۰۰ میلی‌ثانیه، GetSystemLogsAsync، فقط ۴۰۰ میلی‌ثانیه و GetUserStatsAsync دقیقا ۳۰۰ میلی‌ثانیه طول بکشد، کاربر باید یک ثانیه کامل منتظر بماند (جمع کل زمان‌ها). در حالی که اگر این عملیات را موازی اجرا کنیم، زمان کل فقط به اندازه کندترین عملیات (۴۰۰ میلی‌ثانیه) می‌شود – یعنی بهبود ۶۰ درصدی عملکرد!
    اما چرا نمی‌توانیم به سادگی از Task.WhenAll استفاده کنیم؟ اگر سعی کنید عملیات را در taskهای جداگانه بپیچید و منتظر همه‌شان باشید، با خطای معروف روبرو می‌شوید:
    "A second operation started on this context before a previous operation completed. This is usually caused by different threads using the same instance of DbContext, however instance members are not guaranteed to be thread-safe."
    دلیلش ساده است: DbContext یک شیء حالت‌دار (stateful) است که برای مدیریت یک واحد کاری (unit of work) طراحی شده. آن شامل یک Change Tracker برای پیگیری موجودیت‌های بارگذاری‌شده و یک اتصال پایگاه داده زیرین است. پروتکل‌های پایگاه داده (مثل TCP برای PostgreSQL یا SQL Server) معمولاً در سطح اتصال، synchronous هستند؛ یعنی نمی‌توانید دو پرس‌وجوی SQL متفاوت را همزمان از یک اتصال بفرستید. وقتی Task.WhenAll استفاده می‌کنید، نخ‌های متعدد سعی می‌کنند همزمان به آن اتصال واحد دسترسی پیدا کنند، و EF Core برای جلوگیری از فساد داده، exception پرتاب می‌کند. البته این مشکل نه تنها به اتصال پایگاه داده مربوط می‌شود، بلکه Change Tracker هم نقش دارد: اگر دو نخ همزمان موجودیت‌هایی را تغییر دهند، ردیابی تغییرات به هم می‌ریزد. بنابراین، DbContext برای استفاده تک‌نخی طراحی شده و در سناریوهای موازی نیاز به مدیریت جداگانه دارد.

    راه‌حل قدیمی
    راه‌حل سنتی، همان اجرای sequential است که در بالا دیدیم. این روش ایمن است چون همه عملیات از یک DbContext واحد استفاده می‌کنند و هیچ تداخلی پیش نمی‌آید. اما همان‌طور که گفتیم، در endpointهایی با چندین پرس‌وجوی مستقل، این رویکرد منجر به تأخیر غیرضروری می‌شود. بسیاری از توسعه‌دهندگان برای جبران، به سمت خام‌نویسی SQL یا ابزارهای دیگر مثل Dapper می‌روند، اما این کار پیچیدگی کد را افزایش می‌دهد و مزایای ORM مثل EF Core (از جمله type-safety و LINQ) را از دست می‌دهید. علاوه بر این، اگر پرس‌وجوها واقعاً سریع باشند (مثل جستجوی ساده بر اساس ID)، sequential بودن مشکلی ایجاد نمی‌کند، اما در موارد واقعی با داده‌های بزرگ یا پایگاه داده‌های دور، این روش ناکارآمد است.

    راه‌حل جدید
    از .NET 5 به بعد، EF Core یک راه‌حل درجه یک برای این سناریو ارائه کرده: IDbContextFactory. به جای تزریق یک نمونه scoped از DbContext (که برای کل درخواست HTTP عمر می‌کند)، یک کارخانه تزریق می‌کنید که اجازه می‌دهد نمونه‌های سبک و مستقل DbContext را بر حسب نیاز ایجاد کنید.

    نکته: اگر به DbContextOptions دسترسی دارید، می‌توانید دستی هم DbContext بسازید (مثل using var context = new AppDbContext(options);)، اما استفاده از کارخانه برای وابستگی‌تزریق (Dependency Injection) تمیزتر است.

    ابتدا کارخانه را در Program.cs ثبت کنید:
    builder.Services.AddDbContextFactory<AppDbContext>(options =>
    {
        options.UseNpgsql(builder.Configuration.GetConnectionString("db"));
    });
    حالا endpoint کندمان را refactor می‌کنیم. به جای AppDbContext، اینبار IDbContextFactory را تزریق کنید. برای هر پرس‌وجو، یک task اختصاصی ایجاد کنید و داخل هر task، یک DbContext تازه بسازید، پرس‌وجو را اجرا کنید و بلافاصله dispose کنید:
    using Microsoft.EntityFrameworkCore;
    
    public class DashboardService(IDbContextFactory<AppDbContext> contextFactory)
    {
        public async Task<DashboardDto> GetDashboardAsync(int userId)
        {
            // ۱. شروع taskها (پرس‌وجوها بلافاصله شروع به اجرا می‌کنند)
            var ordersTask = GetOrdersAsync(userId);
            var logsTask = GetSystemLogsAsync();
            var statsTask = GetUserStatsAsync(userId);
    
            // ۲. منتظر ماندن برای تکمیل همه
            await Task.WhenAll(ordersTask, logsTask, statsTask);
    
            // ۳. بازگشت نتایج
            return new DashboardDto(
                await ordersTask,
                await logsTask,
                await statsTask
            );
        }
    
        private async Task<List<Order>> GetOrdersAsync(int userId)
        {
            await using var context = await contextFactory.CreateDbContextAsync();
            return await context.Orders
                .AsNoTracking()  // برای پرس‌وجوهای فقط‌خواندنی، tracking را خاموش کنید تا عملکرد بهتر شود
                .Where(o => o.UserId == userId)
                .OrderByDescending(o => o.CreatedAt)
                .ThenByDescending(o => o.Amount)
                .Take(50)
                .ToListAsync();
        }
    
        private async Task<List<SystemLog>> GetSystemLogsAsync()
        {
            await using var context = await contextFactory.CreateDbContextAsync();
            return await context.SystemLogs
                .AsNoTracking()
                .OrderByDescending(l => l.Timestamp)
                .Take(50)
                .ToListAsync();
        }
    
        private async Task<UserStats?> GetUserStatsAsync(int userId)
        {
            await using var context = await contextFactory.CreateDbContextAsync();
            return await context.Users
                .Where(u => u.Id == userId)
                .Select(u => new UserStats { OrderCount = u.Orders.Count })
                .FirstOrDefaultAsync();
        }
    }
    نکات کلیدی:
    • ایزوله‌سازی: هر task یک DbContext اختصاصی دارد، یعنی اتصال پایگاه داده جداگانه. هیچ تداخلی پیش نمی‌آید.
    • Dispose کردن: از await using استفاده کنید تا بعد از پرس‌وجو، اتصال به pool برگردد.
    • استفاده از AsNoTracking در پرس‌وجوهای فقط‌خواندنی (read-only) ضروری است چون Change Tracker را غیرفعال می‌کند و حافظه و زمان کمتری مصرف می‌شود. همچنین، در بنچمارکهای واقعی (مثل یک اپ .NET 10 با PostgreSQL)، اجرای sequential حدود ۳۶ میلی‌ثانیه طول می‌کشد، در حالی که موازی فقط ۱۳ میلی‌ثانیه؛ یعنی بهبود قابل توجه. اگر پایگاه داده دور باشد، این تفاوت بیشتر هم می‌شود.

    نتیجه‌گیری
    IDbContextFactory پلی بین طراحی واحد کاری EF Core و نیازهای موازی مدرن می‌زند و اجازه می‌دهد بدون قربانی کردن ایمنی، از تک‌نخی بودن خارج شوید. اما این الگو را با احتیاط استفاده کنید:
    • تمام شدن pool اتصالات: یک درخواست حالا چند اتصال همزمان را اشغال می‌کند، که در concurrency بالا می‌تواند pool را خالی کند.
    • سربار context: برای پرس‌وجوهای خیلی سریع، ایجاد task و contextهای متعدد ممکن است کندتر از sequential باشد.