Multiproceso .NET: cuando falta rendimiento



La plataforma .NET proporciona muchas primitivas de sincronización preconstruidas y colecciones seguras para subprocesos. Si necesita implementar, por ejemplo, una memoria caché segura para subprocesos o una cola de solicitudes al desarrollar una aplicación, generalmente se usan estas soluciones listas, a veces varias a la vez. En algunos casos, esto lleva a problemas de rendimiento: una larga espera en bloqueos, consumo excesivo de memoria y larga recolección de basura.

Estos problemas pueden resolverse si tenemos en cuenta que las soluciones estándar se hacen bastante generales: pueden tener una sobrecarga en nuestros escenarios que es redundante. En consecuencia, puede escribir, por ejemplo, su propia colección efectiva segura para subprocesos para un caso específico.

Debajo de la escena hay un video y una transcripción de mi informe de la conferencia DotNext , donde analizo algunos ejemplos cuando uso herramientas de la biblioteca estándar .NET (Task.Delay, SemaphoreSlim, ConcurrentDictionary) condujo a caídas en el rendimiento, y propongo soluciones adaptadas para tareas específicas y carentes de Estas deficiencias.


En el momento del informe, él trabajaba en Kontur. Kontur desarrolla varias aplicaciones para negocios, y el equipo con el que trabajé se ocupa de la infraestructura y desarrolla varios servicios de soporte y bibliotecas para ayudar a los desarrolladores de otros equipos a crear servicios de productos.

El equipo de Infraestructura construye su almacén de datos, un sistema de alojamiento de aplicaciones para Windows y varias bibliotecas para el desarrollo de microservicios. Nuestras aplicaciones se basan en la arquitectura de microservicios: todos los servicios interactúan entre sí a través de la red y, por supuesto, utilizan una gran cantidad de código asincrónico y multiproceso. Algunas de estas aplicaciones son bastante críticas para el rendimiento; necesitan poder manejar muchas solicitudes.

¿De qué vamos a hablar hoy?

  • Multithreading y asincronía en .NET;
  • Relleno de primitivas de sincronización y colecciones;
  • ¿Qué hacer si los enfoques estándar no pueden hacer frente a la carga?

Analicemos algunas características de trabajar con código multiproceso y asíncrono en .NET. Veamos algunas primitivas de sincronización y colecciones concurrentes, veamos cómo se organizan en su interior. Discutiremos qué hacer si no hay suficiente rendimiento, si las clases estándar no pueden hacer frente a la carga y si se puede hacer algo en esta situación.

Te contaré cuatro historias que sucedieron en nuestro sitio de producción.

Historial 1: Tarea, Retardo y Temporizador


Esta historia ya es bastante conocida, incluso sobre ella en DotNext anterior. Sin embargo, obtuvo una secuela bastante interesante, así que la agregué. Entonces, ¿cuál es el punto?

1.1 Encuestas y encuestas largas


El servidor realiza operaciones largas, el cliente las espera.
Sondeo: el cliente pregunta periódicamente al servidor sobre el resultado.
Sondeo largo: el cliente envía una solicitud con un tiempo de espera prolongado y el servidor responde cuando se completa la operación.

Ventajas:

  • Menos tráfico
  • El cliente aprende sobre el resultado más rápido.

Imagine que tenemos un servidor que puede manejar algunas solicitudes largas, por ejemplo, una aplicación que convierte archivos XML a PDF, y hay clientes que ejecutan estas tareas para el procesamiento y desean esperar su resultado de forma asincrónica. ¿Cómo puede realizarse tal expectativa?

La primera forma es el sondeo . El cliente inicia la tarea en el servidor, luego verifica periódicamente el estado de esta tarea, mientras el servidor devuelve el estado de la tarea ("completado" / "fallido" / "completado con un error"). El cliente envía periódicamente solicitudes hasta que aparece el resultado.

