Tolok Ukur Janji Node.js

Diterbitkan: 2022-03-11

Kita hidup di dunia baru yang berani. Dunia yang penuh dengan JavaScript. Dalam beberapa tahun terakhir, JavaScript telah mendominasi web yang mengambil alih seluruh industri. Setelah pengenalan Node.js, komunitas JavaScript dapat memanfaatkan kesederhanaan dan dinamika bahasa menjadi satu-satunya bahasa untuk melakukan segalanya, menangani sisi server, sisi klien, dan bahkan dengan berani mengklaim posisi untuk pembelajaran mesin. Tetapi JavaScript telah berubah secara drastis sebagai bahasa selama beberapa tahun terakhir. Konsep baru telah diperkenalkan yang belum pernah ada sebelumnya, seperti fungsi panah dan janji.

Ah, janji. Seluruh konsep janji dan panggilan balik tidak masuk akal bagi saya ketika saya pertama kali mulai belajar Node.js. Saya sudah terbiasa dengan cara prosedural dalam mengeksekusi kode, tetapi seiring waktu saya mengerti mengapa itu penting.

Ini membawa kita ke pertanyaan, mengapa panggilan balik dan janji tetap diperkenalkan? Mengapa kita tidak bisa menulis kode yang dieksekusi secara berurutan dalam JavaScript?

Nah, secara teknis Anda bisa. Tapi haruskah?

Dalam artikel ini, saya akan memberikan pengenalan singkat tentang JavaScript dan runtime-nya, dan yang lebih penting, menguji kepercayaan yang tersebar luas di komunitas JavaScript bahwa kode sinkron di bawah standar dalam kinerja dan, dalam arti tertentu, benar-benar jahat, dan seharusnya tidak pernah digunakan. Apakah mitos ini benar adanya?

Sebelum Anda mulai, artikel ini mengasumsikan Anda sudah terbiasa dengan janji dalam JavaScript, namun, jika Anda tidak atau membutuhkan penyegaran, silakan lihat Janji JavaScript: Tutorial dengan Contoh

NB Artikel ini telah diuji pada lingkungan Node.js, bukan JavaScript murni. Menjalankan Node.js versi 10.14.2. Semua benchmark dan sintaks akan sangat bergantung pada Node.js. Pengujian dijalankan pada MacBook Pro 2018 dengan Prosesor Quad-Core Intel i5 Generasi ke-8 yang menjalankan kecepatan clock dasar 2,3 GHz.

Putaran Acara

Benchmarking Janji Node.js: Ilustrasi Loop Peristiwa Node.js

Masalah dengan menulis JavaScript adalah bahwa bahasa itu sendiri adalah utas tunggal. Ini berarti bahwa Anda tidak dapat menjalankan lebih dari satu prosedur pada satu waktu tidak seperti bahasa lain, seperti Go atau Ruby, yang memiliki kemampuan untuk menelurkan utas dan menjalankan beberapa prosedur pada saat yang sama, baik pada utas kernel atau utas proses .

Untuk mengeksekusi kode, JavaScript bergantung pada prosedur yang disebut loop peristiwa yang terdiri dari beberapa tahap. Proses JavaScript melewati setiap tahap, dan pada akhirnya, itu dimulai dari awal lagi. Anda dapat membaca lebih lanjut tentang detailnya di panduan resmi node.js di sini.

Tetapi JavaScript memiliki sesuatu untuk melawan masalah pemblokiran. panggilan balik I/O.

Sebagian besar kasus penggunaan kehidupan nyata yang mengharuskan kami membuat utas adalah fakta bahwa kami meminta beberapa tindakan yang tidak bertanggung jawab atas bahasa tersebut—misalnya, meminta pengambilan beberapa data dari database. Dalam bahasa multithreaded, utas yang membuat permintaan hanya hang atau menunggu respons dari database. Ini hanya pemborosan sumber daya. Ini juga membebani pengembang dalam memilih jumlah utas yang benar di kumpulan utas. Ini untuk mencegah kebocoran memori dan alokasi banyak sumber daya saat aplikasi sedang diminati.

JavaScript unggul dalam satu hal lebih dari faktor lainnya, menangani operasi I/O. JavaScript memungkinkan Anda untuk memanggil operasi I/O seperti meminta data dari database, membaca file ke dalam memori, menulis file ke disk, menjalankan perintah shell, dll. Ketika operasi selesai, Anda menjalankan panggilan balik. Atau dalam hal janji, Anda menyelesaikan janji dengan hasil atau menolaknya dengan kesalahan.

Komunitas JavaScript selalu menyarankan kami untuk tidak pernah menggunakan kode sinkron saat melakukan operasi I/O. Alasan terkenal untuk itu adalah bahwa kami TIDAK ingin memblokir kode kami dari menjalankan tugas lain. Karena single-threaded, jika kita memiliki sepotong kode yang membaca file secara sinkron, kode tersebut akan memblokir seluruh proses hingga pembacaan selesai. Sebaliknya, jika kita mengandalkan kode asinkron, kita dapat melakukan beberapa operasi I/O dan menangani respons setiap operasi satu per satu saat selesai. Tidak ada pemblokiran apapun.

Tapi tentunya dalam lingkungan di mana kita sama sekali tidak peduli menangani banyak proses, menggunakan kode sinkron dan asinkron tidak membuat perbedaan sama sekali, bukan?

Tolok ukur

Pengujian yang akan kita jalankan bertujuan untuk memberi kita tolok ukur tentang seberapa cepat sinkronisasi dan kode asinkron berjalan dan jika ada perbedaan kinerja.

Saya memutuskan untuk memilih membaca file sebagai operasi I/O untuk diuji.

Pertama, saya menulis fungsi yang akan menulis file acak yang diisi dengan byte acak yang dihasilkan dengan modul Crypto Node.js.

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

File ini akan bertindak sebagai konstanta untuk langkah kita selanjutnya yaitu membaca file. Berikut kodenya

 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()

Menjalankan kode sebelumnya menghasilkan hasil berikut:

Berlari # Sinkronkan tidak sinkron Rasio Asinkron/Sinkronisasi
1 0.278ms 3,829ms 13,773
2 0.335ms 3.801ms 11.346
3 0.403ms 4.498ms 11.161

Ini tidak terduga. Harapan awal saya adalah bahwa mereka harus mengambil waktu yang sama. Nah, bagaimana kalau kita menambahkan file lain dan membaca 2 file, bukan 1?

Saya mereplikasi file yang dihasilkan test.txt dan menyebutnya test2.txt. Berikut kode yang diperbarui:

 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") }

Saya hanya menambahkan bacaan lain untuk masing-masing dari mereka, dan dalam janji, saya sedang menunggu janji membaca yang harus berjalan secara paralel. Ini adalah hasilnya:

Berlari # Sinkronkan tidak sinkron Rasio Asinkron/Sinkronisasi
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

Yang pertama memiliki nilai yang sama sekali berbeda dari 3 run berikutnya. Dugaan saya adalah bahwa ini terkait dengan kompiler JavaScript JIT yang mengoptimalkan kode pada setiap proses.

Jadi, hal-hal tidak terlihat bagus untuk fungsi async. Mungkin jika kita membuat segalanya lebih dinamis dan mungkin lebih menekankan aplikasi, kita bisa menghasilkan hasil yang berbeda.

Jadi pengujian saya berikutnya melibatkan penulisan 100 file berbeda dan kemudian membaca semuanya.

Pertama, saya memodifikasi kode untuk menulis 100 file sebelum pelaksanaan tes. File berbeda pada setiap proses, meskipun mempertahankan ukuran yang hampir sama, jadi kami menghapus file lama sebelum setiap proses.

Berikut kode yang diperbarui:

 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") }

Dan untuk pembersihan dan eksekusi:

 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()

Dan mari kita lari.

Berikut tabel hasilnya:

Berlari # Sinkronkan tidak sinkron Rasio Asinkron/Sinkronisasi
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

Hasil ini mulai menarik kesimpulan di sini. Ini menunjukkan bahwa dengan peningkatan permintaan atau konkurensi, janji overhead mulai masuk akal. Untuk elaborasi, jika kita menjalankan server web yang seharusnya menjalankan ratusan atau mungkin ribuan permintaan per detik per server, menjalankan operasi I/O menggunakan sinkronisasi akan mulai kehilangan manfaatnya cukup cepat.

Hanya demi eksperimen, mari kita lihat apakah itu sebenarnya masalah dengan janji itu sendiri atau apakah itu sesuatu yang lain. Untuk itu, saya menulis sebuah fungsi yang akan menghitung waktu untuk menyelesaikan satu janji yang sama sekali tidak melakukan apa-apa dan yang lain yang menyelesaikan 100 janji kosong.

Berikut kodenya:

 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()
Berlari # Janji Tunggal 100 Janji
1 1.651ms 3.293ms
2 0,758ms 2.575ms
3 0.814ms 3.127ms
4 0,788ms 2.623ms

Menarik. Tampaknya janji bukanlah penyebab utama penundaan, yang membuat saya menebak bahwa sumber penundaan adalah utas kernel yang melakukan pembacaan yang sebenarnya. Ini mungkin membutuhkan lebih banyak eksperimen untuk menghasilkan kesimpulan yang menentukan tentang alasan utama di balik penundaan.

Sebuah Kata Terakhir

Jadi haruskah Anda menggunakan janji atau tidak? Pendapat saya akan menjadi sebagai berikut:

Jika Anda menulis skrip yang akan berjalan pada satu mesin dengan aliran tertentu yang dipicu oleh pipeline atau satu pengguna, gunakan kode sinkronisasi. Jika Anda menulis server web yang akan bertanggung jawab untuk menangani banyak lalu lintas dan permintaan, maka overhead yang berasal dari eksekusi asinkron akan mengatasi kinerja kode sinkronisasi.

Anda dapat menemukan kode untuk semua fungsi dalam artikel ini di repositori.

Langkah logis berikutnya dalam perjalanan Pengembang JavaScript Anda, dari janji, adalah sintaks async/await. Jika Anda ingin mempelajari lebih lanjut tentangnya, dan bagaimana kami sampai di sini, lihat Asynchronous JavaScript: From Callback Hell to Async and Await .