Coroutine ในภาษา C (2000)
(chiark.greenend.org.uk)- เมื่อโค้ดฝั่งผู้ผลิตและผู้บริโภคส่งข้อมูลให้กัน หากพลิกฝั่งใดฝั่งหนึ่งให้เป็นรูปแบบผู้ถูกเรียก โครงสร้างอัลกอริทึมที่เดิมมองเห็นได้ชัดมักถูกกลบด้วยการเปลี่ยนสถานะ
- 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
- โค้ดคลายการบีบอัดแบบ run-length encoding อ่านอินพุตด้วย
- เมื่อดูแยกกัน ทั้งสองรูทีนเป็นธรรมชาติ แต่หากต้องการให้เอาต์พุต
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 ไว้ที่จุดเริ่มต้นฟังก์ชันและหลัง
- วิธีนี้ทำงานได้ แต่การจัดการ 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และคืนค่า จากนั้นวาง labelcaseไว้ที่ตำแหน่งเดียวกัน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 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ผมกลับมาหน้านี้หลายครั้งระหว่างพยายามลดความซับซ้อนของ API ในโปรเจกต์ C และคิดว่าคำอธิบายเรื่อง control flow ทำได้ยอดเยี่ยม
ยังช่วยให้คิดได้ชัดขึ้นเรื่องการเก็บสถานะไว้ใน/นอกสแตก และความแตกต่างด้านความอ่านง่ายของแต่ละแนวทาง
ข้อสรุปตอนนี้คือ ให้ผู้ใช้ไลบรารีเป็นคนตัดสินใจดีกว่าว่าจะใช้โครูทีน C หรือไม่ เช่น Mongoose(https://github.com/cesanta/mongoose) จัดการงาน async ด้วย event callback ไลบรารีแบบนี้ถ้าห่อด้วย primitive ของ thread/task ของแต่ละระบบ จะสบายกว่ามาก แทนที่จะพยายามพอร์ตไปเป็นโครูทีน C ข้ามแพลตฟอร์มในตำนาน หรือแย่กว่านั้นคือ
std::thread[1] https://www.chiark.greenend.org.uk/~sgtatham/putty/
[2] https://www.chiark.greenend.org.uk/~sgtatham/puzzles/
โครูทีน เป็นแนวคิดที่เจ๋งจริง ๆ และโดยเฉพาะวิดีโอ 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
co_yield,co_return,co_awaitกระจายอยู่ทั่วโค้ด C++ มีทั้งข้อดีและข้อเสียข้อดีเมื่อเทียบกับวิธีภายใน google3 คือ เวลาอ่านโค้ดจะเห็นชัดว่าส่วนไหนเป็น async โปรแกรมเมอร์บางคนที่ Google เคยไม่รู้ threading model เกินระดับ branch แล้วภายหลังก่อบั๊กร้ายแรง
ข้อเสียนั้นตรงไปตรงมากว่า พอโค้ดจำนวนมาก “อาจเป็น async” ได้ เมื่อเวลาผ่านไปทั้งระบบก็ค่อย ๆ กลายเป็น async เพียงเพราะโปรแกรมเมอร์เขียนในโหมดนั้น การเลือกว่าจะใช้ spinlock หรือ mutex ที่ yield ได้ควรตัดสินจากขนาดของ critical section และสถานการณ์ threading ในตอนนั้น แต่พอพยายามรักษาความอ่านง่ายและความสอดคล้อง โปรเจกต์ทั้งโปรเจกต์ก็มักไหลไปด้านใดด้านหนึ่ง
อยากรู้จัก implementation ของภาษา threading ที่ไม่ตั้งค่า default ไปด้านใดด้านหนึ่ง แต่ใช้โปรไฟล์จากการรันครั้งก่อนมาปรับการรันครั้งถัดไปให้เหมาะขึ้น โดยไม่ต้องแก้โค้ดและไม่ก่อบั๊ก
lc-addrlabels.hผมยังใช้ฟีเจอร์ local label ของ GCC ด้วย จึงเลี่ยงการใช้
__LINE__ได้ทั้งหมด และสามารถมีcoReturnหลายตัวในบรรทัดเดียวได้caseได้แทบทุกที่ในบล็อกswitchนั้นน่าจะจริง แต่ตัวฟีเจอร์เองแทบแน่นอนว่าเป็น ฟีเจอร์ที่ตั้งใจออกแบบมาอย่างที่ส่วนท้ายของบทความก็กล่าวไว้ Duff ยังตระหนักด้วยว่าสามารถสร้างโครูทีนบนสิ่งนั้นได้ แต่เขามองความคิดนั้นว่า “น่ารังเกียจ”
ถ้าคิด
switchของ C เป็นเหมือน pattern matching ที่แสดงออกได้น้อย “fallthrough” ก็จะดูเหมือนบั๊กได้ง่าย แต่จริง ๆ ไม่ใช่ มันอยู่ตระกูลเดียวกับ computedgotoแบบของ Fortran และสะดวกกว่าเพราะค่าไม่จำเป็นต้องต่อเนื่องกัน และไม่ต้องไล่ label ทั้งหมดไว้ด้านบนสุด พอเขียนออกมาแล้วก็เหมือนจะใกล้กับ computedCOMEFROMมากกว่าด้วยซ้ำประโยคที่ว่า “ภาษา high-level ที่ใช้กันแพร่หลายไม่รองรับโครูทีน” อาจจริงในปี 2000 แต่ตอนนี้มีหลายภาษาที่รองรับแล้ว เช่น C++20, Lua, Python, Ruby
yieldก็น่าจะมีมาตั้งแต่ตอนนั้นหรือไม่นานหลังจากนั้นแนวทางปรับปรุงท้ายบทความที่ว่า “เพิ่ม pointer ไปยัง context struct เป็นอาร์กิวเมนต์ของฟังก์ชัน แล้วประกาศ local state และตัวแปรสถานะของโครูทีนทั้งหมดเป็นสมาชิกของ struct นั้น” ดูเหมือนการ implement closure ฝั่งที่ถูกเรียกถูกทำให้เป็น lambda แล้วใช้ตัวแปรภายนอก/context/state เพื่อตัดสินใจว่าจะทำอะไรหรือใช้ค่าใด ผมสงสัยว่าเข้าใจแบบนี้ถูกไหม
วิธี
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/
โครูทีนสนุกก็จริง แต่ในโค้ดจริงก็ควรพิจารณาใช้ เธรดจริง ด้วย โปรเซสเซอร์สมัยใหม่มีหลายคอร์ แต่โครูทีนมักใช้แค่คอร์เดียว
เรื่องนี้เป็นปัญหาจริงด้วย จนไม่นานมานี้ qemu ซึ่งใช้โครูทีนเยอะ ส่ง I/O ของอุปกรณ์บล็อกจำนวนมากไปยังเธรดเดียว และทำให้เกิดปัญหาประสิทธิภาพ Kevin Wolf กับคนอื่น ๆ แก้เรื่องนี้กันมาหลายปี จน qemu สมัยใหม่ใช้หลายเธรดสำหรับ I/O แล้ว และงานนี้มีกำหนดจะเข้า RHEL 9.4
โครูทีนคือ วิธีจัดโครงสร้างการรันแบบเธรดเดียว และมีประโยชน์ในตัวเอง ตัวอย่างในบทความอย่างแพตเทิร์น producer-consumer เป็นตัวอย่างที่ดี และการต่อสตรีมเข้ากับพาร์เซอร์ไม่ใช่อัลกอริทึมแบบขนาน ดังนั้นเธรดจึงไม่มีประโยชน์ในการเขียนสิ่งนั้น
ถ้าใช้แพราดามเธรดเดียวกับงานที่ทำขนานได้ ก็ย่อมไม่มีประสิทธิภาพ แต่โครูทีนไม่ใช่ parallelism สำหรับคนจน หากเป็นโครงสร้างควบคุมที่มีความหมายโดยอิสระ มันยังผสานกับเธรดได้อย่างมีประสิทธิผล เช่นในเว็บเซิร์ฟเวอร์ที่ event loop ร่วมกับ dispatcher ร้อยโครูทีนผ่านเหตุการณ์แบบบล็อกหลายรายการ และถ้า runtime ทำ parallelization โดยเปิดเธรดต่อคอร์ การประสานงานระหว่างเธรดก็ลดเหลือเพียงตรวจความลึกของคิวงานแต่ละอันแล้วส่งคำขอไปยังฝั่งที่ยุ่งน้อยกว่า
แนวคิดของโครูทีนคือใช้เมื่อมีงานเฉพาะที่และข้อมูลแบบซิงโครนัสบางอย่าง แล้วการแสดงมันในรูปแบบกลับด้าน—ที่ฟังก์ชันวนลูปทำบางอย่างแล้ว “ผลัก” ผลลัพธ์ไปยังผู้บริโภคเชิงนามธรรมที่อยู่ที่อื่น—ทำได้ง่ายกว่าแพราดามเชิงฟังก์ชันที่ผู้เรียก “ดึง” ลูปด้านในออกมา
จากนั้นแทบไม่ต้องย้ายโครูทีนข้าม scheduler และแทบไม่ต้องแชร์ข้อมูลระหว่างโครูทีนที่อยู่คนละ scheduler
โครูทีนทำให้ได้สไตล์การเขียนโปรแกรมแบบ concurrent ที่สะดวกและไม่ต้องใช้ล็อกเลย ผ่าน cooperative scheduling โดยปกติ latency ของการจัดตารางจะสูงขึ้น แต่เมื่อไม่มี overhead ของ atomic operation/lock และไม่มี timer มาคอยตัดการรันเพื่อทำ preemptive scheduling อยู่เรื่อย ๆ throughput ก็อาจสูงได้พอสมควร
เวอร์ชัน 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 แม้แต่ ternary conditional operator ก็ยังถูกมองว่าแรงเกินไป และ C99 ก็ถูกปฏิบัติเหมือนของใหม่ล้ำยุค ส่วนในโลก C++ เหตุผลเดียวที่ห้ามปราม template metaprogramming คือเพราะมาตรฐานที่ใช้อยู่สามารถทำสิ่งเดียวกันด้วย
constexprได้