Управление JavaScript UI-потоком с помощью планировщика WinJS

в 12:43, , рубрики: html, javascript, promise, Shedule, Windows 8, Windows 8.1, winjs, WinRT, Блог компании Microsoft, планировщик

От переводчика: в статье рассказывается о новом планировщике задач в библиотеке WinJS 2.0, обновившейся вместе с выходом Windows 8.1. Для понимания части материала крайне желательно понимание работы с отложенными результатами (Promise). См. раздел MSDN, посвященный асинхронному программированию на JavaScript.

Не считая рабочих веб-процессов (web workers) и фоновых задач, которые также выполняются как отдельные веб-процессы, весь JavaScript-код в приложениях для Windows Store выполняется в общем так называемом UI-потоке. Этот код может делать асинхронные вызовы WinRT API, которые выполняют свои операции в отдельных потоках, но есть один важный момент, о котором нужно помнить: результаты из этих не-UI-потоков возвращаются назад для обработки в UI-поток. Это означает, что запуск серии асинхронных вызовов WinRT (например, HTTP-запросов), — всех сразу, — может потенциально перегрузить UI-поток, если результаты от них придут примерно в одно и то же время. Более того, если вы (или WinJS) добавляете элементы в DOM или изменяете стили, которые требуют обновления компоновки страницы в UI-потоке, это создает еще больше задач, конкурирующих за ресурсы CPU. Как результат ваше приложение становится «тормозящим» и неотзывчивым.

В Windows 8 приложение может предпринять ряд шагов для снижения таких эффектов, например, запускать асинхронные операции в рамках временных блоков, чтобы управлять частотой возвратов в UI-поток, или объединять вместе задачи, требующие цикла обновления страницы, чтобы за один проход выполнялось больше операций. Начиная с Windows 8.1, появилась возможность асинхронно расставлять приоритеты разным задачам непосредственно в UI-потоке.

Хотя хост приложения предлагает низкоуровневый API планировщика (MSApp.executeAtPriority), мы рекомендуем пользоваться вместо него API WinJS.Utilities.Scheduler. Причины такой рекомендации заключаются в том, что WinJS управляет своими задачами именно через этот API планировщика, в свою очередь это означает, что любая работа, которой вы управляете аналогичным способом, будет должным образом скоординирована с той работой, которую для вас делает WinJS. Планировщик WinJS также предоставляет более простой интерфейс ко всему процессу, особенно в случаях, когда это касается работы с отложенными результатами (promises).
Важно отметить, что использование планировщика не является чем-то обязательным. Он нужен для того, чтобы помочь вам настроить производительность вашего приложения, а не для того, чтобы усложнить вашу жизнь! Давайте первым делом разберемся в разных приоритетах, которые используются планировщиком, а потом посмотрим как планировать и управлять работой с учетом этих приоритетов.

Важно отметить, что использование планировщика не является чем-то обязательным. Он нужен для того, чтобы помочь вам настроить производительность вашего приложения, а не для того, чтобы усложнить вашу жизнь! Давайте первым делом разберемся в разных приоритетах, которые используются планировщиком, а потом посмотрим как планировать и управлять работой с учетом этих приоритетов.

Приоритеты планировщика

Относительные приоритеты для планировщика WinJS указаны в перечислении Scheduler.Priority, в порядке уменьшения они выглядят так: max, high, aboveNormal, normal (значение по умолчанию для кода приложения), belowNormal, idle и min. Ниже приведено общее руководство по тому, как их лучше использовать:

  • max, high — используйте умеренно для задач с действительно высоким приоритетом, так как эти приоритеты перекрывают проходы отрисовки страницы в процессе рендеринга. Если вы используете эти приоритеты слишком активно, приложение на деле может стать менее отзывчивым.
  • aboveNormal, normal, belowNormal — используйте для указания относительной важности для большинства важных задач.
  • idle, min — используйте для долгих в выполнении или поддерживающих задач, не накладывающих зависимости на UI.

