Compartir a través de


Escritura de High-Performance aplicaciones administradas: A Primer

 

Gregor Noriskin
Equipo de rendimiento de Microsoft CLR

Junio de 2003

Se aplica a:
   Microsoft® .NET Framework

Resumen: Obtenga información sobre Common Language Runtime de .NET Framework desde una perspectiva de rendimiento. Obtenga información sobre cómo identificar los procedimientos recomendados de rendimiento de código administrado y cómo medir el rendimiento de la aplicación administrada. (19 páginas impresas)

Descargue CLR Profiler. (330 KB)

Contenido

Juggling as a Metafórica para el desarrollo de software
Common Language Runtime de .NET
Datos administrados y el recolector de elementos no utilizados
Perfiles de asignación
La API de generación de perfiles y el generador de perfiles de CLR
Hospedaje del GC del servidor
Finalización
El patrón Dispose
Una nota sobre referencias débiles
Código administrado y CLR JIT
Tipos de valor
Control de excepciones.
Subprocesos y sincronización
Reflexión
Enlace en tiempo de demora
Seguridad
Interoperabilidad COM e invocación de plataforma
Contadores de rendimiento
Otras herramientas
Conclusión
Recursos

Juggling as a Metafórica para el desarrollo de software

Juggling es una gran metáfora para describir el proceso de desarrollo de software. La alternancia normalmente requiere al menos tres elementos, aunque no hay ningún límite superior para el número de elementos que puede intentar juggle. Cuando empieces a aprender a jugártelos, descubres que watch cada pelota individualmente a medida que las capturas y las lanzas. A medida que avanzas, comienzas a centrarte en el flujo de las bolas, en lugar de cada bola individual. Cuando has maestro jugándose, puedes concentrarte una vez más en una sola pelota, equilibrando esa bola en la nariz, mientras continúas jugándose con los demás. Sabes intuitivamente dónde van a estar las bolas y puedes poner la mano en el lugar adecuado para atraparlas y tirarlas. ¿Cómo es esto como el desarrollo de software?

Diferentes roles en el proceso de desarrollo de software alternan diferentes "trinidades"; Los administradores de proyectos y programas jugán las características, los recursos y el tiempo, y los desarrolladores de software jugán la corrección, el rendimiento y la seguridad. Uno siempre puede tratar de jugar más elementos, pero como cualquier estudiante de juggling puede atestiguar, agregar una sola bola hace que sea exponencialmente más difícil mantener las bolas en el aire. Técnicamente si estás haciendo malabares menos de tres bolas no estás haciendo malabares en absoluto. Si, como desarrollador de software, no está considerando la exactitud, el rendimiento y la seguridad del código que está escribiendo, el caso se puede hacer que no esté realizando su trabajo. Al empezar a considerar la corrección, el rendimiento y la seguridad, tendrá que centrarse en un aspecto a la vez. A medida que se convierten en parte de su práctica diaria, usted encontrará que no necesita centrarse en un aspecto específico, simplemente será parte de la forma en que trabaja. Cuando los haya dominado, podrá realizar inconvenientes intuitivamente y centrar sus esfuerzos de forma adecuada. Y al igual que con la malabarismo, la práctica es la clave.

La escritura de código de alto rendimiento tiene una trinidad propia; Establecer objetivos, medir y comprender la plataforma de destino. Si no sabe lo rápido que tiene que ser el código, ¿cómo sabrá cuándo ha terminado? Si no mide y genera perfiles de su código, ¿cómo sabrá cuándo ha cumplido sus objetivos o por qué no cumple sus objetivos? Si no entiende la plataforma de destino, cómo sabrá qué optimizar en caso de que no cumpla sus objetivos. Estos principios se aplican al desarrollo de código de alto rendimiento en general, sea cual sea la plataforma que tenga como destino. Ningún artículo sobre la escritura de código de alto rendimiento sería completo sin mencionar esta trinidad. Aunque los tres son igualmente significativos, este artículo se centrará en los dos últimos aspectos, ya que se aplican a la escritura de aplicaciones de alto rendimiento destinadas a Microsoft® .NET Framework.

Los principios fundamentales para escribir código de alto rendimiento en cualquier plataforma son:

  1. Establecer objetivos de rendimiento
  2. Medir, medir y, a continuación, medir algo más
  3. Comprender las plataformas de hardware y software a las que se dirige la aplicación

Common Language Runtime de .NET

El núcleo de .NET Framework es Common Language Runtime (CLR). CLR proporciona todos los servicios en tiempo de ejecución para el código; Compilación Just-In-Time, Administración de memoria, Seguridad y otros servicios. ClR se diseñó para ser de alto rendimiento. Dicho esto, hay formas en que puede aprovechar ese rendimiento y las formas en que puede dificultarlo.

El objetivo de este artículo es proporcionar información general de Common Language Runtime desde una perspectiva de rendimiento, identificar los procedimientos recomendados de rendimiento de código administrado y mostrar cómo puede medir el rendimiento de la aplicación administrada. Este artículo no es una explicación exhaustiva sobre las características de rendimiento de .NET Framework. Para los fines de este artículo, definiré el rendimiento para incluir el rendimiento, la escalabilidad, el tiempo de inicio y el uso de memoria.

Datos administrados y el recolector de elementos no utilizados

Una de las principales preocupaciones de los desarrolladores sobre el uso de código administrado en aplicaciones críticas para el rendimiento es el costo de la administración de memoria de CLR, que realiza el recolector de elementos no utilizados (GC). El costo de la administración de memoria es una función del costo de asignación de memoria asociado a una instancia de un tipo, el costo de administrar esa memoria durante la duración de la instancia y el costo de liberar esa memoria cuando ya no se necesita.

