کانالها (Channels) در مقابل صفها (Queues) در داتنت؛ چرا در سال ۲۰۲۶ باید ساختارهای سنتی را کنار بگذاریم؟
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۴/۱۷ ۰۸:۲۵
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
Queue به همراه یک قفل تداخلی (lock) یا در بهترین حالت، استفاده از مجموعههای نخی امن مثل ConcurrentQueue به همراه یک حلقه Task.Run است. OutOfMemoryException مواجه میشوند. چالش اصلی در این سیستمها، «همروندی» نیست؛ بلکه «هماهنگی و کنترل جریان» (Backpressure) است. در این مقاله، به بررسی عمیق فضای نام System.Threading.Channels میپردازیم و بررسی میکنیم که چرا این ابزار، پناهگاه امن توسعهدهندگان داتنت برای الگوهای تولیدکننده-مصرفکننده (Producer-Consumer) است. Queue + Lock)ConcurrentQueue)private readonly ConcurrentQueue<MyJob> _queue = new();
public void Enqueue(MyJob job) => _queue.Enqueue(job);
public void StartWorker()
{
_ = Task.Run(async () =>
{
while (true)
{
if (_queue.TryDequeue(out var job))
{
await ProcessJobAsync(job);
}
else
{
await Task.Delay(100); // رویکردی ابتدایی برای جلوگیری از مشغولماندن نخ
}
}
});
}Task.Delay(100) یک توازن ناکارآمد ایجاد میکند؛ یا صف خالی است و نخ دائم در حال بیدار و بستهشدن است، یا کارها با تأخیر ۱۰۰ میلیثانیهای پردازش میشوند. TryDequeue ماهیت سنکرون دارد و با زنجیره Async/Await به خوبی ترکیب نمیشود. BlockingCollectionمنسوخ شده است؟BlockingCollection سالها ابزار اصلی داتنت بود و از Backpressure نیز پشتیبانی میکرد، اما ریشه در دنیای سنکرون دارد. مسدود کردن نخها (Thread Blocking) در دورانی که تمام معماریها بر پایه برنامهنویسی آسنکرون و بهرهوری حداکثری از هستههای پردازنده بنا شدهاند، هزینهای بسیار سنگین به سرور تحمیل میکند. Channel) چیست؟Channel میتوان دو نوع کانال اصلی ایجاد کرد: var channel = Channel.CreateUnbounded<MyEvent>();
var options = new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait
};
var channel = Channel.CreateBounded<MyEvent>(options);BoundedChannelFullMode)| وضعیت (Mode) | رفتار سیستم (Behavior) | موارد کاربرد (Use Cases) |
Wait | تولیدکننده به صورت آسنکرون منتظر میماند تا فضا خالی شود. | امنترین حالت برای کارهای پردازشی که حذف هیچ رویدادی مجاز نیست. |
DropOldest | قدیمیترین آیتم درون صف حذف میشود تا جا برای داده جدید باز شود. | سیستمهای اینترنت اشیاء (IoT) و دادههای زنده مثل وضعیت بورس یا دماسنج. |
DropNewest | جدیدترین آیتم در حال انتظار حذف میشود. | زمانی که دادههای اولیه ارزش پردازشی بالاتری دارند. |
DropWrite | آیتمی که هماکنون در حال نوشتن است حذف میشود و متد بدون خطا برمیگردد. | سناریوهای تلمتری با اولویت پایین که مفقود شدن جزئی داده اهمیت ندارد. |
نکته مهم پیرامون رصد دادههای حذف شده (Observability): اگر از حالتهایی غیر ازWaitاستفاده میکنید، حتماً باید از کالبکitemDroppedاستفاده کنید تا متوجه از دست رفتن دادهها در لایه مانیتورینگ شوید. این کالبک سنکرون است؛ پس عملیات داخل آن باید فوقالعاده سریع باشد.
var channel = Channel.CreateBounded<MyEvent>(
new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.DropOldest },
itemDropped: droppedItem =>
{
Console.WriteLine($"[Warning] Item Dropped: {droppedItem.Id}");
}
);WriteAsync(T item): آیتم را مینویسد. اگر صف پر باشد، به صورت غیرمسدودکننده (Non-blocking) منتظر فضا میماند. TryWrite(T item): تلاش میکند به صورت سنکرون بنویسد. اگر موفق شود true و در صورت پر بودن یا بسته بودن صف false برمیگرداند. WaitToWriteAsync(): زمانی کاربرد دارد که میخواهید دستهای از دادهها (Batching) را وارد کنید؛ ابتدا منتظر فضای خالی میمانید و سپس به صورت سنکرون از TryWrite استفاده میکنید. // اشتباه: استفاده مستقیم از ReadAsync در حلقه بیطایان
while (true) {
var item = await reader.ReadAsync(); // با بسته شدن کانال خطای صریح پرتاب میکند!
}WaitToReadAsync و TryRead (بسیار بهینه برای پردازش دستهای)while (await reader.WaitToReadAsync(cancellationToken))
{
while (reader.TryRead(out var item))
{
await ProcessJobAsync(item); // تخلیه کاملاً سنکرون و سریع حافظه بوفربندی شده
}
}ReadAllAsync (سینتکس تمیز با رویکرد صریح رویدادمحور)await foreach (var item in reader.ReadAllAsync(cancellationToken))
{
await ProcessJobAsync(item);
}BackgroundService پردازش میکند. public record WebhookPayload(string Id, string EventType, string Data);
public interface IEventBus
{
ChannelReader<WebhookPayload> Reader { get; }
ValueTask PublishAsync(WebhookPayload payload, CancellationToken ct);
}
public sealed class ChannelEventBus : IEventBus, IDisposable
{
private readonly Channel<WebhookPayload> _channel;
public ChannelEventBus()
{
// اعمال بهینهسازیهای نخ با Hints
_channel = Channel.CreateBounded<WebhookPayload>(
new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true, // فقط یک BackgroundService خروجی را میخواند
SingleWriter = false // ممکن است چندین کنترلر همزمان بنویسند
});
}
public ChannelReader<WebhookPayload> Reader => _channel.Reader;
public async ValueTask PublishAsync(WebhookPayload payload, CancellationToken ct)
{
await _channel.Writer.WriteAsync(payload, ct); // اعمال فشار معکوس هوشمند در صورت پر بودن صف
}
public void Dispose() => _channel.Writer.Complete(); // بستن امن کانال هنگام اتمام کار نرمافزار
}[ApiController]
[Route("api/webhooks")]
public class WebhookController : ControllerBase
{
private readonly IEventBus _eventBus;
public WebhookController(IEventBus eventBus) => _eventBus = eventBus;
[HttpPost]
public async Task<IActionResult> Receive([FromBody] WebhookPayload payload, CancellationToken ct)
{
// کنترلر بدون درگیر شدن در منطق پردازش، بلافاصله رویداد را ثبت کرده و پاسخ داده را بازمیگرداند
await _eventBus.PublishAsync(payload, ct);
return Accepted();
}
}public class WebhookProcessorWorker : BackgroundService
{
private readonly IEventBus _eventBus;
private readonly ILogger<WebhookProcessorWorker> _logger;
public WebhookProcessorWorker(IEventBus eventBus, ILogger<WebhookProcessorWorker> logger)
{
_eventBus = eventBus;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("سرویس پردازش وبهوکها کار خود را آغاز کرد.");
// پردازش کاملاً آسنکرون، بدون قفلشدگی و با قابلیت لغو امن
await foreach (var payload in _eventBus.Reader.ReadAllAsync(stoppingToken))
{
try
{
_logger.LogInformation("در حال پردازش رویداد: {Id}", payload.Id);
await Task.Delay(200, stoppingToken); // شبیهسازی کار واقعی
}
catch (Exception ex)
{
_logger.LogError(ex, "خطا در پردازش رویداد {Id}", payload.Id);
}
}
_logger.LogInformation("تمام کارهای باقیمانده در کانال با موفقیت تخلیه و به پایان رسیدند.");
}
}Program.csbuilder.Services.AddSingleton<IEventBus, ChannelEventBus>(); builder.Services.AddHostedService<WebhookProcessorWorker>();
| ابزار | بومی آسنکرون؟ | مدیریت Backpressure؟ | موارد استفاده کلیدی |
Channels | بله | بله (بسیار دقیق) | خطوط لوله درونبرنامهای با کارایی بالا، جداسازی لایههای تولید و مصرف. |
TPL Dataflow | بله | بله | خطوط لوله بسیار پیچیده که نیاز به معماریهای درختی، نگاشت (Mapping) و اتصال گرهها دارند. |
ConcurrentQueue | خیر | خیر | ساختارهای داده همروند ساده و لولههای سنکرون لایه پایین بدون سیگنالدهی وضعیت اتمام. |
Message Brokers | بستگی به کلاینت دارد | بله | ارتباطات بین چندین میکروسرویس مجزا، نیاز به پایداری داده پس از ریستارت سرور (Durability). |
SingleReader = true یا SingleWriter = true را تنظیم کنید تا کدهای لایه پایین داتنت از ساختارهای همگامسازی سبکتری استفاده کنند. CancellationToken معتبر را پاس دهید تا فرآیند خاموش شدن سرور یا استقرار نسخه جدید (Deployment) بدون مفقود شدن دادهها انجام شود. Complete() را صدا بزند. در غیر این صورت، مصرفکننده تا ابد در وضعیت انتظار باقی مانده و نخها رها نمیشوند. _ = channel.Writer.WriteAsync(...) یک باگ بزرگ است؛ زیرا نه تنها استثناها (Exceptions) بلعیده میشوند، بلکه فلسفه وجودی Backpressure و متوقف شدن نخ تولیدکننده کاملاً بیاثر میگردد. System.Threading.Channels را میتوان «کمکفنرهای معماری نرمافزار» نامید. این ابزار با به حداقل رساندن تخصیص حافظه (Allocation)، پشتیبانی بومی از الگوهای مدرن آسنکرون و اعمال مکانیزمهای پیشرفته کنترل جریان، راهکاری بینظیر برای پایداری سیستمها در ترافیکهای سنگین ارائه میدهد. اگر در پروژههای خود در حال توسعه رفتارهای کارگری (Worker Services)، پردازش پسزمینه رویدادها یا لولههای جابجایی داده هستید، زمان آن رسیده است که ساختارهای قدیمی را کنار بگذارید و از کارایی بومی داتنت لذت ببرید. ConcurrentQueue و SemaphoreSlim برای مدیریت و سیگنالدهی کارها به یک BackgroundService است. این الگو اگرچه در سناریوهای ساده و بارهای کاری سبک به خوبی کار میکند، اما تحت بار شدید در محیطهای عملیاتی با چالشهای جدی مواجه میشود. در این مقاله آموزشی، ابتدا پیادهسازی سنتی مدیریت صف پسزمینه را کالبدشکافی کرده، معایب آن را بررسی میکنیم و سپس آن را با استفاده از مکانیزم بومی و پرقدرت System.Threading.Channels به یک پیادهسازی مدرن، امن و با کارایی بالا بازنویسی خواهیم کرد.public class BackgroundTaskQueue : IBackgroundTaskQueue
{
private ConcurrentQueue<Func<CancellationToken, Task>> _workItems = new();
private SemaphoreSlim _signal = new SemaphoreSlim(0);
public void QueueBackgroundWorkItem(Func<CancellationToken, Task> workItem)
{
if (workItem == null) throw new ArgumentNullException(nameof(workItem));
_workItems.Enqueue(workItem);
_signal.Release(); // سیگنالدهی به نخ مصرفکننده
}
public async Task<Func<CancellationToken, Task>> DequeueAsync(CancellationToken cancellationToken)
{
await _signal.WaitAsync(cancellationToken);
_workItems.TryDequeue(out var workItem);
return workItem;
}
}ConcurrentQueue در این بخش ظرفیت نامحدودی دارد. اگر نرخ تولید کارها در سیستم (مثلاً از طریق کلاینتهای API) بسیار بیشتر از سرعت پردازش آنها توسط BackgroundService باشد، صف به شکل نامحدود در حافظه (Heap) رشد کرده و در نهایت منجر به کرش سرور با خطای OutOfMemoryException میشود.ConcurrentQueue برای ذخیره و SemaphoreSlim برای سیگنالدهی) را به طور دستی هماهنگ کنید. این هماهنگی دستی هزینهبر است و احتمال بروز باگهای همروندی را افزایش میدهد.Func (یک لامبدا یا دلیگیت) میکند. اگر توسعهدهندهای هنگام ثبت کار، یک سرویس دارای طول عمر Scoped (مانند کانتکست دیتابیس یا IUserContext) را درون این لامبدا کپچر (Capture) کند، این شیء به یک کلوژر تبدیل شده و به دلیل ذخیره در یک صف Singleton، طول عمر آن همیشگی میشود! این موضوع یکی از ریشههای پنهان و کابوسوار نشتی حافظه در داتنت است.Channel مجهز میکنیم. برای امنیت بیشتر و جلوگیری از نشتی حافظه، به جای ارسال مستقیم دلیگیتها (Func)، سیستم را به ساختار «پیاممحور یا دادهمحور» (Data-Only Messages) تغییر میدهیم تا نمونهسازی و حل وابستگیها (Resolution) در لایه مصرفکننده و در یک Scope جدید رخ دهد.// ساختار دادهمحور کوچک بدون کپچر کردن کانتکستهای طول عمر کوتاه
public record BackgroundJobMessage(Guid JobId, string JobType, string Payload);
public interface IBackgroundTaskQueue
{
ValueTask QueueBackgroundWorkItemAsync(BackgroundJobMessage workItem, CancellationToken cancellationToken = default);
ValueTask<BackgroundJobMessage> DequeueAsync(CancellationToken cancellationToken);
}using System.Threading.Channels;
public class ChannelBackgroundTaskQueue : IBackgroundTaskQueue
{
private readonly Channel<BackgroundJobMessage> _channel;
public ChannelBackgroundTaskQueue(int capacity = 1000)
{
// ساخت یک کانال ناهمگام و محدود برای مدیریت پایداری حافظه
var options = new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait, // در صورت پر شدن، تولیدکننده به صورت آسنکرون منتظر میماند
SingleReader = true, // بهینهسازی: فقط یک BackgroundService کارها را میخواند
SingleWriter = false // کنترلرها یا سرویسهای متعدد میتوانند کار تزریق کنند
};
_channel = Channel.CreateBounded<BackgroundJobMessage>(options);
}
public async ValueTask QueueBackgroundWorkItemAsync(BackgroundJobMessage workItem, CancellationToken cancellationToken = default)
{
if (workItem == null) throw new ArgumentNullException(nameof(workItem));
// نوشتن کاملاً آسنکرون و غیرمسدودکننده نخ در کانال
await _channel.Writer.WriteAsync(workItem, cancellationToken);
}
public async ValueTask<BackgroundJobMessage> DequeueAsync(CancellationToken cancellationToken)
{
// خواندن بومی و آسنکرون
return await _channel.Reader.ReadAsync(cancellationToken);
}
}ReadAllAsync به شکل یک جریان آسنکرون مصرف کند و برای پردازش هر پیام، یک DI Scope مجزا بسازد:using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public class QueuedHostedService : BackgroundService
{
private readonly Channel<BackgroundJobMessage> _channel; // یا تزریق مستقیم کانتکست Reader
private readonly IBackgroundTaskQueue _taskQueue;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<QueuedHostedService> _logger;
public QueuedHostedService(
IBackgroundTaskQueue taskQueue,
IServiceProvider serviceProvider,
ILogger<QueuedHostedService> logger)
{
_taskQueue = taskQueue;
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("سرویس پردازش پسزمینه کانال آغاز به کار کرد.");
// با استفاده از الگوی پیشنهادی ReadAllAsync، از استثناهای ناخواسته جلوگیری میکنیم
while (!stoppingToken.IsCancellationRequested)
{
try
{
// دریافت کار بعدی به صورت بومی و کاملاً آسنکرون
var workItem = await _taskQueue.DequeueAsync(stoppingToken);
// ایجاد یک Scope جدید برای حل وابستگی سرویسهای طول عمر کوتاه (Scoped)
using var scope = _serviceProvider.CreateScope();
// فرض کنید یک مفسر یا هاندر برای پردازش کارهای پسزمینه دارید
var processor = scope.ServiceProvider.GetRequiredService<IJobProcessor>();
await processor.ProcessAsync(workItem, stoppingToken);
}
catch (OperationCanceledException)
{
// خروج امن سرویس در زمان درخواست لغو یا خاموشی سرور
_logger.LogWarning("عملیات خواندن از صف لغو شد.");
}
catch (Exception ex)
{
_logger.LogError(ex, "خطایی در طول پردازش آیتم صف پسزمینه رخ داد.");
}
}
}
}WriteAsync به طور آسنکرون اجرای متد کنترلر وب را متوقف میکند (پاز غیرمسدودکننده). این dam یا سد مانع از خراب شدن Heap حافظه سرور میشود.stoppingToken) را فعال میکند. ساختار کانال به دلیل بومی بودن در بستر Async/Await این توکن را به زیبایی در متد خواندن اعمال کرده و کارگران به سرعت و بدون نقص کارهای جاری را به اتمام رسانده و متوقف میشوند.Func) و حرکت به سمت ساختار دادهمحور (BackgroundJobMessage)، عملاً امکان انتقال کلوژرها و زنده نگهداشتن اشیاء سنگین لایه وب به صفهای سنگین Singleton صفر شده است.SemaphoreSlim، از الگوریتمهای درونی بدون قفل (Lock-free) استفاده میکنند کهContention کامپیوتر را به حداقل ممکن رسانده و امکان انتقال دهها هزار پیام در ثانیه را به سادگی فراهم میآورد.System.Threading.Channels نه تنها کد تمیزتر، خواناتر و منعطفتری خواهیم داشت، بلکه نرمافزاری تولید میکنیم که تحت سنگینترین بارهای کاری در محیط عملیاتی، رفتاری کاملاً پیشبینیپذیر و پایدار از خود نشان میدهد.