Why `nonisolated lazy var` is allowed on actor?

If trying to make var declared on actor nonisolated, Swift 6 compiler of course produces error:
"'nonisolated' cannot be applied to mutable stored properties".

That is clear because such declaration would create shared mutable state which Swift 6 doesn't allow.

However for some reason it allows such kind of declaration if variable is lazy. The following code compiles and runs with Swift 6 compiler:

actor A {
    var i = 0
    nonisolated lazy var k = 0
    
    nonisolated func readK() -> Int {
        k += 1
        return k
    }
}

lazy affects only how variable is initialized, however following mutations behave the same as if it would be declared nonisolated var k = 0, which is not allowed by the compiler.

This effectively leads to possibility to create following code, which is explicit data race:

func run() {
        let a = A()
        Task.detached {
            let result = a.readK()
            print("\(result)")
        }
        Task.detached {
            let result = a.readK()
            print("\(result)")
        }
    }

I've ran this code and indeed it creates data race leading to "1" printed by both tasks.

To me this looks like a bug in the compiler and should not be allowed. Or am I missing something?

14 Likes

Ah, lazy properties.

I’m not at a computer right now so I’ll add the disclaimer that I can’t test your example, but I suspect this is a bug and the fix is to take lazy properties into account explicitly when performing this check.

A lazy property looks like a computed property to the rest of the compiler, except we also synthesize a hidden stored property of optional type to store the cached value. We then generate a getter and setter for the lazy property that accesses the underlying stored property.

If you wrote the lazy pattern out by hand, we would either complain about the underlying stored property (if it was also nonisolated) or reject the getter (if the getter was nonisolated and then it tried to access this stored property).

Property wrappers need similar treatment too, if that hasn’t been fixed already.

14 Likes

Checked with release version of Xcode 16 (16A242d), the bug is still there.

Can you please file a bug at Issues · swiftlang/swift · GitHub?

2 Likes

Absolutely. Here is the created issue It is possible to declare `nonisolated lazy var` on `actor` in Swift 6 · Issue #76513 · swiftlang/swift · GitHub

5 Likes

Thanks! Here's the fix: Concurrency: Reject nonisolated lazy properties by slavapestov · Pull Request #76518 · swiftlang/swift · GitHub

8 Likes

I understand this is an issue for a lazy property in an actor but why would this be forbidden for a non-global-actor-isolated class that conforms to Sendable and handles synchronization manually?

final class FooFoo: Sendable {
    private let synchronize = OSAllocatedUnfairLock()

    nonisolated(unsafe) lazy var foo: Int = {
        synchronize.withLockUnchecked { 0 }
    }()
}

Is it because the actual assignment to the underlying storage (of the lazy property) will happen outside of any concurrency safety mechanism, like withLock function?
Therefore it's not guarded by the lock, and can lead to a data race?

If so then I think the language should provide a possibility to somehow manually assign a value to the storage of the lazy property?
Or any other solution if possible?

After all, I can work around that with:

final class FooBar: Sendable {
    private let synchronize = OSAllocatedUnfairLock()
    private nonisolated(unsafe) var _foo: Int?

    var foo: Int {
        synchronize.withLockUnchecked {
            if let _foo {
                return _foo
            } else {
                let foo = 0
                _foo = foo
                return foo
            }
        }
    }
}

it's just a lot of boilerplate code.

I could wrap all that into a propertyWrapper but then I would run into the same issue

'nonisolated' is not supported on properties with property wrappers

which I can ignore by adding @unchecked:

final class FooBar: @unchecked Sendable {

Though with @unchecked it's hard to spot other issues, even if you remove it temporarily the compiler stops at the first lazy or wrapped property and doesn't display other errors...