Una asignación administrada suele ser muy barata; en la mayoría de los casos tarda menos tiempo que en C/C++ malloc o new. Esto se debe a que CLR no necesita examinar una lista gratuita para encontrar el siguiente bloque contiguo de memoria disponible lo suficientemente grande como para contener el nuevo objeto; mantiene un puntero a la siguiente posición libre en la memoria. Una puede pensar en las asignaciones de montón administradas como "pila como". Una asignación puede provocar una colección si el GC necesita liberar memoria para asignar el nuevo objeto, en cuyo caso la asignación es más costosa que o mallocnew. Los objetos anclados también pueden afectar al costo de asignación. Los objetos anclados son objetos que se han indicado a la GC que no se muevan durante una colección, normalmente porque la dirección del objeto se ha pasado a una API nativa.

A diferencia de o mallocnew, hay un costo asociado a la administración de memoria durante la duración de un objeto. El GC de CLR es generacional, lo que significa que no siempre se recopila todo el montón. Sin embargo, el GC sigue necesitando saber si hay objetos activos en el resto de los objetos raíz del montón en la parte del montón que se está recopilando. La memoria que contiene objetos que contienen referencias a objetos de generaciones más jóvenes es caro de administrar durante la duración de los objetos.

El GC es una marca generacional y un recolector de elementos no utilizados de barrido. El montón administrado contiene tres generaciones; La generación 0 contiene todos los objetos nuevos, la generación 1 contiene objetos de duración ligeramente más larga y la generación 2 contiene objetos de larga duración. El GC recopilará la sección más pequeña del montón posible para liberar suficiente memoria para que la aplicación continúe. La colección de una generación incluye la colección de todas las generaciones más jóvenes, en este caso una colección generation 1 también recopila generation 0. La generación 0 tiene un tamaño dinámico según el tamaño de la memoria caché del procesador y la velocidad de asignación de la aplicación, y normalmente tarda menos de 10 milisegundos en recopilarse. La generación 1 se ajusta dinámicamente según la tasa de asignación de la aplicación y normalmente tarda entre 10 y 30 milisegundos en recopilarse. El tamaño de generación 2 dependerá del perfil de asignación de la aplicación, ya que el tiempo que se tarda en recopilarse. Se trata de estas colecciones de generación 2 que afectarán de forma más significativa al costo de rendimiento de administrar la memoria de las aplicaciones.

PISTA El GC es el ajuste automático y se ajustará según los requisitos de memoria de las aplicaciones. En la mayoría de los casos, invocar mediante programación un GC impedirá ese ajuste. "Ayudar" al GC mediante una llamada a GC. La recopilación mejorará más de lo probable que no mejore el rendimiento de las aplicaciones.

El GC puede reubicar objetos activos durante una colección. Si esos objetos son grandes, el costo de reubicación es alto, por lo que esos objetos se asignan en un área especial del montón denominado Montón de objetos grandes. El montón de objetos grandes se recopila, pero no se compacta, por ejemplo, los objetos grandes no se reubican. Los objetos grandes son aquellos que tienen más de 80 kb. Tenga en cuenta que esto puede cambiar en versiones futuras de CLR. Cuando el montón de objetos grandes debe recopilarse, fuerza una colección completa y el montón de objetos grandes se recopila durante las colecciones gen 2. La asignación y la tasa de muerte de objetos en el montón de objetos grandes puede tener un efecto significativo en el costo de rendimiento de la administración de la memoria de las aplicaciones.

Perfiles de asignación

El perfil de asignación general de una aplicación administrada definirá lo difícil que tiene que trabajar el recolector de elementos no utilizados para administrar la memoria asociada a la aplicación. Cuanto más difícil tenga que trabajar la GC para administrar la memoria, mayor será el número de ciclos de CPU que tarda el GC y menos tiempo dedicará la CPU a ejecutar el código de la aplicación. El perfil de asignación es una función del número de objetos asignados, el tamaño de esos objetos y su duración. La manera más obvia de aliviar la presión de GC es simplemente asignar menos objetos. Las aplicaciones diseñadas para extensibilidad, modularidad y reutilización mediante técnicas de diseño orientado a objetos casi siempre darán como resultado un mayor número de asignaciones. Hay una penalización de rendimiento para la abstracción y la "elegancia".

Un perfil de asignación descriptivo para GC tendrá asignados algunos objetos al principio de la aplicación y, a continuación, sobrevivirán durante la duración de la aplicación y, a continuación, todos los demás objetos serán de corta duración. Los objetos de larga duración contendrán pocas o ninguna referencia a objetos de corta duración. A medida que el perfil de asignación se desvía de esto, el GC tendrá que trabajar más duro para administrar la memoria de las aplicaciones.

Un perfil de asignación no amistoso de GC tendrá muchos objetos que sobreviven a la generación 2 y, a continuación, morirán, o tendrán muchos objetos de corta duración asignados en el montón de objetos grandes. Los objetos que sobreviven lo suficiente para entrar en la generación 2 y luego morir son los más caros de administrar. Como mencioné antes de los objetos de generaciones anteriores que contienen referencias a objetos en generaciones más jóvenes durante un GC, también aumentan el costo de la colección.

Un perfil de asignación típico del mundo real estará en algún lugar entre los dos perfiles de asignación mencionados anteriormente. Una métrica importante del perfil de asignación es el porcentaje del tiempo total de CPU dedicado a GC. Puede obtener este número de la memoria clR de .NET: % de tiempo en el contador de rendimiento de GC. Si el valor medio de este contador es superior al 30 %, es probable que considere la posibilidad de echar un vistazo más a su perfil de asignación. Esto no significa necesariamente que el perfil de asignación sea "malo"; hay algunas aplicaciones con un uso intensivo de memoria en las que este nivel de GC es necesario y adecuado. Este contador debe ser lo primero que vea si experimenta problemas de rendimiento; debería mostrar inmediatamente si el perfil de asignación forma parte del problema.

