How to solve 'Sending 'url' risks causing data races'

I have 2 classes, one of them implements a protocol that comes from an external library (so I have to use it as-is). When using Swift 6 I get an error I don't know how to solve. Here is my code. You can put it in a Swift Playground and it will show the error

import UIKit
import WebKit

final class LoginWebViewController: UIViewController {
    var webView: WKWebView!
    
    override func loadView() {
        let webConfiguration = WKWebViewConfiguration()
        webView = WKWebView(frame: .zero, configuration: webConfiguration)
        self.view = webView
    }
    
    func load(_ url: URL) {
        let request = URLRequest(url: url)
        self.webView.load(request)
    }
}

protocol SomeLibProtocolIcantChange {
    func loadURL()
}

final class DoSomething: SomeLibProtocolIcantChange {
    
    let controller: LoginWebViewController
    
    @MainActor
    init() {
        controller = LoginWebViewController()
    }
    
    func loadURL() {
        let url = URL(string: "https://www.google.com")!

        Task { @MainActor in
            controller.load(url)
        }
    }
}

the controller.load(url) line gives the error: Sending 'url' risks causing data races.

I understand how in theory this could cause an issue once I start passing url around to other places that could call modifiers on that object (when do we get an immutable URL class btw?), but this is clearly not the case as I just use it to load a webpage.

I have no idea how to solve the issue. It looks like it should be simple, but it isn't.
I tried replacing the url with a String but it gives exactly the same error.
I tried replacing the url with an Int (which makes no sense, but ok, compiler test) and it still gives the same error??

I managed to reduce it to this:

import UIKit
import WebKit

final class LoginWebViewController: UIViewController {
    func load(_ someInt: Int) {
    }
}

protocol SomeLibProtocolIcantChange {
    func loadURL()
}

final class DoSomething: SomeLibProtocolIcantChange {
    
    let controller: LoginWebViewController
    
    @MainActor
    init() {
        controller = LoginWebViewController()
    }
    
    func loadURL() {
        let someInt = 5

        Task { @MainActor in
            controller.load(someInt)
        }
    }
}

and then I get:

Sending 'someInt' risks causing data races

if I move the variable inside the task, like this:

    func loadURL() {
        Task { @MainActor in
            let someInt = 5
            controller.load(someInt)
        }
    }

I get: Task or actor isolated value cannot be sent

What is wrong?

If I declare the protocol as @MainActor and remove the task it works, but the entire issue is I can't change the protocol.

This happens in Xcode Version 16.0 (16A242d)

Okay, those error messages really aren't helpful, no wonder you got stuck.

Your LoginWebViewController class is @MainActor isolated (it inherits that from UIViewController), so I assume that message is somehow related to the problems you can get when calling load(_ url: URL) from a different isolation context. You correctly call it from a @MainActor constrained Task.

I'm not the best at analyzing this, but as far as I can see it, the actual problem is with your controller property or the DoSomething class as a whole. I don't get why the compiler can't point that out, at least, sorry!

Since the class or at least the property are not @MainActor annotated, the controller instance is not isolated, so when you pass it to the Task, it crosses an isolation context.

To resolve that, you can e.g. make the entire DoSomething isolated to @MainActor and to satisfy the SomeLibProtocolIcantChange exclude the loadURL function by making it nonisolated.
Another way is to simply explicitly capture the controller in the Task, i.e. write

