Заключване
Накратко: Ключалката (lock, mutex) пуска само една нишка наведнъж в критичната секция; в Java това е synchronized или ReentrantLock, а под нея стои само малкото, което трябва.
Проблем
Как да спреш другата каса
В урока „Race condition“ разпродажбата губеше продажби: две нишки правеха „прочети, извади, запиши“ наведнъж и едната презаписваше другата. Трите стъпки трябва да са едно цяло: докато една нишка е вътре, другата да чака отвън.
Точно това прави ключалката.
Идеята
Ключалката
Ключалката (mutex, от „mutual exclusion“, взаимно изключване) е обект с един собственик. Нишката я взима, прави своето и я пуска. Друга нишка, която я поиска междувременно, чака. Кодът между взимането и пускането е критичната секция: в нея е най-много една нишка.
В Java всеки обект може да е ключалка.synchronized (lock) { … } я взима при влизане в блока и я пуска при излизане, дори с изключение:class Stock { private final Object lock = new Object(); private int left;
Stock(int left) { this.left = left; }
void sell() { synchronized (lock) { left--; } }
int left() { synchronized (lock) { return left; } }}Stock stock = new Stock(200_000);Runnable checkout = () -> { for (int i = 0; i < 100_000; i++) { stock.sell(); }};Thread a = new Thread(checkout);Thread b = new Thread(checkout);a.start();b.start();a.join();b.join();System.out.println("Поръчки: " + 2 * 100_000);System.out.println("Продадени според склада:");System.out.println(200_000 - stock.left());Поръчки: 200000Продадени според склада:200000Точно, при всяко пускане. Ключалката е поле, създадено веднъж: двете каси заключват една и съща.
Обектът ключалка е отделно частно поле, а неthis. Така никой код отвън не може да заключи същия обект и да спре касите ти.Виж го
Последната бройка, поправена
Проверката „има ли“ и вземането са в една и съща критична секция. Ако някой е вътре и проверява, другият не може да провери, преди първият да е взел:
class Stock { private final Object lock = new Object(); private int left;
Stock(int left) { this.left = left; }
boolean reserve() { synchronized (lock) { if (left == 0) { return false; // няма: отказ } left--; // има: взимам бройката return true; } }
int left() { synchronized (lock) { return left; } }}Стъпка по стъпка, с думи
- Остават 3 бройки, а 4 купувачи викат reserve() в един и същ момент.
- През synchronized минава само един, другите трима чакат пред него. Влиза купувач 1: вижда 3 и взима една. Остават 2.
- Купувач 1 излиза и пуска ключалката. Влиза купувач 2: вижда 2 и взима една. Остават 1.
- Купувач 2 излиза и пуска ключалката. Влиза купувач 3: вижда 1 и взима една. Остават 0.
- Купувач 4 влиза последен и вижда 0: отказ. Никой не видя число, което друг тъкмо сменя.
- 3 продадени, 1 отказ, наличност 0: вярно. Цената: купувачите се чакаха един друг пред турникета.
Четирима купувачи, три бройки, всички наведнъж:
Stock stock = new Stock(3);boolean[] ok = new boolean[4];List<Thread> buyers = new ArrayList<>();for (int b = 0; b < 4; b++) { int buyer = b; Thread t = new Thread(() -> ok[buyer] = stock.reserve()); buyers.add(t); t.start();}for (Thread t : buyers) { t.join();}int sold = 0;for (boolean o : ok) { if (o) sold++;}System.out.println("Продадени: " + sold);System.out.println("Отказани: " + (4 - sold));System.out.println("Наличност: " + stock.left());Продадени: 3Отказани: 1Наличност: 0А ето двамата купувачи от миналия урок, които купуваха последната бройка. Таксуването на картата (~100 ms) е след ключалката: бройката е запазена, а картата се таксува, без другият да чака:
Stock stock = new Stock(1);boolean[] bought = new boolean[2];Runnable[] buyers = new Runnable[2];for (int b = 0; b < 2; b++) { int buyer = b; buyers[b] = () -> { if (stock.reserve()) { pause(100); // таксуването на картата, ~100 ms bought[buyer] = true; } };}Thread first = new Thread(buyers[0]);Thread second = new Thread(buyers[1]);first.start();second.start();first.join();second.join();int sold = (bought[0] ? 1 : 0) + (bought[1] ? 1 : 0);System.out.println("Наличност преди: 1");System.out.println("Продадени: " + sold);System.out.println("Отказани: " + (2 - sold));System.out.println("Наличност: " + stock.left());Наличност преди: 1Продадени: 1Отказани: 1Наличност: 0Един продаден, един отказан, наличност 0. При всяко пускане.
Пробвай
Не забравяй да пуснеш
synchronized Java има и ключалка-обект, ReentrantLock: lock() я взима, unlock() я пуска. Пускането е във finally, за да стане и при изключение:class Stock { private final ReentrantLock lock = new ReentrantLock(); private int left;
Stock(int left) { this.left = left; }
void sell() { lock.lock(); try { left--; } finally { lock.unlock(); } }
int left() { lock.lock(); try { return left; } finally { lock.unlock(); } }}Какво пуска ключалката?
void sell() {
lock.lock();
try {
left--;
} finally {
}
}Без него ключалката остава взета, а следващата нишка, която я поиска, чака вечно. Затова обикновено пишеш synchronized: той пуска сам.
Пробвай
Ключалката, която не заключва
Колегата е добавил ключалка, но касите пак губят продажби. Кой ред е грешен?
Избери реда с грешката.
new Object() прави нова ключалка при всяко извикване. Всяка нишка взима своята, свободна ключалка и никой не чака никого. Ключалката е поле, създадено веднъж: private final Object lock = new Object();.Идеята
Колко струва ключалката
Ключалката работи, като кара нишките да се чакат. Колкото повече стои вътре една нишка, толкова повече чакат другите. Това е борбата за ключалката (contention).
Магазин с 10 бройки и четири поръчки наведнъж. Всяка поръчка вади бройка и праща имейл (~200 ms), а Network от урока „Защо нишки“ брои кръговете чакане:
class Network { private int inFlight = 0; private int rounds = 0;
// Пита услуга по мрежата: отговорът идва след ~200 ms. String call(String service, String answer) { synchronized (this) { if (inFlight == 0) rounds++; // никой не чака: нов кръг inFlight++; } try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } synchronized (this) { inFlight--; } return service + ": " + answer; }
synchronized int rounds() { return rounds; }}class Shop { private final Object lock = new Object(); private final Network net; private int left = 10;
Shop(Network net) { this.net = net; }
// Лошо: имейлът тръгва, докато ключалката е взета. void orderSlow(String customer) { synchronized (lock) { left--; net.call("имейл", customer); } }
// Добре: под ключалката е само наличността. void order(String customer) { synchronized (lock) { left--; } net.call("имейл", customer); }
int left() { synchronized (lock) { return left; } }}orderSlow имейлът е под ключалката:Network net = new Network();Shop shop = new Shop(net);List<Thread> orders = new ArrayList<>();for (int c = 1; c <= 4; c++) { String customer = "клиент " + c; Thread t = new Thread(() -> shop.orderSlow(customer)); orders.add(t); t.start();}for (Thread t : orders) { t.join();}System.out.println("Поръчки: 4, наличност: " + shop.left());System.out.println("Кръгове чакане: " + net.rounds());Поръчки: 4, наличност: 6Кръгове чакане: 4Четири кръга: имейлите тръгват един по един, а последният клиент чака около 800 ms. С order под ключалката е само наличността:
Network net = new Network();Shop shop = new Shop(net);List<Thread> orders = new ArrayList<>();for (int c = 1; c <= 4; c++) { String customer = "клиент " + c; Thread t = new Thread(() -> shop.order(customer)); orders.add(t); t.start();}for (Thread t : orders) { t.join();}System.out.println("Поръчки: 4, наличност: " + shop.left());System.out.println("Кръгове чакане: " + net.rounds());Поръчки: 4, наличност: 6Кръгове чакане: 1Същата наличност, един кръг. Правилата:
- Под ключалката само общите данни. Пресмятай, форматирай и чакай отвън. Вътре само прочети и запиши.
- Никога чакане под ключалка: мрежа, диск, база данни,
sleep. Вземи каквото трябва, пусни ключалката и тогава чакай. - Една ключалка за едни данни. Наличността и оборотът са отделни: всяко със своя ключалка, за да не се чакат касите, които пипат само едното.
По-точно, за любопитните
synchronizedе повторно влизаща (reentrant): нишка, която вече държи ключалката, може да влезе пак в блок със същата ключалка, без да чака себе си.ReentrantLockсъщо, оттам е и името.ReentrantLockможе повече отsynchronized:tryLock()(взимам, ако е свободна, иначе правя друго),tryLock(100, TimeUnit.MILLISECONDS)и честна опашка сnew ReentrantLock(true).- Без ключалка и
left()може да върне старо число: ключалката гарантира и видимостта, за която пишеше в миналия урок.
Ключалката не е единственият начин. За един брояч има атомарни операции, които изобщо не карат нишките да се чакат, а данни, които никой не променя, не искат ключалка: урокът „Атомарни операции и неизменими обекти“. А две ключалки, които две нишки взимат в различен ред, могат да спрат програмата завинаги: урокът „Deadlock“.
Упражнение
Оборотът и поръчката от много бройки
Реши задачите в редактора си.
Оборотът. Поправи задачата от миналия урок: две каси по 50 000 продажби по 3 €, а Revenue пази total с ключалка. Пусни програмата няколко пъти: оборотът трябва да е един и същ всеки път.
Каса 1: 50000 продажбиКаса 2: 50000 продажбиОборот (€):300000Поръчка от няколко бройки. В склада има 5 бройки. Напиши reserve(count): ако има поне count, взима ги и връща истина, иначе не взима нищо и връща лъжа. Трима клиенти искат по 2 бройки наведнъж, всеки в своя нишка.
Поръчани: 3Приети: 2Отказани: 1Наличност: 1Още задачи
- Цената на ключалката. Пусни двете каси по 1 000 000 продажби с ключалка и без нея и засечи времето. Колко по-бавно е с ключалка? А с 8 каси?
- Две ключалки. Добави оборот към склада, със своя ключалка. Продажбата вади от наличността и добавя към оборота. Пази ли двете заедно, ако някой прочете наличност и оборот между двете ключалки?
- tryLock. Направи продажба, която чака ключалката най-много 50 ms с
tryLock, а ако не успее, връща „опитай пак“.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Ламбди · Нишки · Защо нишки · Race condition
- Продължава в: Атомарни операции и неизменими обекти · Deadlock · Производител и потребител · async не е нишка
- Задачи по темата: Нишки и race conditions