La segunda forma es una larga votación . La diferencia aquí es que el cliente envía solicitudes con largos tiempos de espera. El servidor, al recibir dicha solicitud, no informará de inmediato que la tarea no se ha completado, pero intentará esperar un momento para que aparezca el resultado.
Entonces, ¿cuál es la ventaja de las encuestas largas sobre las encuestas regulares? En primer lugar, se genera menos tráfico. Hacemos menos solicitudes de red: se persigue menos tráfico a través de la red. Además, el cliente podrá conocer el resultado más rápido que con un sondeo regular, ya que no necesita esperar el intervalo entre varias solicitudes de sondeo. Lo que queremos obtener es comprensible. ¿Cómo implementaremos esto en el código?
Tarea: tiempo de espera
Queremos esperar a la tarea con un tiempo de espera
esperar SendAsync ();
Por ejemplo, tenemos una Tarea que envía una solicitud al servidor y queremos esperar su resultado con un tiempo de espera, es decir, devolveremos el resultado de esta Tarea o enviaremos algún tipo de error. El código C # se verá así:

var sendTask = SendAsync(); var delayTask = Task.Delay(timeout); var task = await Task.WhenAny(sendTask, delayTask); if (task == delayTask) return Timeout; 

Este código lanza nuestra tarea, cuyo resultado queremos esperar, y Task.Delay. Luego, usando Task.WhenAny, estamos esperando ya sea nuestra Task o Task.Delay. Si resulta que Task.Delay se ejecuta primero, entonces se acabó el tiempo y tenemos un tiempo de espera, debemos devolver un error.

Este código, por supuesto, no es perfecto y se puede mejorar. Por ejemplo, no estaría mal cancelar Task.Delay si SendAsync regresó antes, pero esto no es muy interesante para nosotros ahora. La conclusión es que si escribimos dicho código y lo aplicamos para encuestas largas con tiempos de espera prolongados, tendremos algunos problemas de rendimiento.

1.2 Problemas con encuestas largas


  • Grandes tiempos de espera
  • Muchas consultas concurrentes
  • => Alta utilización de CPU

En este caso, el problema es el alto consumo de recursos del procesador. Puede suceder que el procesador esté completamente cargado al 100%, y la aplicación generalmente deja de funcionar. Parece que no consumimos los recursos del procesador en absoluto: hacemos algunas operaciones asincrónicas, esperamos una respuesta del servidor y el procesador todavía está cargado con nosotros.

Cuando nos enfrentamos a esta situación, eliminamos un volcado de memoria de nuestra aplicación:

  ~*e!clrstack System.Threading.Monitor.Enter(System.Object) System.Threading.TimerQueueTimer.Change(…) System.Threading.Timer.TimerSetup(…) System.Threading.Timer..ctor(…) System.Threading.Tasks.Task.Delay(…) 

Para analizar el volcado, utilizamos la herramienta WinDbg. Ingresamos un comando que muestra los rastros de la pila de todos los subprocesos administrados y vimos ese resultado. Tenemos muchos subprocesos en proceso que esperan algún bloqueo. El método Monitor.Enter es en lo que se expande la construcción de bloqueo en C #. Este bloqueo se captura dentro de las clases llamadas Timer y TimerQueueTimer. En Timer, venimos de Task.Delay cuando intentamos crearlos. Que es Cuando se inicia Task.Delay, se captura el bloqueo dentro de TimerQueue.

1.3 Bloquear convoy


  • Muchos hilos intentan bloquear una cerradura
  • Debajo del candado, se ejecuta un pequeño código
  • Se dedica tiempo a la sincronización de subprocesos, no a la ejecución de código.
  • Los bloques de hilos están bloqueados, no son infinitos

Teníamos un convoy de bloqueo en la aplicación. Muchos hilos intentan capturar el mismo bloqueo. Bajo este bloqueo, se ejecuta bastante código. Los recursos del procesador aquí no se gastan en el código de la aplicación en sí, sino en operaciones para sincronizar subprocesos entre ellos en este bloqueo. También vale la pena señalar una característica relacionada con .NET: los hilos que participan en el convoy de bloqueo son hilos del grupo de hilos.

En consecuencia, si los subprocesos del conjunto de subprocesos están bloqueados, pueden finalizar: el número de subprocesos en el conjunto de subprocesos es limitado. Se puede configurar, pero todavía hay un límite superior. Una vez alcanzado, todos los hilos del conjunto de subprocesos participarán en el convoy de bloqueo, y cualquier código que involucre al conjunto de subprocesos dejará de ejecutarse en la aplicación. Esto empeora mucho la situación.

1.4 TimerQueue


  • Gestiona temporizadores en una aplicación .NET.
  • Los temporizadores se usan en:
    - Tarea. Retraso
    - CancellationTocken.CancelAfter
    - HttpClient

TimerQueue es una clase que administra todos los temporizadores en una aplicación .NET. Si una vez programó en WinForms, es posible que haya creado temporizadores manualmente. Para aquellos que no saben qué son los temporizadores: se usan en Task.Delay (este es solo nuestro caso), también se usan dentro de CancellationToken, en el método CancelAfter. Es decir, reemplazar Task.Delay con CancellationToken.CancelAfter no nos ayudaría de ninguna manera. Además, los temporizadores se usan en muchas clases internas de .NET, por ejemplo, en HttpClient.

Hasta donde yo sé, algunas implementaciones de controladores HttpClient tienen temporizadores. Incluso si no los usa explícitamente, no inicie Task.Delay, lo más probable es que los use de todos modos.

Ahora veamos cómo se organiza TimerQueue en su interior.

  • Estado global (por dominio de aplicación):
    - Lista doblemente vinculada de TimerQueueTimer
    - Bloquear objeto
  • Devolución de llamada de temporizador de rutina
  • Temporizadores no ordenados por tiempo de respuesta
  • Agregar un temporizador: O (1) + bloqueo
  • Eliminación del temporizador: O (1) + bloqueo
  • Temporizadores de inicio: O (N) + bloqueo

Dentro de TimerQueue hay un estado global, es una lista doblemente vinculada de objetos de tipo TimerQueueTimer. TimerQueueTimer contiene un enlace a otro TimerQueueTimer, contiguo a una lista vinculada, también contiene la hora del temporizador y la devolución de llamada, que se llamará cuando se active el temporizador. Esta lista doblemente vinculada está protegida por un objeto de bloqueo, justo en el que ocurrió el convoy de bloqueo en nuestra aplicación. También dentro de TimerQueue hay una rutina que lanza devoluciones de llamada vinculadas a nuestros temporizadores.

Los temporizadores no están ordenados por tiempo de respuesta, toda la estructura está optimizada para agregar / eliminar nuevos temporizadores. Cuando se inicia Rutina, recorre toda la lista doblemente vinculada, selecciona los temporizadores que deberían funcionar y los devuelve la llamada.

La complejidad de la operación aquí es tal. Agregar o quitar un temporizador ocurre O por unidad, y el inicio de los temporizadores ocurre por línea. Además, si todo es aceptable con la complejidad algorítmica, hay un problema: todas estas operaciones capturan el bloqueo, lo que no es muy bueno.

¿Qué situación puede pasar? Tenemos demasiados temporizadores acumulados en TimerQueue, por lo que cuando se inicia Routine, bloquea su larga operación lineal, en ese momento aquellos que intentan iniciar o eliminar temporizadores de TimerQueue no pueden hacer nada al respecto. Debido a esto, se produce el bloqueo de convoy. Este problema se ha solucionado en .NET Core.
Reducir la contención de bloqueo del temporizador (coreclr # 14527)
  • Fragmento de bloqueo
    - Environment.ProcessorCount TimerQueue's TimerQueueTimer
  • Colas separadas para temporizadores de corta / larga vida
  • Temporizador corto: tiempo <= 1/3 segundo

https://github.com/dotnet/coreclr/issues/14462
https://github.com/dotnet/coreclr/pull/14527
¿Cómo se solucionó? Asaltaron TimerQueue: en lugar de un TimerQueue, que era estático para todo el AppDomain, para toda la aplicación, se hicieron varios TimerQueue. Cuando los hilos llegan allí e intentan iniciar sus temporizadores, estos temporizadores caerán en un TimerQueue aleatorio, y los hilos tendrán menos posibilidades de colisionar en una cerradura.

También en .NET Core aplicó algunas optimizaciones. Los temporizadores se dividieron en de larga duración y de corta duración, ahora se usan TimerQueue separados para ellos. El temporizador de corta duración se selecciona para que sea inferior a 1/3 de segundo. No sé por qué se eligió esa constante. En .NET Core, no pudimos detectar problemas con los temporizadores.



https://github.com/Microsoft/dotnet-framework-early-access/blob/master/release-notes/NET48/dotnet-48-changes.md
https://github.com/dotnet/coreclr/labels/netfx-port-consider

Esta revisión se hizo backport a .NET Framework, versión 4.8. La etiqueta netfx-port-consider se indica en el enlace anterior, si va al repositorio de .NET Core, CoreCLR, CoreFX, puede buscar este problema que se transferirá a .NET Framework, ahora hay alrededor de cincuenta de ellos. Es decir, el .NET de código abierto ayudó mucho, se corrigieron bastantes errores. Puede leer el registro de cambios .NET Framework 4.8: se han corregido muchos errores, mucho más que en otras versiones de .NET. Curiosamente, esta solución está desactivada de forma predeterminada en .NET Framework 4.8. Se incluye en todo el archivo que conoce llamado App.config

La configuración en App.config que habilita esta corrección se llama UseNetCoreTimer. Antes de que apareciera .NET Framework 4.8, para que nuestra aplicación funcionara y no entrara en el convoy de bloqueo, tenía que usar su implementación de Task.Delay. En él, intentamos usar un montón binario para comprender de manera más eficiente qué temporizadores deberían llamarse ahora.

1.5 Tarea. Retraso: implementación nativa


  • Binaryheap
  • Sharding
  • Ayudó, pero no en todos los casos.

El uso de un almacenamiento dinámico binario le permite optimizar la Rutina, que llama a las devoluciones de llamada, pero empeora el tiempo que lleva eliminar un temporizador arbitrario de la cola; para esto necesita reconstruir el almacenamiento dinámico. Esto es muy probable por qué .NET utiliza una lista doblemente vinculada. Por supuesto, solo usar un montón binario no nos ayudaría aquí, también tuvimos que resolver TimerQueue. Esta solución funcionó durante un tiempo, pero aún así todo volvió a caer en el convoy de bloqueo debido al hecho de que los temporizadores se usan no solo donde se ejecutan explícitamente en el código, sino también en bibliotecas de terceros y código .NET. Para solucionar completamente este problema, debe actualizar a .NET Framework versión 4.8 y habilitar la solución de los desarrolladores de .NET.

1.6 Tarea. Retraso: conclusiones


  • Errores en todas partes, incluso en las cosas más utilizadas
  • Haz pruebas de estrés
  • Cambie a Core, obtenga correcciones de errores (y nuevos errores) primero :)

¿Cuáles son las conclusiones de toda esta historia? En primer lugar, las trampas se pueden ubicar realmente en todas partes, incluso en las clases que usa todos los días sin pensar, por ejemplo, en la misma Tarea, Tarea. Retraso.

Recomiendo realizar pruebas de estrés de sus propuestas. Este problema lo acabamos de identificar en la etapa de prueba de carga. Luego lo filmamos varias veces en producción en otras aplicaciones, pero, sin embargo, las pruebas de estrés nos ayudaron a retrasar el tiempo antes de encontrar este problema en la realidad.

Cambie a .NET Core: será el primero en recibir correcciones de errores (y nuevos errores). ¿Dónde sin nuevos errores?

La historia sobre los temporizadores ha terminado y pasamos a la siguiente.

Historia 2: SemaphoreSlim


La siguiente historia es sobre el conocido SemaphoreSlim.

2.1 Limitación del servidor


  • Se requiere limitar el número de solicitudes procesadas simultáneamente en el servidor

