โปรแกรมเมอร์ Go สาย 4-chan
(dolthub.com)- DoltHub จงใจซ้อน channel ที่ใช้กันบ่อยใน concurrency ของ Go ให้เกินจำเป็น เพื่อทำตัวอย่างเล่น ๆ ที่ ส่ง channel ผ่าน channel
- ในโค้ดที่เคยรับช่วงมาจริง ๆ มี
chan chan struct{}อยู่ และถูกใช้ใน แพตเทิร์น fan-out ที่ส่ง channel ใหม่ไปยัง worker goroutine แต่เพราะอนุมานและจัดการได้ยาก จึงถูกเขียนใหม่ - ตัวอย่างนี้ต่อยอดมุก “4-star programmer” ของ
int****ในตระกูลภาษา C มาเป็นchanของ Go โดยใช้_4chan:= make(chan chan chan chan int)เป็น channel ระดับบนสุด - เมื่อ
factor = 3จะแยก producer และ consumer ในแต่ละชั้นของ channel แล้วรวมค่าintสุดท้าย เพื่อพิมพ์ผลลัพธ์ 3 ยกกำลัง 5 หรือ 243 - ในงานจริงไม่เหมาะใช้งาน เพราะความยากในการ implement และ debug, การจัดการการปิด channel, ความจำเป็นของ
sync.WaitGroupและ goroutine leak โดยตัวอย่างนี้พึ่งพาtime.Sleep()แทน logic การจบงาน
กรณีการซ้อน channel จาก Dolt
- DoltHub กำลังเขียน Dolt ฐานข้อมูล SQL แบบมี version control ตัวแรกของโลกด้วย Go
- เช่นเดียวกับ codebase Go ทั่วไป ใช้ channels และ goroutines สำหรับทำงานแบบ concurrent
- เพราะ concurrency programming นั้นยากในตัวเองอยู่แล้ว โดยทั่วไปจึงมักใช้ channel และ goroutine ในแบบที่ เรียบง่ายและเข้าใจตรงไปตรงมา
- ครั้งหนึ่ง ในโค้ดที่นำมาจากโปรเจกต์โอเพนซอร์สอื่น มี channel ที่ส่ง channel อยู่ดังนี้
var c chan chan struct{}
- โครงสร้างนี้เป็นวิธีส่ง channel ระหว่าง goroutine เพื่อ implement แพตเทิร์น fan-out ของ worker goroutine
- channel ชั้นกลางทำหน้าที่เป็นตัวกลาง ส่ง channel ที่สร้างขึ้นใหม่ไปให้ worker ที่ทำงานจริง
- แม้มันจะทำงานได้ แต่อนุมานและจัดการได้ยาก โดยเฉพาะเมื่อคำนึงถึง goroutine leak
- โค้ดส่วนนั้นถูกเขียนใหม่ และ
chan chan struct{}ก็หายไป
เวอร์ชัน Go ของมุก “4-star programmer”
- ในยุคที่ C และภาษาที่แตกแขนงจาก C ถูกใช้อย่างแพร่หลาย มีมุกเรียกมือใหม่ที่เข้าใจ pointer ได้ยากว่า “4-star programmer”
- ตัวอย่างที่เป็นตัวแทนคือโค้ดที่ใช้การอ้างอิงทางอ้อมของ pointer หลายชั้น เช่น
int**** - Go ก็สืบทอดมาจาก C อย่างมาก จึงสามารถเขียนโค้ดลักษณะเดียวกันด้วย pointer ได้
- ส่ง
*int,**int,***int,****intต่อกันไปตามลำดับ - ถ้าฟังก์ชันสุดท้ายทำ
****i = 100โปรแกรมจะพิมพ์i is now 100
- ส่ง
- Go มี
chanซึ่ง C ไม่มี ดังนั้นจึงสามารถขยายมุกเดียวกันไปเป็น การอ้างอิงทางอ้อมด้วย channel ได้
คำนวณยกกำลัง 5 ด้วย channel 4 ชั้น
- channel ระดับบนสุดประกาศไว้ดังนี้
_4chan := make(chan chan chan chan int)
- identifier ของ Go เริ่มต้นด้วยตัวเลขไม่ได้ ตัวอย่างนี้จึงใช้ชื่อ
_4chan - ค่าที่ส่งเข้า
_4chanคือ channel 3 ชั้น_3chan := make(chan chan chan int)
- ไล่ระดับลงไปด้วยวิธีเดียวกัน จนสุดท้ายไปถึง channel ของค่า
chan int - ในแต่ละชั้นของการอ้างอิงทางอ้อม จะสร้าง producer ตามค่าคงที่
factor- ในตัวอย่างคือ
const factor = 3 sendChanChanChanเริ่ม 3-channel producer เป็น goroutine
- ในตัวอย่างคือ
- ฝั่ง consumer ก็รับ channel ที่เข้ามาในแต่ละชั้น แล้วเริ่ม consumer ชั้นถัดไปจำนวน
factorตัวreceiveChanChanChanรับ_3chanจาก_4chanแล้วเริ่ม 3-channel consumer
การส่งค่าและรวมผลในชั้นสุดท้าย
- ในชั้นล่างสุด จะไม่ใช่ channel อีกต่อไป แต่เป็นการส่งค่า
intจริง - ฟังก์ชัน
sendส่ง_1chanเข้า_2chanแล้วเริ่ม int producer จำนวนfactorตัว - int producer แต่ละตัวจะสร้าง goroutine อีกจำนวน
factorตัวเพื่อรัน_1chan <- 1 - consumer นำจำนวนเต็มที่รับได้ไปบวกเข้ากับ
sumแบบ globalsumประกาศเป็นatomic.Int32receive(c chan int)รับค่าจาก channel แล้วทำsum.Add(int32(s))
ผลลัพธ์การรันและจำนวนการแตกแขนง
- โปรแกรมทั้งหมดสร้าง
_4chanจากนั้นเริ่มชั้นส่งและชั้นรับเป็น goroutine อย่างละตัว แล้วรอเป็นเวลา500 * time.Millisecond - ตัวอย่างผลลัพธ์เป็นดังนี้
3 ^ 5: 243
- โปรแกรมนี้เป็นตัวอย่างแบบ generalized ที่คำนวณ ยกกำลัง 5 ของตัวเลขด้วยวิธีที่กระจายให้มากที่สุด
- ตัวอย่างที่รันได้ดูได้ที่ Go Playground และเวอร์ชันที่มี syntax highlighting อยู่ที่ GitHub Gist
- หากใช้
factorที่ใหญ่ขึ้น อาจต้องเพิ่มเวลาSleepเพื่อให้การรันจบได้ - ถ้าเปิด log จะตรวจสอบจำนวนการแตกแขนงของแต่ละชั้นการผลิตและการบริโภค channel ได้
starting 3chan producer: 3 ครั้งstarting 2chan producer: 9 ครั้งstarting 3chan consumer: 9 ครั้งstarting 2chan consumer: 27 ครั้งstarting chan producer: 27 ครั้งstarting 1chan consumer: 81 ครั้งstarting int producer: 81 ครั้งsending int: 243 ครั้งreceived int: 243 ครั้ง
เหตุผลที่ควรหลีกเลี่ยงในโค้ดใช้งานจริง
- วิธีนี้ implement และ debug ได้ยุ่งยากเกินกว่าจะใช้ในโค้ดจริง
- เมื่อส่ง channel ผ่าน channel จะตัดสินใจได้ยากว่าแต่ละ channel ควรถูกปิดเมื่อไร
- ถ้าเป็นกรณีใช้งานจริง จำเป็นต้องปิด channel แต่หากจะใส่ logic การจบงาน ก็ต้องติดตามว่าการส่งผ่าน channel ทั้งหมดเสร็จแล้วหรือยัง
- หากจะ implement การจบงาน ต้องเพิ่ม
sync.WaitGroupหลายจุด และนั่นจะทำให้ตัวอย่างเล่น ๆ นี้อ่านยากขึ้น - ตัวอย่างสุดท้ายจึงถูกทำให้ง่ายลงโดยใช้
time.Sleep()แทน logic การจบงาน และปล่อยให้มี goroutine leak จำนวนมาก
1 ความคิดเห็น
ความคิดเห็นใน Hacker News
ในมุมของนักวิทยาศาสตร์ที่ทำงานใกล้ชิดกับวิศวกรซอฟต์แวร์มืออาชีพจริง ๆ งานจำนวนมากที่พวกเขาทำดูเป็นแบบนี้ จนยากจะเข้าใจจริง ๆ ว่าทำไปทำไม
เคยเห็นโค้ดหนึ่งบรรทัดต้องผ่าน ฟังก์ชันอินเทอร์เฟซ 4 ตัว ตามลำดับก่อนจะถูกเรียกใช้งานจริง และฟังก์ชันเหล่านั้นก็กระจัดกระจายอยู่คนละไฟล์ในคนละโฟลเดอร์
เลยทำให้อ่านว่าโค้ดทำอะไรนั้นเหนื่อยมาก และพอไล่เข้าไปหลายชั้น ก็เริ่มสงสัยว่ากำลังดูถูกที่อยู่ไหม และสุดท้ายจะไปถึงจุดที่มีการคำนวณจริง ๆ สักทีหรือเปล่า
ความรู้สึกว่ามันมากเกินไปและดูชวนสับสนนั้นไม่ผิด ตอนที่เริ่มเขียนโค้ดที่ “น่าสนใจ” มันอาจดูซับซ้อนทางเทคนิคและถึงขั้นสง่างาม แต่ในซอฟต์แวร์ที่ต้องเติบโตจริง ๆ มันจะกลายเป็นฝันร้ายทางเทคนิค
เคยใช้เวลาเกือบ 2 ปีเก็บกวาด การใช้ channel ผิด ๆ ในโค้ด Go โดย channel เป็นสิ่งที่จริง ๆ แล้วแทบไม่ค่อยจำเป็น แต่ช่วงแรก ๆ มันเอาไปใช้ได้ง่ายกับหลายอย่าง จึงกลายเป็นปัญหา
เกณฑ์ในการดูว่าใช้ channel ถูกต้องหรือไม่คือ ต้องตอบว่าไม่กับคำถาม “ทำด้วยการเรียกฟังก์ชันโดยตรงไม่ได้หรือ?”, “ทำด้วย wait group หรือ mutex ไม่ได้หรือ?” และต้องตอบว่าใช่กับคำถาม “ประโยชน์ด้าน concurrency/parallelism มากพอที่จะคุ้มกับความซับซ้อนในการดีบักโค้ด concurrent หรือไม่?”
มีบางกรณีที่ขาดความเข้าใจและความสามารถอย่างน่ากลัว ในจุดที่ควรจะมีสิ่งเหล่านั้น
สมัยปริญญาตรีเคยทำ pair programming กับดอกเตอร์ด้านวิทยาการคอมพิวเตอร์อยู่ราว 30 นาที ซึ่งเปิดหูเปิดตามาก
เขาไม่เข้าใจซอฟต์แวร์เลย ถึงขั้นชี้ว่าทำไมไม่ตรวจสอบว่าขนาดของโครงสร้างข้อมูลใน standard library ไม่เป็นค่าลบ
แต่บางครั้งโครงสร้างแบบนี้ก็มีเหตุผลอยู่ บางครั้งมันสมเหตุสมผลจริง ๆ และบางครั้งก็เป็นวิธีรับมือกับโค้ดเบสวิกลจริตที่คนก่อน ๆ สร้างไว้
เคยอ่านโค้ดที่ใช้ในเปเปอร์วิจัย ทฤษฎีคณิตศาสตร์โดยมากก็เกินความเข้าใจอยู่แล้ว แต่พอเข้าไปดูโค้ดเพื่อดูตรรกะ กลับยิ่งหนักและอ่านไม่รู้เรื่องกว่าเดิมมาก
สุดท้ายเราก็คุ้นกับวิธีที่ตัวเองทำอยู่ และวิธีอื่นย่อมรู้สึกแปลกตา
ถึงอย่างนั้น ชั้นของการอ้อมเพิ่มขึ้นมามักมีเหตุผลรองรับในสถาปัตยกรรมโดยรวม และถ้าเป็นกรณีที่สมเหตุสมผล การมองเฉพาะจุดอาจทำให้มองไม่เห็นคุณค่า
“สัมผัสอันแผ่วเบาและความสงบนิ่งของภาษาโปรแกรมยุคแรก ๆ นั้นน่ายินดีเสมอ มีข้อความไม่มาก แต่ทำงานได้มากมาย โปรแกรมเก่า ๆ อ่านแล้วไม่เหมือนการโต้เถียงกับคอมไพเลอร์ แต่เหมือนบทสนทนาเงียบ ๆ ระหว่างนักวิจัยที่พูดเก่งกับเพื่อนร่วมงานเครื่องจักรที่ได้รับการฝึกมาอย่างดี ใครจะไปรู้ว่าความประณีตจะซื้อเสียงรบกวนแบบนี้มาด้วย?” — Dick Gabriel
เหมือนเห็นสุดขั้วทั้งสองด้าน ทั้งโค้ดเบสที่มี abstraction มากเกินจนกระจัดกระจายเกินไป และโค้ดเบสที่แทบไม่มี abstraction เลย จนเหมือนสคริปต์ทำให้บรรลุเป้าหมายเฉพาะหน้า ล้วนทำงานด้วยยาก
สคริปต์ Python, JS, PHP จำนวนมากถูกเขียนด้วยท่าทีแบบ “ช่างมัน เอาผลลัพธ์ที่ฉันต้องการมาก็พอ” และสำหรับคนที่ทำงานกับโค้ดทุกวัน ก็จำเป็นต้องมี abstraction ที่ช่วยเรื่องการทำงานร่วมกันและความทนทานต่อการเปลี่ยนแปลง
มีมช่วงต้นทำให้หัวเราะจริง ๆ ในฐานะโปรแกรมเมอร์ C ที่กำลังฟื้นตัว
การได้เห็นตัวอย่างการบิดภาษาไปใช้แบบนี้สนุกดี และ C ก็มีโอกาสให้ทำแบบนั้นเต็มไปหมด แต่น่าสนใจที่ได้เห็นใน Go ด้วย
เขาบอกว่า “มุกเก่า ๆ ทางโปรแกรมมิงจากยุคที่ C และภาษาที่สืบทอดจากมันครองโลก” แต่เราก็ยังมีชีวิตอยู่ในยุคนั้นอยู่ดี
น่าขันที่ในเธรดนี้วิจารณ์สิ่งนี้ว่าเป็นตัวอย่างคลาสสิกของ abstraction เกินขนาด
เหตุผลที่เราไม่ค่อยเห็นตัวแปรดาวสามดวงใน C ไม่ใช่เพราะ pointer chain พบได้น้อย แต่เพราะไม่ค่อยมีความจำเป็นต้องจัดการมากกว่าสองชั้นพร้อมกัน
ในภาษาอย่าง Python, Java, JavaScript ซึ่งโดยพื้นฐานส่วนใหญ่ใกล้เคียงกับ pointer อยู่แล้ว ย่อมมี pointer chain ที่ยาวเกิน 4 อยู่แน่นอน
ส่วนที่ลึก ๆ มักถูกซ่อนอยู่ภายใน struct ที่เราไม่ต้องสนใจรายละเอียดภายใน กล่าวคือถูกทำให้เป็น abstraction แล้ว
channel เองก็ทำหน้าที่ในโค้ด concurrent โดยคร่าว ๆ เหมือนที่ pointer ทำในโค้ดลำดับปกติ ดังนั้นถ้าตรงนี้มีข้อบกพร่องร้ายแรง มันอาจไม่ใช่ abstraction เกินไป แต่กลับเป็น abstraction ไม่พอ ด้วยซ้ำ
ถึงอย่างนั้นก็ไม่รู้จักโค้ดเบสนี้ บางที
chan chanอาจเป็น abstraction ที่ตรงกับสิ่งที่พวกเขาตั้งใจจะเขียนพอดีก็ได้ ในภาษาที่เน้น concurrency อย่าง Erlang การส่ง PID ไปยัง PID อื่นเพื่อระบุโปรเซสที่จะรับคำตอบนั้นเป็นเรื่องธรรมดามากแต่มันไม่ใช่แบบนั้น มันคือที่อยู่ของอาร์เรย์ของสตริงต่างหาก เพราะเป็น C ก็เลยเป็นแบบนั้น และแม้ผ่านมาเกือบ 20 ปีแล้ว ก็ยังคิดว่านั่นเป็นวิธีแก้ที่ตรงไปตรงมาที่สุด
ถ้าจะปกป้องเพื่อนร่วมงาน ก็น่าจะเป็นเพราะตอนนั้นคงไม่มีคอมเมนต์ ซึ่งส่วนนั้นเป็นความรับผิดชอบของผมเอง
ทำให้นึกถึงงานคลาสสิกเหนือกาลเวลาของ Buena Vista Social Club https://www.youtube.com/watch?v=o5cELP06Mik
chan chan Valueหรือchan struct{resp chan Value}เป็น แพตเทิร์นที่เคยใช้จริงในสถานการณ์ที่เฉพาะเจาะจงมากจะใช้ message bus ก็ได้ แต่ถ้าทำอย่างนั้นก็ต้องไปจัดการ message bus อีก
chan chanใช้ค่อนข้างบ่อย เป็นกรณีที่ส่งข้อความไปยังเซิร์ฟเวอร์ภายในหรือ actor พร้อมแนบ channel สำหรับรับคำตอบไปกับข้อความด้วยในทางปฏิบัติมักอยู่ในรูป
chan struct { ... อะไรสักอย่างที่มี channel อยู่ข้างใน ... }มากกว่าchan chanแบบตัวอักษรตรง ๆ ที่ค้นด้วย grep ได้ แต่หลักการเหมือนกันเป็นแพตเทิร์นที่มีประโยชน์มาก และมองว่าเป็นหนึ่งในพื้นฐานของ Go
แต่เท่าที่รู้ ไม่เคยใช้
chan chan chanการเปลี่ยนทั้งหมดนี้ให้เป็น message bus ถือว่าเกินจำเป็น คุณลักษณะด้านประสิทธิภาพก็ต่างกันมาก และ channel ของ Go ใกล้เคียงกับส่วนประกอบภายในโปรเซสของระบบปฏิบัติการ ดังนั้นถ้าเป็นการสื่อสารภายในโปรเซส ก็ไม่คุ้มที่จะ “อัปเกรด” เป็น message bus
[0]: https://github.com/adonovan/gopl.io/blob/master/ch8/chat/cha...
channel ของ channel เป็นแพตเทิร์นปกติ แต่โดยทั่วไปมักพบในรูป channel ของค่า struct มากกว่า และ type ของ struct นั้นมีฟิลด์ channel อยู่
ใช้เพื่อส่ง request ที่จะถูกทำให้เสร็จ แล้วทำให้รอค่าเหล่านั้นได้โดยไม่ต้องสนใจลำดับ เพื่อหลีกเลี่ยง head-of-line blocking
เช่น ส่ง request ผ่าน channel ในลักษณะ
type request struct { params, reply chan response }แล้ว worker ประมวลผลงาน จากนั้นใส่ผลลัพธ์ลงใน channelreplyแต่ที่เคยมีประโยชน์คือถึงสองชั้นเท่านั้น และไม่เคยเห็น use case ที่ต้องใช้
chan chan chanมีบล็อกตัวอย่างโต้แย้งที่ใช้ channel ที่ส่ง channel เพื่อทำ dynamic dispatch แม้ไม่ใช่ Go แต่เป็น Limbo แนวคิดก็เหมือนกัน ความซับซ้อนอาจยิ่งช่วยพิสูจน์ประเด็นนี้ก็ได้ https://ipn.caerwyn.com/2007/07/lab-78-dynamic-dispatch.html...
นึกถึง “My favorite Erlang Program” ของ Joe Armstrong
https://joearms.github.io/published/2013-11-21-My-favorite-e...