Заключване
Накратко: Ключалката (lock, mutex) пуска само един Worker наведнъж в критичната секция; в JavaScript се прави с Atomics върху SharedArrayBuffer.
Проблем
Как да спреш другата каса
В урока „Race condition“ разпродажбата губеше продажби: две нишки правеха „прочети, извади, запиши“ наведнъж и едната презаписваше другата. Трите стъпки трябва да са едно цяло: докато една нишка е вътре, другата да чака отвън.
Точно това прави ключалката.
Идеята
Ключалката
Ключалката (mutex, от „mutual exclusion“, взаимно изключване) е обект с един собственик. Нишката я взима, прави своето и я пуска. Друга нишка, която я поиска междувременно, чака. Кодът между взимането и пускането е критичната секция: в нея е най-много една нишка.
JavaScript няма готова ключалка между Worker-и, затова я правиш отAtomics върху SharedArrayBuffer. shop[0] е ключалката: 0 е свободна, 1 е заета. Atomics.compareExchange прави „ако е 0, сложи 1“ като една неделима стъпка, затова само един Worker я взима. Другите спят в Atomics.wait, докато unlock не ги събуди:// state[0]: 0 е свободно, 1 е заето.function lock(state) { while (Atomics.compareExchange(state, 0, 0, 1) !== 0) { Atomics.wait(state, 0, 1); // спи, докато е заето }}
function unlock(state) { Atomics.store(state, 0, 0); Atomics.notify(state, 0, 1); // събуди една от чакащите}
// shop[0] е ключалката, shop[1] е наличността.function sell(shop) { lock(shop); shop[1]--; unlock(shop);}if (isMainThread) { const shop = new Int32Array(new SharedArrayBuffer(8)); shop[1] = 200_000; const checkouts = [1, 2].map( () => new Worker(new URL(import.meta.url), { workerData: shop }), ); await Promise.all(checkouts.map((worker) => once(worker, "exit"))); console.log(`Поръчки: ${2 * 100_000}`); console.log("Продадени според склада:"); console.log(200_000 - shop[1]);} else { for (let i = 0; i < 100_000; i++) { sell(workerData); }}Поръчки: 200000Продадени според склада:200000Точно, при всяко пускане. Ключалката е едно число в общата памет: двете каси заключват една и съща.
Виж го
Последната бройка, поправена
Проверката „има ли“ и вземането са в една и съща критична секция. Ако някой е вътре и проверява, другият не може да провери, преди първият да е взел:
// state[0]: 0 е свободно, 1 е заето.function lock(state) { while (Atomics.compareExchange(state, 0, 0, 1) !== 0) { Atomics.wait(state, 0, 1); // спи, докато е заето }}
function unlock(state) { Atomics.store(state, 0, 0); Atomics.notify(state, 0, 1); // събуди една от чакащите}
// shop[0] е ключалката, shop[1] е наличността.function reserve(shop) { lock(shop); try { if (shop[1] === 0) { return false; // няма: отказ } shop[1]--; // има: взимам бройката return true; } finally { unlock(shop); }}Стъпка по стъпка, с думи
- Остават 3 бройки, а 4 купувачи викат reserve() в един и същ момент.
- През lock(shop) минава само един, другите трима чакат пред него. Влиза купувач 1: вижда 3 и взима една. Остават 2.
- Купувач 1 излиза и пуска ключалката. Влиза купувач 2: вижда 2 и взима една. Остават 1.
- Купувач 2 излиза и пуска ключалката. Влиза купувач 3: вижда 1 и взима една. Остават 0.
- Купувач 4 влиза последен и вижда 0: отказ. Никой не видя число, което друг тъкмо сменя.
- 3 продадени, 1 отказ, наличност 0: вярно. Цената: купувачите се чакаха един друг пред турникета.
Четирима купувачи, три бройки, всички наведнъж:
if (isMainThread) { const shop = new Int32Array(new SharedArrayBuffer(8)); shop[1] = 3; const buyers = Array.from({ length: 4 }, () => new Worker(new URL(import.meta.url), { workerData: shop })); const results = await Promise.all( buyers.map(async (buyer) => (await once(buyer, "message"))[0]), ); console.log(`Продадени: ${results.filter((ok) => ok).length}`); console.log(`Отказани: ${results.filter((ok) => !ok).length}`); console.log(`Наличност: ${shop[1]}`);} else { parentPort.postMessage(reserve(workerData));}Продадени: 3Отказани: 1Наличност: 0А ето двамата купувачи от миналия урок, които купуваха последната бройка. Таксуването на картата (~100 ms) е след ключалката: бройката е запазена, а картата се таксува, без другият да чака:
if (isMainThread) { const shop = new Int32Array(new SharedArrayBuffer(8)); shop[1] = 1; const buyers = [1, 2].map( () => new Worker(new URL(import.meta.url), { workerData: shop }), ); const bought = await Promise.all( buyers.map(async (buyer) => (await once(buyer, "message"))[0]), ); const sold = bought.filter((ok) => ok).length; console.log("Наличност преди: 1"); console.log(`Продадени: ${sold}`); console.log(`Отказани: ${2 - sold}`); console.log(`Наличност: ${shop[1]}`);} else { const ok = reserve(workerData); if (ok) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); // таксуването на картата, ~100 ms } parentPort.postMessage(ok);}Наличност преди: 1Продадени: 1Отказани: 1Наличност: 0Един продаден, един отказан, наличност 0. При всяко пускане.
Пробвай
Не забравяй да пуснеш
lock и unlock викаш сам, затова пускането е във finally. Иначе изключение в критичната секция оставя ключалката заета завинаги:// state[0]: 0 е свободно, 1 е заето.function lock(state) { while (Atomics.compareExchange(state, 0, 0, 1) !== 0) { Atomics.wait(state, 0, 1); // спи, докато е заето }}
function unlock(state) { Atomics.store(state, 0, 0); Atomics.notify(state, 0, 1); // събуди една от чакащите}
function sell(shop) { lock(shop); try { shop[1]--; } finally { unlock(shop); }}Какво пуска ключалката?
function sell(shop) {
lock(shop);
try {
shop[1]--;
} finally {
}
}Без него ключалката остава взета, а следващата нишка, която я поиска, чака вечно. Затова е във finally: пуска и при изключение.
Пробвай
Ключалката, която не заключва
Колегата е добавил ключалка, но касите пак губят продажби. Кой ред е грешен?
Избери реда с грешката.
new SharedArrayBuffer(4) прави нова ключалка при всяко извикване, и то в паметта само на този Worker. Всеки взима своята, свободна ключалка и никой не чака никого. Ключалката е в общата памет, създадена веднъж: shop[0], както в lock(shop).Идеята
Колко струва ключалката
Ключалката работи, като кара нишките да се чакат. Колкото повече стои вътре една нишка, толкова повече чакат другите. Това е борбата за ключалката (contention).
Магазин с 10 бройки и четири поръчки наведнъж. Всяка поръчка вади бройка и праща имейл (~200 ms), а Network от урока „Защо нишки“ брои кръговете чакане:
// Пита услуга по мрежата: отговорът идва след ~200 ms.function call(net, service, answer) { if (Atomics.add(net, 0, 1) === 0) Atomics.add(net, 1, 1); // никой не чака: нов кръг Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); Atomics.sub(net, 0, 1); return `${service}: ${answer}`;}// shop[0] е ключалката, shop[1] е наличността.
// Лошо: имейлът тръгва, докато ключалката е взета.function orderSlow(shop, net, customer) { lock(shop); try { shop[1]--; call(net, "имейл", customer); } finally { unlock(shop); }}
// Добре: под ключалката е само наличността.function order(shop, net, customer) { lock(shop); try { shop[1]--; } finally { unlock(shop); } call(net, "имейл", customer);}orderSlow имейлът е под ключалката:if (isMainThread) { const shop = new Int32Array(new SharedArrayBuffer(8)); const net = new Int32Array(new SharedArrayBuffer(8)); shop[1] = 10; const orders = [1, 2, 3, 4].map((c) => new Worker(new URL(import.meta.url), { workerData: { shop, net, customer: `клиент ${c}` }, })); await Promise.all(orders.map((order) => once(order, "exit"))); console.log(`Поръчки: 4, наличност: ${shop[1]}`); console.log(`Кръгове чакане: ${net[1]}`);} else { orderSlow(workerData.shop, workerData.net, workerData.customer);}Поръчки: 4, наличност: 6Кръгове чакане: 4Четири кръга: имейлите тръгват един по един, а последният клиент чака около 800 ms. С order под ключалката е само наличността:
if (isMainThread) { const shop = new Int32Array(new SharedArrayBuffer(8)); const net = new Int32Array(new SharedArrayBuffer(8)); shop[1] = 10; const orders = [1, 2, 3, 4].map((c) => new Worker(new URL(import.meta.url), { workerData: { shop, net, customer: `клиент ${c}` }, })); await Promise.all(orders.map((order) => once(order, "exit"))); console.log(`Поръчки: 4, наличност: ${shop[1]}`); console.log(`Кръгове чакане: ${net[1]}`);} else { order(workerData.shop, workerData.net, workerData.customer);}Поръчки: 4, наличност: 6Кръгове чакане: 1Същата наличност, един кръг. Правилата:
- Под ключалката само общите данни. Пресмятай, форматирай и чакай отвън. Вътре само прочети и запиши.
- Никога чакане под ключалка: мрежа, диск, база данни,
sleep. Вземи каквото трябва, пусни ключалката и тогава чакай. - Една ключалка за едни данни. Наличността и оборотът са отделни: всяко със своя ключалка, за да не се чакат касите, които пипат само едното.
По-точно, за любопитните
- Тази ключалка е най-простата: не е честна (събуденият Worker може да изпусне реда си) и не е повторно влизаща. В истински код ползваш готова библиотека.
Atomics.waitне може да се вика в главната нишка на браузъра: тя не бива да спи. В Node може.- В една нишка с
asyncключалка трябва само околоawait. Това е урокът „async не е нишка“.
Ключалката не е единственият начин. За един брояч има атомарни операции, които изобщо не карат нишките да се чакат, а данни, които никой не променя, не искат ключалка: урокът „Атомарни операции и неизменими обекти“. А две ключалки, които две нишки взимат в различен ред, могат да спрат програмата завинаги: урокът „Deadlock“.
Упражнение
Оборотът и поръчката от много бройки
Реши задачите в редактора си.
Оборотът. Поправи задачата от миналия урок: две каси по 500 000 продажби по 3 €, а revenue[1] += 3 е между lock(revenue) и unlock(revenue), а ключалката е revenue[0]. Пусни програмата няколко пъти: оборотът трябва да е един и същ всеки път.
Каса 1: 50000 продажбиКаса 2: 50000 продажбиОборот (€):300000Поръчка от няколко бройки. В склада има 5 бройки. Напиши reserve(shop, count): ако има поне count, взима ги и връща истина, иначе не взима нищо и връща лъжа. Трима клиенти искат по 2 бройки наведнъж, всеки в своя Worker.
Поръчани: 3Приети: 2Отказани: 1Наличност: 1Още задачи
- Цената на ключалката. Пусни двете каси по 1 000 000 продажби с ключалка и без нея и засечи времето. Колко по-бавно е с ключалка? А с 8 каси?
- Две ключалки. Добави оборот към склада, със своя ключалка. Продажбата вади от наличността и добавя към оборота. Пази ли двете заедно, ако някой прочете наличност и оборот между двете ключалки?
- tryLock. Напиши
tryLock(state), който с единcompareExchangeвзима ключалката, ако е свободна, и връщаfalse, без да спи, ако е заета.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Стрелкови функции · Async, await и нишки · Защо нишки · Race condition
- Продължава в: Атомарни операции и неизменими обекти · Deadlock · Производител и потребител · async не е нишка
- Задачи по темата: Нишки и race conditions