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

Защо нишки

Накратко: Нишката е отделен ред на изпълнение в програмата; нишките ускоряват работа, която чака (мрежа, диск), а за работа, която смята, в Python трябват процеси заради GIL.

Проблем

Клиентът чака три пъти

Клиентът натиска „Поръчай“. Преди да види „Благодарим“, сървърът трябва да пита три услуги по мрежата: плащането (банката приема ли картата), склада (има ли го) и имейла (тръгна ли потвърждението). Всяка отговаря за около 200 ms.

Тук мрежата е Network. call чака 200 ms и връща отговора на услугата. Програмата пита трите една след друга:

net = Network()
payment = net.call("плащане", "прието")
stock = net.call("склад", "има")
email = net.call("имейл", "изпратен")
print(payment)
print(stock)
print(email)
print(f"Кръгове чакане: {net.rounds}")
Изход
плащане: прието
склад: има
имейл: изпратен
Кръгове чакане: 3

Всяко питане започва, след като предишното е свършило. Клиентът чака сбора: около 600 ms. А през цялото време процесорът не прави нищо: само чака отговор.

Network брои и кръговете чакане: нов кръг започва, когато програмата пита, а никой друг отговор не се чака. Тук са три, по един за всяко питане. В урока бързината се мери с този брой, а не с милисекунди: времето зависи от машината, а броят кръгове е един и същ при всяко пускане.

По-точно, за любопитните

Така работи Network. with self.lock е от урока „Нишки“: двата брояча се пипат от няколко нишки наведнъж. Защо без него се губят числа, е следващият урок, „Race condition“.

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}"

Идеята

Работа, която чака, и работа, която смята

Работата на една програма е от два вида.

  • Работа, която чака (I/O-bound): заявка към банката, заявка към базата, четене на файл, изпращане на имейл. Програмата пита и стои, докато отговорът дойде. Процесорът през това време е свободен.
  • Работа, която смята (CPU-bound): 1000 фактури в PDF, умалени снимки на продуктите, отчет от милион поръчки. Тук процесорът е зает през цялото време.
Нишка, която чака, не ползва процесора. Затова нишките помагат на работа, която чака, дори на едно ядро: първата нишка пита и заспива, втората пита и заспива, третата също, и трите чакания вървят заедно. Работа, която смята, иска ядро през цялото време. Тя става по-бърза само ако има повече ядра, по едно на нишка.

В Python това е още по-рязко заради GIL: в даден момент само една нишка изпълнява Python код. Чакането пуска GIL (time.sleep, мрежата, файловете), затова нишките помагат на работа, която чака. Сметката го държи, затова нишките не я ускоряват. За нея трябват процеси.

Затова първият въпрос пред бавна програма е „какво чака тя?“. Отговорът определя какво помага.

Виж го

Трите наведнъж

Трите питания стават задачи в басейн от 3 нишки (ThreadPoolExecutor от урока „Нишки“). submit дава Future веднага, а result() чака отговора:
net = Network()
with ThreadPoolExecutor(max_workers=3) as pool:
payment = pool.submit(net.call, "плащане", "прието")
stock = pool.submit(net.call, "склад", "има")
email = pool.submit(net.call, "имейл", "изпратен")
print(payment.result())
print(stock.result())
print(email.result())
print(f"Кръгове чакане: {net.rounds}")
Изход
плащане: прието
склад: има
имейл: изпратен
Кръгове чакане: 1

net = Network()
with ThreadPoolExecutor(max_workers=3) as pool:
payment = pool.submit(net.call, "плащане", "прието")
stock = pool.submit(net.call, "склад", "има")
email = pool.submit(net.call, "имейл", "изпратен")
print(payment.result())
print(stock.result())
print(email.result())
print(f"Кръгове чакане: {net.rounds}")
Стъпка по стъпка, с думи
  1. Поръчката иска три отговора по мрежата: плащане, склад и имейл за потвърждение. Всеки чака около 200 ms.
  2. Едно след друго: нишката пита за плащането и не прави нищо, докато отговорът дойде.
  3. Чак тогава пита за склада. Още 200 ms чакане.
  4. Чак тогава пита за имейла. Още 200 ms чакане.
  5. 3 кръга чакане: клиентът чака около 600 ms, а процесорът през цялото време стои.
  6. Наведнъж: трите въпроса тръгват заедно, всеки в своя нишка, и трите чакания се застъпват.
  7. Програмата чака отговорите. Докато дойде първият, другите два също са готови.
  8. 1 кръг чакане: около 200 ms, колкото най-бавният отговор. Същите отговори, същият ред, една трета от чакането.

Отговорите са същите и в същия ред. Кръгът чакане е един: трите въпроса тръгват, преди някой отговор да е дошъл. Клиентът чака около 200 ms, колкото най-бавната услуга, вместо 600.

Нишките тук почти не ползват процесора: три кратки изпълнения на call и три заспивания. Затова това работи и на сървър с едно ядро.
По-точно, за любопитните
За много питания наведнъж Python има и asyncio: една нишка, която редува хиляди чакания, без нишка за всяко. Това е урокът „async не е нишка“.

Пробвай

Имейлът чака плащането

Колега преправя поръчката: имейлът трябва да казва дали е платено и дали го има, затова тръгва чак когато плащането и складът са отговорили.

net = Network()
with ThreadPoolExecutor(max_workers=3) as pool:
payment = pool.submit(net.call, "плащане", "прието")
stock = pool.submit(net.call, "склад", "има")
# имейлът казва дали е платено и дали го има
print(payment.result())
print(stock.result())
email = pool.submit(net.call, "имейл", "изпратен")
print(email.result())
print(f"Кръгове чакане: {net.rounds}")
Отговор

Пробвай

Чакай отговора

Какво чака отговора на плащането?

payment = pool.submit(net.call, "плащане", "прието")
print(payment.)

Проблем

Хиляда фактури в края на месеца

В края на месеца магазинът прави фактура в PDF за всяка от 1000 поръчки. Тук нищо не чака мрежата: процесорът смята през цялото време. render замества рисуването на PDF със сметка, която само товари процесора и връща контролна сума:

def render(invoice):
"""Рисува фактурата (тук: сметка, която само товари процесора)."""
h = invoice
for i in range(2000):
h = (h * 31 + i) % 1_000_003
return h
def render_range(bounds):
"""Фактурите от start до end включително."""
start, end = bounds
return sum(render(n) for n in range(start, end + 1))
total = render_range((1, 1000))
print("Фактури: 1000")
print(f"Контролна сума: {total}")
Изход
Фактури: 1000
Контролна сума: 499409728

Едно ядро смята всичко, а останалите стоят. Кръгове чакане тук няма: нищо не чака.

Виж го

Четири процеса, четири ядра

Първо с нишки. Резултатът е верен, но заради GIL четирите нишки смятат една по една и не стават по-бързи:

bounds = [(p * 250 + 1, p * 250 + 250) for p in range(4)]
with ThreadPoolExecutor(max_workers=4) as pool:
total = sum(pool.map(render_range, bounds))
print("Фактури: 1000")
print(f"Контролна сума: {total}")
Изход
Фактури: 1000
Контролна сума: 499409728

Със ProcessPoolExecutor всяка част отива в отделен процес: отделно копие на Python със свой GIL, на свое ядро. pool.map дава резултатите в реда на частите. if __name__ == "__main__": пази главния код да не се изпълни наново във всеки процес:

if __name__ == "__main__":
bounds = [(p * 250 + 1, p * 250 + 250) for p in range(4)]
with ProcessPoolExecutor(max_workers=4) as pool:
total = sum(pool.map(render_range, bounds))
print("Фактури: 1000")
print(f"Контролна сума: {total}")
Изход
Фактури: 1000
Контролна сума: 499409728

Контролната сума е същата: разделянето не сменя резултата, само кой го смята.

Колко по-бързо е, зависи от машината. На моя лаптоп с 25 пъти по-тежка сметка на фактура (50 000 стъпки вместо 2000) една нишка смяташе около 1,2 s, четири нишки пак около 1,2 s, а четири процеса около 0,4 s.

Това са примерни времена, не обещание. Повече части от ядрата не помагат: те само се редуват на същите ядра.

По-точно, за любопитните
  • Колко нишки. За работа, която смята, колкото ядрата: os.cpu_count() казва колко са. За работа, която чака, може много повече: те само спят.
  • Не всичко се дели. Ако една десета от работата не може да се раздели (например събирането на частите или записът на един файл), никакъв брой ядра не я прави повече от 10 пъти по-бърза. Това е законът на Amdahl.
  • Python 3.13 има и версия без GIL (free-threaded). В нея нишките смятат паралелно, но тя още е експериментална.
  • Процесите не делят памет: частите им се подават, а резултатът се връща, като се копира. Затова на процес се дава голямо парче работа, а не по една фактура.

Идеята

Кое помага

Работа, която чака Работа, която смята
Какво я бави отговорът по мрежата, диска, базата самото смятане
Помага да чакаш няколко неща наведнъж повече ядра
В Python нишки, asyncio процеси: ProcessPoolExecutor
На едно ядро помага не помага

Нишките идват с цена. Докато всяка работи със своите данни (своята услуга, своите 250 фактури), всичко е наред. Щом две нишки пипат едно и също (общ брояч, наличността в склада), започват грешки, които не се виждат при всяко пускане. Това е следващият урок: „Race condition“.

Упражнение

Страницата на продукта

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

Страницата на продукта. Преди да покаже страницата, сървърът пита три услуги: цената, отзивите и доставката. Всяка отговаря за около 200 ms и не зависи от другите. Ползвай Network от първата карта и ги питай наведнъж, така че кръгът чакане да е един:

Изход
цена: 89 €
отзиви: 4,6 от 5
доставка: утре
Кръгове чакане: 1

Още задачи

  • Пет услуги. Добави наличност и отстъпка. Колко са кръговете, ако отстъпката зависи от цената?
  • Колко ядра. Пусни фактурите с 1, 2, 4 и 8 части и мери времето при теб. Кога спира да става по-бързо? Сравни с броя ядра на машината си. - Грешният инструмент. Сложи pause/sleep от 200 ms в render на всяка десета фактура, сякаш чака диска. Кое помага сега повече: още нишки или още ядра?

Тест

Провери се

1. Поръчката чака три услуги по 200 ms, които не зависят една от друга. Колко чака клиентът, ако ги питаш наведнъж?
2. Сървърът има едно ядро. Помагат ли нишките на работа, която чака мрежата?
3. Трябва да сметнеш 1000 фактури в Python на 4 ядра. Какво ги ускорява?
4. Защо урокът брои кръговете чакане, а не милисекундите?

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