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

Explore Merging Aync and IO into a unified construct #709

Closed
Luka-J9 opened this issue Sep 29, 2024 · 16 comments
Closed

Explore Merging Aync and IO into a unified construct #709

Luka-J9 opened this issue Sep 29, 2024 · 16 comments

Comments

@Luka-J9
Copy link

Luka-J9 commented Sep 29, 2024

Summary from larger conversation on discord:

I may be confusing that there is a different pending effect for Async as with IO. In other effect systems and by (at least my) intuition usually IO is considered the "biggest" effect type (as in capable of doing the most things). With Async and IO split you can get significantly different effect types depending on which actions are called.

Motivating example:

for {
 result: Int <- IO{...}
 _ <- Async.sleep(10.second)
 _ <- Console.println("Done sleeping")
} yield (result)

The addition of a sleep changes the type from IO -> Sync, which changes the signature from Int < IO -> Int < Async. This is a pretty minor change in code bases where you control the signature, but may be difficult to satisfy in instances where the modification is not within the users control.

Copying some background reasoning from @fwbrasil in the discord thread

... I like the more fine-grained effect tracking because it makes it easier to reason about the properties of some code. A good example is the distinction in Kyo between Queue and Channel. While the other effect systems bundle both in the same primitive, Kyo separates them so Queue can depend only on IO as a regular concurrent queue and Channel introduces Async to be able to park fibers if necessary. If I see a method with only IO, I wouldn't expect the execution to be able to park, which is a good guarantee if performance is a concern. That said, I think that's something the majority of users don't want to worry about that and the tendency is for the boundary between IO and Async to become more unclear due to Loom. ...

Alternative solutions:

If it's determined that this split between Async and IO must continue and the concepts must be distinct - and alternative path is to rename the current values. Exact naming may change but it may be better to rename Async -> IO and rename the current IO -> Sync (or something else). I believe this keeps in line with "IO" being the biggest type while also preserving the reasoning for splitting the original types. Additionally - this might be me - I've always associated Async with being under the umbrella of IO

@fwbrasil
Copy link
Collaborator

fwbrasil commented Sep 30, 2024

Thank you for the details @Luka-J9! There's also this relevant comment by @steinybot in #contributors:

Jason Pickens — 09/26/2024 3:00 PM
When this came up my gut feeling was to keep them separate. I think it is important to know what is synchronous and asynchronous. This is especially true in Scala.js. With React you need to know when your state updates are going to happen. E.g. If you have a button onClick handler which updates two bits of state and one update is synchronous and the other is asynchronous but you don't know then you will likely render an inconsistent state.
I don't think merging them even solves the issue Luka had of deciding whether to define an interface in terms of IO or Async. You can extend that example to any number of effects such as whether to include Abort or not. Isn't the real solution to this problem to define your traits to take the effect as a parameter? Then the implementation can choose the effect. Dare I say it but something akin to tagless final although I think this is way better with Kyo.

I was sympathetic to merging Async into IO but I'm not sure anymore. Even after the cleaning up of IO.run calls in #702, having to handle async for code that only performs side effects seems an important limitation of Kyo's capabilities. For example, we can now call IO.run(value: A < IO) and get an A directly. If we merge the effects, we'd need to return a Fiber[Nothing, A] but its methods will introduce IO suspensions again when used. @steinybot also makes a good point that, although Loom might blur the distinction between IO and Async, that's likely not the case for other platforms like JS.

Dare I say it but something akin to tagless final although I think this is way better with Kyo.

Yeah, parametrizing effects is simpler in Kyo. I'd reserve this kind of pattern for libraries and advanced users, though. A major trap I see with people using tagless final is over abstraction. If using Async in an API serves the user's need, adding a type parameter to be able to use the API with only IO as well doesn't have much benefit and can scare newcomers away.

Exact naming may change but it may be better to rename Async -> IO and rename the current IO -> Sync (or something else). I believe this keeps in line with "IO" being the biggest type while also preserving the reasoning for splitting the original types. Additionally - this might be me - I've always associated Async with being under the umbrella of IO

That should be the case for most of the people adopting Kyo initially since many will have experience with other effect systems. That's a product of the low composability of other effect systems, though. They have to bundle async in the same monad that suspends side effects because that's the only option. I don't like the renaming from IO to Sync since it obscures its meaning but I wonder if there are other names that might work well.

