- Published on
Python asyncio Task was destroyed but it is pending 경고 원인 5가지와 완벽 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 크롤러, 워커를 asyncio로 돌리다 보면 종료 시점에 아래 같은 경고를 만나기 쉽습니다.
Task was destroyed but it is pending!
이 메시지는 단순한 “경고”처럼 보이지만, 실제로는 종료 시점에 처리 중이던 작업이 강제 파기(destroy)되어 리소스 누수/데이터 유실/커넥션 미정리가 발생할 수 있다는 의미입니다. 특히 aiohttp 커넥션 풀, 파일 핸들, DB 커넥션, 백그라운드 재시도 루프가 섞이면 재현이 어렵고 운영에서만 터지기도 합니다.
이 글에서는 현업에서 가장 자주 보는 원인 5가지와, 한 번에 정리되는 “완벽한 종료(graceful shutdown)” 패턴을 소개합니다.
경고의 본질: 루프가 닫히는데 Task가 아직 pending
Task was destroyed but it is pending!은 보통 다음 상황에서 발생합니다.
- 이벤트 루프가 종료/닫힘(
loop.close()또는asyncio.run()종료) 직전 - 아직 완료되지 않은
asyncio.Task가 존재 - 그 Task는 더 이상 await될 기회가 없어서 GC 과정에서 파기
즉, 핵심은 "종료 전에 남은 Task를 식별하고, 취소(cancellation)를 전파하고, 리소스를 정리하고, 끝까지 기다리는" 것입니다.
원인 1) create_task로 띄우고 await/추적을 안 함 (fire-and-forget)
가장 흔한 형태입니다.
import asyncio
async def worker():
await asyncio.sleep(10)
async def main():
asyncio.create_task(worker()) # 추적 안 함
await asyncio.sleep(0.1) # main이 먼저 끝나며 루프 종료
asyncio.run(main())
해결
- Task 핸들을 저장하고 종료 시
cancel()+gather()로 회수 - 또는
asyncio.TaskGroup(3.11+)로 구조화된 동시성 사용
import asyncio
async def worker():
try:
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
# 정리 작업
raise
async def main():
task = asyncio.create_task(worker())
await asyncio.sleep(0.2)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(main())
원인 2) 취소(cancel)를 걸었는데 CancelledError를 삼켜서 종료가 안 됨
취소는 예외(asyncio.CancelledError)로 전달됩니다. 아래처럼 broad exception으로 삼키면 Task가 계속 pending이거나, 상위로 취소가 전파되지 않습니다.
async def worker():
try:
while True:
await asyncio.sleep(1)
except Exception:
# 여기서 CancelledError까지 먹어버리면 곤란
pass
해결
CancelledError는 반드시 다시 raise- 정리 후
raise가 정석
import asyncio
async def worker():
try:
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
# cleanup
raise
except Exception as e:
# 진짜 에러 처리
raise
Best Practice
except Exception:을 쓰더라도CancelledError는 별도로 먼저 처리- “취소는 정상 제어 흐름”으로 취급
원인 3) aiohttp ClientSession/커넥터를 닫지 않음 (백그라운드 연결 관리 Task 잔존)
aiohttp.ClientSession은 내부적으로 커넥션 풀/커넥터 관련 작업이 얽혀 있어, 세션을 닫지 않으면 종료 시점에 pending Task가 남을 수 있습니다.
문제 코드
import aiohttp
import asyncio
async def main():
session = aiohttp.ClientSession()
async with session.get("https://example.com") as resp:
await resp.text()
# session.close() 누락
asyncio.run(main())
해결
- 가장 안전한 방식:
async with ClientSession()
import aiohttp
import asyncio
async def main():
async with aiohttp.ClientSession() as session:
async with session.get("https://example.com") as resp:
await resp.text()
asyncio.run(main())
종료 훅에서 세션 정리하기
앱 전역에서 세션을 공유한다면, shutdown 단계에서 확실히 닫아야 합니다.
class App:
def __init__(self):
self.session = None
async def start(self):
import aiohttp
self.session = aiohttp.ClientSession()
async def stop(self):
if self.session:
await self.session.close()
원인 4) signal 처리 없이 프로세스가 급종료되거나, 종료 이벤트가 Task에 전달되지 않음
운영에서는 Ctrl+C(SIGINT)나 SIGTERM(컨테이너 종료)로 내려가는 경우가 많습니다. 이때 종료 신호를 받아 "종료 플래그"를 세팅하고, 백그라운드 Task를 취소하고, 리소스를 닫고, join하는 구조가 없으면 pending Task가 남습니다.
해결: signal + shutdown 이벤트 + Task 취소 전파
아래는 Linux/macOS에서 잘 동작하는 전형적인 패턴입니다.
import asyncio
import signal
class Service:
def __init__(self):
self._tasks: set[asyncio.Task] = set()
self._stop_event = asyncio.Event()
async def run_forever(self):
self._tasks.add(asyncio.create_task(self._background_loop(), name="bg"))
await self._stop_event.wait() # 종료 신호 대기
async def _background_loop(self):
try:
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
# cleanup
raise
async def shutdown(self):
self._stop_event.set()
# 1) 모든 task cancel
for t in list(self._tasks):
t.cancel()
# 2) 취소 완료까지 대기
await asyncio.gather(*self._tasks, return_exceptions=True)
async def main():
svc = Service()
loop = asyncio.get_running_loop()
def _on_signal():
# signal handler는 sync 함수여야 하므로 task로 연결
asyncio.create_task(svc.shutdown())
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _on_signal)
await svc.run_forever()
asyncio.run(main())
Windows 주의
Windows는 loop.add_signal_handler 지원이 제한적입니다. 이 경우:
KeyboardInterrupt를 잡아shutdown()호출- 또는 ProactorEventLoop 환경에 맞는 별도 처리 필요
원인 5) 종료 순서가 잘못됨 (리소스를 닫기 전에 Task를 기다리거나, 반대로 Task가 리소스를 붙잡음)
자주 나오는 실수는 다음 두 가지입니다.
- Task가 네트워크/큐를 사용 중인데 리소스를 먼저 close → Task가 예외/대기 상태로 꼬임
- 리소스 close를 Task가 담당하는데, Task를 먼저 cancel → close 로직이 실행되지 않음
해결: “정리 순서”를 명시적으로 설계
추천 순서(일반적인 서버/워커 기준):
- 새로운 작업 유입 차단(accept 중단, 큐 소비 중단)
- 백그라운드 Task에 종료 신호 전달(이벤트 set)
- Task cancel 전파(필요 시)
- Task join(
gather) - 세션/커넥션/파일 close
예: 큐 소비 워커
import asyncio
class QueueWorker:
def __init__(self):
self.q = asyncio.Queue()
self.stop = asyncio.Event()
self.task: asyncio.Task | None = None
async def start(self):
self.task = asyncio.create_task(self._loop())
async def _loop(self):
try:
while not self.stop.is_set():
try:
item = await asyncio.wait_for(self.q.get(), timeout=0.5)
except asyncio.TimeoutError:
continue
# 처리
await asyncio.sleep(0.1)
except asyncio.CancelledError:
raise
async def shutdown(self):
# 1) 유입 차단
self.stop.set()
# 2) join (필요 시 cancel)
if self.task:
self.task.cancel()
await asyncio.gather(self.task, return_exceptions=True)
완벽 해결 템플릿: TaskGroup + 종료 훅 + aiohttp 세션 + signal
Python 3.11+라면 TaskGroup을 중심으로 “구조화된 동시성”을 구성하면 경고가 크게 줄어듭니다. 아래 예시는:
- aiohttp 세션을 수명 범위로 묶고
- 백그라운드 작업을 TaskGroup에 넣고
- SIGINT/SIGTERM에 반응해 graceful shutdown
- cancel 전파가 자동으로 정리되도록 구성
import asyncio
import signal
import aiohttp
async def poller(stop: asyncio.Event, session: aiohttp.ClientSession):
try:
while not stop.is_set():
async with session.get("https://example.com") as r:
await r.read()
await asyncio.sleep(1)
except asyncio.CancelledError:
# 필요한 cleanup 후 전파
raise
async def main():
stop = asyncio.Event()
loop = asyncio.get_running_loop()
def on_signal():
stop.set()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, on_signal)
except NotImplementedError:
# Windows 등
pass
async with aiohttp.ClientSession() as session:
async with asyncio.TaskGroup() as tg:
tg.create_task(poller(stop, session))
# stop이 set될 때까지 대기
await stop.wait()
# TaskGroup 블록을 빠져나가면 남은 task는 cancel되고 join됨
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
이 패턴이 강한 이유
- TaskGroup 스코프를 벗어나면 미완료 Task를 자동 cancel + join
- aiohttp 세션은
async with로 반드시 close - stop 이벤트로 “정상 종료 경로”를 만들고, 필요 시 cancel로 마무리
트러블슈팅: 남은 pending Task를 찾아내는 방법
경고가 계속 뜬다면 “어떤 Task가 남았는지”부터 잡아야 합니다.
1) 종료 직전에 all_tasks 덤프
import asyncio
def dump_pending_tasks():
tasks = [t for t in asyncio.all_tasks() if not t.done()]
for t in tasks:
print("PENDING:", t.get_name(), t)
# t.print_stack() # 3.11+
asyncio.run()을 쓰는 경우 종료 직전에 훅을 넣기 어렵기 때문에, 서비스 코드 내 shutdown 루틴에서 호출하는 방식이 현실적입니다.
2) aiohttp 경고도 같이 확인
Unclosed client session, Unclosed connector가 함께 보이면 세션 수명 관리가 1순위입니다.
3) 타임아웃을 둔 shutdown
외부 I/O가 hang이면 영원히 종료가 안 됩니다. shutdown 단계에 타임아웃을 두고, 타임아웃 시 강제 cancel로 넘어가세요.
await asyncio.wait_for(graceful_shutdown(), timeout=10)
Best Practice 체크리스트
-
create_task()로 만든 Task는 반드시 추적(set/list/TaskGroup) -
CancelledError는 삼키지 말고 정리 후 재발생(raise) - aiohttp
ClientSession은 async with 또는 shutdown에서await close() - SIGTERM/SIGINT를 받아 stop 이벤트 → cancel → gather 순으로 종료
- shutdown에 타임아웃을 둬서 무한 대기를 방지
- 재시도/백오프 루프는 종료 가능하도록 설계(이벤트 체크)
재시도/레이트리밋 대응처럼 백그라운드 큐와 워커를 운영한다면, 종료 설계가 더 중요해집니다. 예를 들어 API 호출을 큐잉하고 지수 백오프를 돌리는 구조에서는 “종료 시 큐 소비 중단 + in-flight 요청 취소 + 세션 정리”가 필수입니다. 관련 설계는 OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기에서도 비슷한 관점으로 확장해볼 수 있습니다.
결론: 경고를 없애는 게 아니라 종료를 설계하라
Task was destroyed but it is pending!는 “운 좋게 지금까진 괜찮았던 종료”가 더 이상 안전하지 않다는 신호입니다. 해결의 핵심은 단 하나입니다.
- Task 수명 추적
- 취소 전파
- 리소스(특히 aiohttp 세션) 정리
- signal 기반 graceful shutdown
지금 운영 중인 asyncio 서비스에 위 템플릿을 그대로 적용해 보세요. 경고가 사라지는 것뿐 아니라, 배포/재시작/스케일 인/아웃 상황에서 장애가 눈에 띄게 줄어듭니다.