-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
[RFC] Structured Concurrency #6468
Comments
I'd rather write a "good" Talking about abstractions based on cancellation should wait untilwe actually have cancellation. |
Promises are more about communication than structuring. You can do a lot of things in a similar way, but it feels like an inferior solution to me. Running two methods concurrently would look like this with a promise: promise = future ->do_some_stuff
do_some_other_stuff
promise.get In my example implementation it looks like this: concurrent do |spindle|
spindle.spawn ->do_some_stuff
spindle.spawn ->do_some_other_stuff
end It's little bit more verbose, but easier to follow control flow. Especially when it gets more complicated. (Verbosity could actually be reduced when using One way or the other: a good promise implementation would need a way to cancel a fiber as well, wouldn't it? |
@straight-shoota you could do it like that or you could use promises like this: Promise.all(
Promise.new ->func,
Promise.new ->func2
) which is both less verbose and more powerful since you can do value1, value2 = Promise.all(
Promise.new ->func,
Promise.new ->func2
) very easily to get the function's return values. Changing it to "first fiber wins" aka a race becomes |
and JS promises have no way to cancel them, we don't need cancellation for them |
These |
At least I would want to have a way to cancel a promise/fiber (and do it gracefully) in case its result/effect is no longer required. It shouldn't block resources for unnecessary work. |
@straight-shoota No, |
How would that work with adding child tasks during runtime? You would have to append them to the array - somehow that could probably be made to work with |
Ah I see what you want. We could easily add block-based versions of |
Promises isn't Structured Concurrency. Don't mix concepts, please :) Structured Concurrency is about controlling the lifetime of nested coroutines (they can't outlive their parent). We can spawn new coroutines at any time (e.g. on A promise defers computation of a fixed set of fibers to return a set of values. Since Passing a mutable Promises != Structured Concurrency. |
I was thinking more of a Promise.all do |promises|
promises << ...
end interface in addition to the other promise interfaces to make it do wait groups as well. I guess it's probably quite a hack though, and better to separate the two concepts. |
@RX14 That doesn't seem much different from concurrent do |spindle|
spindle.spawn ...
end So we pretty much want the same thing in this regard ;) |
Honestly I kind of like the idea of having something other than promises, which I sometimes feel like is a bad solution but manages to be used everywhere... |
@straight-shoota yeah I like the concept I was just wondering if we could work it into the "promise" concept for simplicity. |
I've been writing a Promise library for Crystal Lang and it's almost complete (core implementation complete with specs) It might not be "Structured Concurrency" in the most strict sense however it does simplify coordinating a bunch of async events and it's quite a popular paradigm. Would love to see it in the standard library if that's something you would consider. I think my implementation is pretty neat in any case: require "promise"
promise = Promise.new(Int32)
result = p.then { |result| result.not_nil! + 100 }.then { |result| "change #{result} type" }
promise.resolve(10)
# Can also grab the value via a future
result.value # => "change 110 type" Any Thanks to the Crystal type safety it puts most promise implementations to shame. |
I don't think a promise library in crystal would look anything at all like a JS promise library. In fact making promises too much like JS will mean people start using promises like JS and crystal promises absolutely should not be used the same as JS. They should be used pretty sparingly. |
Well yeah (exposing synchronous APIs) |
@stakach ah, my terminology was all messed up. I'd like futures in the stdlib and promises perhaps can be a shard built on that. |
I'm still not sure what the conceptual difference between a promise and future would be in crystal though |
I like to think of promises and futures as either end of a pipe, the promise is where I can put something in and the future is where I can wait and listen for the result. Now taking this picture, I just described a Channel with a buffer capacity of one that can only be written to once. So perhaps for us promises would actually be just redundant to channels. |
Promises are really complimentary to channels. Channels for distributing async work and promises for handling the results. For instance, I'm working on updating this influxdb library to the latest version of Crystal lang as the original maintainer doesn't have the time. The original version uses if sync
send_write(body).status_code == 204
else
spawn { send_write(body) }
true
end A Promise in this case is the perfect solution.
For comparison, the promise version of the above would be: promise = Promise.new(Bool)
channel.send {promise, body}
promise Promises provide flexibility and are simple to use. I don't think they should be seen as competition to other flow control solutions, they are just one of many tools |
Well, regardless of the terminology what I want is a fiber which can return a value, and you can wait for it to complete with a value or error |
@stakach I don't think a promise is the right tool in your example. The library method This would be idiomatic Crystal with a simplified API by removing |
@straight-shoota problem is running the write concurrently doesn't work on the current version of Crystal so it has to be via the channel to ensure serial writes. Basically because the HTTP client response from influx is chunked, crystal yields the current fiber while it waits for IO mid HTTP response cycle. |
Yes, that's a shortcoming of Until this is resolved, you would need to use several client instances or guard one with a mutex. But this issue exists whether |
The problem of being called from multiple different fibers is already solved by using the channel - any fiber can call the code, the channel is used to make the HTTP::Client requests in serial and the promise returns the result to the calling fibers. Very little complexity vs locks and/or multiple client instances |
@RX14 I deliver you "a fiber which can return a value, and you can wait for it to complete with a value or error" It's not an alternative structured concurrency but I still think it's pretty cool. |
@straight-shoota thanks for the advice - I threw a lock around the influxdb HTTP client, you were right, was definitely the way to go. |
@vinipsmaker the fiber cancel method, that @straight-shoota has implemented, raises an error |
Is this impacted (e.g. made easier, harder, or superseded) in any way by the proposed MT support? Even if it's orthogonal, as suggested here, it would be nice to have eyes on the MT proposal with compatibility (or non-incompatibility) with this proposal in mind. |
@chocolateboy Structured concurrency does not depend on multithreading, but it can easily integrate it. A concurrency context allows to configure very specifically whether tasks can be executed in parallel, how many threads, error handling etc. |
I suppose only a limited version of this would be possible after 1.0, as a unlimited ability to |
@yxhuvud Not sure I understand. |
The way I see it, So we should keep those in Crystal, and build good abstractions on top of them. For instance, actors would be nice. |
"I don't see a reason why unrestricted spawns wouldn't work with a concept of structured concurrency." Because it defeats the point. Users need to be forced to think about the lifetime of the things they spawn, and it should possible to see using visual inspection of the code what fibers can be running at any given point in the code. Or to quote the referred blogpost referred to above with the (quite telling) title
The whole point of that whole post is a denouncement of the Go model with unbounded spawns! And yes, fibers would obviously still be implemented using low level constructs. But probably used as often as |
Okay, I see what you mean. There's no technical reason, but it would make sense to advocate the higher level interface. I agree to that. And frankly, assuming its used in every API, there would be no reason for direct access to the low level interface. I just feel that can only be achieved as a second step. The first step would be to design and implement a high-level interface for structured concurrency. Then wait for it being adopted. After that, the now (hopefully) unused features can be removed/reduced. |
I would like to link a presentation about structured concurrency in context of C++ to this conversation. Memory management doesn't apply to Crystal, but allocated resources could be external, e.g., database connection. Hence, the presentation might help people to see how resource allocation can be done safer with structured concurrency.
In addition, I like how Martin creates connection between object lifetime management and fiber lifetime management (see Martin Sústrik's blog post linked in issue description). |
BTW, the swift proposal around structured concurrency https://github.com/apple/swift-evolution/blob/main/proposals/0304-structured-concurrency.md is also very interesting. |
Interesting. The concept of Executors confuse me. I can understand the wrap of the Job around a Task, but the way the Executors is expressed seems over-complex to me. I've played with the Task in C#, very close to the concept of future in C++ materialized by the Async template. For the two, a lightweight process is spread in a thread-pool. We can grab the result later and continue our work. The way Task is used in C# permit to organize the work (when it start, when we need to wait), perform check on the task and act on failure. This is a good way to materialize asynchronous running. The other way is to have a long running work and use the classical channel / subroutine as in Go or Elixir (with the Mailbox / Job primitives). A bit light for more advanced management, but everything is possible (Elixir have other models to organize long running jobs, but the basic is here). A very interesting topic anyway :) Thanks a lot. |
There's also discussion of go concurrency bugs and structured concurrency. Some bugs were caused by unbounded go statements in the golang stdlib. I think structured concurrency should be provided by the language as primitives and the stdlib should always use it. Kotlin coroutines is a very good implementation of structured concurrency. I think Crystal should have something similar to it. |
Ruby implementation: Polyphony (discussion) |
@straight-shoota even Java has Structured Concurrency. Now there is one reason to use Java over Crystal, how long are we going to allow this? |
This issue has been mentioned on Crystal Forum. There might be relevant details there: https://forum.crystal-lang.org/t/charting-the-route-to-multi-threading-support/7320/1 |
This issue has been mentioned on Crystal Forum. There might be relevant details there: |
Crystal has an great concurrency model based on Fibers and Channels, which can be used to pass messages around.
Fibers are conceptually pretty simple. If you spawn one, it takes off from the main context and runs concurrently for an indefinite amount of time, depending on what it does and how long it takes to do this. From the perspective of the main control flow, it's essentially fire and forget.
Real life problems typically ask for a more sophisticated way of handling concurrent tasks. Sometimes you need to wait for either one, some or all tasks to be finished before continuing the main scope. Error handling in concurrent tasks is also important and the ability to cancel the remaining tasks if others have finished or errored.
Fibers and Channels can be used to implement a model for structured concurrency. Given that this is a pretty common idiom, I'd like to see a generalized implementation in Crystal's stdlib.
What we have
HTTP::Server#listen
uses a custom implementation of a wait group executing a number of tasks simultaneously and waiting for all to finish. Other examples are in theparallel
macro orCrystal::Compiler#codegen_many_units
.parallel
is the only feature of structured concurrency currently available in the stdlib, but it is only suitable for a fixed number of concurrent tasks that are known at compile time.A more generalized approach would help to make this concept easily re-useable.
It can be implemented based on the existing features that Fiber and Channel provide. The only thing that's missing is a way to deliberatly kill fibers and unwrap their stack (see #3561, and a proposed implementation in #6450).
Background
I recommend reading the articles referenced below. They both describe a model of structured concurrency which essentially restricts the execution of concurrent tasks to a specific scope and having tools to manage them. This contrasts with the model of
go
(Go) andspawn
(Crystal) which just fires off a new fiber without caring about it's life cycle. This makes it hard to follow control flow: what happens where and when in which scope.The main idea of this proposal is to understand that each fiber is limited to the scope it is executed in:
This ensures that fibers don't get lost doing whatever stuff they might not even be supposed to do anymore.
I believe this concept can be applied to almost any real-life use case of fibers.
Having a structured flow of control also allows for a proper exception flow. Right now, unhandled exceptions within a fiber are just printed and ignored. When a fiber is scoped to some parent context, an exception can just be propagated there.
Prototype
I have implemented a simple prototype of a concurrency feature (based on
Fiber.cancel
from #6450). The idea is to have a coordination tool for running fibers, called aSpindle
. It is used to spawn fibers and ensure to collect them. This particular implementation allows running multiple tasks concurrently and if one of them fails, it cancels all the others. This is of course just an example of behaviour, there are many different ways to react.The code can be found at: https://gist.github.com/straight-shoota/4437971943bae7000f03fabf3d814a2f
I don't have a concrete proposal how this should be implemented in terms of stdlib API's but the general idea is to provide tools for running tasks concurrently. We could even think about removing unscoped
spawn
(it can be considered harmful after all), but that's not necessarily required and can probably be decided upon later.References
Some examples of similar libraries:
The text was updated successfully, but these errors were encountered: