View on GitHub

info

Материјали за курс из УВИТ-а на Математичком факултету Универзитета у Београду

УВИТ - Програмски језик ЈаваСкрипт

Владимир Филиповић

Асинхроно програмирање

Типични модели извршавања програма

Према начину извршавања програма, типично се говори о следећим моделима извршавања:

Синхрони модел програмирања

Синхрони модел је најстарији и најједноставнији модел про­грамирања. Задаци се извршавају сукцесивно, један по један, и тек када се изврши претходни, почиње следећи.

У синхроном моделу, наредба која следи ће бити извршена тек након што се изврши наредба која њој претходи. Ако се догоди да је наредба која претходи скупа и да дуго траје, наредба ће бити бло­кирана, мораће да чека. Ово је озбиљан проблем када се развијају системи високих перформанси.

Постоји још један проблем код синхроног модела, који се манифестује код корисничког интерфејса. Док програм извршава задатак који може да потраје неко време, нема могућности да се бло­кирају корисници, који могу да уносе нешто у поље за унос док се извршава скупи задатак. Са друге стране, не би било добро да се блокиа унос корисника током извршавања скупе операције - захтевни задаци треба да се извршавају у позадини.

Вишенитни модел програмирања

Једно од решења овог проблема је да се сваки задатак подели на програмске нити контроле. Ово се зове вишенитни модел (енгл. multithreading). У вишенитном моделу сваки задатак се извршава у нитима контроле. Нитима, обично, управља оперативни систем и оне могу да се извршавају упоредо на другим процесорима. Захваљујући модер­ним процесорима, вишенитни модел може да има изузетно добре перформансе. Неко­лико језика подржава овај модел (C#, C++, Java, Rust…).

Вишенитни модел може бити комплексан за имплементирање. Наиме, нити треба међусобно да сарађују, што може врло брзо да постане „незгодно“. Но, постоје варијације вишенитног модела у којима је стање непроменљиво и тада се модел поједностављује, јер свака нит је одговорна за непроменљиво стање и нема потребе да се управља стањима између нити.

Асинхрони модел програмирања

Модел асинхроног програмирања има једну нит кон­троле, унутар које се задаци преплићу. Када се извршава један задатак, може се бити сигуран само да тај задатак извршен. У асинхроном моделу није потребан сложен механизам за комуникацију између нити, па је зато предвидљивији.

У којим ситацијама је асинхрони модел бољи од синхроног? Када год програм чека нешто - учитавање података са диска, упит према бази података или мрежне захтеве. Ово су све блокирајуће операције. У случајевима када програм има много улаза/излаза из извора као што су учитавање диска или мрежни позиви, кашњење се не може предвидети. У синхроном програму непредвидљивост је „рецепт“ за лошу перформансу. Када се асинхрони програм суочи са блокирајућим задатком, извршава се наредни задатак, без чекања да се блокирајућа операција заврши.

Асинхроно програмирање у ЈаваСкрипту

Као што је већ истакнуто, језик ЈаваСкрипт је настао како би се олакшало веб програмирање. Специфичност веба је и у томе што, код мало сложениих апликација, се често долази у ситуацију да приликом извршавања програма (на пола посла) се морају чекати податке са сервера. Тада се мора зауставити рад, тако да све може да се настави када подаци буду стигли.

То практично значи да треба да се “преполови” ЈаваСкрипт програм на део који “позива” и део који “дочекује” податке. Идеја је да се, у моменту када сервер одговори, позива функција повратног позива која служи као почетна тачка за наставак рада. С обзиром да још нисмо овладали напреднијим концептима веб програмирања, у ориемрима који селде кашњење у комуникацији са сервером ће бити симулрано позивом функције setTimeout() - њени аргументи су функција повраног позива и врее чекања дато у милисекундама.

ЈаваСкрипт окружење и асинхроно програмирање

ЈаваСкрипт окружење за извршавање се састоји од следећих компоненти:

ЈаваСкрипт окружење за извршавање

Асинхроно програмирање се рализује тако што се наредбе извршавају једна за другом - програмски код се поставља на Стек и извршава се онај код који је на вргу стека.

Међутим, ако су неке наредбе/позиви функција такви да их не треба одмах извршити, њихов програмски код се ставља у одвојени ред за чекање, па се (по испуњењу услова) премести у Ред задатака.

У међувремену се настави са извршавањем наредби које следе - он се гурају на Стек и тамо извршавају (ако су такве да их треба одмах извршити).

Када стек постане празан, Петља за догађаје узима елементе из Реда задатака, пребавује је их на Стек и онда тај програмски код бива извршен.

Овим је постигнуто да се не чека приликом извршавања “скупих” наредби, већ се наставља са извршавањем.

Наравно, јасно је да треба обезбедити и неке механизме координације у оваквом раду.

Постоји више програмских приступа у ЈаваСкрипту којима се реализује асинхроно програмирање.

Историјски нјстарији је рад помоћу функција повратног позива (енгл. callback function).

Асинхроно програмирање и повратни позиви

Пример. Илуструје рад са повратним позивом:

let povratniPoziv = () => {
  console.log(`Ziv sam!`)
}

console.log(`Pokrenuto...`)
setTimeout(povratniPoziv, 2000)
console.log(`Zavrsava...`)

ЈаваСкрипт окружење извшава скрипту нарабу по наредбу:

  1. Прво стави на стек прву console.log методу и затим је изврши.

  2. Након тога окружење извршава наредбу где се позива setTimeout, па ову методу ставља на врх стекаа и извршава је.

  3. По извршења setTimeout методе она се склања са стека, а њена функција повратног позива (енгл. callback) на коју реферише променљива povratniPoziv се пребацује у ред повратних позива на чекању и укључује часовник који броји милисекунде.

  4. Потом окружење наставља са обрадом наредне наредбе кода где наилази на други позив console.log методе,коју ставља на стек и извршава је.

  5. Док се извршава остали део програма, а по истеку времена задатог позиву setTimeout (овде је то 2000) функција повратног позива се се пребацује у ред задатака и тамо чека да се стek изпразни да би могла да се изрши.

  6. Петља за догађаје потом, када стек постане празан, а овај задатак “дође на ред” пребацује задатак (тј. функцију повратног позива) из реда задака на стек.

  7. Када се функција повратнг позива нађе на стеку, она се извршава и након тога склања са стека.

Према томе, излаз који ће се показати на конзоли је:

Pokrenuto...
Zavrsava...
Ziv sam!

Наравно, да би се могао успешно реаализовати асинхроним програми, неопходно је да постоји механизам за синхронизацију, тј. обезбеђивање чекања тамо где је то неопходно.

У примеру који следи описан је ЈаваСкрипт код без икакве синхронизације.

Пример. Приказ (са чекањем) првих пет слова у произвољном редоследу.

Приказ појединачног слова је реализован коришћењем функције за приказ ниске после чекања:

function prikaziNisku(niska){
    setTimeout(
      () => {
        console.log(niska)
      },
      Math.floor(Math.random() * 50) + 1
    );
  }

  function prikaziSve(){
    prikaziNisku("А");
    prikaziNisku("Б");
    prikaziNisku("В");
    prikaziNisku("Г");
    prikaziNisku("Д");
  }

  prikaziSve();

Функција за приказ ниске после чекања, тј. prikaziNisku() је реализована преко функције setTimeout(), где је поовратним позив у оквиру те функције ламбда-израз којим се ниска шаље на конзолу.

Као што се може очекивати, редослед приказа зависи од вредности коју вреће генератор псеудослучајних бројева, па се у поновљеним извршавањима скрипте добијају различите вредности:

Д   A   Б
В   Г   Г
Г   В   А
А   Д   В
Б   Б   Д

Овакво решење је најефикасније, у смислу да је укупно чекање минимално, али нема никакве гаранције о томе којим ће се редоследом извршити методе. █

Следећи пример показује како се може обезбедити синхронизација коришћњем повератних позива.

Пример. Приказ (са чекањем) првих пет слова уз ограничење у редоследу тако да се слово В мора увек приказати пре слова Г.

И у овом примеру је приказ појединачног слова реализован коришћењем функције за приказ ниске после чекања, само што, за разлику од претходног примера, та функција има и додатни аргумент - функција повратног позива, а у телу функције се повратни позив реализује после слања ниске на конзолу.

Дакле, ако треба постићи да се слово Г увек приказује после слова В, онда се функција за приказ слова Г поставља као повратни позив код функције за приказ слова В.

У свим осталим случајевима, функција повратног позива је функција која не ради ништа, овде записана помоћу ламбда-израза ()=>{}.

function prikaziNisku(niska, povratniPoziv){
    setTimeout(
      () => {
        console.log(niska);
        povratniPoziv();
      },
      Math.floor(Math.random() * 50) + 1
    );
  }

  function prikaziSve(){
    prikaziNisku("А", ()=>{});
    prikaziNisku("Б", ()=>{});
    prikaziNisku("В", ()=>{prikaziNisku("Г", ()=>{})});
    prikaziNisku("Д", ()=>{});
  }

  prikaziSve();
В   Д   В   Д
Д   А   Б   Б
А   Б   Г   В
Б   В   А   А
Г   Г   Д   Г

Као што се види из претходних резултата, слова В и Г не морају бити непосредно једно испред другог, али у сваком случају (тј. приликом сваког извршавања скрипте) ће слово В претходити слову Г. █

Даљом применом претходно описане идеје могуће је постићи серијализовано ивршавање жељених операција у оквиру асинхроног кода.

Пример. Приказ (са чекањем) првих пет слова по азбучном редоследу.

function prikaziNisku(niska, povratniPoziv){
    setTimeout(
      () => {
        console.log(niska)
        if(typeof povratniPoziv === 'function')
          povratniPoziv()
      },
      Math.floor(Math.random() * 50) + 1
    );
  }

  function prikaziSveRedom(){
    prikaziNisku("A", 
    ()=>{
      prikaziNisku("Б", 
      ()=>{
        prikaziNisku("В", 
        ()=>{
          prikaziNisku("Г", 
          ()=>{
            prikaziNisku("Д");
          });
        });
      });
    });
  }

  prikaziSveRedom();

И овде функција за приказ ниске очекује два параметра: ниску коју треба приказати на конозли и функцију повртатног позива. Међутим, тело функције је мало измењено, па после приказа ниске повратни позив буде реализован само ако други параметар заиста јесте функција.

Сада ће се приликом сваког извршавања горње скрипте, слова појављивати увек у истом редоследу (уз чекање које варира између различитих извршавања):

A
Б
В
Г
Д

Уочава се да овакво структурисање програма, са високим степеном угњежђавања функција, није погодно у нешто сложенијим ситуацијама, које су у пракси честе. █

Могуће је “уланчати” позиве, тј. функцијама повратног позива проследити резултат рада тј. вредност коју враћа претходно извршена функција.

Пример. Приказ (са чекањем) прва три слова по азбучном редоследу.

Овај пример илуструје асинхрони рад са функцијама које враћају резултате.

function dodajNisku(prethodna, tekuca, povratniPoziv) {
    setTimeout(
        () => {
            povratniPoziv((prethodna + ' ' + tekuca));
        },
        Math.floor(Math.random() * 50) + 1
    );
}

function dodajSveRedom() {
    dodajNisku('', 'A', result => {
        dodajNisku(result, 'Б', result => {
            dodajNisku(result, 'В', result => {
                console.log(result);
            });
        });
    });
}

dodajSveRedom();

У овом случају, резултат је:

 A Б В

Додатну компликацију у интензивном коришћењу овог принципа представља и чињеница да треба обезбедити смислен одговор уколико приликом извршавања неке од функција повертног позива буде дошло до грешке.

Асинхроно програмирање и обећања

У ЈаваСкрипту постоји израз за део кода са угњежденим функцијама повратних позива, под називом “пакао повратних позива” или “пирамида прописати”. Дебагирање таквог програмског кода (па чак и само разумевање) је веома отежано.

Са стандардом ЕS2015 дошла је нова конструкција под називом обећање (енгл. promise), која са својим АПИ-јем обезбедјује бољи и прегледнији начин за организовање функција повратних позива. Ово се наручито примећује у раду са асинхроним операцијама јер синтакса обећања веома личи на стандардну синхрону синтаксу.

Обећање је ЈаваСкрипт објекат у коме се чувају резултати асихроне функције све док траје извршавање асинхроне операције.

Прецизније исказано, обећање је синхроно враћен објекат при извршењу асинхроне операције, који представља привремену замену за могуће резултате те асинхроне операције. Он уместо крајње вредности, даје обећање да ће доставити ту вредност у неком тренутку у будућности (отуд му и име).

Пошто су објекти-обећања привремена замена за будућу евентуалну вредност, то омогућава да програмер преко обећања придружи руковаоце будућем резултату асинхроне операције. На овај начин и синхроне и асинхроне операције могу да враћају неку вредности, стим што синхроне одмах враћају крајњи податак а асинхроне референцу за будући податак.

Асинхрона функција може имати два могућа крајња резултата, а то су “успешно извршена операција” или “неуспешно извршена оперција”.

Обећање може налазити у једном од три стања:

  1. На чекању (енгл. pending) – када се асинхрона радња још увек извршава
  2. Испуњено (енгл. fulfill) – када је асинхрона радња завршена успешно
  3. Одбијено (енгл. reject) – када је асинхрона радња неуспешно завршена грешком

Први корак у раду са обећањима је креирање обећања унутар асинхроне функције. Наиме, да би се будући крајњи резултат асинхроне функције могао замениоТИ са обећањем, потребно је да функција као рзултат врати објекат-обећање. Сваком новом обећању се кроз параметар прослеђује функција-извршитељ (енгл. executor function) која обрађује саму асинхрону операцију и будуће резултате те асинхроне операције.

Псеудо-код асинхроне функције које користи обећања је дат у продужетку:

function asinhronaFunkcija() {
    return new Promise(function (resolve, reject) {
            //...kod za asinhronu operaciju...

            if (uspešna operacija) {
               resolve(result_value);
            } else {
               reject(error);
            }
        }
    );
}

У зависности од успешности операције, функција-извршитељ позива једну од две функције које су јој прослеђене као параметри:

Пример. Приказ (са чекањем) првих пет слова.

function prikaziNisku(niska) {
    return new Promise((razresi, odbij) => {
        setTimeout(
            () => {
                console.log(niska);
                razresi();
            },
            Math.floor(Math.random() * 50) + 1
        );
    });
}

function prikaziSve() {
    prikaziNisku("A");
    prikaziNisku("Б");
    prikaziNisku("В");
    prikaziNisku("Г");
    prikaziNisku("Д");
}

prikaziSve();

У овм примеру, приказ ниске са чекањем је издвојен у посебну функцију која користи обећања. Овде је, што је атипично, предвиђено да ће се асинхроне операције увек успешно реализовати и зато се структура те функције не поклапа у потпуости са псеудокодом који јој претходи.

Резултат рада скрипте дводи да се прикажу слова А-Г, свако после извесног чекања, при чему редослед приказа није финсиран, нити је на њему постављено било какво ограничење. █

Потпуно коришћење обећања подразумева обраду евентуалних резултата добијених по завршетку асинхроне операције. Обећање реагује на промену свога стања позивајући функцију повратног позива.

Обично се након промене стања обећања (из стања “на чекању” у стање “испуњено” или у стање “одбијено”) позива метод then(). Овај метод, дефинисан у прототипу обећања, прихвата два параметра који типа функције. Први праметар описује шта ће се радити ако је обећање испуњено - то је функција која прихвата један параметар кроз који јој се прослеђује податак добијен асинхроном операцијом. Други параметар описује шта ће се радити ако је обећање одбијено - та функција такође прихвата један параметар кроз који јој се проследјује разлог неуспеха.

Пример. Приказ (са чекањем) првих пет слова уз ограничење у редоследу тако да се словa A, Б и В морају увек приказати у том редоследу.

function prikaziNisku(niska) {
    return new Promise((razresi, odbij) => {
        setTimeout(
            () => {
                console.log(niska);
                razresi();
            },
            Math.floor(Math.random() * 50) + 1
        );
    });
}

function prikaziTriRedom() {
    prikaziNisku("А")
        .then(() => {
            return prikaziNisku("Б")
        })
        .then(() => prikaziNisku("В"));
    prikaziNisku("Г");
    prikaziNisku("Д");
}

prikaziTriRedom();

Извршење овос скрипта више пута доводи до следећег резултата:

А   Г   Г
Г   Д   А
Д   А   Д
Б   Б   Б
В   В   В

Као што се може видети резултат извршавања није детеминистичан, али сваки пут се слово В мора појавити после слова Б, које се мора појавити после слова А - мада не непосредно после, већ између њих може бити “уметнут” и приказ неког другог слова. █

Пример. Приказ (са чекањем) првих пет слова по азбучном редоследу.

function prikaziNisku(niska) {
    return new Promise((razresi, odbij) => {
        setTimeout(
            () => {
                console.log(niska);
                razresi();
            },
            Math.floor(Math.random() * 50) + 1
        );
    });
}

function prikaziSveRedom() {
    prikaziNisku("А")
        .then(() => prikaziNisku("Б"))
        .then(() => prikaziNisku("В"))
        .then(() => prikaziNisku("Г"))
        .then(() => prikaziNisku("Д"));
}

prikaziSveRedom();

Пример. Приказ (са чекањем) прва три слова по азбучном редоследу.

Овај пример илуструје асинхрони рад са функцијама које враћају резултате. У овом прмеру је “уланчавање” резултата реализовано преко обећања.

function dodajNisku(prethodna, tekuca) {
    return new Promise((razresi, odbij) => {
        setTimeout(
            () => {
                razresi(prethodna + ' ' + tekuca);
            },
            Math.floor(Math.random() * 50) + 1
        )
    })
}

function dodajSveRedom() {
    dodajNisku('', 'A')
        .then(result => {
            return dodajNisku(result, 'Б')
        })
        .then(result => {
            return dodajNisku(result, 'В')
        })
        .then(result => {
            console.log(result)
        });
}

dodajSveRedom();

Наравно, и овом случају резултат је приказ прва три слова азбуке у растућем редоследу:

 A Б В

Пример. Приказ (са чекањем) прва три слова по азбучном редоследу.

У овом прмеру је “уланчавање” резултата реализовано преко обећања, при чему су функције повратног позива дате као ламбда-изрази.

function dodajNisku(prethodna, tekuca) {
    return new Promise((razresi, odbij) => {
        setTimeout(
            () => {
                razresi(prethodna + ' ' + tekuca);
            },
            Math.floor(Math.random() * 50) + 1
        )
    })
}

function dodajSveRedom() {
    dodajNisku('', 'A')
        .then(result => dodajNisku(result, 'Б'))
        .then(result => dodajNisku(result, 'В'))
        .then(result => console.log(result) );
}

dodajSveRedom();

И овом случају је исти резултат: приказ прва три слова азбуке у растућем редоследу:

 A Б В

Асинхроно програмирање и наредбе async и await

Коришћење само једног обећања је јасно и једноставно, међутим када се програм закомпликује асинхроном логиком, рад са обећањима се рапидно отежава.

Са стандардном ЕS2017 је стигла и нова синтакса async/await, која олакшава рад са обећањима и омогућава једноставније представљање серије асинхроних обећања.

Коришћење async/await синтаксе омогућава да се читљивије прикажу вишеструке међусобно зависне асинхроне радње, и да се на тај начин избегне тзв. “пакао обећања”.

Треба напоменути да се уз async функције не могу користити “обичне” функције повратних позива.

Синтакса async/await не искључује обећања, него мења начин конзумирања обећања. Ова синтакса омогућава програмеру да пише асинхрони код према секвенцијалном редоследу извршавања тако да личи на синхрони код.

Асинхрона функција, обележена се са кључном речи async, је функција унутар које се извршава асинхрони код. Тако обележена функција даје до знања извршном окружењу да ће се унутар ње извршити асинхрона операција.

Приликом извршења асинхроне функција, у позадини се извршавају следеће активности:

  1. Аутоматски конвертује регуларну функцију у обећање

  2. Све што је враћено наредбом return у телу функције, након успешне операције то бива прослеђено функцији за разрешавање обећања из тачке 1. Асинхрона функција увек враћа обећање. Чак и ако враћена вредност функције није обећање асинхрона функција ће обавијати сваку враћену вредност и прослеђивати је као обећање.

  3. Асинхрона функција омогућава коришћење await оператора.

Пример. Приказ (са чекањем) првих пет слова.

У овом случају је функција за приказ свих елемената направљена као асинхрона функција.

function prikaziNisku(niska) {
    return new Promise((razresi, odbij) => {
        setTimeout(
            () => {
                console.log(niska);
                razresi();
            },
            Math.floor(Math.random() * 50) + 1
        );
    });
}

async function prikaziSve() {
    prikaziNisku("A");
    prikaziNisku("Б");
    prikaziNisku("В");
    prikaziNisku("Г");
    prikaziNisku("Д");
}

prikaziSve();

У овом случају, проглашавање функције асинхроном допушта да се наредбе унутар метода асинхроно извршавају, па редослед приказа слова није предефинисан. █

Оператор await може да се користи једино у оквиру асинхроне функције, где паузира њено извршење. Наиме, резервисана реч await паузира извршење асинхроне функције све док не дообије резултате операције тј. док се не врати обећање. Ако је обећање испуњено, онда је резултат који је “дочекао” await враће вредност испуњено, а ако је дочекано обећање одбијено онда await враће вредност одбијено.

Пример. Приказ (са чекањем) првих пет слова, тако да прво буде приказано A, потом Б и да нема других ограничења на редослед.

Захтевана ограничења су реализована преко резервисане речи await.

function prikaziNisku(niska) {
    return new Promise((razresi, odbij) => {
        setTimeout(
            () => {
                console.log(niska);
                razresi();
            },
            Math.floor(Math.random() * 50) + 1
        );
    });
}

async function prikaziDvaRedom() {
    await prikaziNisku("А");
    await prikaziNisku("Б");
    prikaziNisku("В");
    prikaziNisku("Г");
    prikaziNisku("Д");
}

prikaziDvaRedom();

Резултат рада скрипте није потпуно детерминисан, али се на почетку морају наћи слова А и Б:

А   А   А
Б   Б   Б
Д   В   Г
В   Г   Д
Г   Д   В

Пример. Приказ (са чекањем) прва три слова по азбучном редоследу.

У овом прмеру је “уланчавање” резултата реализовано преко await наредбе, при чему су функције повратног позива дате као ламбда-изрази.

function dodajNisku(prethodna, tekuca) {
    return new Promise((razresi, odbij) => {
        setTimeout(
            () => {
                razresi(prethodna + ' ' + tekuca);
            },
            Math.floor(Math.random() * 50) + 1
        );
    });
}

async function dodajSveRedom() {
    let ret = '';
    ret = await dodajNisku(ret, 'A');
    ret = await dodajNisku(ret, 'Б');
    ret = await dodajNisku(ret, 'В');
    console.log(ret);
}

dodajSveRedom();

Резултат извршења скрипте је приказ прва три слова азбуке у растућем редоследу:

 A Б В

Литература

  1. Haverbeke M.: Eloquent JavaScript

  2. JavaScript - Mozzila Developer Network (MDN)

  3. Живановић, Д.: Веб програмирање - ЈаваСкрипт

  4. Copes F.: Complete JavaScript Handbook