JavaScript / Спагетти в последовательном вызове асинхронных функций. Теория и практика

в 12:40, , рубрики: ajax, event loop, javascript, jquery, асинхронная загрузка, асинхронное программирование, события, теория, метки: , , , , , , ,

В продолжение статьи Последовательный вызов асинхронных функций.
Часть 1. Теория

Большинство традиционных, не-веб языков программирования являются синхронными (блокирующими).
Как можно определить, синхронный или же асинхронный данный язык? Например, по наличию/отсутствию функции sleep (может называться также delay, pause и т.д.), когда программа полностью останавливается на определённое количество времени.
В JavaScript, как вы знаете, такой функции нет. Есть, например setTimeout, но она делает совсем другое. Она может отсрочить выполнение комманды, но это не значит что после setTimeout, программа останавливается и ничего нельзя в ней делать.
Наоборот, теоретически, после того как setTimeout был вызван, часть ресурсов может даже освободиться и отсроченные коллбеки (функции в очереди) могут выполниться быстрее.
Рекомендется не путать синхронность/асинхронность с однопоточностью/многопоточностью. Это понятия связаны слабо.
Реализация асинхронности в JavaScript — это просто один из подходов к другому понятию — мультизадачности, для которой есть наиболее традиционное решение — мультипоточность
Преимущества Многопоточности:

Не нужно менять мышление, переобучаться из традиционных блокирующих языков (С++, Java, Python)

Если у вас многопроцессорный компьютер и программа написана с использованием потоков и они не блокируют друг друга, т.е. оперируют независивыми данными то программа на таком процессоре будет работать несколько быстрее

Недостатки Многопоточности:

Каждому потоку нужно место для работы с данными, поэтому каждый поток отъедает оперативную память даже если эта память не используется и поток спит.
Раньше это была большая проблема, сейчас память стоит дёшево, но всё равно как-то неприятно становится когда простая но многопоточная Java программа занимает в памяти 2 гб.

Если два потока используют один и тот же дефицитный ресурс (объект в памяти, сетевое соединение и т.д), может произойти состязание потоков за ресурс (Race Condition). «Может произойти» это даже хуже, чем если бы мы сказали «произойдёт».
Для борьбы с состязанием потоков используются флаги, семафоры и т.д., у которых одна цель — заставить другие потоки ждать дефицитный ресурс. Но тогда может возникнуть проблема дедлоков, когда поток A ждёт поток B, а B ждет A.
Это два самых больших недостатка мультипоточности, это настолько большая проблема, что, например, в собеседовании при приёме на работу Java-программиста обязательно присутствуют вопросы по потокам вроде "чем отличается Thread.start() от Thread.run()?", "как бороться с дедлоками?" и т.д. Java программисты тратят огромное количество времени на героическое создание потоков а потом на не менее героический преодоление проблем связанных с этим.
Как вы, наверное, знаете, разработка традиционных процессоров упёрлась в определённой предел, и больше не удаётся повысить производительность гонкой гигагерцев. В этом случае мультипоточность даёт большой прирост в обработке большого количества более-менее независимых данных, например при кодировании видео.
Несмотря на это, впечатление от многозадачности через многопоточность со стороны программиста складывается такое: "невероятно усложнить программу так, чтобы потратилась оперативная память на то чтобы 90% времени потоки ждали друг друга, а как только перестали ждать вступали в борьбу друг с другом угрожая устроить в дедлок".

В Javascript же чтобы создать паралленую задачу нужно написать всего лишь:
setTimeout(function () {
console.log('Async');
}, 0);

или
button.addEventListener('click', function () {
console.log('Async click');
}, false)

