openlang

asyncio.gather 3.9에서 task 하나가 실패해도 다른 하나는 계속 돌면서 파일을 써요

클라이언트 서버가 3.9에 묶여 있어서 TaskGroup을 못 쓰고, 이름 붙인 태스크 두 개에 gather를 썼어요. 문서에서 하나가 예외를 던지면 나머지는 취소된다고 읽었는데, 맞죠? 오늘 pier-9의 measurements 엔드포인트가 503을 내고 있어서 "score failed"는 찍히는데, weights는 계속 살아 있다가 결국 weights_cache.json을 써요. main.py의 0.5초 sleep은 마지막 확인이 쓰기 이후에 오게 하려고 넣은 것뿐이에요.

async def score(pier):
    m = asyncio.create_task(fetch_measurements(pier), name="measurements")
    w = asyncio.create_task(fetch_weights(pier), name="weights")
    measurements, weights = await asyncio.gather(m, w)
    return combine(measurements, weights)
$ python3 main.py
score failed: measurements: upstream 503 for pier-9
in flight after failure: ['weights']
weights written to weights_cache.json for pier-9
in flight at end: []

실패한 뒤에도 weights가 왜 계속 돌고 있는 건가요?

영어에서 번역원문 보기

이어지는 대화 11

서로의 근거를 따라
  1. 고유 링크

    문서에는 그렇게 안 나와 있어요. 기본값 return_exceptions=False에서는 gather가 첫 번째 예외를 await하는 쪽에 넘겨줘요. 나머지 awaitable은 취소되지 않고 계속 실행돼요. gather 자체가 취소될 때만 같이 취소돼요. 태스크를 직접 만드셨으니 weights는 사실상 아무도 멈추지 않는 제멋대로 도는 태스크예요.

    3.9에서는 직접 취소해야 해요:

    try:
        measurements, weights = await asyncio.gather(m, w)
    except Exception:
        for t in (m, w):
            t.cancel()
        await asyncio.gather(m, w, return_exceptions=True)
        raise

    아니면 asyncio.wait(..., return_when=asyncio.FIRST_COMPLETED)를 쓰고, pending에 남은 건 전부 cancel하세요. 어느 쪽이든 확인하기 전에 취소한 태스크를 await해서 실제로 끝나게 하세요.

    그러고 나서 0.5초 sleep은 빼세요.

    영어에서 번역원문 보기
  2. 고유 링크

    고마워요, 이해됐어요. 제가 문서를 완전히 잘못 읽은 거네요, 맞죠? 첫 번째 방법으로 해볼게요. 예전에 클라이언트 사이트에서 비슷한 일을 겪은 적이 있어요. 쓰기가 시작된 뒤에 cancel이 들어가서, 취소된 태스크가 그래도 파일을 썼거든요. 그래서 파일이 없는지도 확인해 볼게요.

    영어에서 번역원문 보기
  3. 고유 링크

    gather는 다른 쪽을 취소 안 해요. 첫 예외만 넘겨주고 weights는 계속 돌아요. 지금 보이는 게 그거예요. 문서가 좀 애매하게 써 있어서 짜증나요.

    그러니까 except 블록에서 m이랑 w를 직접 cancel하고 다시 await하세요. go의 errgroup은 이걸 알아서 해주는데, python은 손으로 하게 만들죠.

    영어에서 번역원문 보기
  4. 고유 링크

    짧게 답하면, 기본값 return_exceptions=False인 gather는 첫 번째 예외만 전파해요. 나머지는 취소하지 않아요. 취소는 gather future 자체가 취소될 때만 일어나는데, 여기서는 아무도 그러지 않아요. 그래서 관찰하신 대로 weights는 끝까지 실행돼요.

    "in flight"는 정확히 무엇을 보고하나요? asyncio.all_tasks()에서 나온 거라면 맞는 걸 재고 있는 거예요. 직접 만든 bookkeeping에서 나온 거라면 저라면 그것부터 확인해 볼 거예요.

    TaskGroup은 3.12에서 들어온 걸로 알고 있어서, 3.9에서는 수동 취소가 필요해요. 취소한 뒤에 태스크를 await하고, 그다음에 파일을 확인하세요...

    영어에서 번역원문 보기
  5. 고유 링크

    네, sync 맞아요. fetch_weights에서 마지막 await 뒤에 평범한 open()과 json.dump를 쓰고, 쓰기 안에는 await가 없어요. 취소에서는 이게 중요해요. cancel은 await 지점에서만 들어갈 수 있으니까, weights가 아직 네트워크를 기다리는 중이면 쓰기는 시작도 안 하고, 이미 그 지점을 지났으면 쓰기가 통째로 끝나요. 어느 쪽이든 실행 전에 파일을 지우고 끝난 뒤에 확인할게요.

    영어에서 번역원문 보기
  6. 고유 링크

    좋은 지적이에요. json.dump 도중에 크래시가 나면 반쪽짜리 파일이 남고, 그러면 다음 실행이 그걸 진짜 캐시로 읽겠죠? 그래서 단계별로 할게요:

    1. os.replace는 같은 파일시스템에서만 atomic하니까, 같은 디렉터리에 weights_cache.json.tmp로 써요.
    2. 캐시가 정말 중요하다면, 닫기 전에 파일을 flush하고 os.fsync 해요.
    3. os.replace("weights_cache.json.tmp", "weights_cache.json").

    이건 cancel 확인에도 도움이 돼요. 최종 이름은 쓰기가 통째로 끝났을 때만 존재하니까요. 그래서 cancel 뒤에 weights_cache.json이 없는지만 확인하면 되고, .tmp가 남아 있으면 쓰기는 시작됐는데 rename이 안 된 거겠죠.

    open과 replace 사이에 await가 여전히 없으니 어차피 cancel이 중간에 끼어들 수는 없어요. 그래서 이건 대부분 크래시나 프로세스가 강제 종료되는 경우를 위한 거예요.

    영어에서 번역원문 보기
  7. 고유 링크

    main.py의 0.5초 sleep은 마지막 확인이 쓰기 이후에 오게 하려고 넣은 것뿐이에요.

    버그를 드러내는 sleep은 아마 두 번째 버그를 숨기고 있을 거예요. fetch_weights가 어딘가에서 run_in_executor나 to_thread를 거치나요, 예를 들어 HTTP 호출이나 파일 쓰기에서요? 태스크를 취소해도 이미 실행 중인 스레드는 못 멈추니까 cancel 뒤에도 파일이 생길 수 있어요. 수정을 믿기 전에 알아 둘 만해요.

    영어에서 번역원문 보기
  8. 고유 링크

    fetch_weights가 어딘가에서 run_in_executor나 to_thread를 거치나요, 예를 들어 HTTP 호출이나 파일 쓰기에서요?

    쓰기는 아니에요. 위에서 말했듯이 코루틴 안에서 그냥 open()과 json.dump예요. HTTP 호출은 클라이언트 라이브러리가 내부에서 뭘 하는지 100% 확실하진 않아서, 먼저 두 이름을 grep해 볼게요. 그다음 cancel 수정 후 한 번은 sleep을 그대로 둘게요, 맞죠? 그래도 파일이 생기면 스레드예요.

    영어에서 번역원문 보기

같은 질문의 다른 갈래

  1. context를 취소했는데도 첫 에러 이후 worker goroutine이 계속 살아 있어요 (항상 workers + 2)

    안녕하세요, 작은 Go CLI인 shotcheck를 만들고 있어요. 폴더 안의 모든 PNG가 App Store 슬롯에서 요구하는 정확한 크기인지 확인하는 도구예요. worker 풀(기본 8개)이 jobs에서 이름을 읽어서 검…

    답글 4최근 활동 go
  2. filter를 async로 바꿔도 멈춤 현상은 그대로예요

    ledgerscan으로 400k개 항목을 검색해요. run을 async로 바꾸고 main에서 await했어요. 100 ms 하트비트 Timer가 계속 돌 줄 알았거든요. 그런데 실제로는 틱이 찍히고, searching "re…

    답글 2최근 활동 dart
  3. await for over ReceivePort가 isolate가 다 끝나도 끝나지 않아요

    안녕하세요, 온도 CSV를 요약해 주는 작은 dart 툴을 만들고 있어요. 큰 파일들이 서로 기다리지 않도록 파일마다 isolate를 하나씩 띄워요. WSL에서 이렇게 실행해요: dart run bin/summarize.da…

    답글 2최근 활동 dart