Към съдържанието
wannacode

Заключване

Накратко: Ключалката (lock, mutex) пуска само една нишка наведнъж в критичната секция; в Python това е threading.Lock с with, а под нея стои само малкото, което трябва.

Проблем

Как да спреш другата каса

В урока „Race condition“ разпродажбата губеше продажби: две нишки правеха „прочети, извади, запиши“ наведнъж и едната презаписваше другата. Трите стъпки трябва да са едно цяло: докато една нишка е вътре, другата да чака отвън.

Точно това прави ключалката.

Идеята

Ключалката

Ключалката (mutex, от „mutual exclusion“, взаимно изключване) е обект с един собственик. Нишката я взима, прави своето и я пуска. Друга нишка, която я поиска междувременно, чака. Кодът между взимането и пускането е критичната секция: в нея е най-много една нишка.

В Python ключалката е threading.Lock(). with self.lock: я взима при влизане в блока и я пуска при излизане, дори с изключение. time.sleep(0) е на мястото си: другата нишка пак може да дойде точно тук, но чака пред ключалката.
class Stock:
def __init__(self, left):
self.left = left
self.lock = threading.Lock()
def sell(self):
with self.lock:
seen = self.left
time.sleep(0) # другата нишка може да влезе тук, но чака пред ключалката
self.left = seen - 1
Двете каси от миналия урок, със същите по 10 000 продажби:
stock = Stock(20_000)
def checkout():
for _ in range(10_000):
stock.sell()
a = threading.Thread(target=checkout)
b = threading.Thread(target=checkout)
a.start()
b.start()
a.join()
b.join()
print(f"Поръчки: {2 * 10_000}")
print("Продадени според склада:")
print(20_000 - stock.left)
Изход
Поръчки: 20000
Продадени според склада:
20000

Точно, при всяко пускане. Ключалката е поле, създадено веднъж: двете каси заключват една и съща.

Виж го

Последната бройка, поправена

Проверката „има ли“ и вземането са в една и съща критична секция. Ако някой е вътре и проверява, другият не може да провери, преди първият да е взел:

class Stock:
def __init__(self, left):
self.left = left
self.lock = threading.Lock()
def reserve(self):
with self.lock:
if self.left == 0:
return False # няма: отказ
self.left -= 1 # има: взимам бройката
return True
Стъпка по стъпка, с думи
  1. Остават 3 бройки, а 4 купувачи викат reserve() в един и същ момент.
  2. През with self.lock минава само един, другите трима чакат пред него. Влиза купувач 1: вижда 3 и взима една. Остават 2.
  3. Купувач 1 излиза и пуска ключалката. Влиза купувач 2: вижда 2 и взима една. Остават 1.
  4. Купувач 2 излиза и пуска ключалката. Влиза купувач 3: вижда 1 и взима една. Остават 0.
  5. Купувач 4 влиза последен и вижда 0: отказ. Никой не видя число, което друг тъкмо сменя.
  6. 3 продадени, 1 отказ, наличност 0: вярно. Цената: купувачите се чакаха един друг пред турникета.

Четирима купувачи, три бройки, всички наведнъж:

stock = Stock(3)
results = []
def buyer():
results.append(stock.reserve())
buyers = [threading.Thread(target=buyer) for _ in range(4)]
for t in buyers:
t.start()
for t in buyers:
t.join()
print(f"Продадени: {results.count(True)}")
print(f"Отказани: {results.count(False)}")
print(f"Наличност: {stock.left}")
Изход
Продадени: 3
Отказани: 1
Наличност: 0

А ето двамата купувачи от миналия урок, които купуваха последната бройка. Таксуването на картата (~100 ms) е след ключалката: бройката е запазена, а картата се таксува, без другият да чака:

stock = Stock(1)
bought = []
def buyer():
if stock.reserve():
time.sleep(0.1) # таксуването на картата, ~100 ms
bought.append(True)
buyers = [threading.Thread(target=buyer) for _ in range(2)]
for t in buyers:
t.start()
for t in buyers:
t.join()
print("Наличност преди: 1")
print(f"Продадени: {len(bought)}")
print(f"Отказани: {2 - len(bought)}")
print(f"Наличност: {stock.left}")
Изход
Наличност преди: 1
Продадени: 1
Отказани: 1
Наличност: 0

Един продаден, един отказан, наличност 0. При всяко пускане.

Пробвай

Не забравяй да пуснеш

with self.lock: е съкратено за acquire() и release(). Пускането е във finally, за да стане и при изключение:
class Stock:
def __init__(self, left):
self.left = left
self.lock = threading.Lock()
def sell(self):
self.lock.acquire()
try:
seen = self.left
time.sleep(0)
self.left = seen - 1
finally:
self.lock.release()

Какво пуска ключалката?

def sell(self):
    self.lock.acquire()
    try:
        seen = self.left
        time.sleep(0)
        self.left = seen - 1
    finally:
        

Пробвай

Ключалката, която не заключва

Колегата е добавил ключалка, но касите пак губят продажби. Кой ред е грешен?

Избери реда с грешката.

Идеята

Колко струва ключалката

Ключалката работи, като кара нишките да се чакат. Колкото повече стои вътре една нишка, толкова повече чакат другите. Това е борбата за ключалката (contention).

