ประสิทธิภาพ I/O ฝั่งเซิร์ฟเวอร์: โหนดเทียบกับ PHP เทียบกับ Java เทียบกับ Go

เผยแพร่แล้ว: 2022-03-11

การทำความเข้าใจโมเดลอินพุต/เอาท์พุต (I/O) ของแอปพลิเคชันของคุณอาจหมายถึงความแตกต่างระหว่างแอปพลิเคชันที่เกี่ยวข้องกับภาระงานที่ได้รับ กับแอปพลิเคชันที่พังทลายเมื่อเผชิญกับกรณีการใช้งานจริง บางทีแม้ว่าแอปพลิเคชันของคุณจะมีขนาดเล็กและไม่รองรับงานจำนวนมาก แต่ก็อาจมีความสำคัญน้อยกว่ามาก แต่เมื่อปริมาณการรับส่งข้อมูลของแอปพลิเคชันของคุณเพิ่มขึ้น การทำงานกับรูปแบบ I/O ที่ไม่ถูกต้องจะทำให้คุณตกอยู่ในโลกแห่งความเจ็บปวด

และเช่นเดียวกับสถานการณ์อื่นๆ ที่เป็นไปได้หลายวิธี ไม่ใช่แค่ว่าวิธีใดดีกว่า แต่เป็นเรื่องของการทำความเข้าใจจุดประนีประนอม ลองเดินข้ามแนวนอนของ I/O และดูว่าเราสามารถสอดแนมอะไรได้บ้าง

ในบทความนี้ เราจะเปรียบเทียบ Node, Java, Go และ PHP กับ Apache โดยจะอภิปรายว่าภาษาต่างๆ จำลอง I/O อย่างไร ข้อดีและข้อเสียของแต่ละรุ่น และสรุปด้วยเกณฑ์มาตรฐานพื้นฐานบางประการ หากคุณกังวลเกี่ยวกับประสิทธิภาพ I/O ของเว็บแอปพลิเคชันถัดไป บทความนี้เหมาะสำหรับคุณ

ข้อมูลเบื้องต้นเกี่ยวกับ I/O: การทบทวนอย่างรวดเร็ว

เพื่อให้เข้าใจถึงปัจจัยที่เกี่ยวข้องกับ I/O อันดับแรก เราต้องทบทวนแนวคิดที่ระดับระบบปฏิบัติการ แม้ว่าไม่น่าจะต้องจัดการกับแนวคิดเหล่านี้โดยตรง แต่คุณจัดการกับแนวคิดเหล่านี้ทางอ้อมผ่านสภาพแวดล้อมรันไทม์ของแอปพลิเคชันของคุณตลอดเวลา และรายละเอียดก็สำคัญ

การโทรระบบ

ประการแรก เรามีการเรียกระบบซึ่งสามารถอธิบายได้ดังนี้:

  • โปรแกรมของคุณ (ใน "พื้นที่ผู้ใช้" อย่างที่พวกเขาพูด) ต้องขอให้เคอร์เนลของระบบปฏิบัติการดำเนินการ I/O ในนามของโปรแกรม
  • “syscall” เป็นวิธีที่โปรแกรมของคุณขอให้เคอร์เนลทำอะไรบางอย่าง ลักษณะเฉพาะของการดำเนินการนี้แตกต่างกันไปในแต่ละ OS แต่แนวคิดพื้นฐานเหมือนกัน จะมีคำสั่งเฉพาะบางอย่างที่ถ่ายโอนการควบคุมจากโปรแกรมของคุณไปยังเคอร์เนล (เช่น การเรียกใช้ฟังก์ชัน แต่มีซอสพิเศษบางอย่างสำหรับการจัดการกับสถานการณ์นี้โดยเฉพาะ) โดยทั่วไป syscalls กำลังบล็อกอยู่ หมายความว่าโปรแกรมของคุณรอให้เคอร์เนลกลับสู่โค้ดของคุณ
  • เคอร์เนลดำเนินการ I/O พื้นฐานบนอุปกรณ์จริงที่มีปัญหา (ดิสก์ การ์ดเครือข่าย ฯลฯ) และตอบกลับ syscall ในโลกแห่งความจริง เคอร์เนลอาจต้องทำหลายอย่างเพื่อตอบสนองคำขอของคุณ รวมถึงการรอให้อุปกรณ์พร้อม อัปเดตสถานะภายใน ฯลฯ แต่ในฐานะนักพัฒนาแอปพลิเคชัน คุณไม่สนใจเรื่องนี้ นั่นคืองานของเคอร์เนล

Syscalls Diagram

การบล็อกกับการโทรที่ไม่ปิดกั้น

ตอนนี้ ฉันเพิ่งพูดไปข้างต้นว่า syscalls กำลังบล็อกอยู่ และนั่นก็เป็นความจริงในความหมายทั่วไป อย่างไรก็ตาม การเรียกบางสายถูกจัดประเภทเป็น “ไม่มีการบล็อก” ซึ่งหมายความว่าเคอร์เนลรับคำขอของคุณ วางไว้ในคิวหรือบัฟเฟอร์ที่ใดที่หนึ่ง จากนั้นส่งคืนทันทีโดยไม่ต้องรอให้ I/O เกิดขึ้นจริง ดังนั้น "บล็อก" ในช่วงเวลาสั้นๆ เท่านั้น นานพอที่จะทำตามคำขอของคุณได้

ตัวอย่างบางส่วน (ของ syscall ของ Linux) อาจช่วยชี้แจง: - read() เป็นการบล็อกการโทร - คุณส่งผ่านแฮนเดิลที่บอกว่าไฟล์ใดและบัฟเฟอร์ของตำแหน่งที่จะส่งข้อมูลที่อ่าน และการเรียกกลับเมื่อมีข้อมูลอยู่ที่นั่น โปรดทราบว่าสิ่งนี้มีข้อดีของการเป็นคนดีและเรียบง่าย - epoll_create() , epoll_ctl() และ epoll_wait() เป็นการเรียกตามลำดับ ให้คุณสร้างกลุ่มของหมายเลขอ้างอิงเพื่อฟัง เพิ่ม/ลบตัวจัดการจากกลุ่มนั้น แล้วบล็อกจนกว่าจะมีกิจกรรมใดๆ สิ่งนี้ช่วยให้คุณควบคุมการดำเนินการ I/O จำนวนมากได้อย่างมีประสิทธิภาพด้วยเธรดเดียว แต่ฉันกำลังก้าวไปข้างหน้า นี่เป็นสิ่งที่ดีถ้าคุณต้องการฟังก์ชันการทำงาน แต่อย่างที่คุณเห็นมันซับซ้อนกว่าในการใช้งานอย่างแน่นอน

สิ่งสำคัญคือต้องเข้าใจลำดับความสำคัญของความแตกต่างของเวลาที่นี่ หากแกนประมวลผลของ CPU ทำงานที่ 3GHz โดยไม่ได้ปรับให้เหมาะสมตามที่ CPU สามารถทำได้ แกนประมวลผลจะทำงาน 3 พันล้านรอบต่อวินาที (หรือ 3 รอบต่อนาโนวินาที) การเรียกระบบที่ไม่บล็อกอาจใช้เวลาในลำดับ 10 วินาทีของรอบจึงจะเสร็จสมบูรณ์ - หรือ "ไม่กี่นาโนวินาที" การโทรที่บล็อกการรับข้อมูลผ่านเครือข่ายอาจใช้เวลานานกว่ามาก - สมมติว่า 200 มิลลิวินาที (1/5 ของวินาที) ตัวอย่างเช่น การโทรแบบไม่บล็อกใช้เวลา 20 นาโนวินาที และการบล็อกการโทรใช้เวลา 200,000,000 นาโนวินาที กระบวนการของคุณรอการบล็อกสายนานขึ้น 10 ล้านเท่า

การบล็อกกับ Syscalls ที่ไม่ปิดกั้น

เคอร์เนลมีวิธีการทำทั้งการบล็อก I/O (“อ่านจากการเชื่อมต่อเครือข่ายนี้และให้ข้อมูลแก่ฉัน”) และ I/O ที่ไม่บล็อก (“บอกฉันเมื่อการเชื่อมต่อเครือข่ายใด ๆ เหล่านี้มีข้อมูลใหม่”) และกลไกใดที่ใช้จะบล็อกกระบวนการเรียกในระยะเวลาที่แตกต่างกันอย่างมาก

การจัดตารางเวลา

สิ่งที่สามที่สำคัญในการติดตามคือสิ่งที่เกิดขึ้นเมื่อคุณมีเธรดหรือกระบวนการจำนวนมากที่เริ่มบล็อก

สำหรับจุดประสงค์ของเรา ไม่มีความแตกต่างอย่างมากระหว่างเธรดและกระบวนการ ในชีวิตจริง ความแตกต่างด้านประสิทธิภาพที่เห็นได้ชัดเจนที่สุดคือเนื่องจากเธรดใช้หน่วยความจำร่วมกัน และแต่ละกระบวนการมีพื้นที่หน่วยความจำของตัวเอง ทำให้กระบวนการแยกกันจึงมีแนวโน้มที่จะใช้หน่วยความจำมากกว่ามาก แต่เมื่อเรากำลังพูดถึงการตั้งเวลา สิ่งที่น่าสนใจจริงๆ คือรายการของสิ่งต่างๆ (เธรดและกระบวนการที่เหมือนกัน) ที่แต่ละฝ่ายต้องการเวลาในการดำเนินการบนแกนประมวลผลของ CPU ที่มีอยู่ หากคุณมี 300 เธรดที่ทำงานอยู่และ 8 คอร์เพื่อรัน คุณต้องแบ่งเวลาเพื่อให้แต่ละคอร์มีส่วนแบ่ง โดยแต่ละคอร์ทำงานในช่วงเวลาสั้น ๆ จากนั้นจึงย้ายไปยังเธรดถัดไป ทำได้โดยใช้ "สวิตช์บริบท" ทำให้ CPU สลับจากการเรียกใช้เธรด/กระบวนการหนึ่งไปยังอีกรายการหนึ่ง

การสลับบริบทเหล่านี้มีค่าใช้จ่ายที่เกี่ยวข้อง ซึ่งใช้เวลาพอสมควร ในบางกรณีที่รวดเร็ว อาจน้อยกว่า 100 นาโนวินาที แต่ไม่ใช่เรื่องแปลกที่จะใช้เวลา 1,000 นาโนวินาทีหรือนานกว่านั้นขึ้นอยู่กับรายละเอียดการใช้งาน ความเร็ว/สถาปัตยกรรมของโปรเซสเซอร์ แคชของ CPU ฯลฯ