@Luka-J9
Copy link
Author

Luka-J9 commented Oct 3, 2024

Hey @fwbrasil! Chewing on this, some additional thoughts:

I don't think merging them even solves the issue Luka had of deciding whether to define an interface in terms of IO or Async. You can extend that example to any number of effects such as whether to include Abort or not. Isn't the real solution to this problem to define your traits to take the effect as a parameter? Then the implementation can choose the effect. Dare I say it but something akin to tagless final although I think this is way better with Kyo.

As it relates to this I think the big difference between the Async/IO and Abort is that Async is a comprised of IO where as Abort is independent. Said another way Int < (IO & Abort) can exist, but Int < (IO & Async) would not (unless I'm mistaken).

Looking at prior art it seems like the need for this differentiation isn't unique to Kyo. While I don't totally understand the differentiation is strictly necessary (I am but a humble user) I think the name change would be more clear and easier for user

I think having the name still contain IO might make it more clear e.g. SyncIO instead of IO and Async -> IO

@steinybot
Copy link
Collaborator

As it relates to this I think the big difference between the Async/IO and Abort is that Async is a comprised of IO where as Abort is independent. Said another way Int < (IO & Abort) can exist, but Int < (IO & Async) would not (unless I'm mistaken).

I could be totally wrong but I think the reason Async includes IO is more for convenience. Async.Join is the real effect which does not include IO. When you run an Async you get an IO so there is no way to escape it (although now I'm starting to wonder why this is, I would have expected the same thing to be true of Var but it is not).

@fwbrasil
Copy link
Collaborator

fwbrasil commented Oct 3, 2024

Said another way Int < (IO & Abort) can exist, but Int < (IO & Async) would not (unless I'm mistaken).

I think your intuition is in the right direction. Async is currently encoded as:

opaque type Async <: (IO & Async.Join) = Async.Join & IO

It's possible to have Int < (IO & Async) but IO is redundant because it's already included in Async. I'd say that's more a property of the current encoding than a fundamental aspect, though.

I think having the name still contain IO might make it more clear e.g. SyncIO instead of IO and Async -> IO

Thanks for giving it some more thought :) I like Async => IO and IO => SyncIO. I think people will naturally end up selecting IO, which is probably what is best for most users. If the more fine-grained effect tracking is necessary for example to ensure a computation has no async operations, which can be beneficial in JS, or because the user sees value in the distinction, then they can decide to use SyncIO. I'd prefer to still keep the distinction in Kyo's APIs, though.

@fwbrasil
Copy link
Collaborator

fwbrasil commented Oct 3, 2024

I could be totally wrong but I think the reason Async includes IO is more for convenience.

Exactly! 🎯 We could, for example, suspend all async operations in a custom effect and then introduce IO suspensions only when Async.run is called.

When you run an Async you get an IO so there is no way to escape it (although now I'm starting to wonder why this is, I would have expected the same thing to be true of Var but it is not)

That's because Var is 100% based on pure function composition! There's no mutability in its implementation other than the kernel's safepoint mechanism for stack safety and tracing. The execution of the Var operations is based only on pure code as it is the case for all other effects in kyo-prelude (besides Debug).

@steinybot
Copy link
Collaborator

Thanks for giving it some more thought :) I like Async => IO and IO => SyncIO. I think people will naturally end up selecting IO, which is probably what is best for most users. If the more fine-grained effect tracking is necessary for example to ensure a computation has no async operations, which can be beneficial in JS, or because the user sees value in the distinction, then they can decide to use SyncIO. I'd prefer to still keep the distinction in Kyo's API's, though.

I'm on the fence with this. Implementations and interfaces have the opposite requirements. Liskov substitution principle (LSP) says that method parameter types should be contravariant and return types covariant. The most useful, i.e. most substitutable w.r.t LSP, implementations are those that have the most general parameter types and most specific return types. In other words, accept Async and return IO. However, when the implementation is taking a parameter that is itself an abstract type then that is in contravariant position and it should be as general as possible which means it has the most specific contravariant types and most general covariant types. In other words, accept an implementation of an interface that accepts IO and returns Async. That’s a bit of a confusing way to say it but this mostly demonstrates what I mean:

trait Interface[-In, +Out]:
    def doIt(in: In): Out

// Not actually useful because the implementation is kind of cheating
object MostUseful extends Interface[String < Async, String < IO]:
    def doIt(in: String < Async): String < IO = ???

object MoreUseful extends Interface[String < IO, String < IO]:
    def doIt(in: String < IO): String < IO = ???

// To be fair this fails the same number of times as MoreUseful does.
object LessUseful extends Interface[String < Async, String < Async]:
    def doIt(in: String < Async): String < Async = ???

object LeastUseful extends Interface[String < IO, String < Async]:
    def doIt(in: String < IO): String < Async = ???

object MostRestricted:
    def doMagic(thing: Interface[String < Async, String < IO]): Unit = ???

object RestrictedOut:
    def doMagic(thing: Interface[String < IO, String < IO]): Unit = ???

object RestrictedIn:
    def doMagic(thing: Interface[String < Async, String < Async]): Unit = ???

object MostPermissive:
    def doMagic(thing: Interface[String < IO, String < Async]): Unit = ???

def foo =
    MostRestricted.doMagic(MostUseful)
    MostRestricted.doMagic(MoreUseful) // Fails
    MostRestricted.doMagic(LessUseful) // Fails
    MostRestricted.doMagic(LeastUseful) // Fails
    RestrictedOut.doMagic(MostUseful)
    RestrictedOut.doMagic(MoreUseful)
    RestrictedOut.doMagic(LessUseful) // Fails
    RestrictedOut.doMagic(LeastUseful) // Fails
    RestrictedIn.doMagic(MostUseful)
    RestrictedIn.doMagic(MoreUseful) // Fails
    RestrictedIn.doMagic(LessUseful)
    RestrictedIn.doMagic(LeastUseful) // Fails
    MostPermissive.doMagic(MostUseful)
    MostPermissive.doMagic(MoreUseful)
    MostPermissive.doMagic(LessUseful)
    MostPermissive.doMagic(LeastUseful)
end foo

So I don't know if I quite agree that making IO the default is what we want. As always it depends. Maybe I'm just used to Async but IO does not sound like it is async to me.

Exactly! 🎯 We could, for example, suspend all async operations in a custom effect and then introduce IO suspensions only when Async.run is called.

That seemed appealing at first but that might just be annoying. Would it make sense to go the opposite way and encourage that whenever the run method for an effect always returns another pending effect that it should be an alias including that other effect?

That's because Var is 100% based on pure function composition! There's no mutability in its implementation other than the kernel's safepoint mechanism for stack safety and tracing. The execution of the Var operations is based only on pure functions as it is the case for all other effects in kyo-prelude (besides Debug).

That's cool. Seemed impossible at first but now that I think about it, it is just a hole that gets filled when run which then flows through all the places that use it and the update functions.

@Luka-J9
Copy link
Author

Luka-J9 commented Oct 3, 2024

Just to be clear @steinybot the suggestion is that the type Async would be renamed to IO and the current IO would be called SyncIO (or insert better name here, suggested as to indicate it's related to IO in some way). Async would no longer exist. It would be a breaking change as the semantically IO would mean what Async means now, but it's just that, a semantic change.

The reason for this may be minor, but I'd argue important. If you learn about effect systems outside of Kyo, or learn about effect systems in the abstract, the primitive you expect to use as the "default" is some form of IO which includes things like sleeping. This is because it has all the bells and whistles attached, leaving the user to worry about the details when they are ready.

Admittedly it could be just me, but I definitely spun my wheels for a while before I realized that Async was the "biggest" type and that was the type I should be using for my case

Given that most examples and discussions around effects use Foo < IO, this makes a strong case for this being the "default" IMO.

Admittedly all of this can perhaps be resolved with some documentation or educational materials. I just think it ends up being an unnecessary stumbling block for folks

@steinybot
Copy link
Collaborator

Spiel incoming lol. Sorry, I tried cutting it down. Damn these strong but loosely held opinions of mine...

My previous comment was about changing the "default" which I don't think we should do. Our API and docs should use the most specific type. The fact that most examples just have IO is a good thing. The should be easier to understand. The concerns about what type to use are not specific to Kyo or effect systems for that matter. Use the same judgement with effects as you would with anything else. The process of starting with IO, trying to add sleep, then finding that the type must be Async.Join & IO instead is exactly what I would want and expect. I initially brought up Abort because the same thing happens there. You need an early exit so you now you have to change the type again. Same for Vars and Env etc. There just isn't a batteries included type to deafult to.

Ease of learning is definitely very important. I find the easiest things to learn are those that have simple rules that can be combined in predictible ways. This is especially true for people who might be learning effects for the first time. I'd argue that merging Async and IO is complecting the two unnecessarily. This is fairly evident from the fact that IO.run would have to return a Fiber. I know the latest suggestion is to not do that and change the names and/or default instead but I think it is still important to make a clear distinction between what Async and IO are. In my mind there is no overlap.

I agree that we need to try and help people who are coming from other effect systems, especially Cats Effect and ZIO. We might be able to use some existing concepts to scaffold learnings in Kyo but I'm not convinced that this is a good idea. The differences can be subtle but they are significant. Changing Async to IO could make things worse because they are so similar it might be hard to spot the differences. I actually think calling anything in Kyo IO is a bad idea. That name has too much baggage and it's not even a good name to begin with. We should have documentation that shows how to do something in CE/ZIO/Kyo but base it on the problems being the same not the names.

I think the idea of "bigger" effects needs to be unlearned. There aren't bigger effects. I know you put that in quotes so maybe you don't mean it literally. Yes you probably need to learn IO first since most other effects when run will result in a pending IO. So in that sense it is a prerequisite and therefore is "smaller". There might be multiple effects combined together in an alias like is the case for Async but that is just to save some typing. Async doesn't even have bells and whistles. Even with the Async alias, each effect is handled separately, i.e. IO.run(Async.run(...)). I think it would be less confusing if Async did not include IO and was just Async.Join. It'd be easier to learn but it would be more annoying to use once you had learnt it so I dunno.

In summary, I think we should:

  • Not worry about a "default" effect.
  • Stick to using the most specific effects.
  • Have documentation specifically for users coming from other effect systems.
  • Rename IO to something else. I personally don't like Sync because it implies that it is the dual to Async which I don't think is the right way to think about it.
  • Don't rename Async to IO. It could be something else but I'm also fine with Async.
  • Consider whether Async should still include IO. I suspect yes but maybe the documentation should show it the verbose way without aliases.

@fwbrasil
Copy link
Collaborator

fwbrasil commented Oct 3, 2024

Let me try to phrase it in a different way. I think we have three main concerns:

  1. Users coming from other effect systems expect IO in include Async capabilities. That's a product of effect composability limitations that aren't present in Kyo but seems a problem for adoption.
  2. More advanced users like library authors can leverage the distinction between IO and Async and merging the effects would be an important limitation of Kyo's capabilities as @steinybot elaborated.
  3. The majority of users will write code where the distinction between IO and Async doesn't matter. If you're building a backend system and calling a few services to perform a few transformations, using Async directly shouldn't introduce any limitation.

It seems we're aligned regarding the need to cater to all these audiences. We don't want to merge the effects since that'd be an important limitation for advanced users but we can mitigate the implications for the other audiences. A naming change like @Luka-J9 suggested paired with documentation suggesting the equivalent of Async in the new naming as the recommended effect users rely on seems a good path for that.

I must be honest that naming isn't my forte so please feel free to suggest naming schemes! Suggestions I could identify in our discussions:

  1. Rename Async to IO to satisfy the expectation of users coming from other effect systems and IO to SyncIO
  2. Keep Async and rename IO to something else other than Sync.

Consider whether Async should still include IO. I suspect yes but maybe the documentation should show it the verbose way without aliases.

Reducing the tracking overhead is a major challenge for usability. We need to find a good balance that works well for the different potential users of the library. It seems the majority of the users wouldn't benefit from the additional tracking if we don't include IO in Async and the inclusion doesn't seem to introduce limitations.

@steinybot
Copy link
Collaborator

steinybot commented Oct 3, 2024

Keep Async and rename IO to something else other than Sync.

How about Impure or Mutate?

Chaos or the Loki effect 😄

@hearnadam
Copy link
Collaborator

Speaking strictly from the user perspective of Kyo, IO is only confusing due to the misuse of it's name by other effect systems. When I was first reading the new core, it took me a minute to understand that: A < IO is equivalent to A < Any with a specific Tag. For this reason, we should aim to clearly communicate 'what' IO is doing, which is suspending the computation.

I much prefer Defer as it can be used to represent 'pure' suspensions as well (not that IO couldn't be). My only issue is now we have a longer signatures for the same functions.

  • A < Defer
  • A < Async

Perhaps users can also make an alias if they so choose...

@steinybot
Copy link
Collaborator

A < IO is equivalent to A < Any with a specific Tag.

Huh I did not catch onto that either although I haven't fully groked the kernel stuff yet. So does that mean that it is eval that is actually running the suspended computations not IO.run?

we should aim to clearly communicate 'what' IO is doing, which is suspending the computation.

This is a great point that I agree with completely. I like Defer. Far better to have a slightly longer name with a more accurate meaning IMO. As has been mentioned, people are going to generally end up using Async or a custom alias anyway.

@fwbrasil
Copy link
Collaborator

fwbrasil commented Oct 10, 2024

A < IO is equivalent to A < Any with a specific Tag.

To be more precise, A < IO is actually A < (IO & Any) but the Any can be omitted.

I think it's confusing because of the mental model of how other effect systems work. A computation of type A < Any essentially means a computation without effect suspensions (besides the kernel's internal mechanisms, more details below). It can't perform any side effects since it would require IO suspensions and thus a pending IO effect. It's something other effect systems can't represent in their base monad and why they need separate monads like ZPure.

This approach enables kyo-prelude to offer a relatively complete set of effects without any side effects, which is a remarkable property given that it's possible to express fully deterministic computations with advanced effects like Var, Batch, Abort, and Choice based only on pure function composition. In theory, such computations could even be compiled to a physical chip.

A lot of work has gone into enabling that. For example, other effect systems typically rely on mutable collections used across multiple transformations, which seems fine if the base monad includes IO by default, but kyo-prelude's implementations strictly use immutable collections otherwise they'd require IO suspensions.

I agree this distinction doesn't matter for most users but, although we try to keep a down-to-earth approach to usability, ensuring Kyo is able to represent a wide range of computations with fundamental properties like this one can yield interesting results in the long term. For most users, I'm hoping our recommendation of Async as the effect people typically select can mitigate usability issues. If not, type aliases can be another simple mitigation. We should also be careful not to design too much around the expectations of users coming from other effect systems since we should have a more diverse user base over time.

Huh I did not catch onto that either although I haven't fully groked the kernel stuff yet. So does that mean that it is eval that is actually running the suspended computations not IO.run?

Yes, the kernel also introduces internal suspensions to ensure stack safety. The internal effect used for that is called Defer, we'll need to rename it. When eval is called, the computation can only not be an A in case an internal suspension was required to unwind the stack. Calling eval shouldn't execute side effects since it's not possible to call eval on a computation with a pending effect like IO.

This is a great point that I agree with completely. I like Defer. Far better to have a slightly longer name with a more accurate meaning IMO. As has been mentioned, people are going to generally end up using Async or a custom alias anyway.

I agree with the rename to Defer, especially in this initial phase where it can be confusing to people coming from other effect systems, but I'd argue it's less descriptive. People associate suspension with side effects but in Kyo's case, suspensions are the basis of all functionality, not only side effect management. IO properly expresses that the computation interacts with the "external world". The issue is the baggage of the "IO monad".

@fwbrasil
Copy link
Collaborator

fwbrasil commented Dec 4, 2024

Any concerns regarding proceeding with the renaming from IO to Defer? I think we can rename the current internal Defer in the kernel as Unwind since it's meant to unwind the stack.

@sideeffffect
Copy link
Contributor

sideeffffect commented Dec 11, 2024

For what my opinion is worth, I think the most user friendly and recognizable naming would be IOSync and IO. IO has been since long recognized as synonym for side effects -- we can use this established terminology for our advantage.

  • IOSync is a special kind of, more constrained side effect and the name even suggests it's constrained in it being merely "synchronous"
  • IO the most generic and all encompassing and all powerful side effect, subsumes anything, because anything can happen

@fwbrasil
Copy link
Collaborator

fwbrasil commented Jan 9, 2025

Every time I pick this up to work on, I have doubts if it's a good change 😅 Thank you everyone for the feedback but I think the current naming seems the best to express what those effects do. IO being associated to async execution is a product of the lack of effect composability in traditional monadic encodings, something not present in Kyo.

I've created a PR better documenting this aspect and introducing an Async.apply method, which enables using Async as an almost complete super set of IO (@hearnadam's suggestion): #986

Please share if you feel strongly about this but I think we could close this once the PR lands.

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

No branches or pull requests

5 participants