My (anecdotal) experience with using Lwt in IO-heavy applications is that it already has a very non-negligible allocation cost (i.e. even after performance tuning, it’s still a large factor in perf
/ memtrace
profiles), but this will obviously depend on how “substantial” your actions are.
A big factor is obviously that Lwt
-ified functions bleed throughout the stack, so using Lwt for IO incurs a performance penalty proportional to the depth of your stack – every time one factors out a function on the route from the top-level API to the Lwt-ified IO layer, there’s a good chance of introducing another allocation per IO operation. (The symptom is that my Lwt
programs have a lot of Lwt.map
s and Lwt.return
s necessary to appease the type system.) YMMV.
The ()
wrappers have a similar characteristic, which I think is made a bit clearer by expanding f
and g
, assuming they both do something useful and then massage the result in some way:
p >>= (fun f_x -> ... return f_y) >>= (fun g_x -> ... return g_y)
(* becomes *)
p >>= (fun f_x -> ...<some-async-action>... fun () -> Lwt.return f_y)
>>= (fun g_x -> ...<some-async-action>... fun () -> Lwt.return g_y)
(* becomes *)
fun () ->
Lwt.bind
(Lwt.bind
(p ())
(fun x -> (fun f_x -> ... fun () -> Lwt.return f_y) x ()))
(fun x -> (fun g_x -> ... fun () -> Lwt.return g_y) x ())
I think the problem here is that the x
and ()
arguments are never passed together: you first pass x
, which does some computation and allocates a closure representing the next task, and then pass ()
to execute that task. Again, for some definition of a “substantial” async task this won’t matter, but in other cases this will be a non-trivial perf loss.
This is fair enough, although now one would seem to also want a monadic encoding of mutations / exceptions, and sooner or later we may as well be writing Haskell (Kidding, of course.)
I was thinking more generally of cases where I want to provide resources to an IO-using operation (e.g. a task equivalent of Lwt_unix.read : file_descr -> bytes -> off:int -> len:int -> int Lwt.t
), i.e. when I want to either temporarily or permanently delegate control of a resource. Using bracketing works, but I think your bracketing combinator is missing an ~acquire
phase (a left bracket ):
val bracket : ('a -> 'c Task.t)
-> acquire:'a Task.t
-> finalize:('a -> unit Task.t)
-> 'c Task.t
Otherwise, I think the implication is that the inner task being passed to bracket
already has ownership of some value – so it’s not referentially transparent. c.f. Haskell’s bracket and Cats’ bracket.
This model works, but it requires my resource usage to actually be well-bracketed, and in my real-world code this is often not the case: e.g. I have a “database handle” or some such that lives throughout my program lifetime and needs to keep a single FD open in order to write
and read
from many times, so the write
and read
functions can’t be well-bracketed tasks (it’s not acceptable to open and close my FD each time I want to interact with it). To work around this, I’d have to have my database handle be well-bracketed too, and so this obligation works its way up the stack. (In Tezos, these FDs are three or four libraries deep.)
Interestingly, the Cats library has a solution for this boilerplate propagation, which is to add another monad layer for each resource
To be clear, I wasn’t trying to dismiss the idea of a referentially-transparent tasks a priori: I think they’re a nice abstraction and would fit some use-cases really nicely. I was just thinking about the implications for my own usage of Lwt.