- แอสิงโครนัส และ คอนเคอร์เรนซี เป็นแนวคิดที่มักถูกสับสนกัน แต่มีความหมายต่างกัน
- แอสิงโครนัส หมายถึงความเป็นไปได้ที่งานจะถูกรันโดยไม่ขึ้นกับลำดับ
- คอนเคอร์เรนซี หมายถึงความสามารถของระบบในการดำเนินหลายงานไปพร้อมกัน
- การที่ไม่มีการแยกสองแนวคิดนี้อย่างชัดเจนใน ecosystem ของภาษาและไลบรารี ก่อให้เกิด ความไม่มีประสิทธิภาพและความซับซ้อน
- ใน ภาษา Zig การแยกระหว่างแอสิงโครนัสกับคอนเคอร์เรนซีทำให้โค้ดแบบซิงโครนัสและแอสิงโครนัสอยู่ร่วมกันได้โดยไม่ต้องเขียนโค้ดซ้ำ
บทนำ: ความจำเป็นของการแยกแอสิงโครนัสกับคอนเคอร์เรนซี
จากคำบรรยายอันโด่งดังของ Rob Pike ทำให้ประโยคที่ว่า 'concurrency is not parallelism' เป็นที่รู้จักกันดี แต่ยังมีประเด็นที่สำคัญกว่าในเชิงปฏิบัติอยู่ นั่นคือความจำเป็นของแนวคิด 'asynchrony' ตามคำนิยามในวิกิพีเดีย
- คอนเคอร์เรนซี: ความสามารถของระบบในการจัดการหลายงานพร้อมกันด้วยการแบ่งช่วงเวลาหรือแบบขนาน
- การประมวลผลแบบขนาน: การรันหลายงานพร้อมกันจริงในระดับกายภาพ
และนอกเหนือจากนี้ ยังมีแนวคิดสำคัญอีกอย่างที่เรามักมองข้ามไป คือ 'แอสิงโครนัส'
ตัวอย่าง 1: บันทึกไฟล์สองไฟล์
เมื่อบันทึกไฟล์สองไฟล์ (A, B) แล้วลำดับไม่สำคัญ
io.async(saveFileA, .{io})
io.async(saveFileB, .{io})
- จะบันทึก A ก่อนหรือ B ก่อนก็ได้ และแม้จะสลับกันทำระหว่างทางก็ไม่มีปัญหา
- แม้จะบันทึกไฟล์ A เสร็จก่อนแล้วค่อยเริ่ม B ทั้งหมด ก็ยังถือว่าถูกต้องตามโค้ด
ตัวอย่าง 2: สองซ็อกเก็ต (เซิร์ฟเวอร์, ไคลเอนต์)
เมื่อจำเป็นต้องสร้าง TCP เซิร์ฟเวอร์และให้ไคลเอนต์เชื่อมต่อภายในโปรแกรมเดียวกัน
io.async(Server.accept, .{server, io})
io.async(Client.connect, .{client, io})
- ในกรณีนี้ งานทั้งสองจำเป็นต้อง ทับซ้อนกันในการทำงาน
- กล่าวคือ ขณะที่เซิร์ฟเวอร์กำลังรับการเชื่อมต่อ ไคลเอนต์ก็ต้องพยายามเชื่อมต่อไปพร้อมกัน
- หากประมวลผลแบบอนุกรมเหมือนตัวอย่างไฟล์แรก จะไม่ได้พฤติกรรมตามที่ตั้งใจไว้
สรุปแนวคิด
นิยามของแอสิงโครนัส คอนเคอร์เรนซี และพารัลเลลลิซึม มีดังนี้
- แอสิงโครนัส (asynchrony) : คุณสมบัติที่แม้งานจะถูกรันนอกลำดับก็ยังให้ผลลัพธ์ที่ถูกต้อง
- คอนเคอร์เรนซี (concurrency) : ความสามารถในการดำเนินหลายงานพร้อมกัน ไม่ว่าจะเป็นแบบขนานหรือแบบแบ่งสลับเวลา
- พารัลเลลลิซึม (parallelism) : ความสามารถในการรันหลายงานพร้อมกันจริงในเชิงกายภาพ
ตัวอย่างการบันทึกไฟล์และการเชื่อมต่อซ็อกเก็ตทั้งสองแบบล้วนเป็นแอสิงโครนัส แต่กรณีที่สอง (เซิร์ฟเวอร์-ไคลเอนต์) จำเป็นต้องมี คอนเคอร์เรนซี
ประโยชน์เชิงปฏิบัติของการแยกแอสิงโครนัสกับคอนเคอร์เรนซี
หากไม่แยกสองแนวคิดนี้ให้ชัดเจน จะเกิดปัญหาต่อไปนี้
- ผู้สร้างไลบรารีต้องเขียนโค้ดเวอร์ชันแอสิงโครนัส/ซิงโครนัสซ้ำสองรอบ (เช่น redis-py vs asyncio-redis)
- ผู้ใช้ต้องเผชิญกับปัญหาที่โค้ดแอสิงโครนัสมีลักษณะ 'แพร่กระจาย' จนแค่พึ่งพาไลบรารีแอสิงโครนัสเพียงตัวเดียว ก็ต้องเปลี่ยนทั้งโปรเจ็กต์ให้เป็นแอสิงโครนัส เกิดเป็น ความไม่สะดวก
- เพื่อหลีกเลี่ยงสิ่งนี้ มักเกิดทางลัดหรือวิธีแก้เฉพาะหน้า ซึ่งบ่อยครั้งนำไปสู่ *เดดล็อก (deadlock)* และความไม่มีประสิทธิภาพ
ดังนั้น การแยกสองแนวคิดนี้อย่างชัดเจนจึงให้ประโยชน์อย่างมากทั้งกับผู้พัฒนาไลบรารีและผู้ใช้
Zig: การแยกระหว่างแอสิงโครนัสกับคอนเคอร์เรนซี
ภาษา Zig ใช้แอสิงโครนัสผ่าน io.async แต่สิ่งนี้ ไม่ได้รับประกันคอนเคอร์เรนซี
- กล่าวคือ ต่อให้ใช้
io.asyncภายในก็ยังอาจรันแบบเธรดเดียวและบล็อกกิงได้ - ตัวอย่างเช่น
โค้ดนี้ในสภาพแวดล้อมแบบบล็อกกิง อาจทำงานเทียบเท่ากับio.async(saveFileA, .{io}) io.async(saveFileB, .{io})saveFileA(io) saveFileB(io) - หมายความว่า แม้ผู้สร้างไลบรารีจะใช้
io.asyncผู้ใช้ก็ยังมีความยืดหยุ่นที่จะเลือกรันแบบ blocking I/O ตามลำดับได้หากต้องการ
การนำคอนเคอร์เรนซีมาใช้และกลไกสลับงาน (scheduling)
ในกรณีที่ต้องใช้คอนเคอร์เรนซี เพื่อให้ทำงานได้อย่างมีประสิทธิภาพจริง จำเป็นต้องมี
- I/O แบบ event-based ที่ไม่บล็อก (เช่น epoll, io_uring)
- primitive สำหรับสลับงาน (เช่น yield)
- ตัวอย่างเช่น Zig ใช้เทคนิค stack swapping ในสภาพแวดล้อม green thread เพื่อสลับงาน
- คล้ายกับการจัดตารางเธรดของระบบปฏิบัติการ โดยบันทึก/กู้คืนสถานะอย่าง CPU register และสแตก เพื่อสลับระหว่างหลาย task
- ต้องมีกลไกการสลับลักษณะนี้ จึงจะสามารถ schedule โค้ดแอสิงโครนัสให้ทำงานแบบคอนเคอร์เรนต์ได้จริง
- การทำ coroutine แบบ stackless (เช่น suspend, resume) ก็อาศัยหลักการเดียวกัน
การอยู่ร่วมกันของโค้ดซิงโครนัสและแอสิงโครนัส
หากรัน saveData สองครั้งด้วย io.async ตามด้านล่าง
io.async(saveData, .{io, "a", "b"})
io.async(saveData, .{io, "c", "d"})
- เนื่องจากสองงานนี้ เป็นแอสิงโครนัสต่อกัน แม้ฟังก์ชันภายในจะเขียนแบบซิงโครนัส ก็ยังสามารถนำไป schedule ในบริบทคอนเคอร์เรนซี ได้อย่างเป็นธรรมชาติ
- ผู้ใช้หรือผู้สร้างไลบรารีจึงสามารถใช้ฟังก์ชันซิงโครนัส/แอสิงโครนัสร่วมกันได้โดยไม่มีปัญหา และไม่ต้องเขียนโค้ดซ้ำ
การระบุสถานการณ์ที่คอนเคอร์เรนซีเป็น 'สิ่งจำเป็น'
สำหรับบางฟังก์ชัน (เช่น accept ของ TCP เซิร์ฟเวอร์) จำเป็นต้องแสดงในโค้ดอย่างชัดเจนว่า ต้องใช้คอนเคอร์เรนซี ขณะรัน
- ใน Zig จะแยกไว้ด้วยฟังก์ชันชัดเจนอย่าง
io.asyncConcurrent - วิธีนี้ทำให้หากสภาพแวดล้อมรันไทม์ไม่รองรับคอนเคอร์เรนซี งานดังกล่าวจะส่ง error ได้
- ต่างจาก
io.asyncที่มีเป้าหมายด้านแอสิงโครนัส เพราะกรณีนี้จำเป็นต้องมีการรับประกันคอนเคอร์เรนซี จึงถูกออกแบบเป็นฟังก์ชันที่อาจล้มเหลวได้
บทสรุป
- แอสิงโครนัสกับคอนเคอร์เรนซีเป็นคนละแนวคิดโดยสิ้นเชิง และควรแยกให้ชัดเจน
- ทำให้โค้ดซิงโครนัสและโค้ดแอสิงโครนัส อยู่ร่วมกัน ได้
- โมเดลแอสิงโครนัส/คอนเคอร์เรนซีของ Zig ช่วยให้ใช้ทั้งสองโลกได้ร่วมกันโดยไม่ต้องเขียนโค้ดซ้ำ
- โครงสร้างลักษณะนี้ถูกนำไปใช้ในภาษาอื่นอย่าง Go เช่นกัน และชี้ทางในการก้าวข้ามปัญหาการแพร่กระจายของ async/await
- ด้วยการออกแบบ async I/O แบบใหม่ของ Zig จึงน่าคาดหวังได้ว่าจะมีสภาพแวดล้อมการเขียนโปรแกรมแบบคอนเคอร์เรนซี/แอสิงโครนัสที่ใช้งานได้อย่างเป็นธรรมชาติยิ่งขึ้นในอนาคต
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
รู้สึกว่าการนิยาม async เป็นเรื่องที่ยากมาก และฉันเองก็เป็นหนึ่งในหลายคนที่เคยออกแบบ async ใน JavaScript ด้วย ฉันไม่เห็นด้วยกับนิยามที่บทความนี้เสนอ แค่เป็น async ไม่ได้แปลว่าจะทำงานได้ถูกต้องเสมอไป ในโค้ด async ก็ยังเกิด race condition ระดับผู้ใช้ได้หลายแบบ ไม่ว่าภาษาจะรองรับ async/await หรือไม่ก็ตาม นิยามที่ฉันสรุปไว้ล่าสุดคือ async คือ “โค้ดที่ถูกจัดโครงสร้างไว้อย่างชัดเจนเพื่อรองรับ concurrency” แต่มุมมองนี้ก็ยังต้องขัดเกลาอีก ฉันเคยเขียนสรุปไว้เองด้วย ดูได้ที่ Quite a few words about async
ผมคิดว่าสำคัญมากที่จะต้องแยกแนวคิดนามธรรมของ asynchronism ออกจากการนำไปใช้งานจริง ซึ่งอย่างหลังครอบคลุมทั้ง abstraction ระดับภาษาและกลไกการประสานงานเชิงเครื่อง ในระดับแนวคิดนามธรรมสูงสุด asynchronism ก็คือสิ่งตรงข้ามกับ synchronism โดยทั่วไปเมื่อมีหลายฝ่ายต้องทำงานร่วมกัน เช่น งานหนึ่งต้องเสร็จก่อนอีกงานถึงจะดำเนินต่อได้ แก่นของความเป็น asynchronous คือเราไม่รู้หรือไม่ได้กำหนดแน่ชัดว่าเหตุการณ์นั้นจะเกิดขึ้นเมื่อไร ตัวนิยามเองไม่ได้ยาก ปัญหาอยู่ที่ภาระทางความคิดเวลาจะออกแบบ abstraction แบบนี้ในระดับภาษา
ผมไม่ได้เชี่ยวชาญหัวข้อนี้มากนัก แต่ในมุมมองของผม โค้ด async คือการเปลี่ยนงานที่เดิม block ให้กลายเป็น non-blocking เพื่อให้งานอื่นเดินต่อไปพร้อมกันได้ โดยเฉพาะในกรณีของผมกับ embedded loop โค้ดที่ block นาน ๆ จะทำให้ I/O พัง และก่อให้เกิดอาการผิดพลาดที่มองเห็นหรือได้ยินได้ชัด มุมมองนี้เลยตรงไปตรงมามาก
ผมยังสงสัยด้วยซ้ำว่าเราจำเป็นต้องนิยาม async หรือเปล่า ที่นิยามยากในทางปฏิบัติก็เพราะมันไม่มีอะไรที่ลงล็อกกับแนวคิดเดียวแบบสมบูรณ์ ผมก็ไม่แน่ใจว่าจำเป็นต้องนิยาม async หรือ event loop ให้ตายตัวหรือไม่ ในโลกของชิปจริงที่ทำ parallel processing ได้ คงมีแนวคิดอีกมากมายที่ผมไม่รู้ สำหรับผม แค่รู้จัก “user finger” (เช่น การแตะหน้าจอ) กับ “quickies” (งานที่ใช้เวลาสั้นมาก), job queue, และ API แบบ blocking/non-blocking ก็พอแล้ว ถ้าจะให้บรรลุเป้าหมายของผม API แบบ non-blocking ดีกว่า เพราะงานที่กินเวลานานสามารถโยนให้ subsystem จัดการ ส่วนผมก็แค่เขียน “quicky” อย่างการบันทึกข้อมูลที่ต้องการ แล้วกำหนด quicky อีกแบบสำหรับกรณีสำเร็จ/ล้มเหลวไว้ได้เลย การแยก sync กับ async เองไม่ได้ช่วยมากนัก แน่นอนว่าต้องเข้าใจเวลาคนอื่นพูดถึงมัน แต่โดยแก่นแล้วผมมองว่า async ก็คือ API แบบ non-blocking โมเดลการเขียนโปรแกรมแบบ async ก็คือการเขียนงาน blocking ที่เล็กและเป็น atomic มาก ๆ (ในแง่เวลาในการรัน) ให้สอดคล้องกับเหตุการณ์ที่ “วุ่นวายและไม่เป็นเชิงกำหนด” ไม่ว่าภายในระบบจะทำอะไร ผมเชื่อว่า browser, OS, หรือตัวอุปกรณ์เองมี execution unit หลายตัวและมี scheduler ที่ดี สำหรับผม async เป็นแนวคิดที่นิยามไม่ชัด และถึงนิยามได้ก็ไม่แน่ใจว่าจะมีประโยชน์นัก ตรงกันข้าม แนวคิดอย่าง event, คุณสมบัติการ block ของงานที่ผมเขียน, function closure, หรือการรู้ว่าเวลาใช้ API แล้วอะไรจะถูกแยกไปเป็น job อื่น กลับใช้งานได้จริงกว่าเยอะ แม้แต่คำว่า “callback” เองตอนเริ่มต้นก็ทำให้ผมสับสนมาก ผมเคยคิดว่าโค้ดหยุดอยู่ตรงนั้น แต่จริง ๆ แล้วมันจะรันส่วนนั้นไปจนจบก่อน แล้วเราต้องเข้าใจอย่างแม่นยำว่าเมื่อ “callback” ถูกเรียก จะมีโค้ดอะไรทำงาน และมองเห็นข้อมูลอะไรได้บ้าง พูดตามตรงมันทั้งโกลาหลและอัจฉริยะ สิ่งที่ง่ายกว่าคำว่า “async” คือโมเดลพื้นฐานอย่าง event, งาน blocking, job queue, และ API แบบ non-blocking รวมถึงการเข้าใจด้วยว่าผมทำอะไรอยู่ และ browser/OS ฯลฯ ทำอะไรอยู่ เช่น cpp ประกาศโมเดล concurrent แต่ให้ OS เป็นคนรันจริง ส่วนใน JS เราประกาศกับ browser หรือ Node ผ่าน API แบบ non-blocking ว่า “น่าจะ” มี concurrency แล้วให้มันจัดการแบบ concurrent ภายใน สิ่งสำคัญที่สุดคือทำให้งานแต่ละชิ้นสั้น (<50ms) และบอกเจตนาได้ผ่าน API แบบ non-blocking ก็พอ cpp หรือ rust จะบอก OS ให้รัน task แบบ concurrent จริง ๆ ดังนั้นต่อให้มีเพียงเธรดเดียวในเชิงกายภาพ UI ก็ยัง responsive ได้ สุดท้ายสิ่งที่โปรแกรมเมอร์ async ต้องทำคือสร้าง “โมเดล UX ที่ดี” และแมป event ไปเป็น quickies ให้เหมาะสม
ดูเหมือนผู้เขียนจะหยิบ “แนวคิดเรื่องการ yield การประมวลผล” ออกจากนิยามของ concurrency แล้วเอามาสร้างคำใหม่ว่า “asynchrony” พร้อมทั้งอ้างว่าถ้าไม่มีแนวคิดนี้ concurrency ทั้งหมดจะใช้การไม่ได้ ผมคิดว่า concurrency มีความสามารถในการ yield เป็นองค์ประกอบจำเป็นอยู่แล้ว จึงเป็นแนวคิดที่ฝังอยู่ในตัวมันเอง มันเป็นแนวคิดสำคัญก็จริง แต่การแยกออกไปตั้งเป็นคำใหม่ยิ่งทำให้สับสน
ผมมองว่า parallelism แบบ 1:1 เป็นรูปแบบหนึ่งของ concurrency ที่ไม่มีการ yield นอกนั้น concurrency ที่ไม่ใช่ parallel แทบทั้งหมดต้องยอมปล่อยการประมวลผลเป็นช่วง ๆ ไม่ว่าระดับนั้นจะเป็นระดับ instruction ก็ตาม ตัวอย่างเช่นใน CUDA เธรดที่แตกแขนงกันภายใน warp เดียวกันจะสลับกันรันคำสั่ง ทำให้แขนงหนึ่งไป block อีกแขนงได้
ผมอยากเน้นว่าในบทความที่อ้างถึง เขียนไว้ชัดเจนเลยว่า “การ yield เป็นแนวคิดของ concurrency”
concurrency ไม่ได้จำเป็นต้องหมายถึงการ yield เสมอไป logic แบบ synchronous ต้องการการ synchronization ที่ชัดเจน และการ yield ก็เป็นเพียงวิธีการ synchronization แบบหนึ่ง สิ่งที่ผมเรียกว่า logic แบบ asynchronous คือ concurrency ที่ทำงานได้โดยไม่มีทั้ง synchronization หรือการ yield ในเชิงปฏิบัติแล้ว concurrency หรือ logic แบบ asynchronous ก็ไม่ได้มีอยู่ครบถ้วนจริงบนเครื่องแบบฟอน นอยมันน์
ในบริบทนี้ asynchronous คือ abstraction ที่แยกการเตรียม/ส่งคำขอออกจากการเก็บผลลัพธ์ ทำให้สามารถส่งคำขอหลายรายการก่อนแล้วค่อยตรวจผลทีหลังได้ มันเปิดทางให้มี implementation แบบ concurrent แต่ไม่ได้บังคับ อย่างไรก็ตาม เป้าหมายของ abstraction นี้ก็คือการได้มาซึ่ง concurrency ถ้าไม่มี concurrency ก็ไม่มีประโยชน์ตามที่ต้องการ abstraction แบบ asynchronous บางชนิดถึงขั้นทำไม่ได้เลยถ้าไม่มี concurrency ขั้นต่ำ เช่นรูปแบบ callback อาจเลียนแบบได้ใน single-thread แต่จะติดข้อจำกัดอย่าง deadlock ตอนถือ non-recursive mutex อยู่ กล่าวคือ abstraction แบบ asynchronous ที่ไม่มี concurrency จะต้องล้มเหลวแน่นอน ผู้ร้องขออาจส่งคำขอขณะถือ mutex อยู่ แล้วถ้า callback ถูกรันก่อน unlock ก็อาจไม่มีวันได้ unlock อย่างน้อยต้องมีเธรดแยกเพื่อให้ผู้ร้องขอไปถึงจุด unlock ได้
"cooperative multitasking ไม่ใช่ preemptive" คำว่า “asynchronous” มักหมายถึง “single-thread, cooperative multitasking (yield แบบ explicit) และขับเคลื่อนด้วย event” โดยมีการประมวลผลภายนอกรันแบบ concurrent แล้วรายงานผลกลับมาผ่าน event ในโมเดลแบบ multithread หรือการรันแบบ concurrent จริง async แทบไม่มีความหมายมากนัก เพราะต่อให้เธรดนั้น block โปรแกรมโดยรวมก็ยังเดินต่อได้ และจุด yield ก็ไม่จำเป็นต้อง explicit อีกต่อไป
ไอเดีย I/O ใหม่ของ Zig ดูสดใหม่สำหรับการพัฒนาแอปทั่วไป และเหมาะมากสำหรับคนที่ไม่ต้องการ stackless coroutine แต่ผมคิดว่ามันอาจทำให้การเขียนไลบรารีผิดพลาดได้ง่ายขึ้น ผู้เขียนไลบรารีจะรู้ได้ยากว่า I/O ที่ได้รับมาเป็น single-thread หรือ multithread เป็น event-driven I/O หรือไม่ โค้ดที่เกี่ยวกับ concurrency/asynchrony/parallelism ก็เขียนยากอยู่แล้วแม้จะรู้ I/O stack ทั้งหมด แต่เมื่อ I/O ถูกส่งเข้ามาจากภายนอก ความยากจะยิ่งเพิ่มขึ้นอีก ถ้า I/O interface ใหญ่โตเหมือน “OS ขนาดย่อม” จำนวนสถานการณ์ที่ต้องทดสอบก็จะเพิ่มแบบระเบิด ผมไม่มั่นใจว่า async primitive ที่ interface ให้มาจะครอบคลุม edge case จริงทั้งหมดได้หรือไม่ ถ้าจะรองรับ I/O implementation หลากหลาย โค้ดคงต้อง “ตั้งการ์ด” สูงมากและต้องสมมติกรณี I/O ที่ parallel มากที่สุดไว้เสมอ โดยเฉพาะการผสมวิธีนี้กับ stackless coroutine น่าจะไม่ง่าย ถ้าอยากลดการ spawn coroutine ที่ไม่จำเป็น ก็คงต้องมีการ poll coroutine แบบ explicit ซึ่งนักพัฒนาส่วนใหญ่คงไม่เขียนโค้ดแบบนั้นเอง สุดท้ายโครงสร้างก็น่าจะกลับไปคล้าย async/await ทั่วไป เมื่อรวมเรื่อง dynamic dispatch และแนวโน้มการออกแบบจากล่างขึ้นบนของ Zig แล้ว สุดท้ายมันอาจกลายเป็นภาษาระดับค่อนข้างสูงไปเลยก็ได้ ตอนนี้ยังไม่มีกรณีใช้งานจริง จึงดูใจกล้าเกินไปที่จะเรียกแนวทางนี้ว่า “ไร้การประนีประนอม” คงต้องรอใช้งานจริงหลายปีถึงจะประเมินได้อย่างแท้จริง
stackless coroutine ยังไงก็มีแผนจะรองรับอยู่แล้ว เพราะจำเป็นต่อการรองรับเป้าหมาย WASM จึงต้องมีแน่นอน dynamic dispatch จะใช้ก็ต่อเมื่อมี I/O implementation มากกว่าหนึ่งแบบเท่านั้น ถ้ามีแบบเดียวก็จะถูกแทนที่ด้วย direct call และเพราะมันยังไม่ได้ผ่านการพิสูจน์ในภาคสนาม ผมก็เห็นว่าคำว่า “ไร้การประนีประนอม” ยังเร็วเกินไป แม้จะได้ยินว่าภาษา Jai ใช้โมเดลคล้ายกันได้สำเร็จ (ต่างกันตรงใช้ implicit I/O context แทนการส่ง context แบบ explicit) แต่นั่นก็ยังยากจะเรียกว่าผ่านการใช้งานจริงในภาคสนาม
ผมเห็นด้วยกับประเด็นที่ว่าถ้าจะรองรับทั้งการรันแบบ synchronous และ asynchronous โค้ดต้องสมมติกรณี I/O ที่ parallel มากที่สุดไว้เสมอ แต่ถ้า asynchronous ถูก implement ไว้ดีที่ตัวจัดการ I/O event ชั้นล่าง ก็แค่ใช้หลักเดียวกันนี้ให้สม่ำเสมอทุกที่ อย่างแย่ที่สุดโค้ดก็เพียงรันแบบลำดับธรรมดา (และช้าลง) แต่จะไม่หลุดไปเจอปัญหา race/deadlock
ผมคิดว่าไอเดียของ Zig ดีมากตรงที่ไม่ต้องแยกใช้สองไลบรารี แต่สิ่งที่กังวลเสมอคือการทดสอบโค้ด asynchronous ผมไม่รู้ว่าจะมั่นใจได้อย่างไรว่าการทดสอบที่ผ่านวันนี้ได้จำลองทุกสถานการณ์/ทุกลำดับที่อาจเกิดขึ้นจริงระหว่างทางแล้ว โปรแกรมแบบ thread ก็มีปัญหาเดียวกัน แต่โค้ด multithread นั้นทั้งเขียนและ debug ยากกว่าเสมอ ผมจึงพยายามหลีกเลี่ยงการใช้ thread เท่าที่ทำได้ ปัญหาจริงคือ ‘ทำอย่างไรให้ผู้พัฒนาเข้าใจสภาพแวดล้อม async/thread ได้อย่างถูกต้อง’ ไม่นานมานี้ผมทำงานกับทีมหนึ่งที่ใช้ระบบ Python โดยครึ่งหนึ่งเป็น JS อีกครึ่งเป็น Python แล้วพวกเขาเปลี่ยนโค้ดขนาดใหญ่ให้เป็น async และ threaded แต่กลับไม่รู้ด้วยซ้ำว่า Global Interpreter Lock (GIL) คืออะไร สิ่งที่ผมพูดคงฟังเหมือนบ่นเสียมากกว่า แถมการทดสอบของพวกเขาก็ผ่านหมดเสมอแม้จะทำโค้ดพังก็ตาม mangum ไปบังคับให้ background และงาน async จบตอนสิ้นสุด HTTP request แต่พวกเขาก็ไม่รู้เรื่องนี้ ต่อให้บอกเรื่องพวกนี้ไป ทุกคนก็ดูไม่ค่อยใส่ใจ ไม่ใช่แค่การรู้เท่านั้นที่สำคัญ แต่สำคัญกว่าคือมีคนอื่นช่วยมองเห็นและตรวจสอบมันหรือไม่
ใน Zig มีแผนจะเพิ่ม I/O test implementation ซึ่งจะใช้ทำ stress test เช่น fuzz test ภายใต้โมเดลการรันแบบ parallel ได้ แต่ประเด็นสำคัญคือโค้ดไลบรารีส่วนใหญ่คงไม่จำเป็นต้องเรียก io.async หรือ io.asyncConcurrent โดยตรง ตัวอย่างเช่นไลบรารีฐานข้อมูลส่วนใหญ่น่าจะเป็นโค้ด synchronous ล้วนก็เพียงพอ แล้วนักพัฒนาแอปค่อยทำให้มัน asynchronous ได้ง่าย ๆ ด้วยการเขียน io.async(writeToDb), io.async(doOtherThing) แบบนี้ ซึ่งจะ error-prone น้อยกว่าการโรย async/await ไปทั่วทั้งโค้ด และเข้าใจได้ง่ายกว่ามาก
เห็นด้วยอย่างยิ่ง การทดสอบ interleaving ทั้งหมดในโค้ด async หรือ multithread เป็นเรื่องยากขึ้นชื่อ แม้จะใช้ fuzzer หรือเฟรมเวิร์กทดสอบ concurrency ก็ยังยากจะมั่นใจหากไม่มีบทเรียนจากระบบจริง ใน distributed system ยิ่งหนักขึ้นไปอีก เช่น เวลาวางสถาปัตยกรรม webhook infrastructure เราไม่ได้รับมือแค่ async ในโค้ดของตัวเอง แต่ยังมีปัญหาภายนอกอย่าง network retry, timeout, partial failure ฯลฯ ซ้อนเข้ามาอีก ในสภาวะ concurrency สูง เรื่อง retry, deduplication, การรับประกัน idempotency ฯลฯ ก็กลายเป็นโจทย์วิศวกรรมของตัวมันเอง นั่นจึงเป็นเหตุผลที่เกิดบริการเฉพาะทางอย่าง Vartiq.com (ผมทำงานอยู่ที่นั่น) บริการแบบนี้ช่วย abstraction ความซับซ้อนของ concurrency ในเชิงปฏิบัติการได้ระดับหนึ่งและลด blast radius ได้ แต่ปัญหาการทดสอบ async ในโค้ดของผมเองก็ยังอยู่ สรุปคือ async, threading, และ distributed concurrency ต่างขยายความเสี่ยงให้กันและกัน ดังนั้นการสื่อสารและการออกแบบระบบจึงสำคัญกว่าวากยสัมพันธ์หรือไลบรารีใด ๆ
ผมคิดว่าผู้เขียนดูจะสับสนในนิยามของ concurrency อยู่บ้าง ลองดู บทความของ Lamport ได้
อย่าทิ้งไว้แค่ลิงก์บทความ ช่วยอธิบายหน่อย ผมว่านิยามเองก็ใช้ได้ดีนะ เช่น asynchronous: ถ้างานยังถูกต้องแม้ไม่ได้รันตามลำดับ นั่นคือ asynchronous, concurrency: คุณสมบัติของระบบที่ทำให้งานหลายอย่างคืบหน้าไปพร้อมกันได้ ไม่ว่าจะด้วย parallelism หรือ task switching, parallelism: การที่มีงานตั้งแต่สองอย่างขึ้นไปรันพร้อมกันจริงในระดับกายภาพ
ด้วยเหตุนี้ผมเลยเลิกใช้คำเหล่านี้ไปเลย ไม่ว่าจะคุยกับใคร ความเข้าใจก็ไม่ตรงกัน ทำให้ตัวคำเองไม่มีความหมายในการสื่อสารแล้ว
ผู้เขียนเองก็รู้ว่ามีนิยามเดิมของคำนี้อยู่แล้วจากที่เขาเขียนในบล็อก เขาแค่เสนอคำนิยามใหม่ของตัวเอง และถ้านิยามนั้นสอดคล้องในตัวเองก็ถือว่าเพียงพอ ความต่างมีแค่ว่าผู้อ่านจะยอมรับหรือไม่
ครึ่งหนึ่งของบทความของ Lamport แทบไม่มีทางแทนเชิงแนวคิดได้ในภาษาส่วนใหญ่ แค่สร้าง thread ขึ้นมาเราก็แทบไม่เคยต้องไปถกเรื่อง total order กับ partial order หรอก ประเด็นแบบนี้จะจำเป็นตอนออกแบบ protocol ด้วย TLA+ มากกว่า ใน Zig async API การที่ฟังก์ชันซึ่ง “ทำงานได้เฉพาะในสภาพแวดล้อม asynchronous” คอมไพล์ไม่ผ่าน ไม่จำเป็นต้องยกระดับเป็นทฤษฎีใหม่ด้วยซ้ำ
วิธีหนึ่งที่ดีในการชั่งน้ำหนักว่าคำว่า “asynchrony” จำเป็นจริงไหม คือดูว่ามันมีประโยชน์ข้ามภาษาและข้ามโมเดล concurrency หรือไม่ ไม่ใช่ใช้ได้แค่กับภาษา/โมเดลเดียว เช่น ถ้ามันจำเป็นร่วมกันใน Haskell, Erlang, OCaml, Scheme, Rust, Go ฯลฯ ก็ถือว่ามีคุณค่าสูง โดยทั่วไปเมื่อมี cooperative scheduling เราต้องใส่ใจกับปัญหาอย่างระบบทั้งระบบ lock up หรืออาการหน่วงที่เกิดจากโค้ดจุดเดียวมากขึ้นมาก แต่ถ้าเป็น preemptive scheduling ปัญหาเหล่านี้จะหายไปเป็นจำนวนมาก เพราะระบบทั้งระบบจะไม่สามารถ lock up ได้ ทำให้กลุ่มปัญหาลดลงอย่างชัดเจน
คำว่า “asynchrony” ในกรณีนี้ดูไม่เหมาะนัก เพราะมีคำทางคณิตศาสตร์ที่นิยามชัดอยู่แล้วคือ “commutativity” บาง operation ลำดับไม่สำคัญ เช่น การบวก การคูณ ฯลฯ แต่บาง operation ลำดับสำคัญ เช่น การลบ การหาร ฯลฯ ปกติลำดับของ operation ในโค้ดจะแสดงผ่านเลขบรรทัดจากบนลงล่าง แต่ในโค้ด async ลำดับนี้พังลง จึงไม่แปลกที่ asyncConcurrent(...) ซึ่งเขียนแบบนี้จะทำให้สับสนมาก ถ้าไม่ได้ทำความเข้าใจเนื้อหาในบล็อกอย่างครบถ้วนก็ยากจะเดาได้ว่าหมายถึงอะไร Zig (รวมถึง Rust ที่ผมชอบด้วย) ดูจะชอบแนวทาง “สายฮิปสเตอร์” แบบนี้อยู่บ่อย ๆ จะสร้างระบบเชิงกระบวนวิธีเรื่อง commutativity/ordering แบบ async-based ให้เหมือน lifetime ของ Rust ก็ได้ หรือไม่ก็ใช้สิ่งที่คนคุ้นเคยอยู่แล้วไปเลยจะดีกว่า
ผมไม่เห็นด้วยกับความเห็นที่ว่า “asyncConcurrent(...) ทำให้สับสน” ถ้าซึมซับแก่นของบทความบล็อกได้ มันก็ไม่สับสนเลย คำถามจริงคือแนวคิดนี้คุ้มค่าพอให้เรียนรู้ไหม ซึ่งเป็นอีกเรื่องหนึ่ง ในทางปฏิบัติก็ต้องให้คนที่ internalize แนวคิดนี้ไปลองใช้กันเยอะ ๆ แล้วค่อยดูเมื่อเวลาผ่านไปว่ามันดีจริงหรือไม่ และถ้าจะเอาคำว่า “commutativity” มาแทนอย่างอื่น ใน Zig กลับจะยิ่งสับสนมากขึ้นเพราะตัวภาษาเองก็มี operator ที่เป็น commutative อยู่แล้ว เช่นถ้าเป็น f() + g() ก็อาจเกิดคำถามว่าในเมื่อการบวกเป็น commutative แล้ว Zig ควรรันสองฝั่งแบบขนานได้ไหม ทั้งที่ลำดับการประมวลผลกับ commutativity เป็นคนละเรื่องกันโดยสิ้นเชิง จึงควรแยกออกจากกัน
พูดอย่างเคร่งครัด commutativity เป็นคุณสมบัติที่ใช้กับ operation แบบ (binary) ดังนั้นถ้าจะบอกว่าคำสั่ง async สองคำสั่งอย่าง connect/accept สลับที่กันได้ ก็ต้องถามต่อว่า “ภายใต้ operation อะไร” ตอนนี้ operator ที่ใกล้เคียงที่สุดคงเป็น bind (>>=) หรือ .then(...) แต่ทั้งหมดนี้ก็ยังอยู่ในระดับสัญชาตญาณมากกว่า
asynchrony ยังยอมให้มี partial order ได้ด้วย ต่อให้ operation สองอย่างต้อง retire ตามลำดับเดิม ก็ไม่ได้แปลว่าลำดับการประมวลผลจริงต้องเหมือนกันเสมอไป เช่น การลบไม่ใช่ commutative แต่ก็ยังสามารถคำนวณยอดคงเหลือกับจำนวนที่จะหักด้วยสอง query แบบขนาน แล้วค่อยนำผลไปใช้ตามลำดับที่เหมาะสมได้
แค่เพราะมีคำอื่นที่ครอบคลุมแนวคิดนี้อยู่ ไม่ได้แปลว่ามันจะเป็นคำที่ดีกว่า “asynchrony” คำว่า “commutativity” ทั้งอ่าน ฟัง และเขียนก็ดูเกะกะกว่า asynchrony มาก และ asynchrony ก็เป็นคำที่คุ้นเคยกว่ามาก
การอ้างว่าเป็น commutativity ก็มีข้อจำกัด ถ้า A และ B ต่างก็ commute กับ C เราจะได้ ABC=CAB แต่ไม่ได้แปลว่าจะต้องเท่ากับ ACB เสมอไป สำหรับ async เราต้องการให้ ABC=ACB=CAB ทั้งหมดเท่ากัน (ถ้ามีคำทางคณิตศาสตร์ที่ตรงกว่านี้ก็อาจมี แต่ผมไม่ทราบ)
ในฐานะโปรแกรมเมอร์สายเครือข่าย ผมเขียนทั้งโค้ด concurrent, parallel และ async มาเยอะมาก แต่บทความนี้กลับทำให้รู้สึกสับสนอยู่บ้าง เหมือนกำลังพยายามหาคำตอบบน abstraction ที่มีช่องโหว่มากมาย ถ้าเครื่องมือหรือ implementation เองผิดพลาดจน “พังได้” ง่ายขนาดนี้ นั่นแหละคือปัญหา จริง ๆ แล้วการ debug โค้ด multithread ก็ค่อนข้างสนุกด้วยซ้ำ พอเห็นคนอื่นกลัวสัตว์ประหลาด multithread กันมาก ๆ ก็ยิ่งรู้สึกเพลิน