Having a hard time understanding anchored ARC lifetimes

i’ve been investigating a lot of excessive-COW + overretain bugs lately, and i am having a really hard time understanding how the new anchored ARC lifetime rules work.

for the examples in this post, i am using this dummy class:

final
class Connection
{
    let id:Int

    init(id:Int)
    {
        self.id = id
    }
    deinit
    {
        print("deinitialized: \(id)")
    }
}

and i am building everything with -O.

to start, if i do not use any anchoring, the Connection objects get deinitialized instantly, even if the local variable bindings referencing them are in use:

@main
enum Main
{
    static
    func main()
    {
        let a:Connection = .init(id: 0)

        print("x")

        let _:Connection = a

        print("y")
    }
}
deinitialized: 0
x
y

this was surprising to me, because i assumed a would live for at least until after the print("x") executes, because there is a usage after it:

let _:Connection = a

but whatever, the optimizer does weird stuff and i didn’t really expect this to work in the first place.

next, if i do use anchoring, by passing a through a function:

@main
enum Main
{
    static
    func main()
    {
        let a:Connection = .init(id: 0)

        print("x")

        let _:Void = { (_:__owned Connection) in }(a)

        print("y")
    }
}

then it suddenly lives for the entire duration of main:

x
y
deinitialized: 0

this was surprising to me, because i assumed passing a to a function would make it live at least until the print("x") executes, but no longer afterwards. but it even outlasts the print("y").

but whatever, the optimizer does weird stuff and i didn’t really expect this to work in the first place because formally a is still loitering around for the entire scope, it is just not being used after passing it to the closure literal.

now i try to explicitly get rid of the a binding, by overwriting it with a new binding:

@main
enum Main
{
    static
    func main()
    {
        let a:Connection = .init(id: 0)
        let b:Int? = 5

        print("x")

        let _:Void = { (_:__shared Connection) in }(a)

        print("y")

        guard let a:Int = b
        else
        {
            return
        }

        let _:Int = a

        print("z")
    }
}

but this doesn’t work either! the connection still lives for the entire duration of main!

x
y
z
deinitialized: 0

this was surprising to me, and unlike the previous two examples, i did expect this to work because formally the old binding a:Connection no longer exists by the time print("z") happens, and the guard let should have forced a deinitialization of the old value.

there seems to be no way to kill the binding in the middle of a lexical scope, either it dies instantly, or it lives for the entire duration of the scope. at this point, i am quite confused about how this new “anchored lifetimes” system is actually supposed to behave.

Shadowing does not end a binding's lifetime, even though there's no way to access it anymore (this is observable in debug builds the debugger). And arguably shadowing using guard, as opposed to a proper nested scope, should never have been allowed for consistency reasons, but that'd be source-breaking to change, and people do like it.

(EDIT: and I'm not the one to speak about everything else, but my guess is that assigning to _ does not sufficiently count as a use, but nearly any other use would be, including your closures that don't actually do anything.)

1 Like

the problem i am facing is i have a helper function that looks like (simplified):

@inlinable public
func run<Query, Success>(query:Query,
    over connection:__owned Connection,
    with consumer:(Batches) async throws -> Success)
    async throws -> Success
    where Query:MongoQuery
{
    let batches:Batches = .create(pinned: connection)
    
    do
    {
        let success:Success = try await consumer(batches)
        await batches.destroy()
        return success
    }
    catch let error
    {
        await batches.destroy()
        throw error
    }
}

where Batches is a reference type that holds a strong reference to a Connection.

the helper function is called by an API function that looks like:

@inlinable public
func run<Query, Success>(query:Query,
    with consumer:(Batches) async throws -> Success)
    async throws -> Success
    where Query:MongoQuery
{
    ...
    let connections:ConnectionPool = try await ...

    return try await self.run(query: query, 
        over: try await .init(from: connections),
        with: consumer)
}

it is very important that the Connection wrapped by the Batches object passed to the user-supplied closure is uniquely-referenced, to prevent the framework from creating an excessive number of TCP sockets.

the helper function is way too large to manually inline into the API function, and _move/take or whatever it’s called now is still an experimental feature and won’t be an option for libraries for several years.

as far as i understand, it is the over connection:__owned Connection parameter of the helper function that is overretaining the connection. but if shadowing cannot end its lifetime, what can?

The newly-accepted consume (your _move/take), and I think literally nothing else for a parameter. For a local you could assign nil over it, but parameters are alive for the entire function body in today's Swift, even __owned ones. Previously this lifetime was sometimes shortened; my understanding is that that doesn't happen anymore.

(Now that I think of it, this doesn't seem optimal for existing setters and initializers taking implicitly-consuming parameters…but maybe those still get "forwarded" in enough cases, where there's no observable side effect between the last use and a possible deinit.)

i found a workaround by turning the connection parameter into an inout Connection? optional parameter, which works for my use case because the framework is not ABI stable. then i do a

connection = nil

before yielding the Batches object to the user-provided closure.

i had to add a ton of fatalError("unreachable")s to handle the nil optional, but at least my unit tests are passing now.

not that relying on a fragile optimizer behavior was ever ideal to begin with, but it seems unfortunate that this was removed in favor of consume without any sort of escape hatch for code that cannot use consume until Q2 2024 (assuming consume lands in swift 5.8).