How does this example using Span and Swift concurrency compiles?

Hello everyone.

I am trying to learn more about the Swift's new Span type. I first tried writing this piece of code and it behaved as I expected:

func testSpan() {
    var array: [Double] = [1, 2, 3]
    let span = array.span
    array.append(4) // This produces compile time error because of overlapping access to "array"
}

This piece of code does not compile as modifications of array are not allowed as long as span is alive. This seems correct and as I expected.

However, when I tried to make something a little bit more complex using Swift concurrency:

func testSpan() async throws {
    var array: [Double] = [1, 2, 3]
    let span = array.span
    Task {
        array.append(4)
    }

    await printSpanAfterDelay(span: span)
}



func printSpanAfterDelay(span: Span<Double>) async {
    try? await Task.sleep(nanoseconds: 3_000_000_000)
    for i in span.indices {
        print(span[i])
    }
}

This piece of code compiles but produces inconsistent results (the result of printing has "garbage" values and is different on every run.)

If I understand correctly, in this example array is modified before the span is deallocated and then traversing span indices has undefined behavior (Array could be moved to another memory address if its capacity has been reached)

I would assume compiling second example should produce similar error as the first example.

My question is: Am I missing something or is this bug in the compiler?

For context: I am compiling this code with Xcode 26.4 with Swift 6 enabled and default actor isolation set to main.

2 Likes

This looks like a bug to me. When a local variable is captured by an escaping closure (as in the Task { } call here), then we lose the ability to statically enforce exclusivity violations, but the runtime should still dynamically catch attempts to mutate a variable while a read is active (in this case, from the existence of the Span), and raise a runtime error.

4 Likes

In fact the issue exists even without concurrency. The following compiles to UB:

func f(_ fn: @escaping () -> ()) {
  fn()
}
func g(_: Span<Double>) {
  ...
}

func test() {
  var array: [Double] = [1, 2, 3]
  let span = array.span
  f {
    array.append(4)
  }
  g(span)
}
4 Likes

So if I understand correctly, there are actually 2 bugs here.

First one is that this should not compile in the first place (as you said, we are capturing mutable variable in an escaping closure and lose the ability to statically enforce exclusivity violation)

The second one is that dynamic runtime should nevertheless catch this and raise an error.

@Slava_Pestov Thanks for providing even simpler example, I did not think of that one!

To be specific, capturing a mutable variable and falling back to dynamic checking is not the bug, but the fact that dynamic exclusivity checking is failing to prevent these accesses at runtime. Slava's example is supposed to trap at runtime when f invokes its closure and attempts to access array while the span is alive.

Edit: @Slava_Pestov I tried your example on my machine, and with a 6.4 compiler, it appears to work as intended and trap at runtime:

% cat ~/example.swift 
func f(_ fn: @escaping () -> ()) {
  fn()
}
func g(_: Span<Double>) {
}

func test() {
  var array: [Double] = [1, 2, 3]
  let span = array.span
  f {
    array.append(4)
  }
  g(span)
}
test()
–––!2279 ?0 &0 @~/swift/public/build/Ninja-RelWithDebInfoAssert/swift-macosx-arm64–––
% xcrun ./bin/swiftc -swift-version 6 ~/example.swift
–––!2280 ?0 &0 @~/swift/public/build/Ninja-RelWithDebInfoAssert/swift-macosx-arm64–––
% ./example                                          
Simultaneous accesses to 0x102c41250, but modification requires exclusive access.
Previous access (a read) started at example`test() + 176 (0x1024647d0).
Current access (a modification) started at:
0    libswiftCore.dylib                 0x00000001ab5e8418 _swift_reportExclusivityConflict + 332
1    libswiftCore.dylib                 0x00000001ab584f30 swift_beginAccess + 192
2    example                            0x0000000102464ac8 closure #1 in test() + 68
3    example                            0x0000000102464884 f(_:) + 64
4    example                            0x0000000102464720 test() + 248
5    example                            0x0000000102464708 main + 12
6    dyld                               0x000000019651eee8 start + 6700
Fatal access conflict detected.
zsh: abort      ./example

So dynamic exclusivity checking appears to be working properly within a thread. In the OP's example, I suspect that either we're incorrectly allowing the local variable to be captured because of region isolation not seeing the active span, or because of dynamic exclusivity checking failing to track accesses across process threads.

3 Likes

Ah, I was expecting the code to fail with an error at compile time, I didn't realize this case is handled by runtime enforcement.

IIUC dynamic exclusivity checking across threads has never worked and was never promised to. I also inquired about the inter-Task dynamic exclusivity checking behavior somewhat recently and John said such checks don't currently work and wouldn't be easy to support. Curious if that assessment has changed in the past months, or if there are any tractable solutions to this sort of thing.

Prior to strict concurrency checking, we did not impose any compile-time constraints on data races, but in a strict concurrency world, it should not be possible to get into a dynamic situation where dynamic exclusivity checking could miss an invalid access (without using unsafe constructs). Slava pointed out that the capture in this case is most likely allowed typically on the basis of region-based analysis, since if you try to capture the same variable twice, you do get an error at compile time:

func testSpan() async throws {
    var array: [Double] = [1, 2, 3]
    Task { // error because `array` is captured while still being used locally
        array.append(4)
    }
    Task {
        array.append(4) // used again here
    }
}

which suggests that maybe the region analysis is not tracking the dependent Span as an ongoing local use of the value.

1 Like

I'm not sure RBI can entirely fix this issue. E.g. you have a sort of worse problem than the fully synchronous case if you involve multiple tasks, even if they are all serialized with the same isolation:

// run this with ASAN on and you see use after frees and no dynamic exclusivity trap

@MainActor
func testSpan() async throws {
    var array: [Double] = [1, 2, 3]
    let span = array.span

    await Task { @MainActor in
        array.append(4)
    }.value

    for i in span.indices {
        print(span[i])
    }
}

try await testSpan()
1 Like

I really don't want to add a truly concurrent dynamic exclusivity runtime.

I think Joe is right that this being allowed is a bug in region analysis. It's fine to transfer an escaping local variable into a sending closure in general, but yeah, we cannot do that if there are ongoing accesses to the variable.

1 Like

RBI alone, if properly enforced, shouldn't be able to admit accesses from different threads, but different tasks in the same isolation domain (as in jamieQ's example above) can. However, the accesses still shouldn't be able to happen in parallel since only one context should be running at a time. Do we need a fully concurrent dynamic exclusivity runtime in order to track accesses by actor rather than by thread?

I think we just shouldn't be allowing accesses across potential suspension points unless we know that the variable is local to the task rather than the isolation. (So a local variable is probably fine.)

1 Like

Banning accesses across suspension points would probably do it, though presumably you'd lose the ability to do some things that could in actuality be safe (without explicitly opting out somehow). At any rate, here's a bug report for the problem surfaced by OP.

To clarify – in the "single isolation, multiple tasks" example the accesses don't happen in parallel, but they are concurrent ("overlapping" in the diagnostic parlance). In general that is something that is not supposed to be allowed by the exclusivity model, right?

Right. The dynamic exclusivity mechanism is intended to catch overlapping accesses that can't be statically prevented, but that can happen on a single thread already without involving multiple tasks. But since we shouldn't need to handle parallel attempts to access the same variable, that seems to me like it keeps the problem of handling overlapping accesses from different Tasks simpler, since the bookkeeping doesn't require additional synchronization.

Would this new form of per-actor exclusivity checking require new runtime entry points?