PISTA Si la memoria clR de .NET: % de tiempo en el contador de rendimiento de GC indica que la aplicación está gastando un promedio de más del 30 % de su tiempo en GC, debe echar un vistazo más de cerca al perfil de asignación.

PISTA Una aplicación compatible con GC tendrá mucho más de generación 0 que las colecciones de generación 2. Esta relación se puede establecer comparando los contadores de rendimiento memoria de NET CLR: #Gen 0 Collections y NET CLR Memory: # Gen 2 Collections performance counters ( Memoria de NET CLR: # Gen 2 Collections).

Api de generación de perfiles y CLR Profiler

CLR incluye una API de generación de perfiles eficaz que permite a terceros escribir generadores de perfiles personalizados para aplicaciones administradas. CLR Profiler es una herramienta de ejemplo de generación de perfiles de asignación no admitida, escrita por el equipo de productos de CLR, que usa esta API de generación de perfiles. CLR Profiler permite a los desarrolladores ver el perfil de asignación de sus aplicaciones de administración.

Figura 1 Ventana principal del generador de perfiles de CLR

ClR Profiler incluye una serie de vistas muy útiles del perfil de asignación, incluido un histograma de tipos asignados, gráficos de asignación y llamadas, una línea de tiempo que muestra los GCs de varias generaciones y el estado resultante del montón administrado después de esas colecciones, y un árbol de llamadas que muestra las asignaciones por método y las cargas de ensamblado.

Figura 2 Gráfico de asignación de generador de perfiles clR

PISTA Para obtener más información sobre cómo usar CLR Profiler, consulte el archivo Léame incluido en el archivo ZIP.

Tenga en cuenta que CLR Profiler tiene una sobrecarga de alto rendimiento y cambia significativamente las características de rendimiento de la aplicación. Los errores de estrés emergentes probablemente desaparecerán cuando ejecute la aplicación con CLR Profiler.

Hospedaje del GC del servidor

Hay dos recolectores de elementos no utilizados diferentes disponibles para CLR: un GC de estación de trabajo y un GC de servidor. Las aplicaciones de consola y Windows Forms hospedan el GC de estación de trabajo y ASP.NET hospedan el GC del servidor. El GC de servidor está optimizado para el rendimiento y la escalabilidad de varios procesadores. El GC del servidor pausa todos los subprocesos que ejecutan código administrado durante toda la duración de una colección, incluidas las fases de marcado y barrido, y GC se produce en paralelo en todas las CPU disponibles para el proceso en subprocesos con afinidad de CPU de alta prioridad dedicados. Si los subprocesos ejecutan código nativo durante un GC, esos subprocesos solo se pausan cuando se devuelve la llamada nativa. Si va a compilar una aplicación de servidor que se va a ejecutar en máquinas multiprocesador, se recomienda encarecidamente usar el GC de servidor. Si la aplicación no hospedada por ASP.NET, tendrá que escribir una aplicación nativa que hospede explícitamente clR.

PISTA Si va a compilar aplicaciones de servidor escalables, hospede el GC del servidor. Consulte Implementación de un host de Common Language Runtime personalizado para la aplicación administrada.

El GC de estación de trabajo está optimizado para una latencia baja, que normalmente es necesaria para las aplicaciones cliente. Uno no quiere una pausa notable en una aplicación cliente durante un GC, ya que normalmente el rendimiento del cliente no se mide por el rendimiento sin procesar, sino por el rendimiento percibido. El GC de estación de trabajo realiza gc simultáneo, lo que significa que realiza la fase de marcado mientras el código administrado todavía se está ejecutando. El GC solo pausará los subprocesos que ejecutan código administrado cuando necesite realizar la fase de barrido. En el GC de estación de trabajo, GC solo se realiza en un subproceso y, por tanto, solo en una CPU.

Finalización

CLR proporciona un mecanismo en el que la limpieza se realiza automáticamente antes de liberar la memoria asociada a una instancia de un tipo. Este mecanismo se denomina Finalización. Normalmente, la finalización se usa para liberar recursos nativos, en este caso conexione de base de datos o identificadores del sistema operativo que usa un objeto .

La finalización es una característica costosa y aumenta la presión que se pone en la GC. El GC realiza un seguimiento de los objetos que requieren finalización en una cola finalizable. Si durante una colección, el GC encuentra un objeto que ya no está activo, pero requiere finalización, la entrada del objeto en la cola finalizable se mueve a la cola FReachable. La finalización se produce en un subproceso independiente denominado Subproceso de finalizador. Dado que el estado completo del objeto puede ser necesario durante la ejecución del finalizador, el objeto y todos los objetos a los que apunta se promueven a la próxima generación. La memoria asociada al objeto o gráfico de objetos solo se libera durante el siguiente GC.

Los recursos que deben liberarse deben encapsularse en un objeto Finalizable lo más pequeño posible; por ejemplo, si la clase requiere referencias a recursos administrados y no administrados, debe encapsular los recursos no administrados en una nueva clase Finalizable y convertir esa clase en un miembro de la clase. La clase primaria no debe ser Finalizable. Esto significa que solo se promoverá la clase que contiene los recursos no administrados (suponiendo que no contenga una referencia a la clase primaria de la clase que contiene los recursos no administrados). Otra cosa que hay que tener en cuenta es que solo hay un subproceso de finalización. Si un finalizador hace que este subproceso bloquee, no se llamará a los finalizadores posteriores, los recursos no se liberarán y la aplicación se perderá.