Однако «параллельная задача» не значит что на десятиядерном процессоре ваш JavaScript заработает быстрее. JavaScript потоконейтрален, в спецификации ECMA не описано как JavaScript машина реализует многозадачность. Насколько я знаю, все существующие реализации JavaScript используют вариант многозадачности типа «Потоки в пространстве пользователя» (процессор быстро-быстро переключает задачи прерывая по таймеру внутри одного процесса) что однако не гарантирует что ядерная многопоточность никогда в будущем не сможет появиться и в JavaScript.
Забегая вперёд скажу, что в конце концов потоки всё-таки насильно были введены в JavaScript немного странным образом через Web Worker, но об этом будет рассказано ниже, во второй части.
Итак, в стандартном JavaScript всё сделано иначе, через бесконечный цикл событий (Event Loop) и неблокирующие вызовы. В главном и единственном UI потоке работает этот Event Loop, который обращается к очереди коллбэков и отбирает и последовательно их выполняет пока очередь не очистится.
Вызов setTimeout, onclick, и XmlHttpRequest с флагом true помещает новый коллбек в очередь событий. Когда коллбек будет выбран из очереди и выполнится, в процессе выполнения он может поставить в очередь ещё один коллбек и т.д.
Если вы хотите быстро работающий сайт с обильным JavaScript, который не "грузится два часа" — вам следует отсрочить как можно больше операций и как можно быстрее выскочить в главный поток чтобы освободить UI, а менеджер событий сам разберётся когда и что вызывать из очереди, и элементы будут подгружаться постепенно.
Процесс сканирования очереди колбеков никогда не прерывается но и никогда не ждёт. Хотя конечную скорость работы самой программы постепенная подгрузка данных не изменит, но восприниматься посетителем асинхронный сайт с постепенно появляющимися элементами будет как более быстрый.
JavaScript очень хорошо приспособлен для асинхронной работы и был задуман именно таким.
К сожалению в синтаксисе есть синхронные исключения — это команды alert, confirm, promt и xmlhttprequest с флагом false которые всё блокируют.
Настоятельно не рекомендуется использовать эти комманды ни в каких случаях, кроме, пожалуй, одного исключения о котором пойдёт речь в конце нашей статьи.
Асинхронный вызов всегда лучше синхронного с точки зрения производительности. Посмотрите, например, на nginx — он стал суперпопулярным именно из-за высокой производительности, которая достигается, в основном, асинхронной работе.
К моему большому сожалению, node.js всё-таки не удержался и ввёл ещё одну синхронную комманду — require. Пока эта комманда есть в node.js, я, лично никогда не буду использовать его, ибо убеждён что производительность будет всегда хромать.
Зачем же в асинхронный язык вводятся синхронные комманды, которые портят всю идеологию языка?
Во первых, JavaScript машина работает не сама по себе, а в броузере, в котором сидит пользователь, и блокирующие комманды были добавлены не в язык JavaScript, а в среду, которая его окружает — броузер, хотя нам трудно логически разделить эти понятия.
«С другой стороны броузера» сидят программисты, самые разные, пришедшие из разных языков, чаще всего синхронных. Писать асинхронный код намного сложнее, это требует совершенно другого мышления.
Поэтому "по многочисленным просьбам программистов слабо понимающих асинхронность", добавили привычные порочные синхронные функции, полностью останавливающие Event Loop.
Всегда есть возможность выполнить задачу асинхронно, но соблазн упростить себе жизнь, заменив асинхронный вызов на синхронный слишком велик.
В чём заключается сложность асинхронной разработки?
К примеру, рано или поздно любой JavaScript программист столкнётся с таким «багом» (Один из самых популярных вопросов на StackOverflow):
Серверный код

Клиентский код

var getBookList = function () {
var boolListReturn;
$.ajax({
url : 'bookList.php',
dataType : 'json',
success : function (data) {
boolListReturn = data;
}
});
return boolListReturn;
};
console.log(getBookListSorted()); // Почему-то не работает :)

Здесь, разумеется, непонимание вызвано тем что ajax запрос ушёл в очередь, а console.log остался в главном Ui потоке.
Когда ajax выполнится успешно, он поместит в очередь коллбек success, который тоже, может быть, когда-нибудь выполнится. Разумеется, console.log уже останется далеко в прошлом с тем что вернула функция (undefined).
Более правильно немного изменить программу, допустим передав вызов console.log внутрь коллбека success.
var getBookList = function (callback) {
$.ajax({
url : 'bookList.php',
dataType : 'json',
success : function (data) {
callback(data);
}
});
};
getBookList(function (bookList) {
console.log(bookList);
});

