4. Actor Re-entrancy: Pisau Bermata Dua
4. Actor Re-entrancy: Pisau Bermata Dua
Re-entrancy = saat method actor await, method lain di actor yang sama bisa mulai berjalan sebelum method pertama selesai.
Ini sering disebut "fitur tak terduga" karena programmer berpikir actor = mutex (yang non-reentrant). Tapi tanpa re-entrancy, Swift akan dipenuhi deadlock.
Mengapa Swift Memilih Re-entrancy?
Bayangkan kalau actor non-reentrant:
actor Cache {
var data: [String: Data] = [:]
func fetch(key: String) async -> Data {
if let cached = data[key] { return cached }
let fresh = await downloader.download(key) // (1) await
data[key] = fresh
return fresh
}
}
// Caller 1 dan 2 sama-sama panggil fetch("foo") bersamaan
Kalau non-reentrant: Caller 2 akan terblokir hingga Caller 1 selesai. Selama Caller 1 menunggu di (1), executor terhenti — deadlock kalau downloader ternyata butuh actor Cache juga. Pada sistem berbasis async, deadlock semacam ini hampir tidak bisa dihindari tanpa re-entrancy.
Dengan re-entrancy: saat Caller 1 await, Caller 2 bisa masuk. Tidak ada deadlock. Tapi ada efek samping yang harus diperhatikan.
Bug Klasik: Invariant Pecah Setelah Await
actor UserCache {
var users: [UUID: User] = [:]
func loadUser(id: UUID) async throws -> User {
// ❌ BUG re-entrancy
if let cached = users[id] { return cached }
let user = try await api.fetchUser(id) // <-- await, executor dilepas
// Selama await, panggilan loadUser(id) yang lain bisa
// menyelesaikan dan menulis users[id] juga.
// Kita overwrite hasil mereka dengan instance baru.
users[id] = user
return user
}
}
Akibatnya: dua atau lebih panggilan paralel untuk id yang sama akan menjalankan api.fetchUser lebih dari sekali. Bisa wasted network, atau bahkan duplicate side-effect (kalau API tidak idempotent).
Pola Mitigasi 1: In-Flight Dedup dengan Task
actor UserCache {
private var users: [UUID: User] = [:]
private var inflight: [UUID: Task<User, Error>] = [:]
func loadUser(id: UUID) async throws -> User {
if let cached = users[id] { return cached }
if let existing = inflight[id] { return try await existing.value }
let task = Task<User, Error> {
try await api.fetchUser(id)
}
inflight[id] = task
defer { inflight[id] = nil }
let user = try await task.value
users[id] = user
return user
}
}
Task di sini menjadi "pegangan" untuk semua caller paralel yang minta id yang sama. Hanya satu request HTTP, semua caller dapat hasil yang sama.
Pola Mitigasi 2: Re-check Setelah Await
Kadang lebih murah: re-validasi invariant setelah await.
actor Counter {
var value = 0
func incrementIfBelow(limit: Int, fetcher: () async -> Int) async {
// snapshot sebelum await
let snapshot = value
let bonus = await fetcher()
// re-check: bisa jadi value sudah berubah selama await
guard value == snapshot, value + bonus < limit else { return }
value += bonus
}
}
Pola Mitigasi 3: Synchronous Critical Section
Pindahkan semua state mutation kritis ke method synchronous (tanpa await di dalamnya). Method async hanya orchestrate.
actor SessionStore {
private var sessions: [String: Session] = [:]
// Sync: tidak ada await, tidak bisa interleave
func _storeSync(_ s: Session) {
sessions[s.id] = s
}
func saveSession(token: String) async throws {
let s = try await api.fetchSession(token) // I/O
_storeSync(s) // mutation atomic
}
}