CodeGym /Kurslar /Swift SELF /Layihədə umum state haradadir

Layihədə umum state haradadir

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

1. Umumi state: niye bu problem oldu

Bizim LibraryCLI ciddi ardıcıllıqla işlədiyi vaxtda (əmri oxuduq → etdik → nəticəni çıxardıq), umum state “gizli pislik” ola bilərdi: sanki var idi, amma onu eyni anda heç kim dartmırdı və buna görə hər şey sabit görünürdü. Amma paralel sorğular əlavə edən kimi (async let, TaskGroup, bir neçə Task {}) yaddaşdakı eyni obyekt birdən-birə bir neçə tapşırığın diqqət mərkəzinə düşür.

“Umumi state” (shared state) dedikdə bir funksiyadan daha uzun yaşayan və fərqli yerlərdən istifadə oluna bilən datanı nəzərdə tutacağıq. “Umumi deyishilen state” (shared mutable state) isə eyni şeydir, amma onu dəyişmək (var) mümkündür və nəticədə data yarışları yaranır. Məhz bunu Swift actor və Sendable ilə qəfəsə salmağa çalışır.

Bunu daha aydın göstərmək üçün CLI-də tipik problemin portreti belədir:

flowchart TD
    CLI["CLI (commands parsing)"] --> SVC["Application Service"]
    SVC -->|parallel| NET1["Network fetch #1"]
    SVC -->|parallel| NET2["Network fetch #2"]
    SVC --> REPO["Repository (file + index)"]
    SVC --> CACHE["Cache (dictionary in memory)"]
    SVC --> LOG["Logger (buffer/file/console)"]

Şəbəkə çox vaxt özü-özlüyündə stateless olur: sorğu getdi — cavab gəldi. Amma keş, repository və logger uzunömürlü şeylərdir. Onları intizamsız şəkildə konkurent çağıranda klassika başlayır: “bəzən işləyir, bəzən işləmir, bəzən də cümə axşamı axşamı.”

Layihədə shared mutable state-i necə tapmaq olar

Böyük sistemlərdə “hamı üçün concurrency auditi” bazar ertəsindən yeni həyat başlamaq kimi səslənir. Amma tədris layihəsində bunu dürüst və kifayət qədər tez etmək olar, çünki bizi cəmi üç-dörd tipik “state yığan” maraqlandırır. Və onlar adətən qoxusundan tanınır: var, kolleksiyalar (Dictionary, Array) və servisdə field kimi yaşayan, lokal dəyişən olmayan nəsə.

Praktik fikir belədir: funksiyanın içindəki lokal dəyişənlər adətən təhlükəsizdir, çünki onlar bir icra axınına məxsusdur. Amma uzunömürlü obyektin/service-in property-lərində saxlanılan və bir neçə CLI əmri boyunca yaşayan hər şey izolyasiya üçün namizəddir.

“Marker ilə işarələ” adlı çox sadə qayda

Təsəvvür edin ki, kodda marker götürüb bunları dairəyə alırsınız:

  1. class və onun var-property-ləri,
  2. static var, qlobal var, singleton-lar,
  3. bu tip service field-ləri: private var storage: [Key: Value],
  4. fayla yazan, index saxlayan, ya da statistika yığan hər şey.

Swift 6 həm də özü xəbər verəcək ki, qlobal var-lar ağrıdır: sərt concurrency xüsusilə qlobal dəyişənlərə tələbləri gücləndirir, çünki onlara “istənilən yerdən müraciət etmək olar”, deməli bu, data yarışları üçün ideal məkandır.

2. Keş: balaca Dictionary, böyük nəticələr

Tədris layihəsində keş çox günahsız görünür. “Sadəcə cavabı yadda saxlayırıq ki, bir daha şəbəkəyə getməyək.” Tək iplikdə bu, həqiqətən belə işləyir. Amma paralel sorğularda keşin iki tipik sıradan çıxma ssenarisi olur: ya iki tapşırıq eyni anda eyni dictionary-yə yazır, ya da biri oxuyarkən digəri dictionary-nin strukturunu dəyişir (bu isə birbaşa data yarışına aparır).

