I have two questions:
- How should scope functions be implemented?
- 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?