Магазин с 10 бройки и четири поръчки наведнъж. Всяка поръчка вади бройка и праща имейл (~200 ms), а Network от урока „Защо нишки“ брои кръговете чакане:

class Network:
def __init__(self):
self.lock = threading.Lock()
self.in_flight = 0
self.rounds = 0
def call(self, service, answer):
"""Пита услуга по мрежата: отговорът идва след ~200 ms."""
with self.lock:
if self.in_flight == 0:
self.rounds += 1 # никой не чака: нов кръг
self.in_flight += 1
time.sleep(0.2)
with self.lock:
self.in_flight -= 1
return f"{service}: {answer}"
class Shop:
def __init__(self, net):
self.net = net
self.left = 10
self.lock = threading.Lock()
def order_slow(self, customer):
"""Лошо: имейлът тръгва, докато ключалката е взета."""
with self.lock:
self.left -= 1
self.net.call("имейл", customer)
def order(self, customer):
"""Добре: под ключалката е само наличността."""
with self.lock:
self.left -= 1
self.net.call("имейл", customer)
С order_slow имейлът е под ключалката:
net = Network()
shop = Shop(net)
orders = [
threading.Thread(target=shop.order_slow, args=(f"клиент {c}",))
for c in range(1, 5)
]
for t in orders:
t.start()
for t in orders:
t.join()
print(f"Поръчки: 4, наличност: {shop.left}")
print(f"Кръгове чакане: {net.rounds}")
Изход
Поръчки: 4, наличност: 6
Кръгове чакане: 4

Четири кръга: имейлите тръгват един по един, а последният клиент чака около 800 ms. С order под ключалката е само наличността:

net = Network()
shop = Shop(net)
orders = [
threading.Thread(target=shop.order, args=(f"клиент {c}",))
for c in range(1, 5)
]
for t in orders:
t.start()
for t in orders:
t.join()
print(f"Поръчки: 4, наличност: {shop.left}")
print(f"Кръгове чакане: {net.rounds}")
Изход
Поръчки: 4, наличност: 6
Кръгове чакане: 1

Същата наличност, един кръг. Правилата:

  • Под ключалката само общите данни. Пресмятай, форматирай и чакай отвън. Вътре само прочети и запиши.
  • Никога чакане под ключалка: мрежа, диск, база данни, sleep. Вземи каквото трябва, пусни ключалката и тогава чакай.
  • Една ключалка за едни данни. Наличността и оборотът са отделни: всяко със своя ключалка, за да не се чакат касите, които пипат само едното.
По-точно, за любопитните
  • threading.Lock не е повторно влизаща: ако нишка, която я държи, я поиска пак, чака себе си вечно. threading.RLock е повторно влизащата.
  • self.lock.acquire(timeout=0.1) взима ключалката само ако успее за 100 ms и връща True или False.
  • В asyncio ключалката е asyncio.Lock с async with: това е урокът „async не е нишка“.

Ключалката не е единственият начин. За един брояч има атомарни операции, които изобщо не карат нишките да се чакат, а данни, които никой не променя, не искат ключалка: урокът „Атомарни операции и неизменими обекти“. А две ключалки, които две нишки взимат в различен ред, могат да спрат програмата завинаги: урокът „Deadlock“.

Упражнение

Оборотът и поръчката от много бройки

Реши задачите в редактора си.

Оборотът. Поправи задачата от миналия урок: две каси по 10 000 продажби по 3 €, а Revenue пази total с ключалка. Пусни програмата няколко пъти: оборотът трябва да е един и същ всеки път.

Очакваш
Каса 1: 10000 продажби
Каса 2: 10000 продажби
Оборот (€):
60000

Поръчка от няколко бройки. В склада има 5 бройки. Напиши reserve(count): ако има поне count, взима ги и връща истина, иначе не взима нищо и връща лъжа. Трима клиенти искат по 2 бройки наведнъж, всеки в своя нишка.

Очакваш
Поръчани: 3
Приети: 2
Отказани: 1
Наличност: 1

Още задачи

  • Цената на ключалката. Пусни двете каси по 1 000 000 продажби с ключалка и без нея и засечи времето. Колко по-бавно е с ключалка? А с 8 каси?
  • Две ключалки. Добави оборот към склада, със своя ключалка. Продажбата вади от наличността и добавя към оборота. Пази ли двете заедно, ако някой прочете наличност и оборот между двете ключалки? - Timeout. Направи продажба, която чака ключалката най-много 50 ms с acquire(timeout=0.05), а ако не успее, връща „опитай пак“.

Тест

Провери се

1. Две нишки викат sell(), който прави left-- под една и съща ключалка. Какво става, когато втората стигне до ключалката, докато първата е вътре?
2. Проверката „има ли бройки“ е под ключалката, а изваждането е в отделен блок под същата ключалка. Двама купуват последната бройка. Какво става?
3. Под ключалката е изваждането от наличността и изпращането на имейл (~200 ms). Четири поръчки идват наведнъж. Колко чака последната?
4. Какво е грешно в synchronized (new Object()) { left--; }? (C#: lock (new object()), Python: with threading.Lock())

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·