عنوان:

‫آشنایی با Stream Adapterهای جدید در دات‌نت 11: پلی میان داده‌های درون‌حافظه‌ای و دنیای Stream


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۴/۲۴ ۰۹:۳۵
آدرس: www.dntips.ir
در معماری دات‌نت، کلاس انتزاعی Stream نقش یک زبان مشترک را برای کار با داده‌های ترتیبی (Sequential Data) ایفا می‌کند. بسیاری از APIهای فریم‌ورک — از عملیات شبکه و فایل گرفته تا سریال‌سازی و پردازش HTTP — به‌جای انواع خاص داده، یک Stream را به‌عنوان ورودی یا خروجی می‌پذیرند. این طراحی انعطاف بالایی به ارمغان می‌آورد، اما در عمل مشکلی رایج به همراه دارد: وقتی داده‌ی شما از قبل در حافظه (مثلاً به‌صورت string، آرایه بایت یا بافر) موجود است، برای تطبیق آن با یک API که Stream می‌خواهد، معمولاً مجبور بودید آن را در یک MemoryStream کپی کنید. این کپی اضافه، به‌ویژه در سناریوهای پرتکرار یا با حجم داده‌ی بالا، هزینه‌ی محسوسی از نظر تخصیص حافظه (Memory Allocation) و کارایی تحمیل می‌کند. نسخه‌ی Preview 6 دات‌نت 11.0 (که در چارچوب issue شماره #129811 در مخزن dotnet/runtime پیگیری شده) این مشکل را با معرفی چهار نوع جدید از Stream حل می‌کند. این کلاس‌ها به‌جای کپی کردن داده، به‌سادگی یک لایه‌ی نازک (Wrapper) روی ساختارهای داده‌ی موجود در حافظه می‌سازند و آن‌ها را به شکل یک Stream استاندارد در معرض دید سایر بخش‌های برنامه قرار می‌دهند. در ادامه‌ی این مقاله، هر یک از این کلاس‌ها را به‌همراه کاربردهای عملی و نکات تکمیلی بررسی می‌کنیم.

توضیح و بررسی فنی: چرا این قابلیت اهمیت دارد؟
پیش از این تغییر، تنها راه رایج برای تبدیل یک بافر حافظه یا رشته به Stream، استفاده از MemoryStream بود. برای مثال:
byte[] bytes = Encoding.UTF8.GetBytes(someString);
using var stream = new MemoryStream(bytes);
این روش دو مشکل دارد: نخست، برای تبدیل string به Stream، باید ابتدا رشته را به آرایه بایت تبدیل کنید که خود یک تخصیص حافظه‌ی جداگانه است. دوم، ساختارهای مدرن‌تر دات‌نت مانند ReadOnlyMemory یا ReadOnlySequence (که در System.IO.Pipelines پرکاربرد هستند) به‌طور طبیعی با MemoryStream سازگار نیستند و تبدیل آن‌ها به آرایه‌ی مسطح (Flattened Array)، مزیت اصلی این ساختارها — یعنی پرهیز از کپی و تکه‌تکه بودن حافظه (Segmented Memory) — را از بین می‌برد. چهار کلاس جدید معرفی‌شده در این نسخه، دقیقاً برای رفع همین شکاف طراحی شده‌اند و هرکدام برای یک سناریوی مشخص بهینه شده‌اند:
۱. ReadOnlyMemoryStream این کلاس یک ReadOnlyMemory را به‌صورت یک Stream فقط-خواندنی (Read-Only) در معرض دید قرار می‌دهد. مناسب زمانی است که داده‌ای در قالب بافر فقط‌خواندنی در اختیار دارید و می‌خواهید بدون کپی، آن را به یک متد که Stream می‌پذیرد ارسال کنید.
۲. WritableMemoryStream برخلاف مورد قبلی، این نوع یک Memory قابل نوشتن را به‌صورت یک Stream با اندازه‌ی ثابت (Fixed-Size) نمایش می‌دهد. نکته‌ی مهمی که باید به آن توجه کرد این است که «قابل نوشتن» به معنای «قابل رشد» نیست؛ اندازه‌ی این Stream از پیش تعیین شده و برخلاف MemoryStream که در صورت نیاز بافر داخلی خود را بزرگ‌تر می‌کند، این کلاس چنین رفتاری ندارد. این ویژگی برای سناریوهایی مناسب است که یک بافر با اندازه‌ی از پیش مشخص (مثلاً یک بافر Pool‌شده از ArrayPool) دارید و می‌خواهید مستقیماً درون آن بنویسید.
۳. ReadOnlySequenceStream این کلاس شاید کاربردی‌ترین گزینه برای توسعه‌دهندگانی باشد که با System.IO.Pipelines کار می‌کنند. یک ReadOnlySequence می‌تواند از چند بخش ناپیوسته (Segment) در حافظه تشکیل شده باشد؛ وضعیتی که معمولاً هنگام خواندن داده از یک PipeReader رخ می‌دهد. ReadOnlySequenceStream به‌جای آنکه این بخش‌ها را در یک آرایه‌ی پیوسته کپی کند، مستقیماً روی همان Segmentها پیمایش می‌کند. این یعنی صرفه‌جویی واقعی در حافظه، به‌خصوص برای بافرهای بزرگ یا با حجم بالای درخواست.
۴. StringStream این کلاس یک string یا ReadOnlyMemory را با یک Encoding مشخص، به‌صورت Stream در می‌آورد. برخلاف روش قدیمی که نیازمند فراخوانی Encoding.GetBytes برای تبدیل کامل رشته به آرایه بایت بود، StringStream می‌تواند تبدیل کاراکترها به بایت را به‌صورت تنبل (Lazy) و در حین خواندن (On-the-fly) انجام دهد و از تخصیص یک آرایه‌ی بایت کامل و اضافه در حافظه پرهیز کند.

نمونه کد و کاربرد عملی
مثال زیر نشان می‌دهد چگونه می‌توان یک متن (مثلاً یک فایل تنظیمات YAML) را بدون تبدیل واسط به آرایه بایت، مستقیماً به‌عنوان Stream به یک تابع پردازش‌کننده پاس داد:
using System.IO;
using System.Text;

// ارسال مستقیم یک رشته به APIای که Stream می‌پذیرد؛ بدون نیاز به byte[] واسط
using Stream config = new StringStream(yamlText, Encoding.UTF8);
var settings = ParseConfiguration(config);

و در سناریوی دوم، یک بافر از پیش موجود در حافظه—مثلاً محتوایی که قرار است بدنه‌ی یک درخواست HTTP باشد—بدون کپی اضافه در معرض دید یک StreamContent قرار می‌گیرد:
// نمایش یک بافر موجود به‌صورت یک Stream فقط‌خواندنی؛ مثلاً به‌عنوان بدنه‌ی درخواست HTTP
ReadOnlyMemory<byte> payload = GetPayload();
using Stream body = new ReadOnlyMemoryStream(payload);
await httpClient.PostAsync(uri, new StreamContent(body));
همان‌طور که اشاره شد، ReadOnlySequenceStream ارزش خود را بیشتر در کنار System.IO.Pipelines نشان می‌دهد، چون به‌جای تخصیص یک کپی پیوسته از داده، مستقیماً روی Segmentهای یک ReadOnlySequence جریان می‌یابد:
// فرض کنید buffer از یک PipeReader خوانده شده است
ReadOnlySequence<byte> buffer = readResult.Buffer;
using Stream sequenceStream = new ReadOnlySequenceStream(buffer);
await sequenceStream.CopyToAsync(destination);

نکات تکمیلی: نکاتی برای استفاده‌ی درست
برای بهره‌گیری مؤثر از این کلاس‌ها، توجه به چند نکته‌ی عملی، خالی از فایده نیست:
  • این کلاس‌ها جایگزین MemoryStream در همه‌ی سناریوها نیستند. اگر به یک Stream نیاز دارید که بتواند به‌صورت پویا رشد کند (مثلاً برای Buffer کردن خروجی با حجم نامشخص)، MemoryStream همچنان انتخاب درستی است. این چهار کلاس بیشتر برای سناریوهایی طراحی شده‌اند که داده از پیش کامل و مشخص است و صرفاً نیاز به «قالب‌بندی» آن به شکل Stream دارید.
  • مدیریت طول عمر حافظه اهمیت دارد. از آنجا که این کلاس‌ها داده را کپی نمی‌کنند، بلکه مستقیماً به بافر اصلی اشاره دارند، باید مطمئن شوید بافر پشتیبان (مثلاً Memory یا ReadOnlySequence) در طول عمر Stream معتبر و بدون تغییر باقی می‌ماند؛ در غیر این صورت ممکن است داده‌ی خوانده‌شده نادرست یا غیرقابل‌پیش‌بینی باشد.
  • سازگاری با الگوهای Async و Span-based API. این کلاس‌ها معمولاً متدهای مدرن Stream نظیر ReadAsync(Memory و Read(Span را نیز پیاده‌سازی می‌کنند، بنابراین در کنار APIهای جدید دات‌نت که مبتنی بر Span و Memory هستند، به‌خوبی ترکیب می‌شوند و از تبدیل‌های غیرضروری بین آرایه و Span جلوگیری می‌کنند.
  • کاهش فشار بر Garbage Collector. در برنامه‌های با کارایی بالا (High-Throughput)، مانند سرویس‌های ASP.NET Core که حجم زیادی داده‌ی HTTP پردازش می‌کنند، حذف تخصیص‌های واسط (مثل کپی رشته به آرایه بایت) می‌تواند به‌طور محسوسی فشار روی Garbage Collector را کاهش داده و تأخیر (Latency) را بهبود بخشد.

نتیجه‌گیری
معرفی ReadOnlyMemoryStream، WritableMemoryStream، ReadOnlySequenceStream و StringStream در دات‌نت 11.0 Preview 6، گامی هدفمند در راستای کاهش تخصیص‌های غیرضروری حافظه و افزایش سازگاری میان APIهای مبتنی بر Stream و ساختارهای داده‌ی مدرن دات‌نت مانند Memory و ReadOnlySequence است. این ابزارها به‌جای آنکه راه‌حلی همه‌منظوره باشند، هرکدام برای یک نیاز مشخص طراحی شده‌اند: از نمایش ساده‌ی یک بافر فقط‌خواندنی گرفته تا پیمایش کارآمد بافرهای تکه‌تکه‌ی حاصل از System.IO.Pipelines. برای توسعه‌دهندگانی که با APIهای مبتنی بر Stream سروکار دارند — به‌ویژه در لایه‌های شبکه، سریال‌سازی و پردازش فایل — آشنایی و به‌کارگیری این کلاس‌ها می‌تواند بهبود محسوسی در کارایی و مصرف حافظه‌ی برنامه به همراه داشته باشد، بدون آنکه پیچیدگی قابل‌توجهی به کد اضافه شود.