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 บังคับ ไม่ใช่คำแนะนำที่ฝ่าฝืนได้
บทนี้เดินบน 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: อะไรเปลี่ยนไปบ้าง ควบคู่ไป
1,136 byte หายไปไหน และมันไม่ใช่ก้อนเดียว
หัวข้อที่มีชื่อว่า “1,136 byte หายไปไหน และมันไม่ใช่ก้อนเดียว”การอ่านออร์เดอร์หกบรรทัดสามวิธี ซึ่งให้ 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;}| วิธีตัด field | input ที่วัด | 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 “จอง array” ตัววัดแยกทีละก้อนบอกว่ามันจอง array หนึ่งก้อน บวก string ลูกอีกสี่ก้อน และตัววัดบังคับให้ผลรวมของก้อนย่อยเท่ากับที่วัดได้ทั้งบรรทัดเป๊ะ ไม่งั้นมัน throw
| input ที่วัด | ก้อนที่เกิด | byte |
|---|---|---|
"1042,ผัดไทยกุ้งสด,2,18000".Split(',') | ทั้งบรรทัดรวมกัน | 192 B |
new string[4] | array ที่คืนออกมา | 56 B |
"1042" (4 ตัวอักษร) | string ลูกตัวที่ 1 | 32 B |
"ผัดไทยกุ้งสด" (12 ตัวอักษร) | string ลูกตัวที่ 2 | 48 B |
"2" (1 ตัวอักษร) | string ลูกตัวที่ 3 | 24 B |
"18000" (5 ตัวอักษร) | string ลูกตัวที่ 4 | 32 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 ด้วยอะไร
แกนของกล่องนี้คือ จำนวนออเดอร์ที่ประมวลผลไปแล้ว ไม่ใช่เวลา — จังหวะของปุ่ม “เดินอัตโนมัติ” เป็นค่าคงที่ที่ตั้งไว้เพื่อให้ดูทัน ไม่ได้แทนความเร็วจริงของ 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)
| ออเดอร์ | string.Split | ReadOnlySpan<char> | ก้อนที่เกิดขึ้น |
|---|---|---|---|
| ผัดไทยกุ้งสด | 192 | 0 | ▦ string[4] 56 B · ▨ "1042" 32 B · ▨ "ผัดไทยกุ้งสด" 48 B · ▨ "2" 24 B · ▨ "18000" 32 B |
| ต้มยำกุ้ง | 184 | 0 | ▦ string[4] 56 B · ▨ "1043" 32 B · ▨ "ต้มยำกุ้ง" 40 B · ▨ "1" 24 B · ▨ "22000" 32 B |
| ข้าวมันไก่ | 192 | 0 | ▦ string[4] 56 B · ▨ "1044" 32 B · ▨ "ข้าวมันไก่" 48 B · ▨ "3" 24 B · ▨ "13500" 32 B |
| ส้มตำไทย | 184 | 0 | ▦ string[4] 56 B · ▨ "1045" 32 B · ▨ "ส้มตำไทย" 40 B · ▨ "1" 24 B · ▨ "8000" 32 B |
| แกงเขียวหวานไก่ | 200 | 0 | ▦ string[4] 56 B · ▨ "1046" 32 B · ▨ "แกงเขียวหวานไก่" 56 B · ▨ "2" 24 B · ▨ "17000" 32 B |
| ชาไทยเย็น | 184 | 0 | ▦ string[4] 56 B · ▨ "1047" 32 B · ▨ "ชาไทยเย็น" 40 B · ▨ "4" 24 B · ▨ "9000" 32 B |
| รวมหกออเดอร์ | 1,136 | 0 | ผลลัพธ์ที่คำนวณได้เท่ากันทั้งสองเลน (182,830) |
วนซ้ำหลายรอบแล้ววัดใหม่ทั้งหมด — ไม่ได้เอาเลขข้างบนไปคูณ
| จำนวนรอบ | 1 | 10 | 100 | 1,000 |
|---|---|---|---|---|
| string.Split | 1,136 | 11,360 | 113,600 | 1,136,000 |
| ReadOnlySpan<char> | 0 | 0 | 0 | 0 |
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
หลักฐาน pointer ว่า slice คือ buffer เดิมจริง ไม่ใช่สำเนา
หัวข้อที่มีชื่อว่า “หลักฐาน pointer ว่า slice คือ buffer เดิมจริง ไม่ใช่สำเนา”การเทียบที่อยู่หน่วยความจำของ 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()));| field | start | len | copyBytes ของ Substring | spanOffsetBytes | sameBuffer |
|---|---|---|---|---|---|
รหัสออร์เดอร์ ORD-10482 | 0 | 9 | 40 B | 0 | True |
รหัสลูกค้า 742 | 10 | 3 | 32 B | 20 | True |
สถานะ Confirmed | 14 | 9 | 40 B | 28 | True |
ยอดรวมสตางค์ 145000 | 24 | 6 | 40 B | 48 | True |
เวลาที่สั่ง 2024-06-15T18:32:07 | 31 | 19 | 64 B | 62 | True |
อ่าน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
บรรทัดของหัวข้อนี้ให้ 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 นั้นมีชีวิตอยู่ได้แค่ไหน
จุดที่ folklore ผิด ตัว Parse ไม่เคยจองอยู่แล้ว
หัวข้อที่มีชื่อว่า “จุดที่ folklore ผิด ตัว Parse ไม่เคยจองอยู่แล้ว”การวัดที่ปิดคำแนะนำยอดนิยมว่า “เปลี่ยนไปเรียก 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
การไล่แก้ทั้ง codebase ให้เรียก overload ที่รับ span เป็น diff ที่ใหญ่ มีความเสี่ยงเรื่อง regression และวัดแล้วได้กำไร 0 B ตราบใดที่ยังมี Substring อยู่ก่อนหน้า
สิ่งที่ควรทำแทนคือไล่หา จุดที่สร้าง string กลาง แล้วตัดจุดนั้นทิ้ง ซึ่งมักเป็นการแก้ไม่กี่บรรทัดต่อ1 hot path
stackalloc ได้0 byte บน heap แต่ใน loop มันคือระเบิดเวลา
หัวข้อที่มีชื่อว่า “stackalloc ได้0 byte บน heap แต่ใน loop มันคือระเบิดเวลา”การเทียบ 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 เท่าเดิม
StackOverflow จับไม่ได้ และเพดานจริงไม่ใช่ 1 KB
หัวข้อที่มีชื่อว่า “StackOverflow จับไม่ได้ และเพดานจริงไม่ใช่ 1 KB”เมทริกซ์ exit code ของ process ลูกที่ตายจริง ซึ่งตอบสองคำถามพร้อมกัน คือ try/catch ช่วยไหม และเพดานอยู่ตรงไหน
// รันเป็น process ลูกเพื่อให้ตายได้จริงและอ่าน exit codetry{ 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 ซึ่งอยู่ใต้ค่านั้นลงมาเล็กน้อย
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 ไม่ใช่ตัวเลขคงที่
ขีดจำกัดแข็ง ที่ compiler บังคับ
หัวข้อที่มีชื่อว่า “ขีดจำกัดแข็ง ที่ compiler บังคับ”รหัส error จริงจาก compiler 16 เคส ซึ่งแยกให้เห็นว่าอะไรห้ามจริง อะไรที่คนจำผิด และทางออกอยู่ตรงไหน
Span<T> เป็น ref structref structชนิดที่ compiler บังคับให้มีชีวิตอยู่บน stack เท่านั้น จึงเป็น field ของ class ไม่ได้และข้ามเส้นแบ่งบางเส้นไม่ได้ compiler จึงบังคับให้มันมีชีวิตอยู่บน stack เท่านั้น ข้อจำกัดทั้งชุดไม่ใช่คำแนะนำ มันเป็น error ที่ compile ไม่ผ่าน ตารางข้างล่าง compile ทีละเคสจริงแล้วอ่านรหัสที่ได้
| เคส | input ที่วัด (code ที่ป้อน compiler) | ผล |
|---|---|---|
| L1 | class C { ReadOnlySpan<char> _menu; } | CS8345 |
| L2 | struct C { ReadOnlySpan<char> _menu; } | CS8345 |
| L3 | span ประกาศก่อน await Task.Yield() แล้วใช้ต่อหลัง await | CS4007 |
| L4 | span ประกาศแล้ว yield return s.Length; โดยไม่ใช้ต่อ | ผ่าน |
| L5 | span ถูกอ้างใน lambda Func<int> f = () => s.Length; | CS8175 |
| L6 | var x = new List<ReadOnlySpan<char>>(); | CS9244 |
| L7 | object o = l.AsSpan(); | CS0029 |
| L8 | var a = new ReadOnlySpan<char>[4]; | CS0611 |
| L9 | คืน Span<char> ที่ชี้ stackalloc ออกจาก method | CS8352 |
| L10 | async Task M(ReadOnlySpan<char> s) | CS4012 |
| L11 | span ถูกใช้ใน lock (this) { … } | ผ่าน |
| L12 | using ReadOnlySpan<char> s = l.AsSpan(); | CS1674 |
| L13 | span ประกาศก่อน yield return 1; แล้วใช้ต่อหลังจากนั้น | CS4007 |
| L14 | ref struct C { public ReadOnlySpan<char> Menu; } | ผ่าน — ทางออก |
| L15 | method static คืน slice ของ input ที่รับเข้ามา | ผ่าน |
| L16 | span อยู่ใน 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
ราคาที่จ่าย Span ซื้อได้แค่ระหว่างทาง
หัวข้อที่มีชื่อว่า “ราคาที่จ่าย Span ซื้อได้แค่ระหว่างทาง”การวัดเดียวกันเมื่อบังคับให้เก็บผลลัพธ์เป็น 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 + GetAlternateLookup → List<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 ยังไง” แต่เป็น “ผลลัพธ์ต้องมีอายุยืนแค่ไหน”
กับดักที่ซ่อนอยู่ในตัวอย่างของ Microsoft Learn เอง
หัวข้อที่มีชื่อว่า “กับดักที่ซ่อนอยู่ในตัวอย่างของ Microsoft Learn เอง”การวัดตัวอย่างสอนที่อยู่ในหน้าเอกสารทางการ ซึ่งเป็นตัวอย่างเรื่องการเลี่ยงการจอง แต่ตัวมันเองจอง
หน้า 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 ได้จริง แต่มาพร้อมสัญญาที่ผู้ยืมต้องรักษาเอง และมีขนาดหนึ่งที่การยืมแพงกว่าการจองใหม่ทิ้งทุกครั้ง
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- Span<T> Struct (System) — Microsoft Learn (ms.date 2025-07-01 · updated 2026-06-12) — ระบุว่าเป็น
ref structพร้อมข้อห้ามทั้งชุด และเป็นหน้าที่มีตัวอย่างGetContentLengthซึ่งเรียกToCharArray()ที่วัดได้ 64 B - CA2014: Do not use stackalloc in loops — Microsoft Learn (2020-06-18 · updated 2026-04-02) — ระบุว่า “Enabled by default in .NET 10: As warning” ตรงกับที่ compiler ยิงออกมาจริงในบทนี้
- stackalloc expression — C# reference (2026-06-16) — สำนวน
MaxStackLimit= 1024 และคำเตือน “Avoid using stackalloc inside loops” - ref struct types — C# reference (2026-01-14 · updated 2026-08-07) — ระบุว่า C# 13 ผ่อนกฎเรื่อง
awaitและyield - MemoryExtensions.Split Method — Microsoft Learn (2025-07-01 · updated 2026-08-03) — overload ที่รับ
Span<Range>ตั้งแต่ .NET 8 - Dictionary<TKey,TValue>.GetAlternateLookup<TAlternateKey> — Microsoft Learn (2025-07-01 · updated 2026-07-01) — ทางค้น dictionary ด้วย span ตั้งแต่ .NET 9
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3ทีมของคุณเสนอ PR ที่ไล่เปลี่ยนทุกจุดใน codebase จาก int.Parse ที่รับ string ไปเป็น overload ที่รับ span โดยยังเรียก Substring เพื่อตัด field เหมือนเดิม ข้อมูลของบทนี้บอกว่าอย่างไร