Галоўная / Артыкулы

Забэрэжыце адпаведнае выкананне сторонні: калі пераказваць роботу CPU у Web Worker

Дазнайце, чаму большыя ціклы зупіняюць інтерфейс прыёмніка, як модель перадачы паведамленняў Web Worker аддае цю роботу на іншы процес, і як вярнуцца, чы робота взагалі патрабуе Worker.

Выдавец: REACTAPP.TOP12 хв чытання2703 слоў

Крыніцы: JavaScript Workers: How to Run Code Without Freezing Your UI

Корыстнік нажымае кнопку, пазначэнне зменяецца на „Обробка…“, а пасля не выходзіць жадных рэакцый: анімазіі зупыняюцца, стрэлкі для прасування не работаюць, дальнейшыя нажаткі ігноруюцца. Таблетка не зламалася; проста частка JavaScript захопіла главную вяроўку. У этамая інструкцыі пояснюецца, чаму так адбываецца, паказаны базовыя прынцыпы Web Worker для перанесення трохі складных вычысленняў в іншае месца, а таксама даўаецца практычны спосаб дакладна вярнуцца, калі Worker ёсць правы спосаб рашэння проблемы, а калі ён толькі дадае дапэўнай сложнасці.

Чаму адна длігая задача замарожвае ўсё

Браузер у цаласці выканае горазд за большае, чым простае выкананне вашых скрыптав, але JavaScript, які належыць да сторанніка, выканаецца на адной галоўнай ніткае. Якщо вы хочаце пазнакоміцца з болей шырокай кантэкстам таго, як гэтая нітка впісваецца ў мнагапроцэсавую структуру браузера, адзірніце JavaScript-аўсім ніткавы прыем паўстаець у протыяворасці да мнагапроцэсавага двігу браузера. Практычны наследкі просты: гэтая нітка можа стаць вузкам местам.

Возьмім функцыю, якая сумуе ўсі цэлыя числа менейшыя за мільярд. У ёй немае нічога пакладзенага, і двіг будзе з задоволеннем выканаць яе да канца:

function calculate() {
  let total = 0;
  for (let i = 0; i < 1_000_000_000; i++) {
      total += i;
    }
    return total;
  }
calculate();

Проблема крыўцяе ў тым, што відбуваецца за гэты час. Калі цикл продаўжвае працаваць, галоўны поток не можа абработваць нічога іншага, што ад яго залежыць. Функціі для обработкі клікаў, працэс адобразы вводу і большая частка работ з апডেйту таго, што паказваецца на экране, всі чакають у черзі на той самы поток. Саме гэта чаканне і ўособліваеся ў корыстніку як замарожаны інтерфейс.

Ланцюг падзеяў выглядае так:

User clicks button
       ↓
JavaScript starts heavy calculation
       ↓
Main thread stays busy
       ↓
UI work has to wait
       ↓
Page feels frozen

Тут нічога не зламана. Я ваш код затримвае поток прытолькі, і браузер па-справедлівам дазволяе яму завершыцца. Гэта таксама прычына, чаму код, які здаваўся абсалютна нормальным пад час розработы, можа стаць справжнім проблемам з продуктывасцю пад час рэальнай експлуатацыі: функцыя не зменілася, але набор дадзенаў або колькасць вычыслаў, якія ёй трэба адрабатваць, зрасла.

Стварэнне састаноўкі выканання для важкіх задач

Web Worker — это адзінаковы контэкст выканання JavaScript, який працюе незалежна ад галоўнага скрыпта стораніцы. У замяне таго, каб усё было адбывалася на аднай ніткі:

MAIN THREAD
     │
     ├── UI
     ├── Events
     ├── JavaScript
     └── Heavy calculation  ← problem

вы раздзеляеце абавескі так, каб стораніца захавала UI, данні вводу і логіку стораніцы, тады калі Worker абрабатвае выкалкаванні, якія займаюць багато ресурсаў:

MAIN THREAD                  WORKER
     │                         │
     ├── UI                    ├── Heavy calculation
     ├── User input            ├── Data processing
     └── Page logic            └── Result
             │                  │
             └── messages ─────┘

