ในโลกของเครือข่ายสมรรถนะสูง วิธีการที่เซิร์ฟเวอร์จัดการกับการเชื่อมต่อพร้อมกันหลายพันครั้งสามารถทำให้แอปพลิเคชันประสบความสำเร็จหรือล้มเหลวได้ ในขณะที่ระบบสมัยใหม่เช่น epoll และ kqueue เป็นกำลังหลักให้กับบริการเว็บที่ขยายขนาดได้ในปัจจุบัน บรรพบุรุษของพวกเขาอย่าง select และ poll ยังคงมีข้อผิดพลาดน่าประหลาดใจที่ก่อให้เกิดการถกเถียงในหมู่นักพัฒนาอย่างต่อเนื่อง การอภิปรายในชุมชนล่าสุดเปิดเผยว่า API ที่หลายคนมองว่าเป็นระบบเก่ายังคงสร้างความเสี่ยงที่แท้จริงในการเขียนโปรแกรมสมัยใหม่
มรดกอันตรายของ System Call ชื่อ select()
System call ชื่อ select() ซึ่งเปิดตัวในปี 1983 มีข้อบกพร่องพื้นฐานในการออกแบบที่สามารถนำไปสู่ความเสียหายของสแต็กและทำให้โปรแกรมค้างได้ ปัญหานี้เกิดจากวิธีที่ select() จัดการกับไฟล์เดสคริปเตอร์ที่เกินขีดจำกัด FD_SETSIZE ซึ่งโดยค่าเริ่มต้นในระบบส่วนใหญ่อยู่ที่ 1024 เมื่อนักพัฒนาพยายามตรวจสอบไฟล์เดสคริปเตอร์ที่เกินขีดจำกัดนี้ select() จะอ่านและเขียนไปยังตำแหน่งหน่วยความจำที่เกินโครงสร้าง fd_set ที่จัดสรรไว้ ซึ่งอาจทำให้คอลสแต็กเสียหายด้วยผลลัพธ์ที่คาดเดาไม่ได้
หากคุณพยายามตรวจสอบไฟล์เดสคริปเตอร์หมายเลข 2000 select จะวนลูปผ่าน fd ตั้งแต่ 0 ถึง 1999 และจะอ่านข้อมูลที่ไม่มีความหมาย ปัญหาที่ใหญ่กว่าคือเมื่อมันพยายามตั้งค่าผลลัพธ์สำหรับไฟล์เดสคริปเตอร์ที่เกิน 1024 และพยายามตั้งค่าฟิลด์บิตนั้น — มันจะเขียนบางสิ่งแบบสุ่มลงบนสแต็กและในที่สุดก็ทำให้กระบวนการค้าง
ช่องโหว่นี้มีอยู่เพราะเคอร์เนลเชื่อถือพารามิเตอร์ nfds ที่ให้มาจากยูสเซอร์สเปซโดยไม่ตรวจสอบกับขนาดจริงของบัฟเฟอร์ fd_set แม้ว่าเคอร์เนลจะตรวจจับความพยายามในการเข้าถึงหน่วยความจำที่ไม่ได้แมปได้ แต่ก็ไม่สามารถป้องกันความเสียหายได้เมื่อหน่วยความจำที่อยู่นอกขอบเขตนั้นถูกแมปและสามารถเขียนได้ สิ่งนี้สร้างความฝันร้ายในการดีบัก ซึ่งการสุ่มสแต็กทำให้การค้างวินิจฉัยและทำซ้ำได้ยาก
การปรับปรุงของ Poll และข้อจำกัดที่ยังคงอยู่
System call ชื่อ poll() ซึ่งเปิดตัวในปี 1986 และถูกเพิ่มเข้าไปใน Linux libc ในปี 1997 ได้แก้ไขข้อบกพร่องหลายประการของ select() โดยขจัดขีดจำกัดไฟล์เดสคริปเตอร์ 1024 ตัวและให้ API ที่สมเหตุสมผลมากขึ้นโดยใช้อาร์เรย์แบบเบาบางของโครงสร้าง pollfd แทนที่บิตแมสก์ นักพัฒนาสามารถระบุรายการไฟล์เดสคริปเตอร์ที่ต้องการตรวจสอบได้อย่างชัดเจนโดยไม่ต้องกังวลเกี่ยวกับขีดจำกัดเชิงตัวเลข
อย่างไรก็ตาม poll() ยังคงรักษาลักษณะสมรรถนะพื้นฐานเดียวกันกับ select() นั่นคือความซับซ้อน O(n) ซึ่ง system call ต้องสแกนผ่านไฟล์เดสคริปเตอร์ทั้งหมดที่ให้มาโดยไม่คำนึงว่ามีกี่ตัวที่ทำงานอยู่จริง สิ่งนี้ทำให้อินเทอร์เฟซทั้งสองไม่เหมาะสำหรับแอปพลิเคชันที่จัดการกับการเชื่อมต่อพร้อมกันหลายพันครั้ง แม้ว่าพวกมันจะยังคงเพียงพอสำหรับกรณีการใช้งานที่เรียบง่ายกว่า เช่น เครื่องมือ command-line ที่ตรวจสอบไฟล์เดสคริปเตอร์เพียงไม่กี่ตัว
ภาพรวมของการจัดการ I/O แบบมัลติเพล็กซ์สมัยใหม่
แอปพลิเคชันสมรรถนะสูงในปัจจุบันมักจะเลือกระหว่าง epoll บนระบบ Linux และ kqueue บนระบบที่ได้มาจาก BSD รวมถึง macOS และ FreeBSD ทั้งสองอย่างให้การแจ้งเตือนเหตุการณ์แบบ O(1) ซึ่งทำให้เหมาะสำหรับเซิร์ฟเวอร์ที่จัดการกับการเชื่อมต่อพร้อมกัน 10,000+ การเชื่อมต่อ ข้อแตกต่างหลักอยู่ที่ API ของพวกเขา: epoll ใช้อินเทอร์เฟซแบบจำนวนเต็มที่เรียบง่ายกว่า ในขณะที่ kqueue ให้ความสามารถในการกรองเหตุการณ์ที่หลากหลายกว่าผ่านโครงสร้าง kevent ของมัน
ชุมชนยังคงแบ่งออกในเรื่องชั้นของการทำให้เป็นนามธรรม นักพัฒนาบางส่วนสนับสนุนการใช้งาน system call โดยตรง โดยให้เหตุผลว่าหาก poll() ทำงานได้ ก็ควรอยู่กับ poll() มันสามารถพกพาได้ในระดับสากล สิ่งต่างๆ ยังคงสะอาดตาและเรียบง่ายเป็นผล นักพัฒนาอื่นๆ ชอบไลบรารีข้ามแพลตฟอร์มเช่น libevent หรือ libuv ที่ทำให้ความแตกต่างของระบบเป็นนามธรรม แต่นี่เป็นการนำความซับซ้อนเพิ่มเติมและข้อกังวลในการจัดการการพึ่งพามา
การเปรียบเทียบ API สำหรับ I/O Multiplexing
API | ปีที่เปิดตัว | ความซับซ้อน | ขีดจำกัด FD | คุณสมบัติสำคัญ |
---|---|---|---|---|
select | 1983 | O(n) | 1024 (ค่าเริ่มต้น) | ใช้ bitmask เรียบง่ายแต่มีข้อจำกัด |
poll | 1986 (1997 Linux) | O(n) | ไม่มีขีดจำกัดแน่นอน | sparse array รองรับ event มากขึ้น |
epoll | Linux 2.5.44 (2002) | O(1) | ขีดจำกัดของระบบ | Edge/level triggered ขยายขนาดได้ดี |
kqueue | FreeBSD 4.1 (2000) | O(1) | ขีดจำกัดของระบบ | การกรองที่หลากหลาย รองรับ event หลายประเภท |
ข้อควรพิจารณาในทางปฏิบัติสำหรับนักพัฒนา
สำหรับโครงการใหม่ ฉันทามติสนับสนุน poll() มากกว่า select() อย่างมาก เนื่องจากไม่มีขีดจำกัดไฟล์เดสคริปเตอร์ตามอำเภอใจ ดังที่ผู้แสดงความคิดเห็นหนึ่งคนระบุไว้ ในสิ่งใหม่ใดๆ คุณควรใช้ poll ไม่ใช่ select พวกมันเป็น API ที่เหมือนกันโดยพื้นฐาน แต่ poll ไม่มีขีดจำกัดแบบตายตัวและทำงานกับ fd จำนวนสูงได้ คำแนะนำนี้ใช้ได้แม้สำหรับแอปพลิเคชันที่ไม่ได้ต้องการการขยายขนาดครั้งใหญ่ เนื่องจากหลีกเลี่ยงปัญหาความเสียหายของสแต็กที่มีอยู่ใน select()
การเลือกระหว่าง system call โดยตรงและไลบรารีแบบนามธรรมขึ้นอยู่กับข้อกำหนดของโครงการอย่างมาก ยูทิลิตี้ขนาดเล็กที่มีความต้องการ I/O เรียบง่ายอาจพบว่า poll() เพียงพออย่างสมบูรณ์ ในขณะที่แอปพลิเคชันที่ซับซ้อนจะได้ประโยชน์จากความสามารถในการพกพาและคุณสมบัติขั้นสูงที่ให้โดยไลบรารีเช่น libuv ที่น่าสนใจคือ แม้แต่นักพัฒนาระบบปฏิบัติการก็ตระหนักถึงการแลกเปลี่ยนนี้ — OpenBSD รวมและใช้ libevent ภายใน แม้จะมี kqueue ให้ใช้งาน
เมื่อไหร่ควรใช้วิธี I/O Multiplexing แต่ละแบบ
- select: โค้ดเก่า, เครื่องมือ CLI ที่มี FDs น้อย (<10), ระบบฝังตัว
- poll: แอปพลิเคชันข้ามแพลตฟอร์ม, การทำงานพร้อมกันระดับปานกลาง (10-1000 FDs)
- epoll: เซิร์ฟเวอร์ประสิทธิภาพสูงเฉพาะ Linux (การเชื่อมต่อพร้อมกัน 1000+ ขึ้นไป)
- kqueue: เซิร์ฟเวอร์ประสิทธิภาพสูงสำหรับ BSD/macOS
- ไลบรารีแบบ Abstraction (libuv, libevent): แอปพลิเคชันข้ามแพลตฟอร์มที่ต้องการประสิทธิภาพสูง
อนาคตของการจัดการ I/O แบบมัลติเพล็กซ์
เมื่อมองไปข้างหน้า อินเทอร์เฟซใหม่ๆ เช่น io_uring บน Linux สัญญาว่าจะให้สมรรถนะที่ยิ่งใหญ่ขึ้นผ่านการดำเนินการ I/O แบบอะซิงโครนัสอย่างแท้จริง อย่างไรก็ตาม สิ่งเหล่านี้มาพร้อมกับความซับซ้อนและข้อควรระวังของตัวเอง การขาดการมาตรฐานในระบบที่คล้าย Unix หมายความว่านักพัฒนามีแนวโน้มที่จะยังคงพึ่งพาไลบรารีแบบนามธรรมมากกว่า API ดั้งเดิมสำหรับแอปพลิเคชันข้ามแพลตฟอร์ม
วิวัฒนาการอย่างต่อเนื่องของการจัดการ I/O แบบมัลติเพล็กซ์สะท้อนให้เห็นรูปแบบที่กว้างขึ้นในการเขียนโปรแกรมระบบ: แต่ละรุ่นแก้ปัญหาสมรรถนะของรุ่นก่อนหน้าในขณะที่นำความซับซ้อนใหม่ๆ มา สิ่งที่เริ่มต้นจากการประมวลผล I/O แบบเรียงลำดับอย่างง่ายได้วิวัฒนาการผ่าน select(), poll() และตอนนี้คือ epoll/kqueue สู่สถาปัตยกรรมแบบขับเคลื่อนโดยเหตุการณ์ที่ซับซ้อนซึ่งเป็นกำลังหลักให้กับแอปพลิเคชันที่ต้องการสูงที่สุดของอินเทอร์เน็ต
แม้จะมีความก้าวหน้า บทเรียนจากข้อบกพร่องในการออกแบบของ select() ยังคงมีความเกี่ยวข้อง การตัดสินใจในการออกแบบ API ที่ทำขึ้นเมื่อหลายทศวรรษที่แล้วสามารถสร้างปัญหาด้านความปลอดภัยและความเสถียรที่ละเอียดอ่อนซึ่งคงอยู่ผ่านรุ่นของซอฟต์แวร์ ขณะที่เราสร้างระบบ I/O ที่ใหม่และซับซ้อนมากขึ้น การเข้าใจประวัติศาสตร์นี้ช่วยให้เราหลีกเลี่ยงการทำผิดพลาดเดิมซ้ำในขณะที่ชื่นชมว่าทำไมการเลือกการออกแบบบางอย่างจึงถูกตัดสินใจ
อ้างอิง: I/O Multiplexing (select vs. poll vs. epoll/kqueue)