Раздзеленне памяці межа працоўных нитэй Node.js за дапамой SharedArrayBuffer і Atomics
Чаму функцыя postMessage дае кожнам працоўніку Node.js асобную копію, калі SharedArrayBuffer ўсё ж прыдатны, і як Atomics запобегае втратам апдэйтаў і дазволяе чаканне між нитямі.
Крыніцы: The Day I Learned JavaScript Threads Don’t Actually Share Memory
Рабочыя ніткі Node.js — это справжнія ніткі операцыйной системы, кожная з яых мае свой сабэкземпляр двіжка JavaScript. Чырагаеся думка, што яны дзеляцца памэтю так сама, як ніткі ў C або Java, але гэта припущэння паспяхваецца ў першы раз, калі два рабочыя элемента павінны скоординаваць свою дзеяльнасць над аднаковымі даннымі. У гэтым артыкуле показана, што на самай працэ postMessage з вашымі даннымі, калі ёсць праблема з справжнейя дзеленай памэтю, і як Atomics дапамагае падтрымліваць правильнасць адночасных зменаў.
Якщо вы новачак у вопытах рабочых ніткаў, пачніце з выканання роботы, якая выкарыстоўвае многа CPU-ресурсаў, у Node.js за дапамогою рабочых ніткаў і пулів; тут акцэнт зроблены на болей вузкай тэме: што выдзейваецца з памэтю, калі вядомы рабочы элемент.
postMessage адправляе копію, а не рэферэнс
Эксперымент пачатваецца там, што галоўны адлін стварае рабочы адлін з аднойчы незалежнай файлавой структуры.
// main.js
const { Worker } = require("worker_threads");
const worker = new Worker("./worker.js");
Пасля чаго ён адправляе звычны масіў да рабочага адліна, а чырвоны момант пазней фіксуе свой сопакаваны копію таго масіву.
const sharedLookingArray = [1, 2, 3];
worker.postMessage(sharedLookingArray);setTimeout(() => {
console.log("Main thread's array:", sharedLookingArray); // still [1, 2, 3]
}, 100);
З іншай стороны, рабочы адлін прымеў масіў, дадае да яго елемент і фіксуе рэзультат.
// worker.js
const { parentPort } = require("worker_threads");
parentPort.on("message", (arr) => {
arr.push(4);
console.log("Worker's array:", arr); // [1, 2, 3, 4]
});
Рабочы адлін выводзіць [1, 2, 3, 4], тады як галоўны адлін продовжае выводзіць [1, 2, 3] незалежна ад таго, як дуго ён чакае. Прычына ў тым, што postMessage перадае данні за дапамою алгорытму структураванага клонавання, тое самае механізма, які ў глобальнай доступнасці называецца structuredClone. Данні серыязуюцца на стороне адправкі і паўтарна будуюцца як незалежны об’ект на стороне прыемкі, таму пасля вызова існуе два незв’язаныя масіўы — по аднаму на кожны адлін.
Это ўсвядомлена і безпечная стандартна настройка. Два потакі не можу конкуруваць за памэць, якая ўсёлікі час не давалася ім обом. Косцю є сама копія, яка зрастае разам з розмерам пакета дадзеных і платаецца за кожна зместо. (Для большых бінарных дадзеных Node таксама можа пераказаць ArrayBuffer у іншы потак як об’ект, які можна перадаць, чым ухіліваецца ад копіювання, але не дазволяе падаўшымі яго викорыстоўваць; гэта ўсьмо пераказ, а не спільнае викорыстанне.)
Калі спільная памэць — правы інструмент
Копіюванне стае неправым компромісам, калі потакі павінны коордынаціяваць сваю дзеяльнасць над тымі ж дадзенымі з вялікай частотой, настолькі часта, што ўсьмо перадаванне іх туды-сюды будзе коштаць больш, чым сама паралелізаваная робота. Для такога случая JavaScript прысвячае SharedArrayBuffer: необработаны блок памэці, да якога кілька потакоў можа адразу звертацца, без жадных копій.
Главны потак знову завантажае модуль працоўніка:
// main.js
const { Worker } = require("worker_threads");
Потым выделяеця чатырыбайтовы буфер для адзінаго 32-бітовага цэлага числа, апакаваеця яго ў відобразэнне Int32Array і перадае буфер рабочай задачы через workerData.
const sharedBuffer = new SharedArrayBuffer(4); // 4 bytes, one 32-bit integer
const sharedCounter = new Int32Array(sharedBuffer);const worker = new Worker("./worker.js", { workerData: { sharedBuffer } });
Рабочая задача стварае сваё власнае відобразэнне Int32Array на тым жа буфере:
// worker.js
const { workerData } = require("worker_threads");
const sharedCounter = new Int32Array(workerData.sharedBuffer);
З гэтага момента будь-якія змены, выкарыстоўваныя через аднае з відобразэнняў, стаюць виднымі через другое, таму што оба вказуюць на тыя ж байты.
sharedCounter[0] += 1; // this thread and the main thread are now touching the same memory
Это сапраўдны адрозненне ад таго, як зазвычай працюе паралельнасць у JavaScript. Код у аднай сфере выконваецца на адной нітке, таму знакомыя ситуацыі асінхронных перагананняў, калі два async функцыі выконваюцца ў неспадзейанай парады, все равно являюць сабою адна нітка, якая па черзе выконвае задачі у чытальна адначасованах моментах. З SharedArrayBuffer две ніткі можаць чытаць і запісваць тую ж памяць буквальна ў той самы момент.
Загубленыя апдэйты, якія тепер сталі можлівымі
Разглянем просты спосаб развіць кантэр, які выкарыстоўваецца саюзна:
// naive, and genuinely unsafe with real shared memory
sharedCounter[0] = sharedCounter[0] + 1;
Здаецца, што гэта адна операцыя, але на самай працэ ёй трэба тры крокі: зачытаць текущую значэнне, дадаць 1, запісаць рэзультат зноў. Якщо іншы поток зачытае кантэр між чытаннем і запісам гэтага потока, оба вырахаваюць адно і тое ж новая значэнне, і адна зі збільшэнняў зникае. Нічога не выклікае памылак і нічага не фіксуецца; канечная цифра проста занадта низкая. Паколькі рэзультат залежыць ад точнага часавог моменту двух потакоў ОС, гэтая баг можа праказацца раз у тысячу запускаў і зникнуць, калі дадаць логаванне.
Атамічныя операцыі: недзелімыя дзеяння ў саюзной памяці
Об’ект Atomics апранае операцыі над саюзнымі масівамі цэлах, якія выкананыя як адна ейка. Ніхто іншы поток не можа спазірваць чы прымешчацца да атамічнай операцыі ў паўпаце ўсьго процэсу.
Atomics.add(sharedCounter, 0, 1); // read, increment, and write, as one indivisible step
Якщо кожны адэнт прырастае за дапамогай Atomics.add у зменшыцю да лініі чытання-змены-запісу, ніякія апдэйты не можаць быць загублены, таму што дварчык гарантуе, што іншыя атамічныя операцыі над тым элементам не можаць пераскладвацца ўнутрэй гэтай. Гаранцыя дзейсніца толькі якшо всі запісчыкі вядуцься за дапамогай атамічных операцый; адзін адэнт, які ўсё ж такі выкарыстоўвае просты спосаб прырастання, зноў стварае рызык супернаганяння. Усё болей складнае, што можна стварыць на спакульнай памяці — будзь то лічылкі, блакі або кале — у падсумку складаецца з такіх жа операцый, якія є атамічнымі на рэверсе апаратуры, а не проста „зазвычай працуюць“.
Atomics.wait і Atomics.notify: блакіранне межы адэнтамі
Atomics таксама прыносіць тое, чаго не хапяе JavaScript: можлівасць для аднаго потока зупыніцца ў выкананні паўнасцю, пакуль іншы поток не прыгучы яго. У рабочым потоку Atomics.wait блакуе столькі часу, сколькі лічылка яшчэ мае значэнне 0:
// worker.js — waits for main thread to signal it's time to proceed
Atomics.wait(sharedCounter, 0, 0); // blocks this thread while sharedCounter[0] === 0
console.log("Signaled to continue, value is now:", sharedCounter[0]);
Галоўны поток зберагае новаяе значэнне, а пасля прыгучвае адны з потакоў, які чакае:
// main.js — does the signaling
Atomics.store(sharedCounter, 0, 1);
Atomics.notify(sharedCounter, 0, 1); // wakes up one waiting thread
Важна зберагчы данні раней, чым паведамляць пра іх. Atomics.wait спачатку пераканальвае значэнне і негайна вяртаецца, якщо ёно больш не збігаецца з апэкаваным, таму сигнал, апрацоўваны раней, чым рабочы поток пачне чакаць, не трапляецца без увагі.
Это справжній блакуючы выклік, што ў JavaScript є не звычайным, адкуль асінхронная робота зазвычай ведэцца через калебакі івент-лупа. Блакуванне ниткі, якая запускае івент-луп, заморозіць усё іншае, што обробляе тая нітка, і самэ гэта прычына, чаму браузеры не дазваляюць викорыстоўваць Atomics.wait у галоўнай нітцы, і чаму ён, загалом кажучы, паслужыць у нітках-рабоцах. (Браузеры таксама выклікаюць патрабаванне, каб сторынка была ізольавана між рознымі кантэкстамі, перш чым SharedArrayBuffer узагалі стане доступным; у Node такіх обмежэнняў няма.)
Рэалістычны выкарыстанне: реальны прогрэс у пулі нітак-рабоцаў
Ёсць прыклад, калі дадзеная сложнасць практычна робіць сваю працу. Калькі рабочых процэсаў, залежных ад CPU, кожны з яых обрабоцвае частку вялікага набора дадзеных, а галоўны поток хочаць побачыць актуальную статыстыку праходкі, без таго, каб кожны рабочы процэс падаў запаведзі ўсё для кожнага элемента. Галоўны поток стварае аднаго спільнага лічылка і передае яго буфер кожнаму рабочаму процэсу разам з яго часткай дадзеных:
// main.js
const progress = new Int32Array(new SharedArrayBuffer(4));
const workers = chunks.map((chunk) =>
new Worker("./process-chunk.js", { workerData: { chunk, progressBuffer: progress.buffer } })
);
Потым ён адбірае значэння лічылка кожныя паў секунды за дапамой Atomics.load, што гарантуе, што ён чытае цэлы, актуальны значэння:
setInterval(() => {
console.log(`Processed ${Atomics.load(progress, 0)} items so far`);
}, 500);
Кожны рабочы процэс абгортае спільны буфер у свой саўстаннівы відглед:
// process-chunk.js
const { workerData } = require("worker_threads");
const progress = new Int32Array(workerData.progressBuffer);
Працюючы з элементамі, ён атомарна падвайшвае значэння спільнага лічылка:
for (const item of workerData.chunk) {
processItem(item);
Atomics.add(progress, 0, 1); // every worker safely increments the same shared counter
}
Усі працоўніякі безпасэродзе запішуць у тыя ж чатыры байты, немае додатковых витрачэнняў на перадачу паведамленняў за кожны элемент, і главны поток можа у будь-які момент прачытаць точную суму, не патрабуючы ад каго-небудзь дапамогі. Самэ гэта явышча — частая і строгая координацыя між практычна паралельнымі заведаменнямі CPU — і ўсунець патрэбу ў копіюванні дадзеных зарады викорыстання спяльнай памяці.
Большасць коду не должна вяртатыся да гэтага падходу
Тыповы код backend на Node рэдка калі патрабуе такога падходу. Большасць задач у серверах API є I/O-залежнымі, і для ўсунення гэтых проблем вже дастатнька однопоточная цыклічная структура. Для заведаменняў, якія залежаць ад працы CPU, такіх як обробка зображэнняў, трансформацыя дадзеных чы крыптаграфія, зазвычай дастатнька працоўніякіў, якія викорыстоўваюць простае копіювання через postMessage.
SharedArrayBuffer і Atomics падчыряюцца для спецыяльных ситуацый: высакачастай коордынаціі межа паралельнымі ниткамі, калі копіюванне праз кожную абмянку сама стае вузкім месцам. Адпрыянне спакульнае памяці пры тым, калі проблема ўсё еще не змерана, не робіць код быстрэйшым; гэта толькі прыводзіць да супернаганяння на рэвэрсе апаратуры, якія модель копіювання была створана, каб усунуць.
Ключовыя выводы
postMessageстварае структураваныя клонаванні дадзеных, таму кожная нітка працюе з сабой клонам і мутацыі ніколі не пераходзяць адной з іншай.SharedArrayBufferдае калькам прымытны доступ да тых сабе байтаў чераз перакладнікі типаваных масэв.- Звычайна операция
x = x + 1у спакульнае памяць можа тыхо адмовіцца ад зберагання актуальных значэнняў; для кожнай змены трэба вжывацьAtomics.addі падобныя методы.
Atomics.wait практычна блакуець адпаведны поток і паслужыць для выканання задач у рамках працоўнікаў; яго трэба выкорыстоўваць разам з Atomics.store і Atomics.notify.