Sadəlövh versiya

import Foundation

final class ResponseCache {
    private var storage: [String: Data] = [:]

    func get(_ key: String) -> Data? { storage[key] }
    func set(_ data: Data, for key: String) { storage[key] = data }
}

Bu “pis kod” deyil — bu, ardıcıl giriş olduğunu dürüst şəkildə fərz edən koddur. Problem o zaman başlayır ki, bir neçə şəbəkə tapşırığını paralel edirsiniz və hamısı eyni ResponseCache-ə baxır.

Concurrency ilə dost olan versiya: actor

Actor məhz ona görə yaradılıb ki, state kisəsi çox müştəri olsa belə ardıcıl servis olunsun.

import Foundation

actor ResponseCache {
    private var storage: [String: Data] = [:]

    func get(_ key: String) -> Data? { storage[key] }
    func set(_ data: Data, for key: String) { storage[key] = data }
}

Burada yeni başlayan üçün vacib məqam budur: get/set metodlarının özləri mütləq async olmalı deyil. Amma kənardan onlara müraciət edəndə yenə də await lazım olacaq, çünki bu, cross-actor girişdir.

Nümunə:

import Foundation

let cache = ResponseCache()

func demo() async {
    await cache.set(Data("OK".utf8), for: "health")
    let data = await cache.get("health")
    print(data?.count ?? 0) // 2
}

Keşdə nə saxlanmalıdır: “canlı linklər” yox, “snapshot”lar

Keş xüsusilə təhlükəlidir, əgər ora referans tipli dəyişən obyektlər (class) qoyub sonra onları kənara verirsinizsə. Onda actor sanki izolyasiya edir, amma siz “canlı” linki sərhəddən keçirdiniz — və yenə shared mutable state alındı, sadəcə bu dəfə maskalanmış şəkildə.

Daha doğrusu, Data və ya referans mutabilliyi olmayan value tipləri (struct-lar) keşləmək lazımdır. Bu, birbaşa Sendable ideyası ilə bağlıdır: concurrency kontekstlərinin sərhədləri boyunca təhlükəsiz şəkildə dəyərləri ötürmək, “ümumi dəyişən linklər”i yox.

3. Repository: diskdəki fayl da umum state-dir

LibraryCLI-də repository məlumatı işə salınmalar arasında saxlayır: JSON oxuyuruq, index qururuq, kitablar əlavə edirik/silirik, sonra geri yazırıq. Hətta “heç nəyi yaddaşda saxlamırsınızsa” belə, fayla yazma faktının özü ümumi resursdur. Eyni fayla paralel iki yazı sizə zədələnmiş JSON verə bilər, sonra da decode xətasına uzun-uzadı baxıb bunun elə belə nəzərdə tutulduğunu iddia edərsiniz.

Repository daxilində demək olar həmişə dəyişən state olur: kitablar kolleksiyası, index, “dirty” bayraqları, fayl yolu və s. Üstəlik repository-yə müxtəlif əmrlərdən müraciət olunur: add, remove, fetch, fetch-many. Paralel fetch-many icazə verən kimi isə adamda belə bir həvəs yaranır: “gəlin hər tapşırıq özü repository-yə kitab əlavə etsin.” Məhz burada əsl sirk başlayır.

Tipik səhv: repository-ni paralel tapşırıqlardan yeniləmək

Problem “bunu heç vaxt etmək olmaz” deyil. Problem budur ki, bunu səhvən etmək çox asandır, sonra isə data yarışları və çətin təkrarlanan bug-lar alırsınız.

Tədris səviyyəli həll: repository — actor üçün ideal namizəddir, çünki o, təbiətən single-writer olmalıdır.

Sadələşdirilmiş actor-repository

Aşağıda formu görmək üçün skelet verilib. O qəsdən kiçikdir və serialization detallarından azaddır: vacib olan fikir “bütün state hasarın arxasındadır”.

import Foundation

struct BookID: Hashable, Sendable {
    let rawValue: UUID
}

struct BookSnapshot: Sendable {
    let id: BookID
    let title: String
}

