โหมดมืด
บทที่ 11 — Async + Tasks + Cancellation
🟡 หมายเหตุระดับ: บทนี้เริ่มจากพื้นฐาน async/await (§1–§11) แต่ค่อย ๆ ลึกขึ้นจน §17–§18 (
AsyncLocal,TaskCompletionSource) เข้าใกล้ระดับกลาง–สูง — มือใหม่อ่าน §1–§11 พอ ส่วนที่เหลือกลับมาเมื่อเริ่ม debug หรือเขียน library
C# มี async/await (การทำงานแบบไม่บล็อกรอ — ปล่อย thread หรือเส้นการทำงานของโปรแกรม ไปทำอย่างอื่นระหว่างรอ I/O เช่นอ่านไฟล์หรือเรียก API) ตั้งแต่ปี 2012 — นานก่อน JavaScript/Python/Rust — และออกแบบให้เป็นธรรมชาติของภาษา (idiomatic)
1. ทำไมต้อง Async
I/O = input/output เช่นอ่านไฟล์, ยิงเครือข่าย — งานที่ "รอ" นาน; thread = เส้นการทำงานของโปรแกรม; thread pool = แหล่งรวม thread ที่หมุนเวียนใช้
csharp
// Sync (ทำตามลำดับ) — บล็อก thread ค้างรอ I/O
public string Get(string url)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
var resp = _http.Send(req); // บล็อกค้าง ~100ms
return resp.Content.ReadAsStringAsync().Result; // ❌ block อีกที — ห้ามใช้จริง
// ⚠️ .Result ทำให้โปรแกรมค้างได้ในบางสถานการณ์ (รายละเอียด "deadlock"/"thread pool starvation" ดู §11.2)
// ถ้าจำเป็นต้องเรียก sync จริง ๆ ใช้ HttpClient.Send() + resp.Content.ReadAsStream() ทั้งสาย ไม่ผสม async กับ .Result
}
// Async — คืน thread กลับ pool ระหว่างรอ
public async Task<string> GetAsync(string url, CancellationToken ct = default)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
var resp = await _http.SendAsync(req, ct); // yield (ปล่อยมือ) ระหว่างรอ
return await resp.Content.ReadAsStringAsync(ct);
}ในระบบ web — thread pool ขนาดเล็ก (หลักสิบ thread) รองรับ connection หลายหมื่นพร้อมกันได้ เพราะ thread ถูกคืนกลับ pool ระหว่างรอ I/O ไม่ค้างผูกกับ connection ตัวใดตัวหนึ่ง
(หลักการ async I/O ของ Kestrel/ASP.NET Core — ตัวเลข exact ขึ้นกับ workload, ดู release notes/docs ทางการของ Kestrel)
ไม่ได้ทำให้ "1 request เร็วขึ้น" — แต่ทำให้ "1 server handle ได้เยอะขึ้น"
2. Task<T> — รากของ async
Task / Task<T> คือหัวใจของ async ใน C# — เป็น "ตัวแทนงานที่จะเสร็จในอนาคต" เปรียบได้กับใบรับของจากร้านซักผ้า — คุณฝากผ้าไว้แล้วได้รับ "ใบรับ" (Task) กลับมา แล้วค่อยกลับไปรับผ้า (await) ตอนมันซักเสร็จ method async คืน Task<T> แล้วผู้เรียกใช้ await รอผลโดยไม่บล็อก thread:
💡 ถ้าเคยเขียน JS หรือ Java มาก่อน:
Taskเทียบได้กับPromiseใน JS หรือFutureใน Java — concept เดียวกัน คนละชื่อ
csharp
// async method return Task<T>
public async Task<int> ComputeAsync()
{
await Task.Delay(100); // simulate I/O
return 42;
}
// await unwrap
int result = await ComputeAsync();
// หรือ Task ตรง ๆ (เทียบบ้าง — ไม่แนะนำ)
Task<int> t = ComputeAsync();
int r = await t;
int r2 = t.Result; // ❌ block!Task = "future value" — ตัวแทนค่าที่ยังไม่มาถึงตอนนี้ แต่จะมาถึงในอนาคต
3. async/await — กฎพื้นฐาน
csharp
public async Task<User> GetUserAsync(string id, CancellationToken ct = default)
{
// await = suspend ขณะ I/O — thread ปล่อย
// GetFromJsonAsync stream JSON ตรงจาก response — ไม่ต้องอ่าน string ก่อน deserialize
var user = await _http.GetFromJsonAsync<User>($"/users/{id}", ct);
return user ?? throw new InvalidOperationException($"user {id} not found");
}Rules:
- method ที่ใช้
awaitต้องasync+ returnTask/Task<T>/ValueTask(อธิบายในหัวข้อ 8) /IAsyncEnumerable<T>(สอนในหัวข้อ 9) - ห้าม
async void(ยกเว้น event handler) - ทุก async method ลงท้ายด้วย
Async(convention = ข้อตกลงการตั้งชื่อ ไม่ใช่กฎ compiler)
4. Parallel — Task.WhenAll
csharp
// ❌ sequential (300ms รวม)
var u1 = await GetUserAsync("a");
var u2 = await GetUserAsync("b");
var u3 = await GetUserAsync("c");
// ✅ parallel (100ms) — WhenAll<T> คืน T[] ตรง ๆ ไม่ต้องแตะ .Result
var users = await Task.WhenAll(
GetUserAsync("a"),
GetUserAsync("b"),
GetUserAsync("c"));
// users[0], users[1], users[2]
// ✅ pattern เดียวกับ collection
var allUsers = await Task.WhenAll(ids.Select(id => GetUserAsync(id)));⚠️ WhenAll ไม่มี limit — ถ้า
idsมีหลายพันตัว อาจถล่ม service ปลายทาง → ใช้Parallel.ForEachAsync+MaxDegreeOfParallelism(§15) หรือSemaphoreSlim(§12) เมื่อต้องจำกัด concurrency
⚠️ อย่า
.Resultแม้หลังWhenAllawait แล้วบางตัวอย่างบนเน็ตเขียนว่า
await Task.WhenAll(t1, t2); var (a, b) = (t1.Result, t2.Result);— technically ไม่บล็อก (task เสร็จแล้ว) แต่:
- ขัดกฎ "ห้าม .Result" ใน §3
- exception behavior ต่างกัน:
.Result→ โยนAggregateException(ตัวห่อรวมหลาย exception) ที่ห่อ inner exception (exception ตัวจริงที่ถูกห่อไว้ข้างใน) เอาไว้await→ unwrap (แกะห่อ) ออกมาเป็น inner exception ตัวแรกตรง ๆ- ผลคือ
catchblock อาจจับผิดประเภทใช้ overload ที่คืน
T[]แทน
WhenAll รอจนทุก task เสร็จ ถ้ามีหลาย task throw exception พฤติกรรมจะซับซ้อนเล็กน้อย:
Task.Exceptionของตัว WhenAll เก็บทุก exception ในAggregateException.InnerExceptions- แต่
await Task.WhenAll(...)unwrap แค่ตัวแรก (InnerExceptions[0]) - ในทางปฏิบัติ "ตัวแรก" มักตรงกับ ลำดับตาม array ที่ส่งให้ WhenAll — ไม่ใช่ ลำดับเวลาที่ throw จริง (แต่ไม่ใช่ contract ที่ document ไว้ตายตัว — อย่าพึ่งพาลำดับนี้แบบ hard-code)
- ถ้าต้องการเห็นทุก exception → iterate ผ่าน
task.Exceptionของแต่ละ task เอง (ดูตัวอย่าง §10)
5. Task.WhenAny — "ใครเสร็จก่อน"
Task.WhenAny รอ "ตัวแรกที่เสร็จ" จากหลาย task — เหมาะกับ pattern เช่นแข่งกันระหว่าง cache กับ DB, หรือทำ timeout (แข่งงานจริงกับ Task.Delay):
csharp
var fast = GetFromCacheAsync();
var slow = GetFromDbAsync();
var done = await Task.WhenAny(fast, slow);
var result = await done;ใช้กับ timeout pattern (เวอร์ชันเก่า):
csharp
// ⚠️ pattern เก่า — ปัญหา: ถ้า task ชนะก่อน Task.Delay จะค้างเป็น timer ที่ยังหมุน
// (ใน long-running service สะสมเป็นพันตัวได้) → ต้อง cancel delay เอง
var task = LongOperation();
using var cts = new CancellationTokenSource();
var delay = Task.Delay(TimeSpan.FromSeconds(5), cts.Token);
if (await Task.WhenAny(task, delay) == task)
{
cts.Cancel(); // หยุด timer ทิ้ง ไม่ให้ค้าง
return await task;
}
throw new TimeoutException();แต่จริง ๆ ใน .NET 6+ มี Task.WaitAsync(TimeSpan) ที่จัดการให้หมดในบรรทัดเดียว ใช้ตัวนี้แทน:
csharp
// ✅ pattern ปัจจุบัน — .NET 6+
try
{
return await LongOperation().WaitAsync(TimeSpan.FromSeconds(5));
}
catch (TimeoutException)
{
// handle timeout
throw;
}6. CancellationToken — สำคัญทุกที่
csharp
public async Task<User> GetUserAsync(string id, CancellationToken ct = default)
{
var user = await _http.GetFromJsonAsync<User>($"/users/{id}", ct);
ct.ThrowIfCancellationRequested();
return user ?? throw new InvalidOperationException($"user {id} not found");
}
// caller สร้าง token
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
var u = await GetUserAsync("1", cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("timed out");
}กฎ:
- ทุก async method ที่อาจช้า → รับ
CancellationToken(default =default) - forward token ไป API ที่เรียกต่อ (HttpClient, DbContext, Stream)
ThrowIfCancellationRequested()ใน loop
7. ConfigureAwait — โค้ดเก่า ๆ จะเจอ
⏭️ โซนขั้นสูง — มือใหม่ข้ามได้
ใน ASP.NET Core (งาน web ที่เป็นเป้าหมายหลักของเล่ม dotnet) ไม่ต้องใช้
ConfigureAwaitเลย จำเป็นเฉพาะตอนเขียน library หรือแอป desktop (WinForms/WPF) มือใหม่ข้ามได้
csharp
await SomeAsync().ConfigureAwait(false);.ConfigureAwait(false) คือคำสั่งบอก runtime ว่า: "หลังจาก await เสร็จ ไม่ต้องพากลับมา resume (กลับมาทำงานต่อหลัง await) บน context เดิม"
ค่า "context" ในที่นี้คือ SynchronizationContext — ตัวประสานงานที่ผูก continuation (โค้ดที่รันต่อหลัง await เสร็จ) ให้รันบน thread เฉพาะ เช่น UI thread (เส้นการทำงานที่วาดหน้าจอใน WinForms/WPF/MAUI)
ใน ASP.NET Core (ทุกเวอร์ชันตั้งแต่ 1.0 ปี 2016) ไม่มี SynchronizationContext → ไม่ต้องใส่ (ไม่ใช่ feature ใหม่ของ .NET 6 — เป็นแบบนี้มาตั้งแต่ ASP.NET Core เกิด ต่างจาก ASP.NET Framework รุ่นเก่าที่มี)
ที่ต้องใส่ยัง:
- Library code — เผื่อมีคนเอาไปใช้ใน WinForms/WPF (มีกฎ "library code ควร
ConfigureAwait(false)ทุกawait" สำหรับ library ทั่วไป) - WinForms/WPF/MAUI app — มี UI thread → ต้องคิดว่าจะ resume กลับ UI thread หรือไม่
ใน ASP.NET Core app — ไม่ต้องเลย
8. ValueTask — Performance variant
csharp
public ValueTask<int> Fast()
{
if (_cached.HasValue) return new ValueTask<int>(_cached.Value);
return new ValueTask<int>(SlowAsync());
}ValueTask<T> ดีกว่า Task<T> เมื่อ ผลลัพธ์มักพร้อมทันที (sync) เช่น cache hit (เจอใน cache แล้ว ไม่ต้องรอ) → ไม่ต้อง allocate (จองหน่วยความจำ)
⚠️
ValueTaskเป็น pitfall (จุดพลาดที่ทำให้งงมาก) — ใช้เฉพาะตอน profiler ชี้ว่ามี allocation pressure จากTaskเท่านั้น default ให้ใช้Taskตลอดข้อจำกัดของ
ValueTask:
awaitซ้ำไม่ได้ — ใช้ได้ครั้งเดียวawaitพร้อมกันจากหลาย consumer ไม่ได้ — race condition ทันที- เรียก
.GetAwaiter().GetResult()แบบ blocking ไม่ได้เป็น primitive สำหรับคนเขียน library ที่รู้ตัวว่าทำอะไร ไม่ใช่ของใช้ทั่วไป
9. async streams — IAsyncEnumerable<T> (C# 8+)
IAsyncEnumerable<T> (C# 8+) คือ "สตรีม (สายข้อมูล) ที่ทยอยส่งค่าทีละตัวแบบ async" — วนด้วย await foreach เหมาะกับการอ่านข้อมูลทีละชิ้นจากแหล่งช้า (ไฟล์ใหญ่, paged API คือ API ที่ส่งข้อมูลมาทีละหน้า) โดยไม่ต้องโหลดทั้งหมดเข้า memory:
csharp
using System.Runtime.CompilerServices; // ต้องมีสำหรับ [EnumeratorCancellation]
public async IAsyncEnumerable<string> ReadLinesAsync(
string path,
[EnumeratorCancellation] CancellationToken ct = default)
{
using var reader = new StreamReader(path);
while (!reader.EndOfStream)
{
ct.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync(ct); // overload รับ CT มาตั้งแต่ .NET 7
if (line is null) yield break;
yield return line;
}
}
// consume
await foreach (var line in ReadLinesAsync("big.log"))
Console.WriteLine(line);💡 เวอร์ชัน:
ReadLineAsync(CancellationToken)มีตั้งแต่ .NET 7+ ถ้าอยู่บน .NET 6 ใช้ReadLineAsync()ที่ไม่รับ CT (แล้วเช็คct.ThrowIfCancellationRequested()ใน loop เอา)
Streaming async data — เช่น log lines, paged API result
10. Exception in async
exception ในโค้ด async ถูก "เก็บ" ไว้ใน Task แล้วโยนตอน await — ดักด้วย try/catch รอบ await ได้ตามปกติ แต่ระวัง: ถ้าหลาย task พังพร้อมกัน (WhenAll) await เห็นแค่ตัวแรก ต้องดู AggregateException เอง:
csharp
async Task ThrowAsync()
{
await Task.Delay(10);
throw new InvalidOperationException("oops");
}
try
{
await ThrowAsync();
}
catch (InvalidOperationException ex)
{
// catch ได้ปกติ
}ใน Task.WhenAll → ทุก exception รวมใน AggregateException แต่ await show แค่ตัวแรก:
csharp
try
{
await Task.WhenAll(t1, t2);
}
catch (Exception ex)
{
// ex = first exception
}
// ถ้าต้องการทุกตัว
var tasks = new[] { t1, t2 };
var whenAll = Task.WhenAll(tasks);
try { await whenAll; }
catch
{
foreach (var t in tasks)
{
if (!t.IsFaulted || t.Exception is null) continue;
// iterate InnerExceptions ทุกตัว — ไม่ใช่แค่ InnerException ตัวแรก
foreach (var inner in t.Exception.InnerExceptions)
Console.WriteLine(inner.Message);
}
}11. Async pitfalls
11.1 async void
csharp
// ❌ exception หลุดจาก async void → ปกติ process crash
async void Handler() { throw new Exception(); }
// ✅
async Task Handler() { throw new Exception(); }💡 พฤติกรรมตอน throw ขึ้นกับ context:
- ASP.NET Core / Console — ไม่มี
SynchronizationContext→ exception ขึ้น thread pool → process crash- WinForms/WPF — มี SyncContext จะ post กลับ UI thread แล้ว throw บนนั้น (catch ที่
Application.ThreadExceptionได้ในบางกรณี) ผลลัพธ์: ห้ามใช้async voidยกเว้น event handler — เพราะคุณคุมไม่ได้ว่าจะรันใน context ไหน
11.2 Sync over async (.Result, .Wait())
csharp
// ❌ pattern ที่ทำให้ปวดหัว
var result = SomeAsync().Result;
SomeAsync().Wait();
// ✅
var result = await SomeAsync();💡 ผลกระทบต่างกันตาม context:
- WinForms/WPF/MAUI (มี SyncContext) → deadlock ค้างตาย (continuation รอ UI thread, UI thread รอ
.Result)- ASP.NET Core (ไม่มี SyncContext) → ปกติไม่ deadlock แต่ทำให้ thread pool starvation (ขาดแคลน thread) — thread หนึ่งบล็อกรออีก thread ทำงาน thread pool พังเร็ว สรุป: ห้ามใช้ทั้งสองที่ ไม่ว่า context ไหน
11.3 Fire-and-forget โดยไม่ handle exception
csharp
// ❌
_ = LongRunning(); // exception ที่นี่หาย
// ✅
_ = Task.Run(async () =>
{
try { await LongRunning(); }
catch (Exception ex) { _logger.LogError(ex, "..."); }
});11.4 await ใน loop ที่ควร parallel
csharp
// ❌ sequential
foreach (var id in ids)
await Process(id);
// ✅
await Task.WhenAll(ids.Select(Process));
// ⚠️ สร้าง Task ทุกตัวพร้อมกันทันที — ถ้า ids ใหญ่มาก ใช้ Parallel.ForEachAsync ดูข้างล่าง
// ✅ limit concurrency
await Parallel.ForEachAsync(ids,
new ParallelOptions
{
MaxDegreeOfParallelism = 10, // default = -1 (ไม่จำกัด) — ควรตั้งค่าให้เหมาะสม
CancellationToken = ct, // forward CT ให้ Parallel จัดการ cancel เอง
},
async (id, token) => await Process(id, token)); // ใช้ token ที่ส่งมาในแต่ละ item11.5 CPU-bound ใน async method
csharp
// ❌ async ไม่ช่วย CPU-bound — ยังคง block thread
// (compiler ยังเตือน CS1998: "async method ไม่มี await" อีกด้วย)
public async Task<int> Compute()
{
var sum = 0;
for (int i = 0; i < 1_000_000_000; i++) sum += i;
return sum;
}
// ✅ ให้ method เป็น sync ตรง ๆ — ปล่อย caller ตัดสินใจว่าจะ Task.Run เอง
public int Compute()
{
var sum = 0;
for (int i = 0; i < 1_000_000_000; i++) sum += i;
return sum;
}
// caller (เช่น UI event handler) เป็นคน offload เอง:
// var result = await Task.Run(() => obj.Compute());⚠️ อย่าห่อ
Task.Runใน library code โดยซ่อนจาก callerเป็น guidance ของทีม .NET (Stephen Toub — สถาปนิก async ของ .NET):
- ถ้า library คืน
Taskที่ "แอบรันบน thread pool" → caller คุม concurrency ไม่ได้- ใน ASP.NET Core → request handler (โค้ดรับ HTTP request) รันบน thread pool อยู่แล้ว
Task.Runในนั้นเท่ากับ ยืม thread จาก pool เดียวกันมาทำงาน → ไม่ได้อะไรเพิ่ม แถมเร่งให้ pool หมดเร็วขึ้น (thread pool starvation)- แนวทาง: library ควรคืน sync method ตรง ๆ แล้วให้ caller (UI/desktop) เป็นคนตัดสินใจ
Task.Runเอง
✅ ASP.NET Core เกือบไม่ต้อง
Task.Run— request handler รันบน thread pool อยู่แล้ว
12. SemaphoreSlim — limit concurrency
ถ้ายิงงาน async พร้อมกันไม่จำกัดอาจถล่ม resource (เช่นยิง API พันตัวพร้อมกัน) — SemaphoreSlim จำกัดจำนวนที่ทำพร้อมกันได้ (เช่นทีละ 3) โดย task ส่วนเกินจะรอคิว:
csharp
private readonly SemaphoreSlim _sem = new(3); // max 3 concurrent
public async Task<T> DoWork()
{
await _sem.WaitAsync();
try
{
return await HeavyAsync();
}
finally
{
_sem.Release();
}
}13. Channel<T> — producer/consumer
Channel<T> คือคิวแบบ thread-safe (ปลอดภัยเมื่อหลาย thread ใช้พร้อมกัน) สำหรับ pattern producer/consumer (ผู้ผลิต/ผู้บริโภค — ฝั่งหนึ่งป้อนงาน อีกฝั่งหยิบไปทำ) ในโลก async
ฝั่งผลิตเขียนเข้า channel, ฝั่งบริโภค await foreach ดึงออก และจัดการ backpressure ให้อัตโนมัติ — backpressure คือเมื่อผู้ผลิตเร็วกว่าผู้บริโภคจนคิวเต็ม ก็ให้ผู้ผลิตรอ ไม่ให้ล้น:
csharp
using System.Threading.Channels;
var channel = Channel.CreateUnbounded<string>();
// ⚠️ Unbounded = คิวไม่จำกัดขนาด — ถ้า producer เร็วกว่า consumer มาก memory อาจเต็ม
// ในงาน production ใช้ Channel.CreateBounded<string>(capacity: 100) แทน เพื่อให้ backpressure ทำงาน
// producer
_ = Task.Run(async () =>
{
for (int i = 0; i < 10; i++)
await channel.Writer.WriteAsync($"msg {i}");
channel.Writer.Complete();
});
// consumer
await foreach (var msg in channel.Reader.ReadAllAsync())
Console.WriteLine(msg);ใช้แทน BlockingCollection ใน modern async code
14. Lock alternatives
ใน async ใช้ lock ไม่ได้ (เพราะ lock ไม่รองรับ async) — ใช้:
SemaphoreSlim(1, 1)แทน mutexlockสำหรับ sync code ปกติInterlockedสำหรับ atomic operation
csharp
private int _count;
Interlocked.Increment(ref _count);15. ParallelOptions + Parallel.ForEachAsync (.NET 6+)
Parallel.ForEachAsync (.NET 6+) คือวิธีที่ดีที่สุดในการทำงาน async กับหลาย item พร้อมกัน — คุมจำนวน concurrency (MaxDegreeOfParallelism) และยกเลิกได้ ในตัวเดียว เหมาะกับ bulk work เช่นยิง API หลายตัว:
csharp
await Parallel.ForEachAsync(items,
new ParallelOptions
{
MaxDegreeOfParallelism = 10, // default = -1 (ไม่จำกัด) — ควรตั้งค่าให้เหมาะสมกับ service ปลายทาง
CancellationToken = ct,
},
async (item, token) =>
{
await Process(item, token);
});ดีที่สุดสำหรับ bulk parallel work — control concurrency + cancel ได้
16. PeriodicTimer (.NET 6+) — replace System.Timers.Timer
PeriodicTimer (.NET 6+) คือวิธีทำงานเป็นรอบ ๆ แบบ async ที่ทันสมัย — รอด้วย await timer.WaitForNextTickAsync() ใน loop แทน System.Timers.Timer แบบ callback เดิมที่จัดการ error/cancel ยาก:
csharp
var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(ct))
{
Console.WriteLine($"tick {DateTime.Now}");
}Async-friendly + cancellable
17. AsyncLocal<T> — ambient context ใน async chain
⏭️ โซนขั้นสูง — มือใหม่ข้ามได้
หัวข้อนี้เจอบ่อยตอนเขียน middleware หรือ library ที่ต้องส่งข้อมูลข้าม layer โดยไม่ผ่าน parameter — มือใหม่ข้ามไปหัวข้อ 19 (สรุป pitfalls) ได้เลย กลับมาอ่านเมื่อเจอปัญหานี้จริง
AsyncLocal<T> คือ "ค่าที่ flow (ไหลตาม) async call chain"
เทียบกับ thread-local (ค่าที่ผูกกับ thread ตัวใดตัวหนึ่ง) — แบบนี้ไม่เหมาะกับโลก async เพราะ await กระโดดข้าม thread ได้ตลอด ค่าจะหายไปทันทีที่เปลี่ยน thread
AsyncLocal แก้ปัญหานี้ด้วยการผูกค่ากับ logical context แทนที่จะผูกกับ thread จริง โดย logical context ในที่นี้คือ ExecutionContext — กล่องเก็บข้อมูลที่ runtime ส่งต่อข้าม await ให้อัตโนมัติ
ใช้สำหรับ ambient context (ข้อมูลแวดล้อมที่ทุก layer (ชั้น) ของโค้ดเข้าถึงได้โดยไม่ต้องส่งเป็น parameter):
- CurrentUser / Tenant ในระบบ web/multi-tenant
- Correlation ID / Trace context สำหรับ logging + distributed tracing
- DbContext scope ของบางระบบ DI
csharp
public static class CurrentUser
{
private static readonly AsyncLocal<UserContext?> _store = new();
public static UserContext? Value
{
get => _store.Value;
set => _store.Value = value;
}
}
// middleware ใน ASP.NET Core
app.Use(async (ctx, next) =>
{
CurrentUser.Value = new UserContext(ctx.User.Identity?.Name);
try { await next(); }
finally { CurrentUser.Value = null; } // clear ตอนจบ (กัน leak ข้าม request)
});
// ที่ service ลึก ๆ — ไม่ต้องรับ user เป็น parameter
public class OrderService
{
public async Task<Order> CreateAsync(/* ไม่มี user param */)
{
var u = CurrentUser.Value ?? throw new UnauthorizedAccessException();
// ...
}
}Pitfall (จุดพลาดที่ทำให้ค่าหาย):
AsyncLocal flow ผ่าน ExecutionContext[^ec] runtime จะ "ส่งต่อ" context ให้อัตโนมัติเมื่อกระโดดข้าม async boundary (จุดเปลี่ยนผ่านตอน await) แต่ค่าจะหายได้ใน 2 กรณี:
- มีการเรียก
ExecutionContext.SuppressFlow()(สั่ง runtime หยุดส่งต่อ context) - ใช้ low-level API ที่ไม่ capture context (ไม่ "จับภาพ" บริบทตอนเริ่ม task) เช่น
ThreadPool.UnsafeQueueUserWorkItem
รายละเอียดเชิงลึกของ DI lifetime อยู่ใน dotnet/07 §9
[^ec]: ExecutionContext = กล่องเก็บข้อมูล ambient ที่ runtime ส่งต่อให้อัตโนมัติเมื่อข้าม async boundary (จุดเปลี่ยนผ่านตอน await) ทำให้ AsyncLocal<T> ทำงานได้แม้โค้ดจะกระโดด thread
18. TaskCompletionSource<T> — สร้าง Task เอง (bridge event → async)
⏭️ โซนขั้นสูง — มือใหม่ข้ามได้
หัวข้อนี้ใช้ตอนต้อง bridge API แบบเก่า (event/callback เช่น SignalR, RabbitMQ) ให้กลายเป็น async/await — งานส่วนใหญ่ของมือใหม่ไม่ต้องเขียนแบบนี้เอง มือใหม่ข้ามได้
TaskCompletionSource<T> (TCS) คือ primitive ที่ให้คุณ "สร้าง Task<T> เปล่า แล้วเซ็ตผลทีหลัง" — ใช้เวลาต้อง bridge API แบบเก่า (event, callback, signal) ให้กลายเป็น async/await:
csharp
// ตัวอย่าง: รอ event "Closed" จาก connection แบบ awaitable
// (pseudocode — สมมติ IConnection มี event Closed; ของจริง pattern นี้ใช้กับ SignalR HubConnection,
// RabbitMQ IConnection, หรือ event-based API ที่ต้องการ bridge เป็น Task)
public static async Task WaitForCloseAsync(this IConnection conn, CancellationToken ct = default)
{
var tcs = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously); // ⭐ สำคัญ ดูด้านล่าง
void OnClose(object? s, EventArgs e) => tcs.TrySetResult(true);
conn.Closed += OnClose;
// forward cancellation; เก็บ registration ไว้ dispose ใน finally
using var ctr = ct.Register(() => tcs.TrySetCanceled(ct));
try
{
await tcs.Task.ConfigureAwait(false);
}
finally
{
// unsubscribe ทุกกรณี (ทั้ง event มาก่อน, cancel มาก่อน, หรือ exception)
conn.Closed -= OnClose;
}
}
// ใช้
await conn.WaitForCloseAsync(ct);💡 หมายเหตุ:
System.Net.WebSockets.WebSocketไม่มี eventClosed— ตัวอย่างข้างบนเป็น pseudocode ที่สื่อ pattern สำหรับ event-based API ทั่วไป (เช่นMicrosoft.AspNetCore.SignalR.Client.HubConnection.Closed)
ทำไมต้อง RunContinuationsAsynchronously
- Default ของ TCS = continuation (โค้ดหลัง
await tcs.Task) รันบน thread ที่เรียกSetResult - ถ้าเรียก
SetResultใน event handler แบบ sync → continuation ทั้งสาย ยึด thread ของ event handler - ผลที่ตามมา:
- Deadlock (ค้างตาย) — ถ้า continuation พยายาม raise event เดิมต่อ
- Stack overflow — ถ้า continuation ยาวมากเรียกซ้อนเข้าไป
- ทางออก: ใส่ flag
RunContinuationsAsynchronouslyทุกครั้ง ยกเว้นมีเหตุผลแน่ชัดจะไม่ใส่
TrySetResult vs SetResult — ใช้ TrySet* (เช่น TrySetResult, TrySetCanceled, TrySetException) ปลอดภัยกว่าเมื่อมีหลายตัวอาจ complete TCS แข่งกัน (เช่น event มาพร้อม cancellation) Set* จะ throw InvalidOperationException ถ้า TCS complete ไปแล้ว — TrySet* แค่ return false เฉย ๆ
💡 ใน .NET 6+ ใช้
TaskCompletionSource(ไม่มี generic) แทนถ้าไม่ต้องการ value, หรือChannel<T>ถ้าต้องการ "ส่งหลายค่า" (ดู §13)
19. Common Pitfalls (จุดพลาดบ่อย — ทบทวน)
async voidที่ไม่ใช่ event handler (ตัวจัดการเหตุการณ์).Result/.Wait()ใน UI/web thread → deadlock (ค้างตายเพราะต่างฝ่ายต่างรอกัน)- await ในลูป → กลายเป็น sequential (ทำทีละตัวต่อกัน) — ใช้
WhenAll - งาน CPU-bound (ใช้ CPU หนัก) ใน
async— ใช้Task.Run - ลืมรับ/ส่งต่อ
CancellationToken(ตัวสั่งยกเลิกงาน) - fire-and-forget (ยิงงานทิ้งไว้ไม่รอผล) โดยไม่ catch exception
- ใส่
ConfigureAwaitที่ไม่จำเป็นใน ASP.NET Core
🛠️ Checkpoint 11 — ลงมือทำ
- method
Task<string> FetchAsync(string url, CancellationToken ct) - fetch 5 URL ขนาน + รอทั้งหมด + handle เฉพาะ exception ตัวที่ fail
- ทำ timeout 2 วินาทีต่อ request โดยใช้
task.WaitAsync(TimeSpan) Parallel.ForEachAsyncดาวน์โหลด 100 URL — limit 10 concurrentIAsyncEnumerable<string>ที่ stream log line จากไฟล์ใหญ่ + ใช้await foreach- SemaphoreSlim limit DB connection ที่ 5 พร้อมกัน
สรุปบทที่ 11
async/await= thread กลับ pool ขณะรอ I/O → server handle ได้มากขึ้น- ทุก async method ลงท้าย
Async+ รับCancellationToken Task.WhenAll= parallel;WhenAny= first to finish- ห้าม
.Result/.Wait()/async void ValueTask<T>สำหรับ hot path ที่มักจะ syncIAsyncEnumerable<T>สำหรับ streamParallel.ForEachAsyncสำหรับ bulk parallelSemaphoreSlimสำหรับ limit concurrencyAsyncLocal<T>สำหรับ ambient context ที่ flow ตาม async chain (CurrentUser, TraceId)TaskCompletionSource<T>(ใส่RunContinuationsAsynchronouslyเสมอ) สำหรับ bridge event/callback → Task
🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-12