ข้าม​ไป​ยัง​เนื้อหา

Span<T>: 0 byte ระหว่าง​ทาง และ​ราคา​ที่​ต้อง​จ่าย

บท 3 ปิด​เรื่อง boxing ไป​แล้ว บท​นี้​กลับ​มา​ที่​งาน​ที่ hot path ของ domain Order ทำ​บ่อย​ที่สุด คือ​การ​ตัด field ออก​จาก​บรรทัด​ออร์เดอร์ และ​เครื่องมือ​ที่​จะ​ใช้​คือ ReadOnlySpan<char>

อ่านออร์เดอร์​หก​บรรทัด​หนึ่ง​รอบ​ด้วย string.Split วัด​ได้ 1,136 byte อ่าน​ชุด​เดิม​ด้วย ReadOnlySpan<char> วัด​ได้ 0 byte และ​ทั้ง​สอง​ทาง​คำนวณ​ผลออก​มา​ได้​ค่า​เดียวกันเป๊ะ

ตัวเลข​คู่​นั้น​จริง แต่​มัน​เป็น​แค่​ครึ่ง​เดียว​ของ​เรื่อง อีก​ครึ่ง​คือ Span<T> ซื้อ0 byte ให้​ได้​เฉพาะ ระหว่าง​ทาง เท่านั้น พอ​ต้อง​เก็บ​ผลลัพธ์​ไว้​ใช้​ต่อ การ​จอง​กลับ​มา​ทันที และ​ราคา​ที่​จ่าย​เพิ่ม​คือ​ขีด​จำกัด​แข็ง​ที่ compiler บังคับ ไม่ใช่​คำ​แนะนำ​ที่​ฝ่าฝืน​ได้

📦 kaen-food-ordering

บท​นี้​เดิน​บน hot path เส้น​เดิม​ของ domain Order ที่​คอร์ส #8–#11 สร้าง​ไว้ คือ parse บรรทัด​ออร์เดอร์ → รวม​ยอด → พิมพ์​ใบเสร็จ (repo ตัวอย่าง .NET กำลัง​จัด​ทำ) ทุก snippet รัน​ได้​จริง​บน .NET SDK 10 และ​ทุก​ตัวเลข​ที่​พิมพ์​คือ​ค่าที่​วัด​ได้​จริง​บน​เครื่อง Intel Core i7-7700 3.60GHz (Kaby Lake) · 8 logical / 4 physical · Ubuntu 24.04.4 LTS (WSL2) · .NET 10.0.10 linux-x64

ตาม​เส้น​ขอบเขต​ที่​บท 1 ประกาศ​ไว้ ทุก​ตัวเลข​ใน​บท​นี้​เป็น จำนวน byte ล้วน ไม่มี​ตัวเลข​เวลา​สัก​ตัว เหตุผล​อยู่​ใน​หัวข้อ “ขีด​จำกัด​ที่​ต้อง​เขียนตรงๆ” ท้าย​บท

ไวยากรณ์​และ API ยุค C# 8–14 ที่​ใช้​ใน​บท​นี้ (collection expression = [], range/index buf[..n] กับ buf[^1], GetAlternateLookup ของ .NET 9) คอร์ส​นี้ ใช้ โดย​ไม่​หยุด​อธิบาย ถ้า​เจอ​ของ​ที่​ไม่​คุ้น​ให้​เปิด C# 8 → 14: อะไร​เปลี่ยน​ไป​บ้าง ควบคู่​ไป

การ​อ่านออร์เดอร์​หก​บรรทัด​สาม​วิธี ซึ่ง​ให้ checksum เท่า​กัน​ทั้ง​สาม​ทาง แต่ column byte ห่าง​กัน​ตั้งแต่ 1,136 ถึง 0

input คงที่​ทั้ง​หัวข้อ​นี้ คือ​ออร์เดอร์​หก​บรรทัด

string[] lines =
[
"1042,ผัดไทยกุ้งสด,2,18000", "1043,ต้มยำกุ้ง,1,22000", "1044,ข้าวมันไก่,3,13500",
"1045,ส้มตำไทย,1,8000", "1046,แกงเขียวหวานไก่,2,17000", "1047,ชาไทยเย็น,4,9000",
];
static long WorkSplit(string[] src)
{
long sum = 0;
foreach (string line in src)
{
string[] p = line.Split(',');
sum += int.Parse(p[0], CultureInfo.InvariantCulture);
sum += p[1].Length;
sum += int.Parse(p[2], CultureInfo.InvariantCulture)
* int.Parse(p[3], CultureInfo.InvariantCulture);
}
return sum;
}
static long WorkSpan(string[] src)
{
long sum = 0;
foreach (string line in src)
{
ReadOnlySpan<char> s = line;
int c1 = s.IndexOf(',');
int c2 = s.Slice(c1 + 1).IndexOf(',') + c1 + 1;
int c3 = s.Slice(c2 + 1).IndexOf(',') + c2 + 1;
sum += int.Parse(s.Slice(0, c1), CultureInfo.InvariantCulture);
sum += s.Slice(c1 + 1, c2 - c1 - 1).Length;
sum += int.Parse(s.Slice(c2 + 1, c3 - c2 - 1), CultureInfo.InvariantCulture)
* int.Parse(s.Slice(c3 + 1, s.Length - c3 - 1), CultureInfo.InvariantCulture);
}
return sum;
}
วิธี​ตัด fieldinput ที่​วัดAllocated ต่อ​การ​อ่าน​หนึ่ง​รอบ
line.Split(',')ออร์เดอร์ 6 บรรทัด​ข้าง​บน1,136 B
line.Substring(start, len)ออร์เดอร์ 6 บรรทัด​ข้าง​บน800 B
line.AsSpan().Slice(start, len)ออร์เดอร์ 6 บรรทัด​ข้าง​บน0 B

checksum ของ​ทั้ง​สาม​ทาง​อ่าน​ได้ 182830 เท่า​กัน นี่​คือ​เงื่อนไข​ที่​ทำให้​ตาราง​นี้​เป็นการ​เทียบ ไม่ใช่​การ​เทียบ code ที่​ทำงาน​คนละ​อย่าง

แถว​กลาง​คือ​คำ​ตอบ​ยอด​นิยม​ที่​ยัง​ไป​ไม่​ถึง​ไหน การ​เลิก​ใช้ Split แล้ว​หัน​ไป Substring ประหยัด​ได้ 336 B คือ 29.6% ซึ่ง​เป็น array string[4] 56 B × 6 บรรทัด​เท่านั้น ส่วน string ลูก​อีก 800 B ยัง​ถูก​จอง​ครบ​ทุก​ก้อน ว่า​ก้อน​พวก​นั้น​คือ​อะไร​บ้าง กล่อง​ถัด​ไป​แยก​ให้​ดู​ที​ละ​ก้อน

