2 คะแนน โดย GN⁺ 2023-07-18 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • ใน Go มีแพตเทิร์นด้าน concurrency ที่ใช้แค่ goroutine และ channel แล้วดูไม่เป็นธรรมชาติ และ coroutine เป็นข้อเสนอที่ช่วยจัดโครงสร้างโปรแกรมด้วยการส่งต่อลำดับการทำงานอย่างชัดเจนโดยไม่มี parallelism
  • Coroutine รับส่งสิทธิ์การทำงานกันด้วย resume และ yield และเพราะมีการรันได้ทีละตัว จึงหลีกเลี่ยง data race บนข้อมูลที่ใช้ร่วมกันได้ ขณะเดียวกันจุดสลับก็กลายเป็น จุดซิงโครไนซ์
  • Python generator และ CLU iterator คล้ายกับ coroutine แต่ตำแหน่งที่ใช้ yield ถูกจำกัด จึงมีความต่างตรงที่หากย้ายการท่องต้นไม้แบบซ้อนของ Lua มาแบบตรงตัว จะมีค่าบางส่วนหายไป
  • coro.New ของ Go สามารถอธิบายได้ด้วย channel และ goroutine เช่นกัน และ coro.Pull จะเปลี่ยน push iterator ให้เป็น pull iterator ที่ดึงค่าออกมาทีละหนึ่งทุกครั้งที่เรียกใช้
  • การติดตั้งแบบใช้ channel มีต้นทุนราว 190ns ต่อการสลับหนึ่งครั้ง ส่วนการสลับโดยตรงในรันไทม์ลดลงได้เหลือราว 20ns ต่อการสลับ และประมาณ 40ns ต่อค่าของ coro.Pull โดยมีเป้าหมายให้เร็วพอจนไม่เป็นคอขวดในการใช้งานจริง

โมเดลการทำงานของ coroutine

  • Coroutine ดูคล้ายการเรียกฟังก์ชัน แต่ทำงานอยู่บนสแตกคนละชุด และไม่ได้ทำงานพร้อมกัน
    • แม้ F จะเริ่ม G แต่ G จะยังไม่รันทันที ต้องให้ F เรียก resume อย่างชัดเจนก่อนจึงจะทำงาน
    • ระหว่างที่รันอยู่ G สามารถคืนสิทธิ์การทำงานกลับไปให้ F ได้ทุกเมื่อด้วย yield
    • เมื่อ G return ก็จะถูกเก็บกวาด และ F จะได้รับสัญญาณว่าไม่ควร resume G อีกต่อไป
  • ในโมเดลนี้ จะมี coroutine เพียงตัวเดียวในแต่ละครั้ง ที่กำลังทำงานอยู่ และผู้เรียกจะรออยู่บนอีกสแตกหนึ่ง
  • การสลับการทำงานจะเกิดขึ้นเฉพาะที่จุดบางจุดของโปรแกรมเท่านั้น จึงทำให้หลายลำดับการทำงานผลัดกันรันอย่างมีการประสานกัน

Coroutine ผ่านตัวอย่างของ Lua

  • ตัวอย่างใน Lua 5 ใช้เปรียบเทียบว่าต้นไม้ไบนารีสองต้นที่มีโครงสร้างต่างกัน มีลำดับค่าชุดเดียวกันหรือไม่
    • t1 และ t2 มีค่า 1, 2, 3, 4, 5
    • t3 มีค่า 1, 2, 3, 4, 6
  • visit(t) จะท่องต้นไม้แบบ inorder และส่งค่าแต่ละตัวออกมาด้วย coroutine.yield(t.value)
  • ฟังก์ชันเปรียบเทียบจะสร้าง visit coroutine สองตัว แล้วสลับกัน coroutine.resume เพื่ออ่านค่าถัดไป
    • ถ้าสถานะการจบของ coroutine สองตัวต่างกัน หรือค่าต่างกัน จะได้ false
    • ถ้าทั้งคู่จบพร้อมกัน จะได้ true
  • โค้ด Lua ที่เป็นสำนวนมากกว่าจะใช้ coroutine.wrap เพื่อให้ได้ฟังก์ชัน next ที่ซ่อนออบเจ็กต์ coroutine ไว้
    • เมื่อ coroutine จบแล้ว ฟังก์ชัน next จะคืนค่า nil
    • โค้ดทั้งหมดอยู่ใน Gist

ข้อจำกัดของ Python generator และ CLU iterator

  • Python generator ดูคล้าย Lua coroutine แต่ไม่ได้เป็นโมเดลเดียวกัน
  • ถ้าย้ายตัวอย่าง Lua มาเป็น Python แบบตรงตัว visit(t['left']) จะไม่ได้ท่องต้นไม้จริง แต่เพียงสร้าง generator object ขึ้นมาแล้วทิ้งไป
    • ถ้ามี yield อยู่ในเนื้อฟังก์ชัน def visit จะไม่ใช่ฟังก์ชันทั่วไป แต่เป็นตัวนิยาม generator
    • ตัวอย่างที่แปลตรง ๆ จะพิมพ์ออกมาแค่ 4 จากต้นไม้ และทำค่า 1, 2, 3, 5 หายไป
  • โค้ด Python ที่ถูกต้องต้องวน generator ชั้นในด้วยตัวเอง แล้ว yield ค่ากลับออกมาอีกครั้ง
    • yield from ใน Python 3.3 ช่วยทำให้แพตเทิร์นนี้ง่ายขึ้น
  • Python generator object เก็บสถานะของการเรียก visit ได้เพียงครั้งเดียว
    • ค่าตัวแปรโลคัลและบรรทัดที่กำลังทำงานจะถูกเก็บไว้ใน generator object
    • เมื่อ resume สถานะนั้นจะถูกดันกลับขึ้นสู่ call stack และเมื่อ yield ก็จะหลุดกลับออกมาเป็น generator object อีกครั้ง
    • yield ใช้ได้เฉพาะใน call frame บนสุดเท่านั้น
  • CLU เรียก abstraction นี้ว่า iterator และแยก iter กับ proc ออกจากกันแบบสแตติก
    • ด้วยข้อมูลชนิดข้อมูลนี้ คอมไพเลอร์สามารถวินิจฉัยการใช้ iterator ผิดแบบ เช่น เรียกเหมือนฟังก์ชันทั่วไป ได้
    • บทความปี 1977 ของ Barbara Liskov และคณะ “Abstraction Mechanisms in CLU” อธิบายว่า iterator เป็น coroutine แบบจำกัดรูปแบบ จึงติดตั้งได้ด้วย program stack เพียงอย่างเดียว

