Lazy property initialization question

I am trying to implement a thread-safe lazy property wrapper on the grounds that achieving the thread-safe initialization code and correct handling a situation where multiple threads can enter the initialization code should be the solution. Here's the code of my property wrapper:

@propertyWrapper
public final class LazyThreadSafe<T> {
    private let handler: () -> T
    private var computedValue: T?
    private let lock = NSLock()
    public lazy var wrappedValue: T = lock.transaction {
        if computedValue == nil {
            computedValue = handler()
        }
        return computedValue!
    }
    public init(wrappedValue: @autoclosure @escaping () -> T) {
        self.handler = wrappedValue
    }
}

I'm also trying to take advantage of the lazy initialization built into Swift, which runs the initialization code only once if the property has not yet been initialized, thus avoiding the overhead of going through locking code after the property is initialized. One trick to note in the code is that when subsequent threads get to the initialization code after the initialization, they are given a cached value (computedValue) without re-running the initialization code.

When using exactly this approach but without a property wrapper, my thorough tests (including race condition tests) pass successfully. But with a property wrapper, the testing quite often ends up crashing with the "malloc: Double free of object" error.

I'm wondering if I'm on the wrong track because of contradictions with Swift's design, or is it some sort of current Swift limitations?

While you are waiting for full blown answer, I just want to say that lazy var isn't thread safe. Period. Even in your property wrapper it isn't thread safe as far as I can tell.

wrappedValue should be a computed property instead of a lazy var:

public var wrappedValue: T {
    return lock.transaction {
        if computedValue == nil {
            computedValue = handler()
        }
        return computedValue!
    }
}

I have a little class for thread safe var access. Does this help?

private let queue = DispatchQueue(label: "keyworder.threadsafevar.queue", attributes: .concurrent)

class ThreadsafeVar<T>
{
  private var value = Optional<T>.none
  
  func callAsFunction() -> T?
  {
    queue.sync(flags: .barrier) { [unowned self] in value }
  }
  
  func set(_ value: T?)
  {
    queue.async(flags: .barrier) { [unowned self] in self.value = value }
  }
  
  func set(closure: @escaping () -> T?)
  {
    queue.async(flags: .barrier)
    {
      [unowned self] in self.value = closure()
    }
  }
}
1 Like

As for the thread-safety of lazy properties, the official documentation only states:

If a property marked with the lazy modifier is accessed by multiple threads simultaneously and the property hasn’t yet been initialized, there’s no guarantee that the property will be initialized only once.

My approach takes that into account. So far it withstands thorough thread-safety testing and if implemented on plain lazy instance properties it works flawlessly at all. Its logic is congruent with the intuitive concurrency logic. The only doubtful place left is whether the internal assignment operation is atomic with respect to the result of the initialization handler in all situations.

Can an actor have a lazy var?

Thanks. As I said, my idea was to use Swift's built-in lazy behavior when a property is accessed multiple times - to reuse the cached value without calling the initialization handler. And all the added logic resides within the initialization handler itself.

Thanks. But readers-write pattern is not very useful in my case.

I would say yes, but haven't tried. From my understanding, actor serialise access to its properties, so using lazy var should be fine.

The error message you see tells that an object tried to be deallocated twice, what exactly causes this crash I can't tell, may be somebody from compiler team knows. But my wild guess is that it something related to ARC, multithreading and lazy var initialised twice.

I saw exactly these kind of errors when fixed multiple "lazy var" related crashes in an application. The fix was replacing "lazy var" with custom thread-safe implementation.

By the way:

@propertyWrapper
public final class LazyAtomic<Value> {
    private let lock: NSLock = .init()
    private var storage: Value?
    private var initializer: (() -> Value)?

    public init(wrappedValue initializer: @autoclosure @escaping () -> Value) {
        self.initializer = initializer
    }

    public init() {}

    public func setInitializer(_ initializer: @escaping () -> Value) {
        lock.lock(); defer {
            lock.unlock()
        }
        self.initializer = initializer
    }

    public var wrappedValue: Value {
        lock.lock(); defer {
            lock.unlock()
        }
        if storage == nil {
            guard let initializer = initializer else {
                preconditionFailure()
            }
            storage = initializer()
            self.initializer = nil
        }
        return storage!
    }

    public func assignNewValue(_ newValue: Value) {
        lock.lock(); defer {
            lock.unlock()
        }
        storage = newValue
    }
}

Thanks, but where in your code is the lazy property? As I've said many times, my idea is based on leveraging it(!) And again, it works perfectly without property wrappers, on a regular lazy class property. The only question is whether such an approach is wrong, for example by Swift's design, or it's some current Swift limitation. But in any case, thanks for your help :slightly_smiling_face:

You can't use the lazy keyword if you want thread safety. Period. Even if the initializer uses a lock, it's still not thread-safe.

There are plenty of other ways to implement lazy one-time initialization, as others on this thread have demonstrated. Why are you so obsessed with using the lazy keyword?

Thank you for your opinion. It would be really useful if you could provide some authoritative source, such as where it clearly states that lazy must not be used in a concurrent environment or at least some technical insight. As far as I know, not only does the official documentation say nothing about lazy initialization being completely non-threadsafe, but it even gives some hint as to where exactly you can expect the problem: "there is no guarantee that the property will only be initialized once” and that’s solvable. Could that specific property wrappers case be a bug, which should be fixed? Why make unbounded judgments?

I analyzed the SIL-code generated by the compiler for the lazy property, and essentially in Swift, it can be represented just like this:

class YYY {
    var backingFoo: Int?
    var foo: Int {
        get {
            lock()
            let foo = backingFoo
            unlock()
            if let foo = foo {
                return foo
            } else {
                let result = handler()
                lock()
                backingFoo = result
                unlock()
                return result
            }
        }
        set {
            lock()
            backingFoo = newValue
            unlock()
        }
    }
    ...
}

As you can see for yourself, the code is trivial and there is nothing in it that prevents you from using a thread-safe initializer approach.

You probably have misunderstood the begin_accessend_access pair as a lock. It is not; it's a compiler intrinsic used to enforce the rule of exclusivity (think borrow checker if you know Rust) — AFAIK they are a no-op for structs and only get compiled into classes in debug builds for purely diagnostic purposes.

2 Likes

Thank you, your answer has cleared the air...

While I get the appeal of this, we generally recommend against this pattern (concurrent queue + async for property access) because the overhead of spawning a thread to do the async if there isn't one active in the pool is many thousands of times slower than the work being done. The rule of thumb is async is only worth it with at least 100 microseconds of work, ideally 1ms or more. Additionally, DispatchQueue.sync cannot donate priority correctly with concurrent queues, though due to the previous issue that's less important here.

2 Likes
Terms of Service

Privacy Policy

Cookie Policy