Dotnet logo

.NET Tools

Essential productivity kit for .NET and game developers

.NET Tools

How to use the Tasks View in JetBrains Rider

The Task Parallel Library (TPL) is foundational to all .NET applications, as it allows frameworks to write and execute multithreaded and parallel code. Additionally, developers looking to get the most out of their resources may want to write custom code, taking advantage of the types found in System.Threading and System.Threading.Tasks. You must understand concurrency and threading fundamentals to write fast and scalable solutions, including locks, deadlocks, awaits, and scheduling. Part of expanding your understanding of these concepts requires excellent tooling to help you visualize and make sense of a potentially chaotic swarm of executing tasks.

Exciting news! We’ve recently launched the first iteration of our Tasks view, a powerful tool designed to help you understand the existing tasks in the current application process.

In this post, we’ll examine the new tool window, discuss its essential UI elements, and demonstrate some common scenarios. You’ll be ready to explore your code bases and uncover optimization opportunities by the end.

Task Execution in .NET Applications

In .NET, Tasks offer an abstraction over concepts like concurrency and multithreading. The idea is to worry less about CPU cores and threads and deal with the high-level concepts of scheduling and executing work concurrently. This is generally great since it can help developers write more imperative code while efficiently utilizing all their system resources.

While abstractions are reasonably good, no abstraction is perfect, and at some point, you’ll have to deal with the pitfalls of multiple tasks concurrently. These include deadlocks, race conditions, and inefficient scheduling that causes backpressure. Dealing with abstractions doesn’t mean you should not understand how and why they work.

Typically, you will be responsible for scheduling tasks but never know when and how those tasks will be completed. Execution is the job of the .NET runtime. While you might see particular code patterns that are a sign of trouble to come, the best way to diagnose issues with Tasks is at runtime. Let’s look at some common scenarios you might uncover in your codebase and see how the Tasks view can help you better understand your applications.

Common Tasks

The most likely scenario when working with tasks is consuming an API that returns tasks that require the async and await keywords. These asynchronous APIs are in ASP.NET Core, MAUI, and Entity Framework Core. Many open-source projects have also moved to async-first APIs to support developer needs.

Let’s take a look at a simple example.

await BasicWork();

async Task BasicWork()
{
    await Task.Delay(TimeSpan.FromSeconds(1));
    Console.WriteLine("👋 Hello Tasks View!");
}
Copy to clipboard

In this example, we have two tasks: The main task originating from our program and the BasicWork method. We can confirm this by using the new Tasks View. While in a debugging session, click the Tasks tab to view the following table.

JetBrains Rider’s Tasks View in Table mode

You can also switch to a Graph view by choosing the option at the top right.

JetBrains Rider’s Tasks View in Graph mode

You can also see the row information in the graph view by hovering over any stack item.

Showing the row in the graph view

When working with Tasks, there are five statuses any task can have at any one time:

  • Active: Currently executing and running.
  • Scheduled: The task has been created but has not yet been executed.
  • Awaiting: The task has been awaited but may be awaiting other tasks.
  • Blocked: This task is at the top of the stack, and the thread executing it is blocked (sleep, waiting on lock, etc.). There are also some other tasks higher in the stack.
  • Deadlocked: The task is in contention for a resource, and there’s a severe problem.

Now that we’ve seen the most common scenario, let’s look at parented tasks.

Parented Tasks

The act of parenting allows developers to logically group tasks. When creating a task within a task, you can use the TaskCreationOptions.AttachedToParent to tie any new task to the containing task.

await ParentedTasks();

Task ParentedTasks()
{
    // Parent task
    var parentTask = Task.Factory.StartNew(() =>
    {
        Console.WriteLine("Parent task started.");

        // Child task
        var task = Task.Factory.StartNew(() =>
        {
            Console.WriteLine("Child task started.");
            Task.Delay(2000).Wait(); // Simulating some work
            Console.WriteLine("Child task completed.");
        }, TaskCreationOptions.AttachedToParent);

        Console.WriteLine("Parent task doing some work.");
    }, TaskCreationOptions.AttachedToParent);

    // Wait for parent task to complete, which includes the children
    parentTask.Wait();

    Console.WriteLine("Parent task completed.");
    return Task.CompletedTask;
}
Copy to clipboard