Task { @MainActor [controller] in
    controller.load(url)

I am actually not sure why that works, but I think this way the closure "understands" that you only send the instance to the main actor for the load(_:) method to be called (which is isolated to the main actor anyway). Perhaps somebody else can elaborate.

Which version works better for you depends on how you need to use the DoSomething class, I guess.

1 Like

Looks like a bug in the compiler diagnostic message, worth filing. There is actually sending of controller instance property on non-Sendable DoSomething type that causes the error.

The version on capturing controller is great I think, since passing around controller instance itself is safe and doesn't require to make your type isolated or Sendable as whole.

Another way is to simply explicitly capture the controller in the Task, i.e. write

Task { @MainActor [controller] in
    controller.load(url)

This works. Thanks!

@ir_fuel not sure if you aren't the author, but the issue on GitHub were created:

2 Likes

No it's not me. Thanks for letting me know.

Ah, now that I look at it with fresh eyes it makes sense. I played around a bit with this (also with the code from the bug issue on github) and turns out what is actually incorrectly sent is self, i.e. the instance of DoSomething.
Or in other words:

Task { @MainActor in
    controller.load(someInt)
}

is interpreted as

Task { @MainActor in
    self.controller.load(someInt)
}

and so the self is implicitly captured, the explicit variant would be:

Task { @MainActor [self] in
    self.controller.load(someInt)
}

This explains why capturing controller explicitly instead resolves the error, but it's interesting to note why the compiler is okay with this in spite of LoginWebViewController not adopting Sendable (which it would have to do via @unchecked Sendable and manual data-race protections like locks anyway as it contains mutable state):
It works because the Task closure is isolated to the same actor as LoginWebViewController: @MainActor.

I wonder if there is a way to automatically isolate the task closure to the same isolation that LoginWebViewController has, perhaps with a macro? That could help to write more reusable code like this, I guess, but on the other hand it's not terribly inconvenient to write out the isolations...

1 Like

Writing the isolations is less cumbersome to me than the [weak self] guard let self = self else { return } I have to write all too often :D

1 Like

That is one way to look at this, but we can expand further, and say that all actor-isolated types are implicitly Sendable because they itself ensure to call on the right executor, and therefore it is safe to pass them around. So even if Task wasn’t isolated, it still would be safe to pass it, it just will require await to switch to main actor isolation.

That's me! What a coincidence and thanks for notifying me on GitHub.

1 Like

I would explicitly not phrase it that way, as it's not entirely true. There is a difference between a Sendable type and one that is isolated.
I think you kind of tricked yourself there on accident, because if it were Sendable, you would not have to await the access (mutation or just reading). The object would be "sent" over to the other isolation context and be mutated there in a synchronous fashion.
If it is instead "just" isolated, then you have to await the access, alas, there is no "awaiting setter".

Small sample code to comment in and out various combinations:

I used a custom global actor to play around with some other things, but that's not a difference. Commenting in and out the various lines illustrates all we have said so far, I think. :smiley:

@MyActor
class A/*: @unchecked Sendable*/ {
    var description = ""
}

class B {
    let a = A()

    func b() {
        let asdf = ""
        Task { @MyActor [a] in
            // with "@MyActor" in Task closure and on type A
            // OR
            // without "@MyActor" in Task closure nor on type A, but type A is Sendable:
            a.description = asdf // no synchronous access, we're in the same isolation
            _ = a.description // same

            // with "@MyActor" on type A, but NOT in Task closure:
//            _ = await a.description // okay
//            await a.description = asdf // error
        }
    }
}

@globalActor
actor MyActor: GlobalActor {
    static let shared = MyActor()
}

This stated by the actors proposal:

Actors protect their mutable state, so actor instances can be freely shared across concurrently-executing code, and the actor itself will internally maintain synchronization. Therefore, every actor type implicitly conforms to the Sendable protocol. [emphasis mine]

And this is shares to the global actor-isolated types as well:

A non-protocol type that is annotated with a global actor implicitly conforms to Sendable. Instances of such types are safe to share across concurrency domains because access to their state is guarded by the global actor. [emphasis mine]

I apologize, poor choice of words.
Yes, they are implicitly Sendable, as in you can make them cross the isolation boundary. Still, the way you can then access their data and/or mutate it differs, and you had the case when you need to actually await flipped if I read your statement correctly (which I might not have managed, but I promise I tried twice).

Just check out the sample code: With an @unchecked Sendable class you do not need to await anything regardless of whether the closure is isolated or not, but when the class is only isolated to a global actor and the closure is not isolated to the same actor, you can a) only read the property and b) you have to await that then. Any form of awaited mutation requires you to write a function.

I see the need to await on access and type being Sendable as two distinct concepts (which I believe they are), and don't think one excludes another. Sendable marks types that are safe to pass in other isolation because of some mechanism they use to ensure thread-safety, actor and actor-isolated types are fall into that category. It is only the question how this [thread-safety] is achieved. If we draw a (rough) parallel to private-queue protection on type in GCD world, you'll have the same behaviour with equivalent of "hop to actor's executor" in the form of dispatching to the queue, just less obvious. And if we then bring such type to the Swift Concurrency, we can safely mark it as @unchecked Sendable, having calls to that type with completion handlers or even will be able to use await-bridging, making them even more alike to actor (leaving aside actual actors implementation specifics).

I'm not sure I follow you, sorry. They are different mechanisms to ensure thread-safety. With @unchecked Sendable is a bit of a "hack" here, because you would need to ensure thread-safety in other way after all, e.g. with mutex, because otherwise your type is just not thread-safe and can be misused. We can argue on some semantic details and difference in exact behaviour, which clearly exists [1], yet loosely coupled to the why compiler allows to capture controller there, so returning to the initial statement I've commented on:

It works because the Task closure is isolated to the same actor as LoginWebViewController : @MainActor .

I was simply stating that this works on a bit more broader scale of isolated type being implicitly Sendable and Swift allows it to move to another isolation in a Task, because if type not Sendable by any means (either by explicit conformance or implicitly), compiler just won't let you capture it in the closure in either way.

controller instance itself initially in a different isolation – to be precise, it is [instance] nonisolated – and you pass it to the different isolation via Task { ... } (which happens to be @MainActor-isolated), and there is no need to await only because isolation match, there is still isolation boundary crossing which is possible only because type of the controller instance is Sendable.


To expand a bit more, we can have some actor that we want to pass in the Task and (at least currently) we cannot make Task isolated to the same actor if that's not a global actor, but we still can pass its instance to the Task because it is implicitly Sendable:

actor SomeActor {
    func bar() {
    }
}

class NonSendable {
    let a = SomeActor()

    func foo() {
        Task { [a] in
            await a.bar()
        }
    }
}

If I'd change SomeActor to SomeOtherClass which is non-Sendable, Swift won't allow to pass it:

class SomeOtherClass {
    func bar() {
    }
}

class NonSendable {
    let b = SomeOtherClass()

    func foo() {
        Task { [b] in  // error
            b.bar()
        }
    }
}

  1. Like if you need to make several calls to main-actor isolated code, you clearly better with Task { @MainActor in ... } as there are less hops forth-and-back between executors. ↩︎

I fully agree and there's not actually any difference in our understanding as far as I can (now) tell.
We seem to just have encountered another one of the many situations in which an exact verbal explanation is just as hard (if not harder) than the concept itself.

Example:

and then

We were both understanding it the same, I was (with "it works") just referring to the reason why you don't have to await (which would internally cross an isolation context again) while you were focusing on the reason why you can capture it in the first place.

Gee, the concurrency syntax is at the same time elegant and concise and expressing highly complex conditions... it is kind of fun, though... :smiley_cat:

1 Like