3 คะแนน โดย GN⁺ 2024-07-05 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • ไลบรารีการเชื่อมต่อ Rust ของ Firezone ชื่อ connlib จัดการการเชื่อมต่อเครือข่ายและท่อส่งข้อมูล WireGuard โดยใช้การออกแบบแบบ sans-IO เพื่อให้ทดสอบได้รวดเร็วและมีความน่าเชื่อถือในการทำงานสูง
  • โปรโตคอลถูกนำไปใช้เป็น state machine แบบบริสุทธิ์ โดยไม่จัดการซ็อกเก็ตโดยตรง และ event loop จะเรียก API อย่าง handle_input, poll_transmit, handle_timeout, poll_timeout
  • เมื่อผลักการเลือก IO ออกไปไว้นอก state machine ก็จะลดภาระของ function colouring ใน async ของ Rust และเปิดให้แอปพลิเคชันเป็นผู้เลือกเองว่าจะใช้ blocking IO, non-blocking IO หรือ async runtime แบบใด
  • เมื่อทำให้ซ็อกเก็ตและเวลาเป็นนามธรรม ก็สามารถตรวจสอบ การผ่านไปของเวลา การสูญหายของแพ็กเก็ต และการตอบกลับที่ผิดปกติ ได้ด้วยแค่ Instant และ Transmit โดยไม่ต้องใช้พอร์ตจริงหรือรอเวลาจริง
  • แต่ก็ต้องแลกมากับการต้องจัดการ event loop เอง ซึ่งอาจทำให้เกิดบั๊กที่ละเอียดอ่อนได้, เวิร์กโฟลว์แบบลำดับจะทำให้โค้ด state machine ยาวขึ้น และ ไลบรารีแบบ sans-IO ใน ecosystem ของ Rust ก็ยังมีไม่มากนัก

โครงสร้าง sans-IO ที่ Firezone เลือกใช้ใน connlib

  • Firezone ใช้ Rust เพื่อสร้างระบบ remote access ที่ปลอดภัยและขยายได้บน Android, macOS และ Linux
  • ใจกลางของแต่ละแอปคือ connlib ซึ่งเป็นไลบรารีที่จัดการการเชื่อมต่อเครือข่ายและ ท่อส่งข้อมูล WireGuard เพื่อปกป้องทราฟฟิก
  • สแตก Rust ของ Firezone ใช้ tokio, tungstenite, boringtun, rustls ฯลฯ แต่โครงสร้างภายในต่างจากโค้ด async Rust ทั่วไป
    • แทบไม่มีการเรียก tokio::spawn
    • การสื่อสารทั้งหมดถูกมัลติเพล็กซ์ผ่าน UDP socket เพียงตัวเดียว
    • มีการใช้ API อย่าง handle_timeout, poll_transmit, handle_input ซ้ำในหลายชั้น
  • ลักษณะเหล่านี้เป็นสัญญาณของ การออกแบบแบบ sans-IO ซึ่ง logic ของโปรโตคอลจะไม่ทำ IO เองโดยตรง แต่จะแสดงสถานะและเจตนาของอินพุต/เอาต์พุตแทน
  • ใน ecosystem ของ Python มี เว็บไซต์เอกสาร สำหรับ sans-IO โดยเฉพาะ ส่วนใน Rust มีไลบรารีต่อไปนี้ที่ใช้แพตเทิร์นนี้
    • quinn: implementation ของ QUIC แบบแยกอิสระ
    • quiche: implementation ของ QUIC จาก Cloudflare
    • str0m: implementation ของ WebRTC แบบ sans-IO

async Rust กับภาระของ function colouring

  • ฟังก์ชัน async ใน Rust เรียกได้จากภายในฟังก์ชัน async อื่นเท่านั้น จึงเกิดข้อจำกัดของ function colouring ที่ทำให้ทั้งสายการเรียกต้องกลายเป็น async ไปด้วย
  • ข้อจำกัดนี้เป็นการบังคับในระดับ compile time ว่าความสามารถในการหยุดการทำงานชั่วคราวและกลับมาทำต่อภายหลังนั้นเป็นส่วนหนึ่งของสัญญา API ของฟังก์ชัน
  • ฟังก์ชัน async เพียงตัวเดียวที่อยู่ลึกในสแตก อาจทำให้ฟังก์ชันอื่น ๆ ตามเส้นทางการเรียกต้องกลายเป็น async เพื่อใช้ .await
  • งาน async จริง ๆ มักเกิดขึ้นที่ส่วนล่างสุดของ call stack
    • การเขียนลงซ็อกเก็ต
    • การอ่านไฟล์
    • การรอให้เวลาผ่านไป
  • ฟังก์ชัน async จำนวนมากไม่ได้ทำงานแบบอะซิงโครนัสด้วยตัวเองโดยตรง แต่ต้องเป็น async เพราะพึ่งพาฟังก์ชัน async อื่น
  • connlib ของ Firezone ใช้ ICE เพื่อทำ NAT traversal และใช้ STUN เพื่อค้นหา server-reflexive candidate หรือก็คือที่อยู่สาธารณะ
  • STUN binding เป็นโปรโตคอลง่าย ๆ ที่ส่งแพ็กเก็ต UDP ไปยังเซิร์ฟเวอร์ แล้วรับการตอบกลับ UDP ที่มี IP และพอร์ตที่เซิร์ฟเวอร์มองเห็น
  • ตัวอย่าง STUN เดียวกันนี้สามารถเขียนด้วย UdpSocket แบบ async ของ tokio หรือด้วย blocking IO ของ standard library ได้โดยแทบไม่ต่างกัน
  • หากจะให้ความสามารถ STUN ในรูปแบบไลบรารี ก็จะเกิดความซ้ำซ้อนจากการต้องเลือกระหว่างเวอร์ชัน async กับ blocking หรือรองรับทั้งสองแบบ
  • โค้ดตัวอย่างอยู่ที่ firezone/sans-io-blog-example

