How to Implement Scope Functions Compatible with Concurrency?

I have two questions:

  1. How should scope functions be implemented?
  2. Why does the standard library’s TaskLocal.withValue compile successfully?

Let me explain step by step.

Suppose we want to create a higher-order function that adds log output to a code block. A scope function is something that adds such effects to a block of code without modifying the original block.

However, the following implementation results in a compilation error:

func withLog1<T>(
    operation: () async throws -> T,
    isolation: isolated (any Actor)? = #isolation
) async throws -> T {
    print("start")
    defer { print("end") }

    // ⛔️ Non-sendable type 'T' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary
    return try await operation()
}

Interestingly, it does compile if the isolation parameter is removed:

func withLog2<T>(
    operation: () async throws -> T
) async throws -> T {
    print("start")
    defer { print("end") }
    return try await operation()
}

However, this version cannot be used because it causes an error on the caller side:

final class NS { var k = 0 }

actor A {
    let a: NS = .init()

    func foo() async throws {
        // ⛔️ Sending 'self'-isolated value of type '() async -> ()' with later accesses to nonisolated context risks causing data races
        try await withLog2 {
            print(self.a)
        }
    }
}

A well-known example of a scope function is withSpan in the swift-distributed-tracing library.

Its core implementation looks like this:

public func withAnySpan<T, Instant: TracerInstant>(
    _ operationName: String,
    at instant: @autoclosure () -> Instant,
    context: @autoclosure () -> ServiceContext = .current ?? .topLevel,
    ofKind kind: SpanKind = .internal,
    isolation: isolated (any Actor)? = #isolation,
    function: String = #function,
    file fileID: String = #fileID,
    line: UInt = #line,
    _ operation: (any Tracing.Span) async throws -> T
) async rethrows -> T {
    let span = self.startAnySpan(
        operationName,
        at: instant(),
        context: context(),
        ofKind: kind,
        function: function,
        file: fileID,
        line: line
    )
    defer { span.end() }
    do {
        return try await ServiceContext.$current.withValue(span.context) {
            try await operation(span)
        }
    } catch {
        span.recordError(error)
        throw error  // rethrow
    }
}

This is almost identical to my withLog1 implementation. Yet, intriguingly, if we remove TaskLocal.withValue, the code fails to compile:

public func withAnySpan<T, Instant: TracerInstant>(
    _ operationName: String,
    at instant: @autoclosure () -> Instant,
    context: @autoclosure () -> ServiceContext = .current ?? .topLevel,
    ofKind kind: SpanKind = .internal,
    isolation: isolated (any Actor)? = #isolation,
    function: String = #function,
    file fileID: String = #fileID,
    line: UInt = #line,
    _ operation: (any Tracing.Span) async throws -> T
) async rethrows -> T {
    let span = self.startAnySpan(
        operationName,
        at: instant(),
        context: context(),
        ofKind: kind,
        function: function,
        file: fileID,
        line: line
    )
    defer { span.end() }
    do {
        // ⛔️ Non-sendable type 'T' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary
        return try await operation(span)
    } catch {
        span.recordError(error)
        throw error  // rethrow
    }
}

This suggests that the secret lies in how TaskLocal.withValue is implemented. I tried reproducing it in my own code as follows:

final class MyTaskLocal<Value: Sendable> {
    func withValueImpl<R>(
        _ valueDuringOperation: __owned Value,
        operation: () async throws -> R,
        isolation: isolated (any Actor)?,
        file: String = #fileID, line: UInt = #line
    ) async rethrows -> R {
//        _taskLocalValuePush(key: key, value: consume valueDuringOperation)
//        defer { _taskLocalValuePop() }

        // ⛔️ Non-sendable type 'R' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary
        return try await operation()
    }
}

But the same error occurs. So how is Task.withValue in the standard library able to compile?
Is there some special compiler magic implemented to bypass type checking?

You can make your version compile by adding sending in two places. Like this:

func withLog1<T>(
    operation: () async throws -> sending T,
    isolation: isolated (any Actor)? = #isolation
) async throws -> sending T {
    print("start")
    defer { print("end") }
    return try await operation()
}

This tells the compiler that the non-Sendable return value of type T is safe to cross isolation domains.

I don't know.

3 Likes

Thank you for your response.

Unfortunately, your version has a issue when used as a scoped function.
There are cases where adding a scoped function causes a previously compilable function to fail compilation.

Here’s an example:

func withLogOle<T>(
    operation: () async throws -> sending T,
    isolation: isolated (any Actor)? = #isolation
) async throws -> sending T {
    print("start")
    defer { print("end") }
    return try await operation()
}

final class NS { var k = 0 }

actor A {
    func mainOriginal() async throws -> NS {
        try await createNS()
    }

    func mainWithScoped() async throws -> NS {
        try await withLogOle {
            // ⛔️ Returning a 'self'-isolated 'NS' value as a 'sending' result risks causing data races
            try await createNS()
        }
    }

    func createNS() async throws -> NS { .init() }
}

