Skip to content

7. Потоки виконання

7.1. Вступ

Коли запускається додаток, він виконується у потоці виконання, який називається потоком. Клас, що моделює thread, — це клас java.lang.Thread, деякі властивості та методи якого наведено нижче:

currentThread()
повертає потік, що наразі виконується
setName()
встановлює ім’я потоку
getName()
ім'я потоку
isAlive()
вказує, чи є потік активним (true) чи ні (false)
start()
запускає виконання потоку
run()
метод, що виконується автоматично після виконання попереднього методу start
sleep(n)
призупиняє виконання потоку на n мілісекунд
join()
блокуюча операція — очікує завершення роботи потоку, щоб перейти до наступної інструкції

Найпоширеніші конструктори такі:

Thread()
створює посилання на асинхронне завдання. Воно поки що неактивне. Створене завдання повинно мати метод run: найчастіше використовується клас, похідний від Thread.
Thread(Runnable object)
Те саме, але саме об’єкт Runnable, переданий як параметр, реалізує метод run.

Розглянемо перший приклад, що ілюструє наявність головного потоку виконання — того, в якому виконується функція main класу:

// використання потоків

import java.io.*;
import java.util.*;

public class thread1{
    public static void main(String[] arg)throws Exception {
         // ініціалізація поточного потоку
        Thread main=Thread.currentThread();
         // виведення
        System.out.println("Thread courant : " + main.getName());
         // зміна імені
        main.setName("myMainThread");
         // перевірка
        System.out.println("Thread courant : " + main.getName());

         // нескінченний цикл
        while(true){
        // отримання часу
      Calendar calendrier=Calendar.getInstance();
      String H=calendrier.get(Calendar.HOUR_OF_DAY)+":"
      +calendrier.get(Calendar.MINUTE)+":"
      +calendrier.get(Calendar.SECOND);
             // відображення
            System.out.println(main.getName() + " : " +H);
             // тимчасове зупинення
            Thread.sleep(1000);
        }//while
    }//main
}//клас

Результати на екрані:


Thread courant : main
Thread courant : myMainThread
myMainThread : 15:34:9
myMainThread : 15:34:10
myMainThread : 15:34:11
myMainThread : 15:34:12
Terminer le programme de commandes (O/N) ? o

Попередній приклад ілюструє наступні моменти:

  • функція main успішно виконується у потоці
  • доступ до характеристик цього потоку здійснюється за допомогою Thread.currentThread()
  • роль методу sleep. Тут потік, що виконує main, регулярно переходить у режим очікування на 1 секунду між двома відображеннями.

7.2. Створення потоків виконання

Можливі програми, в яких фрагменти коду виконуються «одночасно» у різних потоках виконання. Коли кажуть, що thread виконуються одночасно, це часто є неточною формулюванням. Якщо машина має лише один процесор, як це досі часто буває, то thread-и ділять між собою цей процесор: кожен з них по черзі має доступ до нього протягом короткого проміжку часу (декількох мілісекунд). Саме це створює ілюзію паралельності виконання. Часовий проміжок, відведений для одного thread, залежить від різних факторів, зокрема від його пріоритету, який має значення за замовчуванням, але також може бути встановлений програмно. Коли thread має доступ до процесора, він зазвичай використовує його протягом усього відведеного йому часу. Однак він може звільнити його достроково:

  • перейшовши в режим очікування події (wait, join)
  • перейшовши в режим сну на певний час (sleep)
  • Потік T можна створити різними способами
    • шляхом успадкування класу Thread та перевизначення його методу run
    • шляхом реалізації інтерфейсу Runnable у класі та використання конструктора new Thread(Runnable). Runnable — це інтерфейс, який визначає лише один метод: public void run(). Отже, аргументом попереднього конструктора є будь-який екземпляр класу, що реалізує цей метод run.

У наведеному нижче прикладі потоки створюються за допомогою анонімного класу, що є похідним від класу Thread:

            // створюємо потік i
            tâches[i]=new Thread() {
          public void run() {
            affiche();
        }
      };//визначення завдань[i]

Метод run тут просто перенаправляє на метод affiche.

  • Виконання потоку T запускається методом T.start(): цей метод належить до класу Thread і виконує низку ініціалізацій, а потім автоматично запускає метод run класу Thread або інтерфейсу Runnable. Програма, яка виконує інструкцію T.start(), не чекає на завершення завдання T: вона одразу переходить до наступної інструкції. Таким чином, маємо два завдання, що виконуються паралельно. Часто їм потрібно мати можливість спілкуватися між собою, щоб знати, на якому етапі знаходиться спільна робота, яку потрібно виконати. Це і є проблема синхронізації потоків.
  • Після запуску потік thread виконується автономно. Він зупиниться, коли функція run, яку він виконує, завершить свою роботу.
  • Можна дочекатися завершення виконання потоку T за допомогою T.join(). Це блокуюча інструкція: програма, яка її виконує, залишається заблокованою, доки завдання T не завершить свою роботу. Це також є одним із засобів синхронізації.

Розглянемо таку програму:

// використання потоків

import java.io.*;
import java.util.*;

public class thread2{
    public static void main(String[] arg) {
         // ініціалізація поточного потоку
        Thread main=Thread.currentThread();
         // присвоєння імені поточному потоку
        main.setName("myMainThread");
         // початок функції main
        System.out.println("début du thread " +main.getName());

         // створення потоків виконання
        Thread[] tâches=new Thread[5];
        for(int i=0;i<tâches.length;i++){
             // створення потоку i
            tâches[i]=new Thread() {
          public void run() {
            affiche();
        }
      };//завдання[i]
             // встановлення імені потоку
            tâches[i].setName(""+i);
             // запуск виконання потоку i
            tâches[i].start();
        }//for

         // кінець функції main
        System.out.println("fin du thread " +main.getName());
    }//Main

    public static void affiche() {
         // отримано час
    Calendar calendrier=Calendar.getInstance();
    String H=calendrier.get(Calendar.HOUR_OF_DAY)+":"
      +calendrier.get(Calendar.MINUTE)+":"
      +calendrier.get(Calendar.SECOND);
         // відображення початку виконання
        System.out.println("Début d'exécution de la méthode affiche dans le Thread " + 
        Thread.currentThread().getName()+ " : " + H);
         // перехід у режим сну на 1 с
        try{
        Thread.sleep(1000);
    }catch (Exception ex){}
     // отримання часу
    calendrier=Calendar.getInstance();    
    H=calendrier.get(Calendar.HOUR_OF_DAY)+":"
      +calendrier.get(Calendar.MINUTE)+":"
      +calendrier.get(Calendar.SECOND);
         // відображення завершення виконання
        System.out.println("Fin d'exécution de la méthode affiche dans le Thread " 
    +Thread.currentThread().getName()+ " : " + H);
    }// відображення
}//клас

Головний потік, який виконує функцію main, створює ще 5 потоків, що відповідають за виконання статичного методу affiche. Результати такі:


début du thread myMainThread
Début d'exécution de la méthode affiche dans le Thread 0 : 15:48:3
fin du thread myMainThread
Début d'exécution de la méthode affiche dans le Thread 1 : 15:48:3
Début d'exécution de la méthode affiche dans le Thread 2 : 15:48:3
Début d'exécution de la méthode affiche dans le Thread 3 : 15:48:3
Début d'exécution de la méthode affiche dans le Thread 4 : 15:48:3
Fin d'exécution de la méthode affiche dans le Thread 0 : 15:48:4
Fin d'exécution de la méthode affiche dans le Thread 1 : 15:48:4
Fin d'exécution de la méthode affiche dans le Thread 2 : 15:48:4
Fin d'exécution de la méthode affiche dans le Thread 3 : 15:48:4
Fin d'exécution de la méthode affiche dans le Thread 4 : 15:48:4

Ці результати є дуже показовими:

  • по-перше, видно, що запуск виконання потоку не є блокуючим. Метод main запустив виконання 5 потоків паралельно і завершив своє виконання раніше за них. Операція
             // запускається виконання потоку i
            tâches[i].start();

запускає виконання потоку tâches[i], але після цього виконання негайно продовжується з наступною інструкцією, не чекаючи на завершення виконання потоку.

  • Усі створені потоки повинні виконати метод affiche. Порядок виконання є непередбачуваним. Навіть якщо в цьому прикладі порядок виконання, здається, відповідає порядку запуску потоків, з цього не можна робити загальних висновків. Операційна система в даному випадку має 6 потоків і один процесор. Вона розподілятиме ресурси процесора між цими 6 потоками згідно з власними правилами.
  • У результатах ми бачимо наслідок виконання методу sleep. У цьому прикладі саме потік 0 першим виконує метод affiche. З’являється повідомлення про початок виконання, після чого він виконує метод sleep, який призупиняє його на 1 секунду. Тоді він втрачає доступ до процесора, який стає доступним для іншого потоку. Приклад показує, що його отримає потік 1. Потік 1 пройде той самий шлях, що й інші потоки. Коли секунда очікування потоку 0 закінчиться, його виконання може поновитися. Система надає йому процесор, і він може завершити виконання методу affiche.

Змінимо нашу програму, щоб завершити метод main за допомогою таких інструкцій:

         // завершення функції main
        System.out.println("fin du thread " +main.getName());
     // завершення роботи програми
    System.exit(0);

Виконання нової програми дає:


début du thread myMainThread
Début d'exécution de la méthode affiche dans le Thread 0 : 16:5:45
Début d'exécution de la méthode affiche dans le Thread 1 : 16:5:45
Début d'exécution de la méthode affiche dans le Thread 2 : 16:5:45
Début d'exécution de la méthode affiche dans le Thread 3 : 16:5:45
fin du thread myMainThread
Début d'exécution de la méthode affiche dans le Thread 4 : 16:5:45

Як тільки метод main виконує інструкцію:

    System.exit(0);

вона зупиняє всі потоки програми, а не лише потік main. Метод main може чекати завершення виконання потоків, які він створив, перш ніж завершити свою роботу. Це можна зробити за допомогою методу join класу Thread:

     // очікування завершення всіх потоків
        for(int i=0;i<tâches.length;i++){
            // очікування потоку i
            tâches[i].join();
    }//цикл  

         // завершення функції main
        System.out.println("fin du thread " +main.getName());
     // зупинка програми
    System.exit(0);

У результаті отримуємо такі результати:

début du thread myMainThread
Début d'exécution de la méthode affiche dans le Thread 0 : 16:11:9
Début d'exécution de la méthode affiche dans le Thread 1 : 16:11:9
Début d'exécution de la méthode affiche dans le Thread 2 : 16:11:9
Début d'exécution de la méthode affiche dans le Thread 3 : 16:11:9
Début d'exécution de la méthode affiche dans le Thread 4 : 16:11:9
Fin d'exécution de la méthode affiche dans le Thread 0 : 16:11:10
Fin d'exécution de la méthode affiche dans le Thread 1 : 16:11:10
Fin d'exécution de la méthode affiche dans le Thread 2 : 16:11:10
Fin d'exécution de la méthode affiche dans le Thread 3 : 16:11:10
Fin d'exécution de la méthode affiche dans le Thread 4 : 16:11:10
fin du thread myMainThread

7.3. Значення потоків

Тепер, коли ми виявили наявність потоку за замовчуванням, який виконує метод Main, і знаємо, як створювати інші, зупинимося на тому, у чому для нас полягає користь потоків і чому ми їх тут розглядаємо. Існує тип додатків, які добре підходять для використання потоків, — це клієнт-серверні додатки в Інтернеті. У такому додатку сервер, розташований на машині S1, відповідає на запити клієнтів, розташованих на віддалених машинах C1, C2, ..., Cn.

Image

Ми щодня користуємося інтернет-додатками, що відповідають цій схемі: веб-сервіси, електронна пошта, перегляд форумів, передача файлів... На наведеній вище схемі сервер S1 повинен одночасно обслуговувати клієнтів Ci. Якщо взяти за приклад сервер FTP (File Transfer Protocol), який передає файли своїм клієнтам, то ми знаємо, що передача файлу іноді може тривати кілька годин. Звісно, не може бути й мови про те, щоб один клієнт монополізував сервер на такий тривалий час. Зазвичай сервер створює стільки потоків виконання, скільки є клієнтів. Кожен потік відповідає за обслуговування конкретного клієнта. Оскільки процесор циклічно розподіляється між усіма активними потоками машини, сервер приділяє трохи часу кожному клієнту, забезпечуючи таким чином одночасність обслуговування.

Image

7.4. Графічний годинник

Розглянемо наступний додаток, який відображає вікно з годинником та кнопкою для зупинки або перезапуску годинника:

Щоб годинник працював, необхідний процес, який щосекунди оновлює час. Водночас потрібно відстежувати події, що відбуваються у вікні: коли користувач натисне кнопку «Зупинити», годинник слід зупинити. Тут маємо дві паралельні та асинхронні задачі: користувач може натиснути кнопку в будь-який момент.

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

Рішенням цієї проблеми було б таке: коли користувач натискає кнопку «Запустити», запускається завдання для управління годинником, але програма може продовжувати обробляти події, що відбуваються у вікні. Тоді ми матимемо два окремі завдання, які виконуватимуться паралельно:

  • управління годинником
  • прислуховування до подій у вікні

Повернімося до нашого графічного годинника:

тип
назва
роль
1
JTextField (Редагувати=false)
txtHorloge
відображає час
2
JButton
btnGoStop
зупиняє або запускає годинник

Корисний код додатка, створеного за допомогою JBuilder, виглядає так:

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import java.util.*;

public class interfaceHorloge extends JFrame {
  JPanel contentPane;
  JTextField txtHorloge = new JTextField();
  JButton btnGoStop = new JButton();

  // атрибути екземпляра
  boolean finHorloge=true;

   //Створити фрейм
  public interfaceHorloge() {
    enableEvents(AWTEvent.WINDOW_EVENT_MASK);
    try {
      jbInit();
    }
    catch(Exception e) {
      e.printStackTrace();
    }
  }

  private void runHorloge(){
    // цикл триває, доки не отримаємо команду зупинитися
    while( ! finHorloge){
       // отримуємо час
      Calendar calendrier=Calendar.getInstance();
      String H=calendrier.get(Calendar.HOUR_OF_DAY)+":"
      +calendrier.get(Calendar.MINUTE)+":"
      +calendrier.get(Calendar.SECOND);
       // виводимо її в поле T
      txtHorloge.setText(H);
       // очікування протягом однієї секунди
      try{
        Thread.sleep(1000);
      } catch (Exception e){
        // вихід з помилкою
        System.exit(1);
      }//try
    }// while
  }// runHorloge

   //Ініціалізувати компонент
  private void jbInit() throws Exception  {
...................
  }

   //Замінено, щоб ми могли вийти, коли вікно закрито
  protected void processWindowEvent(WindowEvent e) {
.............
  }

  void btnGoStop_actionPerformed(ActionEvent e) {
    // запускаємо/зупиняємо годинник
     // отримуємо напис кнопки
    String libellé=btnGoStop.getText();
    // запустити?
    if(libellé.equals("Lancer")){
      // створюємо потік, у якому буде виконуватися таймер
      Thread thHorloge=new Thread(){
        public void run(){
          runHorloge();
        }
      };//завершення потоку
       // дозволяємо потоку запуститися
      finHorloge=false;
       // змінюємо напис на кнопці
      btnGoStop.setText("Arrêter");
      // запускаємо потік
      thHorloge.start();
      // кінець
      return;
    }//if
    // зупинити
    if(libellé.equals("Arrêter")){
      // наказуємо потоку зупинитися
      finHorloge=true;
       // змінюємо напис на кнопці
      btnGoStop.setText("Lancer");
       // кінець
      return;
    }//if
  }
} 

