Programación asíncrona C #: ¿Cómo le va con el rendimiento?

Más recientemente, ya hablamos sobre si anular Equals y GetHashCode al programar en C #. Hoy nos ocuparemos de los parámetros de rendimiento de los métodos asincrónicos. Únete ahora!



En los últimos dos artículos en el blog msdn, observamos la estructura interna de los métodos asincrónicos en C # y los puntos de extensión que el compilador de C # proporciona para controlar el comportamiento de los métodos asincrónicos.

Según la información del primer artículo, el compilador realiza muchas transformaciones para hacer que la programación asincrónica sea lo más parecida posible a la sincrónica. Para hacer esto, crea una instancia de la máquina de estados, se la pasa al constructor del método asincrónico, que llama al objeto camarero para la tarea, etc. Por supuesto, esa lógica tiene un precio, pero ¿cuánto nos cuesta?

Hasta que apareció la biblioteca TPL, las operaciones asincrónicas no se utilizaron en una cantidad tan grande, por lo tanto, los costos no eran altos. Pero hoy, incluso una aplicación relativamente simple puede realizar cientos, si no miles, de operaciones asincrónicas por segundo. La biblioteca de tareas paralelas de TPL se creó con esa carga de trabajo en mente, pero aquí no hay magia y hay que pagar por todo.

Para estimar los costos de los métodos asincrónicos, utilizaremos un ejemplo ligeramente modificado del primer artículo.

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)); } } 

La clase StockPrices los precios de las acciones de una fuente externa y le permite solicitarlos a través de la API. La principal diferencia con el ejemplo del primer artículo es la transición de un diccionario a una lista de precios. Para estimar los costos de varios métodos asincrónicos en comparación con los métodos sincrónicos, la operación en sí misma debe hacer un cierto trabajo, en nuestro caso, es una búsqueda lineal de precios de acciones.

El método GetPricesFromCache construye intencionalmente alrededor de un bucle simple para evitar la asignación de recursos.

Comparación de métodos sincrónicos y métodos asincrónicos basados ​​en tareas


En la primera prueba de rendimiento, comparamos el método asincrónico que llama al método de inicialización asincrónica ( GetStockPriceForAsync ), el método síncrono que llama al método de inicialización asincrónica ( GetStockPriceFor ) y el método síncrono que llama al método de inicialización síncrona.

 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(); } 

Los resultados se muestran a continuación:



Ya en esta etapa recibimos datos bastante interesantes:

  • El método asincrónico es bastante rápido. GetPricesForAsync ejecuta sincrónicamente en esta prueba y es aproximadamente un 15% (*) más lento que el método puramente sincrónico.
  • El método sincrónico GetPricesFor , que llama al método asincrónico InitializeMapIfNeededAsync , tiene costos aún más bajos, pero lo más sorprendente es que no asigna recursos en absoluto (en la columna Asignada en la tabla anterior, cuesta 0 para GetPricesDirectlyFromCache y GetStockPriceFor ).

(*) Por supuesto, no se puede decir que los costos de ejecutar sincrónicamente el método asincrónico son del 15% para todos los casos posibles. Este valor depende directamente de la carga de trabajo realizada por el método. La diferencia entre la sobrecarga de una invocación pura de un método asincrónico (que no hace nada) y un método síncrono (que no hace nada) será enorme. La idea de esta prueba comparativa es mostrar que los costos del método asincrónico, que realiza una cantidad relativamente pequeña de trabajo, son relativamente bajos.

¿Cómo es que cuando llamas a InitializeMapIfNeededAsync , los recursos no se asignan en absoluto? En el primer artículo de esta serie, mencioné que un método asincrónico debería asignar al menos un objeto en el encabezado administrado: la instancia de la tarea misma. Analicemos este punto con más detalle.

Optimización n. ° 1: instancias de tareas de almacenamiento en caché cuando sea posible


La respuesta a la pregunta anterior es muy simple: AsyncMethodBuilder utiliza una instancia de la tarea para cada operación asincrónica completada con éxito . El método asincrónico que devuelve Task utiliza AsyncMethodBuilder con la siguiente lógica en el método SetResult :

 // 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); } 

