Basic Asynchronous Programming with C# Part 1

Keerati Ratanapatayakorn
T. T. Software Solution
6 min readMar 9, 2023

หลายๆ ท่านอาจจะเคยเห็น keyword เช่น async Task, await, Wait(), WhenAll() แต่ที่แน่ๆ มีเพียงไม่กี่คนที่จะเข้าใจได้ว่า มันคืออะไร และตอนไหนที่เราจะใช้มันได้บ้าง นี่คือส่วนหนึ่งของเนื้อหาที่เราจะนำพาทุกท่านมาทำความเข้าใจในการทำ Asynchronous Programming

คำถามที่หลายๆ ท่านอาจจะถามต่อก็คือ แล้ว Asynchronous Programming คืออะไรล่ะ แล้วเอาไว้ใช้ทำอะไรกันแน่

คำตอบคือ Asynchronous Programming คือการทำ Concurrent Programming ประเภทหนึ่ง ซึ่งการทำ Concurrent Programming ที่เราเคยรู้จัก(หรืออาจจะเคยได้ยิน)มาก่อนหน้านี้แล้ว เช่น

  • Multi-threaded Programming
  • Parallel Processing
  • Etc.

แล้ว Concurrent Programming คืออะไรล่ะ ฮ่าๆๆๆๆ คำตอบสั้นๆ เลยครับ

💡 Doing more than one thing at a time

คือความสามารถในการทำได้หลายสิ่งในช่วงเวลาหนึ่ง

เพื่อที่จะได้ทำความเข้าใจในเนื้อหา Asynchronous Programming ผมขอบอกไว้เนิ่นๆ ก่อนเลยละกันครับว่านับจากตรงนี้ เราจะไม่มีการพูดถึงสิ่งต่างๆ ดังต่อไปนี้นะครับ

  • Multi-threaded Programming
  • การนำ class จาก System.Threading.Thread มาใช้งาน
  • Parallel Processing
  • การทำ background process เมื่อ function นั้นเชื่อมต่อกับ I/O
  • การทำ performance tuning

💡ถ้าเราเอา Thread class มาใช้งาน เราจะถือว่ามันสามารถเป็น legacy code ได้ในอนาคต

เท่าที่ผมศึกษาและทำความเข้าใจในเนื้อหา Asynchronous Programming มานั้น หนึ่งในบทความที่ผมคิดว่าน่าจะช่วยให้ผมเริ่มจากจุดที่ผมไม่เคยเข้าใจเลย ให้สามารถเข้าใจได้ง่ายนั้น คือ บทความจาก Microsoft

ดังนั้น ใน Part 1 นี้ ผมจะขออ้างอิงเนื้อหาหลักจาก Microsoft รวมถึงใส่ความคิดเห็นส่วนตัวลงไปด้วยนะครับ

https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/

ก่อนที่จะเริ่มดูในส่วนของ code ผมอยากให้ทุกท่านทำการ clone git นี้ลงมาในเครื่อง

โครงสร้าง project หน้าตาจะเป็นแบบนี้

เราจะมาเริ่มต้นที่ project SynchronousProgramming กันครับ ซึ่ง project นี้จะทำการเขียน code ในรูปแบบของ Synchronous Programming

ให้ดูที่ไฟล์ Program.cs เราจะเห็นแต่ method ที่เราเอาไว้ operate สิ่งต่างๆ ดังนี้

        private static void PourOJ()
{
Console.WriteLine("Pouring orange juice");
}

private static void ApplyJam(Toast toast)
{
// Apply jam to toast
Console.WriteLine("Putting jam on the toast");
}

private static void ApplyButter(Toast toast)
{
// Apply butter to toast
Console.WriteLine("Putting butter on the toast");
}

private static Toast ToastBread(int slices)
{
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("Putting a slice of bread in the toaster");
}
Console.WriteLine("Start toasting...");
Task.Delay(3000).Wait();
Console.WriteLine("Remove toast from toaster");
return new Toast();
}