Хотя для вас не обязательно использовать планировщик в вашем коде, небольшой анализ использования асинхронных операций, скорее всего, позволит выявить места, в которых выставление приоритетов может сыграть большое значение. К пример, вы можете приоритизировать не-UI работу, пока отображается экран заставки, так как экран заставки не является интерактивным по определению, или отправлять самые важные HTTP-запросы с приоритетами max или high в то время, как вторичные запросы устанавливать в belowNormal. Это поможет обработать те первые запросы до того, как будет отрисована домашняя страница, на которой пользователь ожидает возможности взаимодействия с контентом, а далее можно отработать вторичные запросы в фоне. Конечно, если пользовать переходит к странице, на которой нужен вторичный контент, вы можете поменять приоритет этой задачи на aboveNormal или high.

Библиотека WinJS сама по себе активно использует выставление приоритетов. Например, она будет отрабатывать блоки изменений источнике для связывания данных с приоритетом high, а планирование задач очистки будет делаться с приоритетом idle. В сложных элементах управления, например, ListView, запросы новых элементов, необходимых для рендеринга видимой части списка ListView делаются с максимальным приоритетом, рендеринг видимых элементов делается на aboveNormal, предзагрузка следующей страницы элементов (вперед) делается на normal (в предположении, что пользователь будет листать дальше), а предзагрузка предыдущей страницы (для случая обратного листания) делается на belowNormal.

Планирование и управление задачами

Теперь, когда мы знаем о приоритетах планировщика, можно поговорить о том, что нужно сделать, чтобы асинхронно выполнить код в UI-потоке с нужным приоритетом. Для этого нужно вызвать метод Scheduler.schedule (приоритет по умолчанию – normal). Этот метод дает вам возможность указать опциональный объект для использования в качестве this внутри функции, а также имя для использования для лога и диагностики. (Метод Scheduler.execHigh является короткой ссылкой на прямой вызов MSApp.execAtPriority с приоритетом Priority.high. Этот метод не принимает дополнительных аргументов.)
В качестве простой иллюстрации сценарий 1 примера HTML Scheduler добавляет в планировщик набор функций с разными приоритетами в некотором случайном (js/schedulesjobscenario.js):

window.output("nScheduling Jobs...");
var S = WinJS.Utilities.Scheduler;

S.schedule(function () { window.output("Running job at aboveNormal priority"); },
    S.Priority.aboveNormal);
window.output("Scheduled job at aboveNormal priority");

S.schedule(function () { window.output("Running job at idle priority"); },
    S.Priority.idle, this);
window.output("Scheduled job at idle priority");

S.schedule(function () { window.output("Running job at belowNormal priority"); },
    S.Priority.belowNormal);
window.output("Scheduled job at belowNormal priority");

S.schedule(function () { window.output("Running job at normal priority"); }, S.Priority.normal);
window.output("Scheduled job at normal priority");

S.schedule(function () { window.output("Running job at high priority"); }, S.Priority.high);
window.output("Scheduled job at high priority");

window.output("Finished Scheduling Jobsn");

Окно результатов показывает, что «задачи», когда они вызываются, выполняются в ожидаемом порядке:

Scheduling Jobs...
Scheduled job at aboveNormalPriority
Scheduled job at idlePriority
Scheduled job at belowNormalPriority
Scheduled job at normalPriority
Scheduled job at highPriority
Finished Scheduling Jobs
Running job at high priority
Running job at aboveNormal priority
Running job at normal priority
Running job at belowNormal priority
Running job at idle priority

Надеюсь, для вас это не является сюрпризом!

При вызове метода schedule вы получаете назад объект, удовлетворяющий интерфейсу Scheduler.IJob, который определяет следующие методы и свойства:

Свойства

  • id — (только для чтения) уникальный id, присвоенный планировщиком.
  • name — (чтение-запись) имя задачи, предоставленное приложением, если таковое было указано (name-атрибут в методе schedule).
  • priority — (чтение-запись) приоритет, присвоенный при обращении к планировщику; установка свойства поменяет приоритет.
  • completed — (только для чтения) Boolean-значение, указывающее, была ли завершена задача (функция, переданная в планировщик, завершилась и все зависимые асинхронные задачи также завершены).
  • owner — (чтение-запись) признак владельца, используемый для группировки задач. По умолчанию не определен.

Методы

  • pause — останавливает дальнейшее выполнение задачи.
  • resume — запускает ранее приостановленную задачу (нет эффекта, если задача не остановлена).
  • cancel — удаляет задачу из планировщика.

На практике, если вы запланировали задачу с низким приоритетом, но переходите на страницу, на которой действительно необходимо, чтобы задача выполнилась до начала рендеринга, вы просто обновляете ее priority-свойство (и далее очистить планировщик, как мы увидим очень скоро). Аналогично, если вы запланировали какую-то работу на странице и необходимость в ее продолжении исчезла при переходе в другое место, просто вызовите метод cancel у ваших задач в методе unload страницы. Или, возможно, у вас есть стартовая страница, с которой вы обычно переходите к детальной и обратно. В этом случае вы можете поставить на паузу (pause) любую задачу на стартовой странице при переходе к детальной и далее продолжить выполнение (resume) при возврате назад. Для демонстрации смотрите сценарии 2 и 3 в примере.

Сценарий 2 также показывает использование свойства owner (код достаточно понятный, поэтому вы можете легко его изучить самостоятельно). Признак владельца (токен) создается через метод Scheduler.createOwnerToken, далее присваивается через свойство owner задачи (это замещает предыдущее значение). Признак владельца – это просто объект с единственным методом cancelAll, который вызывает метод cancel для всех задач, к которым он привязан, и ничего более. Это простой механизм, в реальности он просто поддерживает массив задач, но зато он позволяет вам группировать связанные задачи и отменять их единым вызовом. Таким образом, у вас нет необходимости поддерживать свои собственные списки и проходится по ним для этого действия. (Чтобы сделать аналогичное решение для паузы и продолжения вы, конечно, можете просто повторить этот паттерн в своем коде.)

Другая важная возможность планировщика – это метод requestDrain. Он позволяет убедиться, что все запланированные задачи с данным приоритетом или выше будут выполнены до передачи управления UI-потоку. Обычно это используется для того, чтобы убедиться, что все задачи с высоким приоритетом завершены до начала отрисовки. requestDrain возврашает отложенный результат (promise), который реализуется, когда все задачи «очищены». В этот момент можно перейти к менее приоритетным задачам или добавить новые.

Простая демонстрация есть в сценарии 5 примера. В нем две кнопки, которые планирую одинаковые наборы различных задач и далее вызывают requestDrain с высоким приоритетом или приоритетом belowNormal. Когда отложенный результат выполняется, выводится соответствующее сообщение (js/drainingscenario.js):

S.requestDrain(priority).done(function () {
window.output("Done draining");
});

Если сравнить два вывода параллельно (high слева, belowNormal справа), как это указано ниже, вы заметите, что отложенный результат появляется в разные моменты в зависимости от приоритета:

Draining scheduler to high priority	| 	Draining scheduler to belowNormal priority
Running job2 at high priority		|	Running job2 at high priority
Done draining				|	Running job1 at normal priority
Running job1 at normal priority		|	Running job5 at normal priority
Running job5 at normal priority		|	Running job4 at belowNormal priority
Running job4 at belowNormal priority	|	Done draining
Running job3 at idle priority		|	Running job3 at idle priority

Еще один метод, определенный в планировщике, — retrieveState, диагностическое средства, возвращающее описание текущих задач или запросов на очистку. Если в сценарии 5 добавить его вызов сразу после requestDrain, вы получите следующие результаты:

id: 28, priority: high
id: 27, priority: normal
id: 31, priority: normal
id: 30, priority: belowNormal
id: 29, priority: idle
n requests:
*priority: high, name: Drain Request 0

Выставление приоритетов в цепочке отложенных результатов

Представьте, что у вас есть набор асинхронных методов, запрашивающих данные, которые вы хотите выполнить последовательно, как указано ниже, обрабатывая их результаты на каждом шаге:

getCriticalDataAsync().then(function (results1) {
    var secondaryPages = processCriticalData(results1);
    return getSecondaryDataAsync(secondaryPages);
}).then(function (results2) {
    var itemsToCache = processSecondaryData(results2);
    return getBackgroundCacheDataAsync(itemsToCache);
}).done(function (results3) {
    populateCache(results3);
});

По умолчанию, весь этот код будет выполняться с текущим приоритетом на фоне всего остального, происходящего в UI-потоке. Но вы, возможно, хотите, чтобы функция processCriticalData выполнялась с приоритетом high, processSecondaryData работала в режиме normal и populateCache – в idle. Напрямую работая с планировщиком, вам бы пришлось делать все сложным путем:

var S = WinJS.Utilities.Scheduler;

getCriticalDataAsync().done(function (results1) {
    S.schedule(function () {
        var secondaryPages = processCriticalData(results1);
        S.schedule(function () {
            getSecondaryDataAsync(secondaryPages).done(function (results2) {
                var itemsToCache = processSecondaryData(results2); 
                S.schedule(function () {
                    getBackgroundCacheDataAsync(itemsToCache).done(function (results3) {
                        populateCache(results3);
                    });
                }, S.Priority.idle);
            });
        }, S.Priority.normal);
    }, S.Priority.high);
});

На наш взгляд, поход к стоматологу – это более веселое занятие, чем написание подобного кода! Для упрощения, вы могли бы обернуть процесс установки нового приоритета внутрь другого отложенного результата, который в свою очередь вы бы вставили в цепочку. Лучший способ это сделать – это динамически генерировать обработчик на событие завершенности (completed), который возьмет результаты с предыдущего шага в цепочке, спланирует выполнение с нужным приоритетом и вернет Promise-объект с тем же результатом:

function schedulePromise(priority) {
    //Возвращаемая функция – обработчик завершенности.
    return function completedHandler (results) {
        //Обработчик завершенности возвращает новый отложенный результат, который сразу завершен
        //с теми же результатами, что он получил...
        return new WinJS.Promise(function initializer (completeDispatcher) {
            //Но доставка результатов спланирована в соответствии с приоритетом.
            WinJS.Utilities.Scheduler.schedule(function () {
                completeDispatcher(results);
            }, priority);
        });
    }
}

К счастью, у нас нет необходимости писать подобный код самостоятельно. WinJS.Utilities.Scheduler уже содержит пять готовых обработчиков завершенности наподобие приведенного выше, которые также автоматически отменяют задачу при возникновении ошибки. Называются они соответственно: schedulePromiseHigh, schedulePromiseAboveNormal, schedulePromiseNormal, schedulePromiseBelowNormal и schedulePromiseIdle.

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

var S = WinJS.Utilities.Scheduler;

getCriticalDataAsync().then(S.schedulePromiseHigh).then(function (results1) {
    var secondaryPages = processCriticalData(results1);
    return getSecondaryDataAsync(secondaryPages);
}).then(S.schedulePromise.normal).then(function (results2) {
    var itemsToCache = processSecondaryData(results2);
    return getBackgroundCacheDataAsync(itemsToCache);
}).then(S.schedulePromiseIdle).done(function (results3) {
    populateCache(results3);
});

Для ясности: использование этих функций заключается только в том, что вы вставляете их названия в цепочке отложенных вызовов. Вам не нужно вызывать их напрямую как отдельные функции (это может быть не вполне ясно изложено в текущей документации).

Долго живущие задачи

Все примеры работ, которые мы рассматривали на текущий момент, являются коротко живущими в том смысле, что мы планируем некоторую рабочую функцию с определенным приоритетом и она просто выполняет свою работу в момент вызова. Однако некоторые задачи могут требовать намного большего времени для завершения. В этом случае вы, навряд ли хотите блокировать работу с более высоким приоритетом в вашем UI-потоке.

Чтобы помочь в таких ситуациях, планировщик имеет встроенный таймер интервалов для сортировок задач, которые спланированы с приоритетами aboveNormal или ниже – с тем, чтобы задача могла проверить, не должна ли она быть «кооперативно» вытеснена и перепланирована для следующего блока работы. Тут нужно пояснить слово «кооперативно»: ничто не заставляет задачу отложить свое выполнение, но так как это все затрагивает производительность UI вашего приложения, да и в целом все приложение, то если вы не будете обрабатывать такие ситуации должным образом, вы сами нанесете себе вред!