แก่นของ sans-IO คือการแยกนโยบายออกจาก implementation ของ IO

  • หัวใจของ sans-IO คล้ายกับ หลักการกลับทิศทางการพึ่งพา ในเชิง object-oriented
  • โค้ดเชิงนโยบายที่ตัดสินใจว่า “จะทำอะไร” ไม่ควรพึ่งพารายละเอียด implementation ที่จัดการว่า “จะทำอย่างไร”
  • หากโค้ดที่ตัดสินใจจะส่งข้อความเครือข่ายไปผูกติดกับโค้ดส่งผ่านซ็อกเก็ตจริงโดยตรง โค้ดชั้นบนก็จะถูกผูกไปกับการเลือก async หรือ blocking IO ด้วย
  • ในตัวอย่าง STUN โค้ดเชิงนโยบายเหมือนเดิม แต่ถ้าประกอบบน tokio::UdpSocket ก็จะเป็น async และถ้าประกอบบน std::net::UdpSocket ก็จะเป็น blocking IO
  • sans-IO จะไม่เรียก UdpSocket::send โดยตรง แต่สร้าง นามธรรม ที่แสดงเจตนาของการส่งข้อมูลแทน
  • Transmit ในตัวอย่างเก็บข้อมูลดังนี้
    • ปลายทาง SocketAddr
    • payload ที่จะส่ง
  • โค้ดของโปรโตคอลจะไม่เขียนลงซ็อกเก็ตโดยตรง แต่จะปล่อย Transmit ออกมา
  • การเรียก UdpSocket::send หรือ send_to จริงจะเป็นหน้าที่ของ event loop
  • โค้ด sans-IO ต้องถูกขับเคลื่อนโดย event loop เช่นเดียวกับที่ Future ใน Rust ต้องถูก poll โดย runtime จึงจะเดินหน้าต่อได้

เปลี่ยน STUN binding ให้เป็น state machine

  • คำขอ STUN binding สามารถจำลองเป็น state machine ที่มีสถานะ Sent และ Received
  • สถานะในตัวอย่างแทนด้วย enum ต่อไปนี้
    • Sent
    • Received { address: SocketAddr }
  • StunBinding มีสถานะปัจจุบันและคิวของ Transmit ที่รอส่ง
  • API หลักมีบทบาทชัดเจน
    • handle_input: ส่งแพ็กเก็ตที่เข้ามาจากผลของ UdpSocket::recv เข้าไปยัง state machine
    • poll_transmit: ให้ event loop ดึง Transmit ที่ state machine ต้องการส่งออกไป
    • public_address: ใช้ดูที่อยู่สาธารณะที่ได้รับ
  • ในโครงสร้างนี้ logic ของโปรโตคอลจะจำลองเฉพาะพฤติกรรมของโปรแกรมโดยไม่มี IO
  • event loop จะส่งแพ็กเก็ตผ่านซ็อกเก็ตเมื่อ poll_transmit มีข้อมูลให้ส่ง และถ้าไม่มี ก็จะอ่านข้อมูลจากซ็อกเก็ตแล้วส่งต่อให้ handle_input
  • event loop สามารถทำงานได้โดยไม่ต้องรู้รายละเอียดว่า STUN เป็นโปรโตคอลแบบ request-response
  • UDP เป็นโปรโตคอลที่ไม่เชื่อถือได้ จึงอาจเกิดการสูญหายของแพ็กเก็ต และ STUN ต้องใช้ตัวจับเวลา retransmission เพื่อบรรเทาปัญหานี้

ทำเวลาให้เป็นนามธรรมด้วย

  • ในโปรโตคอลเครือข่าย กรณีที่ต้องใช้เวลาปัจจุบัน ส่วนใหญ่คือการตรวจว่าผ่านไป นานเท่าไรแล้ว นับจากจุดอ้างอิงบางจุด
    • ผ่านไป 5 วินาทีหลังส่งคำขอหรือยัง
    • ผ่านไป 30 วินาทีหลัง keep-alive ครั้งล่าสุดหรือยัง
  • ในกรณีเช่นนี้ ไม่จำเป็นต้องใช้เวลาแบบ wall clock จริง แต่ต้องการแค่ Duration เมื่อเทียบกับเวลาจุดก่อนหน้า
  • Instant ของ Rust ไม่เปิดเผยเวลาปัจจุบัน แต่ให้วัด Duration ระหว่าง Instant สองค่าได้
  • state machine แบบ sans-IO สามารถมี API สองตัวสำหรับพฤติกรรมที่อิงเวลา
    • poll_timeout: คืนเวลาที่ event loop ควรตั้ง wake-up timer ครั้งถัดไป
    • handle_timeout: แจ้ง state machine ว่า timer หมดเวลาแล้ว
  • ตัวอย่างได้ขยาย state machine ให้ส่ง binding request ใหม่เมื่อผ่านไป 5 วินาทีหลังได้รับการตอบกลับครั้งล่าสุด
  • handle_input รับ Instant ปัจจุบันมาพร้อมแพ็กเก็ต และเก็บในรูปแบบ State::Received { address, at }
  • event loop จะจัดการทั้งการรับข้อมูลจากซ็อกเก็ตและการหมดเวลาของ timer แล้วตั้ง timer ใหม่ตามผลของ poll_timeout

การประกอบรวมและความยืดหยุ่นของ API

  • API หลักของ StunBinding ได้แก่ handle_timeout, handle_input, poll_transmit, poll_timeout ไม่ได้เฉพาะเจาะจงกับ STUN เท่านั้น
  • โปรโตคอลเครือข่ายส่วนใหญ่สามารถนำไปใช้ในรูปแบบนี้หรือแบบดัดแปลงได้ ทำให้ การประกอบ state machine เข้าด้วยกัน ทำได้ง่าย
  • หากต้องการถามหา public IP จาก STUN server 5 ตัว ก็สามารถสร้าง StunBinding 5 ตัวและเรียกใช้งานตามลำดับได้
    • ในกรณีนี้ต้อง implement การมัลติเพล็กซ์ข้อความ STUN ให้เหมาะสม โดยอาจใช้ TransactionId หรือที่อยู่เซิร์ฟเวอร์
  • snownet ของ Firezone รวม ICE และ WireGuard เข้าด้วยกัน เพื่อมอบ IP tunnel ที่ทำงานได้ในสภาพแวดล้อมเครือข่ายที่หลากหลายให้กับแอปพลิเคชัน
  • snownet ถูกสร้างบนไลบรารี WebRTC แบบ sans-IO อย่าง str0m และ implementation ของ WireGuard ที่เกือบเป็น sans-IO อย่าง boringtun
  • Firezone ไม่ต้องการสแตก WebRTC ทั้งชุด แต่ต้องการเพียง IceAgent ที่ implement RFC 8445
  • เพราะ str0m เป็นแบบ sans-IO จึงหยิบเฉพาะ IceAgent มาแล้วประกอบเข้ากับ state machine ของโค้ดเดิมได้ง่าย
  • connection ของ snownet จะเก็บทั้ง IceAgent และ WireGuard tunnel และส่งต่อข้อความที่เข้ามาไปยังอย่างใดอย่างหนึ่ง

