-
Notifications
You must be signed in to change notification settings - Fork 532
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
Proposal: Make Shift Easier to Reason About (and a bit more efficient) #58
Comments
@clhodapp see these previous discussions:
The idea is intriguing. Note you can achieve the same effect right now: for {
_ <- IO.unit.shift(blocking)
a <- ioA
_ <- IO.unit.shift(computing)
b <- ioB
} yield a + b Personally I think Having a shift that is exposed as an effect might be cool, but it doesn't protect the user much: IO.async { cb =>
compute.execute(new Runnable {
def run() = cb(Right(1))
})
} In the case above, no matter what combination of In other words, if a |
@clhodapp This is very elegant! I like the idea much better than either of the implementations that we've had (and also better than Would you be interested in formulating a PR? We should change @alexandru I agree that there's nothing that can save us from a producer giving us an In general, I would say that most of the time, improperly shifted The proposal in the OP does a very nice job, imo, of collapsing the dual semantics of You're certainly right that we can achieve this effect now, but IMO this should be the only way to use |
Implemented by @djspiewak in #63 |
Hey!
Link Summary:
I'm super-thrilled to see that a standard typeclass and datatype for pure IO in the cats is under development!
I read @djspiewak's introductory blog post yesterday and I was struck by how unintuitive the existing
shift
implementation is.Before I explain why I think
shift
is confusing, though, let me first try to frame things by explaining the two goals I believeshift
is trying to accomplish:ExecutionContext
runs their synchronous effects.ExecutionContext
after some possibly-thread-jumping effects complete.The way that the present
shift
goes about achieving these goals is to bracket a target effect with jumps onto a targetExecutionContext
(jump to the new context, run the effects, jump to the new context again). That way, if the effects are synchronous, we can control whichExecutionContext
runs them (yay, first goal accomplished!). Of course, if the effects are fully or partially asynchronous, they could very well end up shifting execution onto a thread outside of our targetExecutionContext
. Therefore, in order to achieve our second goal, we need to insert a second jump to our targetExecutionContext
after the target effects complete. That way, we have control of whichExecutionContext
is active coming out of our bracket region, instead of our target effect. Interestingly,shift
actually turns effects asynchronous. By this, I mean that if you callshift
on one effect several times in a row on a single synchronous effect (eff.shift(a).shift(b).shift...
), the only the first shift has the power to change whichExecutionContext
the synchronous effect runs on. Subsequent calls toshift
can only change which context will gain control after the effect completes. Exploiting this is actually critical to achieving both of our two goals at the same time, by leveraging the "double-shift" pattern described in Daniel's post: If we want to jump over to a specialExecutionContext
to run a blocking effect and then come back to the mainExecutionContext
, we can achieve that by doing something likeeff.shift(ioEC).shift(mainEC)
.I've just explained how the current implementation allows us to meet both of our goals (even both at the same time!). Why am I writing this issue, then? There are two reasons:
First,
shift
is extremely difficult to reason about, especially for people other than the original author of a piece of code. This is because each timeshift
is used, it actually generates twoExecutionContext
jumps. This means that the user ofshift
has to keep the effects of both jumps in mind. This isn't too hard to do once you understand what's going on, but it does mean that you will often get twoExecutionContext
jumps when you were only after one. For the reader, though, it's much worse. Any time you come across a call toshift
, you generally don't have any clues as to what the author's intentions were. That is, you don't know whether a given call toshift
is intended to move a synchronous effect to anotherExecutionContext
or whether the intention is to shift the execution of subsequent effects (or whether it's both). You end up having to go down the rabbit hole of finding out whether the target effect is contains blocking IO or is fully or partially asynchronous. And even doing that just gives you heuristics.Things become worse still for learners, in my opinion, since I think the implementation creates a trap that will drive new users toward using
shift
improperly. This is because most people will initially have just one of the two problems thatshift
solves. For example, let's say that that a programmer wants to do a file read on theirExecutionContext
for blocking operations. They are likely to come across example code likeIO(println("Hello!")).shift(ioCtx)
. This looks really promising! Thisscan
thing seems to allow you to cause some specific effect to run on theExecutionContext
of your choice! At this point, the programmer will probably try usingshift
this way in their code, find that it seems to work, and move on, unaware that they have introduced a bug into their code. Of course, a programmer that first learns to useshift
in order to control theExecutionContext
for subsequent effects is likely to have a similar problem. In both cases, the bug is likely to go unnoticed too, since it probably doesn't affect any computed result. Basically, I believe, that in order to be able to useshift
correctly, a programmer has to understand the details ofshift
's implementation and its interactions withIO.async
, which I don't think is reasonable to expect of new learners.Second,
shift
is likely to cause code to reschedule itself onExecutionContext
s about twice as often as it needs to. This is because, the vast majority of the time when you useshift
, you are either trying to change whichExecutionContext
a synchronous effect runs on (in which case, you only need the firstExecutionContext
jump thatshift
inserts) or you are trying to jump to some targetExecutionContext
after an asynchronous computation completes (in which casee, you only need the secondExecutionContext
jump thatshift
inserts). In either case, your code will end up yielding itsThread
and being rescheduled twice as often as it needs to.So... What do I propose should be done instead? In short, I propose that
shift
become an independent,unit
-returning effect that injects singleExecutionContext
jumps. I've pushed working code showing what I mean to GitHub. You can see the most-relevant section at this link. As you can see, we retain the ability to control whichExecutionContext
runs synchronous effects: we just have toshift
to our desiredExecutionContext
before running them. We also retain the ability to transfer execution back to our desiredExecutionContext
after an asynchronous effect: we just have to add ashift
after the target effect. There is less ambiguity for readers about the intention behind each call toshift
. I believe that it's also night-and-day clearer for code-sample learners. It makes the double-shift
pattern something so intuitive that pretty much any user could come up with it on their own on the spot, as opposed to something that you have to be taught as a weird non-intuitive trick. It also completely eliminates the unnecessary reschedulings that the current version ofshift
creates. In short, I feel thisshift
is just... better and I propose that it should be adopted incats-effect
.One other thing... Why is this example tagged as
proposal-one
in my repo? Well... I have always felt that this pattern of sealingExecutionContext
objects inside of our effects is... well... awful. I've long had the idea that it would be better to directly encode the notion that there should be one pool for blocking IO and one for compute directly into our datatypes and defer the selection of those pools until we actually run an effect. It's less flexible but I'm not sure that it a good design ever needs moreThreadPool
s than that (plus maybe one extra usually-sleeping Thread for timers if it can't be cleanly integrated into the compute pool). And the flexibility is actually still retained by virtue of the existence of e.g.IO.async
(it's just a bit harder to access). Anyway, I intend to play around with this some and may write another proposal if the results are promising. I make no commitment on that, though.Note: This proposal is also available as a separate file in my
cats-effect-scheduling-proposals
repo, in case it proves to be hard to read as an issue. Here's a link.The text was updated successfully, but these errors were encountered: