Async let capture list

I'm having an issue capturing a sendable type in an async let closure.

struct NonSendable {
	var description: String
	init(description: String) {
		self.description = description
	}
}
var nonSendables: [NonSendable] = []
let aTask = Task { [sendables = nonSendables.map(\.description)] in
	for something in sendables {
		// this is fine
	}
}
async let asyncLet = { [sendables = nonSendables.map(\.description)] in
	for something in sendables {
		// Sending 'nonSendables' into async let risks causing data races between async let uses and local uses
	}
}()
nonSendables = []

Why does the async let closure throw an error using the same capture list as the Task closure? One thing to note... there is no error if nonSendables is not mutated at the end. Shouldn't this work?

What version of swift are you using? You can paste the output of xcrun swift -version or the version number of Xcode > About Xcode.

Your code compiles for me as a task or an async let on

swift-driver version: 1.166 Apple Swift version 6.4 (swiftlang-6.4.0.19.4 clang-2100.3.19.4)
Target: arm64-apple-macosx26.0

I was using Xcode 26.6 but I just tried in Xcode 27 beta 5 as well and have the same problem there.

swift-driver version: 1.148.6 Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)
Target: arm64-apple-macosx26.0

I just tried creating a new SPM executable project in Xcode 27 beta 5 which got me swift-tools-version: 6.4. I added a static async func to the "Hello, world!" struct and pasted my code and got the same error.

I could be wrong, but I believe async let makes everything to the right of its equal sign async, which in this case includes not only the running of your closure but the creation of it, when the capture list is evaluated. (Whereas for Task, the closure is created synchronously and then passed to Task.init.) If you create the closure on a separate line, or use a helper variable for the capture, the problem should go away.

4 Likes

Yes, that did fix it. Thank you! It would be nice if the capture list was not async...

struct NonSendable {
	var description: String
	init(description: String) {
		self.description = description
	}
}
var nonSendables: [NonSendable] = []
let aTask = Task { [sendables = nonSendables.map(\.description)] in
	for something in sendables {
		// this is fine
	}
}
let asyncWork = { [sendables = nonSendables.map(\.description)] in
	for something in sendables {
		// this is now fine
	}
}
async let asyncLet = asyncWork()
nonSendables = []

I don't know that's exactly the right way to think about it. Going back to the Swift Evolution proposal, SE-0317 :

The right-hand side of a async let expression can be thought of as an implicit @Sendable closure, similar to how the Task.detached { ... } API works, however the resulting task is a child task of the currently executing task.

IOW, when you write:

async let a = b
await a

you actually get (approximately):

let a
a = await Task { b }

Plugging in your original example, you got (approximately):

let asyncLet
asyncLet = await Task {
    { [sendables = nonSendables.map(\.description)] in
	    for something in sendables {
		// Sending 'nonSendables' into async let risks causing data races between async let uses and local uses
	    }
    }()
}

IOW, your capture list is not really async. It's synchronous, but it's inside a task wrapper.

It's also not like your own Task-based code, since it has an extra level of closure that yours doesn't have.

1 Like

"It would be nice if there was a way to spell"

let asyncLet = ChildTask { [sendables = nonSendables.map(\.description)] in
    { // instead of having the capture list here
	    for something in sendables {
	    }
    }()
}
await asyncLet

(note that I adjusted your translation to show that the child task is created immediately, not at the point of await)

I don't personally think this is a huge deal, but what the original poster was asking for is reasonable.

It might not be unreasonable for Swift to treat this specific pattern as a special case and promise to evaluate the captures in the original context. But Jordan is right: it would have to be a special case because the semantics today are that the entire expression is evaluated asynchronously.

2 Likes

It all makes logical sense as implemented now, but I am having a tough time coming up with a scenario where it would be desirable for the capture list to be executed async along with the rest of the closure. I think a special case is warranted here.

I find this problem quite vexing, for many reasons.

The first is because the semantics of async let are actually quite complex. Adding a special case feels like making it even more so. But then, I am pretty much always in favor of making the compiler smarter so it can accept code which matches the intention of the developer. But then on the other, other hand I don't know how I feel about including semantically-important logic in capture expressions.

My inclination is to say that this failure is, ultimately, useful. I agree that accepting this code would be nice in this case. But I think it also would serve to obscure the true nature of the special-ness of the right-hand side of the assignment.

Moving this essential preparation step to a line before the async let, in my view, reduces the mental parsing requirements. But, more importantly, it serves to clarity not just what is happening, but when.

4 Likes

I think you're right. The fact that this is also an error seals the deal for me:

@Sendable func foo(with sendables: [String]) {
	for something in sendables {
		// this func is fine, but...
	}
}
async let result = foo(with: nonSendables.map(\.description)) // Error: Sending 'foo' risks causing data races

If that's not going to call nonSendables.map() synchronously neither should the capture list of a closure. Maybe @jrose is right and ChildTask is the right answer for the future because I would still like a succinct way to limit the scope of certain prepared variables to the closure itself.

Thanks for the help everyone.

2 Likes

Any chance of adding a capture list to the left-hand-side of the async let?

async[sendables = nonSendables.map(\.description)] let result = foo(with: sendables)
1 Like

There is always a chance :slight_smile:

But I think you would have to make a very compelling case that it is better than:

let sendables = nonSendables.map(\.description)
async let result = foo(with: sendables)
3 Likes

All I can do is point at existing capture list semantics and say if there's a reason for them in a closure then there's a reason for them in an async let closure.

Hmm, this is an interesting point. My gut says this is not an expressivity limitation in the case of async let. But am I wrong?

As far as I am aware I have no way of saying "declare sendables for use only on the right-hand side of async let result = foo(with: sendables)" which is what I'm really after with my original question.

I can't wrap let sendables = ... inside a "do" with the foo() call (async let result: String; do { let sendables = ...; result = foo(with: sendables) }) because I'd have to forward-declare async let result outside of the do and that's not allowed. If everything on the right-hand side is async end of story I think I'm out of options for limiting the scope of sendables in a child task.

1 Like