ასინქრონული ჯავასკრიპტი

ცოტა უცნაურად დავიწყოთ..

წარმოვიდგინოთ რესტორნის სამზარეულო, რომელიც შემდეგი პრინციპით მუშაობს : დროის კონკრეტულ მომენტში მზარეული ამზადებს მხოლოდ ერთ კერძს, ანუ თითოეული კერძის მზადება იწყება მას შემდეგ, რაც სრულდება წინა კერძი. ასეთი მიდგომა შეიძლება არაეფექტური იყოს და მომხმარებლებს დიდხანს მოუხდეთ ლოდინი.

უმჯობესია თუ მზარეული კერძებს პარალელურ რეჟიმში დაამზადებს, ანუ რამდენიმე კერძის ერთდროულად მზადებას შეუდგება, პერიოდულად გადაამოწმებს კონკრეტული კერძების მდგომარეობას და საჭიროებისამებრ დაუთმობს დროს თითოეულ მათგანს. ბუნებრივია ამ შემთხვევაში შეკვეთები გაცილებით სწრაფად გამზადდება და მომხმარებლებსაც ნაკლები ლოდინი მოუწევთ.

უფრო კონკრეტულად : დავუშვათ მზარეული ამზადებს ჩაქაფულს (🌿🍀🥩😀), გაამზადა ყველა საჭირო ინგრედიენტი და შემოდგა ხორცი ცეცხლზე 🔥 ამ დროს შემოვიდა ყველის ასორტის შეკვეთა 🧀, იმის ნაცვლად, რომ დაელოდოს ჩაქაფულის დასრულებას, მზარეული იწყებს ყველის დაჭრას, შიგადაშიგ კი ურევს ცეცხლზე შემოდგმულ ხორცს, ასორტის შეკვეთაც მზადდება და არც ხორცი იწვება 😍

სინქრონული პროგრამირება

სინქრონული პროგრამირება არის პროგრამირების მოდელი, რომელშიც ოპერაციები სრულდება თანმიმდევრულად, ერთმანეთის მიყოლებით - სრულდება ერთი, იწყება მეორე და ა.შ. ეს წრფიული მიდგომა გულისხმობს იმას, რომ ისეთი ოპერაციები, რომელთა შესრულებასაც შედარებით დიდი დრო სჭირდება, წარმოადგენენ ერთგვარ მბლოკავებს შესრულების ნაკადში მათ შემდეგ მდგომი ოპერაციებისათვის. სინქრონული პროგრამირება დაფუძნებულია ერთნაკადიან, მბლოკავ არქიტექტურაზე.

სწორედ ამ მიდგომით მუშაობდა მზარეული ზემოთ მოყვანილ პირველ მაგალითში.

ასინქრონული პროგრამირება

ასინქრონული პროგრამირება არის პროგრამირების მოდელი, რომელშიც ინსტრუქციები სრულდება ერთმანეთის პარალელურად. ამ მოდელით შექმნილ სისტემებში ოპერაციები არ ბლოკავენ ერთმანეთს. ერთი ოპერაცია შეიძლება შესრულდეს ისე, რომ არ დაელოდოს პარალელურად მიმდინარე სხვა ოპერაციის ან ოპერაციების დასრულებას. ასინქრონული მოდელი იყენებს არამბლოკავ მიდგომას (non-blocking).

სწორედ ამგვარად მუშაობდა მზარეული ზემოთ მოყვანილ მეორე მაგალითში.

ასინქრონული პროგრამირება VS სინქრონული პროგრამირება

წარმადობა და ეფექტურობა

როდესაც საქმე ეხება შეტანა/გამოტანა (I/O - Input/Output) ტიპის ოპერაციებს, როგორც წესი, ხშირადაა საჭირო მონაცემთა ბაზებთან მიმართვა, http მოთხოვნების გაგზავნა, სხვადასხვა ფაილებში ინფორმაციების ჩაწერა ან პირიქით - ფაილების შიგთავსების წაკითხვა... ეს ყველაფერი შედარებით მეტ დროით რესურსს მოითხოვს. ასეთ დროს ასინქრონული მიდგომა უპირობო ლიდერია, რადგან იგი ამ ოპერაციებს საშუალებას არ აძლევს დაბლოკონ სხვა ოპერაციები.

