อันตรายที่ซ่อนเร้นของ select() และเหตุใดการจัดการ I/O แบบมัลติเพล็กซ์สมัยใหม่จึงสำคัญ

ทีมชุมชน BigGo
อันตรายที่ซ่อนเร้นของ select() และเหตุใดการจัดการ I/O แบบมัลติเพล็กซ์สมัยใหม่จึงสำคัญ

ในโลกของเครือข่ายสมรรถนะสูง วิธีการที่เซิร์ฟเวอร์จัดการกับการเชื่อมต่อพร้อมกันหลายพันครั้งสามารถทำให้แอปพลิเคชันประสบความสำเร็จหรือล้มเหลวได้ ในขณะที่ระบบสมัยใหม่เช่น 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)