6 คะแนน โดย GN⁺ 2024-02-26 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • เมื่อโค้ดฝั่งผู้ผลิตและผู้บริโภคส่งข้อมูลให้กัน หากพลิกฝั่งใดฝั่งหนึ่งให้เป็นรูปแบบผู้ถูกเรียก โครงสร้างอัลกอริทึมที่เดิมมองเห็นได้ชัดมักถูกกลบด้วยการเปลี่ยนสถานะ
  • Coroutine แบบ Knuth เป็นโมเดลที่สองรูทีนบันทึกตำแหน่งการทำงานของตัวเองแล้วส่งต่อการควบคุมให้กัน แต่ในโครงสร้างการเรียกแบบอิงสแตกของ C นั้นยากที่จะทำโดยตรงอย่างพกพาได้
  • เคล็ดลับหลักของบทความนี้คือการใช้ไวยากรณ์ C ที่อนุญาตให้วาง case ไว้ในบล็อกย่อยของ switch ได้ ร่วมกับแมโคร __LINE__ เพื่อสร้าง state machine โดยนัยที่กลับเข้าไปทำงานต่อจากตำแหน่งหลัง return
  • ด้วยแมโคร crBegin, crReturn, crFinish สามารถรักษาโครงสร้างลูปเดิมของตัวคลายบีบอัดและ parser ไว้ได้ แต่ตัวแปร local ที่ต้องเก็บค่าข้ามการเรียกต้องเป็น static และห้ามวาง crReturn ไว้ใน switch ที่เขียนเองโดยตรงหรือวางหลายตัวในบรรทัดเดียวกัน
  • ในโค้ดจริง เนื่องจากข้อจำกัดเรื่อง reentrancy และ multithreading จึงต้องใช้เวอร์ชันปรับปรุงที่ส่งผ่าน context structure และ coroutine.h มีทั้งแมโคร scr แบบง่ายและแมโคร ccr แบบ reentrant ให้ใช้

ปัญหาเชิงโครงสร้างเมื่อเชื่อมผู้ผลิตกับผู้บริโภค

  • ในโปรแกรมขนาดใหญ่ มักมีโค้ดหนึ่งสร้างข้อมูลและอีกโค้ดหนึ่งใช้ข้อมูลนั้น ซึ่งทำให้การออกแบบยากขึ้นเมื่อต้องตัดสินว่าฝั่งใดจะเป็น ผู้เรียก และฝั่งใดจะเป็น ผู้ถูกเรียก
  • ตัวอย่างประกอบด้วยรูทีนขนาดเล็กสองตัว
    • โค้ดคลายการบีบอัดแบบ run-length encoding อ่านอินพุตด้วย getchar() และส่งออกอักขระทีละตัวด้วย emit()
    • โค้ด parser อ่านอักขระด้วย getchar() โดยช่วงที่เป็นตัวอักษรต่อเนื่องจะจัดเป็น WORD ส่วนอักขระอื่นจะจัดเป็น PUNCT
  • เมื่อดูแยกกัน ทั้งสองรูทีนเป็นธรรมชาติ แต่หากต้องการให้เอาต์พุต emit() ของตัวคลายบีบอัดต่อเข้ากับอินพุต getchar() ของ parser โดยตรง ก็ต้องมีโครงสร้างสำหรับเชื่อมทั้งสองเข้าด้วยกัน
  • แก้ได้ด้วย pipe ระหว่างสอง process หรือสอง thread เช่นกัน
    • emit() ของตัวคลายบีบอัดเขียนลง pipe และ getchar() ของ parser อ่านจากอีกด้านหนึ่ง
    • วิธีนี้เรียบง่ายและแข็งแรง แต่หนักและพกพาได้น้อย จึงมักไม่อยากแยก thread สำหรับงานง่าย ๆ

ความเสียหายด้านความอ่านง่ายจากการเขียนฟังก์ชันใหม่

  • วิธีแก้แบบดั้งเดิมคือ เขียนใหม่ ให้ปลายด้านหนึ่งของช่องทางสื่อสารอยู่ในรูปฟังก์ชันที่เรียกได้
  • หากเปลี่ยนตัวคลายบีบอัดให้เป็นฟังก์ชันที่คืนค่าอักขระหนึ่งตัวทุกครั้งที่เรียก parser เดิมก็สามารถเรียก decompressor() แทน getchar() ได้
  • ในทางกลับกัน หากเปลี่ยน parser ให้เป็นฟังก์ชันที่ถูกเรียกทุกครั้งเมื่อได้รับอักขระหนึ่งตัว โค้ดคลายบีบอัดเดิมก็เรียก parser() แทน emit() ได้
  • ไม่จำเป็นต้องเปลี่ยนทั้งสองฝั่ง เปลี่ยนเพียงฝั่งเดียวก็เชื่อมต่อได้ แต่โค้ดที่เขียนใหม่จะอ่านยากกว่าเดิมมาก
    • ตัวคลายบีบอัดและ parser เดิมเผยให้เห็นลำดับการทำงานของอัลกอริทึมอย่างเป็นธรรมชาติในลูป
    • รูปแบบที่เขียนใหม่ต้องพึ่งตัวแปรสถานะ static และการเปลี่ยนสถานะด้วย switch ทำให้อ่านรูปแบบการบีบอัดหรือไวยากรณ์ของ parser จากโค้ดได้ยาก
  • เป้าหมายคือเชื่อมต่อทั้งสองฝั่งโดยไม่ต้องพลิกฝั่งใดฝั่งหนึ่งให้กลายเป็น state machine ที่ชัดแจ้ง

Coroutine แบบ Knuth และข้อจำกัดของ C

  • แนวทาง coroutine ของ Donald Knuth ตัดการแบ่งผู้เรียกกับผู้ถูกเรียกทิ้ง แล้วมองสอง process เป็นสิ่งที่เท่าเทียมกันและร่วมมือกัน
  • หลักการเรียกของโมเดลนี้ต่างจากการเรียกฟังก์ชันทั่วไป
    • บันทึกตำแหน่งการทำงานปัจจุบันไว้ในตำแหน่งแยกต่างหาก ไม่ใช่บนสแตก
    • กระโดดไปยังตำแหน่งการทำงานที่อีก routine บันทึกไว้ล่าสุด
    • เมื่อตัวคลายบีบอัดปล่อยอักขระ จะบันทึก program counter ของตัวเองแล้วไปยังตำแหน่งที่ parser บันทึกไว้
    • เมื่อ parser ต้องการอักขระถัดไป จะบันทึก program counter ของตัวเองแล้วไปยังตำแหน่งที่ตัวคลายบีบอัดบันทึกไว้
  • การควบคุมจะสลับไปมาระหว่างสองรูทีนเท่าที่จำเป็น
  • วิธีนี้ดีในเชิงทฤษฎี แต่ในทางปฏิบัติทำได้เฉพาะในภาษา assembly
  • ภาษาระดับสูงอย่าง C พึ่งพา โครงสร้างแบบอิงสแตก ดังนั้นในการย้ายการควบคุมระหว่างฟังก์ชัน ฝั่งหนึ่งต้องเป็นผู้เรียกและอีกฝั่งต้องเป็นผู้ถูกเรียก
  • ในโค้ด C ที่พกพาได้ วิธี coroutine แท้จึงใช้ได้จริงน้อยพอ ๆ กับวิธี Unix pipe

การจำลอง “return and continue” ใน C

  • พฤติกรรมที่ต้องการใน C คือ return and continue ซึ่งฟังก์ชันผู้ถูกเรียก return แล้ว ในการเรียกครั้งถัดไปจะทำงานต่อจากตำแหน่งถัดจาก return นั้นทันที
  • ตัวอย่างเช่น หากฟังก์ชันรูปแบบ for (i = 0; i < 10; i++) return i; ถูกเรียก 10 ครั้ง แล้วคืนค่า 0 ถึง 9 ตามลำดับ ก็คือรูปแบบที่ต้องการ
  • การทำครั้งแรกใช้ตัวแปรสถานะและ goto
    • วาง label ไว้ที่จุดเริ่มต้นฟังก์ชันและหลัง return แต่ละจุด
    • ตัวแปร state ที่คงอยู่ข้ามการเรียกจะชี้ไปยัง label ที่ต้อง resume ถัดไป
    • เมื่อเริ่มฟังก์ชัน ใช้ switch(state) เพื่อไปยัง label ที่เหมาะสม
    • ก่อน return จะบันทึก label ที่จะกลับมาในการเรียกถัดไปไว้ใน state
  • วิธีนี้ทำงานได้ แต่การจัดการ label ด้วยมือทำให้ดูแลรักษายาก
    • ทุกครั้งที่เพิ่ม return ต้องสร้าง label ใหม่และเพิ่มใน switch ตอนต้นด้วย
    • เมื่อเอา return ออก ก็ต้องเอา label ที่สอดคล้องกันออกด้วย
    • ต้องคอยรักษาความสอดคล้องระหว่างตัวฟังก์ชันกับรายการใน switch

State machine ที่ซ่อนไว้ด้วย Duff’s device

  • Duff’s device อันโด่งดังของ C ใช้ประโยชน์จากไวยากรณ์ที่อนุญาตให้วางคำสั่ง case ของ switch ไว้ในบล็อกย่อยของมันได้
  • เมื่อนำคุณสมบัตินี้มาใช้กับเคล็ดลับ coroutine แทนที่ switch จะเลือกว่าจะรัน goto ใด ตัว switch เองจะทำงานเหมือนการกระโดดกลับเข้าไปทำงานต่อ
  • รูปแบบพื้นฐานเป็นดังนี้
    • static int state เก็บจุด resume ถัดไป
    • ที่ต้นฟังก์ชัน เข้าใช้งานด้วย switch(state) { case 0: ... }
    • ก่อน return บันทึกค่า case ถัดไปลงใน state
    • วาง label case นั้นไว้ทันทีหลัง return
  • เมื่อห่อด้วยแมโคร ก็ได้อินเทอร์เฟซที่ดูเหมือน coroutine
    • crBegin: ซ่อน static int state=0; switch(state) { case 0:
    • crReturn: บันทึก state และคืนค่า จากนั้นวาง label case ไว้ที่ตำแหน่งเดียวกัน
    • crFinish: ปิดบล็อกที่เปิดไว้
  • crReturn ถูกห่อด้วย do ... while(0) จึงใช้ระหว่าง if กับ else โดยไม่ใส่วงเล็บปีกกาได้โดยไม่เกิดปัญหาไวยากรณ์
  • ช่วงแรกต้องกำหนดเลขสถานะเอง เช่น crReturn(1, i) แต่ถ้าใช้แมโคร __LINE__ ของ ANSI C ก็ใช้หมายเลขบรรทัดซอร์สปัจจุบันเป็นค่าสถานะได้
  • หลังปรับปรุงนี้ ใช้แค่ crReturn(x) ก็พอ แต่เพิ่มกฎว่าห้ามวาง crReturn สองตัวในบรรทัดเดียวกัน

กฎการใช้แมโครและตัวอย่าง

  • Coroutine แบบใช้แมโครตั้งอยู่บนกฎบางข้อ
    • ห่อตัวฟังก์ชันด้วย crBegin และ crFinish
    • ตัวแปร local ที่ต้องคงอยู่ข้าม crReturn ต้องประกาศเป็น static
    • ห้ามใส่ crReturn ไว้ในคำสั่ง switch ที่เขียนเองโดยเด็ดขาด
    • ใน implementation ที่อิง __LINE__ ห้ามใส่ crReturn สองตัวในบรรทัดเดียวกัน
  • ตัวอย่างตัวคลายบีบอัดยังคงโครงสร้างลูปเดิมไว้ เพียงใช้ crReturn(c) แทน emit(c) เมื่อปล่อยอักขระ
  • ตัวอย่าง parser เมื่อจำเป็นต้องมีอักขระใหม่ จะใช้ crReturn() เพื่อกลับไปหาผู้เรียก และในการเรียกครั้งถัดไปจะทำงานต่อในสภาพที่รับอักขระใหม่ไว้ในพารามิเตอร์ c แล้ว
  • parser มีการเปลี่ยนโครงสร้างเล็กน้อย
    • เนื่องจากอักขระตัวแรกอยู่ใน c แล้วตั้งแต่ตอนเข้าฟังก์ชัน crReturn ที่สอดคล้องกับ getchar() ตรงต้นลูปเดิมจึงย้ายไปอยู่ท้ายลูป
    • หากต้องการ อาจกำหนดให้ parser ต้องมีการเรียกเพื่อ initialize ก่อนก็ได้
  • ไม่จำเป็นต้องเปลี่ยนทั้งสองรูทีนให้ใช้แมโคร coroutine เปลี่ยนเพียงฝั่งเดียวแล้วให้อีกฝั่งยังเป็นผู้เรียกก็ได้
  • ผลลัพธ์คือการผสม ANSI C, preprocessor และไวยากรณ์ switch ที่ไม่ค่อยถูกใช้ เพื่อจัดการการส่งข้อมูลระหว่างผู้ผลิตกับผู้บริโภคโดยไม่ต้องเขียนใหม่เป็น state machine ที่ชัดแจ้ง

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

  • เทคนิคนี้ละเมิดมาตรฐานการเขียนโค้ดทั่วไปอย่างหนัก
    • มีวงเล็บปีกกาในแมโครที่จับคู่กันไม่พอดี
    • ใช้ case ภายในบล็อกย่อย
    • crReturn ซ่อน switch, return, case ไว้ในแมโครเดียว
  • แมโครที่ซ่อนโครงสร้างไวยากรณ์อาจถือว่าทำลายความชัดเจนตามมาตรฐานการเขียนโค้ด
  • อย่างไรก็ตาม ฟังก์ชันที่เขียนใหม่เป็น state machine ชัดแจ้งก็ประกอบด้วยบล็อก case STATE เล็ก ๆ และการเปลี่ยนสถานะ ซึ่งโครงสร้างทางสายตาไม่ได้ต่างจากฟังก์ชันที่เรียงบล็อก label ของ goto มากนัก
  • ยิ่งฟังก์ชันยาวขึ้น การเขียนใหม่เป็น state machine ก็ยิ่งทำลายโครงสร้างอัลกอริทึมเดิมมากขึ้น
  • เทคนิคนี้เป็นการประนีประนอมที่ซ่อนโครงสร้างไวยากรณ์บางส่วน เพื่อให้ โครงสร้างอัลกอริทึม ปรากฏชัดขึ้น

เวอร์ชันปรับปรุงที่ reentrant ได้และโค้ดที่ให้มา

  • implementation ของเล่นแบบง่ายพึ่งตัวแปร static จึงไม่ reentrant และไม่เหมาะกับ multithreading
  • ในแอปพลิเคชันจริง ต้องสามารถเรียกฟังก์ชันเดียวกันในหลาย context และให้แต่ละ context ทำงานต่อจากหลัง return ล่าสุดของตัวเองได้
  • วิธีปรับปรุงคือเพิ่มพารามิเตอร์เป็น pointer ไปยัง context structure
    • เก็บทั้งสถานะ local และตัวแปรสถานะของ coroutine เป็นสมาชิกของ structure
    • ตัวแปรอย่างตัวนับลูปก็ต้องเข้าถึงแบบ ctx->i แทน i
    • โค้ดจะดูน่าเกลียดขึ้นเล็กน้อย แต่กำจัดปัญหา reentrancy พร้อมยังรักษาโครงสร้างโดยรวมของรูทีนไว้ได้
  • ผู้ใช้ C++ สามารถทำ coroutine เป็นสมาชิกของ class และเก็บสถานะที่เทียบเท่าตัวแปร local ไว้ใน class เพื่อจัดการ scope ได้เป็นธรรมชาติมากขึ้น
  • coroutine.h ที่ให้มา implement เคล็ดลับ coroutine นี้เป็นชุดแมโครที่นิยามไว้ล่วงหน้า
    • แมโครคำนำหน้า scr เป็นแบบง่ายที่ใช้ตัวแปร static
    • แมโครคำนำหน้า ccr เป็นแบบขั้นสูงที่ reentrant ได้
    • เอกสารรายละเอียดรวมอยู่ในคอมเมนต์ในไฟล์ header
  • Visual C++ 6 ไม่ชอบเคล็ดลับนี้ เพราะในการตั้งค่า debug เริ่มต้น “Program Database for Edit and Continue” จัดการแมโคร __LINE__ แปลก ๆ
    • หากต้องการคอมไพล์โปรแกรมที่ใช้ coroutine ใน VC++ 6 ต้องปิด Edit and Continue
    • ใน Project settings แท็บ “C/C++”, หมวด “General”, ค่า “Debug info” ให้เลือกตัวเลือกอื่นที่ไม่ใช่ “Program Database for Edit and Continue”
  • ไฟล์ header เผยแพร่ภายใต้ MIT license

แหล่งอ้างอิงที่เกี่ยวข้องและการใช้งานจริง

  • The Art of Computer Programming ของ Donald Knuth, Volume 1, Section 1.4.2 กล่าวถึง coroutine ในรูปแบบบริสุทธิ์
  • การอภิปราย Duff’s device ของ Tom Duff มีเนื้อหาที่ชวนให้คิดว่าเขาอาจคิดเคล็ดลับ coroutine ที่คล้ายกันได้อย่างอิสระ และในการอัปเดตเมื่อ 2005-03-07 Tom Duff ได้ยืนยันเรื่องนี้ในคอมเมนต์บล็อก
  • โค้ดโปรโตคอล SSH ของ PuTTY ใช้เคล็ดลับ coroutine นี้จริง
  • กรณีของ PuTTY ถือเป็นระดับ การแฮ็ก C ที่เข้มข้น ซึ่งพบได้ไม่บ่อยในโค้ด production จริงจัง

1 ความคิดเห็น

 
GN⁺ 2024-02-26
ความคิดเห็นจาก Hacker News
  • ผมกลับมาหน้านี้หลายครั้งระหว่างพยายามลดความซับซ้อนของ API ในโปรเจกต์ C และคิดว่าคำอธิบายเรื่อง control flow ทำได้ยอดเยี่ยม
    ยังช่วยให้คิดได้ชัดขึ้นเรื่องการเก็บสถานะไว้ใน/นอกสแตก และความแตกต่างด้านความอ่านง่ายของแต่ละแนวทาง
    ข้อสรุปตอนนี้คือ ให้ผู้ใช้ไลบรารีเป็นคนตัดสินใจดีกว่าว่าจะใช้โครูทีน C หรือไม่ เช่น Mongoose(https://github.com/cesanta/mongoose) จัดการงาน async ด้วย event callback ไลบรารีแบบนี้ถ้าห่อด้วย primitive ของ thread/task ของแต่ละระบบ จะสบายกว่ามาก แทนที่จะพยายามพอร์ตไปเป็นโครูทีน C ข้ามแพลตฟอร์มในตำนาน หรือแย่กว่านั้นคือ std::thread

  • โครูทีน เป็นแนวคิดที่เจ๋งจริง ๆ และโดยเฉพาะวิดีโอ C++ coroutine จาก CppCon ที่คนฝั่ง Microsoft มักเป็นผู้นำเสนอก็ดูสนุกด้วย ฮุกคำว่า “abstraction ต้นทุนติดลบ” ก็ใช้ได้ดีทีเดียว
    เมื่อไม่กี่ปีก่อน เพื่อน ๆ ที่ Meta เริ่มใช้ C++ coroutine แล้วบอกผมทีหลังว่าสุดท้ายเป็นความผิดพลาดครั้งใหญ่ ต้องเจอบั๊กใน implementation ของคอมไพเลอร์ และคงตามรอยปัญหาได้เละเทะมาก ที่ Google ตอนนี้ก็รอให้คนเก่ง ๆ ที่ผสานมันเข้ากับ google3/ ได้อย่างถูกต้องบอกว่าเริ่มใช้ได้แล้ว
    บทความนี้อธิบายโครงสร้าง goto แบบมีโครงสร้างที่ใช้มาโครเป็นฐาน ผ่าน Duff's device [1] ในฐานะกลยุทธ์ implement โครูทีนใน C แก่นสำคัญคือสามารถวางคำสั่ง case ได้แทบทุกที่ภายในบล็อก switch จากนั้นห่อทั้งฟังก์ชันด้วย switch เก็บตำแหน่งที่โครูทีน return ล่าสุดไว้ในตัวแปร static และติด label ให้ coReturn แต่ละจุดด้วย case
    บทความของ Sustrik เรื่องโครูทีน C ก็น่าสนใจเช่นกัน [2]
    [1] https://en.wikipedia.org/wiki/Duff%27s_device
    [2] https://250bpm.com/blog:48/index.html

    • ช่วงไม่กี่ปีมานี้หลังจากย้ายจาก google3 ไป fbcode การที่มี โค้ด async อย่าง co_yield, co_return, co_await กระจายอยู่ทั่วโค้ด C++ มีทั้งข้อดีและข้อเสีย
      ข้อดีเมื่อเทียบกับวิธีภายใน google3 คือ เวลาอ่านโค้ดจะเห็นชัดว่าส่วนไหนเป็น async โปรแกรมเมอร์บางคนที่ Google เคยไม่รู้ threading model เกินระดับ branch แล้วภายหลังก่อบั๊กร้ายแรง
      ข้อเสียนั้นตรงไปตรงมากว่า พอโค้ดจำนวนมาก “อาจเป็น async” ได้ เมื่อเวลาผ่านไปทั้งระบบก็ค่อย ๆ กลายเป็น async เพียงเพราะโปรแกรมเมอร์เขียนในโหมดนั้น การเลือกว่าจะใช้ spinlock หรือ mutex ที่ yield ได้ควรตัดสินจากขนาดของ critical section และสถานการณ์ threading ในตอนนั้น แต่พอพยายามรักษาความอ่านง่ายและความสอดคล้อง โปรเจกต์ทั้งโปรเจกต์ก็มักไหลไปด้านใดด้านหนึ่ง
      อยากรู้จัก implementation ของภาษา threading ที่ไม่ตั้งค่า default ไปด้านใดด้านหนึ่ง แต่ใช้โปรไฟล์จากการรันครั้งก่อนมาปรับการรันครั้งถัดไปให้เหมาะขึ้น โดยไม่ต้องแก้โค้ดและไม่ก่อบั๊ก
    • อีกทางเลือกคือใช้ฟีเจอร์ labels as values ของ GCC สามารถเอา address ของ label แล้วกระโดดกลับไปที่นั่นภายหลังได้ ในปี 2005 ผมเคย contribute โค้ดที่ตอนนี้อยู่ใน lc-addrlabels.h
      ผมยังใช้ฟีเจอร์ local label ของ GCC ด้วย จึงเลี่ยงการใช้ __LINE__ ได้ทั้งหมด และสามารถมี coReturn หลายตัวในบรรทัดเดียวได้
    • คำกล่าวที่ว่า Duff ตระหนักว่าสามารถใช้คำสั่ง case ได้แทบทุกที่ในบล็อก switch นั้นน่าจะจริง แต่ตัวฟีเจอร์เองแทบแน่นอนว่าเป็น ฟีเจอร์ที่ตั้งใจออกแบบมา
      อย่างที่ส่วนท้ายของบทความก็กล่าวไว้ Duff ยังตระหนักด้วยว่าสามารถสร้างโครูทีนบนสิ่งนั้นได้ แต่เขามองความคิดนั้นว่า “น่ารังเกียจ”
      ถ้าคิด switch ของ C เป็นเหมือน pattern matching ที่แสดงออกได้น้อย “fallthrough” ก็จะดูเหมือนบั๊กได้ง่าย แต่จริง ๆ ไม่ใช่ มันอยู่ตระกูลเดียวกับ computed goto แบบของ Fortran และสะดวกกว่าเพราะค่าไม่จำเป็นต้องต่อเนื่องกัน และไม่ต้องไล่ label ทั้งหมดไว้ด้านบนสุด พอเขียนออกมาแล้วก็เหมือนจะใกล้กับ computed COMEFROM มากกว่าด้วยซ้ำ
    • อ้อ C preprocessor นี่ยังคงมอบของขวัญให้เราไม่รู้จบจริง ๆ :-(
  • ประโยคที่ว่า “ภาษา high-level ที่ใช้กันแพร่หลายไม่รองรับโครูทีน” อาจจริงในปี 2000 แต่ตอนนี้มีหลายภาษาที่รองรับแล้ว เช่น C++20, Lua, Python, Ruby

    • Python ถูกสร้างขึ้นในปี 1991 ดังนั้นคีย์เวิร์ด yield ก็น่าจะมีมาตั้งแต่ตอนนั้นหรือไม่นานหลังจากนั้น
      แนวทางปรับปรุงท้ายบทความที่ว่า “เพิ่ม pointer ไปยัง context struct เป็นอาร์กิวเมนต์ของฟังก์ชัน แล้วประกาศ local state และตัวแปรสถานะของโครูทีนทั้งหมดเป็นสมาชิกของ struct นั้น” ดูเหมือนการ implement closure ฝั่งที่ถูกเรียกถูกทำให้เป็น lambda แล้วใช้ตัวแปรภายนอก/context/state เพื่อตัดสินใจว่าจะทำอะไรหรือใช้ค่าใด ผมสงสัยว่าเข้าใจแบบนี้ถูกไหม
    • เกร็ดคือ Simula67 มีโครูทีนอยู่แล้ว ไม่ใช่ภาษาแรกสุด แต่เท่าที่จำได้เป็นภาษาใหญ่ภาษาแรกที่รองรับโครูทีน
  • วิธี switch ไม่ได้พบได้น้อยมากนัก แต่โดยทั่วไปจะมี พอยน์เตอร์สถานะ ที่ส่งต่อให้ฟังก์ชันเริ่มต้นและฟังก์ชันโครูทีน
    ผมใช้วิธีนี้บ่อยในโปรเจกต์ฝังตัว โดยโครูทีนตัวหนึ่งจัดการการเร่ง/ชะลอมอเตอร์ ส่วนอีกโครูทีนแค่บอกว่าจะไปทิศทางไหน เคยใช้ในไลบรารีเครือข่าย[1] ด้วย ในไลบรารีมาตรฐานก็มีฟังก์ชันแบบโครูทีนอย่าง strtok()[2]
    ถ้าจะทำให้จัดการได้ ไม่จำเป็นต้องถึงขั้นลากนรกของมาโครเข้ามา แต่ผมไม่เคยรู้สึกสนุกกับการอ่านโฟลว์แบบ switch/case เลย
    [1]: https://github.com/REONTeam/libmobile/blob/master/relay.c#L3...
    [2]: https://manpages.debian.org/bookworm/manpages-dev/strtok.3.e...

  • ยังมี Simon Tatham's Portable Puzzle Collection ของผู้เขียนคนเดียวกันด้วย
    https://www.chiark.greenend.org.uk/~sgtatham/puzzles/

  • ถ้าสิ่งนี้ดูเหมือนมนตร์ดำใน C ก็น่าอ่านบทความของผู้เขียนคนเดียวกันเกี่ยวกับการสร้างโครงสร้างควบคุมแบบใดก็ได้ด้วยมาโคร: https://www.chiark.greenend.org.uk/~sgtatham/mp/

    • วิธีใช้ขีดล่างเป็นคำนำหน้าก็ยังมักเปราะบางต่อ การบังชื่อ อยู่ดี ถ้าจะเลี่ยงก็ต้อง mangling ชื่อให้ดูน่าเกลียดพอสมควร และต่างจากมาโครที่ใกล้เคียงนิพจน์/คำสั่ง สำหรับมาโครบล็อกภายนอก แม้แต่แฮ็กมาโครแบบ hygienic ของ GNU/C23 ก็เลี่ยงไม่ได้
  • โครูทีนสนุกก็จริง แต่ในโค้ดจริงก็ควรพิจารณาใช้ เธรดจริง ด้วย โปรเซสเซอร์สมัยใหม่มีหลายคอร์ แต่โครูทีนมักใช้แค่คอร์เดียว
    เรื่องนี้เป็นปัญหาจริงด้วย จนไม่นานมานี้ qemu ซึ่งใช้โครูทีนเยอะ ส่ง I/O ของอุปกรณ์บล็อกจำนวนมากไปยังเธรดเดียว และทำให้เกิดปัญหาประสิทธิภาพ Kevin Wolf กับคนอื่น ๆ แก้เรื่องนี้กันมาหลายปี จน qemu สมัยใหม่ใช้หลายเธรดสำหรับ I/O แล้ว และงานนี้มีกำหนดจะเข้า RHEL 9.4

    • จุดเชื่อมโยงเดียวระหว่างเธรดกับโครูทีนคือ บาง runtime ของภาษาที่เป็นเธรดเดียวมีให้ใช้แค่โครูทีน จึงทำให้บางครั้งมีการใช้โครูทีนในที่ที่เธรดน่าจะเป็นตัวเลือกที่ดีกว่า
      โครูทีนคือ วิธีจัดโครงสร้างการรันแบบเธรดเดียว และมีประโยชน์ในตัวเอง ตัวอย่างในบทความอย่างแพตเทิร์น producer-consumer เป็นตัวอย่างที่ดี และการต่อสตรีมเข้ากับพาร์เซอร์ไม่ใช่อัลกอริทึมแบบขนาน ดังนั้นเธรดจึงไม่มีประโยชน์ในการเขียนสิ่งนั้น
      ถ้าใช้แพราดามเธรดเดียวกับงานที่ทำขนานได้ ก็ย่อมไม่มีประสิทธิภาพ แต่โครูทีนไม่ใช่ parallelism สำหรับคนจน หากเป็นโครงสร้างควบคุมที่มีความหมายโดยอิสระ มันยังผสานกับเธรดได้อย่างมีประสิทธิผล เช่นในเว็บเซิร์ฟเวอร์ที่ event loop ร่วมกับ dispatcher ร้อยโครูทีนผ่านเหตุการณ์แบบบล็อกหลายรายการ และถ้า runtime ทำ parallelization โดยเปิดเธรดต่อคอร์ การประสานงานระหว่างเธรดก็ลดเหลือเพียงตรวจความลึกของคิวงานแต่ละอันแล้วส่งคำขอไปยังฝั่งที่ยุ่งน้อยกว่า
    • การที่โครูทีนโดยทั่วไปใช้แค่ คอร์เดียว ส่วนใหญ่เป็นพฤติกรรมที่ต้องการ ถ้าเป็นงานขนานที่แยกจากกัน ก็จะจัดการข้อมูลคนละชุดโดยธรรมชาติ
      แนวคิดของโครูทีนคือใช้เมื่อมีงานเฉพาะที่และข้อมูลแบบซิงโครนัสบางอย่าง แล้วการแสดงมันในรูปแบบกลับด้าน—ที่ฟังก์ชันวนลูปทำบางอย่างแล้ว “ผลัก” ผลลัพธ์ไปยังผู้บริโภคเชิงนามธรรมที่อยู่ที่อื่น—ทำได้ง่ายกว่าแพราดามเชิงฟังก์ชันที่ผู้เรียก “ดึง” ลูปด้านในออกมา
    • มีจุดที่ค่อนข้างดีในการผสมเธรดกับโครูทีน คือให้แต่ละเธรดมีอินสแตนซ์ของ ตัวจัดตารางโครูทีน และสร้างเธรดหนึ่งตัวต่อคอร์
      จากนั้นแทบไม่ต้องย้ายโครูทีนข้าม scheduler และแทบไม่ต้องแชร์ข้อมูลระหว่างโครูทีนที่อยู่คนละ scheduler
      โครูทีนทำให้ได้สไตล์การเขียนโปรแกรมแบบ concurrent ที่สะดวกและไม่ต้องใช้ล็อกเลย ผ่าน cooperative scheduling โดยปกติ latency ของการจัดตารางจะสูงขึ้น แต่เมื่อไม่มี overhead ของ atomic operation/lock และไม่มี timer มาคอยตัดการรันเพื่อทำ preemptive scheduling อยู่เรื่อย ๆ throughput ก็อาจสูงได้พอสมควร
    • คำแนะนำว่า “ให้พิจารณาเธรดจริง” โดยทั่วไปไม่ค่อยดีนัก ตัวอย่างเช่น ถ้าแค่อยาก เดินผ่านโหนดของทรี ซึ่งเป็นคอลเลกชันที่ไม่แบน ผมไม่เห็นว่าทำไมต้องรันเธรดแยก
    • โครูทีนเบาและซิงโครไนซ์ง่ายมาก เหมาะกับการคำนวณแบบค่อยเป็นค่อยไปหน่วยเล็ก ๆ เช่น iterator หรือ tokenizer มาก บางทีคุณอาจนึกถึง green thread อยู่
  • เวอร์ชัน C++ ของแนวทางนี้: https://www.codeproject.com/Tips/29524/Generators-in-C
    ผมก็ใช้ใน Sciter ของผมเผื่อไว้เหมือนกัน และมันทำงานได้ค่อนข้างดีและสะดวก

  • วิธีทำสิ่งนี้ให้เป็นโมดูลและปลอดภัยน่าจะเป็น effect handler คล้าย yield ของ Python แต่สามารถคืนค่าได้ และไม่ได้จำกัดอยู่แค่การเรียกฟังก์ชัน แต่ถูกกำหนดสโคปเหมือน exception ถ้ายังไม่คุ้น บทความนี้เป็นแรงจูงใจที่ดี
    แต่ละฟังก์ชันที่เขียนแบบ direct style สามารถทำ “effect” ได้เมื่อการควบคุมต้องไปที่อื่น ในที่นี้คือกรณี c=getchar() และ emit(c)
    จากนั้นการควบคุมจะไปยัง effect handler และในกรณีนี้น่าจะเป็นผู้เรียกของสองฟังก์ชันที่ตัดสินใจว่าจะทำอะไรต่อ เมื่อ decompressor ปล่อยอักขระออกมา ก็ส่งอักขระนั้นให้โค้ด parser แล้ว resume จากนั้นเดินหน้าต่อจนกว่า parser จะบอกว่าต้องการเพิ่ม แล้วค่อย resume decompressor อีกครั้ง
    effect สามารถทำให้มีประสิทธิภาพได้ โดยเฉพาะเมื่อจำกัดให้เรียก continuation ได้เพียงครั้งเดียว OCaml เป็นกรณีแบบนั้น มันอนุญาตให้เขียนโค้ดแบบ direct style พร้อมกับความปลอดภัยด้าน type/memory และยังมีประโยชน์มากในสภาพแวดล้อม concurrent
    ตัวอย่างอยู่ที่นี่: https://effekt-lang.org/docs/casestudies/lexer

  • ไม่เห็นด้วยเลยกับส่วนที่บอกว่า “ทริกนี้แน่นอนว่าละเมิดมาตรฐานการเขียนโค้ดทุกข้อ… ผมขอเถียงว่ามาตรฐานการเขียนโค้ดต่างหากที่ผิด”
    การที่มาตรฐานการเขียนโค้ดปฏิเสธโค้ดนี้ไม่ได้ผิด และโค้ดนี้ก็เป็นเพียงทริกน่ารัก ๆ เท่านั้น วิศวกรรมซอฟต์แวร์ขนาดใหญ่คือการกำจัดความประหลาดใจ และสร้างโค้ดที่แม้แต่คนอดนอนที่ถูกเรียกมาตอนตี 3 เพื่อดีบักก็ยังอ่านได้ เราไม่อาจคาดหวังให้โปรแกรมเมอร์จำกฎพื้นฐานสี่ข้อได้เสมอไป
    ข้ออ้างที่ว่าเอาองค์ประกอบสำคัญอย่าง switch, return, case ไปซ่อนไว้ในมาโคร “ทำให้อ่านยาก” จนทำให้โครงสร้างไวยากรณ์พร่าเลือน แต่เผยให้เห็นโครงสร้างอัลกอริทึมนั้น ก็ยากจะยอมรับได้ โปรแกรมที่ดีควรมีทั้ง โครงสร้างไวยากรณ์และโครงสร้างอัลกอริทึม ที่ชัดเจน และวิธีนี้ยังไปไม่ถึงจุดนั้น ผมคิดว่าวิธีที่ Rust สร้าง state machine โดยนัยในฟังก์ชัน async ควรเป็นโมเดลในกรณีนี้

    • ท่าทีที่คอย “ลดระดับให้โง่ลง” อย่างไม่มีที่สิ้นสุดเพื่อให้เข้ากับตัวหารร่วมต่ำสุด คือสาเหตุที่ทำให้คุณภาพซอฟต์แวร์ส่วนใหญ่ในปัจจุบันเป็นอย่างที่เห็น หรือแทบไม่มีคุณภาพเลย หากหลีกเลี่ยงความรู้และการศึกษา สุดท้ายก็ต้องจ่ายราคา
    • พอลองย้ายจาก C ไป C++ ก็พบว่าระหว่างสองชุมชนมีความแตกต่างอย่างมากในเรื่องว่าอะไรคือ โค้ดที่อ่านง่าย หรือก็คือเราคาดหวังให้ผู้อ่านในอนาคตต้องเข้าใจอะไร
      ในโลก C แม้แต่ ternary conditional operator ก็ยังถูกมองว่าแรงเกินไป และ C99 ก็ถูกปฏิบัติเหมือนของใหม่ล้ำยุค ส่วนในโลก C++ เหตุผลเดียวที่ห้ามปราม template metaprogramming คือเพราะมาตรฐานที่ใช้อยู่สามารถทำสิ่งเดียวกันด้วย constexpr ได้