Защо нишки
Накратко: Нишката е отделен ред на изпълнение в програмата; нишките ускоряват работа, която чака (мрежа, диск), дори на едно ядро, а работа, която смята, иска повече ядра.
Проблем
Клиентът чака три пъти
Клиентът натиска „Поръчай“. Преди да види „Благодарим“, сървърът трябва да пита три услуги по мрежата: плащането (банката приема ли картата), склада (има ли го) и имейла (тръгна ли потвърждението). Всяка отговаря за около 200 ms.
Тук мрежата е Network. call чака 200 ms и връща отговора на услугата. Програмата пита трите една след друга:
Network net = new Network();String payment = net.call("плащане", "прието");String stock = net.call("склад", "има");String email = net.call("имейл", "изпратен");System.out.println(payment);System.out.println(stock);System.out.println(email);System.out.println("Кръгове чакане: " + net.rounds());плащане: приетосклад: имаимейл: изпратенКръгове чакане: 3Всяко питане започва, след като предишното е свършило. Клиентът чака сбора: около 600 ms. А през цялото време процесорът не прави нищо: само чака отговор.
Network брои и кръговете чакане: нов кръг започва, когато програмата пита, а никой друг отговор не се чака. Тук са три, по един за всяко питане. В урока бързината се мери с този брой, а не с милисекунди: времето зависи от машината, а броят кръгове е един и същ при всяко пускане.
По-точно, за любопитните
Така работи Network. synchronized е от урока „Нишки“: двата брояча се пипат от няколко нишки наведнъж. Защо без него се губят числа, е следващият урок, „Race condition“.
class Network { private int inFlight = 0; private int rounds = 0;
// Пита услуга по мрежата: отговорът идва след ~200 ms. String call(String service, String answer) { synchronized (this) { if (inFlight == 0) rounds++; // никой не чака: нов кръг inFlight++; } pause(200); synchronized (this) { inFlight--; } return service + ": " + answer; }
synchronized int rounds() { return rounds; }
private static void pause(int ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }}Идеята
Работа, която чака, и работа, която смята
Работата на една програма е от два вида.
- Работа, която чака (I/O-bound): заявка към банката, заявка към базата, четене на файл, изпращане на имейл. Програмата пита и стои, докато отговорът дойде. Процесорът през това време е свободен.
- Работа, която смята (CPU-bound): 1000 фактури в PDF, умалени снимки на продуктите, отчет от милион поръчки. Тук процесорът е зает през цялото време.
Затова първият въпрос пред бавна програма е „какво чака тя?“. Отговорът определя какво помага.
Виж го
Трите наведнъж
ExecutorService от урока „Нишки“). submit дава Future веднага, а get() чака отговора:Network net = new Network();ExecutorService pool = Executors.newFixedThreadPool(3);Future<String> payment = pool.submit(() -> net.call("плащане", "прието"));Future<String> stock = pool.submit(() -> net.call("склад", "има"));Future<String> email = pool.submit(() -> net.call("имейл", "изпратен"));System.out.println(payment.get());System.out.println(stock.get());System.out.println(email.get());pool.shutdown();System.out.println("Кръгове чакане: " + net.rounds());плащане: приетосклад: имаимейл: изпратенКръгове чакане: 1Network net = new Network();ExecutorService pool = Executors.newFixedThreadPool(3);Future<String> payment = pool.submit(() -> net.call("плащане", "прието"));Future<String> stock = pool.submit(() -> net.call("склад", "има"));Future<String> email = pool.submit(() -> net.call("имейл", "изпратен"));System.out.println(payment.get());System.out.println(stock.get());System.out.println(email.get());pool.shutdown();System.out.println("Кръгове чакане: " + net.rounds());Network net = new Network();String payment = net.call("плащане", "прието");String stock = net.call("склад", "има");String email = net.call("имейл", "изпратен");System.out.println(payment);System.out.println(stock);System.out.println(email);System.out.println("Кръгове чакане: " + net.rounds());Стъпка по стъпка, с думи
- Поръчката иска три отговора по мрежата: плащане, склад и имейл за потвърждение. Всеки чака около 200 ms.
- Едно след друго: нишката пита за плащането и не прави нищо, докато отговорът дойде.
- Чак тогава пита за склада. Още 200 ms чакане.
- Чак тогава пита за имейла. Още 200 ms чакане.
- 3 кръга чакане: клиентът чака около 600 ms, а процесорът през цялото време стои.
- Наведнъж: трите въпроса тръгват заедно, всеки в своя нишка, и трите чакания се застъпват.
- Програмата чака отговорите. Докато дойде първият, другите два също са готови.
- 1 кръг чакане: около 200 ms, колкото най-бавният отговор. Същите отговори, същият ред, една трета от чакането.
Отговорите са същите и в същия ред. Кръгът чакане е един: трите въпроса тръгват, преди някой отговор да е дошъл. Клиентът чака около 200 ms, колкото най-бавната услуга, вместо 600.
Нишките тук почти не ползват процесора: три кратки изпълнения наcall и три заспивания. Затова това работи и на сървър с едно ядро.По-точно, за любопитните
Executors.newVirtualThreadPerTaskExecutor()): докато чака, виртуалната нишка не държи истинска, затова може да са стотици хиляди. Кодът е същият.Пробвай
Имейлът чака плащането
Колега преправя поръчката: имейлът трябва да казва дали е платено и дали го има, затова тръгва чак когато плащането и складът са отговорили.
Network net = new Network();ExecutorService pool = Executors.newFixedThreadPool(3);Future<String> payment = pool.submit(() -> net.call("плащане", "прието"));Future<String> stock = pool.submit(() -> net.call("склад", "има"));// имейлът казва дали е платено и дали го имаSystem.out.println(payment.get());System.out.println(stock.get());Future<String> email = pool.submit(() -> net.call("имейл", "изпратен"));System.out.println(email.get());pool.shutdown();System.out.println("Кръгове чакане: " + net.rounds());Плащането и складът тръгват заедно: това е първият кръг. Имейлът тръгва чак след двата отговора, когато нищо друго не се чака: втори кръг. Около 400 ms. Работа, която зависи от друга, не може да се застъпи с нея.
Пробвай
Чакай отговора
Какво чака отговора на плащането?
Future<String> payment =
pool.submit(() -> net.call("плащане", "прието"));
System.out.println(payment.);get() чака задачата да свърши и връща резултата ѝ.Проблем
Хиляда фактури в края на месеца
В края на месеца магазинът прави фактура в PDF за всяка от 1000 поръчки. Тук нищо не чака мрежата: процесорът смята през цялото време. render замества рисуването на PDF със сметка, която само товари процесора и връща контролна сума:
// Рисува фактурата (тук: сметка, която само товари процесора).static int render(int invoice) { int h = invoice; for (int i = 0; i < 2000; i++) { h = (h * 31 + i) % 1_000_003; } return h;}
// Фактурите от from до to включително.static long renderRange(int from, int to) { long sum = 0; for (int n = from; n <= to; n++) { sum += render(n); } return sum;}long total = renderRange(1, 1000);System.out.println("Фактури: 1000");System.out.println("Контролна сума: " + total);Фактури: 1000Контролна сума: 499409728Едно ядро смята всичко, а останалите стоят. Кръгове чакане тук няма: нищо не чака.
Виж го
Четири нишки, четири ядра
ExecutorService pool = Executors.newFixedThreadPool(4);List<Future<Long>> parts = new ArrayList<>();for (int p = 0; p < 4; p++) { int from = p * 250 + 1; parts.add(pool.submit(() -> renderRange(from, from + 249)));}long total = 0;for (Future<Long> part : parts) { total += part.get();}pool.shutdown();System.out.println("Фактури: 1000");System.out.println("Контролна сума: " + total);Фактури: 1000Контролна сума: 499409728Контролната сума е същата: разделянето не сменя резултата, само кой го смята.
Колко по-бързо е, зависи от машината. На моя лаптоп с 250 пъти по-тежка сметка на фактура (500 000 стъпки вместо 2000) едно ядро смяташе около 1,9 s, а четири около 0,5 s.Това са примерни времена, не обещание. Повече части от ядрата не помагат: те само се редуват на същите ядра.
По-точно, за любопитните
- Колко нишки. За работа, която смята, колкото ядрата:
Runtime.getRuntime().availableProcessors()казва колко са. За работа, която чака, може много повече: те само спят. - Не всичко се дели. Ако една десета от работата не може да се раздели (например събирането на частите или записът на един файл), никакъв брой ядра не я прави повече от 10 пъти по-бърза. Това е законът на Amdahl.
Идеята
Кое помага
| Работа, която чака | Работа, която смята | |
|---|---|---|
| Какво я бави | отговорът по мрежата, диска, базата | самото смятане |
| Помага | да чакаш няколко неща наведнъж | повече ядра |
| В Java | басейн от нишки, виртуални нишки | басейн с толкова нишки, колкото ядра |
| На едно ядро | помага | не помага |
Нишките идват с цена. Докато всяка работи със своите данни (своята услуга, своите 250 фактури), всичко е наред. Щом две нишки пипат едно и също (общ брояч, наличността в склада), започват грешки, които не се виждат при всяко пускане. Това е следващият урок: „Race condition“.
Упражнение
Страницата на продукта
Реши задачата в редактора си.
Страницата на продукта. Преди да покаже страницата, сървърът пита три услуги: цената, отзивите и доставката. Всяка отговаря за около 200 ms и не зависи от другите. Ползвай Network от първата карта и ги питай наведнъж, така че кръгът чакане да е един:
цена: 89 €отзиви: 4,6 от 5доставка: утреКръгове чакане: 1Още задачи
- Пет услуги. Добави наличност и отстъпка. Колко са кръговете, ако отстъпката зависи от цената?
- Колко ядра. Пусни фактурите с 1, 2, 4 и 8 части и мери времето при теб. Кога спира да става по-бързо? Сравни с броя ядра на машината си.
- Грешният инструмент. Сложи
pause/sleepот 200 ms вrenderна всяка десета фактура, сякаш чака диска. Кое помага сега повече: още нишки или още ядра?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изключения · Списъци · Ламбди · Нишки
- Продължава в: Race condition · Заключване · Атомарни операции и неизменими обекти · Производител и потребител
- Задачи по темата: Нишки и race conditions