private static void FryBacon(int slices)
{
Console.WriteLine($"putting {slices} slices of bacon in the pan");
Console.WriteLine("cooking first side of bacon...");
Task.Delay(3000).Wait();
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("flipping a slice of bacon");
}
Console.WriteLine("cooking the second side of bacon...");
Task.Delay(3000).Wait();
Console.WriteLine("Put bacon on plate");
}

private static void FryEggs(int howMany)
{
Console.WriteLine("Warming the egg pan...");
Task.Delay(3000).Wait();
Console.WriteLine($"cracking {howMany} eggs");
Console.WriteLine("cooking the eggs ...");
Task.Delay(3000).Wait();
Console.WriteLine("Put eggs on plate");
}

private static void PourCoffee()
{
Console.WriteLine("Pouring coffee");
}

ที่ FryBacon() และ FryEggs() จะมีการทำ Task.Delay(3000).Wait() เพื่อบอกว่า เราต้องรอคำสั่งนี้ 3 วินาทีให้จบก่อน จึงจะไปที่คำสั่งถัดไปได้

ปล. Task.Delay(int millisecondsDelay) จะรับ parameter เป็น int โดยนับเป็นมิลลิวินาที

        static void Main(string[] args)
{
Stopwatch sw = Stopwatch.StartNew();

PourCoffee();
Console.WriteLine("coffee is ready");

FryEggs(2);
Console.WriteLine("eggs are ready");

FryBacon(3);
Console.WriteLine("bacon is ready");

Toast toast = ToastBread(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("toast is ready");

PourOJ();
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!");
sw.Stop();
Console.WriteLine($"Time usage : {sw.ElapsedMilliseconds}");
}

ใน Main() เราจะทำการ call method ตามลำดับดังนี้

  1. PourCoffee()
  2. FryEggs(2)
  3. FryBacon(3)
  4. ToastBread(2)
  5. ApplyButter(toast) (โดยเอา result ที่ได้จากขั้นตอนที่ 4 มาใช้)
  6. ApplyJam(toast) (โดยเอา result ที่ได้จากขั้นตอนที่ 4 มาใช้)
  7. PourOJ()

ในการทำงานแบบ Synchronous จะทำงานแบบเป็นลำดับขั้นตอนตามที่เราเขียนโปรแกรม ดังนั้นลำดับการทำงานก็จะเป็น 1→2→3→4→5→6→7

เมื่อเราทำการ run program ก็จะได้ผลลัพท์การทำงานดังนี้

ใช้เวลาในการทำงานทั้งหมด 15037 มิลลิวินาที

เนื่องจากวัตถุประสงค์ของบทความนี้คือเราต้องการที่จะเขียนโปรแกรมแบบ Asynchronous

ดังนั้น คำถามคือ ถ้าเราอยากจะเปลี่ยน code ที่เราเขียนขึ้นมาเป็นแบบ Asynchronous จะทำอย่างไร?

คำตอบ : เรามาดูว่า method ไหนที่เราเรียกใช้ มันมี return type ดังนี้

  • Task
  • Task<T>
  • ValueTask<TResult> (ในบทความนี้จะยังไม่พูดถึงเรื่องนี้)

เราจะทำการใช้ keyword async await เพื่อช่วยให้เราสามารถเขียน code แบบ Asynchronous ได้

ให้เรามาดู code ใน project 01.ChangeFromSyncToAsyncProgramming

เราจะสังเกตเห็นได้ว่า คำสั่ง Task.Delay(int millisecondsDelay) มี return type เป็น Task

public static System.Threading.Tasks.Task Delay (int millisecondsDelay);

ดังนั้น เราจะทำการ change การเรียก method จากเดิม Task.Delay(3000).Wait() ให้เป็บแบบ asynchronous ได้โดยการใช้ keyword await แบบนี้

await Task.Delay(3000);

จุดสำคัญของการเขียน Asynchronous Programming ที่ถูกต้องคือ เราต้องทำให้ครบทั้งสายของการ call method (บางท่านอาจจะเรียกว่า call stack ก็ได้) ดังนั้นเราจะทำการแก้ไข method ToastBread(), FryBacon(), และ FryEggs() ดังนี้

  1. ปรับจาก return type void เป็น async Task
  2. ปรับจาก return type Toast เป็น async Task<Toast>
  3. method ที่เราปรับเป็น async ให้ทำ naming convention โดยการใส่คำว่า Async ต่อท้ายชื่อ method นั้น เพื่อให้ผู้อื่นที่มาทำการ call method นี้จะรับรู้ได้ทันทีว่า method นี้เราจะต้อง call แบบ Asynchronous
        private static async Task<Toast> ToastBreadAsync(int slices)
{
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("Putting a slice of bread in the toaster");
}
Console.WriteLine("Start toasting...");
await Task.Delay(3000);
Console.WriteLine("Remove toast from toaster");
return new Toast();
}
        private static async Task FryBaconAsync(int slices)
{
Console.WriteLine($"putting {slices} slices of bacon in the pan");
Console.WriteLine("cooking first side of bacon...");
await Task.Delay(3000);
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("flipping a slice of bacon");
}
Console.WriteLine("cooking the second side of bacon...");
await Task.Delay(3000);
Console.WriteLine("Put bacon on plate");
}
        private static async Task FryEggsAsync(int howMany)
{
Console.WriteLine("Warming the egg pan...");
await Task.Delay(3000);
Console.WriteLine($"cracking {howMany} eggs");
Console.WriteLine("cooking the eggs ...");
await Task.Delay(3000);
Console.WriteLine("Put eggs on plate");
}

