เปรียบเทียบ epoll และ io_uring ของ Linux
(sibexi.co)- รีเวิร์สพร็อกซี 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
- ลงทะเบียน file descriptor ของ
-
ตัวอย่าง 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 ความคิดเห็น
ความคิดเห็นใน 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
น่าจะลองดู https://github.com/concurrencykit/ck กับ https://github.com/microsoft/mimalloc ด้วย น่าจะเข้ากันได้ดีกับ reverse proxy แบบ zero-copy และจัดแนวหน่วยความจำ
ถ้าอยากใส่ระบบป้องกัน DDoS กับฟีเจอร์ L4 ขั้นสูงกว่านี้ https://docs.ebpf.io/ebpf-library/libxdp/libxdp/ ก็น่าดูเหมือนกัน
เป็นบทความที่ดีมาก
บทความนี้ทำให้ผมตกลงไปในโพรงกระต่ายของ
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
epollcontext และยังควบคุม 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
epollที่ทำเองโดยตรงแล้ว RPS ดีขึ้นประมาณ 16% เป็นผลจาก SQL server ขนาดพอเหมาะ จึงควรระวังเวลาใช้ไลบรารีที่แพ็กมาอย่างดีepollbackend ของ Asio เป็นio_uringบน database server แล้ว การใช้ CPU กลับพุ่งสูงขึ้นมาก มีโอกาสสูงว่าจะขึ้นอยู่กับวิธีใช้งานและวิธีผนวกเข้ากับโค้ด eventพอถึงราวปี 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 ที่อาจเกิดขึ้นio_uringemulation ด้วย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
io_uringแบบเต็มโดยค่าเริ่มต้นแล้ว เป็นเรื่องที่เพิ่งเกิดขึ้นไม่นาน แต่นั่นก็ครอบคลุมสภาพแวดล้อมติดตั้ง Linux ในองค์กรจำนวนมาก Gemini “บอกว่า” Ubuntu กับ SuSE ก็รองรับด้วย แต่ไม่ได้ให้ลิงก์มายืนยันhttps://access.redhat.com/solutions/4723221
Go เองก็ควรกลับมาทบทวนการรองรับอีกครั้ง น่าจะคุ้มค่าที่จะลอง
io_uring**เพียงครั้งเดียวตอนเริ่มรันไทม์ไม่ได้หรือ? exploit ไม่ได้เป็นปัญหาเฉพาะของโปรแกรมที่เลือกใช้io_uringแต่เป็นปัญหาของทั้ง OS ไม่ใช่หรือ?io_uring—สุดท้ายแล้วมักมีลักษณะที่ผู้ใช้ต้องรับผิดชอบเรื่อง memory isolation เองแต่ในกรณีของ
io_uringตัว ring อยู่ในเคอร์เนล จึงไม่ใช่ว่าผู้ใช้จะทำอะไรได้มากนักหวังว่าด้วยอานิสงส์จาก LLM มันจะดีขึ้นในอนาคต แต่เป็นปัญหาที่แก้ยาก ตัวเคอร์เนลเองก็จัดการได้ยากมาก และหลายคนก็ยังไม่เข้าใจวิธีจูนสิ่งนี้อย่างถูกต้อง