เดลิ​มิเตอร์​ไม่มี​ผล เปลี่ยน​จาก comma เป็น pipe แล้ว​วัด​ใหม่​ได้ 1,136 B เท่า​กัน​เป๊ะ และ​เมื่อ​ขยาย input เป็น 6,000 บรรทัด​ชุด​เดียวกัน Split วัด​ได้ 1,136,000 B พอดี ส่วน Span ยัง​อ่าน​ได้ 0 B ยืนยัน​ซ้ำ​แบบ​อิสระ​ด้วย BenchmarkDotNet MemoryDiagnoser บน input 6,000 บรรทัด​เดียวกัน ได้ 1,136,000 B / 800,001 B / –

Split ไม่​ได้​จอง​ก้อน​เดียว แต่​จอง​ห้า​ก้อน​ต่อ​หนึ่ง​บรรทัด

คน​ส่วน​ใหญ่​จำ​ได้​ว่า Split “จอง array” ตัว​วัด​แยก​ที​ละ​ก้อน​บอกว่า​มัน​จอง array หนึ่ง​ก้อน บวก string ลูก​อีก​สี่​ก้อน และ​ตัว​วัด​บังคับ​ให้​ผล​รวม​ของ​ก้อน​ย่อย​เท่ากับ​ที่​วัด​ได้​ทั้ง​บรรทัด​เป๊ะ ไม่​งั้น​มัน throw

input ที่​วัดก้อน​ที่​เกิดbyte
"1042,ผัดไทยกุ้งสด,2,18000".Split(',')ทั้ง​บรรทัด​รวม​กัน192 B
new string[4]array ที่​คืน​ออก​มา56 B
"1042" (4 ตัว​อักษร)string ลูก​ตัว​ที่ 132 B
"ผัดไทยกุ้งสด" (12 ตัว​อักษร)string ลูก​ตัว​ที่ 248 B
"2" (1 ตัว​อักษร)string ลูก​ตัว​ที่ 324 B
"18000" (5 ตัว​อักษร)string ลูก​ตัว​ที่ 432 B

ขนาด​ของ​ก้อน string ไม่ใช่​ความ​เชื่อ ตัว​วัด​ตรวจ​สูตร หัว 22 B + 2 B ต่อ​ตัว​อักษร ปัด​ขึ้น​เป็น​ทวีคูณ​ของ 8 กับ​ความ​ยาว 1 ถึง 24 ที​ละ​ค่า​ด้วย​การ​วัด​จริง ถ้า​ไม่​ตรง​สัก​ค่า​เดียว​มัน​พัง​ทันที (ตัวเลข​ชุด​นี้​ผูก​กับ x64 ที่ IntPtr = 8)

ผล​ที่​ตาม​มา​คือ ขนาด​ของ​ก้อน​ขึ้น​กับ​ข้อมูล ไม่​ได้​ขึ้น​กับ code บรรทัด​ที่​ชื่อ​เมนู​ยาว​กว่า​จอง​มากกว่า วัด​ที​ละ​บรรทัด​ได้ 192 / 184 / 192 / 184 / 200 / 184 = 1,136 B

เทียบเลนหน่วยความจำ

6 / 6 ออเดอร์.NET 10.0.10 · X64

บทที่ 4 · อ่านบรรทัด CSV ของออเดอร์แล้วดึง id / จำนวน / ราคา ออกมา — จะตัด field ด้วยอะไร

6 / 6 ออเดอร์

แกนของกล่องนี้คือ จำนวนออเดอร์ที่ประมวลผลไปแล้ว ไม่ใช่เวลา — จังหวะของปุ่ม “เดินอัตโนมัติ” เป็นค่าคงที่ที่ตั้งไว้เพื่อให้ดูทัน ไม่ได้แทนความเร็วจริงของ code และหน้านี้ไม่แสดงเวลาที่ไหนเลย

ออเดอร์ล่าสุดที่ป้อนเข้าไป: 1047,ชาไทยเย็น,4,9000

code เดิม string.Split

คืนอาร์เรย์ใหม่ พร้อม string ใหม่ทุก field

code ที่ถูกวัด

public static long ParseWithSplit(string line)
{
    string[] parts = line.Split(',');
    int id = int.Parse(parts[0], CultureInfo.InvariantCulture);
    int qty = int.Parse(parts[2], CultureInfo.InvariantCulture);
    int satang = int.Parse(parts[3], CultureInfo.InvariantCulture);
    return id + (long)qty * satang + parts[1].Length;
}

1,136byte ที่จองบน heap

30 ก้อน · อาร์เรย์ 6 · string 24 · ออเดอร์ล่าสุดเพิ่ม +184 B

heap หลังผ่านไป 6 ออเดอร์

code ใหม่ ReadOnlySpan<char>

เฉือนหน้าต่างมองบน string เดิม ไม่คัดลอกอะไรเลย

code ที่ถูกวัด

public static long ParseWithSpan(string line)
{
    ReadOnlySpan<char> s = line;
    int c1 = s.IndexOf(',');
    int id = int.Parse(s[..c1], provider: CultureInfo.InvariantCulture);
    ReadOnlySpan<char> rest = s[(c1 + 1)..];
    int c2 = rest.IndexOf(',');
    ReadOnlySpan<char> menu = rest[..c2];
    rest = rest[(c2 + 1)..];
    int c3 = rest.IndexOf(',');
    int qty = int.Parse(rest[..c3], provider: CultureInfo.InvariantCulture);
    int satang = int.Parse(rest[(c3 + 1)..], provider: CultureInfo.InvariantCulture);
    return id + (long)qty * satang + menu.Length;
}

0byte ที่จองบน heap

ไม่มีก้อนไหนถูกจองเลย · ออเดอร์ล่าสุดเพิ่ม 0 B

heap หลังผ่านไป 6 ออเดอร์

heap ว่าง — 0 ก้อน · 0 byte

ก้อนพวกนี้ ตายเกือบทันที หลังถูกจอง ราคาที่จ่ายจึงเป็น GC pressure กับ cache traffic ไม่ใช่ RAM ที่ถูกยึดค้างไว้ — กล่องนี้วัด allocation ไม่ใช่ residency

หนึ่งช่องบนตาราง heap = 8 byte (ความละเอียดจริงของ heap บน X64) · ▦ อาร์เรย์ · ▨ string · ▩ กล่อง

Split ไม่ได้จองแค่อาร์เรย์ มันจอง string ใหม่ทุก field ด้วย — ห้าก้อนต่อหนึ่งบรรทัด ทั้งที่ code ใช้จริงแค่สาม field และใช้แค่ชั่วครู่ ส่วนฝั่ง Span อ่านอักขระชุดเดียวกันจาก string เดิมโดยไม่คัดลอก จึงไม่มีก้อนไหนเกิดขึ้นเลย · สังเกตว่าขนาดของก้อนขึ้นกับ *ข้อมูล* ไม่ใช่ขึ้นกับ code ชื่อเมนูที่ยาวกว่าทำให้ออเดอร์นั้นจองมากกว่า

ราคาที่จ่าย Span ผูกอายุกับ string ต้นทาง เก็บไว้ใน field ของ class ไม่ได้ ส่งข้าม method async ไม่ได้ — ราคาคือความอิสระในการถือข้อมูล ไม่ใช่ความอ่านง่ายอย่างเดียว

ตัวเลขที่วัดได้ทั้งหมดของฉากนี้ (byte)

byte ที่จองต่อออเดอร์ ของ string.Split เทียบกับ ReadOnlySpan<char>
ออเดอร์string.SplitReadOnlySpan<char>ก้อนที่เกิดขึ้น
ผัดไทยกุ้งสด1920▦ string[4] 56 B · ▨ "1042" 32 B · ▨ "ผัดไทยกุ้งสด" 48 B · ▨ "2" 24 B · ▨ "18000" 32 B
ต้มยำกุ้ง1840▦ string[4] 56 B · ▨ "1043" 32 B · ▨ "ต้มยำกุ้ง" 40 B · ▨ "1" 24 B · ▨ "22000" 32 B
ข้าวมันไก่1920▦ string[4] 56 B · ▨ "1044" 32 B · ▨ "ข้าวมันไก่" 48 B · ▨ "3" 24 B · ▨ "13500" 32 B
ส้มตำไทย1840▦ string[4] 56 B · ▨ "1045" 32 B · ▨ "ส้มตำไทย" 40 B · ▨ "1" 24 B · ▨ "8000" 32 B
แกงเขียวหวานไก่2000▦ string[4] 56 B · ▨ "1046" 32 B · ▨ "แกงเขียวหวานไก่" 56 B · ▨ "2" 24 B · ▨ "17000" 32 B
ชาไทยเย็น1840▦ string[4] 56 B · ▨ "1047" 32 B · ▨ "ชาไทยเย็น" 40 B · ▨ "4" 24 B · ▨ "9000" 32 B
รวมหกออเดอร์1,1360ผลลัพธ์ที่คำนวณได้เท่ากันทั้งสองเลน (182,830)

วนซ้ำหลายรอบแล้ววัดใหม่ทั้งหมด — ไม่ได้เอาเลขข้างบนไปคูณ

จำนวนรอบ1101001,000
string.Split1,13611,360113,6001,136,000
ReadOnlySpan<char>0000
ประมวลผลแล้ว 6 จาก 6 ออเดอร์ · string.Split จอง 1136 byte · ReadOnlySpan<char> จอง 0 byte
ทุกตัวเลขในกล่องนี้คือผลของ GC.GetAllocatedBytesForCurrentThread() บน .NET 10.0.10 (X64) — ขนาดของแต่ละก้อนวัดจากการสร้าง object ชนิดเดียวกันจริง ๆ แล้ว script จะล้มถ้าผลรวมของก้อนไม่เท่ากับเดลตาที่วัดได้ ถ้าการจองไม่เป็นเส้นตรงตามจำนวน ออเดอร์ หรือถ้า code สอง version คำนวณผลออกมาไม่เท่ากัน · ไม่มีตัวเลขเวลาในกล่องนี้เลย เพราะจำนวน byte ที่จองซ้ำได้เป๊ะทุกรอบ แต่เวลาบนนาฬิกาไม่ซ้ำ · สร้างซ้ำได้ด้วย dotnet run -c Release --project scripts/alloc-lanes/AllocViz.csproj

กล่อง​ข้าง​บน​เดิน​ตัวเลข​ชุด​เดียวกัน​นี้​ที​ละ​ออร์เดอร์ แกน​นอน​คือ​จำนวนออร์เดอร์​ที่​ประมวล​ผล​ไป​แล้ว ไม่ใช่​เวลา และ​ปุ่ม​เดิน​อัตโนมัติ​เป็น​จังหวะ​ที่​ตั้ง​ไว้​เพื่อ​การนำ​เสนอ ไม่​ได้​แทน​ความเร็ว​ของ code ถ้า​เลื่อน​ดู​ฉาก​อื่น​ใน​ชิป​ด้าน​บน​จะ​เจอ​ฉาก​ของ​บท​อื่น​ด้วย — pattern เดียวกัน​นี้ generalise ข้าม​บท​ได้ ไม่​ได้​จำกัด​อยู่​แค่​การ​ตัด string

การ​เทียบ​ที่​อยู่​หน่วย​ความ​จำ​ของ slice กับ​ของ buffer ต้นทาง​ที​ละ field ซึ่ง​ตอบ​ว่า 0 B ที่​เห็น​ข้าง​บน​มา​จาก​ไหน

SpanSpanมุมมอง​แบบ​ต่อ​เนื่อง​ที่​ชี้​กลับ​เข้าไป​ใน buffer เดิม​โดย​ไม่​คัด​ลอก​ข้อมูล เป็น ref struct จึง​มี​ชีวิต​ข้าม​เส้น​แบ่ง​บาง​เส้น​ไม่​ได้ คือ​มุมมอง​ต่อ​เนื่อง​ที่​ชี้​กลับ​เข้าไป​ใน buffer เดิม ประโยค​นี้​ตรวจสอบ​ได้​โดย​ไม่​ต้อง​เชื่อ เพราะ MemoryMarshal.GetReference คืน reference ไป​ยัง​ตัว​อักษร​ตัว​แรกจริงๆ และ Unsafe.AreSame กับ Unsafe.ByteOffset เทียบ​ที่อยู่ให้ได้ตรงๆ

const string Line = "ORD-10482,742,Confirmed,145000,2024-06-15T18:32:07";
ReadOnlySpan<char> whole = Line.AsSpan();
ReadOnlySpan<char> slice = whole.Slice(start, length);
ref char w = ref MemoryMarshal.GetReference(whole);
ref char s = ref MemoryMarshal.GetReference(slice);
nint delta = Unsafe.ByteOffset(ref w, ref s);
bool same = Unsafe.AreSame(ref s, ref Unsafe.Add(ref w, start));
// เทียบกับ Substring ที่ได้คนละก้อน
string copy = Line.Substring(start, length);
bool copyIsSeparate = !Unsafe.AreSame(
ref MemoryMarshal.GetReference(Line.AsSpan(start, length)),
ref MemoryMarshal.GetReference(copy.AsSpan()));
fieldstartlencopyBytes ของ SubstringspanOffsetBytessameBuffer
รหัส​ออร์เดอร์ ORD-104820940 B0True
รหัส​ลูกค้า 74210332 B20True
สถานะ Confirmed14940 B28True
ยอด​รวม​สตางค์ 14500024640 B48True
เวลา​ที่​สั่ง 2024-06-15T18:32:07311964 B62True

อ่าน2 column ขวา​คู่​กับ column start จะ​เห็น​ความ​สัมพันธ์​เดียว​ที่​ต้อง​จำ spanOffsetBytes = start × 2 byte ต่อ char เป๊ะ​ทุก​แถว เพราะ char บน .NET กว้าง 2 byte และ sameBuffer อ่าน​ได้ True ทั้ง5 field แปล​ว่า​ตัว​อักษร​ตัว​แรก​ของ slice คือ ตัว​อักษร​ตัว​นั้น​ใน buffer เดิม ไม่ใช่​สำเนา​ที่​หน้าตา​เหมือน​กัน

ฝั่ง Substring ให้​คำ​ตอบ​ตรง​ข้าม copyIsSeparate อ่าน​ได้ True คือ​ได้ object คนละ​ก้อน​จริง และ string ลูก​ทั้ง​ห้า​ตัว​ของ​บรรทัด​นี้​รวม​กัน​วัด​ได้ 216 B

บน​บรรทัด​นี้ (ยาว 50 ตัว​อักษร 5 field) วัด​ต่อ​หนึ่ง​บรรทัด​ได้ Split 280 B · Substring 216 B · Span 0 B โดย array ที่ Split จอง​เพิ่ม​คือ 64 B

ตัวเลข byte อ่าน​ไม่​ได้​ถ้า​ไม่รู้​ว่า input คือ​บรรทัด​ไหน

บรรทัด​ของ​หัวข้อ​นี้​ให้ Split = 280 B ต่อ​บรรทัด ส่วน​บรรทัด 1042,ผัดไทยกุ้งสด,2,18000 ของ​หัวข้อ​ที่​แล้ว​ให้ 192 B ต่อ​บรรทัด ทั้ง​สอง​ค่า​ถูกต้อง​ทั้ง​คู่ เพราะ input คนละ​บรรทัด​กัน field คนละ​จำนวน ความ​ยาว​คนละ​ค่า

นี่​คือ​เหตุผล​ที่​ทุก​ตาราง​ใน​คอร์ส​นี้​มี column input กำกับ ตัวเลข byte ที่​ลอย​มา​โดย​ไม่​บอกว่า​วัด​จาก​อะไร คือ​ตัวเลข​ที่ reproduce ไม่​ได้

flowchart TD
    A["บรรทัดออร์เดอร์หนึ่งก้อนบน heap · 50 ตัวอักษร"] --> B{"จะหยิบ field ออกมาอย่างไร"}
    B -- "Substring" --> C["จองก้อนใหม่ แล้วคัดลอกตัวอักษรลงไป"]
    C --> D["5 field รวม 216 byte · copyIsSeparate=True"]
    B -- "AsSpan แล้ว Slice" --> E["จำแค่ที่อยู่เริ่มต้นกับความยาว ไม่คัดลอกอะไร"]
    E --> F["spanOffsetBytes = start × 2 · sameBuffer=True · 0 byte"]
    F --> G["ราคาคือ slice ผูกอายุกับ buffer ต้นทาง และ compiler เป็นคนบังคับ"]
    D --> H["ราคาคือ byte ที่จ่ายทุกครั้งที่หยิบ field"]

คำ​บรรยาย​ภาพ: เส้นทาง​สอง​ทาง​ของ​การ​หยิบ field เดียวกัน​ออก​จาก​บรรทัด​ออร์เดอร์ · ฝั่ง Substring จอง​ก้อน​ใหม่​แล้ว​คัด​ลอก จึง​ได้ object คนละ​ก้อน​กับ buffer เดิม ส่วน​ฝั่ง Slice บันทึก​แค่​ตำแหน่ง​เริ่มต้น​กับ​ความ​ยาว จึง​ชี้​กลับ​เข้าไป​ใน buffer เดิม​และ​ไม่มี​ก้อน​ใหม่​เกิด​ขึ้น · ราคา​ของ​ฝั่ง​ขวา​ไม่​ได้​จ่าย​เป็น byte แต่​จ่าย​เป็น​ข้อ​จำกัด​ว่า slice นั้น​มี​ชีวิต​อยู่​ได้​แค่​ไหน

การ​วัด​ที่​ปิด​คำ​แนะนำ​ยอด​นิยม​ว่า “เปลี่ยน​ไป​เรียก overload ที่​รับ span แล้ว​จะ​ประหยัด” ซึ่ง​วัด​แล้ว​ประหยัด​ได้​ศูนย์

คำ​แนะนำ​ที่​เจอ​บ่อย​ที่สุด​เวลา​พูด​ถึง Span<T> คือ​ให้​ไล่​เปลี่ยน int.Parse(string) เป็น overload ที่​รับ ReadOnlySpan<char> วัด​แล้ว​คำ​แนะนำ​นี้​ไม่​ได้​ช่วย​อะไร​เลย เพราะ​ทาง​เดิม​ก็​ไม่​ได้​จอง​อยู่​แล้ว

