Learn C# - Asynchronous Programming & Concurrency
Series/Learn C#/Episode 14
Episode 14 of 23

Learn C# - Asynchronous Programming & Concurrency

This episode covers concurrency in C#: the Task-based async pattern with async/await, Parallel LINQ and the Task Parallel Library, Channel and ValueTask with asynchronous streams, and synchronization primitives and thread safety for correct parallel code.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Modern hardware has many cores, and applications must take advantage of them. But writing code that runs in parallel is hard: race conditions, deadlocks, and bugs that only appear once in a while are a developer's worst enemies.

In episode 7 you already saw async/await for I/O. Episode 14 expands that scope: when to use async for I/O, when to use parallelism for CPU, how to process endless data streams, and how to protect data shared between threads.

Task-based Async Pattern

The Basics of async/await

Task represents an operation in progress. async and await make asynchronous code as readable as synchronous code:

Pola Task-based async
async Task<string> MuatDataAsync(HttpClient client)
{
    var request = new HttpRequestMessage(HttpMethod.Get,
        "https://api.contoh.id/orders");
 
    var response = await client.SendAsync(request);
    var body = await response.Content.ReadAsStringAsync();
    return body;
}
 
var hasil = await MuatDataAsync(client);
Console.WriteLine(hasil);

Key point: await doesn't block a thread — while waiting, the thread returns to the pool and can serve other work. To run several operations at once, use Task.WhenAll:

Running multiple tasks
var t1 = MuatDataAsync(client);
var t2 = MuatDataAsync(client);
await Task.WhenAll(t1, t2);

Task.WhenAll waits for all tasks to finish simultaneously, cutting total time to the slowest one rather than the sum.

Parallel LINQ and the Task Parallel Library

PLINQ for CPU-bound Work

For heavy, purely CPU-bound computation, PLINQ parallelizes a LINQ query with just .AsParallel():

Parallel LINQ
var hasil = dataBesar
    .AsParallel()
    .Where(item => item.Berat > 100)
    .Select(item => item.Proses())
    .ToArray();
 
Console.WriteLine($"Hasil: {hasil.Length}");

.AsParallel() partitions the data across several threads. Use it only for genuinely heavy CPU-bound operations — for small jobs, the partitioning overhead actually slows things down.

The Task Parallel Library

TPL provides lower-level control. Parallel.For runs a loop with many threads:

Parallel.For
Parallel.For(0, 100, i =>
{
    var kuadrat = i * i;
    Console.WriteLine($"{i} kuadrat = {kuadrat}");
});

Parallel.For distributes iterations across worker threads. Be careful: don't call methods that aren't thread-safe inside it, and don't mutate shared state without synchronization.

Channel, ValueTask, and Async Streams

Channel for Producer-Consumer

Channel is a safe queue for the producer-consumer pattern — one piece of code produces data, another consumes it:

Channel producer-consumer
var channel = Channel.CreateUnbounded<int>();
 
async Task Producer()
{
    for (var i = 0; i < 10; i++)
    {
        await channel.Writer.WriteAsync(i);
    }
    channel.Writer.Complete();
}
 
async Task Consumer()
{
    await foreach (var item in channel.Reader.ReadAllAsync())
    {
        Console.WriteLine($"Terima {item}");
    }
}
 
await Task.WhenAll(Producer(), Consumer());

await foreach reads a data stream without blocking. This pattern is the foundation of a scalable processing pipeline.

Even though System.Threading.Channels is already part of the modern .NET shared framework, for projects targeting older frameworks or wanting the latest version, you can add the package with dotnet add package System.Threading.Channels.

ValueTask and Asynchronous Streams

For operations that almost always complete synchronously, ValueTask avoids allocating a Task object:

ValueTask and async streams
ValueTask<int> BacaCepatAsync() => new(42);
 
async IAsyncEnumerable<int> AngkaBermain()
{
    for (var i = 0; i < 5; i++)
    {
        await Task.Delay(50);
        yield return i;
    }
}
 
await foreach (var n in AngkaBermain())
{
    Console.WriteLine(n);
}

An IAsyncEnumerable method streams results incrementally — memory doesn't balloon even with huge amounts of data.

Synchronization Primitives and Thread Safety

Protecting Shared Data

When many threads mutate the same data, the result is unpredictable without synchronization. lock ensures only one thread enters a critical block at a time:

Synchronization with lock
class Counter
{
    private int _nilai;
    private readonly object _pintu = new();
 
    public void Tambah()
    {
        lock (_pintu)
        {
            _nilai++;
        }
    }
 
    public int Nilai => _nilai;
}

For simple atomic operations, Interlocked is faster than lock:

Atomic increment
Interlocked.Increment(ref _nilai);

Thread safety rules of thumb: reduce shared data, prefer immutable data, and synchronize only when truly necessary. Use SemaphoreSlim to limit the number of concurrent threads, and ConcurrentDictionary and ConcurrentQueue for collections that are safe across threads.

Closing

Key takeaways:

  • await doesn't block a thread; Task.WhenAll runs many tasks at once.
  • PLINQ and Parallel.For for CPU-bound work; async for I/O-bound work.
  • Channel and async streams model producer-consumer data flows.
  • ValueTask avoids allocations for operations that often finish synchronously.
  • lock, Interlocked, and concurrent collections keep things thread-safe.

In the next episode 15 we chase speed: performance tuning and diagnostics — profiling with dotnet-trace and dotnet-counters, memory diagnostics and garbage collection, optimization with Span, Memory, and stackalloc, and JIT optimization and tiered compilation.

Learn C# - Asynchronous Programming & Concurrency | Learn C#