Section 4/172 menit

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:

swift
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

swift
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

swift
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.

swift
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.

swift
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
    }
}