Защо нишки
Накратко: Нишката е отделен ред на изпълнение в програмата; нишките ускоряват работа, която чака (мрежа, диск), а за работа, която смята, в 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, мрежата, файловете), затова нишките помагат на работа, която чака. Сметката го държи, затова нишките не я ускоряват. За нея трябват процеси.
Затова първият въпрос пред бавна програма е „какво чака тя?“. Отговорът определя какво помага.
Виж го
Трите наведнъж
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}")плащане: приетосклад: имаимейл: изпратенКръгове чакане: 1net = 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}")net = Network()payment = net.call("плащане", "прието")stock = net.call("склад", "има")email = net.call("имейл", "изпратен")print(payment)print(stock)print(email)print(f"Кръгове чакане: {net.rounds}")Стъпка по стъпка, с думи
- Поръчката иска три отговора по мрежата: плащане, склад и имейл за потвърждение. Всеки чака около 200 ms.
- Едно след друго: нишката пита за плащането и не прави нищо, докато отговорът дойде.
- Чак тогава пита за склада. Още 200 ms чакане.
- Чак тогава пита за имейла. Още 200 ms чакане.
- 3 кръга чакане: клиентът чака около 600 ms, а процесорът през цялото време стои.
- Наведнъж: трите въпроса тръгват заедно, всеки в своя нишка, и трите чакания се застъпват.
- Програмата чака отговорите. Докато дойде първият, другите два също са готови.
- 1 кръг чакане: около 200 ms, колкото най-бавният отговор. Същите отговори, същият ред, една трета от чакането.
Отговорите са същите и в същия ред. Кръгът чакане е един: трите въпроса тръгват, преди някой отговор да е дошъл. Клиентът чака около 200 ms, колкото най-бавната услуга, вместо 600.
Нишките тук почти не ползват процесора: три кратки изпълнения наcall и три заспивания. Затова това работи и на сървър с едно ядро.По-точно, за любопитните
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}")Плащането и складът тръгват заедно: това е първият кръг. Имейлът тръгва чак след двата отговора, когато нищо друго не се чака: втори кръг. Около 400 ms. Работа, която зависи от друга, не може да се застъпи с нея.
Пробвай
Чакай отговора
Какво чака отговора на плащането?
payment = pool.submit(net.call, "плащане", "прието")
print(payment.)result() чака задачата да свърши и връща резултата ѝ.Проблем
Хиляда фактури в края на месеца
В края на месеца магазинът прави фактура в 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на всяка десета фактура, сякаш чака диска. Кое помага сега повече: още нишки или още ядра?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изключения · Списъци: append, remove и sort · Ламбди · Нишки
- Продължава в: Race condition · Заключване · Атомарни операции и неизменими обекти · Производител и потребител
- Задачи по темата: Нишки и race conditions