Pitanja bezbednosti niti: atomičnost, vidljivost, živost
Ranije smo saznali da tehnologija više niti ima mnogo prednosti — na primer, više niti može u potpunosti iskoristiti računsku moć višejgarnog CPU-a. Pa zar više niti nemaju nijednu manu?
Imaju.
Više niti je teško savladati; uz malo nepažnje lako može doći do pada programa. Uzećemo primer sa vožnjom automobila na putu:
Na putu sa jednim smerom vožnje, svaki automobil poštuje saobraćajna pravila, pa je ukupan protok normalan. „Jednosmerni tok" znači „jedna nit", a „više automobila" znači „više poslova (job)".

Ako je potrebno povećati efikasnost protoka vozila, uobičajeni postupak je dodavanje traka, što u programu odgovara „dodavanju bazena niti", odnosno povećanju broja niti. Tako u isto vreme broj vozila koja prolazi znatno premašuje jednotračni.

Međutim, kada se broj traka poveća, sve je više situacija „ubacivanja u red", a nakon sudara uticaće se i na efikasnost protoka cele ulice. Tako upoređeno, „višetrakačni" postaje mnogo sporiji od „jednotračnog".

Da bi se sprečilo često prebacivanje automobila između traka, između traka se mogu dodati „zaštitne ograde". Šta onda uraditi u svetu programa?
Problemi sa kojima se susreće više niti se svode na tri kategorije: „pitanja bezbednosti niti", „pitanja živosti", „pitanja performansi".
Pitanja bezbednosti niti
Ponekad otkrijemo da kôd koji normalno radi u jednonitnom okruženju u višenitnom okruženju daje neočekivane rezultate — to je ono što se često naziva „nebezbednost za niti". Pa šta zapravo znači nebezbednost za niti?
Atomičnost
Uzećemo primer bankovnog prenosa: na primer, prenos 1000 RMB sa računa A na račun B obavezno obuhvata 2 operacije: oduzeti 1000 RMB sa računa A i dodati 1000 RMB na račun B. Tek kada obe operacije uspeju, jedan prenos se konačno smatra uspešnim.

Zamislite da te dve operacije nemaju atomičnost: nakon što se sa računa A odbije 1000 RMB, operacija iznenada stane, a na račun B nije dodato 1000 RMB — problem je veliki.

Bankovni prenos ima dva koraka; nezgoda nakon toga dovela je do neuspeha prenosa, što pokazuje da nema atomičnosti.
- Atomičnost: jedna operacija ili više operacija — ili se izvrše sve i proces izvršenja neće biti prekinut nijednim faktorom, ili se ne izvrši nijedna.
- Atomska operacija: operacija koja neće biti prekinuta mehanizmom rasporeda niti, bez prebacivanja konteksta.
U konkurentnom programiranju mnoge operacije nisu atomske. Hajde da postavimo malo pitanje:
int i = 0; // Operacija 1
i++; // Operacija 2
int j = i; // Operacija 3
i = i + 1; // Operacija 4Koje su od ovih četiri operacije atomske, a koje nisu?
Neki će možda misliti da su sve atomske operacije, ali zapravo je samo operacija 1 atomska.
- Operacija 1: ovo je atomska operacija jer je jedan, nedeljiv korak.
- Operacija 2: ovo nije atomska operacija. To je zapravo operacija „read-modify-write" koja obuhvata čitanje vrednosti i, uvećanje i, a zatim upisivanje i.
- Operacija 3: ovo je atomska operacija jer je jedan, nedeljiv korak.
- Operacija 4: ovo nije atomska operacija. Kao i i++, i ovo je operacija „read-modify-write".
U jednonitnom okruženju ove četiri operacije neće izazvati probleme, ali u višenitnom okruženju, bez zaključavanja, mogu se dobiti neočekivane vrednosti. Hajde da testiramo i pogledamo izlazni rezultat.
public class YuanziDeo {
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
int numThreads = 2;
int numIncrementsPerThread = 100000;
Thread[] threads = new Thread[numThreads];
for (int j = 0; j < numThreads; j++) {
threads[j] = new Thread(() -> {
for (int k = 0; k < numIncrementsPerThread; k++) {
i++;
}
});
threads[j].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("Final value of i = " + i);
System.out.println("Expected value = " + (numThreads * numIncrementsPerThread));
}
}Izlaz je:
Final value of i = 102249
Expected value = 200000Očekivana vrednost i je 200000, ali stvarni rezultat je 102249, što dokazuje da i++ nije atomska operacija, zar ne?
Vidljivost
talk is cheap, show me code — pogledajte ovaj kod:
class Test {
int i = 50;
int j = 0;
public void update() {
// Izvršava nit 1
i = 100;
}
public int get() {
// Izvršava nit 2
j = i;
return j;
}
}Pretpostavimo da postoje dve niti: nit 1 izvršava metodu update i dodeljuje i vrednost 100. Uobičajeno, nit 1 će završiti operaciju dodeljivanja u svojoj radnoj memoriji, ali neće blagovremeno osvežiti novu vrednost u glavnu memoriju.
U tom trenutku nit 2 izvršava metodu get: prvo će iz glavne memorije pročitati vrednost i, a zatim je učitati u svoju radnu memoriju; pročitana vrednost i i dalje je 50, zatim se 50 dodeljuje j, i konačno vraćena vrednost j je 50. Prvobitno se očekivalo da se vrati 100, a vraćeno je 50 — to je problem vidljivosti: nit 1 je izmenila promenljivu i, ali nit 2 nije odmah videla novu vrednost i.
Vidljivost: kada više niti pristupa istoj promenljivoj, a jedna nit izmeni vrednost te promenljive, druge niti mogu odmah da vide izmenjenu vrednost.

Kao na gornjoj slici, svaka nit ima svoju radnu memoriju, a između radne memorije i glavne memorije potrebna je interakcija putem store, load i sl.
Da bi rešio problem vidljivosti više niti, Java pruža ključnu reč volatile. Kada je deljena promenljiva modifikovana sa volatile, garantuje se da će izmenjena vrednost biti odmah ažurirana u glavnu memoriju, tako da će, kada drugim nitima bude potrebno da čitaju, iz memorije pročitati novu vrednost. Obične deljene promenljive ne mogu da garantuju vidljivost, jer nije sigurno kada će se promenljiva nakon izmene osvežiti u glavnu memoriju, pa druga nit može pročitati staru vrednost.
Naravno, mehanizam brava u Javi, kao što su synchronized i lock, takođe može da garantuje vidljivost.
Pitanja živosti
Gore smo rekli da, da bismo rešili problem vidljivosti, možemo primeniti zaključavanje, ali ako se zaključavanje nepravilno koristi, lako se mogu uvesti i drugi problemi, kao što je „mrtva blokada".
Pre nego što pričamo o „mrtvoj blokadi", moramo da uvedemo još jedan koncept: pitanja živosti.
Živost znači da će se nešto ispravno konačno dogoditi, ali kada neka operacija ne može da se nastavi, javlja se pitanje živosti.
Koncept možda zvuči pomalo nezgrapno. Pitanja živosti obično obuhvataju sledeće kategorije: mrtvu blokadu, živu blokadu, problem gladi.
Mrtva blokada
Mrtva blokada je stanje u kojem više niti zbog kružnog čekanja na brave zauvek ostane blokirano.

Živa blokada
Mrtva blokada je stanje u kojem dve niti čekaju da druga pusti bravu i zbog toga bivaju blokirane. A živa blokada znači da nit nije blokirana — još uvek je živa. Kada više niti radi i svaka menja sopstveno stanje, dok druge niti zavise od tog stanja, to dovodi do toga da nijedna nit ne može da nastavi izvršenje i samo ponavlja sopstvene radnje — tada nastaje živa blokada.

Primer iz svakodnevnog života: kada idete ulicom i naiđete na nekoga iz suprotnog smera, dvoje ljudi se međusobno sklanjaju, ali istovremeno odu na istu stranu. Ako se tako stalno iznova izmiču, ta dvoje ljudi su u živoj blokadi — naučili ste, hehe.
Gladovanje
Ako jedna nit bez ikakvih drugih izuzetaka dugo ne može da nastavi sa radom, najverovatnije je u stanju gladovanja.
Uobičajeno je nekoliko scenarija:
- Nit visokog prioriteta stalno radi i troši CPU, dok sve niti niskog prioriteta stalno čekaju.
- Neke niti su trajno blokirane u stanju čekanja na ulazak u sinhronizovani blok, dok druge niti uvek uspevaju da mu pristupe pre njih.
Jedan veoma klasičan problem gladovanja je problem večere filozofa. Kao na slici ispod, pet filozofa večera; svako mora istovremeno da uzme dve viljuške da bi počeo da jede. Ako filozof 1 i filozof 3 istovremeno počnu da jedu, filozofi 2, 4 i 5 moraju da gladuju i čekaju.

Pitanja performansi
Gore smo govorili o pitanjima bezbednosti niti, mrtve blokade, žive blokade itd. Ako se ništa od toga ne dogodi, da li višenitno konkurentno izvršenje uvek mora biti brže od jednonitnog serijskog izvršenja? Odgovor je ne obavezno, jer više niti nose troškove kreiranja niti i prebacivanja konteksta niti.
Kreiranje niti direktno traži resurse od sistema; za operativni sistem kreiranje jedne niti je veoma skupo — potrebno joj je dodeliti memoriju, uvrstiti je u raspored itd.
Nakon kreiranja niti, nailazi se i na prebacivanje konteksta niti.

CPU je veoma dragocen resurs i veoma brz; kako bi se osigurala pravednost, obično se različitim nitima dodeljuju vremenski isečci. Kada se CPU prebaci sa izvršenja jedne niti na izvršenje druge niti, CPU mora da sačuva lokalne podatke trenutne niti, stanje pokazivača programa itd., i da učita lokalne podatke naredne niti koja se izvršava, pokazivač programa itd. — to je „prebacivanje konteksta".
Uobičajeni načini za smanjenje prebacivanja konteksta su:
- Konkurentno programiranje bez brava: može se pozvati na ideju segmentnih brava iz ConcurrentHashMap-a, gde različite niti obrađuju podatke različitih segmenata, čime se u uslovima višenitnog takmičenja može smanjiti vreme prebacivanja konteksta.
- CAS algoritam: korišćenjem Atomic + CAS algoritma za ažuriranje podataka i usvajanjem optimističke brave može se efikasno smanjiti deo nepotrebnih prebacivanja konteksta koja donose takmičenje za bravu.
- Korišćenje minimalnog broja niti: izbegavati kreiranje nepotrebnih niti; ako je zadataka malo, a kreirano je mnogo niti, to će dovesti do toga da veliki broj niti bude u stanju čekanja.
- Korutine: implementacija rasporeda više zadataka unutar jedne niti i održavanje prebacivanja između više zadataka unutar jedne niti.
Rezime
Ako se više niti dobro iskoristi, efikasnost programa se može višestruko povećati; ako se iskoristi loše, može biti sporije i od jedne niti.
Jednom slikom sumiraćemo gore navedeno:

Urednik: Chenmo Wang Er. Sadržaj pre uređivanja potiče iz otvorenog repozitorijuma mog prijatelja Lei Xiaoshuai-ja Java pitanja za intervju. Sadržaj je vrlo dobar, toplo preporučujem.
