Многонишково програмиране и синхронизация на нишки в Java

Светлин Наков – www.nakov.com

Многонишкови програми

Многонишковите (multithreaded) програми представляват програми, които могат да изпълняват едновременно няколко редици от програмни инструкции. Всяка такава редица от програмни инструкции наричаме thread (нишка). Изпълнението на многонишкова програма много прилича на изпълнение на няколко програми едновременно. Например в Microsoft Windows е възможно едновременно да слушаме музика, да теглим файлове от Интернет и да въвеждаме текст. Тези три действия се изпълняват от три различни програми (процеси), които работят едновременно. Когато няколко процеса в една операционна система работят едновременно, това се нарича многозадачност. Когато няколко отделни нишки в рамките на една програма работят едновременно, това се нарича multithreading (многонишковост). Например ако пишем програма, която работи като Web-сървър и Mail-сървър едновременно, то тази програма трябва да може да изпълнява едновременно поне 3 независими нишки – една за обслужване на Web заявките (по протокол HTTP), друга за изпращане на поща (по протокол SMTP) и трета за теглене на поща (по протокол POP3). Много вероятно е освен това за всеки потребител на тази програма да се създава по още една нишка, за да може този потребител да се обслужва независимо от другите и да не бъде каран да чака, докато системата обслужва останалите.

Използване на нишки в Java

С Java създаването на многонишкови програми е изключително лесно. Достатъчно е да наследим класа java.lang.Thread и да припокрием метода run(), в който да напишем програмния код на нашата нишка. След това можем да създаваме обекти от нашия клас и с извикване на метода им start() да започваме паралелно изпълнение на написания в тях програмен код. Ето един пример, който илюстрира как чрез наследяване на класа Thread можем да създадем няколко нишки, които работят едновременно в рамките на нашето приложение:

ThreadTest.java
class MyThread extends Thread {
    private String mName;
    private long mTimeInterval;
 
    public MyThread(String aName, long aTimeInterval) {
        mName = aName;
        mTimeInterval = aTimeInterval;
    }
 
    public void run() {
        try {
            while (!isInterrupted()) {
                System.out.println(mName);
                sleep(mTimeInterval);
            }
        } catch (InterruptedException intEx) {
            // Current thread interrupted by another thread
        }
    }
}
 
public class ThreadTest
{
    public static void main(String[] args) {
        MyThread thread1 = new MyThread("thread 1", 1000);
        MyThread thread2 = new MyThread("thread 2", 2000);
        MyThread thread3 = new MyThread("thread 3", 1500);
        thread1.start();
        thread2.start();
        thread3.start();
    }
}

След стартиране на тази програмка се създават и стартират 3 нишки от класа MyThread. Всяка от тях в безкраен цикъл печата на конзолата името си и изчаква някакво предварително зададено време между 1 и 2 секунди. Понеже трите нишки работят паралелно, се получава резултат подобен на следния:

thread 1

thread 2

thread 3

thread 1

thread 3

thread 1

thread 2

thread 1

...

Прекратяване изпълнението на нишки в Java

Досега разгледахме как можем да стартираме нова нишка. Често пъти освен да стартираме на нишки се налага и да спираме изпълнението на работещи нишки. Прекратяването на нишки има някои особености. То в никакъв случай не трябва да става насилствено чрез метода stop() на класа Thread. Вместо това нишката трябва учтиво да бъде помолена да прекрати работата си чрез извикване на метода й interrupt(). Затова по време на работата си всеки thread трябва от време на време да проверява, извиквайки метода isInterrupted(), дали не е помолен да прекрати работата си.

Други интересни методи на класа Thread са setPriority(), sleep() и setDaemon(), но за тях можем да прочетем повече документацията.

Синхронизация на нишки

В предходната тема изяснихме какво е нишка (thread) и как се разработват многонишкови приложения с Java. В тази тема ще се запознаем с възможностите за синхронизация при достъп до общи ресурси при многонишковото програмиране.

Конфликти при едновременен достъп до общ ресурс

Има много ситуации, в които няколко нишки едновременно осъществяват достъп до общ ресурс. Например в една банка може едновременно двама клиенти да поискат да внесат пари по една и съща сметка. Да предположим, че сметките са обекти от класа Account, а операциите върху тях се извършват от класа Bank:

class Account {
    private double mAmmount = 0;
 
    void setAmmount(double aAmmount) {
        mAmmount = aAmmount;
    }
 
    double getAmmount() {
        return mAmmount;
    }
}
 
class Bank {
    public static void deposit(Account aAcc, double aSum) {
        double oldAmmount = aAcc.getAmmount();
        double newAmmount = oldAmmount + aSum;
        aAcc.setAmmount(newAmmount);
    }
}

Нека двамата клиенти се опитат едновременно да внесат съответно 100 и 500 лева в сметката acc, която е празна. Това би могло да стане по следния начин:

Клиент 1: Bank.deposit(acc, 100);

Клиент 2: Bank.deposit(acc, 500);

Както се вижда от кода, алгоритъмът за внасяне на пари към сметка работи съвсем просто на следните три стъпки:

1)     Прочита сумата от сметката.

2)     Добавя сумата за внасяне към нея.

3)     Записва новата сума в сметката.

Ако заявките за внасяне на пари от двамата клиента се изпълняват едновременно, ще се получи следният неприятен ефект:

1)     Клиент 1 прочита сумата от сметката – 0 лева.

2)     Клиент 2 прочита сумата от сметката – също 0 лева.

3)     Клиент 1 прибавя към прочетената в стъпка 1) сума 100 лева и записва в сметката новата сума – 100 лева.

4)     Клиент 2 прибавя към прочетената в стъпка 2) сума 500 лева и записва в сметката новата сума – 500 лева.

В резултат в сметката се получават 500 вместо 600 лева, а това за една банка това е абсолютно недопустимо. Натъкнахме се на класически синхронизационен проблем.

Синхронизация

Когато няколко конкурентни нишки или няколко отделни програми се опитват едновременно да осъществят достъп до общ ресурс, често пъти се получават неприятни ефекти подобни на този с банката. Такива ефекти наричаме синхронизационни проблеми, а техниката за решаването им наричаме синхронизация.

Синхронизацията решава проблемите с конкурентния достъп до общи ресурси, като прави достъпа до тях последователен. Тя предизвиква подреждане на заявките в последователност по такъв начин, така че когато една заявка се изпълнява, всички останали я чакат и се изпълняват едва след като тя приключи. Този процес съвсем не е автоматичен и се задава от програмиста чрез средствата за синхронизация, които ни дава операционната система или платформата, за която разработваме софтуер.

Средства за синхронизация в Java. Запазена дума synchronized

В Java средствата за синхронизация са вградени в самия език и са част от самата платформа. Запазената дума synchronized предизвиква синхронизирано изпълнение на програмен блок. Това означава, че две нишки не могат едновременно да изпълняват програмен код този блок. Ако едната е започнала изпълнение на код от блока, другата ще я изчака да завърши. Проблемът с банката можем да решим много просто, като заменим декларацията на метода deposit(…) от горната програма

public static void deposit(
    Account aAcc, double aSum)

с декларацията

synchronized public static void deposit(
    Account aAcc, double aSum)

Запазената дума synchronized, зададена при декларацията на метод предизвиква синхронизиране на изпълнението на този метод по обекта, на който той принадлежи, а при статични методи – по класа, на който той принадлежи. Синхронизацията на програмен код по някакъв обект предизвиква заключване на този обект при започване на изпълнението на синхронизирания код и отключване на обекта при завършване на изпълнението на кода. Когато някоя нишка се опита да изпълни синхронизиран код, чийто обект е заключен, тя принудително изчаква отключването на този обект. Така код, синхронизиран по един и същ обект, не може да се изпълнява от две нишки едновременно и заявките за изпълнението му се изпълняват една след друга в някакъв ред. В Java синхронизацията може да става по всеки обект, защото е вградена в началния за цялата класова йерархия базов клас java.lang.Object. В горния пример чрез ключовата дума synchronized синхронизирахме достъпа до метода deposit() по банката, което означава, че двама клиенти не могат да бъдат едновременно обслужвани от нея. Въпреки, че това решава проблема, такъв подход не е правилен, защото заключва цялата банка, вместо само сметката, с която се работи. За да заключваме само сметката,  до която методът deposit() осъществява достъп, можем да използваме следния синхронизиран код:

public static void deposit(Account aAccount, double aSum) {
    synchronized (aAccount) {
        double oldAmmount = aAccount.getAmmount();
        double newAmmount = oldAmmount + aSum;
        aAccount.setAmmount(newAmmount);
    }
}

Това вече решава правилно проблема, защото заключва само сметката, която се променя, а не цялата банка. Препоръчително е когато се използва заключване на обекти, да се заключва само този обект, който се променя и то само за времето, през което се променя, а не за по-дълго, за да могат конкурентните нишки да чакат минимално при опит за достъп до него. Освен това, ако достъпът до някакъв обект трябва да е синхронизиран, той трябва да е синхронизиран навсякъде, където се работи с този обект. В противен случай полза от синхронизацията няма. Например ако в нашата банка внасянето на пари е синхронизирано, а тегленето не е синхронизирано, възможността за грешки при финансовите операции ще си остане съвсем реална.

Синхронизация с wait() и notify()

Въпреки че синхронизацията чрез запазената дума synchronized върши работа в повечето случаи, тя съвсем не е достатъчна. За това в класа java.lang.Object съществуват още няколко важни метода свързани със синхронизацията – wait(), notify() и notifyAll(). Методът wait() приспива текущата нишка по даден обект докато друга нишка не извика notify() за същия обект за да я събуди. Методът notify() събужда една (произволна) от заспалите по даден обект нишки, а notifyAll() събужда всичките. Ако по обекта няма заспали нишки, notify() и notifyAll() не правят нищо.

Изчакване на ресурс и процесорно време

Понякога за работата на една нишка е необходим ресурс, който се получава в резултат от работата на друга нишка. В този случай първата нишка трябва да изчака втората да свърши някаква работа и след това да продължи своето изпълнение. Ако първата нишка в един цикъл постоянно проверява дали втората е свършила очакваната работа, тя ще консумира по неразумен начин много процесорно време и ще намали производителността на цялата система. Много по-ефективно е първата нишка да заспи, очаквайки събуждане от втората, а втората да свърши очакваната работа и веднага след това да събуди първата. Характерното за заспалите (или както още се наричат блокирали) нишки е, че не консумират процесорно време, което е причината приспиването на нишки да бъде предпочитан начин за чакане на ресурси.

Проблемът „производител-потребител”

Да разгледаме един класически проблем, известен като “производител – потребител”. Един завод произвежда някаква продукция и разполага със складове в които може да побере някакво определено количество от нея. Когато складовете се напълнят заводът спира работа докато не продаде част от продукцията за да освободи място. Търговците от време на време идват в складовете и купуват част от произведената продукция. Когато търговец дойде и складът е празен, той чака докато заводът произведе продукция, за да му я продаде. Взаимодействието между производителя (завода) и потребителите (търговците) представлява постоянен процес, в който всеки върши своята работа, но същевременно зависи от другите и ги изчаква ако е необходимо. Проблемът “производител – потребител” се изразява в това да се организира коректно многонишковият процес на взаимодействие между производителя и потребителите без да се отнема излишно процесорно време когато някой чака някого за някакъв ресурс. Нека ресурсите, които производителят произвежда и потребителите консумират са текстови съобщения, а буферът, с който производителят разполага, е опашка с вместимост 5 съобщения. Следната е програма е примерно решение на проблема, реализирано на базата на средствата за синхронизация в Java:

ProducerConsumerTest.java
import java.util.*;
 
class SharedQueue {
    private static final int QUEUE_SIZE = 5;
    private Vector mQueue = new Vector();
 
    public synchronized void put(String aObject)
            throws InterruptedException {
        while (mQueue.size() == QUEUE_SIZE)
            wait();
        mQueue.addElement(aObject);
        notify();
    }
 
    public synchronized Object get()
            throws InterruptedException {
        while (mQueue.size() == 0)
            wait();
        String message = (String) mQueue.firstElement();
        mQueue.removeElement(message);
        notify();
        return message;
    }
}
 
class Producer extends Thread {
    private SharedQueue mSharedQueue;
 
    public Producer(SharedQueue aSharedQueue) {
        mSharedQueue = aSharedQueue;
    }
 
    public void run() {
        try {
            while (true) {
                String message = new Date().toString();
                System.out.println("producer : put " + message);
                mSharedQueue.put(message);
                sleep(500);
            }
        } catch (InterruptedException e) {
        }
    }
}
 
class Consumer extends Thread {
    private SharedQueue mSharedQueue;
 
    public Consumer(SharedQueue aSharedQueue) {
        mSharedQueue = aSharedQueue;
    }
 
    public void run() {
        try {
            while (true) {
                String message =
                    (String) mSharedQueue.get();
                System.out.println(
                    getName() + " : get " + message);
                sleep(2000);
            }
        } catch (InterruptedException e) {
        }
    }
}
 
public class ProducerConsumerTest {
    public static void main(String args[]) {
        SharedQueue sharedQueue = new SharedQueue();
        Producer producer = new Producer(sharedQueue);
        producer.start();
        Consumer consumer1 = new Consumer(sharedQueue);
        consumer1.setName("consumer Mincho");
        consumer1.start();
        Consumer consumer2 = new Consumer(sharedQueue);
        consumer2.setName("consumer Pencho");
        consumer2.start();
    }
}

