Skip to content

Converting nonisolated(nonsending) closure to @concurrent closure may cause data race #87674

@rayx

Description

@rayx

Description

See this example. It compiles on both 6.2 and nightly build. It looks like passing the nonisolated(nonsending) closure fn to bar, which takes a @concurrent closure, causes data race.

  • foo is called in multiple Tasks.
  • In each call of foo, the closure is actually run on global executor and hence access A.ns simultaneously.

Perhaps compiler shouldn't convert a nonisolated(nonsending) closure to @concurrent if the closure captures value that is in region other than disconected region or task-isolated region?

class NS {}

actor A {
    let ns = NS()

    func foo() async {
        let fn: nonisolated(nonsending) () async -> Void = { _ = self.ns }
        await bar(fn)
    }

    func bar(_ fn: @concurrent () async -> Void) async {
        await fn()
    }

    func test() {
        Task {
            await foo()
        }

        Task {
            await foo()
        }
    }
}

Note it appears that the data race couldn't be observed in practice, but the result is still weird. See more details in reproduction section.

Reproduction

Below is a complete test program that I used to demonstrate the data race. However, Its output isn't what I expected. fn isn't excuted on global executor, instead it's actor isolated.

task1 | BEGIN fn [output.A]
task2 | BEGIN fn [output.A]
task2 | END fn
task1 | END fn

A few comments:

  1. Based on the output there isn't data race.
  2. However, the output doesn't make sense. While it's true that fn in foo is actor isolated, I wonder how it's converted to a @concurrent closure when passed to bar? IMO the convertion perhaps should fail in the first place. It looks like what happens is that the convertion succeeds but the result is still an actor-isolated closure, instead of a @concurrent closure? I think it's worth to investigate what really happens.
class NS {
    var value = 0
}

func currentIsolation(isolation actor: (any Actor)? = #isolation) -> String {
    return "\(actor, default: "non-isolated")"
}

func logStart(_ fnName: String, _ isolation: String) {
    print("\(Task.name ?? "") | BEGIN \(fnName) [\(isolation)]")
}

func logEnd(_ fnName: String) {
    print("\(Task.name ?? "") | END \(fnName)")
}

actor A {
    let ns = NS()

    func foo() async {
        let fn: nonisolated(nonsending) () async -> Void = {
            logStart("fn", currentIsolation()) 
            _ = self.ns 
            try? await Task.sleep(for: .seconds(1))
            logEnd("fn")
        }
        await bar(fn)
    }

    func bar(_ fn: @concurrent () async -> Void) async {
        await fn()
    }

    func test() {
        Task(name: "task1") {
            await foo()
        }

        Task(name: "task2") {
            await foo()
        }
    }
}

let a = A()
await a.test()
try? await Task.sleep(for: .seconds(3))

Expected behavior

The code shouldn't compile

Environment

It can be reproduced on 6.2 and nightly.

Additional information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    @concurrentFeature → attributes: The "concurrent" attributecompilerThe Swift compiler itselfconcurrencyFeature: umbrella label for concurrency language features

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions