Структурная конкурентность: области supervised, сроки, отмена

Область supervised { } владеет каждым файбером, порождённым внутри неё: область не завершится, пока все дети не закончили работу или не были отменены, а spawn легален только внутри такой области (D50). Эта страница — про управление временем жизни области: сроки (timeout: / deadline:) и кооперативную отмену (cancel:) — и про одно правило размещения, в котором ошибаются с первой попытки.

Про каналы и selectchannels. Про освобождение ресурсов на выходе из области — cleanup cookbook.

Сроки: timeout: / deadline:

Область может нести собственный срок — относительный (timeout: принимает Duration) или абсолютный (deadline: принимает момент Monotonic):

supervised(timeout: 5.to_seconds()) {
    spawn { work() }
}

Когда срок истекает, все дети отменяются, и область падает с типизированной TimeoutError. Это школа «срок на области» (Kotlin/Swift/Trio), а не Go/Rust-школа «дедлайн на дескрипторе»: срок привязан к участку программы, а не к каждому дескрипторному вводу-выводу внутри него.

Где ставится обработчик — снаружи области

Срок принадлежит области, поэтому его истечение — событие выхода из неё: к моменту, когда летит TimeoutError, область — вместе с любым обработчиком, установленным внутри, — уже свёрнута. Обработчик ставится вокруг области:

// ✅ WORKING form: handler OUTSIDE supervised(timeout:)
mut timed_out = AtomicBool.new(false)
ro r = with Fail[TimeoutError] = |_e| {
    timed_out.store(true)
    0
} {
    supervised(timeout: 50.to_millis()) {
        spawn { 5000.to_millis().sleep() }
    }
    5                       // reached only when the scope finished in time
}

Естественно выглядящая обратная форма — обработчик внутри области — компилируется, но не ловит ничего:

// ❌ NON-WORKING form: handler INSIDE the scope it is supposed to guard.
// Compiles, but on expiry the program dies with:
//   nova: unhandled Fail: supervised-timeout: scope deadline exceeded
supervised(timeout: 50.to_millis()) {
    with Fail[TimeoutError] = |_e| { println("never reached") } {
        spawn { 5000.to_millis().sleep() }
    }
}

Оба фрагмента проверены на реальном компиляторе; второй завершается кодом 127 с показанным сообщением. Если запасное значение не нужно, можно не ставить обработчик вовсе и дать TimeoutError пролететь к вашему вызывающему.

(Флаг mut timed_out — сознательно AtomicBool: обработчик with Fail выполняется в файбере падающей операции, а не в файбере установки — голый mut-флаг был бы гонкой данных под M:N, D441.)

Кооперативная отмена: cancel:

Область можно завершить раньше и мимо механизма сроков — через CancelToken:

ro tok = CancelToken.new()
supervised(cancel: tok) {
    spawn { 10.to_millis().sleep(); tok.cancel() }
    spawn { 5000.to_millis().sleep() }
}
assert(tok.is_cancelled())      // distinguish the outcome after the scope

В отличие от срока, отмена не бросает ничего — ловить нечем, и это сознательно: tok.cancel() — это штатное раннее завершение, а не отказ. Область просто сворачивается раньше, и управление идёт на следующую строку. Чтобы узнать, как завершилась область, спросите токен: tok.is_cancelled().

cancel: и timeout: сочетаются — побеждает более раннее из двух. Если первым сработал токен, TimeoutError не поднимается; если срок — поднимается.

Прямые блокирующие операции в теле области (починено 2026-08-07, два честных остатка). cancel: / timeout: / deadline: теперь корректно будят прямой Time.sleep в теле самой области — область сворачивается в срок. Два узких хвоста открыты и отслеживаются: (1) прерванная прямая операция возвращается без броска, поэтому операторы между ней и концом блока могут успеть выполниться до выхода из области — внешний наблюдатель видит корректное время, но не кладите туда код, который не должен бежать после отмены; (2) прямой Channel.recv() в теле под cancel: всё ещё виснет. Обе беды обходятся одной структурой: отменяемая работа — spawn-детьми, как во фрагменте выше.

Сетевые чтения: всегда под сроком

Каждое сетевое чтение в программе должно жить под supervised(timeout:):

fn fetch_head(addr str) Net Time -> Option[str] {
    with Fail[TimeoutError] = |_e| { None } {
        mut out = ""
        supervised(timeout: 5.to_seconds()) {
            consume conn = TcpStream.connect(addr)!!
            out = conn.read_text(1024)!!
            conn.close()
        }
        Some(out)
    }
}

Это не вопрос стиля. Обыденный сценарий «сервер отдал часть ответа и закрыл соединение» иначе оставляет голый read() висеть навсегда — и есть известный открытый дефект: второе чтение после частично полученных данных не будится даже сроком области (в реестре проекта; корень — в слое libuv на Windows, осознанно отложено до ближайшего тега). Срок области надёжно прерывает первое чтение — ровно это и защищает шаблон выше. Пока дефект не закрыт, не стройте протокольные циклы с повторными чтениями из потока, чей пир может полузакрыться посреди ответа; предпочитайте одиночные ограниченные чтения на область, как выше.

См. также

  • channelsselect, паттерн «таймаут как ветка», ChanReader.close_after
  • cleanup cookbook — exit-таймауты consume{} при сворачивании области
  • std/src/concurrency/supervised_deadline_test.nv — авторитетные исполняемые примеры всех сочетаний timeout:/deadline:/cancel:
  • Спека: D50 (структурные области), D349 (сроки), D441 (семантика файбера обработчика), spec/decisions/06-concurrency.md