ข้อดีของการเขียน event loop เอง

  • โค้ด sans-IO แสดงเพียงสถานะของระบบและไม่ก่อให้เกิด side effect ดังนั้น event loop จึงต้องคอยถามสถานะ ดำเนินการ และส่งอินพุตใหม่เข้าไป
  • โครงสร้างนี้อาจดูเหมือน boilerplate แต่การเขียน event loop เองทำให้ควบคุมรายละเอียดได้อย่าง ละเอียดมาก
  • แอปพลิเคชันสามารถเลือกวิธีการทำงานเองได้ เช่น
    • ใช้ sendmmsg เพื่อลดจำนวน system call เวลาส่งแพ็กเก็ต
    • มัลติเพล็กซ์หลายโปรโตคอลบนซ็อกเก็ตตัวเดียว
  • ผู้พัฒนาไลบรารีจึงสามารถโฟกัสกับการ implement ฟีเจอร์ของโปรโตคอล แทนที่จะต้องเข้าไปอยู่ในข้อถกเถียงเรื่อง async runtime หรือการเปิด API สำหรับ socket option
  • str0m มองว่าการ enumerate network interface เป็นเรื่องของ IO จึงปล่อยให้แอปพลิเคชันจัดการเอง
  • โดยให้เพียง API สำหรับเพิ่ม socket address เข้าไปในสถานะปัจจุบันในฐานะ ICE candidate
  • Firezone ใช้โครงสร้างนี้ทำ optimization โดยเก็บ TURN candidate ล่วงหน้าก่อนสร้างการเชื่อมต่อ เพื่อลด latency ของการตั้งค่าการเชื่อมต่อ
  • ใน ICE ทั้งสองฝั่งจะรวบรวม candidate หรือก็คือซ็อกเก็ต แล้วทดสอบการเชื่อมต่อระหว่างกัน

การทดสอบที่รวดเร็วและการตรวจสอบ edge case

  • โค้ด sans-IO โดยธรรมชาติไม่มี side effect จึงเหมาะกับ unit test มาก
  • เมื่อทำให้ซ็อกเก็ตและเวลาเป็นนามธรรมแล้ว การทดสอบก็ไม่ต้องเปิดพอร์ตจริงหรือรอเวลาจริง
  • หากต้องการทดสอบพฤติกรรมหลังผ่านไป 5 นาที ก็เพียงส่ง Instant ที่เปลี่ยนไปเข้าไปในฟังก์ชัน แล้วตรวจสอบการเปลี่ยนแปลงของสถานะ
  • Firezone มีตัวอย่างจริงของการทดสอบว่า snownet ปิด idle connection หลัง 5 นาทีหรือไม่
  • การส่งข้อมูลก็ไม่ต้องผ่านซ็อกเก็ตจริง เพียงดึง Transmit จากฝั่งหนึ่งแล้วส่งเข้า handle_input ของ state machine อีกฝั่ง
  • Firezone ได้ implement reference state machine เพื่อแสดงว่า connlib ควรทำงานอย่างไร
  • reference state machine นี้ใช้เป็นเกณฑ์ในงานทดสอบ
  • โดยใช้ state machine testing ของ proptest เพื่อสุ่มและรันหลายพันสถานการณ์อย่างกำหนดผลได้ในทุก CI แล้วเปรียบเทียบสถานะจริงของ connlib กับ reference state machine
  • เมื่อไม่มี IO ก็สามารถทดสอบความล้มเหลวและพฤติกรรมผิดปกติต่อไปนี้ได้ง่ายเช่นกัน
    • แพ็กเก็ตสูญหายจนไม่ได้รับการตอบกลับ
    • ได้รับการตอบกลับที่ไม่ถูกต้อง
    • RTT ไปยังเซิร์ฟเวอร์ยาวมาก
    • ไม่มี IPv6 interface ที่ใช้งานได้
    • มีเฉพาะ IPv6 interface เท่านั้น
  • เมื่อแยก implementation ของโปรโตคอลออกจาก side effect ของ IO จริง การตรวจจับและจัดการข้อผิดพลาดก็จะกลายเป็นส่วนหนึ่งของการประมวลผลอินพุตใน state machine

ทำไม Rust จึงเข้ากับ sans-IO ได้ดี

  • Rust บังคับให้ระบุอย่างชัดเจนว่าคอมโพเนนต์หรือฟังก์ชันใดเป็นเจ้าของค่าใด
  • เมื่อต้องอ่านจาก UdpSocket จำเป็นต้องส่ง &mut [u8] เข้าไปเป็นพื้นที่สำหรับรับไบต์จริง
  • มีเพียงเจ้าของค่าเท่านั้นที่สามารถทำให้ค่านั้น mutable หรือส่ง mutable reference ชั่วคราวให้ฟังก์ชันอื่นได้
  • โมเดลเรื่อง ownership และ mutability ที่ชัดเจนนี้เป็นรากฐานของฟีเจอร์อย่าง borrow checker ใน Rust
  • API ของ state machine ในการออกแบบแบบ sans-IO ล้วนเป็นฟังก์ชัน synchronous และไม่ block เพื่อรอ IO หรือเวลา
  • เพราะ state machine เป็นเพียง data structure จึงสามารถใช้ &mut self เพื่อแสดงการเปลี่ยนสถานะได้ง่าย และใช้ borrow checker เพื่อรับประกันความ soundness ของโค้ด
  • ในทางกลับกัน &mut อาจรู้สึกจัดการได้ยากกว่าใน async Rust
  • ฟังก์ชัน async ของ Rust จะถูก compile ให้เป็น data structure ที่ implement Future
  • หากต้องการ spawn Future นี้บน runtime อย่าง tokio data structure ดังกล่าวต้องเป็น 'static จึงไม่สามารถมี reference อย่าง &mut อยู่ภายในได้
  • หากต้องการเปลี่ยนสถานะที่อยู่นอก Future มักต้องใช้วิธีใดวิธีหนึ่งต่อไปนี้
    • reference-counted pointer และ mutex อย่าง Arc<Mutex<T>>
    • แนวทาง actor ที่ spawn หลาย task แล้วเชื่อมด้วย channel
  • ทั้งสองวิธีมี runtime overhead
    • lock อาจก่อให้เกิด contention
    • การส่งข้อความผ่าน channel ต้องมีการคัดลอกข้อมูล
  • เมื่อหลาย task ทำงานใน runtime ด้วยลำดับที่ไม่กำหนดแน่นอน ก็อาจนำไปสู่ race condition และ deadlock ได้
  • โค้ดโปรโตคอลแบบ sans-IO ไม่ spawn task จึงต้องใช้เพียง &mut self สำหรับการเปลี่ยนสถานะ
  • เมื่อไม่มี task หรือ thread ก็ไม่จำเป็นต้องใช้ primitive สำหรับ synchronization อย่าง Mutex และเมื่อไม่มี channel ก็ลดความจำเป็นในการคัดลอกข้อมูลได้เช่นกัน
  • Firezone มองว่าหลังจากย้ายมาใช้ sans-IO แล้ว งานอย่างการตามหาอีกฝั่งของ channel, channel ที่ถูกปิด, หรือโค้ดส่วนใดที่กำลัง lock Mutex ลดลง ทำให้เข้าใจโค้ดได้ง่ายขึ้น

