Is waiting for a handle indefinitly considered correct?

As far as I have seen, the usage of async vs concurrent is all about correctness.

An example that was provided in Asynchrony is not Concurrency:

io.async(Server.accept, .{server, io});
io.async(Client.connect, .{client, io});

indicates that the usage of async is not correct here as it may deadlock for synchronous implementations of Io (or the implementation decided to use a blocking call for whatever reason).

I am gathering from this that a deadlock is by definition incorrect.

But how about potentially infinitely blocking calls? Are they considered correct?

For example consider that we opened a TCP socket for listening and accept a client in a loop

while(select.await()) {
    const client = io.async(Server.accept, .{server, io});
    select.async(handle_client, .{client, io}); // handle_client calls `read()` on the stream
}

This is a very rough pseudo-code but I hope it gets the idea across.

Lines 2 and 3 can potentially block indefinitely if the Io implementation decided to use a blocking call.

But! as far as I can see, this is considered a correct program as there are no deadlocks, the accept operation and the handle_client operation are truly asynchronous to each other (as in they don’t depend on each other for the program to make progress).

Would you agree with that assessment? Or would you consider this an incorrect program and use concurrent instead of async?

1 Like

The reason why any code is incorrect is that it is not guaranteed to do what is intended; within reason ofc, not every bug can be prevented or doing so may come at the cost of weakening a more important guarantee.

A deadlock is not inherently incorrect, it could be precisely what you intend, however you most likely intend to do something more useful than wait forever.

Is incorrect because those two tasks communicate with each other, hence they are required to be concurrent relative to the other. So at least 1 must be guaranteed to be concurrent, and the concurrent task should be started first.
This is assuming any following code does not need to be concurrent relative to those 2 tasks.

True, it does progress, so it may be better than a deadlock; but it is reasonably likely the author intends to be able to handle multiple clients at once, in which case the code would be incorrect as it could have a stronger guarantee of that.

1 Like

My recommendation is to always mentally translate io.async(func, args) to func(args) and evaluate if you are happy with the execution.

For an server accept loop, you could have also done this:

while(true) {
    const client = server.accept(io);
    handle_client(client, io);
}

It would be fine, but as said, it only accepts one client at a time.

2 Likes

Yes, I do expect to handle multiple clients concurrently.

The reason why any code is incorrect is that it is not guaranteed to do what is intended

I think that Loris Cro’s comment that we must be mathematically rigorous to unlock key insights will really help me out here. And while I did study computer science I am by no means an expert, but I will try my best to give out my thoughts

What I meant by “correct” is not what you are referring to. I think your definition can be described as “logical correctness”. I was referring to the property of a program to both converge and be sound.

  • A program that divides by zero is convergent and unsound. (assuming SIGFPE will kill the process and that the system generates it for division by zero)
  • A program that encounters a deadlock is divergent and sound (assuming all other parts of the program are indeed sound)
  • A server that can halt due to user request and assuming all errors are handled correctly is both sound and convergent

As I see it, the blog post seems to hint that using concurrent() is to make a correct program a possibility under certain conditions (as in the provided example in the post, without concurrent() the program could diverge for blocking syscalls).

In other words we can categorize a sound program to one of three categories

  1. The program can’t make progress
  2. The program can make progress
  3. The program must make progress

It seems that using async() can land you on category (1) or (2) but, using concurrent() guarantees that a program will be in category (3).

in which case the code would be incorrect as it could have a stronger guarantee of that.

Maybe we are missing a primitive that guarantees that a program will be in category (2)?

1 Like

concurrent does not inherently guarantee (3), that depends on the logic of the task(s), as would any guarantee of (2). Dont forget that it is fallable as concurrency may not be available.

I dont know if its possible to offer any primitive that can guarantee (2) or (3), and if it is, I dont think it would be easy to make them practical to use.

1 Like

Ok, I’m very sorry but scratch almost everything that I said previously. English isn’t my first language and I am having a hard time trying to explain what I mean.

Let’s take a concrete example using the Posix’s poll() mechanism (which can be easily extended to io_uring or epoll) and see how syscalls are executed.

task1 and task2 try to issue a read() call, be it asynchronously using poll() or blocking using the actual read() syscall.

Scenario 1:

select.async(task1); // If there are system resources available, a new task will be spawned to handle task1, if there are no resources, read() will be called in place here
select.async(task2);
select.await(); // If the task was spawned, a `poll()` will be issued and when the fd is ready, a corresponding `read()` syscall will be issued

Scenario 2:

select.concurrent(task1); // If no system resources are available, an error will be returned
select.concurrent(task2);
select.await();

But I am looking for a solution for scenario 3:

select.asyncAll(&.{task1, task2}); // If system resources are available, this will schedule a new task for each array member, If there aren't any, a `poll()` will be called in-place for all tasks
select.await(); // If the tasks were spawned, `poll()` will be called here instead of at `asyncAll` call site

Note that asyncAll() issues a poll() syscall in-place, unlike scenario 1 or 2 which always defer the poll() to the await() call site.
So when I talked about programs in my previous post I was actually referring to “tasks”.

You are thinking about this wrong. Think of futures like threads, not small tasks. Your future should be the entire accept loop, not individual accepts. Every io.async means one or more allocations, and some synchronization. It’s inefficient to use it for small tasks.

3 Likes

Yes. The tasks only serve as an example, they don’t just try to call read(), rather they eventually call read(). task1 and task2 can be for example client handlers, each running a loop that reads from the socket.