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

ValueTask: ไม่ใช่ "Task รุ่น​ประหยัด"

คำ​แนะนำ​ที่​ได้ยิน​บ่อย​ที่สุด​เรื่อง async บน hot path คือ “เปลี่ยน​จาก Task เป็น ValueTask แล้ว​จะ alloc น้อย​ลง” บท​นี้​เอา​คำ​แนะนำ​นั้น​ไป​วัด​บน domain Order แล้ว​พบ​ว่า​มัน​เข้าใจ​กลับ​ด้าน​อยู่​สอง​ชั้น​พร้อม​กัน

ชั้น​แรก บน​เส้นทาง​ที่ await จริง ValueTask แพง​กว่า Task ไม่ใช่​ถูก​กว่า วัด​ได้ 160 เทียบ 168 byte ต่อ op และ​เมื่อ cross-check ด้วย await บน Task จริง​แทน custom awaiter ส่วน​ต่าง​ยิ่ง​กว้าง​ขึ้น​เป็น 80 เทียบ 112 byte ต่อ op สุทธิ

ชั้น​ที่​สอง บน​เส้นทาง​ที่​เสร็จ​แบบ synchronous Task ก็​ฟรี​อยู่​แล้ว​บ่อยกว่าที่​คำ​แนะนำ​บอก​มาก และ​ที่​แปลก​กว่า​นั้น​คือ “ฟรี​หรือ​ไม่​ฟรี” ขึ้น​กับ ค่าที่​คืน ไม่ใช่​ชนิด​ที่​ประกาศ — async Task<int> ที่​คืน 3 วัด​ได้ 0 byte ต่อ op ส่วนตัว​เดียวกัน​ที่​คืน 9 วัด​ได้ 72

และ​ข้อ​สรุป​ที่​คม​ที่สุด​ของ​บท​นี้​คือ สิ่ง​ที่​ลบ 160 byte ต่อ op ออก​ไป​ได้​จริง​บน​เส้นทาง async ไม่ใช่​ตัว ValueTask แต่​เป็น PoolingAsyncValueTaskMethodBuilder ซึ่ง​เป็น​คนละ​ของ​กัน​คนละ​เรื่อง และ​มี​ราคา​ที่​ต้อง​จ่าย​คนละ​แบบ

📦 kaen-food-ordering

บท​นี้​ยัง​เดิน​บน hot path เส้น​เดิม​ของ domain Order คือ​การ​อ่าน​บรรทัด​ออร์เดอร์​ขึ้น​มา​แล้ว​ยก​จาก cache (repo ตัวอย่าง .NET กำลัง​จัด​ทำ) รูปทรง​ที่​วัด​คือ cache lookup ที่ hit แล้ว​เสร็จ​ทันที และ miss แล้ว​ต้อง​รอ

object ที่​ทุก​ตัวเลข​ใน​บท​นี้​อ้าง​ถึง​มี​ตัว​เดียว และ​มัน​คงที่​ทั้ง​บท

public sealed record OrderLine(int OrderId, string ItemName, int Quantity, int PriceSatang);
static readonly OrderLine Hit = new(1042, "ต้มยำกุ้ง", 2, 18000);
static readonly Dictionary<int, OrderLine> Cache = new() { [1042] = Hit };

ทุก​ตาราง​ใน​บท​นี้​มี column “input ที่​วัด” ซึ่ง​พิมพ์​นิพจน์​ที่​ถูกวัดจริงไว้ตรงๆ และ​มี block ID กำกับ​ไว้​เหนือ​ตาราง

ไวยากรณ์ C# 11–14 ที่​ใช้​ใน​บท​นี้ (record แบบ positional, collection expression, target-typed new) คอร์ส​นี้ ใช้ โดย​ไม่​หยุด​อธิบาย ถ้า​เจอ​ของ​ที่​ไม่​คุ้น​ให้​เปิด C# 8 → 14: อะไร​เปลี่ยน​ไป​บ้าง ควบคู่​ไป

เครื่องมือ​วัด​ของ​บท​นี้ และ​เหตุผล​ที่​มัน​ต้อง​มี Gate

หัวข้อ​ที่​มีชื่อ​ว่า “เครื่องมือ​วัด​ของ​บท​นี้ และ​เหตุผล​ที่​มัน​ต้อง​มี Gate”

โครง​วัด​ที่​ทุก​ตัวเลข​ใน​บท​นี้​ออก​มา​จาก​มัน พร้อม​สอง​อย่าง​ที่​ถ้า​ขาด​ไป ตัวเลข​ทั้ง​บท​จะ​ผิด​โดย​ไม่มี​คำ​เตือน

การ​วัด allocation ของ code async มี​ปัญหา​เฉพาะ​ตัว​สอง​ข้อ ข้อ​แรก​คือ allocationallocationการ​ขอ​พื้นที่​ก้อน​ใหม่​บน managed heap หนึ่ง​ครั้ง นับ​สะสม​ต่อ thread โดย runtime และ​เป็น​ตัวเลข​เดียว​ใน​คอร์ส​นี้​ที่​วัด​ซ้ำ​ได้​ถึง​ระดับ byte ที่​เกิด​จาก continuation จะ​ถูก​นับ​ด้วย GC.GetAllocatedBytesForCurrentThread ก็​ต่อ​เมื่อ continuation รัน​บน thread เดิม ข้อ​ที่​สอง​คือ​ถ้า​เส้นทาง​ที่​ต้องการ​วัด​เสร็จ​เร็ว​เกิน​ไป มัน​จะ​ไม่​เคย​เดิน​เข้าไป​ใน​สาขา async เลย

โครง​วัด​จึง​ต้อง​มี awaitable ที่ ไม่​เสร็จ​จนกว่า​จะ​สั่ง และ​ปล่อย​ให้ continuation รัน inline บน thread เดิม

// _sink เป็น static เพื่อกันไม่ให้ escape analysis ของ .NET 10 พิสูจน์ว่า object ไม่หลุด
// แล้วย้ายมันไปวางบน stack ซึ่งจะทำให้ตัวเลขที่เราต้องการวัดหายไปเงียบๆ โดยไม่มี warning
static object? _sink;
static object? _sink2;
static int _sinkInt;
// awaitable ที่ยังไม่เสร็จเสมอ จนกว่าจะเรียก Release()
sealed class Gate : ICriticalNotifyCompletion {
Action? _c;
public Gate GetAwaiter() => this;
public bool IsCompleted => false;
public void GetResult() { }
public void OnCompleted(Action c) => _c = c;
public void UnsafeOnCompleted(Action c) => _c = c;
public void Release() { var c = _c; _c = null; c!.Invoke(); }
}
static readonly Gate G = new();
// harness: อุ่นด้วยการเรียกซ้ำคั่นด้วย Thread.Sleep(250) สองรอบ ให้ tiered JIT rejit เสร็จก่อนวัด
static void Report(string label, Action body, int n) {
for (int i = 0; i < 60_000; i++) body();
Thread.Sleep(250);
for (int i = 0; i < 20_000; i++) body();
Thread.Sleep(250);
for (int i = 0; i < 20_000; i++) body();
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
long a1 = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < n; i++) body();
long t1 = GC.GetAllocatedBytesForCurrentThread() - a1;
Console.WriteLine($"{label} = {(double)t1 / n} B/op");
}

