Summary
- A synchronous program blocks until it returns, while an asynchronous program allows for the computation of other processors.
- In Java, there is the
Thread
,CompletableFuture
and also its implementation of the Fork-Join-Pull in order to implement asynchronous programming.
Synchronous
A synchronous program blocks until it returns.
When a synchronous method is invoked,
- a value is expected to be returned
- while the method is not done, execution stalls
- execution of program can only continue after method returns.
This becomes an efficiency issue when there are frequent method calls that block for a long period.
Threads
A thread is a single flow of execution in a program.
Utilising java.lang.Thread
, a function can be encapsulated to run in a separate thread.
new Thread(() -> {
for (int i = 1; i < 100; i += 1) {
System.out.print("_");
}
}).start();
new Thread(() -> {
for (int i = 2; i < 100; i += 1) {
System.out.print("*");
}
}).start();
Example
In the example above,
start()
is called, but it returns immediately, and does not block until the function encapsulated inside is completed. This is asynchronous execution.The threads run in two separate sequences of execution, and the operating system schedules the threads, thus there might be different interleaving of executions running the same program.
However, Thread
still requires a fair amount of effort to write multi-threaded programs in Java. Implementing a complex operation with Thread
- requires careful coordination
- results in overhead.
Thread
instances should be reused, but managing multiple instances and deciding which instances should run certain functions is difficult.
CompletableFuture
The CompletableFuture
class is a monad with the ability to hold a function, and check if the value promised is ready or not (if the task(s) encapsulated are complete).
Creation
completedFuture
creates a completed task and returns a valuerunAsync
takes in aRunnable
(lambda expression). It has a return type ofCompletableFuture<Void>
.supplyAsync
similarly takes in aSupplier<T>
(lambda expression). It has a return type ofCompletableFuture<T>
.
For both runAsync
and supplyAsync
, the instance completes when the lambda expression finishes. However, for the completedFuture
, the instance completes when the completedFuture
method returns.
Chaining
Consider the following operations:
thenApply
is similar to themap
functionthenCompose
is similar to theflatMap
functionthenCombine
is similar to thecombine
function
These operations also have a asynchronous version, with similar method names and the suffix Async
after it, which may cause the lambda expression to run in different thread (increasing concurrency).
Getting results
get
and join
can be used to get the results, but these calls should only be done at the final step as they are synchronous calls. Thus, to maximise concurrency, call them at the end of the program, or only when it is first necessary.
Exception handling
The completableFuture
monad provides multiple ways to handle exceptions:
handle
takes in aBiFunction
with parameters(value, exception)
exceptionally
takes in aFunction
that is triggered when an exception is encountered.whenComplete
takes in aBiConsumer
that is triggered when it is complete.
The
handle
method allows for control of both situations where an exception is encountered, and not. However, theexceptionally
only allows for control when exceptions are encountered.
Example
Given a function that retrieves a person’s age, the person might not exist, and throw a
Exception
. However, we might want to increment the person’s age by 1 if they exist.
handle
allows this:handle((x, y) -> y == null ? 0 : x + 1)
Fork-Join-Pool
- Each thread has a deque of tasks
- When a thread is idle, it checks its deque.
- If thread is non-empty, it takes a task at the head of the deque to execute.
- Otherwise, it picks up a task from the tail of the deque of another thread (work stealing).
- When
fork
is called, the caller adds itself to the head of the deque of the executing thread. (The most recently forked tasks gets executed first.) - When
join
is called,- If subtask joined has not been executed, it is executed.
- If it is completed, result is read, and
join()
returns. - If subtask is stolen and executed by another thread, then the current thread can find other tasks to work on or commit work-stealing.
Note that the most recently forked tasks get executed first.
Since the most recently forked tasks get executed first, and join
is synchronous and will block the program from running, the order of fork
, compute
and join
should form a palindrome. There should only be at most a single compute
.
left.fork();
right.fork();
right.join(); // or right.compute()
left.join();