Async vs. Threads: การถกเถียงในโลกโปรแกรมมิ่งที่ไม่เคยจบสิ้น

ทีมชุมชน BigGo
Async vs. Threads: การถกเถียงในโลกโปรแกรมมิ่งที่ไม่เคยจบสิ้น

ในโลกของการพัฒนาซอฟต์แวร์ มีหัวข้อไม่กี่เรื่องที่ก่อให้เกิดการอภิปรายอย่างหลงใหดได้เหมือนกับการเลือกระหว่างการเขียนโปรแกรมแบบ async/await และการเขียนโปรแกรมแบบเธรดดั้งเดิม ในขณะที่บทความทางเทคนิคล่าสุดได้สำรวจผลกระทบด้านประสิทธิภาพของทั้งสองแนวทาง ชุมชนนักพัฒนากำลังอภิปรายอย่างจริงจังถึงคำถามทางสถาปัตยกรรมที่ลึกซึ้งยิ่งขึ้นเกี่ยวกับวิธีที่เราจัดการกับการดำเนินการพร้อมกันในแอปพลิเคชันสมัยใหม่

ความตึงเครียดหลักระหว่างการทำงานพร้อมกันแบบชัดแจ้งและแบบโดยนัย

ความแตกแยกพื้นฐานในการอภิปรายนี้มุ่งเน้นไปที่ว่าผู้พัฒนาควรมีอำนาจควบคุมการดำเนินการพร้อมกันมากน้อยเพียงใด Async/await ทำให้การทำงานพร้อมกันชัดเจน - ทุกจุดที่หยุดทำงานจะถูกทำเครื่องหมายด้วยคำสำคัญ await สร้างสิ่งที่บางคนเรียกว่าฟังก์ชันสี (colored functions) ซึ่งทำงานแตกต่างจากโค้ดแบบประสานเวลา (synchronous) ความชัดแจ้งนี้มาพร้อมกับภาระทางความคิด แต่ให้ผู้พัฒนาควบคุมอย่างละเอียดได้ว่าเมื่อใดที่การดำเนินการควรส่งมอบการควบคุม

การใช้งาน Green thread เช่นใน goroutines ของ Go หรือภาษาใน BEAM (Erlang, Elixir) ใช้แนวทางตรงกันข้าม พวกมันทำให้การทำงานพร้อมกันแทบจะมองไม่เห็นในระดับโค้ด โดยอาศัยระบบรันไทม์ที่ซับซ้อนในการจัดการการจัดเวลาและการสลับบริบท ดังที่ผู้แสดงความคิดเห็นหนึ่งระบุเกี่ยวกับภาษา BEAM: คุณไม่มีสิ่งยุ่งยากอึดอัดระหว่างคำสำคัญที่สงวนไว้และลูปเหตุการณ์ (event loop) หากคุณต้องการให้ส่วนอื่นเกิดขึ้นในภายหลังเนื่องจากเหตุการณ์ คุณเพียงแค่จัดการให้เหตุการณ์ประเภทนั้นถูกส่งมา

ไม่มี callback ไม่มีการระบายสี async มีเพียงเหตุการณ์ วิธีแก้ปัญหาเกี่ยวกับเหตุการณ์คือการย้ำและทำให้ลูปเหตุการณ์ของคุณใช้งานได้ทั่วไปมากขึ้น

การแลกเปลี่ยนด้านประสิทธิภาพที่เกินกว่าการวัดมาตรฐานอย่างง่าย

ในขณะที่การอภิปรายเริ่มแรกมักจะมุ่งเน้นไปที่ปริมาณงานดิบ (throughput) แต่การอภิปรายในชุมชนเผยให้เห็นข้อพิจารณาด้านประสิทธิภาพที่ละเอียดอ่อนยิ่งขึ้น การใช้งาน Async/await โดยทั่วไปจะใช้ state machines ที่บันทึกเฉพาะตัวแปรที่จำเป็นในช่วงที่หยุดทำงาน ทำให้มีประสิทธิภาพด้านหน่วยความจำสำหรับ workloads ที่ถูกจำกัดด้วย I/O (I/O-bound) ในทางตรงกันข้าม Green threads รักษาสแต็กแบบเต็มสำหรับแต่ละงานที่ทำงานพร้อมกัน ซึ่งอาจใช้หน่วยความจำมากกว่าแต่หลีกเลี่ยงการ ฉีกร่าง สแต็ก (stack ripping) ที่จำเป็นโดย state machines แบบ async

บทสนทนาเกี่ยวกับประสิทธิภาพได้พัฒนาขึ้นเพื่อยอมรับว่าทั้งสองแนวทางได้ก้าวข้ามขีดจำกัดดั้งเดิมของพวกเขาแล้ว .NET กำลังพัฒนา Runtime Async เพื่อแทนที่ state machines ที่ชัดแจ้งด้วยกลไกการหยุดทำงานของรันไทม์ (runtime suspension mechanisms) ในขณะที่ Go ได้ปรับปรุงการจัดการสแต็กจาก segmented stacks เป็น stack copying ดังที่นักพัฒนาคนหนึ่งสังเกตเห็น ทั้ง green threads และ async/await มีต้นทุนที่สูงกว่าการเขียนโค้ดแบบเธรดเดี่ยว (single-threaded) อย่างมีนัยสำคัญ แต่ต้นทุนของพวกมันแสดงออกในรูปแบบที่แตกต่างกัน

ความแตกต่างหลักระหว่าง Async/Await และ Green Threads

ด้าน Async/Await Green Threads
การควบคุม ชัดเจน (คีย์เวิร์ด await) โดยปริยาย (จัดการโดย runtime)
การใช้หน่วยความจำ State machines (บันทึกเฉพาะตัวแปรที่จำเป็น) Full stacks ต่อแต่ละ task
ระบบนิเวศ แพร่หลาย (Python, JS, C, Rust) เฉพาะภาษา (Go, BEAM)
การกระจาย ต้องใช้เฟรมเวิร์กเพิ่มเติม มีในตัว (ภาษา BEAM)
ความยากในการเรียนรู้ สูงกว่าเนื่องจาก "colored functions" ง่ายกว่าสำหรับกรณีที่ไม่ซับซ้อน
ลักษณะด้านประสิทธิภาพ ดีกว่าสำหรับงานที่ติด I/O ดีกว่าสำหรับรูปแบบ concurrent บางแบบ

การแบ่งแยกระบบนิเวศและเครื่องมือ

บางทีข้อพิจารณาที่เป็นจริงที่สุดที่เกิดขึ้นจากการอภิปรายในชุมชนคือความ成熟ของระบบนิเวศรอบๆ แต่ละแนวทาง Async/await ได้กลายเป็นสิ่งที่มีอยู่ทั่วไปใน Python, JavaScript, C# และ Rust สร้างคลังไลบรารีที่เข้ากันได้และรูปแบบที่ยอมรับแล้ว อย่างไรก็ตาม สิ่งนี้มาพร้อมกับปัญหาการระบายสีฟังก์ชัน (function coloring problem) ซึ่งโค้ด async และ synchronous มักจะผสมผสานกันอย่างไม่ราบรื่น

ระบบนิเวศ Green thread โดยเฉพาะภาษาใน BEAM นำเสนอความสามารถในการคำนวณแบบกระจาย (distributed computing) ที่ถูกสร้างขึ้นมาในพื้นฐานของการออกแบบ ดังที่ผู้แสดงความคิดเห็นหนึ่งอธิบาย: BEAM มีไว้สำหรับการคำนวณแบบกระจาย คุณสามารถสร้างกระบวนการใหม่ (spawn new processes) สื่อสารระหว่างกระบวนการ (ซึ่งไม่จำเป็นต้องอยู่บนคอมพิวเตอร์เครื่องเดียวกัน) ส่งข้อมูลทุกชนิดระหว่างพวกเขา รวมถึง closures การกระจายตัวในตัวนี้แสดงถึงข้อได้เปรียบทางสถาปัตยกรรมที่สำคัญสำหรับกรณีการใช้งานบางอย่าง