และยิ่งมีเธรด (หรือกระบวนการ) มากเท่าใด การสลับบริบทก็จะยิ่งมากขึ้นเท่านั้น เมื่อเรากำลังพูดถึงหลายพันเธรด และหลายร้อยนาโนวินาทีสำหรับแต่ละรายการ สิ่งต่างๆ อาจช้ามาก

อย่างไรก็ตาม การโทรแบบไม่ปิดกั้นในสาระสำคัญจะบอกเคอร์เนลว่า "โทรหาฉันเมื่อคุณมีข้อมูลหรือเหตุการณ์ใหม่ ๆ ในการเชื่อมต่อเหล่านี้เท่านั้น" การเรียกที่ไม่บล็อกเหล่านี้ได้รับการออกแบบมาเพื่อรองรับการโหลด I/O ขนาดใหญ่อย่างมีประสิทธิภาพและลดการสลับบริบท

กับฉันจนถึงตอนนี้? เพราะตอนนี้มาถึงส่วนที่สนุกแล้ว: มาดูกันว่าภาษายอดนิยมบางภาษาทำอะไรกับเครื่องมือเหล่านี้ และหาข้อสรุปเกี่ยวกับข้อแลกเปลี่ยนระหว่างความง่ายในการใช้งานและประสิทธิภาพ... และเกร็ดเล็กเกร็ดน้อยอื่นๆ ที่น่าสนใจ

หมายเหตุ แม้ว่าตัวอย่างที่แสดงในบทความนี้จะเล็กน้อย (และบางส่วน โดยแสดงเฉพาะบิตที่เกี่ยวข้อง) การเข้าถึงฐานข้อมูล ระบบแคชภายนอก (memcache และอื่น ๆ ทั้งหมด) และทุกสิ่งที่ต้องใช้ I/O จะต้องดำเนินการเรียก I/O บางประเภทภายใต้ประทุนซึ่งจะมีผลเช่นเดียวกับตัวอย่างง่ายๆ ที่แสดง นอกจากนี้ สำหรับสถานการณ์ที่ I/O ถูกอธิบายว่าเป็น “การบล็อก” (PHP, Java) คำขอ HTTP และการอ่านและการเขียนตอบกลับนั้นกำลังปิดกั้นการโทร: อีกครั้ง I/O ที่ซ่อนอยู่ในระบบที่มีปัญหาด้านประสิทธิภาพการเข้าร่วม เพื่อนำมาพิจารณา

มีหลายปัจจัยในการเลือกภาษาโปรแกรมสำหรับโครงการ มีหลายปัจจัยเมื่อคุณพิจารณาถึงประสิทธิภาพเท่านั้น แต่ถ้าคุณกังวลว่าโปรแกรมของคุณจะถูกจำกัดโดย I/O เป็นหลัก ถ้าประสิทธิภาพของ I/O นั้นสร้างหรือหยุดทำงานสำหรับโครงการของคุณ นี่คือสิ่งที่คุณจำเป็นต้องรู้

แนวทาง "ให้มันง่าย": PHP

ย้อนกลับไปในยุค 90 ผู้คนจำนวนมากสวมรองเท้า Converse และเขียนสคริปต์ CGI ใน Perl จากนั้น PHP ก็เข้ามา และเท่าที่บางคนชอบใช้ มันทำให้หน้าเว็บแบบไดนามิกง่ายขึ้นมาก

โมเดล PHP ใช้ค่อนข้างง่าย มีรูปแบบบางอย่าง แต่เซิร์ฟเวอร์ PHP เฉลี่ยของคุณมีลักษณะดังนี้:

คำขอ HTTP มาจากเบราว์เซอร์ของผู้ใช้และเข้าสู่เว็บเซิร์ฟเวอร์ Apache ของคุณ Apache สร้างกระบวนการแยกกันสำหรับแต่ละคำขอ โดยมีการปรับให้เหมาะสมเพื่อนำกลับมาใช้ใหม่ เพื่อลดจำนวนสิ่งที่ต้องทำ (กระบวนการสร้างค่อนข้างพูดช้า) Apache เรียก PHP และบอกให้เรียกใช้ไฟล์ .php ที่เหมาะสมบนดิสก์ โค้ด PHP ทำงานและบล็อกการโทร I/O คุณเรียก file_get_contents() ใน PHP และภายใต้ประทุนมันทำให้ read() syscalls และรอผล

และแน่นอนว่าโค้ดจริงถูกฝังลงในเพจของคุณ และการบล็อกการดำเนินการ:

 <?php // blocking file I/O $file_data = file_get_contents('/path/to/file.dat'); // blocking network I/O $curl = curl_init('http://example.com/example-microservice'); $result = curl_exec($curl); // some more blocking network I/O $result = $db->query('SELECT id, data FROM examples ORDER BY id DESC limit 100'); ?>

ในแง่ของวิธีการรวมเข้ากับระบบ มันเป็นดังนี้:

I/O รุ่น PHP

ค่อนข้างง่าย: หนึ่งกระบวนการต่อคำขอ การโทร I/O เพียงแค่บล็อก ข้อได้เปรียบ? เรียบง่ายและได้ผล เสียเปรียบ? โจมตีด้วยไคลเอนต์ 20,000 รายพร้อมกันและเซิร์ฟเวอร์ของคุณจะลุกเป็นไฟ วิธีการนี้ปรับขนาดได้ไม่ดีนัก เนื่องจากไม่ได้ใช้เครื่องมือที่เคอร์เนลจัดเตรียมไว้สำหรับจัดการกับ I/O ที่มีปริมาณมาก (epoll ฯลฯ) และเพื่อเพิ่มการดูถูกการบาดเจ็บ การดำเนินการแยกกันสำหรับแต่ละคำขอมักจะใช้ทรัพยากรระบบจำนวนมาก โดยเฉพาะอย่างยิ่งหน่วยความจำ ซึ่งมักจะเป็นสิ่งแรกที่คุณหมดในสถานการณ์เช่นนี้