string numStr = "18000";
long a = PerOp(1000, () => { Sink.Num += int.Parse(numStr, CultureInfo.InvariantCulture); });
long b = PerOp(1000, () => { Sink.Num += int.Parse(numStr.AsSpan(), CultureInfo.InvariantCulture); });
long c = PerOp(1000, () =>
{
if (int.TryParse(numStr.AsSpan(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int v))
Sink.Num += v;
});
string line0 = "1042,ผัดไทยกุ้งสด,2,18000";
long cut = PerOp(1000, () => { Sink.Num += int.Parse(line0.Substring(line0.LastIndexOf(',') + 1), CultureInfo.InvariantCulture); });
long slice = PerOp(1000, () => { Sink.Num += int.Parse(line0.AsSpan(line0.LastIndexOf(',') + 1), CultureInfo.InvariantCulture); });
input ที่​วัดAllocated ต่อ​การ​เรียก​หนึ่ง​ครั้ง
int.Parse("18000", CultureInfo.InvariantCulture)0 B
int.Parse("18000".AsSpan(), CultureInfo.InvariantCulture)0 B
int.TryParse("18000".AsSpan(), …)0 B
int.Parse(line0.Substring(line0.LastIndexOf(',') + 1), …)32 B
int.Parse(line0.AsSpan(line0.LastIndexOf(',') + 1), …)0 B
Encoding.UTF8.GetString(utf8) แล้ว parse (เคส​เดียวกัน​ใน​รูป UTF-8)72 B
int.TryParse(ReadOnlySpan<byte>) ตรงๆ (เคส​เดียวกัน​ใน​รูป UTF-8)0 B

โดย line0 คือ 1042,ผัดไทยกุ้งสด,2,18000 ส่วน​สอง​แถว​สุดท้าย​คือ​เคส​เดียวกัน​ที่​ข้อมูล​เข้า​มา​เป็น byte UTF-8 แทนที่​จะ​เป็น string

สาม​แถว​แรก​อ่าน​ได้ 0 B เท่า​กัน​หมด ความ​ต่าง​จริง​ทั้งหมด​อยู่​ที่​แถว​ที่​สี่​กับ​ห้า คือ Substring แล้ว​ค่อย parse เสีย 32 B ส่วน AsSpan แล้ว parse เสีย 0 B ก้อน 32 B นั้น​คือ string กลาง​ที่ Substring สร้าง​ขึ้น ไม่ใช่​ต้นทุน​ของ Parse

กฎ​ที่​ถูกต้อง​คือ “อย่า​สร้าง string กลาง” ไม่ใช่ “เปลี่ยน overload” และ​กฎ​เดียวกัน​นี้​ใช้ได้​กับ​ฝั่ง UTF-8 ด้วย การ​แปลง byte เป็น string ก่อน parse เสีย 72 B ส่วน​การ​ยิง int.TryParse ลงบน ReadOnlySpan<byte> ตรงๆ เสีย 0 B

ทำไม​การ​ไล่​เปลี่ยน overload ถึง​อันตราย​กว่า​การ​ไม่​ทำ​อะไร

การ​ไล่​แก้​ทั้ง codebase ให้​เรียก overload ที่​รับ span เป็น diff ที่​ใหญ่ มี​ความ​เสี่ยง​เรื่อง regression และ​วัด​แล้ว​ได้​กำไร 0 B ตราบ​ใด​ที่​ยัง​มี Substring อยู่​ก่อนหน้า

สิ่ง​ที่​ควร​ทำ​แทน​คือ​ไล่​หา จุด​ที่​สร้าง string กลาง แล้ว​ตัด​จุด​นั้น​ทิ้ง ซึ่ง​มัก​เป็นการ​แก้​ไม่​กี่​บรรทัด​ต่อ1 hot path

การ​เทียบ buffer ชั่วคราว​บน stack กับ​บน heap และ​หลักฐาน​ที่​อยู่​หน่วย​ความ​จำ​ว่า​พื้นที่​ใน loop ไม่​เคย​ถูก​ใช้​ซ้ำ

stackallocstackallocการ​จอง​พื้นที่​ต่อ​เนื่อง​บน stack ของ method ซึ่ง​คืนให้​เมื่อ *method จบ* ไม่ใช่​เมื่อ​จบ​รอบ loop จอง​พื้นที่​ต่อ​เนื่อง​บน stack ของ method ซึ่ง​ได้0 byte บน heap จริง

static long ScratchStack(string line)
{
Span<char> buf = stackalloc char[32];
int n = 0;
foreach (char c in line)
if (c != ',' && n < buf.Length) buf[n++] = char.ToUpperInvariant(c);
return buf[..n].Length;
}
static long ScratchHeap(string line)
{
char[] buf = new char[32];
int n = 0;
foreach (char c in line)
if (c != ',' && n < buf.Length) buf[n++] = char.ToUpperInvariant(c);
return n;
}
input ที่​วัดAllocated บน heap
stackalloc char[32] (ป้อน​บรรทัด 1042,ผัดไทยกุ้งสด,2,18000)0 B
new char[32] (ป้อน​บรรทัด​เดียวกัน)88 B

จนถึง​บรรทัด​นี้ stackalloc ดูเหมือน​ของ​ฟรี ประโยค​ที่​ต้อง​อ่าน​ให้​ครบ​คือ พื้นที่​นั้น​คืนให้​เมื่อ method จบ ไม่ใช่​เมื่อ​จบ​รอบ loop

unsafe
{
var addrs = new List<long>();
for (int i = 0; i < 5; i++)
{
Span<char> b = stackalloc char[16];
b[0] = (char)i;
fixed (char* p = b) addrs.Add((long)p);
}
// addrs.Distinct().Count() == 5, ผลต่างระหว่างรอบ = −32 byte คงที่
}

loop ห้า​รอบ​ให้​ที่​อยู่ ต่าง​กัน​ครบ​ห้า​ค่า และ​ผล​ต่าง​ระหว่าง​รอบ​คงที่​ที่ −32 byte ซึ่ง​เท่ากับ 16 chars × 2 B พอดี นั่น​คือ​หลักฐาน​ว่า​รอบ​ถัด​ไป​ไม่​ได้​ใช้​ช่อง​เดิม​ซ้ำ แต่​เลื่อน​ลง​ไป​กิน​พื้นที่​ใหม่​ทุกรอบ

ค่าที่​อยู่​สัมบูรณ์​ใน​การ​ทดลอง​นี้​ไม่ใช่​ผล​การ​วัด​ที่​อ้าง​ได้ เพราะ ASLR ทำให้​มัน​เปลี่ยน​ทุก process สิ่ง​ที่​คงที่​ทุก​ครั้ง​มี​สอง​อย่าง​เท่านั้น คือ​จำนวน​ค่าที่​ต่าง​กัน (5) กับ​ผล​ต่าง​ต่อ​รอบ (−32 B) บท​นี้​จึง​ไม่​พิมพ์​เลข​ที่​อยู่​ลง​มา​ให้​จำ

ข่าวดี​คือ compiler เตือน​ให้​แล้ว​โดย​ไม่​ต้อง​ตั้ง​ค่า​อะไร​เลย .NET 10 ยิง CA2014 “Potential stack overflow. Move the stackalloc out of the loop.” เป็น warning by default และ warning หาย​ทันที​เมื่อ​ยก stackalloc ออก​ไป​นอก loop โดย​ยัง​วัด​ได้ 0 B เท่า​เดิม

เมทริกซ์ exit code ของ process ลูก​ที่​ตาย​จริง ซึ่ง​ตอบ​สอง​คำถาม​พร้อม​กัน คือ try/catch ช่วย​ไหม และ​เพดาน​อยู่​ตรง​ไหน

// รันเป็น process ลูกเพื่อให้ตายได้จริงและอ่าน exit code
try
{
Span<char> buf = stackalloc char[n]; // n มาจาก args — นี่คือสิ่งที่ห้ามทำ
buf[0] = 'x';
buf[^1] = 'y';
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"CAUGHT {ex.GetType().Name}");
return 3; // ← ไม่เคยถึงบรรทัดนี้
}
input ที่​วัดผล
stackalloc char[1000000] (2 MB)exit=0 รอด
stackalloc char[4171875] (8,148 KB)exit=0 รอด — ค่า​สูงสุด​ที่​รอด
stackalloc char[4185546] (8,174 KB)exit=134
stackalloc char[8000000] (16 MB)“Stack overflow.” exit=134 (SIGABRT)
loop 260,000 รอบ ก้อน​ละ stackalloc char[16] (8.32 MB)exit=0 รอด
loop 300,000 รอบ ก้อน​ละ stackalloc char[16] (9.6 MB)exit=134

เพดาน​ของ thread หลัก​บน​เครื่อง​นี้​อ่าน​จาก ulimit -s ได้ 8192 KB และ​ไบนารี​เสิร์ช​หา​ขอบ​ได้ที่ 8,148 KB ซึ่ง​อยู่​ใต้​ค่า​นั้น​ลง​มา​เล็กน้อย

⛔ exit=134 ทุก​ครั้ง และ​ไม่​เคย​ได้ exit=3 เลย​สัก​ครั้ง

block catch (Exception ex) ใน code ข้าง​บน​คืน 3 ถ้า​มัน​ทำงาน เมทริกซ์​ทั้ง​ชุด​รัน​ซ้ำ​สอง​รอบ​เต็ม ไม่​เคย​ได้ exit=3 เลย​แม้แต่​ครั้ง​เดียว ทุก​เคส​ที่​ตาย​ได้ exit=134 ซึ่ง​คือ SIGABRT

แปล​ว่า StackOverflowException ไม่ใช่ exception ที่ code ของ​คุณ​จับ​ได้ process ถูก​ฆ่า​ทิ้ง​ทั้ง​ตัว ไม่มี finally ไม่มี log ไม่มี​การ​ปิด connection