მაგალითისათვის განვიხილოთ მომხმარებლის რეგისტრაციის პროცესი ასინქრონული მიდგომის შემთხვევაში :

  1. ინფორმაციის გაგზავნის სწრაფდამოწმება:
    • როდესაც მომხმარებელი აგზავნის ინფორმაციას, სერვერს შეუძლია რეგისტრაციის პროცესის დასრულებამდე დაუდასტუროს მას, რომ ინფორმაცია წარმატებით გაიგზავნა - ეკრანზე გამოუტანოს შესაბამისი შეტყობინება.
  2. პროგრესის მაჩვენებლები:
    • მანამ სერვერი რეგისტრაციის პროცესს ამუშავებს, ეკრანზე შეიძლება გამოვიდეს პროგრესის მაჩვენებელი, ე.წ პროგრეს-ბარი, რომლის მეშვეობითაც მომხმარებელი შეიტყობს თუ რა ეტაპზეა და როგორ მიმდინარეობს რეგისტრაციის პროცესი. ეს ყველაფერი იწვევს შეგრძნებას, რომ სისტემა გაყინული არ არის და რაღაც ხდება :))
  3. ინტერაქტიულობა:
    • იმის ნაცვლად, რომ მომხმარებელს ვაიძულოთ უყუროს სტატიკურ, გაყინულ, ჩატვირთვის გვერდს (page loading), შეგვიძლია სამომხმარებლო ინტერფეისი გაცილებით უფრო ინტერაქტიულად დავაგეგმაროთ: მომხმარებელმა შეძლოს აპლიკაციის სხვა ნაწილებზე გადასვლა, სხვა სექციების ნახვა, სხვა ოპერაციების შესრულება და ა.შ.
  4. დაუყოვნებლივი შეტყობინებები შეცდომების შესახებ:
    • თუ რეგისტრაციისას დაფიქსირდება რაიმე შეცდომები, მაგალითად ელ_ფოსტა, რომელიც მომხმარებელმა აკრიფა, უკვე დაკავებული იქნება, სერვერს შეუძლია დაუყოვნებლივ დაამყაროს უკუკავშირი (callback) მომხმარებელთან და შეატყობინოს ამ შეცდომის შესახებ, ნაცვლად იმისა, რომ ხელახლა ჩატვირთოს მთლიანი გვერდი. ასინქრონული პროგრამირებით შესაძლებელია ინფორმაციის პირდაპირ რეჟიმში (real-time) ვალიდაცია და შეცდომების დამუშავება (დიდი ალბათობით ერთხელ მაინც გექნებათ გაკეთებული რეგისტრაციის სისტემა ან რაიმე მსგავსი მაგალითად AJAX-ის მეშვეობით, სწორედ ასე მუშაობს იგი).
  5. მრავალსაფეხურიანი რეგისტრაცია:
    • თუ რეგისტრაციის პროცესი რამდენიმე საფეხურისაგან შედგება, შეგვიძლია მომხმარებელი ისე გადავიყვანოთ შემდეგ საფეხურზე, რომ სერვერს ჯერ კიდევ არ ჰქონდეს წინა საფეხურის დამუშავება დასრულებული. ეს კი საკმაოდ მოქნილი მიდგომაა სამომხმარებლო ინტერფეისის კომფორტულობის თვალსაზრისით.
  6. ოპერაციები პარალელურ რეჟიმში:
    • თუ ჩასატარებელი გვაქვს რეგისტრაციასთან დაკავშირებული დამატებითი ოპერაციები (მაგალითად შეტყობინების გაგზავნა ელ_ფოსტაზე), ეს შეგვიძლია გავაკეთოთ პარალელურ რეჟიმში, რაც იმას ნიშნავს, რომ მომხმარებელი დაყოვნების გარეშე გააგრძელებს მუშაობას აპლიკაციაში.

სინქრონული მიდგომის შემთხვევაში, როდესაც მომხმარებელი დააჭერს რეგისტრაციის ღილაკს, მოხდება გვერდის განახლება (refresh) რომელიც გაგრძელდება მანამ, სანამ სერვერი არ დაასრულებს რეგისტრაციის პროცესს, განახლების დასრულებამდე კი არაფრის გაკეთება იქნება შესაძლებელი.

მეორეს მხრივ, არის სიტუაციები როდესაც უმჯობესია სინქრონული მიდგომის გამოყენება. მაგალითად : მათემატიკური გამოთვლებისას, დიდი ზომის მონაცემთა ანალიზისას, მაღალი ხარისხის 3D გრაფიკული გამოსახულებების გენერირებასას და ზოგადად - ისეთი ოპერაციებისას, რომლებიც ცენტრალური პროცესორის ინტენსიურ ჩართულობას მოითხოვენ და შეტანა/გამოტანა ტიპის ამოცანებთან ნაკლებად აქვთ შეხება. ასეთ დროს ფოკუსირება გამოთვლების სისწრაფესა და სიზუსტეზე ხდება, გამოთვლებისას კი სინქრონული მიდგომის წრფიულობას, პროგნოზირებადობასა და თანმიმდევრულობას შეიძლება გადამწყვეტი მნიშვნელობა ჰქონდეს, მით უფრო თუ ოპერაციათა ჯაჭვის ყოველი რგოლი დამოკიდებული იქნება მისი წინა რგოლის შედეგზე.

კოდის სიმარტივე

გამომდინარე იქიდან, რომ სინქრონული პროგრამირება წრფიულ მიდგომას იყენებს, მასში დაწერილი კოდი უფრო ადვილად აღქმადია, ამიტომ ხარვეზების ძიება და შეცდომების აღმოფხვრაც შედარებით მარტივად ხდება. მოკლედ - სინქროლი მიდგომისას უფრო ლამაზი კოდი იწერება :))

პარალელიზმი

პარალელიზმის თვალსაზრისით ასინქრონული პროგრამირებაა ლიდერი, რადგან, როგორც ზემოთ აღვნიშნეთ, მისი ერთ-ერთი მთავარი მახასიათებელი სწორედ ისაა, რომ შეგვიძლია ერთდროულად შევასრულოთ სხვადასხვა ოპერაციები, რასაც ვერ ვიტყვით სინქრონულ პროგრამირებაზე.

როდის გამოვიყენოთ სინქრონული პროგრამირება ?

როდის გამოვიყენოთ ასინქრონული პროგრამირება ?


საბოლოოდ თუ ყველაფერს შევაჯამებთ შეიძლება ითქვას, რომ როდესაც საქმე ეხება სინქრონულ და ასინქრონულ მიდგომებს შორის არჩევანს, გადამწყვეტი მნიშვნელობა მაინც პროექტის შინაარსსა და მოთხოვნებს ენიჭება - არის სიტუაციები, სადაც სინქრონული მიდგომის გამოყენება სჯობს და არის შემთხვევები, როდესაც ასინქრონული მიდგომაა კარგი გადაწყვეტა ('სოლუშენი' - ძალიან რომ უყვართ ეს სიტყვა 😕)

ერთნაკადიანი მბლოკავი ჯავასკრიპტი

განვიხილოთ ასეთი კოდი :

function funcOne() {
    console.log('პირველი');
}

function funcTwo() {
    console.log('მეორე');
}

funcOne();
funcTwo();                      
            

შედეგი იქნება :

პირველი
მეორე                 
            

ანუ მანამ funcOne ფუნქციაში აღწერილი ინსტრუქციები არ შესრულდება, funcTwo ფუნქცია დაიბლოკება და ეს ბლოკირება მით უფრო დიდხანს გაგრძელდება, რაც უფრო რთულ და კომპლექსურ ოპერაციებს შევასრულებთ პირველ ფუნქციაში.

