12.24 Обработчики эффектов
- 12.24.1 Основы
- 12.24.2 Конкурентность
- 12.24.3 Потоки пользовательского уровня
- 12.24.4 Обращение контроля
- 12.24.5 Семантика
- 12.24.6 Обработчики поверхностного уровня
(Введено в версии 5.0)
Примечание: Обработчики эффектов в OCaml 5.0 следует считать экспериментальными. Обработчики эффектов представлены в стандартной библиотеке как тонкий обертка вокруг их реализации в среде выполнения. Они не поддерживаются как языковая возможность с новым синтаксисом. Вы можете полагаться на них для построения абстракций управления потоком, таких как потоки пользовательского уровня, которые не предоставляют пользователю примитивы обработчика эффектов. Ожидайте в будущем внесения изменений, которые могут повлиять на работоспособность.
Обработчики эффектов — это механизм модульного программирования с пользовательскими эффектами. Обработчики эффектов позволяют программистам описывать вычисления, которые выполняют эффективные операции, смысл которых описывается обработчиками, которые заключают вычисления. Обработчики эффектов являются обобщением обработчиков исключений и позволяют составно выражать механизмы нелокального управления потоком, такие как возобновляемые исключения, лёгкие потоки, сопрограммы, генераторы и асинхронное ввод-вывод. В этом руководстве мы рассмотрим, как некоторые из этих механизмов можно построить с помощью обработчиков эффектов.
12.24.1 Основы
Для понимания основ определим эффект (то есть операцию), который принимает целочисленное значение в качестве аргумента и возвращает целочисленное значение. Назовём этот эффект Xchg.
open Effect open Effect.Deep type _ Effect.t += Xchg: int -> int t let comp1 () = perform (Xchg 0) + perform (Xchg 1)
Мы объявляем эффект обмена Xchg, расширяя предопределённый расширяемый вариант типа Effect.t с новым конструктором Xchg: int -> int t. Объявление можно интуитивно прочитать как «эффект Xchg принимает целочисленный параметр, и при выполнении этого эффекта возвращается целое число». Вычисление comp1 дважды выполняет эффект с использованием примитива perform и возвращает сумму результатов.
Мы можем обработать эффект Xchg, реализовав обработчик, который всегда возвращает преемника предложенного значения:
try_with comp1 ()
{ effc = fun (type a) (eff: a t) ->
match eff with
| Xchg n -> Some (fun (k: (a, _) continuation) ->
continue k (n+1))
| _ -> None }
- : int = 3 try_with выполняет вычисление comp1 () в рамках обработчика эффектов, который обрабатывает эффект Xchg. Как упоминалось ранее, обработчики эффектов являются обобщением обработчиков исключений. Подобно обработчикам исключений, при выполнении вычислением эффекта Xchg управление переходит к соответствующему обработчику. Однако, в отличие от обработчиков исключений, обработчик также получает ограниченное продолжение k, которое представляет приостановленное вычисление между моментом вызова perform и этим обработчиком.
Обработчик использует примитив continue для возобновления приостановленного вычисления с преемником предложенного значения. В этом примере вычисление comp1 выполняет Xchg 0 и Xchg 1 и получает значения 1 и 2 соответственно от обработчика. Таким образом, всё выражение вычисляется до 3.
Полезно заметить, что мы должны использовать локально абстрактный тип (type a) в обработчике эффекта. Тип Effect.t является GADT, и объявления эффектов могут иметь различные параметры типов для различных эффектов. Параметр типа a в типе a Effect.t представляет тип значения, возвращаемого при выполнении эффекта. Из того факта, что eff имеет тип a Effect.t, и из того факта, что Xchg n имеет тип int Effect.t, тип-чекер выводит, что a должно быть int, поэтому мы можем передать целочисленное значение n+1 как аргумент в continue k.
Ещё один момент, который следует отметить, состоит в том, что случай «| _ -> None» необходим при обработке эффектов. Этот случай можно интуитивно прочитать как «передать необработанные эффекты внешнему обработчику».
В этом примере мы используем глубокую версию обработчиков эффектов по сравнению с поверхностной версией. Глубокий обработчик отслеживает вычисление до тех пор, пока вычисление не завершится (обычно или посредством исключения), и обрабатывает все эффекты, выполненные (последовательно) вычислением. В отличие от этого, обработчик поверхностного уровня отслеживает вычисление до тех пор, пока вычисление не завершится или не выполнит один эффект, и он обрабатывает только этот единственный эффект. В ситуациях, где они применимы, обычно предпочтительнее использовать глубокие обработчики. Пример, использующий обработчики поверхностного уровня, обсуждается позже в 12.24.6.
12.24.2 Конкурентность
Выразительные возможности обработчиков эффектов обусловлены ограниченным продолжением. Хотя в предыдущем примере вычисление сразу возобновлялось, вычисление может быть возобновлено позднее, выполняя другое вычисление между этими моментами. Давайте расширим предыдущий пример и реализуем конкурентность передачи сообщений между двумя конкурирующими вычислениями, используя эффект Xchg. Мы называем эти конкурирующие вычисления задачами.
Задача находится либо в приостановленном состоянии, либо завершена. Мы представляем статус задачи следующим образом:
type 'a status =
Complete of 'a
| Suspended of {msg: int; cont: (int, 'a status) continuation}
Задача либо завершена, с результатом типа 'a, или приостановлена с сообщением msg для отправки и продолжением cont. Тип (int,'a status) continuation означает, что приостановленное вычисление ожидает целочисленного значения для возобновления и возвращает значение типа 'a status при возобновлении.
Далее, мы определяем функцию step, которая выполняет один шаг вычисления до тех пор, пока оно не завершится или не приостановится:
let step (f : unit -> 'a) () : 'a status =
match_with f ()
{ retc = (fun v -> Complete v);
exnc = raise;
effc = fun (type a) (eff: a t) ->
match eff with
| Xchg msg -> Some (fun (cont: (a, _) continuation) ->
Suspended {msg; cont})
| _ -> None }
Аргумент функции step, f, — это вычисление, которое может выполнить эффект Xchg и вернуть результат типа 'a. Сама функция step возвращает значение типа 'a status.
В функции step мы используем примитив match_with. Как и try_with, примитив match_with устанавливает обработчик эффекта. Однако, в отличие от try_with, где предоставляется только случай эффекта effc, match_with ожидает обработчиков для случаев возврата значения (retc) и исключения (exnc). На самом деле, try_with можно определить с помощью match_with следующим образом: let try_with f v {effc} = match_with f v {retc = Fun.id; exnc = raise; effc}.
В функции step,
- Случай retc: Если вычисление возвращает значение v, мы возвращаем Complete v.
- Случай exnc: Если вычисление вызывает исключение, то обработчик вызывает то же самое исключение.
- Случай effc: Если вычисление выполняет эффект Xchg msg с продолжением cont, то мы возвращаем Suspended{msg;cont}. Таким образом, в этом случае продолжение cont не вызывается обработчиком сразу; вместо этого оно сохраняется в структуре данных для использования в дальнейшем.
Поскольку функция step обрабатывает эффект Xchg, step f представляет собой вычисление, которое не выполняет эффект Xchg. Однако оно может выполнять другие эффекты. Кроме того, поскольку мы используем глубокие обработчики, продолжение cont, хранящееся в состоянии, не выполняет эффект Xchg.
Теперь мы можем написать простой планировщик, который выполняет пару задач до завершения:
let rec run_both a b =
match a (), b () with
| Complete va, Complete vb -> (va, vb)
| Suspended {msg = m1; cont = k1},
Suspended {msg = m2; cont = k2} ->
run_both (fun () -> continue k1 m2)
(fun () -> continue k2 m1)
| _ -> failwith "Improper synchronization"
Обе задачи могут быть выполнены до завершения, или обе могут предложить обмен сообщением. В последнем случае каждое вычисление получает значение, предложенное другим вычислением. Ситуация, когда одно вычисление предлагает обмен, а другое вычисление завершается, считается ошибкой программиста и приводит к тому, что обработчик генерирует исключение.
Теперь мы можем определить второе вычисление, которое также обменивается двумя сообщениями:
let comp2 () = perform (Xchg 21) * perform (Xchg 21)
Наконец, мы можем запустить две вычисления вместе:
run_both (step comp1) (step comp2) - : int * int = (42, 0)
Вычисление comp1 предлагает значения 0 и 1, а в обмен получает значения 21 и 21, которые оно складывает, получая 42. Вычисление comp2 предлагает значения 21 и 21, а в обмен получает значения 0 и 1, которые оно умножает, получая 0. Взаимодействие между двумя вычислениями полностью запрограммировано внутри run_both. Действительно, определения comp1 и comp2 сами по себе не придают никакого смысла эффекту Xchg.
12.24.3 Потоки пользовательского уровня
Давайте расширим предыдущий пример для произвольного числа задач. Многие языки, такие как GHC Haskell и Go, предоставляют потоки пользовательского уровня как примитивную функцию, реализованную в системе выполнения. С помощью обработчиков эффектов потоки пользовательского уровня и их планировщики могут быть реализованы в самом OCaml. Обычно системы многопоточности пользовательского уровня предоставляют примитив fork для создания новой конкуретнтной задачи и примитив yield для передачи управления другой задаче. Соответственно, мы объявим два эффекта следующим образом:
type _ Effect.t += Fork : (unit -> unit) -> unit t
| Yield : unit t
Эффект Fork принимает замыкание (приостановленное вычисление, представленное как функция типа unit -> unit) и возвращает единицу исполнителю. Эффект Yield не параметризован и возвращает единицу при выполнении. Рассмотрим, что задача, выполняющая эффект Xchg, может совпасть с любой другой задачей, также предлагающей обмен значением.
Мы также определим вспомогательные функции, которые просто выполняют эти эффекты:
let fork f = perform (Fork f) let yield () = perform Yield let xchg v = perform (Xchg v)
Функция верхнего уровня run определяет планировщик:
(* A concurrent round-robin scheduler *)
let run (main : unit -> unit) : unit =
let exchanger = ref None in (* waiting exchanger *)
let run_q = Queue.create () in (* scheduler queue *)
let enqueue k v =
let task () = continue k v in
Queue.push task run_q
in
let dequeue () =
if Queue.is_empty run_q then () (* done *)
else begin
let task = Queue.pop run_q in
task ()
end
in
let rec spawn (f : unit -> unit) : unit =
match_with f () {
retc = dequeue;
exnc = (fun e ->
print_endline (Printexc.to_string e);
dequeue ());
effc = fun (type a) (eff : a t) ->
match eff with
| Yield -> Some (fun (k : (a, unit) continuation) ->
enqueue k (); dequeue ())
| Fork f -> Some (fun (k : (a, unit) continuation) ->
enqueue k (); spawn f)
| Xchg n -> Some (fun (k : (int, unit) continuation) ->
begin match !exchanger with
| Some (n', k') ->
exchanger := None; enqueue k' n; continue k n'
| None -> exchanger := Some (n, k); dequeue ()
end)
| _ -> None
}
in
spawn main
Мы используем изменяемую очередь run_q для хранения очереди планировщика. Очередь FIFO позволяет круговой планировке задач в планировщике. enqueue вставляет задачи в очередь, а dequeue извлекает задачи из очереди и выполняет их. Ячейка ссылок exchanger содержит (приостановленную) задачу, предлагающую обмен значением. В любое время существует либо ноль, либо одна приостановленная задача, предлагающая обмен.
Основная работа выполняется функцией spawn. Функция spawn выполняет данное вычисление f в обработчике эффектов. Если f возвращает значение (случай retc), мы извлекаем и выполняем следующую задачу из очереди планировщика. Если вычисление f вызывает исключение (случай exnc), мы выводим исключение и выполняем следующую задачу из очереди планировщика.
Вычисление f также может выполнять эффекты. Если f выполняет эффект Yield, текущая задача приостанавливается (вставляется в очередь готовых задач), и выполняется следующая задача из очереди планировщика. Если эффект равен Fork f, тогда текущая задача приостанавливается, а новая задача f выполняется немедленно через вызов по хвосту spawn f. Обратите внимание, что этот выбор для выполнения новой задачи сначала является произвольным. Мы вполне могли бы вместо этого поместить задачу для f в очередь готовности и возобновить k немедленно.
Если эффект равен Xchg, то мы сначала проверяем, есть ли задача, ожидающая обмена. Если есть, мы помещаем ожидающую задачу в очередь со значением, которое предлагается, и немедленно возобновляем текущую задачу со значением, которое предлагается. Если нет, мы делаем текущую задачу ожидающим обменом и выполняем следующую задачу из очереди планировщика.
Обратите внимание, что этот код планировщика не идеален — он может утечь ресурсы. Мы объясним и исправим это в следующем разделе 12.24.3.
Теперь мы можем написать конкурирующую программу, которая использует недавно определенные операции:
open Printf
let _ = run (fun _ ->
fork (fun _ ->
printf "[t1] Sending 0\n";
let v = xchg 0 in
printf "[t1] received %d\n" v);
fork (fun _ ->
printf "[t2] Sending 1\n";
let v = xchg 1 in
printf "[t2] received %d\n" v))
[t1] Sending 0
[t2] Sending 1
[t2] received 0
[t1] received 1 Обратите внимание, что сообщения от двух задач чередуются. Также обратите внимание, что фрагмент кода выше не ссылается на обработчики эффектов и написан в прямом стиле (без монадных операций). Этот пример иллюстрирует, что с обработчиками эффектов пользовательский код в конкурирующей программе может оставаться в простом прямом стиле, а использование обработчиков эффектов может быть полностью заключено в реализации библиотеки параллелизма.
Возобновление с исключением
Помимо возобновления продолжения со значением, обработчики эффектов также позволяют возобновление путём возбуждения эффекта в точке выполнения. Это делается с помощью примитива discontinue. Примитив discontinue помогает гарантировать, что ресурсы всегда в конечном итоге будут освобождены, даже в присутствии эффектов.
Например, рассмотрим операцию dequeue в предыдущем примере, воспроизведенном ниже:
… let dequeue () = if Queue.is_empty run_q then () (* done *) else (Queue.pop run_q) ()
Если очередь планировщика пуста, dequeue считает, что планировщик закончил работу, и возвращается к вызывающей стороне. Однако может быть задача, ожидающая обмена значением (хранящаяся в ячейке ссылок exchanger), которая остается заблокированной вечно! Если заблокированная задача удерживает ресурсы, эти ресурсы утекают. Например, рассмотрим следующую задачу:
let leaky_task () =
fork (fun _ ->
let oc = open_out "secret.txt" in
Fun.protect ~finally:(fun _ -> close_out oc) (fun _ ->
output_value oc (xchg 0)))
Задача записывает полученное сообщение в файл secret.txt. Она использует Fun.protect, чтобы гарантировать, что выходной канал oc закрывается как при нормальном, так и при исключительном возврате. К сожалению, этого недостаточно. Если эффект обмена xchg 0 не может быть сопоставлен с эффектом обмена, выполненным другим потоком, то эта задача остается заблокированной вечно. Таким образом, выходной канал oc никогда не закрывается.
Чтобы избежать этой проблемы, необходимо соблюдать простое правило: каждое продолжение должно быть в конечном итоге либо продолжено, либо прервано. Здесь мы используем discontinue, чтобы гарантировать, что заблокированная задача не останется заблокированной вечно. Прервав эту задачу, мы вынуждаем её завершиться (с исключением):
exception Improper_synchronization
let dequeue () =
if Queue.is_empty run_q then begin
match !exchanger with
| None -> () (* done *)
| Some (n, k) ->
exchanger := None;
discontinue k Improper_synchronization
end else (Queue.pop run_q) ()
Когда очередь планировщика пуста и есть заблокированный поток обмена, функция dequeue прерывает заблокированный поток с исключением Improper_synchronization. Это исключение генерируется в вызове заблокированной функции xchg, что приводит к выполнению блока finally и закрытию выходного канала oc. С точки зрения пользователя, кажется, что функция xchg 0 генерирует исключение Improper_synchronization.
12.24.4 Инверсия управления
Когда дело доходит до выполнения обходов структуры данных, есть два основных способа в зависимости от того, у производителя или потребителя есть контроль над обходом. Например, в List.iter f l производитель List.iter имеет контроль и передаёт элемент потребителю f, который их обрабатывает. С другой стороны, модуль Seq предоставляет механизм, аналогичный отложенным спискам, где потребитель контролирует обход. Например, Seq.forever Random.bool возвращает бесконечную последовательность случайных битов, где каждый бит производится (по требованию) при запросе потребителя.
Естественно, производители, такие как List.iter, легче писать в первом стиле. Второй стиль более удобен для потребителя, поскольку предпочтительнее и естественнее иметь контроль. Чтобы получить лучшее из обоих миров, мы хотели бы написать производителя в первом стиле и автоматически преобразовать его во второй стиль. Преобразование можно написать один раз и навсегда в качестве функции библиотеки, благодаря обработчикам эффектов. Давайте назовём эту функцию invert. Сначала мы рассмотрим, как использовать функцию invert, а затем перейдём к её реализации.
val invert : iter:(('a -> unit) -> unit) -> 'a Seq.t
Функция invert принимает функцию iter (производитель, который передаёт элементы потребителю) и возвращает последовательность (где потребитель имеет контроль). Например,
let lst_iter = Fun.flip List.iter [1;2;3] val lst_iter : (int -> unit) -> unit =
является функцией iter с типом (int -> unit) -> unit. Выражение lst_iter f передаёт элементы 1, 2 и 3 потребителю f. Например,
lst_iter (fun i -> Printf.printf "%d\n" i) 1 2 3 - : unit = ()
Выражение invert lst_iter возвращает последовательность, которая позволяет потребителю обходить список по требованию. Например,
let s = invert ~iter:lst_iter let next = Seq.to_dispenser s;; val s : int Seq.t =val next : unit -> int option =
next();; - : int option = Some 1
next();; - : int option = Some 2
next();; - : int option = Some 3
next();; - : int option = None
Мы можем использовать ту же функцию invert для любой функции iter. Например,
let s = invert ~iter:(Fun.flip String.iter "OCaml") let next = Seq.to_dispenser s;; val s : char Seq.t =val next : unit -> char option =
next();; - : char option = Some 'O'
next();; - : char option = Some 'C'
next();; - : char option = Some 'a'
next();; - : char option = Some 'm'
next();; - : char option = Some 'l'
next();; - : char option = None
Реализация инверсии управления
Ниже приведена реализация функции invert:
let invert (type a) ~(iter : (a -> unit) -> unit) : a Seq.t =
let module M = struct
type _ Effect.t += Yield : a -> unit t
end in
let yield v = perform (M.Yield v) in
fun () -> match_with iter yield
{ retc = (fun _ -> Seq.Nil);
exnc = raise;
effc = fun (type b) (eff : b Effect.t) ->
match eff with
| M.Yield v -> Some (fun (k: (b,_) continuation) ->
Seq.Cons (v, continue k))
| _ -> None }
Функция invert объявляет эффект Yield, который принимает параметр — элемент, который нужно сгенерировать. Функция yield выполняет эффект Yield. Лямбда-абстракция fun () -> ... откладывает все действия до первого запроса элемента из последовательности. После этого вычисление iter yield выполняется под обработчиком эффектов. Каждый раз, когда функция iter передаёт элемент функции yield, вычисление прерывается эффектом Yield. Эффект Yield обрабатывается путём возвращения значения Seq.Cons(v,continue k) потребителю. Потребитель получает элемент v, а также приостановленное вычисление, которое с точки зрения потребителя является просто хвостом последовательности.
Когда потребитель запрашивает следующий элемент из последовательности (применив его к ()), возобновляется продолжение k. Это позволяет вычислению iter yield идти вперёд, пока оно не сгенерирует следующий элемент или не завершится нормально. В последнем случае возвращается значение Seq.Nil, указывая потребителю, что итерация завершена.
Важно отметить, что последовательность, возвращаемая функцией invert, является эфемерной (как определено модулем Seq), т.е., последовательность должна использоваться не более одного раза. Кроме того, последовательность должна быть полностью израсходована (т.е. использована по крайней мере один раз), чтобы гарантировать, что захваченное продолжение используется линейно.
12.24.5 Семантика
В этом разделе мы рассмотрим семантику обработчиков эффектов с помощью примеров.
Вложенные обработчики
Как и обработчики исключений, обработчики эффектов могут быть вложенными.
type _ Effect.t += E : int t
| F : string t
let foo () = perform F
let bar () =
try_with foo ()
{ effc = fun (type a) (eff: a t) ->
match eff with
| E -> Some (fun (k: (a,_) continuation) ->
failwith "impossible")
| _ -> None }
let baz () =
try_with bar ()
{ effc = fun (type a) (eff: a t) ->
match eff with
| F -> Some (fun (k: (a,_) continuation) ->
continue k "Hello, world!")
| _ -> None }
В этом примере вычисление foo выполняет F, внутренний обработчик обрабатывает только E, а внешний обработчик обрабатывает F. Вызов baz возвращает Hello, world!.
baz () - : string = "Hello, world!"
Волокна
Полезно немного узнать о реализации обработчиков эффектов, чтобы понять принятые решения и их характеристики производительности. Обработчики эффектов реализуются с помощью управляемых во время выполнения, динамически увеличивающихся участков стека, называемых волокнами. Стек программы в OCaml является связанным списком таких волокон.
Для оценки вычисления, заключённого в обработчике эффектов, выделяется новое волокно. Волокно освобождается, когда вычисление возвращается вызывающему элементу, либо нормально, вернув значение, либо сгенерировав исключение.
В момент perform в foo в предыдущем примере стек программы выглядит так:
+-----+ +-----+ +-----+ | | | | | | | baz |<--| bar |<--| foo | | | | | | | | | | | | | +-----+ +-----+ +-----+ <- stack_pointer
Две ссылки соответствуют двум обработчикам эффектов в программе. Когда эффект F обрабатывается в baz, состояние программы выглядит следующим образом:
+-----+ +-----+ +-----+ | | | | | | +-+ | baz | | bar |<--| foo |<--|k| | | | | | | +-+ +-----+ <- stack_pointer +-----+ +-----+
Ограниченное продолжение k — это объект в куче, который ссылается на участок стека, соответствующий приостановленному вычислению. Получение продолжения не предполагает копирования кадров стека. При возобновлении продолжения стек восстанавливается в предыдущее состояние путём связывания участка стека, на который указывает k, с текущим стеком. Поскольку ни получение, ни возобновление продолжения не требуют копирования кадров стека, приостановка выполнения с помощью perform и возобновление с помощью continue или discontinue являются быстрыми операциями.
Необработанные эффекты
В отличие от таких языков, как Eff и Koka, обработчики эффектов в OCaml не обеспечивают безопасность эффектов; компилятор не гарантирует статически, что все эффекты, выполняемые программой, обрабатываются. Если эффекты не имеют соответствующего обработчика, то исключение Effect.Unhandled генерируется в момент соответствующего perform. Например, в предыдущем примере bar не обрабатывает эффект F. Следовательно, при выполнении bar будет сгенерировано исключение Effect.Unhandled F.
try bar () with Effect.Unhandled F -> "Saw Effect.Unhandled exception" - : string = "Saw Effect.Unhandled exception"
Линейные продолжения
Как обсуждалось ранее 12.24.3, ограниченные продолжения в OCaml должны использоваться линейно — каждое захваченное продолжение должно быть возобновлено либо с помощью continue, либо discontinue ровно один раз. Попытка использовать продолжение более одного раза вызывает исключение Continuation_already_resumed. Например:
try_with perform (Xchg 0)
{ effc = fun (type a) (eff : a t) ->
match eff with
| Xchg n -> Some (fun (k: (a, _) continuation) ->
continue k 21 + continue k 21)
| _ -> None }
Exception: Stdlib.Effect.Continuation_already_resumed. Основной мотивацией добавления обработчиков эффектов в OCaml является возможность конкуретного программирования. Одноразовые продолжения достаточны для практически всех задач конкуретного программирования. Они также значительно дешевле в реализации по сравнению с многоразовыми продолжениями, так как не требуют копирования стековых кадров. Более того, программы OCaml также могут манипулировать линейными ресурсами, такими как сокеты и дескрипторы файлов. Дисциплина линейности легко нарушается, если продолжениям разрешено возобновляться более одного раза. Было бы довольно сложно отладить такие нарушения линейности ресурсов из-за отсутствия статических проверок линейности и нелокального характера потока управления. Поэтому OCaml не поддерживает многоразовые продолжения.
Хотя свойство «возобновление не более одного раза» продолжений обеспечивается динамической проверкой, нет проверки, гарантирующей, что продолжения возобновляются «хотя бы один раз». Пользователь должен убедиться, что захваченные продолжения возобновляются по крайней мере один раз. Невозобновление продолжений приведет к утечке памяти, выделенной для волокна, а также любых ресурсов, которые может удерживать приостановленное вычисление.
Можно установить финализатор на захваченное продолжение, чтобы гарантировать освобождение ресурсов:
exception Unwind Gc.finalise (fun k -> try ignore (discontinue k Unwind) with _ -> ()) k
В этом случае, если k станет недоступным, финализатор гарантирует, что стек продолжений будет размотан, прервав вычисление исключением Unwind, позволяя вычислению освободить ресурсы. Однако временные затраты финализаторов намного выше, чем стоимость захвата продолжения. Поэтому рекомендуется, чтобы пользователь позаботился о возобновлении продолжения ровно один раз, а не полагался на финализатор.
12.24.6 Обработчики поверхностного уровня
В рассмотренных до сих пор примерах использовались глубокие обработчики. Глубокий обработчик обрабатывает все эффекты, выполняемые (последовательно) вычислением. Всякий раз, когда продолжение захватывается в глубоком обработчике, захваченное продолжение также включает обработчик. Это означает, что при возобновлении продолжения обработчик эффектов автоматически переустанавливается и будет обрабатывать эффекты, которые вычисление может выполнить в будущем.
OCaml также предоставляет поверхностные обработчики. По сравнению с глубокими обработчиками поверхностный обработчик обрабатывает только первый эффект, выполненный вычислением. Продолжение, захваченное поверхностным обработчиком, не включает обработчик. Это означает, что при возобновлении продолжения обработчик больше не присутствует. По этой причине при возобновлении продолжения пользователь должен предоставить новый обработчик эффектов (возможно, другой) для обработки следующего эффекта, который может выполнить вычисление.
Поверхностные обработчики облегчают выражение определенных типов программ. Давайте реализуем поверхностный обработчик, который обеспечивает определенную последовательность эффектов (протокол) вычисления. Для этого примера предположим, что вычисление может выполнить следующие эффекты:
type _ Effect.t += Send : int -> unit Effect.t
| Recv : int Effect.t
Предположим, что мы хотим обеспечить протокол, допускающий только чередующуюся последовательность эффектов Send и Recv, соответствующую регулярному выражению (Send;Recv)*;Send?. Таким образом, допускаются последовательности эффектов [] (пустая последовательность), [Send], [Send;Recv], [Send;Recv;Send] и т.д., но не [Recv], [Send;Send], [Send;Recv;Recv] и т.д. Ключевое наблюдение состоит в том, что набор обрабатываемых эффектов со временем изменяется. Мы можем довольно естественно обеспечить этот протокол, используя поверхностные обработчики, как показано ниже:
open Effect.Shallow
let run (comp: unit -> unit) : unit =
let rec loop_send : type a. (a,unit) continuation -> a -> unit = fun k v ->
continue_with k v
{ retc = Fun.id;
exnc = raise;
effc = fun (type b) (eff : b Effect.t) ->
match eff with
| Send n -> Some (fun (k: (b,_) continuation) ->
loop_recv n k ())
| Recv -> failwith "protocol violation"
| _ -> None }
and loop_recv : type a. int -> (a,unit) continuation -> a -> unit = fun n k v ->
continue_with k v
{ retc = Fun.id;
exnc = raise;
effc = fun (type b) (eff : b Effect.t) ->
match eff with
| Recv -> Some (fun (k: (b,_) continuation) ->
loop_send k n)
| Send v -> failwith "protocol violation"
| _ -> None }
in
loop_send (fiber comp) ()
Функция run выполняет вычисление comp, гарантируя, что оно может выполнить только чередующуюся последовательность эффектов Send и Recv. Поверхностный обработчик использует различные базовые элементы по сравнению с глубоким обработчиком. Базовый элемент fiber (в последней строке) принимает функцию 'a -> 'b и возвращает ('a,'b) Effects.Shallow.continuation. Выражение continue_with k v h возобновляет продолжение k со значением v в рамках обработчика h.
Взаимно рекурсивные функции loop_send и loop_recv возобновляют заданное продолжение k со значением v с разными обработчиками. Функция loop_send обрабатывает эффект Send и выполняет хвостовую рекурсию функции loop_recv. Если вычисление выполняет эффект Recv, то loop_send прерывает вычисление, сгенерировав исключение. Аналогично, функция loop_recv обрабатывает эффект Recv и выполняет хвостовую рекурсию функции loop_send. Если вычисление выполняет эффект Send, то loop_recv прерывает вычисление. Учитывая, что продолжение, захваченное в поверхностном обработчике, не включает обработчик, в динамической области видимости вычисления comp всегда установлен только один обработчик.
Вычисление изначально выполняется функцией loop_send (см. последнюю строку в приведенном выше коде), которая гарантирует, что первым эффектом, который может выполнить вычисление, является эффект Send. Обратите внимание, что вычисление свободно выполняет эффекты, отличные от Send и Recv, которые могут обрабатываться внешним обработчиком.
Можно видеть, что функция run допустит вычисление, которое соответствует протоколу:
run (fun () -> printf "Send 42\n"; perform (Send 42); printf "Recv: %d\n" (perform Recv); printf "Send 43\n"; perform (Send 43); printf "Recv: %d\n" (perform Recv)) Send 42 Recv: 42 Send 43 Recv: 43 - : unit = ()
и прервет те, которые не соответствуют:
run (fun () -> Printf.printf "Send 0\n"; perform (Send 0); Printf.printf "Send 1\n"; perform (Send 1) (* protocol violation *)) Send 0 Send 1 Exception: Failure "protocol violation".
Мы можем реализовать тот же пример с использованием глубоких обработчиков с помощью ячеек ссылок (легко, но неудовлетворительно) или без них (сложнее). Мы оставляем это в качестве упражнения для читателя.
© 1995-2022 INRIA.
https://v2.ocaml.org/releases/5.0/htmlman/effects.html