1 คะแนน โดย GN⁺ 3 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • รีเวิร์สพร็อกซี TinyGate เพิ่มประสิทธิภาพได้ด้วยการเปลี่ยนไปใช้ epoll ในสถาปัตยกรรมแบบ worker แต่ภายหลังก็ชนข้อจำกัดและถูกเขียนใหม่ด้วย io_uring
  • epoll เป็น โมเดลแบบ readiness ที่แจ้งว่า I/O พร้อมใช้งานเมื่อใด จึงต้องเรียก read()/write() แยกต่างหากหลัง epoll_wait
  • io_uring เป็น โมเดลแบบ completion ที่ขับเคลื่อนตามการเสร็จสิ้นของ I/O โดยแอปพลิเคชันและเคอร์เนลสื่อสารกันผ่าน ring buffer ที่ใช้ร่วมกันสำหรับ submission queue และ completion queue
  • โดยปกติ io_uring_enter() ยังจำเป็นอยู่ แต่สามารถส่งและรับหลายงานพร้อมกันได้ในครั้งเดียว และ IORING_SETUP_SQPOLL ช่วยลด syscall โดยแลกกับ การใช้ CPU
  • หากเริ่มโปรเจ็กต์ใหม่บน Linux server รุ่นใหม่ที่ใช้ kernel v5.1+ io_uring ถูกมองว่าเป็นตัวเลือกที่เหมาะสมกว่า epoll

ข้อจำกัดของ epoll ที่ TinyGate เปิดให้เห็น

  • TinyGate เป็น เซิร์ฟเวอร์รีเวิร์สพร็อกซี ที่สร้างร่วมกับนักศึกษา และเวอร์ชันแรกใช้โครงสร้างแบบ worker อย่างเรียบง่าย
  • สำหรับโปรเจ็กต์เพื่อการเรียนรู้มันใช้งานได้ แต่เมื่อเทียบกับเครื่องมืออย่าง nginx หรือ haproxy ก็มีข้อจำกัดด้านสถาปัตยกรรมอย่างมาก
  • เวอร์ชันที่สองเปลี่ยนมาใช้ epoll ทำให้ประสิทธิภาพดีขึ้นอย่างชัดเจนเมื่อเทียบกับเวอร์ชันแรก
    • อย่างไรก็ตาม ในการ benchmark ก็ยังไม่สามารถแซง nginx/haproxy ได้
  • หลังจากนั้นจึงเปลี่ยนไปใช้ io_uring เพราะข้อจำกัดของ epoll และต้องเขียนโปรเจ็กต์ใหม่ตั้งแต่ต้น

epoll: การแจ้งสถานะพร้อมใช้งานและ syscall ที่เกิดซ้ำ

  • epoll เป็นวิธีจัดการ asynchronous I/O ที่ใช้กันมานานบน Linux และถูกเพิ่มเข้า Linux kernel ในปี 2002
  • แกนสำคัญคือ การแจ้งสถานะพร้อมใช้งาน ว่า I/O สามารถทำได้เมื่อใด
    • epoll จะแจ้งว่า “อ่านได้หรือเขียนได้แล้ว”
    • ส่วนการอ่านและเขียนข้อมูลจริงนั้น แอปพลิเคชันต้องทำเองภายหลังผ่าน syscall read() หรือ write()
  • ในลำดับการทำงานทั่วไป ต้นทุนของ syscall จะเกิดซ้ำในทุกอีเวนต์
    • epoll_ctl เป็น syscall แบบครั้งเดียวสำหรับลงทะเบียน file descriptor
    • แต่ในทุกอีเวนต์ I/O จริง จะต้องมี epoll_wait และ read()/write()
    • ผลลัพธ์คือการประมวลผลอีเวนต์จะมี syscall เพิ่มเข้ามาเรื่อย ๆ
  • syscall ทำให้เกิด context switch ระหว่าง user mode และ kernel mode และยิ่งมีจำนวนการเชื่อมต่อมาก overhead ก็ยิ่งสูงขึ้น

