Szia! Folytatjuk a többszálúság vizsgálatát. Ma megismerjük a
Amikor meghívjuk a
volatile
kulcsszót és a yield()
módszert. Merüljünk bele :)
Az illékony kulcsszó
Többszálú alkalmazások készítésekor két komoly problémába ütközhetünk. Először is, amikor egy többszálú alkalmazás fut, a különböző szálak gyorsítótárazhatják a változók értékeit (erről már beszéltünk a 'A volatile használata' című leckében ). Előfordulhat olyan helyzet, hogy az egyik szál megváltoztatja egy változó értékét, de a második szál nem látja a változást, mert a változó gyorsítótárazott másolatával dolgozik. Természetesen a következmények súlyosak lehetnek. Tegyük fel, hogy ez nem egy régi változó, hanem a bankszámla egyenlege, ami hirtelen véletlenszerűen kezd fel-le ugrálni :) Ez nem hangzik valami szórakoztatónak, igaz? Másodszor, a Java-ban az összes primitív típus olvasására és írására szolgáló műveletek,long
double
, atomosak. Nos, ha például az egyik szálon megváltoztatod egy int
változó értékét, egy másik szálon pedig kiolvasod a változó értékét, akkor vagy a régi értékét kapod, vagy az újat, vagyis azt az értéket, ami a változásból adódott. az 1. szálban. Nincsenek 'köztes értékek'. Ez azonban nem működik long
s és double
s esetén. Miért? A platformok közötti támogatás miatt. Emlékszel a kezdeti szinteken, hogy azt mondtuk, hogy a Java vezérelve az „egyszer írd, futtasd bárhol”? Ez többplatformos támogatást jelent. Más szóval, a Java alkalmazások mindenféle platformon futnak. Például Windows operációs rendszereken, Linux vagy MacOS különböző verzióiban. Probléma nélkül fog futni mindegyiken. 64 bites súlyú,long
double
a „legnehezebb” primitívek a Java nyelven. Bizonyos 32 bites platformok pedig egyszerűen nem valósítják meg a 64 bites változók atomi olvasását és írását. Az ilyen változók olvasása és írása két művelettel történik. Először az első 32 bitet írjuk a változóba, majd további 32 bitet írunk. Ennek eredményeként probléma merülhet fel. Az egyik szál valamilyen 64 bites értéket ír egy változóba X
, és ezt két művelettel teszi meg. Ugyanakkor egy második szál megpróbálja beolvasni a változó értékét, és ezt a két művelet között teszi meg – amikor az első 32 bitet írták, de a második 32 bitet nem. Ennek eredményeként egy köztes, helytelen értéket olvas, és van egy hibánk. Például, ha egy ilyen platformon megpróbáljuk beírni a számot egy 9223372036854775809 változóhoz, akkor 64 bitet fog elfoglalni. Bináris formában ez így néz ki: 1000000000000000000000000000000000000000000000000000000000000001 Az első szál elkezdi írni a számot a változóhoz. Eleinte az első 32 bitet (10000000000000000000000000000) , majd a második 32 bitet (000000000000000000000000000000001) írja. És a második szál ébredhet e műveletek között, elolvasta a változó közbenső értékét (100000000000000000000000000000), amelyek az első 32 bit, amelyet már megírtak. A decimális rendszerben ez a szám 2 147 483 648. Vagyis csak a 9223372036854775809 számot akartuk beírni egy változóba, de mivel ez a művelet bizonyos platformokon nem atomi, megvan a 2 147 483 648 gonosz szám, ami a semmiből jött és ismeretlen hatással lesz a program. A második szál egyszerűen beolvasta a változó értékét, mielőtt befejezte volna az írást, azaz a szál látta az első 32 bitet, de a második 32 bitet nem. Ezek a problémák persze nem tegnap jelentkeztek. A Java egyetlen kulcsszóval oldja meg őket: volatile
. Ha használjuk avolatile
kulcsszó, amikor valamilyen változót deklarálunk a programunkban…
public class Main {
public volatile long x = 2222222222222222222L;
public static void main(String[] args) {
}
}
…ez azt jelenti:
- Mindig atomosan lesz olvasható és írva. Még akkor is, ha 64 bites
double
vagylong
. - A Java gép nem fogja gyorsítótárazni. Így nem lesz olyan helyzet, amikor 10 szál a saját helyi másolataival dolgozik.
A hozam() módszer
Az osztály számos módszerét már áttekintettükThread
, de van egy fontos módszer, amely újdonság lesz számodra. Ez a yield()
módszer . És pontosan azt teszi, amit a neve is sugall! 
yield
metódust egy szálon, az valójában a többi szálhoz beszél: „Hé, srácok. Nem sietek különösebben sehova, úgyhogy ha valakinek fontos, hogy processzoridőhöz jusson, vegye ki – várhatok. Íme egy egyszerű példa ennek működésére:
public class ThreadExample extends Thread {
public ThreadExample() {
this.start();
}
public void run() {
System.out.println(Thread.currentThread().getName() + " yields its place to others");
Thread.yield();
System.out.println(Thread.currentThread().getName() + " has finished executing.");
}
public static void main(String[] args) {
new ThreadExample();
new ThreadExample();
new ThreadExample();
}
}
Három szálat hozunk létre és indítunk el egymás után: Thread-0
, Thread-1
, és Thread-2
. Thread-0
először indul és azonnal enged a többieknek. Aztán Thread-1
elindul és hozam is. Ezután Thread-2
elindul, ami szintén hozamot hoz. Nincs több szálunk, és miután Thread-2
utoljára átadta a helyét, a szálütemező azt mondja: „Hmm, nincs több új szál. Kik állnak a sorban? Ki adta át a helyét korábban Thread-2
? Úgy tűnik, az volt Thread-1
. Oké, ez azt jelenti, hogy hagyjuk futni. Thread-1
befejezi a munkáját, majd a szálütemező folytatja a koordinációt: „Rendben, Thread-1
kész. Van még valaki a sorban?' A 0-s szál a sorban van: pont azelőtt adta át a helyétThread-1
. Most rákerül a sor, és befut a befejezésig. Ezután az ütemező befejezi a szálak koordinálását: „Rendben, Thread-2
, engedett a többi szálnak, és most már minden kész. Te voltál az utolsó, aki engedett, tehát most rajtad a sor. Ezután Thread-2
fut a befejezésig. A konzol kimenete így fog kinézni: A 0. szál átadja a helyét másoknak Az 1. szál átadja a helyét másoknak A 2. szál átadja a helyét másoknak Az 1. szál végrehajtása befejeződött. A 0. szál végrehajtása befejeződött. A 2. szál végrehajtása befejeződött. Természetesen a szálütemező elindíthatja a szálakat más sorrendben (például 2-1-0 a 0-1-2 helyett), de az elv ugyanaz marad.
Történik – a szabályok előtt
Az utolsó dolog, amit ma érintünk, az a „ korábban történik ” fogalma. Amint azt már tudja, a Java-ban a szálütemező végzi el az idő és az erőforrások szálakhoz való hozzárendelésével kapcsolatos munka nagy részét feladataik elvégzéséhez. Azt is többször láttad, hogy a szálak véletlenszerű sorrendben futnak, amit általában lehetetlen előre megjósolni. Általánosságban elmondható, hogy a „szekvenciális” programozás után, amit korábban csináltunk, a többszálú programozás véletlenszerűnek tűnik. Már azt hitte, hogy számos módszert használhat a többszálú programok áramlásának szabályozására. De a Java többszálas működésének van még egy pillére – a 4 „ előtte történik ” szabályok. Ezeknek a szabályoknak a megértése meglehetősen egyszerű. Képzeljük el, hogy két szálunk van –A
ésB
. Ezen szálak mindegyike végrehajthat műveleteket 1
és 2
. Minden szabályban, amikor azt mondjuk, hogy „ A megtörténik-B előtt ”, akkor azt értjük, hogy a szál által A
a művelet előtt végrehajtott összes változtatás 1
, és az ebből a műveletből származó változtatások láthatóak a szál számára a művelet végrehajtása B
során és azt követően. 2
Mindegyik szabály garantálja, hogy többszálú program írásakor bizonyos események az esetek 100%-ában előbb történnek meg, mint a többiek, és hogy a műveleti 2
szál B
mindig tudatában lesz a szál által A
a művelet során végrehajtott változtatásoknak 1
. Tekintsük át őket.
1. szabály
A mutex feloldása azelőtt megtörténik, hogy ugyanazt a monitort egy másik szál megszerezné. Szerintem itt mindent értesz. Ha egy objektum vagy osztály mutexét egy szál szerzi meg, például a szálA
, akkor egy másik szál (szál B
) nem tudja egyszerre megszerezni. Meg kell várnia, amíg a mutex felszabadul.
2. szabály
AThread.start()
módszer korábban történik Thread.run()
. Itt megint nincs semmi nehéz. Már tudja, hogy a metóduson belüli kód futtatásához run()
meg kell hívnia a start()
metódust a szálon. Pontosabban a start metódus, nem run()
maga a metódus! Ez a szabály biztosítja, hogy a hívás előtt beállított összes változó értéke Thread.start()
látható legyen a run()
metóduson belül, amint elkezdődik.
3. szabály.
Arun()
metódus vége a metódusból való visszatérés előtt történikjoin()
. Térjünk vissza a két szálunkhoz: A
és B
. A metódust úgy hívjuk meg join()
, hogy a szál B
garantáltan megvárja a szál befejezését, A
mielőtt elvégezné a munkáját. Ez azt jelenti, hogy az A objektum run()
metódusa garantáltan a végéig fut. És minden adatmódosítás, amely a run()
szál metódusában történik A
, száz százalékig garantáltan látható lesz a szálban, B
miután befejezte a szál befejezését, A
és megkezdheti saját munkáját.
4. szabály.
Avolatile
változóba való írás azelőtt történik, hogy ugyanabból a változóból olvasna. Amikor a kulcsszót használjuk volatile
, valójában mindig az aktuális értéket kapjuk. Akár a long
vagy -val double
is (korábban beszéltünk az itt előforduló problémákról). Amint azt már megérti, bizonyos szálakon végrehajtott módosítások nem mindig láthatók más szálak számára. De természetesen nagyon gyakran vannak olyan helyzetek, amikor az ilyen viselkedés nem illik hozzánk. Tegyük fel, hogy értéket rendelünk egy változóhoz a szálon A
:
int z;
….
z = 555;
Ha a B
szálunk a z
változó értékét jelenítené meg a konzolon, könnyen 0-t jeleníthet meg, mert nem tud a hozzárendelt értékről. z
De a 4. szabály garantálja, hogy ha a változót ként deklaráljuk volatile
, akkor az értékének változásai az egyik szálon mindig láthatóak lesznek a másik szálon. Ha hozzáadjuk volatile
az előző kódhoz tartozó szót...
volatile int z;
….
z = 555;
...akkor megakadályozzuk azt a helyzetet, hogy a szál B
0-t jelenítsen meg. volatile
A változók írása az olvasás előtt történik.
GO TO FULL VERSION