Task — 태스크를 생성하고 대기하는 편의 함수들
Task — 태스크를 생성하고 대기하는 편의 함수들
순차 코드를 동시(concurrent) 코드로 바꾸고 싶을 때 Elixir에서 가장 쉽게 쓰는 도구가 Task 모듈이에요. 태스크(task)를 생성하고 그 결과를 기다리는 편의 함수들이 담겨 있어요.
본문
태스크는 수명 내내 하나의 특정 동작만 실행하기 위해 만들어진 프로세스예요. 보통 다른 프로세스와 통신이 거의 없죠. 태스크의 가장 흔한 용도는 값을 비동기로 계산해서 순차 코드를 동시 코드로 바꾸는 거예요.
task = Task.async(fn -> do_some_work() end)
res = do_some_other_work()
res + Task.await(task)
async로 생성된 태스크는 **호출한 프로세스(그리고 그 호출자만)**가 대기(await)할 수 있어요. 내부적으로는 계산이 끝나면 호출자에게 메시지를 보내는 프로세스를 스폰해서 구현돼요.
일반 프로세스(spawn/1로 시작한 것)와 비교하면, 태스크는 모니터링 메타데이터와 오류 시 로깅을 포함해요. 그래서 죽었을 때 감지되고 오류가 기록되기 쉽죠.
async와 await
Task.async/1은 순차 코드를 의미는 그대로 유지하면서 동시 코드로 바꾸는 대표적인 방법이에요. 호출하면 새 프로세스가 만들어지고, 호출자와 링크(link)되고 모니터(monitor) 되어요. 태스크 동작이 끝나면 결과와 함께 메시지가 호출자에게 전송돼요. Task.await/2는 태스크가 보낸 그 메시지를 읽어요.
async를 쓸 때 기억할 두 가지가 있어요.
- async 태스크는 항상 응답을 보내기 때문에 반드시 await 해야 해요. 응답을 기대하지 않는 작업이라면 아래에서 설명할
Task.start_link/1을 고려하는 게 좋아요. - async 태스크는 호출자와 링크되어 있어요. 즉 호출자가 죽으면 태스크도 죽고, 그 반대도 성립해요. 이는 의도된 동작이에요 — 결과를 받을 프로세스가 더 이상 없는데 계산을 완료할 이유가 없으니까요. 이러한 링크 동작이 싫다면 감독되는 태스크(supervised tasks) 를 사용하면 돼요.
태스크는 프로세스다
태스크는 프로세스이므로 데이터가 통째로 복사돼요. 다음 코드를 보면요.
large_data = fetch_large_data()
task = Task.async(fn -> do_some_work(large_data) end)
res = do_some_other_work()
res + Task.await(task)
위 코드는 large_data 전체를 태스크로 복사해요. 데이터 크기에 따라 자원이 많이 들 수 있죠. 두 가지 대처 방법이 있어요.
첫째, large_data의 일부만 필요하다면 태스크 밖에서 먼저 떼어내 쓰는 방법이에요.
large_data = fetch_large_data()
subset_data = large_data.some_field
task = Task.async(fn -> do_some_work(subset_data) end)
둘째, 아예 데이터 로딩 자체를 태스크 안으로 옮기는 게 더 나을 수도 있어요.
task = Task.async(fn ->
large_data = fetch_large_data()
do_some_work(large_data)
end)
동적 감독 태스크(Dynamically supervised tasks)
Task.Supervisor 모듈은 감독되는 태스크를 동적으로 여럿 생성할 수 있게 해줘요. 짧은 예시는 다음과 같아요.
{:ok, pid} = Task.Supervisor.start_link()
task =
Task.Supervisor.async(pid, fn ->
# Do something
end)
Task.await(task)
대부분의 경우 태스크 슈퍼바이저를 감독 트리에 추가하고 싶을 거예요.
Supervisor.start_link([
{Task.Supervisor, name: MyApp.TaskSupervisor}
], strategy: :one_for_one)
이제 PID 대신 슈퍼바이저의 이름을 넘겨서 async/await를 쓸 수 있어요.
Task.Supervisor.async(MyApp.TaskSupervisor, fn ->
# Do something
end)
|> Task.await()
가능하면 감독되는 태스크를 최대한 활용하길 권장해요. 감독되는 태스크는 특정 시점에 몇 개의 태스크가 돌고 있는지 가시성을 높여 주고, 결과·오류·타임아웃을 명시적으로 제어하는 다양한 패턴을 가능하게 해요. 요약하면 이래요.
Task.Supervisor.start_child/2— 결과나 성공 여부를 신경 쓰지 않는 fire-and-forget 작업Task.Supervisor.async/2+Task.await/2— 태스크를 동시에 실행하고 결과를 가져옴. 실패하면 호출자도 실패Task.Supervisor.async_nolink/2+Task.yield/2+Task.shutdown/2— 동시 실행 후 결과나 실패 이유를 주어진 시간 안에 수집. 실패해도 호출자는 실패하지 않고 yield나 shutdown에서 오류 이유를 받음
또한 슈퍼바이저는 애플리케이션이 종료될 때 설정 가능한 종료 기간 안에 모든 태스크가 끝나도록 보장해요.
분산 태스크(Distributed tasks)
Task.Supervisor로 노드 간에 태스크를 동적으로 시작하는 것도 쉬워요.
# First on the remote node named :remote@local
Task.Supervisor.start_link(name: MyApp.DistSupervisor)
# Then on the local client node
supervisor = {MyApp.DistSupervisor, :remote@local}
Task.Supervisor.async(supervisor, MyMod, :my_fun, [arg1, arg2, arg3])
분산 태스크를 다룰 때는 명시적인 module, function, arguments를 기대하는 Task.Supervisor.async/5 를 써야 해요. 익명 함수를 쓰는 async/3은 모든 노드에 동일한 모듈 버전이 존재해야 하기 때문이에요.
정적 감독 태스크(Statically supervised tasks)
Task 모듈은 child_spec/1을 구현하고 있어서, Task.Supervisor 대신 일반 Supervisor 아래에 바로 시작할 수 있어요.
Supervisor.start_link([
{Task, fn -> :some_work end}
], strategy: :one_for_one)
이 방식은 감독 트리를 구성하면서 동시에 코드를 실행할 때(예: 캐시 예열, 초기화 상태 로깅) 유용해요. use Task를 쓰면 child_spec/1이 정의되므로 자신만의 모듈을 만들어 슈퍼바이저 자식으로 쓸 수도 있어요. 감독되는 태스크는 호출자와 직접 링크되어 있지 않기 때문에 await 할 수 없고, 기본적으로 Task.start/1, Task.start_link/1은 결과를 기다리지 않는 fire-and-forget 용도라는 점을 기억하세요. 슈퍼바이저는 태스크가 끝나기를 기다리지 않고 다음 자식을 시작하므로, 동기적 초기화가 필요하다면 Agent나 GenServer를 쓰는 게 좋아요.
더 알아보기
- 비동기 실행:
Task.async/1,Task.await/2,Task.yield/2,Task.shutdown/2 - 감독 태스크:
Task.Supervisor모듈,Task.Supervisor.async/2,Task.Supervisor.start_child/2 - fire-and-forget:
Task.start/1,Task.start_link/1 - 가이드:
task-and-gen-tcp(Task와 gen_tcp로 서버 만들기)