Лінія, якая спаўнае два канцы ў той дыяграме, — галоўная деталь. Worker не дзеліцца зменнымі чы рэкаментамі вашай стораніцы. Два канцы адмаўляюцься паведамленнямі, і гэтыя абмежэнні вплываюць на ўсё іншае, якое стосуецца работы з Workers.

Першы Worker, па кроках

Ёсць цыкл сумавання з ранейшага прыкладу, перанесены ўнеў галоўнай ніткі. Скрыпт стораніцы стварае Worker з аднойчынага файла, надае яму верхняю межу і рэгіструе працоўнік для приймання адпаведзі:

const worker = new Worker("worker.js");

worker.postMessage(1_000_000_000);

worker.onmessage = (event) => {
  console.log("Result:", event.data);
};

Файл Worker слухае прыходзячыя паведамленні, выканае цикл на адповідаючый яму номер і падае загальную суму назад:

self.onmessage = (event) => {
  let total = 0;
  for (let i = 0; i < event.data; i++) {
    total += i;
  }
  self.postMessage(total);
};

Дапамагае адстэпна пераследаванне кожнага вызову. Старонка перадае Worker своі данні за дапамогою postMessage:

worker.postMessage(...)

Унутрь Worker атрыбут self паводзіцца на глобальны прастор Worker, а обрабоўчык onmessage — гэта там, дзе прыходзяць данні:

self.onmessage = (...)

Калі вычысленні завершаюцца, Worker адпавядае за дапамогою свайго postMessage:

self.postMessage(...)

У канцы, обрабоўчык старонкі для об’екта Worker прыме гэта адпаведзенне як event.data:

worker.onmessage = (...)

Заўважыце, што прызначэнне атрыбута onmessage пасля вызову postMessage у скрыпцы сторанкі ўсё гаразд: адпаведнае падтрымкі можа прыйсці толькі асінхронна, пасля таго, як ныякая скрыпта завершыць сваю роботу, таму працоўнік обробкі завжды будзе ў готовасці.

Што змянілася, а шта — ні

Прыдатны спосаб разумець гэта — чераз дэлегаванне. Галоўны поток фактычна кажа "гэтая ў вас даныя, распрацавайце іх", працоўнік виконвае задачу, а пасля завершэння дапытываецца. Без працоўніка прагэт распрацоўкі є строга последовным, і інтэрфейс чакае:

Main thread
     ↓
Heavy calculation
     ↓
UI waits

З працоўнікам галоўны поток перадае задачу і продовжвае обслугаваць сторанку, тады калі вычысленні ведуцца паралельна:

Main thread ──→ Worker
     │             │
     │             └── Heavy calculation
     │
     └── continues handling the page

Паведамце чытра, калі вы адчувайце практычную корысть. Пераказваўшы выконанне у Worker, яго зазвычай не можна завершыць раней. Цикл выконваецца прыблізна таксама дзяўно, як і раней, а дадатковыя перадачы та прыемы паведамлення дагэтуль даўаюць мінімальны плюс. Ваша корысть заключаецца у тым, што вычысленні больш не конкуруюць за час з главным потокам, таму стораніца застаецца інтерактываючай пад час ўжытку.

Спілкаванне з Worker: паведамленні, а не DOM

Сэрьозныя змены ў манеры мышлення стаюць там, што Worker не можа з’явіцца ў структуре стораніцы. У глобальным прасторе Worker няма элемента document, таму такі вызов там не будзе работаць:

document.querySelector("#result");

Усё, што пераходзіць за межы, адбываецца чераз паведамленні. Данныя вы адправляеце так:

worker.postMessage(data);

а тое, што вяртаецца, вы прыемляеце так:

worker.onmessage = (event) => {
  console.log(event.data);
};

Гэтыя абмежнення на самай працоўны становяцца функцыяй. Паколькі Worker можа толькі прымаць данні і выдаваць рэзультаты, тракт прыема-выдачы дакументаў межу двумя часткамі застойвы ўсё час явны:

Main Thread
     │
     │ postMessage()
     ▼
  Worker
     │
     │ postMessage()
     ▼
Main Thread

Для большай часткі застаўвы вузькая, у форме паведамлення граніца межу „часткай, якая атрыбутуе“ і „часткай, якая вырахоўвае“ ёсць самэ тым дизайнам, які вы і так хацелі. Worker ніколі не чыпае DOM, а сторанка сама вяршыць, што робіць з кожным рэзультатам.

Рефактораванне застойвага адпаведніка на клік

Тыповая пачаткова точка — адпаведнік на клік, який сінхронна обрабоцвае вялікі набор дакументаў і атрыбутуе рэзультаты негаразды:

button.addEventListener("click", () => {
  const result = processHugeDataset(data);
  renderResult(result);
});

Якщо processHugeDataset() працуе значны час, галоўная ніт застойваецца на весь гэты час, і сторанка перестае рэагаваць пакуль ён не вернёцца.

Версія, яка выкорыстоўвае Worker, дзеліць працоўнік обробкі на два часты. Клік толькі адправляе даны; атрыбуты стораны прадстаўляюцца пазней, калі прыходзіць спамяркі пра рэзультат:

const worker = new Worker("worker.js");

button.addEventListener("click", () => {
  worker.postMessage(data);
});

worker.onmessage = (event) => {
  renderResult(event.data);
};

Даскладная обробка тепер выконваліцца ўнутры Worker, а главны поток можа продовжыць обробляць сторану, пакуль чакае. Є два моменты, якія варта памяцать у такой схеме. Першы — Worker ствараецца толькі адна раз, за межамі працоўніка обробкі, таму падчас кальканняя клікаў ён пераўтвараецца, а не ствараецца занова ў кожны раз. Другі — якщо корыстнік можа калькануць колька разоў за час, рэзультаты можа прыйсці пасля таго, як корыстнік вярнуўся да іншага элемента; у рэальным кодзе зазвычай кожная спамяркі пазначаецца ідэнтыфікатарам або ігноруюцца старыя адпаведзі, каб стары рэзультат не перазапісаў новы.

Адліквідацыя таго, што патрабуецца ў Worker

Лёгкаа ўтварыць азарт і пачаць перакладваць кожную функцыю ў Worker. Аднак гэта рэдкаа часам яе добрая ідея. Worker становіць сэнс тады, калі задача ўскладнена настолькі, што патрабуе значных ресурсаў CPU і павінна конкуруваць з галоўным потокам за час выконання. Тыповыя прыклады такіх задач:

  • Обробка або агрэгацыя велікіх набораў дадзеных
  • Складныя числовыя або алгарытмічныя вырахункі
  • Масштабныя трансфармацыі JSON
  • Парсінг велікіх файлаў
  • Маніпуляцыі з адпраўкамі
  • Обробка аудыю чы ўвядзення
  • Компрэсія і іншыя трансфармацыі дадзеных
  • Адпаведныя крыптаграфічныя чы матэматычныя алгорытмы
  • Акцэнтуванне сінтаксісу і іншая даскладная обробка тексту

Паўтарыючыся прызнакі такія ж: велікі объём вырахункаў і мінімальная патрэба ў безпосереднім адносе з DOM.

Калі Worker ўжо не патрэбны

Для такой простай задачы, як множэнне двух чисоў, Worker ёсць чыстаю додатковай навантажэнням:

const total = price * quantity;

Стварэнне Worker мае пачатковыя витраты, кожны запіс несе навантажэння на комунікацыю, і тады у вас з’яўляецца ўтолькі што ўсё новы файл з сае власной жыцёвай ціклам. Для маленьких операцый кращым выборам є звычны JavaScript на галоўнай вясі. Уместа таго, каб спытацца, чы моглі б вы вжываць Worker, спытайцеся, чы робота настолькі складная, што ўсуненне яе з галоўнай вясі даследжвае пашкодзіць корыстніцкі досвід. Гэтыя запитанні адсеююць большасць случаў, калі Worker толькі дадае складнасці.

Архітектурны парадокс: чыстая межа

Workerы — не толькі інструмент для паспрабоўкі працэсу, яны таксама забезпечваюць раздзеленне абавязакаў. Код вашага інтерфейсу можа сфокусавацца на ператварэнні вхідных дадзеных у стан і стану — у відображэнне:

Input
↓
State
↓
Display

Коліквіт працоўнік зосераджуецца на перетварэнні вхідных дадзеных у выходныя:

Input data
↓
Processing
↓
Output data

Такое ізольаванне дорогіх процесаў спрыяе лёгкам тэставанню та розумеенню ўсьго, адтолькі працоўнік паводзіцца як чыстая функцыя за інтерфейсам паведамленняў.

Аднак існуюць певныя компрэсіі. Тепер трэба карманаваць асінхронным прайом на базе паведамленняў, моментам запуску та зупінкі працоўнікаў, способам выявлення бядаў та перадачай дадзеных через межы. Гэта разумная цена, калі робота є складной. Хорашая інжынерыя не значыць усунення слоžнасці всюды; гэта значыць ўстановленне яе там, дзе яна прыносіць корысть.

Спецыяльныя, спільныя та сервісныя працоўнікі — гэта разныя інструменты

„Web Worker“ часта вжываецца так, нібы ён пазначае адзін API, але існуе калькі API, якія стосуюцца працоўнікаў, і яны служыць розным цэлям:

  • Dedicated Worker: выконвае JavaScript за адзін конкрэтны старонку чыста. Гэты тип выкарыстан у всім гіде.
  • Shared Worker: адзін Worker, да якога можа падключыцца і з якім можа кантактацься кальколька контэкстаў прагляду (напрыклад, кальколька вікон з таго ж адказвача).
  • Service Worker: знаходзіцца межы старонкай і сетью, ўрабляючы такія функцыі, як перехопленне запитоў, кэшаванне і падтрымка роботы без з’яўлення ў інтернете.

Для перакладчыцтва задач, якія выкарыстоўваюць моц CPU, спачатку трэба разумець Dedicated Worker. Service Worker не ўсуне яго; гэтыя два рашаюць разныя проблемы. Чытайце аб глэбокіма парадоўжаннях трохох типаў у Dedicated, Shared and Service Workers: picking the right browser thread.

Деталі, які маюць значэнне ў працоўным режыме

Мінімальны прыклад спецыяльна зроблена маленькім. Рэальныя прыклады застосоўвання выклікаюць большай увагі да адказоў на абыекты, чысткі і розмеру дадзеных.

Следзіце за адказамі

Неадрабаваная асывячэнне ўнутры рабочага процесу праказваецца на сторанцы як адбыўшыся error эвэнт. Прыўяжыце обрабоўчык, каб адказы на абыекты былі запісаны, а не каб інтэрфейс незаканчэна чакаў на рэзультат:

worker.onerror = (error) => {
  console.error("Worker error:", error);
};

Заканчыце работу рабочых процэсаў, якія вам больш не патрэбны

Рабочы процес продовжвае працаваць, пакуль яго не зупіняюць. Калі рабочы процес існуе толькі для адной задачы, зупініце яго на сторанцы пасля таго, как вы завершылі работу:

worker.terminate();

Гэта ўсё болей значыць для рабочых процэсаў короткага трымання; рабочы процес дзейснітэльна довгага трымання, які обслужвае весь застосоў, можа проста заставацца актываным.

Перадача дадзеных — не безкоштовна

Кожная паведамленне выклікае перадачу дакументаў между кантэкстамі. Для большасці значэнняў JavaScript прыгледач выкарыстоўвае алгорытм структураванага клонавання, які стварае копію на прымоцай. Для дзяўальных бінарных пакетаў, такіх як экземпляры ArrayBuffer, іх можна перадаць як Перадавальныя об’екты, якіе пераказваюць права на власнасць іншай стороне заместа копіювання; пасля перадачы адправнік больш не можа выкарыстоўваць пераданы буфер. Вам не трэба негайна знать усіх деталей, але памятайце адну правілу: зняць вычысленні з галоўнай ніткі не значыць, што перадача дакументаў да яе будзе безкоштовная. Клонаванне вельмі большога об’екта сама по сабе можа зайняць значны час на галоўнай ніткі.

Рабочыя процесы і React

React не змінюе спосабу, якым прыгледач плануе выконанне JavaScript. Якщо обрабоўчык запуску ў React адбывае большое сінхроннае вычысленне перад апдэйтом стану, гэта вычысленне все равно адбываецца ў галоўным контэксте выконання стораніцы, так сама як і будь-які іншы скрыпт:

function handleClick() {
  const result = expensiveCalculation();
  setResult(result);
}

Ніякая фрэймворк не можа змаўчыць JavaScript, які вимагае многа вычысленняў ад CPU. Тое, што можна зробіць, — гэта выкорыстаць Worker як шар вычысленняў пад вашымі компонентамі: UI адправляе даны, Worker іх обрабоўвае, а рэзультат вяртаецца, каб быў зберагнуты ў стані і адрасаваны:

React UI
   │
   │ send data
   ▼
Web Worker
   │
   │ process data
   ▼
React UI

Гэта ўзнаграджваецца яшчэ больш у інтэрфейсах, дзе корыстувальнікі працуюць з вялікіми набрамі дадзэнняў, редактарамі, візуалізацыямі чыра іншымі варыянтамі з высокай навантажэнням на обчысленні. Фреймворк не ёсць галоўным моментам; ключовы момент — це знанне каторы поток выканачае дорогія завданні. У компоненте гэта зазвычай значыць стварэнне Workerа ў эфекте, вызов setResult з яго працоўніка обробкі паведамленняў і завершэнне яго ў функцыі чысткі эфекта, каб компоненты, якія былі зняты, не залишалі Workerаў у робочы стан.

Workerы не ёсць загальным спосабам прышвартавання

Найбольшая памятка — спроба выкорыстоўваць Worker як універсальны спосаб падвышэння працэздатнасі. Якщо аплікацыя працуе повольна чераз непатрэбныя перерасчытанні, занадто многія запиты да сеті, вялікія розмеры файлав, неэфектывныя апдэйты React чы суперкомплексны код, Worker не рашае жадных з гэтых проблем. Спачатку трэба выявіць справжню прычыну затрохтвання, як правіло, за дапамогою інструменту аналізу працэздатнасі браузера, і толькі пасля чаго вяршыць, чы Worker є правильным рашэнням.

Короткі чартак памагае:

  1. Чы задача справды вимагае значных вычыслювальных ресурсоў?
  2. Чы яна виконваецца так даваў, што паграждае швальнасцю рэакціі?
  3. Чы яй трэба безпосередній доступ да DOM?
  4. Чы ўсё, што яна прымечае і выдае, можа пераводзіцца у вачынку?
  5. Чы дадатковая складнасць ўзгоджваецца з корыстю?

Калі адпаведзенні паказваюць, што задача вимагае значных вычыслювальных ресурсоў, виконваецца даваў і не патрэбуе доступу да DOM, а ўсё можна пераводзіць у вачынку, тады Worker, шчыльна, є хорашым выбрам.

Гэта таксама дапомагае адзьяйнаць два проблемы, якія лёгкая сплутаць. У першым случае JavaScript выканана большая колькасць задач, чым трэба, і рашэнняя — гэта кращы алгорытм або меншая колькасць выканаючых задач. У другім случае выкананне задачі справды ўскладнена, але няма прычыны блакаваць інтэрфейс; самэўсёлі тут і паводзіцца Worker. Worker не ператварае неэфектыўны код у эфектыўны. Ён дае дорогі коду інша месца для выканання.

Ключовыя выводы

Вам не трэба запамяцваць весь API Worker. Найважлівейшыя элементы можна паказаць на адной дыяграме:

MAIN THREAD
     │
     │ "Here is the work."
     ▼
  WORKER
     │
     │ "Here is the result."
     ▼
MAIN THREAD
  • Галоўны поток контролюе стораніцу; будзь-якая дугая сінхронная задача ў яму затрымляе обработку вводу і апдэйты экрана.
  • Dedicated Worker выканана ізольаваны JavaScript і камунікуе толькі через postMessage і onmessage; у яго няма доступу да DOM.
  • Абсарбаванне нагрузкі прымае участь у падтрыманні адказальнасці інтерфейса, але зазвычай не прыводзіць да прышвыткі самага вычыслення, а перадача вялікых дадзэйнаў таксама мае свою цену.
  • Керуйце зместам onerror, вызывайце terminate() для тымчасовых Worker-аў і рассмотрзіце можлівасць викорыстання Transferable-об’ектаў для большых бінарных пакетаў дадзэйнаў.
  • Спачатку адрабоціце профілі. Оптымізавайце алгорытм, калі робота є некэраглівай; адзначыце викорыстанне Workerа, калі робота неабходная, але не паслужыць для выканання на галоўнай ніткае.
  • Калі наступны раз стораніца зацягнёцца пад час вялікага вычыслення, запытайце не толькі, як зробіць функцыю швэйцарскай, але і чы рэальна яя патрэбна для выканання на галоўнай ніткае.