El método SetResult llama solo para métodos asincrónicos completados con éxito, y un resultado exitoso para cada método basado en Task se puede usar libremente juntos . Incluso podemos rastrear este comportamiento con la siguiente prueba:

 [Test] public void AsyncVoidBuilderCachesResultingTask() { var t1 = Foo(); var t2 = Foo(); Assert.AreSame(t1, t2); async Task Foo() { } } 

Pero esta no es la única optimización posible. AsyncTaskMethodBuilder<T> optimiza el trabajo de manera similar: almacena en caché las tareas para la Task<bool> y algunos otros tipos simples. Por ejemplo, almacena en caché todos los valores predeterminados para un grupo de tipos enteros y utiliza una memoria caché especial para la Task<int> , colocando valores del rango [-1; 9] (para más detalles, vea AsyncTaskMethodBuilder<T>.GetTaskForResult() ).

Esto se confirma con la siguiente prueba:

 [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; } 

No confíe demasiado en ese comportamiento , pero siempre es bueno darse cuenta de que los creadores del lenguaje y la plataforma están haciendo todo lo posible para aumentar la productividad en todas las formas disponibles. El almacenamiento en caché de tareas es un método de optimización popular que también se usa en otras áreas. Por ejemplo, una nueva implementación de Socket en el repositorio de repositorios corefx hace un uso extensivo de este método y aplica tareas en caché siempre que sea ​​posible.

Optimización # 2: Uso de ValueTask


El método de optimización descrito anteriormente solo funciona en algunos casos. Por lo tanto, en lugar de ello, podemos usar ValueTask<T> (**), un tipo especial de valor similar a la tarea; no asignará recursos si el método se ejecuta sincrónicamente.

ValueTask<T> es una combinación distinguible de T y Task<T> : si se completa la "tarea de valor", se utilizará el valor base. Si la asignación básica aún no se ha agotado, se asignarán recursos para la tarea.

Este tipo especial ayuda a evitar el aprovisionamiento excesivo de almacenamiento dinámico cuando se realiza una operación sincrónicamente. Para usar ValueTask<T> , debe cambiar el tipo de retorno para GetStockPriceForAsync : en lugar de Task<decimal> debe especificar ValueTask<decimal> :

 public async ValueTask<decimal> GetStockPriceForAsync(string companyId) { await InitializeMapIfNeededAsync(); return DoGetPriceFromCache(companyId); } 

Ahora podemos evaluar la diferencia usando una prueba comparativa adicional:

 [Benchmark] public decimal GetStockPriceWithValueTaskAsync_Await() { return _stockPricesThatYield.GetStockPriceValueTaskForAsync("MSFT").GetAwaiter().GetResult(); } 



Como puede ver, la versión con ValueTask es solo un poco más rápida que la versión con Task. La principal diferencia es que se evita la asignación del montón. En un minuto discutiremos la viabilidad de tal transición, pero antes de eso me gustaría hablar sobre una optimización difícil.

Optimización No. 3: abandono de métodos asincrónicos dentro de una ruta común


Si a menudo usa algún método asincrónico y desea reducir los costos aún más, le sugiero la siguiente optimización: elimine el modificador asincrónico y luego verifique el estado de la tarea dentro del método y realice toda la operación de forma sincrónica, abandonando por completo los enfoques asincrónicos.

¿Parece complicado? Considera un ejemplo.

 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); } } 

En este caso, el modificador async no se usa en el método GetStockPriceWithValueTaskAsync_Optimized , por lo que cuando recibe una tarea del método InitializeMapIfNeededAsync , verifica su estado de ejecución. Si la tarea se completa, el método simplemente usa DoGetPriceFromCache para obtener el resultado inmediatamente. Si la tarea de inicialización aún está en progreso, el método llama a una función local y espera los resultados.

Usar una función local no es la única, sino una de las formas más fáciles. Pero hay una advertencia. Durante la implementación más natural, la función local recibirá un estado externo (variable local y argumento):

 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); } } 

Pero, desafortunadamente, debido a un error del compilador, este código generará un cierre, incluso si el método se ejecuta dentro de la ruta común. Así es como se ve este método desde adentro:

 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 } 

Como se discutió en el artículo Disección de las funciones locales en C # , el compilador utiliza una instancia común de cierre para todas las variables y argumentos locales en un área específica. En consecuencia, tiene cierto sentido en dicha generación de código, pero hace que toda la lucha con la asignación de montones sea inútil.

CONSEJO Tal optimización es una cosa muy insidiosa. Los beneficios son insignificantes, e incluso si escribe la función local original correcta , puede obtener accidentalmente un estado externo que hace que se asigne el montón. Todavía puede recurrir a la optimización si trabaja con una biblioteca de uso común (por ejemplo, BCL) en un método que definitivamente se utilizará en una sección de código cargada.

Costos asociados con la espera de una tarea.


Por el momento, hemos considerado solo un caso específico: la sobrecarga de un método asincrónico que se ejecuta sincrónicamente. Esto se hace a propósito. Cuanto más pequeño es el método asincrónico, más notables son los costos en su rendimiento general. Los métodos asincrónicos más detallados, como regla, se ejecutan sincrónicamente y realizan una carga de trabajo más pequeña. Y generalmente los llamamos con más frecuencia.

Pero debemos ser conscientes de los costos del mecanismo asincrónico cuando el método "espera" la finalización de una tarea pendiente. Para estimar estos costos, realizaremos cambios en InitializeMapIfNeededAsync y llamaremos a Task.Yield() incluso cuando se inicialice el caché:

 private async Task InitializeMapIfNeededAsync() { if (_stockPricesCache != null) { await Task.Yield(); return; } // Old initialization logic } 

Agregamos los siguientes métodos a nuestro paquete de referencia para pruebas comparativas:

 [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(); } 



Como puede ver, la diferencia es palpable, tanto en términos de velocidad como en términos de uso de memoria. Explica brevemente los resultados.

  • Cada operación en espera para una tarea inacabada toma aproximadamente 4 microsegundos y asigna casi 300 bytes (**) para cada llamada. Es por eso que GetStockPriceFor se ejecuta casi el doble de rápido que GetStockPriceForAsync y asigna menos memoria.
  • Un método asincrónico basado en ValueTask tarda un poco más que la variante con Task, cuando este método no se ejecuta sincrónicamente. Una máquina de estado de un método basado en ValueTask <T> debería almacenar más datos que una máquina de estado de un método basado en la Tarea <T>.

(**) Depende de la plataforma (x64 o x86) y varias variables locales y argumentos del método asincrónico.

Método asincrónico Rendimiento 101


  • Si el método asincrónico se ejecuta sincrónicamente, la sobrecarga es bastante pequeña.
  • Si el método asincrónico se ejecuta sincrónicamente, se produce la siguiente sobrecarga de memoria: para los métodos de Tarea asíncrona, no hay sobrecarga, y para los métodos de Tarea asíncrona <T>, la saturación es de 88 bytes por operación (para plataformas x64).
  • ValueTask <T> elimina la sobrecarga antes mencionada para los métodos asincrónicos ejecutados sincrónicamente.
  • Cuando un método asincrónico basado en ValueTask <T> se ejecuta sincrónicamente, lleva un poco menos de tiempo que el método con la Tarea <T>, de lo contrario, existen ligeras diferencias a favor de la segunda opción.
  • La sobrecarga de rendimiento para los métodos asincrónicos que esperan completar una tarea sin terminar es significativamente mayor (aproximadamente 300 bytes por operación para plataformas x64).

Por supuesto, las medidas son nuestro todo. Si ve que una operación asincrónica está causando problemas de rendimiento, puede cambiar de la Task<T> a ValueTask<T> , almacenar en caché la tarea o hacer que la ruta de ejecución general sea síncrona, si es posible. También puede intentar agregar sus operaciones asincrónicas. Esto ayudará a mejorar el rendimiento, simplificar la depuración y el análisis de código en general. No todas las piezas pequeñas de código deben ser asíncronas.

Source: https://habr.com/ru/post/es420627/


All Articles