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

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. запиши
Стъпка по стъпка, с думи
  1. Остават 5 бройки. Каса A и каса B продават по една в един и същ момент.
  2. Каса A прочита наличността: 5.
  3. time.sleep(0) пуска другата нишка. Каса B прочита, преди A да е записала: също 5.
  4. Каса A изважда 1 в своята променлива: 4.
  5. Каса B прави същото от своето 5: също 4.
  6. Каса A записва 4. Една продажба, остават 4: дотук е вярно.
  7. Каса B също записва 4, върху 4 на A. Две продажби, а наличността е 4, не 3.
  8. Една продажба е изгубена: двете прочетоха едно и също число, преди някоя да запише. Това е race condition, а складът показва бройка, която вече е продадена.
  1. Прочети наличността в своя променлива.
  2. Извади 1 от своето копие.
  3. Запиши копието обратно.

Две нишки могат да се редуват между всеки две стъпки. Ако и двете прочетат 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())
Изход
RESUME
LOAD_FAST self
COPY
LOAD_ATTR left
LOAD_CONST 1
BINARY_OP -=
SWAP
STORE_ATTR left
RETURN_CONST None

LOAD_ATTR left прочита, BINARY_OP -= изважда, STORE_ATTR left записва. Три отделни инструкции: между всеки две GIL може да даде ход на друга нишка.

Пробвай

Три каси, наличност 5

Ето три каси, разменени на ръка, в един от възможните редове. Всяка прочита в своя променлива, после записва с 1 по-малко:

left = 5
seen_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}")
Отговор

Идеята

„При мен работи“

Същата програма, но с по-малко продажби на каса:

В 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
Продадени според склада: 200000
Пет пъти вярно. GIL сменя нишките само на определени места в изпълнението, и тук случайно не е между прочитането и записването. Python не обещава това: друга версия, малко повече код в sell или 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 False
stock = 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() едновременно. Понякога две поръчки получават един и същ номер. След кой ред другата нишка трябва да влезе, за да стане това?

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

Идеята

Кога може да има race condition

Трябват три неща наведнъж:

  1. Общи данни: нещо, което виждат няколко нишки (наличността, броячът, номерът).
  2. Поне една нишка ги променя. Ако всички само четат, няма какво да се обърка.
  3. Нищо не подрежда достъпа: нишките влизат в стъпките си когато им дойде редът.

Махни едно от трите и 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 между проверката и записа. Колко гости получават стаята?

Тест

Провери се

1. Две нишки викат по веднъж left-- върху общо поле, което е 5. Какво може да е накрая?
2. Без time.sleep(0) self.left -= 1 с две нишки по 100 000 дава 200 000 пет пъти от пет. Безопасно ли е?
3. В склада има 1 бройка. Кодът проверява „left > 0“, таксува картата за 100 ms и чак тогава вади 1. Двама купуват наведнъж. Какво става?
4. Кое от тези НЕ може да има race condition?

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