Async I/O in SWI-Prolog

What’s the recommended way to do async I/O in SWI-Prolog? Pengines/Engines bound to threads? I’m thinking a base library that works like Mozart/Oz’s “data flow variables” which can be used for synchronization would be nice and perhaps can be implemented using attributed variables? Currently the simple thing I’m interested in doing multiple http_posts simultaneously without launching them in threads (manually, that is)

Just a historical note: the idea of synchronizing on the binding of shared variables (X=a waits until X is bound by someone else) comes from the concurrent LP work in the 80’s and 90’s (Shapiro, Ueda, Gregory, …), and, in fact. even before, since it is really the mechanism used in freeze/when/etc. which are concurrency primitives. This was generalized to entailment (implication) for constraint LP by Michael Maher, i.e., where X>3 waits until the store implies this constraint, e.g., when someone states (‘tells’) that X>5, which implies it is greater than 3. The latter more complex to implement of course that synchronizing on simple variable bindings.

Regarding the idea of implementing this type of synchronization using attributed variables, there is this paper from ICLP95: Using Attributed Variables in the Implementation of Concurrent and Parallel Logic Programming Systems

1 Like

(I meant of course X>5, corrected)

As is, the options in SWI-Prolog are limited for this. Threads scale quite well. Just a few weeks ago I ran 100,000 on my Linux desktop :slight_smile:

To do async/wait style programming, there are probably two routes. One is to capture the continuation using reset/3 and friends. The other is to use engines. Continuations come with some limitations. engines are in that sense more robust, but also more expensive. It is not so hard to wait on multiple I/O channels and continue some coroutine if there is data.

It is an interesting topic, in particular in relation to the WASM version as WASM has rather limited support for real threads.

1 Like

I’d expect that in the case of X=a, if X is unbound at that point it would get bound to a, right?

I’ll likely do what I need with threads anyway, but thanks for pointing out reset/3 - didn’t know it existed. For I/O though, reset/3 won’t be enough to work with existing predicates since the I/O primitive call points will need be changed to use shift for it to be usable.

(Looks like I was signed into another account hen replying. Apologies.)

Yes, good question, I did not want to get into too many details. Normally what you do is you mark the unifications as ‘ask’ or ‘tell’. Ask unifications wait for the variable to be bound (as in freeze), tell unifications go ahead and bind. This can be done explicitly, e.g., ask(X=a), tell(X=A), and you can also just use ask/1 and make tell be the default, or use other means: e.g., GHC elegantly split the clauses in two with a ‘|’ and unifications in the left part were ask and those in the right part were ‘tell’. You can see some discussion on this in relation to Prolog in some older Ciao papers.

1 Like

That is indeed the issue. Well, you can do a little by using wait_for_input/3 (an interface to select() or poll()) to verify the input is not ready. If so, you can shift/1 (for reset/3) or engine_yield/1 (for engines). The controlling thread should then use wait_for_input/3 on all suspended tasks and restart the ones that have input ready. The problem is that many I/O primitives may need to read multiple bytes and thus may block. The typical example is calling read_term/2, which needs to read a complete Prolog term. So, to make this work practically all I/O predicates, which are typically written in C, need to use some form of yielding. That can be done. SWI-Prolog’s foreign language interface allows foreign predicates to yield, returning a pointer to a state. They can be restarted later, which implies the VM calls the implementation again, providing the state.

If you have a need and resources (time or money), please contact me offline. “Resources” here refers primarily to people with good skills in C.

If we’re getting down to that level, we should perhaps put in an I/O event loop as a first class thing (or hidden per thread) using libuv. Right now I don’t have access to such resources and am strapped for time myself, though I’d like to pitch in.

That is an interesting thought. It would probably result in a major redesign of SWI-Prolog’s I/O, networking, timers, etc. Note that we also have threads and I surely would not like to get rid of those. Pre-emptively scheduled Prolog threads are a key strength of SWI-Prolog and many of its applications rely on it. Prolog is probably well suited for multi-threading as it deals with most of its data on the (term) stack, i.e., isolated between threads. For dynamic predicates we have the logical update view and a couple of high level synchronization primitives that seem to work well.

What we cannot do is many tasks, each with low CPU requirements. Here we need to create a thread for each task. The scalability of this design depends a lot on the OS. For example, I used this recently for a multi-agent simulation. On my Linux box using an AMD3950X CPU this easily scaled to 100,000 agents where it used about 1 full core (of 16). On my Macbook air with M1, the 8 cores went through the roof with only a few thousands agents and MacOS seems to refuse to create more than 4K threads on this platform. I did not try Windows. As the simulation needed to run on MacOS I eventually redesigned it to run multiple agents in one thread. That was a non-trivial transformation that spoiled the elegance of the initial implementation :frowning:

You mention many HTTP POST requests as your test case. How many is many? If the main delay is in the server formulating a reply, you could use http_open/3 to get the socket and then wait for all sockets using wait_for_input/3. When input is ready you give the socket to a new thread to handle the reply.

My case is very doable with threads and I don’t have a problem doing one request per thread. I suppose going down the libuv path we’ll end up with something leaning towards Erlang … especially given its origins in Prolog :slight_smile:

AFAIK (correct me if I’m wrong), Erlang never supported backtracking. Backtracking and continuation-style programming are not a trivial mix. We get into these issues if we have a Prolog program that parses input. The source may become ready, providing us with access to a some data, but not enough to commit. This means we must wait for a new ready event to continue the ongoing non-deterministic computation. For example, delimited continuations (reset/3) cannot do this. Engines can, as they contain the full Prolog state rather then merely the forward continuation.

These are interesting problems. They require a solution to make the WASM version a serious option for Prolog applications that need to deal with multiple event streams. In my current vision, engines are the way to solve this. As a first step I untangled engines from threads, i.e., allowing the single threaded version to create engines.

Another interesting system is the Janus library, providing access to Python. This currently allows exploiting Python’s async I/O, using multiple independent Prolog threads to deal with the Prolog computation.

2 Likes

AFAIK (correct me if I’m wrong), Erlang never supported backtracking.

Right. However, before it became Erlang, it was apparently an extension of Prolog used internally. Erlang just kept unification and ditched backtracking for high concurrency and soft realtime characteristics.

1 Like

While somewhat more explicit than use of data-flow variables in some concurrent prologs, there is library(spawn) which uses threads and freeze to achieve something similar and stays pretty high level. It’s not maintained, but I think (basic idea) should still work.

One more idea worth looking exploring are non-blocking fibers in Ruby 3 which are somewhere between preemptive threads and coroutines:

The concept of non-blocking fiber was introduced in Ruby 3.0. A non-blocking fiber, when reaching a operation that would normally block the fiber (like sleep , or wait for another process or I/O) will yield control to other fibers and allow the scheduler to handle blocking and waking up (resuming) this fiber when it can proceed.