การเปรียบเทียบ 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
วงเหตุการณ์
ปัญหาในการเขียน 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