Ещё более современный путь — перейти к какому-нибудь удобному интерфейсу работы с коллбеками, например к так называемой концепции «обещание» (promise, также известен как Deferred) — специальному объекту который хранит собственную очередь коллбеков, флаги текущего состояния и другие вкусности.
var getBookList = function () {
return $.ajax({
url : 'bookList.php',
dataType : 'json',
}).promise();
};
// В собственную очередь объекта promise коллбек добавляется коммандой done
getBookList().done(function (bookList) {
console.log(bookList);
});

Однако увеличавая нагрузку на коллбеки, может возникнуть вторая проблема, которая заключается в том что использовать больше одной-двух асинхронных комманд проблематично.
Представим что по полученному списку id нам нужно найти названия книг используя другой сервис book.php
Cерверная часть:

$id
);
switch ($id) {
case '1':
$response['title'] = "Bobcat 1";
break;
case '2':
$response['title'] = "Lion 2";
break;
case '88':
$response['title'] = "Tiger 88";
break;
}
header('Content-type: application/json');
echo json_encode($response);
?>

Наш клиентский код будет таким:

var getBookList = function () {
return $.ajax({
url : 'bookList.php',
dataType : 'json',
}).promise();
};

var getBook = function (id) {
return $.ajax({
url : 'book.php?id='+id,
}).promise();
};

getBookList().done(function (bookList) {
$.each(bookList, function (index, bookId) {
getBook(bookId).done(function (book) {
console.log(book.title);
});
});
});

Вот этот трёхэтажный код уже не очень. Разобраться, что тут происходит ещё, конечно, можно, но большой уровень вложенности очень мешает
и становится местом где потенциально могут возникнуть баги.
Вот один из багов:. Если список id был отсортированный, может произойти потеря сортировки. К примеру, если некоторые запросы возвращаются медленнее чем другие, или
просто у пользователя параллельно запущен качаться торрент, то скорость выдачи результатов запросов может «скакать».
На php мы сэмулируем это коммандой sleep:

case '2':
sleep(2);
$response['title'] = «Lion 2»;
break;

наш скрипт выведет
Bobcat 1
Tiger 88
Lion 2
Здесь видна беда, ведь наш список больше не отсортирован по алфавиту!..
Как же нам сохранить упорядоченность списка при том что запросы занимают разное время?
Эта проблема не так проста как кажется, здесь уже даже объекты-обещания (promise) не сильно помогут, попробуйте решить эту проблему сами и вы на своей шкуре почувствуете драматичность ситуации.
Часть 2. Практика

Посмотрите на этот неполный список библиотек для JavaScript:
async.js, async, async-mini, atbar, begin, chainsaw, channels, Cinch, cloudd, deferred, each, EventProxy.js, fiberize, fibers, fibers-promise, asyncblock, first, flow-js, funk, futures, promise, groupie, Ignite, jam, Jscex, JobManager, jsdeferred, LAEH2, miniqueue, $N, nestableflow, node.flow, node-fnqueue, node-chain, node-continuables, node-cron, node-crontab, node-inflow, node_memo, node-parallel, node-promise, narrow, neuron, noflo, observer, poolr, q, read-files, Rubberduck, SCION, seq, sexy, Signals, simple-schedule, Slide, soda.js, Step, stepc, streamline.js, sync, QBox, zo.js, pauseable, waterfall
Все эти библиотеки-велосипеды обещают решить примерно одну проблему «Write async code in sync form.». Т.е. позволить писать асинхронный код так же легко как и в синхронном стиле.
Я попробовал большинство из них, но в реальности они не особо помогают. Больших удобств в по сравнению со стандартным jQuery.Deferred я не заметил.
Но всё же давайте рассмотрим какие есть варианты:
Вариант 1 «Синхронные вызовы»

Очевидным решением проблемы вложенных запросов (получить список, пройтись по элементам списка и выполнить ещё по запросу для каждого элемента, при этом сохранив упорядоченность оригинального списка) будет тупо сделать все вызовы синхронными:
var getBookList = function () {
var strReturn;
$.ajax({
url : '../bookList.php',
dataType : 'json',
success : function (html) {
strReturn = html;
},
async : false
});
return strReturn;
};
var getBook = function (id) {
var strReturn;
$.ajax({
url : '../book.php?id='+id,
success : function (html) {
strReturn = html;
},
async : false
});
return strReturn;
};
var getBookTitles = function () {
return $.map(getBookList(), function (val, i) {
return getBook(val).title;
});
};

var ul = $('

    ').appendTo($('body'));
    $.each(getBookTitles(), function (index, title) {
    $('

  • '+ title +'
  • ').appendTo(ul);
    });

    Это решение из серии «очень быстрое и грязное» ведь запросы не только блокируют броузер но и делают это дольше, ведь каждый следующий запрос ждет предыдущий.
    Достоинства

    Простой код, легко ловить ошибки

    Легко тестировать

    Недостатки:

    Блокирует броузер

    Результирующее время равно сумме времени всех запросов

    Вариант 2 «Обещание, которое ждет выполения всех входящих в его список обещаний»

    Список книг сам по себе будет являться одним обещанием(не списком), однако внутри он будет содержать список индивидуальных обещаний и лишь после того как все входящие в него обещания выполнятся,
    будет возвращён результат как массив содержащий синхронные данные
    var getBookTitles = function () {
    var listOfDeferreds = [];
    var listDeferred = $.Deferred();
    getBookList().done(function (bookList) {
    $.each(bookList, function (i, val) {
    listOfDeferreds.push(getBook(val));
    });
    $.when.apply(null, listOfDeferreds).then(function () {
    listDeferred.resolve($.map(arguments, function (triple) {
    return triple[0].title;
    }));
    });
    });
    return listDeferred.promise();
    };
    getBookTitles().done(function (bookTitles) {
    $.each(bookTitles, function (index, title) {
    $('

  • '+ title +'
  • ').appendTo('#ul');
    });
    });

    Код функции getBookTitles весьма тяжёл. Основная проблема — он ошибкоопасен, сложно ловить проблемы, сложно отлаживать.
    Достоинства этого варианта:

    Не блокирует броузер

    Результирующее время равно самому долгому из запросов

    Недостатки:

    Сложный, ошибкоопасный код

    Трудно тестировать

    Вариант 3 «Резервация места для результата в ui»

    В данном случае, получив список id, мы перебираем входящие в него элементы, сразу создаём UI объект.
    В той же итерации запрашиваем вторую порцию асинхронных данных, при этом UI элемент виден через замыкание и мы наполняем его содержимым:
    getBookList().done(function (bookList) {
    $.each(bookList, function (index, id) {
    var li = $('

  • Loading...
  • ');
    li.appendTo('#ul');
    getBook(id).done(function (book) {
    li.html(book.title);
    });
    });
    });

    Достоинства

    Не блокирует броузер

    Результат показывается сразу как закончится каждый индивидуальный запрос

    Запросы идут параллельно

    Недостатки:

    Код средней читаемости

    Трудно тестировать

    Вариант 4 «Синхронные вызовы в отдельном потоке webworker»

    В процессе написания статьи пришёл в голову немного экзотический вариант — запускать синхронные запросы но в отдельном потоке через WebWorker и модули. При этом броузер не блокируется, но код упрощается.
    Для этого напишем файл для воркера, плюс в нём будет синхронная функция подобная require из node.js.
    var require = function () {
    // Only load the module if it is not already cached.
    var cache = {};
    var gettext = function (url) {
    var xhr = new XMLHttpRequest();
    xhr.open("GET", url, false); // sync
    xhr.send(null);
    if (xhr.status && xhr.status != 200)
    throw xhr.statusText;
    return xhr.responseText;
    };
    return function (url) {
    if (!cache.hasOwnProperty(url)) {
    try {
    // Load the text of the module
    var modtext = gettext(url);
    // Wrap it in a function
    var f = new Function("require", "exports", "module", modtext);
    // Prepare function arguments
    var context = {}; // Invoke on empty obj
    var exports = cache[url] = {}; // API goes here
    var module = { id: url, uri: url }; // For Modules 1.1
    f.call(context, require, exports, module); // Execute the module
    } catch (x) {
    throw Error("ERROR in require: Can't load module " + url + ": " + x);
    }
    }
    return cache[url];
    }
    }();
    onmessage = function(e){
    if ( e.data.message !== "start" ) {
    return
    }
    var url = e.data.url;
    var funcname = e.data.funcname;
    var args = e.data.args;
    var module = require(url);
    postMessage(module[funcname].apply(null, args));
    };

    Вспомогательной функцией для удобного запуска воркера будет такая:
    Она также будет кешировать и воркер и модули для того чтобы не загружать модуль с сервера при каждом вызове.
    //Если воркер не поддерживается, эмулируем его через блокирующий эмулятор, например
    /**
    * Web Worker Asynchroneous Remote Procedure Call
    */
    var wwarpc = function () {
    var worker;
    var getWorker = function () { // for lazy load
    if (worker === undefined) {
    worker = new Worker("wwarpc.js");
    }
    return worker;
    };
    return function (url, funcname) {
    var args = Array.prototype.slice.call(arguments, 2);
    var d = $.Deferred();
    var worker = getWorker();
    worker.onmessage = function (e) {
    d.resolve(e.data);
    };
    worker.postMessage({
    message : "start",
    url : url,
    funcname : funcname,
    args : args
    });
    return d.promise();
    };
    }();

    Интересно что модули будут node.js-подобные
    exports.getBookList = function () {
    var the_object = {};
    var http_request = new XMLHttpRequest();
    http_request.open( "GET", 'bookList.php', false );
    http_request.send(null);
    if ( http_request.readyState == 4 && http_request.status == 200 ) {
    the_object = JSON.parse( http_request.responseText );
    }
    return the_object;
    };
    exports.getBook = function (id) {
    var the_object = {};
    var http_request = new XMLHttpRequest();
    http_request.open( "GET", 'book.php?id='+id, false );
    http_request.send(null);
    if ( http_request.readyState == 4 && http_request.status == 200 ) {
    the_object = JSON.parse( http_request.responseText );
    }
    return the_object;
    };
    exports.getBookTitles = function () {
    var Books = exports;
    return Array.prototype.map.call(Books.getBookList(), function (val, i) {
    return Books.getBook(val).title;
    });
    };

    При этом один и тот же код модулей можно вызывать как синхронно (во время тестирования юнит-тестами) так и асинхронно (во продакшне).
    Благодаря этому основной код будет намного проще, двухэтажный вместо трёх:
    wwarpc('modules/Books.js', 'getBookTitles').done(function (bookTitles) {
    $.each(bookTitles, function (index, title) {
    $('

  • '+ title +'
  • ').appendTo('#ul');
    });
    });

    Идеология будет такая, что все операции будут выполняться в воркере но сам воркер будет вызываться асинхронно, в итоге вложенность всегда будет минимальная.
    Достоинства:

    Не блокирует новые броузеры

    Простой, легко понимаемый код

    Легко тестировать (можно тестировать в синхронном режиме, а вызывать в асинхронном)

    Недостатки:

    Блокирует IE и старые броузеры не поддерживающие воркеры

    Результирующее время равно сумме времени всех запросов

    Вывод:

    Вариант 3 «Резервация места для результата в ui» — самый быстрый вариант, но несколько усложнённый код

    Если читаемость и тестируемость кода важнее чем комфорт пользователей IE, то Вариант 4 «Синхронные вызовы в отдельном потоке webworker» может быть неплохим выбором

    Следует категорически избегать Варианта 2 («Обещание, которое ждет выполения всех входящих в его список обещаний»)

    Использование синхронных вызовов ничем нельзя оправдать

    IE must die!

    Статья написана под впечатлением от следующих материалов:

    Презентация Doug Crockford loopage

    Презентация Callbacks,Promises, and Coroutines

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js