io_uring: โมเดลแบบ completion และ shared ring buffer

  • io_uring ปรากฏขึ้นในปี 2019 ราว 17 ปีหลัง epoll ถูกเพิ่มเข้า Linux kernel และรองรับบน kernel v5.1+
  • ต่างจาก epoll ตรงที่ไม่ได้ดูว่า I/O พร้อมหรือยัง แต่ทำงานโดยอิงว่า I/O เสร็จสิ้นแล้วหรือยัง
  • แอปพลิเคชันและเคอร์เนลใช้ ring buffer ใน shared memory ร่วมกัน
    • ใน submission queue แอปพลิเคชันจะใส่งานที่ต้องการให้เคอร์เนลดำเนินการ
    • ใน completion queue เคอร์เนลจะส่งผลลัพธ์ของงานที่เสร็จแล้วกลับมา
  • ในการตั้งค่าปกติ ยังต้องเรียก io_uring_enter() เพื่อให้เคอร์เนลตรวจสอบ submission queue
    • การเรียกเพียงครั้งเดียวสามารถส่งหลายงานและดึงผล completion หลายรายการกลับมาได้
    • จึงไม่ใช่โครงสร้างที่ต้องทำ syscall เป็นคู่ซ้ำ ๆ ต่อหนึ่งงานแบบ epoll ร่วมกับ read()
  • หากใช้ IORING_SETUP_SQPOLL เธรดของเคอร์เนลจะคอย poll submission queue
    • ในสถานะการทำงานปกติสามารถลด syscall ได้เกือบหมด
    • แต่แม้ queue จะว่าง เธรดของเคอร์เนลก็ยังทำงานอยู่และใช้ CPU
    • หลัง sq_thread_idle มันอาจถอยไปสู่ sleep ได้ แต่ต้นทุนไม่ได้หายไป

ความแตกต่างผ่านตัวอย่างโค้ด

  • ตัวอย่าง epoll

    • ลงทะเบียน file descriptor ของ stdin และเมื่อมีอีเวนต์ก็เรียก read() แยกต่างหาก
    • สร้าง epoll instance ด้วย epoll_create1
    • ลงทะเบียน STDIN_FILENO ด้วย epoll_ctl
    • บล็อกจนกว่าจะอ่านได้ด้วย epoll_wait
    • เมื่อมีอีเวนต์เข้ามา ก็อ่านข้อมูลด้วย syscall read()
    • ในลำดับนี้ ทุกอีเวนต์ I/O จริงจะต้องมีทั้ง epoll_wait และ read
  • ตัวอย่าง io_uring

    • ใช้ liburing
    • เริ่มต้น ring ด้วย io_uring_queue_init
    • รับ submission queue entry ด้วย io_uring_get_sqe
    • เตรียมงานอ่าน stdin ด้วย io_uring_prep_read
    • ส่งงานด้วย io_uring_submit และรอ completion ด้วย io_uring_wait_cqe
    • ในตัวอย่าง io_uring ไม่มีการตรวจสอบ สถานะพร้อมใช้งาน แยกต่างหาก และเมื่อเสร็จสิ้นก็ไม่ต้องเรียก read() เพิ่มอีก
    • เพื่อให้เรียบง่าย ตัวอย่างทั้งสองละการจัดการข้อยกเว้นสำคัญบางส่วนไว้
    • หาก stdin ไม่มีข้อมูล ก็อาจบล็อกไปตลอด
    • ตัวอย่าง io_uring ไม่ได้ตรวจสอบกรณีที่ io_uring_get_sqe() คืนค่า NULL เมื่อ submission queue เต็ม