การใช้งานที่โดดเด่นจำแนกตามภาษา

  • Async/Await: asyncio ของ Python, async/await ของ JavaScript, async ของ C, async/await ของ Rust
  • Green Threads: goroutines ของ Go, ภาษาในตระกูล BEAM (processes ของ Erlang/Elixir), Project Loom ของ Java
  • แนวทางแบบผสมผสาน: Runtime Async ที่กำลังจะเปิดตัวของ .NET, ตัวเลือก runtime หลากหลายรูปแบบของ Rust

เครื่องมือที่เหมาะสมสำหรับงานที่เหมาะสม

ฉันทามติที่เกิดขึ้นจากการอภิปรายของนักพัฒนาชี้ให้เห็นว่าการเลือกระหว่างกระบวนทัศน์เหล่านี้ขึ้นอยู่กับข้อกำหนดของแอปพลิเคชันเฉพาะเป็นอย่างมาก Async/await ทำงานได้ดีในสภาพแวดล้อมที่มีทรัพยากรจำกัดและการเขียนโปรแกรมระบบ (systems programming) ในขณะที่ green threads ให้หลักการยศาสตร์สำหรับนักพัฒนา (developer ergonomics) ที่เหนือกว่าสำหรับแอปพลิเคชันทางธุรกิจและระบบกระจาย (distributed systems)

นักพัฒนาจำนวนมากกำลังใช้แนวทางแบบไฮบริด โดยใช้รันไทม์ async แบบเธรดเดี่ยว (single-threaded) สำหรับการดำเนินการจัดการ ในขณะที่ใช้โซลูชันแบบหลายเธรด (multi-threaded) สำหรับงานที่สำคัญต่อประสิทธิภาพ ดังที่ผู้ปฏิบัติงานคนหนึ่งแบ่งปัน: ในงานของฉันกับโค้ดฝั่งเซิร์ฟเวอร์ ฉันใช้รันไทม์ async หลายตัว รันไทม์หนึ่งเป็นแบบหลายเธรดและจัดการการรับส่งข้อมูลจริงทั้งหมด อีกรันไทม์หนึ่งเป็นแบบเธรดเดี่ยวและจัดการการดำเนินการจัดการ เช่น การส่งเมตริกและบันทึก (logs)

การอภิปรายยังคงพัฒนาต่อไปเมื่อมีการใช้งานใหม่ๆ เกิดขึ้น ขณะนี้ Rust มีรันไทม์ async เช่น Glommio และ Monoio ที่สร้างบน io_uring ในขณะที่การปรับปรุงที่กำลังจะมาถึงของ .NET สัญญาว่าจะลดช่องว่างประสิทธิภาพระหว่างโค้ด async และ synchronous สิ่งที่ชัดเจนคือทั้งสองแนวทางจะยังคงอยู่ร่วมกันต่อไป โดยแต่ละแนวทางแก้ปัญหาที่แตกต่างกันในภูมิทัศน์ที่ซับซ้อนของการพัฒนาซอฟต์แวร์สมัยใหม่

บทสนทนาเกี่ยวกับ async เทียบกับ threads ไม่ได้เกี่ยวกับว่าแนวทางใดดีกว่าโดยสากลอีกต่อไป แต่เป็นเรื่องเกี่ยวกับการทำความเข้าใจการแลกเปลี่ยนและเลือกเครื่องมือที่เหมาะสมสำหรับกรณีการใช้งานเฉพาะ เมื่อภาษาการเขียนโปรแกรมยังคงพัฒนาต่อไป เรากำลังเห็นการบรรจบกันมากกว่าการแยกออกจากกัน โดยระบบนิเวศแต่ละแห่งยืมแนวคิดที่ประสบความสำเร็จจากระบบนิเวศอื่นๆ

อ้างอิง: [Quite] A Few Words About Async