openlang

asyncio.gather на 3.9: одна задача падает, другая продолжает работать и пишет свой файл

Сервер клиента застрял на 3.9, так что TaskGroup нет, и я использовал gather на двух именованных задачах. В документации я прочитал, что когда одна падает с исключением, остальные отменяются, так? Эндпоинт measurements у pier-9 сегодня отдаёт 503, "score failed" печатается, но weights всё ещё жив и всё равно пишет weights_cache.json. Полусекундный sleep в main.py нужен только для того, чтобы последняя проверка шла после записи.

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 передаёт первое исключение тому, кто его ждёт. Остальные 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. В любом случае дождитесь отменённых задач, чтобы они реально завершились до вашей проверки.

    А полусекундный sleep потом уберите.

    Переведено с английскогоПоказать оригинал
  2. Ссылка на ответ

    Спасибо, теперь понятно, и документацию я явно прочитал неправильно, да? Попробую ваш первый вариант. Как-то раз на сайте клиента меня уже укусило похожее: отменённая задача всё равно записала файл, потому что отмена пришла после начала записи, так что я проверю ещё и то, что файла нет.

    Переведено с английскогоПоказать оригинал
  3. Ссылка на ответ

    gather не отменяет вторую задачу, он просто отдаёт вам первое исключение, а weights продолжает работать. это вы и наблюдаете. в доках об этом довольно расплывчато, бесит.

    так что отмените m и w сами в блоке except и дождитесь их ещё раз. в go errgroup делает это за вас, в python приходится руками.

    Переведено с английскогоПоказать оригинал
  4. Ссылка на ответ

    Коротко: gather со значением по умолчанию return_exceptions=False только пробрасывает первое исключение. Соседние задачи он не отменяет. Отмена происходит только если отменён сам future от gather, а здесь этого никто не делает. Так что weights выполняется до конца, как вы и увидели.

    Что именно показывает "in flight"? Если это идёт из asyncio.all_tasks(), то измеряется то, что нужно. Если из вашего собственного учёта, я бы сначала проверил его.

    TaskGroup появился в 3.12, насколько я помню, так что на 3.9 ручная отмена необходима. После отмены дождитесь задач, потом проверьте файл...

    Переведено с английскогоПоказать оригинал
  5. Ссылка на ответ

    Да, синхронная, да? В fetch_weights это обычный open() плюс json.dump после последнего await, так что внутри самой записи await нет. Для отмены это важно. Она может сработать только в точке await, так что если weights ещё ждёт сеть, запись не начнётся, а если уже прошёл эту точку, запись завершится целиком. В любом случае я удалю файл перед запуском и проверю после.

    Переведено с английскогоПоказать оригинал
  6. Ссылка на ответ

    weights — это по сути свободно бегающая задача, которую никто не останавливает

    Задача на вольном выпасе — самоотдача, какой я ни у кого из тех, с кем работал, не видел. И файл тоже записала.

    Переведено с английскогоПоказать оригинал
  7. Ссылка на ответ

    Хорошее замечание, падение посреди json.dump оставит половину файла, и тогда следующий запуск прочитает его как настоящий кэш, так? Значит, сделаю по шагам:

    1. Пишу в weights_cache.json.tmp в той же директории, так как os.replace атомарен только в пределах одной файловой системы.
    2. Делаю flush и os.fsync для файла перед закрытием, если кэш действительно важен.
    3. os.replace("weights_cache.json.tmp", "weights_cache.json").

    Это ещё и помогает с проверкой отмены, потому что итоговое имя существует, только если запись завершилась целиком. Так что после отмены я могу просто проверить, что weights_cache.json нет, а оставшийся .tmp скажет мне, что запись началась и не дошла до переименования.

    Раз между open и replace по-прежнему нет await, отмена всё равно не может попасть в середину, так что это в основном на случай падений и убитых процессов.

    Переведено с английскогоПоказать оригинал
  8. Ссылка на ответ

    Полусекундный sleep в main.py нужен только для того, чтобы последняя проверка шла после записи.

    Sleep, который показывает баг, наверное, прячет второй. Проходит ли fetch_weights где-нибудь через run_in_executor или to_thread, например для HTTP-вызова или записи файла? Отмена задачи не остановит уже запущенный поток, так что файл всё равно может появиться после вашей отмены. Стоит это знать, прежде чем доверять исправлению.

    Переведено с английскогоПоказать оригинал
  9. Ссылка на ответ

    Проходит ли fetch_weights где-нибудь через run_in_executor или to_thread, например для HTTP-вызова или записи файла?

    Запись нет, это обычные open() и json.dump прямо в корутине, как я говорил выше. Что делает клиентская библиотека под капотом для HTTP-вызова, я не на 100% уверен, так что сначала погреп��ю оба имени. Потом оставлю sleep на один запуск после исправления с отменой, да? Если файл всё равно появится, значит, это поток.

    Переведено с английскогоПоказать оригинал

Другие стороны того же вопроса

  1. Контекст отменён, но горутины-воркеры живы после первой ошибки (всегда workers + 2)

    Всем привет, Пишу небольшой CLI на Go, shotcheck, который проверяет каждый PNG в папке на точное соответствие размеру, которы…

    Ответы 4Последняя активность go
  2. Пометка фильтра как async не помогла с фризом

    ledgerscan, поиск по 400k записей. сделал run async и вызываю через await из main, думал, что 100 мс heartbeat Timer продолжи…

    Ответы 2Последняя активность dart
  3. await for по ReceivePort никогда не завершается после того, как изоляты закончили

    всем привет, у меня небольшая тулза на Dart, она делает сводку по CSV с температурами, и я запускаю по одному изоляту на файл…

    Ответы 2Последняя активность dart