While mainOriginal is a compilable function, wrapping its implementation with withLogOle to create mainWithScoped results in a compilation error.

Yes, the sending requirement propagates to callees because you have to prove to the compiler that the non-Sendable value you want to return is safe to send to another isolation domain. This is described in SE-0430: sending parameter and result values.

In your new example, you'd have to make the return type of the createNS function sending too. I don't know of a way to avoid this. Perhaps the upcoming(?) pitch Closure isolation control would help with this?

1 Like

Yes, but that isn’t always easy to do, nor is it a universal solution.
For instance, consider the following example:

actor A {
    ...

    var localNS: NS = .init()

    func createNS() async throws -> sending NS {
        if Int.random(in: 0...1) == 0 {
            // ⛔️ Sending 'self.localNS' risks causing data races
            return localNS
        } else {
            return NS()
        }
    }
}

Thank you for introducing the proposal draft.
The @inheritsIsolation mentioned in the article might be exactly the feature I’ve been looking for.

I’ll download the snapshot and give it a try.

This would be invalid under any circumstances. I mean, technically if you can ensure on your own that localNS wouldn’t be accessible after sending, you can make it unsafe and pass, but from the perspective of the compiler it will always be unsafe.

Using sending is the best way to achieve the behavior. My guess for stdlib here is either that compiler is able to infer that it is safe to pass values there somehow (implicitly treat as sending because Value is Sendable), or this might be a diagnostic issue and hole in checking. I lean towards the latter, but might be missing some input.

It seems that @inheritsIsolation has not been implemented yet, even in the main branch snapshot.

Yes, I understand that.

What I want to emphasize with this example is that if the return value of the operation has sending, it cannot be used as a universally scoped function.

Did you try with -enable-experimental-feature ClosureIsolation (or .enableExperimentalFeature("ClosureIsolation") in Package.swift)?

Yes, I have specified it.
Here is the terminal log.

[omochi@omochi-mbp temp]$ echo $TOOLCHAINS
org.swift.62202412221a
[omochi@omochi-mbp temp]$ swift --version
Apple Swift version 6.2-dev (LLVM 9f6c3d784782c34, Swift 55189bae8e55169)
Target: arm64-apple-macosx15.0
[omochi@omochi-mbp temp]$ cat a.swift
func withLogClosureIsolation<T>(
    @inheritsIsolation operation: @Sendable () async throws -> T,
    isolation: isolated (any Actor)? = #isolation
) async throws -> T {
    print("start")
    defer { print("end") }
    return try await operation()
}
[omochi@omochi-mbp temp]$ swift -swift-version 6 -enable-experimental-feature ClosureIsolation a.swift
a.swift:2:6: error: unknown attribute 'inheritsIsolation'
1 | func withLogClosureIsolation<T>(
2 |     @inheritsIsolation operation: @Sendable () async throws -> T,
  |      `- error: unknown attribute 'inheritsIsolation'
3 |     isolation: isolated (any Actor)? = #isolation
4 | ) async throws -> T {

Even after searching the repository, there doesn’t seem to be any indication that it has been implemented.

your approach seems like it's generally appropriate to me – inherit your caller's isolation, and decide on how you want to deal with non-sendable values (sending vs Sendable). Franz Busch recently gave a talk on precisely this matter, and the recommendations look very similar to what you have (and the modifications Ole suggested).

which toolchain are you using exactly? when i test this in Compiler Explorer with a recent nightly snapshot it appears to compile without issue.

i'm speculating somewhat here, so discount my explanation appropriately (hopefully someone more knowledgeable will weigh in if this is way off), but i think the reason is that the stdlib is compiled in the Swift 5 language mode, and without strict concurrency checking enabled. you can see some evidence for this in the various cmake config files (here, here, and here).

1 Like

i think the reason is that the stdlib is compiled in the Swift 5 language mode

Thank you for sharing the reasoning. It does seem that the standard library is compiled in Swift 5 language mode. That clears up my question. Thank you so much!

which toolchain are you using exactly? when i test this in Compiler Explorer with a recent nightly snapshot it appears to compile without issue.

As indicated in the logs I reported, the bundle ID is org.swift.62202412221a, which corresponds to the December 22 snapshot build. The tested source code is also included there.

The feature that didn’t work even with the latest compiler is the @inheritsIsolation attribute mentioned in the proposal draft shared by Ole. It didn’t work on Compiler Explorer either I’ll attach a screenshot to demonstrate.


Here’s what I have understood from our discussion so far:

Currently, the compiler does not provide a language feature to declare that both the withLog function and operation belong to the same isolation domain as the caller.
As a result, it is not possible to implement the universal scope function I am aiming for.
However, with the draft-stage @inheritsIsolation attribute, this functionality would become achievable.

The existing @_inheritActorContext provides similar functionality, but it exhibits unexpected behavior in certain situations, making it unsuitable for this use case.

With this, my question is resolved.
I sincerely thank everyone who contributed with their comments.

1 Like