Race condition
Накратко: Race condition е бъг, при който резултатът зависи от това как се преплитат нишките: left -= 1 е три стъпки дори с GIL, затова две нишки могат да загубят продажба.
Проблем
Разпродажбата брои криво
Петък, разпродажба. Слушалките са на половин цена и поръчките валят. Две каси продават от един склад: всяка продажба вади 1 от наличността.
Касите са две нишки. Всяка продава по 10 000 бройки и викаsell() за всяка. sell прочита наличността, извиква time.sleep(0) (пусни другите нишки за момент) и чак тогава записва. Защо е нужно time.sleep(0), е по-долу:class Stock: def __init__(self, left): self.left = left
def sell(self): seen = self.left # 1. прочети time.sleep(0) # тук другата нишка влиза after = seen - 1 # 2. извади 1 self.left = after # 3. запиши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)Клиентите са платили 20 000 поръчки. Ето колко продадени брои складът в пет пускания:
Продадени според склада: 10000Продадени според склада: 10000Продадени според склада: 10000Продадени според склада: 10000Продадени според склада: 10000Идеята
self.left -= 1 е три стъпки
self.left -= 1 е един ред, но Python го прави на три стъпки. В sell те са разписани, а time.sleep(0) стои точно между прочитането и записването:class Stock: def __init__(self, left): self.left = left
def sell(self): seen = self.left # 1. прочети time.sleep(0) # тук другата нишка влиза after = seen - 1 # 2. извади 1 self.left = after # 3. запишиСтъпка по стъпка, с думи
- Остават 5 бройки. Каса A и каса B продават по една в един и същ момент.
- Каса A прочита наличността: 5.
- time.sleep(0) пуска другата нишка. Каса B прочита, преди A да е записала: също 5.
- Каса A изважда 1 в своята променлива: 4.
- Каса B прави същото от своето 5: също 4.
- Каса A записва 4. Една продажба, остават 4: дотук е вярно.
- Каса B също записва 4, върху 4 на A. Две продажби, а наличността е 4, не 3.
- Една продажба е изгубена: двете прочетоха едно и също число, преди някоя да запише. Това е race condition, а складът показва бройка, която вече е продадена.
- Прочети наличността в своя променлива.
- Извади 1 от своето копие.
- Запиши копието обратно.
Две нишки могат да се редуват между всеки две стъпки. Ако и двете прочетат 5, преди някоя да запише, и двете записват 4. Две продажби, а наличността намалява с една.
Когато резултатът зависи от това коя нишка коя стъпка прави първа, това е race condition (състезание). Трите стъпки „прочети, промени, запиши“ върху общи данни са най-честата форма.
GIL не спасява. Той пуска само една нишка да изпълнява Python код наведнъж, но може да я смени между двете стъпки. time.sleep(0) кара смяната да стане точно там. Без него тя пак е възможна, само по-рядко (виж по-долу).
По-точно, за любопитните
Модулът dis показва инструкциите, на които Python изпълнява self.left -= 1. Това е Python 3.12, на който се проверява кодът:
import dis
class Stock: def __init__(self, left): self.left = left
def sell(self): self.left -= 1 # прочети, извади 1, запиши
for step in dis.get_instructions(Stock.sell): print(f"{step.opname} {step.argrepr}".strip())RESUMELOAD_FAST selfCOPYLOAD_ATTR leftLOAD_CONST 1BINARY_OP -=SWAPSTORE_ATTR leftRETURN_CONST NoneLOAD_ATTR left прочита, BINARY_OP -= изважда, STORE_ATTR left записва. Три отделни инструкции: между всеки две GIL може да даде ход на друга нишка.
Пробвай
Три каси, наличност 5
Ето три каси, разменени на ръка, в един от възможните редове. Всяка прочита в своя променлива, после записва с 1 по-малко:
left = 5seen_a = left # A прочитаseen_b = left # B прочитаleft = seen_a - 1 # A записваseen_c = left # C прочитаleft = seen_b - 1 # B записваleft = seen_c - 1 # C записваprint("Продажби: 3")print(f"Наличност: {left}")A и B прочитат 5. A записва 4. C прочита вече записаното 4. B записва 4 (своето 5 минус 1), а C записва 3. Три продажби, а наличността е 3 вместо 2: само продажбата на B се е изгубила, защото B прочете, преди A да запише.
Идеята
„При мен работи“
Същата програма, но с по-малко продажби на каса:
В Python пробвай без time.sleep(0): sell е само self.left -= 1, а всяка каса продава по 100 000:
class Stock: def __init__(self, left): self.left = left
def sell(self): self.left -= 1 # прочети, извади 1, запишиstock = Stock(200_000)
def checkout(): for _ in range(100_000): stock.sell()
a = threading.Thread(target=checkout)b = threading.Thread(target=checkout)a.start()b.start()a.join()b.join()print(f"Поръчки: {2 * 100_000}")print("Продадени според склада:")print(200_000 - stock.left)Продадени според склада: 200000Продадени според склада: 200000Продадени според склада: 200000Продадени според склада: 200000Продадени според склада: 200000sell или Python без GIL (3.13) губят продажби.Затова race condition е коварен:
- тестът минава на лаптопа и пада на сървъра с 64 ядра и хиляди клиенти;
- грешката идва „понякога“ и не се повтаря, когато я търсиш;
- добавиш ли ред за отпечатване, редът на нишките се сменя и грешката изчезва.
Липсата на загуба в тест не доказва нищо. Безопасността се доказва с това как е написан кодът: кои данни са общи и кой ги пази.
Проблем
Последната бройка
Една бройка е останала. Двама купувачи натискат „Купи“ в един и същ момент. buy проверява дали има, таксува картата (около 100 ms) и чак тогава взима бройката:
class Stock: def __init__(self, left): self.left = left
def buy(self): if self.left > 0: # проверка: има ли? time.sleep(0.1) # картата се таксува ~100 ms self.left -= 1 # действие: взимам бройката return True return Falsestock = Stock(1)bought = []
def buyer(): bought.append(stock.buy())
first = threading.Thread(target=buyer)second = threading.Thread(target=buyer)first.start()second.start()first.join()second.join()print("Наличност преди: 1")print("Продадени:")print(bought.count(True))print("Наличност след:")print(stock.left)Продадени: 2, наличност след: -1Продадени: 2, наличност след: -1Продадени: 2, наличност след: -1Продадени: 2, наличност след: -1Продадени: 2, наличност след: -1И двамата минават проверката: докато първият плаща, вторият вижда същата 1. Двамата платиха, а складът е на -1. Тук race condition се случва всеки път, защото между проверката и действието има цели 100 ms.
Това е втората форма на race condition: проверка, после действие (check-then-act). Решението се взима по стойност, която друг може да смени, преди решението да се изпълни. Тя е навсякъде: „има ли свободна стая“, „свободно ли е потребителското име“, „има ли пари в сметката“.
Пробвай
Номерата на поръчките
Всяка поръчка получава номер: 1001, 1002, 1003… Касите са нишки и викат take() едновременно. Понякога две поръчки получават един и същ номер. След кой ред другата нишка трябва да влезе, за да стане това?
Избери реда с грешката.
След прочитането. Ако двете нишки прочетат 1001, преди някоя да запише 1002, и двете връщат 1001. Две поръчки с един номер: едната ще презапише другата в базата или ще стигне до грешен клиент. Това е същото „прочети, промени, запиши“.
Идеята
Кога може да има race condition
Трябват три неща наведнъж:
- Общи данни: нещо, което виждат няколко нишки (наличността, броячът, номерът).
- Поне една нишка ги променя. Ако всички само четат, няма какво да се обърка.
- Нищо не подрежда достъпа: нишките влизат в стъпките си когато им дойде редът.
Махни едно от трите и race condition няма:
- Не дели. Всяка нишка брои в своя променлива, а накрая резултатите се събират. Така ставаше с 1000-те фактури в „Защо нишки“: всяка част смята своето.
- Не променяй. Обект, който никой не променя, може да се чете от колкото искаш нишки. Това е урокът „Атомарни операции и неизменими обекти“.
- Подреди достъпа. Само една нишка наведнъж в трите стъпки. Това е ключалката, следващият урок: „Заключване“.
По-точно, за любопитните
Има и по-тиха беда: видимостта. В Python с GIL всяка нишка вижда последната записана стойност, затова тази беда тук почти не се среща. В Python без GIL правилата са като в Java. Ключалката решава и това: каквото една нишка е записала, преди да я пусне, следващата го вижда, след като я вземе.
Как е в другите езици: в Java и C# count++ губи както тук. В Python губи по-рядко заради GIL, но губи. В JavaScript губи между Worker-и върху SharedArrayBuffer, а в една нишка race condition има само между два await.
Упражнение
Оборотът на разпродажбата
Реши задачата в редактора си.
Оборотът. Две каси продават по 10 000 слушалки по 3 €. Всяка каса брои своите продажби в своя променлива, а оборотът е общ: обект Revenue с метод add(euros), който прави total += euros. В add прочети, извикай time.sleep(0) и чак тогава запиши. Отпечатай продажбите на всяка каса и оборота. Пусни програмата няколко пъти.
Каса 1: 10000 продажбиКаса 2: 10000 продажбиОборот (€):60000Продажбите на касите са верни при всяко пускане: всяка брои в своята променлива. Оборотът е по-малък и различен всеки път. Поправката е в следващия урок.
Още задачи
- Колко се губи. Пусни продажбата с 2, 4 и 8 каси. Губи ли се повече с повече каси?
- Намери общото. Вземи програма с нишки, която си писал, и изброй данните, които повече от една нишка пипа. Кои от тях някоя нишка променя? - Свободна стая. Напиши хотел с метод „резервирай стая 12, ако е свободна“ и пусни двама гости наведнъж, с пауза от 100 ms между проверката и записа. Колко гости получават стаята?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Списъци: append, remove и sort · Ламбди · Нишки · Защо нишки
- Продължава в: Заключване · Атомарни операции и неизменими обекти · Deadlock · Производител и потребител
- Задачи по темата: Нишки и race conditions