ตัวเลข 60_000, 20_000 และ 250 ใน​โครง​นี้​เป็น parameter ของ​โครง​วัด ไม่ใช่​ผล​การ​วัด​และ​ไม่ใช่​ตัวเลข​ประสิทธิภาพ สิ่ง​ที่​ทำให้​โครง​นี้​อ่าน​เลข tier-1 ได้​คือ Thread.Sleep(250) สอง​รอบ​ที่​คั่น​อยู่ ไม่ใช่​จำนวน​รอบ ซึ่ง​เป็น​บทเรียน​ที่​บท 1 วาง​ไว้​แล้ว

🔁 บท​นี้​เกือบ​ชิป​เลข tier-0 ไป​เหมือน​กัน

ตอน​วัด​ชุด cross-check ครั้ง​แรก โครง​วัด​ยัง​อุ่น​สั้น​กว่า​นี้ input ที่​วัด​คือ block F1 (RealTaskPath(tcs.Task) แล้ว SetResult()) block ละ 50,000 รอบ วัด3 block ติด​กัน ตัวเลข​ข้าง​ล่าง​เป็น ยอด​รวม​ต่อ block ไม่ใช่​ต่อ op

  • tier-0 (อุ่น​ไม่​พอ): block แรก​อ่าน​ได้ 8,542,464 byte
  • tier-1 (อุ่น​ถึง​แล้ว): 2 block ถัด​มา​อ่าน​ได้ 8,400,000 byte เท่า​กัน​ทั้ง​คู่

ส่วน​ต่าง​นั้น​คือ code ที่​ยัง​ไม่​ถูก​เลื่อน​ขั้น หลัง​เพิ่ม​รอบ​อุ่น​และ​ใส่ Thread.Sleep(250) สอง​รอบ​ให้ tiered JIT rejit เสร็จ​ก่อน ทั้ง3 block อ่าน​ได้ 8,400,000 byte เท่า​กัน​เป๊ะ ทั้ง​ใน run3 และ run4

ทั้ง​สอง​เลข​เป็น​ของ​จริง เลข tier-0 คือ​สิ่ง​ที่​คน​อุ่น​ไม่​พอ​จะ​ได้ ส่วน​เลข tier-1 คือ​สิ่ง​ที่ process ที่​รัน​ยาว​จะ​เจอ บท​นี้​ใช้​เลข tier-1 ทั้ง​บท และ​พิมพ์​เลข tier-0 ไว้​ให้​เห็น​ว่า​มัน​มี​อยู่

ข้อ​สรุป​ทั้ง​บท​อ่าน​ได้​จาก​ที่​นี่​ก่อน แล้ว​อีก​หก​หัวข้อ​ถัด​ไป​คือ​หลักฐาน​ที่​ทำให้​แต่ละ​ช่อง​เป็น​ตัวเลข​นั้น

รูปทรง​ที่​วัด​คือ cache lookup จริง​ของ Order สอง​รูปแบบ

static async Task<OrderLine?> LookupTaskAsync(int id) {
if (Cache.TryGetValue(id, out var hit)) return hit;
await G;
return null;
}
static async ValueTask<OrderLine?> LookupValueTaskAsync(int id) {
if (Cache.TryGetValue(id, out var hit)) return hit;
await G;
return null;
}
input ที่​วัดTaskValueTaskpooled ValueTask
เสร็จ​ทันที คืน Hit (Hit = new OrderLine(1042, "ต้มยำกุ้ง", 2, 18000))72 B/op0 B/op0 B/op
await G ก่อน แล้ว​คืน Hit160 B/op168 B/op0 B/op

ช่อง​ที่ folklore สัญญา​ไว้​คือ column ValueTask แถว​บน (0 B/op) และ​มัน​เป็น​จริง ส่วน column ValueTask แถว​ล่าง (168 B/op) คือ​ช่อง​ที่ folklore ไม่​เคย​พูด​ถึง

ทีนี้​ตาราง​เดียวกัน​แต่​เป็น non-generic ValueTask ซึ่ง​เป็น​ตัว​ที่​คน​เปลี่ยน​ทิ้ง​ไว้​เยอะ​ที่สุด​เพราะ​มัน​เปลี่ยน​ง่าย

input ที่​วัดTaskValueTaskผล​เทียบ
async (void) ที่ return ทันที0 B/op0 B/opเท่า​กัน​เป๊ะ
async (void) ที่ await G แล้ว​จบ160 B/op160 B/opเท่า​กัน​เป๊ะ
non-generic ValueTask ประหยัด​ได้0 byte ใน​ทุก​เส้นทาง​ที่​บท​นี้​วัด

async ValueTask (ไม่มี <TResult>) วัด​ได้ 0 B/op เท่ากับ Task เป๊ะ บน​เส้นทาง sync และ 160 B/op เท่ากับ Task เป๊ะ บน​เส้นทาง async

ไม่มี​เส้นทาง​ไหน​ใน​บท​นี้​ที่​มัน​ประหยัด​ได้​แม้แต่ byte เดียว การ​เปลี่ยน async Task เป็น async ValueTask โดย​ไม่มี​ค่า​คืน​กลับ จึง​เป็น​พิธีกรรม​ล้วนๆ ที่​แลก​ความ​อ่าน​ง่าย​กับ​ศูนย์

เอกสาร Microsoft Learn ของ ValueTask เขียนไว้ตรงๆ อยู่​แล้ว​ว่า​ไม่​แนะนำ​ให้​ใช้​ตัว​ที่​ไม่มี​ค่า​คืน​กลับ​เป็น​ค่า​เริ่มต้น สิ่ง​ที่​บท​นี้​เพิ่ม​เข้าไป​คือ​ตัวเลข​ที่​วัด​ได้​ว่า​คำ​เตือน​นั้น​มี​ราคา​เท่าไร คำ​ตอบ​คือ​ศูนย์ — คุณ​ไม่​ได้​อะไร​เลย

async Task ที่​เสร็จ​ทันที ฟรี​อยู่​แล้ว บ่อยกว่าที่​คำ​แนะนำ​บอก

หัวข้อ​ที่​มีชื่อ​ว่า “async Task ที่​เสร็จ​ทันที ฟรี​อยู่​แล้ว บ่อยกว่าที่​คำ​แนะนำ​บอก”

สิบ​เจ็ด​เคส​ที่​เสร็จ​แบบ synchronous ทั้งหมด ซึ่ง​แยก​ให้​เห็น​ว่า​อะไร​ทำให้ Task เสีย 72 byte และ​อะไร​ไม่

ทุก method ใน​ชุด​นี้​เขียน​ให้​เดิน​เข้า​สาขา​ที่​คืน​ค่า​ทันที​เสมอ โดย​มี await G วาง​ไว้​ใน​สาขา​ที่​ไม่มี​วัน​ถูก​เดิน เพื่อ​บังคับ​ให้ compiler ยัง​สร้าง state machine ให้​จริง

