CodeGym /Kurslar /Swift SELF /Paralel yazma

Paralel yazma

Swift SELF
Səviyyə, Dərs
Mövcuddur

1. Atomik yazma kifayət deyil

JSON-u ilk dəfə diskə saxlamağı öyrənəndə elə görünür ki, problem həll olunub: «axı faylı yazdıq, hər şey işləyir». Amma real dünyada tətbiq vakuumda yaşamır: onu iki dəfə işə salmaq olar, komandalar paralel icra oluna bilər (fərqli proseslərdə), həm də «oxu → dəyiş → saxla» yazması faylın ideal atomik əvəzlənməsi olsa belə, məlumat itkisinə səbəb ola bilər. Bu gün single-writer siyasətini təsbit edirik və ümumi dəyişən vəziyyəti elə izolyasiya etməyi öyrənirik ki, sistem proqnozlaşdırılan qalsın.

İki fərqli problem: «yarımfayl» və «yeniləmələrin itirilməsi»

Bu iki problemi çox asanlıqla qarışdırmaq olur, çünki hər ikisi diskə yazma ilə bağlıdır və hər ikisi «nəsə itib» kimi görünür. Amma onların mahiyyəti, simptomları və qorunma üsulları fərqlidir.

Birinci bəla faylın bütövlüyünün pozulması-dır. Fayl zədələnib: oxunmur, deşifrə olunmur, sanki «JSON yarıda qırılıb» kimi görünür. Bundan bizi temp → replace, backup və recovery qoruyur.

İkinci bəla yeniləmələrin itirilməsidir (lost update). Burada fayl düzgündür və hətta mükəmməl deşifrə olunur. Sadəcə, orada başqa saxlamanın dəyişikliklərini «üstündən keçən» verilənlər versiyası qalıb. Əsas məqam da budur: əsas faylın atomik əvəzlənməsi bunu qorumağa heç də borclu deyil. O, yalnız «köhnə faylın yerinə yenisi bütöv şəkildə gəldi» zəmanəti verir, amma «bu yeni fayl kimsə paralel şəkildə etdiyin bütün dəyişiklikləri də ehtiva edir» zəmanəti vermir.

Həyat analoqları ilə desək: temp → replace «kurs işini yazırdım, noutbuk yadda saxlayarkən ortada söndü» vəziyyətindən qoruyur. Amma lost update — «iki nəfər eyni sənədi redaktə edir, sonra kimsə daha gec Save düyməsini basıb başqasının düzəlişlərini silir» vəziyyətidir. Fayl gözəldir. Sadəcə düzəlişlər yoxdur.

CLI tətbiqində “ümumi dəyişən vəziyyət” haradadır

Bizim tədris məqsədli CLI-da (şərti olaraq onu LibraryCLI adlandıraq) ümumi dəyişən vəziyyət adətən belə görünür:

  1. Yaddaşdakı vəziyyət: məsələn, kitablar massivi, ID üzrə lüğət, versiya sayğacı — nə istəsəniz.
  2. Diskdəki vəziyyət: library.json (və yanında library.json.bak).

İdeal dünyada bir prosesimiz var, o da hər şeyi ardıcıl edir: yüklə → əmri emal et → saxla → bitir. Onda elə görünür ki, rəqabət yoxdur.

Amma reallıqda CLI-da paralellik çox asan yaranır: istifadəçi iki terminal aça və demək olar eyni anda iki əmri işə sala bilər:

  • terminal A: librarycli add ...
  • terminal B: librarycli remove ...

Və artıq qarşınızda iki proses, yaddaşda iki müstəqil «dünya» var; hər ikisi öz snapshot-ını eyni library.json-a geri yazmağa çalışacaq.

Swift dünyasından daha bir vacib məqam: hətta bir proses daxilində də «ümumi dəyişən vəziyyət» təhlükəlidir, əgər ona paralel müraciət olunursa. Swift yaddaşa eksklüziv giriş qaydasına ciddi yanaşır: paralel yazmalar və düzgün izolyasiya olmadan qarışıq oxu/yazı qeyri-müəyyən davranışa və runtime xətalarına gətirib çıxara bilər, xüsusən də inout və setter-lər ətrafında. Bu, yaddaş modeli və giriş eksklüzivliyi müzakirəsində aydın görünür.