หมายเหตุ: แนวทางที่ใช้สำหรับ Ruby นั้นคล้ายคลึงกับของ PHP มาก และโดยทั่วไปแล้ว ในลักษณะที่เป็นคลื่นด้วยมือ ก็ถือว่าเหมือนกันสำหรับจุดประสงค์ของเรา

วิธีการแบบมัลติเธรด: Java

ดังนั้น Java ก็เข้ากันได้ดีในช่วงเวลาที่คุณซื้อชื่อโดเมนแรกของคุณ และมันเจ๋งมากที่จะสุ่มพูดว่า “dot com” หลังประโยค และ Java มีมัลติเธรดในตัวภาษา ซึ่ง (โดยเฉพาะเมื่อสร้างขึ้น) นั้นยอดเยี่ยมมาก

เว็บเซิร์ฟเวอร์ Java ส่วนใหญ่ทำงานโดยเริ่มเธรดใหม่ของการดำเนินการสำหรับแต่ละคำขอที่เข้ามา จากนั้นในเธรดนี้จึงเรียกใช้ฟังก์ชันที่คุณในฐานะนักพัฒนาแอปพลิเคชันเขียนไว้

การทำ I/O ใน Java Servlet มักจะมีลักษณะดังนี้:

 public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // blocking file I/O InputStream fileIs = new FileInputStream("/path/to/file"); // blocking network I/O URLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection(); InputStream netIs = urlConnection.getInputStream(); // some more blocking network I/O out.println("..."); }

เนื่องจากวิธีการ doGet ของเราด้านบนสอดคล้องกับคำขอเดียวและทำงานในเธรดของตัวเอง แทนที่จะเป็นกระบวนการที่แยกจากกันสำหรับแต่ละคำขอที่ต้องใช้หน่วยความจำของตัวเอง เราจึงมีเธรดแยกต่างหาก สิ่งนี้มีประโยชน์บางอย่าง เช่น สามารถแชร์สถานะ ข้อมูลแคช ฯลฯ ระหว่างเธรดได้ เนื่องจากสามารถเข้าถึงหน่วยความจำของกันและกันได้ แต่ผลกระทบต่อการโต้ตอบกับกำหนดการ ยังคงเกือบจะเหมือนกับที่กำลังทำใน PHP ตัวอย่างก่อนหน้านี้ แต่ละคำขอได้รับเธรดใหม่และบล็อกการดำเนินการ I/O ต่างๆ ภายในเธรดนั้น จนกว่าคำขอจะได้รับการจัดการอย่างสมบูรณ์ เธรดถูกรวมเข้าด้วยกันเพื่อลดต้นทุนในการสร้างและทำลายมัน แต่ถึงกระนั้น การเชื่อมต่อนับพันก็หมายถึงเธรดนับพันซึ่งไม่ดีต่อตัวจัดกำหนดการ

เหตุการณ์สำคัญที่สำคัญคือในเวอร์ชัน 1.4 Java (และการอัพเกรดที่สำคัญอีกครั้งใน 1.7) ได้รับความสามารถในการทำการโทร I/O แบบไม่บล็อก แอปพลิเคชัน เว็บ และอื่นๆ ส่วนใหญ่ไม่ใช้งาน แต่อย่างน้อยก็มีให้ใช้งานได้ เว็บเซิร์ฟเวอร์ Java บางตัวพยายามใช้ประโยชน์จากสิ่งนี้ในรูปแบบต่างๆ อย่างไรก็ตาม แอปพลิเคชัน Java ที่ปรับใช้ส่วนใหญ่ยังคงทำงานตามที่อธิบายไว้ข้างต้น

I/O รุ่น Java

Java ทำให้เราใกล้ชิดกันมากขึ้นและมีฟังก์ชันที่ใช้งานได้ทันทีสำหรับ I/O แต่ก็ยังไม่สามารถแก้ปัญหาได้ว่าเกิดอะไรขึ้นเมื่อคุณมีแอปพลิเคชันที่ผูกกับ I/O อย่างหนักซึ่งกำลังถูกโจมตี พื้นดินที่มีเส้นด้ายขวางกั้นหลายพันเส้น

ไม่บล็อก I/O ในฐานะพลเมืองชั้นหนึ่ง: Node

เด็กที่ได้รับความนิยมในกลุ่มนี้เมื่อพูดถึง I/O ที่ดีกว่าคือ Node.js ใครก็ตามที่มีการแนะนำ Node แบบสั้นๆ มาก่อนจะได้รับการแจ้งว่า "ไม่มีการบล็อก" และจัดการ I/O ได้อย่างมีประสิทธิภาพ และนี่เป็นความจริงในความหมายทั่วไป แต่มารอยู่ในรายละเอียดและวิธีการที่ทำให้คาถานี้ประสบความสำเร็จในการแสดง