Queríamos implementar la limitación en el servidor. Que es esto Probablemente todos conozcan la aceleración de la CPU: cuando el procesador se sobrecalienta, disminuye su frecuencia para enfriarse, y esto limita su rendimiento. Entonces está aquí. Sabemos que nuestro servidor puede procesar N solicitudes en paralelo y no caerse. Que queremos hacer Limite el número de solicitudes procesadas simultáneamente a esta constante y asegúrese de que, si llegan más solicitudes, hagan cola y esperen hasta que se ejecuten las solicitudes anteriores. ¿Cómo se puede resolver este problema? Es necesario utilizar algún tipo de sincronización primitiva.

Semaphore es una primitiva de sincronización en la que puede esperar N veces, después de lo cual el que llega N + primero y así sucesivamente esperará hasta que aquellos que ingresaron antes liberen Semaphore. Resulta algo como esto: dos hilos de ejecución, dos trabajadores fueron bajo Semáforo, el resto estaba en línea.



Por supuesto, es solo que Semaphore no es muy adecuado para nosotros, está en .NET sincrónico, así que tomamos SemaphoreSlim y escribimos este código:

 var semaphore = new SemaphoreSlim(N); … await semaphore.WaitAsync(); await HandleRequestAsync(request); semaphore.Release(); 

Creamos SemaphoreSlim, espere, bajo Semaphore procesamos su solicitud, luego de eso lanzamos Semaphore. Parece que esta es una implementación ideal de la limitación del servidor, y ya no puede ser mejor. Pero todo es mucho más complicado.

2.2 Regulación del servidor: complicación


  • Procesando solicitudes en orden LIFO
  • SemáforoSlim
  • Pila concurrente
  • TaskCompletionSource

Nos olvidamos un poco de la lógica empresarial. Las solicitudes que llegan a la aceleración son solicitudes http reales. Como regla, tienen un tiempo de espera establecido por aquellos que enviaron esta solicitud automáticamente o un tiempo de espera del usuario que presiona F5 después de un tiempo. En consecuencia, si procesa solicitudes en un orden de cola, como un semáforo normal, entonces, en primer lugar, las solicitudes de la cola que han excedido el tiempo de espera ya pueden procesarse. Si trabaja en orden de pila, primero procese las solicitudes que llegaron por última vez, tal problema no surgirá.

Además de SemaphoreSlim, tuvimos que usar ConcurrentStack, TaskCompletionSource, para envolver mucho código alrededor de todo esto, de modo que todo funcionara en el orden que necesitábamos. TaskCompletionSource es tal cosa, que es similar a CancellationTokenSource, pero no para CancellationToken, sino para Task. Puede crear una TaskCompletionSource, extraer una Tarea de ella, entregarla y luego decirle a TaskCompletionSource que necesita establecer el resultado para esta Tarea, y aquellos que estén esperando esta Tarea se enterarán de este resultado.

Todos lo hemos implementado. El código es horrible. y, lo peor de todo, resultó no funcionar.

Unos meses después del inicio de su uso en una aplicación bastante cargada, encontramos un problema. De la misma manera que en el caso anterior, el consumo de CPU ha aumentado al 100%. Hicimos lo mismo, retiramos el basurero, lo miramos en WinDbg y nuevamente encontramos el convoy de la cerradura.



Esta vez, el convoy de bloqueo se produjo dentro de SemaphoreSlim.WaitAsync y SemaphoreSlim.Release. Resultó que hay un bloqueo dentro de SemaphoreSlim, no está libre de bloqueo. Esto resultó ser un inconveniente bastante serio para nosotros.



Dentro de SemaphoreSlim hay un estado interno (un contador de cuántos trabajadores aún pueden pasar por debajo de él), y una lista doblemente vinculada de aquellos que esperan en este Semáforo. Las ideas aquí son más o menos las mismas: puede esperar en este semáforo, puede cancelar su expectativa para abandonar esta fila. Hay una cerradura que acaba de arruinar nuestras vidas.

Decidimos: abajo con todo el terrible código que teníamos que escribir.



Escribamos nuestro semáforo, que estará inmediatamente libre de bloqueos y que funcionará inmediatamente en orden de pila. Cancelar la espera no es importante para nosotros.