ზოგადად, კოდის ნებისმიერი ფრაგმენტი, რომლის შესრულებასაც დიდი დროითი რესურსი სჭირდება, შეიძლება განხილულ იქნას, როგორც მის შემდეგ არსებული კოდის მბლოკავი. მბლოკავში შეიძლება ხდებოდეს გარე რესურსთან მიმართვა, დიდი ზომის ფოტოს ჩატვირთვა, სრულდებოდეს 1000000 იტერაციიანი ციკლური ოპერაცია, ან გვქონდეს უბრალოდ alert ფუნქცია, რომელიც ყველაფერს ბლოკავს მანამ, სანამ მომხმარებელი არ ირეაგირებს მასზე:

alert('გამარჯობა');
console.log('სხვა შეტყობინება');                                   
            

მბლოკავის კიდევ ერთი მაგალითია უსასრულო ციკლი (ეს კოდი თუ გინდათ არ გაუშვათ 😀) :

while (true) {
    console.log('უსასრულო ციკლი');
}
console.log('ეს შეტყობინება არასდროს გამოჩნდება');                                 
            

ასე მუშაობს სტანდარტული ანუ ერთნაკადიანი (single-thread), მბლოკავი (blocking) ჯავასკრიპტი თავისი სპეციფიკაციიდან (ECMAScript) გაგმოდინარე.

ასინქრონული ჯავასკრიპტისადმი მიძღვნილ სტატიაში ვამბობთ, რომ :

ჯავასკრიპტი, თავისი ბუნებით (ECMAScript), არის ერთნაკადიანი, მბლოკავ არქიტექტურაზე დაფუძნებული სინქრონული ენა.

ყველაფერს მალე გავარკვევთ 😊 ცოტა შორიდან დავიწყოთ.

რა არის ჯავასკრიპტის შესრულების გარემო ?

ჯავასკრიპტის შესრულების გარემო (runtime environment) არის პროგრამული გარემო, ერთგვარი ინფრასტრუქტურა, რომელიც უზრუნველყოფს ჯავასკრიპტის კოდის შესრულებას. იგი მოიცავს ჯავასკრიპრის ძრავს, ასევე სხვადასხვა აპი-ებს (API) და სერვისებს, რომლებიც ამრავალფეროვნებენ სტარნდარტული ჯავასკრიპტის შესაძლებლობებს.

ადრეულ წლებში ერთადერთ ასეთ გარემოდ ვებ-ბრაუზერი მოიაზრებოდა, რადგან, როგორც ცნობილია, ეს ენა იმისთვის შეიქმნა, რომ "გაეცოცხლებინა", უფრო დინამიური გაეხადა HTML გვერდები, მაშინ მას მეტი არაფერი მოეთხოვებოდა, დღესდღეობით კი სიტუაცია შეიცვალა და ჯავასკრიპტი სერვერულ მხარესაც წარმატებით გამოიყენება, ასე რომ შეგვიძლია გამოვყოთ ყველაზე პოპულარული ორი გარემო : ბრაუზერი და სერვერი.

რა არის ჯავასკრიპტის ძრავი ?

მარტივი სიტყვებით თუ ვიტყვით ჯავასკრიპრის ძრავი (javascript engine) არის შესრულების გარემოში არსებული პროგრამა, რომელიც ახდენს ჩვენს მიერ დაწერილი კოდის ანალიზს და შემდეგ თარგმნის მას ცენტრალური პროცესორისათვის გასაგებ ენაზე.

ბრაუზერ Chrome-ს შემთხვევაში ჯავასკრიპტის ძრავი არის Google-ს მიერ შექმნილი V8, ხოლო სერვერის შემთხვევაში ყველაზე პოპულარულია ამავე V8-ზე დაფუძნებული პროგრამული პლატფორმა Node.js, რომელიც ჯავასკრიპტს, როგორც სპეციალიზირებულ ენას, გარდაქმნის პროგრამირების სრულფასოვან და საერთო დანიშნულების ენად. ამ პლატფორმაზე დაფუძნებით, ჯავასკრიპტი შეგვიძლია გამოვიყენოთ სერვერული მხარის სამუშაოებში.

განვიხილოთ ძრავის მუშაობის ერთ-ერთი ყველაზე გავრცელებული სქემა :


ახლა გავარკვიოთ რა ხდება ამ სურათზე :))

სინტაქსური ანალიზატორი

ჯავასკრიპტის კოდი გადაეცემა სინტაქსურ ანალიზატორს (parser), რომელიც კითხულობს მას და ამოწმებს გამართულია თუ არა ყველაფერი სინტაქსური თვალსაზრისით. თუ კოდში რაიმე შეცდომაა - გვატყობინებს, თუ არა და ქმნის აბსტრაქტულ სინტაქსურ ხეს (AST - Abstract syntax tree).

აბსტრაქტული სინტაქსური ხე

აბსტრაქტული სინტაქსური ხე წარმოადგენს კოდში არსებული ცვლადების, ოპერაციების, გამოსახულებების და ა.შ საფუძველზე შექმნილი კვანძების იერარქიულ ხეს. ასეთი სახით წარმოდგენილი კოდის დამუშავება შედარებით უფრო მარტივია ინტერპრეტირების/კომპილირების დროს, რაც ბუნებრივია აისახება შესრულების სისწრაფეზე.

ინტერპრეტატორი

აბსტრაქტული სინტაქსური ხე გადაეცემა ინტერპრეტატორს, რომელიც მისგან აგენერირებს კოდის შუალედურ წარმოდგენას (IR - Intermediate Representation). არსებობს შუალედური წარმოდგენის სხვადასხვა ფორმები, ჯავასკრიპტის შემთხვევაში ეს ფორმა ხშირადაა Bytecode.

შუალედური წარმოდგენა

შუალედური წარმოდგენა არის ერთგვარი ხიდი ჯავასკრიპტის კოდსა და სამანქანო ენას შორის. მისი ძირითადი დანიშნულებაა AST-საგან შექმნას ისეთი სტრუქტურა და კოდის აბსტრაქცია, რომლის ანალიზი, ოპტიმიზაცია და სამანქანო ენაში გადაყვანაც უფრო მარტივი იქნება. ეს აბსტრაქცია უზრუნველყოფს შესრულების პროცესის მოქნილობასაც - ერთი და იგივე შუალედური წარმოდგენა წარმატებით იმუშავებს სხვადასხვა პლატფორმებში.

