1

I'm in the process of familiarizing myself with the async keyword in C# as well as the library ecosystem as I work towards a practical solution.

My research has lead me to the following static main loop code:

Task loop = Task.Factory.StartNew(async () => {

    try {
        using(RedisConnection redis = new RedisConnection("localhost")) {

            var queue = "my_queue";
            var reserved = string.Concat(queue, "_reserved");

            redis.Open();

            while(true) {

                Task.Factory.StartNew(async () => {

                    var pushRequest = await redis.Lists.RemoveLastAndAddFirstString(0, queue, reserved);

                });

            }

        }
    }
    catch(Exception ex) {
        Console.Error.WriteLineAsync(ex.Message);
    }

}, cancellationToken);

loop.Wait();

As you can see, I'm trying to create a non-blocking worker. I feel like I'm nearing a complete solution for the core loop code itself and then obviously what's inside the internal Task.Factory.StartNew call will start to be moved out into separate classes & methods.

A few questions I have:

  • Should any of what I have here right now be moved out of the static main method?
  • I notice that when I run this code, my CPU activity goes up a bit, I assume because I'm creating multiple continuations requesting redis for information. Is this normal or should I be mitigating that?
  • Is this truly asynchronous/non-blocking at this point and will code inside my inner lambda never tie up my main worker thread?
  • Does C# spawn additional threads for the continuations?

Apologies for what might be any amateur errors. As I mentioned, I'm still learning but am hoping to get that last bit of guidance from the community.

wohlstad
  • 12,661
  • 10
  • 26
  • 39
Alexander Trauzzi
  • 7,277
  • 13
  • 68
  • 112
  • 2
    You're continually spawning new listeners, forever. That isn't a good idea. – SLaks Mar 06 '14 at 23:03
  • Can you suggest what or where I can fix this so that it only attempts to work upon getting a value? – Alexander Trauzzi Mar 06 '14 at 23:59
  • `Task.Factory.StartNew`+`async` is tricky. `Task.Run` is preferable. Take a look to [this article](https://devblogs.microsoft.com/pfxteam/task-run-vs-task-factory-startnew/ "Task.Run vs Task.Factory.StartNew") that explains the differences. – Theodor Zoulias Jun 06 '22 at 11:19

1 Answers1

1

You don't need additional pool threads for IO-bound operations (more thoughts on this here). In this light, your re-factored code may look like below:

async Task AsyncLoop(CancellationToken cancellationToken)
{
    using (RedisConnection redis = new RedisConnection("localhost"))
    {

        var queue = "my_queue";
        var reserved = string.Concat(queue, "_reserved");

        redis.Open();

        while (true)
        {
            // observe cancellation requests
            cancellationToken.ThrowIfCancellationRequested();

            var pushRequestTask = redis.Lists.RemoveLastAndAddFirstString(0, queue, reserved);

            // continue on a random thread after await, 
            // thanks to ConfigureAwait(false)
            var pushRequest = await pushRequestTask.ConfigureAwait(false);

            // process pushRequest
        }
    }
}

void Loop(CancellationToken cancellationToken)
{
    try
    {
        AsyncLoop().Wait();
    }
    catch (Exception ex)
    {
        while (ex is AggregateException && ex.InnerException != null)
            ex = ex.InnerException;

        // might be: await Console.Error.WriteLineAsync(ex.Message),
        // but you cannot use await inside catch, so: 
        Console.Error.WriteLine(ex.Message);
    }
}

Note, the absence of Task.Run / Task.Factory.StartNew doesn't mean there's always going to be the same thread. In this example, the code after await will most likely continue on a different thread, because of ConfigureAwait(false). However, this ConfigureAwait(false) might be redundant if there is no synchronization context on the calling thread (e.g, for a console app). To learn more, refer to the articles listed in async-await Wiki, specifically to "It's All About the SynchronizationContext" by Stephen Cleary.

It's possible to replicate the single threaded, thread-affine behavior of Node.js event loop inside AsyncLoop, so every continuations after await is executed on the same thread. You would use a custom task scheduler (or a custom SynchronizationContext and TaskScheduler.FromCurrentSynchronizationContext). It isn't difficult to implement, although I'm not sure if that's what you're asking for.

In case this is a server-side app, serializing await continuations on the same thread a-la Node.js may hurt the app's scalability (especially if there's some CPU-bound work you do between awaits).

To address your specific questions:

Should any of what I have here right now be moved out of the static main method?

If your're using a chain of async methods, at the topmost stack frame there's going to be an async-to-Task transition. In case with a console app, it's OK to block with Task.Wait() inside Main, which is the topmost entry point.

I notice that when I run this code, my CPU activity goes up a bit, I assume because I'm creating multiple continuations requesting redis for information. Is this normal or should I be mitigating that?

It's hard to tell why your CPU activity goes up, precisely. I'd rather blame the server side of this app, something that listens to and process redis requests on localhost.

Is this truly asynchronous/non-blocking at this point and will code inside my inner lambda never tie up my main worker thread?

The code I posted is truly non-blocking, provided that new RedisConnection is non-blocking and RemoveLastAndAddFirstString doesn't block anywhere inside its synchronous part.

Does C# spawn additional threads for the continuations?

Not explicitly. There is no thread while the asynchronous IO operation is "in-flight". However, when the operation is getting completed, there will be an IOCP thread from ThreadPool, assigned to handle the completion. Whether or not the code after await will continue on this thread or on the original thread depends on the synchronization context. This behavior can be controlled with ConfigureAwait(false), as already mentioned.

Community
  • 1
  • 1
noseratio
  • 59,932
  • 34
  • 208
  • 486
  • Great information, thanks so much! I think it's also worth mentioning that I think while node is "single threadded", what I'm reading indicates it's just the worker that never blocks. Other operations may sometimes result in a new thread, it's just the main logic that never gets held up. That probably makes your suggested code here nearly identical to a node.js approach? – Alexander Trauzzi Mar 07 '14 at 14:41
  • Also, quick note. Should the AsyncLoop method be returning a Task somewhere? – Alexander Trauzzi Mar 07 '14 at 15:57
  • 1
    @Omega, indeed, the worker would only block if there's a blocking piece anywhere (e.g, syncnhronous DNS resolution). Whenever this happens and there's no proper naturally async API to use, you can wrap such calls with `await Task.Run()`. So `AsyncLoop` is similar to Node's event loop, besides there is no thread affinity for `await` continuations. `AsyncLoop` doesn't have to return anything *explicitly* because there's `async Task` signature (`async` was missing, fixed). The C# Compiler makes it return a `Task`. Without `async`, it would have to, but you couldn't use `await` inside it. – noseratio Mar 07 '14 at 22:22
  • 1
    Thank you so much for your help, lots of clarity here. – Alexander Trauzzi Mar 08 '14 at 03:08
  • Last question - I noticed while running this again that my program's process spiked for CPU activity and started chewing up a lot of memory. Is it safe to be running `await pushRequestTask`? That seems like it would basically call it over and over? – Alexander Trauzzi Mar 08 '14 at 05:10
  • 1
    @Omega, it is safe. The CPU and memory usage really depends on what's going on inside ` RemoveLastAndAddFirstString`. Try `await Task.Delay(500)` instead and compare your mileage. – noseratio Mar 08 '14 at 05:53