Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added @MainActor to SharedSequence function arguments #2613

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

fabianmuecke
Copy link

To get started on #2586 this PR adds @preconcurrency @MainActor to functions of SharedSequence, Driver, and Signal, which take function arguments and adds @MainActor to the according function argument signatures.

I added this for all according SharedSequence functions, although technically it would be possible, to create a custom SharedSequence type, which does not use the MainScheduler. I tried to find a way to limit this to SharingStrategys, which use the MainScheduler, but failed to check that properly at compile-time, because the scheduler can be exchanged for a mock.

This means that:

Important

Before this gets merged, the SharedSequence functions should probably be separated by SharingStrategy type and @MainActor addition limited to SignalSharingSharingStrategy, and DriverSharingStrategy, so it doesn't break possibly existing custom implementations of SharingStrategyProtocol out there.

Alternatively we could introduce a MainSchedulerSharingStrategy marker protocol, which would allow to limit it to sharing strategies conforming to that protocol and allow for easier adoption in custom implementations of sharing strategies (which might also be using MainScheduler).

I didn't go the extra mile of doing anything like that, because I first wanted to hear, if you consider adding @preconcurrency to introduce @MainActor to the code base is the right direction to go.

So please let me know what you think.

@tommyming
Copy link

I think instead of adding @MainActor to every single function that is conformed to SharedSequence, how about adding Main Actor functionality to the MainScheduler?

Not sure if that's possible, but it seems to be more reasonable and more elegant.

Feel free to correct me if I am wrong, or share your thoughts!

@fabianmuecke
Copy link
Author

I think instead of adding @MainActor to every single function that is conformed to SharedSequence, how about adding Main Actor functionality to the MainScheduler?

Not sure if that's possible, but it seems to be more reasonable and more elegant.

Feel free to correct me if I am wrong, or share your thoughts!

The problem is, that there's no way to transport the fact that anything running on the MainScheduler is being performed on the MainActor. Each function has its own signature. I can see no way to apply @MainActor to all of them without either specifying it for each function individually or isolating the whole type to @MainActor.

I've been experimenting with adding an actor type to the type signature to solve this, so the actor isolation could be passed up and down the chaing and changed by using observe(on:) or subscribe(on:). This would require a scheduler to provide an actor type.

I failed at getting this implemented, because even if you do add an actor there, it does not add said actor to the function signatures. The only way to add that type to the function signatures would be to use add the actor type's instance to its signature like this (pseudo):

SharedSequence<SharingStrategy, Element, ActorType: Actor> {
    func map<NewElement>(_ transform: (isolated ActorType, Element) -> NewElement) -> SharedSequence<SharingStrategy, Element, ActorType> {
    
    }
}

The problem here is: even if ActorType is MainActor, this does not (and cannot) add the @MainActor attribute, which is required by the Swift compiler to be able to run things marked as @MainActor isolated. There's a difference between the attribute and the type, because theoretically there could be multiple instances, even of a global actor type, so just having an actor of that type isn't good enough. Does that make any sense? It's hard to explain. 🥴

@tommyming
Copy link

Thanks for the detailed reply @fabianmuecke!

The problem here is: even if ActorType is MainActor, this does not (and cannot) add the @mainactor attribute, which is required by the Swift compiler to be able to run things marked as @mainactor isolated. There's a difference between the attribute and the type, because theoretically there could be multiple instances, even of a global actor type, so just having an actor of that type isn't good enough.

I totally understand that, since I have been facing similar issues in my working projects.
Have been working on Swift 6 migration preparation recently, trying to adding specific GlobalActors to different services and layers, turns out is does not work as intended. The Swift compiler will just throwing errors at you about the actor isolation issue, which is the same issue that you are facing.

That's why I am asking is it possible to perform the same way in RxSwift.

In this case, I personally think using attributes this is a possble way to handle it properly.

side note:
I think we need to take Swift 6 migration more seriously, I am skeptical how the strict concurrency mode will affect RxSwift. More investigation is needed.

@fabianmuecke
Copy link
Author

Thanks for the detailed reply @fabianmuecke!

The problem here is: even if ActorType is MainActor, this does not (and cannot) add the @mainactor attribute, which is required by the Swift compiler to be able to run things marked as @mainactor isolated. There's a difference between the attribute and the type, because theoretically there could be multiple instances, even of a global actor type, so just having an actor of that type isn't good enough.

