Заключване
Накратко: Ключалката (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 - 1stock = 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Стъпка по стъпка, с думи
- Остават 3 бройки, а 4 купувачи викат reserve() в един и същ момент.
- През with self.lock минава само един, другите трима чакат пред него. Влиза купувач 1: вижда 3 и взима една. Остават 2.
- Купувач 1 излиза и пуска ключалката. Влиза купувач 2: вижда 2 и взима една. Остават 1.
- Купувач 2 излиза и пуска ключалката. Влиза купувач 3: вижда 1 и взима една. Остават 0.
- Купувач 4 влиза последен и вижда 0: отказ. Никой не видя число, което друг тъкмо сменя.
- 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:
Без него ключалката остава взета, а следващата нишка, която я поиска, чака вечно. Затова обикновено пишеш with: той пуска сам.
Пробвай
Ключалката, която не заключва
Колегата е добавил ключалка, но касите пак губят продажби. Кой ред е грешен?
Избери реда с грешката.
threading.Lock() прави нова ключалка при всяко извикване. Всяка нишка взима своята, свободна ключалка и никой не чака никого. Ключалката е поле, създадено веднъж в __init__: self.lock = threading.Lock().Идеята
Колко струва ключалката
Ключалката работи, като кара нишките да се чакат. Колкото повече стои вътре една нишка, толкова повече чакат другите. Това е борбата за ключалката (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), а ако не успее, връща „опитай пак“.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Ламбди · Нишки · Защо нишки · Race condition
- Продължава в: Атомарни операции и неизменими обекти · Deadlock · Производител и потребител · async не е нишка
- Задачи по темата: Нишки и race conditions