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 ซึ่งเป็นคนละของกันคนละเรื่อง และมีราคาที่ต้องจ่ายคนละแบบ
บทนี้ยังเดินบน 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 ซึ่งจะทำให้ตัวเลขที่เราต้องการวัดหายไปเงียบๆ โดยไม่มี warningstatic 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 วางไว้แล้ว
ตอนวัดชุด 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 ที่วัด | Task | ValueTask | pooled ValueTask |
|---|---|---|---|
เสร็จทันที คืน Hit (Hit = new OrderLine(1042, "ต้มยำกุ้ง", 2, 18000)) | 72 B/op | 0 B/op | 0 B/op |
await G ก่อน แล้วคืน Hit | 160 B/op | 168 B/op | 0 B/op |
ช่องที่ folklore สัญญาไว้คือ column ValueTask แถวบน (0 B/op) และมันเป็นจริง ส่วน column ValueTask แถวล่าง (168 B/op) คือช่องที่ folklore ไม่เคยพูดถึง
ทีนี้ตารางเดียวกันแต่เป็น non-generic ValueTask ซึ่งเป็นตัวที่คนเปลี่ยนทิ้งไว้เยอะที่สุดเพราะมันเปลี่ยนง่าย
| input ที่วัด | Task | ValueTask | ผลเทียบ |
|---|---|---|---|
async (void) ที่ return ทันที | 0 B/op | 0 B/op | เท่ากันเป๊ะ |
async (void) ที่ await G แล้วจบ | 160 B/op | 160 B/op | เท่ากันเป๊ะ |
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 |
|---|---|---|
| A1 | async Task (void) sync | 0 |
| A2 | async ValueTask (void) sync | 0 |
| A3 | async Task<bool> => true | 0 |
| A4 | async Task<int> => 3 | 0 |
| A5 | async Task<int> => 9 | 72 |
| A6 | async Task<int> => 18000 | 72 |
| A7 | async ValueTask<int> => 18000 | 0 |
| A8 | async Task<OrderLine?> => null | 0 |
| A9 | async Task<OrderLine?> => Hit | 72 |
| A10 | async ValueTask<OrderLine?> => Hit | 0 |
| A11 | pooled ValueTask<OrderLine?> => Hit | 0 |
| A12 | Task.FromResult(Hit) | 72 |
| A13 | new ValueTask<OrderLine?>(Hit) | 0 |
| A14 | Task.FromResult(3) | 0 |
| A15 | Task.FromResult(18000) | 72 |
| A16 | Task.FromResult(true) | 0 |
| A17 | Task.CompletedTask | 0 |
ตัวเลขทั้งตารางมาจาก 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
ขอบเขต [−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 ชนะที่ไหน: cache hit ล้วนๆ ที่ T cache ไม่ได้
หัวข้อที่มีชื่อว่า “ValueTask ชนะที่ไหน: cache hit ล้วนๆ ที่ T cache ไม่ได้”ช่องเดียวในเมทริกซ์ที่
ValueTaskชนะขาด และขนาดของชัยชนะนั้นเมื่อคูณด้วยปริมาณงานจริง
ช่อง ValueTask ของแถวแรกในเมทริกซ์ (เสร็จทันที คืน Hit) คือช่องที่ ValueTaskValueTaskชนิดคืนค่า async ที่เป็น struct ซึ่งลบการจองได้เฉพาะเส้นทางที่เสร็จแบบ synchronous และมีกฎการใช้ที่ละเมิดแล้วไม่ระเบิด ทำในสิ่งที่มันถูกออกแบบมาให้ทำ คือคืนค่าที่มีอยู่แล้วโดยไม่ต้องสร้างกล่องมาห่อ
| input ที่วัด | Task | ValueTask | pooled ValueTask |
|---|---|---|---|
LookupTaskAsync / LookupValueTaskAsync / LookupPooledAsync บน MakeIds(100_000, 100) คือ hit ทุกครั้ง | 72 B/op | 0 B/op | 0 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 B | 172 ครั้ง |
LookupValueTaskAsync(1042) 10,000,000 ครั้ง | 0 B | 0 ครั้ง |
ตัวเลข 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
เส้นทางที่ await จริง: ValueTask แพงกว่า ไม่ใช่ถูกกว่า
หัวข้อที่มีชื่อว่า “เส้นทางที่ await จริง: ValueTask แพงกว่า ไม่ใช่ถูกกว่า”แถวล่างของเมทริกซ์ใน 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 |
|---|---|---|
| B1 | async Task (void) ที่ await G | 160 |
| B2 | async ValueTask (void) ที่ await G | 160 |
| B3 | async Task<OrderLine?> ที่ await G แล้วคืน Hit | 160 |
| B4 | async ValueTask<OrderLine?> ที่ await G แล้วคืน Hit | 168 |
| B4b | เคส B4 แต่จอดผลไว้ด้วย .AsTask() กัน escape analysis | 168 |
| B5 | pooled ValueTask<OrderLine?> ที่ await G แล้วคืน Hit | 0 |
แถว B4b มีอยู่เพื่อปิดข้อโต้แย้งข้อเดียวที่เหลือ คือ “168 อาจเป็นของตัววัด ไม่ใช่ของ code” การจอดผลลัพธ์ไว้จริงไม่ทำให้ตัวเลขขยับ ยังอ่านได้ 168 เท่าเดิม
ตัวเลข 160 กับ 168 ชุดนี้รวม Action delegate ขนาด 64 B ที่เกิดจาก custom awaiter ของโครงวัดเข้าไปด้วย ถ้าจะพูดถึง “await บน Task จริง” ต้องใช้ตัวเลขจากชุดถัดไปเท่านั้น
cross-check ด้วย await บน Task จริง
หัวข้อที่มีชื่อว่า “cross-check ด้วย 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 |
|---|---|---|---|
| F0 | new TaskCompletionSource() แล้ว SetResult() (baseline) | 88 B/op | — |
| F1 | RealTaskPath(tcs.Task) แล้ว SetResult() | 168 B/op | 80 B/op |
| F2 | RealValueTaskPath(tcs.Task) แล้ว SetResult() | 200 B/op | 112 B/op |
| F3 | RealPooledPath(tcs.Task) แล้ว SetResult() | 88 B/op | 0 B/op |
ส่วนต่าง F2 ลบ F1 คือ 32 B/op หรือ +40% — ValueTask แพงกว่า Task บนเส้นทางที่ suspend จริง ด้วยเครื่องมือคนละชุดกับส่วนก่อนหน้า และชี้ไปทางเดียวกัน
วัดสองวิธีที่ไม่ใช้กลไกร่วมกันเลย ได้ผลไปทางเดียวกันทั้งคู่
- custom awaiter:
Task160 B/op เทียบValueTask168 B/op awaitบนTaskจริง หัก baseline แล้ว:Task80 B/op เทียบValueTask112 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 10% ไม่ใช่ 50%
หัวข้อที่มีชื่อว่า “จุดคุ้มทุนอยู่ที่ hit ratio 10% ไม่ใช่ 50%”ตารางที่กวาด 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)) | Task | ValueTask | pooled | ประหยัด |
|---|---|---|---|---|
| 100% | 72 | 0 | 0 | 100% |
| 99% | 72.88 | 1.68 | 0 | 97.7% |
| 95% | 76.4 | 8.4 | 0 | 89% |
| 90% | 80.8 | 16.8 | 0 | 79.2% |
| 75% | 94 | 42 | 0 | 55.3% |
| 50% | 116 | 84 | 0 | 27.6% |
| 25% | 138 | 126 | 0 | 8.7% |
| 20% | 142.4 | 134.4 | 0 | 5.6% |
| 15% | 146.8 | 142.8 | 0 | 2.7% |
| 12% | 149.44 | 147.84 | 0 | 1.1% |
| 10% | 151.2 | 151.2 | 0 | 0% (จุดตัดพอดี) |
| 8% | 152.96 | 154.56 | 0 | −1% |
| 5% | 155.6 | 159.6 | 0 | −2.6% |
| 2% | 158.24 | 164.64 | 0 | −4% |
| 0% | 160 | 168 | 0 | −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%
กฎเหล็กสี่ข้อ ที่ runtime ไม่ได้บังคับให้
หัวข้อที่มีชื่อว่า “กฎเหล็กสี่ข้อ ที่ runtime ไม่ได้บังคับให้”ผลจาก 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 แล้วนับว่ากี่ครั้งที่ throwint 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 | ได้ค่าถูก |
|---|---|---|---|
| J1 | pooled ValueTask<int> เก็บไว้ await ทีหลัง โดยมี call แทรก 3 ครั้ง | 0/2000 | 2000/2000 |
| J2 | pooled ValueTask<int> await ซ้ำสองครั้ง | 2000/2000 | — |
| J3 | ValueTask<int> ธรรมดา (ห่อ Task) await ซ้ำสองครั้ง | 0/2000 | 2000/2000 |
| J4 | ValueTask<OrderLine?> sync-hit await ซ้ำสองครั้ง | 0/2000 | 2000/2000 |
| J5 | pooled: await แล้วมี call ใหม่มายืม box แล้วย้อนกลับมา await ตัวเก่า | 2000/2000 | ไม่เคยได้ค่าของคนอื่น 0/2000 |
| J6 | pooled เก็บไว้ แล้วมี call แทรก 64 ครั้ง | 0/200 | 200/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 ให้เห็นทันที
.AsTask() คืนกำไรทั้งหมด ส่วน .Preserve() ไม่คืน
หัวข้อที่มีชื่อว่า “.AsTask() คืนกำไรทั้งหมด ส่วน .Preserve() ไม่คืน”คู่ method ที่แทบไม่มีใครพูดถึง ซึ่งตัดสินว่าการเปลี่ยนมาใช้
ValueTaskจะได้กำไรจริงหรือได้ศูนย์
รูปแบบที่พบบ่อยเวลาทยอยเปลี่ยน code เก่าคือ “ใช้ ValueTask ไปก่อน ถ้าที่เรียกต้องการ Task ค่อย .AsTask()” บทนี้วัดราคาของประโยคนั้น
| # | input ที่วัด | B/op |
|---|---|---|
| D7 | LookupValueTaskAsync(1042) (sync hit) แล้ว .AsTask() | 72 |
| D8 | LookupValueTaskAsync(1042) (sync hit) ไม่แปลง | 0 |
| D9 | AsyncValueTaskPath() (async) แล้ว .AsTask() | 168 |
| D10 | pooled AsyncPooledPath() (async) แล้ว .AsTask() | 72 |
| D11 | LookupValueTaskAsync(1042) (sync hit) แล้ว .Preserve() | 0 |
| D12 | AsyncValueTaskPath() (async) แล้ว .Preserve() | 168 |
| D13 | pooled 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?> ที่ถูก box | 40 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 นั้นได้ตั้งแต่แรกบนเส้นทางที่เสร็จทันที
builder ที่ใช้ box ซ้ำ คือสิ่งที่ลบ 160 B/op ได้จริง
หัวข้อที่มีชื่อว่า “builder ที่ใช้ box ซ้ำ คือสิ่งที่ลบ 160 B/op ได้จริง”ของที่ทำสิ่งที่ 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 จริงได้ และเป็นทางที่แลกความปลอดภัยของการละเมิดกฎออกไป
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 รวม
Actiondelegate 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 ของ boxedValueTaskผูกกับ x64 (IntPtr = 8) บนสถาปัตยกรรมอื่นตัวเลขชุดนี้เปลี่ยน - สิ่งที่บทนี้ไม่ได้วัดเลย คือ I/O จริง (socket / file / DB),
SynchronizationContextจริง,ConfigureAwait(false), ServerGC, การแข่งกันข้าม thread และValueTaskที่ถูก await ข้าม thread — ห้ามยกตัวเลขในบทนี้ไปตอบคำถามเหล่านั้น - allocation ไม่เท่ากับ residency เหมือนทุกบทในคอร์สนี้
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”สี่ประโยคที่บทนี้พิสูจน์ได้ และรายการที่บทสุดท้ายจะยกไปใช้ต่อ
- non-generic
ValueTaskประหยัดได้0 byte ในทุกเส้นทางที่วัด — 0 เท่ากับTaskบน sync path และ 160 เท่ากับTaskเป๊ะบน async path - บนเส้นทางที่ suspend จริง
ValueTask<T>แพงกว่าTask<T>— 160 เทียบ 168 B/op และสุทธิ 80 เทียบ 112 B/op (+40%) จากสองโครงวัดที่ไม่เกี่ยวกัน ValueTask<T>คุ้มเฉพาะเมื่อTcache ไม่ได้ และเสร็จแบบ synchronous บ่อยกว่า 10% ซึ่งเป็นจุดตัดที่ต่ำกว่าที่คนส่วนใหญ่เดาไว้ห้าเท่า- สิ่งที่ลบ 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 น้อยลง ข้อมูลในบทนี้บอกว่าอย่างไร