~bonbud-macryg recently asked me if there is a way to run HTTP requests concurrently via urwasm, to go around a seeming limitation of Spider threads. Turns out Spider threads can already be run concurrently: multiple threads can be combined to emit their requests together and collect the responses in any order.
Usually we write Spider threads in sequence, using ;< to thread one result into the next:
;< res1=type bind:m do1
;< res2=type bind:m do2
...
The ;< rune desugars to bind:m applied to do1 and a function |=(res1=type ...). That function is the rest of the thread, and it takes res1 as input, which means do2 is allowed to depend on res1. The evaluator can’t start do2 until do1 has returned, because it doesn’t know what do2 is until then.
When the threads don’t actually depend on each other, we’re paying that price for nothing. If we’re willing to commit to the shape of the computation up front - no branching on intermediate results - we can combine two independent threads a and b into a single thread that emits both their cards, waits for both to finish and produces [a b]:
++ app-cons
|* [hed=(strand-form-raw:rand *) tel=(strand-form-raw:rand *)]
|= tin=strand-input:rand
=* this .
=/ h (hed tin)
=/ t (tel tin)
:- (weld cards.h cards.t)
:: we could even combine the errors if we wanted
::
?: ?=(%fail -.next.h) next.h
?: ?=(%fail -.next.t) next.t
?: &(?=(%done -.next.h) ?=(%done -.next.t))
[%done value.next.h value.next.t]
=^ change1 hed
=- [!=(hed -) -]
:: if done: turn the thread into (pure v). It is written
:: in this way to avoid capturing the subject so that comparison
:: still works, and to make wetness work
::
?: ?=(%done -.next.h) =>(v=value.next.h |~(* `done+v))
?: ?=(%cont -.next.h) self.next.h
hed
::
=^ change2 tel
=- [!=(tel -) -]
?: ?=(%done -.next.t) =>(v=value.next.t |~(* `done+v))
?: ?=(%cont -.next.t) self.next.t
tel
:: if our threads "hed" or "tel" changed for whatever reason we
:: need to return ourselves as a continuation to propagate the change.
:: we can't always do that without provoking infinite loops
::
?: |(change1 change2) [%cont this]
?: |(?=(%wait -.next.h) ?=(%wait -.next.t)) [%wait ~]
:: If a thread spins forever by returning an unchanged version of itself
:: via %cont, it would get stuck here. It would never return anything, so
:: consing its product with anything is unsound
::
[%skip ~]
The combined thread short-circuits if any of the threads failed, returns the pair of results if both are done, advances if any thread made progress, and waits/skips if the child threads wait/skip.
Folding app-cons over a list of threads gives us a thread that returns a list of products. For n independent IO operations this runs in roughly the time of the slowest one rather than the sum - the example linked above is more than twice as fast as its monadic counterpart.
The tradeoff is, again, independence: a child thread is going to be evaluated unconditionally, no matter what its sibling returned. If you need conditional evaluation you have to use ;<, otherwise +app-cons gives you free speedup.