Task.Supervisor.async/2 task lifecycle after Task.await/2 timeout
0 reputation · 24 Nov 2023, 16:41 UTC
0 reputation · 24 Nov 2023, 16:41 UTC
A service starts background work with Task.Supervisor.async/2 and waits for results with Task.await/2 using a timeout. When the timeout elapses, the caller exits with :timeout, but the supervised task keeps running because Task.Supervisor children are not linked to the caller.
The goal is to stop the background task once the await times out. BEAM does not preemptively kill processes, and an unlinked supervised task does not receive the caller's exit signal. Explicit shutdown through Task.shutdown/1 or supervisor termination appears necessary, but the correct boundary is unclear.
Assuming current stable Elixir, should the caller invoke Task.shutdown/1 directly after a :timeout result, or should a separate process own reaping timed-out children? How does that choice affect the task's own cleanup code?
34775 reputation · 24 Nov 2023, 19:16 UTC
The caller should invoke Task.shutdown/1 directly after receiving a :timeout result from Task.await/2. Because Task.Supervisor.async/2 decouples the task from the caller to prevent cascading failures, the BEAM will not automatically terminate the task when the caller stops waiting.
To ensure the background process is reaped, wrap the await call in a case statement that handles the timeout explicitly:
task = Task.Supervisor.async(MySupervisor, fn ->
# Long running work
end)
case Task.await(task, 5000) do
:timeout ->
Task.shutdown(task)
{:error, :timeout}
result ->
{:ok, result}
end
The behavior is a result of how Task.Supervisor manages process links. In a standard Task.async/1 call, the task is linked to the caller; if the caller dies, the task dies. However, Task.Supervisor.async/2 links the task to the Supervisor, not the caller. This ensures that if the caller crashes, the task can still complete its work or be managed by the supervisor's strategy.
Task.await/2 is a blocking call that monitors the task's mailbox. When the timeout elapses, it simply returns :timeout to the caller. It does not send a signal to the task process. Without an explicit Task.shutdown/1, the task will continue to consume resources until it either completes naturally or the Supervisor itself is terminated.
Task.shutdown/1 sends an exit signal to the process. If the task is using try...after blocks or trapping exits, these cleanup mechanisms will be triggered.Task.shutdown/1 on a task that has already finished is safe and will not raise an error.You can verify this behavior in an IEx session by starting a task with a long Process.sleep/1, awaiting it with a short timeout, and checking the process status:
# Check if process still exists after timeout
:erlang.process_info(task_pid)
# After Task.shutdown(task_pid)
:erlang.process_info(task_pid) # Should return :undefined
Diagnostic Detail: Are you using Process.flag(:trap_exit, true) inside the async task? If so, Task.shutdown/1 will send a message rather than killing the process immediately, requiring the task to handle the exit message to terminate.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.