static async Task NgTaskSync() { if (Cache.Count >= 0) return; await G; }
static async ValueTask NgValueTaskSync() { if (Cache.Count >= 0) return; await G; }
static async Task<bool> TaskBoolSync() { if (Cache.Count >= 0) return true; await G; return false; }
static async Task<int> TaskInt3Sync() { if (Cache.Count >= 0) return 3; await G; return 0; }
static async Task<int> TaskInt18000Sync() { if (Cache.Count >= 0) return 18000; await G; return 0; }
static async ValueTask<int> ValueTaskInt18000Sync() { if (Cache.Count >= 0) return 18000; await G; return 0; }
static async Task<OrderLine?> TaskNullSync() { if (Cache.Count >= 0) return null; await G; return Hit; }
#input ที่​วัดB/op
A1async Task (void) sync0
A2async ValueTask (void) sync0
A3async Task<bool> => true0
A4async Task<int> => 30
A5async Task<int> => 972
A6async Task<int> => 1800072
A7async ValueTask<int> => 180000
A8async Task<OrderLine?> => null0
A9async Task<OrderLine?> => Hit72
A10async ValueTask<OrderLine?> => Hit0
A11pooled ValueTask<OrderLine?> => Hit0
A12Task.FromResult(Hit)72
A13new ValueTask<OrderLine?>(Hit)0
A14Task.FromResult(3)0
A15Task.FromResult(18000)72
A16Task.FromResult(true)0
A17Task.CompletedTask0

ตัวเลข​ทั้ง​ตาราง​มา​จาก delta หาร​ด้วย 100,000 iterations วัด3 block ติด​กัน​ใน process เดียว แล้ว​รัน​ซ้ำ 4 process แยก ได้​ตัวเลข​เท่า​กัน​ทุก​บรรทัด — diff ของ​ช่วง​นี้​ระหว่าง run3 กับ run4 คืน​ค่า 0

คู่​ที่​ต้อง​อ่าน​ช้าๆ คือ A4 กับ A5 method ตัว​เดียวกัน ชนิด​เดียวกัน ต่าง​กัน​แค่​ค่า​คงที่​ที่​คืน 3 กับ 9 แล้ว​อ่าน​ได้ 0 กับ 72 byte ต่อ op

เหตุผล​คือ runtime มี Task<int> ที่​สร้าง​ไว้​แล้ว​สำหรับ​ค่า​จำนวน​หนึ่ง เมื่อ​ค่าที่​คืน​อยู่​ใน​ชุด​นั้น ไม่มี​อะไร​ถูก​สร้าง​ใหม่ ขอบเขต​ที่​บันทึก​ไว้​จาก​การ​วัด​ชุด​นี้​บน .NET 10.0.10 คือ [−1, 8] และ​ตาราง​ข้าง​บน​คร่อม​ขอบ​บน​ของ​ช่วง​นั้น​ไว้​ทั้ง​สอง​ฝั่ง คือ 3 ซึ่ง​อยู่​ใน​ช่วง อ่าน​ได้ 0 ส่วน 9 ซึ่ง​พ้น​ช่วง​ไป​หนึ่ง อ่าน​ได้ 72

⛔ นี่​คือ implementation detail ห้าม​ออกแบบ code พึ่งพา

ขอบเขต [−1, 8] เป็น​สิ่ง​ที่ วัด​ได้ บน .NET 10.0.10 บน​เครื่อง Intel Core i7-7700 · Ubuntu 24.04.4 LTS (WSL2) · linux-x64 ไม่ใช่​สัญญา​ที่​เอกสาร​ให้​ไว้ รุ่น​ถัด​ไป​เปลี่ยน​ได้​โดย​ไม่​ผิด​สัญญา​อะไร​เลย

สิ่ง​ที่​เอา​ไป​ใช้ได้​จริง​คือ​ข้อ​สรุป​เชิง​คุณภาพ: “async Task<T> ที่​เสร็จ​ทันที​จะ​ฟรี​หรือ​ไม่ ขึ้น​กับ​ค่าที่​คืน ไม่ใช่​ชนิด​ที่​ประกาศ” — และ​นั่น​แปล​ว่าการ​เดา​ด้วย​ตา​จาก​ซิก​เนเจอร์อย่าง​เดียว​เชื่อ​ไม่​ได้ ต้อง​วัด

สิ่ง​ที่​เอา​ไป​ใช้​ไม่​ได้​คือ​การ​เขียน code ที่ ตั้งใจ คืน​ค่า​ใน​ช่วง [−1, 8] เพื่อ​ประหยัด byte

รายการ​ที่​วัด​แล้ว​ฟรี​จริง​บน​เส้นทาง sync จึง​ยาวกว่าที่​คำ​แนะนำ​ทั่วไป​ทำให้​เชื่อ ทั้ง async Task เปล่า, async Task<bool> ที่​คืน true, async Task<int> ที่​คืน​ค่า​ใน​ช่วง​ที่ cache ไว้, async Task<OrderLine?> ที่​คืน null และ Task.CompletedTask — ทั้งหมด​อ่าน​ได้ 0 B/op

72 byte ต่อ op โผล่​เฉพาะ​ตอน​ที่ T เป็น​ค่าที่ cache ไม่​ได้ เช่น OrderLine ที่​ยก​มา​จาก cache และ​เลข 72 นั้น​เท่ากับ​ขนาด​ของ Task<T> หนึ่ง​ตัว​พอดี ซึ่ง​วัด​แยก​ไว้​ใน process เดียวกัน​ได้ 72 B

ช่อง​เดียว​ใน​เมทริกซ์​ที่ ValueTask ชนะ​ขาด และ​ขนาด​ของ​ชัยชนะ​นั้น​เมื่อ​คูณ​ด้วย​ปริมาณ​งาน​จริง

ช่อง ValueTask ของ​แถว​แรก​ใน​เมทริกซ์ (เสร็จ​ทันที คืน Hit) คือ​ช่อง​ที่ ValueTaskValueTaskชนิด​คืน​ค่า async ที่​เป็น struct ซึ่ง​ลบ​การ​จอง​ได้​เฉพาะ​เส้นทาง​ที่​เสร็จ​แบบ synchronous และ​มี​กฎ​การ​ใช้​ที่​ละเมิด​แล้ว​ไม่​ระเบิด ทำ​ใน​สิ่ง​ที่​มัน​ถูก​ออกแบบ​มา​ให้​ทำ คือ​คืน​ค่าที่​มี​อยู่​แล้ว​โดย​ไม่​ต้อง​สร้าง​กล่อง​มา​ห่อ

input ที่​วัดTaskValueTaskpooled ValueTask
LookupTaskAsync / LookupValueTaskAsync / LookupPooledAsync บน MakeIds(100_000, 100) คือ hit ทุก​ครั้ง72 B/op0 B/op0 B/op

ตัวเลข​ต่อ op เล็ก​จน​อ่าน​แล้ว​ไม่รู้สึก​อะไร บท​นี้​จึง​วัด​ซ้ำ​ที่​ปริมาณ​งาน​ระดับ​ที่​ระบบ​จริง​เจอ คือ 10,000,000 lookup แบบ cache hit ล้วน แล้ว​นับ gen0 GC ไป​พร้อม​กัน

GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
int g0 = GC.CollectionCount(0);
long a = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10_000_000; i++) { var t = LookupTaskAsync(1042); _sink2 = t.GetAwaiter().GetResult(); _sink = t; }
Console.WriteLine($"Task -> {GC.GetAllocatedBytesForCurrentThread() - a:N0} B, gen0 GC = {GC.CollectionCount(0) - g0}");
input ที่​วัดbyte รวมgen0 GC
LookupTaskAsync(1042) 10,000,000 ครั้ง720,000,000 B172 ครั้ง
LookupValueTaskAsync(1042) 10,000,000 ครั้ง0 B0 ครั้ง

ตัวเลข byte ซ้ำ​เป๊ะ 720,000,000 ใน 5 จาก 6 พาส ส่วน​พาส​ที่​เหลือ​อ่าน​ได้ 720,006,192 ซึ่ง​เกิน​มา 6,192 byte จาก noise ของ Console และ​ตัว​โครง​วัด​เอง ไม่ใช่​จาก loop ส่วน​จำนวน gen0 GC เท่ากับ 172 ครั้ง​ซ้ำ​เป๊ะ​ทุก​ครั้ง

เลข 172 ครั้ง​เป็น​ภาพประกอบ ไม่ใช่​ค่า​คงที่​สากล มัน​ผูก​กับ workstation non-concurrent GC และ gen0 budget บน​เครื่อง​นี้ เครื่อง​อื่น​หรือ ServerGC จะ​ให้​คนละ​เลข สิ่ง​ที่​ยก​ไป​ใช้ได้​คือ column byte

แถว​ล่าง​ของ​เมทริกซ์​ใน column ValueTask วัด​สอง​วิธี​ที่​ไม่​เกี่ยว​กัน แล้ว​ทั้ง​สอง​วิธี​ชี้​ไป​ทาง​เดียวกัน

method คู่​นี้​ต่าง​กัน​แค่​ชนิด​ที่​คืน เนื้อ​ใน​เหมือน​กัน​บรรทัด​ต่อ​บรรทัด

static async Task<OrderLine?> AsyncTaskPath() { await G; return Hit; }
static async ValueTask<OrderLine?> AsyncValueTaskPath() { await G; return Hit; }
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
static async ValueTask<OrderLine?> AsyncPooledPath() { await G; return Hit; }
// ทุกรอบ: เริ่ม -> ชน await (ยังไม่เสร็จ) -> ปล่อย gate ให้ continuation รัน inline บน thread เดิม -> เก็บผล
Report("B3", () => { var t = AsyncTaskPath(); G.Release(); _sink2 = t.GetAwaiter().GetResult(); _sink = t; }, 50_000);
Report("B4", () => { var v = AsyncValueTaskPath(); G.Release(); _sink2 = v.GetAwaiter().GetResult(); }, 50_000);
Report("B4b",() => { var v = AsyncValueTaskPath(); G.Release(); _sink = v.AsTask(); _sink2 = ((Task<OrderLine?>)_sink).GetAwaiter().GetResult(); }, 50_000);
Report("B5", () => { var v = AsyncPooledPath(); G.Release(); _sink2 = v.GetAwaiter().GetResult(); }, 50_000);
#input ที่​วัดB/op
B1async Task (void) ที่ await G160
B2async ValueTask (void) ที่ await G160
B3async Task<OrderLine?> ที่ await G แล้ว​คืน Hit160
B4async ValueTask<OrderLine?> ที่ await G แล้ว​คืน Hit168
B4bเคส B4 แต่​จอด​ผล​ไว้​ด้วย .AsTask() กัน escape analysis168
B5pooled ValueTask<OrderLine?> ที่ await G แล้ว​คืน Hit0

แถว B4b มี​อยู่​เพื่อ​ปิด​ข้อ​โต้แย้ง​ข้อ​เดียว​ที่​เหลือ คือ “168 อาจ​เป็น​ของ​ตัว​วัด ไม่ใช่​ของ code” การ​จอด​ผลลัพธ์​ไว้​จริง​ไม่​ทำให้​ตัวเลข​ขยับ ยัง​อ่าน​ได้ 168 เท่า​เดิม

ตัวเลข 160 กับ 168 ชุด​นี้​รวม Action delegate ขนาด 64 B ที่​เกิด​จาก custom awaiter ของ​โครง​วัด​เข้าไป​ด้วย ถ้า​จะ​พูด​ถึง “await บน Task จริง” ต้อง​ใช้​ตัวเลข​จาก​ชุด​ถัด​ไป​เท่านั้น

โครง​วัด​ชุด​นี้​ทิ้ง Gate แล้ว​เปลี่ยน​ไป​ใช้ TaskCompletionSource ที่​ไม่​ตั้ง RunContinuationsAsynchronously ทำให้ SetResult() รัน continuation แบบ inline บน thread เดิม และ​มี baseline แยก​ไว้​หัก​ออก

static async Task<OrderLine?> RealTaskPath(Task gate) { await gate; return Hit; }
static async ValueTask<OrderLine?> RealValueTaskPath(Task gate) { await gate; return Hit; }
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
static async ValueTask<OrderLine?> RealPooledPath(Task gate) { await gate; return Hit; }
#input ที่​วัดgrossสุทธิ​หลัง​หัก F0
F0new TaskCompletionSource() แล้ว SetResult() (baseline)88 B/op
F1RealTaskPath(tcs.Task) แล้ว SetResult()168 B/op80 B/op
F2RealValueTaskPath(tcs.Task) แล้ว SetResult()200 B/op112 B/op
F3RealPooledPath(tcs.Task) แล้ว SetResult()88 B/op0 B/op

ส่วน​ต่าง F2 ลบ F1 คือ 32 B/op หรือ +40%ValueTask แพง​กว่า Task บน​เส้นทาง​ที่ suspend จริง ด้วย​เครื่องมือ​คนละ​ชุด​กับ​ส่วน​ก่อนหน้า และ​ชี้​ไป​ทาง​เดียวกัน

ห้าม​อ้าง​ว่า ValueTask ลด GC pressure บน​เส้นทาง​ที่​ทำงาน asynchronous — มัน​เพิ่ม

วัด​สอง​วิธี​ที่​ไม่​ใช้​กลไก​ร่วม​กัน​เลย ได้​ผล​ไป​ทาง​เดียวกัน​ทั้ง​คู่

  • custom awaiter: Task 160 B/op เทียบ ValueTask 168 B/op
  • await บน Task จริง หัก baseline แล้ว: Task 80 B/op เทียบ ValueTask 112 B/op (+40%)

เหตุผล​เชิงกลไก​อ่าน​ได้​จาก Stephen Toub โดยตรง: บน​เส้นทาง​ที่​ต้อง suspend จริง ValueTask<T> ไม่​ได้​ลบ object ที่​ต้อง​เก็บ state ทิ้ง มัน​ต้อง​มี object ตัว​นั้น​อยู่ดี แล้ว​ยัง​ต้อง​ห่อ ValueTask struct ทับ​เข้าไป​อีก​ชั้น