PISTA Los finalizadores deben mantenerse lo más simples posible y nunca deben bloquearse.

PISTA Convierta en finalizador solo la clase contenedora en torno a objetos no administrados que necesiten limpieza.

La finalización se puede considerar como alternativa al recuento de referencias. Un objeto que implementa el recuento de referencias realiza un seguimiento de cuántos otros objetos tienen referencias a él (lo que puede provocar algunos problemas muy conocidos), de modo que pueda liberar sus recursos cuando su recuento de referencias sea cero. CLR no implementa el recuento de referencias, por lo que debe proporcionar un mecanismo para liberar automáticamente los recursos cuando no se mantienen más referencias al objeto. La finalización es ese mecanismo. Normalmente, la finalización solo es necesaria en el caso de que no se conozca explícitamente la duración de un objeto que requiere limpieza.

Patrón Dispose

En caso de que se conozca explícitamente la duración del objeto, los recursos no administrados asociados a un objeto deben liberarse diligentemente. Esto se denomina "Desechar" el objeto . El patrón Dispose se implementa a través de la interfaz IDisposable (aunque implementarlo usted mismo sería trivial). Si desea que la finalización diligente esté disponible para la clase, por ejemplo, hacer que las instancias de la clase sean descartables, debe hacer que el objeto implemente la interfaz IDisposable y proporcione una implementación para el método Dispose . En el método Dispose , llamará al mismo código de limpieza que se encuentra en el finalizador e informará al GC de que ya no necesita finalizar el objeto llamando al GC. Método SuppressFinalization . Es recomendable que el método Dispose y el finalizador llamen a una función de finalización común para que solo sea necesario mantener una versión del código de limpieza. Además, si la semántica del objeto es tal que un método Close será más lógico que un método Dispose , también se debe implementar un Close ; en este caso, una conexión de base de datos o un socket están "cerrados" lógicamente. Close simplemente puede llamar al método Dispose.

Siempre es recomendable proporcionar un método Dispose para las clases con un finalizador; nunca puede estar seguro de cómo se va a usar esa clase, por ejemplo, si su duración se va a conocer explícitamente o no. Si una clase que está usando implementa el patrón Dispose y sabe explícitamente cuándo ha terminado con el objeto, definitivamente llame a Dispose.

PISTA Proporcione un método Dispose para todas las clases que sean finalizables.

PISTA Suprima la finalización en el método Dispose .

PISTA Llame a una función de limpieza común.

PISTA Si un objeto que usa implementa IDisposable y sabe que el objeto ya no es necesario, llame a Dispose.

C# proporciona una manera muy cómoda de eliminar automáticamente objetos. La using palabra clave permite identificar un bloque de código después del cual se llamará a Dispose en una serie de objetos descartables.

Palabra clave using de C#.

using(DisposableType T)
{
   //Do some work with T
}
//T.Dispose() is called automatically

Una nota sobre referencias débiles

Cualquier referencia a un objeto que se encuentra en la pila, en un registro, en otro objeto o en una de las demás raíces de GC mantendrá activo un objeto durante una GC. Esto suele ser muy bueno, teniendo en cuenta que normalmente significa que la aplicación no se realiza con ese objeto. Sin embargo, hay casos en los que desea tener una referencia a un objeto, pero no desea afectar a su duración. En estos casos, CLR proporciona un mecanismo denominado Referencias débiles para hacerlo. Cualquier referencia segura (por ejemplo, una referencia que raíz un objeto) se puede convertir en una referencia débil. Un ejemplo de cuándo es posible que quiera usar referencias débiles es cuando desea crear un objeto de cursor externo que pueda atravesar una estructura de datos, pero no debería afectar a la duración del objeto. Otro ejemplo es si desea crear una memoria caché que se vacía cuando hay presión de memoria; por ejemplo, cuando se produce un GC.

Creación de una referencia débil en C#

MyRefType mrt = new MyRefType();
//...

//Create weak reference
WeakReference wr = new WeakReference(mrt); 
mrt = null; //object is no longer rooted
//...

//Has object been collected?
if(wr.IsAlive)
{
   //Get a strong reference to the object
   mrt = wr.Target;
   //object is rooted and can be used again
}
else
{
   //recreate the object
   mrt = new MyRefType();
}

Código administrado y EL ARCHIVO JIT de CLR

Los ensamblados administrados, que son la unidad de distribución para código administrado, contienen un lenguaje independiente del procesador denominado Lenguaje intermedio de Microsoft (MSIL o IL). CLR Just-In-Time (JIT) compila el IL en instrucciones X86 nativas optimizadas. JIT es un compilador de optimización, pero dado que la compilación se produce en tiempo de ejecución y solo se llama a la primera vez que se llama a un método, el número de optimizaciones que necesita equilibrar con el tiempo necesario para realizar la compilación. Normalmente, esto no es fundamental para las aplicaciones de servidor, ya que el tiempo de inicio y la capacidad de respuesta no suelen ser un problema, pero es fundamental para las aplicaciones cliente. Tenga en cuenta que el tiempo de inicio se puede mejorar mediante la compilación en tiempo de instalación mediante NGEN.exe.

Muchas de las optimizaciones realizadas por jiT no tienen patrones de programación asociados, por ejemplo, no se puede codificar explícitamente para ellos, pero hay un número que sí. En la sección siguiente se describen algunas de esas optimizaciones.

PISTA Mejore el tiempo de inicio de las aplicaciones cliente mediante la compilación de la aplicación en tiempo de instalación mediante la utilidad NGEN.exe.

Inserción de métodos

Hay un costo asociado a las llamadas de método; Los argumentos deben insertarse en la pila o almacenarse en registros, el prólogo del método y el epílogo deben ejecutarse, etc. El costo de estas llamadas se puede evitar para determinados métodos simplemente moviendo el cuerpo del método del método al que se llama al cuerpo del llamador. Esto se denomina Método en forro. El JIT usa una serie de heurística para decidir si un método debe estar en línea. A continuación se muestra una lista de los más significativos (tenga en cuenta que esto no es exhaustivo):

  • Los métodos que tienen más de 32 bytes de IL no se insertarán.
  • Las funciones virtuales no están insertadas.
  • Los métodos que tienen un control de flujo complejo no estarán en línea. El control de flujo complejo es cualquier control de flujo distinto if/then/else; de en este caso, switch o while.
  • Los métodos que contienen bloques de control de excepciones no están insertados, aunque los métodos que inician excepciones siguen siendo candidatos para la inserción.
  • Si alguno de los argumentos formales del método es structs, el método no se insertará.

Consideraría cuidadosamente la codificación explícita para estas heurística porque podrían cambiar en versiones futuras del JIT. No ponga en peligro la corrección del método para intentar garantizar que se inserte. Es interesante tener en cuenta que las inline palabras clave y __inline de C++ no garantizan que el compilador inserte un método (aunque __forceinline sí).

Los métodos get y set de propiedades suelen ser buenos candidatos para la inserción, ya que todo lo que hacen suele ser inicializar miembros de datos privados.

**HINT **No ponga en peligro la corrección de un método en un intento de garantizar la inserción.

Eliminación de comprobación de intervalos

Una de las muchas ventajas del código administrado es la comprobación automática de intervalos; cada vez que se accede a una matriz mediante la semántica array[index], el JIT emite una comprobación para asegurarse de que el índice está en los límites de la matriz. En el contexto de bucles con un gran número de iteraciones y un pequeño número de instrucciones ejecutadas por iteración, estas comprobaciones de intervalo pueden ser costosas. Hay casos en los que el JIT detectará que estas comprobaciones de intervalo no son necesarias y eliminarán la comprobación del cuerpo del bucle, y solo la comprobarán una vez antes de que comience la ejecución del bucle. En C# hay un patrón de programación para asegurarse de que estas comprobaciones de intervalo se eliminarán: pruebe explícitamente la longitud de la matriz en la instrucción "for". Tenga en cuenta que las desviaciones sutiles de este patrón provocarán que no se elimine la comprobación y, en este caso, agregar un valor al índice.

Eliminación de comprobación de intervalos en C#

//Range check will be eliminated
for(int i = 0; i < myArray.Length; i++) 
{
   Console.WriteLine(myArray[i].ToString());
}

//Range check will NOT be eliminated
for(int i = 0; i < myArray.Length + y; i++) 
{ 
   Console.WriteLine(myArray[i+x].ToString());
}

La optimización es especialmente notable al buscar grandes matrices escalonadas, por ejemplo, ya que se elimina la comprobación de intervalos del bucle interno y externo.

Optimizaciones que requieren seguimiento de uso de variables

Aa number of JIT compiler optimizations require that the JIT track the usage of formal arguments and local variables; por ejemplo, cuando se usan por primera vez y la última vez que se usan en el cuerpo del método. En la versión 1.0 y 1.1 de CLR hay una limitación de 64 en el número total de variables para las que el JIT realizará un seguimiento del uso. Un ejemplo de una optimización que requiere el seguimiento del uso es Enregistration. La inscripción es cuando las variables se almacenan en registros de procesador en lugar de en el marco de pila, por ejemplo, en ram. El acceso a las variables registradas es significativamente más rápido que si se encuentran en el marco de pila, incluso si la variable del marco tiene lugar en la memoria caché del procesador. Solo se considerarán 64 variables para la inscripción; todas las demás variables se insertarán en la pila. Hay otras optimizaciones que no sean Enregistration que dependen del seguimiento de uso. El número de argumentos formales y variables locales de un método debe mantenerse por debajo de 64 para garantizar el número máximo de optimizaciones JIT. Tenga en cuenta que este número puede cambiar para versiones futuras de CLR.

PISTA Mantenga los métodos cortos. Hay varias razones por las que se incluye la inserción de métodos, la inscripción y la duración JIT.

Otras optimizaciones JIT

El compilador JIT realiza una serie de otras optimizaciones: propagación constante y de copia, elevación de bucle invariable y varios otros. No hay patrones de programación explícitos que necesite usar para obtener estas optimizaciones; son libres.

¿Por qué no veo estas optimizaciones en Visual Studio?

Cuando use Iniciar desde el menú Depurar o presione F5 para iniciar una aplicación en Visual Studio, independientemente de si ha creado una versión de versión o depuración, se deshabilitarán todas las optimizaciones JIT. Cuando un depurador inicia una aplicación administrada, incluso si no es una compilación de depuración de la aplicación, JIT emitirá instrucciones x86 no optimizadas. Si desea que jiT emita código optimizado, inicie la aplicación desde el Explorador de Windows o use CTRL+F5 desde Visual Studio. Si desea ver el desensamblaje optimizado y contrastarlo con el código no optimizado, puede usar cordbg.exe.

PISTA Use cordbg.exe para ver el desensamblaje del código optimizado y no optimizado emitido por jiT. Después de iniciar la aplicación con cordbg.exe, puede establecer el modo JIT escribiendo lo siguiente:

(cordbg) mode JitOptimizations 1
JIT's will produce optimized code

(cordbg) mode JitOptimizations 0

JIT generará código depurable (no optimizado).

Tipos de valor

CLR expone dos conjuntos diferentes de tipos, tipos de referencia y tipos de valor. Los tipos de referencia siempre se asignan en el montón administrado y se pasan por referencia (como indica el nombre). Los tipos de valor se asignan en la pila o en línea como parte de un objeto del montón y se pasan por valor de forma predeterminada, aunque también puede pasarlos por referencia. Los tipos de valor son muy baratos para asignar y, suponiendo que se mantienen pequeños y sencillos, son baratos para pasar como argumentos. Un buen ejemplo de un uso adecuado de los tipos de valor sería un tipo de valor Point que contiene una coordenada x e y .

Tipo de valor de punto

struct Point
{
   public int x;
   public int y;
   
   //
}

Los tipos de valor también se pueden tratar como objetos; por ejemplo, se puede llamar a los métodos de objeto en ellos, se pueden convertir en objeto o pasarse donde se espera un objeto. Cuando esto sucede, sin embargo, el tipo de valor se convierte en un tipo de referencia, a través de un proceso denominado Boxing. Cuando un tipo de valor es Boxed, se asigna un nuevo objeto en el montón administrado y el valor se copia en el nuevo objeto. Se trata de una operación costosa y puede reducir o negar completamente el rendimiento obtenido mediante el uso de tipos de valor. Cuando el tipo Boxed se convierte implícita o explícitamente en un tipo de valor, se convierte en Unboxed.

Tipo de valor box/Unbox

C #:

int BoxUnboxValueType()
{
   int i = 10;
   object o = (object)i; //i is Boxed
   return (int)o + 3; //i is Unboxed
}

MSIL:

.method private hidebysig instance int32
        BoxUnboxValueType() cil managed
{
  // Code size       20 (0x14)
  .maxstack  2
  .locals init (int32 V_0,
           object V_1)
  IL_0000:  ldc.i4.s   10
  IL_0002:  stloc.0
  IL_0003:  ldloc.0
  IL_0004:  box        [mscorlib]System.Int32
  IL_0009:  stloc.1
  IL_000a:  ldloc.1
  IL_000b:  unbox      [mscorlib]System.Int32
  IL_0010:  ldind.i4
  IL_0011:  ldc.i4.3
  IL_0012:  add
  IL_0013:  ret
} // end of method Class1::BoxUnboxValueType

Si implementa tipos de valor personalizados (struct en C#), debe considerar la posibilidad de invalidar el método ToString . Si no invalida este método, las llamadas a ToString en el tipo de valor harán que el tipo sea Boxed. Esto también es cierto para los otros métodos que se heredan de System.Object, en ese caso, Equals, aunque ToString es probablemente el método al que se llama con más frecuencia. Si desea saber si el tipo de valor y cuándo se está conversión Boxed, puede buscar la box instrucción en el MSIL mediante la utilidad ildasm.exe (como en el fragmento de código anterior).

Invalidación del método ToString() en C# para evitar la conversión boxing

struct Point
{
   public int x;
   public int y;

   //This will prevent type being boxed when ToString is called
   public override string ToString()
   {
      return x.ToString() + "," + y.ToString();
   }
}

Tenga en cuenta que, al crear colecciones (por ejemplo, arraylist de float), cada elemento se boxeará cuando se agregue a la colección. Debe considerar el uso de una matriz o la creación de una clase de colección personalizada para el tipo de valor.

Boxing implícito al usar clases de colección en C#

ArrayList al = new ArrayList();
al.Add(42.0F); //Implicitly Boxed becuase Add() takes object
float f = (float)al[0]; //Unboxed

Control de excepciones.

Es habitual usar condiciones de error como control de flujo normal. En este caso, al intentar agregar un usuario mediante programación a una instancia de Active Directory, simplemente puede intentar agregar el usuario y, si se devuelve un hrESULT de E_ADS_OBJECT_EXISTS, sabe que ya existen en el directorio. Como alternativa, podría buscar el directorio del usuario y, a continuación, agregar solo el usuario si se produce un error en la búsqueda.

Este uso de errores para el control de flujo normal es un antipatrón de rendimiento en el contexto de CLR. El control de errores en CLR se realiza con el control estructurado de excepciones. Las excepciones administradas son muy baratas hasta que se inician. En CLR, cuando se produce una excepción, se requiere un recorrido de pila para buscar un controlador de excepciones adecuado para la excepción iniciada. Caminar a la pila es una operación costosa. Las excepciones deben utilizarse como su nombre implica; en circunstancias excepcionales o inesperadas.

**HINT **Considere la posibilidad de devolver un resultado enumerado para los resultados esperados, en lugar de producir una excepción, para los métodos críticos para el rendimiento.

**HINT **Hay una serie de contadores de rendimiento de excepciones clR de .NET que le indicarán cuántas excepciones se producen en la aplicación.

**HINT **Si usa VB.NET usar excepciones en lugar On Error Gotode ; el objeto de error es un costo innecesario.

Subprocesos y sincronización

CLR expone características enriquecidas de subprocesos y sincronización, incluida la capacidad de crear sus propios subprocesos, un grupo de subprocesos y varias primitivas de sincronización. Antes de aprovechar la compatibilidad con subprocesos en CLR, debe tener en cuenta cuidadosamente el uso de subprocesos. Tenga en cuenta que agregar subprocesos puede reducir realmente el rendimiento en lugar de aumentarlo, y puede asegurarse de que aumentará el uso de memoria. En las aplicaciones de servidor que se van a ejecutar en máquinas de varios procesadores, agregar subprocesos puede mejorar significativamente el rendimiento al paralelizar la ejecución (aunque depende de la contención de bloqueo que está ocurriendo, por ejemplo, la serialización de la ejecución) y en las aplicaciones cliente, agregar un subproceso para mostrar la actividad o el progreso puede mejorar el rendimiento percibido (a un pequeño costo de rendimiento).

Si los subprocesos de la aplicación no están especializados para una tarea específica o tienen un estado especial asociado a ellos, debe considerar la posibilidad de usar el grupo de subprocesos. Si ha usado el grupo de subprocesos de Win32 en el pasado, el grupo de subprocesos de CLR será muy familiar para usted. Hay una única instancia del grupo de subprocesos por proceso administrado. El grupo de subprocesos es inteligente sobre el número de subprocesos que crea y se ajustará según la carga en la máquina.

No se puede analizar el subproceso sin discutir la sincronización; todas las ganancias de rendimiento que el multithreading puede dar a la aplicación se puede negar mediante una lógica de sincronización mal escrita. La granularidad de los bloqueos puede afectar significativamente al rendimiento general de la aplicación, tanto debido al costo de crear y administrar el bloqueo como al hecho de que los bloqueos pueden serializar la ejecución. Usaré el ejemplo de intentar agregar un nodo a un árbol para ilustrar este punto. Si el árbol va a ser una estructura de datos compartida, por ejemplo, varios subprocesos necesitan tener acceso a él durante la ejecución de la aplicación y tendrá que sincronizar el acceso al árbol. Puede optar por bloquear todo el árbol al agregar un nodo, lo que significa que solo incurre en el costo de crear un único bloqueo, pero es probable que otros subprocesos que intenten acceder al árbol se bloquearán. Este sería un ejemplo de un bloqueo general. Como alternativa, podría bloquear cada nodo mientras atraviesa el árbol, lo que significaría que incurre en el costo de crear un bloqueo por nodo, pero otros subprocesos no se bloquearían a menos que intentaran acceder al nodo específico que había bloqueado. Este es un ejemplo de un bloqueo específico definido. Probablemente una granularidad más adecuada del bloqueo sería bloquear solo el subárbol en el que está trabajando. Tenga en cuenta que en este ejemplo probablemente usaría un bloqueo compartido (RWLock), ya que varios lectores deberían poder obtener acceso al mismo tiempo.

La manera más sencilla y más alta de rendimiento para realizar operaciones sincronizadas es usar la clase System.Threading.Interlocked. La clase Interlocked expone una serie de operaciones atómicas de bajo nivel: Increment, Decrement, Exchange y CompareExchange.

Usar la clase System.Threading.Interlocked en C#

using System.Threading;
//...
public class MyClass
{
   void MyClass() //Constructor
   {
      //Increment a global instance counter atomically
      Interlocked.Increment(ref MyClassInstanceCounter);
   }

   ~MyClass() //Finalizer
   {
      //Decrement a global instance counter atomically
      Interlocked.Decrement(ref MyClassInstanceCounter);
      //... 
   }
   //...
}

Probablemente el mecanismo de sincronización más usado es la sección Supervisión o crítica. Un bloqueo monitor se puede usar directamente o mediante la lock palabra clave en C#. La lock palabra clave sincroniza el acceso, para el objeto especificado, con un bloque de código específico. Un bloqueo monitor que es bastante ligeramente impugnado es relativamente barato desde una perspectiva de rendimiento, pero se vuelve más caro si es muy disputado.

Palabra clave lock de C#

//Thread will attempt to obtain the lock
//and block until it does
lock(mySharedObject)
{
   //A thread will only be able to execute the code
   //within this block if it holds the lock
}//Thread releases the lock

RWLock proporciona un mecanismo de bloqueo compartido: por ejemplo, "lectores" pueden compartir el bloqueo con otros "lectores", pero un "escritor" no. En los casos en los que esto es aplicable, RWLock puede dar lugar a un mejor rendimiento que usar un Monitor, que solo permitiría a un solo lector o escritor obtener el bloqueo a la vez. El espacio de nombres System.Threading también incluye la clase Mutex. Una exclusión mutua es un primitivo de sincronización que permite la sincronización entre procesos. Tenga en cuenta que esto es significativamente más caro que una sección crítica y solo debe usarse en el caso de que se requiera la sincronización entre procesos.

Reflexión

La reflexión es un mecanismo proporcionado por CLR, que permite obtener información de tipos mediante programación en tiempo de ejecución. La reflexión depende en gran medida de los metadatos, que se insertan en ensamblados administrados. Muchas API de reflexión requieren la búsqueda y el análisis de los metadatos, que son operaciones costosas.

Las API de reflexión se pueden agrupar en tres cubos de rendimiento; comparación de tipos, enumeración de miembros e invocación de miembros. Cada uno de estos cubos obtiene progresivamente más caro. Las operaciones de comparación de tipos(en este caso, typeof en C#, GetType, is, IsInstanceOfType , etc.) son las más baratas de las API de reflexión, aunque no son baratas. Las enumeraciones de miembros permiten inspeccionar mediante programación los métodos, propiedades, campos, eventos, constructores, etc. de una clase. Un ejemplo de dónde se pueden usar en escenarios en tiempo de diseño, en este caso enumerando propiedades de controles web de aduanas para el explorador de propiedades en Visual Studio. Las API de reflexión más caras son las que permiten invocar dinámicamente los miembros de una clase, o emitir dinámicamente JIT y ejecutar un método. Sin duda, hay escenarios enlazados en tiempo de ejecución en los que se requiere la carga dinámica de ensamblados, instancias de tipos y invocaciones de método, pero este acoplamiento flexible requiere una compensación explícita del rendimiento. En general, las API de reflexión deben evitarse en rutas de acceso de código sensibles al rendimiento. Tenga en cuenta que, aunque no use directamente la reflexión, una API que use podría usarla. Por lo tanto, tenga en cuenta también el uso transitivo de las API de reflexión.

Enlace en tiempo de demora

Las llamadas enlazadas en tiempo de ejecución son un ejemplo de una característica que usa Reflection en segundo plano. Visual Basic.NET y JScript.NET admiten llamadas enlazadas en tiempo de ejecución. Por ejemplo, no es necesario declarar una variable antes de su uso. Los objetos enlazados en tiempo de ejecución son realmente de tipo y Reflection se usa para convertir el objeto al tipo correcto en tiempo de ejecución. Una llamada enlazada en tiempo de ejecución es más lenta que una llamada directa. A menos que necesite específicamente un comportamiento enlazado en tiempo de ejecución, debe evitar su uso en las rutas de acceso de código críticas para el rendimiento.

PISTA Si usa VB.NET y no necesita explícitamente el enlace en tiempo de demora, puede indicar al compilador que lo desconecte si incluye y Option Explicit OnOption Strict On en la parte superior de los archivos de origen. Estas opciones le obligan a declarar y escribir fuertemente las variables y desactivan la conversión implícita.

Seguridad

La seguridad es una parte necesaria e integral de CLR y tiene un costo de rendimiento asociado. En caso de que el código sea de plena confianza y la directiva de seguridad sea la predeterminada, la seguridad debe tener un impacto menor en el rendimiento y el tiempo de inicio de la aplicación. El código de confianza parcial (por ejemplo, el código de internet o la zona de intranet) o la restricción del conjunto de concesión MyComputer aumentará el costo de rendimiento de la seguridad.

Interoperabilidad COM e invocación de plataforma

Interoperabilidad COM e invocación de plataforma exponen las API nativas al código administrado de forma casi transparente; Llamar a la mayoría de las API nativas normalmente no requiere código especial, aunque puede requerir unos pocos clics del mouse. Como cabría esperar, hay un costo asociado a la llamada a código nativo desde código administrado y viceversa. Hay dos componentes para este costo: un costo fijo asociado a realizar las transiciones entre código nativo y administrado, y un costo variable asociado a cualquier serialización de argumentos y valores devueltos que podrían ser necesarios. La contribución fija al costo de interoperabilidad COM y P/Invoke es pequeña: normalmente menos de 50 instrucciones. El costo de serializar hacia y desde los tipos administrados dependerá de la forma en que las representaciones se encuentren en cualquiera de los lados del límite. Los tipos que requieren una cantidad significativa de transformación serán más caros. Por ejemplo, todas las cadenas de CLR son cadenas Unicode. Si llamas a una API de Win32 a través de P/Invoke que espera una matriz de caracteres ANSI, todos los caracteres de la cadena deben restringirse. Sin embargo, si se pasa una matriz de enteros administrada donde se espera una matriz de enteros nativa, no se requiere ninguna serialización.

Dado que hay un costo de rendimiento asociado a la llamada a código nativo, debe asegurarse de que el costo está justificado. Si va a realizar una llamada nativa, asegúrese de que el trabajo que realiza la llamada nativa justifica el costo de rendimiento asociado a la llamada, mantenga los métodos "fragmentados" en lugar de "chatty". Una buena manera de medir el costo de una llamada nativa es medir el rendimiento de un método nativo que no toma argumentos y no tiene ningún valor devuelto y, a continuación, medir el rendimiento del método nativo al que desea llamar. La diferencia le dará una indicación del costo de serialización.

PISTA Realice llamadas de interoperabilidad COM "Fragmentada" y P/Invoke en lugar de llamadas "Chatty" y asegúrese de que el costo de realizar la llamada está justificado por la cantidad de trabajo que realiza la llamada.

Tenga en cuenta que no hay modelos de subprocesos asociados a subprocesos administrados. Cuando vaya a realizar una llamada de interoperabilidad COM, debe asegurarse de que el subproceso en el que se va a realizar la llamada se inicializa en el modelo de subproceso COM correcto. Normalmente, esto se hace mediante MTAThreadAttribute y STAThreadAttribute (aunque también se puede realizar mediante programación).

Contadores de rendimiento

Se exponen varios contadores de rendimiento de Windows para .NET CLR. Estos contadores de rendimiento deben ser el arma de elección de un desarrollador al diagnosticar por primera vez un problema de rendimiento o al intentar identificar las características de rendimiento de una aplicación administrada. Ya he mencionado algunos de los contadores relacionados con la administración de memoria y las excepciones. Hay contadores de rendimiento para casi todos los aspectos de CLR y .NET Framework. Estos contadores de rendimiento siempre están disponibles y no son invasivos; tienen una sobrecarga baja y no cambian las características de rendimiento de la aplicación.

Otras herramientas

Aparte de los contadores de rendimiento y CLR Profiler, querrá usar un generador de perfiles convencional para determinar qué métodos de la aplicación tardan más tiempo y se llama con más frecuencia. Estos van a ser los métodos que se optimizan primero. Hay varios generadores de perfiles comerciales disponibles que admiten código administrado, incluido DevPartner Studio Professional Edition 7.0 de Compuware y VTune™ Analizador de rendimiento 7.0 desde Intel®. Compuware también genera un generador de perfiles gratuito para código administrado denominado DevPartner Profiler Community Edition.

Conclusión

Este artículo solo comienza el examen de CLR y .NET Framework desde una perspectiva de rendimiento. Hay muchos otros aspectos de la arquitectura de CLR y .NET Framework que afectarán al rendimiento de la aplicación. La mejor guía que puedo dar a cualquier desarrollador es no realizar ninguna suposición sobre el rendimiento de la plataforma a la que se dirige la aplicación y las API que está usando. ¡Mida todo!

Feliz malabarismo.

Recursos