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

Защо нишки

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

Проблем

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

Клиентът натиска „Поръчай“. Преди да види „Благодарим“, сървърът трябва да пита три услуги по мрежата: плащането (банката приема ли картата), склада (има ли го) и имейла (тръгна ли потвърждението). Всяка отговаря за около 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, умалени снимки на продуктите, отчет от милион поръчки. Тук процесорът е зает през цялото време.
Нишка, която чака, не ползва процесора. Затова нишките помагат на работа, която чака, дори на едно ядро: първата нишка пита и заспива, втората пита и заспива, третата също, и трите чакания вървят заедно. Работа, която смята, иска ядро през цялото време. Тя става по-бърза само ако има повече ядра, по едно на нишка.

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

Виж го

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

Трите питания стават задачи в басейн от 3 нишки (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());
Изход
плащане: прието
склад: има
имейл: изпратен
Кръгове чакане: 1

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());
Стъпка по стъпка, с думи
  1. Поръчката иска три отговора по мрежата: плащане, склад и имейл за потвърждение. Всеки чака около 200 ms.
  2. Едно след друго: нишката пита за плащането и не прави нищо, докато отговорът дойде.
  3. Чак тогава пита за склада. Още 200 ms чакане.
  4. Чак тогава пита за имейла. Още 200 ms чакане.
  5. 3 кръга чакане: клиентът чака около 600 ms, а процесорът през цялото време стои.
  6. Наведнъж: трите въпроса тръгват заедно, всеки в своя нишка, и трите чакания се застъпват.
  7. Програмата чака отговорите. Докато дойде първият, другите два също са готови.
  8. 1 кръг чакане: около 200 ms, колкото най-бавният отговор. Същите отговори, същият ред, една трета от чакането.

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

Нишките тук почти не ползват процесора: три кратки изпълнения на call и три заспивания. Затова това работи и на сървър с едно ядро.
По-точно, за любопитните
Нишките в басейна са истински нишки на операционната система. Всяка има свой стек и струва памет, затова хиляда едновременни питания с хиляда нишки са скъпи. Java 21 има виртуални нишки (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());
Отговор

Пробвай

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

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

Future<String> payment =
        pool.submit(() -> net.call("плащане", "прието"));
System.out.println(payment.);

Проблем

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

В края на месеца магазинът прави фактура в 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

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

Виж го

Четири нишки, четири ядра

Фактурите се делят на 4 части по 250, а всяка част е задача в басейн от 4 нишки. Всяка нишка смята на свое ядро:
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 на всяка десета фактура, сякаш чака диска. Кое помага сега повече: още нишки или още ядра?

Тест

Провери се

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

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