Производител и потребител
Накратко: Производител и потребител е шаблон, в който едни части на програмата слагат задачи в опашка с ограничено място, а други ги взимат; в JavaScript това са async функции в една нишка, а пълната опашка кара производителя да чака.
Проблем
Черен петък
В 10:00 започва разпродажбата и сайтът приема по 50 поръчки в секунда. Складът опакова по 10. Има три лоши начина да се справиш с това.
Сайтът опакова сам. Клиентът натиска „Поръчай“ и чака, докато някой не опакова кашона. Сайтът е бърз колкото склада.
Сайтът слага поръчките в обикновен списък, а складът ги взима оттам. Два опаковчика могат да вземат една и съща поръчка, а друга да се загуби: това е race condition. Ако сложиш ключалка, опаковчик с празен списък трябва да проверява пак и пак, дали нещо е дошло.
Списъкът няма граница. Сайтът е доволен: никога не чака. Но всяка секунда списъкът расте с 40 поръчки. След час в него има 144 000, последната е чакала час, а паметта свършва.
Идеята
Опашка с ограничено място
Между двете страни се слага опашка. Производителят (producer) слага задачи в края ѝ, потребителите (consumers) ги взимат отначало. Това е шаблонът производител и потребител. Двете страни не се познават: сайтът не знае колко опаковчици има, а опаковчиците не знаят откъде идват поръчките.
Опашката е блокираща и с ограничено място:
- ако е празна, потребителят чака, без да върти процесора, докато не дойде нещо;
- ако е пълна, производителят чака, докато потребител не освободи място;
- ключалката е вътре в опашката: всяка поръчка отива точно при един потребител.
async функции тя се пише за по-малко от 40 реда с обещания: put и take връщат обещание, което се изпълнява, когато има място или поръчка. Ключалка не трябва, защото кодът между два await не може да бъде прекъснат.class BoundedQueue { #items = []; #putters = []; // чакащите да сложат #takers = []; // чакащите да вземат
constructor(capacity) { this.capacity = capacity; }
get size() { return this.#items.length; }
async put(item) { while (this.#items.length >= this.capacity) { await new Promise((resolve) => this.#putters.push(resolve)); } this.#items.push(item); this.#takers.shift()?.(); }
tryPut(item) { if (this.#items.length >= this.capacity) return false; this.#items.push(item); this.#takers.shift()?.(); return true; }
async take() { while (this.#items.length === 0) { await new Promise((resolve) => this.#takers.push(resolve)); } const item = this.#items.shift(); this.#putters.shift()?.(); return item; }}#putters и #takers са опашки от resolve функции: кой чака място и кой чака поръчка. take събужда един чакащ производител, put един чакащ потребител. Събуденият проверява пак (while), защото някой друг може да е бил по-бърз.
Виж го
Сайтът и опаковчиците
const DONE = null;
async function front(orders, queue, packers) { for (const order of orders) { await queue.put(order); // чака, ако е пълна } for (let i = 0; i < packers; i++) { await queue.put(DONE); // хапче за всеки опаковчик }}
async function pack(queue, packed) { while (true) { const order = await queue.take(); // чака, ако няма if (order === DONE) return; // хапчето: спри packed.push(order); }}Стъпка по стъпка, с думи
- Сайтът (производител) слага поръчки в опашка с 3 места, двама опаковчици (потребители) ги взимат отпред. Тя е празна и те чакат в await take.
- Сайтът слага 1001 и 1002 в края на опашката.
- Всеки опаковчик взима поръчката отпред и я опакова. Опаковането е по-бавно от приемането на поръчки.
- Сайтът слага 1003, 1004 и 1005. И трите места са заети.
- 1006 не се събира: await put чака и сайтът спира да приема поръчки. Това е backpressure: бавната страна задържа бързата.
- Опаковчик 1 приключва с 1001 и взима 1003. Освобождава се място и чакащият put пуска 1006.
- И така нататък: опаковчиците взимат отпред, сайтът добавя отзад и в опашката никога няма повече от 3.
- Поръчките свършиха. Сайтът слага по едно хапче за опаковчик след поръчките, затова всяка поръчка се опакова преди тях.
- Всеки опаковчик довършва поръчката си, взима хапче и спира. Без хапчето щеше да чака в await take вечно.
- 8 от 8, всяка веднъж: 3 експресни за 90 €, 5 обикновени за 180 €. Кой коя опакова се сменя при всяко пускане, сумите не.
Поръчката:
// поръчката е { id, euro, express }Сайтът и опаковчиците:
const DONE = null;
async function front(orders, queue, packers) { for (const order of orders) { await queue.put(order); // чака, ако е пълна } for (let i = 0; i < packers; i++) { await queue.put(DONE); // хапче за всеки опаковчик }}
async function pack(queue, packed) { while (true) { const order = await queue.take(); // чака, ако няма if (order === DONE) return; // хапчето: спри packed.push(order); }}front и двата pack са три async функции в една нишка. await е мястото, където всяка може да чака и да пусне другите. Това е разделяне на работата, не паралелна работа: за опаковане, което смята много, трябват Worker-и (виж по-долу).const orders = [ { id: 1001, euro: 25, express: false }, { id: 1002, euro: 40, express: true }, { id: 1003, euro: 15, express: false }, { id: 1004, euro: 60, express: false }, { id: 1005, euro: 30, express: true }, { id: 1006, euro: 45, express: false }, { id: 1007, euro: 20, express: true }, { id: 1008, euro: 35, express: false },];const queue = new BoundedQueue(3);const packed = [];await Promise.all([ front(orders, queue, 2), pack(queue, packed), pack(queue, packed),]);report(orders, packed);Отчетът проверява неща, които не зависят от това кой опаковчик коя поръчка е взел:
function report(orders, packed) { const distinct = new Set(packed.map((order) => order.id)).size; console.log(`Опаковани: ${packed.length} от ${orders.length}`); console.log(`Всяка поръчка веднъж: ${distinct === orders.length ? "да" : "не"}`); for (const express of [true, false]) { const group = packed.filter((order) => order.express === express); const euro = group.reduce((sum, order) => sum + order.euro, 0); const kind = express ? "Експресни" : "Обикновени"; console.log(`${kind}: ${group.length} поръчки, ${euro} €`); }}Опаковани: 8 от 8Всяка поръчка веднъж: даЕкспресни: 3 поръчки, 90 €Обикновени: 5 поръчки, 180 €Изходът е един и същ при всяко пускане. Кой опаковчик колко поръчки е взел, се сменя, затова програмата не го печата.
Идеята
Как спират опаковчиците
Опаковчикът чака в await queue.take(), докато не дойде поръчка. Ако сайтът просто свърши, опаковчиците ще чакат вечно и програмата няма да свърши.
Затова сайтът слага накрая отровно хапче (poison pill): специална стойност, която не е поръчка. Опаковчик, който вземе хапчето, спира. Хапчетата са по едно за всеки опаковчик: всеки взима само едно и не го връща.
Хапчетата влизат в опашката след поръчките, затова всяка поръчка се опакова преди тях.
Тук хапчето еDONE = null.Идеята
Backpressure
Пълната опашка кара сайта да чака. Това не е грешка, а целта: бавната страна дърпа назад бързата. На английски това е backpressure. Сайтът не може да изпревари склада с повече от размера на опашката, паметта не расте, а последната поръчка чака най-много толкова, колкото трябва да се опаковат 3 пред нея.
Чакането не е единственият отговор на пълна опашка. Има и метод, който не чака, а казва дали е успял:
tryPut(order) в нашата опашка връща false, ако няма място.С него сайтът може да покаже „Голям интерес, опитайте след минута“ вместо да виси. Важното е, че решението е твое: поръчката не изчезва тихо.
Пробвай
Предвиди изхода
Опашка с 2 места и без опаковчици. Какво ще отпечата?
const queue = new BoundedQueue(2);console.log(queue.tryPut("1001"));console.log(queue.tryPut("1002"));console.log(queue.tryPut("1003"));console.log(await queue.take());console.log(queue.tryPut("1003"));console.log(queue.size);Първите две влизат. Третата няма място и методът, който не чака, връща „не“. take() взима първата, която е влязла: 1001. Сега има място и 1003 влиза. Вътре са 1002 и 1003: 2.
Пробвай
Попълни
for (let i = 0; i < ; i++) {
await queue.put(DONE);
}packers: по едно хапче за всеки опаковчик. С по-малко някой опаковчик ще чака вечно, с повече хапчетата просто остават в опашката.Пробвай
Намери грешката
Колега не иска сайтът да чака и сменя един ред. Програмата вече не виси никога, а отчетът понякога казва „Опаковани: 5 от 8“. На кой ред е грешката?
Избери реда с грешката.
tryPut не чака: ако опашката е пълна, връща false, а кодът не го гледа. Поръчката просто изчезва. Поправката е await queue.put, или, ако наистина не трябва да се чака, да се провери резултатът и клиентът да разбере, че поръчката не е приета.
Виж го
Нишки, Worker-и и съобщения
Когато опаковането смята много, трябват истински нишки: Worker-и. Между тях опашка с общи обекти няма. Главната нишка праща всяка поръчка на Worker със postMessage, а той я получава в on("message"):
import { Worker, isMainThread, parentPort } from "node:worker_threads";import { once } from "node:events";
if (isMainThread) { const orders = [ { id: 1001, euro: 25, express: false }, { id: 1002, euro: 40, express: true }, { id: 1003, euro: 15, express: false }, { id: 1004, euro: 60, express: false }, { id: 1005, euro: 30, express: true }, { id: 1006, euro: 45, express: false }, { id: 1007, euro: 20, express: true }, { id: 1008, euro: 35, express: false }, ]; const packers = [0, 1].map(() => new Worker(new URL(import.meta.url))); const packed = []; const reports = packers.map((packer) => once(packer, "message")); orders.forEach((order, i) => packers[i % 2].postMessage(order)); // не чака никога packers.forEach((packer) => packer.postMessage(null)); for (const [done] of await Promise.all(reports)) packed.push(...done); report(orders, packed);} else { const done = []; parentPort.on("message", (order) => { if (order === null) { parentPort.postMessage(done); parentPort.close(); } else { done.push(order); } });}Опаковани: 8 от 8Всяка поръчка веднъж: даЕкспресни: 3 поръчки, 90 €Обикновени: 5 поръчки, 180 €Това също е производител и потребител, но с две разлики. Първо, граница няма: postMessage никога не чака, съобщенията се трупат в паметта на Worker-а и backpressure трябва да си направиш сам (Worker-ът отговаря „готов“ и чак тогава получава следващата). Второ, поръчките не се взимат от обща опашка, а главната нишка решава коя на кой Worker да отиде (тук поред). Поръчката се копира при пращането, затова race condition върху нея няма.
Упражнение
Два входа, три опаковчика
Реши задачата в редактора си.
Поръчките идват от два входа: сайта (2001–2006) и мобилното приложение (3001–3006). Всеки вход е отделен производител, а опаковчиците са трима. Опашката е с 4 места. Хапчетата (по едно за опаковчик) идват чак след като и двата входа свършат. Отпечатай:
Опаковани: 12 от 12Всяка поръчка веднъж: даОбщо: 325 €Още задачи
- Отказ. Направи опашката с 1 място и сайта с
tryPut. Брой отказаните поръчки и отпечатай „приети + отказани = 12“. - По вид. Експресните поръчки трябва да се опаковат първи. две опашки помага ли? Какво става с хапчетата?
- Верига. Добави втора опашка след опаковчиците: те слагат опакованите кашони в нея, а куриер ги взима. Колко хапчета трябват сега и кой ги слага?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Race condition · Заключване · Атомарни операции и неизменими обекти · Deadlock
- Продължава в: Пул от нишки и задачи · Събития и опашки
- Задачи по темата: Нишки и race conditions