AL.
🇪🇸 ES
Back to blog
.NET & C# · 9 min read

Async vs Parallel in .NET Explained: Stop Mixing Them Up

Learn the difference between asynchronous I/O and true parallel execution in C#, and when to use each for scalable apps.


One of the most common misconceptions in .NET is conflating asynchronous programming with parallel programming. Developers use async/await thinking they’re making code run in parallel, or they use Task.Run for I/O operations thinking they’re being clever.

Both are wrong, and both lead to performance problems.

In this article, we’ll clarify the difference once and for all: async is about responsiveness and efficient resource usage, while parallel is about throughput and utilizing multiple CPU cores. We’ll cover when to use each, common mistakes, and how to combine them correctly.

The Core Difference

Asynchronous (async/await):

  • For I/O-bound work (network, file, database)
  • Doesn’t use extra threads while waiting
  • Improves scalability by freeing up threads
  • About waiting efficiently

Parallel (Task.Run, Parallel.ForEach):

  • For CPU-bound work (calculations, data processing)
  • Uses multiple threads/CPU cores simultaneously
  • Improves throughput by doing more work
  • About doing work faster

Async/Await: I/O-Bound Operations

When you make a network request, read from a file, or query a database, your thread is mostly waiting. The actual work is happening in the network card, disk controller, or database server.

Without Async: Blocking Threads

public string GetUserData(int userId)
{
    var httpClient = new HttpClient();
    var response = httpClient.GetStringAsync($"https://api.example.com/users/{userId}").Result; // Bad!
    return response;
}

What happens here?

  1. Thread starts the HTTP request
  2. Thread blocks waiting for the response (wasting resources)
  3. Thread continues after response arrives

If you’re building an ASP.NET Core API, each request gets a thread from the thread pool. Blocking threads means you can handle fewer concurrent requests.

With Async: Efficient Waiting

public async Task<string> GetUserDataAsync(int userId)
{
    using var httpClient = new HttpClient();
    var response = await httpClient.GetStringAsync($"https://api.example.com/users/{userId}");
    return response;
}

What happens here?

  1. Thread starts the HTTP request
  2. Thread is released back to the thread pool (can handle other requests)
  3. When response arrives, a thread (possibly different) continues execution

No thread sits idle. This is why async code scales better.

Real-World Example: API Controller

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly HttpClient _httpClient;
    private readonly AppDbContext _context;

    public UsersController(HttpClient httpClient, AppDbContext context)
    {
        _httpClient = httpClient;
        _context = context;
    }

    // Good: Async I/O
    [HttpGet("{id}")]
    public async Task<ActionResult<UserDto>> GetUser(int id)
    {
        // Database call: I/O-bound
        var user = await _context.Users.FindAsync(id);
        if (user == null) return NotFound();

        // HTTP call: I/O-bound
        var enrichmentData = await _httpClient.GetStringAsync($"https://api.example.com/enrich/{id}");

        return Ok(new UserDto
        {
            Id = user.Id,
            Name = user.Name,
            EnrichmentData = enrichmentData
        });
    }
}

During both await calls, the thread handling this request is freed to handle other requests.

Parallel: CPU-Bound Operations

CPU-bound work means your thread is actively computing. Examples:

  • Image processing
  • Complex calculations
  • Data transformations
  • Parsing large files

Here, async doesn’t help—your thread is busy working, not waiting.

Using Task.Run for CPU Work

public async Task<byte[]> ProcessImageAsync(byte[] imageData)
{
    // Offload CPU work to thread pool
    return await Task.Run(() =>
    {
        // This is CPU-intensive work
        using var image = Image.Load(imageData);
        image.Mutate(x => x.Resize(800, 600));
        image.Mutate(x => x.Grayscale());

        using var ms = new MemoryStream();
        image.SaveAsJpeg(ms);
        return ms.ToArray();
    });
}

Why Task.Run here? Because the image processing is CPU-bound. We don’t want to block the calling thread (e.g., an ASP.NET Core request handler), so we offload to a thread pool thread.

Parallel.ForEach for Bulk CPU Work

If you need to process many items in parallel:

public void ProcessLargeDataset(List<DataItem> items)
{
    var results = new ConcurrentBag<ProcessedItem>();

    Parallel.ForEach(items, item =>
    {
        // CPU-intensive processing
        var processed = PerformComplexCalculation(item);
        results.Add(processed);
    });

    // All items processed in parallel
    SaveResults(results);
}

Parallel.ForEach uses multiple threads to process items concurrently, utilizing all available CPU cores.

PLINQ for Parallel Queries

public List<int> FindPrimes(int max)
{
    return Enumerable.Range(2, max - 1)
        .AsParallel()
        .Where(IsPrime)
        .ToList();
}

private bool IsPrime(int number)
{
    if (number < 2) return false;
    for (int i = 2; i <= Math.Sqrt(number); i++)
    {
        if (number % i == 0) return false;
    }
    return true;
}

The .AsParallel() partitions the work across multiple threads.

Common Mistake #1: Task.Run for I/O

One of the most common anti-patterns:

// Bad: Using Task.Run for I/O
public async Task<string> GetUserDataAsync(int userId)
{
    return await Task.Run(async () =>
    {
        using var httpClient = new HttpClient();
        return await httpClient.GetStringAsync($"https://api.example.com/users/{userId}");
    });
}

What’s wrong here?

  1. You’re using a thread pool thread unnecessarily
  2. The inner await already handles async—wrapping it in Task.Run adds overhead
  3. You’re wasting a thread while waiting for I/O

Correct version:

// Good: Just use async/await
public async Task<string> GetUserDataAsync(int userId)
{
    using var httpClient = new HttpClient();
    return await httpClient.GetStringAsync($"https://api.example.com/users/{userId}");
}

Rule of thumb: If the underlying API is already async (returns Task), don’t wrap it in Task.Run.

Common Mistake #2: Blocking on Async with .Result or .Wait()

// Bad: Blocking on async
public string GetUserData(int userId)
{
    using var httpClient = new HttpClient();
    return httpClient.GetStringAsync($"https://api.example.com/users/{userId}").Result; // Deadlock risk!
}

Problems:

  1. Deadlock risk in UI apps or old ASP.NET (not ASP.NET Core)
  2. Blocks the calling thread
  3. Loses all benefits of async

Correct version:

// Good: Async all the way
public async Task<string> GetUserDataAsync(int userId)
{
    using var httpClient = new HttpClient();
    return await httpClient.GetStringAsync($"https://api.example.com/users/{userId}");
}

If you absolutely must block (e.g., in a console app Main before C# 7.1):

// Only acceptable in console app entry points
static void Main(string[] args)
{
    MainAsync(args).GetAwaiter().GetResult(); // Safer than .Result or .Wait()
}

static async Task MainAsync(string[] args)
{
    // Actual async work here
}

Common Mistake #3: Async Void

// Bad: async void (except for event handlers)
public async void ProcessDataAsync()
{
    await Task.Delay(1000);
    // If an exception is thrown here, it crashes the app!
}

Problems:

  • Can’t await it
  • Exceptions are unhandled and crash the app
  • No way to know when it completes

Correct version:

// Good: async Task
public async Task ProcessDataAsync()
{
    await Task.Delay(1000);
    // Exceptions can be caught by the caller
}

Only exception: Event handlers must be async void:

private async void Button_Click(object sender, EventArgs e)
{
    try
    {
        await ProcessDataAsync();
    }
    catch (Exception ex)
    {
        // Handle error
        ShowError(ex.Message);
    }
}

The Thread Pool

Both async and parallel rely on the thread pool, but differently.

The thread pool is a collection of worker threads managed by .NET. Creating threads is expensive, so the pool reuses them.

Async and the Thread Pool

Async operations minimize thread pool usage:

  • Start operation on current thread
  • Release thread during await
  • Resume on (possibly different) thread pool thread

Parallel and the Thread Pool

Parallel operations maximize thread pool usage:

  • Use multiple threads simultaneously
  • Keep threads busy with CPU work
  • Default parallelism matches CPU core count

Combining Async and Parallel

Sometimes you need both: parallel processing of async operations.

Scenario: Fetch Data from Multiple APIs Concurrently

public async Task<List<UserData>> GetAllUsersDataAsync(List<int> userIds)
{
    // Start all requests concurrently
    var tasks = userIds.Select(id => GetUserDataAsync(id)).ToList();

    // Wait for all to complete
    var results = await Task.WhenAll(tasks);

    return results.ToList();
}

private async Task<UserData> GetUserDataAsync(int userId)
{
    using var httpClient = new HttpClient();
    var response = await httpClient.GetStringAsync($"https://api.example.com/users/{userId}");
    return JsonSerializer.Deserialize<UserData>(response);
}

This is concurrent async, not parallel. The operations happen concurrently, but they’re I/O-bound, not CPU-bound.

Scenario: CPU Work on Each Item Concurrently

public async Task<List<ProcessedImage>> ProcessImagesAsync(List<byte[]> images)
{
    var tasks = images.Select(imageData => Task.Run(() => ProcessImage(imageData))).ToList();

    return await Task.WhenAll(tasks);
}

private ProcessedImage ProcessImage(byte[] imageData)
{
    // CPU-intensive work
    using var image = Image.Load(imageData);
    image.Mutate(x => x.Resize(800, 600));

    using var ms = new MemoryStream();
    image.SaveAsJpeg(ms);

    return new ProcessedImage { Data = ms.ToArray() };
}

Here we’re combining:

  1. Task.Run to offload CPU work to thread pool
  2. Task.WhenAll to await all tasks concurrently

Benchmarking: Proof of Difference

Let’s benchmark I/O-bound work with and without async:

// Simulate I/O delay
private async Task<int> SimulateIoAsync()
{
    await Task.Delay(100); // Simulates I/O wait
    return 42;
}

// Synchronous blocking
[Benchmark]
public void SyncBlocking()
{
    var tasks = Enumerable.Range(0, 100)
        .Select(_ => Task.Run(() => SimulateIoAsync().Result))
        .ToArray();

    Task.WaitAll(tasks);
}

// Asynchronous
[Benchmark]
public async Task AsyncNonBlocking()
{
    var tasks = Enumerable.Range(0, 100)
        .Select(_ => SimulateIoAsync())
        .ToArray();

    await Task.WhenAll(tasks);
}

Results (approximate):

  • SyncBlocking: ~100ms, uses 100 threads
  • AsyncNonBlocking: ~100ms, uses 1-2 threads

Both take the same time, but async uses far fewer resources.

Now let’s benchmark CPU-bound work:

private int CalculatePrimes(int max)
{
    return Enumerable.Range(2, max).Count(IsPrime);
}

// Sequential
[Benchmark]
public void Sequential()
{
    CalculatePrimes(100000);
}

// Parallel
[Benchmark]
public void ParallelVersion()
{
    Parallel.For(0, Environment.ProcessorCount, _ => CalculatePrimes(100000 / Environment.ProcessorCount));
}

Results (approximate on 8-core CPU):

  • Sequential: ~500ms
  • ParallelVersion: ~70ms

Parallel is dramatically faster for CPU-bound work.

Decision Tree: Async vs Parallel

Is the work I/O-bound (network, file, database)?
├─ Yes → Use async/await
│  └─ Do NOT use Task.Run

└─ No → Is it CPU-bound?
   ├─ Yes → Use Task.Run or Parallel
   │  ├─ Single operation → Task.Run
   │  └─ Many operations → Parallel.ForEach or PLINQ

   └─ Not sure? → Profile it!

Real-World Scenario: Background Processing

public class OrderProcessor : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _serviceProvider.CreateScope();
            var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

            // I/O-bound: Fetch orders from database
            var orders = await context.Orders
                .Where(o => o.Status == OrderStatus.Pending)
                .Take(100)
                .ToListAsync(stoppingToken);

            if (orders.Any())
            {
                // CPU-bound: Process orders in parallel
                await Task.Run(() =>
                {
                    Parallel.ForEach(orders, order =>
                    {
                        ProcessOrder(order); // CPU-intensive
                    });
                }, stoppingToken);

                // I/O-bound: Save changes
                await context.SaveChangesAsync(stoppingToken);
            }

            // Wait before checking again (I/O-bound wait)
            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }

    private void ProcessOrder(Order order)
    {
        // Complex calculations, validation, etc.
        order.Status = OrderStatus.Processed;
    }
}

This combines:

  1. Async for database I/O
  2. Parallel for CPU-intensive processing
  3. Async for saving changes

Conclusion

Async and parallel solve different problems:

  • Async makes your app more responsive and scalable by not blocking threads during I/O
  • Parallel makes your app faster by utilizing multiple CPU cores for computation

Stop mixing them up. Use async for I/O, parallel for CPU work, and combine them when you have both.

Key takeaways:

  • Use async/await for I/O-bound work (HTTP, database, file)
  • Use Task.Run or Parallel for CPU-bound work (calculations, processing)
  • Never use Task.Run to wrap already-async code
  • Never block on async code with .Result or .Wait()
  • Async is about efficient waiting, parallel is about doing more work

Master this distinction and you’ll write faster, more scalable .NET applications.