-
Notifications
You must be signed in to change notification settings - Fork 17
Control.Distributed.Platform.Async #8
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
Comments
Some of the features here are not dissimilar, which might be worth looking to for inspiration. |
Also have a look at the Haskell async library (the talk about the design of this library is well worth watching). |
I have added preliminary support for async calls: In a nutshell: -- | Async data type
data Async a = Async MonitorRef (MVar a)
-- | Sync call to a server
call :: (Serializable rq, Show rq, Serializable rs, Show rs) => ServerId -> Timeout -> rq -> Process rs
call sid timeout rq = do
a1 <- callAsync sid rq
waitTimeout a1 timeout
-- | Async call to a server
callAsync :: (Serializable rq, Show rq, Serializable rs, Show rs) => ServerId -> rq -> Process (Async rs)
callAsync sid rq = do
cid <- getSelfPid
ref <- monitor sid
--say $ "Calling server " ++ show cid ++ " - " ++ show rq
send sid (CallMessage cid rq)
respMVar <- liftIO newEmptyMVar
return $ Async ref respMVar
-- | Wait for the call response
wait :: (Serializable a, Show a) => Async a -> Process a
wait a = waitTimeout a Infinity
-- | Wait for the call response given a timeout
waitTimeout :: (Serializable a, Show a) => Async a -> Timeout -> Process a
waitTimeout (Async ref respMVar) timeout =
let
receive to = case to of
Infinity -> do
resp <- receiveWait matches
return $ Just resp
Timeout t -> receiveTimeout (intervalToMs t) matches
matches = [
match return,
match (\(ProcessMonitorNotification _ _ reason) -> do
mayResp <- receiveTimeout 0 [match return]
case mayResp of
Just resp -> return resp
Nothing -> error $ "Server died: " ++ show reason)]
in do
respM <- liftIO $ tryTakeMVar respMVar
case respM of
Just resp -> return resp
Nothing -> do
respM <- finally (receive timeout) (unmonitor ref)
case respM of
Just resp -> do
liftIO $ putMVar respMVar resp
return resp
Nothing -> error "Response-receive timeout" There is one pending issue with Async and receiveWait/receiveTimeout: the |
This is totally cool, I love it. I'll be keen to use this internally (for stuff like gen server) when you've ironed out the kinks. Brilliant. |
@rodlogic - update on this: I would like to get the async functionality generalised and moved into Does that seem reasonable to you? |
Definitely! I had the same thought regarding Async.hs, but decided to get an implementation working first. This has been a bit of my approach here in generalI: i.e. am taking small incremental steps and then refactoring. I am also fully aware that the implementations I am cranking out could be way shorter if I were to reuse more of the existing Haskell functions to avoid, for instance, nested case expressions etc. |
fyi: the current Async implementation is not yet complete besides the wait/waitTimeout issue mentioned above. Basically, it is not correlating a response with the corresponding request. Instead of implementing this in the Async module it seems reasonable to leverage CH channels to do the correlation work for us. |
I have done a bug of experimental refactoring that I'd like to discuss, as there are a couple of issues in the current implementation (completeness notwithstanding) so I'll push to my personal fork later on so we can talk about them. I still haven't got my access rights back since the repository was transferred to the haskell-distributed organisation. |
@hyperthunk Sure. I know you are pushing for an alpha release, but it seems that we may need quite a few iterations before that specially with respect to the overall code structure, organization and reuse. Regarding the use of channels for async responses, there is one caveat: monitoring notifications come in the process inbox and the fact that we can't receive on both (unless we resort to spawning threads and the complexity that arises with it) is a bit problematic reducing the usefulness of channels in situations like this. This seems to be a broader question of how to use channels together with process messages and monitoring notifications and a question better answered by CH. |
Yes you're right about that. Probably we'll see another few hundred commits before 0.1.0.
I've had some thoughts about this and will post them shortly along with a link to the experimenting I've been doing. |
Ok. I've pushed a branch with some refactoring ideas. I'll go through and explain them in this thread when I've got a bit more time. There's currently a silly clause in the The change btw were not stylistic in nature and were motivated by specific things viz API consistency with the other async package but more importantly the need to prevent stray (unexpected) messages being sent to unsuspecting code. Anyway like I said I'll write up what was going through my mind and we can go through it and see which bits actually make sense. |
Oh and I should probably point out that I deliberately reverted the gen server changes that were using async. That was just a temporary hack to avoid dealing with lots of compilation breakage. In reality this branch is a total experiment (and tool for discussion) so even if we do use some of its code, we will patch that back into a feature branch off development anyway. |
@hyperthunk This is an interesting alternative. First, you are spawning a child process to receive the call's response and monitoring notifications (a good reminder to me that threads are cheap in Haskell/Erlang and can be used in interesting ways) and solving the correlation problem this way. Second, you created a richer protocol between the client and the async process with Finally, I see how the async child process is also responsible for the applying the I didn't understand, though, why there is the notion of a wpid (worker process id?) in addition to the gpid (gathered process id) and also why the |
Yes. This approach is idiomatic Erlang. :)
That was really inspired by the .NET async APIs, and Java's Future/Promise to some extent.
Yes that's right, the The advantage of letting the caller provide the spawn function that creates the worker is that you can spawn a worker on any node you like, but the gatherer (which uses the getValue mvar = async $ do
val <- liftIO $ takeMVar mvar
return val The call to
This is exactly right. My reasoning worked thus:
I avoided using linking as that imposes a policy, which is better described by the user than the library API itself. I was thinking of adding to this...
But before I do that, it is time to fix the stack overflow in |
On another note, after reading through Simon M's code in detail, I've noticed that he's in-lining in a few select places (that's less of a concern for us as most of our functions are likely too long to benefit) but he's also forcing strict evaluation in a few places too. I must admit that my knowledge of optimising code for the GHC runtime is somewhat limited, and I do worry that we will have a tough time making sure we're time and space efficient enough to be useful in a production environment. Hopefully at some point in the future, the other folks around the CH may have a bit of time to dip into that and point us in the right direction. |
@rodlogic - my latest commit makes a sane implementation of |
I like the changes: it generalizes it quite a bit. I was initially considering a single use-case when I extracted the first Async implementation: separating the asynchronous reply from GenServer's synchronous call. This new implementation makes it much more general. The only slight concern is what kind of overhead this adds when thinking about GenServer's call usage, but let that become a clear problem, if at all. getValue mvar = async $ do
val <- liftIO $ takeMVar mvar
return val
Yes, I see how the initial async implementation was misleading in this regard. I was reading async above as 'get the reply/response asynchronously' only (very focused on GenServer's call semantics) and not 'execute this computation (locally or remotely) asynchrnously and get the reply/response'. Back to async :: (Serializable a) => Process () -> Process (Async a)
async proc = ...
wpid <- spawnProcess localNode proc
... Where
Sounds good! I can help integrating this into the GenServer, if you want. |
@hyperthunk I think the improvements to Async are a good step in the right direction and we should move forward with it. I have, though, a few comments for consideration regarding how to better integrate 'client' operations within server handler's. First to make sure the thinking at least make sense and if so consider a possible solution, if one is needed. Right now, a GenServer is a collection of handlers, or Behavior's as you nicely named in GenProcess, that are served by a single CH receive loop. When these Behavior's need to interact with other servers or processes, they act as a client. Before Async, that was done through a synchronous Based on this, here is one observation:
Now, the other observation is about the main receive loop vs the Async receive loop:
|
Hi @rodlogic - thanks for taking a look at this.
Well, the
Yes I can see the argument for doing that, but...
That doesn't work, because if you don't spawn then the computation isn't asynchronous at all. However...
That is a very good point and would make for a much nicer API design. I'll add that shortly.
Absolutely. I'm going to add that in once I've managed to get the tests passing. :) Now, with regards the gen server design, I think we should probably move the discussion over to issue #4, but I'll cover the basics here to get us started.
I'm going to stick to this question for now. Erlang's gen server is implemented as a single process (the server) that receives messages serially and defers to a user defined behaviour/module to process them. The result of that processing can do one of several things, possibly at the same time
The call/cast abstraction is just a bit of protocol layering on top of that. Because the gen server is implemented as a single process, it can only take messages from its mailbox one at a time. When the server/process loop calls the user defined behaviours, it does so sequentially. This leads to two important characteristics:
These characteristics are something that Erlang programmers rely heavily upon whilst using the gen server, and they prove quite useful when reasoning about one's code. Now the So how do I think this should work for us in Haskell? Here's my opinion, in two parts: 1. If in doubt, do what Erlang does ;)I think that our gen server should work very much the same way as the Erlang/OTP gen server. This is a battle tested, weather worn design that has stood the test of time and is easy to program. The role of the I don't think that our async API has much of a role to play in the server process' implementation, although obviously the person writing a gen server can easily use this API to implement non-blocking themselves. Here's a typical example provided in both Erlang and Haskell - I think our CH version is much prettier personally. %% the client API - this *looks* to the caller as though we're
%% making a blocking call, even though it is non-blocking in the server
run_command(Server, Cmd) ->
ok = gen_server:cast(Server, {execute, Cmd}),
receive
{done, Result} ->
Result;
{failed, Reason} ->
{error, Reason}
end.
%% and somewhere in the gen server callback module
handle_call({execute, Cmd}, From, State) ->
Self = self(),
{MRef, Pid} = spawn_monitor(
fun() -> Result = command_executor:execute(Cmd), Self ! {finished, MRef, Result} end),
%% do *not* block the server loop, nor reply to the client
{noreply, add_client(MRef, From, State)};
%% .... snip
handle_info(Result={finished, _, _}, State) ->
%% a task has finished
reply(Result, State),
{noreply, remove_client(MRef, State)};
handle_info({'DOWN', MRef, process, _, normal}, State) ->
case lookup_client(MRef, State) of
undefined ->
%% we've already replied - just drop this message
ok;
Client ->
%% oh dear, we've not seen a reply for this job!
receive
{finished, _, _}=R -> reply(R, State);
after 0 ->
gen_server:reply(Client, {failed, unknown})
end
end,
{noreply, remove_client(MRef, State)};
handle_info({'DOWN', MRef, process, _, Reason}, State) ->
Client = lookup_client(MRef, State),
gen_server:reply(Client, {failed, Reason}),
{noreply, remove_client(MRef, State)}.
reply({finished, MRef, Result}, State) ->
Client = lookup_client(MRef, State),
gen_server:reply(Client, {done, Result}).
Versus a CH version.... type TaskID
data TaskFailed = Reason String | Cancelled
submitTask :: ServerId -> Task -> Process TaskID
submitTask server task = call server $ Submit task -- returns jobId
waitForTask (Serializable a) => ServerId -> TaskID -> Process (Either a TaskFailed)
waitForTask server taskId = call server $ WaitFor taskId
-- and on the server side, as it were
handleTaskSubmitted task = getState >>= startWorker task >>= reply
where
stateWorker task state = do
hAsync <- async $ task
(taskId, state') <- addWorker state hAsync
putState state'
return taskId
handleWaitForJob jobId =
s <- getState
hAsync <- lookupJob s jobId
r <- wait hAsync
case r of
AsyncFailed r -> reply TaskFailed (show r)
AsyncCancelled -> reply Cancelled
AsyncDone a -> reply a 2. On the other hand, don't limit ourselves to what Erlang/OTP providesJust because our gen server follows the pattern of OTP, doesn't mean the points you've raised aren't valid. Associating tasks with simultaneous clients is an important issue and the asynchronous bed-fellow of the reactor pattern is probably a very important abstraction. There are other important abstractions that we should implement in the platform layer, and I will create tickets for each of these. One classic abstraction, for example is that of composable asynchronous tasks or higher order channels. Keep your eyes peeled on the issue tracker and by all means add your own concepts as tasks that we can plan and discuss! |
Another possibility is that This implementation would basically set up a channel and put the |
I have pushed just that change set. It is much shorter and cleaner code, however it's far from perfect. In particular it still needs some kind of API for spawning the task on a remote node. |
Isn't that already supported by CH: spawn :: NodeId -> Closure (Process ()) -> Process ProcessId The K-means seems again a good reference: http://www.well-typed.com/blog/74 |
Make sense. I did experiment with this in the first Async iteration but the fact that we couldnt efficiently receive channel and process (for notifications) messages was a bit problematic. Maybe this is no longer the case now that we are spawning a child/worker process, but, for what it is worth, see this commit by Simon Marlow: |
@hyperthunk Thanks for the detailed explanation of how Erlang handles this. It is quite helpful and puts the discussion in firm grounds. The way I am 'reading' the Async module is that it is basically the |
Hi @rodlogic - if you look at the latest changes to that branch you'll see that I've got asyn working using typed channels now. Locally I have passing test cases for all the API calls and will |
This is coming along nicely now. There are two variants: channel based and STM based, where the latter is useful if you need to 'wait' or 'poll' on the asyncHandle from outside the process which created it. Neither module supports sending the async handle to another process - that kind of thing will be supported by the 'Task' API though. |
Updated title to reflect name change
The text was updated successfully, but these errors were encountered: