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

ArrayPool: การ​ยืม​ที่​มี​สัญญา​แนบ​มา

บท 4 ปิด​ด้วย​ข้อ​จำกัด​ข้อ​เดียว​ที่ stackalloc แก้​ให้​ไม่​ได้ คือ buffer ที่​ใหญ่​เกิน​กว่า​จะ​วาง​บน stack และ buffer ที่​ต้อง​มี​ชีวิต​ข้าม method บท​นี้​เอา​เครื่องมือ​ที่​ตอบ​โจทย์​นั้น​ขึ้น​โต๊ะ คือ​คลัง array ที่​ให้​ยืม​และ​คืน

ตัวเลข​ฝั่ง​ดี​ของ​มัน​แรง​กว่า​ทุก​เทคนิค​ที่​คอร์ส​นี้​ผ่าน​มา loop ที่​จอง new byte[128] หนึ่ง​ใบ​ต่อ​หนึ่ง​บรรทัด​ออร์เดอร์ วัด​ได้ 152,000,000 byte ที่​หนึ่ง​ล้าน​บรรทัด ส่วน loop เดียวกัน​ที่​เปลี่ยน​เป็น​ยืม​คืน วัด​ได้ 0 byte

แต่​คำ​ที่​ต้อง​อ่าน​ให้​ครบ​คือ​คำ​ว่า ยืม บท​นี้​จึง​ใช้​เวลา​ส่วน​ใหญ่​ไป​กับ​สัญญา​ที่​แนบ​มา​กับ​การ​ยืม ไม่ใช่​กับ​ตัวเลข 0 ที่​หน้า​ปก เพราะ​ทุก​ข้อ​ของ​สัญญา​นั้น เมื่อ​ถูก​ละเมิด runtime จะ​ไม่​บอก​อะไร​เลย

📦 kaen-food-ordering

ทั้ง​คอร์ส​เดิน​บน 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] × NRent(128)/Return × Nstackalloc byte[128] × N
N = 1152 B0 B0 B
N = 101,520 B0 B0 B
N = 10015,200 B0 B0 B
N = 1,000152,000 B0 B0 B
N = 10,0001,520,000 B0 B0 B
N = 100,00015,200,000 B0 B0 B
N = 1,000,000152,000,000 B0 B0 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 ที่​ได้เกิน​มา
000
11615
7169
15161
16160
173215
10012828
1281280
129256127
1,0001,02424
1,0241,0240
1,0252,0481,023 (99.8%)
10,00016,3846,384 (63.8%)
65,53665,5360
100,000131,07231,072
1,000,0001,048,57648,576
1,048,5761,048,5760
1,048,5772,097,1521,048,575 (100%)

bucketbucketช่อง​ขนาด​กำลัง​สอง​ของ pool ที่​คำขอ​ถูก​ปัด​ขึ้น​ไป​หา ทำให้ `Length` ของ array ที่​ได้​ใหญ่​กว่า​ที่​ขอ​เสมอ คือ​ช่อง​เหล่า​นี้ และ​ผล​ของ​มัน​ตรง​ไป​ตรง​มา คำขอ​ที่​เกิน​ขอบ bucket ไป1 byte จะ​ได้ array ที่​ใหญ่​เป็น​สอง​เท่า​ของ bucket เดิม

จาก​ตาราง​นี้​ได้​กฎ​ข้อ​เดียว​ที่​บังคับ​ใช้​ตลอด​ชีวิต​ของ buffer ที่​ยืม​มา คือ ต้อง​พก​จำนวน byte ที่​เขียน​จริง​ไว้​เอง ไม่​ว่า​จะ​เป็น​ตัวแปร written หรือ​การ​ส่ง buf.AsSpan(0, written) ต่อ​ไป​ทุก​ทอด เพราะ buf.Length ตอบ​คำถาม “ช่อง​นี้​ใหญ่​แค่​ไหน” ไม่​ได้​ตอบ​คำถาม “ข้อมูล​ของ​ฉัน​ยาว​แค่​ไหน”

⛔ ทุก bug ที่​เหลือ​ของ​บท​นี้ ตั้ง​อยู่​บน​ความ​ต่าง​ระหว่าง Length กับ written

Rent(64) แล้ว​เขียน​บรรทัด​ออร์เดอร์ 1042|ต้มยำกุ้ง|2|18000 ตาม​ด้วย​ขึ้น​บรรทัด​ใหม่ ได้​ข้อมูล​จริง 41 byte ขณะ​ที่ buf.Length = 64 (M5.4)

ระยะ​ห่าง 23 byte นั้น​คือ​ที่​ที่​ข้อมูล​ของ​ผู้​เช่า​คน​ก่อน​นอน​รอ​อยู่ และ​เป็น​ที่​ที่​ข้อมูล​ของ​คุณ​จะ​นอน​รอ​ผู้​เช่า​คน​ถัด​ไป

ต้นทุน​ตั้งต้น​ของ 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 ที่​วัด​ได้
อ่าน​คลัง​ครั้ง​แรก​ใน processArrayPool<Q1>.Shared608 B
ยืม​ครั้ง​ที่ 1pool.Rent(128)184 B
ยืม​ครั้ง​ที่ 2pool.Rent(128)0 B
ยืม​ครั้ง​ที่ 3pool.Rent(128)0 B
รวม​สอง​ก้อน​แรกShared + Rent#1792 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 ต่อ​รอบ
1buffer ใบ​แรก​ของ pool ที่​ยัง​ไม่​เคย​ใช้152 B768 B
2buffer ใบ​ที่​สอง152 B0 B
3–20ทุก​ใบ​ถัด​ไป152 B0 B
21–120ทุก​ใบ​ถัด​ไป152 B0 B