actor LibraryRepository {
    private var books: [BookID: BookSnapshot] = [:]

    func upsert(_ book: BookSnapshot) { books[book.id] = book }
    func all() -> [BookSnapshot] { Array(books.values) }
}

Nəzərə alın iki məqamı.

Birincisi: kənara snapshot (BookSnapshot) veririk — task-lar arasında daşımaq üçün canlı object linkindən daha təhlükəsiz olan value-struct. Bu, cross-actor qarşılıqlı əlaqədə Sendable tələblərinə yaxşı oturur: actor sərhədlərini keçən tiplər ötürmək üçün təhlükəsiz olmalıdır.

İkincisi: upsert metodu “böyük əməliyyat”dır. Biz kənara books vermirik və “dictionary-ni əl ilə qurdalamağa” icazə etmirik. Kənar kod nə qədər az “üç çağırışlıq transaction” yığa bilsə, məntiqi yarışların şansı bir o qədər azdır.

Niyə daxili index-i kənara vermək olmaz

Bir çox repository-də Dictionary<Token, Set<BookID>> tipli index olur. Axtarış üçün çox faydalıdır, amma bu da dəyişən state-dir. Əgər bu index-i bütöv halda kənara versəniz, kənar kod “ağıllı optimizasiya” etməyə başlaya və təsadüfən invariantları poza bilər.

Yaxşı stil: repository özü axtarış əməliyyatlarını yerinə yetirir və BookSnapshot və ya BookID siyahısı qaytarır, index isə daxili detal olaraq qalır. Beləliklə, invariant-ları actor izolyasiyası daxilində saxlayırsınız.

4. Logger: “sadəcə print” kimi görünsə də state-dir

Logger ilə adətən belə mübahisə edirlər: “Orada nə concurrency olsun ki, bu sadəcə konsola mesajdır.” Əgər logger doğrudan da sadəcə print çağırırsa, ən çoxu qarışıq sətirlər alacaqsınız. Amma təkcə bu da debug prosesini “hansı sətir hansı tapşırığa aiddir?” kimi bir tapşırığa çevirir.

Əgər logger fayla yazırsa, bufer saxlayırsa, mesajları kontekst toplayaraq formatlayırsa, o artıq klassik shared mutable state olur. Onda siz təkcə loglarda qarışıqlıq yox, həm də log faylının zədələnməsi ilə qarşılaşa bilərsiniz.

Fikir: logging-i actor vasitəsilə seriallaşdırmaq

Kursda bizim Logger kontraktımız var (logging günü). Arxitekturanı sındırmamaq üçün yeni protocol uydurmuruq. Bunun əvəzinə actor-wrapper etmək olar: bu wrapper logging çağırışlarının bir növbədən (actor mailbox) keçməsinə zəmanət verir, deməli daxili logger state-inə münasibətdə ardıcıl icra olunur.

Skelet-fikir:

import Foundation

actor LoggerActor {
    private let base: any Logger

    init(base: any Logger) { self.base = base }

    func log(_ message: String) {
        base.log(message) // actor daxilində — ardıcıl
    }
}

Burada ən sadə “bir sətirlik” variant göstərilib. Real layihədə siz level/category/konteksti ötürəcəksiniz. Amma əsas qazanc budur ki, indi müxtəlif tapşırıqlardan bunu edə bilərsiniz:

await logger.log("fetch started")

və eyni bufer/fayla iki tapşırığın eyni anda girməsi barədə düşünməyəcəksiniz.

Log sırası ilə bağlı nüans

Vacibdir ki, çox vəd verməyək: actor mesajları ardıcıl icra edir, amma xidmət sırası planlamadan asılı ola bilər (runtime-da task priority-lər nəzərə alınır). Ona görə logların “sərt vaxt sırası” riyazi aksioma kimi zəmanətli deyil.

Amma “eyni daxili state-ə eyni anda yazı olmayacaq” — məhz bizə lazım olan budur.

5. Nəyi izolyasiya etməli, nəyi olduğu kimi saxlamalı: LibraryCLI üçün şparqalka