Define esta condición. Aquí estará el número currentCount: esta es la cantidad de lugares que quedan en el semáforo. Si no quedan asientos en Semaphore, este número será negativo y mostrará cuántos trabajadores hay en la cola. También habrá un ConcurrentStack, que consiste en TaskCompletionSource'ov, que es solo una pila de waiter'ov del que se extraerán si es necesario. Escribamos el método WaitAsync.

 var decrementedCount = Interlocked.Decrement(ref currentCount); if (decrementedCount >= 0) return Task.CompletedTask; var waiter = new TaskCompletionSource<bool>(); waiters.Push(waiter); return waiter.Task; 

Primero, disminuimos el contador, tomamos un lugar en el semáforo para nosotros, si tuviéramos lugares libres, y luego decimos: "Eso es, te metiste debajo del semáforo".

Si no había lugares en Semaphore, creamos una TaskCompletionSource, la lanzamos a la pila de waiter'ov y devolvemos la Task al mundo exterior. Cuando llegue el momento, esta tarea funcionará, y el trabajador podrá continuar su trabajo y pasará a Semáforo.

Ahora escribamos el método Release.

 var countBefore = Interlocked.Increment(ref currentCount) - 1; if (countBefore < 0) { if (waiters.TryPop(out var waiter)) waiter.TrySetResult(true); } 

El método de lanzamiento es el siguiente:

  • Un asiento libre en semáforo
  • Incremento currentCount

Si podemos saber por currentCount si hay un camarero dentro de la pila sobre el que necesitamos señalizar, sacamos a dicho camarero de la pila y la señal. Aquí el camarero es una TaskCompletionSource. Pregunta a este código: parece ser lógico, pero ¿funciona? Que problemas hay Hay un matiz relacionado con dónde se inician continuation'y y TaskCompletionSource'y.



Considera este código. Creamos una TaskCompletionSource y lanzamos dos Task's. La primera tarea muestra una unidad, establece el resultado en una TaskCompletionSource y luego muestra un deuce en la consola. La segunda tarea espera en este TaskCompletionSource, en su tarea, y luego bloquea para siempre su hilo del grupo de hilos.

¿Qué pasará aquí? La tarea 2 en la compilación se dividirá en dos métodos, el segundo de los cuales es una continuación que contiene Thread.Sleep. Después de configurar el resultado de TaskCompletionSource, esta continuación se ejecutará en el mismo hilo en el que se ejecutó la primera tarea. En consecuencia, el flujo de la primera Tarea se bloqueará para siempre y el deuce a la consola ya no se imprimirá.

Curiosamente, intenté cambiar este código, y si eliminé la salida a la unidad de consola, se inició la continuación en otro hilo del grupo de hilos y se imprimió el deuce. En cuyo caso, la continuación se ejecutará en el mismo hilo, y en el cual - llegará al grupo de hilos - una pregunta para los lectores.

 var tcs = new TaskCompletionSource<bool>( TaskCreationOptions.RunContinuationsAsynchronously); /* OR */ Task.Run(() => tcs.TrySetResult(true)); 

Para resolver este problema, podemos crear un TaskCompletionSource con el indicador RunContinuationsAsynchronously correspondiente, o llamar al método TrySetResult dentro de Task.Run/ThreadPool.QueueUserWorkItem para que no se ejecute en nuestro hilo. Si se ejecuta en nuestro hilo, podemos tener efectos secundarios no deseados. Además, hay un segundo problema, nos detendremos en él con más detalle.



Mire los métodos WaitAsync y Release e intente encontrar otro problema en el método Release.

Muy probablemente, encontrarla tan simplemente imposible. Hay una carrera aquí.



Se debe al hecho de que en el método WaitAsync el cambio de estado no es atómico. Primero disminuimos el mostrador y solo luego empujamos al camarero hacia la pila. Si sucede que Release se ejecuta entre decremento y push, puede salir para que no saque nada de la pila. Esto debe tenerse en cuenta y, en el método Release, espere a que aparezca el camarero en la pila.

 var countBefore = Interlocked.Increment(ref currentCount) - 1; if (countBefore < 0) { Waiter waiter; var spinner = new SpinWait(); while (!waiter.TryPop(out waiter)) spinner.SpinOnce(); waiter.TrySetResult(true); } 