ข้อเสียและขอบเขตการใช้งาน

  • sans-IO ไม่ใช่คำตอบสารพัดปัญหา
  • การเขียน event loop เองให้การควบคุมที่สูง แต่ก็อาจก่อให้เกิด บั๊กที่ละเอียดอ่อน ซึ่งหาพบได้ยากในช่วงแรก
  • ตัวอย่างเช่น หากค่าที่ poll_timeout ของ state machine คืนมากลับไม่ขยับไปข้างหน้า event loop อาจตกอยู่ใน busy loop ได้
  • เวิร์กโฟลว์แบบลำดับต้องใช้โค้ดมากขึ้น
  • ฟังก์ชัน async ของ Rust จะถูก compile เป็น state machine ที่แต่ละจุด .await คือการเปลี่ยนไปยังอีกสถานะหนึ่ง ทำให้นักพัฒนาสามารถเขียน non-blocking IO ควบคู่กับโค้ดแบบลำดับได้สะดวก
  • ใน sans-IO ขั้นตอนเหล่านี้ต้องถูกจำลองเป็น state machine ด้วยตนเอง
  • โปรโตคอลแบบ request-response อย่าง StunBinding ไม่ได้ยากนัก แต่ถ้าต้องแสดงเวิร์กโฟลว์แบบลำดับที่ใหญ่ขึ้นก็อาจกลายเป็นงานน่าเบื่อได้
  • การออกแบบแบบ sans-IO ยังไม่แพร่หลายในชุมชน Rust มากนัก
  • ไลบรารีส่วนใหญ่ implement แบบ blocking IO หรือ non-blocking IO แทน sans-IO
  • boringtun มีบางส่วนที่ไม่บริสุทธิ์นักเพราะเรียก Instant::now ภายใน และมี issue ที่เกี่ยวข้องอยู่ใน cloudflare/boringtun#391

