Asynchronous Calls
Overview
As we've seen in Separate Calls, feature calls to a non-separate target are always synchronous. Furthermore, queries are always synchronous as well, because the caller has to wait for the result.
Target | Query | Command |
---|---|---|
non-separate | synchronous | synchronous |
separate | synchronous | potentially asynchronous |
Asynchronous calls can therefore only happen on commands with a separate target. Indeed, such calls are by default executed asynchronously, but there are some important exceptions to this rule. A command to a separate target is executed synchronously if any of the following applies:
- The client (caller) and supplier (target) region are the same.
- The target region is passive.
- The callee needs a lock currently held by the caller (lock passing).
- The caller holds the locks of the callee (separate callbacks).
Triggers for Synchronization
Same Regions
The first case happens when a reference is declared separate, but happens to be non-separate. This case follows directly from the type system: A non-separate type A
always conforms to its variation separate A
. At run-time such cases can be detected with an object test:
sep_object: separate A
--...
if attached {A} sep_object as non_sep_object then
-- ...
end
Passive Regions
In the SCOOP model, a passive region does not have a processor attached to it.This means that clients of the passive region need to apply features logged against a passive region themselves.The logical consequence of this is that all call to a passive region, including commands, are executed synchronously.
Lock Passing
Lock passing is another source of synchronization. It is one of the trickiest issues to detect, and to fully understand it we must first introduce a few more definitions.
In Exclusive Access we have learned that an object is controlled if it appears as a formal argument of the enclosing routine. SCOOP however always grants exclusive access over a whole region. We therefore introduce the new term Lock:
Note the difference between controlled and locked:
- Controlled applies to a single object, whereas locked applies to a region.
- The controlled property can be determined statically at compile-time, whereas locked is determined at runtime.
- The set of controlled objects of a processor is always a subset of the set of objects in locked regions.
Now consider small classes HASH_STORAGE
and EXAMPLE
:class HASH_STORAGE feature
hash_code: INTEGER
set_hash_code (a_string: separate STRING)
do
hash_code := a_string.hash_code
end
end
class EXAMPLE feature
run (a_hash_storage: separate HASH_STORAGE; a_string: separate STRING)
do
a_hash_storage.set_hash_code (a_string)
io.put_integer (a_hash_storage.hash_code)
end
end
You might notice a problem here:In the feature {EXAMPLE}.run
, exclusive access to 'a_hash_storage' and 'a_string' is guaranteed by the SCOOP semantics. Or in other words, the corresponding regions are locked. The feature {HASH_STORAGE}.set_hash_code
however needs access to a_string as well. In the SCOOP model, as seen so far, this would result in a deadlock. The handler of the HASH_STORAGE
object waits for exclusive access on the string object, and the EXAMPLE
object waits for the query {HASH_STORAGE}.hash_code
to return.
To resolve this problem, SCOOP implements a technique called Lock Passing. Locks on regions can be passed to the handler of the target of a separate call. Lock passing happens whenever the client processor (the handler of the EXAMPLE
object) has locked a region that holds an object which is passed as an actual argument to a separate call. Note that this also includes non-separate reference objects, because a processor always holds a lock over its own region.
When a client has passed its locks to the supplier processor, it cannot continue execution until the called feature has been applied by the supplier processor, and the supplier processor has given back the locks to the client. Therefore, this type of call must be synchronous.
The advantage of lock passing is that it enables some very common programming patterns without triggering a deadlock. The disadvantage, however, is that it's hard to tell when it happens. However, there are a few cases when lock passing is guaranteed to happen, namely when the actual argument passed to a separate call is
- a formal argument of the enclosing routine,
- of a non-separate reference type or
-
Current
.
There are, however, some cases where it's not immediately obvious that lock passing happens.For example, a region might be locked because of a controlled argument somewhere further up in the call stack (i.e. not the enclosing routine, but the caller of that routine), or because an object is passed as an argument which happens to be on the same region as one of the controlled objects.
There is a workaround to disable lock passing for a specific call:async_call (a_procedure: separate PROCEDURE [TUPLE])
do
a_procedure.call (Void)
end
example (a_object: separate ANY)
do
async_call (agent a_object.some_feature (Current))
end
The feature async_call
can be defined somewhere in the project and can be reused. The downside is that an agent needs to be created, but there's no lock passing happening, because all arguments to the agent are closed and only Void
is passed to the separate call which cannot trigger lock passing.However, this mechanism should be used with some care, because it's easy to run into one of the above mentioned deadlocks.
Separate Callbacks
The last occurrence of synchronous calls is closely related to lock passing. If a processor A has passed a non-separate reference argument to another processor B, and thus has passed its locks away, it cannot proceed its execution. Sometimes however processor B has to log some calls back to A, which is called a separate callback.
During a separate callback processor B has to give back the locks it has previously received from A.This in turn means B has to wait until A has finished its execution of the separate callback and returned the locks, which effectively makes the call synchronous.