Clarification about implicitly Sendable closures

Hello,

I need a bit of clarification about Sendable closures. According to the documentation and SE-0302, any closure that satisfies the requirements for a Sendable closure and is used in a context that expects such closure is inferred by the compiler to be Sendable. Also, according to SE-0434 @MainActor closures are inferred @Sendable.

So, given the following example code:

private class Foo {
	func work() {
		let task: () -> Void = {
		}
		
		callbackMainActor {
			task() // this is ok
		}
		
		callbackSendable {
			task() // error: Capture of 'task' with non-sendable type '() -> Void' in a `@Sendable` closure
		}
	}
	
	func callbackMainActor(_ block: @escaping @MainActor () -> Void) {
	}
	
	func callbackSendable(_ block: @escaping @Sendable () -> Void) {
	}
}

What I think should happen (and this is probably where my understanding is flawed) is that task is used in a sendable closure in both callbackMainActor and callbackSendable and since it doesn't capture any non-sendable values it implicitly conforms to Sendable. If the closures of both methods are sendable, then why can't I use the same closure in both places? Why is it ok to use it inside a @MainActor closure but not inside a @Sendable closure?

I'm using
Apple Swift version 6.0.3 (swiftlang-6.0.3.1.10 clang-1600.0.30.1)
SWIFT_VERSION = 6

2 Likes

I wonder if by explicitly adding this type annotation you are not removing the sendable inference. I think adding : : @Sendable () -> Void or removing the type annotation should get this working as you expect.

1 Like

Thank you for responding. Yes, adding a @Sendable attribute works. It doesn't if I remove the type annotation (let task = {}), however. In any case, even if somehow I'm impeding the compiler from correctly inferring sendable, then shouldn't it show an error for the callbackMainActor method the same way it's showing an error for callbackSendable?

Because your closure is not inferred to be sendable only the first use will work, I'm pretty sure if you reverse the order of your callback methods the error will be in the callbackMainActor one

I think that you can force the sensibility inference by adding @Sendable (in addition to @MainActor) to the parameter of callbackMainActor

I should have mentioned. I tried commenting out one or the other. It didn't affect the outcome. Here's a corrected sample code:

private class Foo {
	func work() {
		let task = {}
		
		callbackMainActor {
			task() // ok
		}
	}
	
	func moreWork() {
		let task = {}
		
		callbackSendable {
			task() // error
		}
	}
	
	func callbackMainActor(_ block: @escaping @MainActor () -> Void) {
	}
	
	func callbackSendable(_ block: @escaping @Sendable () -> Void) {
	}
}

Thank you for the suggestion. I tried it. Still the same result.

Well then I think there is an issue indeed, will teach me to engage without testing

What swift version are you using, and do you have any special build settings that might affect this? I just pasted that code into SwiftFiddle and I didn't get any errors at all.

Hello, I'm using
Apple Swift version 6.0.3 (swiftlang-6.0.3.1.10 clang-1600.0.30.1)
I created an empty project using Xcode 16.2 and the only build settings I changed are
SWIFT_VERSION = 6
SWIFT_STRICT_CONCURRENCY = complete

Oh, I see, SwiftFiddle passes -swift-version 5 to the Swift 6.0 compiler by default. If I add -swift-version 6 to the custom flags then I get the error.

It seems to be something special about how global actors are inferred, not the main actor specifically, because custom global actors also work fine.

In the same document this was mentioned:

Global-actor-isolated closures are allowed to capture non-Sendable values despite being @Sendable.

This is what you are getting at. Yes, @Sendable is inferred for global-actor-isolated functions and closures, but they are also allowed to capture non-Sendable values like your non-Sendable task closure.

Also logically, it does make sense, no? Your closure will always only be invoked in @MainActor context, so how is it going to race?

1 Like

Only closure expressions are inferred @Sendable: Inference of @Sendable for Closure Expressions

1 Like

Ah, I see. Yeah, it makes sense. If I only capture the closure, it's ok. But if I call it after it gets captured, the compiler throws an error. So the closure expression is in a sending state prior to capturing it in a global actor isolated closure. I can't use it from another isolation domain after that. Thanks for clarifying!

Yeah, very important distinction. Thanks for the correction.

What you are actually seeing here is the effects of region-based isolation.

The compiler is able to reason that task can be transferred to the MainActor only because it has no dependencies on the current isolation. But if we add one, it results in a compiler error.

private class Foo {
	func work() {
		let task: () -> Void = {
			// comment this line to compile
			print(self)
		}

		callbackMainActor {
			// error: sending 'task' risks causing data races
			task()
		}
	}

	func callbackMainActor(_ block: @escaping @MainActor () -> Void) {
	}
}

RBI can make it hard to reason about certain situations, because the compiler is trying really hard to relax isolation checking constraints. And when you are playing around with simple examples, they are often too simple to fully capture what's going on.

Now, you might be wondering, why doesn't this work for the @Sendable closure as well? Honestly, I'm not certain! There are numerous complex situations related to closures and limitations of what RBI can do. It could be this is unnecessarily conservative in that case, but I'd have to read the proposal quite closely to be sure.

6 Likes

Thanks. I'll read the proposal.