Swift Actors unusable with Concurrency

Hello friends,

Right now, we've got some classes with some mutable Arrays, and to use them with Swift Concurrency, we've rewritten them as Actors like such

actor TheActor {
    var logMessages: [Message]
}

The road block we are running into is we have some methods on the Actor that use types that don’t conform to Sendable.

actor TheActor {
    var logMessages: [Message]

    func doSomething() {
        let handle = makeHandle()
        logMessages.append(handle.x())
    }
}

This gives us Swift Concurrency warnings and I'm not sure what the correct design here is. Any suggestions? Or is Sendable an implicit requirement for all types that interact with Swift Actors?

You forgot to show the rest. :slight_smile:

Did you have something like the below?

@main
enum ___A {
    static func main () async  {
        await test ()
    }
}
private func test () async {
    let actor = TheActor ()
    await actor.doSomething()
    await actor.doSomething()
    await actor.doSomething()
    await print (actor.logMessages)
}

actor TheActor {
    var logMessages: [Message] = []

    func doSomething() {
        let handle = Handle()
        logMessages.append(handle.x())
    }
}

struct Message {
    let value: String
}

extension Message: CustomStringConvertible {
    var description: String {
        "\(type (of: self)): \(value)"
    }
}
struct Handle {
    func x () -> Message {
        Message (value: "\(Int.random(in: 0..<1024))")
    }
}

Should print something like this:

[Message: 463, Message: 563, Message: 221]
1 Like

Which exactly concurrency warnings are you getting?

This is a common problem! There's some discussion about this in the migration guide:

https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/commonproblems#Actors

1 Like

i don’t get any warnings when compile OP’s original snippet, but i believe his frustration stems from an async makeHandle() method, which does produce an unresolvable concurrency warning:

class FileHandle
{
    var x:Int

    init() { x = 0 }
}

func makeFileHandle() async -> FileHandle
{
    return FileHandle()
}

actor A
{
    init() {}
    func f() async
    {
        let fh = await makeFileHandle()
// Non-sendable type 'FileHandle' returned by implicitly 
// asynchronous call to nonisolated function cannot cross 
// actor boundary
        fh.x = 1
    }
}

this oversight is infuriating to me because there is no inherent problem with this code and it is not possible to work around the problem without non-trivial refactoring.

By moving both the non-Sendable data and operations on that data into the actor, no isolation boundaries need to be crossed. This provides a Sendable interface to those operations that can be freely accessed from any asynchronous context.

i don’t think that section is applicable here because all of FileHandle uses are already within the actor.

i think the unspoken truth we seem reluctant to admit is that RBI, and not earnest philosophical introspection about Good App Architecture and the extensive refactoring that comes with it, was the real solution to this problem all along. but that doesn’t help people who need to support Swift 5.10 and earlier.

IMO, enabling Sendable checking before RBI landed in a Swift release was a mistake that probably let to a lot of wasted engineering effort and fed into perceptions of Swift Concurrency being hard to understand or adopt.

2 Likes

I’m not sure if you mean “unresolvable in Swift 5.x” but just for posterity I believe this should be resolvable if you make the return type of makeFileHandle() be sending FileHandle.

5 Likes

To be clear, this wasn't an oversight. Region-based isolation is an approach that was adapted from very recent PL research -- research that did not exist when Sendable checking was originally under development. (The timing is almost comical - the paper was presented at the first PLDI after Sendable checking was included in a release of Swift!)

I understand the frustration that the Swift 6.0 compiler makes many false-positive data-race warnings just go away, and it's very easy to strategize different rollout paths for complete concurrency checking with the benefit of hindsight. I don't think that it's beneficial to discuss the hypothetical alternative path of holding up Sendable checking for an approach that wasn't known at the time. It's also important to recognize that real-world adoption of complete concurrency checking in Swift 5.10 and earlier informed the usability focus areas in the Swift 6.0 compiler, just as real-world adoption feedback from Swift 6.0 being surfaced now will inform future usability improvements to data-race safety.

11 Likes

I believe the compatibility constraints here are for packages that support older tools versions; sending FileHandle only works if you're using Swift 6.0 tools.

3 Likes

yes, this is resolvable when using the betas, but i didn’t want to suggest switching to the beta toolchain as i usually default to assuming the App Store is involved in these questions and my understanding is the App Store doesn’t allow you to compile with a beta.

right, for apps this problem goes away in less than a year but libraries in the worst case scenario will have to wait until 6.0 is a reasonable minimum required toolchain version. (the vibe right now seems to be 5.8 for “reasonable” minimum)

to speed that up i imagine we might “lie a little” and mark types conditionally Sendable for swift(<=5.10).

1 Like

I believe it is untenable to support < Swift 6.0 if your package has meaningful concurrency usage. I've been somewhat succesful in maintaining support for 5.10, but I think it is too hard to be worthwhile. I think it is pretty reasonable for Xcode-based clients, which adopt new toolchains almost immediately.

just to clarify, is this advice for packages in the future, after Swift 6 is released, or is this advice for packages right now?

funny enough, once a year or so i work up the courage to hike the minimum required toolchain version for the OSS packages i maintain, and am perennially reminded how many people there are out there using older versions of Swift.

I know you can use strings for OS versions instead of the enum values, but I'm unsure what the implications are.

I'm definitely going to be moving to Swift 6 minimum, as of ~today, now that the Xcode 16 RC is available. I know some folks are versioning their Package.swift, and I might go that route too. Still kind of experimenting. But, I've found lots of stuff, like sending and isolated params, is too hard to live without.

3 Likes

Xcodes Plug...

As an aside, for those on macOS, there's an (open source) project called Xcodes that makes it trivial to manage multiple Xcode versions (and the corresponding SDKs), including betas. They have a polished desktop app, and a command-line version.

It will pick up (and manage) existing Xcode installations too (so you don't need to uninstall any Xcodes you have already).

It retains the version names, so you can select the Xcode you want from your Applications folder:

Screenshot 2024-09-10 at 3.47.36 AM

It's really handy if you want to get ahead of Swift 6.

Yeah, with 5.10 this gave me a lot of frustration too as many similar designs has started to give warnings. The good part is that they have uncovered problematic cases as well.

I think global actor might be the (partial) answer: you can isolate such function to the same actor. Not a perfect, and won’t work in every case, but better then nothing. Plus you can provide wrapper with @unchecked Sendable for such case as a workaround. But still I agree that this makes some troubles if you stick with 5.x versions.


I’m just not sure if it was ever possible to reach this point with concurrency checks in a less disruptive way. And if that’s what was required to get to compile-time safety in the end, I think that’s a right choice despite complications.