Aquí lo hacemos en un bucle hasta que logramos sacarlo. Para no volver a desperdiciar los ciclos del procesador, usamos SpinWait.

En las primeras iteraciones, girará en un bucle. Si hay muchas iteraciones, el camarero no aparecerá durante mucho tiempo, entonces nuestro hilo irá a Thread.Sleep, para no desperdiciar recursos de la CPU una vez más.

De hecho, el semáforo de orden LIFO no es solo nuestra idea.
LowLevelLifoSemaphore
  • Síncrono
  • En Windows usa el puerto IO Completion como una pila de Windows

https://github.com/dotnet/corert/blob/master/src/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.cs
Existe tal semáforo en .NET en sí, pero no en CoreCLR, no en CoreFX, sino en CoreRT. A veces es bastante útil echar un vistazo al repositorio .NET. Hay un semáforo llamado LowLevelLifoSemaphore. Este semáforo no nos convendría de todos modos: es sincrónico.

Sorprendentemente, en Windows funciona a través de puertos IO Completion. Tienen la propiedad de que los subprocesos pueden esperarlos, y estos subprocesos se liberarán solo en el orden LIFO. Esta característica se usa allí, es realmente de bajo nivel.

2.3 Conclusiones:


  • No esperes que el relleno del marco sobreviva bajo tu carga
  • Es más fácil resolver un problema específico que el caso general.
  • La prueba de esfuerzo no siempre ayuda
  • Cuidado con el bloqueo

¿Cuáles son las conclusiones de toda esta historia? En primer lugar, no espere que algunas clases del marco que utiliza de la biblioteca estándar puedan hacer frente a su carga. No quiero decir que SemaphoreSlim es malo, simplemente resultó ser inadecuado específicamente en este escenario.

Resultó ser mucho más fácil para nosotros escribir nuestro semáforo para una tarea específica. Por ejemplo, no admite la cancelación de la espera. Esta característica está disponible en el SemaphoreSlim habitual, no la tenemos, pero esto nos permitió simplificar el código.

La prueba de carga, aunque ayuda, puede no siempre ayudar.

.NET , — . lock, : « ?» CPU 100%, lock', , , - .NET. .

.

3: (A)sync IO


/, .



lock convoy, stack trace Overlapped PinnableBufferCache. lock. : Overlapped PinnableBufferCache?

OVERLAPPED — Windows, /. , . , . , lock convoy. , lock convoy, , .



, , .NET 4.5.1 4.5.2. .NET 4.5.2, , .NET 4.5.2. .NET 4.5.1 OverlappedDataCache, Overlapped — , , . , lock-free, ConcurrentStack, . .NET 4.5.2 : OverlappedDataCache PinnableBufferCache.

? PinnableBufferCache , Overlapped , , — . , , . PinnableBufferCache . , lock-free, ConcurrentStack. , . , , - lock-free list lock'.

3.1 PinnableBufferCache


LockConvoy:


lock convoy , - . list , lock , , .

PinnableBufferCache , . :

 PinnableBufferCache_System.ThreadingOverlappedData_MinCount 

, . : « ! - ». -:

 Environment.SetEnvironmentVariable( "PinnableBufferCache_System.Threading.OverlappedData_MinCount", "10000"); new Overlapped().GetHashCode(); for (int i = 0; i < 3; i++) GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced); 

? , Overlapped , , . , , , , PinnableBufferCache lock convoy'. , .

.NET Core PinnableBufferCache , OverlappedData . , , Garbage collector , . .NET Core . .NET Framework, , .

3.2 :


  • .NET Core

, . , .NET , . , , .NET Core. , , -.

key-value .

4: Concurrent key-value collections


.NET concurrent-. lock-free ConcurrentStack ConcurrentQueu, . ConcurrentDictionary, . lock-free , , . ConcurrentDictionary?

4.1 ConcurrentDictionary


