async/await และ Task — I/O อยู่ที่ขอบ
เจ็ดบทที่ผ่านมาเราปั้น domain Order จนครบ — Money, OrderId, Quantity, OrderLine, ตัว Order ที่รักษากฎธุรกิจ, OrderStatus ที่คุมสถานะ และ port (IPaymentGateway, IOrderRepository) ที่ให้ domain คุยกับโลกภายนอกได้โดยไม่ผูกกับของจริง แต่ในทุก port มีของสามอย่างที่เราเลื่อนคำอธิบายมาตลอด: Task, async/await และ CancellationToken
บทสุดท้ายนี้เก็บปมนั้นให้จบ แล้วปิดคอร์สด้วยแผนที่ว่า feature ทุกอย่างที่เรียนมาไปต่อยอดที่คอร์สไหนของสาย .NET/DDD หัวใจของบทมีประโยคเดียว: แกน domain ตัดสินใจล้วนๆ จึงเป็น synchronous ส่วน async อยู่ที่ “ขอบ” ที่ต้องคุยกับ I/O จริง
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เกี่ยวข้องกับ port ใน FoodOrdering.Domain/Orders/ และตัวประสานงานที่ FoodOrdering.Application/Orders/
async/await คืออะไร
หัวข้อที่มีชื่อว่า “async/await คืออะไร”งานบางอย่างเสร็จทันที เช่น บวกเลขหรือเทียบค่า แต่บางงาน “ใช้เวลา” เพราะต้องรอคนอื่น เช่น ถามฐานข้อมูลหรือยิง API ไปตัดเงินที่ธนาคาร ระหว่างที่รอ ถ้าเราปล่อยให้ thread (สายการทำงานหนึ่งเส้นของโปรแกรม) ยืนเฉยๆ รอเป็นวินาที ก็เท่ากับเปลืองแรงงานไปฟรีๆ — thread นั้นทำอย่างอื่นได้อีกเยอะระหว่างรอ
Async and AwaitAsync and Awaitkeyword คู่ที่ทำให้เขียน code รอผลลัพธ์ที่ใช้เวลา (เช่น เรียกฐานข้อมูลหรือ API ภายนอก) แบบไม่ block thread — method ที่มี `async` คืนค่าเป็น `Task`/`Task<T>` และ `await` คือจุด 'หยุดรอ' ผลโดยไม่ค้าง thread ไว้เฉย ๆ จำเป็นเมื่อ Repository หรือ port อื่นต้องคุยกับโลกภายนอกArchitecture คือ keyword คู่ของ C# ที่แก้ปัญหานี้ method ที่ติดคำว่า async จะคืนค่าเป็น Task (“งานที่ยังทำไม่เสร็จ”) และ await คือจุด “หยุดรอผล” ที่ ปล่อย thread ให้ไปทำงานอื่นก่อน แล้วค่อยกลับมาทำต่อเมื่อผลพร้อม ไม่ใช่ยืนตรึงรอ
// async บอกว่า method นี้มีจุด "หยุดรอ" ข้างใน และคืนค่าเป็น Taskpublic async Task<int> CountPlacedAsync(IOrderRepository orders, CancellationToken ct){ // await = หยุดรอผลของงานที่ใช้เวลา โดยปล่อย thread ให้ไปทำอย่างอื่นระหว่างรอ IReadOnlyList<Order> all = await orders.FindByStatusAsync(OrderStatus.Placed, ct); return all.Count; // พอผลกลับมาแล้ว บรรทัดนี้ค่อยทำงานต่อ}แกะทีละส่วน:
asyncหน้า method บอก compiler ว่า “ข้างในมีawaitนะ” — เป็นใบอนุญาตให้ใช้awaitได้Task<int>คือชนิดที่คืนออก อ่านว่า “งานที่พอเสร็จแล้วจะได้intหนึ่งตัว” ถ้า method ไม่มีผลลัพธ์จะคืนแค่Taskเฉยๆ (งานที่เสร็จแล้วไม่ได้ค่าอะไรกลับมา)await orders.FindByStatusAsync(...)—FindByStatusAsyncคืนTask<IReadOnlyList<Order>>การใส่awaitข้างหน้าแปลว่า “หยุดตรงนี้จนกว่างานจะเสร็จ แล้วแกะผลข้างในออกมา” ผลที่ได้จึงเป็นIReadOnlyList<Order>ตรงๆ ไม่ใช่Taskอีกต่อไป
ข้อสำคัญที่ต้องจำ: await ไม่ได้ทำให้โปรแกรม “ช้าลง” มันแค่ทำให้ thread ไม่ต้องยืนรอเปล่าๆ ระหว่างที่งาน I/O กำลังดำเนินอยู่ — เป็นการใช้แรงงานที่มีอยู่ให้คุ้มขึ้น ไม่ใช่เพิ่มเวลา
Task และ ValueTask
หัวข้อที่มีชื่อว่า “Task และ ValueTask”Task คือ “กล่อง” ที่ห่องานซึ่งยังทำไม่เสร็จเอาไว้ มีสองแบบที่เจอบ่อย:
Task— งานที่ทำเสร็จแล้ว ไม่มี ค่ากลับมา เช่นChargeAsync(ตัดเงินเสร็จก็จบ ไม่ต้องคืนอะไร)Task<T>— งานที่ทำเสร็จแล้ว มี ผลเป็นชนิดTเช่นTask<Order?>(หาออเดอร์เสร็จแล้วได้Order?กลับมา)
มีญาติอีกตัวชื่อ ValueTask / ValueTask<T> หน้าตาใช้งานเหมือน Task เป๊ะ แต่ออกแบบมาให้ “เบากว่า” ในกรณีที่ผลลัพธ์มัก พร้อมอยู่แล้วทันที โดยไม่ต้องรอจริง เช่น ค่าที่ cache ไว้ในหน่วยความจำ การใช้ ValueTask ในเคสนั้นช่วยลดการสร้างอ็อบเจ็กต์ (allocation) ที่ไม่จำเป็น สำหรับตอนนี้จำแค่ว่า ค่าเริ่มต้นให้ใช้ Task ก่อนเสมอ แล้วค่อยเปลี่ยนเป็น ValueTask เฉพาะจุดที่วัดแล้วว่าคุ้ม — อย่าเพิ่งเสียเวลากับมันในฐานะผู้เริ่มต้น
CancellationToken — ปุ่มยกเลิก
หัวข้อที่มีชื่อว่า “CancellationToken — ปุ่มยกเลิก”สังเกตว่าทุก port ในบทที่ 7 รับ parameter ตัวสุดท้ายชื่อ ct เสมอ นั่นคือ Cancellation TokenCancellation Tokenอ็อบเจ็กต์ (`CancellationToken`) ที่ส่งต่อเข้าไปใน method async เพื่อให้ผู้เรียก 'ยกเลิก' งานที่กำลังทำอยู่ได้ เช่น ผู้ใช้ปิดหน้าเว็บระหว่างรอผล — method ที่ดีควรรับ parameter นี้และส่งต่อไปให้ทุก call ข้างในด้วย (เช่น query ฐานข้อมูล)Architecture — อ็อบเจ็กต์ที่ทำหน้าที่เป็น “ปุ่มยกเลิก” ส่งต่อเข้าไปในงาน async เพื่อให้ผู้เรียก สั่งเลิก งานที่กำลังทำอยู่ได้ก่อนมันจะเสร็จ
ทำไมถึงต้องมี? ลองนึกภาพ: ผู้ใช้กดโหลดรายการออเดอร์ แล้วปิดหน้าเว็บทิ้งไปก่อนผลจะกลับมา ถ้าไม่มีปุ่มยกเลิก server ก็ยังขยันถามฐานข้อมูลต่อทั้งที่ไม่มีใครรอผลแล้ว — เปลืองทรัพยากรฟรีๆ CancellationToken แก้เรื่องนี้: พอผู้ใช้ยกเลิก token จะถูก “จุดสัญญาณ” และงานที่กำลังทำอยู่จะหยุดเองอย่างสุภาพ
กฎง่ายๆ ของการเขียน method async ที่ดี คือ รับ CancellationToken เข้ามาแล้วส่งต่อให้ทุก call ข้างในด้วย อย่าปล่อยให้มันตกหล่นกลางทาง:
public async Task<Money> LoadTotalAsync( IOrderRepository orders, OrderId id, CancellationToken ct){ // ส่ง ct ต่อเข้าไปในทุกงาน I/O เพื่อให้ยกเลิกได้ทั้งสาย Order order = await orders.FindAsync(id.Value, ct) ?? throw new InvalidOperationException("ไม่พบออเดอร์นี้");
return order.Total; // ตรงนี้ sync ล้วน — แค่คำนวณ ไม่มี I/O ให้ยกเลิก}?? throw ... ที่เห็นคือ null-coalescing (บทที่ 3) — FindAsync คืน Order? ถ้าเป็น null (ไม่เจอ) ก็โยน exception ทันที ไม่ปล่อยให้ null ไหลต่อ
IAsyncEnumerable — stream ผลทีละชิ้น
หัวข้อที่มีชื่อว่า “IAsyncEnumerable — stream ผลทีละชิ้น”Task<IReadOnlyList<Order>> เหมาะกับ “โหลดผลมาให้ครบเป็นก้อนเดียว” แต่บางครั้งผลมีเป็นหมื่นแถว และเราอยากประมวลผล ทีละชิ้นตามที่มันไหลเข้ามา โดยไม่ต้องอมทั้งหมดไว้ในหน่วยความจำก่อน — นี่คืองานของ IAsyncEnumerable<T> คือ “ลำดับของค่าที่ทยอยมาแบบ async”
ฝั่ง port ประกาศเป็นแบบนี้ (ไม่มี Task ห่อ เพราะมันคืน “สายพานที่ไหลเรื่อยๆ” ไม่ใช่ “ผลก้อนเดียว”):
public interface IOrderStream{ IAsyncEnumerable<Order> StreamPlacedAsync(CancellationToken ct);}ฝั่งเรียกใช้ ใช้ await foreach — เหมือน foreach ธรรมดา แต่เติม await เพราะแต่ละชิ้นทยอยมาแบบ async:
await foreach (Order order in stream.StreamPlacedAsync(ct)){ // จัดการทีละออเดอร์ที่ไหลเข้ามา ไม่ต้องรอโหลดครบทั้งหมื่นแถวก่อน Console.WriteLine(order.Total);}สำหรับผู้เริ่มต้น จำแค่ว่า IAsyncEnumerable = “stream ข้อมูล async” และคู่หูของมันคือ await foreach ส่วนใหญ่คุณจะได้ใช้ตอนอ่านผลลัพธ์จำนวนมากจากฐานข้อมูล ซึ่งจะเจอเต็มๆ ในคอร์ส EF Core
port แบบ async — ทำไมทุก method ลงท้าย Async
หัวข้อที่มีชื่อว่า “port แบบ async — ทำไมทุก method ลงท้าย Async”ทวน port จากบทที่ 7 อีกครั้ง คราวนี้เข้าใจทุกส่วนแล้ว:
namespace FoodOrdering.Domain.Orders;
public interface IPaymentGateway{ Task ChargeAsync(Money amount, CancellationToken ct); Task RefundAsync(Money amount, CancellationToken ct);}
public interface IOrderRepository : IRepository<Order>{ // สืบทอด FindAsync/AddAsync จาก IRepository<Order> มาแล้ว Task<IReadOnlyList<Order>> FindByStatusAsync(OrderStatus status, CancellationToken ct);}ทุก method ของ port มีสามลักษณะร่วมกัน และตอนนี้เรารู้แล้วว่าทำไม:
- คืน
TaskหรือTask<T>— เพราะปลายทางจริงคือ I/O (ตัดเงินที่ธนาคาร, อ่าน/เขียนฐานข้อมูล) ซึ่งใช้เวลา จึงต้องเป็นงาน async - ลงท้ายชื่อด้วย
Async— เป็นธรรมเนียมของ .NET ที่ทำให้เห็นปุ๊บว่า “นี่คืองาน async ต้องawait” เช่นChargeAsync,FindAsync,AddAsync(method บันทึกลงที่เก็บ บางทีก็ตั้งชื่อSaveAsync) - รับ
CancellationTokenเป็น parameter สุดท้าย — เพื่อให้ยกเลิกงานที่กำลังคุยกับโลกภายนอกได้
port จึงเป็น “ประตูออกสู่ I/O” ของ domain โดยธรรมชาติ และเพราะ I/O เป็น async ตัวประตูจึงพูดภาษา async ทั้งหมด — แต่โปรดสังเกตว่า Money, Order, OrderStatus ที่มันรับส่งนั้นไม่มีตัวไหนเป็น async เลย ของพวกนั้นคือแกน domain ที่เป็น sync ล้วน
แกน domain sync ↔ ขอบ async
หัวข้อที่มีชื่อว่า “แกน domain sync ↔ ขอบ async”นี่คือหัวใจของบท ลองแยกงานสองชนิดออกจากกัน:
- การตัดสินใจ — “ตะกร้าว่างสร้างออเดอร์ไม่ได้”, “ราคารวมเท่าไร”, “จากสถานะนี้ไปสถานะไหนได้” งานพวกนี้เป็นแค่การคำนวณและเช็กกฎ ในหน่วยความจำล้วนๆ ไม่รอใคร ไม่แตะ I/O จึงเป็น synchronous ธรรมชาติ ไม่มี
awaitสักตัว - การคุยกับโลกภายนอก — อ่านออเดอร์จากฐานข้อมูล, ตัดเงินที่ธนาคาร, บันทึกผล งานพวกนี้ ใช้เวลาและรอคนอื่น จึงต้องเป็น async
หลักการคือ: เก็บการตัดสินใจไว้ในแกน domain ที่ sync ล้วน แล้วผลัก I/O ออกไปไว้ที่ขอบให้ async จัดการ ตัวประสานงาน (handler) ที่ขอบทำหน้าที่เหมือนแซนด์วิช — await โหลดข้อมูลเข้ามา, เรียก domain ตัดสินใจแบบ sync, แล้ว await ส่งผลออกไป:
namespace FoodOrdering.Application.Orders;
public class CheckoutHandler(IOrderRepository orders, IPaymentGateway payments){ public async Task CheckoutAsync(OrderId id, CancellationToken ct) { // 1) ขอบ (async): อ่านออเดอร์จากฐานข้อมูล — งาน I/O ต้อง await Order order = await orders.FindAsync(id.Value, ct) ?? throw new InvalidOperationException("ไม่พบออเดอร์นี้");
// 2) แกน domain (sync): คำนวณยอดที่ต้องจ่าย — ตัดสินใจล้วน ไม่มี await Money total = order.Total;
// 3) ขอบ (async): ตัดเงินแล้วบันทึก — งาน I/O ต้อง await อีกครั้ง await payments.ChargeAsync(total, ct); await orders.AddAsync(order, ct); }}สังเกตบรรทัด 2: order.Total ไม่มี await เพราะมันคือการบวกราคาในหน่วยความจำ (LINQ Sum จากบทที่ 6) — เป็นการตัดสินใจ ไม่ใช่ I/O การที่ domain ไม่ต้องรู้จัก async เลยทำให้มัน test ง่ายมาก: เรียกกฎธุรกิจตรงๆ แล้วเช็กผลได้ทันที ไม่ต้องตั้งฐานข้อมูลปลอมหรือ mock อะไร
flowchart TB
subgraph EDGEIN["ขอบขาเข้า — async"]
HANDLER["CheckoutHandler.CheckoutAsync<br/>await ทุกจุดที่แตะ I/O"]
end
subgraph CORE["แกน domain — synchronous<br/>ตัดสินใจล้วน ไม่มี await"]
DEC["Order.Place · Total<br/>คำนวณและรักษากฎในหน่วยความจำ"]
end
subgraph EDGEOUT["ขอบขาออก — async · port/adapter"]
REPO["IOrderRepository<br/>FindAsync · AddAsync"]
PAY["IPaymentGateway<br/>ChargeAsync"]
end
HANDLER -->|await FindAsync| REPO
REPO -->|ข้อมูลดิบ| HANDLER
HANDLER -->|เรียกแบบ sync| DEC
DEC -->|ผลการตัดสินใจ| HANDLER
HANDLER -->|await ChargeAsync| PAY
HANDLER -->|await AddAsync| REPO
คำบรรยายภาพ: กล่องกลาง (แกน domain) เป็น synchronous ล้วน — Order.Place กับ Total แค่คำนวณและรักษากฎในหน่วยความจำ ไม่มี await เลย ส่วนงาน I/O ทั้งหมดถูกผลักออกไปอยู่ที่ ขอบ: CheckoutHandler ที่ขอบเป็นตัวเดียวที่ await — มัน await โหลดข้อมูลเข้ามาจาก port, ยื่นให้แกน domain ตัดสินใจแบบ sync, แล้ว await ส่งผลออกไปตัดเงินและบันทึก ทิศทางชัดเจน: async ห่ออยู่รอบนอก, การตัดสินใจอยู่แกนใน สลับกันไม่ได้
version ดิบ: .Result และ .Wait() ที่ block thread
หัวข้อที่มีชื่อว่า “version ดิบ: .Result และ .Wait() ที่ block thread”มีกับดักหนึ่งที่ผู้เริ่มต้นตกบ่อยมาก: มี Task อยู่ในมือแล้วอยากได้ผล “เดี๋ยวนี้” เลยเผลอเรียก .Result หรือ .Wait() เพื่อดึงผลออกมาแบบ sync นี่คือ ❌ version ดิบ ที่ควรเลิก:
// ❌ version ดิบ: block thread ด้วย .Result / .Wait() — เสี่ยง deadlockpublic class BlockingCheckout(IOrderRepository orders, IPaymentGateway payments){ public void Checkout(OrderId id) { // .Result สั่ง "หยุดตรงนี้จนกว่างานจะเสร็จ" โดยตรึง thread ไว้เฉย ๆ Order order = orders.FindAsync(id.Value, CancellationToken.None).Result ?? throw new InvalidOperationException("ไม่พบออเดอร์นี้");
// .Wait() ก็ block แบบเดียวกัน payments.ChargeAsync(order.Total, CancellationToken.None).Wait(); }}ต่างจาก await ตรงที่ await ปล่อย thread ให้ไปทำงานอื่นระหว่างรอ แต่ .Result และ .Wait() ตรึง thread ไว้เฉยๆ จนกว่างานจะเสร็จ ปัญหามีสองชั้น:
- เปลือง thread — ตรึง thread ไว้รอเป็นวินาที ทั้งที่มันไปรับงานคนอื่นได้ พอโหลดเยอะๆ thread หมด ระบบก็อืดหรือค้าง
- เสี่ยง deadlock (ค้างถาวร) — ในบางบริบท (เช่น UI หรือ ASP.NET แบบเดิม) งานที่
awaitข้างในรอ “คิว” เดิมกลับมาทำงานต่อ แต่คิวนั้นถูก.Resultตรึงไว้ไม่ปล่อย — ต่างฝ่ายต่างรอกันไปมาแบบไม่มีวันหลุด โปรแกรม ค้างสนิท ไม่ใช่แค่ช้า
ทางแก้ไม่มีอะไรซับซ้อน: async ต้องต่อ async ไปตลอดสาย ถ้า method ของคุณเรียกงาน async ก็ทำให้ method นั้นเป็น async แล้วใช้ await — อย่าตัดจบด้วย .Result/.Wait() เขียน version ที่ถูกก็แค่:
// ✅ async ต่อ async: ปล่อย thread ระหว่างรอ ไม่ตรึง ไม่ค้างpublic async Task CheckoutAsync(OrderId id, CancellationToken ct){ Order order = await orders.FindAsync(id.Value, ct) ?? throw new InvalidOperationException("ไม่พบออเดอร์นี้");
await payments.ChargeAsync(order.Total, ct);}ปิดคอร์ส — แผนที่ต่อยอดทั้งอาร์ค
หัวข้อที่มีชื่อว่า “ปิดคอร์ส — แผนที่ต่อยอดทั้งอาร์ค”จบแปดบท คุณปั้น domain Order ขึ้นมาจากศูนย์ พร้อมกับเรียนไวยากรณ์ C# สมัยใหม่ทีละชิ้น ทุก feature ที่เรียนไม่ใช่ของจบในตัว — มันคือ “ภาษา” ที่คอร์สอื่นในสาย .NET/DDD หยิบไปใช้ต่อลึกๆ ตารางนี้คือแผนที่ว่าอันไหนไปโผล่ที่ไหน:
| feature C# ที่เรียน (บท) | ต่อยอดลึกๆ ที่คอร์ส |
|---|---|
| record → Value Object (บท 1-3) | DDD in Code — ออกแบบ Value Object ที่มี behavior เต็มรูป |
| pattern matching, ทำ illegal states ให้ compile ไม่ผ่าน (บท 4-5) | DDD in Code — model state machine ของ aggregate |
collection + LINQ, aggregate Order (บท 6) | DDD in Code — ออกแบบ aggregate boundary และ invariant |
| interface (port), generics, DI (บท 7) | Clean Architecture .NET — ports & adapters, dependency inversion เต็มรูป |
async/await, Task, repository async (บท 8) | EF Core — บันทึกออเดอร์ลงฐานข้อมูลจริงหลัง port |
รูปทรงเดียวที่ร้อยทุกบทเข้าด้วยกันคือ แกน domain บริสุทธิ์อยู่ตรงกลาง ส่วน I/O และรายละเอียดของจริงอยู่ที่ขอบ — record ที่ปลอดภัย, pattern matching ที่ครบทุกเคส, aggregate ที่รักษากฎ, port ที่ซ่อน infrastructure และ async ที่ห่ออยู่รอบนอก ทั้งหมดรับใช้แนวคิดเดียวกันนี้
ตอนนี้คุณอ่านทั้งอาร์คได้แล้ว — ไวยากรณ์ C# ไม่ใช่กำแพงอีกต่อไป ไปลุยคอร์สถัดไปในสายได้เลย
เจาะลึกแนวคิดเบื้องหลังบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Persistence Ignorance — หลักการเบื้องหลังทั้งบท: domain ไม่ควรรู้เรื่องการเก็บข้อมูลหรือ I/O เลย นี่คือเหตุผลเชิงหลักการว่าทำไมแกน domain ถึงบริสุทธิ์และ sync ส่วน async/persistence ถูกผลักไปอยู่ที่ขอบ
- Repository Pattern — pattern เบื้องหลัง
IOrderRepository: ห่องานเก็บ/ดึงข้อมูล (ที่เป็น async) ไว้หลัง interface ให้ domain ไม่รู้จักฐานข้อมูลจริง
เช็กความเข้าใจ — บทที่ 8
ข้อ 1 / 3ทำไมแกน domain ถึงยังเป็น synchronous ในขณะที่ขอบเป็น async?