Išsamus vadovas pasaulio programuotojams apie lygiagretumo valdymą. Naršykite užraktais paremtą sinchronizavimą, miuteksus, semaforus, aklavietes ir gerąsias praktikas.
Sinchronizavimo Įvaldymas: Išsami Pažintis su Užraktais Paremtu Sinchronizavimu
Įsivaizduokite šurmuliuojančią profesionalią virtuvę. Keli virėjai dirba vienu metu ir visiems reikia prieigos prie bendro ingredientų sandėliuko. Jei du virėjai bando paimti paskutinį reto prieskonio stiklainį lygiai tuo pačiu metu, kuris jį gaus? O kas, jei vienas virėjas atnaujina recepto kortelę, o kitas ją skaito, dėl ko gaunama pusiau parašyta, beprasmė instrukcija? Šis chaosas virtuvėje yra puiki analogija pagrindiniam šiuolaikinės programinės įrangos kūrimo iššūkiui: lygiagretumui (concurrency).
Šiuolaikiniame daugiabranduolių procesorių, paskirstytųjų sistemų ir greitai reaguojančių programų pasaulyje lygiagretumas – gebėjimas skirtingoms programos dalims vykdytis ne eilės tvarka arba daline tvarka, nepaveikiant galutinio rezultato – nėra prabanga; tai būtinybė. Tai variklis, slypintis už greitų interneto serverių, sklandžių vartotojo sąsajų ir galingų duomenų apdorojimo sistemų. Tačiau ši galia ateina su dideliu sudėtingumu. Kai kelios gijos ar procesai vienu metu pasiekia bendrus išteklius, jie gali trukdyti vieni kitiems, sukeldami duomenų sugadinimą, nenuspėjamą elgesį ir kritinius sistemos gedimus. Štai čia į pagalbą ateina lygiagretumo valdymas (concurrency control).
Šis išsamus vadovas nagrinės fundamentaliausią ir plačiausiai naudojamą metodą šiam kontroliuojamam chaosui valdyti: užraktais paremtą sinchronizavimą (lock-based synchronization). Mes išaiškinsime, kas yra užraktai, išnagrinėsime įvairias jų formas, aptarsime pavojingus spąstus ir nustatysime pasaulinių geriausių praktikų rinkinį, skirtą patikimam, saugiam ir efektyviam lygiagrečiam kodui rašyti.
Kas yra Lygiagretumo Valdymas?
Iš esmės, lygiagretumo valdymas yra informatikos disciplina, skirta vienu metu vykdomų operacijų su bendrais duomenimis valdymui. Pagrindinis jos tikslas – užtikrinti, kad lygiagrečios operacijos būtų vykdomos teisingai, netrukdant viena kitai, išsaugant duomenų vientisumą ir nuoseklumą. Galvokite apie tai kaip apie virtuvės vadybininką, kuris nustato taisykles, kaip virėjai gali naudotis sandėliuku, kad būtų išvengta išsiliejimų, painiavos ir iššvaistytų ingredientų.
Duomenų bazių pasaulyje lygiagretumo valdymas yra būtinas norint palaikyti ACID savybes (Atomiškumą, Suderinamumą, Izoliaciją, Patvarumą), ypač Izoliaciją. Izoliacija užtikrina, kad lygiagretus transakcijų vykdymas sukuria sistemos būseną, kuri būtų gauta, jei transakcijos būtų vykdomos nuosekliai, viena po kitos.
Yra dvi pagrindinės filosofijos, kaip įgyvendinti lygiagretumo valdymą:
- Optimistinis lygiagretumo valdymas: Šis metodas daro prielaidą, kad konfliktai yra reti. Jis leidžia operacijoms tęstis be jokių išankstinių patikrinimų. Prieš patvirtinant pakeitimą, sistema patikrina, ar kita operacija tuo tarpu nepakeitė duomenų. Jei aptinkamas konfliktas, operacija paprastai atšaukiama ir bandoma iš naujo. Tai „prašyk atleidimo, o ne leidimo“ strategija.
- Pesimistinis lygiagretumo valdymas: Šis metodas daro prielaidą, kad konfliktai yra tikėtini. Jis priverčia operaciją įgyti užraktą ištekliui, prieš pradedant jį naudoti, taip užkertant kelią kitoms operacijoms trukdyti. Tai „prašyk leidimo, o ne atleidimo“ strategija.
Šis straipsnis skirtas išskirtinai pesimistiniam metodui, kuris yra užraktais paremto sinchronizavimo pagrindas.
Pagrindinė Problema: Lenktynių Sąlygos
Prieš įvertindami sprendimą, turime visiškai suprasti problemą. Dažniausia ir klastingiausia klaida lygiagrečiame programavime yra lenktynių sąlyga (race condition). Lenktynių sąlyga atsiranda, kai sistemos elgesys priklauso nuo nenuspėjamos ir nekontroliuojamų įvykių sekos ar laiko, pavyzdžiui, operacinės sistemos gijų planavimo.
Panagrinėkime klasikinį pavyzdį: bendrą banko sąskaitą. Tarkime, sąskaitoje yra 1000 JAV dolerių likutis, ir dvi lygiagrečios gijos bando įnešti po 100 JAV dolerių.
Štai supaprastinta įnešimo operacijų seka:
- Nuskaityti esamą likutį iš atminties.
- Pridėti įnešamą sumą prie šios vertės.
- Įrašyti naują vertę atgal į atmintį.
Teisingas, nuoseklus vykdymas duotų galutinį 1200 JAV dolerių likutį. Bet kas atsitinka lygiagrečiame scenarijuje?
Galimas operacijų persipynimas:
- A gija: Nuskaito likutį (1000 JAV dolerių).
- Konteksto perjungimas: Operacinė sistema sustabdo A giją ir paleidžia B giją.
- B gija: Nuskaito likutį (vis dar 1000 JAV dolerių).
- B gija: Apskaičiuoja savo naują likutį (1000 JAV dolerių + 100 JAV dolerių = 1100 JAV dolerių).
- B gija: Įrašo naują likutį (1100 JAV dolerių) atgal į atmintį.
- Konteksto perjungimas: Operacinė sistema atnaujina A giją.
- A gija: Apskaičiuoja savo naują likutį remdamasi anksčiau nuskaityta verte (1000 JAV dolerių + 100 JAV dolerių = 1100 JAV dolerių).
- A gija: Įrašo naują likutį (1100 JAV dolerių) atgal į atmintį.
Galutinis likutis yra 1100 JAV dolerių, o ne tikėtasi 1200 JAV dolerių. Dėl lenktynių sąlygos 100 JAV dolerių indėlis tiesiog dingo. Kodo blokas, kuriame pasiekiamas bendras išteklius (sąskaitos likutis), yra žinomas kaip kritinė sekcija (critical section). Norėdami išvengti lenktynių sąlygų, turime užtikrinti, kad vienu metu kritinėje sekcijoje galėtų vykdytis tik viena gija. Šis principas vadinamas abipuse išimtimi (mutual exclusion).
Pristatome Užraktais Paremtą Sinchronizavimą
Užraktais paremtas sinchronizavimas yra pagrindinis mechanizmas abipusei išimčiai įgyvendinti. Užraktas (taip pat žinomas kaip miuteksas) yra sinchronizavimo primityvas, veikiantis kaip kritinės sekcijos sargas.
Labai tinka analogija su raktu nuo vienviečio tualeto. Tualetas yra kritinė sekcija, o raktas yra užraktas. Daug žmonių (gijų) gali laukti lauke, bet įeiti gali tik tas, kuris turi raktą. Baigę, jie išeina ir grąžina raktą, leisdami kitam eilėje jį paimti ir įeiti.
Užraktai palaiko dvi pagrindines operacijas:
- Įgyti (arba Užrakinti): Gija iškviečia šią operaciją prieš įeidama į kritinę sekciją. Jei užraktas yra laisvas, gija jį įgyja ir tęsia darbą. Jei užraktą jau turi kita gija, iškviečianti gija blokuojasi (arba „užmiega“), kol užraktas bus atlaisvintas.
- Atlaisvinti (arba Atrakinti): Gija iškviečia šią operaciją baigusi vykdyti kritinę sekciją. Tai padaro užraktą prieinamą kitoms laukiančioms gijoms įgyti.
Apgaubę mūsų banko sąskaitos logiką užraktu, galime garantuoti jos teisingumą:
acquire_lock(account_lock);
// --- Kritinės sekcijos pradžia ---
balance = read_balance();
new_balance = balance + amount;
write_balance(new_balance);
// --- Kritinės sekcijos pabaiga ---
release_lock(account_lock);
Dabar, jei A gija pirma įgyja užraktą, B gija bus priversta laukti, kol A gija užbaigs visus tris žingsnius ir atlaisvins užraktą. Operacijos daugiau nebesipina, ir lenktynių sąlyga yra pašalinta.
Užraktų Tipai: Programuotojo Įrankių Rinkinys
Nors pagrindinė užrakto koncepcija yra paprasta, skirtingi scenarijai reikalauja skirtingų tipų užrakinimo mechanizmų. Norint sukurti efektyvias ir teisingas lygiagrečias sistemas, būtina suprasti turimų užraktų įrankių rinkinį.
Miuteksai (Abipusės Išimties Užraktai)
Miuteksas yra paprasčiausias ir labiausiai paplitęs užrakto tipas. Tai binarinis užraktas, reiškiantis, kad jis turi tik dvi būsenas: užrakinta arba atrakinta. Jis skirtas griežtai abipusei išimčiai užtikrinti, garantuojant, kad vienu metu užraktą gali turėti tik viena gija.
- Nuosavybė: Svarbi daugumos miuteksų įgyvendinimo savybė yra nuosavybė. Gija, kuri įgyja miuteksą, yra vienintelė gija, kuriai leidžiama jį atlaisvinti. Tai apsaugo nuo to, kad viena gija netyčia (arba piktavališkai) neatrakintų kritinės sekcijos, kurią naudoja kita.
- Panaudojimo atvejis: Miuteksai yra numatytasis pasirinkimas trumpoms, paprastoms kritinėms sekcijoms apsaugoti, pavyzdžiui, atnaujinant bendrą kintamąjį ar modifikuojant duomenų struktūrą.
Semaforai
Semaforas yra bendresnis sinchronizavimo primityvas, kurį išrado olandų informatikas Edsger W. Dijkstra. Skirtingai nuo miutekso, semaforas palaiko neneigiamo sveikojo skaičiaus skaitiklį.
Jis palaiko dvi atomines operacijas:
- wait() (arba P operacija): Sumažina semaforo skaitiklį. Jei skaitiklis tampa neigiamas, gija blokuojasi, kol skaitiklis tampa didesnis arba lygus nuliui.
- signal() (arba V operacija): Padidina semaforo skaitiklį. Jei yra gijų, užblokuotų dėl semaforo, viena iš jų atblokuojama.
Yra du pagrindiniai semaforų tipai:
- Binarinis semaforas: Skaitiklis inicializuojamas į 1. Jis gali būti tik 0 arba 1, todėl funkciškai yra lygiavertis miuteksui.
- Skaičiuojantis semaforas: Skaitiklis gali būti inicializuotas į bet kurį sveikąjį skaičių N > 1. Tai leidžia iki N gijų vienu metu naudotis ištekliumi. Jis naudojamas prieigai prie riboto išteklių telkinio kontroliuoti.
Pavyzdys: Įsivaizduokite interneto programą su jungčių telkiniu, galinčiu aptarnauti ne daugiau kaip 10 lygiagrečių duomenų bazės jungčių. Skaičiuojantis semaforas, inicializuotas į 10, gali tai puikiai valdyti. Kiekviena gija, prieš paimdama jungtį, turi įvykdyti `wait()` operaciją semaforui. 11-oji gija blokuosis, kol viena iš pirmųjų 10 gijų baigs savo darbą su duomenų baze ir įvykdys `signal()` operaciją semaforui, grąžindama jungtį į telkinį.
Skaitymo-Rašymo Užraktai (Bendrinami/Išskirtiniai Užraktai)
Dažnas modelis lygiagrečiose sistemose yra tas, kad duomenys skaitomi daug dažniau nei rašomi. Naudoti paprastą miuteksą šiame scenarijuje yra neefektyvu, nes tai neleidžia kelioms gijoms vienu metu skaityti duomenų, nors skaitymas yra saugi, nemodifikuojanti operacija.
Skaitymo-rašymo užraktas sprendžia šią problemą suteikdamas du užrakinimo režimus:
- Bendrinamas (Skaitymo) užraktas: Kelios gijos gali vienu metu įgyti skaitymo užraktą, kol jokia gija neturi rašymo užrakto. Tai leidžia vykdyti didelio lygiagretumo skaitymą.
- Išskirtinis (Rašymo) užraktas: Vienu metu tik viena gija gali įgyti rašymo užraktą. Kai gija turi rašymo užraktą, visos kitos gijos (tiek skaitančios, tiek rašančios) yra blokuojamos.
Analogija – dokumentas bendroje bibliotekoje. Daug žmonių gali skaityti dokumento kopijas tuo pačiu metu (bendrinamas skaitymo užraktas). Tačiau, jei kas nors nori redaguoti dokumentą, jis turi jį paimti išskirtinai, ir niekas kitas negali jo skaityti ar redaguoti, kol jis nebaigs (išskirtinis rašymo užraktas).
Rekursyvūs Užraktai (Pakartotinio Įėjimo Užraktai)
Kas atsitinka, jei gija, kuri jau turi miuteksą, bando jį įgyti dar kartą? Su standartiniu miuteksu tai sukeltų tiesioginę aklavietę – gija amžinai lauktų, kol pati atlaisvins užraktą. Rekursyvus užraktas (arba pakartotinio įėjimo užraktas) yra sukurtas šiai problemai spręsti.
Rekursyvus užraktas leidžia tai pačiai gijai įgyti tą patį užraktą kelis kartus. Jis palaiko vidinį nuosavybės skaitiklį. Užraktas visiškai atlaisvinamas tik tada, kai jį turinti gija iškviečia `release()` tiek kartų, kiek iškvietė `acquire()`. Tai ypač naudinga rekursyviose funkcijose, kurios turi apsaugoti bendrą išteklių vykdymo metu.
Užrakinimo Pavojai: Dažniausios Kliūtys
Nors užraktai yra galingi, jie yra dviašmenis kardas. Netinkamas užraktų naudojimas gali sukelti klaidas, kurias diagnozuoti ir ištaisyti yra daug sunkiau nei paprastas lenktynių sąlygas. Tarp jų – aklavietės, gyvosios aklavietės ir našumo problemos.
Aklavietė (Deadlock)
Aklavietė yra labiausiai bijomas scenarijus lygiagrečiame programavime. Ji įvyksta, kai dvi ar daugiau gijų yra blokuotos neribotam laikui, kiekviena laukdama ištekliaus, kurį laiko kita gija iš to paties rinkinio.
Panagrinėkime paprastą scenarijų su dviem gijomis (1 gija, 2 gija) ir dviem užraktais (A užraktas, B užraktas):
- 1 gija įgyja A užraktą.
- 2 gija įgyja B užraktą.
- 1 gija dabar bando įgyti B užraktą, bet jį laiko 2 gija, todėl 1 gija blokuojasi.
- 2 gija dabar bando įgyti A užraktą, bet jį laiko 1 gija, todėl 2 gija blokuojasi.
Abi gijos dabar yra įstrigusios nuolatinėje laukimo būsenoje. Programa sustoja. Ši situacija kyla dėl keturių būtinų sąlygų (Coffman sąlygų) buvimo:
- Abipusė išimtis: Ištekliai (užraktai) negali būti bendrinami.
- Laikymas ir laukimas: Gija laiko bent vieną išteklių, laukdama kito.
- Be priverstinio atėmimo: Ištekliaus negalima priverstinai atimti iš jį laikančios gijos.
- Ciklinis laukimas: Egzistuoja dviejų ar daugiau gijų grandinė, kurioje kiekviena gija laukia ištekliaus, kurį laiko kita grandinės gija.
Aklavietės prevencija apima bent vienos iš šių sąlygų pažeidimą. Dažniausia strategija yra pažeisti ciklinio laukimo sąlygą, nustatant griežtą, globalią užraktų įgijimo tvarką.
Gyvoji aklavietė (Livelock)
Gyvoji aklavietė yra subtilesnė aklavietės giminaitė. Gyvojoje aklavietėje gijos nėra blokuotos – jos aktyviai veikia – bet jos nedaro jokios pažangos. Jos įstringa cikle, reaguodamos viena į kitos būsenos pasikeitimus, neatlikdamos jokio naudingo darbo.
Klasikinė analogija – du žmonės, bandantys prasilenkti siaurame koridoriuje. Abu bando būti mandagūs ir pasitraukia į kairę, bet galiausiai užblokuoja vienas kitą. Tada abu pasitraukia į dešinę, vėl užblokuodami vienas kitą. Jie aktyviai juda, bet neina koridoriumi į priekį. Programinėje įrangoje tai gali atsitikti su prastai suprojektuotais aklavietės atkūrimo mechanizmais, kur gijos nuolat atsitraukia ir bando iš naujo, bet vėl susiduria su konfliktu.
Badavimas (Starvation)
Badavimas įvyksta, kai gijai nuolat neleidžiama pasiekti būtino ištekliaus, nors išteklius tampa prieinamas. Tai gali nutikti sistemose su planavimo algoritmais, kurie nėra „sąžiningi“. Pavyzdžiui, jei užrakinimo mechanizmas visada suteikia prieigą aukšto prioriteto gijoms, žemo prioriteto gija gali niekada negauti progos veikti, jei yra nuolatinis aukšto prioriteto varžovų srautas.
Našumo Antkainis
Užraktai nėra nemokami. Jie sukelia našumo antkainį keliais būdais:
- Įgijimo/Atlaisvinimo kaina: Užrakto įgijimo ir atlaisvinimo veiksmai apima atomines operacijas ir atminties barjerus, kurie yra skaičiavimo požiūriu brangesni nei įprastos instrukcijos.
- Varžymasis (Contention): Kai kelios gijos dažnai konkuruoja dėl to paties užrakto, sistema praleidžia daug laiko perjungdama kontekstus ir planuodama gijas, užuot atlikdama produktyvų darbą. Didelis varžymasis efektyviai paverčia vykdymą nuosekliu, paneigdamas paralelizmo tikslą.
Gerosios Užraktais Paremtos Sinchronizacijos Praktikos
Teisingo ir efektyvaus lygiagretaus kodo rašymas su užraktais reikalauja disciplinos ir geriausių praktikų laikymosi. Šie principai yra universalūs, nepriklausomai nuo programavimo kalbos ar platformos.
1. Kritines Sekcijas Laikykite Mažas
Užraktas turėtų būti laikomas kuo trumpesnį laiką. Jūsų kritinėje sekcijoje turėtų būti tik tas kodas, kurį absoliučiai būtina apsaugoti nuo lygiagrečios prieigos. Bet kokios nekritinės operacijos (pvz., I/O, sudėtingi skaičiavimai, nesusiję su bendra būsena) turėtų būti atliekamos už užrakintos srities ribų. Kuo ilgiau laikote užraktą, tuo didesnė varžymosi tikimybė ir tuo labiau blokuojate kitas gijas.
2. Pasirinkite Tinkamą Užrakto Detalumą
Užrakto detalumas (granularumas) reiškia duomenų kiekį, apsaugotą vienu užraktu.
- Stambaus detalumo užrakinimas: Naudojamas vienas užraktas didelei duomenų struktūrai ar visai posistemei apsaugoti. Tai paprasčiau įgyvendinti ir suprasti, bet gali sukelti didelį varžymąsi, nes nesusijusios operacijos su skirtingomis duomenų dalimis yra serializuojamos tuo pačiu užraktu.
- Smulkaus detalumo užrakinimas: Naudojama keletas užraktų skirtingoms, nepriklausomoms duomenų struktūros dalims apsaugoti. Pavyzdžiui, vietoj vieno užrakto visai maišos lentelei, galite turėti atskirą užraktą kiekvienam segmentui. Tai sudėtingiau, bet gali dramatiškai pagerinti našumą, leisdama daugiau tikrojo paralelizmo.
Pasirinkimas tarp jų yra kompromisas tarp paprastumo ir našumo. Pradėkite nuo stambesnio detalumo užraktų ir pereikite prie smulkesnio detalumo tik tada, jei našumo profiliavimas rodo, kad užrakto varžymasis yra kliūtis.
3. Visada Atlaisvinkite Savo Užraktus
Neatlaisvintas užraktas yra katastrofiška klaida, kuri greičiausiai sustabdys jūsų sistemą. Dažna šios klaidos priežastis yra išimtis arba ankstyvas grįžimas iš funkcijos kritinės sekcijos viduje. Kad to išvengtumėte, visada naudokite kalbos konstrukcijas, kurios garantuoja išvalymą, pavyzdžiui, try...finally blokus Javoje ar C#, arba RAII (Resource Acquisition Is Initialization) modelius su apibrėžtos srities užraktais C++.
Pavyzdys (pseudokodas naudojant try-finally):
my_lock.acquire();
try {
// Kritinės sekcijos kodas, kuris gali sukelti išimtį
} finally {
my_lock.release(); // Garantuojama, kad tai bus įvykdyta
}
4. Laikykitės Griežtos Užraktų Įgijimo Tvarkos
Norint išvengti aklaviečių, efektyviausia strategija yra pažeisti ciklinio laukimo sąlygą. Nustatykite griežtą, globalią ir savavališką kelių užraktų įgijimo tvarką. Jei gijai kada nors reikia laikyti ir A, ir B užraktus, ji visada turi įgyti A užraktą prieš įgydama B užraktą. Ši paprasta taisyklė padaro ciklinius laukimus neįmanomus.
5. Apsvarstykite Alternatyvas Užrakinimui
Nors užraktai yra fundamentalūs, jie nėra vienintelis sprendimas lygiagretumo valdymui. Aukšto našumo sistemoms verta išnagrinėti pažangesnes technikas:
- Beužraktės duomenų struktūros: Tai sudėtingos duomenų struktūros, sukurtos naudojant žemo lygio atomines aparatinės įrangos instrukcijas (pvz., Compare-And-Swap), kurios leidžia lygiagrečią prieigą nenaudojant užraktų. Jas labai sunku teisingai įgyvendinti, bet jos gali pasiūlyti geresnį našumą esant dideliam varžymuisi.
- Nekintami duomenys: Jei duomenys niekada nėra modifikuojami po jų sukūrimo, juos galima laisvai bendrinti tarp gijų be jokio sinchronizavimo poreikio. Tai yra pagrindinis funkcinio programavimo principas ir vis labiau populiarėjantis būdas supaprastinti lygiagrečius dizainus.
- Programinė transakcinė atmintis (STM): Aukštesnio lygio abstrakcija, leidžianti programuotojams apibrėžti atomines transakcijas atmintyje, panašiai kaip duomenų bazėje. STM sistema užkulisinėse tvarko sudėtingas sinchronizavimo detales.
Išvada
Užraktais paremtas sinchronizavimas yra lygiagretaus programavimo kertinis akmuo. Jis suteikia galingą ir tiesioginį būdą apsaugoti bendrus išteklius ir išvengti duomenų sugadinimo. Nuo paprasto miutekso iki niuansų turinčio skaitymo-rašymo užrakto, šie primityvai yra būtini įrankiai kiekvienam programuotojui, kuriančiam daugiagijes programas.
Tačiau ši galia reikalauja atsakomybės. Gilus potencialių pavojų – aklaviečių, gyvųjų aklaviečių ir našumo sumažėjimo – supratimas nėra pasirenkamas. Laikydamiesi geriausių praktikų, tokių kaip kritinės sekcijos dydžio minimizavimas, tinkamo užrakto detalumo pasirinkimas ir griežtos užraktų įgijimo tvarkos laikymasis, galite išnaudoti lygiagretumo galią, išvengdami jo pavojų.
Lygiagretumo įvaldymas yra kelionė. Ji reikalauja kruopštaus projektavimo, griežto testavimo ir mąstysenos, kuri visada atsižvelgia į sudėtingas sąveikas, galinčias atsirasti, kai gijos veikia lygiagrečiai. Įvaldę užrakinimo meną, žengiate kritinį žingsnį link programinės įrangos, kuri yra ne tik greita ir jautriai reaguojanti, bet ir tvirta, patikima bei teisinga.