동시 작업 제한은 1인데 진행 중인 함수는 2개: asyncio 타임아웃의 수명 경계

1. 기다리기를 끝낸 것과 일을 끝낸 것은 다르다

에이전트가 동기식 SDK로 파일을 읽거나 외부 도구를 호출한다고 해 보자. 이벤트 루프를 막지 않으려고 asyncio.to_thread()로 옮기고, 오래 기다리지 않도록 바깥에 타임아웃을 둔다. 여기에 asyncio.Semaphore(1)까지 붙이면 실제 작업도 한 번에 하나씩만 실행될 것처럼 보인다. 하지만 세마포어를 가진 코루틴의 수명과 별도 스레드에서 실행되는 함수의 수명이 다르면 그 기대가 깨질 수 있다.

격리된 로컬 실험에서는 세마포어 값을 1로 설정했는데도 실행을 시작한 뒤 아직 끝나지 않은 함수가 2개가 됐다. 첫 번째 함수를 기다리던 태스크가 타임아웃으로 취소되면서 허가증을 반납했지만, 첫 번째 함수는 스레드 안에서 계속 남아 있었기 때문이다. 뒤이어 두 번째 함수가 같은 허가증을 얻고 시작했다. 여기서 ‘진행 중’은 대기 중인 함수를 포함한 수명 기준이며, 파이썬 바이트코드 두 개가 같은 순간 CPU에서 실행됐다는 뜻이 아니다.

이 글은 새로 발표된 취약점이나 특정 에이전트의 실제 장애 보고가 아니다. 현재 Python 공식 문서, CPython의 버전 고정 구현, 두 로컬 런타임의 순서 제어 실험으로 확인한 운영 해설이다. 조사 당시 3.14 문서는 3.14.7로 표시됐고, 직접 실행한 버전은 3.14.6과 3.12.11이다. 실행한 버전을 최신 권장 버전으로 소개하거나 두 버전 사이의 성능을 비교하지 않는다.

2. 취소는 요청이고, 실행 중인 스레드를 끄는 스위치는 아니다

Python 공식 문서에서 Task.cancel()은 태스크 취소를 요청하는 메서드다. 코루틴이 다음 기회에 CancelledError를 받아 정리하고 종료할 수 있도록 한다. 그래서 try/finally로 자원을 정리하고, 취소 예외를 직접 잡았다면 정리 뒤 대체로 다시 전파하라고 권한다. 예외를 삼키면 타임아웃이나 구조적 동시성을 구현하는 구성 요소가 의도와 다르게 동작할 수 있다.

asyncio.timeout()도 이 취소 경로를 이용한다. 기한을 넘으면 현재 태스크를 취소하고, 문맥 관리자를 빠져나오는 취소 예외를 바깥에서 잡을 수 있는 TimeoutError로 바꾼다. 그러므로 바깥에서 타임아웃을 받았다는 사실은 우선 그 기다리기 경로가 끝났다는 관측이다. 그 경로가 기다리던 모든 종류의 작업이 물리적으로 중단됐다는 증명은 아니다.

to_thread()의 CPython 3.14.6 구현은 현재 문맥을 복사한 뒤 run_in_executor()에 동기 함수를 맡긴다. asyncio 쪽 Future가 취소되면 연결된 concurrent Future에도 취소를 전달하지만, concurrent.futures.Future.cancel()은 이미 실행 중인 호출을 취소할 수 없다. 실행 전에 대기 중인 호출과 이미 스레드가 시작한 호출을 구별해야 한다. 스레드 풀 구현도 실행 직전에 취소 상태를 확인한 뒤 함수를 호출한다.

타임아웃으로 대기 태스크가 취소돼도 시작한 동기 함수는 아직 끝나지 않을 수 있는 두 수명 도식

공식 취소 의미와 시작 확인 후 Event로 붙잡은 로컬 실험. 대기 태스크 종료 뒤 함수가 기록을 남길 수 있었다. 출처: docs.python.org/3.14/library/asyncio-task.html.

이 경계를 확인하려고 함수 시작을 threading.Event로 확인하고, 함수 내부의 작은 기록 작업은 다른 Event 뒤에 붙잡아 두었다. 그 상태에서 기다리는 태스크에 타임아웃을 발생시켰다. 태스크는 취소됐고 타임아웃도 관측됐지만, 함수 완료 Event는 아직 꺼져 있었다. 이후 실험자가 통과 신호를 주자 함수가 메모리 목록에 기록을 남겼다. 네트워크 요청이나 사용자 파일을 쓰지 않고도 타임아웃 뒤에 함수의 후속 동작이 가능함을 확인한 것이다.

3. 세마포어가 센 것은 함수가 아니라 취소된 문맥이었다

문제의 핵심 형태는 async with sem: 안에서 await asyncio.to_thread(work)를 수행하는 것이다. 공식 문서는 이 문맥을 허가증 획득 뒤 try/finally에서 반납하는 것과 동등하게 설명한다. 정상 종료든 취소든 문맥을 빠져나가면 반납이 일어난다. 이는 세마포어의 버그가 아니라, 세마포어가 보호하는 범위를 무엇으로 잡았는지의 문제다.

실험에서는 같은 세마포어를 사용하는 함수 두 개를 준비했다. 첫 번째 함수가 실제로 시작했음을 확인한 뒤 기다리는 태스크를 타임아웃으로 취소했다. 이 시점에 태스크는 끝났고 세마포어는 다시 획득할 수 있었지만, 첫 번째 함수는 내부 Event에서 계속 대기했다. 두 번째 요청을 시작하자 두 번째 함수도 진입했다. 잠금으로 보호한 카운터가 기록한 최대 진행 중 함수 수는 2였다.

대조 조건에서는 작업을 책임지는 태스크를 별도로 만들고 참조를 유지했다. 요청자는 그 태스크를 asyncio.shield()를 통해 기다렸고, 자신의 대기만 타임아웃으로 끝냈다. 작업 소유 태스크는 직접 취소하지 않았으며, 함수가 완료될 때까지 세마포어를 유지했다. 두 번째 요청은 첫 번째 함수가 끝나고 허가증이 돌아온 다음 시작했다. 이 조건의 최대 진행 중 함수 수는 1이었다.

같은 Semaphore 1에서 대기 태스크 소유의 최대 진행 중 함수 2개와 별도 shielded 소유 태스크의 1개를 비교

두 함수를 제어한 로컬 대조 실험: 각 조건을 Python 3.14.6과 3.12.11에서 각각 3회 반복. 대기 포함 진행 중 함수 수이며 CPU 병렬성이나 성능 개선율이 아니다. 출처: docs.python.org/3.14/library/asyncio-sync.html#asyncio.Semaphore.

두 조건을 각각 세 번씩, Python 3.14.6과 3.12.11에서 반복해 같은 순서를 확인했다. 숫자 2와 1은 두 함수를 Event로 붙잡아 둔 이 실험의 최대 진행 중 개수다. 서비스의 처리량, 개선 비율, 모든 환경의 최대 스레드 수를 뜻하지 않는다. executor 자체의 작업자 수, 별도 제한기, 함수의 종료 방식이 다르면 결과도 달라진다. asyncio.Semaphore를 스레드 안에서 조작한 실험도 아니다. 획득과 반납은 이벤트 루프 쪽 태스크가 수행했다.

4. TaskGroup과 정리 코드도 서로 다른 종료를 보여 준다

TaskGroup은 그룹에 속한 태스크를 모아 기다리고, 한 태스크가 일반 예외로 실패하면 다른 태스크를 취소하는 유용한 도구다. 다만 그룹이 기다리는 대상은 태스크다. 별도 실험에서 한 태스크가 실행 중인 to_thread() 함수를 기다리게 하고, 다른 태스크가 시작 확인 뒤 의도적으로 실패하게 했다. 그룹의 예외 처리가 끝나고 기다리던 태스크가 취소된 뒤에도, Event 뒤의 동기 함수는 완료되지 않은 상태였다. 통과 신호를 주자 그 함수의 기록 작업이 실행됐다. 구조적 동시성이 무용하다는 뜻이 아니라, 그룹 밖 실행기의 수명까지 자동으로 같은 경계가 되는 것은 아니라는 뜻이다.

반대로 순수 코루틴의 정리는 타임아웃 반환을 늦출 수 있다. 공식 wait_for() 문서는 취소가 실제로 처리될 때까지 기다리므로 전체 대기 시간이 설정값보다 길어질 수 있다고 명시한다. 로컬 대조 실험에서도 짧은 기한을 두고, 코루틴의 finally 안에 별도 비동기 정리 대기를 넣었다. 관측 순서는 정리 시작, 정리 완료, 바깥의 타임아웃 처리였다. 이 짧은 지연은 순서를 확인하는 장치이지 지연시간 보장이나 성능 벤치마크가 아니다.

또 다른 의도적 반례에서는 내부 코드가 CancelledError를 잡아 정상 값을 반환하게 했다. 타임아웃 문맥의 기한 초과 상태는 참이었지만 바깥으로 TimeoutError는 나오지 않았다. 이것은 권장 구현이 아니라, “예외를 잡아 조용히 끝내기”가 종료 신호를 흐릴 수 있음을 확인한 대조 조건이다. 반면 아직 시작하지 않은 스레드 풀의 대기 작업은 취소에 성공했고 함수 자체가 실행되지 않았다. 따라서 ‘스레드 작업은 어떤 상태에서도 취소할 수 없다’라는 과장도 피해야 한다.

5. 필요한 것은 shield 한 줄이 아니라 작업을 끝까지 맡는 주체다

