Ending the lifetime of a value of type can cause a deallocation (@_noLocks)

The following code sample fails to compile with the following error:

"Ending the lifetime of a value of type 'TestInner' can cause a deallocation"

final class TestInner {}

final class Test {
    let inner = TestInner()

    @_noLocks
    func asdf() {
        inner
    }
}

I am not sure what is problematic here. It seems to me that accessing inner should be the same as accessing self (which compiles ok)?

2 Likes

Not sure about it as written, but if you were to call anything on the "inner" wouldn't there be the corresponding "retain, call, release" sequence? (with both retain/release being @_noLocks unsafe).

1 Like

I suspect that's indeed what the compiler's unhappy about, although in principle none of that should be necessary since it's a let property of self and therefore cannot (validly) be deallocated while asdf is executing.

So, maybe an implementation limitation or oversight?

1 Like

Can it do it though? Imagine you do have retain/release in debug and not in release. When you compile with release – there are no @_noLocks errors. Then you switch to Debug – and start getting those errors... Will that be an acceptable workflow? Or should "no locks" checker be pessimistic and always assume debug? Similar to how #if condition in Swift always checks both branches. I don't know, thinking out loud.

Not sure about it as written, but if you were to call anything on the "inner" wouldn't there be the corresponding "retain, call, release" sequence?

Yeah, if I was getting error specific to reference counting, that would make more sense.

E.g. this code will generate the following error:

"This code performs reference counting operations which can cause locking"

final class Test {

    @_noLocks
    func asdf() {
        let closure = { self.test() }
    }

    func test() {
    }
}

But with the error that I am getting, I can't do anything with inner (e.g. pass it to Unmanaged)

Also, I am not sure if referencing inner needs to increase retain count necessarily? We are accessing existing variable that is already at +1 retain count, we are not assigning this to another variable. But I might be missing something here?

The call to inner.foo() might deallocate the outer class (named test in your example):

var holder: Outer!

class Inner {
    deinit {
        print("Inner deinit")
    }
    func foo() {
        print("before assigning holder to nil")
        holder = nil
        print("after assigning holder to nil")
    }
    func bar() {
        print("Inner.bar")
    }
}
class Outer {
    deinit {
        print("Outer deinit")
    }
    let inner = Inner()

    func asdf() {
        print("before inner.foo")
        inner.foo()
        print("after inner.foo")
        inner.bar()
    }
}

holder = Outer()
holder.asdf()

with -O this outputs:

before inner.foo
before assigning holder to nil
Outer deinit
Inner deinit
after assigning holder to nil
after inner.foo
Inner.bar
1 Like

That is good point! But this is somewhat different example since here Inner.foo would fail to compile if it was annotated with @_noLocks since Outer.asdf would require it to be.

I think in my original example, it was not possible for it to happen? But maybe compiler can't have visibility into that?

2 Likes

@tera you were right, looking at godbolt, there is retain/release pair when accessing inner. But it is coming from inlined getter which retains it.

output.Test.inner.getter : output.TestInner:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     rdi, qword ptr [r13 + 16]
        mov     qword ptr [rbp - 8], rdi
        call    swift_retain@PLT
        mov     rax, qword ptr [rbp - 8]
        add     rsp, 16
        pop     rbp
        ret

output.Test.asdf() -> ():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     qword ptr [rbp - 8], 0
        mov     qword ptr [rbp - 8], r13
        mov     rdi, qword ptr [r13 + 16]
        mov     qword ptr [rbp - 16], rdi
        call    swift_retain@PLT
        mov     rdi, qword ptr [rbp - 16]
        call    swift_release@PLT
        add     rsp, 16
        pop     rbp
        ret

I guess what I would need here is borrow inner.

Ideally global "let" variables could be "immortal" and don't incur ARC traffic at all. Right?

1 Like

Right. But the original case was for a class member variable, not a global…?

In principle the compiler is free to omit any retains and releases that it can prove are unnecessary. Hypothetically it could do some pretty awesome optimisations in this regard, like not bothering to retain or release within an entire complicated object graph when it knows that the whole thing will go away at a specific point in program control flow (e.g. imagine building a big but temporary object graph during a processing task, and having it boil down to malloc/realloc and a single free).

To date, however, I'm not aware of any such advanced smarts (in Swift or any other notable language).

Also, I suspect this is hampered by the technical requirement to preserve even undesirable behaviour, such as retain loops.

2 Likes

Yes, sure.

I always wondered how to do immortal objects in Swift, this could be an answer (one of the answers).

Is leaking through retain loops UB?

Nope, it’s well-defined to waste your memory and not do anything else.

2 Likes

Whoops, I misread "undesirable" as undefined. I'm not sure it's possible to disallow memory leaks though.

1 Like

Note that retain loop does not automatically imply "leak":

class C {
    deinit {
        print("deinit")
    }
    init(link: C? = nil) {
        self.link = link
        something()
    }
    var link: C?
}

var a: C! = C()
var b: C! = C()
a.link = b
b.link = a
a = nil
b = nil
RunLoop.current.run(until: .distantFuture)

Here object "a" points to object "b" and object "b" points back to object "a" and we got rid of our external pointers to "a" and "b" (by setting "a" and "b" variables to nil) – there seems to be a "leak", but that's only a temporary one:

extension C {
    func something() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 100) {
            self.link = nil
        }
    }
}

So long as there's a way to break the retain loop by some means it's not necessarily a (permanent) leak.

2 Likes