ผล​ที่​ตาม​มา​โดยตรง​คือ ห้าม stackalloc ด้วย​ความ​ยาว​ที่​ผู้​อื่น​กำหนด ไม่​ว่า​จะ​มา​จาก HTTP request, file ที่​อัปโหลด หรือ config เพราะ​ไม่มี​วิธี​ป้องกัน​ตัวเอง​หลัง​จาก​นั้น

อีก​ด้าน​ของ​เมทริกซ์​เดียวกัน​ก็​ควร​พูดตรงๆ เหมือน​กัน คือ กฎ 1 KB ที่​เล่า​ต่อ​กัน​มา​ไม่ใช่​เพดาน​จริง stackalloc char[1000000] ซึ่ง​กิน 2 MB รอด​สบาย และ​เพดาน​ที่​วัด​ได้​จริง​บน​เครื่อง​นี้​อยู่​ที่ 8,148 KB ซึ่ง​เป็น​คนละ​ระดับ​กับ​ตัวเลข​ที่​ท่อง​กัน​มา

ที่​ยัง​ต้อง​ระวัง​คือ​กับดัก​ที่แท้​จริง​ไม่​ได้​อยู่​ที่​ก้อน​ใหญ่ แต่​อยู่​ที่​ก้อน​เล็ก​ใน loop ก้อน​ละ 32 byte ผ่าน​การ​ทดสอบ​กับ​หก​บรรทัด​สบาย​มาก แล้วไป​ระเบิด​ตอน​เจอ file จริง​ที่​มี​สาม​แสน​บรรทัด — และ​ตัวเลข 8,148 KB นี้​เป็น​ค่า​ของ thread หลัก​ที่ ulimit -s = 8192 KB เท่านั้น thread ที่​สร้าง​เอง​หรือ thread pool มี​ขนาด stack ต่าง​กัน สำนวน​ที่​เอกสาร C# ใช้​จึง​เป็น MaxStackLimit ไม่ใช่​ตัวเลข​คงที่

รหัส error จริง​จาก compiler 16 เคส ซึ่ง​แยก​ให้​เห็น​ว่า​อะไร​ห้าม​จริง อะไร​ที่​คน​จำ​ผิด และ​ทางออก​อยู่​ตรง​ไหน

Span<T> เป็น ref structref structชนิด​ที่ compiler บังคับ​ให้​มี​ชีวิต​อยู่​บน stack เท่านั้น จึง​เป็น field ของ class ไม่​ได้​และ​ข้าม​เส้น​แบ่ง​บาง​เส้น​ไม่​ได้ compiler จึง​บังคับ​ให้​มัน​มี​ชีวิต​อยู่​บน stack เท่านั้น ข้อ​จำกัด​ทั้ง​ชุด​ไม่ใช่​คำ​แนะนำ มัน​เป็น error ที่ compile ไม่​ผ่าน ตาราง​ข้าง​ล่าง compile ที​ละ​เคส​จริง​แล้ว​อ่าน​รหัส​ที่​ได้

เคสinput ที่​วัด (code ที่​ป้อน compiler)ผล
L1class C { ReadOnlySpan<char> _menu; }CS8345
L2struct C { ReadOnlySpan<char> _menu; }CS8345
L3span ประกาศ​ก่อน await Task.Yield() แล้ว​ใช้​ต่อ​หลัง awaitCS4007
L4span ประกาศ​แล้ว yield return s.Length; โดย​ไม่​ใช้​ต่อผ่าน
L5span ถูก​อ้าง​ใน lambda Func<int> f = () => s.Length;CS8175
L6var x = new List<ReadOnlySpan<char>>();CS9244
L7object o = l.AsSpan();CS0029
L8var a = new ReadOnlySpan<char>[4];CS0611
L9คืน Span<char> ที่​ชี้ stackalloc ออก​จาก methodCS8352
L10async Task M(ReadOnlySpan<char> s)CS4012
L11span ถูก​ใช้​ใน lock (this) { … }ผ่าน
L12using ReadOnlySpan<char> s = l.AsSpan();CS1674
L13span ประกาศ​ก่อน yield return 1; แล้ว​ใช้​ต่อ​หลัง​จาก​นั้นCS4007
L14ref struct C { public ReadOnlySpan<char> Menu; }ผ่าน — ทางออก
L15method static คืน slice ของ input ที่​รับ​เข้า​มาผ่าน
L16span อยู่​ใน block ที่​ปิด scope ก่อน​ถึง awaitผ่าน

รหัส​ทั้ง 16 เคส​ผูก​กับ SDK 10.0.302 / LangVersion 14.0 ถ้า​คุณ compile บน version อื่น​แล้ว​ได้​รหัส​ไม่​ตรง ให้​เชื่อ compiler ของ​คุณ

สอง​ข้อ​ที่​บทความ​ส่วน​ใหญ่​เขียน​ผิด และ​วัด​แล้ว​ผิด​จริง

ข้อ​แรก: รหัส​ของ “Span เป็น type argument ไม่​ได้” คือ CS9244 ไม่ใช่ CS0306 — L6 ที่​ยัด ReadOnlySpan<char> เข้าไป​เป็น type argument ของ List<T> ได้ CS9244 บน SDK นี้ ส่วน CS0306 คือ​รหัส​ที่​บทความ​รุ่น​ก่อน​อ้าง​กัน​ต่อๆ มา ถ้า​ค้น​ด้วย​รหัส​เก่า​จะ​หา​คำ​ตอบ​ไม่​เจอ

ข้อ​สอง: กฎ​ที่​ท่อง​กัน​ว่า “Span อยู่​ใน method ที่​มี await หรือ yield ไม่​ได้” ไม่​แม่น​แล้ว​ตั้งแต่ C# 13 — กฎ​จริง​คือ span มี​ชีวิต​ข้าม​เส้น​แบ่ง​ไม่​ได้ ไม่ใช่​ห้าม​อยู่​ใน method เดียวกัน

หลักฐาน​อยู่​ใน​ตาราง​เดียวกัน L16 ที่ span ปิด scope ก่อน​ถึง await compile ผ่าน และ L4 ที่ span ไม่​ได้​ถูก​ใช้​ต่อ​หลัง yield return ก็ ผ่าน ส่วน L3 กับ L13 ที่ span มี​ชีวิต​ข้าม​เส้น​แบ่ง​จริง ได้ CS4007 ทั้ง​คู่

ผล​ต่าง​ใน​ทาง​ปฏิบัติ​คือ​คน​ที่​ท่อง​กฎ​แบบ​เก่า​จะ​หนี​ไป​ใช้ Memory<T> ทั้ง​ที่​จัด scope ให้​ถูก​ก็​พอแล้ว

ทางออก​เวลา​ต้อง​ถือ span ไว้​ใน​โครงสร้าง​ของ​ตัวเอง​อยู่​ใน​ตาราง​เช่น​กัน L14 บอกว่า ref struct ถือ field ที่​เป็น span ได้ (ส่วน L1 กับ L2 ที่​เป็น class และ struct ธรรมดา ได้ CS8345 ทั้ง​คู่) และ L15 บอกว่า method คืน slice ของ input ที่​รับ​เข้า​มา​ได้ ต่าง​จาก L9 ที่​คืน span ซึ่ง​ชี้ stackalloc ของ​ตัวเอง​แล้ว​ได้ CS8352 เพราะ​พื้นที่​นั้น​จะ​หาย​ไป​พร้อม method

การ​วัด​เดียวกัน​เมื่อ​บังคับ​ให้​เก็บ​ผลลัพธ์​เป็น object จริง ซึ่ง​เปลี่ยน 0 B ให้​กลาย​เป็น 616 B

0 B ใน​หัวข้อ​แรก​เกิด​ขึ้น​ภาย​ใต้​เงื่อนไข​หนึ่ง​ที่​ต้อง​พูด​ออก​มา​ให้​ชัด คือ อ่าน​แล้ว​รวม​ยอด​แล้ว​ทิ้ง พอ hot path ต้อง​คืน List<OrderLine> ออก​ไป​ให้​ชั้น​ถัด​ไป​ใช้​ต่อ เงื่อนไข​นั้น​หาย​ไป

record OrderLine(int Id, string Menu, int Qty, int UnitSatang);
static List<OrderLine> MaterialiseSpan(string[] src)
{
var list = new List<OrderLine>(src.Length);
foreach (string line in src)
{
ReadOnlySpan<char> s = line;
int c1 = s.IndexOf(',');
int c2 = s.Slice(c1 + 1).IndexOf(',') + c1 + 1;
int c3 = s.Slice(c2 + 1).IndexOf(',') + c2 + 1;
list.Add(new OrderLine(
int.Parse(s.Slice(0, c1), CultureInfo.InvariantCulture),
s.Slice(c1 + 1, c2 - c1 - 1).ToString(), // ← ตรงนี้จองแน่นอน
int.Parse(s.Slice(c2 + 1, c3 - c2 - 1), CultureInfo.InvariantCulture),
int.Parse(s.Slice(c3 + 1, s.Length - c3 - 1), CultureInfo.InvariantCulture)));
}
return list;
}
input ที่​วัดAllocated
Split → List<OrderLine> (ออร์เดอร์ 6 บรรทัด​ชุด​เดิม)1,480 B
Span → List<OrderLine> (ชุด​เดิม)616 B
Span + GetAlternateLookupList<OrderLine> (ชุด​เดิม)344 B
Span รวม​ยอด​อย่าง​เดียว​ไม่​เก็บ​อะไร (ชุด​เดิม)0 B

ประหยัด​ได้ 864 B คือ 58.4% ไม่ใช่ 100% และ 616 B ที่​เหลือ​แยก​ได้​ครบ​พอดี​สาม​ส่วน คือ List<OrderLine>(6) เปล่าๆ 104 B + OrderLine 40 B × 6 = 240 B + string ชื่อ​เมนู​หก​ตัว 272 B

ก้อน​ที่​ตัด​ออก​ได้​ต่อ​คือ string ชื่อ​เมนู เพราะ​ชื่อ​เมนู​มี​อยู่​แล้ว​ใน catalog ไม่​จำเป็น​ต้อง​สร้าง string ใหม่​จาก​บรรทัด​ออร์เดอร์ GetAlternateLookupGetAlternateLookupทาง​ค้น dictionary ด้วย `ReadOnlySpan<char>` โดย​ไม่​ต้อง​สร้าง string key ขึ้น​มา​ก่อน ใช้ได้​เมื่อ comparer รองรับ เปิด​ทาง​ค้น dictionary ด้วย ReadOnlySpan<char> ตรงๆ

var catalogue = new Dictionary<string, string>(StringComparer.Ordinal);
var lookup = catalogue.GetAlternateLookup<ReadOnlySpan<char>>();
string menu = lookup.TryGetValue(s.Slice(c1 + 1, c2 - c1 - 1), out string? hit)
? hit
: s.Slice(c1 + 1, c2 - c1 - 1).ToString();

ทาง​นี้​ตัด string ชื่อ​เมนู​ออก​ได้​ทั้ง 272 B เหลือ 344 B ซึ่ง​คือ List บวก record ที่​ลด​ต่อ​ไม่​ได้​อีก​แล้ว​ถ้า​ยัง​ต้องการ object จริง (บท 6 จะ​กลับ​มา​ที่ GetAlternateLookup อีก​ครั้ง​ใน​บริบท​ของ​งาน string เต็ม​รูปแบบ)

ประโยค​ที่​ต้อง​ติดตัว​ไป​จาก​บท​นี้

Span<T> ไม่​ได้​ลบ​การ​จอง มัน​เลื่อน​เส้น​ที่​การ​จอง​ต้อง​เกิด​ขึ้น ให้​ไป​อยู่​ตรง​จุด​ที่​ข้อมูล​ต้อง​มีอายุ​ยืน​กว่า buffer ต้น​ทางจริงๆ เท่านั้น

  • อ่าน​แล้ว​ทิ้ง (รวม​ยอด, ตรวจ format, เทียบ​ค่า) → 0 B
  • ต้อง​เก็บ​เป็น object → 616 B จาก 1,480 B
  • ต้อง​เก็บ แต่​ชื่อ​มี​อยู่​แล้ว​ที่​อื่น → 344 B

คำถาม​ที่​ควร​ถาม​ก่อน​เขียน Span<T> จึง​ไม่ใช่ “จะ slice ยังไง” แต่​เป็น “ผลลัพธ์​ต้อง​มีอายุ​ยืน​แค่​ไหน”

การ​วัด​ตัวอย่าง​สอน​ที่​อยู่​ใน​หน้า​เอกสาร​ทางการ ซึ่ง​เป็น​ตัวอย่าง​เรื่อง​การ​เลี่ยง​การ​จอง แต่​ตัว​มัน​เอง​จอง

หน้า Span<T> ของ Microsoft Learn มี​ตัวอย่าง​สอน​อยู่​หนึ่ง​ชุด ซึ่ง​เขียน​แบบ​นี้

string contentLength = "Content-Length: 132";
var length = GetContentLength(contentLength.ToCharArray()); // ← จอง char[19]
static int GetContentLength(ReadOnlySpan<char> span) => int.Parse(span.Slice(16));
// เขียนแบบนี้แทน ได้ผลเท่ากันโดยไม่จอง
var length2 = GetContentLength(contentLength.AsSpan());
var length3 = GetContentLength(contentLength); // implicit conversion ก็พอ
input ที่​วัดAllocated
"Content-Length: 132".ToCharArray()64 B
"Content-Length: 132".AsSpan()0 B
ส่ง string เข้าไปตรงๆ (implicit conversion)0 B

64 B นั้น​อธิบาย​ได้​ครบ char[19] = หัว array 24 B + 19 × 2 = 62 ปัด​ขึ้น​เป็น​ทวีคูณ​ของ 8 ได้ 64

นี่​ไม่ใช่​การ​จับผิด เอกสาร​หน้า​นั้น​เป็น​แหล่ง​อ้างอิง​หลัก​ของ​บท​นี้​เรื่อง​ข้อ​จำกัด​ของ ref struct และ​ยัง​ใช้ได้​อยู่ ประเด็น​คือ ตัวอย่าง​ที่​เขียน​ไว้​สอน​เรื่อง​การ​เลี่ยง​การ​จอง กลับ​จอง​เสีย​เอง ซึ่ง​เป็น​เหตุผลตรงๆ ว่า​ทำไม​คอร์ส​นี้​ถึง​วัด​ทุก​อย่าง​เอง​แทนที่​จะ​ลอก​ตัวอย่าง​มา​เชื่อ

ของ​แถม​ที่​วัด​ได้​ใน​รอบ​เดียวกัน และ​ไม่ใช่​ตัวเลข allocation คือ span.Equals(object) และ span.GetHashCode() โยน NotSupportedException ทั้ง​คู่ อย่า​เผลอ​เอา span ไป​ใส่​โครงสร้าง​ที่​เรียก2 method นี้

สิ่ง​ที่​ข้อมูล​ชุด​นี้​ยัง​พิสูจน์​ไม่​ได้ และ​จะ​ไม่​ถูก​อ้าง​ใน​บท​ที่​เหลือ

  • บท​นี้​ไม่มี​ตัวเลข​เวลา​แม้แต่​ตัว​เดียว ตัวเลข​เวลา​ที่​วัด​ไว้​เป็น​รัน​ชุด​เดียว​และ​ไม่​ผ่าน​เกณฑ์​ของ​บท 1 จึง​ถูก​ตัด​ทิ้ง​ทั้ง​ชุด บท​นี้​จึง​ไม่​อ้าง​ว่า Span<T> เร็ว​ขึ้น​กี่​เท่า เพราะ​ไม่มี​หลักฐาน​ที่​ใช้ได้
  • 1,136 B → 0 B ไม่ใช่​สิ่ง​ที่ app จริง​จะ​ได้ 0 B เกิด​เฉพาะ​กรณี​อ่าน​แล้ว​ทิ้ง ถ้า​ต้อง​คืน List<OrderLine> จริง​จะ​ได้ 616 B และ​ดี​ที่สุด​ด้วย alternate lookup คือ 344 B
  • ตัวเลข byte ทุก​ตัว​ผูก​กับ .NET 10.0.10 · linux-x64 · IntPtr = 8 · InvariantGlobalization · workstation non-concurrent GC ขนาด​หัว​ก้อน string (22 B) และ​ขนาด​ของ string[4] (56 B) เปลี่ยน​ตาม​ขนาด pointer
  • เพดาน stackalloc ราว 8,148 KB และ​จุด​ตาย​ที่ loop 300,000 รอบ เป็น​ค่า​ของ thread หลัก​ที่ ulimit -s = 8192 KB เท่านั้น thread ที่​สร้าง​เอง​หรือ thread pool มี​ขนาด stack ต่าง​กัน ให้​ใช้​สำนวน MaxStackLimit ของ​เอกสาร​แทน​การ​จำ​ตัวเลข
  • รหัส error ทั้ง 16 เคส​ผูก​กับ SDK 10.0.302 / LangVersion 14.0 ทั้ง CS9244 และ​พฤติกรรม await/yield ที่​ผ่อน​แล้ว เป็น​ผล​ของ C# 13 ขึ้น​ไป
  • ที่​อยู่​หน่วย​ความ​จำ​ของ stackalloc รายงาน​ได้​แค่​ผล​ต่าง −32 B ต่อ​รอบ กับ​จำนวน​ค่าที่​ต่าง​กัน​ห้า​ค่า เลข​ที่​อยู่​สัมบูรณ์​เปลี่ยน​ทุก process เพราะ ASLR จึง​ไม่ใช่​ผล​การ​วัด​ที่​อ้าง​ได้
  • allocation ไม่​เท่ากับ residency บท​นี้​นับ byte ที่​จัดสรร ไม่​ได้​นับ byte ที่​ยัง​ค้าง​อยู่ และ​ไม่​รับปาก​เรื่อง GC pause, throughput หรือ tail latency

หลักฐาน​ที่​บท​นี้​วาง​ไว้​ให้​บท​ที่​เหลือ​ใช้​ต่อ​โดย​ไม่​ต้อง​พิสูจน์​ซ้ำ

  • อ่านออร์เดอร์ 6 บรรทัด​ด้วย Split = 1,136 B · Substring = 800 B · Span = 0 B โดย checksum เท่า​กัน​ทั้ง​สาม​ทาง​ที่ 182830
  • Split จอง​ห้า​ก้อน​ต่อ​บรรทัด ไม่ใช่​ก้อน​เดียว และ​สูตร​ก้อน string คือ​หัว 22 B + 2 B ต่อ​ตัว​อักษร ปัด​ขึ้น​ทวีคูณ​ของ 8 (ตรวจ​กับ​ความ​ยาว 1–24 ที​ละ​ค่า)
  • slice ชี้​กลับ​เข้า buffer เดิม​จริง วัด​ด้วย​ที่​อยู่​ได้ sameBuffer=True ทั้ง5 field และ spanOffsetBytes = start × 2 เป๊ะ ส่วน Substring ได้​ก้อน​ใหม่​จริง (copyIsSeparate=True)
  • int.Parse ทั้ง overload string และ span อ่าน​ได้ 0 B เท่า​กัน กฎ​คือ “อย่า​สร้าง string กลาง”
  • stackalloc ได้ 0 B บน heap แต่​ใน loop เลื่อน​ลง​รอบ​ละ 32 B และ 300,000 รอบ​ตาย​ด้วย exit=134 โดย catch ไม่​เคย​ทำงาน
  • ขีด​จำกัด​ของ ref struct เป็น error จริง 16 เคส และ​กฎ​ที่​ถูกต้อง​คือ “มี​ชีวิต​ข้าม​เส้น​แบ่ง​ไม่​ได้”
  • พอ​ต้อง materialise จริง Span ซื้อ​ได้ 58.4% ไม่ใช่ 100%

บท 5 รับช่วง​ต่อ​จาก​คำถาม​ที่​บท​นี้​เปิด​ค้าง​ไว้: ถ้า buffer ต้อง​มีอายุ​ยืน​กว่า​ที่ stackalloc ยอม​ให้ และ​เรา​ยัง​ไม่​อยาก​จอง​ใหม่​ทุกรอบ ทาง​เลือก​คือ ArrayPool ซึ่ง​ลบ​การ​จอง​ออก​จาก loop ได้​จริง แต่​มา​พร้อม​สัญญา​ที่​ผู้​ยืม​ต้อง​รักษา​เอง และ​มี​ขนาด​หนึ่ง​ที่​การ​ยืม​แพง​กว่า​การ​จอง​ใหม่​ทิ้ง​ทุก​ครั้ง


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

เช็กความเข้าใจ — บทที่ 4

ข้อ 1 / 3

ทีมของคุณเสนอ PR ที่ไล่เปลี่ยนทุกจุดใน codebase จาก int.Parse ที่รับ string ไปเป็น overload ที่รับ span โดยยังเรียก Substring เพื่อตัด field เหมือนเดิม ข้อมูลของบทนี้บอกว่าอย่างไร