เงื่อนไขเพิ่มเติมเมื่อใช้ io_uring

  • หากต้องการใช้ zero-copy I/O ต้องลงทะเบียนบัฟเฟอร์ล่วงหน้าด้วย io_uring_register_buffers()
    • วิธีนี้ช่วยหลีกเลี่ยงการที่เคอร์เนลต้อง map หน่วยความจำใหม่ในทุกงาน
    • สำหรับการส่งผ่านเครือข่าย IORING_OP_SEND_ZC บน kernel 6.0+ รองรับการส่งโดยไม่คัดลอกบัฟเฟอร์เข้าเคอร์เนล
  • IORING_SETUP_SQPOLL ช่วยลด syscall ได้ แต่ต้องแลกกับ การใช้ CPU
    • แม้ queue จะว่าง เธรดของเคอร์เนลก็ยัง poll ต่อไป
    • หลัง idle timeout อาจเปลี่ยนเป็น sleep ได้ แต่ไม่ได้แปลว่าต้นทุนหายไป
  • ข้อผิดพลาดของ io_uring จะไม่ถูกส่งกลับโดยตรงแบบ synchronous syscall แต่จะกลับมาแบบ asynchronous ในฟิลด์ res ของ completion queue entry
    • การจัดการข้อผิดพลาดจึงต้องทำผ่าน cqe->res

ทางเลือกบน Linux server รุ่นใหม่

  • epoll เป็นวิธี asynchronous I/O แบบดั้งเดิมของ Linux ที่อาศัยการแจ้งเวลาที่ I/O พร้อมใช้งานและการเรียก syscall แยกต่างหาก
  • io_uring มอบ โมเดลแบบ completion และการส่งงาน/รับผลแบบเป็นชุดบน Linux รุ่นใหม่
  • หากสร้างโปรเจ็กต์ใหม่ตั้งแต่ต้นบน Linux server สมัยใหม่ การเลือก io_uring ถือเป็นทางเลือกที่เป็นธรรมชาติมากกว่า
  • หากสามารถยุติการรองรับระบบเก่าได้อย่างสมเหตุสมผล ในสภาพแวดล้อม kernel v5.1+ ก็แทบไม่มีเหตุผลมากนักที่จะเลือก epoll

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

 
GN⁺ 3 시간 전
ความคิดเห็นใน Hacker News
  • ผมลองดู GitHub repository https://github.com/sibexico/TinyGate แวบ ๆ แล้ว ดูเหมือนว่ายังไม่ได้ใช้ การตรึง CPU
    ถ้าตรึงเธรดและ listen socket ไว้กับ CPU แล้วใช้ sockopt SO_INCOMING_CPU ก็น่าจะรีดประสิทธิภาพเพิ่มได้อีกเล็กน้อย
    ถ้าจัดให้ socket ขาออกเรียงตาม CPU ด้วยก็น่าจะดีขึ้นพอสมควร แต่เท่าที่รู้ยังไม่มี API ที่ดีสำหรับเรื่องนี้ ใน Linux มี API สำหรับ traffic steering/flow steering สำหรับ NIC ที่รองรับอยู่ และถ้ารู้ว่า NIC ใช้แฮชอะไร—น่าจะเป็น Toeplitz—ก็อาจเลือก source port ที่ไป backend ให้เหมาะเพื่อให้แฮชออกมาตรงได้
    เป้าหมายคือทำให้พร็อกซีจัดการแพ็กเก็ตได้โดยไม่ต้องสื่อสารข้าม CPU

    • v0 กับ v1 ของ repository นี้เป็น คนละ implementation กันโดยสิ้นเชิง ที่แทบเขียนใหม่ตั้งแต่ต้น และตอนนี้กำลังทำ implementation ที่สามอยู่ ซึ่งน่าจะเป็นตัวสุดท้ายแล้ว การเลือกสถาปัตยกรรมก็เปลี่ยนไปอย่างสิ้นเชิงด้วย
    • อยากเห็น benchmark ของแพตช์นั้น
  • น่าจะลองดู https://github.com/concurrencykit/ck กับ https://github.com/microsoft/mimalloc ด้วย น่าจะเข้ากันได้ดีกับ reverse proxy แบบ zero-copy และจัดแนวหน่วยความจำ
    ถ้าอยากใส่ระบบป้องกัน DDoS กับฟีเจอร์ L4 ขั้นสูงกว่านี้ https://docs.ebpf.io/ebpf-library/libxdp/libxdp/ ก็น่าดูเหมือนกัน

    • แผนคือจะไปดู allocator หลังจากใช้การปรับแต่งในเลเยอร์อื่นก่อน ตอนนี้กำลังศึกษา allocator กับนักศึกษาอยู่ และโพสต์ก่อนหน้าบนบล็อกก็พูดถึง custom allocator ที่ทำด้วยภาษา Zig
  • เป็นบทความที่ดีมาก
    บทความนี้ทำให้ผมตกลงไปในโพรงกระต่ายของ uring, การพัฒนาเคอร์เนล และ C แม้ว่าจะพัฒนา Rust กับ C++ มาค่อนข้างนาน แต่ใน โปรแกรม C ขนาดเล็กถึงกลางนั้นมีทั้งความเรียบง่ายและความเป็นศิลปะอยู่ด้วย

  • ในเว็บเซิร์ฟเวอร์ที่ใช้ io_uring ยังไม่ได้ทดสอบ shared buffer เพราะส่งข้อมูลตรงจากพื้นที่ที่ mmap ไว้ แทนที่จะอ่านจากไฟล์แล้วค่อยเขียนออกไป
    จริง ๆ แล้วอยากใช้ sendfile ผ่าน io_uring แต่ตอนนี้ยังไม่รองรับ
    บทความที่ใส่คำฮิตอย่าง Rust และ kTLS ไว้ด้วย: https://blog.habets.se/2025/04/io-uring-ktls-and-rust-for-ze...
    มีขึ้นบน HN ด้วย: https://news.ycombinator.com/item?id=44980865

    • เผื่อไว้ว่า splice(2) มี implementation แล้ว ดังนั้นจึงใช้ วิธีคล้าย sendfile ผ่าน uring ได้ แม้จะไม่สะดวกเท่า sendfile แต่ก็น่าจะทำงานได้ใกล้เคียงกันมาก
  • ถ้าทำด้วย DPDK จะซับซ้อนขึ้นมาก แต่ในแง่ประสิทธิภาพก็มีโอกาส เหนือกว่า nginx แบบขาดลอย
    ถ้าทำให้รันบน FPGA ได้ก็จะยิ่งซับซ้อนขึ้นไปอีก
    บทเรียนคือ ถ้าต้องการประสิทธิภาพ คุณต้องพร้อมจะเจาะทะลุชั้น abstraction เหมือนมีดร้อนผ่าเนย แต่ทุกอย่างก็จะยากขึ้นตามไปด้วย วิธีใช้ socket และหนึ่งเธรดต่อหนึ่งการเชื่อมต่อเป็นแนวทางที่ดีในยุคที่เครือข่ายช้ากว่า CPU มาก และแม้ทุกวันนี้ ในหลายกรณีก็ยังเป็นวิธีที่ง่ายที่สุดอยู่

  • ผมก็สงสัยเรื่องนี้มาตลอดเหมือนกัน เลยเพิ่งลองเขียน implementation ของ HTTP file server หลายแบบเพื่อทำความเข้าใจความต่างหลัก ๆ
    https://theconsensus.dev/p/2026/05/18/serving-files-three-wa...

  • ในบริบทของพร็อกซี ควรพูดถึง busy polling ของ epoll_wait ด้วย ผมเพิ่งไปดูตอนพิจารณาตัวเลือก low-latency และดูเหมือนว่าจะเข้าใกล้ user-space busy polling ได้ด้วย socket ธรรมดาโดยไม่ต้องพึ่ง DPDK/VMA/io_uring และ Fastly ก็มีส่วนร่วมและใช้งานอยู่
    มัน low-level มากจนผมยังบอกไม่ได้ว่าเข้าใจทั้งหมด รู้แค่แนวคิดคร่าว ๆ เลยขอแปะลิงก์ไว้ มันทำงานได้เฉพาะต่อ NAPI epoll context และยังควบคุม NAPI ID ได้ไม่ง่าย แต่ถ้าใช้ทั้งเครื่องเป็นพร็อกซีโดยเฉพาะ ก็อาจใช้วิธีง่าย ๆ โดยจัด socket ตาม NAPI ID ให้กับ poller เฉพาะตัวได้
    กรณีใช้งานของผมไม่ใช่พร็อกซี แต่เป็นการ poll socket จำนวน N ตัวบนเครื่องเดียว แล้วประมวลผลข้อมูลที่ได้รับ ในกรณีนั้นมันดูไม่น่าทำได้ แม้อาจเป็นไปได้ถ้าให้เธรดเดียว poll NAPI context แบบ round-robin สักวันหนึ่งคงดีถ้ามีวิธีง่าย ๆ ที่จะบอกเคอร์เนลว่า “เชื่อผมเถอะ socket เดี่ยวตัวนี้เดี๋ยวผม poll เองแน่ ๆ ดังนั้นอย่าใช้เส้นทาง IRQ เด็ดขาด”
    การถกเถียงก่อนหน้าบน HN เกี่ยวกับฟีเจอร์เคอร์เนลนี้: https://news.ycombinator.com/item?id=43749271
    สไลด์นำเสนอที่ดีจากผู้มีส่วนร่วมของ Fastly มีไดอะแกรมที่ช่วยให้เข้าใจภาพรวมได้ง่าย: https://netdevconf.info/0x18/docs/netdev-0x18-paper10-talk-s...
    บทความ LWN: https://lwn.net/Articles/1008399/, https://lwn.net/Articles/997491/, https://lwn.net/Articles/959462/
    เอกสารเคอร์เนล: https://docs.kernel.org/networking/napi.html#irq-mitigation

  • ถ้าคุณชอบ C++ กับเครือข่ายแบบอะซิงโครนัส ก็มี Boost.Asio

    • เมื่อไม่นานมานี้ลองเปลี่ยน Asio ไปใช้ event loop epoll ที่ทำเองโดยตรงแล้ว RPS ดีขึ้นประมาณ 16% เป็นผลจาก SQL server ขนาดพอเหมาะ จึงควรระวังเวลาใช้ไลบรารีที่แพ็กมาอย่างดี
    • พอลองเปลี่ยน epoll backend ของ Asio เป็น io_uring บน database server แล้ว การใช้ CPU กลับพุ่งสูงขึ้นมาก มีโอกาสสูงว่าจะขึ้นอยู่กับวิธีใช้งานและวิธีผนวกเข้ากับโค้ด event
    • Boost ใช้งานลำบากเกินไป เป็นชุด dynamic library ขนาดใหญ่ที่ทั้ง build และใช้งานยุ่งยาก ทั้งที่ใช้ CMake อยู่แล้ว แต่กระบวนการติดตั้ง Boost ให้ระบบหาเจอนั้นน่ารำคาญมาก อย่างไรก็ตาม นี่เป็นประสบการณ์บน Mac
  • พอถึงราวปี 2050 ก็น่าจะมีวิธี polling socket บน Linux สัก 20 แบบ

    • ใช่ แม้แต่ใน io_uring เองก็เป็นแบบนั้น เพื่อให้เร็วขึ้นก็มีโหมดยิงครั้งเดียวของ io_uring ออกมา แล้วต่อมาก็ยังมีโหมดยิงหลายครั้งอีก
  • ใช่ io_uring เร็วกว่า epoll อย่างชัดเจน ในกรณีของผม io_uring ดูเหมือนจะเร็วกว่าในแง่ requests per second อยู่ ประมาณ 20%
    ปัญหาคือต้องเปิดใช้ในเคอร์เนลอย่างชัดเจน และแทบทุกที่ปิดไว้ด้วยเหตุผลด้านความปลอดภัย ดูเหมือนจะมีการแชร์หน่วยความจำโดยตรงระหว่างเคอร์เนลกับ user space ซึ่งค่อนข้างน่ากังวล ช่วงหลังก็มี exploit ที่เจาะ io_uring หลายครั้งด้วย
    เพราะแบบนี้ แม้แต่โปรเจกต์วิศวกรรมอย่าง Go ที่พยายามรีดประสิทธิภาพสูงสุดเท่าที่ทำได้ ก็ยังไม่ฝัง io_uring เป็นค่าเริ่มต้นที่สมเหตุสมผลแบบลึก ๆ ถ้าคุณยอมรับความเสี่ยงได้ ก็ยังรันมันเองในภาษาที่คุณชอบได้ มันเร็วกว่า แต่สิ่งที่ต้องแลกคือความเป็นไปได้ของ exploit ที่อาจเกิดขึ้น

    • สาเหตุหลักที่ถูกปิดใช้งานตอนนี้ได้รับการแก้แล้ว ใน RC รุ่นล่าสุดมีการรองรับ cBPFเข้ามา ทำให้สามารถจำกัดงานที่รันได้แทนการปิดทั้งหมด
    • แล้วแต่กรณี มีครั้งหนึ่งที่วิธีแบบ POSIX ของผมซึ่งทำ io_uring emulation ด้วย poll ไม่ใช่ epoll กลับเร็วกว่า io_uring เอง แต่สำหรับบัฟเฟอร์ zero-copyขนาดใหญ่ io_uring ยังดีที่สุด
      io_uring มีประโยชน์แม้ไม่ใช่ asynchronous I/O เช่น สามารถทำสายโซ่ของงานอย่าง mkdir แล้วค่อยเปิดไดเรกทอรีนั้น ให้เป็นเหมือนงาน atomic เดียวได้
      ถ้าพยายามดันจำนวน packet ต่อวินาทีในงานเครือข่ายให้สูงสุด ก็จะชนข้อจำกัดของเคอร์เนล[1] อย่างรวดเร็ว และสุดท้ายต้องพึ่งความสามารถอย่าง GSO/GRO หรือไม่ก็ข้าม network stack ไปเลย
      1: https://github.com/axboe/liburing/discussions/1346
    • ตอนนี้ RHEL 9 และ 10 รองรับ io_uring แบบเต็มโดยค่าเริ่มต้นแล้ว เป็นเรื่องที่เพิ่งเกิดขึ้นไม่นาน แต่นั่นก็ครอบคลุมสภาพแวดล้อมติดตั้ง Linux ในองค์กรจำนวนมาก Gemini “บอกว่า” Ubuntu กับ SuSE ก็รองรับด้วย แต่ไม่ได้ให้ลิงก์มายืนยัน
      https://access.redhat.com/solutions/4723221
      Go เองก็ควรกลับมาทบทวนการรองรับอีกครั้ง น่าจะคุ้มค่าที่จะลอง
    • สำหรับโปรเจกต์อย่าง Go จะมีทางเลือกให้ตรวจจับ**ความสามารถของ io_uring**เพียงครั้งเดียวตอนเริ่มรันไทม์ไม่ได้หรือ? exploit ไม่ได้เป็นปัญหาเฉพาะของโปรแกรมที่เลือกใช้ io_uring แต่เป็นปัญหาของทั้ง OS ไม่ใช่หรือ?
    • ระบบเครือข่ายแบบ polling mode ทุกประเภท—RDMA, DPDK, io_uring—สุดท้ายแล้วมักมีลักษณะที่ผู้ใช้ต้องรับผิดชอบเรื่อง memory isolation เอง
      แต่ในกรณีของ io_uring ตัว ring อยู่ในเคอร์เนล จึงไม่ใช่ว่าผู้ใช้จะทำอะไรได้มากนัก
      หวังว่าด้วยอานิสงส์จาก LLM มันจะดีขึ้นในอนาคต แต่เป็นปัญหาที่แก้ยาก ตัวเคอร์เนลเองก็จัดการได้ยากมาก และหลายคนก็ยังไม่เข้าใจวิธีจูนสิ่งนี้อย่างถูกต้อง