- ไลบรารีการเชื่อมต่อ 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 มีไลบรารีต่อไปนี้ที่ใช้แพตเทิร์นนี้
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 ต่อไปนี้
SentReceived { address: SocketAddr }
StunBindingมีสถานะปัจจุบันและคิวของTransmitที่รอส่ง- API หลักมีบทบาทชัดเจน
handle_input: ส่งแพ็กเก็ตที่เข้ามาจากผลของUdpSocket::recvเข้าไปยัง state machinepoll_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 ตัว ก็สามารถสร้าง
StunBinding5 ตัวและเรียกใช้งานตามลำดับได้- ในกรณีนี้ต้อง implement การมัลติเพล็กซ์ข้อความ STUN ให้เหมาะสม โดยอาจใช้
TransactionIdหรือที่อยู่เซิร์ฟเวอร์
- ในกรณีนี้ต้อง implement การมัลติเพล็กซ์ข้อความ STUN ให้เหมาะสม โดยอาจใช้
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 อย่างtokiodata structure ดังกล่าวต้องเป็น'staticจึงไม่สามารถมี reference อย่าง&mutอยู่ภายในได้ - หากต้องการเปลี่ยนสถานะที่อยู่นอก
Futureมักต้องใช้วิธีใดวิธีหนึ่งต่อไปนี้- reference-counted pointer และ mutex อย่าง
Arc<Mutex<T>> - แนวทาง actor ที่ spawn หลาย task แล้วเชื่อมด้วย channel
- reference-counted pointer และ mutex อย่าง
- ทั้งสองวิธีมี 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 ความคิดเห็น
ความคิดเห็นจาก Hacker News
สิ่งนี้ถูกนำเสนอเหมือนเป็นนวัตกรรมและความก้าวหน้า แต่จริง ๆ แล้วก่อนจะมี การรองรับภาษาแบบ async/await ก็จัดการ async กันแบบนี้ใน
$langรวมถึง Rust อยู่แล้วสำหรับการพัฒนาเฟิร์มแวร์แบบฝังตัวด้วย Rust การเพิ่มประสิทธิภาพครั้งใหญ่ที่สุดคือการเลิกต้องเขียน state machine เองระหว่างงาน I/O แต่ละช่วง และย้ายตัวแปรโลคัลไปเก็บในสถานะที่สร้างขึ้นเอง แล้วให้ Rust ทำแทนผ่านไวยากรณ์ async/await
ใน Rust นั้น async ท้ายที่สุดแล้วถูกคลี่ออกเป็น state machine อัตโนมัติ ที่เก็บค่าระหว่างจุด I/O (
await) ต่าง ๆแต่ก็ไม่ได้เป็นแบบนั้นเสมอไป สำหรับกรณีใช้งานที่ยึด packet เป็นศูนย์กลางอย่าง QUIC, WebRTC หรือ IP ตัว I/O จริง ๆ นั้นง่าย แค่ส่งและรับ packet/datagram ทีละตัว
จุด
.awaitมีไม่มาก เลยแทบไม่มีอะไรให้คอมไพเลอร์ต้องสร้างขึ้นมา แต่ในขณะเดียวกันก็มีหลายแง่มุมที่ต้องทำงานพร้อมกันและต้องแยกไปอยู่ใน future/task ของตัวเอง ทำให้การจัดการสถานะข้าม future เหล่านี้กลายเป็นโค้ดสปาเก็ตตีได้ง่ายทำให้มีสมมติฐานต่อสภาพแวดล้อมรันไทม์น้อยลง เลยทดสอบง่ายขึ้นและประกอบรวมกันได้ง่ายขึ้น
ในทางทฤษฎี async/await ก็อาจทำสิ่งเดียวกันได้ผ่านวิธีที่มันสร้าง state machine ขึ้นมา แต่ในทางปฏิบัติค่อนข้างเจ็บปวด และโค้ด async/await ส่วนใหญ่ก็ไม่บริสุทธิ์นัก
ภาษาเชิงทดลองอย่าง Eff, Koka, Frank รองรับสไตล์การเขียนแบบนี้ได้ดีมาก ส่วนเบื้องหลังการถกเรื่อง I/O ของ Haskell ก็มีการลงทุนอย่างลึกซึ้งกับเทคนิคอย่าง free monad และอนุพันธ์ของมัน
ช่วงหลัง Unison ก็เป็นภาษาที่น่าสนใจ เพราะนอกจากจะสำรวจแนวคิดใหม่หลายอย่างแล้ว ยังวาง ระบบเอฟเฟ็กต์ที่ขยายได้ ไว้เป็นแกนหลัก ทำให้รองรับการเขียนโค้ดแบบนี้ได้ดีในระดับภาษา
.awaitไปทั่ว ถ้ากลับด้านได้ให้.awaitเกิดขึ้นเป็นค่าเริ่มต้น และใช้ไวยากรณ์พิเศษเฉพาะตอนที่ไม่ต้องการ ก็น่าจะดีถึงอย่างนั้น ในทางปฏิบัติก็ยังเห็น ไลบรารีโปรโตคอล ทำ 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 ลงโทษมันอย่างไร
มี 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 เฉพาะที่ไหนสักแห่งสามารถส่งข้อความนั้นต่อได้ ก็ทำได้sans-IO ใช้กับแอปพลิเคชันได้ แต่รู้สึกว่ามีประโยชน์กับ ไลบรารี เป็นพิเศษ เพราะในไลบรารีจะไม่ไปบังคับผู้ใช้ว่าต้องทำ I/O แบบไหน จึงยิ่งมีประโยชน์มาก
ใน Rust มีความแตกแยกของ ecosystem ระหว่าง synchronous I/O กับ asynchronous I/O อยู่แล้ว แถมยังมี async runtime หลายแบบอีกด้วย จึงเป็นประเด็นสำคัญ
แต่ก็มีปัญหาตามที่กล่าวไว้ เช่น 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
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
gensyntax: gen(yield_type, resume_type)gen(Transmit, &[u8]) fn stun_binding(server: SocketAddr) -> SocketAddr {
let req = make_stun_request();
yield Transmit {
server,
payload: req
};
ในชั้นบนของสแตก ทั้งสองอย่างเข้ากันได้ค่อนข้างดี 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 ไปยังโครงสร้างข้อมูลที่แชร์กัน พอแสดงมันเป็นฟังก์ชันasyncmutable 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 นั้นก็ใช้ดีไซน์คล้ายกัน
ส่วนที่คุณมองว่าคล้ายกันคือ งานที่ถูก 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 ถ้าคุณทำไลบรารี WebRTC ที่ใช้ async runtime อย่าง Tokio คนที่ใช้ synchronous I/O หรือใช้ runtime อื่น (smol, async-std ฯลฯ) หรือใช้ iouring โดยตรง จะใช้งานได้ลำบากมาก
แนวทางนี้ไม่บังคับให้ผู้ใช้ไลบรารีต้องเลือก I/O แบบใดแบบหนึ่ง จึงทำให้ไลบรารีมีประโยชน์กับคนได้กว้างขึ้น