فصل ۲۳: Span<T> و Memory<T>
ساختارهای Span<T> و Memory<T> نماهای سطحپایینی روی یک آرایه، رشته یا هر بلوک پیوستهای از حافظهٔ مدیریتشده یا مدیریتنشده هستند. هدف اصلی آنها کمک به برخی ریزبهینهسازیها (micro-optimization) است؛ بهویژه نوشتن کدی با تخصیص حافظهٔ اندک که allocationهای حافظهٔ مدیریتشده را کمینه میکند و در نتیجه بار garbage collector را کاهش میدهد، بدون اینکه مجبور شوید برای انواع مختلف ورودی کد را تکرار کنید. این ساختارها همچنین slicing را ممکن میکنند؛ یعنی کار با بخشی از آرایه، رشته یا بلوک حافظه بدون ایجاد کپی.
Span<T> و Memory<T> بهخصوص در نقاط حساس از نظر performance مفیدند؛ برای نمونه pipeline پردازش ASP.NET Core یا parser مربوط به JSON که به یک object database سرویس میدهد.
Span<T> بهطور مشخص دو کار انجام میدهد:
- یک interface شبیه آرایه و مشترک روی managed arrayها، stringها و حافظهٔ متکی بر pointer ارائه میکند. در نتیجه میتوانید برای اجتناب از garbage collection از stack-allocated memory و unmanaged memory بهره ببرید، بدون آنکه کد را تکرار کنید یا درگیر pointerها شوید.
- امکان slicing را میدهد؛ یعنی زیربخشهای قابل استفادهٔ مجدد از span را بدون کپیکردن آشکار میکند.
چون Span<T> میتواند حافظهٔ تخصیصیافته روی stack را در بر بگیرد، محدودیتهایی در نحوهٔ ذخیره یا عبور دادن instanceهای آن وجود دارد؛ بخشی از این محدودیت از ref struct بودن آن ناشی میشود. Memory<T> شبیه span است اما این محدودیتها را ندارد؛ در عوض نمیتواند stack-allocated memory را wrap کند. بااینحال مزیت slicing را حفظ میکند.
برای هر یک از این structها نسخهٔ read-only نیز وجود دارد: ReadOnlySpan<T> و ReadOnlyMemory<T>. این نسخهها علاوه بر جلوگیری از تغییر ناخواسته، با آزادی بیشتر برای compiler و runtime میتوانند به optimization بیشتر کمک کنند. خود .NET و ASP.NET Core از این typeها برای افزایش کارایی I/O، networking، string handling و JSON parsing استفاده میکنند.
Spanها و Slicing
برخلاف آرایه، یک span را میتوان بهآسانی slice کرد تا زیربخشهای متفاوتی از همان دادهٔ زیربنایی را نمایش دهد، همانطور که در شکل 23-1 نشان داده شده است.
شکل 23-1 ـ Slicing: چند span میتوانند بدون کپیکردن، بخشهای متفاوتی از دادهٔ زیربنایی واحد را نمایش دهند.
فرض کنید متدی برای جمعزدن یک آرایهٔ integer مینویسید. پیادهسازی micro-optimized بهجای LINQ از foreach استفاده میکند:
int Sum (int[] numbers)
{
int total = 0;
foreach (int i in numbers) total += i;
return total;
}
اگر فقط بخشی از آرایه را بخواهید جمع کنید، دو انتخاب سنتی دارید: آن بخش را ابتدا در آرایهٔ دیگری کپی کنید، یا پارامترهای اضافی مانند offset و count به متد بدهید. راه اول ناکارآمد است و راه دوم clutter و complexity ایجاد میکند؛ بهخصوص وقتی متد بیش از یک آرایه دریافت کند.
Span این مشکل را تمیز حل میکند. کافی است نوع پارامتر را از int[] به ReadOnlySpan<int> تغییر دهید و بقیهٔ کد همان بماند:
int Sum (ReadOnlySpan<int> numbers)
{
int total = 0;
foreach (int i in numbers) total += i;
return total;
}
var numbers = new int [1000];
for (int i = 0; i < numbers.Length; i++) numbers [i] = i;
int total = Sum (numbers);
var span = numbers.AsSpan();
// Sum the middle 500 elements (starting from position 250):
int total2 = Sum (numbers.AsSpan (250, 500));
فراخوانی Sum با آرایه ممکن است چون از T[] به Span<T> و ReadOnlySpan<T> تبدیل implicit وجود دارد. همچنین extension methodِ AsSpan در دسترس است. Indexer مربوط به ReadOnlySpan<T> از قابلیت ref readonly در C# برای دسترسی مستقیم به دادهٔ زیرین استفاده میکند؛ بنابراین performance تقریباً به اندازهٔ نسخهٔ مبتنی بر آرایه خوب باقی میماند، در حالی که اکنون slicing نیز داریم.
اگر از قبل Span<T> یا ReadOnlySpan<T> دارید، با Slice آن را برش دهید. همچنین indices و ranges در C# 8 قابل استفادهاند:
Span<int> span = numbers;
int total = Sum (span.Slice (250, 500));
Console.WriteLine (span [^1]); // Last element
Console.WriteLine (Sum (span [..10])); // First 10 elements
Console.WriteLine (Sum (span [100..])); // 100th element to end
Console.WriteLine (Sum (span [^5..])); // Last 5 elements
هرچند Span<T> بهدلیل ref struct بودن نمیتواند IEnumerable<T> را پیادهسازی کند، pattern لازم برای کار با دستور foreach را پیادهسازی میکند.
CopyTo و TryCopyTo
CopyTo عناصر یک span یا Memory<T> را به دیگری کپی میکند:
Span<int> x = [1, 2, 3, 4]; // Collection expression
Span<int> y = new int[4];
x.CopyTo (y);
Slicing کاربرد CopyTo را بیشتر میکند:
Span<int> x = [1, 2, 3, 4 ];
Span<int> y = [10, 20, 30, 40];
x[..2].CopyTo (y[2..]); // y is now [10, 20, 1, 2]
اگر destination فضای کافی نداشته باشد، CopyTo exception میاندازد؛ TryCopyTo در همان وضعیت false برمیگرداند و چیزی کپی نمیکند. Spanها همچنین متدهای Clear، Fill و IndexOf دارند.
جستوجو در Spanها
کلاس MemoryExtensions extension methodهای زیادی برای جستوجوی مقادیر در span تعریف میکند، از جمله Contains، IndexOf، LastIndexOf و BinarySearch، و همچنین متدهای تغییردهنده مانند Fill، Replace و Reverse.
از .NET 8، متدهایی برای جستوجوی یکی از چند مقدار نیز وجود دارد؛ مانند ContainsAny، ContainsAnyExcept، IndexOfAny و IndexOfAnyExcept. مقادیر مورد جستوجو را میتوان بهصورت span یا instanceای از SearchValues<T> در System.Buffers داد:
ReadOnlySpan<char> span = "The quick brown fox jumps over the lazy dog.";
var vowels = SearchValues.Create ("aeiou");
Console.WriteLine (span.IndexOfAny (vowels)); // 2
اگر SearchValues<T> در چند جستوجو reuse شود، performance بهتر میشود. همین متدها برای array و string نیز با فراخوانی AsSpan() قابل استفادهاند.
کار با متن
Spanها برای کار با string مناسب طراحی شدهاند و string بهصورت ReadOnlySpan<char> قابل مشاهده است. مثال زیر whitespaceها را میشمارد:
int CountWhitespace (ReadOnlySpan<char> s)
{
int count = 0;
foreach (char c in s)
if (char.IsWhiteSpace (c))
count++;
return count;
}
int x = CountWhitespace ("Word1 Word2"); // OK
int y = CountWhitespace (someString.AsSpan (20, 10));
ToString() یک ReadOnlySpan<char> را دوباره به string تبدیل میکند. Extension methodها برخی متدهای رایج string را نیز برای ReadOnlySpan<char> فراهم میکنند:
var span = "This ".AsSpan();
Console.WriteLine (span.StartsWith ("This")); // True
Console.WriteLine (span.Trim().Length); // 4
متدهایی مانند StartsWith روی span از ordinal comparison استفاده میکنند، در حالی که نسخههای متناظر در کلاس string بهطور پیشفرض culture-sensitive هستند. ToUpper و ToLower نیز موجودند اما باید destination span با طول درست به آنها بدهید تا خودتان محل و شیوهٔ allocation را کنترل کنید.
برخی متدهای string، از جمله Split، مستقیماً برای span قابل ساخت نیستند، زیرا نمیتوان arrayای از spanها ایجاد کرد: spanها ref struct هستند و خود struct تنها روی stack میتواند وجود داشته باشد؛ دادهای که wrap میکنند میتواند روی heap باشد.
فضای نام System.Buffers.Text typeهای بیشتری برای متن مبتنی بر span فراهم میکند: Utf8Formatter.TryFormat مشابه ToString برای typeهای ساده است اما در span مینویسد؛ Utf8Parser.TryParse عمل معکوس را انجام میدهد؛ و typeِ Base64 متدهای خواندن/نوشتن دادهٔ Base64 را ارائه میکند. از .NET 8، typeهای عددی و date/time نیز formatting و parsing مستقیم UTF-8 روی Span<byte> را پشتیبانی میکنند و interfaceهای IUtf8SpanFormattable و IUtf8SpanParsable<TSelf> را به کار میگیرند. متدهای بنیادی مانند int.Parse نیز overload پذیرندهٔ ReadOnlySpan<char> دارند.
Memory<T>
Span<T> و ReadOnlySpan<T> برای بیشینهکردن ظرفیت optimization و کار ایمن با stack-allocated memory بهصورت ref struct تعریف شدهاند؛ اما این انتخاب محدودیت دارد. نمیتوان آنها را field یک class قرار داد و در نتیجه نمیتوانند در lambda expressionهایی که capture میشوند، بهعنوان parameter متد async، iterator یا asynchronous stream باقی بمانند:
async void Foo (Span<int> notAllowed) // Compile-time error!
Compiler برای async method و iterator یک state machine خصوصی میسازد؛ در نتیجه parameterها و local variableها به field تبدیل میشوند. همین اتفاق برای variableهایی که lambda آنها را capture میکند در closure رخ میدهد.
Memory<T> و ReadOnlyMemory<T> این مشکل را حل میکنند. آنها spanهایی هستند که نمیتوانند stack-allocated memory را wrap کنند، و بنابراین در fieldها، lambdaها و async methodها قابل استفادهاند. از یک array با implicit conversion یا AsMemory() ساخته میشوند:
Memory<int> mem1 = new int[] { 1, 2, 3 };
var mem2 = new int[] { 1, 2, 3 }.AsMemory();
با propertyِ Span میتوان Memory<T> یا ReadOnlyMemory<T> را بدون کپیکردن به span متناظر تبدیل کرد:
async void Foo (Memory<int> memory)
{
Span<int> span = memory.Span;
...
}
همچنین خود Memory را با Slice یا range در C# برش دهید و طول آن را از Length بخوانید. راه دیگر بهدستآوردن Memory، اجارهکردن آن از pool با System.Buffers.MemoryPool<T> است؛ مشابه array pooling و راهی دیگر برای کاهش بار garbage collector.
برخلاف span، محدودیت ساخت array از ReadOnlyMemory<char> وجود ندارد؛ بنابراین نسخهای شبیه string.Split میتوان نوشت که بدون ساخت stringهای جدید sliceهای رشتهٔ اصلی را برگرداند:
IEnumerable<ReadOnlyMemory<char>> Split (ReadOnlyMemory<char> input)
{
int wordStart = 0;
for (int i = 0; i <= input.Length; i++)
if (i == input.Length || char.IsWhiteSpace (input.Span [i]))
{
yield return input [wordStart..i];
wordStart = i + 1;
}
}
foreach (var slice in Split ("The quick brown fox jumps over the lazy dog"))
{
// slice is a ReadOnlyMemory<char>
}
Enumeratorهای فقط رو به جلو (Forward-Only Enumerators)
اگر برای ساخت Split از ReadOnlyMemory<char> استفاده کنیم، توانایی slice کردن spanهای متکی بر unmanaged memory را از دست میدهیم. یک راه این است که متد Split آرایهای از Range برگرداند و caller با آن rangeها span اصلی را slice کند؛ اما ساخت List<Range>، افزودن itemها و تبدیل به array دستکم دو allocation و یک memory-copy ایجاد میکند.
راه allocation-free کنارگذاشتن list و array و استفاده از forward-only enumerator مبتنی بر struct است:
// We must define this as a ref struct, because _input is a ref struct.
public readonly ref struct CharSpanSplitter
{
readonly ReadOnlySpan<char> _input;
public CharSpanSplitter (ReadOnlySpan<char> input) => _input = input;
public Enumerator GetEnumerator() => new Enumerator (_input);
public ref struct Enumerator
{
readonly ReadOnlySpan<char> _input;
int _wordPos;
public ReadOnlySpan<char> Current { get; private set; }
public Enumerator (ReadOnlySpan<char> input)
{
_input = input;
_wordPos = 0;
Current = default;
}
public bool MoveNext()
{
for (int i = _wordPos; i <= _input.Length; i++)
if (i == _input.Length || char.IsWhiteSpace (_input [i]))
{
Current = _input [_wordPos..i];
_wordPos = i + 1;
return true;
}
return false;
}
}
}
public static class CharSpanExtensions
{
public static CharSpanSplitter Split (this ReadOnlySpan<char> input)
=> new CharSpanSplitter (input);
public static CharSpanSplitter Split (this Span<char> input)
=> new CharSpanSplitter (input);
}
با تعریف propertyِ Current و متد MoveNext، enumerator با foreach کار میکند و نیازی به پیادهسازی IEnumerable<T>/IEnumerator<T> ندارد؛ در واقع ref struct نمیتواند interface پیادهسازی کند. در اینجا abstraction را به سود micro-optimization قربانی میکنیم.
کار با Stack-Allocated و Unmanaged Memory
یک ریزبهینهسازی مؤثر دیگر، کاهش allocationهای heap و در نتیجه کاهش فشار روی garbage collector است؛ یعنی استفادهٔ بیشتر از stack memory یا حتی unmanaged memory. بدون span معمولاً مجبور میشوید کد را برای pointerها بازنویسی کنید:
unsafe int Sum (int* numbers, int length)
{
int total = 0;
for (int i = 0; i < length; i++) total += numbers [i];
return total;
}
int* numbers = stackalloc int [1000];
int total = Sum (numbers, 1000);
Span این نیاز را برطرف میکند؛ میتوان آن را مستقیماً از pointer ساخت یا با stackalloc در یک مرحله:
int* numbers = stackalloc int [1000];
var span = new Span<int> (numbers, 1000);
Span<int> numbers2 = stackalloc int [1000];
نسخهٔ قبلی Sum(ReadOnlySpan<int>) بدون تغییر با stack-allocated span نیز کار میکند. سه مزیت داریم: همان متد هم با array و هم stack memory کار میکند؛ استفاده از pointer حداقلی است؛ و span همچنان slice میشود.
Span همچنین میتواند unmanaged heap memory را wrap کند. مثال زیر با Marshal.AllocHGlobal حافظهٔ مدیریتنشده تخصیص میدهد، آن را در Span<char> میپیچد، string را در آن کپی میکند و سپس با CharSpanSplitter به واژهها تقسیم میکند:
var source = "The quick brown fox".AsSpan();
var ptr = Marshal.AllocHGlobal (source.Length * sizeof (char));
try
{
var unmanaged = new Span<char> ((char*)ptr, source.Length);
source.CopyTo (unmanaged);
foreach (var word in unmanaged.Split())
Console.WriteLine (word.ToString());
}
finally { Marshal.FreeHGlobal (ptr); }
Indexerِ Span<T> bounds checking انجام میدهد و جلوی buffer overrun را میگیرد، مشروط بر اینکه span را با طول درست بسازید. اگر طول را اشتباه و بزرگتر تعریف کنید، این حفاظت از بین میرود. همچنین در برابر معادل dangling pointer حفاظتی وجود ندارد؛ پس پس از آزادکردن unmanaged memory با Marshal.FreeHGlobal نباید به span دسترسی پیدا کنید.