I totally understand that, since I have been facing similar issues in my working projects. Have been working on Swift 6 migration preparation recently, trying to adding specific GlobalActors to different services and layers, turns out is does not work as intended. The Swift compiler will just throwing errors at you about the actor isolation issue, which is the same issue that you are facing.

That's why I am asking is it possible to perform the same way in RxSwift.

In this case, I personally think using attributes this is a possble way to handle it properly.

side note: I think we need to take Swift 6 migration more seriously, I am skeptical how the strict concurrency mode will affect RxSwift. More investigation is needed.

Yeah, if we want to make RxSwift be at least publicly compliant, this needs more work. Especially all Elements will need to conform to Sendable, anything using internal thread safety mechanisms (like Subjects, Relay, etc.) should be marked as @unchecked Sendable and lots of other stuff.

Fortunately the strict concurrency compilation will simply tell you to add @preconcurrency to your import of frameworks like RxSwift or Combine, which will silence the compiler issues - but of course you also lose all benefits that way.

@BrentMifsud
Copy link

Thanks for the detailed reply @fabianmuecke!

The problem here is: even if ActorType is MainActor, this does not (and cannot) add the @mainactor attribute, which is required by the Swift compiler to be able to run things marked as @mainactor isolated. There's a difference between the attribute and the type, because theoretically there could be multiple instances, even of a global actor type, so just having an actor of that type isn't good enough.

I totally understand that, since I have been facing similar issues in my working projects. Have been working on Swift 6 migration preparation recently, trying to adding specific GlobalActors to different services and layers, turns out is does not work as intended. The Swift compiler will just throwing errors at you about the actor isolation issue, which is the same issue that you are facing.
That's why I am asking is it possible to perform the same way in RxSwift.
In this case, I personally think using attributes this is a possble way to handle it properly.
side note: I think we need to take Swift 6 migration more seriously, I am skeptical how the strict concurrency mode will affect RxSwift. More investigation is needed.

Yeah, if we want to make RxSwift be at least publicly compliant, this needs more work. Especially all Elements will need to conform to Sendable, anything using internal thread safety mechanisms (like Subjects, Relay, etc.) should be marked as @unchecked Sendable and lots of other stuff.

Fortunately the strict concurrency compilation will simply tell you to add @preconcurrency to your import of frameworks like RxSwift or Combine, which will silence the compiler issues - but of course you also lose all benefits that way.

I also believe that scheduler's should be deprecated, and most observables should have their closures marked as async and sendable.

For example, if I am using .flatMap, chances are I am firing off another observable, which is very likely to be something that is running async. In an async context, if I want to switch thread contexts, I can simply mark the async closure as main actor or some other global actor.

And for things that are inherently main threaded, like drivers, you can simply mark all of their closures as being main actor.

@danielt1263
Copy link
Collaborator

I also believe that scheduler's should be deprecated, and most observables should have their closures marked as async and sendable.

I disagree with that. The power of Rx is that it isn't necessarily async. For example, all of my test code is synchronous, even if the same code is asynchronous in production.

@freak4pc
Copy link
Member

freak4pc commented Oct 2, 2024

This is a very deep topic but in essence there are a few points to consider.

First, don't necessarily take anything I say here as a 100% fact, this is my current understanding, and if someone has a vision they're passionate about, I'll be happy to see a more concrete POC of how that'd look.