โดยพื้นฐานแล้ว การเปลี่ยนกระบวนทัศน์ที่โหนดใช้คือแทนที่จะพูดว่า "เขียนโค้ดของคุณที่นี่เพื่อจัดการคำขอ" พวกเขากลับพูดว่า "เขียนโค้ดที่นี่เพื่อเริ่มจัดการคำขอ" แต่ละครั้งที่คุณต้องการทำบางสิ่งที่เกี่ยวข้องกับ I/O คุณจะต้องส่งคำขอและให้ฟังก์ชันเรียกกลับซึ่งโหนดจะเรียกใช้เมื่อเสร็จสิ้น

รหัสโหนดทั่วไปสำหรับการดำเนินการ I/O ในคำขอมีลักษณะดังนี้:

 http.createServer(function(request, response) { fs.readFile('/path/to/file', 'utf8', function(err, data) { response.end(data); }); });

อย่างที่คุณเห็น มีฟังก์ชันเรียกกลับสองฟังก์ชันที่นี่ รายการแรกจะถูกเรียกเมื่อคำขอเริ่มต้น และรายการที่สองจะถูกเรียกเมื่อมีข้อมูลไฟล์

สิ่งนี้ทำโดยพื้นฐานแล้วทำให้โหนดมีโอกาสจัดการ I/O อย่างมีประสิทธิภาพระหว่างการโทรกลับเหล่านี้ สถานการณ์ที่มันจะมีความเกี่ยวข้องมากกว่านั้นคือที่ที่คุณทำการเรียกฐานข้อมูลใน Node แต่ฉันจะไม่รบกวนกับตัวอย่างเพราะมันเป็นหลักการเดียวกันทั้งหมด: คุณเริ่มการเรียกฐานข้อมูล และให้ฟังก์ชันการเรียกกลับของ Node ดำเนินการ I/O แยกกันโดยใช้การโทรแบบไม่ปิดกั้น จากนั้นเรียกใช้ฟังก์ชันการโทรกลับของคุณเมื่อมีข้อมูลที่คุณขอ กลไกของการจัดคิวการเรียก I/O และปล่อยให้ Node จัดการแล้วรับการเรียกกลับนี้เรียกว่า “Event Loop” และมันใช้งานได้ค่อนข้างดี

I/O Model Node.js

อย่างไรก็ตามมีการจับกับรุ่นนี้ ภายใต้ประทุน เหตุผลของมันมีส่วนเกี่ยวข้องกับการที่เอ็นจิ้น V8 JavaScript (เอ็นจิ้น JS ของ Chrome ที่ใช้โดย Node) ใช้งาน 1 มากกว่าอย่างอื่น โค้ด JS ที่คุณเขียนทั้งหมดทำงานในเธรดเดียว ลองคิดดูสักครู่ หมายความว่าในขณะที่ I/O ดำเนินการโดยใช้เทคนิคการไม่บล็อกที่มีประสิทธิภาพ JS ของคุณสามารถทำได้ซึ่งดำเนินการกับ CPU-bound ที่ทำงานในเธรดเดียว โดยแต่ละส่วนของโค้ดจะบล็อกการทำงานถัดไป ตัวอย่างทั่วไปของปัญหาที่อาจเกิดขึ้นคือการวนรอบระเบียนฐานข้อมูลเพื่อประมวลผลด้วยวิธีใดวิธีหนึ่งก่อนที่จะส่งออกไปยังไคลเอ็นต์ นี่คือตัวอย่างที่แสดงวิธีการทำงาน:

 var handler = function(request, response) { connection.query('SELECT ...', function (err, rows) { if (err) { throw err }; for (var i = 0; i < rows.length; i++) { // do processing on each row } response.end(...); // write out the results }) };

แม้ว่า Node จะจัดการกับ I/O ได้อย่างมีประสิทธิภาพ แต่ for ลูปในตัวอย่างด้านบนนั้นใช้รอบ CPU ภายในเธรดหลักเพียงตัวเดียวของคุณ ซึ่งหมายความว่าหากคุณมีการเชื่อมต่อ 10,000 ครั้ง ลูปนั้นอาจทำให้แอปพลิเคชันทั้งหมดของคุณรวบรวมข้อมูลได้ ขึ้นอยู่กับระยะเวลาที่ใช้ คำขอแต่ละรายการต้องแบ่งช่วงเวลาหนึ่งๆ ในเธรดหลักของคุณ

สมมติฐานของแนวคิดทั้งหมดนี้มีพื้นฐานมาจากว่าการดำเนินการ I/O เป็นส่วนที่ช้าที่สุด ดังนั้นการจัดการสิ่งเหล่านั้นอย่างมีประสิทธิภาพจึงเป็นสิ่งสำคัญที่สุด แม้ว่าจะหมายถึงการประมวลผลอื่นๆ ตามลำดับ นี่เป็นความจริงในบางกรณี แต่ไม่ใช่ทั้งหมด

อีกประเด็นคือ แม้ว่านี่จะเป็นเพียงความคิดเห็น แต่การเขียนคอลแบ็กที่ซ้อนกันหลายๆ ครั้งอาจเป็นเรื่องที่น่าเบื่อหน่าย และบางคนก็โต้แย้งว่าโค้ดดังกล่าวทำให้ติดตามได้ยากขึ้นอย่างมาก ไม่ใช่เรื่องแปลกที่จะเห็นการเรียกกลับซ้อนกันสี่ ห้า หรือระดับลึกลงไปในโค้ดของโหนด

เรากลับมาอีกครั้งเพื่อประนีประนอม โมเดลโหนดทำงานได้ดีหากปัญหาด้านประสิทธิภาพหลักของคุณคือ I/O อย่างไรก็ตาม จุดอ่อนของมันคือคุณสามารถเข้าสู่ฟังก์ชันที่จัดการคำขอ HTTP และใส่โค้ดที่เน้น CPU และนำทุกการเชื่อมต่อไปสู่การรวบรวมข้อมูลหากคุณไม่ระวัง

ไม่มีการปิดกั้นโดยธรรมชาติ: Go

ก่อนที่ฉันจะเข้าสู่ส่วน Go เป็นการเหมาะสมสำหรับฉันที่จะเปิดเผยว่าฉันเป็นแฟนบอย Go ฉันเคยใช้มันมาหลายโครงการ และฉันก็เห็นด้วยอย่างเปิดเผยของข้อดีด้านประสิทธิภาพการทำงาน และฉันเห็นมันในงานของฉันเมื่อฉันใช้มัน

ที่กล่าวว่า ลองดูว่ามันเกี่ยวข้องกับ I/O อย่างไร คุณลักษณะสำคัญประการหนึ่งของภาษา Go คือมีตัวกำหนดตารางเวลาของตัวเอง แทนที่จะทำงานแต่ละเธรดของการดำเนินการที่สอดคล้องกับเธรด OS เดียว มันใช้งานได้กับแนวคิดของ "goroutines" และรันไทม์ Go สามารถกำหนด goroutine ให้กับเธรด OS และให้รันไทม์ หรือระงับการทำงานและไม่ให้เชื่อมโยงกับเธรด OS ตามสิ่งที่ goroutine นั้นทำ คำขอแต่ละรายการที่มาจากเซิร์ฟเวอร์ HTTP ของ Go จะได้รับการจัดการใน Goroutine แยกต่างหาก

ไดอะแกรมของวิธีการทำงานของตัวจัดกำหนดการมีลักษณะดังนี้:

I/O รุ่น Go

ภายใต้ประทุน สิ่งนี้ถูกนำไปใช้โดยจุดต่างๆ ในรันไทม์ Go ที่ใช้การเรียก I/O โดยการร้องขอให้เขียน/อ่าน/เชื่อมต่อ/ฯลฯ ทำให้ goroutine ปัจจุบันเข้าสู่โหมดสลีป พร้อมข้อมูลเพื่อปลุก goroutine กลับ ขึ้นเมื่อสามารถดำเนินการต่อไปได้

อันที่จริง รันไทม์ของ Go กำลังทำสิ่งที่ไม่เหมือนกับสิ่งที่ Node กำลังทำอย่างมาก ยกเว้นว่ากลไกการเรียกกลับนั้นถูกสร้างขึ้นในการปรับใช้การเรียก I/O และโต้ตอบกับตัวจัดกำหนดการโดยอัตโนมัติ นอกจากนี้ยังไม่ต้องทนทุกข์ทรมานจากข้อจำกัดที่จะต้องให้โค้ดจัดการทั้งหมดของคุณทำงานในเธรดเดียวกัน Go จะแมป Goroutines ของคุณโดยอัตโนมัติกับเธรด OS จำนวนมากเท่าที่เห็นสมควรตามตรรกะในตัวกำหนดตารางเวลา ผลลัพธ์คือรหัสเช่นนี้:

 func ServeHTTP(w http.ResponseWriter, r *http.Request) { // the underlying network call here is non-blocking rows, err := db.Query("SELECT ...") for _, row := range rows { // do something with the rows, // each request in its own goroutine } w.Write(...) // write the response, also non-blocking }

ดังที่คุณเห็นด้านบน โครงสร้างโค้ดพื้นฐานของสิ่งที่เรากำลังทำนั้นคล้ายกับแนวทางที่เรียบง่ายกว่า และยังบรรลุ I/O แบบไม่ปิดกั้นภายใต้ประทุน

ในกรณีส่วนใหญ่ สิ่งนี้จบลงด้วยการเป็น “สิ่งที่ดีที่สุดของทั้งสองโลก” Non-blocking I/O ใช้สำหรับสิ่งสำคัญทั้งหมด แต่โค้ดของคุณดูเหมือนว่ากำลังบล็อกอยู่ ดังนั้นจึงมีแนวโน้มที่จะเข้าใจและบำรุงรักษาง่ายกว่า การโต้ตอบระหว่างตัวกำหนดเวลา Go และตัวกำหนดตารางเวลา OS จะจัดการส่วนที่เหลือ ไม่ใช่เวทมนตร์ที่สมบูรณ์ และหากคุณสร้างระบบขนาดใหญ่ ก็ควรสละเวลาเพื่อทำความเข้าใจรายละเอียดเพิ่มเติมเกี่ยวกับวิธีการทำงาน แต่ในขณะเดียวกัน สภาพแวดล้อมที่คุณได้รับ "นอกกรอบ" ก็ทำงานได้ดีและปรับขนาดได้ค่อนข้างดี

Go อาจมีข้อบกพร่อง แต่โดยทั่วไปแล้ว วิธีจัดการกับ I/O นั้นไม่ใช่หนึ่งในนั้น

การโกหก การโกหกที่ถูกสาปแช่งและเกณฑ์มาตรฐาน

เป็นการยากที่จะกำหนดเวลาที่แน่นอนในการเปลี่ยนบริบทที่เกี่ยวข้องกับแบบจำลองต่างๆ เหล่านี้ ฉันยังอาจโต้แย้งว่ามีประโยชน์น้อยกว่าสำหรับคุณ ดังนั้น ฉันจะให้การวัดประสิทธิภาพพื้นฐานที่เปรียบเทียบประสิทธิภาพเซิร์ฟเวอร์ HTTP โดยรวมของสภาพแวดล้อมเซิร์ฟเวอร์เหล่านี้แทน โปรดทราบว่ามีหลายปัจจัยที่เกี่ยวข้องกับประสิทธิภาพของเส้นทางคำขอ/การตอบสนอง HTTP แบบ end-to-end ทั้งหมด และตัวเลขที่แสดงที่นี่เป็นเพียงตัวอย่างบางส่วนที่ฉันรวบรวมไว้เพื่อเปรียบเทียบพื้นฐาน

สำหรับแต่ละสภาพแวดล้อมเหล่านี้ ฉันเขียนโค้ดที่เหมาะสมเพื่ออ่านในไฟล์ 64k ด้วยไบต์สุ่ม รันแฮช SHA-256 กับมัน N จำนวนครั้ง (มีการระบุ N ในสตริงการสืบค้นของ URL เช่น .../test.php?n=100 ) และพิมพ์ผลลัพธ์ที่ได้เป็นเลขฐานสิบหก ฉันเลือกสิ่งนี้เพราะมันเป็นวิธีที่ง่ายมากในการรันการวัดประสิทธิภาพเดียวกันกับ I/O ที่สอดคล้องกันและวิธีที่ควบคุมได้เพื่อเพิ่มการใช้งาน CPU

ดูหมายเหตุเปรียบเทียบเหล่านี้สำหรับรายละเอียดเพิ่มเติมเล็กน้อยเกี่ยวกับสภาพแวดล้อมที่ใช้

อันดับแรก มาดูตัวอย่างการทำงานพร้อมกันในระดับต่ำ การเรียกใช้ซ้ำ 2,000 ครั้งโดยมีคำขอพร้อมกัน 300 รายการและแฮชเดียวเท่านั้นต่อคำขอ (N=1) ให้สิ่งนี้แก่เรา:

จำนวนมิลลิวินาทีเฉลี่ยในการดำเนินการตามคำขอสำหรับคำขอที่เกิดขึ้นพร้อมกันทั้งหมด N=1

เวลาคือจำนวนเฉลี่ยของมิลลิวินาทีในการดำเนินการตามคำขอสำหรับคำขอที่เกิดขึ้นพร้อมกันทั้งหมด ล่างดีกว่า

เป็นการยากที่จะสรุปจากกราฟเดียวนี้ แต่สำหรับฉันดูเหมือนว่า ที่ปริมาณการเชื่อมต่อและการคำนวณนี้ เราเห็นเวลาที่มากขึ้นเกี่ยวกับการดำเนินการทั่วไปของภาษาเอง มากขึ้นเพื่อให้ ไอ/โอ โปรดทราบว่าภาษาที่ถือว่าเป็น "ภาษาสคริปต์" (การพิมพ์หลวม การตีความแบบไดนามิก) จะทำงานช้าที่สุด

แต่จะเกิดอะไรขึ้นหากเราเพิ่ม N เป็น 1,000 ซึ่งยังคงมีคำขอพร้อมกัน 300 รายการ - โหลดเท่ากัน แต่มีแฮชซ้ำมากกว่า 100 เท่า (โหลด CPU มากขึ้นอย่างมีนัยสำคัญ):

จำนวนมิลลิวินาทีเฉลี่ยในการดำเนินการตามคำขอสำหรับคำขอที่เกิดขึ้นพร้อมกันทั้งหมด N=1000

เวลาคือจำนวนเฉลี่ยของมิลลิวินาทีในการดำเนินการตามคำขอสำหรับคำขอที่เกิดขึ้นพร้อมกันทั้งหมด ล่างดีกว่า

จู่ๆ ประสิทธิภาพของโหนดก็ลดลงอย่างมาก เนื่องจากการดำเนินการที่ใช้ CPU สูงในแต่ละคำขอกำลังบล็อกกันและกัน และที่น่าสนใจก็คือ ประสิทธิภาพของ PHP ดีขึ้นมาก (เทียบกับตัวอื่นๆ) และเหนือกว่า Java ในการทดสอบนี้ (เป็นที่น่าสังเกตว่าใน PHP นั้น การใช้งาน SHA-256 นั้นเขียนด้วยภาษา C และเส้นทางการดำเนินการนั้นใช้เวลามากขึ้นในลูปนั้น เนื่องจากตอนนี้เรากำลังทำซ้ำ 1,000 แฮช)

ตอนนี้ มาลองเชื่อมต่อพร้อมกัน 5,000 รายการ (ด้วย N=1) - หรือใกล้เคียงที่สุดเท่าที่จะทำได้ น่าเสียดาย สำหรับสภาพแวดล้อมเหล่านี้ส่วนใหญ่ อัตราความล้มเหลวไม่มีนัยสำคัญ สำหรับแผนภูมินี้ เราจะพิจารณาจำนวนคำขอทั้งหมดต่อวินาที ยิ่งสูงยิ่งดี :

จำนวนคำขอทั้งหมดต่อวินาที N=1, 5000 คำขอ/วินาที

จำนวนคำขอทั้งหมดต่อวินาที สูงกว่าจะดีกว่า

และภาพก็ดูแตกต่างไปจากเดิมอย่างสิ้นเชิง เป็นการคาดเดา แต่ดูเหมือนว่าปริมาณการเชื่อมต่อที่สูงค่าใช้จ่ายต่อการเชื่อมต่อที่เกี่ยวข้องกับการวางไข่ของกระบวนการใหม่และหน่วยความจำเพิ่มเติมที่เกี่ยวข้องใน PHP + Apache ดูเหมือนจะเป็นปัจจัยสำคัญและทำให้ประสิทธิภาพของ PHP ลดลง เห็นได้ชัดว่า Go เป็นผู้ชนะที่นี่ ตามด้วย Java, Node และสุดท้ายคือ PHP

แม้ว่าปัจจัยที่เกี่ยวข้องกับปริมาณงานโดยรวมของคุณมีมากมายและยังแตกต่างกันไปตามแต่ละแอปพลิเคชัน ยิ่งคุณเข้าใจถึงความกล้าของสิ่งที่เกิดขึ้นภายใต้ประทุนและการแลกเปลี่ยนที่เกี่ยวข้องมากเท่าไหร่ คุณก็จะยิ่งดีขึ้นเท่านั้น

สรุป

จากทั้งหมดที่กล่าวมา มันค่อนข้างชัดเจนว่าในขณะที่ภาษามีการพัฒนา โซลูชันในการจัดการกับแอปพลิเคชันขนาดใหญ่ที่มี I/O จำนวนมากได้พัฒนาไปพร้อมกับมัน

เพื่อความเป็นธรรม ทั้ง PHP และ Java แม้จะมีคำอธิบายในบทความนี้ แต่ก็มีการใช้งาน I/O ที่ไม่บล็อกสำหรับใช้ในเว็บแอปพลิเคชัน แต่สิ่งเหล่านี้ไม่ธรรมดาตามแนวทางที่อธิบายไว้ข้างต้น และค่าใช้จ่ายในการบำรุงรักษาเซิร์ฟเวอร์โดยใช้วิธีการดังกล่าวของผู้ดูแลจะต้องนำมาพิจารณาด้วย ไม่ต้องพูดถึงว่าโค้ดของคุณต้องมีโครงสร้างในลักษณะที่ทำงานร่วมกับสภาพแวดล้อมดังกล่าวได้ เว็บแอปพลิเคชัน PHP หรือ Java "ปกติ" ของคุณมักจะไม่ทำงานหากไม่มีการปรับเปลี่ยนที่สำคัญในสภาพแวดล้อมดังกล่าว

ในการเปรียบเทียบ หากเราพิจารณาปัจจัยสำคัญสองสามประการที่ส่งผลต่อประสิทธิภาพและความง่ายในการใช้งาน เราจะได้สิ่งนี้:

ภาษา เธรดกับกระบวนการ ไม่บล็อก I/O สะดวกในการใช้
PHP กระบวนการ ไม่
Java กระทู้ มีอยู่ ต้องโทรกลับ
Node.js กระทู้ ใช่ ต้องโทรกลับ
ไป กระทู้ (Goroutines) ใช่ ไม่จำเป็นต้องโทรกลับ


โดยทั่วไปเธรดจะมีหน่วยความจำที่มีประสิทธิภาพมากกว่าการประมวลผล เนื่องจากเธรดเหล่านี้ใช้พื้นที่หน่วยความจำเดียวกันในขณะที่กระบวนการไม่ใช้ เมื่อรวมกับปัจจัยที่เกี่ยวข้องกับ I/O ที่ไม่บล็อก เราจะเห็นว่าอย่างน้อยด้วยปัจจัยที่พิจารณาข้างต้น เมื่อเราเลื่อนรายการการตั้งค่าทั่วไปที่เกี่ยวข้องกับ I/O ให้ดีขึ้น ดังนั้น ถ้าฉันต้องเลือกผู้ชนะในการแข่งขันข้างต้น ก็คงเป็น Go อย่างแน่นอน

ในทางปฏิบัติ การเลือกสภาพแวดล้อมในการสร้างแอปพลิเคชันของคุณนั้นมีความเกี่ยวข้องอย่างใกล้ชิดกับความคุ้นเคยที่ทีมของคุณมีกับสภาพแวดล้อมดังกล่าว และประสิทธิภาพการทำงานโดยรวมที่คุณสามารถทำได้ ดังนั้น อาจไม่สมเหตุสมผลเลยที่ทุกทีมจะเริ่มพัฒนาเว็บแอปพลิเคชันและบริการใน Node หรือ Go อันที่จริง การค้นหานักพัฒนาซอฟต์แวร์หรือความคุ้นเคยของทีมในองค์กรมักถูกอ้างถึงว่าเป็นเหตุผลหลักที่จะไม่ใช้ภาษาและ/หรือสภาพแวดล้อมที่ต่างไปจากเดิม ที่กล่าวว่าเวลามีการเปลี่ยนแปลงอย่างมากในช่วงสิบห้าปีที่ผ่านมา

หวังว่าด้านบนนี้จะช่วยให้เห็นภาพที่ชัดเจนขึ้นของสิ่งที่เกิดขึ้นภายใต้ประทุน และให้แนวคิดบางประการเกี่ยวกับวิธีจัดการกับความสามารถในการปรับขนาดในโลกแห่งความเป็นจริงสำหรับแอปพลิเคชันของคุณ มีความสุขในการป้อนและส่งออก!