Коли користувач натискає кнопку «Запустити», створюється потік за допомогою анонімного класу:

      Thread thHorloge=new Thread(){
        public void run(){
          runHorloge();
        }

Метод run потоку перенаправляє до методу runHorloge програми. Після цього потік запускається:

      // запускаємо потік
      thHorloge.start();

Потім виконується метод runHorloge:

  private void runHorloge(){
    // виконується цикл, доки не буде отримано команду на зупинку
    while( ! finHorloge){
       // отримуємо час
      Calendar calendrier=Calendar.getInstance();
      String H=calendrier.get(Calendar.HOUR_OF_DAY)+":"
      +calendrier.get(Calendar.MINUTE)+":"
      +calendrier.get(Calendar.SECOND);
       // виводимо її в поле T
      txtHorloge.setText(H);
       // очікування протягом однієї секунди
      try{
        Thread.sleep(1000);
      } catch (Exception e){
        // вивід з помилкою
        System.exit(1);
      }//спроба
    }// while
  }// runHorloge

Принцип роботи методу такий:

  1. відображає поточний час у текстовому полі txtHorloge
  2. затримується на 1 секунду
  3. повторює крок 1, попередньо перевіривши логічне значення finHorloge, яке буде встановлено в «true», коли користувач натисне кнопку Arrêter.

7.5. Аплет-годинник

Перетворюємо попередню графічну програму на аплет за звичним методом і створюємо такий документ HTML appletHorloge.htm:

<html>
  <head>
    <title>Applet Horloge</title>
  </head>
  <body>
      <h2>Une applet horloge</h2>
    <applet
      code="appletHorloge.class"
      width="150"
      height="130"
    ></applet>
    </center>
  </body>
</html>

Коли ми завантажуємо цей документ безпосередньо в IE, двічі клацнувши по ньому, ми отримуємо таке зображення:

Image

У цьому прикладі всі елементи, необхідні для аплету, знаходяться в одній папці:

E:\data\serge\Jbuilder\horloge\1>dir
13/06/2002  12:17                3 174 appletHorloge.class
13/06/2002  12:17                  658 appletHorloge$1.class
13/06/2002  12:17                  512 appletHorloge$2.class
13/06/2002  12:20                  245 appletHorloge.htm

Наш аплет можна вдосконалити. Ми вже зазначали, що під час завантаження аплету виконується метод init, а потім — метод start, якщо він існує. Крім того, коли користувач залишає сторінку, виконується метод stop, якщо він існує. Коли він повертається на сторінку аплету, знову викликається метод start. Коли аплет використовує потоки візуальної анімації, для запуску та зупинки цих потоків часто використовуються методи start та stop самого аплету. Адже немає сенсу, щоб потік візуальної анімації продовжував працювати у фоновому режимі, коли анімація прихована.

Тому ми додаємо до нашого аплету такі методи: start та stop:

  public void stop(){
    // сторінка прихована
     // відстеження
    System.out.println("Page stop");
     // сторінка прихована — потік зупиняється
    finHorloge=true;
  }

  public void start(){
     // сторінка знову з'являється
     // продовження
    System.out.println("Page start");
     // за необхідності запускається новий циклічний потік
    if(btnGoStop.getText().equals("Arrêter")){
      // змінюємо напис
      btnGoStop.setText("Lancer");
       // і імітується, ніби користувач натиснув на нього
      btnGoStop_actionPerformed(null);
    }//if
  }//start

Крім того, ми додали моніторинг у метод run потоку, щоб дізнатися, коли він запускається та зупиняється:

  private void runHorloge(){
    // відстеження
    System.out.println("Thread horloge lancé");
    // виконуємо цикл, доки нам не скажуть зупинитися
    while( ! finHorloge){
       // отримуємо час
      Calendar calendrier=Calendar.getInstance();
      String H=calendrier.get(Calendar.HOUR_OF_DAY)+":"
      +calendrier.get(Calendar.MINUTE)+":"
      +calendrier.get(Calendar.SECOND);
       // виводимо її в поле T
      txtHorloge.setText(H);
       // очікування протягом однієї секунди
      try{
        Thread.sleep(1000);
      } catch (Exception e){
        // вихід з помилкою
        return;
      }//try
    }// while
    // продовження
    System.out.println("Thread horloge terminé");
  }// runHorloge

Тепер запускаємо аплет за допомогою AppletViewer:

E:\data\serge\Jbuilder\horloge\1>appletviewer appletHorloge.htm
Page start    // аплет запущено — сторінка відображена
Thread horloge lancé    // відповідно запущено потік
Page stop    // аплет згорнуто в іконку
Thread horloge terminé    // потік відповідно зупинено
Page start    // аплет знову відображено
Thread horloge lancé    // потік запущено повторно
Thread horloge terminé    // натискання кнопки «Зупинити»
Thread horloge lancé    // натискання кнопки «Запустити»
Page stop    // аплет у вигляді піктограми    
Thread horloge terminé    // потік відповідно зупинено
Page start    // повторне відображення аплету
Thread horloge lancé    // потоку відповідно запущено знову

У випадку з AppletViewer подія start відбувається, коли вікно AppletViewer є видимим, а подія stop — коли його згорнуто в іконку. Наведені вище результати показують, що коли документ HTML приховано, потік успішно зупиняється, якщо він був активним.

7.6. Синхронізація завдань

У нашому попередньому прикладі було два завдання:

  • головне завдання, представлене самою програмою
  • завдання, відповідальне за годинник

Координацію між цими двома завданнями забезпечувало головне завдання, яке встановлювало логічне значення для зупинки потоку годинника. Тепер ми розглянемо проблему паралельного доступу завдань до спільних ресурсів, відому також як «спільне використання ресурсів». Щоб проілюструвати її, спочатку розглянемо приклад.

7.6.1. Несинхронізований підрахунок

Розглянемо такий графічний інтерфейс:

Image

тип
назва
роль
1
JTextField
txtAGénérer
вказує кількість потоків, які потрібно створити
2
JTextfield
(нередаговане)
txtGénéres
вказує кількість створених потоків
3
JTextField
(нередаговане)
txtStatus
надає інформацію про виявлені помилки та про саму програму
4
JButton
btnGénérer
запускає створення потоків

Принцип роботи програми такий:

  • користувач вказує кількість потоків, які потрібно створити, у полі 1
  • запускає створення цих потоків за допомогою кнопки 4
  • потоки зчитують значення поля 2, збільшують його та відображають нове значення. Спочатку це поле містить значення 0.

Створені потоки спільно використовують один ресурс: значення поля 2. Ми прагнемо продемонструвати тут проблеми, що виникають у такій ситуації. Ось приклад виконання:

Image

Бачимо, що було задано створення 1000 потоків, а налічено лише 7. Корисний код програми такий:

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;

public class interfaceSynchro extends JFrame {
  JPanel contentPane;
  JLabel jLabel1 = new JLabel();
  JTextField txtAGénérer = new JTextField();
  JButton btnGénérer = new JButton();
  JTextField txtStatus = new JTextField();
  JTextField txtGénérés = new JTextField();
  JLabel jLabel2 = new JLabel();

   // змінні екземпляра
    Thread[] tâches=null;   // потоки
    int[] compteurs=null;   // лічильники

   //Створити фрейм
  public interfaceSynchro() {
..........
  }

   //Ініціалізація компонента
  private void jbInit() throws Exception  {
......................
  }

   //Замінено, тому ми можемо вийти, коли вікно закрито
  protected void processWindowEvent(WindowEvent e) {
..................
  }

  void btnGénérer_actionPerformed(ActionEvent e) {
     // генерація потоків

     // зчитуємо кількість потоків, які потрібно створити
    int nbThreads=0;
    try{
      // читання поля, що містить кількість потоків
      nbThreads=Integer.parseInt(txtAGénérer.getText().trim());
      // позитивне значення >
      if(nbThreads<=0) throw new Exception();
    }catch(Exception ex){
       //помилка
      txtStatus.setText("Nombre invalide");
       // повторюємо
      txtAGénérer.requestFocus();
      return;
    }//перехоплення

     // на початку не було створено жодного потоку
    txtGénérés.setText("0");  // лічильник завдань дорівнює 0

     // створюємо та запускаємо потоки
    tâches=new Thread[nbThreads];
    compteurs=new int[nbThreads];
    for(int i=0;i<tâches.length;i++){
      // створюється потік i
      tâches[i]=new Thread() {
          public void run() {
          incrémente();
        }
      };//потік i
       // визначається його ім'я
      tâches[i].setName(""+i);
      // запускається його виконання
      tâches[i].start();
    }//for
  }//генерувати

   // збільшуємо
  private void incrémente(){
     // отримуємо номер потоку
    int iThread=0;
    try{
      iThread=Integer.parseInt(Thread.currentThread().getName());
    }catch(Exception ex){}
     // зчитується значення лічильника завдань
    try{
      compteurs[iThread]=Integer.parseInt(txtGénérés.getText());
    } catch (Exception e){}
     // збільшуємо його
    compteurs[iThread]++;

     // зачекати 100 мілісекунд — після чого потік втратить доступ до процесора
    try{
      Thread.sleep(100);
    } catch (Exception e){
      System.exit(0);
    }

     // виводимо нове значення лічильника
    txtGénérés.setText("");
    txtGénérés.setText(""+compteurs[iThread]);
    // спостереження
    System.out.println("Thread " + iThread + " : " + compteurs[iThread]);
  }// збільшується

}// клас

Розглянемо код детальніше:

  • у вікні оголошено дві екземплярні змінні:
   // змінні екземпляру
    Thread[] tâches=null;   // потоки
    int[] compteurs=null;   // лічильники

Масив tâches буде масивом згенерованих потоків. Масив compteurs буде пов'язаний з масивом tâches. Кожне завдання матиме власний лічильник для отримання значення поля txtGénérés з графічного інтерфейсу.

  • При натисканні кнопки Générer виконується метод btnGénérer_actionPerformed.
  • Цей метод спочатку отримує кількість потоків, які потрібно створити. За необхідності, якщо ця кількість є неприйнятною, повідомляється про помилку. Потім він створює запитувані потоки, записуючи їхні посилання в масив і присвоюючи кожному з них номер. Метод run, що обробляє згенеровані потоки, переходить до методу incrémente цього класу. Усі потоки запускаються (start). Також створюється масив лічильників, пов’язаних із потоками.
  • метод incrémente:
  • зчитує поточне значення поля txtGénérés і зберігає його в лічильнику, що належить потоку, який зараз виконується
  • затримується на 100 мс, щоб навмисно звільнити процесор
  • виводить нове значення в поле txtGénérés

Тепер пояснимо, чому підрахунок потоків є некоректним. Припустимо, що потрібно згенерувати 2 потоки. Вони виконуються у непередбачуваному порядку. Один із них проходить першим і зчитує значення 0 з лічильника. Потім він змінює його на 1, але не записує це у вікно: він навмисно призупиняється на 100 мс. У результаті він втрачає доступ до процесора, який передається іншому потоку. Цей потік діє так само, як і попередній: він зчитує лічильник з вікна і отримує 0, яке там все ще знаходиться. Він змінює значення лічильника на 1 і, як і попередній, призупиняється на 100 мс. Процесор знову надається першому потоку: той запише значення 1 у лічильник вікна та завершить роботу. Тепер процесор надається другому потоку, який також запише 1. Ми отримуємо неправильний результат.

У чому полягає проблема? Другий потік зчитав неправильне значення через те, що перший потік був перерваний до завершення своєї роботи, яка полягала в оновленні лічильника у вікні. Це підводить нас до поняття критичного ресурсу та критичної ділянки програми:

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

7.6.2. Синхронізований підрахунок за допомогою методу

У попередньому прикладі кожен потік виконував метод incrémente вікна. Метод incrémente був оголошений наступним чином:

    private void incremente()

Тепер ми оголошуємо його інакше:

   // інкрементувати
  private synchronized void incrémente(){

Ключове слово synchronized означає, що метод incrémente може виконувати лише один потік одночасно. Розглянемо такі позначення:

  • об’єкт вікна F, який створює потоки в btnGénérer_actionPerformed
  • два потоки T1 та T2, створені об’єктом F

Обидва потоки створюються об’єктом F, а потім запускаються. Отже, обидва вони виконають метод F.run. Припустимо, що T1 прибуває першим. Він виконує F.run, а потім F.incremente, який є синхронізованим методом. Він зчитує значення лічильника, рівне 0, збільшує його на 1, а потім зупиняється на 100 мс. Після цього процесор передається T2, який, у свою чергу, виконує F.run, а потім F.incremente. І тут він блокується, оскільки потік T1 саме виконує F.incremente, а ключове слово synchronized гарантує, що одночасно лише один потік може виконувати F.incremente. Тоді T2, у свою чергу, втрачає доступ до процесора, не встигнувши зчитати значення лічильника. Через 100 мс T1 знову отримує доступ до процесора, виводить значення лічильника 1, виходить з F.incremente, а потім з F.run і завершує роботу. Тоді T2 отримує доступ до процесора і цього разу може виконати F.incremente, оскільки T1 вже не виконує цей метод. T2 зчитує значення лічильника 1, збільшує його на 1 і зупиняється на 100 мс. Через 100 мс він знову отримує доступ до процесора, виводить значення лічильника 2 і також завершує роботу. Цього разу отримане значення є правильним. Ось перевірений приклад:

Image

7.6.3. Підрахунок, синхронізований об’єктом

У попередньому прикладі доступ до лічильника txtGénérés було синхронізовано за допомогою методу. Якщо вікно, яке створює потоки, називається F, можна також сказати, що метод F.incremente представляє ресурс, який мав використовуватися лише одним потоком одночасно. Отже, це критичний ресурс. Синхронізований доступ до цього ресурсу було забезпечено за допомогою ключового слова synchronized:

    private synchronized void incrémente()

Можна також сказати, що критичним ресурсом є сам об’єкт F. Це більш суворе обмеження, ніж у випадку, коли критичним ресурсом є F.incremente. Адже в останньому випадку, якщо потік T1 виконує F.incremente, потік T2 не зможе виконати F.incremente, але зможе виконати інший метод об’єкта F, незалежно від того, чи є він синхронізованим, чи ні. У випадку, коли об’єкт F сам є критичним ресурсом, коли потік T1 виконує синхронізовану секцію цього об’єкта, будь-яка інша синхронізована секція об’єкта стає недоступною для інших потоків. Таким чином, якщо потік T1 виконує синхронізований метод F.incremente, потік T2 не зможе виконати не тільки F.incremente, але й будь-яку іншу синхронізовану ділянку об’єкта F, навіть якщо жоден потік її не використовує. Отже, це більш обмежуючий метод.

Припустимо, що вікно стає критичним ресурсом. Тоді ми напишемо:

   // інкремент
  private void incrémente(){
     // критична секція
    synchronized(this){
       // отримуємо номер потоку
      int iThread=0;
      try{
        iThread=Integer.parseInt(Thread.currentThread().getName());
      }catch(Exception ex){}
       // зчитування значення лічильника завдань
      try{
        compteurs[iThread]=Integer.parseInt(txtGénérés.getText());
      } catch (Exception e){}
       // збільшуємо його
      compteurs[iThread]++;

       // очікуємо 100 мілісекунд — після чого потік втратить доступ до процесора
      try{
        Thread.sleep(100);
      } catch (Exception e){
        System.exit(0);
      }

       // виводимо нове значення лічильника
      txtGénérés.setText("");
      txtGénérés.setText(""+compteurs[iThread]);
    }//synchronized
  }// збільшується

Усі потоки використовують вікно this для синхронізації. Під час виконання отримуємо ті самі правильні результати, що й раніше. Насправді можна синхронізуватися за допомогою будь-якого об’єкта, відомого всім потокам. Ось, наприклад, інший метод, який дає ті самі результати:

   // змінні екземпляру
    Thread[] tâches=null;   // потоки
    int[] compteurs=null;   // лічильники
    Object synchro=new Object(); // об'єкт синхронізації потоків

   // інкрементує
  private void incrémente(){
     // критичний розділ
    synchronized(synchro){
..............
    }//синхронізовано
  }// збільшується

Вікно створює об’єкт типу Object, який слугуватиме для синхронізації потоків. Цей метод кращий за той, що синхронізується з об’єктом this, оскільки є менш обмежувальним. У цьому випадку, якщо потік T1 перебуває в синхронізованій секції об’єкта incrémente, апотік T2 захоче виконати іншу синхронізовану секцію того самого об’єкта this, але синхронізовану за допомогою іншого об’єкта, ніж synchro, він зможе це зробити.

7.6.4. Синхронізація за подіями

Цього разу ми використовуємо булеве значення peutPasser, щоб повідомити потоку, чи може він увійти в критичну ділянку. Код без синхронізації міг би виглядати так:

while(! peutPasser);        // очікується, що peutPasser набуде значення «true»
peutPasser=false;            // жодний інший потік не повинен пройти
section critique;            // тут потік знаходиться сам
peutPasser=true;            // інший потік може перейти в критичну секцію

Перша інструкція, в якій потік зациклюється, чекаючи, поки peutPasser не стане істинним, є недосконалою: потік даремно забирає ресурси процесора. Це називається активним очікуванням. Код можна вдосконалити таким чином:

while(! peutPasser){        // очікуємо, поки peutPasser не стане істинним
   Thread.sleep(100);    // зупинка на 100 мс
}
peutPasser=false;            // жодний інший потік не повинен перейти
section critique;            // тут потік знаходиться сам
peutPasser=true;            // інший потік може увійти в критичну ділянку

Цей цикл очікування є кращим: якщо потік не може пройти, він переходить у режим очікування на 100 мс, а потім знову перевіряє, чи може він пройти. Тим часом процесор буде виділено іншим потокам системи.

Насправді обидва ці методи є некоректними: вони не запобігають одночасному входженню двох потоків у критичну ділянку. Припустимо, що потік T1 виявляє, що peutPasser має значення «true». Тоді він перейде до наступної інструкції, де знову встановить peutPasser на «false», щоб заблокувати інші потоки. Однак у цей момент його цілком можуть перервати — або тому, що його частка процесорного часу вичерпалася, або тому, що завдання з вищим пріоритетом зажадало процесор, або з іншої причини. У результаті він втрачає доступ до процесора. Він отримає його назад трохи пізніше. Тим часом доступ до процесора отримають інші завдання, серед яких, можливо, буде потік T2, що зациклюється, чекаючи, поки peutPasser не стане істинним. Він також виявить, що peutPasser перебуває у стані «true» (перший потік не встиг перевести його у стан «false»), і теж перейде у критичну секцію. А цього не слід було робити.

Послідовність


while(! peutPasser){            // очікуємо, поки peutPasser не стане істинним
   try{
        Thread.sleep(100);    // зупинка на 100 мс
    } catch (Exception e) {}
}// while
peutPasser=false;                // жодний інший потік не повинен перейти

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


    synchronized(synchro){
        while(! peutPasser){            // очікуємо, поки peutPasser не стане істинним
            try{
                Thread.sleep(100);    // зупинка на 100 мс
            } catch (Exception e) {}
        }//while
        peutPasser=false;                // жодний інший потік не повинен пройти
    }// synchronized
    section critique;                    // тут потік працює самостійно
    peutPasser=true;                    // інший потік може пройти в критичну секцію

Цей приклад працює коректно. Його можна вдосконалити, уникнувши напівактивного очікування потоку, коли він регулярно перевіряє значення логічного оператора peutPasser. Замість того, щоб регулярно прокидатися кожні 100 мс для перевірки стану peutPasser, він може перейти в режим очікування та попросити пробудити його, коли peutPasser набуде значення «true». Це записується наступним чином:


synchronized(synchro){
    if (! peutPasser) {
        try{
            synchro.wait();            // якщо пройти неможливо, то чекаємо
        } catch (Exception e){
            
        }
    }
    peutPasser=false;            // жодний інший потік не повинен проходити
}// synchronized

Операція synchro.wait() може бути виконана лише потоком, який на даний момент є «власником» об’єкта synchro. У даному випадку це послідовність:


synchronized(synchro){

}// synchronized

гарантує, що потік є власником об’єкта synchro. За допомогою операції synchro.wait() потік передає право власності на блокування синхронізації. Чому так відбувається? Зазвичай тому, що йому бракує ресурсів для продовження роботи. Тож замість того, щоб блокувати інші потоки, які очікують на ресурс synchro, він віддає його та переходить у стан очікування на ресурс, якого йому бракує. У нашому прикладі він чекає, поки булеве значення peutPasser стане істинним. Як він отримає повідомлення про цю подію? Ось так:


synchronized(synchro){
    if (! peutPasser) {
        try{
            synchro.wait();            // якщо не можна перейти, то чекаємо
        } catch (Exception e){
            
        }
    }
    peutPasser=false;            // жодний інший потік не повинен пройти
}// synchronized
section critique...
synchronized(synchro){
      synchro.notify();
    }

Розглянемо перший потік, який проходить через блокування синхронізації. Назвемо його T1. Уявімо, що він знаходить булеве значення peutPasser рівним «true», оскільки він перший. Отже, він змінює його на «false». Потім він виходить із критичної секції, заблокованої об’єктом synchro. Тоді інший потік зможе увійти в критичну ділянку, щоб перевірити значення peutPasser. Він виявить, що воно має значення «false», і перейде в стан очікування події (wait). При цьому він передає право власності на об’єкт synchro. Тоді інший потік може увійти в критичну ділянку: він також перейде в режим очікування, оскільки peutPasser має значення «false». Отже, може бути кілька потоків, що очікують на подію на об’єкті synchro.

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


synchronized(synchro){
      synchro.notify();
    }

Спочатку він повинен повернути собі об’єкт synchro за допомогою інструкції synchronized. Це не повинно становити проблеми, оскільки він конкурує з потоками, які, якщо тимчасово отримають об’єкт synchro, мусять відмовитися від нього за допомогою команди wait, оскільки вони виявляють, що peutPasser є недійсним. Отже, наш потік T1 зрештою отримає право власності на об’єкт synchro. Зробивши це, він за допомогою операції synchro.notify вказує, що один із потоків, заблокованих операцією synchro.wait, має бути розблокований. Потім він знову відмовляється від права власності на об’єкт synchro, яке буде передано одному з потоків, що перебувають у стані очікування. Цей потік продовжує виконання з інструкції, що йде після wait, яка поставила його в режим очікування. У свою чергу, він виконає критичну секцію та виконає synchro.notify, щоб звільнити інший потік. І так далі.

Розглянемо цей режим роботи на прикладі підрахунку, який ми вже вивчали.

  void btnGénérer_actionPerformed(ActionEvent e) {
     //створення потоків

     // зчитуємо кількість потоків, які потрібно створити
    int nbThreads=0;
    try{
      // читання поля, що містить кількість потоків
      nbThreads=Integer.parseInt(txtAGénérer.getText().trim());
      // позитивне >
      if(nbThreads<=0) throw new Exception();
    }catch(Exception ex){
       //помилка
      txtStatus.setText("Nombre invalide");
       // повторюємо спробу
      txtAGénérer.requestFocus();
      return;
    }//перехоплення

     // RAZ лічильник завдань
    txtGénérés.setText("0");  // лічильник завдань дорівнює 0
    // перший потік може пройти
    peutPasser=true;
     // генеруються та запускаються потоки
    tâches=new Thread[nbThreads];
    compteurs=new int[nbThreads];
    for(int i=0;i<tâches.length;i++){
      // створюється потік i
      tâches[i]=new Thread() {
          public void run() {
          synchronise();
        }
      };//потік i
       // визначається його ім'я
      tâches[i].setName(""+i);
      // запускаємо його виконання
      tâches[i].start();
    }//for
  }//генерувати

Тепер потоки більше не виконують метод incrémente, а наступний метод synchronise:

   // етап синхронізації потоків
  public void synchronise(){
     // запитується доступ до критичної секції
    synchronized(synchro){
      try{
        // чи можна пройти?
        if(! peutPasser){
          System.out.println(Thread.currentThread().getName()+ " en attente");
          synchro.wait();
        }
         // пройшли — забороняємо іншим потокам проходити
        peutPasser=false;
      } catch(Exception e){
        txtStatus.setText(""+e);
                    return;
      }//try
    }// synchronized

    // критичний сегмент
    System.out.println(Thread.currentThread().getName()+ " passé");
    incrémente();

     // закінчено — звільняємо можливий потік, заблокований на вході до критичної секції
    peutPasser=true;
    System.out.println(Thread.currentThread().getName()+ " terminé");
    synchronized(synchro){
      synchro.notify();
    }// synchronized
  } // синхронізується

Метод synchronise призначений для послідовного переходу між потоками. Для цього він використовує змінну синхронізації synchro. Метод incrémente тепер більше не захищений ключовим словом synchronized:

   // інкрементує
  private void incrémente(){
     // отримуємо номер потоку
    int iThread=0;
    try{
      iThread=Integer.parseInt(Thread.currentThread().getName());
    }catch(Exception ex){}
     // зчитується значення лічильника завдань
    try{
      compteurs[iThread]=Integer.parseInt(txtGénérés.getText());
    } catch (Exception e){}
     // збільшуємо його
    compteurs[iThread]++;
     // очікуємо 100 мілісекунд — після чого потік втратить доступ до процесора
    try{
      Thread.sleep(100);
    } catch (Exception e){
      System.exit(0);
    }
     // виводимо нове значення лічильника
    txtGénérés.setText("");
    txtGénérés.setText(""+compteurs[iThread]);
  }// збільшуємо

Для 5 потоків отримано такі результати:

0 passé
1 en attente
2 en attente
3 en attente
4 en attente
0 terminé
1 passé
1 terminé
2 passé
2 terminé
3 passé
3 terminé
4 passé
4 terminé

Нехай T0 до T4 — це 5 потоків, згенерованих додатком. T0 першим отримує блокування synchro і виявляє, що peutPasser має значення «true». Він встановлює для peutPasser значення «false» і продовжує роботу: саме в цьому полягає зміст першого повідомлення passé. Цілком ймовірно, що він продовжує роботу і виконує критичну секцію, зокрема метод incrémente. У ньому він впадає в режим очікування на 100 мс (sleep). Отже, він звільняє процесор. Процесор надається іншому потоку — потоку T1, який отримує право власності на об’єкт synchro. Він виявляє, що не може пройти, і переходить у режим очікування (wait). Тоді він звільняє володіння об’єктом synchro, а також процесор. Процесор надається потоку T2, який зазнає тієї самої долі. Протягом 100 мс зупинки T0 потоки від T1 до T4, отже, переходять у стан очікування. У цьому полягає значення 4 повідомлень «у стані очікування». Через 100 мс T0 знову отримує доступ до процесора і завершує свою роботу: саме це означає повідомлення «0 terminé». Потім він звільняє один із заблокованих потоків і завершується. Звільнений процесор тоді призначається доступному потоку: тому, що щойно був звільнений. У даному випадку це T1. Потік T1 тоді входить у критичну секцію: саме це означає повідомлення «1 passé». Він виконує необхідні дії і, у свою чергу, зупиняється на 100 мс. Процесор стає доступним для іншого потоку, але всі потоки очікують на подію: жоден із них не може отримати доступ до процесора. Через 100 мс потік T1 отримує доступ до процесора і завершується: саме це означає повідомлення «1 terminé». Потоки від T1 до T4 будуть поводитися так само, як і T1: саме це означають три серії повідомлень: «пройшов», «завершено».