Bu gün nə çoxaxınlı tətbiq quracağıq, nə də mürəkkəb sinxronizasiya primitivləri əlavə edəcəyik. Məqsədimiz daha sadədir: necə arxitektura qurmaq olar ki, yazan bir olsun, ümumi dəyişən isə kod boyu dağılmasın.

«Yeniləmələrin itirilməsi» necə görünür: zaman xətti

Lost update-i bir dəfə şəkillərdə hekayə kimi görmək faydalıdır. Təsəvvür edək ki, iki proses (A və B) klassik «read → modify → write» dövrünü edirlər:

sequenceDiagram
    participant A as Proses A
    participant F as library.json
    participant B as Proses B

    A->>F: load (v1 oxuyur)
    B->>F: load (v1 oxuyur)
    A->>A: modify (v1 + kitab əlavə edir)
    B->>B: modify (v1 - kitab silir)
    A->>F: save (v2A yazır)
    B->>F: save (v2A üzərinə v2B yazır)

Diqqət edin: həm v2A, həm də v2B tamamilə düzgün ola bilər. Amma sonda yalnız son saxlayan qalacaq, birincinin dəyişiklikləri isə yox olacaq.

Və burada düşüncənin hiyləgər tələsi gizlənir: «amma biz axı atomik yazırıq!». Bəli, atomik. Amma atomiklik «fayl bütövdürmü?» sualına cavab verir, «bütün dəyişikliklər nəzərə alınıbmı?» sualına yox.

2. Arxitekturada single-writer

Single-writer policy FileManager-dən deyil, başdan başlayır. Bu, məhz məsuliyyət siyasətidir: tətbiqimizdə konkret faylı yazmaq haqqı olan bir komponent olmalıdır, qalanların hamısı ona müraciət etməlidir, «bir az burada yazım, bir az orada» etməməlidir.

LibraryCLI çərçivəsində bu, adətən o deməkdir ki, diskə yazma komandaların koduna səpələnmir. add komandası özü fayl URL-ni açıb write etməməlidir. remove komandası da etməməlidir. Bunun əvəzinə hər iki komanda etibarlı yazma qaydalarına əsasən snapshot saxlaya bilən vahid «anbar»a müraciət edir.

Kod səviyyəsində bunu belə hiss etmək olar: bizdə mainURL-a sahib olan və «yüklə/saxla» səviyyəli metodlar təqdim edən bir tip yaranır. Daxildə hər şey hələ sadə olsa belə, sərhəd artıq yaranıb, və bu, hər cür sehrdən vacibdir.

Mini fraqment (ayrı tip vasitəsilə «bir yazan» ideyası):

import Foundation

struct LibraryStorage {
    let mainURL: URL

    func loadData() throws -> Data {
        try Data(contentsOf: mainURL)
    }

    func saveData(_ data: Data) throws {
        // burada əvvəlki mühazirələrdən etibarlı yazma siyasəti olacaq
        try data.write(to: mainURL) // müvəqqəti olaraq, sadələşdirilmiş
    }
}

Bəli, saveData hələ «sadəlövh»dür. Amma əsas odur ki, indi yalnız LibraryStorage hara yazacağını bilir, qalan kod isə hətta mainURL-ə çıxışa malik olmamalıdır (yaxud ən azından ondan istifadə etməməlidir).

Proses daxilində dəyişən vəziyyətin izolyasiyası

Single-writer ideyasının ikinci hissəsi təkcə fayl yazmasının deyil, həm də «ümumi vəziyyətin» izolyasiyasıdır. Hətta ardıcıl işləyən CLI-da da tezliklə belə bir cazibə yaranır: cari kitabxananı bir dəyişəndə saxlamaq, onu funksiyalar arasında ötürmək, bir yerdə dəyişmək, bir yerdə saxlamaq.

«Muta­siya şorbası» almamaq üçün başınızda sadə prinsip saxlamaq rahatdır: vəziyyəti bir qat dəyişə bilər, qalan qatlar isə niyyət formalaşdırır.

İnsani dildə desək: komanda «bu kitabı əlavə et» deməlidir, yoxsa «sənə massiv verirəm, özüm ora əlavə edib özüm də saxlayacağam» deməməlidir. Onda sizdə dəyişikliklərin tətbiq olunduğu və «nə vaxt saxlayırıq» ilə «səhv olarsa nə edirik» qərarlarının mərkəzləşdirilə bildiyi vahid nöqtə yaranır.

Mini fraqment: komanda niyyət formalaşdırır, servis tətbiq edir:

import Foundation

struct AddBookInput {
    let title: String
}

struct LibraryService {
    func addBook(_ input: AddBookInput) {
        print("Adding:", input.title) // Adding: ...
    }
}

Bu çox sadə görünür, amma izolyasiya məhz belə «sadə» sərhədlərdə yaşayır. Məntiq böyüyəndə siz «bəs faylı axırda kim saxlayırdı?» vəziyyətini çözmək məcburiyyətində qalmayacaqsınız.

3. Proseslər arasında kooperativ single-writer

Niyə proses daxilində single-writer kifayət deyil

Təəssüf ki (və ya xoşbəxtlikdən, yoxsa darıxdırıcı olardı), bir proses daxilində single-writer iki prosesdən qurtarmır. Hər bir prosesdə nizam-intizamlı ola və dəqiq bir LibraryStorage-a sahib ola bilərsiniz, amma onların sayı iki olacaq — çünki proseslər də ikidir.

Deməli, bizə daha bir ideya lazımdır: fayl səviyyəsində kooperativ «bir-bir yazırıq» intizamı. Və burada «kooperativ» sözü vacibdir: biz «deşilməz sistem» qurmuruq, sadəcə CLI-nın bütün nüsxələrinin əməl etdiyi aydın qayda tətbiq edirik.

Bu mühazirədə biz şüurlu şəkildə aşağı səviyyəli sistem bloklamalarını gətirmirik. Bizə sadə bir siqnal lazımdır: «yazma gedir». Ən sadə variant əsas faylın yanında lock-file-dır: məsələn, library.json.lock.

Məna belədir: proses saxlamadan əvvəl lock-fayl yaratmağa çalışır. Alınsa — o, «yazmanın sahibi» olur, saxlayır və lock-u silir. Əgər lock artıq mövcuddursa — deməli, kimsə yazır və biz qarışmırıq.

Bu, iclas otağının qapısındakı «məşğuldur» lövhəsinə bənzəyir. O, qapını fiziki olaraq açmağa mane olmur, amma normal insanlar yenə də döyünürlər.

Minimum qoruma kimi lock-file

Öncədən özümüzlə razılaşmaq vacibdir: lock-file möcüzə gülləsi deyil. O, dünyanı «qorumaq» üçün deyil, davranışı proqnozlaşdırılan etmək və eyni anda saxlamalardan yaranan yeniləmə itkisi riskini xeyli azaltmaq üçündür.

Mini reallaşdırma — «lock götür → əməli icra et → lock-u azad et»:

import Foundation

enum StorageWriteError: Error {
    case locked
}

func withFileLock(lockURL: URL, _ body: () throws -> Void) throws {
    let fm = FileManager.default
    let created = fm.createFile(atPath: lockURL.path, contents: nil, attributes: nil)
    guard created else { throw StorageWriteError.locked }

    defer { try? fm.removeItem(at: lockURL) }
    try body()
}

Diqqət edin, burada defer niyə bu qədər vacibdir. defer sizin «fövqəladə təmizlik işçiniz»dir: hətta body() daxilində nəsə xəta ilə dayansa da, lock-u silməyə çalışacağıq. defer olmadan lock-fayl asanlıqla «əbədi məşğuldur» lövhəsinə çevrilə bilər, və onda anbarınız fayl əl ilə silinənə qədər «əbədi məşğul» olacaq.

Və istifadə nümunəsi:

import Foundation

let mainURL = URL(fileURLWithPath: "library.json")
let lockURL = mainURL.appendingPathExtension("lock")

do {
    try withFileLock(lockURL: lockURL) {
        print("Saving...") // Saving...
        // burada etibarlı yazma olacaq: temp → replace (+ backup)
    }
} catch StorageWriteError.locked {
    print("Skip save: storage is locked") // Skip save: storage is locked
}

Bəli, bu «əbədi düzgünlük» zəmanəti vermir, amma əsas şeyi verir: paralel yazmaların baş vermədiyini artıq özümüzə yalandan demirik.

Lock-file ilə etibarlı yazmanı necə birləşdirmək olar

İndi hər şeyi bir «saxlama siyasəti»nə yığırıq; bu, az qala yoxlama siyahısı kimi səslənir:

Əvvəlcə lock götürürük (eyni anda iki yazan olmasın deyə). Sonra backup edirik (əgər bu sizin siyasətinizin hissəsidirsə). Daha sonra temp → replace vasitəsilə yazırıq. Və yalnız bundan sonra lock-u buraxırıq.

Əvvəlki mühazirələrdən bütün funksiyaları yenidən yazmayacağıq, amma addımların ardıcıllığını müəyyən edən üst funksiyada «yapışdırma»nın necə göründüyünü göstərəcəyik.

import Foundation

func saveSnapshot(_ data: Data, mainURL: URL) throws {
    let lockURL = mainURL.appendingPathExtension("lock")

    try withFileLock(lockURL: lockURL) {
        // Burada fərz edirik ki, artıq bunlar var:
        // makeBackupIfPossible(of:) və atomicWrite(_:to:)
        // günün əvvəlki mühazirələrindən.
        // Ardıcıllıq vacibdir: backup → atomicWrite.
        // try makeBackupIfPossible(of: mainURL)
        // try atomicWrite(data, to: mainURL)
        print("Saved safely") // Saved safely
    }
}

Bəli, burada iki çağırış şərhə çevrilib: biz bu günün əvvəlki mühazirələrindəki koda söykənirik. Amma məhz bu «ardıcıllıq çərçivəsi» siyasətin özüdür. Əgər ardıcıllıq qarışsa, backup-u səhv vəziyyətdən götürmək və ya main-i ehtiyat nüsxəsiz yazmaq olar, halbuki siz elə düşünürdünüz ki, nüsxə həmişə var.

Lock alınmasa nə etməli

Bu anda «hər necə olsa» etmək istəyi yaranır — məsələn, lock alınmayıbsa, «elə də olsun, yenə də saxlayarıq, biz ağıllıyıq». Bu, birbaşa lost update-ə qayıdış yoludur.

Əgər lock almamısınızsa, davranış üçün bir neçə variant var və hər biri siyasətin hissəsidir. Kurs çərçivəsində adətən ən proqnozlaşdırılan variant seçilir: heç nə yazmamaq, və «anbar məşğuldur» xətası qaytarmaq. Bu, istifadəçiyə xoş olmaya bilər, amma düzdür: siz məlumatları səssiz şəkildə itirmirsiniz.

CLI-da bu xüsusilə uyğundur: istifadəçi bir saniyədən sonra əmri sadəcə yenidən göndərə bilər. Amma «başqasının dəyişikliklərini səssizcə üstündən yazmaq» — sanki «masanın üstündə qaldığı üçün başqasının kitabını gizlicə götürmək» kimidir.

Niyə çoxaxınlılıq olmasa da “izolyasiya” deyirik

Haqlı bir sual vermək olar: «Axı biz çoxaxınlı kod yazmırıq, burada ümumiyyətlə “vəziyyətin izolyasiyası” nə üçün lazımdır?»

Çünki vəziyyət yalnız axınlar arasında «ümumi» olmur. O, aşağıdakılar arasında da ümumi ola bilər:

  • eyni proses daxilində fərqli komandalar arasında (kod bazası böyüyür və müxtəlif yerlərdən verilənləri dəyişmək asanlaşır),
  • fərqli proseslər arasında (CLI-nın iki işə salınması),
  • kodun fərqli səviyyələri arasında (komanda parseri, domen məntiqi, saxlama qatı).

Vəziyyətin izolyasiyası o deməkdir ki, siz şüurlu şəkildə qərar verirsiniz: «burada vəziyyət dəyişir, burada isə yalnız oxunur və ya niyyət formalaşır». Hətta bir dənə axın olmasa belə, bu yanaşma xaosu kəskin azaldır və səhvləri proqnozlaşdırılan edir.

Və bəli, Swift eksklüziv giriş yanaşması ilə bizə xatırladır ki, «dəyişən verilənlərə paralel giriş» — prinsip etibarilə təhlükəli bir şeydir və ona xırda məsələ kimi yanaşmaq olmaz.

4. Tipik səhvlər

Səhv №1: yazmanın atomikliyini yeniləmə itkisindən qorunma ilə qarışdırmaq.
Çox yayğın məntiq tələsi budur: temp → replace edirik deyə, deməli, paralellik artıq qorxulu deyil. Amma temp → replace faylın bütövlüyünü təmin edir, iki paralel düzəlişin birə birləşməsini yox. Nəticədə ideal oxunan JSON əldə etmək olar, amma orada dəyişikliklərin yarısı olmayacaq.

Səhv №2: yazmanı kod boyunca səpmək («bir dəqiqəyə write edərəm də»).
add, remove, update və daha beş komanda faylı birbaşa özü yazanda single-writer yox olur. Sonra siz «niyə bəzən .bak main ilə uyğun gəlmir» və ya «niyə recovery qəribə versiyanı bərpa etdi» kimi problemləri düzəltməyə başlayırsınız, və məlum olur ki, kimsə qaydaya uyğun yazmayıb. Burada müalicə darıxdırıcı, amma etibarlıdır: bir qat yazmaya cavabdeh olur, qalanlar hətta faylın harada olduğunu da bilmir.

Səhv №3: lock almaq və xətada onu silməyi unutmaq.
defer olmadan lock-file tələyə çevrilir: yazma zamanı baş verən hər hansı xəta .lock-ı orada saxlayacaq və sonra bütün anbar «əbədi məşğul» kimi görünəcək. Buna görə tərtibatçılar lock-u «nə isə olsa» silməyə və ya onu görməməzliyə vurmağa başlayırlar — nəticədə sistem yenidən yeniləmə itkilərinə qayıdır. Düzgün yol — lock uğurla yaradılan kimi dərhal defer yerləşdirməkdir.

Səhv №4: locked xətasını ignor etmək və yenə də saxlamaq.
Bu, demək olar həmişə lock-file mənasını öz əlinizlə ləğv etdiyiniz deməkdir. Əgər lock alınmayıbsa — deməli, siyasətə görə siz yazan deyilsiniz. Bu anda səssizcə «kimin daha tez üstündən yazacağını» yarışdırmaqdansa, dürüst şəkildə xətayla bitmək daha yaxşıdır.

Səhv №5: lock-file-ı “mütləq qoruma” kimi görməyə çalışmaq.
Lock-file kooperativ mexanizmdir. O, qaydalara əməl etməyən «pis qonşu»dan qorunmur və sistem bloklaması deyil. Bu normaldır: bugünkü məqsədimiz davranışı proqnozlaşdırılan etmək və itki riskini kəskin azaltmaqdır, fayl qalası tikmək deyil.

1
Tapşırıq
Swift SELF, səviyyə, dərs
Bağlanıb
Arxiv kilidi
Arxiv kilidi
1
Tapşırıq
Swift SELF, səviyyə, dərs
Bağlanıb
Fayl qoruyucusu
Fayl qoruyucusu
1
Tapşırıq
Swift SELF, səviyyə, dərs
Bağlanıb
Kilidlə saxlama
Kilidlə saxlama
1
Tapşırıq
Swift SELF, səviyyə, dərs
Bağlanıb
Itirilmiş yeniləmə
Itirilmiş yeniləmə
Şərhlər
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION