A simplistic Clojure library for creating, manipulating and composing asynchronous actions, that
is built atop Clojure's existing support for futures.
Two of the main things that futures in Clojure lack are composability and a certain degree of laziness. This library attempts to provide these characteristics, plus some additional tools for working with them.
The main abstraction in halfling is something called a Task
.
Task
is essentially a fancy wrapper around Clojure's future
.
Let's create some tasks:
> (require '[halfling.task :as t])
=> nil
> (def adding (t/task (+ 1 1)))
=> #'user/adding
> (def no-please (t/task
(Thread/sleep Integer/MAX_VALUE)
42))
=> #'user/no-please
> (t/executed? adding)
=> false
> (t/executed? no-please)
=> false
Right, so by now nothing actually happened. Tasks are lazy by default and every operation you perform on them (aside from execution) is also computed lazily. In order to make a task do something, you have to explicitly run it. This can be done either synchronously or asynchronously.
The invariant is that running a task will always return another task, which contains the result of that execution. This can be subsequently manipulated and composed with other tasks.
> (t/run adding)
=> #Task{:executed? true, :status :success, :value 2}
This type of execution will naturally block the current thread until the tasks finishes.
> (t/run-async adding)
=> #Task{:executed? false, :status :pending, :value nil}
Running a task asynchronously will not block the current thread and will return immediately. The task itself captures a promise which will eventually be filled with the result of that execution.
Note: wait
can be used to block explicitly.
In some cases, you may want to retrieve the actual inner value of a task.
This can be achieved either with get!
, deref
or @
and these can return one of two things:
> (t/get! (t/run adding))
=> 2
> @(t/run adding)
=> 2
Exception
that occurred:> (-> (t/task (throw (Exception. "Something went wrong!")))
(t/run)
(t/get!))
=> { :cause "Something went wrong!",
:via [...],
:trace [...] }
There's a separate get-or-else
function, which will return either the value in
case of a success, or a provided else
alternative in case of failure:
> (-> (task 1)
(t/run)
(t/get-or-else -1))
=> 1
> (-> (t/task (throw (Exception. "Nope")))
(t/run)
(t/get-or-else -1))
=> -1
Note: All of these will block an asynchronously executing task.
There are a number of functions that check different types of a task's status:
done?
- checks if a running task has finished runningexecuted?
- checks if a task has been run and has finished runningfulfilled?
- checks if a running task has finished running and was successfulbroken?
- checks if a running task has finished running and failedIn addition, you can create finished successful or failed tasks with:
success
- given any value, returns a finished successful task containing that valuefailure
- given a string message, returns a finished failed task with that message wrapped in a Throwable as an errorfailure-t
- given a proper Exception
or Throwable
object, returns a finished failed task with that errorTasks can be composed by using the then
primitive. This takes a
task and some sort of callback function, and returns a new task:
> (def crucial-maths (-> (t/task (+ 1 1))
(t/then inc)
(t/then dec)))
=> #'user/crucial-maths
> @(t/run crucial-maths)
=> 2
Additionally, the callback function can either return a simple value or another task:
> (def crucial-maths (-> (t/task (+ 1 1))
(t/then #(t/task (inc %)))
(t/then dec)))
=> #'user/crucial-maths
> @(t/run crucial-maths)
=> 2
By the magic of referential transparency, this leads to the same outcome.
Tasks maintain composability after execution. Every time they get run, they return new tasks containing the future results. Because tasks are lazy, once you've run a task and afterwards composed new things into it, you'll have to run it again in order to force the new compositions.
Example:
> (def crucial-math (-> (t/task (+ 1 1))
(t/then #(t/task (inc %)))
(t/run) ;; <- (inc (+ 1 1))
(t/then dec))) ;; <- unexecuted
=> #'user/crucial-math
In this case run
(and also run-async
) will only execute those tasks that came before its invocation.
If additional compositions are made after or while it's executing, these shall remain un-executed until another
call to either run
or run-async
is made:
> @(t/run crucial-math)
=> 2
The task will then pick up where it's left off and execute the remaining changes.
If you're not interested in the return value of some previous task,
you can chain fire-and-forget-like task effects by using the then-do
macro.
then-do
sequentially composes effects into one task:
> @(-> (t/task (println "Launching missiles!"))
(t/then-do (println "Missiles launched!"))
(t/then-do (println "Death is imminent!"))
(t/run))
Launching missiles!
Missiles launched!
Death is imminent!
=> nil
This is equivalent to:
> @(-> (t/task (println "Launching missles!")
(t/task (fn [_] (println "Missiles launched!")))
(t/task (fn [_] (println "Death is imminent!")))
(t/run))
A potentially failed task may be recovered with either recover
or recover-as
.
Both of these may well return either simple values, or tasks alltogether.
recover
allows you to recover a task, based on the error that occured, whilst recover-as
simply
ignores the error and lets you reset the task to any given value after failure.
> @(-> (t/task (throw (Exception. "Failed)))
(t/recover #(.getMessage %))
(t/run))
=> "Failed"
> @(-> (t/task (throw (Exception. "Failed")))
(t/recover-as -1)
(t/run))
=> -1
Whilst threading tasks from one to the other looks pretty, it isn't particuarly suited for working with mutliple interdependent tasks.
For this there is do-tasks
:
> (def crucial-maths
(t/do-tasks [a (t/task (+ 1 1))
b1 (t/task (inc a))
b2 (dec a)]
(+ a (- b1 b2))))
=> #'user/crucial-maths
> @(t/run crucial-maths)
=> 4
With this, you can use binding-forms to treat task
values as if they were realized, and use them in that local context.
do-tasks
accepts both simple values and other tasks. It automatically "promotes"
simple values to tasks in order to work with them.
As of 1.2.1
, do-tasks
also supports syntax for recover
and recover-as
.
These can be placed wherever within the do-tasks
binding block:
> (def crucial-maths
(t/do-tasks [a (t/task (+ 1 1))
b1 (t/task (inc a))
b2 (dec a)
:recover #(.getMessage %)]
(+ a (- b1 b2))))
=> #'user/crucial-maths
Note: do-tasks
essentially desugars to nested then
-calls,
which means that the binding-forms are serialised.
Halfing supports parallel execution with the functions:
mapply
- given any number of tasks and a function of arity equal to that number,
will call that function with all the values of those tasks if they are successful:> (def task1 (t/task 1))
=> #'halfling.task/task1
> (def task2 (t/task 2))
=> #'halfling.task/task2
> (def task3 (t/task 3))
=> #'halfling.task/task3
> @(t/run (t/mapply (fn [a b c] (+ a b c)) task1 task2 task3)) ; task1, task2, task3 executed in parallel
=> 6
zip
- takes any number of tasks and returns a task, which, in case of success, aggregates their values
in a vector:> @(t/run (t/zip task1 task2 task3)) ; task1, task2, task3 executed in parallel
=> [1 2 3]
sequenced
- takes a collection of tasks and returns a task, which, in case of success, aggregates the values of
those tasks in the same type of collection:> @(t/run (t/sequenced #{task1 task2 task3})) ; task1, task2, task3 executed in sequence
=> #{1 2 3}
Failed tasks will contain the error of the first execution that failed.
See halfing.task
for more information.
As of version 1.0.0
halfling has a separate namespace called lib
, which contains
different types of library functions that use the task
API in their implementation.
Current functions:
p-map
- just like map
but returns a task, which applies the function in parallel.
Similarly to Clojure's pmap
, should only be used when the computation performed outweighs
the distribution overhead.An example usage:
> (require '[halfling.lib :refer [p-map]])
=> nil
> (defn letters [start-char]
(iterate (comp char inc int) start-char))
=> #'user/letters
> (def alph (vec (concat
(take 26 (letters \A))
(take 26 (letters \a)))))
=> #'user/alph
> (defn rand-str [n]
(->> (range 0 n)
(map (fn [_] (rand-nth alph)))
(apply str)))
=> #'user/rand-str
> (defn strings [n length]
(->> (range 0 n)
(map (fn [_] (rand-str n)))))
=> #'user/strings
> (def work (->> (strings 4000 1000)
(p-map clojure.string/lower-case)))
=> #'user/work
> (time (do (t/run work) ()))
"Elapsed time: 1.258364 msecs"
Copyright © 2017-2021 Robert Marius Avram
Distributed under the MIT License.
Can you improve this documentation? These fine people already did:
Robert Marius Avram & Robert M. AvramEdit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close