Java mt

Матеріал з Вікі ЦДУ
Перейти до: навігація, пошук

До цих пір у всіх прикладах ми мали на увазі, що в один момент часу виконується лише один вираз або дія. Починаючи з найперших версій, віртуальні машини Java підтримують багатопоточність, тобто підтримку декількох потоків виконання (threads) одночасно.

При одночасному зверненні декількох потоків до одних і тих же даних може виникнути ситуація, коли результат програми залежатиме від випадкових чинників, таких як часове чергування виконання операцій декількома потоками. У такій ситуації стають необхідними механізми синхронізації, що забезпечують послідовний, або монопольний, доступ. У Java цій мети служить ключове слово synchronized.

Багатопотокова архітектура

Не претендуючи на повноту викладу, розглянемо загальний устрій багатопотокової архітектури, її достоїнства і недоліки. Реалізацію багатопотокової архітектури найпростіше уявити собі для системи, в якій є декілька центральних обчислювальних процесорів. В цьому випадку для кожного з них можна виділити завдання, яке він виконуватиме. В результаті декілька завдань обслуговуватимуться одночасно.

Проте виникає питання – яким же тоді чином забезпечується багатопотоковість в системах з одним центральним процесором, який, в принципі, виконує лише одне обчислення водин момент часу? У таких системах застосовується процедура квантування часу (time-slicing). Час розділяється на невеликі інтервали. Перед початком кожного інтервалу ухвалюється рішення, який саме потік виконання відпрацьовуватиметься впродовж цього кванта часу. За рахунок частого перемикання між завданнями емулюється багатопотокова архітектура.

Насправді, як правило, і для багатопроцесорних систем застосовується процедура квантування часу. Річ у тому, що навіть в могутніх серверах додатків процесорів не так багато (рідко буває більше десяти), а потоків виконання запускається, як правило, значно більше. Наприклад, операційна система Windows без єдиного запущеного додатку ініціалізувала десятки, а то і сотні потоків. Квантування часу дозволяє спростити управління виконанням завдань на всіх процесорах.

Тепер перейдемо до питання про переваги – навіщо взагалі може потрібно більш за один потік виконання?

Серед програмістів існує думка, що багатопотокові програми працюють швидше. Розглянувши спосіб реалізації багатопотоковості, можна стверджувати, що такі програми працюють насправді повільніше. Дійсно, для перемикання між завданнями на кожному інтервалі потрібний додатковий час, адже вони (перемикання) відбуваються досить часто. Якби процесор, не відволікаючись, виконував завдання послідовно, одну за іншою, він завершив би їх помітно швидше. Отже, переваги полягають не в цьому.

Перший тип додатків, який виграє від підтримки багатопотоковості, призначений для завдань, де дійсно потрібно виконувати декілька дій одночасно. Наприклад, буде цілком обгрунтовано чекати, що сервер загального користування почне обслуговувати декілька клієнтів одночасно. Можна легко уявити собі приклад з сфери обслуговування, коли є декілька потоків клієнтів і бажано обслуговувати їх всіх одночасно.

Інший приклад – активні ігри, або подібні додатки. Необхідно одночасно опитувати клавіатуру і інші пристрої введення, щоб реагувати на дії користувача. В той же час необхідний розраховувати і перемальовувати стан ігрового поля, що змінюється. Зрозуміло, що у разі відсутності підтримки багатопотоковості для реалізації подібних застосувань потрібно було б реалізовувати квантування часу уручну. Умовно кажучи, одну секунду перевіряти стан клавіатури, а наступну – перераховувати і перемальовувати ігрове поле. Якщо порівняти дві реалізації time-slicing, одну – на низькому рівні, виконану засобами, як правило, операційної системи, іншу – виконувану уручну, на мові високого рівня, мало відповідного для таких завдань, то стає зрозумілим перше і, можливо, головна перевага багатопотоковості. Вона забезпечує найбільш ефективну реалізацію процедури квантування часу, істотно полегшуючи і укорочувавши процес розробки додатку. Код перемикання між завданнями на Java виглядав би куди громіздкіше, ніж незалежний опис дій для кожного потоку.

Наступна перевага виникає з того, що комп'ютер складається не тільки з одного або декількох процесорів. Обчислювальний пристрій – лише один з ресурсів, необхідних для виконання завдань. Завжди є оперативна пам'ять, дискова підсистема, мережеві підключення, периферія і так далі Припустимо, користувачеві потрібно роздрукувати великий документ і викачати великий файл з мережі. Очевидно, що обидва завдання вимагають зовсім незначної участі процесора, а основні необхідні ресурси, які будуть задіяні на межі можливостей, у них різні – мережеві підключення і принтер. Значить, якщо виконувати завдання одночасно, то уповільнення від організації квантування часу буде незначним, процесор легко справиться з обслуговуванням обох завдань. В той же час, якщо кожне завдання окремо займало, скажімо, дві години, то цілком імовірно, що і при одночасного виконання буде потрібно не більш за ті ж два годинники, а зроблено при цьому буде значно більше.

Якщо ж завдання в основному завантажують процесор (наприклад, математичні розрахунки), то їх одночасного виконання займе в кращому разі стільки ж часу, що і послідовне, а то і більше.

Третя перевага з'являється із-за можливості гнучкіше управляти виконанням завдань. Припустимо, користувач системи, не підтримуючою багатопотоковість, вирішив викачати великий файл з мережі, або провести складне обчислення, що займає, скажімо, дві години. Запустивши завдання на виконання, він може раптово виявити, що йому потрібний не цей, а який-небудь інший файл (або обчислення з іншими початковими параметрами). Проте якщо додаток займається тільки роботою з мережею (обчисленнями) і не реагує на дії користувача (не обробляються дані з пристроїв введення, таких як клавіатура або миша), то він не зможе швидко виправити помилку. Виходить, що процесор виконує більша кількість обчислень, але при цьому приносить значно менше користі.

Процедура квантування часу підтримує пріоритети (priority) завдань. У Java пріоритет представляється цілим числом. Чим більше число, тим вище пріоритет. Строгих правил роботи з пріоритетами немає, кожна реалізація може поводитися по-різному на різних платформах. Проте є загальне правило – потік з вищим пріоритетом отримуватиме більшу кількість квантів часу на виконання і таким чином зможе швидше виконувати свої дії і реагувати на дані, що поступають.

У описаному прикладі представляється розумним запустити додатковий потік, що відповідає за взаємодію з користувачем. Йому можна поставити високий пріоритет, оскільки у разі бездіяльності користувача цей потік практично не займатиме ресурси машини. У разі ж активності користувача необхідно щонайшвидше провести необхідні дії, щоб забезпечити максимальну ефективність роботи користувача.

Розглянемо тут же ще одну властивість потоків. Раніше, коли розглядалися однопоточні застосування, завершення обчислень однозначно приводило до завершення виконання програми. Тепер же додаток повинен працювати до тих пір, поки є хоч один потік виконання, що діє. В той же час частий бувають потрібні обслуговуючі потоки, які не мають ніякого сенсу, якщо вони залишаються в системі одні. Наприклад, автоматичний складальник сміття в Java запускається у вигляді фонового (низькопріоритетного) процесу. Його завдання – відстежувати об'єкти, які вже не використовуються іншими потоками, і потім знищувати їх, звільняючи оперативну пам'ять. Зрозуміло, що робота одного потоку garbage collector'а не має ніякого сенсу.

Такі обслуговуючі потоки називають демонами (daemon), цю властивість можна встановити будь-якому потоку. У результаті додаток виконується до тих пір, поки є хоч би один потік не-демон.

Розглянемо, як потоки реалізовані в Java.

Базові класи для роботи з потоками

Клас Thread

Потік виконання в Java представляється екземпляром класу Thread. Для того, щоб написати свій потік виконання, необхідно успадковуватися від цього класу і перевизначити метод run().

public class MyThread extends Thread {
public void run() {
// деяка довга дія, обчислення
long sum=0;
for (int i=0; i<1000; i++) {
sum+=i;
}
System.out.println(sum);
}
}

Метод run() містить дії, які повинні виконуватися в новому потоці виконання. Щоб запустити його, необхідно створити екземпляр класу-наступника і викликати успадкований метод start(), який повідомляє віртуальну машину, що потрібно запустити новий потік виконання і почати виконувати в нім метод run().

MyThread t = new MyThread();
t.start();

Внаслідок чого на консолі з'явиться результат:

499500

Коли метод run() завершений (зокрема, зустрівся вираз return), потік виконання зупиняється. Проте ніщо не перешкоджає запису нескінченного циклу в цьому методі. В результаті потік не перерве свого виконання і буде зупинений тільки при завершенні роботи всього застосування.

Інтерфейс Runnable

Описаний підхід має один недолік. Оскільки в Java множинне спадкоємство відсутнє, вимога успадковуватися від Thread може привести до конфлікту. Якщо ще раз подивитися на приведений вище приклад, стане зрозуміло, що спадкоємство проводилося тільки з метою перевизначення методу run(). Тому пропонується простіший спосіб створити свій потік виконання. Досить реалізувати інтерфейс Runnable, в якому оголошений тільки один метод, – вже знайомий void run(). Запишемо приклад, приведений вище, за допомогою цього інтерфейсу:

public class MyRunnable implements Runnable {
public void run() {
// деяка довга дія, обчислення
long sum=0;
for (int i=0; i<1000; i++) {
sum+=i;
}
System.out.println(sum);
}
}

Також трохи міняється процедура запуску потоку:

Runnable r = new MyRunnable();
Thread t = new Thread(r);
t.start();

Якщо раніше об'єкт, що представляє сам потік виконання, і об'єкт з методом run(), що реалізовує необхідну функціональність, були об'єднані в одному екземплярі класу MyThread, то тепер вони розділені. Який з двох підходів зручніше, вирішується у кожному конкретному випадку.

Підкреслимо, що Runnable не є повною заміною класу Thread, оскільки створення і запуск самого потоку виконання можливо тільки через метод Thread.start().

Многопоточность в Java