A recent thread inspired me to try to put some more work into ideas that have been bouncing around in my head for a while. These are all, at best, half-baked. This is not a pitch for a single, unified proposal. I guess maybe it could turn into that, or maybe just some parts. But I think the things here are at least worth some discussion.
The whole motivation is around reducing syntactic complexity. This can put a considerable burden on users, and not just when first learning. I've found there can be quite a lot of cognitive load here even for people that have experience working with the concurrency system.
Here are some of the ideas.
1 - implicit @escaping
A synchronous function that accepts asynchronous or isolated function arguments cannot do meaningful work unless they are permitted to escape. This is <waves hands> kinda sorta similar to how optional function types no longer require @escaping
. I know it actually isn't the same. But, is the required @escaping
annotation here actually helping with clarity?
// these would be implicitly @escaping
func example1(_ work: () async-> Void)
func example2(_ work: @MainActor () -> Void)
func example3(_ work: (isolated any Actor) -> Void)
func example4(_ work: @isolated(any) () -> Void)
Maybe there are situations where non-escaping closures could be useful?
2 - A sending
/@Sendable
function parameter of an @isolated(any)
type implies @_inheritActorContext
Users become really familiar with the semantics of Task
, but then get surprised when they have a hard time matching it. When a function accepts a sending
/@Sendable
function argument of arbitrary isolation, capturing the current actor context seems like a very reasonable default.
func inherits(_ work: sending @isolated(any) () -> Void) {}
@MainActor
func example() {
inherits {
// this would be @MainActor
}
}
3 - parameter attribute of @detached
to disable actor context inheritance
There is an existing proposal to formalize @_inheritActorContext
. But I think this is too biased towards the detached semantics. There are only two APIs I have found that do not want to inherit actor context by default: Task.detached
and TaskGroup.addTask
. Yet I have encountered many real situations where I wanted to inherit context. I also like that there is a special thing there in the signature to signal something unusual is going on.
extension Task {
@discardableResult
static func detached(
priority: TaskPriority? = nil,
@detached operation: sending @isolated(any) () async throws -> Success
) -> Task<Success, Failure>
}
However, I bet there are situations where capturing actor context so aggressively will result in problems I have not thought of.
4 - All async or isolated functions get @isolated(any)
I haven't been able to figure out a reason why all functions which could have isolation do not carry it dynamically. The @isolated(any)
attribute is a common source of confusion, probably because while it is rarely needed it is frequently encountered. It is handy in a number of situations, and if it has no downsides why not? Maybe ABI compatibility?
// these would be implicitly @isolated(any)
func example1(_ work: () async-> Void)
func example2(_ work: @MainActor () -> Void)
func example3(_ work: (isolated any Actor) -> Void)
5 - Stricter parsing rules for attributes that apply to the type of a function parameter
The following both compile, but I don't think they should. This is basically harmless if you understand what's going on, but when you do not, it is quite confusing. It really obscures the difference between the type of a function and the behavior of a parameter.
func problem1(_ work: @Sendable @escaping () -> Void) {}
func problem2(_ work: @isolated(any) @escaping () -> Void) {}
This is source-breaking, but a fix-it seems doable.
Most of these introduce more implicit behavior around some pretty complex functionality. But overall, I think changes along these lines could be very helpful for progressive disclosure. You'd only get introduced to the extra complexity if you actually need to interact with it. And the most negatively-affected users are probably those that already understand the nuances of the language deeply.
I want to leave you with a theoretical before-and-after for two Task
APIs, which would both be heavily impacted by these:
@discardableResult
init(
priority: TaskPriority? = nil,
@_inheritActorContext operation: sending @escaping @isolated(any) () async -> Success
)
@discardableResult
init(
priority: TaskPriority? = nil,
operation: sending () async -> Success
)
@discardableResult
static func detached(
priority: TaskPriority? = nil,
operation: sending @escaping @isolated(any) () async throws -> Success
) -> Task<Success, Failure>
@discardableResult
static func detached(
priority: TaskPriority? = nil,
@detached operation: sending () async throws -> Success
) -> Task<Success, Failure>
And with that, where have I gone wrong?