Yeni başlayan ilk dəfə “state-i izolyasiya et” sözünü eşidəndə əli hər şeyi actor-un içinə bükməyə gedir, hətta String.lowercased() funksiyalarını belə. Bu, anlaşılır bir impulstur, amma kodu ağırlaşdırır. Düzgün yol komponentləri “state yığanlar” və “təmiz hesablayanlar” kimi bölməkdir.

Aşağıda layihəmiz üçün bir orientir var. Bu, fizika qanunu deyil, amma yaxşı kompasdır.

LibraryCLI-də komponent Uzunömürlü var var? Paralel tapşırıqlardan çağırıla bilər? Nə edirik
In-memory cache (Dictionary) bəli bəli (xüsusilə fetch-many zamanı) actor ilə izolyasiya edirik
Rate limiter (sayğaclar/zaman/pəncərə) bəli bəli, əgər şəbəkə paraleldirsə actor ilə izolyasiya edirik
Repository (yaddaş + fayl + index) bəli potensial olaraq bəli actor ilə izolyasiya edirik (single-writer)
Logger (bufer/fayl/sayğaclar) asılıdır bəli çox vaxt actor ilə izolyasiya etmək məntiqlidir
ApiClient/HTTPClient statesiz xeyr bəli olduğu kimi saxlayırıq
Komanda parser-i, DTO→Domain mapping xeyr bəli olduğu kimi saxlayırıq
Config (yalnız let) xeyr bəli let kimi saxlayırıq, bu yaxşıdır

Ayrıca xoşdur ki, actor-lar özləri də task-lar arasında təhlükəsiz paylaşıla bilir: Swift modelində actor tipləri Sendable kimi qəbul olunur, çünki onların mutabilliyi izolyasiya ilə qorunur.

6. Arxitekturanı necə pozmamaq olar: state ətrafında actor

Təhlükəli üslub var: “gəlin bir actor App yaradaq və içinə hər şeyi yığaq: şəbəkə, CLI, parser, repository, logger.” Nəticədə formal olaraq təhlükəsiz olan, amma concurrency-nin mənasını itirən nəhəng ardıcıl kombayn alırsınız və onu yenə də debug etmək çətin olur (çünki monolitdir).

Tədris layihəsində bizə daha vacib olan başqa üslubdur: actor kiçik state parçasını izolyasiya edir, təmiz məntiq isə təmiz qalır. Onda “concurrency” orchestration səviyyəsində yaşayır (service-lərdə), “data correctness” isə actor-ların hasarının arxasında olur.

“Əvvəl/sonra” mini sxemi

flowchart LR
    subgraph Before["Before"]
        S1["Service"] --> C1["Cache (class)"]
        S1 --> R1["Repo (class)"]
        S1 --> L1["Logger (class)"]
    end

    subgraph After["After"]
        S2["Service"] --> C2["Cache (actor)"]
        S2 --> R2["Repo (actor)"]
        S2 --> L2["LoggerActor (actor wrapper)"]
    end

“Sonra”nın mənası hər şeyin async olması deyil. Mənası odur ki, service tapşırıqları daha cəsarətlə paralelləşdirsə belə, state özü-özünə qırılmır.

Nümunə: paralel yükləmə və təhlükəsiz saxlama

Aşağıda bunun koddakı hissini hiss etdirmək üçün qəsdən kiçik ilustrasiya verilib. Bizdə kitabı (snapshot kimi) alan və repository-yə qoyan service var. Vacib məqam budur ki, repository actor-dur, deməli yeniləmə onun state-inə münasibətdə ardıcıl olacaq.

import Foundation

struct BookSnapshot: Sendable {
    let id: UUID
    let title: String
}

actor LibraryRepository {
    private var books: [UUID: BookSnapshot] = [:]
    func upsert(_ book: BookSnapshot) { books[book.id] = book }
}

İndi isə “iki yükləmə paralel, yazma ardıcıl”:

import Foundation

func demo(repo: LibraryRepository) async {
    async let a = BookSnapshot(id: UUID(), title: "Swift")
    async let b = BookSnapshot(id: UUID(), title: "Concurrency")

    await repo.upsert(await a)
    await repo.upsert(await b)
}

