NodeJS | Event Loop Phases
JavaScript-ის Event Loop-ში გვაქვს 2 Queue ესენი არის Callback (Task) Queue და MicroTask Queue (Promises), NodeJS-ში გვაქვს 6 Queue რომლის Event Callback–ები გარკვეულ ფაზაზე სრულდება, ყველა ფაზას თავის Queue აქვს. წინა პოსტში განვიხილე Event Loop-ის და Libuv-ის მუშაობის პრინციპი, ამ პოსტში დავწერ ფაზების თანმიმდევრობაზე.
Event Loop Phases
როგორც ზემოთ ვახსენე NodeJS-ში გვაქვს 6 ფაზა, თითოეულს თავისი Event Queue აქვს სადაც დასრულებული callback-ები ვარდება. ექვსი ფაზიდან ოთხი ფაზა არის რეალიზებული Libuv-ში, ორი ფაზა კი Node-ში.
Libuv Phases
- Expired Timer Callbacks
- I/O
- Set Immediate
- Close Handlers
NodeJS Phases
- Next Tick
- Promises
როდესაც Node-ის პროგრამა სრულდება, ინტერპრეტატორი კითხულობს კოდს და ასრულებს მას თუ კოდი ასინქრონულია იძახებს შესაბამის V8 Bindings-ს და Libuv-ი არეგისტრირებს Event-ს რომლის Callback-იც გარკვეულ ფაზაზე შესრულდება.
მაგალითად:
console.log('start');
setTimeout(() => console.log('setTimeout'), 0);
console.log('end');
ამ კოდის შედეგი იქნება:
start
end
setTimeout
გავარჩიოთ step-by-step
- პირველ ხაზზე პროგრამამ წაიკითხა console.log(‘start’) რადგან ეს კოდი სინქრონულია თავსდება Stack-ში, სრულდება და კონსოლში იწერება ‘start’.
- ვიძახებთ setTimeout-ს (setTimeout-ი ასინქრონული ოპერაციაა).
- V8 Binding-ის მეშვეობით ვიძახებთ მის რეალიზაციას Libuv-ში.
- Libuv არეგისტრირებს setTimeout-ის callback-ს.
- Stack-ში ვარდება console.log(‘end’), სრულდება და კონსოლში იწერება ‘end’.
- Libuv-ი ფონურ რეჟიმში ასრულებს setTimeout-ს (მის დეტალურ შესრულებაზე შემდეგ პოსტში დეტალურად დავწერ).
- Timeout დროის გასვლის შემდეგ Libuv-ი მოათავსებს ამ ანონიმურ ფუნქციას
() => console.log('setTimeout')
Set Timeout-ის Event Queue-ში. - NodeJS-ში პირველი ფაზა Set Timeout არის, ამიტომ პირველი Set Timeout-ის Event Queue-ში მოთავსებული callack-ები შესრულდება, შესაბამისად Event Loop-ი ამ ანონიმურ ფუნქციას მოათავსებს Stack-ში.
- Stack-ში შესრულდება console.log(‘setTimeout’) რომელიც კონსოლში დაწერს ‘setTimeout’-ს.
(შეგიძლიათ ეს კოდი ამ საიტზე შეასრულოთ, რომელიც Event Loop-ის ვიზუალიზაციას ახდენს JavaScript-ისთვის, მაგრამ JavaScript-ის და Node-ის Event Loop-ები განსხვავდება)
ასინქრონული კოდი სხვადასხვა ფაზებზე სრულდება. განვიხილოთ მათი თანმდიდევრობა:
NodeJS-ის ფაზებს Intermediate ფაზებს ვუწოდებთ რადგან მათში არსებული callback–ები ყოველი ფაზის დასრულების შემდეგ სრულდებიან. შესაბამისად ფაზების თანმიმდევრობა, როგორც სურათზეა ნაჩვენები, შემდეგნაირია:
- Expired Timer Callbacks (setTimeout/setInterval)
- I/O
- Set Immediate
- Close Handlers
Event Loop-ის მუშაობის დაწყებისას და ყოველი ფაზის დასრულების შემდეგ:
- process.nextTick Callbacks
- Micro Tasks
(ამ სტატიაში დაწერილი კოდი Node 14 ვერსიის გარემოში სრულდება, შეიძლება სხვადასხვა ვერსიაში მათი თანმიმდევრობა განსხვავდებოდეს, ამაზეც შემდეგ პოსტში დავწერ)
შევასრულოთ შემდეგი კოდი რომელშიც ყველა ფაზაზე მოხვდება callback-ი:
const fs = require('fs')
setTimeout(() => console.log('3. Expired Timer Callbacks'), 1)
fs.readFile('./some.txt', { encoding: 'utf-8' }, () =>
console.log('4. I/O'),
)
setImmediate(() => console.log('5. Set Immediate'))
process.nextTick(() => console.log('1. process.nextTick'))
Promise.resolve().then(() => console.log('2. Micro Task | Promise'))
process.on('exit', () => {
console.log('6. Close Handler')
})
(code snippet) ამ კოდის შედეგი იქნება:
1. process.nextTick
2. Micro Task | Promise
3. Expired Timer Callbacks
4. I/O
5. Set Immediate
6. Close Handler
გავარჩიოთ შემდეგი კოდი:
- Node-მა შეასრულა კოდი line-by-line
- დაარეგისრიდა Event-ები (ფაზა + callback)
- Libuv-იმ დაიწყო ასინქრონული event-ების შესრულება
- Event-ების შესრულების შემდეგ Libuv-ი ანთავსებს Callback-ებს Queue-ში
- Event Loop-ი იწყებ მუშაობას
- პირველი სრულდება process.nextTick და promise callback-ები.
- შემდეგ Expired Callbacks, I/O, Set Immediate, Close Handler callback-ები თანმიმდევრობით.
შეგვიძლია Queue-ები ასე წარმოვიდგინოთ:
ჩნდება კითხვა თუ რა მოხდება setTimeout 5 წამამდე რომ გავზარდოთ? დაელოდება timeout-ის დასრულებას 5 წამი? გავზარდოთ setTimeout-ის დრო 5 წამამდე.
ავიღოთ შემდეგი კოდი:
const fs = require('fs')
const net = require('net')
const server = net.createServer();
server.listen(8080);
setTimeout(() => console.log('7. Expired Timer Callbacks'), 5000)
fs.readFile('./some.txt', { encoding: 'utf-8' }, () => console.log('4. I/O'))
setImmediate(() => console.log('5. Set Immediate'))
process.nextTick(() => console.log('1. process.nextTick'))
Promise.resolve().then(() => console.log('2. Micro Task | Promise'))
const socket = net.createConnection(8080, '127.0.0.1');
socket.destroy();
socket.on('close', () => console.log('6. Socket Close Handler'));
(code snippet) კოდის რეზულტატი იქნება შემდეგი:
1. process.nextTick
2. Micro Task | Promise
4. I/O
5. Set Immediate
6. Socket Close Handler
7. Expired Timer Callbacks
რადგან წინა კოდში setTimeout-ს 1 მილიწამიანი ტაიმაუტით ვიძახებდით, Event Loop-ის მუშაობის დაწყებისას მისი callback უკვე Expired Callback Queue-ში იყო, ამ შემთხვევაში მას 5 წამიანი ტაიმაუტით ვიძახებთ, შესაბამისად Event Loop-ი სხვა ფაზებზე გადადის, ბოლო ფაზა Close Handler-ის callback-ი სრულდება Socket Close Handler
, Close Handlers ფაზის დასრულების შემდეგ Node ამოწმებს ხომ არ არის Pending Task-ი, ჩვენს შემთხვევაში setTimeout Pending Task არის ამიტომ Node პროგრამიდან არ გამოდის, setTimeout-ის დასრულების შემდეგ Event Loop-ი იწყებს ახალ იტერაციას Expired Callback-ის Event Queue-ში იქნება callback-ი რომელიც console-ში ჩაწერს Expired Timer Callbacks
. კოდში გვიწერია server.listen(8080);
Event Loop-ი გადავა მოლოდინის რეჟიმში (რადგან ივენთები არ არის), მაგრამ პროგრამა არ შეწყვეტს მუშაობას.
განვიხილოთ კიდევ ერთი კოდის მაგალითი:
console.log('simple console.log')
process.nextTick(() => console.log('processNextTick'));
setImmediate(() => {
console.log('setImmediate');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('second promise resolve'))
});
Promise.resolve().then(() => console.log('promise resolve'));
new Promise((resolve) => {
console.log('new Promise()')
resolve();
});
setTimeout(() => console.log('outer setTimeout'), 0);
(code snippet) ეცადეთ გასცეთ პასუხი რა თანმიმდევრობით შესრულდება კოდი და შეადარეთ რეზულტატს:
simple console.log
new Promise()
processNextTick
promise resolve
outer setTimeout
setImmediate
second promise resolve
setTimeout
- Node იწყებს კოდის შესრულებას
- შესრულდება სინქრონული simple console.log
- შესრულდება process.nextTick და მისი ფაზისთვის დარეგისტრირდება Event-ი შემდები callback-ით: () => console.log(‘processNextTick’).
- შესრულდება setImmediate, დარეგისტრირდება Event-ი
- შესრულდება Promise.resolve, დარეგისტრირდება Event-ი
- შესრულდება new Promise(…), რადგან console.log-ს resolve()-ში არ გადავცემთ callback-ად, ის სინქრონული კოდის ნაწილია (იმის მიუხედავად რომ Promise-ის callback-ში წერია) ამიტომ console-ში ჩაიწერება ‘new Promise()’
- შესრულდება setTimeout, დარეგისტრირდება Event-ი
- process.nextTick, Micro Task callback-ები შესრულდება -> processNextTick promise resolve
- Expired Timeout Callback - callback-ებში არის მე-7. ნაბიჯში დარეგისტრირებული Event-ის callback-ი, რომელიც დალოგავს ‘outer setTimeout’
- მოწმდება I/O Event-ებს, რომელიც ცარიელი იქნება, გადავდივართ შემდეგ ფაზაზე.
- Set Immediate - შესრულდება setImmediate-ის callback-ი რომელიც 11.1 დალოგავს ‘setImmediate’-ს 11.2 სრულდება setTimeout-ი, რეგისტრირდება callback-ი Expired Callback Queue-ში 11.3 სრულდება Promise-ი, რეგისტრირდება callback-ი Micro Task Queue-ში
- setImmediate ფაზის (როგორც ყოველი ფაზის დასრულების შემდეგ) ვამოწმებთ process.nextTick, Micro Task Queue-ებს
- 8.3 ნაბიჯში დარეგისტრირებული Promise არის Micro Task Queue-ში, სრულდება მისი callback-ი, ილოგება ‘second promise resolve’
- Close Handler - ფაზაში ივენთი არ არის. რადგან Close Handlers ბოლო ფაზაა, Event Loop იწყებს ახალ იტერაციას.
- მოწმდება process.nextTick, Micro Task Queue-ები, ივენთები არ არის.
- Expired Callbacks - 8.2 ნაბიჯში დარეგისტრირებული setTimeout-ის callback-ი არის Expired Callback Queue-ში, სრულდება და ილოგება ‘setTimeout’
- შემდეგ არც ერთ ფაზაში არ იქნება ივენთები, ამიტომ Node დაასრულებს მუშაობას.
Event Loop Starvation
როგორც უკვე ვახსენე process.nextTick და Micro Task-ები სრულდება ყოველი ფაზის დასრულების შემდეგ, რაც იმას ნიშნავს რომ სანამ ეს Queue-ბი არ დაცარიელდება, სხვა ფაზაზე არ გადავა Node.
function promiseRecursive(count) {
if (count === 0) return
new Promise(() => {
console.log('promise starvation')
promiseRecursive(count - 1)
})
}
setTimeout(() => console.log('setTimeout'), 0);
fs.readFile('./firebase.json', {encoding: 'utf-8'}, () => {
console.log('readFile')
promiseRecursive(5);
setTimeout(() => console.log('setTimeout'), 0);
})
promiseRecursive
ფუნქცია 5-ჯერ რეკურსიულად დაამატებს Promise-ს Micro Task Queue-ში
თუ ფუნქციას არ შევზღუდავდით 5 გამოძახებით, setTimeout Queue-ზე არასდროს გადავიდოდა Event Loop-ი.
ამიტომ კოდის შესრულების რეზულტატი შემდეგი იქნება:
setTimeout
readFile
promise starvation
promise starvation
promise starvation
promise starvation
promise starvation
setTimeout
ანალოგიურად process.nextTick ფაზაზე.
function setImmediateRecursive(count) {
if (count === 0) return
setImmediate(() => {
console.log('setImmediate')
setImmediateRecursive(count - 1)
})
}
setTimeout(() => console.log('setTimeout'), 0);
fs.readFile('./firebase.json', {encoding: 'utf-8'}, () => {
console.log('readFile')
setImmediateRecursive(5);
setTimeout(() => console.log('setTimeout'), 0);
})
თუ იგივეს ვცდით setImmediate-ის შემთხვევაში, პირველი setImmediate-ის შესრულების შემდეგ Event Loop-ი გადავა შემდეგ ფაზაზე.
setTimeout
readFile
setImmediate
setTimeout
setImmediate
setImmediate
setImmediate
setImmediate
შემდეგ სტატიაში ვეცდები თვითოეული ფაზა ცალკ-ცალკე განვიხილო.