Mix.install([
{:jason, "~> 1.4"},
{:kino, "~> 0.9", override: true},
{:youtube, github: "brooklinjazz/youtube"},
{:hidden_cell, github: "brooklinjazz/hidden_cell"}
])
Upon completing this lesson, a student should be able to answer the following questions.
- How do we use tasks to leverage concurrency?
- How do we use tasks to send one-off fire-and-forget jobs?
We've already seen we can use Kernel.spawn/1 or Kernal.spawn_link/1
to spawn a process that performs some work and then dies.
spawn_pid =
spawn(fn ->
IO.puts("Job Started")
# simulating expensive process
Process.sleep(1000)
IO.puts("Job Ended")
end)
When we want to execute some code in a process, we shouldn't use Kernel.spawn/1 or Kernel.spawn_link/1 directly. Instead, we should rely on the Task module. The Task module allows us to spawn a process, perform some work in that process, then end the process when our work is finished.
Task is also OTP-compliant, meaning it conform to certain OTP conventions that improve error handling, and allow them to start under a supervisor.
We can use Task.start/1 to create a new short-lived process that dies when it's function executes. This is a fire-and-forget process which does not block the caller process or return a response.
{:ok, task_pid} = Task.start(fn -> IO.puts("task ran!") end)
IO.puts("Parent keeps running")
Process.sleep(100)
Process.alive?(task_pid) || IO.puts("task is dead")
With Task, we can use async/1
and await/1
to spawn a process, perform some calculation, and the retrieve the value when it's finished.
task =
Task.async(fn ->
# simulating expensive calculation
Process.sleep(1000)
"response!"
end)
Task.await(task)
We can run two computations concurrently by separating them into two different Task processes. Here, we're simulating a clock tick-tocking every second in two separate processes to demonstrate the run in parallel.
task1 =
Task.async(fn ->
IO.inspect("tick", label: "task 1")
Process.sleep(2000)
IO.inspect("tick", label: "task 1")
Process.sleep(1000)
"tick"
end)
task2 =
Task.async(fn ->
Process.sleep(1000)
IO.inspect("tock", label: "task 2")
Process.sleep(2000)
IO.inspect("tock", label: "task 2")
"tock"
end)
Task.await(task1) |> IO.inspect(label: "Task 1 Response")
Task.await(task2) |> IO.inspect(label: "Task 2 Response")
A computer with a multi-core processor can perform these concurrent computations in parallel, which may make our program faster. In reality, it's a bit more complicated than this, but this is a reasonable mental model for now to understand why concurrency is useful for improving performance.
Here we use the par boxes to demonstrate operations happening in parallel.
sequenceDiagram
par
ParentProcess ->> Task1: spawns
ParentProcess ->> Task2: spawns
end
par
Task1 ->> Task1: performs work
Task2 ->> Task2: performs work
end
Task1 ->> ParentProcess: return awaited result
Task2 ->> ParentProcess: return awaited result
Task.async/1 returns a Task struct, not a pid. The Task struct
contains information about who the parent (:owner
) process is, the task's pid (:pid
), and a reference (:ref
) used to monitor if the task crashes.
Task.async(fn -> nil end)
To demonstrate the performance value of concurrency, let's say we have two computations which each take 1
second, it would normally take us 2
seconds
to run these tasks synchronously.
computation1 = fn -> Process.sleep(1000) end
computation2 = fn -> Process.sleep(1000) end
{microseconds, _result} =
:timer.tc(fn ->
computation1.()
computation2.()
end)
# Expected To Be ~2 Seconds
microseconds / 1000 / 1000
By running these computations in parallel, we can theoretically reduce this time to 1
second instead of 2
.
Note, if your computer does not have multiple cores, then it will still take
2
seconds rather than the expected1
second.
computation1 = fn -> Process.sleep(1000) end
computation2 = fn -> Process.sleep(1000) end
{microseconds, _result} =
:timer.tc(fn ->
task1 = Task.async(fn -> computation1.() end)
task2 = Task.async(fn -> computation2.() end)
Task.await(task1)
Task.await(task2)
end)
# Expected To Be ~1 Second
microseconds / 1000 / 1000
Use Task.async/1 and Task.await/1 to demonstrate the performance benefits between synchronous execution and parallel execution.
You may consider using Process.sleep/1 to simulate an expensive computation.
When working with many parallel tasks, we can use enumeration to spawn many tasks.
tasks =
Enum.map(1..5, fn each ->
Task.async(fn ->
Process.sleep(1000)
each * 2
end)
end)
Then we can also use enumeration to await/1
each task.
Enum.map(tasks, fn task -> Task.await(task) end)
Alternatively, you can use the convenient Taskl.await_many/1
function instead.
tasks =
Enum.map(1..5, fn each ->
Task.async(fn ->
Process.sleep(1000)
each * 2
end)
end)
Task.await_many(tasks)
Task.await/1 pauses the current execution to wait until a task has finished. However, it will not wait forever. By default, Task.await/1 and Task.await_many/1 will wait for five seconds for the task to complete. If the task does not finish, it will raise an error.
task = Task.async(fn -> Process.sleep(6000) end)
Task.await(task)
If we want to wait for more or less time, we can override the default value. await/2
and await_many/2
accept
a timeout value as the second argument to the function.
task = Task.async(fn -> Process.sleep(6000) end)
Task.await(task, 7000)
task1 = Task.async(fn -> Process.sleep(6000) end)
task2 = Task.async(fn -> Process.sleep(6000) end)
Task.await_many([task1, task2], 7000)
In the Elixir cell below, spawn a task which takes one second to complete.
await/2
the task and alter the timeout value to be one second. Awaiting the task should crash.
Consider the following resource(s) to deepen your understanding of the topic.
DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.
Run git status
to ensure there are no undesirable changes.
Then run the following in your command line from the curriculum
folder to commit your progress.
$ git add .
$ git commit -m "finish Task reading"
$ git push
We're proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.
We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.