Node awalnya dibuat untuk membangun server web berkinerja tinggi. Sebagai runtime sisi server untuk JavaScript, Node memiliki fitur seperti event-driven, asynchronous I/O, dan single-threading. Model pemrograman asinkron berdasarkan loop peristiwa memungkinkan Node menangani konkurensi tinggi dan sangat meningkatkan kinerja server. Pada saat yang sama, karena mempertahankan karakteristik thread tunggal JavaScript, Node tidak perlu menangani masalah seperti sinkronisasi status dan. kebuntuan di bawah multi-thread. Tidak ada overhead kinerja yang disebabkan oleh peralihan konteks thread. Berdasarkan karakteristik ini, Node memiliki keunggulan inheren berupa kinerja tinggi dan konkurensi tinggi, dan berbagai platform aplikasi jaringan berkecepatan tinggi dan skalabel dapat dibangun berdasarkan karakteristik tersebut.
Artikel ini akan mendalami implementasi dan mekanisme eksekusi yang mendasari loop asinkron dan event Node. Saya harap ini dapat membantu Anda.
Mengapa Node menggunakan asynchronous sebagai model pemrograman intinya?
Seperti disebutkan sebelumnya, Node awalnya dibuat untuk membangun server web berkinerja tinggi. Dengan asumsi bahwa ada beberapa rangkaian tugas yang tidak terkait yang harus diselesaikan dalam skenario bisnis, ada dua solusi utama modern:
eksekusi serial single-threaded.
Diselesaikan secara paralel dengan beberapa thread.
Eksekusi serial single-thread adalah model pemrograman sinkron meskipun lebih sesuai dengan cara berpikir programmer secara berurutan dan membuatnya lebih mudah untuk menulis kode yang lebih nyaman, karena mengeksekusi I/O secara sinkron, ia hanya dapat memproses I/O. pada saat yang sama. Satu permintaan akan menyebabkan server merespons dengan lambat dan tidak dapat diterapkan dalam skenario aplikasi konkurensi tinggi. Selain itu, karena memblokir I/O, CPU akan selalu menunggu hingga I/O selesai dan tidak dapat melakukannya hal-hal lain, yang akan membatasi kekuatan pemrosesan CPU. Untuk dimanfaatkan sepenuhnya, hal ini pada akhirnya akan menyebabkan efisiensi yang rendah,
dan model pemrograman multi-thread juga akan membuat pusing pengembang karena masalah seperti sinkronisasi status dan kebuntuan dalam pemrograman. Meskipun multi-threading secara efektif dapat meningkatkan pemanfaatan CPU pada CPU multi-core.
Meskipun model pemrograman eksekusi serial single-thread dan eksekusi paralel multi-thread memiliki kelebihannya masing-masing, namun juga memiliki kekurangan dalam hal kinerja dan kesulitan pengembangan.
Selain itu, mulai dari kecepatan merespons permintaan klien, jika klien memperoleh dua sumber daya sekaligus, kecepatan respons metode sinkron akan menjadi jumlah dari kecepatan respons kedua sumber daya, dan kecepatan respons dari metode sinkron. Metode asynchronous akan menjadi pertengahan dari keduanya. Yang terbesar, keunggulan kinerjanya sangat jelas dibandingkan dengan sinkronisasi. Ketika kompleksitas aplikasi meningkat, skenario ini akan berkembang menjadi merespons n permintaan pada saat yang sama, dan keunggulan asinkron dibandingkan dengan sinkronisasi akan disorot.
Singkatnya, Node memberikan jawabannya: gunakan satu thread untuk menghindari kebuntuan multi-thread, sinkronisasi status, dan masalah lainnya; gunakan I/O asinkron untuk menghindari pemblokiran satu thread agar dapat menggunakan CPU dengan lebih baik. Inilah sebabnya Node menggunakan asynchronous sebagai model pemrograman intinya.
Selain itu, untuk menutupi kekurangan single thread yang tidak dapat menggunakan CPU multi-core, Node juga menyediakan sub-proses yang mirip dengan Web Worker di browser, yang dapat memanfaatkan CPU secara efisien melalui proses pekerja.
Setelah membahas mengapa kita harus menggunakan asynchronous, bagaimana cara mengimplementasikan asynchronous?
Ada dua jenis operasi asinkron yang biasa kita sebut: satu adalah operasi yang terkait dengan I/O seperti I/O file dan I/O jaringan; yang lainnya adalah operasi yang tidak terkait dengan I/O seperti setTimeOut
dan setInterval
. Jelasnya, asinkron yang kita bahas mengacu pada operasi yang terkait dengan I/O, yaitu I/O asinkron.
I/O asinkron diusulkan dengan harapan bahwa panggilan I/O tidak akan menghalangi eksekusi program berikutnya, dan waktu awal menunggu penyelesaian I/O akan dialokasikan ke bisnis lain yang diperlukan untuk eksekusi. Untuk mencapai tujuan ini, Anda perlu menggunakan I/O non-pemblokiran.
Memblokir I/O berarti setelah CPU memulai panggilan I/O, CPU akan memblokir hingga I/O selesai. Mengetahui pemblokiran I/O, I/O non-pemblokiran mudah dimengerti. CPU akan segera kembali setelah memulai panggilan I/O alih-alih memblokir dan menunggu. Tentu saja, dibandingkan dengan I/O pemblokiran, I/O non-pemblokiran memiliki lebih banyak peningkatan kinerja.
Jadi, karena I/O non-pemblokiran digunakan dan CPU dapat segera kembali setelah memulai panggilan I/O, bagaimana CPU mengetahui bahwa I/O telah selesai? Jawabannya adalah jajak pendapat.
Untuk mendapatkan status panggilan I/O tepat waktu, CPU akan terus memanggil operasi I/O berulang kali untuk mengonfirmasi apakah I/O telah selesai. Teknologi panggilan berulang untuk menentukan apakah operasi telah selesai disebut polling .
Tentu saja, polling akan menyebabkan CPU berulang kali melakukan penilaian status, sehingga membuang-buang sumber daya CPU. Selain itu, interval polling sulit untuk dikontrol. Jika intervalnya terlalu panjang, penyelesaian operasi I/O tidak akan menerima respons yang tepat waktu, yang secara tidak langsung mengurangi kecepatan respons aplikasi; CPU pasti akan dihabiskan untuk polling. Ini memakan waktu lebih lama dan mengurangi pemanfaatan sumber daya CPU.
Oleh karena itu, meskipun polling memenuhi persyaratan bahwa I/O non-blocking tidak memblokir eksekusi program berikutnya, namun bagi aplikasi, hal tersebut masih dapat dianggap sebagai semacam sinkronisasi, karena aplikasi masih perlu menunggu I/ O untuk kembali sepenuhnya. Masih menghabiskan banyak waktu menunggu.
I/O asinkron sempurna yang kami harapkan adalah aplikasi memulai panggilan non-pemblokiran. Tidak perlu terus-menerus menanyakan status panggilan I/O melalui polling I/O selesai, Cukup teruskan data ke aplikasi melalui semaphore atau callback.
Bagaimana cara mengimplementasikan I/O asinkron ini? Jawabannya adalah kumpulan thread.
Meskipun artikel ini selalu menyebutkan bahwa Node dieksekusi dalam satu thread, thread tunggal di sini berarti kode JavaScript dieksekusi pada satu thread. Untuk bagian seperti operasi I/O yang tidak ada hubungannya dengan logika bisnis utama, dengan menjalankan Implementasi lain dalam bentuk thread tidak akan mempengaruhi atau memblokir jalannya thread utama. Sebaliknya, hal ini dapat meningkatkan efisiensi eksekusi thread utama dan mewujudkan I/O asinkron.
Melalui kumpulan thread, biarkan thread utama hanya melakukan panggilan I/O, biarkan thread lain melakukan pemblokiran I/O atau I/O non-pemblokiran ditambah teknologi polling untuk menyelesaikan akuisisi data, lalu gunakan komunikasi antar thread untuk menyelesaikan I /O Data yang diperoleh diteruskan, yang dengan mudah mengimplementasikan I/O asinkron:
Thread utama melakukan panggilan I/O, sedangkan kumpulan thread melakukan operasi I/O, menyelesaikan akuisisi data, dan kemudian meneruskan data ke thread utama melalui komunikasi antar thread untuk menyelesaikan panggilan I/O, dan thread utama reuses Fungsi panggilan balik memaparkan data kepada pengguna, yang kemudian menggunakan data tersebut untuk menyelesaikan operasi pada tingkat logika bisnis. Ini adalah proses I/O asinkron lengkap di Node.js. Bagi pengguna, tidak perlu khawatir tentang detail implementasi yang rumit dari lapisan yang mendasarinya. Mereka hanya perlu memanggil API asinkron yang dienkapsulasi oleh Node dan meneruskan fungsi panggilan balik yang menangani logika bisnis, seperti yang ditunjukkan di bawah ini:
const fs = require ("fs" ); fs.readFile('example.js', (data) => { // Proses logika bisnis});
Mekanisme implementasi dasar Nodejs yang tidak sinkron berbeda pada platform yang berbeda: di bawah Windows, IOCP terutama digunakan untuk mengirim panggilan I/O ke kernel sistem dan memperoleh operasi I/O yang lengkap dari kernel dengan event loop untuk menyelesaikan proses I/O asinkron; proses ini diimplementasikan melalui epoll di Linux; Kumpulan thread disediakan langsung oleh kernel (IOCP) di Windows, sedangkan seri *nix
diimplementasikan oleh libuv itu sendiri.
Karena perbedaan antara platform Windows dan platform *nix
, Node menyediakan libuv sebagai lapisan enkapsulasi abstrak, sehingga semua penilaian kompatibilitas platform diselesaikan oleh lapisan ini, memastikan bahwa Node lapisan atas dan kumpulan thread kustom lapisan bawah serta IOCP independen satu sama lain. Node akan menentukan kondisi platform selama kompilasi dan secara selektif mengkompilasi file sumber di direktori unix atau direktori win ke dalam program target:
Di atas adalah implementasi asinkron Node.
(Ukuran kumpulan utas dapat diatur melalui variabel lingkungan UV_THREADPOOL_SIZE
. Nilai defaultnya adalah 4. Pengguna dapat menyesuaikan ukuran nilai ini berdasarkan situasi aktual.)
Lalu pertanyaannya adalah, setelah mendapatkan data yang diteruskan oleh kumpulan thread, bagaimana thread utama? Kapan fungsi panggilan balik dipanggil? Jawabannya adalah perulangan peristiwa.
Karenamenggunakan fungsi panggilan balik untuk memproses data I/O, hal ini pasti melibatkan masalah kapan dan bagaimana memanggil fungsi panggilan balik. Dalam pengembangan sebenarnya, skenario panggilan I/O asinkron multi-tipe sering kali dilibatkan. Bagaimana mengatur panggilan-panggilan balik I/O asinkron ini secara wajar dan memastikan kemajuan panggilan balik asinkron yang teratur adalah masalah yang sulit I/O asinkron Selain /O, ada juga panggilan asinkron non-I/O seperti pengatur waktu. API tersebut sangat real-time dan memiliki prioritas yang lebih tinggi.
Oleh karena itu, harus ada mekanisme penjadwalan untuk mengoordinasikan tugas-tugas asinkron dengan prioritas dan jenis berbeda untuk memastikan bahwa tugas-tugas ini berjalan dengan tertib di thread utama. Seperti browser, Node telah memilih event loop untuk melakukan tugas berat ini.
Node membagi tugas menjadi tujuh kategori berdasarkan jenis dan prioritasnya: Timer, Pending, Idle, Prepare, Poll, Check, dan Close. Untuk setiap jenis tugas, terdapat antrian tugas masuk pertama, keluar pertama untuk menyimpan tugas dan callbacknya (Pengatur waktu disimpan di tumpukan kecil teratas). Berdasarkan tujuh jenis ini, Node membagi eksekusi event loop menjadi tujuh tahap berikut:
Prioritas eksekusi tahap
Pada tahap ini, event loop akan memeriksa struktur data (minimum heap) yang menyimpan timer, melintasi timer di dalamnya, membandingkan waktu saat ini dan waktu kedaluwarsa satu per satu, dan menentukan apakah timer telah kedaluwarsa , pengatur waktunya akan menjadi Fungsi panggilan balik dikeluarkan dan dijalankan.
Faseakan mengeksekusi panggilan balik ketika jaringan, IO, dan pengecualian lainnya terjadi. Beberapa kesalahan yang dilaporkan oleh *nix
akan ditangani pada tahap ini. Selain itu, beberapa callback I/O yang seharusnya dijalankan pada fase polling pada siklus sebelumnya akan ditunda ke fase ini.
hanya digunakan di dalam loop peristiwa.
mengambil peristiwa I/O baru; mengeksekusi callback terkait I/O (hampir semua callback kecuali callback shutdown, callback terjadwal waktu, dan setImmediate()
);
Jajak pendapat, yaitu tahap pemungutan suara adalah tahap terpenting dari perulangan peristiwa. Callback untuk I/O jaringan dan I/O file terutama diproses pada tahap ini. Tahap ini memiliki dua fungsi utama:
menghitung berapa lama tahap ini harus diblokir dan melakukan polling untuk I/O.
Menangani callback dalam antrian I/O.
Ketika loop peristiwa memasuki fase polling dan tidak ada pengatur waktu yang disetel:
Jika antrian polling tidak kosong, loop peristiwa akan melintasi antrian, mengeksekusinya secara sinkron hingga antrian kosong atau jumlah maksimum yang dapat dieksekusi tercapai.
Jika antrean polling kosong, salah satu dari dua hal lainnya akan terjadi:
Jika ada callback setImmediate()
yang perlu dijalankan, fase polling segera berakhir dan fase pemeriksaan dimasukkan untuk mengeksekusi callback.
Jika tidak ada callback setImmediate()
yang harus dieksekusi, loop peristiwa akan tetap berada di fase ini menunggu callback ditambahkan ke antrean dan kemudian segera mengeksekusinya. Perulangan peristiwa akan menunggu hingga batas waktu berakhir. Alasan mengapa saya memilih untuk berhenti di sini adalah karena Node terutama menangani IO, sehingga dapat merespons IO dengan lebih tepat waktu.
Setelah antrian polling kosong, loop peristiwa memeriksa pengatur waktu yang telah mencapai ambang waktunya. Jika satu atau lebih pengatur waktu mencapai ambang waktu, perulangan peristiwa akan kembali ke fase pengatur waktu untuk mengeksekusi panggilan balik untuk pengatur waktu tersebut.
Fase ini akan mengeksekusi callback setImmediate()
secara berurutan.
Fase ini akan menjalankan beberapa callback untuk menutup sumber daya, seperti socket.on('close', ...)
. Keterlambatan pelaksanaan tahap ini akan berdampak kecil dan mempunyai prioritas paling rendah.
Ketika proses Node dimulai, proses Node akan menginisialisasi loop peristiwa, mengeksekusi kode masukan pengguna, membuat panggilan API asinkron yang sesuai, penjadwalan pengatur waktu, dll., dan kemudian mulai memasuki loop peristiwa:
┌────────── ── ─────────────────┐ ┌─>│ pengatur waktu │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ panggilan balik tertunda │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ menganggur, siapkan │ │ └─────────────┬─────────────┘ ┌─────────────────┐ │ ┌─────────────┴─────────────┐ │ masuk: │ │ │ jajak pendapat │<─────┤ koneksi, │ │ └─────────────┬──────────────┘ │ data, dll. │ │ ┌─────────────┴─────────────┐ └──────────────────┘ │ │ periksa │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ tutup panggilan balik │ └────────────────────────────────┘Setiap
iterasi dari event loop (sering disebut tick) akan diberikan seperti yang diberikan di atas Prioritasnya pesanan memasuki tujuh tahapan eksekusi. Setiap tahapan akan mengeksekusi sejumlah callback tertentu dalam antrian. Alasan mengapa hanya sejumlah tertentu yang dieksekusi tetapi tidak semuanya dieksekusi adalah untuk mencegah waktu eksekusi tahapan saat ini menjadi terlalu lama dan. menghindari kegagalan tahap berikutnya.
Oke, di atas adalah alur eksekusi dasar dari event loop. Sekarang mari kita lihat pertanyaan lain.
Untuk skenario berikut:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
Ketika layanan berhasil terikat ke port 8000, yaitu ketika listen()
berhasil dipanggil, callback dari acara listening
belum terikat, jadi setelah port berhasil diikat, panggilan balik dari acara listening
yang kami lewati tidak akan dijalankan.
Memikirkan pertanyaan lain, kita mungkin memiliki beberapa kebutuhan selama pengembangan, seperti menangani kesalahan, membersihkan sumber daya yang tidak diperlukan, dan tugas-tugas lain dengan prioritas rendah. Jika logika ini dijalankan secara sinkron, hal itu akan mempengaruhi efisiensi eksekusi. jika setImmediate()
diteruskan secara asinkron, seperti dalam bentuk callback, waktu eksekusinya tidak dapat dijamin, dan performa real-time tidak tinggi. Lalu bagaimana cara menghadapi logika tersebut?
Berdasarkan masalah ini, Node mengambil referensi dari browser dan mengimplementasikan serangkaian mekanisme tugas mikro. Di Node, selain memanggil new Promise().then()
fungsi panggilan balik yang diteruskan akan dienkapsulasi menjadi tugas mikro. Panggilan balik process.nextTick()
juga akan dienkapsulasi menjadi tugas mikro, dan prioritas eksekusi dari tugas tersebut. yang terakhir akan lebih tinggi dari yang sebelumnya.
Dengan tugas mikro, bagaimana proses eksekusi loop peristiwa? Dengan kata lain, kapan tugas mikro dijalankan?
Di node 11 dan versi yang lebih baru, setelah tugas dalam suatu tahapan dijalankan, antrean tugas mikro segera dijalankan dan antrean dihapus.
Eksekusi tugas mikro dimulai setelah tahapan dijalankan sebelum node11.
Oleh karena itu, dengan tugas mikro, setiap siklus perulangan peristiwa pertama-tama akan menjalankan tugas di tahap pengatur waktu, lalu menghapus antrian tugas mikro dari process.nextTick()
dan new Promise().then()
secara berurutan, lalu Lanjutkan untuk mengeksekusi tugas berikutnya dalam tahap pengatur waktu atau tahap berikutnya, yaitu tugas dalam tahap yang tertunda, dan seterusnya dalam urutan ini.
Dengan menggunakan process.nextTick()
, Node dapat menyelesaikan masalah pengikatan port di atas: di dalam metode listen()
, penerbitan acara listening
akan dienkapsulasi menjadi panggilan balik dan diteruskan ke process.nextTick()
, seperti yang ditunjukkan pada pseudo berikut kode:
fungsi mendengarkan() { // Melaksanakan operasi port mendengarkan... // Enkapsulasi penerbitan event `listening` ke dalam callback dan teruskan ke `process.nextTick()` di process.nextTick(() => { emit('mendengarkan'); }); };
Setelah kode saat ini dijalankan, tugas mikro akan mulai dijalankan, sehingga mengeluarkan acara listening
dan memicu panggilan acara callback.
Karena ketidakpastian dan kompleksitas dari asynchronous itu sendiri, dalam proses penggunaan API asynchronous yang disediakan oleh Node, meskipun kita telah menguasai prinsip eksekusi event loop, mungkin masih ada beberapa fenomena yang tidak intuitif atau tidak diharapkan. .
Misalnya, urutan eksekusi pengatur waktu ( setTimeout
, setImmediate
) akan berbeda tergantung pada konteks pemanggilannya. Jika keduanya dipanggil dari konteks tingkat atas, waktu eksekusinya bergantung pada kinerja proses atau mesin.
Mari kita lihat contoh berikut:
setTimeout(() => { console.log('batas waktu'); }, 0); set Segera(() => { console.log('segera'); });
Apa hasil eksekusi dari kode di atas? Berdasarkan uraian kami tentang perulangan peristiwa tadi, Anda mungkin mendapatkan jawaban ini: Karena fase pengatur waktu akan dieksekusi sebelum fase pemeriksaan, panggilan balik setTimeout()
akan dieksekusi terlebih dahulu, dan kemudian panggilan balik setImmediate()
akan menjadi dieksekusi.
Faktanya, hasil keluaran dari kode ini tidak pasti. Batas waktu mungkin merupakan keluaran pertama, atau langsung mungkin keluaran pertama. Hal ini karena kedua pengatur waktu dipanggil dalam konteks global. Ketika loop peristiwa mulai berjalan dan dieksekusi ke tahap pengatur waktu, waktu saat ini mungkin lebih besar dari 1 mdtk atau kurang dari 1 mdtk, bergantung pada kinerja eksekusi mesin , sebenarnya belum pasti setTimeout()
akan dieksekusi pada tahap pengatur waktu pertama, sehingga akan muncul hasil keluaran yang berbeda.
(Bila nilai delay
(parameter kedua setTimeout
) lebih besar dari 2147483647
atau kurang dari 1
, delay
akan disetel ke 1
)
Mari kita lihat kode berikut:
const fs = require('fs'); fs.readFile(__namafile, () => { setWaktu habis(() => { console.log('batas waktu'); }, 0); set Segera(() => { console.log('segera'); }); });
Dapat dilihat bahwa dalam kode ini, kedua pengatur waktu dienkapsulasi ke dalam fungsi panggilan balik dan diteruskan ke readFile
. Jelas bahwa ketika panggilan balik dipanggil, waktu saat ini harus lebih besar dari 1 ms, sehingga panggilan balik setTimeout
akan terjadi. lebih lama dari callback setImmediate
Callback dipanggil terlebih dahulu, sehingga hasil cetakannya adalah: timeout immediate
.
Di atas adalah hal-hal terkait timer yang perlu Anda perhatikan ketika menggunakan Node.js. Selain itu, Anda juga perlu memperhatikan urutan eksekusi process.nextTick()
, new Promise().then()
dan setImmediate()
. Karena bagian ini relatif sederhana, maka telah disebutkan sebelumnya dan tidak akan diulang .
: Artikel diawali dengan penjelasan lebih detail tentang prinsip implementasi event loop Node dari dua perspektif mengapa asynchronous diperlukan dan bagaimana mengimplementasikan asynchronous, serta menyebutkan beberapa hal terkait yang perlu diperhatikan Anda.