Bəli, bu oyuncaq nümunədir, amma vacib vərdişi göstərir: paralelləşdirmək mümkün olanı paralelləşdiririk (məlumatın alınması), umumi state-i isə izolyasiya ilə yeniləyirik (repository). Məhz bu qaydanı biz single-writer policy kimi möhkəmləndirirdik, indi isə actor onu mənəvi qayda yox, proqramın xassəsi edir.

7. Shared state ayıranda tipik səhvlər

Səhv №1: “Keş sadəcə dictionary-dir, onsuz da balacadır, deməli təhlükəsizdir.”
Dictionary-nin ölçüsü data yarışının olub-olmamasına heç nə etmir. Bir Dictionary eyni anda müxtəlif tapşırıqlardan oxunub yazılırsa, bu shared mutable state-dir. Düzgün strategiya ya ardıcıl girişi ciddi təmin etməkdir (və bunu heç vaxt pozmamaq), ya da keş-i actor ilə izolyasiya etməkdir ki, qayda avtomatik yerinə yetirilsin.

Səhv №2: “Repository-ni hər paralel tapşırıqdan yeniləyək, sonra necə olsa saxlarıq.”
Bu yanaşma tez-tez “yarım-korrekt” vəziyyətlər yaradır: bir tapşırıq index-i yenilədi, digəri hələ yalnız kitablar siyahısını, üçüncüsü isə artıq fayl yazısına başladı. Hətta açıq data yarışı tutmasanız belə, məntiqi data korlanması ola bilər. Repository demək olar həmişə single-writer olmalıdır və bunu ifadə etməyin ən aydın yolu actor-dur.

Səhv №3: “Logger sadəcə çıxışdır, data ilə bağlı deyil, deməli izolyasiya lazım deyil.”
Logger çox vaxt daxili state saxlayır: formatter, bufer, sayğaclar, fayl. Paralel girişdə qarışıq sətirlər və ya zədələnmiş fayl ala bilərsiniz. Hətta yalnız konsola yazsanız belə, qarışma debug-u əzaba çevirir. Logger ətrafında actor-wrapper adətən çox ucuz başa gəlir, amma çox əsəb qənaət edir.

Səhv №4: “Actor-dan kənara daxili object-ə referans verək, daha sürətli olar.”
Bu, izolyasiyanı “keçib getməyin” ən hiyləgər yoludur. Əgər actor kənara referans tipli mutable object qaytardısa, xarici kod onu konkurent şəkildə dəyişə bilər və actor artıq təhlükəsizliyə nəzarət etmir. Ona görə kənara snapshot-lar — value tipləri — vermək daha yaxşıdır. Bu da birbaşa Sendable-in niyə mövcud olduğu ilə üst-üstə düşür: shared mutable state-i concurrency domain sərhədlərindən kənara buraxmamaq.

Səhv №5: “Hamısı actor-dadırsa, deməli hər metod atomardır.”
Actor məlumatlara eyni anda girişdən qoruyur, amma await daxilində reentrancy mümkündür: icra dayana bilər, actor başqa mesajı emal edər və sonra geri qayıdar. Əgər “yoxladım → await → dəyişdim” tipli axın qurursunuzsa, invariant-ları riskə atırsınız. Buna görə actor daxilində yoxlama ilə kritik mutasiya arasına await qoymamağa çalışın, əgər qaçılmazdırsa, await-dən sonra şərtləri yenidən yoxlayın.

1
Tapşırıq
Swift SELF, səviyyə, dərs
Bağlanıb
Ümumi sayğac
Ümumi sayğac
1
Tapşırıq
Swift SELF, səviyyə, dərs
Bağlanıb
Sətir keş
Sətir keş
1
Tapşırıq
Swift SELF, səviyyə, dərs
Bağlanıb
Nömrələnmiş loq
Nömrələnmiş loq
1
Tapşırıq
Swift SELF, səviyyə, dərs
Bağlanıb
Kitab deposu
Kitab deposu
1
Sorğu/viktorina
, səviyyə, dərs
Əlçatan deyil
TaskGroup və aktorlar
Paralellik, ləğv etmə, Sendable, reentrancy
Şərhlər
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION