How to Create a Local Actor Isolated Task?

I'm currently working with Swift's concurrency model and have encountered challenges when performing sequential operations on an actor while maintaining its isolation. Below is a simplified version of my code:

actor CountDatabase: Sendable {
    private var value: Int = 0
    func write(_ newValue: Int) {
        value = newValue
    }
    func read() -> Int {
        value
    }
}

func isolate(_ actor: isolated CountDatabase, _ closure: @Sendable @escaping (CountDatabase) -> Void) {
    print("isolate operates on")
    closure(actor)
}

let database = CountDatabase()
Task {
    await isolate(database) { database in
        database.write(10)
        let ten = database.read()
        print("Actor: ten is \(ten)")

        database.write(ten + 1)
        let eleven = database.read()
        print("Actor: eleven is \(eleven)")
    }
}

The current approach feels cumbersome and not in line with Swift's emphasis on clarity and conciseness.

I would expect to do that something like

Task { [isolate database] in
        database.write(10)
        let ten = database.read()
        print("Actor: ten is \(ten)")

        database.write(ten + 1)
        let eleven = database.read()
        print("Actor: eleven is \(eleven)")
}
  • Is there a more straightforward way to perform multiple sequential operations on an actor?
  • if not, what are the underlying reasons for the absence of such a feature in Swift's concurrency model?
3 Likes

That's indeed what we'll want and would be enabled by this pitch: Closure isolation control but implementation work has not started yet.

6 Likes

Thank you for the quick response @ktoso! I came across that proposal and initially thought it might have evolved into something else during the review process. I couldn’t find a final version, so it seems it’s still in the works. Looking forward to its progress!

3 Likes

In the meantime, you can make this a bit simpler by moving the function with the isolated parameter into the type itself. Here are two options:

actor CountDatabase {
	private var value: Int = 0
	func write(_ newValue: Int) {
		value = newValue
	}
	func read() -> Int {
		value
	}

	func performOperation(_ op: @Sendable (isolated CountDatabase) -> Void) {
		op(self)
	}

	func fancierPerformOperation<T, E>(_ op: @Sendable (isolated CountDatabase) throws(E) -> sending T) throws(E) -> sending T {
		try op(self)
	}
}

let database = CountDatabase()
Task {
	await database.performOperation { db in
		db.write(10)
		let ten = db.read()
		print("Actor: ten is \(ten)")

		db.write(ten + 1)
		let eleven = db.read()
		print("Actor: eleven is \(eleven)")
	}
}
3 Likes

@mattie, thank you for the temp solution.

@OguzYuuksel, thank you for creating this interesting and high-value topic.

I couldn't stop myself tinkering with it. :slight_smile:

My Tinkering
//
//  Test.swift
//  Actors
//
//  Created by ibex on 16/1/2025.
//

// [https://forums.swift.org/t/how-to-create-a-local-actor-isolated-task/77225]
// [https://forums.swift.org/t/how-to-create-a-local-actor-isolated-task/77225/4]

@main
enum Test {
    static func main () async throws {
        let x: Self?
        print (type (of: x))
        
        let N = 64

        let database = CountDatabase()
        let signal   = Signal()
    
        print ("spawn some work...")
        let values: [Int] = [16, 32, 64, 67]
        for i in 0..<values.count {
            spawn (key: i,  value: values [i], load: N, database: database, signal: signal)
        }
        print ("finished spawning...")

        print ("wait for all work to finish...")
        while true {
            let u: Bool = await signal.performOperation { sig in
                let u = sig.read()
                let v = u == values.count
                return v
            }
            
            if u {
                break
            }
            else {
                try await Task.sleep(until: .now + .seconds (1))
            }
        }
            
        print ("finished!")
    }
}

extension Test {
    static func spawn (key: Int, value: Int, load N: Int, database: CountDatabase, signal: Signal) {
        Task.detached {
            for i in 0..<N {
                await database.performOperation { db in
                    db.write (value)
                    let u = db.read ()
                    print (key, i, "\(value) is \(u)")
                    assert (u == value)

                    db.write (u + 1)
                    let v = db.read ()
                    print (key, i, "\(value + 1) is \(v)")
                    assert (v == value + 1)
                }
            }
            
            // signal finished
            await signal.performOperation { sig in
                let u = sig.read()
                sig.write (u + 1)
            }
        }
    }
}

actor CountDatabase {
    private var value: Int = 0
    func write(_ newValue: Int) {
        value = newValue
    }
    
    func read() -> Int {
        value
    }
}

extension CountDatabase {
    func performOperation(_ op: @Sendable (isolated CountDatabase) -> Void) {
        op(self)
    }

    func performOperation<T, E>(_ op: @Sendable (isolated CountDatabase) throws(E) -> sending T) throws(E) -> sending T {
        try op(self)
    }
}

actor Signal {
    private var value: Int = 0
    func write(_ newValue: Int) {
        value = newValue
    }
    
    func read() -> Int {
        value
    }
}

extension Signal {
    func performOperation(_ op: @Sendable (isolated Signal) -> Void) {
        op(self)
    }

    func performOperation<T, E>(_ op: @Sendable (isolated Signal) throws(E) -> sending T) throws(E) -> sending T {
        try op(self)
    }
}