Async .NET · Lesson 28

Sharing one cached asynchronous operation

Cache the task when callers should observe the same in-flight work.

How it works

Two callers asking for the same data can accidentally start duplicate work if a cache stores only completed values. Caching the Task itself lets the second caller observe the operation already in flight. The sample's two Get calls receive the same task reference, and the underlying Read function runs only once.

WhenAll observes that shared task through both inputs and produces two results of four. The read counter remains one. The result demonstrates operation sharing, not parallel speed. Both callers depend on the same eventual success or failure, so a task cache is also a shared lifetime and error policy.

This tiny dictionary is intended for coordinated calls from one owner. It is not a general thread-safe cache. Concurrent callers on several threads could both miss the lookup and attempt to add work. A real multi-threaded implementation needs an atomic creation policy; merely replacing Dictionary with a different collection does not automatically settle every factory-invocation concern.

Decide what happens to a faulted or canceled cached task. Keeping it may deliberately cache failure, while removing it may allow a future retry. Caller-specific cancellation should not casually cancel a shared operation still needed by another caller. Separate a caller's wait lifetime from the shared task's owner.

Practice by requesting two different keys and verifying that the counter becomes two. Then make Read fail for one key and request it twice, observing whether the existing implementation repeats the work. Write down the desired eviction policy before changing the code.

Finally, add an explicit invalidation method and test it after successful completion. Explain whether invalidating an in-flight task should stop it, let it finish without storing its result, or keep existing observers attached while a later caller starts a replacement. Task sharing is useful only when ownership, concurrency, and stale-result behavior are as clear as the lookup itself.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.CompilerServices;

var cache = new Dictionary<string, Task<int>>(StringComparer.Ordinal);
int reads = 0;
Task<int> first = Get("rune");
Task<int> second = Get("rune");
Console.WriteLine(ReferenceEquals(first, second));
Console.WriteLine(string.Join(",", await Task.WhenAll(first, second)));
Console.WriteLine(reads);
Task<int> Get(string key) {
    if (cache.TryGetValue(key, out var existing)) return existing;
    Task<int> created = Read(key);
    cache.Add(key, created);
    return created;
}
async Task<int> Read(string key) {
    reads++;
    await Task.Yield();
    return key.Length;
}

Expected output

True
4,4
1
Try this
Caching a task shares completion, not just a value that exists after work finishes.