การเปรียบเทียบ A Node.js Promise

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

เรากำลังอยู่ในโลกใหม่ที่กล้าหาญ โลกที่เต็มไปด้วยจาวาสคริปต์ ในช่วงไม่กี่ปีที่ผ่านมา JavaScript ได้ครอบงำเว็บที่ทำให้ทั้งอุตสาหกรรมตกต่ำ หลังจากการเปิดตัว Node.js ชุมชน JavaScript สามารถใช้ความเรียบง่ายและไดนามิกของภาษาให้เป็นภาษาเดียวในการทำทุกอย่าง จัดการฝั่งเซิร์ฟเวอร์ ฝั่งไคลเอ็นต์ และกล้าได้กล้าเสียและอ้างสิทธิ์ในการเรียนรู้ด้วยเครื่อง แต่ JavaScript ได้เปลี่ยนไปอย่างมากในฐานะภาษาในช่วงไม่กี่ปีที่ผ่านมา มีการแนะนำแนวคิดใหม่ที่ไม่เคยมีมาก่อน เช่น ฟังก์ชันลูกศรและคำสัญญา

อาสัญญา แนวคิดทั้งหมดของคำมั่นสัญญาและการโทรกลับไม่สมเหตุสมผลสำหรับฉันเลยเมื่อฉันเริ่มเรียนรู้ Node.js ครั้งแรก ฉันเคยชินกับวิธีการรันโค้ด แต่เมื่อเวลาผ่านไปฉันก็เข้าใจว่าทำไมมันถึงสำคัญ

สิ่งนี้นำเราไปสู่คำถาม เหตุใดจึงมีการแนะนำการโทรกลับและสัญญา ทำไมเราไม่สามารถเขียนโค้ดที่รันตามลำดับใน JavaScript ได้?

ในทางเทคนิคแล้วคุณทำได้ แต่คุณควร?

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

ก่อนที่คุณจะเริ่มต้น บทความนี้จะถือว่าคุณคุ้นเคยกับคำสัญญาใน JavaScript อยู่แล้ว อย่างไรก็ตาม หากคุณไม่ต้องการหรือต้องการทบทวน โปรดดูที่ JavaScript Promises: A Tutorial with Examples

หมายเหตุ บทความนี้ได้รับการทดสอบบนสภาพแวดล้อม Node.js ไม่ใช่ JavaScript ล้วนๆ ใช้งาน Node.js เวอร์ชัน 10.14.2 การวัดประสิทธิภาพและไวยากรณ์ทั้งหมดจะขึ้นอยู่กับ Node.js เป็นอย่างมาก ทำการทดสอบบน MacBook Pro 2018 โดยใช้โปรเซสเซอร์ Intel i5 รุ่นที่ 8 แบบ Quad-Core ที่ใช้ความเร็วสัญญาณนาฬิกาพื้นฐานที่ 2.3 GHz

วงเหตุการณ์

การเปรียบเทียบ Node.js Promise: ภาพประกอบของ Node.js Event Loop

ปัญหาในการเขียน JavaScript คือภาษานั้นเป็นเธรดเดี่ยว ซึ่งหมายความว่าคุณไม่สามารถดำเนินการได้มากกว่าหนึ่งขั้นตอนในแต่ละครั้ง ซึ่งแตกต่างจากภาษาอื่นๆ เช่น Go หรือ Ruby ที่มีความสามารถในการวางไข่ของเธรดและดำเนินการหลายขั้นตอนพร้อมกัน บนเคอร์เนลเธรดหรือบนเธรดของกระบวนการ .

ในการรันโค้ด JavaScript อาศัยขั้นตอนที่เรียกว่าเหตุการณ์วนซ้ำซึ่งประกอบด้วยหลายขั้นตอน กระบวนการจาวาสคริปต์ต้องผ่านแต่ละขั้นตอน และในตอนท้าย กระบวนการจะเริ่มต้นใหม่ทั้งหมดอีกครั้ง คุณสามารถอ่านรายละเอียดเพิ่มเติมในคู่มืออย่างเป็นทางการของ node.js ได้ที่นี่

แต่ JavaScript ก็มีบางอย่างที่พร้อมจะต่อสู้กับปัญหาการบล็อก การโทรกลับ I/O

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

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

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

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

เกณฑ์มาตรฐาน

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

ฉันตัดสินใจเลือกการอ่านไฟล์เป็นการดำเนินการ I/O เพื่อทดสอบ

ก่อนอื่น ฉันเขียนฟังก์ชันที่จะเขียนไฟล์สุ่มที่เต็มไปด้วยไบต์สุ่มที่สร้างด้วยโมดูล Node.js Crypto

 const fs = require('fs'); const crypto = require('crypto'); fs.writeFileSync( "./test.txt", crypto.randomBytes(2048).toString('base64') )

ไฟล์นี้จะทำหน้าที่เป็นค่าคงที่สำหรับขั้นตอนต่อไปซึ่งก็คือการอ่านไฟล์ นี่คือรหัส

 const fs = require('fs'); process.on('unhandledRejection', (err)=>{ console.error(err); }) function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); await Promise.all([p0]) console.timeEnd("async") } synchronous() asynchronous()

การรันโค้ดก่อนหน้าทำให้เกิดผลลัพธ์ดังต่อไปนี้:

วิ่ง # ซิงค์ อะซิงโครนัส อัตราส่วนอะซิงโครนัส/ซิงค์
1 0.278ms 3.829ms 13.773
2 0.335ms 3.801ms 11.346
3 0.403ms 4.498ms 11.161

นี่เป็นสิ่งที่ไม่คาดคิด ความคาดหวังเบื้องต้นของฉันคือพวกเขาควรใช้เวลาเท่ากัน แล้วเราจะเพิ่มไฟล์อื่นและอ่าน 2 ไฟล์แทนที่จะเป็น 1 ไฟล์ได้อย่างไร

ฉันจำลองไฟล์ที่สร้าง test.txt และเรียกมันว่า test2.txt นี่คือรหัสที่อัปเดต:

 function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); await Promise.all([p0,p1]) console.timeEnd("async") }

ฉันเพียงแค่เพิ่มการอ่านอีกครั้งสำหรับแต่ละรายการ และในคำสัญญา ฉันกำลังรอคำสัญญาการอ่านที่ควรทำงานควบคู่กัน นี่คือผลลัพธ์:

วิ่ง # ซิงค์ อะซิงโครนัส อัตราส่วนอะซิงโครนัส/ซิงค์
1 1.659ms 6.895ms 4.156
2 0.323ms 4.048ms 12.533
3 0.324ms 4.017ms 12.398
4 0.333ms 4.271ms 12.826

ค่าแรกมีค่าแตกต่างไปจาก 3 รันที่ตามมาโดยสิ้นเชิง ฉันเดาว่ามันเกี่ยวข้องกับคอมไพเลอร์ JavaScript JIT ซึ่งปรับโค้ดให้เหมาะสมในการรันแต่ละครั้ง

ดังนั้น สิ่งต่างๆ จึงดูไม่ค่อยดีนักสำหรับฟังก์ชัน async บางทีถ้าเราทำสิ่งต่างๆ ให้มีพลังมากขึ้น และอาจเน้นที่แอปมากขึ้นอีกหน่อย เราอาจให้ผลลัพธ์ที่ต่างออกไป

ดังนั้นการทดสอบครั้งต่อไปของฉันจึงเกี่ยวข้องกับการเขียนไฟล์ต่างๆ 100 ไฟล์ จากนั้นจึงอ่านทั้งหมด

อันดับแรก ฉันแก้ไขโค้ดเพื่อเขียน 100 ไฟล์ก่อนทำการทดสอบ ไฟล์จะแตกต่างกันไปในการรันแต่ละครั้ง แม้ว่าจะรักษาขนาดไว้เกือบเท่าเดิม ดังนั้นเราจึงล้างไฟล์เก่าออกก่อนที่จะรันแต่ละครั้ง

นี่คือรหัสที่อัปเดต:

 let filePaths = []; function writeFile() { let filePath = `./files/${crypto.randomBytes(6).toString('hex')}.txt` fs.writeFileSync( filePath, crypto.randomBytes(2048).toString('base64') ) filePaths.push(filePath); } function synchronous() { console.time("sync"); /* fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") */ filePaths.forEach((filePath)=>{ fs.readFileSync(filePath) }) console.timeEnd("sync") } async function asynchronous() { console.time("async"); /* let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); */ // await Promise.all([p0,p1]) let promiseArray = []; filePaths.forEach((filePath)=>{ promiseArray.push(fs.promises.readFile(filePath)) }) await Promise.all(promiseArray) console.timeEnd("async") }

และสำหรับการทำความสะอาดและดำเนินการ:

 let oldFiles = fs.readdirSync("./files") oldFiles.forEach((file)=>{ fs.unlinkSync("./files/"+file) }) if (!fs.existsSync("./files")){ fs.mkdirSync("./files") } for (let index = 0; index < 100; index++) { writeFile() } synchronous() asynchronous()

และมาวิ่งกันเถอะ

นี่คือตารางผลลัพธ์:

วิ่ง # ซิงค์ อะซิงโครนัส อัตราส่วนอะซิงโครนัส/ซิงค์
1 4.999ms 12.890ms 2.579
2 5.077ms 16.267ms 3.204
3 5.241ms 14.571ms 2.780
4 5.086ms 16.334ms 3.213

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

เพื่อการทดลอง ลองดูว่าเป็นปัญหาจริงกับคำสัญญาหรือเป็นอย่างอื่น เพื่อการนั้น ฉันเขียนฟังก์ชันที่จะคำนวณเวลาในการแก้ไขสัญญาหนึ่งที่ไม่ทำอะไรเลย และอีกรายการหนึ่งที่จะแก้ไข 100 คำสัญญาที่ว่างเปล่า

นี่คือรหัส:

 function promiseRun() { console.time("promise run"); return new Promise((resolve)=>resolve()) .then(()=>console.timeEnd("promise run")) } function hunderedPromiseRuns() { let promiseArray = []; console.time("100 promises") for(let i = 0; i < 100; i++) { promiseArray.push(new Promise((resolve)=>resolve())) } return Promise.all(promiseArray).then(()=>console.timeEnd("100 promises")) } promiseRun() hunderedPromiseRuns()
วิ่ง # สัญญาเดียว 100 สัญญา
1 1.651ms 3.293ms
2 0.758ms 2.575ms
3 0.814ms 3.127ms
4 0.788ms 2.623ms

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

คำสุดท้าย

แล้วคุณควรใช้สัญญาหรือไม่? ความคิดเห็นของฉันจะเป็นดังนี้:

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

คุณสามารถค้นหาโค้ดสำหรับฟังก์ชันทั้งหมดได้ในบทความนี้ในที่เก็บ

ขั้นตอนต่อไปที่สมเหตุสมผลในการเดินทางของนักพัฒนา JavaScript จากคำสัญญาคือไวยากรณ์ async/await หากคุณต้องการเรียนรู้เพิ่มเติมเกี่ยวกับมัน และวิธีที่เรามาที่นี่ โปรดดู JavaScript แบบอะซิงโครนัส: จาก Callback Hell ถึง Async และ Await