ინტერპრეტაცია

საბოლოოდ, Bytecode უნდა გადაითარგმონს ცენტრალური პროცესორისათვის გასაგებ, ანუ ორობით ენაზე. ინტერპრეტაციის შემთხვევაში ხდება Bytecode-ს სათითაო ხაზების გადათარგმნა და შესრულება : ითარგმნება ერთი ხაზი - ცენტრალური პროცესორი ასრულებს ორობით კოდს, ითარგმნება შემდეგი ხაზი - პროცესორი ასრულებს კოდს და ა.შ.

ასეთი მიდგომის უპირატესობა ისაა, რომ შედარებით სწრაფად იწყება შესრულების პროცესი, რადგან არავითარი წინასწარი სამუშაოს ჩატარება არ არის საჭირო, უარყოფითი მხარე კი შემდეგია : გამომდინარე იქიდან, რომ სათითაო ხაზები ითარგმნება, სრული პროცესი შედარებით დიდხანს გრძელდება საერთო ჯამში.

კომპილაცია

კომპილაციის შემთხვევაში ჯერ მთლიანი Bytecode ითარგმნება სამანქანო ენაზე, წინასწარ იქმნება ერთი გამშვები ფაილი ორობითი კოდით და ამის შემდეგ ხდება შესრულება. ეს არის სტანდარტული ანუ AOT კომპილაცია (Ahead Of Time - წინასწარ). ამ მიდგომის დადებითი მხარე ის არის, რომ შესრულების საერთო დრო შედარებით ნაკლებია, რადგან ყველაფერი მზადაა და ცენტრალურ პროცესორს ისღა დარჩენია უბრალოდ წაიკითხოს და შეასრულოს თავის მშობლიურ ენაზე დაწერილი კოდი :)) უარყოფითი მხარე კი ისაა, რომ პროცესის დაწყებას ესაჭიროება შედარებით მეტი დრო, ვინაიდან ჯერ გამშვები ფაილი იქმნება.

JIT კომპილაცია

JIT კომპილაცია (ინგ: Just In Time - დროული), არის სტანდარტული კომპილაციისა და ინტერპრეტაციის ერთგავრი ნაზავი. კოდის შესრულება იწყება ინტერპრეტირების ან ნაწილობრივი კომპილირების გზით. JIT კომპილატორი ახდენს კოდის საკვანძო, ხშირად განმეორებადი ფრაგმენტების იდენტიფიცირებას და მთლიანი კოდის ნაცვლად მხოლოდ ამ ფრაგმენტებს (პირობითად დავარქვათ კოდის ცხელი წერტილები 🔥) თარგმნის, ასევე ახდენს გადათარგმნილი 'ცხელი წერტილების' ქეშირებას, რაც ზრდის შესრულების სისწრაფეს.

შეჯამება: JIT კომპილაციისას შესრულების პროცესი იწყება სწრაფად, რადგან არ ხდება გამშვები ფაილის გენერირება. უშუალოდ შესრულებისას კი კოდის საკვანძო წერტილები მთლიანად ითარგმნება სამნქანო ენაში და აღარ ხდება სათითაო ხაზების გადათარგმნა-შესრულება. შეიძლება ითქვას, რომ ამ მიდგომაში გაერთიანებულია სტანდარტული კომპილაციისა და ინტერპრეტაციის დადებითი მხარეები.

გამოძახების დასტა

ჯავასკრიპტში შესრულების კონტექსტი (EC - execution context) ეწოდება იმ სივრცეს, გარემოს, რომელშიც ხდება კოდში აღწერილი ფუნქციების შესრულება (უცხო სიტყვ. ლექსიკონი : კონტექსტი - სპეციფიკური გარემო, რომელშიც ხდება მოქმედება ან მოვლენა).

მარტივი სიტყვებით თუ ვიტყვით გამოძახების დასტა (call stack) არის მექანიზმი, რომლის მეშვეობითაც ჯავასკრიპტის ძრავი ახდენს ძირითადი შესრულების კონტექსტის (General EC) მეთვალყურეობას, მონიტორინგს, აკონტროლებს როდის, რომელი ფუნქციის გამოძახება ანუ ჩამატება მოხდეს კონტექსტში, როდის რომელი ფუნქცია ამოიშალოს კონტექსტიდან და ა.შ.

განვიხილოთ ასეთი კოდი :

function firstFunction() {
    console.log(1);
    secondFunction();
}

function secondFunction() {
    console.log(2);
    thirdFunction();
}

function thirdFunction() {
    console.log(3);
}

firstFunction();                  
            

შედეგი იქნება :

1
2
3
            

აღვწეროთ სრული პროცესი :

  1. საწყის ეტაპზე გამოძახების დასტა ცარიელია :
    |--------------------------------------|
    |        გამოძახების დასტა ცარიელია    |
    |--------------------------------------|
                        
  2. firstFunction დაემატა გამოძახების დასტაში :
    |--------------------------------------|
    |             firstFunction            |
    |--------------------------------------|
    |           გამოძახების დასტა          |
    |--------------------------------------|
    
  3. firstFunction ფუნქციამ ეკრანზე გამოიტანა '1' და გამოიძახა secondFunction, რომელიც ასევე დაემატა გამოძახების დასტაში :
    |--------------------------------------|
    |             secondFunction           |
    |--------------------------------------|
    |             firstFunction            |
    |--------------------------------------|
    |           გამოძახების დასტა          |
    |--------------------------------------|
    
  4. secondFunction ფუნქციამ ეკრანზე გამოიტანა '2' და გამოიძახა thirdFunction, რომელიც ასევე დაემატა გამოძახების დასტაში :
    |--------------------------------------|
    |              thirdFunction           |
    |--------------------------------------|
    |             secondFunction           |
    |--------------------------------------|
    |             firstFunction            |
    |--------------------------------------|
    |           გამოძახების დასტა          |
    |--------------------------------------|
    
  5. thirdFunction ფუნქციამ ეკრანზე გამოიტანა '3', დაასრულა მუშაობა და წაიშალა გამოძახების დასტიდან :
    |--------------------------------------|
    |             secondFunction           |
    |--------------------------------------|
    |             firstFunction            |
    |--------------------------------------|
    |           გამოძახების დასტა          |
    |--------------------------------------|
    
  6. secondFunction ფუნქციამ დაასრულა მუშაობა და წაიშალა გამოძახების დასტიდან :
    |--------------------------------------|
    |             firstFunction            |
    |--------------------------------------|
    |           გამოძახების დასტა          |
    |--------------------------------------|
    
  7. firstFunction ფუნქციამ დაასრულა მუშაობა და წაიშალა გამოძახების დასტიდან :
    |--------------------------------------|
    |      გამოძახების დასტა ცარიელია      |
    |--------------------------------------|
                        

თუ დავაკვირდებით აღმოვაჩენთ, რომ დასტას პირველი ტოვებს ის ფუნქცია, რომელიც დასტაში ყველაზე ზემოთაა, ანუ ყველაზე ბოლოს დაემატა. სწორედ ამიტომ ამ პროცესს LIFO - ს უწოდებენ ხოლმე (Last In First Out) - ბოლოს შესული პირველი გამოდის :))

აღსანიშნავია, რომ თუ კონკრეტული ფუნქციის გამოძახებების რაოდენობა უსასრულოდ დიდი იქნება, მაგალითად :

function myFunc() {
    myFunc();
}

myFunc()              
            

დაფიქსირდება შემდეგი შეცდომა :

Uncaught RangeError: Maximum call stack size exceeded
            

ანუ მოხდება დასტის გადატვირთვა - stack overflow (სახელი ხომ არ გეცნობათ ? 😍)



მეხსიერების მენეჯმენტი

როდესაც პროგრამირების ამა თუ იმ ენაში ვმუშაობთ, ბუნებრივია გვჭირდება გარკვეული ცვლადების, ობიექტების, ფუნქციების და ა.შ აღწერა. ეს ყველაფერი ინახება კომპიუტერის მეხსიერებაში. არსებობს ენები რომლებშიც ჩვენვე გვიწევს იმის განსაზღვრა თუ როდის გამოიყოს სივრცე მეხსიერებაში კონკრეტული მიზნისათვის, შემდეგ როდის გასუფთავდეს ეს სივრცე და ა.შ (C, C++). ჯავასკრიპტში ეს ავტომატურად ხდება 'დასუფთავების სამსახურის' (Garbage Collection) დამსახურებით 😀 :

function createObject() {
    // ობიექტს გამოეყო ადგილი მეხსიერებაში
    let myObject = {
        name: "სატესტო ობიექტი",
        value: 42
    };

    // ქმედებები ობიექტზე
    console.log(myObject.name); // შედეგი: სატესტო ობიექტი

    // როდესაც ფუნქცია დაასრულებს მუშაობას, `myObject` აღარ იქნება ხელმისაწვდომი
}

// ფუნქციის გამოძახება
createObject();

/* 
    აქ უკვე შეუძლებელია `myObject` ობიექტთან წვდომა, მან თავის საქმე გააკეთა 
    და აღარ არის საჭირო, ამიტომ დასუფთავების სამსახური წაშლის მეხსიერებიდან    
*/                                       
            

თუ createObject ფუნქციას ხელახლა გამოვიძახებთ, მეხსიერებაში ხელახლა გამოიყოფა საჭირო სივრცეები, შემდეგ ისევ გასუფთავდება და ა.შ. უნდა აღინიშნოს, რომ ეს საკმაოდ მოსახერხებელი და ეფექტური მიდგომაა.

მონაცემთა ტიპები მეხსიერების თვალსაზრისით

მეხსიერების თვალსაზრისით მონაცემები იყოფა ორ ნაწილად :

განვიხილოთ ასეთი კოდი :

let name = 'გიორგი';
let age = 30;

let person = {
    name: 'დავითი',
    age: 35
}

let newName = name;
newName = 'ლევანი';

let newPerson = person;
newPerson.name = 'კოტე';

console.log(person.name); // კოტე                            
            

მეხსიერების თვალსაზრისით ხდება შემდეგი :


გავყვეთ სურათს :

  1. პრიმიტიული ცვლადები:
    • let name = 'გიორგი';
      • აღიწერა პრიმიტიული ცვლადი name და მიენიჭა მნიშვნელობა 'გიორგი'
      • მოხდა მისი შენახვა დასტაში (stack)
    • let age = 30;
      • აღიწერა ასევე პრიმიტიული ცვლადი age და მიენიჭა მნიშვნელობა 30
      • ისიც ასევე შეინახა დასტაში (stack)
  2. ობიექტი:
    • აღიწერა ობიექტი let person = { name: 'დავითი', age: 35 };
      • ყურადღება : უშუალოდ ობიექტი შეინახა ნაკრებში (heap), ხოლო person ცვლადი, რომლეც ამ ობიექტს მოიცავს შეინახა დასტაში (stack), ანუ დასტაში შენახულ person ცვლადს მიება ნაკრებში შენახული ობიექტის შიგთავსი
  3. პრიმიტიული ცვლადის დაკოპირება :
    • let newName = name;
      • გამოცხადდა ახალი ცვლადი newName, მნიშვნელობად მიენიჭა უკვე არსებული name ცვლადი, ანუ newName-ის მნიშვნელობა გახდა 'გიორგი' და იგი დამოუკიდებლად შეინახა დასტაში (stack)
    • newName = 'ლევანი';
      • განახლდა newName ცვლადის მნიშვნელობა - მიენიჭა 'ლევანი'
      • ამას არ მოუხდენია ზემოქმედება უკვე არსებულ name ცვლადზე, და მისი მნიშვნელობა არის ისევ 'გიორგი'
  4. კომპლექსური ცვლადის დაკოპირება:
    • let newPerson = person;
      • გამოცხადდა ახალი ცვლადი newPerson და მნიშვნელობად მიენიჭა უკვე არსებული person ცვლადი
      • გამომდინარე იქიდან, რომ person ცვლადი არის კომპლექსური ტიპის, newPerson ცვლადის მიბმაც უკვე არსებულ ობიექტზე მოხდა, ახალი ობიექტი არ შექმინლა
    • newPerson.name = 'კოტე';
      • newPerson ობიექტის name თვისება გახდა 'კოტე'
      • ვინაიდან newPerson და person ცვლადები ორივენი მიბმულნი არიან ერთი და იმავე ობიექტზე, მოხდა ამ ობიექტის განახლება
  5. შედეგი:
    • console.log(person.name);
      • შედეგი იქნება 'კოტე' რადგან person ცვლადთან ასოცირებული ობიექტის name თვისება განახლდა newPerson ცვლადის მეშვეობით

ამ სქემით მუშაობს მეხსიერების მენეჯმენტი ჯავასკრიპტში.

შეიძლება ითქვას, რომ უკვე დავასრულეთ სტანდარტული ჯავასკრიპტის მუშაობის ძირითადი პრინციპების მიმოხილვა.

***

განვიხილოთ ასეთი კოდი :

 
setTimeout(() => {
    console.log('შეტყობინება');
}, 2000);               

console.log('ტექსტი');   
            

როგორც ვიცით setTimeout ფუნქცია გამოიყენება კონკრეტული ინსტრუქციების გადასავადებლად.

აქამდე განხილულ მაგალითებში ინსტრუქციები სრულდებოდა ერთმანეთის მიყოლებით. ამავე ლოგიკით თუ ვიხელმძღვანელებთ, ჯერ უნდა შესრულდეს setTimeout ჩანაწერი, შემდეგ კი console.log('ტექსტი'), კოდის შედეგი კი უნდა იყოს :

შეტყობინება 
ტექსტი               
            

მაგრამ სინამდვილეში შედეგი იქნება :

ტექსტი 
შეტყობინება               
            

რა მოხდა ? რატომ დაირღვა აქამდე არაერთხელ ნახსენები მბლოკავობის პრინციპი ? რატომ არ დაბლოკა setTimeout ფუნქციამ console.log ჩანაწერი ? აშკარაა, რომ სტანდატული ჯავასკრიპტი რაღაცამ აიძულა გადაეხვია თავისი ბუნებისათვის და ეს რაღაც არის ვებ-აპი (Web API).

რა არის ვებ-აპი ?

ვებ-აპი (Web API) არის ბრაუზერში ჩაშენებული ინტერფეისების, ფუნქციების, მეთოდების ნაკრები, რომლის მეშვეობითაც ჯავასკრიპტი ხდება გაცილებით უფრო მაგიური და ძლიერი ვიდრე ის თავისი სპეციფიკაციიდან (ECMAScript) გამომდინარეა. ვებ-აპი არის ბრაუზერის ხელსაწყო და არა უშუალოდ ჯავასკრიპტის.

ზოგადად, თუ ვამბობთ, რომ ოპერაციათა შესრულების ნაკადში არსებული ისეთი ფუნქციები, რომელთა შესრულებასაც შედარებით მეტი დრო სჭირდებათ, არ უნდა ბლოკავდნენ მის შემდეგ მდგარ ფუნქციებს, ეს იმას ნიშნავს, რომ ასეთი ფუნქცია 'რაღაცამ' სადმე 'პარალელურ სამყაროში' უნდა 'გადაიტანოს' დროებით 😀. ხოლო მას შემდეგ რაც ფუნქცია მუშაობას დაასრულებს 'იქ, სადღაც', რაიმე მექანიზზმა ისევ დააბრუნოს ნაკადში.

რა არის დავალებათა რიგი ?

როდესაც გვსურს რაიმე ფუნქციის კონკრეტული დროით გადავადება setTimeout ფუნქციის მეშვეობით, ეს იმას ნიშნავს, რომ სადღაც უნდა მოხდეს ამ დროის ათვლა, ანუ გვჭირდება დროის მთვლელი. ასევე გვჭირდება მექანიზმი, რომელიც დროებით სადმე 'შეინახავს' გადავადებულ ფუნქციას, დროის ამოწურვის შემდეგ კი დააბრუნებს გამოძახების დასტაში. განვიხილოთ სურათი :


როგორც ვხედავთ გამოძახების დასტაში პირველი მოხვდა setTimeout ფუნქცია, რომელმაც აშკარად არ დაბლოკა შემდეგ არსებული ინსტრუქციები და აი რატომ : setTimeout არის ბრაუზერში ჩაშენებული ვებ-აპი, რომელიც ინსტრუქციების ასინქრონულად შესრულების საშუალებას იძლევა და არა ჯავასკრიპტის სტანდარტული ფუნქცია.

ვებ-აპი-ში არსებულმა დროის მთვლელმა გადათვალა 2000 მილიწამი, ანუ 2 წამი და setTimeout-ში აღწერილი ანონიმური უკუფუნქცია ჩააგდო დავალებათა რიგში (task queue):

 
() => {
    console.log('შეტყობინება');
};         
            

დავალებათა რიგი, ან შეიძლება ეწოდოს ამოცანათა რიგიც (task queue), არის ადგილი, სადაც ხვდება ნებისმიერი ასინქრონული ფუნქცია, რომელიც შესასრულებლად მზადაა. ჩვენს შემთხვევაში : გავიდა ორი წამი და setTimeout-ის უკუფუნქცია ჩადგა ამ რიგში.

დავალებათა რიგი მუშაობს ე.წ FIFO (First-In-First-Out) პრინციპით, ანუ რიგს პირველი ტოვებს ის ფუნქცია, რომელიც პირველი ჩადგება რიგში.

რა არის მოვლენათა ციკლი ?

მოვლენათა ციკლი (event loop) არის ჯავასკრიპტის შესრულების გარემოში არსებული უმნიშვნელოვანესი მექანიზმი, რომელიც საშუალებას გვაძლევს დავარღვიოთ სტანდარტული ჯავასკრიპტის ერთნაკადიანი და მბლოკავი სტრუქტურა და ვიმუშაოთ ასინქრონულ, არამბლოკავ ამოცანებთან. კერძოდ : მოვლენათა ციკლი გამუდმებით ახდენს გამოძახების დასტისა და დავალებათა რიგის მონიტორინგს შემდეგი პრინციპით : ამოწმებს დავალებათა რიგს, თუ რიგი ცარიელი არ არის, ამოწმებს გამოძახების დასტას, თუ დასტა ცარიელია, რიგში მდგარი პირველივე დავალება გადააქვს დასტაში. შემდეგ ისევ იგივე და ა.შ.

