Perulangan peristiwa adalah mekanisme Node.js untuk menangani operasi I/O non-pemblokiran - meskipun JavaScript berulir tunggal - dengan memindahkan operasi ke kernel sistem jika memungkinkan.
Karena sebagian besar core saat ini bersifat multi-thread, mereka dapat menangani berbagai operasi di latar belakang. Ketika salah satu operasi selesai, kernel memberi tahu Node.js untuk menambahkan fungsi panggilan balik yang sesuai ke antrian polling dan menunggu kesempatan untuk dieksekusi. Kami akan memperkenalkannya secara rinci nanti di artikel ini.
Ketika Node.js dimulai, ia akan menginisialisasi perulangan peristiwa dan memproses skrip masukan yang disediakan (atau memasukkannya ke dalam REPL, yang tidak dibahas dalam artikel ini). atau panggil process.nextTick()
lalu mulai memproses loop peristiwa.
Diagram di bawah menunjukkan ikhtisar sederhana tentang urutan operasi perulangan peristiwa.
┌─────────────────────────────┐ ┌─>│ pengatur waktu │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ panggilan balik tertunda │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ menganggur, siapkan │ │ └─────────────┬─────────────┘ ┌─────────────────┐ │ ┌─────────────┴─────────────┐ │ masuk: │ │ │ jajak pendapat │<─────┤ koneksi, │ │ └─────────────┬──────────────┘ │ data, dll. │ │ ┌─────────────┴─────────────┐ └──────────────────┘ │ │ periksa │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ tutup panggilan balik │ └────────────────────────────────┘
Catatan: Setiap kotak disebut tahapan mekanisme perulangan peristiwa.
Setiap tahap memiliki antrian FIFO untuk mengeksekusi panggilan balik. Meskipun setiap tahap bersifat khusus, umumnya ketika perulangan peristiwa memasuki tahap tertentu, ia akan melakukan operasi apa pun yang spesifik untuk tahap tersebut dan kemudian mengeksekusi panggilan balik dalam antrean tahap tersebut hingga antrean habis atau jumlah panggilan balik maksimum telah dieksekusi. Ketika antrian habis atau batas panggilan balik tercapai, loop peristiwa berpindah ke fase berikutnya, dan seterusnya.
Karena salah satu dari operasi ini mungkin menjadwalkan lebih banyak operasi dan kejadian baru yang diantrekan oleh kernel untuk diproses selama fase polling , kejadian polling mungkin diantrekan saat memproses kejadian dalam fase polling. Oleh karena itu, callback yang berjalan lama dapat membuat fase pemungutan suara berjalan lebih lama dari waktu ambang batas pengatur waktu. Lihat bagian Pengatur Waktu dan Polling untuk informasi lebih lanjut.
Catatan: Ada sedikit perbedaan antara implementasi Windows dan Unix/Linux, tapi ini tidak penting untuk tujuan demonstrasi. Bagian terpenting ada di sini. Sebenarnya ada tujuh atau delapan langkah, namun yang menjadi perhatian kami adalah Node.js sebenarnya menggunakan beberapa langkah di atas.
Pengatur waktusetTimeout()
dan setInterval()
.setImmediate()
), dalam kasus lain node akan Memblokir di sini bila diperlukan.setImmediate()
dijalankan di sini.socket.on('close', ...)
.Di antara setiap putaran peristiwa yang dijalankan, Node.js memeriksa apakah ia menunggu I/O atau pengatur waktu asinkron, dan jika tidak, mati sepenuhnya.
Waktu Pengatur waktu menentukan ambang batas di mana panggilan balik yang diberikan dapat dieksekusi, bukan waktu persisnya yang diinginkan pengguna untuk dijalankan. Setelah interval yang ditentukan, panggilan balik pengatur waktu akan dijalankan sedini mungkin. Namun, hal tersebut mungkin tertunda karena penjadwalan sistem operasi atau callback lain yang sedang berjalan.
Catatan : Fase polling mengontrol kapan pengatur waktu dijalankan.
Misalnya, Anda menjadwalkan pengatur waktu yang waktu habisnya setelah 100 milidetik, lalu skrip Anda mulai membaca file secara asinkron yang memerlukan waktu 95 milidetik:
const fs = require('fs'); fungsi someAsyncOperation(panggilan balik) { // Asumsikan ini memerlukan waktu 95 md untuk menyelesaikannya fs.readFile('/path/ke/file', panggilan balik); } const timeoutScheduled = Tanggal.sekarang(); setWaktu habis(() => { const delay = Tanggal.sekarang() - batas waktu terjadwal; console.log(`${delay}ms telah berlalu sejak saya dijadwalkan`); }, 100); // lakukan someAsyncOperation yang membutuhkan waktu 95 ms untuk menyelesaikannya someAsyncOperation(() => { const startCallback = Tanggal.sekarang(); // lakukan sesuatu yang memerlukan waktu 10 md... while (Tanggal.sekarang() - startCallback < 10) { // tidak melakukan apa pun } });
Ketika loop peristiwa memasuki fase polling , ia memiliki antrian kosong ( fs.readFile()
belum selesai), sehingga akan menunggu sisa jumlah milidetik hingga ambang batas waktu tercepat tercapai. Ketika menunggu 95 milidetik hingga fs.readFile()
selesai membaca file, callback-nya, yang memerlukan waktu 10 milidetik untuk menyelesaikannya, akan ditambahkan ke antrean polling dan dieksekusi. Ketika panggilan balik selesai, tidak ada lagi panggilan balik dalam antrian, sehingga mekanisme loop peristiwa akan melihat pengatur waktu yang mencapai ambang batas tercepat dan kemudian akan kembali ke fase pengatur waktu untuk mengeksekusi panggilan balik pengatur waktu. Dalam contoh ini, Anda akan melihat bahwa total penundaan antara pengatur waktu yang dijadwalkan dan panggilan baliknya dijalankan adalah 105 milidetik.
CATATAN: Untuk mencegah fase polling membuat loop peristiwa kelaparan, libuv (pustaka C yang mengimplementasikan loop peristiwa Node.js dan semua perilaku asinkron platform) juga memiliki hard maksimum ( bergantung pada sistem).
Fase ini menjalankan panggilan balik untuk operasi sistem tertentu (seperti jenis kesalahan TCP). Misalnya, beberapa sistem *nix ingin menunggu untuk melaporkan kesalahan jika soket TCP menerima ECONNREFUSED
saat mencoba menyambung. Ini akan dimasukkan ke dalam antrean untuk dieksekusi selama fase panggilan balik yang tertunda .
Fase polling memiliki dua fungsi penting:
menghitung berapa lama I/O harus diblokir dan melakukan polling.
Kemudian, tangani kejadian di antrian pemungutan suara .
Ketika loop peristiwa memasuki fase polling dan tidak ada pengatur waktu yang dijadwalkan, salah satu dari dua hal akan terjadi:
Jika antrian polling tidak kosong
, loop peristiwa akan mengulangi antrian panggilan balik dan mengeksekusinya secara sinkron hingga antrian habis , atau batas keras terkait sistem tercapai.
Jika antrian polling kosong , dua hal lagi akan terjadi:
jika skrip dijadwalkan oleh setImmediate()
, loop peristiwa akan mengakhiri fase polling dan melanjutkan fase pemeriksaan untuk mengeksekusi skrip terjadwal tersebut.
Jika skrip tidak dijadwalkan oleh setImmediate()
, loop peristiwa akan menunggu panggilan balik ditambahkan ke antrian dan kemudian segera mengeksekusinya.
Setelah antrian polling kosong, loop peristiwa memeriksa pengatur waktu yang telah mencapai ambang waktunya. Jika satu atau lebih pengatur waktu sudah siap, loop peristiwa akan kembali ke fase pengatur waktu untuk mengeksekusi callback untuk pengatur waktu tersebut.
Fase ini memungkinkan seseorang untuk mengeksekusi callback segera setelah fase polling selesai. Jika fase polling menjadi tidak aktif dan skrip dimasukkan ke dalam antrean setelah menggunakan setImmediate()
, loop peristiwa dapat melanjutkan ke fase pemeriksaan alih-alih menunggu.
setImmediate()
sebenarnya adalah pengatur waktu khusus yang berjalan dalam fase terpisah dari perulangan peristiwa. Ia menggunakan API libuv untuk menjadwalkan callback yang akan dieksekusi setelah fase polling selesai.
Biasanya, saat mengeksekusi kode, loop peristiwa akhirnya mencapai fase polling, di mana ia menunggu koneksi masuk, permintaan, dll. Namun, jika callback telah dijadwalkan menggunakan setImmediate()
dan fase polling menjadi idle, maka fase ini akan berakhir dan melanjutkan ke fase pemeriksaan alih-alih terus menunggu acara polling.
Jika soket atau handler ditutup secara tiba-tiba (misalnya socket.destroy()
), event 'close'
akan dikeluarkan pada tahap ini. Jika tidak, maka akan dikeluarkan melalui process.nextTick()
.
setImmediate()
dan setTimeout()
sangat mirip, namun perilakunya berbeda berdasarkan waktu pemanggilannya.
setImmediate()
dirancang untuk menjalankan skrip setelah fase polling saat ini selesai.setTimeout()
menjalankan skrip setelah ambang batas minimum (dalam ms) terlampaui.Urutan eksekusi pengatur waktu akan bervariasi tergantung pada konteks pemanggilannya. Jika keduanya dipanggil dari dalam modul utama, pengatur waktu akan terikat oleh kinerja proses (yang mungkin dipengaruhi oleh aplikasi lain yang berjalan di komputer).
Misalnya, jika Anda menjalankan skrip berikut yang tidak berada di dalam siklus I/O (yaitu modul utama), urutan eksekusi kedua pengatur waktu adalah non-deterministik karena dibatasi oleh kinerja proses:
// batas waktu_vs_immediate.js setWaktu habis(() => { console.log('batas waktu'); }, 0); set Segera(() => { console.log('segera'); }); $ simpul batas waktu_vs_immediate.js batas waktu segera $ simpul batas waktu_vs_immediate.js segera timeout
Namun, jika Anda memasukkan kedua fungsi ini ke dalam loop I/O dan memanggilnya, setImmediate akan selalu dipanggil terlebih dahulu:
// timeout_vs_immediate.js const fs = memerlukan('fs'); fs.readFile(__namafile, () => { setWaktu habis(() => { console.log('batas waktu'); }, 0); set Segera(() => { console.log('segera'); }); }); $ simpul batas waktu_vs_immediate.js segera batas waktu $ simpul batas waktu_vs_immediate.js segeraKeuntungan utama menggunakan setImmediate() untuk
batas waktu
setImmediate()
setTimeout()
adalah jika setImmediate()
dijadwalkan selama siklus I/O, maka akan dieksekusi sebelum ada pengatur waktu di dalamnya, bergantung pada berapa banyak pengatur waktu yang
Anda mungkin memperhatikan process.nextTick()
tidak ditampilkan dalam diagram, meskipun itu adalah bagian dari API asinkron. Ini karena process.nextTick()
secara teknis bukan bagian dari perulangan peristiwa. Sebaliknya, ia akan menangani nextTickQueue
setelah operasi saat ini selesai, terlepas dari tahap perulangan peristiwa saat ini. Operasi di sini dianggap sebagai transisi dari prosesor C/C++ yang mendasarinya, dan menangani kode JavaScript yang perlu dijalankan.
Melihat kembali diagram kita, setiap kali process.nextTick()
dipanggil dalam fase tertentu, semua callback yang diteruskan ke process.nextTick()
akan diselesaikan sebelum event loop berlanjut. Hal ini dapat menciptakan beberapa situasi buruk, karena memungkinkan Anda untuk "membuat kelaparan" I/O Anda melalui panggilan process.nextTick()
rekursif , sehingga mencegah loop peristiwa mencapai tahap pemungutan suara .
Mengapa hal seperti ini disertakan di Node.js? Salah satu bagiannya adalah filosofi desain yang mengharuskan API selalu asinkron, meskipun sebenarnya tidak harus demikian. Ambil cuplikan kode ini sebagai contoh:
function apiCall(arg, callback) { jika (typeof arg !== 'string') proses kembali.nextTick( panggilan balik, new TypeError('argumen harus berupa string') ); }
Cuplikan kode untuk pemeriksaan parameter. Jika salah, kesalahan diteruskan ke fungsi panggilan balik. API baru-baru ini diperbarui untuk memungkinkan meneruskan argumen ke process.nextTick()
yang akan memungkinkannya menerima argumen apa pun setelah posisi fungsi panggilan balik dan meneruskan argumen ke fungsi panggilan balik sebagai argumen ke fungsi panggilan balik sehingga Anda tidak perlu ke fungsi sarang.
Apa yang kami lakukan adalah meneruskan kesalahan kembali ke pengguna, tetapi hanya setelah kode pengguna lainnya dieksekusi. Dengan menggunakan process.nextTick()
, kami menjamin bahwa apiCall()
selalu menjalankan fungsi callbacknya setelah kode pengguna lainnya dan sebelum membiarkan event loop berlanjut. Untuk mencapai hal ini, tumpukan panggilan JS diperbolehkan untuk bersantai dan kemudian segera mengeksekusi panggilan balik yang disediakan, memungkinkan panggilan rekursif ke process.nextTick()
dilakukan tanpa mencapai RangeError: 超过V8 的最大调用堆栈大小
.
Prinsip desain ini dapat menimbulkan beberapa potensi masalah. Ambil cuplikan kode ini sebagai contoh:
let bar; // ini memiliki tanda tangan asinkron, tetapi memanggil panggilan balik secara sinkron fungsi someAsyncApiCall(panggilan balik) { panggilan balik(); } // callback dipanggil sebelum `someAsyncApiCall` selesai. someAsyncApiCall(() => { // sejak someAsyncApiCall selesai, bar belum diberi nilai apa pun console.log('bar', bar); // tidak terdefinisi }); bar = 1;
Pengguna mendefinisikan someAsyncApiCall()
memiliki tanda tangan asinkron, namun sebenarnya ini berjalan secara sinkron. Saat dipanggil, callback yang diberikan ke someAsyncApiCall()
dipanggil dalam fase yang sama dengan loop peristiwa karena someAsyncApiCall()
sebenarnya tidak melakukan apa pun secara asinkron. Akibatnya, fungsi panggilan balik mencoba mereferensikan bar
, namun variabel tersebut mungkin belum berada dalam cakupannya karena skrip belum selesai dijalankan.
Dengan menempatkan panggilan balik di process.nextTick()
, skrip masih memiliki kemampuan untuk berjalan hingga selesai, memungkinkan semua variabel, fungsi, dll. diinisialisasi sebelum panggilan balik dipanggil. Ini juga memiliki keuntungan karena tidak membiarkan perulangan peristiwa berlanjut, dan cocok untuk memperingatkan pengguna ketika terjadi kesalahan sebelum membiarkan perulangan peristiwa berlanjut. Berikut adalah contoh sebelumnya menggunakan process.nextTick()
:
let bar; fungsi someAsyncApiCall(panggilan balik) { proses.nextTick(panggilan balik); } someAsyncApiCall(() => { konsol.log('bar', bar); // 1 }); bar = 1;
Ini adalah contoh nyata lainnya:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
Hanya ketika port dilewatkan, port akan langsung terikat. Oleh karena itu, callback 'listening'
dapat segera dipanggil. Masalahnya adalah panggilan balik .on('listening')
belum disetel pada saat itu.
Untuk mengatasi masalah ini, acara 'listening'
dimasukkan ke dalam antrian di nextTick()
untuk memungkinkan skrip dijalankan hingga selesai. Hal ini memungkinkan pengguna mengatur event handler apa pun yang mereka inginkan.
Sejauh menyangkut pengguna, kami memiliki dua panggilan serupa, tetapi namanya membingungkan.
process.nextTick()
segera dieksekusi pada tahap yang sama.setImmediate()
diaktifkan pada iterasi berikutnya atau 'centang' dari loop peristiwa.Pada dasarnya, kedua nama tersebut harus ditukar karena process.nextTick()
diaktifkan lebih cepat daripada setImmediate()
, namun ini adalah warisan dari masa lalu dan oleh karena itu kemungkinan tidak akan berubah. Jika Anda melakukan pertukaran nama dengan gegabah, Anda akan merusak sebagian besar paket di npm. Semakin banyak modul baru yang ditambahkan setiap hari, yang berarti setiap hari kita harus menunggu, semakin besar pula potensi kerusakan yang dapat terjadi. Meskipun nama-nama ini membingungkan, nama-nama itu sendiri tidak akan berubah.
Kami menyarankan agar pengembang menggunakan setImmediate()
dalam segala situasi karena lebih mudah dipahami.
Ada dua alasan utama:
untuk memungkinkan pengguna menangani kesalahan, membersihkan sumber daya yang tidak dibutuhkan, atau mencoba ulang permintaan sebelum perulangan peristiwa berlanjut.
Terkadang panggilan balik perlu dijalankan setelah tumpukan dibuka tetapi sebelum perulangan peristiwa berlanjut.
Berikut adalah contoh sederhana yang memenuhi harapan pengguna:
const server = net.createServer(); server.on('koneksi', (sambungan) => {}); server.mendengarkan(8080); server.on('listening', () => {});
Asumsikan listen()
dijalankan pada awal loop peristiwa, namun callback mendengarkan ditempatkan di setImmediate()
. Kecuali jika nama host dilewatkan, port akan segera diikat. Agar event loop dapat dilanjutkan, maka harus mencapai fase polling , yang berarti ada kemungkinan koneksi telah diterima dan event koneksi telah diaktifkan sebelum event listening.
Contoh lain menjalankan konstruktor fungsi yang mewarisi dari EventEmitter
dan ingin memanggil konstruktor:
const EventEmitter = require('events'); const util = memerlukan('util'); fungsi MyEmitter() { EventEmitter.call(ini); this.emit('acara'); } util.inherits(MyEmitter, EventEmitter); const myEmitter = myEmitter baru(); myEmitter.on('acara', () => { console.log('suatu peristiwa terjadi!'); });
Anda tidak dapat langsung memicu peristiwa dari konstruktor karena skrip belum diproses hingga pengguna menetapkan fungsi panggilan balik ke peristiwa tersebut. Jadi di dalam konstruktor itu sendiri Anda dapat menggunakan process.nextTick()
untuk menyiapkan panggilan balik sehingga acara dikeluarkan setelah konstruktor selesai, seperti yang diharapkan:
const EventEmitter = require('events'); const util = memerlukan('util'); fungsi MyEmitter() { EventEmitter.call(ini); // gunakan nextTick untuk memunculkan event setelah handler ditetapkan proses.nextTick(() => { this.emit('acara'); }); } util.inherits(MyEmitter, EventEmitter); const myEmitter = myEmitter baru(); myEmitter.on('acara', () => { console.log('suatu peristiwa terjadi!'); });
Sumber: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/