เส้นทาง​ที่​จอง​ใหม่​จ่าย 152 byte ทุกรอบ​ไม่มี​วัน​จบ เส้นทาง​ที่​ยืม​จ่าย​ก้อน​เดียว​แล้ว​เป็น​ศูนย์​ตลอด จุด​ที่​ยอด​สะสม​ของ​สอง​เส้น​ตัด​กัน​คือ รอบ​ที่ 6

เลข 768 ไม่ใช่​ค่า​คงที่ และ​เลข 6 ไม่ใช่​ค่าที่​วัด​มา

สอง​ประโยค​นี้​ต้อง​อ่าน​คู่​กับ​ตัวเลข​ข้าง​บน​เสมอ

  • 768 B เป็น​ค่าที่​อ่าน​ได้​จาก​การ​วัด​ใน loop ไม่ใช่​ค่า​คงที่​ของ pool ตัวเลข​ที่​แยก​เป็น​ก้อน​ได้​และ​วัด​ซ้ำ​ได้​คือ Shared 608 B บวก Rent#1 184 B รวม 792 B ส่วน​ยอด​รวม​ของ “pool ใหม่​บวก​ยืม​คืน 12 ครั้ง” วัด3 instantiation แรก​แล้ว ไม่​ซ้ำ​กัน จึง​ไม่มี​สิทธิ์​ขึ้น​หน้า​นี้
  • “รอบ​ที่ 6” เป็นการ​เทียบ​เลข​สอง​ตัว​ที่​วัด​มา ไม่ใช่​ผล​การ​วัด​ชิ้น​ที่​สาม มัน​มา​จาก 792 B ที่​จ่าย​ครั้ง​เดียว เทียบ​กับ 152 B ต่อ​รอบ​ที่​จ่าย​ไม่รู้​จบ ถ้า hot path ของ​คุณ​เรียก​ไม่​ถึง​สิบ​ครั้ง​ต่อ​การ​รัน​หนึ่ง​ครั้ง pool ไม่​ได้​ช่วย​อะไร​เลย

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); // หลังล้างแล้ว
True
1042|ต้มยำกุ้ง|2|18000
7|ส้มตำ|1|6000
้ง|2|18000
0

สาม​อย่าง​ที่​ตาราง​ไม่​พอ​จะ​เล่า จึง​ต้อง​อ่าน​จากผลลัพธ์ตรงๆ

  • 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-Return
ArrayPool<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-Return
ArrayPool<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 ทับ​ผู้​เช่า​ใหม่​ที่​เขียน 200fresh[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]) ความ​ยาว​ไม่​ตรง bucketArgumentException

ข้อความ​ของ 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

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 = 101,920 B1,920 B
N = 10019,200 B27,256 B
N = 1,000192,152 B343,848 B
N = 10,0001,920,152 B3,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 ที่​รอด​จาก​เทส​ไป​ถึง production

bug ส่วน​ใหญ่​ที่​คอร์ส​นี้​ไล่​จับ ให้​ตัวเลข​ผิด​ทันที​ที่​วัด bug นี้​ไม่ใช่

ที่ N = 10 คุณ​จะ​ได้ 1,920 byte เท่า​กัน​ทั้ง2 version ไม่​ว่า​จะ​วัด​กี่​รอบ ราคา​เริ่ม​ปรากฏ​ก็​ต่อ​เมื่อ​ของ​สำรอง​ใน​คลัง​หมด และ​มัน​หมด​ตอน​ที่​ระบบ​รับ​โหลด​จริง​เท่านั้น

วิธี​เดียว​ที่​ป้องกัน​ได้​คือ​กฎ​เชิง​โครงสร้าง Return อยู่​ใน finally เสมอ ไม่ใช่​กฎ​เชิง​ตัวเลข

สอง​ตัวเลข​ที่ folklore บอก​ผิด​ทั้ง​คู่ วัด​จาก​การ​ยืม​พร้อม​กัน​หลาย​ใบ และ​จาก​การ​ยืม array ขนาด​ครึ่ง​กิกะ byte

คลัง​ไม่​ได้​เก็บ array ไว้​ไม่​จำกัด​ใบ วัด​โดย​การ​ยืม​พร้อม​กัน k ใบ​แล้ว​คืน​ทั้งหมด ทำซ้ำ 20 รอบ แล้ว​อ่าน byte เฉลี่ย​ต่อ​รอบ

input ที่​วัด (k ใบ​พร้อม​กัน ขนาด 128)byte ต่อ​รอบ
k = 1 ถึง 2570.0 B
k = 258152.0 B
k = 260456.0 B
k = 2641,064.0 B
k = 2722,280.0 B
k = 2884,712.0 B
k = 3209,576.0 B
k = 38419,304.0 B
k = 51238,760.0 B
k = 1,024116,584.0 B
k = 4,096583,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[…] ที่​ถูก​ส่ง​กลับ​มาให้ตรงๆ

เลข 1 MB ที่​พูด​กัน​ทั่วไป เป็น​ค่า default ของ pool คนละ​ตัว

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 พึ่งพา

สาม​เคส​ที่​วัด​แล้ว​การ​ยืม​ไม่​ได้​กำไร รวม​ถึง​เคส 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 GC8,444,800 B
หลัง​บังคับ Gen2 GC รอบ​ที่ 2, 3, 48,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
⛔ ผล​ลบ​ต่อ​สมมติฐาน​ของ​คอร์ส​เอง — บท​นี้​วัด​ไม่​พบ stack allocation เลย

บท 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 ตัวเลขกลับแย่ลง คำอธิบายใดที่ข้อมูลของบทนี้รองรับ