สรุป

  • โค้ดแบบ sans-IO อาจดูแปลกในตอนแรก แต่เมื่อคุ้นเคยแล้วจะเข้ากันได้ดีกับเครื่องมือสำหรับจำลอง state machine ของ Rust
  • โครงสร้างนี้บังคับให้จัดการข้อผิดพลาดเหมือนเป็นอินพุตอีกชนิดหนึ่ง จึงเข้ากันได้ดีกับรูปแบบการเขียนโค้ดเครือข่าย
  • ยังมีวิธีอื่นในการเขียน async Rust และ structured concurrency ก็อยู่กึ่งกลางระหว่าง sans-IO กับแนวทาง async Rust ที่กล่าวถึงในที่นี้
  • หากต้องการอ่านเรื่อง structured concurrency เพิ่มเติม สามารถดู Let futures be futures ของ withoutboats ได้

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

 
GN⁺ 2024-07-05
ความคิดเห็นจาก Hacker News
  • สิ่งนี้ถูกนำเสนอเหมือนเป็นนวัตกรรมและความก้าวหน้า แต่จริง ๆ แล้วก่อนจะมี การรองรับภาษาแบบ async/await ก็จัดการ async กันแบบนี้ใน $lang รวมถึง Rust อยู่แล้ว
    สำหรับการพัฒนาเฟิร์มแวร์แบบฝังตัวด้วย Rust การเพิ่มประสิทธิภาพครั้งใหญ่ที่สุดคือการเลิกต้องเขียน state machine เองระหว่างงาน I/O แต่ละช่วง และย้ายตัวแปรโลคัลไปเก็บในสถานะที่สร้างขึ้นเอง แล้วให้ Rust ทำแทนผ่านไวยากรณ์ async/await
    ใน Rust นั้น async ท้ายที่สุดแล้วถูกคลี่ออกเป็น state machine อัตโนมัติ ที่เก็บค่าระหว่างจุด I/O (await) ต่าง ๆ

    • ท้ายบทความก็พยายามพูดถึงประเด็นนี้อยู่ ถ้าสิ่งที่กำลังทำเป็น งาน I/O แบบลำดับขั้น เป็นส่วนใหญ่ โมเดลนี้จะค่อนข้างทรมาน
      แต่ก็ไม่ได้เป็นแบบนั้นเสมอไป สำหรับกรณีใช้งานที่ยึด packet เป็นศูนย์กลางอย่าง QUIC, WebRTC หรือ IP ตัว I/O จริง ๆ นั้นง่าย แค่ส่งและรับ packet/datagram ทีละตัว
      จุด .await มีไม่มาก เลยแทบไม่มีอะไรให้คอมไพเลอร์ต้องสร้างขึ้นมา แต่ในขณะเดียวกันก็มีหลายแง่มุมที่ต้องทำงานพร้อมกันและต้องแยกไปอยู่ใน future/task ของตัวเอง ทำให้การจัดการสถานะข้าม future เหล่านี้กลายเป็นโค้ดสปาเก็ตตีได้ง่าย
    • ผมคิดว่าคำอธิบายนั้นยังไม่ตรงนัก จุดสำคัญคือ state machine ไม่ได้ทำ I/O ด้วยตัวเอง มันมอบหมายงานนั้นให้ event loop ที่โฮสต์มันอยู่เสมอ จึงตีความได้ในหลายบริบท
      ทำให้มีสมมติฐานต่อสภาพแวดล้อมรันไทม์น้อยลง เลยทดสอบง่ายขึ้นและประกอบรวมกันได้ง่ายขึ้น
      ในทางทฤษฎี async/await ก็อาจทำสิ่งเดียวกันได้ผ่านวิธีที่มันสร้าง state machine ขึ้นมา แต่ในทางปฏิบัติค่อนข้างเจ็บปวด และโค้ด async/await ส่วนใหญ่ก็ไม่บริสุทธิ์นัก
      ภาษาเชิงทดลองอย่าง Eff, Koka, Frank รองรับสไตล์การเขียนแบบนี้ได้ดีมาก ส่วนเบื้องหลังการถกเรื่อง I/O ของ Haskell ก็มีการลงทุนอย่างลึกซึ้งกับเทคนิคอย่าง free monad และอนุพันธ์ของมัน
      ช่วงหลัง Unison ก็เป็นภาษาที่น่าสนใจ เพราะนอกจากจะสำรวจแนวคิดใหม่หลายอย่างแล้ว ยังวาง ระบบเอฟเฟ็กต์ที่ขยายได้ ไว้เป็นแกนหลัก ทำให้รองรับการเขียนโค้ดแบบนี้ได้ดีในระดับภาษา
    • ในยุค 1980 ก็ทำ I/O ในแอสเซมบลีแบบนี้อยู่แล้ว จะเขียน YMODEM implementation แบบใช้ interrupt อย่างอื่นได้ยังไงล่ะ?
    • เห็นด้วย สิ่งเดียวที่น่ารำคาญใน async คือการต้องใส่ .await ไปทั่ว ถ้ากลับด้านได้ให้ .await เกิดขึ้นเป็นค่าเริ่มต้น และใช้ไวยากรณ์พิเศษเฉพาะตอนที่ไม่ต้องการ ก็น่าจะดี
    • ไม่ได้พยายามขายว่าเป็นเรื่องปฏิวัติอะไรนะ ในบทความก็เขียนไว้แบบนี้: “แพตเทิร์นนี้ไม่ใช่สิ่งที่เราคิดค้นขึ้น! ในโลกของ Python มีเว็บไซต์เฉพาะสำหรับเรื่องนี้อยู่แล้ว”
      ถึงอย่างนั้น ในทางปฏิบัติก็ยังเห็น ไลบรารีโปรโตคอล ทำ I/O เองอยู่บ่อยเกินไป :-(
  • ผมครุ่นคิดกับปัญหากลุ่มนี้อยู่ในหัวมาตลอด และแนวทางนี้ก็ตรงกับสิ่งที่ผมคิดไว้มาก แม้ว่าจะยังมีจุดให้ปรับอยู่บ้างเหมือนเชิงอรรถ 3 ในบทความ
    จุดที่ทำให้เริ่มคิดเรื่องนี้คือการถกเรื่อง function colors กับการค้นพบโดยบังเอิญ ตอนกำลังทำไลบรารี VT100 ผมพบว่าการทำ unit test ยากมาก เพราะที่ทำอยู่แทบจะเป็น parser::new(stdin()) นั่นเอง ระหว่างการเขียนใหม่รอบที่สามหรือสี่ ผมเปลี่ยน parser เป็น parser::push(data) แบบไม่ได้คิดอะไรมาก แล้วตอนนั้นเองก็รู้ว่า Rust กำลังลงโทษแอนติแพตเทิร์นแบบ Enterprise OOP ที่ผมเรียกว่า “ความหมกมุ่นกับการห่อหุ้ม
    ตอนนี้ผมเห็นแพตเทิร์นนี้และผลเสียของมันไปทุกที่ ไม่ใช่แค่กับ I/O
    น่าขันตรงที่ทางออกนี้เป็นสิ่งที่สอนกันทั้งก่อนเข้ามหาวิทยาลัยและในช่วงต้นมหาวิทยาลัยด้วยซ้ำ คำอธิบายที่ง่ายที่สุดของคอมพิวเตอร์คือมันเป็นเครื่องที่รับอินพุต นำข้อมูลไปประมวลผล/แปลง แล้วสร้างเอาต์พุตออกมา เหตุผลที่เกี่ยวกับการถกเรื่อง function colors ก็เพราะสิ่งที่ต้องสนใจเรื่องสีจริง ๆ คืออินพุตและเอาต์พุตเท่านั้น ส่วนลอจิกแกนกลางมักเป็นการแปลงข้อมูล
    ฟังดูเหมือนเรื่องธรรมดา แต่ถ้ามองจากขนาดของ “ข้อถกเถียง” เรื่อง function colors ก็แปลว่าหลายคนถูกฝึกให้แก้ปัญหาด้วยการห่อหุ้มก่อน จนมองข้ามหรือลืมข้อเท็จจริงนี้ไป คนสาย functional คงแอบยิ้มกับจุดนี้พอสมควร
    สำหรับผม Rust ไม่ค่อยเหมือนเส้นทางแห่งการเรียนรู้ แต่ใกล้เคียงกับ เส้นทางแห่งการเลิกเรียนรู้สิ่งเดิมและเรียนรู้ใหม่ มากกว่า เป็นแพตเทิร์นที่ดี และผมตั้งใจจะนำไปใช้ต่อ
    แก้ไข: โค้ดที่เกี่ยวข้อง: https://codeberg.org/jcdickinson/termkit/src/branch/main/src...

    • ช่วยอธิบายเพิ่มเติมได้ไหมว่าหมายถึงอะไรตรงที่เปลี่ยนเป็น parser::push(data) และที่บอกว่า Rust ลงโทษแอนติแพตเทิร์นแบบ Enterprise OOP
      ในฐานะคนที่เพิ่งเริ่มเรียน Rust มาก ๆ ผมยังไม่ค่อยเห็นว่าปัญหาที่ชัดเจนของแพตเทิร์นนั้นคืออะไร และ Rust ลงโทษมันอย่างไร
    • ผมก็ย้ายจากโลก OOP มา Rust เหมือนกัน และตอนนี้ก็ 6 ปีแล้ว ช่วง 2-3 ปีแรกผมเขียนโค้ดแย่มาก
      มี type parameter กับ trait เต็มไปหมดทุกแห่ง และก็ใช้ struct ในทางที่ผิดเหมือนเป็นคลาสที่มีไว้ให้ความสามารถบางอย่าง
      Rust จะเข้ามือกว่ามากถ้าหลีกเลี่ยง type parameter และการนิยาม trait เอง เท่าที่ทำได้
      การห่อหุ้มในความหมายของการรักษา invariant นั้นเป็นเรื่องที่ดี เรื่องนี้ทำให้นึกถึงบทความ “parse, don’t validate”: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
  • ถ้าเทียบกับการออกแบบที่ใช้ channel เพื่อส่งข้อมูลไปยัง handler เฉพาะล่ะ? ตอนใช้ channel เคยมีปัญหาหลายอย่าง
    (1) มักเกิดโค้ดที่เป็นใยแมงมุมตามยาก
    (2) ต้องลงมือ implement ชนิดข้อความที่แปลงเป็นข้อความที่ส่งผ่านเครือข่ายได้ด้วยตัวเอง
    (3) ต้องส่งตัวส่งไปให้ entity ที่สนใจหรือได้รับอนุญาตอย่างชัดเจน
    (4) รู้ได้ว่าการส่งข้อความผ่าน channel ล้มเหลวหรือไม่ แต่ไม่รู้ว่าข้อความนั้นล้มเหลวตอนส่งผ่านเครือข่ายหรือไม่
    ถึงอย่างนั้นมันก็ยังค่อนข้างสะดวกอยู่ดี ตัวอย่างเช่น ถ้ามี channel ws_handler ก็แค่ส่งข้อมูลไปที่นั่น แล้วถ้ามี handler เฉพาะที่ไหนสักแห่งสามารถส่งข้อความนั้นต่อได้ ก็ทำได้

    • ข้อ 4 ทำได้โดยส่ง channel สำหรับส่งผลลัพธ์กลับมาพร้อมกับข้อความ ถ้าต้องการก็สามารถบล็อกฝั่งส่งไปจนถึงจุดเรียกได้เช่นกัน
      sans-IO ใช้กับแอปพลิเคชันได้ แต่รู้สึกว่ามีประโยชน์กับ ไลบรารี เป็นพิเศษ เพราะในไลบรารีจะไม่ไปบังคับผู้ใช้ว่าต้องทำ I/O แบบไหน จึงยิ่งมีประโยชน์มาก
      ใน Rust มีความแตกแยกของ ecosystem ระหว่าง synchronous I/O กับ asynchronous I/O อยู่แล้ว แถมยังมี async runtime หลายแบบอีกด้วย จึงเป็นประเด็นสำคัญ
    • ถ้ารับได้ที่จะออกแบบซอฟต์แวร์เป็น สไตล์คล้าย actor channel ก็ทำงานได้ดี
      แต่ก็มีปัญหาตามที่กล่าวไว้ เช่น actor/channel อาจถูกตัดการเชื่อมต่อได้ และถ้าต้องการ backpressure ก็จำเป็นต้องใช้ channel แบบมีขอบเขตเท่านั้น นอกจากนี้ยังต้องมีการคัดลอกข้อมูล จึงอาจยากที่จะทำให้ได้ throughput สูง
  • สิ่งที่น่าดูควบคู่กัน: monad โดยเฉพาะ Free(r) monad และระบบเอฟเฟกต์[0]
    แนวคิดเรื่อง แยกตรรกะออกจากการรันจริง เป็นหัวข้อใหญ่ที่ถูกพูดถึงอย่างมากใน ecosystem ของ Haskell อยู่แล้ว
    แก้ไข: ยังไม่ได้พูดถึงว่าห่อหุ้มการเรียก tokio::select! ที่โผล่มาเมื่อจำเป็นต้องจัดการเรื่องเวลาไว้อย่างไร หรือว่าต้องพก tokio::Runtime ไปด้วยเพื่อทำให้โค้ดลูปเป็น async โดยที่โค้ดภายนอกไม่จำเป็นต้องเป็น async?
    แก้ไข 2: อาจไม่ได้ตั้งใจจะแสดงว่าไลบรารีที่ถูกห่อหุ้มทำแบบนั้น แต่ต้องการแสดงว่าแอปพลิเคชันภายนอกสามารถใช้ binding ได้ในบริบท async
    สิ่งที่อยากรู้มากกว่าคือ ในสไตล์ sans-IO จะ implement ฟังก์ชันที่ถูกห่อหุ้มซึ่งต้องรอการกระทำบางอย่างหรือตัวจับเวลาได้อย่างไร หรือคำตอบที่คาดหวังคือใช้ busy wait หรือไม่ก็จริง ๆ แล้วพกอินสแตนซ์ async runtime ของตัวเองที่ทำหน้าที่แทน busy wait ด้วยวิธีอย่าง block_in_place
    [0]: https://okmij.org/ftp/Computation/free-monad.html

    • ตรงนี้ “ฟังก์ชันที่ถูกห่อหุ้ม” คือ struct StunBinding ซึ่งแสดงฟังก์ชันการทำงานของ STUN binding ไม่ใช่ฟังก์ชันเดี่ยวที่แค่เรียกใช้ได้ แต่ต้องมี event loop
      ประเด็นสำคัญคือ StunBinding สามารถอยู่ในไลบรารีได้ และฝั่งแอปพลิเคชันสามารถนำมันไปประกอบเข้ากับ state machine ของโปรแกรมได้ แน่นอนว่าตั้งอยู่บนสมมติฐานว่าแอปพลิเคชันก็จัดโครงสร้างแบบ sans-IO เช่นกัน
      ไลบรารี snownet ที่ลิงก์ไว้ทำสิ่งนี้พอดี โดยโดเมนคือการรวม ICE + WireGuard โดยไม่มี I/O และถูกใช้ในไลบรารี connlib ที่ประกอบ ACL ไว้อีกชั้นด้านบน
      แก้ไข: ไม่มี busy wait แต่ StunBinding มีฟังก์ชันที่เปิดเผยว่าตัวเองกำลังรออะไรผ่าน poll_timeout ส่วนผู้เรียก ซึ่งก็คือ event loop จะทำให้สิ่งนั้นเกิดขึ้นอย่างไรเป็นเรื่องของผู้เรียก เมื่อ handle_timeout ถูกเรียกด้วย Instant ที่สอดคล้องกัน ก็จะเกิดการทำงานที่เหมาะสม
  • โอ้ thomaseizinger!
    เคยส่องภายในของ rust-libp2p มาก่อน เลยรู้สึกว่าระหว่างอ่านบทความ pattern นี้คุ้นมาก ๆ ดูเหมือนจะไม่ใช่เรื่องบังเอิญ
    Firezone ดูเจ๋งดี เชื่อมต่อทุกสิ่งเข้าด้วยกัน!

    • ขอบคุณครับ!
      ใช่ มีส่วนคล้ายกับ rust-libp2p อยู่บ้าง แต่ที่นั่นตัว stream และ connection จริงยังคงอยู่ในโครงสร้างคล้าย Future จึงพันกันมากกว่า และไม่ได้แยกขาดกันอย่างเข้มงวดแบบ sans-IO ที่นี่
  • มีช่วงหนึ่งพูดว่า workflow แบบลำดับขั้นต้องใช้โค้ดมากกว่า ใน Rust ฟังก์ชัน async จะถูกคอมไพล์เป็น state machine และแต่ละจุด .await แทนการเปลี่ยนไปยังอีกสถานะหนึ่ง จึงทำให้นักพัฒนาเขียนโค้ดแบบลำดับพร้อมกับ non-blocking I/O ได้ง่าย ถ้าไม่มี async ก็ต้องเขียน state machine เองเพื่อแสดงหลายขั้นตอน
    มีใครเคยลอง ผสาน async กับ sans-IO ไหม? อย่างน้อยในเชิงแนวคิด ถ้าเขียนฟังก์ชัน async ที่ await helper ซึ่งเข้าใจ sans-IO ได้ ทั้งหมดก็น่าจะถูกคอมไพล์เป็น state machine ภายใน struct ที่มีอินเทอร์เฟซ sans-IO ที่ดี และควรถูกเรียกจากโค้ดที่ไม่ใช่ async ได้ง่ายด้วย
    ยังไม่เคยลองเอง แต่ปัญหาหลักที่คาดไว้น่าจะเป็นเรื่อง ergonomics ที่ดีพอและการจัดการ Pin

    • ใน Rust มี generator/coroutine ที่พอจะรองรับ use case ที่พูดถึงได้อยู่บ้าง แต่ตอนนี้ยังเป็นฟีเจอร์ที่ไม่เสถียรมาก
      น่าเสียดายที่ coroutine ในรูปแบบปัจจุบันมีข้อจำกัดน่ารำคาญคือถูกเปิดเผยผ่าน trait std::ops::Coroutine เท่านั้น ดังนั้นจึงไม่สามารถจัดสรร internal state machine ที่คอมไพเลอร์สร้างขึ้นได้โดยตรง ทั้งที่ขนาดของ state machine นั้นก็ดูเหมือนเป็นค่าคงที่ตั้งแต่คอมไพล์
      ถ้าเป็น coroutine เดี่ยวที่อยู่ภายในฟังก์ชันซึ่งกำหนดอายุการใช้งานไว้ ปัญหานี้ไม่เกิด เพราะคอมไพเลอร์สามารถวิเคราะห์และจัดสรร state machine บนสแตกได้
      แต่การใช้งานที่มีประโยชน์ที่สุดของ coroutine อาจมองได้ว่าเป็นสมาชิกของคิวในอุปกรณ์ event loop และการทำแบบนี้เป็นไปไม่ได้หากไม่ box coroutine ไว้ Vec<Box<...>> ไม่ใช่โครงสร้างข้อมูลที่เป็นมิตรกับแคช และถ้าต้องมีสมาชิกเป็นล้านตัวใน Vec สำหรับ I/O พร้อมกันในระดับสูงมาก คุณจะรู้สึกเจ็บปวดแน่

    • ถ้าวันหนึ่ง Rust มี ไวยากรณ์ generator แบบ native ก็อาจทำได้ คุณจะสามารถเขียน yield transmit เพื่อ “เขียน” ข้อมูลได้โดยยังอยู่ในบริบทของงาน async กล่าวคือ socket.write ทุกจุดจะถูกแทนด้วย yield transmit
      เวลาอ่านข้อมูล generator จะถูกระงับ (.await) แล้วรอให้ถูก resume พร้อมข้อมูลที่เข้ามา ไม่แน่ใจว่าบน nightly มีไวยากรณ์แนวนี้หรือยัง แต่ภาพรวมควรจะหน้าตาประมาณนี้:

      // Made up gen syntax: gen(yield_type, resume_type)
      gen(Transmit, &[u8]) fn stun_binding(server: SocketAddr) -> SocketAddr {
      let req = make_stun_request();

yield Transmit {
server,
payload: req
};

let res = .await; // ไวยากรณ์ "suspend and resume with argument" ที่สมมติขึ้น

let addr = parse_stun_response(res);

addr  
}
  • ในชั้นบนของสแตก ทั้งสองอย่างเข้ากันได้ค่อนข้างดี Non-blocking I/O หรือก็คือ async ช่วยให้รอทั้ง socket I/O และเวลาได้พร้อมกันง่ายขึ้น ใน blocking I/O ก็ทำได้ถ้าตั้งค่า read timeout ให้ socket แต่การใช้ async primitives จะง่ายกว่านิดหน่อย
    ผมเองก็คิดอยู่เรื่อย ๆ ว่าจะผสานสองอย่างนี้เข้าด้วยกันอย่างไร หนึ่งในปัญหาที่เจอคือฟังก์ชัน async จะถูกคอมไพล์เป็น opaque type ทำให้ใช้ความสามารถที่คอมไพเลอร์สร้างโค้ดเป็น state machine ให้ได้ยากหรือแทบเป็นไปไม่ได้ เพราะเมื่อถูกสร้างแล้ว เราไม่สามารถโต้ตอบกับ state machine นั้นได้ เรื่องนี้ในแง่หนึ่งยังทำให้ borrow checker ใช้งานลำบากขึ้นด้วย
    สมมติว่ามีงาน async ที่มีหลายขั้นตอน หรือมีหลายจุด await และมีเพียงช่วงหนึ่งเท่านั้นที่ต้องใช้ mutable reference ไปยังโครงสร้างข้อมูลที่แชร์กัน พอแสดงมันเป็นฟังก์ชัน async mutable reference นั้นจะถูก capture เข้าไปในชนิด Future ที่สร้างขึ้น และชนิดนั้นจะมีอยู่ตลอดทุกขั้นตอน ผลก็คือ Rust จะไม่ยอมให้รันงานลักษณะนี้พร้อมกันมากกว่าหนึ่งงาน
    ปกติคำแนะนำในสถานการณ์แบบนี้คือ “ให้ capture mutable reference ให้สั้นที่สุดเท่าที่ทำได้” แต่ใน async ทำแบบนั้นไม่ได้ การแยกฟังก์ชัน async ออกเป็นหลายตัวก็ทำให้โค้ดรกขึ้น และยังบั่นทอนเป้าหมายเดิมที่อยากแสดงทุกอย่างไว้ในฟังก์ชันเดียวพอสมควร

  • เมื่อก่อนผมเคยพยายาม encode โปรโตคอล HTTP/1.1 ให้เป็น Sans-IO state machine ที่มีจุด .await สำหรับ I/O แต่ไปได้ไม่ไกลนัก อย่างไรก็ตาม I/O นั้นทำงานโดยคืนการควบคุมกลับให้ผู้ใช้ไปทำ I/O เอง แทนที่จะลงทะเบียน waker กับ async runtime คุณอาจมองได้ว่า .await นั้นคลี่ออกไป “ข้างบน” แทนที่จะเป็น “ข้างล่าง”
    ในบริบทของ HTTP/1.1 โค้ด async กลายเป็นเหมือนพิมพ์เขียวชนิดหนึ่งว่าผู้ใช้ต้องการให้พฤติกรรมการเรียกทำงานเป็นอย่างไร ตอนนั้นผมตั้งใจให้มันต้องทำงานได้ในสภาพแวดล้อม no_std และไม่มี allocator ด้วย และสุดท้ายก็ล้มเลิกไปเพราะหาวิธีเลี่ยง dynamic dispatch ผ่าน Box ซึ่งเป็นส่วนที่ต้องใช้อ allocator ไม่เจอ

  • https://news.ycombinator.com/item?id=40879547
    มีตัวอย่างที่เขียนในรูปแบบ async fn stun โค้ดการทำงานทั้งหมดอยู่ที่นี่: https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...

  • ทำได้ดี! ถ้าเปิดเผยสถานะออกมา ฟังก์ชัน async ใด ๆ ก็ทำให้เป็นแบบ pure ได้ ผู้ใช้เพียงแค่ต้องขยับ state machine ไปยังสถานะถัดไป
    เมื่อก่อนผมเคยพยายาม bind OpenSSL เข้ากับ async Rust และ API แบบ async นั้นก็ใช้ดีไซน์คล้ายกัน

    • ลองค้นเร็ว ๆ ดูแล้ว OpenSSL มี API สำหรับ “async job” อยู่ เพียงแต่มันดูเหมือนจะทำ I/O ด้วย แถมการสร้าง job ยังมีต้นทุนสูงมากจนเอกสารบอกว่าควร reuse job
      ส่วนที่คุณมองว่าคล้ายกันคือ งานที่ถูก schedule เป็น job นั้นเป็นอิสระจากวิธีการรันจริงของมันใช่ไหม?
      จากตัวอย่างนี้ [0] async API ดังกล่าวดูคล้ายกับ future ของ Rust มากกว่าเยอะ
      ภายใน job สามารถเข้าถึง “wait context” ได้ สามารถ suspend ได้ภายใต้เงื่อนไขบางอย่าง และสามารถ trigger การปลุกให้ทำงานต่อได้
      [0]: https://www.openssl.org/docs/man1.1.1/man3/ASYNC_is_capable....
  • นี่ก็แค่ asynchronous I/O แบบทั่วไปที่ใช้ callback แทน coroutine

  • พออ่านบทความกับคอมเมนต์บางส่วนแล้ว ฟังดูเหมือนกำลังประดิษฐ์ hexagonal architecture หรือสไตล์สถาปัตยกรรมแบบ port/adapter ขึ้นมาใหม่

  • ผมไม่ค่อยแน่ใจว่าควรได้อะไรกลับไปจากเรื่องนี้ สิ่งที่พูดถึงทั้งหมดก็คือ การเขียนโปรแกรมเครือข่ายพื้นฐาน ที่มีอยู่แล้ว
    ดูเหมือนจะไปโฟกัสกับงาน plumbing ระดับสูงกว่าและหมกมุ่นกับการจัดการ state มากเกินไป ซึ่งเป็นแค่เรื่องรสนิยม ไม่ได้เกี่ยวกับ networking
    สิ่งที่น่าสนใจที่สุดที่ผมได้เรียนรู้จากบทความคือ Cloudflare มี public STUN server ให้ใช้ แต่ถึงอย่างนั้นมันก็ไม่ได้ช่วยอะไรนัก เพราะ STUN โปรโตคอลเวอร์ชันที่ “ดี” และ “มีประโยชน์” คือเวอร์ชันแรกที่รองรับ change requests ซึ่งทำให้ทำ NAT enumeration ได้ ส่วน STUN เวอร์ชันหลัง ๆ ฟีเจอร์นี้ถูกตัดออกไปเพราะ “ข้อเสนอที่เป็นประโยชน์” จากวิศวกร Cisco ที่ร่วมเขียนสเปก

    • ในบริบทของ Rust ผมมองว่าข้อดีใหญ่คือมันแก้ ปัญหาสีของฟังก์ชัน ได้ และอย่างที่บทความอธิบาย การทดสอบก็ง่ายมากจริง ๆ
      ทุกวันนี้ใน Rust ถ้าคุณทำไลบรารี WebRTC ที่ใช้ async runtime อย่าง Tokio คนที่ใช้ synchronous I/O หรือใช้ runtime อื่น (smol, async-std ฯลฯ) หรือใช้ iouring โดยตรง จะใช้งานได้ลำบากมาก
      แนวทางนี้ไม่บังคับให้ผู้ใช้ไลบรารีต้องเลือก I/O แบบใดแบบหนึ่ง จึงทำให้ไลบรารีมีประโยชน์กับคนได้กว้างขึ้น