:


Pros:

  • (TryAdd/TryUpdate/AddOrUpdate)
  • Lock-free
  • Lock-free enumeration

, memory-, , . , , .NET Framework. . , , (enumeration) lock-free. , .

, , - .NET. key-value - :



-, bucket'. bucket', . , bucket , .

— , ConcurrentDictionary. ConcurrentDictionary «-» . , , , memory traffic. ConcurrentDictionary, lock'. — .

, Dictionary.



Dictionary , Concurrent, . : buckets, entries. buckets bucket' entries. «-» entries. . «-» int, bucket'.

memory overhead, ConcurrentDictionary Dictionary.



Dictionary. Memory overhea' , . Dictionary overhead - , int'. 8 .

ConcurrentDictionary. ConcurrentDictionary ConcurrentDictionary.Node. , . int hashCode . , table ( 16 ), int hashCode . , 64- 28 overhead'. Dictionary.

memory overhead', ConcurrentDictionary GC , . Benchmark. ConcurrentDictionary , GC.Collect. ?



. ConcurrentDictionary 10 , , , . Dictionary . , , , . .

, ConcurrentDictionary?

4.2


  • TTL
  • Dictionary+lock
  • Sharding

. ConcurrentDictionary. 10 . , . TTL , . Dictionary lock'. , , lock . Dictionary lock' , - , lock. , .

4.3


  • in-memory <Guid,Guid>
  • >10 6

. — , in-memory Guid' Guid, . . - - , . , 15 . . Semaphore ConcurrentDictionary.



, lock-free , overhead GC. , . , , , . , - , , . , , Large Object Heap. Por que no

, , Dictionary .



Dictionary bucket', Entry. Entry , , , .



Dictionary , , . , - .

, - ? -, , , , . . Dictionary, , buckets, entries, Interlocked. , .
Dictionary
  • ,
  • , ?
    — Resize buckets entries
    — -
    — Dictionary.Entry
    — -

https://blogs.msdn.microsoft.com/tess/2009/12/21/high-cpu-in-net-app-using-a-static-generic-dictionary/
, Dictionary - bucket'. , . , , . , , .

Entry Dictionary. - - . , .



.NET Framework 1.1. Hashtable, Dictionary, object'. MSDN , . , -. . , Hashtable . , .

4.4 Dictionary.Entry



? Dictionary.Entry , , 8 , , , , . ?

 bool writing; int version; this.writing = true; buckets[index] = …; this.version++; this.writing = false; 

: ( , ) int-. , . , , , , .

 bool writing; int version; while (true) { int version = this.version; bucket = bickets[index]; if (this.writing || version != this.version) continue; break; } 

, , . , . , 8 .

4.5 -


, .



Dictionary bucket , .

Dictionary, . : 0 2. bucket, 1 2. ? 0. , , 2. . , 2, , , 1. 1 2 — bucket. , , . 1 — , bucket. Hashtable , bucket' -. — double hashing .

4.6





  • , resize



  • ,

. , Buckets, Entries ( Buckets, Entries). - , , , , .

. , .

: , , , , . , , .



, , — .

? , - 2. - Capacity , . — 2. , . 2. ? , , , . - , , 3. , , , , , .

, Hashtable, . , double hashing. , , , .

, , — , . Hashtable. , — — . . , bucket', - , . .

, , lock-free LOH.



lock-free ? MSDN Hashtable , . , , .



, , , bucket'. Dictionary bucket', -, bucket' . - bucket, bucket . , .

, Large Object Heap.



. CustomDictionary CustomDictionarySegment . Dictionary, , . — Dictionary, . , Large Object Heap. , bucket' . , , , bucket, - - .

. ConcurrentDictionary, .NET, , .

4.7


  • .NET
  • ,

? .NET . . , , . - — - . , , , .

- , , , , . , , , , , . — , , .



— ConcurrentDictionary. , , ( Diafilm ), .

GitHub. — , , LIFO-Semaphore, . , .
6-7 DotNext 2019 Moscow «.NET: » , .NET Framework .NET Core, , .

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


All Articles