대조 실험의 shield()를 모든 요청에 붙이는 처방으로 읽으면 안 된다. 공식 문서대로 shield는 호출자의 취소가 감싼 태스크로 전달되는 것을 막을 뿐, 호출자 자신의 취소를 없애지 않는다. 태스크가 다른 경로로 직접 취소되면 보호되지 않으며, 태스크 참조도 따로 보관해야 한다. 요청과 함께 취소되는 TaskGroup에 작업 소유 태스크까지 넣어 두고 다른 곳에서 shield로 기다리는 것만으로는 그 직접 취소를 막지 못한다.

설계할 때는 요청자가 기다릴 기한과 실제 작업이 종료될 조건을 따로 적는 편이 낫다. 작업을 계속 허용하는 정책이라면 서비스 수명의 관리자가 태스크를 보관하고, 결과나 예외를 회수하고, 실제 함수가 끝난 뒤 자원 허가증을 반납해야 한다. 종료 시 남은 작업을 어떻게 정리할지도 필요하다. 그냥 백그라운드로 버리는 것은 수명 관리가 아니다. 반대로 중단이 필요한 작업이라면 동기 라이브러리 자체의 I/O 타임아웃과 중단 기능을 확인해야 한다.

요청자 대기 기한과 별도 소유 태스크의 허가증 획득, 실제 함수 완료 추적, 결과 회수와 반납을 분리한 설계 도식

공식 의미에서 도출한 설계 제안. shield만으로 직접 취소나 종료 정리가 해결되지 않으며 실제 작업을 끝까지 책임지는 관리자가 필요하다. 출처: docs.python.org/3.14/library/asyncio-task.html#asyncio.shield.

협력적 중단도 하나의 도구다. 스레드 함수가 안전한 지점에서 중단 Event를 확인하고 종료하도록 하면 불필요한 후속 작업을 줄일 수 있다. 실험에서는 기록 직전의 검사보다 먼저 중단 신호를 줬을 때 기록을 생략했다. 그러나 검사 직후와 실제 기록 사이에 중단 신호가 도착할 수도 있다. 신호 확인은 이미 일어난 일을 되돌리지 않으며, 이후 쓰기를 원자적으로 보호하는 장치도 아니다. 업무상 중요한 효과에는 별도의 일관성 계약이 필요하다.

다른 라이브러리의 기본값도 확인할 가치가 있다. AnyIO 공식 스레드 문서는 기본적으로 작업자 스레드 완료를 기다리는 태스크를 취소로부터 보호하며, abandon_on_cancel=True를 주면 기다리는 태스크는 취소될 수 있지만 스레드는 계속 실행되고 결과만 무시된다고 설명한다. 협력적 검사 API도 별도로 제공한다. 이 글에서는 AnyIO를 실행 비교하지 않았다. 같은 ‘스레드로 보내기’라도 취소 계약이 다르므로 현재 쓰는 API의 기본값을 읽어야 한다는 근거로만 인용한다.

6. 타임아웃 지표 옆에 실제 완료 지표를 놓자

실무 점검은 복잡한 새 프레임워크보다 관측 대상을 나누는 데서 시작할 수 있다. 요청 대기 종료, 태스크 취소 처리, 동기 함수 시작과 완료, 자원 허가증 반납을 각각 기록한다. 타임아웃 직후 진행 중 함수가 남는지, 허가증이 실제 완료보다 먼저 돌아오는지 확인한다. 이벤트 루프의 태스크 개수만 줄었다는 이유로 스레드 작업이나 외부 시스템의 처리가 끝났다고 판단하지 않는다.

회귀 테스트도 정상 완료만 보지 말고 실행 시작 전 취소, 시작 후 취소, 정리 중 취소를 나누자. 실행 후 취소를 시험할 때는 함수가 정말 시작했다는 신호를 먼저 기다리는 것이 중요하다. 단지 짧은 sleep을 넣고 취소하면 대기열에서 취소된 사례를 실행 중 중단으로 오해할 수 있다. 이번 실험은 Event로 그 순서를 고정했고, 마지막에는 남긴 함수와 태스크를 모두 회수했다.

여기서 제안하는 운영 원칙은 단순하다. 진행 중 작업 수를 제한하려면, 제한의 허가증을 실제 작업 수명에 묶어야 한다. 요청자가 기다리기를 포기하는 순간과 작업이 끝나는 순간을 같은 상태로 저장하지 말자. 타임아웃은 응답 정책에 꼭 필요하지만, 이미 실행 중인 함수를 중단하거나 그 효과를 되돌리는 계약까지 대신 제공하지는 않는다.

참고 자료

그림은 공식 동작 의미와 격리된 로컬 실험을 바탕으로 직접 코드 렌더링한 원본 도식이다. 원격 서비스나 특정 모델의 성능, 사고 발생률, 네트워크 쓰기의 취소 여부를 측정한 결과는 아니다.

댓글

이 블로그의 인기 게시물

체크아웃은 분리됐는데 Git 상태는 공유된다: Codex 0.154 worktree의 세 경계

잠근 작업이 커밋 뒤 다시 나온다: PostgreSQL 에이전트 큐의 소유권 설계

코딩 에이전트는 API 문서보다 AGENTS.md를 먼저 읽었다