Как работи решението на проблема „производител-потребител”

Разглеждайки кода на горната програма и документацията на методите wait() и notify(), вероятно ще ви направи впечатление, че тези методи могат да се викат само от код, който е синхронизиран по обекта, на който те принадлежат. Това е така и в горната програма. На всякъде, където се извикват методите wait() и notify(), те са разположени в синхронизиран код.

Нека помислим малко какво се случва при изпълнението на горната програма. Ако методът за заспиване и методът за събуждане се викат от различни нишки и са в блокове код, синхронизирани по един и същ обект, би трябвало ако първата нишка заспи по време на изпълнение на синхронизиран код, кодът, който я събужда, никога да не се изпълни, защото ще чака излизането на заспалата нишка от синхронизирания код. Изглежда, че и двете нишки ще заспят за вечни времена. Първата нишка ще чака втората да я събуди, а втората преди да се опита да събуди първата ще я чака да излезе от синхронизирания код за да изпълни своя синхронизиран код, а пък това няма да се случи никога понеже първата нишка е заспала. Изглежда, че има нещо нередно в горната програма.

Защо горните разсъждения са грешни? Отговорът ще открием ако се вгледаме внимателно в документацията на метода wait(). Извикването на wait() не само приспива текущата нишка, но и отключва обекта, по който тя е синхронизирана. Това позволява на блока, извикващ notify(), който е синхронизиран по същия обект, да се изпълни без да чака. Извикването на notify() събужда заспалата нишка, но не й разрешава веднага да продължи изпълнението си. Събудената нишка изчаква завършването на синхронизирания блок, от който е извикан notify(). След това продължава изпълнението си като заключва отново синхронизационния обект и го отключва едва след завършването на синхронизирания блок, в който е била заспала. Така заспиването за вечни времена, наричано още deadlock или „мъртва хватка” не настъпва. При неправилна употреба на средствата за синхронизация, обаче, настъпването на deadlock съвсем не е изключено. Отговорност на програмиста е да предотврати възможността две или повече нишки в някой момент да започнат да се чакат взаимно.

В програмата по-горе най-интересните фрагменти са двата метода put(…) и get() на каласа SharedQueue. Методът put(…) осигурява добавяне на съобщение в опашката. Ако опашката не е пълна, съобщението се добавя веднага и изпълнението на put(…) завършва веднага. Ако опашката, обаче, е пълна, методът put(…) блокира докато не се освободи място в нея, след което добавя съобщението и завършва изпълнението си. По същия начин работи и методът get(). При непразна опашка той се изпълнява веднага и изважда съобщението, което е наред, а ако опашката е празна, изчаква докато се напълни и след взима първото съобщение от нея. И двата метода put(…) и get() в края си извикват notify(), за да уведомят някоя от чакащите нишки, че нещо се е променило в състоянието на опашката, при което те евентуално биха могли да си свръшат работата, за която чакат.

Ето какъв е приблизително резултата от изпълнението на горната програма:

producer : put Wed Mar 03 20:09:14 EET 2004
consumer Mincho : get Wed Mar 03 20:09:14 EET 2004
producer : put Wed Mar 03 20:09:14 EET 2004
consumer Pencho : get Wed Mar 03 20:09:14 EET 2004
producer : put Wed Mar 03 20:09:15 EET 2004
producer : put Wed Mar 03 20:09:15 EET 2004
consumer Mincho : get Wed Mar 03 20:09:15 EET 2004
producer : put Wed Mar 03 20:09:16 EET 2004
consumer Pencho : get Wed Mar 03 20:09:15 EET 2004
producer : put Wed Mar 03 20:09:16 EET 2004
producer : put Wed Mar 03 20:09:17 EET 2004
producer : put Wed Mar 03 20:09:17 EET 2004
consumer Mincho : get Wed Mar 03 20:09:16 EET 2004
producer : put Wed Mar 03 20:09:18 EET 2004
producer : put Wed Mar 03 20:09:18 EET 2004
consumer Pencho : get Wed Mar 03 20:09:16 EET 2004
producer : put Wed Mar 03 20:09:19 EET 2004
producer : put Wed Mar 03 20:09:19 EET 2004
...

Проблемът „производител-потребител” е много важен, дори основополагащ, при приложения, които използват сокети, защото в такива приложения често може да се получи ситуация, в която една нишка, която изпраща данни по някакъв сокет, трябва да чака друга нишка да прочете данните от някакъв друг източник (например също сокет). Имаме леко опростен вариант на класическия проблем – „потребител” (пишещата нишка), който чака „производителя” (четящата нишка).