First about what I perceive as the consumer crowd of RxSwift, as of 2024:

  1. Existing users of RxSwift heavily use its existing paradigms such as Schedulers, etc
  2. New apps that don't have RxSwift won't necessarily grab it as a dependency when Combine and Swift Concurrency exist out there. (There are some with their own use cases but I don't find that to be the dominant majority).

About the relation to Swift Concurrency and "The Future":

  1. The concurrency model Rx uses in general (not just in Swift specifically), to my understanding, isn't directly compatible (and possibly conflicts) with Complete Concurrency guarantees.
  2. To me it seems that even Apple themselves aren't making progress in making Combine compatible with Structured Concurrency, with things such as Future's closure not even being @Sendable. None of Combine's type are marked Sendable or have any other notes of supported Complete Concurrency. They have Schedulers too and it's a testament of it possibly being two conflicting ideas of dealing with concurrent work.

So the gist here is that it still seems like uncharted territory to me, with many of its own issues (still).

I view RxSwift as an extremely well thought-out and battle-tested framework/project that aims to provide cross-platform compatibility and adherence to the Reactive Extensions standard. There are no plans to do any major diversions from this definition. I'm happy to consider adding general quality-of-life improvements that would make sense (i.e. possibly @MainActor for Driver/Signal/ControlEvent/ControlProperty), but a complete overhaul to use Swift Concurrency under the hood doesn't sound reasonable or valuable, in my perspective.

If you have other opinions I'd be happy to hear them and discuss.

Cheers 🤘

@BrentMifsud
Copy link

This is a very deep topic but in essence there are a few points to consider.

First, don't necessarily take anything I say here as a 100% fact, this is my current understanding, and if someone has a vision they're passionate about, I'll be happy to see a more concrete POC of how that'd look.

First about what I perceive as the consumer crowd of RxSwift, as of 2024:

  1. Existing users of RxSwift heavily use its existing paradigms such as Schedulers, etc
  2. New apps that don't have RxSwift won't necessarily grab it as a dependency when Combine and Swift Concurrency exist out there. (There are some with their own use cases but I don't find that to be the dominant majority).

About the relation to Swift Concurrency and "The Future":

  1. The concurrency model Rx uses in general (not just in Swift specifically), to my understanding, isn't directly compatible (and possibly conflicts) with Complete Concurrency guarantees.
  2. To me it seems that even Apple themselves aren't making progress in making Combine compatible with Structured Concurrency, with things such as Future's closure not even being @Sendable. None of Combine's type are marked Sendable or have any other notes of supported Complete Concurrency. They have Schedulers too and it's a testament of it possibly being two conflicting ideas of dealing with concurrent work.

So the gist here is that it still seems like uncharted territory to me, with many of its own issues (still).

I view RxSwift as an extremely well thought-out and battle-tested framework/project that aims to provide cross-platform compatibility and adherence to the Reactive Extensions standard. There are no plans to do any major diversions from this definition. I'm happy to consider adding general quality-of-life improvements that would make sense (i.e. possibly @MainActor for Driver/Signal/ControlEvent/ControlProperty), but a complete overhaul to use Swift Concurrency under the hood doesn't sound reasonable or valuable, in my perspective.

If you have other opinions I'd be happy to hear them and discuss.

Cheers 🤘

Maybe some more bridging APIs would be nice. Today theres the .asObservable and .values for async sequences, but there's no way to bridge a task (or other asynchronous functions) built into the library.

Maybe something like .asyncFlatMap or Observable.async/task would be nice.

@freak4pc
Copy link
Member

freak4pc commented Oct 2, 2024

You can do asSingle().value to go into a single awaitable value

I agree we miss some variations of converting async values / streams into observables, definitely a good addition for the next version

@hoc081098
Copy link

I also believe that scheduler's should be deprecated, and most observables should have their closures marked as async and sendable.

I disagree with that. The power of Rx is that it isn't necessarily async. For example, all of my test code is synchronous, even if the same code is asynchronous in production.

The Scheduler is foundation in ReactiveX

@tommyming
Copy link

Agree on what @freak4pc said, personally think RxSwift is a different approach from Swift Concurrency. We can provide adoption to Swift 6, which is the language enforcement/enhancement(?), but we should not remove something that is originally in RxSwift Core.

@freak4pc
Copy link
Member

freak4pc commented Oct 4, 2024

This is a very deep topic but in essence there are a few points to consider.
First, don't necessarily take anything I say here as a 100% fact, this is my current understanding, and if someone has a vision they're passionate about, I'll be happy to see a more concrete POC of how that'd look.
First about what I perceive as the consumer crowd of RxSwift, as of 2024:

  1. Existing users of RxSwift heavily use its existing paradigms such as Schedulers, etc
  2. New apps that don't have RxSwift won't necessarily grab it as a dependency when Combine and Swift Concurrency exist out there. (There are some with their own use cases but I don't find that to be the dominant majority).

About the relation to Swift Concurrency and "The Future":

  1. The concurrency model Rx uses in general (not just in Swift specifically), to my understanding, isn't directly compatible (and possibly conflicts) with Complete Concurrency guarantees.
  2. To me it seems that even Apple themselves aren't making progress in making Combine compatible with Structured Concurrency, with things such as Future's closure not even being @Sendable. None of Combine's type are marked Sendable or have any other notes of supported Complete Concurrency. They have Schedulers too and it's a testament of it possibly being two conflicting ideas of dealing with concurrent work.

So the gist here is that it still seems like uncharted territory to me, with many of its own issues (still).
I view RxSwift as an extremely well thought-out and battle-tested framework/project that aims to provide cross-platform compatibility and adherence to the Reactive Extensions standard. There are no plans to do any major diversions from this definition. I'm happy to consider adding general quality-of-life improvements that would make sense (i.e. possibly @MainActor for Driver/Signal/ControlEvent/ControlProperty), but a complete overhaul to use Swift Concurrency under the hood doesn't sound reasonable or valuable, in my perspective.
If you have other opinions I'd be happy to hear them and discuss.
Cheers 🤘

Maybe some more bridging APIs would be nice. Today theres the .asObservable and .values for async sequences, but there's no way to bridge a task (or other asynchronous functions) built into the library.

Maybe something like .asyncFlatMap or Observable.async/task would be nice.

FYI - We're adding a Single.create that takes an async closure to 6.8.0. Feel free to play around with it and check it out:
662d91b

@BrentMifsud
Copy link

Nice I look forward to this.

@fabianmuecke
Copy link
Author

fabianmuecke commented Oct 7, 2024

FYI - We're adding a Single.create that takes an async closure to 6.8.0.

@freak4pc Did you manage to get that working with test schedulers? I've added similar functions (Single.create, mapAsync etc.) as extensions to our app's codebase and never got it to play nicely with Rx schedulers (might actually be impossible because of how different the approaches are, I'm just curious).

@freak4pc
Copy link
Member

freak4pc commented Oct 7, 2024

I haven't attempted specifically but it's an interesting question. I'm unsure if it can work because of the interplay between actors and schedulers. If you want to play with 6.8.0 and let me know what are the issues, I can try and poke a bit.

@fabianmuecke
Copy link
Author

I haven't attempted specifically but it's an interesting question. I'm unsure if it can work because of the interplay between actors and schedulers. If you want to play with 6.8.0 and let me know what are the issues, I can try and poke a bit.

I believe the issue was: the RxTest scheduler expects the scheduled actions to run synchronous, but a Task will run async, so collection of the values is finished, before the Task's closure finishes.

Try this simple test:

func testExample() {
    let scheduler = TestScheduler(initialClock: 0)
    let observer = scheduler.createObserver(Int.self)

    let single = Single.create(work: {
        try await Task.sleep(for: .seconds(1))
        return 3
    })
    _ = single.asObservable().subscribe(observer)
    scheduler.start()
    XCTAssertEqual(observer.events, [.next(0, 3), .completed(0)])
}

It'll fail because the events array is empty.

Of course for such a simple case you can just do something like let result = try await single.value and just compare the result, but if a Single, created using the async closure is flat mapped over in a more complex Observable sequence, testing it can get really nasty.

@tommyming
Copy link

I believe the issue was: the RxTest scheduler expects the scheduled actions to run synchronous, but a Task will run async, so collection of the values is finished, before the Task's closure finishes.

I think this is expected, as you are trying to requesting some async result synchronously.
There are 2 ways to handle this I could think of: Wrap the synchronous part with the async calls using Task or other related methods, or, make scheduler async(?).

but if a Single, created using the async closure is flat mapped over in a more complex Observable sequence, testing it can get really nasty.

I think provide async closure support to Single is reasonable, but I agree the testing complexity would be exponential, especially you may need to sync and async parts separately.

@fabianmuecke
Copy link
Author

I think provide async closure support to Single is reasonable, but I agree the testing complexity would be exponential, especially you may need to sync and async parts separately.

@tommyming I agree, an async Single.create function is necessary (as is an async map, which essentially just wraps such Single and flatMaps over it), that's why we have it anyway in our codebase. I found a way to workaround the testing issues implementing a wrapper around TestScheduler and adding a custom RxNimble-like implementation for collecting the results in an async fashion.

That won't work for all edge-cases (especially the virtual timestamp of the events isn't really helpful anymore), but works for all things we need to test here. I just wish there was a way to integrate it properly with the existing concept of schedulers in RxSwift, so that no workarounds were necessary. But to make that work, I believe schedulers would have to take async closures, which would not only be a lot of work, but also introduce a whole bunch of new problems, I expect.

TLDR; I didn't mean to suggest removing the Single.create function. I just wanted to raise awareness that this breaks the current testing approach using test schedulers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants