Защо нишки
Накратко: Работата, която чака (мрежа, диск), в JavaScript не иска нишки, стига Promise.all; работата, която смята, иска истински нишки, Worker-и, и повече ядра.
Проблем
Клиентът чака три пъти
Клиентът натиска „Поръчай“. Преди да види „Благодарим“, сървърът трябва да пита три услуги по мрежата: плащането (банката приема ли картата), склада (има ли го) и имейла (тръгна ли потвърждението). Всяка отговаря за около 200 ms.
Тук мрежата е Network. call чака 200 ms и връща отговора на услугата. Програмата пита трите една след друга:
const net = new Network();const payment = await net.call("плащане", "прието");const stock = await net.call("склад", "има");const email = await net.call("имейл", "изпратен");console.log(payment);console.log(stock);console.log(email);console.log(`Кръгове чакане: ${net.rounds}`);плащане: приетосклад: имаимейл: изпратенКръгове чакане: 3Всяко питане започва, след като предишното е свършило. Клиентът чака сбора: около 600 ms. А през цялото време процесорът не прави нищо: само чака отговор.
Network брои и кръговете чакане: нов кръг започва, когато програмата пита, а никой друг отговор не се чака. Тук са три, по един за всяко питане. В урока бързината се мери с този брой, а не с милисекунди: времето зависи от машината, а броят кръгове е един и същ при всяко пускане.
По-точно, за любопитните
Така работи Network. sleep е setTimeout от node:timers/promises, както в урока „Async, await и нишки“. Нишката е една, затова броячите не искат защита.
class Network { inFlight = 0; rounds = 0;
// Пита услуга по мрежата: отговорът идва след ~200 ms. async call(service, answer) { if (this.inFlight === 0) this.rounds++; // никой не чака: нов кръг this.inFlight++; await sleep(200); this.inFlight--; return `${service}: ${answer}`; }}Идеята
Работа, която чака, и работа, която смята
Работата на една програма е от два вида.
- Работа, която чака (I/O-bound): заявка към банката, заявка към базата, четене на файл, изпращане на имейл. Програмата пита и стои, докато отговорът дойде. Процесорът през това време е свободен.
- Работа, която смята (CPU-bound): 1000 фактури в PDF, умалени снимки на продуктите, отчет от милион поръчки. Тук процесорът е зает през цялото време.
Затова първият въпрос пред бавна програма е „какво чака тя?“. Отговорът определя какво помага.
Виж го
Трите наведнъж
Promise.all (урокът „Async, await и нишки“). Нишка не трябва:const net = new Network();const [payment, stock, email] = await Promise.all([ net.call("плащане", "прието"), net.call("склад", "има"), net.call("имейл", "изпратен"),]);console.log(payment);console.log(stock);console.log(email);console.log(`Кръгове чакане: ${net.rounds}`);плащане: приетосклад: имаимейл: изпратенКръгове чакане: 1const net = new Network();const [payment, stock, email] = await Promise.all([ net.call("плащане", "прието"), net.call("склад", "има"), net.call("имейл", "изпратен"),]);console.log(payment);console.log(stock);console.log(email);console.log(`Кръгове чакане: ${net.rounds}`);const net = new Network();const payment = await net.call("плащане", "прието");const stock = await net.call("склад", "има");const email = await net.call("имейл", "изпратен");console.log(payment);console.log(stock);console.log(email);console.log(`Кръгове чакане: ${net.rounds}`);Стъпка по стъпка, с думи
- Поръчката иска три отговора по мрежата: плащане, склад и имейл за потвърждение. Всеки чака около 200 ms.
- Едно след друго: нишката пита за плащането и не прави нищо, докато отговорът дойде.
- Чак тогава пита за склада. Още 200 ms чакане.
- Чак тогава пита за имейла. Още 200 ms чакане.
- 3 кръга чакане: клиентът чака около 600 ms, а процесорът през цялото време стои.
- Наведнъж: трите въпроса тръгват заедно, всеки в своя нишка, и трите чакания се застъпват.
- Програмата чака отговорите. Докато дойде първият, другите два също са готови.
- 1 кръг чакане: около 200 ms, колкото най-бавният отговор. Същите отговори, същият ред, една трета от чакането.
Отговорите са същите и в същия ред. Кръгът чакане е един: трите въпроса тръгват, преди някой отговор да е дошъл. Клиентът чака около 200 ms, колкото най-бавната услуга, вместо 600.
Трите таймера изтичат в реда, в който са пуснати, а Node вика продълженията едно по едно. Всичко е в една нишка.По-точно, за любопитните
await, е урокът „async не е нишка“.Пробвай
Имейлът чака плащането
Колега преправя поръчката: имейлът трябва да казва дали е платено и дали го има, затова тръгва чак когато плащането и складът са отговорили.
const net = new Network();const [payment, stock] = await Promise.all([ net.call("плащане", "прието"), net.call("склад", "има"),]);// имейлът казва дали е платено и дали го имаconsole.log(payment);console.log(stock);console.log(await net.call("имейл", "изпратен"));console.log(`Кръгове чакане: ${net.rounds}`);Плащането и складът тръгват заедно: това е първият кръг. Имейлът тръгва чак след двата отговора, когато нищо друго не се чака: втори кръг. Около 400 ms. Работа, която зависи от друга, не може да се застъпи с нея.
Пробвай
Чакай отговора
Какво чака обещанието на плащането?
const payment = net.call("плащане", "прието");
console.log( payment);await чака обещанието и дава резултата. Без него console.log печата самото обещание: Promise { <pending> }.Проблем
Хиляда фактури в края на месеца
В края на месеца магазинът прави фактура в PDF за всяка от 1000 поръчки. Тук нищо не чака мрежата: процесорът смята през цялото време. render замества рисуването на PDF със сметка, която само товари процесора и връща контролна сума:
// Рисува фактурата (тук: сметка, която само товари процесора).function render(invoice) { let h = invoice; for (let i = 0; i < 2000; i++) { h = (h * 31 + i) % 1_000_003; } return h;}
// Фактурите от from до to включително.function renderRange(from, to) { let sum = 0; for (let n = from; n <= to; n++) { sum += render(n); } return sum;}const total = renderRange(1, 1000);console.log("Фактури: 1000");console.log(`Контролна сума: ${total}`);Фактури: 1000Контролна сума: 499409728Едно ядро смята всичко, а останалите стоят. Кръгове чакане тук няма: нищо не чака.
Виж го
Четири Worker-а, четири ядра
postMessage, а главната нишка я получава с once(worker, "message"):if (isMainThread) { const workers = [0, 1, 2, 3].map((p) => { const from = p * 250 + 1; const range = { from, to: from + 249 }; return new Worker(new URL(import.meta.url), { workerData: range }); }); const parts = await Promise.all( workers.map(async (worker) => (await once(worker, "message"))[0]), ); const total = parts.reduce((sum, part) => sum + part, 0); console.log("Фактури: 1000"); console.log(`Контролна сума: ${total}`);} else { parentPort.postMessage(renderRange(workerData.from, workerData.to));}Фактури: 1000Контролна сума: 499409728Контролната сума е същата: разделянето не сменя резултата, само кой го смята.
Колко по-бързо е, зависи от машината. На моя лаптоп с 250 пъти по-тежка сметка на фактура (500 000 стъпки вместо 2000) едно ядро смяташе около 1,9 s, а четири около 0,5 s.Това са примерни времена, не обещание. Повече части от ядрата не помагат: те само се редуват на същите ядра.
По-точно, за любопитните
- Колко нишки. За работа, която смята, колкото ядрата:
os.availableParallelism()казва колко са. За работа, която чака, може много повече: те само спят. - Не всичко се дели. Ако една десета от работата не може да се раздели (например събирането на частите или записът на един файл), никакъв брой ядра не я прави повече от 10 пъти по-бърза. Това е законът на Amdahl.
- Worker-ите не делят обикновени обекти:
workerDataиpostMessageги копират. Обща памет има само вSharedArrayBuffer, а там идват проблемите от следващия урок.
Идеята
Кое помага
| Работа, която чака | Работа, която смята | |
|---|---|---|
| Какво я бави | отговорът по мрежата, диска, базата | самото смятане |
| Помага | да чакаш няколко неща наведнъж | повече ядра |
| В JavaScript | Promise.all, без нишки | Worker нишки |
| На едно ядро | помага | не помага |
Нишките идват с цена. Докато всяка работи със своите данни (своята услуга, своите 250 фактури), всичко е наред. Щом две нишки пипат едно и също (общ брояч, наличността в склада), започват грешки, които не се виждат при всяко пускане. Това е следващият урок: „Race condition“.
Упражнение
Страницата на продукта
Реши задачата в редактора си.
Страницата на продукта. Преди да покаже страницата, сървърът пита три услуги: цената, отзивите и доставката. Всяка отговаря за около 200 ms и не зависи от другите. Ползвай Network от първата карта и ги питай наведнъж, така че кръгът чакане да е един:
цена: 89 €отзиви: 4,6 от 5доставка: утреКръгове чакане: 1Още задачи
- Пет услуги. Добави наличност и отстъпка. Колко са кръговете, ако отстъпката зависи от цената?
- Колко ядра. Пусни фактурите с 1, 2, 4 и 8 части и мери времето при теб. Кога спира да става по-бързо? Сравни с броя ядра на машината си.
- Грешният инструмент. Пусни 1000-те фактури с
Promise.allиasyncфункция вместо с Worker-и. Става ли по-бързо? Защо?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изключения · Масиви: push, splice и sort · Стрелкови функции · Async, await и нишки
- Продължава в: Race condition · Заключване · Атомарни операции и неизменими обекти · Производител и потребител
- Задачи по темата: Нишки и race conditions