สิ่ง​ที่​อ้าง​ได้​จาก​ตัวเลข​ชุด​นี้​คือ ส่วน​ต่าง ระหว่าง Task กับ ValueTask ไม่ใช่​ต้นทุน​สัมบูรณ์​ของ I/O จริง เพราะ continuation ใน​โครง​วัด​นี้​รัน inline บน thread เดิม ส่วน I/O จริง​จะ resume บน thread pool ซึ่ง​มี​งาน​ที่​การ​วัด​นี้​ไม่​ได้​นับ

ตาราง​ที่​กวาด hit ratio 15 ค่า​บน code ชุด​เดียวกัน แล้ว​ชี้​จุด​ตัด​ที่​ต่ำ​กว่า​ที่​คน​ส่วน​ใหญ่​เดา​ไว้​ห้า​เท่า

code จริง​ไม่​ได้ hit 100% หรือ 0% มัน​อยู่​ระหว่าง​กลาง ชุด​วัด​นี้​จึง​สร้าง​ลำดับ id ไว้​ล่วงหน้า​ให้ hit ตรง​ตาม​สัดส่วน​ที่​กำหนด​แบบ deterministic แล้ว​เดิน loop เดียวกัน​ทั้ง3 mode

// ids ถูกสร้างไว้ล่วงหน้า: ทุกๆ 100 ครั้ง จะ hit เท่ากับ hitPct ครั้ง (deterministic)
static int[] MakeIds(int n, int hitPct) {
var ids = new int[n];
for (int i = 0; i < n; i++) ids[i] = (i % 100) < hitPct ? 1042 : -1;
return ids;
}
hit ratio (input: MakeIds(100_000, hitPct))TaskValueTaskpooledประหยัด
100%7200100%
99%72.881.68097.7%
95%76.48.4089%
90%80.816.8079.2%
75%9442055.3%
50%11684027.6%
25%13812608.7%
20%142.4134.405.6%
15%146.8142.802.7%
12%149.44147.8401.1%
10%151.2151.200% (จุด​ตัด​พอดี)
8%152.96154.560−1%
5%155.6159.60−2.6%
2%158.24164.640−4%
0%1601680−5%

ทุก​ตัวเลข​ใน column กลาง​เป็น​ค่า​เฉลี่ย​ต่อ lookup จาก 100,000 lookup ต่อ​แถว และ​ทั้ง 15 แถว​อ่าน​ได้​เท่า​กัน​ทุก​หลัก​ใน 3 process แยก

แถว 10% คือ​แถว​ที่2 column อ่าน​ได้ 151.2 เท่า​กัน​พอดี ต่ำ​กว่า​นั้น ValueTask เริ่ม​แพง​กว่า สูง​กว่า​นั้น​มัน​เริ่ม​ถูก​กว่า จุด​ตัด​นี้​ยัง​สอดคล้อง​กับ​สมการ​ที่​ตั้ง​จาก​ตัวเลข​ปลายทาง​ของ​ตาราง​เดียวกัน คือ 72h + 160(1−h) = 168(1−h) ให้ h = 0.1 — สมการ​นี้​เป็น การ​ตรวจสอบ​ความ​สอดคล้อง ไม่ใช่​ตัวเลข​ที่​วัด​เพิ่ม

คำ​แนะนำ​ที่​ได้ยิน​บ่อย​คือ “ถ้า hit ไม่​ถึง​ครึ่ง​ก็​อย่า​ใช้ ValueTask เลย” ตาราง​บอกว่า​คำ​แนะนำ​นั้น​ตัด​ของดี​ทิ้ง​ไป​เยอะ ที่ hit 50% ยัง​ประหยัด 27.6% และ​ที่ hit 25% ยัง​ประหยัด 8.7%

ผล​จาก 2,000 trial ต่อ​การ​ทดลอง ซึ่ง​บอกว่าการ​ละเมิด​กฎ​ที่​ดัง​ที่สุด​ของ ValueTask ไม่ throw เลย​สัก​ครั้ง

กฎ​การ​ใช้ ValueTask ที่​เอกสาร​ระบุ​ไว้​มี​สี่​ข้อ อ่าน​ครั้ง​เดียว, อย่า​เก็บ​ไว้ await ทีหลัง, อย่า​อ่าน .Result ก่อน​มัน​เสร็จ, และ​อย่า await ตัว​เดียวกัน​พร้อม​กัน​จาก​หลาย​ที่

คำถาม​ที่​บท​นี้​เอา​ไป​วัด​คือ ถ้า​ละเมิด​จริง​จะ​เกิด​อะไร​ขึ้น คำ​ตอบ​น่า​กลัว​กว่า​ที่​คิด