ความต่างระหว่าง coroutine, thread และ generator

  • ทั้งสามแนวคิดต่างก็ให้ concurrency ในรูปแบบหนึ่ง แต่มีทั้งพลังและต้นทุนต่างกัน
  • Coroutine

    • ให้ concurrency โดยไม่มี parallelism
    • ถ้า coroutine ตัวหนึ่งกำลังรันอยู่ coroutine ที่ resume มันขึ้นมาหรือ coroutine ที่มัน yield กลับไปให้ จะยังไม่รันอยู่พร้อมกัน
    • จุดสลับเป็นแบบชัดเจน จึงไม่เกิด race จากการใช้ข้อมูลร่วมกัน
    • การสลับอย่าง coroutine.resume หรือการเรียก next จะเป็นจุดซิงโครไนซ์ที่สร้าง happens-before edge
    • เพราะตั้งตารางการทำงานอย่างชัดเจนโดยไม่พึ่งระบบปฏิบัติการ การสลับจึงทำได้เร็วประมาณไม่เกิน 10ns
  • Thread

    • มีพลังมากกว่า coroutine และพลังที่เพิ่มมาคือ parallelism
    • สิ่งที่ต้องจ่ายคือ scheduling overhead, context switch ที่แพงกว่า และความจำเป็นของการ preempt ไม่ทางใดก็ทางหนึ่ง
    • การสลับ thread ทั่วไปอยู่ในระดับหลายไมโครวินาที
  • Go goroutine

    • ในการจัดหมวดนี้ มันใกล้เคียงกับ thread ราคาถูกมากกว่า
    • Go runtime รับหน้าที่ scheduling บางส่วน ทำให้การสลับใกล้เคียงระดับหลายร้อย ns
    • มันให้ทั้ง parallelism และ preemption แบบเดียวกับ thread
    • lightweight thread แบบใหม่ของ Java โดยพื้นฐานก็เหมือน goroutine
  • Generator

    • มีพลังน้อยกว่า coroutine
    • yield ได้เฉพาะใน top-level frame และ frame นั้นจะสลับไปมาระหว่าง object กับ call stack พร้อมการหยุดและ resume
    • Coroutine มีประโยชน์กับโค้ดที่ต้องการ concurrency เพื่อจัดโครงสร้างโปรแกรม ไม่ใช่เพื่อ parallelism
    • ตัวอย่างงานที่เกี่ยวข้องมี “Storing Data in Control Flow”, “Revisiting Coroutines”, “Design of a Separable Transition-Diagram Compiler”

กรณีใช้งานที่ Go ต้องการ coroutine

  • ไลบรารี concurrency ที่มีอยู่ใน Go ยังไม่ได้ให้แพตเทิร์น coroutine มาโดยตรง
  • Goroutine มักจะคล้ายกันมากพอ แต่เพราะมีทั้ง parallelism และ preemption จึงอาจให้ผลต่างจาก coroutine ได้
  • งานบรรยายปี 2011 ของ Rob Pike “Lexical Scanning in Go” กล่าวถึงการออกแบบ lexer และ parser ยุคแรกของแพ็กเกจ text/template
    • lexer และ parser รันอยู่คนละ goroutine และเชื่อมกันด้วย channel
    • นี่เป็นโครงสร้างที่เลียนแบบ coroutine คู่หนึ่งอย่างไม่สมบูรณ์
    • lexer จะมองโทเค็นถัดไปไว้ล่วงหน้า ขณะที่ parser กำลังจัดการโทเค็นล่าสุด
    • generator ไม่เพียงพอกับ lexer ที่ต้อง yield ค่าจากหลายฟังก์ชัน
    • parallelism ของ goroutine ทำให้เกิด race และสุดท้ายจึงเปลี่ยนไปใช้การเก็บสถานะของ lexer ไว้ใน object
    • ถ้ามี coroutine ที่เหมาะสม ก็น่าจะหลีกเลี่ยง race ได้และมีประสิทธิภาพดีกว่า goroutine
  • กรณีใช้งานในอนาคตคือ การท่องคอลเลกชันแบบ generic
    • ใน Go เคยมีการพูดคุยเรื่อง การรองรับ range บนฟังก์ชัน
    • สิ่งนี้อาจชักนำให้ผู้เขียนคอลเลกชันและ abstraction จัดเตรียมฟังก์ชัน iterator แบบ CLU
  • ใน Go ปัจจุบันก็ยังใช้ค่าฟังก์ชันเพื่อทำ push iterator ได้
    • ตัวอย่าง: func (t *Tree[V]) All(yield func(v V))
    • ตอนนี้สามารถเรียกแบบ t.All(func(v V) { fmt.Println(v) }) ได้
    • ในอนาคตอาจใช้รูปแบบ for v := range t.All ได้
  • ปัญหาคือการท่องที่ไม่เข้ากับลูป for เดียว
    • เช่นกรณีเปรียบเทียบต้นไม้ไบนารีที่ต้องสอดสองการท่องเข้าหากัน
    • Coroutine สามารถแปลง push iterator อย่าง (*Tree).All ให้เป็น pull iterator ที่คืนค่าทีละหนึ่งต่อการเรียกแต่ละครั้ง

coro.New ที่อธิบายด้วย Go ล้วนๆ

  • หากจะเพิ่มคอร์รูทีนให้ Go ก็ควรทำได้โดยไม่ต้องเปลี่ยนภาษา และควรเข้าใจและอิมพลีเมนต์ได้ด้วยโค้ด Go ปกติ
  • coro.New แบบง่ายสามารถอธิบายได้ด้วย channel และ goroutine
    • cin ใช้ส่งค่าขาเข้า
    • cout ใช้ส่งค่าขาออกกลับมา
    • resume ส่งค่าไปที่ cin แล้วรอผลลัพธ์จาก cout
    • goroutine ใหม่จะถูกบล็อกที่ <-cin ในตอนแรก จึงไม่มีโอกาสให้รันแบบขนาน
  • เมื่อเพิ่ม yield เข้าไป f จะสามารถส่งค่าระหว่างรันได้ และผู้เรียกสามารถใส่ค่ากลับเข้าไปได้ใน resume ถัดไป
    • yield(out) ส่งค่าไปที่ cout แล้วรอค่าขาเข้าถัดไปจาก cin
    • สิ่งนี้ก็ยังเป็นคู่ send-receive อยู่ดี จึงไม่มีความขนานเช่นกัน
  • รูปแบบการสื่อสารนี้จำกัดให้ goroutine ทำงานเหมือนคอร์รูทีน
    • ในความเป็นจริงมันยังเป็น goroutine แต่ resume และ yield ทำหน้าที่เป็นโอเปอเรชันสลับการทำงาน

ตัวอย่างพาร์เซอร์สตริง

  • ปัญหาใน “Storing Data in Control Flow” คือการรัน func parseQuoted(read func() byte) bool ในโฟลว์ควบคุมแยกต่างหาก แล้วป้อนไบต์ทีละตัวผ่านเมธอด Write
  • เมื่อใช้ coro.New จะเขียนได้ในระดับที่สูงกว่าการอิมพลีเมนต์ชั่วคราวด้วย channel จากบทความก่อนหน้า
    • Init กำหนดฟังก์ชัน coparse
    • read จะ yield ค่า NeedMoreInput แล้วคืนค่าไบต์ที่ผู้เรียกส่งมา
    • ผลลัพธ์แบบ boolean ของ parseQuoted(read) จะถูกแปลงเป็น BadInput หรือ Success
    • p.resume(0) จะพาให้รันต่อไปจนถึง read ครั้งแรกของ parseQuoted
    • Write(c byte) กลายเป็น wrapper แบบบางๆ ที่เรียก p.resume(c)
  • โค้ดทั้งหมดอยู่ใน Go Playground

