Skip to content

บทที่ 11 — Async + Tasks + Cancellation

← บทที่ 10 | สารบัญ | บทที่ 12: IO + Files →

🟡 หมายเหตุระดับ: บทนี้เริ่มจากพื้นฐาน 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 + return Task / 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 แม้หลัง WhenAll await แล้ว

บางตัวอย่างบนเน็ตเขียนว่า 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 ตัวแรกตรง ๆ
    • ผลคือ catch block อาจจับผิดประเภท

ใช้ 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 ที่ส่งมาในแต่ละ item

11.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) แทน mutex
  • lock สำหรับ 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 ไม่มี event Closed — ตัวอย่างข้างบนเป็น 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 (จุดพลาดบ่อย — ทบทวน)

  1. async void ที่ไม่ใช่ event handler (ตัวจัดการเหตุการณ์)
  2. .Result / .Wait() ใน UI/web thread → deadlock (ค้างตายเพราะต่างฝ่ายต่างรอกัน)
  3. await ในลูป → กลายเป็น sequential (ทำทีละตัวต่อกัน) — ใช้ WhenAll
  4. งาน CPU-bound (ใช้ CPU หนัก) ใน async — ใช้ Task.Run
  5. ลืมรับ/ส่งต่อ CancellationToken (ตัวสั่งยกเลิกงาน)
  6. fire-and-forget (ยิงงานทิ้งไว้ไม่รอผล) โดยไม่ catch exception
  7. ใส่ ConfigureAwait ที่ไม่จำเป็นใน ASP.NET Core

🛠️ Checkpoint 11 — ลงมือทำ

  1. method Task<string> FetchAsync(string url, CancellationToken ct)
  2. fetch 5 URL ขนาน + รอทั้งหมด + handle เฉพาะ exception ตัวที่ fail
  3. ทำ timeout 2 วินาทีต่อ request โดยใช้ task.WaitAsync(TimeSpan)
  4. Parallel.ForEachAsync ดาวน์โหลด 100 URL — limit 10 concurrent
  5. IAsyncEnumerable<string> ที่ stream log line จากไฟล์ใหญ่ + ใช้ await foreach
  6. 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 ที่มักจะ sync
  • IAsyncEnumerable<T> สำหรับ stream
  • Parallel.ForEachAsync สำหรับ bulk parallel
  • SemaphoreSlim สำหรับ limit concurrency
  • AsyncLocal<T> สำหรับ ambient context ที่ flow ตาม async chain (CurrentUser, TraceId)
  • TaskCompletionSource<T> (ใส่ RunContinuationsAsynchronously เสมอ) สำหรับ bridge event/callback → Task

→ บทที่ 12: IO + Files


🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-12