[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
static async ValueTask<int> PooledIdAsync(int id) { await G; return id; }
static async ValueTask<int> PlainIdAsync(int id) { await G; return id; }
// await ซ้ำบน ValueTask ตัวเดียวกัน 2,000 trial แล้วนับว่ากี่ครั้งที่ throw
int threw = 0, right = 0;
for (int t = 0; t < 2000; t++) {
var v = PlainIdAsync(7); G.Release();
try { _sinkInt = v.GetAwaiter().GetResult(); int b = v.GetAwaiter().GetResult(); if (b == 7) right++; }
catch (Exception) { threw++; }
}
#input ที่​วัดthrowได้​ค่า​ถูก
J1pooled ValueTask<int> เก็บ​ไว้ await ทีหลัง โดย​มี call แทรก 3 ครั้ง0/20002000/2000
J2pooled ValueTask<int> await ซ้ำ​สอง​ครั้ง2000/2000
J3ValueTask<int> ธรรมดา (ห่อ Task) await ซ้ำ​สอง​ครั้ง0/20002000/2000
J4ValueTask<OrderLine?> sync-hit await ซ้ำ​สอง​ครั้ง0/20002000/2000
J5pooled: await แล้ว​มี call ใหม่​มา​ยืม box แล้ว​ย้อน​กลับ​มา await ตัว​เก่า2000/2000ไม่​เคย​ได้​ค่า​ของ​คน​อื่น 0/2000
J6pooled เก็บ​ไว้ แล้ว​มี call แทรก 64 ครั้ง0/200200/200

InvalidOperationException ที่ J2 โยน​ออก​มา​คือ "Operation is not valid due to the current state of the object."

⛔ ประโยค​ที่​น่า​กลัว​ที่สุด​ของ​บท​นี้ — เทส​จะ​เขียว​สนิท

การ​ละเมิด​กฎ​ที่​ดัง​ที่สุด​ของ ValueTask คือ​การ await ตัว​เดิม​ซ้ำ บน ValueTask ที่​ไม่ใช่ pooled มัน​ไม่ throw เลย​สัก​ครั้ง​ใน 2,000 trial — ทั้ง​แบบ sync-hit และ​แบบ​ห่อ Task ต่าง​คืน ค่าที่​ถูกต้อง​เงียบๆ ครบ 2000/2000

แปล​ว่า code ที่​ผิด​กฎ​ข้อ​นี้​จะ​ผ่าน​เทส​ทุก​ตัว ผ่าน code review ที่​ไม่​ได้​ตั้งใจ​มอง​หา และ​วิ่ง​อยู่​บน production ได้​นาน​เท่า​ที่​ไม่มี​ใคร​เปลี่ยน builder

มี​แต่ PoolingAsyncValueTaskMethodBuilder เท่านั้น​ที่ throw ครบ 2000/2000 ซึ่ง​แปล​ว่า การ​เปิด pooling บน method ที่​มี​การ​ละเมิด​กฎ​อยู่​ก่อน​แล้ว จะ​เปลี่ยน bug เงียบ​ให้​กลาย​เป็น exception ทันที

กฎ​สี่​ข้อ​นี้​จึง​ต้อง​บังคับ​ด้วย​วินัย​ของ​ทีม ไม่ใช่​ด้วย runtime

ข้อ ”.Result ก่อน​มัน​เสร็จ” ก็​ทรง​เดียวกัน ValueTask ที่​ห่อ Task อ่าน .Result ก่อน​เสร็จ​แล้ว ไม่ throw แต่ block thread จริง — thread watchdog ที่​ตั้ง​ไว้​ยัง​มี​ชีวิต​อยู่​หลัง​ผ่าน​ไป 400 ms ส่วนตัว pooled โยน InvalidOperationException ให้​เห็น​ทันที

คู่ method ที่​แทบ​ไม่มี​ใคร​พูด​ถึง ซึ่ง​ตัดสิน​ว่าการ​เปลี่ยน​มา​ใช้ ValueTask จะ​ได้​กำไร​จริง​หรือ​ได้​ศูนย์

รูปแบบ​ที่​พบ​บ่อย​เวลา​ทยอย​เปลี่ยน code เก่า​คือ “ใช้ ValueTask ไป​ก่อน ถ้า​ที่​เรียก​ต้องการ Task ค่อย .AsTask()” บท​นี้​วัด​ราคา​ของ​ประโยค​นั้น

#input ที่​วัดB/op
D7LookupValueTaskAsync(1042) (sync hit) แล้ว .AsTask()72
D8LookupValueTaskAsync(1042) (sync hit) ไม่​แปลง0
D9AsyncValueTaskPath() (async) แล้ว .AsTask()168
D10pooled AsyncPooledPath() (async) แล้ว .AsTask()72
D11LookupValueTaskAsync(1042) (sync hit) แล้ว .Preserve()0
D12AsyncValueTaskPath() (async) แล้ว .Preserve()168
D13pooled AsyncPooledPath() (async) แล้ว .Preserve()72

คู่ D7 กับ D8 คือ​ทั้ง​เรื่อง เส้นทาง sync-hit ที่ ValueTask เพิ่ง​ประหยัด​ไป 72 byte ต่อ op ถูก​คืน​กลับ​ไป​ครบถ้วน​ด้วย​การ​เรียก .AsTask() หนึ่ง​ครั้ง กลาย​เป็นการ​เปลี่ยน​ชนิด​ที่​ได้​กำไร​สุทธิ​เป็น​ศูนย์ พร้อม​กฎ​การ​ใช้​ที่​เข้ม​ขึ้น​แถม​มา​ฟรี

ถ้า​จำเป็น​ต้อง​เก็บ​ผล​ไว้​จริง .Preserve() ทำงาน​เดียวกัน​ได้ที่ 0 B/op บน​เส้นทาง sync-hit (D11)

ส่วน​คู่ D10 กับ D13 บอก​อีก​เรื่อง​หนึ่ง คือ pooled ValueTask บน​เส้นทาง async จ่าย 72 byte ต่อ op เท่า​กัน​ทั้ง​สอง​ทาง ไม่​ว่า​จะ​ย้าย​ผลออก​ด้วย .AsTask() (D10) หรือ .Preserve() (D13) กำไร​ของ pooling หาย​ไป​ทันที​ที่​ผล​ถูก​ย้าย​ออก​จาก ValueTask ที่ builder คืน​มา ไม่​ว่า​จะ​ย้าย​ด้วย method ไหน

ValueTask ธรรมดา​บน​เส้นทาง async ไม่มี​ปัญหา​นี้ ทั้ง D9 และ D12 อ่าน​ได้ 168 B/op เท่ากับ​ตอน​ไม่​แปลง คือ​แปลง​แล้ว​ไม่​จ่าย​เพิ่ม​สัก byte เพราะ​มัน​คืน Task ตัว​เดิม​ที่​มี​อยู่​แล้ว

ขนาด​วัตถุ​ที่​วัด​แยก​ใน process เดียวกัน​อธิบาย​ตัวเลข​ข้าง​บน​ได้​พอดี และ​ทุก​ตัว​ผูก​กับ x64

input ที่​วัดขนาด
Task<T> หนึ่ง​ตัว72 B
new object()24 B
Action delegate หนึ่ง​ตัว64 B
ValueTask<OrderLine?> ที่​ถูก box40 B
OrderLine record หนึ่ง​ตัว40 B
new TaskCompletionSource()88 B
Unsafe.SizeOf<ValueTask<OrderLine?>>()24 B
Unsafe.SizeOf<ValueTask>()16 B
Unsafe.SizeOf<ValueTask<int>>()16 B

สาม​แถว​ล่าง​เป็น​ขนาด​ของ struct บน stack ไม่ใช่ allocation บน heap ซึ่ง​เป็น​เหตุผล​ว่า​ทำไม ValueTask ถึง​ลบ 72 byte นั้น​ได้​ตั้งแต่​แรก​บน​เส้นทาง​ที่​เสร็จ​ทันที

ของ​ที่​ทำ​สิ่ง​ที่ folklore เข้าใจ​ว่า ValueTask ทำ พร้อม​ราคา​ที่​ต้อง​จ่าย​ซึ่ง​ไม่ใช่ byte

ทุก​ที่​ใน​บท​นี้​ที่ column pooled อ่าน​ได้ 0 มัน​ไม่​ได้​มา​จาก​การ​เปลี่ยน​ชนิด​ที่​คืน แต่​มา​จาก PoolingAsyncValueTaskMethodBuilderPoolingAsyncValueTaskMethodBuilderตัว​สร้าง state machine แบบ​ใช้​ซ้ำ​จาก​คลัง ซึ่ง​เป็น​สิ่ง​ที่​ลบ allocation บน​เส้นทาง​ที่ suspend จริง ไม่ใช่​ตัว `ValueTask` เอง ที่​ผูก​เข้า​กับ method ด้วย attribute บรรทัด​เดียว

[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
static async ValueTask<OrderLine?> AsyncPooledPath() { await G; return Hit; }

หลักฐาน​สาม​ชุด​ที่​วัด​กัน​คนละ​วิธี​ชี้​ตรง​กัน

  • B5 บน​โครง custom awaiter: 0 B/op ขณะ​ที่ Task อ่าน​ได้ 160 และ ValueTask ธรรมดา​อ่าน​ได้ 168 (M7.2)
  • F3 บน​โครง await Task จริง: 88 B/op gross เท่ากับ baseline พอดี คือ 0 B/op สุทธิ (M7.3)
  • column pooled ใน​ตาราง hit ratio: 0 ทุก​แถว​ทั้ง 15 แถว ตั้งแต่ hit 100% ลง​ไป​ถึง 0% (M7.4)

แถว​สุดท้าย​คือ​แถว​ที่​บอก​อะไร​มาก​ที่สุด ValueTask ธรรมดา​แพ้ Task เมื่อ hit ต่ำ​กว่า 10% ส่วน pooled ไม่​แพ้​ใคร​ที่ hit ระดับ​ไหน​เลย เพราะ​มัน​แก้​ปัญหา​คนละ​ข้อ — ValueTask เลี่ยง​การ​สร้าง​กล่อง​บน​เส้นทาง​ที่​ไม่​ต้อง​รอ ส่วน pooling ใช้​กล่อง​เดิม​ซ้ำ​บน​เส้นทาง​ที่​ต้อง​รอ

flowchart TD
    A["method async หนึ่งตัวบน hot path"] --> B{"เส้นทางที่เสร็จแบบ synchronous มีสัดส่วนเท่าไร"}
    B -- "ต่ำกว่า 10 เปอร์เซ็นต์" --> C["คืน Task ต่อไป · ที่ hit 0 เปอร์เซ็นต์วัดได้ Task 160 B/op เทียบ ValueTask 168 B/op"]
    B -- "สูงกว่า 10 เปอร์เซ็นต์" --> D{"ค่าที่คืนเป็นค่าที่ runtime cache ไว้ให้แล้วหรือไม่"}
    D -- "ใช่ เช่น null หรือ true หรือ Task.CompletedTask" --> E["Task ฟรีอยู่แล้ว วัดได้ 0 B/op · เปลี่ยนชนิดแล้วไม่ได้อะไรเพิ่ม"]
    D -- "ไม่ใช่ เช่น OrderLine ที่ยกมาจาก cache" --> F["ValueTask ลบ 72 B/op ของเส้นทาง hit ออกได้ · แต่ห้ามเรียก AsTask ทีหลัง ไม่งั้นจ่ายคืน 72 B/op ครบ"]
    F --> G{"เส้นทางที่ suspend จริงยังเหลือ 160 B/op ต้องการลบด้วยหรือไม่"}
    G -- "ไม่" --> H["จบที่ ValueTask"]
    G -- "ใช่" --> I["PoolingAsyncValueTaskMethodBuilder วัดได้ 0 B/op สุทธิ · แลกกับการที่ await ซ้ำเปลี่ยนจาก throw 0 ใน 2000 เป็น throw 2000 ใน 2000"]

คำ​บรรยาย​ภาพ: เส้น​ทางการ​ตัดสิน​ใจ​ของ​บท​นี้ อ่าน​จาก​บน​ลง​ล่าง โดย​ทุก​กล่อง​แปะ​ตัวเลข​ที่​วัด​ได้​บน​เครื่อง Intel Core i7-7700 · .NET 10.0.10 · linux-x64 กำกับ​ไว้ · สาขา​ซ้าย​จบ​เร็ว​เพราะ Task ยัง​เป็น​คำ​ตอบ​ที่​ถูก​อยู่ ส่วน​สาขา​ขวา​สุด​คือ​ทาง​เดียว​ที่​ลบ 160 byte ต่อ op บน​เส้นทาง​ที่ suspend จริง​ได้ และ​เป็น​ทาง​ที่​แลก​ความ​ปลอดภัย​ของ​การ​ละเมิด​กฎ​ออก​ไป

⛔ 0 B/op ไม่ใช่​เหตุผล​ที่​พอ​จะ​เปิด pooling ทั่ว​ทั้ง codebase

PoolingAsyncValueTaskMethodBuilder เป็น opt-in ราย method ไม่ใช่​สวิตช์​ระดับ project ที่​ควร​เปิด​ทิ้ง​ไว้ และ​ผล​ข้าง​เคียง​ที่​วัด​ได้​ชัด​ที่สุด​ของ​มัน​ไม่ใช่ byte แต่​คือ​การ​ที่​การ​ละเมิด​กฎ​เปลี่ยน​จาก bug เงียบ​เป็น exception

ตัวเลข​ที่​บท​นี้​ยืนยัน​ได้​คือ J2 กับ J5 ที่ pooled โยน InvalidOperationException ครบ 2000/2000 ขณะ​ที่​ตัว​ธรรมดา​ผ่าน​เงียบ​ทุก​ครั้ง

พูด​อีก​อย่าง​คือ pooling ให้ 0 B/op บน​เงื่อนไข​ว่า code ของ​คุณ​เคารพ​กฎ​สี่​ข้อ​ครบถ้วน​อยู่​แล้ว และ​วิธี​เดียว​ที่​จะ​รู้​ว่า​มัน​เคารพ​จริง​คือ​ไป​ตรวจ ไม่ใช่​รอ​ให้​เท​สบ​อก เพราะ​เทสบน​ตัว​ธรรมดา​เขียว​สนิท

ที่​เหลือ​ให้​ปฏิบัติ​กับ​มัน​แบบ​เดียว​กับ​ที่​คอร์ส​นี้​ปฏิบัติ​กับ​ทุก​เทคนิค คือ​เปิด​เฉพาะ​บน hot path ที่​วัด​แล้ว​ว่า​มัน​คุ้ม ไม่ใช่​เปิด​เพราะ​ตัวเลข​ใน​บทเรียน​สวย

การ​ประ​กา​ศตรงๆ ว่า​บท​นี้​วัด​เวลา​ไว้​แล้ว และ​ตัด​ทิ้ง​ทั้ง​ชุด

บท​นี้​มี​การ​วัด​เวลา​ต่อ op ของ​ทั้ง​สาม​ทาง​เลือก ทั้ง​บน​เส้นทาง cache hit และ​เส้นทาง async และ​ตัวเลข​ชุด​นั้น​ถูก​ตัด​ออก​จาก​บทเรียน​ทั้ง​ชุด ไม่มี​ตัว​ไหน​ถูก​ยก​มา​อ้าง

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

เทียบ​กับ column byte ใน​บท​เดียวกัน ซึ่ง​อ่าน​ได้ 72 / 0 / 160 / 168 ซ้ำ​เป๊ะ​ทุก​หลัก​ข้าม 3–4 process ใน​ทุก​ชุด​การ​วัด และ​ตาราง hit ratio ทั้ง 15 แถว​ก็​ตรง​กัน​ทุก​หลัก​ใน 3 process

ถ้า​คุณ​เลือก​ใช้ ValueTask เพราะ​บท​นี้ ให้​เลือก​เพราะ byte และ​ถ้า​เป้าหมาย​จริง​คือ latency ต้อง​ไป​วัด​ของ​ตัวเอง​บน​เครื่อง​จริง ด้วย​โหลด​จริง แล้ว​รายงาน​พร้อม​ค่า​การกระจาย

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

  • ห้าม​อ้าง​ว่า ValueTask ทำให้ “เร็ว​ขึ้น” ทุก​ข้อ​สรุป​ใน​บท​นี้​ยืน​บน byte อย่าง​เดียว
  • ตัวเลข​เส้นทาง async ทั้งหมด​มา​จาก​โครง​วัด​ที่ continuation รัน inline บน thread เดิม I/O จริง​จะ resume บน thread pool ซึ่ง​มี​งาน​ที่​การ​วัด​นี้​ไม่​ได้​นับ สิ่ง​ที่​อ้าง​ได้​คือ ส่วน​ต่าง ระหว่าง Task กับ ValueTask ไม่ใช่​ต้นทุน​สัมบูรณ์​ของ I/O จริง
  • 160 กับ 168 B/op รวม Action delegate 64 B ที่​เกิด​จาก custom awaiter ถ้า​จะ​พูด​ถึง await บน Task จริง ต้อง​ใช้​ตัวเลข​สุทธิ 80 เทียบ 112 B/op เท่านั้น
  • ขอบเขต cache [−1, 8] ของ Task.FromResult(int) เป็น implementation detail ที่​วัด​ได้​บน .NET 10.0.10 ไม่ใช่​สัญญา ห้าม​ออกแบบ code พึ่งพา​มัน
  • gen0 GC 172 ครั้ง​ผูก​กับ workstation non-concurrent GC และ gen0 budget บน​เครื่อง​นี้ เป็น​ภาพประกอบ ไม่ใช่​ค่า​คงที่​สากล
  • 72 B ของ Task<T>, 64 B ของ Action, 40 B ของ boxed ValueTask ผูก​กับ x64 (IntPtr = 8) บน​สถาปัตยกรรม​อื่น​ตัวเลข​ชุด​นี้​เปลี่ยน
  • สิ่ง​ที่​บท​นี้​ไม่​ได้​วัด​เลย คือ I/O จริง (socket / file / DB), SynchronizationContext จริง, ConfigureAwait(false), ServerGC, การ​แข่ง​กัน​ข้าม thread และ ValueTask ที่​ถูก await ข้าม thread — ห้าม​ยก​ตัวเลข​ใน​บท​นี้​ไป​ตอบ​คำถาม​เหล่า​นั้น
  • allocation ไม่​เท่ากับ residency เหมือน​ทุก​บท​ใน​คอร์ส​นี้

สี่​ประโยค​ที่​บท​นี้​พิสูจน์​ได้ และ​รายการ​ที่​บท​สุดท้าย​จะ​ยก​ไป​ใช้​ต่อ

  1. non-generic ValueTask ประหยัด​ได้0 byte ใน​ทุก​เส้นทาง​ที่​วัด — 0 เท่ากับ Task บน sync path และ 160 เท่ากับ Task เป๊ะ​บน async path
  2. บน​เส้นทาง​ที่ suspend จริง ValueTask<T> แพง​กว่า Task<T> — 160 เทียบ 168 B/op และ​สุทธิ 80 เทียบ 112 B/op (+40%) จาก​สอง​โครง​วัด​ที่​ไม่​เกี่ยว​กัน
  3. ValueTask<T> คุ้ม​เฉพาะ​เมื่อ T cache ไม่​ได้ และ​เสร็จ​แบบ synchronous บ่อย​กว่า 10% ซึ่ง​เป็น​จุด​ตัด​ที่​ต่ำ​กว่า​ที่​คน​ส่วน​ใหญ่​เดา​ไว้​ห้า​เท่า
  4. สิ่ง​ที่​ลบ 160 B/op ได้​จริง​คือ PoolingAsyncValueTaskMethodBuilder ไม่ใช่​ตัว ValueTask และ​ราคา​ของ​มัน​คือ​การ​ที่​การ​ละเมิด​กฎ​กลาย​เป็น exception

สิ่ง​ที่​บท​นี้​วัด​ไว้​ให้​ครบ​แล้ว

  • เมทริกซ์ sync/async × Task/ValueTask/pooled บน​รูปทรง cache lookup จริง​ของ Order
  • async Task<T> ที่​เสร็จ​ทันที​จะ​ฟรี​หรือ​ไม่ ขึ้น​กับ ค่า ที่​คืน ไม่ใช่​ชนิด — 3 ให้ 0 B/op ส่วน 9 ให้ 72 B/op
  • ตาราง hit ratio 15 แถว​ที่​ตัด​กัน​พอดี​ที่ 151.2 B/op ทั้ง​สอง​ฝั่ง
  • การ await ซ้ำ​บน ValueTask ธรรมดา throw 0/2000 และ​คืน​ค่า​ถูก 2000/2000 ส่วน pooled throw 2000/2000
  • .AsTask() บน sync-hit จ่าย​คืน 72 B/op ครบ ส่วน .Preserve() จ่าย 0

บท 8 เอา​ทุก​อย่าง​ใน​เจ็ด​บท​มา​ลง​บน hot path เส้น​เดียวกัน คือ parse บรรทัด​ออร์เดอร์ 20 บรรทัด รวม​ยอด แล้ว​พิมพ์​ใบเสร็จ ไล่​จาก 12,272 byte ต่อ batch ลง​ไป​จนถึง 0 byte โดย​ผลลัพธ์​ที่​พิมพ์​ออก​มายัง​ตรง​กัน​ทุก byte ทั้ง7 version และ​บท​นั้น​จะ​แสดง​ด้วย​ว่า​ตัวเลข allocation ที่ “วัด​ซ้ำ​แล้ว​เท่า​เดิม” ก็​ยัง​ผิด​ได้ ถ้า​วัด​ตอน JIT ยัง​ไม่​ขึ้น tier-1


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

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

  • Understanding the Whys, Whats, and Whens of ValueTask — Stephen Toub (2018-11-07) — ที่มา​ของ​กฎ​การ​ใช้ ValueTask และ​คำ​อธิบาย​ว่า​ทำไม​บน​เส้นทาง​ที่​ต้อง suspend จริง มัน​จึง​ไม่​ได้​ลบ object ที่​เก็บ state ทิ้ง
  • ValueTask<TResult> Struct (net-10.0) — Microsoft Learn (ms.date 2025-07-01, อัปเดต 2026-05-27) — กฎ​การ​ใช้​ที่​เป็น​ทางการ รวม​ถึง​คำ​แนะนำ​เรื่อง​ตัว​ที่​ไม่มี​ค่า​คืน​กลับ และ .AsTask() / .Preserve()
  • PoolingAsyncValueTaskMethodBuilder<TResult> Struct (net-6.0+) — Microsoft Learn (ms.date 2025-07-01, อัปเดต 2026-05-27) — builder ที่​ใช้​กับ [AsyncMethodBuilder] ราย method ซึ่ง​เป็น​สิ่ง​ที่​ทำให้ column pooled ใน​บท​นี้​อ่าน​ได้ 0
  • How Async/Await Really Works in C# — Stephen Toub (2023-03-16) — กลไก​ของ state machine และ builder ที่​อยู่​ใต้​ตัวเลข​ทั้ง​บท

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

ข้อ 1 / 3

ทีมของคุณตัดสินใจเปลี่ยน async method ทุกตัวที่ไม่มีค่าคืนกลับ จาก async Task เป็น async ValueTask ทั้ง codebase เพราะเชื่อว่าจะ alloc น้อยลง ข้อมูลในบทนี้บอกว่าอย่างไร