Explicit self not required for Task closures?

I think this is a bug, but I would like confirmation. The following code compiles cleanly, whereas I think it should error due to lack of explicit self

final class Foo {
    var thisVariableInvolvesSelf = 42
    
    func leak1() {
        Task {
            thisVariableInvolvesSelf += 1
        }
    }
}

In the Swift book, it says that escaping closures require an explicit self:

If you want to capture self , write self explicitly when you use it, or include self in the closure’s capture list.

I believe Task {} is actually the following constructor which takes an @escaping parameter

public init(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Success)

As a consequence of this behavior, in the following example, the closure strongly retains self even though [weak self] is in the capture list. As a corollary, the function leaks.

final class Bar {
    var thisVariableInvolvesSelf = 43
    func leak2() {
        Task { [weak self] in
            //never yields
            await withCheckedContinuation({(_: CheckedContinuation<(),Never>) in })
            thisVariableInvolvesSelf += 1
            self?.innocent()
        }
    }
    func innocent() { }
}

This leak has caused me some headache. I do not really understand the mechanism of it, unless I there is some alternative constructor for Task which takes a nonescaping closure.

Is that a leak, or is the capture just extending the lifetime of self? Once the Task completes the memory is released, right?

In the motivating situation (and in example 2) the Task will never complete so there is no distinction between the two.

In the motivating case the task involves looping over an AsyncSequence of incoming push notifications, which is an unbounded sequence.

True enough, though I dislike conflating leaks with extended reference lifetimes, since the lifetime would end given the rest of the code is executed. In any case, does a newer toolchain affect the behavior at all? I wonder if this is a bug in 5.5 that was fixed later.

Task init has a underscored annotation so self is not required even tho os escaping. Xcode doesn’t show it but you can see it in the Swift source.

1 Like

The latest toolchain also produces a warning, though I don't think it's really a very helpful one:

Capture of 'self' with non-sendable type 'Foo' in a @Sendable closure

The warning is unrelated, it just wants concurrency safety

actor Foo {
	var thisVariableInvolvesSelf = 42
	
	func leak1() {
		Task {
			thisVariableInvolvesSelf += 1
		}
	}
}


actor Bar {
	var thisVariableInvolvesSelf = 43
	func leak2() {
		Task { [weak self] in
			//let the task finish after all!
			//  await withCheckedContinuation({(_: CheckedContinuation<(),Never>) in })
			thisVariableInvolvesSelf += 1
			await self?.innocent()
		}
	}
	func innocent() { }
}

On the semantic question, I believe it is a real leak. In practice, it leaks even if you finish the task. My theory of the cycle is as follows

  1. instance-of-Bar retains
  2. Task, which retains
  3. anonymous closure, which retains
  4. 1, through the implicit self

I suspect it is an implementation detail whether finishing the task breaks 2->3, but in practice it doesn't.

Looks like this issue with @_implicitSelfCapture was anticipated by @Douglas_Gregor in the review of SE-0304:

Explicit self is there for a specific purpose: to help identify retain cycles by requiring one to explicitly call out self capture where it's likely to cause retain cycles. We intentionally did not make it required everywhere , and then later we removed explicit self in other unlikely scenarios . When capturing self in a task, you effectively need the task to have an infinite loop for that capture to be the cause of a reference cycle. That fits very well with the direction already set for explicit self .

Though perhaps the necessity of having infinite loops in Tasks was not as anticipated yet since AsyncSequence wasn't fully established. But since throwing off a task to handle an infinite sequence will be rather common, it certainly appears this point needs to be reexamined.

At the very least, allowing implicit capture even after the user has initiated manual capture seems like a bug, so that should be filed.

2 Likes

That is very helpful, thanks! It the infinite loop question but I believe that is something of a red herring actually.

Testing on my end suggests the examples I posted still leak whether the task completes or not, which seems to contradict

you effectively need the task to have an infinite loop for that capture to be the cause of a reference cycle.

That part definitely seems like a bug, regardless of the other behavior, and in addition to manual self capture not overriding the implicit capture.

3 Likes
Terms of Service

Privacy Policy

Cookie Policy