ჩვენს მაგალითს თუ დავუბრუნდებით : მოვლენათა ციკლმა გადაამოწმა დავალებათა რიგი, ნახა რომ რიგში დგას setTimeout-ის უკუფუნქცია, შემდეგ გადაამოწმა გამოძახების დასტა და მას შემდეგ, რაც შესრულდა console.log('ტექსტი') ჩანაწერი, ანუ გამოძახების დასტა გასუფთავდა, უკუფუნქცია გადააგდო გამოძახების დასტაში.

შეიკრა წრე, რომელშიც მიმოვიხილეთ თუ რა პრინციპებით და საშუალებებით ხდება ასინქრონული ოპერაციების ჩატარება ჯავასკრიპტის შესრულების გარემოში. ახლა შეგვიძლია გავეცნოთ უშუალოდ ჯავასკრიპტის იმ ხელსაწყოებს, რომლებიც ამ ოპერაციების ჩატარებას გვიმარტივებენ.

რა არის უკუფუნქცია ?

უკუფუნქცია (callback) ეწოდება ფუნქციას, რომელიც არგუმენტად გადაეცემა სხვა ფუნქციას და რომლის გამოძახებაც ხდება ოდნავ მოგვიანებით - მშობელი ფუნქციის შესრულების შემდეგ.



მოვიყვანოთ მარტივი მაგალითი :

 
function greet(name, myCallback) {
    console.log('გამარჯობა ' + name);
    myCallback();
}

function sayGoodbye() {
    console.log('ნახვამდის!');
}

greet('ვასო', sayGoodbye);                      
            

მშობელ ფუნქცია greet-ს მეორე პარამეტრად გადაეცა უკუფუნქციის პირობითი დასახელება - myCallback, შემდეგ ამავე მშობელ ფუნქციაში, საჭირო ადგილას მოხდა უკუფუნქციის გამოძახება.

შემდეგ აღიწერა უშუალოდ უკუფუნქცია - sayGoodbye().

ბოლოს კი მოხდა მშობელი ფუნქციის გამოძახება.

მშობელ ფუნქციაში უკუფუნქციის პარამეტრად გადაცემისას მრგვალი ფრჩხილები არ გამოიყენება.

უკუფუნქციებს საკმაოდ დიდი როლი ენიჭებათ ასინქრონული ოპერაციებისას, როდესაც ერთ ფუნქციას უწევს მეორე ფუნქციის დალოდება. ამ ყველაფერს სულ მალე ვნახავთ პრაქტიკაში.

დავუშვათ გვაქვს ასეთი ამოცანა : უნდა მივაკითხოთ შემდეგ ბმულს : https://jsonplaceholder.typicode.com/users/1, თუ მოთხოვნა (request) წარმატებით დასრულდება, პასუხი (response) იქნება რაღაც ამდაგვარი :

 
{
    "id": 1,
    "name": "Leanne Graham",
    "username": "Bret",
    "email": "Sincere@april.biz",
    "address": {
        "street": "Kulas Light",
        "suite": "Apt. 556",
        "city": "Gwenborough",
        "zipcode": "92998-3874",
        "geo": {
            "lat": "-37.3159",
            "lng": "81.1496"
        }
    },
    "phone": "1-770-736-8031 x56442",
    "website": "hildegard.org",
    "company": {
        "name": "Romaguera-Crona",
        "catchPhrase": "Multi-layered client-server neural-net",
        "bs": "harness real-time e-markets"
    }
}                     
            

ინფორმაციის წამოღების შემდეგ მომხმარებლის ელ_ფოსტაზე უნდა გავაგზანოთ რაიმე შეტყობინება.

გადავჭრათ ეს ამოცანა უკუფუნქციის გამოყენებით :

 
function getUserData(callback) {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', 'https://jsonplaceholder.typicode.com/users/1', true);
    xhr.onreadystatechange = function() {
        if (xhr.readyState === 4) { // მოთხოვნა გაკეთდა
            if (xhr.status === 200) { // წარმატებული პასუხი
                const user = JSON.parse(xhr.responseText);
                const email = user.email;
                callback(email, null); 
            } else {
                const error = 'დაფიქსირდა შეცდომა';
                callback(null, error);
            }
        }
    };
    xhr.send();
}

getUserData(function(email, error) {
    if(email) {
        console.log(`შეტყობინება იგზავნება მისამართზე: ${email}`);
    }
    else if (error) {
        console.log(error);
    } 
});               
            

ორიოდ სიტყვით XMLHttpRequest-ის შესახებ : იგი არის ბრაუზერში ჩაშენებული ერთ-ერთი ვებ-აპი, რომელიც გამოიყენება სერვერზე HTTP მოთხოვნების ასინქრონულად გასაგზავნად, მომხმარებლის მხარის (client side) კოდიდან.

ვფიქრობ ამ კოდში ყველაფერი გასაგებია : უბრალოდ მოვახდინეთ ასინქრონული ოპერაციის დემონსტრირება უკუფუნქციის მონაწილეობით. აქვე უნდა აღინიშნოს, რომ ამოცანა საკმაოდ მარტივი იყო. უფრო რთულ და კომპლექსურ სიტუაციებში კოდი გაცილებით ჩახლართული და რთულად წაკითხვადი იქნებოდა, მითუმეტეს თუ რამდენიმე უკუფუნქციის გამოყენება დაგვჭირდდებოდა (ასეთ შემთხვევებს 'უკუფუნქციების ჯოჯოხეთს' უწოდებენ ხოლმე - callback hell 😈).

დაპირებები

საბედნიეროდ, ჯავასკრიპტში (ES6 - ECMAScript 2015) არსებობს ამ ყველაფრის შედარებით მარტივად და გასაგებად ჩაწერის გზები - დაპირებები.

