JavaRush/Java блог/Random UA/Управління потоками. Ключове слово volatile та метод yiel...

Управління потоками. Ключове слово volatile та метод yield()

Стаття з групи Random UA
учасників
Вітання! Ми продовжуємо вивчення багатопоточності, і сьогодні познайомимося з новим ключовим словом – volatile та методом yield(). Давай розберемося, що це таке :)

Ключове слово volatile

Під час створення багатопотокових програм ми можемо зіткнутися з двома серйозними проблемами. По-перше, у процесі роботи багатопотокового додатку різні потоки можуть кешувати значення змінних (докладніше про це поговоримо в лекції «Застосування volatile» ). Можлива ситуація, коли один потік змінив значення змінної, а другий не побачив цієї зміни, тому що працював зі своєю кешованою копією змінної. Звичайно, наслідки можуть бути серйозними. Уяви, що це не просто якась «змінна», а, наприклад, баланс твоєї банківської картки, який раптом почав рандомно сякати сюди-туди :) Не дуже приємно, так? По-друге, Java операції читання і запису полів всіх типів, крім longі double, є атомарними. Що таке атомарність? Ну, наприклад, якщо ти в одному потоці змінюєш значення змінної int, а в іншому потоці читаєш значення цієї змінної, ти отримаєш або її старе значення, або нове - те, що вийшло після зміни в потоці 1. Ніяких «проміжних варіантів» там з'явитися не може. Однак і longце doubleне працює. Чому? Через кросплатформність. Пам'ятаєш, ми ще на перших рівнях говорабо, що принцип Java – «написано одного разу – працює скрізь»? Це і є кросплатформність. Тобто Java-додаток запускається на абсолютно різних платформах. Наприклад, на операційних системах Windows, різних варіантах Linux або MacOS, і скрізь ця програма стабільно працюватиме. longіdouble— «найважкіші» примітиви в Java: вони важать по 64 біти. І в деяких 32-бітових платформах просто не реалізовано атомарність читання та запису 64-бітних змінних. Такі змінні читаються та записуються у дві операції. Спочатку в змінну записуються перші 32 біти, потім ще 32. Відповідно, у цих випадках може виникнути проблема. Один потік записує якесь 64-бітове значення зміннуХ, і робить він це «в два заходи». У той же час другий потік намагається прочитати значення цієї змінної, причому робить це якраз посередині, коли перші 32 біти вже записані, а другі ще немає. У результаті він читає проміжне, некоректне значення і виходить помилка. Наприклад, якщо на такій платформі ми спробуємо записати в змінну кількість - 9223372036854775809 - воно займатиме 64 біти. У двійковій формі воно буде виглядати так: 1000000000000000000000000000000000000000000000000000000000000001 Перший потік почне запис цього числа000000 000000000000000000 а потім другі 32: 000000000000000000000000000001 І в цей проміжок може вклинитися другий потік, і прочитати проміжне значення змінної - 10000000000000000 32 біти, які вже було записано. У десятковій системі це число дорівнює 2147483648. Тобто ми всього лише хотіли записати число 9223372036854775809 в змінну, але через те, що ця операція на деяких платформах є не атомарною, у нас з нізвідки виникло «ліве»8, непотрібне4 і невідомо, як воно вплине на роботу програми. Другий потік просто прочитав значення змінної до того, як воно остаточно записалося, тобто перші 32 біти він побачив, а другі 32 біти — ні. Ці проблеми, звичайно, виникли не вчора, і в Java вони вирішуються за допомогою всього одного ключового слова. у нас звідки виникло «ліве», непотрібне нам число 2147483648, і невідомо як воно вплине на роботу програми. Другий потік просто прочитав значення змінної до того, як воно остаточно записалося, тобто перші 32 біти він побачив, а другі 32 біти — ні. Ці проблеми, звичайно, виникли не вчора, і в Java вони вирішуються за допомогою всього одного ключового слова. у нас звідки виникло «ліве», непотрібне нам число 2147483648, і невідомо як воно вплине на роботу програми. Другий потік просто прочитав значення змінної до того, як воно остаточно записалося, тобто перші 32 біти він побачив, а другі 32 біти — ні. Ці проблеми, звичайно, виникли не вчора, і в Java вони вирішуються за допомогою всього одного ключового слова.volatile . Якщо ми оголошуємо у нашій програмі якусь змінну, зі словом volatile…
public class Main {

   public volatile long x = 2222222222222222222L;

   public static void main(String[] args) {

   }
}
…це означає, що:
  1. Вона завжди буде атомарно читатись і записуватись. Навіть якщо це 64-біт doubleабо long.
  2. Java-машина не поміщатиме її в кеш. Тому ситуація, коли 10 потоків працюють зі своїми локальними копіями виключена.
Ось так дві дуже серйозні проблеми вирішуються одним словом:)

Метод yield()

Ми розглянули вже багато методів класу Thread, але є один важливий, який буде тобі новим. Це метод yield() . З англійської перекладається як «поступатися». І це те, що метод робить! Управління потоками.  Ключове слово volatile та метод yield() - 2Коли ми викликаємо метод yield біля потоку, він фактично говорить іншим потокам: «Так, хлопці, я нікуди особливо не поспішаю, тому якщо комусь із вас важливо отримати час процесора — беріть, мені не терміново». Ось простий приклад того, як це працює:
public class ThreadExample extends Thread {

   public ThreadExample() {
       this.start();
   }

   public void run() {

       System.out.println(Thread.currentThread().getName() + "поступається своїм місцем іншим");
       Thread.yield();
       System.out.println(Thread.currentThread().getName() + " has finished executing.");
   }

