One of the new features called out in @araq's Nim 2 talk is Task, which bundles up an expression for evaluation later (asynchronously by a thread pool, in his example.)
There wasn’t a lot of detail in the talk; as far as I could tell, Task is just a wrapper for a lambda/closure/proc with no arguments. Which is a useful abstraction but does not seem exciting enough to be described in a short overview video.
I assume that I missed something and the Task type is cooler than I realize. Does someone with more knowledge feel like explaining it?
I enjoyed Araq's talk, but I also had the same question. How is Task different than wrapping a proc in a closure that includes its arguments, then passing that closure around as an object?
Ringabout/flywind posted the following on discord:
Yeah, let x = toTask(hello(521, "123")) is roughly equal to
var scratch = cast[ptr ScratchObj](c_calloc(csize_t 1, csize_t sizeof(ScratchObj)))
scratch.a = 521
scratch.b = "123"
proc hello_wrapper(args: pointer) {.nimcall.} =
let objTemp = cast[ptr ScratchObj](args)
hello(a = objTemp.a, b = objTemp.b)
let x = Task(callback: hello_wrapper, args: scratch)
So it uses a void pointer to implement type erasure. It might be a bit more efficient/lightweight than a closure?
I don't think we missed anything as it looks like just a syntax sugar for some routine manual work such as declaring a type to pack your arguments in. So, mostly a bit of a convenience and nothing conceptually new.
Just tried and rewritten a multithreaded process executor from a couple of locks for input/output coordination to tasks with two channels. 9 lines reduction but the code surely pulls more stuff - -d:danger binary became 8KB bigger after stripping.
I think it's fine not to unify to early, this allows code to diverge and specialize for their specific use-case.
In particular here, you can't unify due to closures using the GC.
Also tasks are guaranteed unique and can use move semantics. And hopefully in the future you can optimize allocation away: https://github.com/nim-lang/RFCs/issues/443. We could also do that for closure but in a different scenario, when they don't escape their scope.
I've looked at std/tasks page in the stdlib, and cannot understand why's first block does not work:
import std/tasks
type
Runnable = ref object
data: int
proc hello(a: Runnable) =
a.data += 2
block:
let x = Runnable(data: 12)
let b = toTask hello(x) # error ----> expression cannot be isolated: x
b.invoke()
block:
let c = toTask(hello(Runnable(data: 12)))
c.invoke()
simply because in the first block I initialized Runnable outside of arguments list?
simply because in the first block I initialized Runnable outside of arguments list?
Yes.