ArrayPool: การยืมที่มีสัญญาแนบมา
บท 4 ปิดด้วยข้อจำกัดข้อเดียวที่ stackalloc แก้ให้ไม่ได้ คือ buffer ที่ใหญ่เกินกว่าจะวางบน stack และ buffer ที่ต้องมีชีวิตข้าม method บทนี้เอาเครื่องมือที่ตอบโจทย์นั้นขึ้นโต๊ะ คือคลัง array ที่ให้ยืมและคืน
ตัวเลขฝั่งดีของมันแรงกว่าทุกเทคนิคที่คอร์สนี้ผ่านมา loop ที่จอง new byte[128] หนึ่งใบต่อหนึ่งบรรทัดออร์เดอร์ วัดได้ 152,000,000 byte ที่หนึ่งล้านบรรทัด ส่วน loop เดียวกันที่เปลี่ยนเป็นยืมคืน วัดได้ 0 byte
แต่คำที่ต้องอ่านให้ครบคือคำว่า ยืม บทนี้จึงใช้เวลาส่วนใหญ่ไปกับสัญญาที่แนบมากับการยืม ไม่ใช่กับตัวเลข 0 ที่หน้าปก เพราะทุกข้อของสัญญานั้น เมื่อถูกละเมิด runtime จะไม่บอกอะไรเลย
ทั้งคอร์สเดินบน hot path เส้นเดียวของ domain Order ที่คอร์ส #8–#11 สร้างไว้ คือ parse บรรทัดออร์เดอร์ → รวมยอด → พิมพ์ใบเสร็จ (repo ตัวอย่าง .NET กำลังจัดทำ) buffer ที่บทนี้ยืม คือ buffer ที่ใช้เขียนบรรทัดออร์เดอร์หนึ่งบรรทัดออกเป็น UTF-8 ก่อนส่งต่อ
ทุกตัวเลขในบทนี้วัดบนเครื่อง Intel Core i7-7700 3.60GHz (Kaby Lake) · 8 logical / 4 physical · Ubuntu 24.04.4 LTS (WSL2) · .NET SDK 10.0.302 / .NET 10.0.10 · linux-x64 · RyuJIT AVX2 · workstation non-concurrent GC และผูกกับ x64 ที่ IntPtr = 8 byte
ไวยากรณ์ C# รุ่นใหม่ที่โผล่ใน snippet ของคอร์สนี้ คอร์สนี้ ใช้ โดยไม่หยุดอธิบาย ถ้าเจอของที่ไม่คุ้นให้เปิด C# 8 → 14: อะไรเปลี่ยนไปบ้าง ควบคู่ไป
1 buffer ต่อหนึ่งบรรทัด: หนึ่งล้านบรรทัดคือ 152,000,000 byte ที่ลบได้ทั้งก้อน
หัวข้อที่มีชื่อว่า “1 buffer ต่อหนึ่งบรรทัด: หนึ่งล้านบรรทัดคือ 152,000,000 byte ที่ลบได้ทั้งก้อน”loop เดียวกัน input เดียวกัน ต่างกันแค่ว่าจองใหม่ทุกรอบหรือยืมคืน วัดตั้งแต่ 1 รอบถึง 1,000,000 รอบ
code ที่วัดคือ2 method ที่ต่างกันแค่ตรงที่มาของ buffer ทั้งคู่จอดผลลัพธ์ไว้ใน static field ตัวเดียวกัน
static class Sink { public static object? Obj; public static int I; }
static class Bench{ [MethodImpl(MethodImplOptions.NoInlining)] public static long NaiveLoop(int n) { long a = GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < n; i++) { byte[] buf = new byte[128]; Sink.Obj = buf; // กัน escape analysis ตามวินัยของบท 1 Sink.I += buf.Length; } return GC.GetAllocatedBytesForCurrentThread() - a; }
[MethodImpl(MethodImplOptions.NoInlining)] public static long PooledLoop(int n) { ArrayPool<byte> pool = ArrayPool<byte>.Shared; long a = GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < n; i++) { byte[] buf = pool.Rent(128); try { Sink.Obj = buf; Sink.I += buf.Length; } finally { pool.Return(buf); } } return GC.GetAllocatedBytesForCurrentThread() - a; }}| input ที่วัด | new byte[128] × N | Rent(128)/Return × N | stackalloc byte[128] × N |
|---|---|---|---|
| N = 1 | 152 B | 0 B | 0 B |
| N = 10 | 1,520 B | 0 B | 0 B |
| N = 100 | 15,200 B | 0 B | 0 B |
| N = 1,000 | 152,000 B | 0 B | 0 B |
| N = 10,000 | 1,520,000 B | 0 B | 0 B |
| N = 100,000 | 15,200,000 B | 0 B | 0 B |
| N = 1,000,000 | 152,000,000 B | 0 B | 0 B |
ArrayPoolArrayPoolคลัง array ที่ให้ยืมและคืน เพื่อลบการจองออกจาก loop โดยแลกกับสัญญาที่ผู้ยืมต้องรักษาเอง ทำสิ่งที่โฆษณาไว้จริง column กลางอ่านได้ 0 ทุกแถว และไม่ใช่ 0 แบบที่บท 1 เตือน เพราะ buffer ถูกจอดไว้ใน Sink.Obj ทุกรอบ
column ขวาสุดคือเส้นเปรียบเทียบที่ต้องอยู่ในตารางเสมอ stackalloc ให้ 0 byte เท่ากันเป๊ะโดยไม่ต้องมีสัญญาอะไรแนบมาเลย buffer 128 byte ใน method เดียวไม่ควรเป็นงานของ pool ตั้งแต่แรก และหัวข้อ “เมื่อ pool แพ้” ข้างล่างจะกลับมาที่ประเด็นนี้อีกครั้งพร้อมตัวเลข
Rent คืนของที่ใหญ่กว่าที่ขอ และ Length ไม่ใช่ความยาวข้อมูลของคุณ
หัวข้อที่มีชื่อว่า “Rent คืนของที่ใหญ่กว่าที่ขอ และ Length ไม่ใช่ความยาวข้อมูลของคุณ”ตาราง bucket ทั้งชุดที่พิมพ์ออกมาจากการเรียก Rent จริง 18 ค่า รวมค่าที่ปัดขึ้นเกิน 99.8%
pool ไม่ได้เก็บ array ไว้ทุกความยาว มันเก็บเป็นช่องขนาดกำลังสอง คำขอทุกคำขอถูกปัดขึ้นไปหาช่องที่ใกล้ที่สุด
foreach (int req in new[] { 0, 1, 7, 15, 16, 17, 100, 128, 129, 1000, 1024, 1025, 10_000, 65_536, 100_000, 1_000_000, 1_048_576, 1_048_577 }){ byte[] buf = ArrayPool<byte>.Shared.Rent(req); Console.WriteLine($"ขอ {req,9} -> Length {buf.Length,9} (เกิน {buf.Length - req})"); ArrayPool<byte>.Shared.Return(buf);}input ที่วัด (Rent(req)) | buf.Length ที่ได้ | เกินมา |
|---|---|---|
| 0 | 0 | 0 |
| 1 | 16 | 15 |
| 7 | 16 | 9 |
| 15 | 16 | 1 |
| 16 | 16 | 0 |
| 17 | 32 | 15 |
| 100 | 128 | 28 |
| 128 | 128 | 0 |
| 129 | 256 | 127 |
| 1,000 | 1,024 | 24 |
| 1,024 | 1,024 | 0 |
| 1,025 | 2,048 | 1,023 (99.8%) |
| 10,000 | 16,384 | 6,384 (63.8%) |
| 65,536 | 65,536 | 0 |
| 100,000 | 131,072 | 31,072 |
| 1,000,000 | 1,048,576 | 48,576 |
| 1,048,576 | 1,048,576 | 0 |
| 1,048,577 | 2,097,152 | 1,048,575 (100%) |
bucketbucketช่องขนาดกำลังสองของ pool ที่คำขอถูกปัดขึ้นไปหา ทำให้ `Length` ของ array ที่ได้ใหญ่กว่าที่ขอเสมอ คือช่องเหล่านี้ และผลของมันตรงไปตรงมา คำขอที่เกินขอบ bucket ไป1 byte จะได้ array ที่ใหญ่เป็นสองเท่าของ bucket เดิม
จากตารางนี้ได้กฎข้อเดียวที่บังคับใช้ตลอดชีวิตของ buffer ที่ยืมมา คือ ต้องพกจำนวน byte ที่เขียนจริงไว้เอง ไม่ว่าจะเป็นตัวแปร written หรือการส่ง buf.AsSpan(0, written) ต่อไปทุกทอด เพราะ buf.Length ตอบคำถาม “ช่องนี้ใหญ่แค่ไหน” ไม่ได้ตอบคำถาม “ข้อมูลของฉันยาวแค่ไหน”
Rent(64) แล้วเขียนบรรทัดออร์เดอร์ 1042|ต้มยำกุ้ง|2|18000 ตามด้วยขึ้นบรรทัดใหม่ ได้ข้อมูลจริง 41 byte ขณะที่ buf.Length = 64 (M5.4)
ระยะห่าง 23 byte นั้นคือที่ที่ข้อมูลของผู้เช่าคนก่อนนอนรออยู่ และเป็นที่ที่ข้อมูลของคุณจะนอนรอผู้เช่าคนถัดไป
เส้นโค้ง cold → warm จ่ายครั้งเดียว แล้วศูนย์ตลอด
หัวข้อที่มีชื่อว่า “เส้นโค้ง cold → warm จ่ายครั้งเดียว แล้วศูนย์ตลอด”ต้นทุนตั้งต้นของ pool ที่ยังไม่เคยถูกใช้ แยกเป็นสองก้อนที่วัดได้ทีละก้อน และจุดที่มันคืนทุน
การจะวัด pool ที่ “เย็นจริง” ต้องใช้ T ชนิดใหม่ เพราะ ArrayPool<T>.Shared ของแต่ละ T เป็นคนละตัวกัน code ที่วัดจึงประกาศ struct ทิ้งขึ้นมาหนึ่งตัวต่อการทดลองหนึ่งครั้ง
struct Q1 { public byte V; } // T ใหม่ = ArrayPool<T>.Shared คนละตัว = pool ที่ยังเย็นจริง
static class Cold{ [MethodImpl(MethodImplOptions.NoInlining)] static long B() => GC.GetAllocatedBytesForCurrentThread();
[MethodImpl(MethodImplOptions.NoInlining)] public static void Measure<T>() where T : struct { long a = B(); ArrayPool<T> pool = ArrayPool<T>.Shared; long cShared = B() - a;
a = B(); T[] b1 = pool.Rent(128); long c1 = B() - a; pool.Return(b1); a = B(); T[] b2 = pool.Rent(128); long c2 = B() - a; pool.Return(b2); a = B(); T[] b3 = pool.Rent(128); long c3 = B() - a; pool.Return(b3);
Console.WriteLine($"Shared={cShared} Rent#1={c1} Rent#2={c2} Rent#3={c3}"); }}| ขั้นที่วัด | input ที่วัด | byte ที่วัดได้ |
|---|---|---|
| อ่านคลังครั้งแรกใน process | ArrayPool<Q1>.Shared | 608 B |
| ยืมครั้งที่ 1 | pool.Rent(128) | 184 B |
| ยืมครั้งที่ 2 | pool.Rent(128) | 0 B |
| ยืมครั้งที่ 3 | pool.Rent(128) | 0 B |
| รวมสองก้อนแรก | Shared + Rent#1 | 792 B |
| เส้นเทียบ | new Q1[128] เปล่าๆ | 152 B |
| ขยับไป bucket ถัดไปใน pool เดิม | pool.Rent(1024) ครั้งแรก | 1,048 B |
แถวล่างสุดคุ้มค่าที่จะหยุดอ่าน Rent(1024) ครั้งแรกใน pool ที่อุ่นแล้ว เสีย 1,048 byte ซึ่งเท่ากับขนาดของ array 1,024 byte หนึ่งใบพอดี ไม่มีค่าโครงสร้างเพิ่มเลย แปลว่าต้นทุน 608 byte ของ Shared เป็นต้นทุนของคลัง ไม่ใช่ของ bucket และมันจ่ายครั้งเดียวต่อ T ต่อ process
วัดแบบเดียวกันใน loop โดยอ่าน Shared ไว้ก่อนเริ่มจับ ได้เส้นต่อรอบแบบนี้
| รอบที่ | สภาพของคลังตอนวัด | new byte[128] ต่อรอบ | Rent(128)/Return ต่อรอบ |
|---|---|---|---|
| 1 | buffer ใบแรกของ pool ที่ยังไม่เคยใช้ | 152 B | 768 B |
| 2 | buffer ใบที่สอง | 152 B | 0 B |
| 3–20 | ทุกใบถัดไป | 152 B | 0 B |
| 21–120 | ทุกใบถัดไป | 152 B | 0 B |
เส้นทางที่จองใหม่จ่าย 152 byte ทุกรอบไม่มีวันจบ เส้นทางที่ยืมจ่ายก้อนเดียวแล้วเป็นศูนย์ตลอด จุดที่ยอดสะสมของสองเส้นตัดกันคือ รอบที่ 6
สองประโยคนี้ต้องอ่านคู่กับตัวเลขข้างบนเสมอ
- 768 B เป็นค่าที่อ่านได้จากการวัดใน loop ไม่ใช่ค่าคงที่ของ pool ตัวเลขที่แยกเป็นก้อนได้และวัดซ้ำได้คือ
Shared608 B บวกRent#1184 B รวม 792 B ส่วนยอดรวมของ “pool ใหม่บวกยืมคืน 12 ครั้ง” วัด3 instantiation แรกแล้ว ไม่ซ้ำกัน จึงไม่มีสิทธิ์ขึ้นหน้านี้ - “รอบที่ 6” เป็นการเทียบเลขสองตัวที่วัดมา ไม่ใช่ผลการวัดชิ้นที่สาม มันมาจาก 792 B ที่จ่ายครั้งเดียว เทียบกับ 152 B ต่อรอบที่จ่ายไม่รู้จบ ถ้า hot path ของคุณเรียกไม่ถึงสิบครั้งต่อการรันหนึ่งครั้ง pool ไม่ได้ช่วยอะไรเลย
ข้อมูลของผู้เช่าคนก่อนยังอยู่ครบทุก byte
หัวข้อที่มีชื่อว่า “ข้อมูลของผู้เช่าคนก่อนยังอยู่ครบทุก byte”array ใบเดิมที่ถูกส่งต่อพร้อมข้อมูลเก่า และ string ที่หลุดออกมาจริงจากการอ่านเกินความยาวที่ตัวเองเขียน
เอกสารของ Microsoft เขียนไว้ตรงตัวว่า array ที่ Rent คืนมา อาจไม่ถูกล้างเป็นศูนย์ บทนี้ไปวัดว่ามันแปลว่าอะไรในทางปฏิบัติ
ArrayPool<byte> pool = ArrayPool<byte>.Shared;
byte[] a1 = pool.Rent(64);Array.Clear(a1);int w1 = Encoding.UTF8.GetBytes("1042|ต้มยำกุ้ง|2|18000\n", a1);pool.Return(a1); // clearArray: false คือค่า default
byte[] a2 = pool.Rent(64);Console.WriteLine(ReferenceEquals(a1, a2)); // ใบเดิมไหมConsole.WriteLine(Encoding.UTF8.GetString(a2, 0, w1)); // ผู้เช่าใหม่เห็นอะไร
int w2 = Encoding.UTF8.GetBytes("7|ส้มตำ|1|6000\n", a2);Console.WriteLine(Encoding.UTF8.GetString(a2, 0, w1)); // เขียนสั้นกว่า แล้วอ่านทั้งใบ
pool.Return(a2, clearArray: true);byte[] a3 = pool.Rent(64);int nonZero = 0; foreach (byte b in a3) if (b != 0) nonZero++;Console.WriteLine(nonZero); // หลังล้างแล้วTrue1042|ต้มยำกุ้ง|2|180007|ส้มตำ|1|6000้ง|2|180000สามอย่างที่ตารางไม่พอจะเล่า จึงต้องอ่านจากผลลัพธ์ตรงๆ
ReferenceEquals(a1, a2)อ่านได้Trueผู้เช่าคนที่สองได้ array ใบเดิม ไม่ใช่ใบที่หน้าตาเหมือนกัน- ก่อนที่ผู้เช่าคนที่สองจะเขียนอะไรเลย ใบนั้นมี byte ที่ไม่เป็นศูนย์อยู่ 41 byte ซึ่งคือบรรทัดออร์เดอร์เต็มบรรทัดของผู้เช่าคนก่อน
- ผู้เช่าคนที่สองเขียนของตัวเองยาว 25 byte แล้วเผลออ่าน 41 byte ตามความยาวที่จำมาผิด ได้ string
7|ส้มตำ|1|6000ต่อด้วย้ง|2|18000ซึ่งเป็นหางของบรรทัดคนก่อนที่ยังไม่ถูกทับ
clearArrayclearArrayargument ของ `Return` ที่สั่งล้างข้อมูลก่อนคืนเข้าคลัง — ถ้าไม่ล้าง ผู้ยืมคนถัดไปจะอ่านข้อมูลของผู้เช่าคนก่อนได้ คือ argument ที่ปิดช่องนี้ Return(a2, clearArray: true) ทำให้ผู้เช่าคนที่สามได้ใบเดิมโดยมี byte ที่ไม่เป็นศูนย์ 0 byte พอดี
แต่ ห้ามสรุปว่า clearArray: true คือค่าที่ปลอดภัยกว่าจึงควรเปิดเสมอ กฎที่ตัวเลขชุดนี้รองรับคือ เปิดเมื่อ buffer เคยถือข้อมูลที่ผู้เช่าคนหน้าไม่ควรเห็น ส่วนราคาของการเปิด บทนี้วัดแล้วแต่ผลไม่ผ่านเกณฑ์ความน่าเชื่อถือของคอร์ส จึงไม่มีตัวเลขให้อ้างสักตัว
มีอีกกรณีเดียวที่คำตอบเป็น “เปิดเสมอ” คือ pool ของ reference type เพราะที่นั่นมันไม่ใช่เรื่องความลับ แต่เป็นเรื่องอายุของ object
| input ที่วัด | ผลลัพธ์ |
|---|---|
pool.Return(arr, clearArray: false) โดย arr[0] ถือ new byte[4096] | IsAlive = True |
pool.Return(arr, clearArray: true) โดย arr[0] ถือ new byte[4096] | IsAlive = False |
array ที่คืนเข้าคลังไปแล้วยังถือ reference ไปยัง payload 4 KB อยู่ GC จึงเก็บมันไม่ได้ทั้งที่ code ของคุณเลิกใช้ไปนานแล้ว
สัญญาการคืน ที่ผิดแล้วไม่มีอะไรร้อง
หัวข้อที่มีชื่อว่า “สัญญาการคืน ที่ผิดแล้วไม่มีอะไรร้อง”สามวิธีที่ทำให้การคืนพัง และผลการวัดว่าทั้งสามวิธี runtime ไม่โยน exception ให้สักตัว
ข้อแรกของสัญญาคือ คืนครั้งเดียว ข้อสองคือ คืนแล้วเลิกใช้ ทั้งสองข้อฟังดูเป็นสามัญสำนึก จนกระทั่งเห็นว่าการละเมิดมันไม่มีอาการอะไรเลย
struct P4 { public byte V; }struct P6 { public byte V; }
// double-ReturnArrayPool<P4> p4 = ArrayPool<P4>.Shared;P4[] x = p4.Rent(64);p4.Return(x);p4.Return(x); // bug — ไม่มีอะไรร้องเลยP4[] r1 = p4.Rent(64);P4[] r2 = p4.Rent(64);Console.WriteLine(ReferenceEquals(r1, r2)); // สองคนถือใบเดียวกันหรือเปล่าr1[0].V = 11;Console.WriteLine(r2[0].V);
// use-after-ReturnArrayPool<P6> p6 = ArrayPool<P6>.Shared;P6[] stale = p6.Rent(64);p6.Return(stale); // คืนแล้ว แต่ยังถือ reference อยู่P6[] fresh = p6.Rent(64);fresh[0].V = 200; // ผู้เช่าใหม่เขียนราคาstale[0].V = 99; // code เก่าเผลอเขียนต่อConsole.WriteLine(fresh[0].V); // ราคาของผู้เช่าใหม่ยังอยู่ไหม| input ที่วัด | สิ่งที่วัดได้ | exception |
|---|---|---|
Return(x) สองครั้งติด แล้ว Rent(64) สองครั้ง | ReferenceEquals(r1, r2) = True · ตั้ง r1[0].V = 11 แล้วอ่าน r2[0].V ได้ 11 | ไม่มี |
Return(stale) แล้ว code เก่าเขียน stale[0].V = 99 ทับผู้เช่าใหม่ที่เขียน 200 | fresh[0].V กลายเป็น 99 | ไม่มี |
แถวบนคือสถานการณ์ที่ code สองส่วนของระบบเชื่อว่าตัวเองเป็นเจ้าของ buffer คนละใบ ทั้งที่ถือใบเดียวกัน แถวล่างคือราคาของออร์เดอร์ที่ถูกทับ
เอกสารของ Microsoft ในหน้า ArrayPool<T>.Return เรียกสิ่งนี้ตรงๆ ว่าเป็น high-severity security issue และอ้าง CWE-415 (double free) กับ CWE-416 (use after free) ซึ่งเป็นชื่อ bug จากโลก C ไม่ใช่จากโลก managed
ข้อที่สามของสัญญาหลอกตากว่าเพื่อน คือการคืน array ที่ไม่ได้ยืมมา
struct P9 { public byte V; }struct PA { public byte V; }
ArrayPool<P9> pool = ArrayPool<P9>.Shared;P9[] mine = new P9[128]; // ไม่ได้มาจาก pool เลยmine[0].V = 77;pool.Return(mine); // pool รับไว้เฉยๆP9[] got = pool.Rent(128);Console.WriteLine($"{ReferenceEquals(mine, got)} {got[0].V}");
try { ArrayPool<byte>.Shared.Return(null!); } catch (Exception e) { Console.WriteLine(e.GetType().Name); }try { ArrayPool<PA>.Shared.Return(new PA[100]); } catch (Exception e) { Console.WriteLine(e.Message); }| input ที่วัด | ผลลัพธ์ |
|---|---|
Return(new P9[128]) ความยาวตรง bucket แล้ว Rent(128) | ReferenceEquals = True และ got[0].V = 77 — ค่าของคุณถูกส่งต่อให้ผู้เช่าคนถัดไป ไม่มี exception |
Return(null!) | ArgumentNullException |
Return(new PA[100]) ความยาวไม่ตรง bucket | ArgumentException |
ข้อความของ ArgumentException แถวล่างสุดคือ
The buffer is not associated with this pool and may not be returned to it. (Parameter 'array')ข้อความบอกว่ามันตรวจ ที่มา แต่พฤติกรรมจริงบอกว่ามันตรวจแค่ ความยาว — array ที่ไม่ได้มาจาก pool เลยแต่ยาว 128 พอดี ผ่านเข้าไปได้พร้อมค่า 77 ที่ติดอยู่ข้างใน
flowchart TD
A[ขอ buffer ยาว 1025 byte] --> B[Rent ปัดขึ้นไปหา bucket ได้ array Length 2048]
B --> C[เขียนข้อมูลจริง แล้วพกจำนวน byte ที่เขียนไว้เอง]
C --> D{คืนไว้ใน finally หรือไม่}
D -- คืนใน finally --> E[array กลับเข้าคลัง รอบถัดไปวัดได้ 0 byte]
D -- ลืมคืน --> F[รอบถัดไปต้องสร้างใบใหม่ 152 byte ต่อรอบ]
E --> G{ใบนี้เคยถือข้อมูลที่คนหน้าไม่ควรเห็นหรือไม่}
G -- เคย --> H[Return แบบสั่งล้าง ผู้เช่าคนถัดไปเจอ byte ที่ไม่เป็นศูนย์ 0 byte]
G -- ไม่เคย --> I[Return แบบไม่ล้าง ผู้เช่าคนถัดไปเจอ byte ที่ไม่เป็นศูนย์ 41 byte]
E --> J{ยังถือ reference เดิมไว้อีกไหม}
J -- ยังถือ --> K[เขียนทับผู้เช่าใหม่ได้ โดยไม่มี exception]
J -- ปล่อยแล้ว --> L[สัญญาครบทุกข้อ]
คำบรรยายภาพ: เส้นทางของ buffer หนึ่งใบตั้งแต่ยืมจนคืน · ทางแยกทั้งสามจุดคือจุดที่สัญญาของการยืมถูกละเมิดได้ · ทางแยกซ้ายล่างคือการลืมคืนซึ่งพาไปจ่าย 152 byte ต่อรอบเหมือนไม่ได้ใช้ pool เลย ทางแยกกลางคือการไม่ล้างซึ่งส่งข้อมูลเก่า 41 byte ต่อให้ผู้เช่าคนถัดไป และทางแยกขวาล่างคือการยังถือ reference ต่อหลังคืนแล้ว ซึ่งเขียนทับข้อมูลของผู้เช่าใหม่ได้โดยไม่มี exception
การลืมคืน ไม่ทำให้ตัวเลขแย่ลงใน 10 รอบแรก
หัวข้อที่มีชื่อว่า “การลืมคืน ไม่ทำให้ตัวเลขแย่ลงใน 10 รอบแรก”version ที่มี finally กับ version ที่ไม่มี ซึ่งวัดได้เท่ากันเป๊ะที่ N = 10 แล้วต่างกัน 1,519,848 byte ที่ N = 10,000
เขียน pool.Return(buf) ไว้เป็นบรรทัดสุดท้ายของ try แทนที่จะไว้ใน finally เป็น bug ที่โผล่เฉพาะตอนมี exception เท่านั้น code ที่วัดจึงโยน exception ทุกรอบเพื่อบังคับให้เส้นทางนั้นเดิน
// _sink เป็น static ตามวินัยของบท 1 — กันไม่ให้ escape analysis ลบ buffer ทิ้งเงียบๆstatic object? _sink;
[MethodImpl(MethodImplOptions.NoInlining)]public static long WithFinally(int n){ ArrayPool<byte> pool = ArrayPool<byte>.Shared; long a = GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < n; i++) { byte[] buf = pool.Rent(128); try { _sink = buf; throw new InvalidOperationException("boom"); } catch (InvalidOperationException) { } finally { pool.Return(buf); } } return GC.GetAllocatedBytesForCurrentThread() - a;}
[MethodImpl(MethodImplOptions.NoInlining)]public static long WithoutFinally(int n){ ArrayPool<byte> pool = ArrayPool<byte>.Shared; long a = GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < n; i++) { try { byte[] buf = pool.Rent(128); _sink = buf; throw new InvalidOperationException("boom"); // Return ไม่มีวันถูกเรียก } catch (InvalidOperationException) { } } return GC.GetAllocatedBytesForCurrentThread() - a;}| input ที่วัด | คืนใน finally | ลืมคืน |
|---|---|---|
| N = 10 | 1,920 B | 1,920 B |
| N = 100 | 19,200 B | 27,256 B |
| N = 1,000 | 192,152 B | 343,848 B |
| N = 10,000 | 1,920,152 B | 3,439,848 B |
แถวแรกคือหัวใจของหัวข้อนี้ ที่ 10 รอบ 2 version วัดได้เท่ากันเป๊ะ เพราะคลังยังมีของสำรองพอจ่ายโดยไม่ต้องสร้างใบใหม่ ยูนิตเทสที่รันสิบรอบแล้วยืนยันว่า allocation เท่ากัน จึงผ่านทั้งที่ bug อยู่ตรงนั้น
ต้นทุนของ exception ล้วนๆ วัดแยกได้ที่ 192 byte ต่อครั้ง (1,000 รอบ = 192,000 byte) ซึ่งอยู่ในทั้ง2 column เท่ากัน หักออกแล้วส่วนต่างที่เหลือคือราคาของการลืมคืนล้วนๆ อ่านได้ 151,848 byte ที่ N = 1,000 และ 1,519,848 byte ที่ N = 10,000 ซึ่งเท่ากับ 152 byte ต่อรอบพอดี คือราคาของ new byte[128] หนึ่งใบ
bug ส่วนใหญ่ที่คอร์สนี้ไล่จับ ให้ตัวเลขผิดทันทีที่วัด bug นี้ไม่ใช่
ที่ N = 10 คุณจะได้ 1,920 byte เท่ากันทั้ง2 version ไม่ว่าจะวัดกี่รอบ ราคาเริ่มปรากฏก็ต่อเมื่อของสำรองในคลังหมด และมันหมดตอนที่ระบบรับโหลดจริงเท่านั้น
วิธีเดียวที่ป้องกันได้คือกฎเชิงโครงสร้าง Return อยู่ใน finally เสมอ ไม่ใช่กฎเชิงตัวเลข
ขีดจำกัดจริงของ Shared pool 257 ใบต่อขนาด และเพดาน 1 GiB
หัวข้อที่มีชื่อว่า “ขีดจำกัดจริงของ Shared pool 257 ใบต่อขนาด และเพดาน 1 GiB”สองตัวเลขที่ folklore บอกผิดทั้งคู่ วัดจากการยืมพร้อมกันหลายใบ และจากการยืม array ขนาดครึ่งกิกะ byte
คลังไม่ได้เก็บ array ไว้ไม่จำกัดใบ วัดโดยการยืมพร้อมกัน k ใบแล้วคืนทั้งหมด ทำซ้ำ 20 รอบ แล้วอ่าน byte เฉลี่ยต่อรอบ
input ที่วัด (k ใบพร้อมกัน ขนาด 128) | byte ต่อรอบ |
|---|---|
| k = 1 ถึง 257 | 0.0 B |
| k = 258 | 152.0 B |
| k = 260 | 456.0 B |
| k = 264 | 1,064.0 B |
| k = 272 | 2,280.0 B |
| k = 288 | 4,712.0 B |
| k = 320 | 9,576.0 B |
| k = 384 | 19,304.0 B |
| k = 512 | 38,760.0 B |
| k = 1,024 | 116,584.0 B |
| k = 4,096 | 583,528.0 B |
เกิน 257 เมื่อไร ทุกใบที่เกินก็คิดราคาเท่ากับ new byte[128] หนึ่งใบพอดี งาน batch ที่กาง buffer หลายร้อยใบพร้อมกันจึงหลุดออกจากสภาวะ 0 byte โดยไม่มีอะไรเตือน
เลข 257 ไม่ใช่ค่าคงที่ของ .NET มันคือ 1 + ProcessorCount × 32 ซึ่งบนเครื่อง 8 core เครื่องนี้ตรงกับ 257 พอดี ยืนยันกลไกได้ด้วยการตั้ง DOTNET_SYSTEM_BUFFERS_SHAREDARRAYPOOL_MAXARRAYSPERPARTITION=8 หรือ MAXPARTITIONCOUNT=2 แล้ว threshold เลื่อนมาอยู่ระหว่าง k = 64 กับ k = 128 ทั้งสองกรณี ตรงกับ 1 + 8×8 = 65 และ 1 + 2×32 = 65
ส่วนเพดานขนาด เป็นจุดที่ folklore ผิดชัดที่สุดในบทนี้
| input ที่วัด | เก็บเข้าคลังไหม | byte ของ Rent ครั้งที่สอง |
|---|---|---|
ArrayPool<byte>.Shared.Rent(67108864) | ใช่ | 0 B |
ArrayPool<byte>.Shared.Rent(134217728) | ใช่ | 0 B |
ArrayPool<byte>.Shared.Rent(268435456) | ใช่ | 0 B |
ArrayPool<byte>.Shared.Rent(536870912) | ใช่ | 0 B |
ArrayPool<byte>.Shared.Rent(1073741824) | ใช่ | 0 B |
ArrayPool<byte>.Shared.Rent(1073741825) | ไม่ | 1,073,741,856 B |
ArrayPool<byte>.Create().Rent(1048576) | ใช่ | — |
ArrayPool<byte>.Create().Rent(1048577) | ไม่ | 1,048,608 B |
เพดานของ ArrayPool<byte>.Shared อยู่ที่ 1,073,741,824 byte พอดี คือ 1 GiB ไม่ใช่ 1 MB และตัวเลขนี้ตรงกับซอร์สของ .NET 10 ที่ประกาศ NumBuckets = 27 โดยคำนวณจาก 1024*1024*1024 + 1
แถวที่เกินเพดานไป1 byte ยังน่าสนใจกว่า Rent(1073741825) คืน array ที่ Length เท่ากับ 1,073,741,825 เป๊ะ ไม่ปัดขึ้น bucket เพราะมันไม่ได้ผ่าน bucket เลย มันคือ new byte[…] ที่ถูกส่งกลับมาให้ตรงๆ
ArrayPool<T>.Create() สร้าง ConfigurableArrayPool ซึ่งเป็นคนละ class กับคลังที่ Shared คืนมา และค่า default ของมันคือ 1,048,576 byte วัดได้ว่า 1,048,576 เก็บเข้าคลัง ส่วน 1,048,577 ไม่เก็บและจอง 1,048,608 byte ทุกครั้ง
เลข 1 MB ที่ถูกเล่าต่อกันมาว่าเป็นเพดานของ Shared จึงเป็นเลขที่ถูก แต่ถูกของคลังคนละใบ
ทั้งเลข 257 และเลข 1 GiB เป็น implementation detail ทั้งคู่ เขียนไว้ให้ผู้อ่านรู้ว่าขอบอยู่ตรงไหน ไม่ใช่ให้ออกแบบ code พึ่งพา
เมื่อ pool แพ้
หัวข้อที่มีชื่อว่า “เมื่อ pool แพ้”สามเคสที่วัดแล้วการยืมไม่ได้กำไร รวมถึงเคส buffer 64 byte ที่มันแพ้การจองทิ้งทุกรันจาก 6 รันอิสระ
เคสที่1 buffer เล็ก ที่ 64 byte สิ่งที่ pool ลบออกไปได้คือ 88 byte ต่อรอบ ซึ่งคือขนาดของ new byte[64] บนเครื่องนี้ (M5.15) แลกกับสัญญาทุกข้อในบทนี้ ขณะที่ stackalloc byte[64] ให้ 0 byte เท่ากันโดยไม่ต้องรับสัญญาอะไรเลย
ฝั่งเวลาก็ไม่เข้าข้าง pool เช่นกัน แต่ตัวเลขฝั่งนั้นวัดบนเครื่องที่มี agent อื่นรัน benchmark พร้อมกัน (load average 4.4 ถึง 14.0 ตลอดการวัด) ค่า ns สัมบูรณ์จึงซ้ำไม่ได้และ ถูกตัดออกจากบทนี้ทั้งชุดตามกฎข้อ 2 ของเส้นขอบเขตที่บท 1 ประกาศไว้ สิ่งเดียวที่เหลืออ้างได้คือทิศทาง คือ ที่ 64 byte เส้นทางยืมคืนแพ้เส้นทาง new ทิ้ง ทุกรันจาก 6 รันอิสระ และที่ 128 byte แยกกันไม่ออก
เคสที่สอง เลือก MemoryPool<T> เพราะ API สะอาดกว่า MemoryPool<byte>.Shared บน .NET 10 คือ System.Buffers.ArrayMemoryPool<byte> และ Rent ของมันคืน IMemoryOwner<byte> ซึ่งเป็น object ที่ต้องจองใหม่ทุกครั้ง
| input ที่วัด | byte ที่วัดได้ |
|---|---|
MemoryPool<byte>.Shared.Rent(128) + Dispose × 1 รอบ | 24 B |
MemoryPool<byte>.Shared.Rent(128) + Dispose × 1,000 รอบ | 24,000 B |
ArrayPool<byte>.Shared.Rent(128) + Return × 1,000 รอบ | 0 B |
ราคานี้ซื้ออะไรมาบ้าง Memory.Length ของ MemoryPool ตัดพอดีที่ 128 แทนที่จะเป็นความยาวของ bucket ซึ่งเป็นข้อดีจริงและเป็นข้อดีที่ตรงกับกับดักหัวข้อที่สองของบทนี้พอดี แต่ก็ไม่ใช่เสมอไป Rent(1000) ยังได้ Memory.Length เท่ากับ 1024 ส่วน MaxBufferSize ของมันคือ 2,147,483,591
ข้อสรุปนี้ผูกกับ MemoryPool<byte>.Shared บน .NET 10 เท่านั้น ไม่ใช่คำตัดสินว่า MemoryPool แย่กว่า ArrayPool เสมอ
เคสที่สาม หน่วยความจำที่ถูกกักไว้ ยืม array 8 MB ครั้งเดียวแล้วคืน แล้วบังคับ Gen2 GC แบบ blocking
| จังหวะที่วัด | GC.GetTotalMemory(true) |
|---|---|
| ก่อนยืม | 54,344 B |
หลัง Rent(8MB) + Return แล้วบังคับ Gen2 GC | 8,444,800 B |
| หลังบังคับ Gen2 GC รอบที่ 2, 3, 4 | 8,444,800 B |
| ในรันแยกที่รอถึง 95 วินาที บังคับ Gen2 GC รวม 14 ครั้ง | 8,444,968 B |
ส่วนต่างราว 8.39 MB คือของที่ยังอยู่ในคลัง ไม่ใช่ของที่ code คุณถืออยู่ เทียบกับ new byte[8MB] แล้วปล่อยทิ้ง ซึ่ง GC เก็บคืนหมดจนตัวเลขไม่ขยับ
ตัวเลขฐานของการทดลองนี้ไม่ซ้ำข้ามรัน (54,344 กับ 62,568 byte) สิ่งที่ซ้ำได้คือส่วนต่าง และ ห้ามอ่านผลนี้ว่า pool ไม่เคยคืนหน่วยความจำ เครื่องที่วัดยังมีหน่วยความจำเหลือ 6.8 GB runtime มี trim heuristic ที่ผูกกับแรงกดดันหน่วยความจำ ซึ่งการทดลองนี้ไม่ได้สร้างขึ้นมาเลย
กฎตัดสินใจที่ตัวเลขชุดนี้รองรับ
หัวข้อที่มีชื่อว่า “กฎตัดสินใจที่ตัวเลขชุดนี้รองรับ”สรุปเป็นเกณฑ์ที่ใช้ตัดสินใจได้จริง โดยไม่เกินไปกว่าที่วัดมา
- ต่ำกว่าราว 128 byte ใช้
stackallocถ้า buffer อยู่ใน method เดียว หรือnewทิ้งไปเลยถ้าไม่อยู่ — pool ลบได้แค่ 88 byte ต่อรอบที่ 64 byte และแพ้ทุกรันจาก 6 รันอิสระ - ที่ 128 byte ฝั่ง byte pool ชนะขาด (152 byte ต่อรอบ เทียบ 0) แต่ถ้า buffer อยู่ใน method เดียว
stackallocให้ 0 เท่ากันโดยไม่มีสัญญาแนบ - 1 KB ขึ้นไป byte ที่ pool ลบออกต่อรอบโตไปตามขนาด buffer ตามที่บทนี้วัดไว้สามจุด คือ 88 byte ที่ 64 · 152 byte ที่ 128 · 1,048 byte ที่ 1,024 ขณะที่สัญญาที่ผู้ยืมต้องรักษายังเป็นชุดเดิมไม่ว่า buffer จะใหญ่แค่ไหน อัตราแลกเปลี่ยนจึงดีขึ้นเรื่อยๆ ตามขนาด — แต่นั่นเป็นข้อสรุปฝั่ง byte ล้วนๆ บทนี้ไม่ได้วัดเวลาที่ขนาดไหนเลย จึงไม่มีสิทธิ์บอกว่าตรงไหนคือจุดที่ pool “เร็วกว่า”
- ขอเผื่อให้ตรงขอบ bucket ขอ 1,024 ได้ 1,024 ส่วนขอ 1,025 ได้ 2,048
Returnอยู่ในfinallyเสมอ เป็นกฎโครงสร้าง ไม่ใช่กฎที่รอให้ตัวเลขมาเตือนclearArray: trueเมื่อ buffer เคยถือข้อมูลของลูกค้า และ เสมอ สำหรับ pool ของ reference type- ยืมพร้อมกันไม่เกิน 257 ใบต่อขนาด บนเครื่อง 8 core เครื่องนี้ ถ้างาน batch ต้องกางมากกว่านั้น ให้ออกแบบใหม่แทนที่จะพึ่งคลัง
ขีดจำกัดที่ต้องเขียนตรงๆ
หัวข้อที่มีชื่อว่า “ขีดจำกัดที่ต้องเขียนตรงๆ”สิ่งที่บทนี้ไม่มีหลักฐานรองรับ และจะไม่ถูกอ้างในบทที่เหลือ
- ห้ามอ้างว่า
ArrayPoolเร็วกว่า ที่ 64 byte แพ้ทุกรันจาก 6 รัน ที่ 128 byte แยกไม่ออก และตั้งแต่ 1 KB ขึ้นไปบทนี้ ไม่ได้วัดเวลาเลย จึงไม่มีขนาดไหนที่บทนี้อ้างได้ว่า pool เร็วกว่า สิ่งที่อ้างได้มีแต่ byte - บทนี้ไม่มีตัวเลข ns ให้อ้างสักตัว ทั้งชุดวัดบนเครื่องที่มี agent อื่นรัน benchmark พร้อมกัน (load 4.4–14.0) ซ้ำได้แค่ทิศทาง ราคาของ
Return(clearArray: true)ก็อยู่ในชุดนั้นและถูกตัดออกทั้งหมด - 152 / 88 / 40 byte ไม่ใช่ “ขนาดของ array” โดยทั่วไป ทุกตัวผูกกับ linux-x64 ที่
IntPtr= 8 บน .NET 10.0.10 (หัว array 24 byte บวก payload ที่ปัดขึ้นทีละ 8) - 257 ไม่ใช่ค่าคงที่ของ .NET และ 1 GiB กับ 1 MB เป็น implementation detail ทั้งคู่ เปลี่ยนได้ด้วย environment variable และเปลี่ยนได้ตาม version
- 768 byte ไม่ใช่ต้นทุนครั้งแรกแบบตายตัว ตัวเลขที่แยกก้อนได้และซ้ำได้คือ 608 + 184 = 792 byte
- ห้ามอ่านผล retention ว่า pool ไม่เคยคืนหน่วยความจำ สิ่งที่วัดได้คือส่วนต่างราว 8.39 MB ยังอยู่หลังบังคับ Gen2 GC 14 ครั้งใน 95 วินาที บนเครื่องที่ยังมีหน่วยความจำเหลือ 6.8 GB
MemoryPoolไม่ได้แย่กว่าArrayPoolเสมอ บทนี้วัดเฉพาะMemoryPool<byte>.Sharedบน .NET 10
บท 1 แสดงว่า escape analysis ของ .NET 10 ย้าย object ที่ไม่หลุดไปวางบน stack ได้ และบท 2, 3, 8 เห็นผลนั้นชัด บทนี้ลองซ้ำแล้ว ไม่เห็นเลยสักเคส
| input ที่วัด | byte ที่วัดได้ |
|---|---|
new P { X = i, Y = i + 1 } (class สอง int) × 10,000 รอบ | 240,000 B |
object o = i (box ของ int) × 10,000 รอบ | 240,000 B |
new int[4] × 10,000 รอบ | 400,000 B |
new byte[64] ที่ไม่หลุดออกนอก method ต่อรอบ | 88 B |
new byte[64] ที่หลุดผ่าน Sink.Obj ต่อรอบ | 88 B |
version ที่หลุดกับไม่หลุดให้ 88 byte ต่อรอบเท่ากันเป๊ะที่ N = 1 / 100 / 10,000 / 1,000,000 และการสลับ DOTNET_TieredPGO กับ DOTNET_JitObjectStackAllocation ครบทั้งสี่คู่ค่า ไม่ทำให้ตัวเลขขยับเลยสัก byte
คำอธิบายที่ข้อมูลรองรับคือ รูปร่างของสิ่งที่วัดต่างกัน บทอื่นวัด object และ box ก้อนเล็กที่ไม่หลุด ส่วนบทนี้วัด array
ประโยคที่เขียนได้คือ “วัดบนเครื่องนี้ไม่เห็น” ไม่ใช่ “ไม่มี” และ ห้ามอ้างว่า .NET 10 stack-allocate array ที่ไม่ escape ให้เรา วินัย static object? _sink ยังบังคับทุก snippet ของบทนี้ตามกฎของคอร์ส แต่ต้องพูดตรงๆ ว่า ในบทนี้มันไม่ได้เป็นตัวชี้ขาด เพราะตัวเลขไม่ขยับไม่ว่าจะมีหรือไม่มี
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”สิ่งที่บทนี้วัดไว้ให้ครบแล้ว และสิ่งที่บทถัดไปจะยกไปใช้ต่อ
- loop ที่จอง
new byte[128]ต่อบรรทัด วัดได้ 152,000,000 byte ที่หนึ่งล้านบรรทัด ส่วนเส้นทางยืมคืนวัดได้ 0 byte ทุก N ตั้งแต่ 1 ถึง 1,000,000 Rentคืนของที่ใหญ่กว่าที่ขอเสมอ ขอ 1,025 ได้ 2,048 กฎเดียวที่ใช้ได้คือพกwrittenไว้เอง- ต้นทุนครั้งแรกที่แยกก้อนได้คือ 608 + 184 = 792 byte บน thread ที่วัด โดยก้อน 608 byte ของ
Sharedเป็นต้นทุนของคลัง จ่ายครั้งเดียวต่อTต่อ process ส่วนค่าตั้งคลังฝั่ง thread ยังจ่ายซ้ำ — บท 8 วัดต่อว่า thread ที่เพิ่งสร้างจ่าย 672 byte สำหรับRentครั้งแรก ทั้งที่คลังของTนั้นถูกสร้างไปนานแล้ว (วัดบน thread ใหม่2 thread ได้ 672 ทั้งคู่) - ข้อมูลของผู้เช่าคนก่อนยังอยู่ครบ 41 byte และหลุดออกมาเป็น string จริงได้
- คืนซ้ำ ใช้ต่อหลังคืน และคืนของที่ไม่ได้ยืมมา ทั้งสามอย่างผ่านไปได้โดยไม่มี exception
- ลืมคืนแล้ววัดที่ 10 รอบ ได้ 1,920 byte เท่ากันทั้ง2 version ต่างกันจริงที่ 1,519,848 byte เมื่อถึง 10,000 รอบ
- เพดานของ
Sharedคือ 1,073,741,824 byte ไม่ใช่ 1 MB และความจุคือ 257 ใบต่อขนาดบนเครื่อง 8 core เครื่องนี้
บท 6 ย้ายสนามจาก buffer byte ไปที่งาน string ซึ่งเป็นที่ที่ hot path ของ domain Order ใช้ byte มากที่สุด และเป็นบทที่ Span<char> กลับมาทำงานเต็มตัวอีกครั้ง คราวนี้กับการต่อ string การค้น dictionary ด้วย key ที่ยังไม่ได้สร้าง และการค้นชุดอักขระ
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- ArrayPool<T>.Rent(Int32) Method — Microsoft Learn (2025-07-01 อัปเดต 2026-05-27) — ระบุว่า array ที่คืนมายาว “at least the requested length” และ “may not be zero-initialized” ซึ่งเป็นที่มาของทั้งตาราง bucket และ stale data
- ArrayPool<T>.Return(T[], Boolean) Method — Microsoft Learn (2025-07-01 อัปเดต 2026-05-27) — เรียกการคืนซ้ำและการใช้ต่อหลังคืนว่าเป็น high-severity security issue พร้อมอ้าง CWE-415 และ CWE-416
- ArrayPool<T>.Shared Property — Microsoft Learn (2025-07-01 อัปเดต 2026-05-27) — ระบุว่าคลังนี้ “may hand back a larger array” กว่าที่ขอ
- dotnet/runtime v10.0.0 — SharedArrayPool.cs (tag v10.0.0, .NET 10 GA พ.ย. 2025) —
NumBuckets = 27ที่คำนวณจาก 1 GiB + 1, ค่า default ของs_maxArraysPerPartition= 32 และ environment variable ทั้งสองตัวที่ใช้ยืนยันกลไก 257 ใบ - Memory<T> and Span<T> usage guidelines — Microsoft Learn (2025-04-09 อัปเดต 2026-03-30) — กฎข้อ 7 เรื่องความเป็นเจ้าของ buffer ผ่าน
IMemoryOwner - MemoryPool<T>.Rent(Int32) Method — Microsoft Learn (2025-07-01 อัปเดต 2026-05-27) — ระบุว่าคืนค่าเป็น
IMemoryOwner<T>ซึ่งเป็น object ที่ต้องจองใหม่ทุกครั้ง
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3ทีมของคุณเปลี่ยน code จาก new byte[128] เป็น Rent/Return แล้วเขียนยูนิตเทสที่รัน loop 10 รอบเพื่อยืนยันว่า allocation ไม่ถอยหลัง เทสผ่าน แต่บน production ตัวเลขกลับแย่ลง คำอธิบายใดที่ข้อมูลของบทนี้รองรับ