Механизм реализации для такого маневра осуществляется через объект jobInfo, который передается в качестве аргумента в саму рабочую функцию. Давайте для начала посмотрим на то, что доступно для функции в ее области видимости, это проще всего понять из нескольких комментариев внутри базового кода:

var job = WinJS.Utilities.Scheduler.schedule(function worker(jobInfo) {
    //jobInfo.job – та же задача, что возвращается из планировщика.
    //Scheduler.currentPriority – второй аргумент для планировщика.
    //this – третий параметр, передаваемый планировщику.
}, S.Priority.idle, this);

Члены объекта jobInfo определены в интерфейсе Scheduler.IJobInfo:

Свойства

  • job — (только для чтения) тот же объект, что возвращается из schedule.
  • shouldYield — (только для чтения) Boolean-флаг, который обычно установлен в false при первом запуске задачи и далее меняется true, если функция должна быть вытеснена из UI-потока и ее работа должна быть перепланирована.
Методы

  • setWork — функция для перепланирования задачи.
  • setPromise — отложенный результат, которого планировщик будет дожидаться до перепланирования задачи, причем функция для перепланирования – это значение для отложенного результата.

Сценарий 4 примера HTML Scheduler показывает, как с этим работать. Когда вы нажимаете кнопку “Execute a Yielding Task”, в планировщик добавляется функция, названная worker с приоритетом idle, которая просто делает холостые циклы до тех пора, пока вы не нажмете кнопку “Complete Yielding Task”, которая устанавливает флак taskCompleted в true (js/yieldingscenario.js, с интервалами в 2с, замененными на 200мс):

S.schedule(function worker(jobInfo) {
    while (!taskCompleted) {
        if (jobInfo.shouldYield) {
            // не закончена, снова вызываем эту же функцию
            window.output("Yielding and putting idle job back on scheduler.");
            jobInfo.setWork(worker);
            break;
        }
        else {
            window.output("Running idle yielding job...");
            var start = performance.now();
            while (performance.now() < (start + 200)) {
                // ничего не делаем;
            }
        }
    }

    if (taskCompleted) {
        window.output("Completed yielding task.");
        taskCompleted = false;
    }
}, S.Priority.idle);

Если задача активна, она делает «работу» в течение 200мс и далее проверяет, не установлено ли свойство shouldYield в true. Если так, то функция вызываем метод setWork для перепланирования самой себя (или другой функции, если необходимо). Такой переход можно спровоцировать пока долгая задача работает, нажав кнопку “Add Higher Priority Tasks to Queue” в примере. Вы увидите, как эти задачи (с высоким приоритетом) отработают до следующего вызова рабочей функции. В дополнение, вы можете нажать куда-либо еще в интерфейсе, чтобы убедиться, что эта холостая задача не блокирует UI-поток.

Обратите внимание, что рабочая функция проверяет shouldYield вначале, чтобы немедленно вытиснуться, если это необходимо. Однако вполне нормально сделать сначала небольшую работу, после чего сделать проверку. Еще раз, это вопрос кооперации внутри вашего собственного кода, поэтому ваши блокировки на вашей же совести.

Что касается setPromise, то это несколько более тонкая идея. Вызов setPromise планировщику подождать до тех, пока отложенный результат не появится, прежде, чем перепланировать задачу. Причем следующая рабочая функция для задачи предоставляется напрямую через значение отложенного результата. (Как таковой метод IJobInfo.setPromise не занимается управлением асинхронными операциями, как это делают другие методы setPromise внутри WinJS, которые в свою очередь завязаны на механизмы задержке в WinRT. Если вы вызвали IJobInfo.setPromise с отложенным результатом из какого-то случайного асинхронного API, планировщик будет пытаться использовать значение исполнения этой операции, – это может быть все, чем угодно, — в виде функции, что может привести к возникновению исключения.)

