-
-
Notifications
You must be signed in to change notification settings - Fork 5.5k
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
julep: a @select statement #13763
Comments
I really like the idea of a nice way to do @select begin
@case x = read(c1) begin
"Got $x from c1"
end
@case take!(c2) begin
"Got a message from c2"
end
@case write(c3, :write_test) begin
"Wrote to c3"
end
@case z = wait(task) begin
"Task finished and returned $z"
end
@case sleep(3) begin
"waited for 3 seconds and nothing happened"
end
end Downsides to this:
2 and 3 may be a good argument against something that works generally on anything that blocks, as people might not realize the requirements for working within a |
@select begin
if x=take!(c2)
...
elseif put!(c3, :test)
...
end
end Or if we really don't want to overload the meaning of
@case @schedule(write(...)) so I think this point really amounts to a change in syntax (whether the macro will automatically do that, essentially) but not semantics. By allowing arbitrary objects that have
|
Just to clarify though, even if you make the syntax for clauses look like a regular Julia expression like try
wait(channel)
catch err
isa(err, SelectInterrupt) && return
end
kill_rivals()
x=take!(channel) since try
x=take!(channel)
catch SelectInterrupt
... can lead to race conditions involving other rivals that are directly or indirectly waiting to put into That said, I still do like that more normal-looking syntax. |
quote
if x = take!(c)
println(x)
end
end This raises a syntax error due to the assignment: |
That's a good point actually - expressions passed to a macro do have to syntactically valid Julia expressions, since it is the AST that is passed to the macro. So |
I would love a go style select statement, but why not use Julia's Pair type to express the cases (sort of like how Match.jl expresses control flow)? This syntax is concise, allows variable binding, does not overload @select begin
x = read(c1) => "Got $x from c1"
take!(c2) => "Got a message from c2"
write(c3, :write_test) => "Wrote to c3"
z = wait(task) => "Task finished and returned $z"
sleep(3) => "waited for 3 seconds and nothing happened"
end The obvious major problem with this in my view is the non-standard semantic of variable binding. The line x = read(c1) => "Got $x from c1" In the context of the macro, is obviously trying to mean Pair(x = read(c1) , "Got $x from c1") But gets parsed by Julia as x = Pair(read(c1) , "Got $x from c1") This can be fixed in the macro, but may cause a lot of confusion. I personally have no problem using some other infix operator (like @select begin
let x = read(c1)
x => "Got $x from c1"
end
take!(c2) => "Got a message from c2"
write(c3, :write_test) => "Wrote to c3"
let z = wait(task)
z => "Task finished and returned $z"
end
sleep(3) => "waited for 3 seconds and nothing happened"
end |
Like you say, the precedence of => seems to make this unworkable (although With the 'let' notion, how would you express 'put' clauses? Eg, try to put
|
Unless I am missing something, 'put' does not require binding a new name. @select begin
put!(c1, :a) => "put :a in c1"
put!(c2, :b) => "put :b in c2"
end Even if it did somehow did, what would prevent you from using a @select begin
take!(c1) |> x => "took $x from c1"
take!(c2) |> y => "took $y from c2"
end I just happen to like |
Let's avoid focusing on the syntax since if we want this in the language it will get its own keyword. Just make it work with a macro and the most basic syntax possible. |
Yes, I definitely like that @select begin
let x=take!(c1), y=1
x=>"got $x and y is $y")
end
end You could only use exactly the form @select begin
let x=...
x=> ...
end
end which seems a violation of DRY, as well as a bit awkward for the common case where clauses are |
@StefanKarpinski Well, #13661 has a working macro with my original proposed syntax. Are you suggesting we just talk about critiquing implementation strategy, or what are the next steps you're thinking of? I didn't realize it was going to be a keyword, since |
Well, maybe it won't – but it does seem like a pretty good candidate for a keyword. |
Sure, whichever. But how should we proceed from here? Maybe get @amitmurthy's opinion once he's back from his travels? |
For ease of use by those who want to test this functionality out, it might be a good idea to make this |
You can always just merge it to your local Julia checkout.
|
I don't have a built-from-source version of Julia readily available so I went ahead and packaged up your select code. I did take the liberty of changing your overloaded @select begin
c1 |> x => "Got $x from c1"
c2 => "Got a message from c2"
c3 <| :write_test => "Wrote to c3"
task |> z => "Task finished and returned $z"
end Anyway I have run into some problems with the actual functionality of the macro. Here is the closest translation I was able to achieve of that go example linked earlier: function fibonacci(c, q)
x, y = 0, 1
ret = true
while ret
@select begin
c <| x => x, y = y, x+y
q => begin
println("quit")
ret = false
end
end
end
end
function main()
c = Channel{Int64}(32)
q = Channel{Int64}(32)
@schedule begin
for i in 0:9
take!(c) |> println
end
put!(q, 0)
end
fibonacci(c, q)
end
main() A more literal rendition of the fibonacci function: function fibonacci(c, q)
x, y = 0, 1
while true
@select begin
c <| x => x, y = y, x+y
q => begin
println("quit")
return
end
end
end
end Results in a misplaced return statement error. Using a |
This problem stems from the context in which the macro places the selected expression. Currently, a set of
This is why
I have spent less time thinking about the non-blocking case, but at this point they do most of the things I naively expect them to. |
I think this is vulnerable to a race condition where if you're waiting for a channel to have a value for taking, it might notify the waiter waiting for it but by the time the |
You are absolutely right. |
Agreed
|
This should be fixed now. |
Great. Do you want to submit it as a PR against the
|
I am not super familiar with git, but I am sure I can figure out how to submit a PR against the select branch. It may come as one giant commit though. Before I do there are three other strange behaviors of the non-blocking form (like the one with default cases) that I am going to address.
c1 = Channel()
c2 = Channel()
put!(c1, 0)
@select begin
c1 |> x => "c1: $x"
c2 |> x => "c2: $x"
_ => "default"
end returns c1 = Channel()
c2 = Channel()
put!(c1, 0)
@select begin
_ => "default"
c1 |> x => "c1: $x"
c2 |> x => "c2: $x"
end it returns
|
+1 for giving it a language level syntax, since it's the if-else of message-passing programming. One thing I didn't know until recently is that this style also enables timeouts without littering APIs with timeout parameters. |
Indeed, using My question for making it language level syntax is whether there's actually some syntax someone had in mind that isn't possible with a macro, or if it's really just about eliminating the But if it's the latter, then why should |
We should consider a syntax for importing a macro under a different name than the original. |
@vtjnash, I agree that if
I am not sure exactly what you are asking for here. If you want to use c1 = Condition()
c2 = Condition()
t = @schedule wait(c2)
while true
@select begin
c1 => println("this could be missed")
t => println("no race condition")
...
...
end
end If you really don't want to create a task to handle each As for the naming thing, it would be very convenient to be able to re-name imports that conflict with stuff in Base, but that may be a separate discussion. |
As far as I can tell from reading the above, we seemed to have settled on a pretty good implementation that is safe as long as you restrict it to "level-triggering things like There is a potential naming conflict with SQL-like
Is there anything left to decide on besides the name? It sounds like we were mostly happy with the implementation presented above? |
TBH i don't really necessarily think
Another option:
|
Multi-argument |
So to do the other half of the I.e. ch = wait(ch1, ch2, ch3)
if ch == ch1
...
elseif ch == ch2
...
elseif ch == ch3
...
else
# Default / timeout case
end ? It seems not quite as cute/handy/pithy as the |
I guess that's bad if you want to do different things when different things are ready, but often you want to do the same thing for all of the things you're waiting on, which is annoying to express with select. |
Maybe @select begin
ch1 => expr1
ch2 => expr2
ch3 => expr3
end |
Answering the For such a The thinking at that time was to allow for folks to build different communication patterns (for example publish-subscribe where you may publish/subscribe to a specific "topic") by making the backing store flexible enough to support them. |
Thanks for the explanation, @amitmurthy. |
@StefanKarpinski, reading through the discussions above, it sounds like a lot of the syntax-specific questions were around the best way to refer to specify all the ways one might want to wait on a channel: wait on a take!, wait on a put!, wait until there's any value (maybe? does go have this one?). In particular, needing a nice way to give a name to whatever value is read out of the channel. That's what led to the syntaxes proposed in #13763 (comment) and #13763 (comment), namely: @select begin
c1 |> x => "Got $x from c1"
c2 => "Got a message from c2"
c3 <| :write_test => "Wrote to c3"
task |> z => "Task finished and returned $z"
_ => "default"
end Which is pretty similar to your suggestion. |
@StefanKarpinski okay I've gone ahead and done that here! :) I've updated that package to Julia 1.3+, multithreaded it ( So next, i think we play with it, see if it works, fix bugs, and start brainstorming about the syntax a bit more concretely? |
I more like Clojure's glossary, use |
Maybe we can take the syntax from Clojure. First, let's look at following definitions:
take!(fn, c, exclusive_lock)
put!(fn, c, v, exclusive_lock) Now, we do select like this: begin
lck = ExclusiveLock()
@async take!(c, lck) do val
end
@async put!(c, val2, lck) do val2
end
end Well, use macro more convenient: @alt begin
take!(c1) do val
end
put!(c1, val2) do val2
end
end Futher more, sometimes, we need to ChannelOp{T} = Union{Channel, Tuple{Channel,T}, Tuple{Symbol,T}} where T
function alts(fn::Function, chops::Vector{ChannelOp}) end
chops::Vector{ChannelOp} = [c1]
push!(chops, (c2, val2))
push!(chops, (:default, val3))
alts(chops) do ch, val
end |
FYI, there are interesting arguments that
The last link (Deadlocks in non-hierarchical CSP - Roman Elizarov - Medium) explains how you can get deadlocks in rather "innocent" code using |
Just to link some things up, the latest on this is @tkf's cool extensible select in Julio.jl, based on Reagents.jl: |
I realized that what I did sounds like very opposite of my comment just above :) The main observation for me was that supporting Of course, "interesting" often means "more bugs." So, I think the links I quoted still explain very good points. |
I got bit by this issue when I tried to write a persistence layer for two communicating finite state machines; thus, I tried to tackle this issue. The proposed
Note that it would be great to write a simple Implementing such I got curious about how Go implements the The performance seems excellent, and I get about 5x speedup over the implementation with To summarize, there are two internal changes which would be needed:
Any thoughts on whether such changes can be made in [1] https://github.com/nomad-software/go-channel-compendium |
In Julia, you can close |
Quitting tasks is only one of the uses. For my application, the network is highly unreliable, and messages get dropped, manipulated or reordered. To deal with such a situation, I need to authenticate every message|counter with HMAC and let the state machine deal with it. However, sometimes messages would not come, and I shall assume that the last response did not get delivered; thus, I need to get out of being blocked by |
You can also have the heartbeat task itself enqueue the message, rather than having the timer send a message to the receiver to send a message to the client? |
Erlang is more of an actor language than a CSP. As such, it doesn't (cannot) support a generic selective communication like the languages with CSP flavor (e.g., Go) because the messages are asynchronous. I think Julia is too mature to switch to actor model at this point. So, I don't think Erlang is a good language to take (sole) inspiration from. If we were to add selective communication, I think it should be roughly at least as general as Go's. |
I hadn't thought of using Timer that way, which indeed seems like a viable option for retrying and timeouts. Another difficulty I faced was connecting the socket, with a persistence layer and a protocol state machine. From the testing perspective, it makes sense to model a protocol like
Then another component is a network layer which collects bytes and forms a chunk of messages which are put in a channel
which I could test with arbitrary events before putting in a particular protocol. Using a union channel to combine Currently, I have dropped this design in favour of stacking state machines together in the persistence layer. There are some benefits to doing that, like having fewer tasks floating around. But I feel that I lost some ability to be sure that after sticking my components together, the whole system would work. |
Can we safely say this for buffered channels? function poll!(ch)
!isbuffered(ch) && error("Not implemented for unbuffered channels")
length(ch.data) > 0 && return take!(ch)
return false
end
function offer!(ch, val)
!isbuffered(ch) && error("Not implemented for unbuffered channels")
if length(ch.data) < ch.sz_max
put!(ch, val)
return true
end
return false
end |
Motivation
Now that we have channels, the utility for a mechanism to wait on multiple events is especially high. Go has proven that the combination of channels and
select
is a strong unifying mechanism for asynchronous programming such as fine-grained synchronization and building complex parallel pipelines. Here's one simple demonstration of howselect
works in Go and the kinds of program it enabled; I won't fully go into the utlity ofselect
here since so much is already generally available.Syntax
I propose taking inspiration from Go's syntax:
This will wait for a value to be on available on channel
c1
orc2
, or capacity for a value to be available on channelc3
, or for tasktask
to complete. Whichever completes first will then go on to execute its corresponding body. I'll call statements such asc3 <: :write_test
a "clause". I call the actual values that will be waited-upon (like "c3") an "event" (I'm not attached to this nomenclature at all).A complimentary non-blocking form is available by providing a default
else
block to@select
, which will execute immediately if none of the clauses are immediately available.A clause has three possible forms:
event |> value
(a "take" clause kind)If
event
is anAbstractChannel
, wait for a value to become available in the channel and assigntake!(event)
tovalue
.if
event
is aTask
, wait for the task to complete and assignvalue
the return value of the task.event |< value
(a "put" clause kind)Only supported for
AbstractChannel
s. Wait for the channel to have capacity to store an element, and then callput!(event, value)
.event
("a "wait" clause kind)Calls
wait
onevent
, discarding the return value. Usable on any "waitable" events", which include with await method defined (most commonly, channels, tasks,
Condition` objects, and processes.)A functional form of the macro,
select
, will be provided as well in the case that the number of clauses is not known statically. However, as in go, the macro variant is the preferred style whenever feasible.Implementation
In #13661, I provide a simple but functional and reasonably performant implementation. I suspect that this implementation will go through rounds of optimization and gain support for multithreading before Julia 1.0, but it gets the ball rolling.
Non-blocking case
This case is simpler: a function
_isready
is defined that decides if a clause is immediately available for execution. For abstract channels, it will check if it has a value that is ready to be taken for a "take" clause kind; or ready to have a value put into it for a "put" clause type; for tasks, it will check if the task is done. No other event types are supported. This is a limitation ofwait
being edge-triggered: for general objects withwait
defined, there isn't a pre-defined notion of what it means to have a value "available".Blocking case
One task is scheduled for each clause (this set is called the "waiters"):
Each task calls
_wait
on its event within a try-catch block._wait
called on channel events will wait for the channel to have a value available for taking or putting, depending on the clause type._wait
for a task will wait for the task to finish, returning the tasks return value. Otherwise,_wait
will just callwait
on the event.When a task gets past its
_wait
(and hence becomes the "winner"), it signals to all its "rival" tasks (all other waiters) to terminate before modifying the state of their events. To signal the blocked tasks, it throws to the rival a custom error type that interrupts the riva's_wait
statement, and also passes to the rival a reference to the winner task. The rival processes the error in a "catch" block, returns control to the winner, and then exists.If the rival hasn't yet been scheduled, it is deleted from the workqueue. This scheme ensures only one waiter will end up manipulating the state of its event, no matter how the waiters are scheduled or what other tasks happen to be scheduled.
For channels, it is important that between the call to
_wait
and the call totake!
orput!
, no other task manipulates the channel. In this scheme, the scheduler is never run between those calls: all that happens is a sequence ofyieldto
calls amongst the waiters.This correctness is with respect to Julia's current cooperative multitasking; additional locking will have to be done to be thread-safe. As the complexity of go's select implementation shows, getting correct and high-performant
select
functionality in the presence of preemptive multithreading is a subtle problem that I don't think should block us from a reasonable@select
implementation that works in Julia today.The text was updated successfully, but these errors were encountered: