- Item 27: Use Async Methods for Async Work
- Item 28: Never Write async void Methods
- Item 29: Avoid Composing Synchronous and Asynchronous Methods
- Item 30: Use Async Methods to Avoid Thread Allocations and Context Switches
- Item 31: Avoid Marshalling Context Unnecessarily
- Item 32: Compose Asynchronous Work Using Task Objects
- Item 33: Consider Implementing the Task Cancellation Protocol
- Item 34: Cache Generalized Async Return Types
Item 34: Cache Generalized Async Return Types
Every item that discussed the task asynchronous programming model has used the Task or Task<T> type for the return type of the asynchronous code. They are the most common types you’ll use for the return type on asynchronous work. Sometimes, however, the Task types introduce performance bottlenecks in your code. If you make asynchronous calls in a tight loop or in hot code paths, the Task class may be expensive to allocate and use for your asynchronous methods. The C# 7 language does not force you to use Task or Task<T> as the return types for asynchronous methods, but instead demands that a method with the async modifier must return a type that conforms to the Awaiter pattern. It must have an accessible GetAwaiter() method that returns an object that implements the INotifyCompletion and ICriticalNotifyCompletion interfaces. This accessible GetAwaiter() method may be provided by an extension method.
The latest release of the .NET Framework includes a new ValueTask<T> type that can be more efficient to use. This type is a value type, so it does not require an additional allocation—a factor that reduces collection pressure. The ValueTask<T> type is best suited for idioms where your asynchronous method may be retrieving cached results.
As an example, consider this method that checks weather data:
public async Task<IEnumerable<WeatherData>>
RetrieveHistoricalData(DateTime start, DateTime end)
{
var observationDate = this.startDate;
var results = new List<WeatherData>();
while (observationDate < this.endDate)
{
var observation = await RetrieveObservationData(
observationDate);
results.Add(observation);
observationDate += TimeSpan.FromDays(1);
}
return results;
}
As implemented, it calls the network every time it is called. If this method is part of a widget in a phone app that displays a brief status every minute, the app’s operation will be very inefficient—weather information doesn’t change quite that fast. You decide to cache the results for up to 5 minutes. Using Task, that implementation would look like this:
private List<WeatherData> recentObservations =
new List<WeatherData>();
private DateTime lastReading;
public async Task<IEnumerable<WeatherData>>
RetrieveHistoricalData()
{
if (DateTime.Now - lastReading > TimeSpan.FromMinutes(5))
{
recentObservations = new List<WeatherData>();
var observationDate = this.startDate;
while (observationDate < this.endDate)
{
var observation = await RetrieveObservationData(
observationDate);
recentObservations.Add(observation);
observationDate += TimeSpan.FromDays(1);
}
lastReading = DateTime.Now;
}
return recentObservations;
}
In many cases, that change will probably be sufficient to improve performance. The network latency is the most impactful bottleneck in this code.
But now suppose this widget runs in a very memory-constrained environment. In this case, you want to avoid the object allocations each time the method is called. That’s when you would switch to using the ValueTask type. Here’s how that implementation would look:
public ValueTask<IEnumerable<WeatherData>>
RetrieveHistoricalData()
{
if (DateTime.Now - lastReading > TimeSpan.FromMinutes(5))
{
return new ValueTask<IEnumerable<WeatherData>>
(recentObservations);
}
else
{
async Task<IEnumerable<WeatherData>> loadCache()
{
recentObservations = new List<WeatherData>();
var observationDate = this.startDate;
while (observationDate < this.endDate)
{
var observation = await
RetrieveObservationData(observationDate);
recentObservations.Add(observation);
observationDate += TimeSpan.FromDays(1);
}
lastReading = DateTime.Now;
return recentObservations;
}
return new ValueTask<IEnumerable<WeatherData>>
(loadCache());
}
}
This method includes several important idioms that you should use when you incorporate ValueTask. First, the method is not an async method, but rather returns a ValueTask. The nested function that performs the asynchronous work relies on the async modifier. It indicates that your program doesn’t do the extra state machine management and allocation if the cache is valid. Second, notice that ValueTask has a constructor that takes a Task as its argument. It will do the awaiting work internally.
The ValueTask type enables you to implement optimizations when your performance measurements indicate that memory allocations for Task objects are creating bottlenecks in your code. You’ll still likely use the Task types for most of your asynchronous methods. In fact, I’d recommend using Task and Task<T> for all asynchronous methods until you’ve measured and found memory allocations to be a bottleneck. The conversion to a value type is not difficult, and it can be implemented when you discover that change will fix your performance issues.