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:
- class və onun var-property-ləri,
- static var, qlobal var, singleton-lar,
- bu tip service field-ləri: private var storage: [Key: Value],
- 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.
GO TO FULL VERSION