1 คะแนน โดย GN⁺ 2024-08-29 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • 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 แบบ global
    • sum ประกาศเป็น atomic.Int32
    • receive(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 ความคิดเห็น

 
GN⁺ 2024-08-29
ความคิดเห็นใน Hacker News
  • ในมุมของนักวิทยาศาสตร์ที่ทำงานใกล้ชิดกับวิศวกรซอฟต์แวร์มืออาชีพจริง ๆ งานจำนวนมากที่พวกเขาทำดูเป็นแบบนี้ จนยากจะเข้าใจจริง ๆ ว่าทำไปทำไม
    เคยเห็นโค้ดหนึ่งบรรทัดต้องผ่าน ฟังก์ชันอินเทอร์เฟซ 4 ตัว ตามลำดับก่อนจะถูกเรียกใช้งานจริง และฟังก์ชันเหล่านั้นก็กระจัดกระจายอยู่คนละไฟล์ในคนละโฟลเดอร์
    เลยทำให้อ่านว่าโค้ดทำอะไรนั้นเหนื่อยมาก และพอไล่เข้าไปหลายชั้น ก็เริ่มสงสัยว่ากำลังดูถูกที่อยู่ไหม และสุดท้ายจะไปถึงจุดที่มีการคำนวณจริง ๆ สักทีหรือเปล่า

    • นี่เป็นแนวปฏิบัติที่แย่มากจริง ๆ และใกล้เคียงกับวิธีที่ วิศวกรจูเนียร์ที่กระตือรือร้นเกินไป เขียนซอฟต์แวร์
      ความรู้สึกว่ามันมากเกินไปและดูชวนสับสนนั้นไม่ผิด ตอนที่เริ่มเขียนโค้ดที่ “น่าสนใจ” มันอาจดูซับซ้อนทางเทคนิคและถึงขั้นสง่างาม แต่ในซอฟต์แวร์ที่ต้องเติบโตจริง ๆ มันจะกลายเป็นฝันร้ายทางเทคนิค
      เคยใช้เวลาเกือบ 2 ปีเก็บกวาด การใช้ channel ผิด ๆ ในโค้ด Go โดย channel เป็นสิ่งที่จริง ๆ แล้วแทบไม่ค่อยจำเป็น แต่ช่วงแรก ๆ มันเอาไปใช้ได้ง่ายกับหลายอย่าง จึงกลายเป็นปัญหา
      เกณฑ์ในการดูว่าใช้ channel ถูกต้องหรือไม่คือ ต้องตอบว่าไม่กับคำถาม “ทำด้วยการเรียกฟังก์ชันโดยตรงไม่ได้หรือ?”, “ทำด้วย wait group หรือ mutex ไม่ได้หรือ?” และต้องตอบว่าใช่กับคำถาม “ประโยชน์ด้าน concurrency/parallelism มากพอที่จะคุ้มกับความซับซ้อนในการดีบักโค้ด concurrent หรือไม่?”
    • มีที่หนักกว่านี้อีก นี่ไม่ใช่ตัวอย่างที่เว่อร์เกินจริงมากนัก: https://github.com/EnterpriseQualityCoding/FizzBuzzEnterpris...
      มีบางกรณีที่ขาดความเข้าใจและความสามารถอย่างน่ากลัว ในจุดที่ควรจะมีสิ่งเหล่านั้น
      สมัยปริญญาตรีเคยทำ pair programming กับดอกเตอร์ด้านวิทยาการคอมพิวเตอร์อยู่ราว 30 นาที ซึ่งเปิดหูเปิดตามาก
      เขาไม่เข้าใจซอฟต์แวร์เลย ถึงขั้นชี้ว่าทำไมไม่ตรวจสอบว่าขนาดของโครงสร้างข้อมูลใน standard library ไม่เป็นค่าลบ
      แต่บางครั้งโครงสร้างแบบนี้ก็มีเหตุผลอยู่ บางครั้งมันสมเหตุสมผลจริง ๆ และบางครั้งก็เป็นวิธีรับมือกับโค้ดเบสวิกลจริตที่คนก่อน ๆ สร้างไว้
    • ปลายด้านหนึ่งของสเปกตรัมคนเขียนโค้ดคือ นักวิทยาศาสตร์ อีกปลายคือ วิศวกรซอฟต์แวร์ สิ่งเดียวที่จะช่วยเราได้คือ ความสมดุล
      เคยอ่านโค้ดที่ใช้ในเปเปอร์วิจัย ทฤษฎีคณิตศาสตร์โดยมากก็เกินความเข้าใจอยู่แล้ว แต่พอเข้าไปดูโค้ดเพื่อดูตรรกะ กลับยิ่งหนักและอ่านไม่รู้เรื่องกว่าเดิมมาก
      สุดท้ายเราก็คุ้นกับวิธีที่ตัวเองทำอยู่ และวิธีอื่นย่อมรู้สึกแปลกตา
    • การทำวิศวกรรมเกินจำเป็น เป็นสาเหตุที่พบได้บ่อย ทางออกแบบง่าย ๆ มักซ่อนอยู่จนหาเจอยาก
      ถึงอย่างนั้น ชั้นของการอ้อมเพิ่มขึ้นมามักมีเหตุผลรองรับในสถาปัตยกรรมโดยรวม และถ้าเป็นกรณีที่สมเหตุสมผล การมองเฉพาะจุดอาจทำให้มองไม่เห็นคุณค่า
      “สัมผัสอันแผ่วเบาและความสงบนิ่งของภาษาโปรแกรมยุคแรก ๆ นั้นน่ายินดีเสมอ มีข้อความไม่มาก แต่ทำงานได้มากมาย โปรแกรมเก่า ๆ อ่านแล้วไม่เหมือนการโต้เถียงกับคอมไพเลอร์ แต่เหมือนบทสนทนาเงียบ ๆ ระหว่างนักวิจัยที่พูดเก่งกับเพื่อนร่วมงานเครื่องจักรที่ได้รับการฝึกมาอย่างดี ใครจะไปรู้ว่าความประณีตจะซื้อเสียงรบกวนแบบนี้มาด้วย?” — Dick Gabriel
    • ในทางกลับกัน แม้จะเห็นด้วยว่าวิศวกรซอฟต์แวร์มักพา abstraction ไปไกลเกินเมื่อไม่รู้ดีพอ แต่ก็ไม่ได้มองโค้ดที่เขียนโดยคนที่ไม่ได้เป็นวิศวกรซอฟต์แวร์โดยอาชีพว่าสูงส่งเป็นพิเศษ
      เหมือนเห็นสุดขั้วทั้งสองด้าน ทั้งโค้ดเบสที่มี 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 อื่นเพื่อระบุโปรเซสที่จะรับคำตอบนั้นเป็นเรื่องธรรมดามาก

    • ยังจำได้ว่าช่วงต้นอาชีพเคยโดนด่าเพราะคอมมิตที่มีดาวสามดวง ประมาณว่าใครกันต้องการ pointer ของ pointer ของ pointer
      แต่มันไม่ใช่แบบนั้น มันคือที่อยู่ของอาร์เรย์ของสตริงต่างหาก เพราะเป็น 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
    • น่าจะเป็นแพตเทิร์นคล้ายกับ concurrent chat server ของ gopl ช่วงแรกอาจน่าประหลาดใจนิดหน่อย แต่ก็ยังอ่านได้ค่อนข้างดี
      [0]: https://github.com/adonovan/gopl.io/blob/master/ch8/chat/cha...
    • ผมก็เคยใช้เหมือนกัน มีประโยชน์ในการทำ semantics คล้าย Promise ใน Go
  • channel ของ channel เป็นแพตเทิร์นปกติ แต่โดยทั่วไปมักพบในรูป channel ของค่า struct มากกว่า และ type ของ struct นั้นมีฟิลด์ channel อยู่
    ใช้เพื่อส่ง request ที่จะถูกทำให้เสร็จ แล้วทำให้รอค่าเหล่านั้นได้โดยไม่ต้องสนใจลำดับ เพื่อหลีกเลี่ยง head-of-line blocking
    เช่น ส่ง request ผ่าน channel ในลักษณะ type request struct { params, reply chan response } แล้ว worker ประมวลผลงาน จากนั้นใส่ผลลัพธ์ลงใน channel reply
    แต่ที่เคยมีประโยชน์คือถึงสองชั้นเท่านั้น และไม่เคยเห็น use case ที่ต้องใช้ chan chan chan

  • มีบล็อกตัวอย่างโต้แย้งที่ใช้ channel ที่ส่ง channel เพื่อทำ dynamic dispatch แม้ไม่ใช่ Go แต่เป็น Limbo แนวคิดก็เหมือนกัน ความซับซ้อนอาจยิ่งช่วยพิสูจน์ประเด็นนี้ก็ได้ https://ipn.caerwyn.com/2007/07/lab-78-dynamic-dispatch.html...

    • เพิ่มเติมสำหรับคนที่สงสัย: Limbo เป็นภาษาที่เป็นเหมือนบรรพบุรุษของ Go และใช้ในระบบปฏิบัติการ Inferno ซึ่งสืบทอดมาจาก Plan 9
  • นึกถึง “My favorite Erlang Program” ของ Joe Armstrong
    https://joearms.github.io/published/2013-11-21-My-favorite-e...