ตัวอย่างตะแกรงจำนวนเฉพาะ

  • ตะแกรงจำนวนเฉพาะแบบทำพร้อมกัน ของ Doug McIlroy เป็นไปป์ไลน์ที่มีคอร์รูทีนหนึ่งตัวต่อจำนวนเฉพาะแต่ละค่า p
    • แต่ละฟิลเตอร์รับตัวเลขจากเพื่อนบ้านทางซ้าย และถ้าไม่หารด้วย p ลงตัวก็ส่งต่อไปยังเพื่อนบ้านทางขวา
    • counter ที่ปลายซ้ายจะป้อน 2, 3, 4, ...
    • คอร์รูทีนเอาต์พุตที่ปลายขวาจะอ่านจำนวนเฉพาะมาแสดงผล แล้วสร้างคอร์รูทีนฟิลเตอร์ตัวใหม่
  • counter เป็นฟังก์ชันที่ห่อลูปซึ่ง yield ค่าด้วย coro.New
    • more bool ใช้บอกว่าจะสร้างค่าต่อหรือไม่
    • yield(i) ส่งค่าออกไปแล้วรับคำตอบว่าจะทำต่อหรือไม่
  • filter(p, next) ดึงค่าจาก next(true) ของคอร์รูทีนทางซ้าย แล้ว yield(n) เฉพาะเมื่อ n%p != 0
  • main เก็บเอาต์พุตของไปป์ไลน์ปัจจุบันไว้ใน next
    • อ่านจำนวนเฉพาะ p
    • แสดง p
    • เพิ่มฟิลเตอร์ใหม่ทางขวาของไปป์ไลน์เพื่อคัดเลขที่เป็นพหุคูณของ p ออก
  • ความสัมพันธ์การเรียกระหว่างคอร์รูทีนสามารถเปลี่ยนระหว่างรันได้
    • yield ครั้งแรกของ counter ไปที่ main แต่ yield หลังจากนั้นจะไปที่ฟิลเตอร์ของ 2
    • เอาต์พุตครั้งแรกของแต่ละฟิลเตอร์ p จะไปที่ main ในฐานะจำนวนเฉพาะตัวถัดไป ส่วนเอาต์พุตหลังจากนั้นจะไปยังฟิลเตอร์ถัดไป
  • โค้ดทั้งหมดอยู่ใน Go Playground

ความสัมพันธ์ระหว่าง goroutine กับคอร์รูทีน

  • โฟลว์ควบคุมที่สร้างขึ้นตรงนี้ หากพูดอย่างเคร่งครัดก็คือ goroutine
    • มันทำทุกอย่างที่ goroutine ปกติทำได้ เช่น mutex, channel, การรอ system call เป็นต้น
  • coro.New สร้าง goroutine ที่สามารถใช้โอเปอเรชันสลับคอร์รูทีนภายใน yield และ resume
  • คำสั่ง go สร้างโฟลว์ควบคุมใหม่ที่ทำพร้อมกันและขนานได้ แต่ coro.New สร้างโฟลว์ควบคุมใหม่ที่ทำพร้อมกันได้แต่ไม่ขนาน
    • ถ้ารันคำสั่ง go 10 ครั้ง ก็อาจมี goroutine 11 ตัวรวม main ที่ทำงานพร้อมกันได้
    • ถ้าเรียก coro.New 10 ครั้ง ก็จะมีโฟลว์ควบคุม 11 ชุด แต่ความขนานของโปรแกรมยังเท่าเดิม และจะมีเพียงหนึ่งชุดที่รันในแต่ละครั้ง
  • goroutine ใดจะรับบทเป็นคอร์รูทีนแบบ “ไม่ขนาน” สามารถเปลี่ยนได้ระหว่างรัน
    • เรื่องนี้ก็เหมือนกับที่ระหว่างรัน goroutine ใดกำลังส่งหรือรับผ่าน channel สามารถเปลี่ยนได้

resume ที่ทนทานขึ้น

  • coro.New รุ่นแรกจะเกิด deadlock หากเรียก resume หลังจากฟังก์ชันจบไปแล้ว
  • เพื่อแก้ปัญหานี้ resume จะคืนค่า bool มาพร้อมผลลัพธ์
    • true หมายถึงผลลัพธ์มาจาก yield
    • เมื่อฟังก์ชัน return แล้ว resume จะคืนค่าที่รีเทิร์นพร้อม false
    • หากเรียก resume หลังคอร์รูทีนจบแล้ว จะคืนค่า zero value กับ false
  • ตัวแปร running ใช้ติดตามว่า f กำลังรันอยู่หรือไม่
    • resume กับคอร์รูทีนจะรันสลับกัน ดังนั้นการแชร์ running จึงไม่ใช่ race
  • ตัวอย่างจะแสดงผล "hello" true, "world" true, "done" false, "" false
    • โค้ดทั้งหมดอยู่ใน Go Playground

แปลง iterator ด้วย coro.Pull

  • coro.Pull แปลง push iterator ให้เป็น pull iterator
  • รูปแบบของ push iterator ขาเข้ามีดังนี้
    • push func(yield func(V) bool)
    • ค่า boolean ที่ yield คืนมาจะบอกว่าควรทำต่อหรือไม่
  • รูปแบบของ pull iterator เป้าหมายมีดังนี้
    • pull func() (V, bool)
    • เหมือน channel receive หรือ map lookup โดยคืนค่ากับสถานะว่าวนซ้ำจบหรือยัง
  • เพื่อรองรับการหยุดก่อนกำหนด Pull จึงคืนทั้ง pull และ stop
    • ซิกเนเจอร์เต็มคือ func Pull[V any](<https://research.swtch.com/push func(yield func(V>) bool)) (pull func() (V, bool), stop func())
  • การอิมพลีเมนต์ทำได้เพียงสร้าง wrapper เล็กๆ ที่รัน push iterator ด้วย coro.New
    • pull เรียก resume(true)
    • stop เรียก resume(false)
  • เมธอด All ของ tree ถูกปรับให้ใช้ผลลัพธ์ bool ของ yield
    • การเดินฝั่งซ้าย, การ yield ค่าปัจจุบัน, และการเดินฝั่งขวา จะเชื่อมกันด้วย && เพื่อส่งต่อการหยุดก่อนกำหนด
  • ฟังก์ชันเปรียบเทียบ tree จะสร้าง coro.Pull สองตัวแล้วเทียบค่าทีละตัว
    • ใช้ defer stop1() และ defer stop2() เพื่อหยุดคอร์รูทีนเมื่อจบก่อนกำหนด
    • ถ้าค่าหรือสถานะการจบต่างกัน ให้คืน false
    • ถ้าทั้งคู่จบพร้อมกัน ให้คืน true
  • โค้ดทั้งหมดอยู่ใน Go Playground

การส่งต่อ panic และการยกเลิก

  • panic ที่เกิดขึ้นในคอร์รูทีนสามารถส่งกลับไปยังผู้เรียกที่ resume คอร์รูทีนนั้นล่าสุดได้
    • ใน goroutine ทั่วไป ยากที่จะรู้ว่าควรแจ้ง goroutine ใด และ goroutine นั้นพร้อมรับหรือไม่
    • ในคอร์รูทีน ผู้เรียกจะรอค้างอยู่ที่ resume จึงชัดเจนว่าจะต้องส่ง panic ไปให้ใคร
  • การติดตั้งใช้งานจะใช้ cout เพื่อส่งข้อความที่บรรจุค่าหรือ panic
    • defer ของคอร์รูทีนใหม่จะจับ panic
    • resume ที่กำลังรออยู่จะ panic ซ้ำด้วยค่า panic เดิม
  • ในตัวอย่าง คอร์รูทีนจะ yield "hello" แล้ว panic ด้วย "world"
    • panic จะถูกส่งต่อไปยัง main goroutine และในสแตกจะดูเหมือนเกิดขึ้นที่การเรียก resume
    • โค้ดทั้งหมดอยู่ที่ Go Playground
  • มีการเพิ่มฟังก์ชัน cancel เพื่อแจ้งคอร์รูทีนเมื่อผู้เรียกจบการทำงานก่อนกำหนด
    • cancel คล้ายกับ resume แต่ทำให้ yield panic แทนที่จะคืนค่า
    • panic จากการยกเลิกใช้ error wrapper แบบเฉพาะที่ตรงตาม ErrCanceled
    • panic ที่ cancel เป็นผู้ก่อจะไม่ถูกส่งต่อซ้ำ แต่ถ้าคอร์รูทีนเกิด panic อื่นระหว่างการยกเลิก ก็จะส่งต่อ
    • ถ้ายังไม่เคยเรียก resume cancel จะทำให้ f ไม่ถูกรันเลย
  • สำหรับการหยุด iterator ค่า bool แบบชัดเจนเข้าใจง่ายกว่า panic ดังนั้น Pull จึงยังคงใช้การหยุดแบบอิง bool

กลับไปดูตะแกรงจำนวนเฉพาะ: การเก็บกวาดและการส่งต่อข้อผิดพลาด

  • ใน API ใหม่ counter และ filter จะคืนทั้งฟังก์ชัน resume และฟังก์ชัน cancel
  • primes(n int) จะสร้าง counter และลงทะเบียน defer cancel()
    • อ่านและพิมพ์จำนวนเฉพาะแต่ละตัว
    • ทุกครั้งที่เพิ่ม filter ใหม่ ก็จะลงทะเบียน defer ให้กับ cancel ของ filter นั้นด้วย
  • เมื่อฟังก์ชันได้จำนวนเฉพาะครบ n ตัวแล้วคืนค่า การเรียก cancel แบบหน่วงเวลาจะเก็บกวาดคอร์รูทีนที่สร้างไว้
  • ถ้าคอร์รูทีนใด panic ก็จะถูกส่งต่อไปยังคอร์รูทีนที่กำลังรออยู่
    • ถ้าเป็นคอร์รูทีนที่ primes เรียกกลับต่อโดยตรงด้วย next panic จะย้อนกลับมาที่ primes
    • ถ้าเป็นคอร์รูทีนที่ filter เรียกกลับต่อด้วย next panic จะไต่ขึ้นมาตามสายโซ่ของ filter จนถึง p := next(true) ใน primes
    • หลังจากนั้น cancel แบบหน่วงเวลาของ primes จะเก็บกวาดคอร์รูทีนที่เหลือ
  • โค้ดทั้งหมดอยู่ที่ Go Playground

รูปร่างสุดท้ายของ API

  • New จะสร้างคอร์รูทีนใหม่ที่อยู่ในสถานะหยุดพักตั้งแต่ต้น และเตรียมพร้อมรันฟังก์ชัน f
    • คอร์รูทีนใหม่นี้เป็น goroutine แต่จะไม่รันด้วยตัวเอง
    • จะรันเฉพาะตอนที่มี goroutine อื่นเรียก resume หรือ cancel และรออยู่เท่านั้น
  • resume(in) จะหยุด goroutine ของผู้เรียกแล้วสลับไปยังคอร์รูทีนใหม่
    • การเรียกครั้งแรกจะเริ่ม f(in, yield)
    • resume จะค้างรอจนกว่า f จะเรียก yield(out) หรือคืน out
    • เมื่อมีการเรียก yield resume จะคืน out, true
    • เมื่อ f คืนค่า resume จะคืน out, false
    • resume(in) ครั้งถัดไปจะทำให้ yield ที่ค้างอยู่คืนค่า in
  • cancel จะหยุดการทำงานของ f และปิดคอร์รูทีน
    • ถ้ายังไม่เคยเรียก resume เลย f จะไม่ถูกรัน
    • มิฉะนั้น yield ที่ค้างอยู่จะ panic ด้วย error ที่ตรงตาม ErrCanceled
  • ถ้า f เกิด panic ที่ recover ไม่ได้ panic นั้นจะย้ายไปยัง goroutine ที่กำลังรออยู่ใน resume หรือ cancel แล้ว panic ซ้ำด้วยค่าเดิม
    • แต่ cancel จะไม่ panic ซ้ำสำหรับ panic จากการยกเลิกที่ตัวเองเป็นผู้ก่อ
  • เมื่อ f คืนค่าหรือ panic คอร์รูทีนนั้นจะไม่มีอยู่อีกต่อไป
    • หลังจากนั้นการเรียก resume จะคืน zero value และ false
    • หลังจากนั้นการเรียก cancel จะคืนกลับทันที
  • resume, cancel, yield สามารถส่งต่อไปใช้งานใน goroutine อื่นได้
    • ผลคือ goroutine ใดจะเป็น “คอร์รูทีน” อาจเปลี่ยนไปแบบไดนามิก
  • New สร้าง goroutine ใหม่ แต่ยังคงรักษาอินวาเรียนต์ที่ว่าจะมี goroutine หนึ่งตัวค้างอยู่เสมอใน resume, cancel, yield หรือสถานะรอเริ่มต้น
    • อินวาเรียนต์นี้จะคงอยู่จนกว่า f จะคืนค่า
    • ผลลัพธ์คือ coro.New สร้าง concurrency ใหม่ แต่ไม่ได้สร้าง parallelism ใหม่
  • ซิกเนเจอร์สุดท้ายเป็นดังนี้
func New[In, Out any](f func(in In, yield func(Out) In) Out) (resume func(In) (Out, bool), cancel func())

ประสิทธิภาพ

  • ควรต้องสามารถนิยามคอร์รูทีนได้ด้วย Go ล้วน แต่สำหรับการใช้งานจริงจำเป็นต้องมีการติดตั้งในรันไทม์ที่ปรับแต่งมาแล้ว
  • บน MacBook Pro ปี 2019 coro.New ที่อิงช่องทางข้อมูลทำให้การส่งค่ากลับไปกลับมามีต้นทุนราว 190ns ต่อการสลับหนึ่งครั้ง
    • ใน coro.Pull จะเป็นประมาณ 380ns ต่อค่าหนึ่งตัว
  • coro.Pull ไม่ใช่วิธีใช้งานมาตรฐานของ iterator
    • วิธีมาตรฐานคือเรียก iterator โดยตรง ซึ่งในกรณีนั้นจะไม่มีโอเวอร์เฮดจากคอร์รูทีน
    • coro.Pull จำเป็นเมื่อไม่ได้ใช้ for ลูปเดียว แต่ต้องประมวลผลค่าแบบค่อยเป็นค่อยไป
  • ความพยายามปรับแต่งครั้งแรกคือให้คอมไพเลอร์ทำเครื่องหมายคู่ send-receive และทิ้งคำใบ้ไว้ให้รันไทม์รวมมันเป็นโอเปอเรชันเดียว
    • รันไทม์ของ channel สามารถข้าม scheduler และกระโดดตรงไปยังคอร์รูทีนอีกตัวได้
    • ใช้เวลาประมาณ 118ns ต่อการสลับ และประมาณ 236ns ต่อค่าที่ pull ออกมา
    • เร็วกว่าการติดตั้งแบบ channel เดิม 38%
  • การติดตั้งแบบที่สองหลีกเลี่ยง channel โดยสมบูรณ์ และเพิ่มการสลับคอร์รูทีนเข้าไปในรันไทม์โดยตรง
    • การสลับคอร์รูทีนลดเหลือ atomic compare-and-swap 3 ครั้ง
    • หนึ่งครั้งใช้กับโครงสร้างข้อมูลของคอร์รูทีน หนึ่งครั้งใช้กับ scheduler status ของคอร์รูทีนที่กำลังค้าง และอีกหนึ่งครั้งใช้กับ scheduler status ของคอร์รูทีนที่ถูก resume
    • ใช้เวลาประมาณ 20ns ต่อการสลับ และประมาณ 40ns ต่อค่าที่ pull ออกมา
    • เร็วกว่าแบบ channel เดิมประมาณ 10 เท่า
  • ต้นทุน 40ns ต่อค่าถือว่าเล็กพอในเชิงสัมบูรณ์จนไม่น่ากลายเป็นคอขวดในโค้ดที่จำเป็นต้องใช้ coro.Pull

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

 
GN⁺ 2023-07-18
ความคิดเห็นใน Hacker News
  • ดูเหมือนหลายคนจะพลาดประเด็นสำคัญตรงนี้ไป เป็นความจริงที่ว่าไลบรารี coroutine เป็นวิธีจัดการ concurrency ที่แย่กว่าและยุ่งยากกว่า go keyword
    กรณีใช้งานจริงที่ดึงความซับซ้อนนี้เข้ามาคือ function iterator และการทำให้สามารถใช้ range กับฟังก์ชันชนิด func() (T, bool) ได้ เรื่องนี้ถูกถกกันในชุมชน Go มานาน และน่าจะมีความหมายที่เข้าใจได้ตรงไปตรงมาสำหรับโปรแกรมเมอร์ Go ส่วนใหญ่
    บทความนี้พูดถึงปัญหาถัดมา นั่นคือถ้ามีการเพิ่ม function iterator เข้ามาในภาษาแล้ว จะเขียน iterator ที่ใช้ในลูป for อย่างไร โดยเริ่มจากข้อสังเกตว่า push iterator มักเขียนได้ง่ายกว่า แล้วขยายต่อไปเป็น push-pull adapter และ adapter นั้นก็สร้างอยู่บน coroutine
    ถ้าใส่ทั้งหมดนี้เข้ามาจริง การใช้ coroutine เพื่อจุดประสงค์อื่นนอกเหนือจาก iteration ก็ดูจะเป็นแนวปฏิบัติที่ไม่ดี คล้ายกับการใช้ channel/goroutine ในจุดที่ mutex ก็เพียงพอแล้ว

    • ก็ควรพูดด้วยว่าในบางกรณีการใช้งาน coroutine มีประสิทธิภาพสูงกว่า goroutine แบบเต็มรูปแบบมาก เพราะตอนสลับไปยัง coroutine ไม่ต้องมี context switch หรือการ reschedule
      ถ้างานสองส่วนร่วมมือกันแบบ synchronous ในเชิงตรรกะ เช่นกรณี iterator การรันทั้งหมดบน CPU เดียวกันย่อมมีประสิทธิภาพกว่ามาก ไม่ต้องให้เคอร์เนลมาคอย reschedule อะไร หรือปิดและปลุก CPU core ขึ้นมาใหม่ และข้อมูลก็ยังอยู่ใน CPU cache จึงได้ทั้ง cache latency และอัตรา hit ที่ดีขึ้น
      กับ goroutine ก็อาจเกิดแบบนั้นโดยบังเอิญได้ แต่ไม่ได้รับประกัน และอย่างน้อยก็ยังมีต้นทุนจากการต้องผ่านตัว scheduler ของ goroutine ใน Go runtime ถึงจะเร็ว แต่ก็ไม่เร็วเท่ากับการรันบริบทโค้ดอีกชุดหนึ่งภายใน goroutine เดียวกัน
      coroutine ทำให้รู้ได้ว่างาน A จะสลับไปยังงาน B โดยตรง จึงทำให้พฤติกรรมการ schedule คาดเดาได้มากกว่า ตอนท้ายบทความ Russ แสดงให้เห็นว่าการทำ coroutine ใน runtime แบบปรับแต่งแล้ว เร็วกว่า 10 เท่า เมื่อเทียบกับการเลียนแบบด้วย goroutine
      ภายใน Google ก็มี kernel patch สำหรับทำ cooperative multithreading แบบนี้อยู่ และภายในเรียกว่า fibers มันมีไว้เพื่อ latency ที่ดีกว่าและ scheduling ที่คาดเดาได้มากขึ้น Paul Turner ก็เคยมีงานพูดที่ LPC เมื่อราว 10 ปีก่อนอธิบายแรงจูงใจนี้ไว้: https://www.youtube.com/watch?v=KXuZi9aeGTw
    • ไม่เข้าใจว่าการเขียนเป็น for { next := getNext(); ... } มันมีปัญหาอะไร อยากรู้ว่าข้อดีของการเขียนเป็น for next := range getNext { ... } คืออะไร
    • coroutine ใน Go น่าจะทำให้ใช้ Go เป็นภาษาโฮสต์สำหรับนิยาม การจำลองเหตุการณ์ไม่ต่อเนื่อง ได้อย่างสวยงาม ตอนนี้การต้อง yield ให้ actor ยังดูแปลก ๆ อยู่
    • ไม่เข้าใจว่าทำไมแค่ใช้ range หรือ switch กับ channel แล้วรัน goroutine ตัวหนึ่งที่คอย push ค่าเข้า channel ถึงไม่พออยู่ดี ยังไม่ค่อยเชื่อว่าทำไมต้องมี coroutine
    • แต่สุดท้ายแล้วมันก็ดูเหมือน การยัดทุกอย่างลงอ่างล้างจาน อยู่ดี
      ครั้งนี้ไม่ได้ดูเป็นการคิดอย่างรอบคอบและหาทางออกแบบ 80/20 ที่ดี แต่ดูเหมือน “ถ้าจะทำเรื่องนี้ให้ถูกต้องก็คงต้องมี coroutine งั้นก็ใส่มาเลย” มากกว่า
      ตอนเพิ่ม generics พวกเขาใช้เวลาคิดนานมากและลึกมาก และก็ออกแบบทางประนีประนอมที่สร้างสรรค์และสมดุลได้ยอดเยี่ยม
      ในกรณีนี้ผมคาดหวังแนวทางแบบ “เพิ่มความสามารถให้ goroutine อีกอย่างหนึ่งเพื่อให้ควบคุมได้ในบางสถานการณ์” มากกว่า ซึ่งน่าจะดีกว่า “เดินหน้าเต็มรูปแบบแบบ Rust แล้วใส่เข้ามาเลย” สำหรับปัญหานี้
  • ผมใช้ Go แบบมืออาชีพมาหลายปี แต่ไม่อยากให้มันกลายเป็นอะไรทำนอง Python Twisted / Tornado / หรือเฟรมเวิร์กอื่น ๆ แบบนั้น
    go keyword ช่วยกันปัญหา function coloring ที่ค่อนข้างเจ็บปวดได้ดีมาก
    ในบริบทที่ต้องการประสิทธิภาพสูง บางครั้งก็อยากทำอย่างการ shard ข้อมูลตาม CPU core แต่ข้อเสนอนี้ไม่ได้ช่วยตอบโจทย์จุดคันแบบนั้น

    • coroutine กับ goroutine เติมเต็มช่องการใช้งานคนละแบบกัน goroutine เองก็เติมเต็มพื้นที่ที่ของอย่าง Twisted เคยครอบครองอยู่แล้ว ข้อเสนอนี้ไม่ได้มีอะไรที่จะดึง async/await แบบนั้นเข้ามาใน Go
      coroutine จะไปเติมอีกพื้นที่หนึ่งที่ใกล้กับ generator ของ Python มากกว่า ในจุดที่ต้องประกอบ pipeline จากองค์ประกอบที่นำกลับมาประกอบซ้ำได้ มันมักช่วยลดทั้งการใช้หน่วยความจำและความซับซ้อนของโค้ดได้มาก การออกแบบนี้ดูเป็นแบบ synchronous ล้วน ๆ
    • ในบทความไม่มีตรงไหนเลยที่เสนออะไรซึ่งเข้าข่าย function coloring
    • ถ้าต้องเขียน goroutine แล้วใช้ Context นั่นก็คือ function coloring แบบตรงตัวเลย
    • “ปัญหา” ของเฟรมเวิร์ก async/threading อื่น ๆ อาจอยู่ที่การเอา threading ไปซ้อนบน iterator/coroutine แต่ในกรณีนี้สองอย่างนี้เป็นคนละแกนกัน จึงอาจไม่ได้แย่อย่างที่คิด
      แน่นอนว่าแทบจะชัวร์ว่าที่ไหนสักแห่งในโลกจะมีนักพัฒนาฉลาด ๆ เอาสิ่งนี้ไปทำอะไรโง่ ๆ แล้วมันก็จะดังมาก ผมเดาว่าสุดท้ายจะมีการ รวม goroutine กับ coroutine เป็น abstraction เดียวกัน
    • อยากรู้ว่าพอจะแชร์แนวทางหรือเฟรมเวิร์กสำหรับ รูปแบบการจัดการ channel ที่ดีกว่านี้ได้ไหม
      ปกติโค้ดที่เกี่ยวกับ goroutine มักดูรกเพราะ channel และผมไม่ค่อยรู้ว่าจะทำให้มัน “สะอาด” ขึ้นได้อย่างไร ถ้ามีคำแนะนำเรื่องแพตเทิร์นที่พิสูจน์แล้วว่าดูแลรักษาได้จริง จะขอบคุณมาก
  • ระบบมัลติทาสกิงมอบโปรเซสให้เรา
    แต่มันหนักเกินไป
    เลยมีเธรดเกิดขึ้น ซึ่งก็คือโปรเซสที่แชร์ address space, file table และอย่างอื่นบางส่วนร่วมกัน ตัว scheduler สามารถสลับระหว่างเธรดได้ง่ายกว่าระหว่างโปรเซส และการแชร์ข้อมูลระหว่างเธรดก็ไม่ต้องมีการซีเรียลไลซ์
    แต่นั่นก็ยังหนักเกินไป
    เลยมี user-space thread เกิดขึ้น ซึ่งเป็นเธรดการทำงานเชิงตรรกะที่ runtime รันทั้งหมดใน user space โดย runtime จะใส่ scheduling hook ลงในฟังก์ชัน I/O ทั้งหมดของ standard library หรือ preempt logical thread ด้วย system API อย่าง Unix signal ไม่ต้องมี system-level context switch และทำให้มีขนาดเล็กมากได้
    แต่นั่นก็ยังหนักเกินไป
    เลยมี coroutine เกิดขึ้น ทำให้โปรแกรมเมอร์สามารถนิยาม “เธรด” เชิงตรรกะที่โต้ตอบกันแบบร่วมมือกันได้ โดยไม่ได้สมมติว่ามี scheduler อยู่ โปรแกรมเมอร์จะเขียน event loop เอง หรือเรียก event loop ของไลบรารีจาก logical thread “จริง”
    แล้วต่อไปจะมีอะไรมาอีกก็น่าสงสัย จากมุมมองของ [Communicating Sequential Processes][1] นี่อาจเป็นระดับต่ำสุดที่ cooperative coroutine จะลงไปได้แล้ว
    [1]: https://www.cs.cmu.edu/~crary/819-f09/Hoare78.pdf

    • coroutine เองก็ไม่ได้ช่วยให้โปรแกรมเมอร์พ้นจากความสยองของการต้องอธิบายรายละเอียดทุกอย่างที่เป็นไปได้ให้คอมพิวเตอร์เข้าใจ โดยเฉพาะต้องอธิบายให้ได้ว่าถ้าอินพุตต่างจากที่คิดไว้เพียงนิดเดียว โปรแกรมจะไม่ค้างไปตลอดกาล
    • ค่อนข้างน่าแปลกใจนิดหน่อยที่แทบไม่มีภาษาไหนพยายามทำให้โค้ดขนานกันได้โดยอัตโนมัติให้มากที่สุด แต่ขนานเฉพาะในช่วงที่ประสิทธิภาพที่วัดได้ยังได้ประโยชน์
      มันต้องมี semantics ที่รองรับ concurrency เช่น การวนซ้ำควรไม่มีลำดับตายตัวโดยปริยาย และแม้จะต้องพึ่งการวิเคราะห์ flow ของทั้งโปรแกรมด้วย แต่ก็มองไม่เห็นอุปสรรคเชิงหลักการ
      Microsoft เคยวิจัยภาษาแบบนั้นเมื่อหลายปีก่อนไม่ใช่หรือ? ยังมี ParaSail ที่คนจากชุมชน Ada สร้างด้วย สงสัยว่าโปรเจ็กต์พวกนี้ลงเอยอย่างไร หรือว่าไม่มีใครใช้เลย
    • คิดว่าคงลงไปต่ำกว่านี้ไม่ได้แล้ว แต่แทนนั้นอาจขยับขึ้นไปทาง distributed computing ได้ ถ้าจำไม่ผิด ใน Go เวอร์ชันอัลฟาช่วงแรก ๆ channel เคยทำงานข้ามเครื่องได้ด้วย
    • ขั้นต่อไปอาจเป็นรูปแบบอย่าง “ประมวลผลคอลเลกชันของงานนี้ในแบบที่คุณต้องการ” ถ้าหยีตามอง การทำงานพร้อมกันทั้งหมดก็มองเป็นลำดับของงานได้ และตัวงานเองก็อาจเป็นคอลเลกชันได้เช่นกัน ต่อให้มีงานแค่งานเดียวหรือสองงานก็เหมือนกัน
  • เคยคิดว่าหัวใจสำคัญของ green thread คือการได้ cooperative scheduling ที่ดีโดยไม่ต้องใช้คีย์เวิร์ดแบบ yield ของ Python
    มองว่าการตัดสินใจออกแบบของ Go ที่ แทรกจุดเรียกและจุดกลับมาทำงานต่อในตำแหน่งเฉพาะ เป็นทางประนีประนอมที่ดีมาก
    มันเปิดเผยการควบคุมที่ใกล้ฮาร์ดแวร์มากขึ้นเรื่อย ๆ ถึงจุดหนึ่งก็เริ่มสงสัยว่านี่กำลังสร้าง Zig ขึ้นมาใหม่หรือเปล่า ต่อไปจะเป็น garbage collector แบบเลือกเปิดได้ไหม?

    • ตรงนี้ yield กับ resume ไม่ใช่คีย์เวิร์ด แต่เป็นตัวแปรธรรมดา เป็นเพียงการอ้างถึง closure ธรรมดาที่ตั้งชื่อแบบนั้นเพื่อการสอนเท่านั้น
      จุดที่แปลกคือส่วนที่สร้างและใช้ cancel callback ไม่ได้เขียน Go มากนัก เลยไม่แน่ใจว่านี่เป็นการปรับปรุงประสิทธิภาพเพื่อเก็บกวาดสถานะของ iterator ที่ถูกทิ้งให้เร็วขึ้น หรือว่าจำเป็นเพราะ Go จะไม่ garbage collect goroutine ที่รออยู่บน channel เมื่อ goroutine นั้นเป็น reference เดียวที่เหลือไปยัง channel นั้น
      ถ้าเป็น Lua จะไม่ต้องมีสิ่งนี้ เพราะ coroutine/thread ก็ถูก garbage collect เหมือนอ็อบเจ็กต์อื่น ๆ ดังนั้นเมื่อ reference ทั้งหมดหายไป มันก็จะถูกเก็บ แม้การทำงานครั้งสุดท้ายจะเป็น yield ไม่ใช่การ return จาก entry function ก็ตาม
    • garbage collector ของ Go นั้นเลือกปิดได้ ตั้งค่า environment variable GOGC=off เพื่อปิด garbage collection ได้
      รายละเอียดเพิ่มเติมเกี่ยวกับ GOGC: https://dave.cheney.net/tag/gogc
  • น่าจะชอบให้ coroutine ถูกใส่เข้ามาเป็น การรองรับระดับภาษา มากกว่าเป็นไลบรารี
    นึกถึงอะไรประมาณ x := co func(){ var z int; for { z++; yield z } } หรือรูปแบบใกล้เคียงกัน
    การที่ทำได้ด้วย Go ล้วน ๆ ก็เจ๋งดี และก็เข้าใจเสน่ห์ของการทำเป็นแพ็กเกจ standard library ที่มี runtime แบบปรับแต่งมาให้ แทนที่จะทำให้ language spec ซับซ้อนขึ้น สุดท้ายถ้าทำได้ด้วย Go ล้วน ๆ การบูตสแตรป implementation อื่น ๆ ก็ทำได้เร็วด้วย
    ในฐานะคนที่ใช้ Go ทุกวันที่ $work ไม่ว่าจะเป็นแบบไหนก็ยินดีทั้งนั้น แต่ถ้าเลือกได้ก็ชอบแบบที่ฝังในภาษามากกว่า พื้นฐานด้าน concurrency ของ Go เป็นจุดแข็งมาโดยตลอด ก็เดินหน้าในทิศทางนั้นได้เลย

    • coroutine ต้องมีการรองรับระดับภาษา ถ้า สแตกหมด ใน coroutine ตัวหนึ่งจะทำอย่างไร?
      ถ้าเป็นทางออกแบบไลบรารี ก็ต้องฆ่า coroutine หรือไม่ก็ฆ่าโปรแกรมไปเลย ถ้าอยากให้สแตกขยายได้อย่างโปร่งใส มีแต่โค้ดที่ถูก generate เท่านั้นที่ทำได้ เพราะต้องคอยตรวจการใช้สแตกและขยายเมื่อจำเป็น เท่าที่รู้ goroutine มีสิ่งนี้อยู่แล้ว
      บางทีทางออกแบบไลบรารีอาจใส่ guard page ไว้ที่ปลายสแตกได้ ถ้าไปถึงตรงนั้น ตัวจัดการข้อผิดพลาดก็อาจลองขยายสแตกได้ แต่ถ้ามีการเก็บ pointer ไปยังตัวแปรบนสแตกไว้ มันก็คงใช้ไม่ได้
    • อยากเพิ่ม พารามิเตอร์ yield ให้กับฟังก์ชันมากกว่า เพื่อให้ทำงานร่วมกับ type system ปัจจุบันได้ดีกว่า
      ถ้าฟังก์ชันจะ yield ค่า ก็ควรต้องมีพารามิเตอร์ yield ที่ว่า และควรอนุญาตให้ yield ได้เฉพาะภายในฟังก์ชันที่มี yield signature แบบเดียวกัน หรือฟังก์ชันที่ไม่มี signature เท่านั้น
      ตัวอย่างอาจเปลี่ยนเป็น x := func(:z int) { for { z++; :- z } } โดย : ใช้เพิ่ม yield signature และ :- ใช้ yield ค่า
      ฟังก์ชันที่ yield แค่ค่า X สามารถมีแค่ : X หรือ : name X ก็พอ ถ้าจะรับค่า type Y ตอน resume signature ก็จะเปลี่ยนเป็น :[Y] X หรือ :[Y] name X
      การยอมรับควรเป็นแบบหลวม ๆ ในจุดที่คาดหวังฟังก์ชันที่ resume ด้วย Y และ yield X ก็ควรรับฟังก์ชันที่ yield แค่ X โดยไม่มี resume ได้ด้วย
      ถ้าฟังก์ชันพิเศษของแพ็กเกจ co ให้ความสามารถแบบ resume และ New ก็ยังคงสไตล์ของ Go ได้ ไวยากรณ์ range ก็อาจขยายให้ส่งค่า resume ด้วย -: ได้ และถ้าไม่ส่งค่า ก็ให้ resume ด้วยค่า zero value โดยปริยาย
    • อันนี้แย่มากจริง ๆ ไม่เป็นธรรมชาติเลยแม้แต่น้อย และก็ไม่เข้ากับหนึ่งในจุดแข็งของ Go ด้วย ถ้าจะเขียนหรืออ่านโค้ดแบบนี้ คงต้องไปเรียนรู้ semantics ของ coroutine ใน Go แยกต่างหาก
  • ไม่ค่อยชอบเท่าไร ดูจากตัวอย่างแล้วเหมือนทำให้ภาษาอ่านและตามได้ยากขึ้นมาก แน่นอนว่าอาจเป็นเพราะสมองและอคติของผมเองก็ได้
    แถมก็ไม่ได้ดูเหมือนจะทำให้สิ่งที่ตอนนี้ทำด้วย blocking channel หรือ state ทำไม่ได้ กลายเป็นทำได้ขึ้นมาด้วย

    • ก็ตรงตามที่พูดเลย คนที่ปกป้องสิ่งนี้ดูเหมือนจะมองเห็นได้แค่ตรงหน้า
    • ไม่แน่ใจว่าหมายถึง การเปลี่ยนภาษา แบบไหน นี่ก็เป็นแค่ข้อเสนอเพื่อทำให้สิ่งที่คนทำกันอยู่แล้วด้วย “state” เป็นเรื่องปกติและมีประสิทธิภาพขึ้น
      ผมเคยใช้ iterator ที่คล้ายกับในบทความนี้เพื่อหลีกเลี่ยงการจัดสรรหน่วยความจำบนเส้นทางโค้ดหลัก วิธีนี้จะทำให้โค้ดแบบนั้นดูขัดตาน้อยลงมาก โดยเฉพาะถ้ามาพร้อมกับการเปลี่ยนภาษาเรื่อง iterator ของ range ที่กำลังจะเข้ามา
    • การมี implementation ของ iterator ที่ หลายสิบแบบ และเข้ากันไม่ได้เหมือนใน standard library ตอนนี้ ก็ไม่ได้ง่ายกว่าเหมือนกัน
      channel ช้าลงแบบไม่มีเหตุผลนักเมื่อไม่ได้ใช้เพื่อห่อการทำงานแบบ blocking
  • อ่านคอมเมนต์แล้วรู้สึกขมขื่น
    หลายคนมองว่า coroutine กับ green thread แทบจะเป็นสิ่งเดียวกัน แต่จริง ๆ แล้วทั้งคู่ต่างก็มีข้อดีข้อเสียของตัวเอง
    มันน่าเศร้าที่การไม่มี iterator ใน Go community กลับเป็นสิ่งที่ยอมรับกันได้ เหมือนกับว่าพวกเขาตั้งใจปฏิเสธฟีเจอร์ใด ๆ ที่อาจทำให้ภาษาซับซ้อนขึ้นแม้แต่นิดเดียว โดยอ้างเรื่องความเรียบง่าย อย่างน้อยพวกเขาก็ยอมถอยเรื่อง generics แล้ว
    ยิ่งทำให้ผมรู้สึกอีกครั้งว่า Go ไม่ใช่ภาษาของผม

    • ผมใช้ Go ทุกวัน แต่พูดตรง ๆ ว่า generics แทบไม่ได้เปลี่ยนโค้ดของผมเลย
      ตอนนี้ก็ใช้บ้าง เพราะความสะดวกทางไวยากรณ์ที่จุดเรียกใช้นั้นดีขึ้นเล็กน้อย ที่จุดประกาศก็ยังหน้าตาไม่สวยอย่างที่คาดไว้ เพียงแต่ไวยากรณ์ของ Go ก็ยังเกือบจะดีที่สุดเท่าที่ผมเคยเห็นมาเมื่อเทียบกับภาษาอื่น
      สุดท้ายผมมองว่าพวกเขาเสียความเรียบง่ายไปไม่น้อย เพื่อหยุดคำบ่นว่า “ไม่มี generics” ซึ่งไม่ใช่การแลกเปลี่ยนที่ดีนัก
    • อย่าสับสน HN กับ “Go community” คนที่เขียนบทความนี้คือหัวหน้าทีม Go
      package coro ที่เสนอในบทความจะถูกเพิ่มเข้ามาตามนั้นเลยไหม? ก็เป็นไปได้ แต่อาจจะไม่เหมือนเดิมเป๊ะ ๆ จะมีอะไรคล้าย ๆ กันถูกเพิ่มเข้ามาไหม? ถ้าให้เดิมพัน ผมว่ามี
      จะใช้เวลานานแค่ไหน? อย่างน้อย 1 ปี คือราว ๆ Go 1.23 ที่ออกในเดือนสิงหาคม 2024 อาจจะนานกว่านั้นอีกหน่อย แต่ผมว่าเร็วไปกว่านั้นมากคงยาก
    • ไม่ได้มีการยอมถอยเรื่อง generics
      ที่ generics ถูกชุมชนยอมรับได้ ก็เพราะมัน เข้ากันได้ย้อนหลัง กับโค้ดเดิมอย่างสมบูรณ์ และถ้าไม่ต้องการก็สามารถมองข้ามได้อย่างปลอดภัย
      ซึ่งก็ไม่เหนือความคาดหมาย โค้ด Go ส่วนใหญ่ยังคงเป็นแบบนั้นอยู่ นอกจาก “collection” บางประเภทแล้ว การใช้งาน generics ที่เป็นประโยชน์จริง ๆ ก็หาได้ไม่ง่ายนัก แต่เดิมโค้ดส่วนใหญ่ก็แทบไม่เคยต้องเจอมากกว่าหนึ่ง type อยู่แล้ว และถ้าเกินสอง type ก็ยิ่งพบได้น้อย
      ตรงกันข้าม การเพิ่ม generics กลับแสดงให้ชุมชนใหญ่ขึ้นเห็นว่า ฟีเจอร์ที่ถูกยกย่องและถูกเรียกร้องว่า “จำเป็น” หลายอย่างนั้น แท้จริงแล้วจำเป็นน้อยกว่าที่คิดมาก แม้ในภาษาที่ดีและได้รับการยอมรับอย่างกว้างขวาง
    • generics ไม่ได้เปลี่ยนโค้ดเบสของผมถึง 20% ด้วยซ้ำ และใน 20% นั้นก็มีส่วนที่เป็นไลบรารีอยู่ด้วย ไม่แน่ใจว่านี่เป็นนิสัยแบบ Go หรือแบบ C แต่ generics สำหรับผมดูเหมือนเป็นไลบรารีอีกตัวหนึ่งที่โปรแกรมเมอร์ที่ไหนสักแห่งแก้ปัญหาบางอย่างไว้ให้แล้ว
    • ถ้าเป็น Go community ผมนึกว่าจะยินดีกับ อินเทอร์เฟซ iterator ที่เป็นหนึ่งเดียว
  • การที่ตอนนี้เพิ่งหันมาสนใจภาษาโปรแกรมอย่าง CLU ก็ดูเป็นเรื่องดี
    แต่อีกด้านหนึ่ง จากประสบการณ์ที่เคยใช้ coroutine ของ .NET และ C++ รวมถึง Active Object ของ Symbian C++ และ Active Oberon ผมก็ยังไม่แน่ใจว่ามันคุ้มจะเพิ่มสิ่งนี้เข้า Go จริงหรือไม่
    อย่างที่ทีม .NET ก็ยอมรับในงาน BUILD ปีนี้ ถ้าย้อนเวลาได้ ก็น่าจะดีกว่าถ้าปล่อยให้ runtime จัดการแบบสไตล์ Go เพราะนักพัฒนาจำนวนมากยังคงมีปัญหาในการทำความเข้าใจ async/await

  • ไม่แน่ใจว่านี่จำเป็นจริงไหม กรณีส่วนใหญ่ใน Go จัดการด้วย goroutine ก็เพียงพออยู่แล้ว และในแง่ความหมายของ yield/resume แค่มี blocking channel 2 ตัว ก็พอ
    มันดูเหมือนเพิ่มความซับซ้อนเพื่อความซับซ้อน และก็ยังไม่ชัดด้วยว่ามันเพิ่มความสามารถใหม่ที่ Go ไม่มีอยู่แล้วจริงหรือเปล่า

    • goroutine กับ channel เพิ่ม overhead อย่างมหาศาล เอามาใช้เป็น iterator แทบจะไม่มีเหตุผลเลย
    • เรื่องนี้มีพูดไว้ในบทความแล้ว
  • เพื่อใช้เป็นตัวเทียบ ในงานนำเสนอเมื่อไม่นานมานี้ มีเดโม Elixir (BEAM VM) 1 ล้านเธรด โดยสั่งให้ทุกตัวได้รับข้อความ "Hello!" แล้วให้แต่ละเธรดรอเวลาสุ่มระหว่าง 0 ถึง 2 วินาที ก่อนจะส่งกลับ "Process received message !"
    พร้อมกันนั้นก็เปิด Erlang observer ไว้ข้าง ๆ เพื่อดูการใช้ CPU และหน่วยความจำ รวมถึงดูว่าหลังเก็บกวาด garbage แล้วระบบฟื้นกลับมาได้เร็วแค่ไหน
    คอขวดที่ใหญ่ที่สุดตรงนี้คือความสามารถของเทอร์มินัลในการตามให้ทัน แต่ observer ก็ดูจะสะท้อนสถานการณ์จริงได้ค่อนข้างแม่นยำ
    https://www.youtube.com/watch?v=yxyYKnashR0
    โค้ดที่ใช้: https://gist.github.com/pmarreck/4cc8f2f55a561ebce2012085a3a...
    ความสามารถแบบนี้มีอยู่ใน Erlang มาตั้งแต่ยุค 1980s แล้ว และดังนั้นก็มีอยู่ใน Elixir ด้วย คุณอาจเคยได้ยินเรื่อง actor model หรือการติดตั้งใช้งาน Erlang อัน “ระดับตำนาน” มามาก แต่ไม่แน่ใจว่ามีสักกี่คนที่เคยเห็นมันทำงานจริงพร้อมเปิดเครื่องมือตรวจสอบประกบไปด้วย
    คงจะดีถ้า Go มีการรองรับระดับภาษาแบบนี้ แต่การจะไล่ทันก็คงยากมาก เพราะ implementation ของเธรดใน BEAM VM มีประสิทธิภาพด้านทรัพยากรอย่างสุดขีด ทั้งตอนสร้างและตอนรันไทม์ แถมยังผสานเข้ากับความง่ายของงาน concurrent ที่มาจากการอนุญาตให้มีแต่ immutable value เท่านั้นด้วย