В общем, если setWork говорит «давай перепланируй вот с этой рабочей функцией», то setPromise говорит «погоди с перепланированием, подожди, пока я передам тебе нужную функцию когда-нибудь попозже». Обычно это удобно для создания рабочей очереди, составленной из многих работ с сопутствующей задачей для обработки этой очереди. Для иллюстрации, представьте, что у вас есть следующий код:

var workQueue = [];

function addToQueue(worker) {
    workQueue.push(worker);
}

S.schedule(function processQueue(jobInfo) {
    while (work.length) {
        if (jobInfo.shouldYield) {
            jobInfo.setWork(processQueue);
            return;
        }
        work.shift()();  //Вытолкнуть первую функцию из FIFO-очереди и вызвать.
    }
}}, S.Priority.belowNormal);

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

Проблема, однако, в том, что функция processQueue закончится в тот момент, когда очередь будет пуста, это означает, что какие бы вы работы ни добавляли в очередь, они не будут обработаны. Чтобы исправить это, вы могли заставить processQueue периодически вызывать setWork снова и снова даже, если очередь пуста, но это было бы пустой тратой ресурсов. Вместо этого вы можете установить setPromise, чтобы заставить планировщик дождаться, пока появится новая работа в очереди. Вот как это будет работать:

var workQueue = [];
var haveWork = function () { };  //Это просто заглушка

function addToQueue(worker) {
    workQueue.push(worker);
    haveWork();
}

S.schedule(function processQueue(jobInfo) {
    while (work.length) {
        if (jobInfo.shouldYield) {
            jobInfo.setWork(processQueue);
            return;
        }
        work.shift()();  // Вытолкнуть первую функцию из FIFO-очереди и вызвать.
    }

    //Если мы добрались сюда, очередь пуста, но мы не хотим выходить из рабочей функции. 
    //Вместо того, чтобы вызывать setWork без какой-либо работы, создаем отложенный результат, 
    //который будет завершен, когда addToQueue будет снова вызвана, в чем мы убеждаеся, замещая 
    //функцию haveWork другой, вызывающей обработчик завершенности отложенного результата.
    jobInfo.setPromise(new WinJS.Promise(function (completeDispatcher) {
        haveWork = function () { completeDispatcher(processQueue) };
    }))
});

В рамках данного кода, предположим мы заполнили workQueue некоторым количеством работы и далее делаем вызов schedule. К этому моменту и далее, пока очередь не становится пустой, мы находимся внутри цикла while функции processQueue. Любой вызов пустой функции haveWork практически не требует дополнительных операций.

Если очередь становится пустой, мы выходим из цикла while, но не хотим выходить из processQueue. Вместо этого мы хотим сказать планировщику подождать, пока новая работа будет добавлена в очередь. Именно поэтому у нас есть заглушка для функции haveWork, которую мы сможем заменить другой функцией, завершающей отложенный результат в processQueue и, таким образом, вызывающей перепланирование самой рабочей функции.

Заметим, что альтернативным способом достижения той же цели, было бы использования следующего присваивания функции haveWork:

haveWork = completeDispatcher.bind(null, processQueue);

Это дает такой же результат, что и анонимная функция, но без создания замыкания.

Заключение

API WinJS Scheduler позволяет приложениям планировать разные задачи внутри UI-потока с относительными приоритетам, включая разные задачи внутри единой цепочки отложенных результатов. При этом приложение автоматически координирует свои задачи с задачами, которые выполняет WinJS для этого же приложения, например, оптимизируя связывание данных и отрисовку элементов управления. При продуманном использовании доступных приоритетов приложение может сделать заметные шаги в сторону улучшений общей производительности и пользовательского опыта.

— Kraig Brockschmidt
Program Manager, Windows Ecosystem and Frameworks Team
Автор “Programming Windows Store Apps with HTML, CSS, and JavaScript”, Second Edition

Ссылки

Краткое руководство по работе с планировщиком
Пример HTML Scheduler
Загрузка Visual Studio 2013

Автор: kichik

Источник

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


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