public class StockPrices { private const int Count = 100; private List<(string name, decimal price)> _stockPricesCache; // Async version public async Task<decimal> GetStockPriceForAsync(string companyId) { await InitializeMapIfNeededAsync(); return DoGetPriceFromCache(companyId); } // Sync version that calls async init public decimal GetStockPriceFor(string companyId) { InitializeMapIfNeededAsync().GetAwaiter().GetResult(); return DoGetPriceFromCache(companyId); } // Purely sync version public decimal GetPriceFromCacheFor(string companyId) { InitializeMapIfNeeded(); return DoGetPriceFromCache(companyId); } private decimal DoGetPriceFromCache(string name) { foreach (var kvp in _stockPricesCache) { if (kvp.name == name) { return kvp.price; } } throw new InvalidOperationException($"Can't find price for '{name}'."); } [MethodImpl(MethodImplOptions.NoInlining)] private void InitializeMapIfNeeded() { // Similar initialization logic. } private async Task InitializeMapIfNeededAsync() { if (_stockPricesCache != null) { return; } await Task.Delay(42); // Getting the stock prices from the external source. // Generate 1000 items to make cache hit somewhat expensive _stockPricesCache = Enumerable.Range(1, Count) .Select(n => (name: n.ToString(), price: (decimal)n)) .ToList(); _stockPricesCache.Add((name: "MSFT", price: 42)); } }
StockPrices
class StockPrices
stock prices from an external source and allows you to request them through the API. The main difference from the example from the first article is the transition from the dictionary to the list of prices. In order to evaluate the costs of various asynchronous methods in comparison with synchronous ones, the operation itself has to do some work, in our case it is a linear search for stock prices.GetPricesFromCache
method GetPricesFromCache
intentionally built on a simple loop to avoid resource allocation.GetStockPriceForAsync
), a synchronous method that calls the asynchronous initialization method ( GetStockPriceFor
), and a synchronous method that calls the synchronous initialization method. private readonly StockPrices _stockPrices = new StockPrices(); public SyncVsAsyncBenchmark() { // Warming up the cache _stockPrices.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); } [Benchmark] public decimal GetPricesDirectlyFromCache() { return _stockPrices.GetPriceFromCacheFor("MSFT"); } [Benchmark(Baseline = true)] public decimal GetStockPriceFor() { return _stockPrices.GetStockPriceFor("MSFT"); } [Benchmark] public decimal GetStockPriceForAsync() { return _stockPrices.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); }
GetPricesForAsync
runs synchronously in this test and is about 15% (*) slower than the purely synchronous method.GetPricesFor
method, which calls the asynchronous InitializeMapIfNeededAsync
method, has even lower costs, but most surprisingly, it does not allocate resources at all (in the Allocated column in the table above, it is 0 for both GetPricesDirectlyFromCache
and GetStockPriceFor
).InitializeMapIfNeededAsync
no resources were allocated at all? In the first article in this series, I mentioned that the asynchronous method should allocate at least one object in the managed header — the task instance itself. Let's discuss this point in more detail.AsyncMethodBuilder
uses one instance of the task for each successfully completed asynchronous operation . The asynchronous method that Task
returns uses AsyncMethodBuilder
with the following logic in the SetResult
method: // AsyncMethodBuilder.cs from mscorlib public void SetResult() { // Ie the resulting task for all successfully completed // methods is the same -- s_cachedCompleted. m_builder.SetResult(s_cachedCompleted); }
SetResult
method SetResult
called only for successfully completed asynchronous methods, and the successful result for each method based on the Task
can be freely used together . We can even trace this behavior with the following test: [Test] public void AsyncVoidBuilderCachesResultingTask() { var t1 = Foo(); var t2 = Foo(); Assert.AreSame(t1, t2); async Task Foo() { } }
AsyncTaskMethodBuilder<T>
optimizes work in a similar way: it caches tasks for Task<bool>
and some other simple types. For example, it caches all default values ​​for a group of integer types and uses a special cache for Task<int>
, putting values ​​from the range [-1; 9] (for details, see AsyncTaskMethodBuilder<T>.GetTaskForResult()
). [Test] public void AsyncTaskBuilderCachesResultingTask() { // These values are cached Assert.AreSame(Foo(-1), Foo(-1)); Assert.AreSame(Foo(8), Foo(8)); // But these are not Assert.AreNotSame(Foo(9), Foo(9)); Assert.AreNotSame(Foo(int.MaxValue), Foo(int.MaxValue)); async Task<int> Foo(int n) => n; }
Socket
in the corefx repo repository makes extensive use of this method and applies cached tasks wherever possible.ValueTask
ValueTask<T>
(**), a special type of values, similar to the problem; it will not allocate resources if the method is executed synchronously.ValueTask<T>
is a distinguishable combination of T
and Task<T>
: if the “value-task” is completed, then the base value will be used. If the baseline is not yet exhausted, resources will be allocated for the task.ValueTask<T>
, you need to change the return type for GetStockPriceForAsync
: instead of Task<decimal>
must specify ValueTask<decimal>
: public async ValueTask<decimal> GetStockPriceForAsync(string companyId) { await InitializeMapIfNeededAsync(); return DoGetPriceFromCache(companyId); }
[Benchmark] public decimal GetStockPriceWithValueTaskAsync_Await() { return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult(); }
ValueTask
is only slightly faster than the version with Task. The main difference is that heap allocation is prevented. In a minute we will discuss the feasibility of such a transition, but before that I would like to tell you about a clever optimization. public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized(string companyId) { var task = InitializeMapIfNeededAsync(); // Optimizing for acommon case: no async machinery involved. if (task.IsCompleted) { return new ValueTask<decimal>(DoGetPriceFromCache(companyId)); } return DoGetStockPricesForAsync(task, companyId); async ValueTask<decimal> DoGetStockPricesForAsync(Task initializeTask, string localCompanyId) { await initializeTask; return DoGetPriceFromCache(localCompanyId); } }
async
modifier is not used in the GetStockPriceWithValueTaskAsync_Optimized
method; therefore, receiving the task from the InitializeMapIfNeededAsync
method, it checks the status of its execution. If the task is complete, the method simply uses DoGetPriceFromCache
to immediately get the result. If the initialization task is still running, the method calls a local function and waits for the results. public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized2(string companyId) { // Oops! This will lead to a closure allocation at the beginning of the method! var task = InitializeMapIfNeededAsync(); // Optimizing for acommon case: no async machinery involved. if (task.IsCompleted) { return new ValueTask<decimal>(DoGetPriceFromCache(companyId)); } return DoGetStockPricesForAsync(); async ValueTask<decimal> DoGetStockPricesForAsync() { await task; return DoGetPriceFromCache(companyId); } }
public ValueTask<decimal> GetStockPriceWithValueTaskAsync_Optimized(string companyId) { var closure = new __DisplayClass0_0() { __this = this, companyId = companyId, task = InitializeMapIfNeededAsync() }; if (closure.task.IsCompleted) { return ... } // The rest of the code }
InitializeMapIfNeededAsync
and call Task.Yield()
even when the cache is initialized: private async Task InitializeMapIfNeededAsync() { if (_stockPricesCache != null) { await Task.Yield(); return; } // Old initialization logic }
[Benchmark] public decimal GetStockPriceFor_Await() { return _stockPricesThatYield.GetStockPriceFor("MSFT"); } [Benchmark] public decimal GetStockPriceForAsync_Await() { return _stockPricesThatYield.GetStockPriceForAsync("MSFT").GetAwaiter().GetResult(); } [Benchmark] public decimal GetStockPriceWithValueTaskAsync_Await() { return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult(); }
Task<T>
to ValueTask<T>
, cache the task, or make the common execution path synchronous, if possible. You can also try to consolidate your asynchronous operations. This will help improve performance, simplify debugging and code analysis in general. Not every small piece of code should be asynchronous.Source: https://habr.com/ru/post/420627/
All Articles