   public static void main(String[] args) {
       new ThreadExample();
       new ThreadExample();
       new ThreadExample();
   }
}
Ми послідовно створюємо та запускаємо три потоки — Thread-0, Thread-1і Thread-2. Thread-0запускається першим і відразу поступається місцем іншим. Після нього запускається Thread-1, і теж поступається. Після — запускається Thread-2, який теж поступається. Більше потоків у нас немає, і після того, як Thread-2останнім поступився своїм місцем, планувальник потоків дивиться: «Так, нових потоків більше немає, хто там у черзі? Хто поступався своїм місцем останнім, перед Thread-2? Здається, це був Thread-1? Окей, отже, нехай він і виконується». Thread-1виконує свою роботу до кінця, після чого планувальник потоків продовжує координацію: Окей, Thread-1 виконався. Чи є у нас хтось ще в черзі?». У черзі є Thread-0: він поступався своїм місцем відразу до Thread-1. Тепер дійшло до нього, і він виконується до кінця. Після чого планувальник закінчує координацію потоків: «Гаразд, Thread-2, ти поступився місцем іншим потокам, вони всі вже відпрацювали. Ти поступався місцем останнім, так що тепер твоя черга». Після цього відпрацьовує остаточно потік Thread-2. Висновок у консоль буде виглядати так: Thread-0 поступається своїм місцем іншим Thread-1 поступається своїм місцем іншим Thread-2 поступається своїм місцем іншим Thread-1 закінчив виконання. Thread-0 закінчив виконання. Thread-2 закінчив виконання. p align="justify"> Планувальник потоків, звичайно, може запустити потоки в іншому порядку (наприклад, 2-1-0 замість 0-1-2), але сам принцип незмінний.

Правила "happens-before"

Останнє, чого ми торкнемося сьогодні, це принципи " happens before ". Як ти вже знаєш, у Java основну частину роботи з виділення часу та ресурсів потоків для виконання їхніх завдань виконує планувальник потоків. Також ти не раз бачив, як потоки виконуються у довільному порядку, і найчастіше передбачити його неможливо. Та й взагалі, після «послідовного» програмування, яким ми займалися до цього, багатопотоковість виглядає рандомною штукою. Як ти вже переконався, хід багатопоточної програми можна контролювати за допомогою цілого набору методів. Але на додачу до багатопоточності Java існує ще один «острівець стабільності» — 4 правила під назвою « happens-before». Дослівно з англійської це перекладається як «відбувається перед», або «відбувається раніше, ніж». Зрозуміти зміст цих правил досить просто. Уяви, що ми маємо два потоки — Aі B. Кожен із цих потоків може виконувати операції 1та 2. І коли в кожному з правил ми говоримо " A happens-before B ", це означає, що всі зміни, виконані потоком Aдо моменту операції 1та зміни, які спричинила ця операція, видно потоку Bв момент виконання операції 2та після виконання цієї операції. Кожне з цих правил дає гарантію, що при написанні багатопотокової програми одні події у 100% випадків відбуватимуться раніше, ніж інші, і що потік Bу момент виконання операції2завжди буде в курсі змін, які потік Азробив під час операції 1. Давай розглянемо їх.

Правило 1.

Звільнення м'ютексу happens before відбувається раніше захоплення цього ж монітора іншим потоком. Ну, тут начебто все зрозуміло. Якщо м'ютекс об'єкта чи класу захоплений одним потоком, наприклад, потоком А, інший потік (потік B) неспроможна у цей час його захопити. Потрібно почекати, доки м'ютекс не звільниться.

Правило 2

Метод Thread.start() happens before Thread.run() . Теж нічого складного. Ти вже знаєш: щоб почав виконуватися код усередині методу run(), необхідно викликати у потоку метод start(). Саме його, а не сам метод run()! Це правило гарантує, що встановлені до запуску Thread.start()значення всіх змінних будуть видні всередині методу, що почав виконання run().

Правило 3

Завершення методу run() happens before вихід із методу join(). Повернемося до наших двох потоків - Аі B. Ми викликаємо метод join()таким чином, щоб потік Bобов'язково дочекався завершення A, перш ніж виконувати свою роботу. Це означає, що метод run()об'єкта A обов'язково відпрацює до кінця. І всі зміни в даних, які відбудуться в методі run()потоку, Aстовідсотково будуть видні в потоці B, коли він дочекається завершення Aі почне роботу сам.

Правило 4

Запис у volatile мінливу happens-before читання з тієї ж змінної. При використанні ключового слова volatile ми фактично завжди будемо отримувати актуальне значення. Навіть у випадку з longі double, про проблеми з якими йшлося раніше. Як ти вже зрозумів, зміни, зроблені в одних потоках, далеко не завжди видно іншим потокам. Але, звичайно, дуже часто трапляються ситуації, коли подібна поведінка програми нас не влаштовує. Припустимо, в потоці Aми надали значення змінної:
int z;.

z= 555;
Якщо наш потік Bповинен вивести значення змінної zна консоль, він може вивести 0, тому що не знає про присвоєне їй значення. Так ось, Правило 4 гарантує нам: якщо оголосити змінну zяк volatile, зміни її значень в одному потоці завжди буде видно в іншому потоці. Якщо ми додамо до попереднього коду слово volatile...
volatile int z;.

z= 555;
...ситуація, коли потік Bвиведе в консоль 0, виключена. Запис у volatile-змінні відбувається раніше, ніж читання з них.
Коментарі
  • популярні
  • нові
  • старі
Щоб залишити коментар, потрібно ввійти в систему
Для цієї сторінки немає коментарів.