หลังจากนั้น ที่ Main() เราจะต้องแก้ไขต่อดังนี้

  1. ปรับจาก return type void เป็น async Task
  2. ตอนที่ call method ToastBread(), FryBacon(), และ FryEggs() ก็จะต้องใส่ keyword await
        static async Task Main(string[] args)
{
Stopwatch sw = Stopwatch.StartNew();

PourCoffee();
Console.WriteLine("coffee is ready");

await FryEggsAsync(2);
Console.WriteLine("eggs are ready");

await FryBaconAsync(3);
Console.WriteLine("bacon is ready");

Toast toast = await ToastBreadAsync(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("toast is ready");

PourOJ();
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!");
sw.Stop();
Console.WriteLine($"Time usage : {sw.ElapsedMilliseconds}");
}

เท่านี้ก็คือเสร็จสิ้นการเขียน code แบบ Asynchronous แบบทั้งสาย

แต่ แต่ แต่ เดี๋ยวก่อน เรามาลอง run project นี้ดูกันหน่อย ว่าผลลัพท์จะเป็นอย่างไร

จะสังเกตได้ว่า ใช้เวลาในการทำงานทั้งหมด 15050 มิลลิวินาที ซึ่งไม่ได้ดีไปกว่าแบบที่เป็น Synchronous

คำถามคือ ทำไมถึงเป็นแบบนั้น ?

คำตอบคือ

  1. Asynchronous Programming ไม่ได้มีไว้เพื่อทำ performance tuning ดังนั้นจึงไม่แปลกที่ time usage จะยังเท่ากับแบบที่เป็น Synchronous
  2. วิธีการเขียนแบบนี้ ยังเป็นการทำแต่ละ function แบบเรียงขั้นตอนการทำงานอยู่

แต่อย่างน้อย การที่เราเข้าใจวิธีการแปลง code จาก Synchronous เป็น Asynchronous จะทำให้เราสามารถทำ Concurrent Programming ได้

ถัดมา เราจะทำ code ที่เป็น Asynchronous ให้เกิด Concurrent

เรามาทบทวนความเข้าใจกันใหม่อีกครั้งก่อนที่จะมาดู project นี้กันครับว่า Concurrent คืออะไร

💡 Doing more than one thing at a time

คือความสามารถในการทำได้หลายสิ่งในช่วงเวลาหนึ่ง

ดังนั้นเราจะมาทำ code ที่เป็น Asynchronous แบบถูกต้องในตอนนี้ ให้เกิด Concurrent ได้

จาก code ใน project 01.ChangeFromSyncToAsyncProgramming

เรามาดูก่อนว่า แต่ละขั้นตอนแบบเดิม มีอะไรบ้าง

  1. PourCoffee()
  2. FryEggsAsync(2)
  3. FryBaconAsync(3)
  4. ToastBreadAsync(2)
  5. ApplyButter(toast)
  6. ApplyJam(toast)
  7. PourOJ()

หลังจากนั้น มาดู code ใน project 02.OperateTaskAsConcurrent

เราจะมาพิจารณาว่างานไหนที่ไม่ต้องรอกันได้บ้าง มีดังนี้

  • ApplyButter(toast), ApplyJam(toast), และ PourOJ() ไม่ต้องรอให้ FryEggsAsync(2) และ FryBaconAsync(3) ทำเสร็จ ดังนั้น เราจะเลือกให้ FryEggsAsync(2) และ FryBaconAsync(3) ทำก่อน ApplyButter(toast), ApplyJam(toast), และ PourOJ() แต่ไม่ต้องรอให้ทั้ง 3 method นั้นทำเสร็จแล้วนั่นเอง
  • ToastBreadAsync(2) ไม่ต้องรอให้ FryEggsAsync(2) และ FryBaconAsync(3) ทำเสร็จ

ขั้นตอนในการทำ Concurrent มีดังนี้

  1. ทำการ call async method แล้วเอามาเก็บไว้ใน Task หรือ Task<T>
  2. นำเอา Task หรือ Task<T> ที่ได้นั้น ไป await ในขั้นตอนที่เราต้องการ make sure ว่าต้องการให้เสร็จ โดยสามารถข้ามขั้นตอนที่เราเคยรอก่อนหน้านี้ได้เลย

จาก code ชุดเดิม

ก็จะกลายเป็น

สิ่งที่เปลี่ยนไปคือ

  • ทำการเรียก ToastBreadAsync(2) ก่อน FryEggsAsync(2) และ FryBaconAsync(3)
  • ทั้ง 3 method ให้เอา Task หรือ Task<T> มารับค่าตาม return type จะทำให้ทั้ง 3 method ทำงานโดยที่ไม่ต้องรอกัน (แยกไปทำนอก Current Thread)
Task<Toast> toastTask = ToastBreadAsync(2);
Task eggsTask = FryEggsAsync(2);
Task baconTask = FryBaconAsync(3);
  • toastTask ให้ใส่ keyword await และเอาไปไว้หลังจากที่ call Task baconTask = FryBaconAsync(3);
  • eggTask และ baconTask ให้ใส่ keyword await และเอาไปไว้หลังจากที่ทำ ApplyButter(toast), ApplyJam(toast), และ PourOJ() เสร็จแล้ว

สิ่งที่จะเกิดขึ้นหลังจากที่เราเขียน code ลักษณะนี้คือ เราจะได้ความสามารถของการทำ Concurrent มาแล้ว

คราวนี้ เรามาลอง run project กันดูดีกว่า ว่าผลลัพท์ของการทำ Concurrent จะเป็นอย่างไร

ผลลัพท์จะมีความแตกต่างอย่างชัดเจนเมื่อเทียบกับผลลัพท์ก่อนหน้านี้ดังต่อไปนี้

  • time usage เหลือ 6025 มิลลิวินาที ซึ่งใช้เวลาน้อยกว่าเดิม 50%
  • ลำดับการทำงานเปลี่ยนไป

นี่คือข้อดีของการทำ Asynchronous Programming ให้เป็น Concurrent

*** เน้นย้ำ : สิ่งที่เราทำอยู่ในตอนนี้ ไม่ใช่ performance tuning แต่เป็นการทำ Concurrent Programming

เย้ๆๆๆๆๆๆๆ ในที่สุด ตอนนี้เราก็สามารถทำความเข้าใจใน Asynchronous Programming ขั้นพื้นฐานได้แล้ว ยินดีด้วยนะคร้าบบบบบ

หลังจากนี้ เราสามารถทำอะไรกับ code ชุดนี้ได้อีกบ้าง

เราจะสังเกตได้ว่า ApplyButter(toast) และ ApplyJam(toast) จะเกิดขึ้นจาก result ที่ได้จาก ToastBreadAsync(2)

ดังนั้น เราจะทำการ Refactor Code ส่วนนี้

ให้ดูที่ project 03.CompositionWithTasks

เราจะทำ composition task โดยการสร้าง method ขึ้นมา 1 อัน ชื่อ MakeToastWithButterAndJamAsync เพื่อทำการรวม ToastBreadAsync(2), ApplyButter(toast), และ ApplyJam(toast) ไว้ด้วยกัน

        private static async Task MakeToastWithButterAndJamAsync(int number)
{
var toast = await ToastBreadAsync(number);
ApplyButter(toast);
ApplyJam(toast);
}

MakeToastWithButterAndJamAsync() จะทำ ToastBreadAsync(2) แล้วตามด้วย ApplyButter(toast) และ ApplyJam(toast) เหมือนกับที่เราทำใน project ก่อนหน้านี้

หลังจากนั้น ที่ Main() แทนที่เราจะทำการเรียก await ToastBreadAsync(2) เราก็เปลี่ยนไปเรียกใช้ MakeToastWithButterAndJamAsync(2) ได้เลย

        static async Task Main(string[] args)
{
Stopwatch sw = Stopwatch.StartNew();

PourCoffee();
Console.WriteLine("coffee is ready");

Task toastTask = MakeToastWithButterAndJamAsync(2);
Task eggsTask = FryEggsAsync(2);
Task baconTask = FryBaconAsync(3);

await toastTask;
Console.WriteLine("toast is ready");

PourOJ();
Console.WriteLine("oj is ready");

await eggsTask;
Console.WriteLine("eggs are ready");

await baconTask;
Console.WriteLine("bacon is ready");
Console.WriteLine("Breakfast is ready!");
sw.Stop();
Console.WriteLine($"Time usage : {sw.ElapsedMilliseconds}");
}

ซึ่งเมื่อเราทำการ run code ชุดล่าสุด time usage จะไม่ได้แตกต่างจาก project ก่อนหน้า เพราะวิธีการทำงานเหมือนกับ project ก่อนหน้านั่นเอง

ถึงแม้ว่าเราจะไม่ได้ประโยชน์จาก performance ที่ดีขึ้น แต่อยากให้ทุกท่านได้สังเกต code แบบนี้คือ

  • เมื่อเรา refactor code เป็น จะทำให้ผู้ที่มาอ่าน code ของเรา สามารถทำความเข้าใจได้ง่ายขึ้นว่า แต่ละคำสั่งที่เราเขียนขึ้นมานั้น วัตถุประสงค์คืออะไร
  • ถ้าท่านอื่นจะนำเอา function ที่เราสร้างขึ้นมาไปใช้งานต่อ จะสามารถรับรู้ได้เลยว่า เค้าจะต้องหยิบเอา function อะไร ไปใช้เพื่อเรื่องอะไรได้บ้างนั่นเอง

ใน Part ถัดไป จะเป็นหัวข้อสำคัญที่เราจะนำไปใช้ในงานจริง รวมถึงหลักสำคัญของการทำ Asynchronous Programming ที่เราจำเป็นต้องรู้ ไม่ว่าจะเป็น

  • CPU Bound vs I/O Bound
  • Thread Safety vs Non Thread Safety
  • Concurrent Collection

ขอบพระคุณทุกท่านที่เข้ามาแวะชมบทความของเรานะครับ

References

--

--