If you run the code and look at the Task view, you’ll see that we’ve successfully parented the child task.

Showing a task parented to another

Note that each new task has an integer Id assigned by the .NET runtime. These identifiers help you keep track of tasks that exist in your current process.

This time, the graph view shows two async logical stacks resulting from the ParentedTasks code, which uses methods like Wait and returning Task.CompletedTask.

Showing two async logical stacks

That’s cool. Understanding whether tasks are related or have created separate logical stacks can help you understand if you’ve created a potential race condition.

Let’s see how the Tasks view can help us see how work is scheduled.

Scheduling Tasks

Any time you await a task, you effectively schedule that work for a future time. That work can happen immediately after scheduling a task or after other scheduled tasks have been executed. Let’s see an example where we schedule several tasks and wait for them to complete.

await ScheduledWork();
async Task ScheduledWork()
{
    Console.Write("Let's work...");
    var tasks = Enumerable
        .Range(1, 10)
        .Select((i) => Task.Run(() => Console.Write(i)));

    await Task.WhenAll(tasks);
}
Copy to clipboard

The use of the Task.WhenAll attempts to execute all the supplied tasks, with all of them being scheduled for future execution. You can see this in the Tasks view.

Scheduled tasks from the 10 created tasks

Additionally, the use of Task.WhenAll creates an async logical stack under which all these operations can run.

Two async logical stacks with one showing 10 values

When you step through the code during the debugging session, you’ll see the list of Tasks reduced as tasks are completed. You may also notice multiple tasks being executed at the same time.

A dwindling set of tasks in the tasks view

It’s satisfying to see tasks completed, but it’s not great when they overstay their welcome. Let’s move on to the scariest scenario when dealing with tasks: deadlocks.

Deadlocks

The most common cause of a deadlock is contention around a shared resource guarded by some locking mechanism. Locks are essential when dealing with shared resources but can lead to app-breaking issues.

Let’s create a deadlock now because we love to live dangerously. More importantly, we’ll see how the Tasks view can help us identify it. We’ll schedule two tasks, each trying to lock the same variables.

await Deadlock();

// This method will cause a deadlock
// proceed with caution, oOOoOOoOo! 👻
async Task Deadlock()
{
    object one = new();
    object two = new();

    var timer = new System.Timers.Timer(
        TimeSpan.FromSeconds(2)
    ) { Enabled = true, AutoReset = false };

    timer.Elapsed += (_, _) =>
    {
        // only see this if we're deadlocked
        Console.WriteLine("💀Deadlock");
    };

    await Task.WhenAll(Task.Run(() =>
    {
        Console.WriteLine("Getting lock for one.");
        lock (one)
        {
            Thread.Sleep(1000);
            Console.WriteLine("Getting lock two in first task.");
            lock (two)
            {
            }
        }
    }), Task.Run(() =>
    {
        Console.WriteLine("Getting lock two in second task.");
        lock (two)
        {
            Thread.Sleep(1000);
            Console.WriteLine("Getting lock one in second task.");
            lock (one)
            {
            }
        }
    }));
}
Copy to clipboard

When you run the code, you’ll notice the application never exits. In the Run toolbar, hit the pause button to pause the application. Oh no, a deadlock! I’m shocked! (well, I’m not that shocked).

A deadlocked pair of tasks in the tasks table view

The graph view is even more telling, showing the two tasks in contention and how we got here.

A deadlocked pair of tasks in the tasks graph view

Double-clicking on any of the deadlocked logical stacks will take you to the location of the deadlock.

Showing the code that is causing the deadlock in JetBrains Rider

That ease of navigation should make finding and solving deadlocks, well, dead simple.

Conclusion

The Tasks view is currently available in JetBrains Rider 2024.2 EAP, and we’d love to hear your feedback so you can help shape this tool’s future. We understand that tasks can be a challenging part of .NET development, and we hope this additional tooling can help you overcome them. Try it and see if it can help you optimize existing code or find longstanding issues in your codebase.

Thanks for reading, and we look forward to your thoughts and comments.

image credit: Eden Constantino

image description