ტერმინი 'დაპირება (Promise)' მომდინარეობს ინფორმატიკაში არსებული დაპირების კონცეფციიდან, რომელიც დაკავშირებულია ასინქრონული ოპერაციის მოსალოდნელ წარმატება/წარუმატებლობასთან. ანუ 'გვპირდება', მაგრამ გარანტიას ვერ 'გვაძლევს'.

დაპირებებთან სამუშაოდ გამოიყენება ჯავასკრიპტში არსებული Promise კლასი, რომლის ძირითადი დანიშნულებაც, ასინქრონული ოპერაციების შედარებით სუფთად და ლამაზად დაწერაა. მოვიყვანოთ დაპირების შექმნისა და გამოყენების მაგალითი:

 
let myPromise = new Promise(function(myResolve, myReject) {

    // როგორც წესი, აქ ხდება ხოლმე ასინქრონული ოპერაციის ჩატარება, რომელიც შეიძლება დასრულდეს წარმატებით ან წარუმატებლად
    
    let success = true; // წარმატების სიმულაცია

    // რეალურ გარემოში დაგვიბრუნდება კონკრეტული შედეგი, მაგალითად მონაცემთა ბაზიდან ამოღებული ჩანაწერები
    // შედეგი შეიძლება შევინახოთ მაგალითად result ცვლადში
    // შემდეგ ცვლადი გადავცეთ myResolve ფუნქციას  

    if (success) {    
        let result = 'დასრულდა წარმატებით' // ამ ეტაპზე myResolve ფუნქციას გადავცეთ სტატიკური ტექსტი           
        myResolve(result); 
    } else {
        let error = 'დაფიქსირდა ხარვეზი' // გადავცეთ შეცდომის სტატიკური შეტყობინება
        myReject(error); // 
    }

});
            

პირველ რიგში შეიქმნა Promise კლასის ობიექტი 'myPromise'.

Promise კლასს არგუმენტად გადაეცა ფუნქცია, რომელსაც, თავის მხრივ, გადაეცა ორი არგუმენტი - myResolve და myReject. ორივე მათგანი თავის თავში მოიცავს ასევე ფუნქციას. myResolve ასოცირდება სცენარის წარმატებით განვითარებასთან, myReject კი პირიქით - წარუმატებლობასთან.

შემდეგ გვაქვს პირობითი ოპერატორი, რომელიც ამოწმებს თუ რა შედეგით დასრულდა ასინქრონული ოპერაცია.

შედეგების დამუშავება

ნებისმიერ დაპირებას, დროის კონკრეტულ მომენტში შეიძლება გააჩნდეს ამ სამი სტატუსიდან ერთ-ერთი :

დაპირების შედეგების დასამუშავებლად გამოიყენება then და catch მეთოდები. პირველი წარმატების დასამუშავებლად, მეორე - წარუმატებლობის.

 
myPromise.then(
    // ამ ფუნქციის ფსევდონიმია myResolve
    function(result) {  
       console.log(result) // result ცვლადში მოთავსდება myResolve ფუნქციისათვის გადაცემული არგუმენტი ანუ "დასრულდა წარმატებით"
    },  
).catch(
    // ამ ფუნქციის ფსევდონიმია myReject
    function(error) { 
        console.log(error); // error ცვლადში მოთავსდება myReject ფუნქციისათვის გადაცემული არგუმენტი ანუ "დაფიქსირდა ხარვეზი" 
    }
);
            

ეს ყველაფერი საბოლოოდ მიიღებს ასეთ სახეს :

 
let myPromise = new Promise(function(myResolve, myReject) {
    
    let success = true; 

    if (success) {    
        let result = 'დასრულდა წარმატებით'       
        myResolve(result); 
    } else {
        let error = 'დაფიქსირდა ხარვეზი'
        myReject(error); // 
    }

});

myPromise.then(
    function(result) {  
       console.log(result) 
    },  
).catch(
    function(error) { 
        console.log(error); 
    }
);
            

ეს გახლავთ დაპირებების გამოყენების ზოგადი სინტაქსი. ახლა კი ზემოთ მოყვანილი ამოცანა გადავჭრათ დაპირებების მეშვეობით :

 
let myPromise = new Promise(function(myResolve, myReject) {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', 'https://jsonplaceholder.typicode.com/users/1', true);
    xhr.onreadystatechange = function() {
        if (xhr.readyState === 4) { // მოთხოვნა გაკეთდა
            if (xhr.status === 200) { // წარმატებული პასუხი
                let user = JSON.parse(xhr.responseText);
                myResolve(user);
            } else {
                let error = 'დაფიქსირდა ხარვეზი';
                myReject(error);
            }
        }
    };
    xhr.send();
});

myPromise.then(
    function(user) {
        console.log('შეტყობინება იგზავნება მისამართზე: ' + user.email);
    }
).catch(
    function(error) {
        console.error(error);
    }
);
            

უნდა აღიშნოს რომ, მოყვანილ შემთხვევაში სპეციალურად არ ვიყენებთ ე.წ ისარ-ფუნქციებს, რადგან ეს შეიძლება დამაბნეველი იყოს დამწყებისთვის, რომელსაც არც დაპირებებთან მუშაობის გამოცდილება აქვს და არც ფუნქციების ასეთი სახით წერის :

 
let myPromise = new Promise((myResolve, myReject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', 'https://jsonplaceholder.typicode.com/users/1', true);
    xhr.onreadystatechange = function() {
        if (xhr.readyState === 4) { // მოთხოვნა გაკეთდა
            if (xhr.status === 200) { // წარმატებული პასუხი
                let user = JSON.parse(xhr.responseText);
                myResolve(user);
            } else {
                let error = 'დაფიქსირდა ხარვეზი';
                myReject(error);
            }
        }
    };
    xhr.send();
});

myPromise.then(
    (user) => {
        console.log('შეტყობინება იგზავნება მისამართზე: ' + user.email);
    }
).catch(
    (error) => {
        console.error(error);
    }
);
            

თუმცა ესეც უნდა აღინიშნოს - ეს კოდი უფრო წაკითხვადი და მარტივია.

async/await