javascriptnode.jstypescriptasync-awaitcontrol-flow

Async/Await control flow in JavaScript/TypeScript in Node.js


Context: I'm trying to implement a rudimentary socket pool in TypeScript. My current implementation is just a list of sockets that have an "AVAILABLE/OCCUPIED" enum attached to them (it could be a boolean admittedly) that allows me to have a mutex-like mechanism to ensure each socket only sends/receives a single message at once.

What I understand: I got that Node.js's way of handling "parallel" operations is "single-threaded asynchrony".

What I infer: to me, this means that there is only a single "control pointer"/"code read-head"/"control flow position" at once, since there is a single thread. It seems to me that the readhead only ever jumps to somewhere else in the code when "await" is called, and the Promise I am "awaiting" cannot yet be resolved. But I am not sure that this is indeed the case.

What I am wondering: does "single-threaded asynchrony" ensure that there is indeed no jump of the control flow position at any other time than when "await" is called ? Or is there some underlying scheduler that may indeed cause jumps between tasks at random moments, like normal multithreading ?

My question: All of this to ask, do I need a pure mutex/compare-and-swap mechanism to ensure that my mutex-like AVAILABLE/OCCUPIED field is set appropriately ?

Consider the following code:

export enum TaskSocketStatus
{
    AVAILABLE,  //Alive and available
    OCCUPIED,   //Alive and running a task
}

export interface TaskSocket
{
    status:TaskSocketStatus;
    socket:CustomSocket;
}


export class Server //A gateway that acts like a client manager for an app needing to connect to another secure server
{
    private sockets:TaskSocket[];

    [...]

    private async Borrow_Socket():Promise<TaskSocket|null>
    {
        for (const socket of this.sockets)
        {
            if (!socket.socket.Is_Connected())
            {
                await this.Socket_Close(socket);
                continue;
            }
            if (socket.status === TaskSocketStatus.AVAILABLE)
            {
//This line is where things could go wrong if the control flow jumped to another task;
//ie, where I'd need a mutex or compare-and-swap before setting the status
                socket.status = TaskSocketStatus.OCCUPIED;
                return (socket);
            }
        }
        if (this.sockets.length < this.max_sockets)
        {
            const maybe_socket = await this.Socket_Create();
            if (maybe_socket.isError())
            {
                return null;
            }
 //Probably here as well
            maybe_socket.value.status = TaskSocketStatus.OCCUPIED;
            return maybe_socket.value;
        }
        return null;
    }

    [...]
 }

The issue I'm looking to avoid is two different "SendMessage" tasks borrowing the same socket because of race conditions. Maybe this is needless worry, but I'd like to make sure, as this is a potential issue that I would really prefer not to have to confront when the server is already in production...

Thanks for your help !


Solution

  • So, the control flow to another operation is not when await is called. It's when the running piece of Javascript returns back to the event loop and the event loop can then service the next waiting event. Resolved promises work via the event loop too (a special queue, but still in the event loop).

    So, when you hit await in an async function, that doesn't immediately jump control somewhere else. It executes the expression that follows the await and gets the returned promise from that and then suspends further execution of that function and then causes the function to immediately return a promise and control continues with a promise being returned to the caller of the function. The caller's code continues to execute after receiving that promise. Only when the caller or the caller of the caller or the caller of the caller of the caller (depending upon how deep the call stack is) returns back to the event loop from whatever event started this whole chain of execution does the event loop get a chance to serve the next event and start a new chain of execution.

    Some time later when the underlying asynchronous operation connected to that original await finishes it will insert an event into the event queue. When other Javascript execution returns control back to the event loop and this event gets to the start of the event queue, it will get executed and will resolve the promise that the await was waiting for. Only then does the code within the function after the await get a chance to run. When that async function that contained the await finally finishes it's internal execution, then the promise that was originally returned from the async function when that first await was hit will resolve and the caller will be notified that the promise it got back has been resolved (assuming it used either await or .then() on that promise).

    So, there's no jumping of flow from one place to another. The current thread of Javascript execution returns control back to the event loop (by returning and unwinding its call stack) and the event loop can then serves the next waiting event and start a new chain of execution. Only when that chain of execution finishes and returns can the event loop go get the next event and start another chain of execution. In this way, there's just the one call stack frame going at a time.

    In your code, I don't quite follow what you're concerned about. There is no pre-emptive switching in Javascript. If your function does an await, then its execution will be suspended at that point (after executing the statement following the await and other code can run before the promise gets resolved and it continues execution after the statement including the await. But, there's no pre-emptive switching that could change the context and run other code in this thread without your code calling some asynchronous operation and then continuing in the complete callback or after the await.

    So, from a pure Javascript point of view, there's no worry between pure local Javascript statements that don't involve asynchronous operations. Those are guaranteed to be sequential and uninterrupted (we're assuming there's none of your code involved that uses shared memory and worker threads - which there is no sign of in the code you posted).

    What I am wondering: does "single-threaded asynchrony" ensure that there is indeed no jump of the control flow position at any other time than when "await" is called ?

    It ensures that there is no jump of the control flow position at any time except when you return back the event loop (unwind the call stack). It does not occur at await. await may lead to your function returning and may lead to the caller then returning back to the event loop while it waits for the returned promise to resolve, but it's important to understand that the control flow change only happens when the stack unwinds and returns control back to the event loop so the next event can be pulled from the event queue and processed.

    Or is there some underlying scheduler that may indeed cause jumps between tasks at random moments, like normal multithreading ?

    Assuming we're not talking about Worker Threads, there is no pre-emptive Javascript thread switching in nodejs. Execution to another piece of Javascript changes only when the current thread of Javascript returns back to the event loop.

    My question: All of this to ask, do I need a pure mutex/compare-and-swap mechanism to ensure that my mutex-like AVAILABLE/OCCUPIED field is set appropriately ?

    No, you do not need a mutex for that. There is no return back to the event loop between the test and set so they are guaranteed to be not interrupted by any other code.