Нишки
Проблем
Три отчета, един по един
Thread.sleep(ms) спира програмата за толкова милисекунди. Паузата може да бъде прекъсната отвън и тогава хвърля провереното InterruptedException, затова download го хваща.static void download(String file, int ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { return; } System.out.println("Свалих " + file);}
public static void main(String[] args) { download("март.pdf", 600); download("април.pdf", 200); download("май.pdf", 400); System.out.println("Готово");}Свалих март.pdfСвалих април.pdfСвалих май.pdfГотовоИдеята
Процес и нишка
Когато пуснеш програмата, операционната система ѝ дава процес: своя памет, свои отворени файлове. Вътре в процеса работят една или повече нишки (threads). Нишката е поредица от команди, които се изпълняват една след друга. Досега всяка твоя програма имаше една нишка: тази, която изпълнява main.
Нишките в един процес делят паметта си: виждат едни и същи обекти. Това ги прави удобни (лесно си предават данни) и опасни (могат да се объркат взаимно, ще го видим след малко).
- Паралелно значи наистина едновременно, на различни ядра на процесора.
- Конкурентно значи на едно ядро, като се редуват толкова бързо, че изглежда едновременно.
За програмиста разликата рядко има значение: и в двата случая не знаеш коя нишка кога ще продължи.
По-точно, за любопитните
- Браузърът Chrome пуска отделен процес за всеки раздел. Затова един увиснал сайт не спира останалите, но и затова браузърът ползва много памет.
- Нишки ползва всяка програма, която прави няколко неща наведнъж: играта пуска звука в една нишка, а логиката в друга; уеб сървърът обслужва всеки клиент в своя нишка.
- Цената: всяка нишка има свой стек и паметта за нея се заделя отделно. Хиляди нишки струват. Затова в истински програми се ползва басейн от нишки (последната идея в урока).
Виж го
Първата нишка
Класът Thread е нишка. На конструктора му подаваш какво да прави: обект от интерфейса Runnable, който има един метод run(). Най-кратко е с ламбда:
Thread worker = new Thread(() -> download("април.pdf", 200));worker.start();System.out.println("main не чака");worker.join();System.out.println("Отчетът е свален");main не чакаСвалих април.pdfОтчетът е сваленstart()пуска новата нишка и се връща веднага.mainпродължава, без да чака, затова „main не чака“ се отпечатва първо.join()караmainда изчака нишката да свърши.
download е методът от първата карта. Добре, че хваща InterruptedException сам: run() в Runnable няма throws, затова кодът в нишката не може да остави провереното изключение да излезе навън. join() също го хвърля, затова от тук нататък main е public static void main(String[] args) throws InterruptedException.
По-точно, за любопитните
class ReportThread extends Thread { @Override public void run() { System.out.println("1. наследник на Thread"); }}
class ReportJob implements Runnable { @Override public void run() { System.out.println("2. клас, който изпълнява Runnable"); }}
public static void main(String[] args) throws InterruptedException { Thread a = new ReportThread(); Thread b = new Thread(new ReportJob()); Thread c = new Thread(new Runnable() { @Override public void run() { System.out.println("3. анонимен клас"); } }); Thread d = new Thread(() -> System.out.println("4. ламбда")); for (Thread t : new Thread[] { a, b, c, d }) { t.start(); t.join(); }}1. наследник на Thread2. клас, който изпълнява Runnable3. анонимен клас4. ламбдаThread е най-старият начин. По-гъвкаво е да отделиш задачата (Runnable) от нишката: така същата задача може да се даде на басейн от нишки, а класът ти остава свободен да наследи друг клас.Виж го
Три наведнъж
long start = System.currentTimeMillis();Thread march = new Thread(() -> download("март.pdf", 600));Thread april = new Thread(() -> download("април.pdf", 200));Thread may = new Thread(() -> download("май.pdf", 400));march.start();april.start();may.start();march.join();april.join();may.join();long ms = System.currentTimeMillis() - start;System.out.println("Готово за под секунда: " + (ms < 1000));Свалих април.pdfСвалих май.pdfСвалих март.pdfГотово за под секунда: trueНишките тръгват в реда март, април, май, но свършват по реда на паузите: април (0,2 s), май (0,4 s), март (0,6 s). Цялото чакане е колкото най-дългото, 0,6 секунди вместо 1,2.
Реда, в който нишките стигат доprintln, решава операционната система. Тук паузите се различават достатъчно, затова редът е един и същ. Ако бяха еднакви, при всяко пускане можеше да е различен.Пробвай
start или run
Какво ще отпечата програмата? (вторият аргумент на Thread е името на нишката)
Thread t = new Thread(() -> System.out.println("Работи в: " + Thread.currentThread().getName()), "сваляне");t.run();t.start();t.join();t.run() е обикновено извикване на метод: ламбдата се изпълнява веднага, в нишката на main. Нова нишка няма. Чак t.start() пуска нишката „сваляне“, която изпълнява същата ламбда. Да напишеш run() вместо start() е честа грешка: програмата работи, само че нищо не е паралелно.Идеята
Животът на нишката
getState() казва в кое е:Thread t = new Thread(() -> download("май.pdf", 500));System.out.println(t.getState());t.start();Thread.sleep(100);System.out.println(t.getState());t.join();System.out.println(t.getState());NEWTIMED_WAITINGСвалих май.pdfTERMINATED| Състояние | Кога |
|---|---|
NEW |
създадена, още не е пусната със start() |
RUNNABLE |
работи или е готова да работи |
BLOCKED, WAITING, TIMED_WAITING |
чака: ключалка, друга нишка (join), или определено време (sleep) |
TERMINATED |
run() е свършил |
Нишката се пуска само веднъж. Втори start() на същата нишка хвърля IllegalThreadStateException.
По-точно, за любопитните
Още методи на Thread:
interrupt()моли нишката да спре. Ако тя спи или чака,sleep/joinхвърляInterruptedException. Затова го хващаме вdownload.getName(),threadId()(по-раноgetId()): име и номер на нишката.Thread.currentThread()дава нишката, в която се изпълнява кодът.Thread.yield()подсказва, че нишката може да отстъпи процесора на друга. Рядко има нужда от него.
Проблем
Загубените посещения
increment() 100 000 пъти:class Counter { private int count = 0;
public void increment() { count++; }
public int getCount() { return count; }}
public static void main(String[] args) throws InterruptedException { Counter visits = new Counter(); Runnable work = () -> { for (int i = 0; i < 100_000; i++) { visits.increment(); } }; Thread first = new Thread(work); Thread second = new Thread(work); first.start(); second.start(); first.join(); second.join(); System.out.println("Посещения: " + visits.getCount());}Очакваш 200 000. Пет пускания:
Посещения: 150329Посещения: 138149Посещения: 168862Посещения: 165672Посещения: 157704Всеки път по-малко и всеки път различно. Причината е, че count++ не е една стъпка, а три:
int value = count; // 1. прочетиvalue = value + 1; // 2. добави 1count = value; // 3. запишиclass Counter { private int count = 5;
public void increment() { count++; // прочети, добави 1, запиши }
public synchronized void safeIncrement() { count++; }
public synchronized int getCount() { return count; }}Стъпка по стъпка, с думи
- count е 5. Нишките A и B викат increment() едновременно.
- A прочита count: 5.
- B прочита count, преди A да е записала: също 5.
- A добавя 1 в своето копие и записва 6.
- B също записва 6. Две извиквания, а count е 6, не 7: едното добавяне е загубено.
- Отначало, със synchronized: A взима ключалката, прочита 5 и записва 6.
- B стига до метода и чака, докато A пусне ключалката.
- B взима ключалката, прочита 6 и записва 7: нищо не е загубено.
Когато двете нишки прочетат една и съща стойност, преди някоя да е записала, едното добавяне се губи. Това се казва състезание (race condition). Дали ще стане, зависи от това кога операционната система сменя нишките, затова резултатът е различен при всяко пускане.
Идеята
synchronized: по една
synchronized пред метода прави точно това:class Counter { private int count = 0;
public synchronized void increment() { count++; }
public synchronized int getCount() { return count; }}Посещения: 200000Всеки обект има ключалка. Нишката я взима, преди да влезе в синхронизиран метод, и я пуска, когато излезе. Другата нишка, която иска същия обект, чака. Сега резултатът е 200 000 при всяко пускане. И getCount() е синхронизиран: така нишката, която чете, вижда последната записана стойност.
Ако трябва да заключиш само част от метода, има синхронизиран блок с обект за ключалка. За брояч има и готов клас: AtomicInteger (от java.util.concurrent.atomic), чийто incrementAndGet() прави трите стъпки наведнъж, без ключалка:
class Counter { private int count = 0; private final Object lock = new Object();
public void increment() { synchronized (lock) { count++; } }
public int getCount() { synchronized (lock) { return count; } }}class Counter { private final AtomicInteger count = new AtomicInteger();
public void increment() { count.incrementAndGet(); }
public int getCount() { return count.get(); }}Посещения: 200000По-точно, за любопитните
wait()иnotifyAll(): нишка в синхронизиран метод може да пусне ключалката и да заспи, докато друга не я събуди. Така кухнята чака поръчка, а касата чака кухнята да вземе предишната (задачата „производител и потребител“):
class OrderBox { private String order;
public synchronized void put(String newOrder) throws InterruptedException { while (order != null) { wait(); } order = newOrder; notifyAll(); }
public synchronized String take() throws InterruptedException { while (order == null) { wait(); } String result = order; order = null; notifyAll(); return result; }}
public static void main(String[] args) throws InterruptedException { OrderBox box = new OrderBox(); Thread kitchen = new Thread(() -> { try { for (int i = 0; i < 3; i++) { System.out.println("Кухнята прие " + box.take()); } } catch (InterruptedException e) { return; } }); kitchen.start(); box.put("пица"); box.put("салата"); box.put("супа"); kitchen.join();}Кухнята прие пицаКухнята прие салатаКухнята прие супаwait() е в while, а не в if: нишката може да се събуди и без причина, затова проверява условието наново.
- Мъртва хватка (deadlock): две нишки чакат една друга завинаги. Офисът държи принтера и чака скенера, а рецепцията държи скенера и чака принтера (
pauseеsleepс хванато изключение):
Object printer = new Object();Object scanner = new Object();
Thread office = new Thread(() -> { synchronized (printer) { pause(100); synchronized (scanner) { System.out.println("Офисът копира"); } }});Thread reception = new Thread(() -> { synchronized (scanner) { pause(100); synchronized (printer) { System.out.println("Рецепцията копира"); } }});Програмата увисва и никога не печата нищо. Начинът да го избегнеш е всички нишки да взимат ключалките в един и същ ред.
- Приоритети:
setPriorityот 1 до 10 подсказва колко важна е нишката. Операционната система може да не се съобрази, затова не разчитай на тях за правилността на програмата:
Thread report = new Thread(() -> download("годишен.pdf", 100));report.setPriority(Thread.MIN_PRIORITY); // 1; NORM_PRIORITY е 5, MAX_PRIORITY е 10System.out.println(report.getName() + ", приоритет " + report.getPriority());Пробвай
Два банкомата
Избери реда с грешката.
withdraw не е синхронизиран. Двете нишки проверяват balance >= amount едновременно, и двете виждат 100 и двете теглят. Проверката и тегленето трябва да са едно цяло: public synchronized void withdraw(int amount) (и synchronized за getBalance). Тогава вторият банкомат чака първия, вижда 20 € и отказва. Балансът остава 20 €.Идеята
Басейн от нишки и Future
ExecutorService (от java.util.concurrent):static int sizeOf(String file, int ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { return -1; } return file.length() * 100;}String[] files = { "март.pdf", "април.pdf", "май.pdf", "юни.pdf" };ExecutorService pool = Executors.newFixedThreadPool(2);List<Future<Integer>> sizes = new ArrayList<>();for (String file : files) { sizes.add(pool.submit(() -> sizeOf(file, 300)));}for (int i = 0; i < files.length; i++) { System.out.println(files[i] + ": " + sizes.get(i).get() + " KB");}pool.shutdown();март.pdf: 800 KBаприл.pdf: 900 KBмай.pdf: 700 KBюни.pdf: 700 KBExecutors.newFixedThreadPool(2)прави басейн от 2 нишки. Четирите задачи се редуват в тях.submitприема задача, която връща резултат, и веднага даваFuture: обещание, че резултатът ще дойде.get()чака задачата да свърши и връща резултата.shutdown()казва на басейна да не приема нови задачи и да спре нишките, когато свършат. Без него програмата не свършва: нишките на басейна чакат още работа.
main тук има throws Exception, защото get() хвърля и ExecutionException, ако задачата е хвърлила изключение.
По-точно, за любопитните
execute. Тогава чакаш всички с awaitTermination след shutdown:ExecutorService pool = Executors.newFixedThreadPool(2);for (int i = 1; i <= 5; i++) { String file = "отчет-" + i + ".pdf"; pool.execute(() -> download(file, 100));}pool.shutdown();pool.awaitTermination(1, TimeUnit.MINUTES);System.out.println("Всички свършиха.");Свалих отчет-2.pdfСвалих отчет-1.pdfСвалих отчет-3.pdfСвалих отчет-4.pdfСвалих отчет-5.pdfВсички свършиха.while (!executor.isTerminated()) { }. Това също чака, но върти процесора на празно. awaitTermination спи, докато басейнът свърши.Пробвай
Кой резултат пръв?
„архив.zip“ се сваля 0,6 s, а „бележка.txt“ 0,1 s. Какво ще отпечата програмата?
ExecutorService pool = Executors.newFixedThreadPool(2);Future<Integer> big = pool.submit(() -> sizeOf("архив.zip", 600));Future<Integer> small = pool.submit(() -> sizeOf("бележка.txt", 100));System.out.println("Голям: " + big.get());System.out.println("Малък: " + small.get());pool.shutdown();Малкият файл наистина е готов първи. Но big.get() е първият ред и чака големия. Когато той дойде, small.get() връща веднага, защото резултатът вече е там. Редът на изхода е редът на get(), затова е един и същ при всяко пускане.
Упражнение
Програми с нишки
Реши задачите в редактора си. main може да има throws InterruptedException (или throws Exception за Future.get()).
1. Паралелна сума. Масивът sales има 1 000 000 продажби, sales[i] = i % 100. Раздели го на 4 равни части и сумирай всяка в своя нишка. Всяка нишка пише в свое място в масив long[] partial, затова не трябва синхронизация. Изчакай ги с join и събери частите:
Части: [12375000, 12375000, 12375000, 12375000]Общо: 495000002. Сваляне с басейн. Свали шест отчета (март.pdf, април.pdf, май.pdf, юни.pdf, юли.pdf, август.pdf) с басейн от 3 нишки. Ползвай sizeOf(file, 300) от картата за басейна. Отпечатай размерите по реда на файловете, сбора и дали всичко е станало за под 1,5 секунди (едно след друго би било 1,8 s):
март.pdf: 800 KBаприл.pdf: 900 KBмай.pdf: 700 KBюни.pdf: 700 KBюли.pdf: 700 KBавгуст.pdf: 1000 KBОбщо: 4800 KBПод 1,5 секунди: true3. Брояч без загуби. Четири нишки, всяка с по 50 000 посещения, увеличават общ брояч. Напиши брояча така, че резултатът да е винаги 200 000. Опитай първо без синхронизация и виж колко се губи, после поправи веднъж със synchronized и веднъж с AtomicInteger.
Посещения: 200000Още задачи
- Тест за брояча. Напиши JUnit тест, който пуска 8 нишки по 10 000 увеличения и проверява 80 000 с
assertEquals. Пусни го срещу несинхронизирания брояч няколко пъти. Тест, който пада понякога, е типичният знак за състезание. - Мъртвата хватка. Пусни примера с принтера и скенера от „По-точно“ и виж как увисва. Поправи го, като и двете нишки взимат първо принтера.
Тест