Compartir a través de


Consideraciones de rendimiento para las tecnologías de Run-Time en .NET Framework

 

Manuel Schanzer
Microsoft Corporation

Agosto de 2001

Resumen: En este artículo se incluye una encuesta de diversas tecnologías en el trabajo en el mundo administrado y una explicación técnica de cómo afectan al rendimiento. Obtenga información sobre el funcionamiento de la recolección de elementos no utilizados, jiT, comunicación remota, ValueTypes, seguridad y mucho más. (27 páginas impresas)

Contenido

Información general
Recolección de elementos no utilizados
Grupo de subprocesos
The JIT
Dominios de aplicaciones
Seguridad
Comunicación remota
ValueTypes
Recursos adicionales
Apéndice: Hospedaje del tiempo de ejecución del servidor

Información general

El tiempo de ejecución de .NET presenta varias tecnologías avanzadas destinadas a la seguridad, la facilidad de desarrollo y el rendimiento. Como desarrollador, es importante comprender cada una de las tecnologías y usarlas de forma eficaz en el código. Las herramientas avanzadas proporcionadas por el tiempo de ejecución facilitan la compilación de una aplicación sólida, pero hacer que la aplicación vuela rápidamente es (y siempre ha sido) la responsabilidad del desarrollador.

Estas notas del producto le proporcionarán una comprensión más profunda de las tecnologías en el trabajo en .NET y le ayudarán a optimizar el código para obtener mayor velocidad. Nota: esta no es una hoja de especificaciones. Ya hay mucha información técnica sólida. El objetivo aquí es proporcionar la información con una inclinación fuerte hacia el rendimiento, y puede que no responda a todas las preguntas técnicas que tiene. Recomiendo seguir buscando en MSDN Online Library si no encuentra las respuestas que busca aquí.

Voy a cubrir las siguientes tecnologías, proporcionando una visión general de alto nivel de su propósito y por qué afectan al rendimiento. A continuación, profundizaré en algunos detalles de implementación de nivel inferior y usaré código de ejemplo para ilustrar las formas de sacar la velocidad de cada tecnología.

Recolección de elementos no utilizados

Conceptos básicos

La recolección de elementos no utilizados (GC) libera al programador de errores comunes y difíciles de depurar liberando memoria para los objetos que ya no se usan. La ruta de acceso general seguida de la duración de un objeto es la siguiente, tanto en código administrado como nativo:

Foo a = new Foo();      // Allocate memory for the object and initialize
...a...                  // Use the object   
delete a;               // Tear down the state of the object, clean up
                        // and free the memory for that object

En código nativo, debe hacer todas estas cosas usted mismo. La falta de las fases de asignación o limpieza puede dar lugar a un comportamiento totalmente imprevisible que es difícil de depurar y olvidarse de liberar objetos puede provocar pérdidas de memoria. La ruta de acceso para la asignación de memoria en Common Language Runtime (CLR) está muy cerca de la ruta de acceso que acabamos de cubrir. Si agregamos la información específica de GC, terminamos con algo que tiene un aspecto muy similar.

Foo a = new Foo();      // Allocate memory for the object and initialize
...a...                  // Use the object (it is strongly reachable)
a = null;               // A becomes unreachable (out of scope, nulled, etc)
                        // Eventually a collection occurs, and a's resources
                        // are torn down and the memory is freed

Hasta que se pueda liberar el objeto, se realizan los mismos pasos en ambos mundos. En el código nativo, debe recordar liberar el objeto cuando haya terminado con él. En el código administrado, una vez que el objeto ya no es accesible, el GC puede recopilarlo. Por supuesto, si el recurso requiere una especial atención para liberarse (por ejemplo, cerrar un socket), el GC puede necesitar ayuda para cerrarlo correctamente. El código que ha escrito antes de limpiar un recurso antes de liberarlo todavía se aplica, en forma de métodos Dispose() y Finalize(). Hablaré de las diferencias entre estos dos más adelante.

Si mantiene un puntero a un recurso, el GC no tiene forma de saber si quiere usarlo en el futuro. Esto significa que todas las reglas que ha usado en el código nativo para liberar explícitamente objetos se siguen aplicando, pero la mayoría de las veces que el GC controlará todo para usted. En lugar de preocuparse por la administración de memoria al cien por ciento del tiempo, solo tiene que preocuparse por él alrededor de cinco por ciento del tiempo.

El recolector de elementos no utilizados de CLR es un recolector de elementos no utilizados de generación, marcado y compacto. Sigue varios principios que le permiten lograr un rendimiento excelente. En primer lugar, existe la noción de que los objetos que son de corta duración tienden a ser más pequeños y a los que se accede a menudo. El GC divide el gráfico de asignación en varios sub grafos, denominados generaciones, lo que le permite dedicar el menor tiempo posible a recopilar el máximo de tiempo*.* Gen 0 contiene objetos jóvenes y usados con frecuencia. Esto también tiende a ser el más pequeño, y tarda aproximadamente 10 milisegundos en recogerse. Dado que el GC puede omitir las otras generaciones durante esta colección, proporciona un rendimiento mucho mayor. G1 y G2 son para objetos más grandes y antiguos y se recopilan con menos frecuencia. Cuando se produce una colección G1, también se recopila G0. Una colección G2 es una colección completa y es la única vez que el GC recorre todo el gráfico. También hace un uso inteligente de las memorias caché de CPU, que pueden optimizar el subsistema de memoria para el procesador específico en el que se ejecuta. Se trata de una optimización que no está disponible fácilmente en la asignación nativa y puede ayudar a la aplicación a mejorar el rendimiento.

¿Cuándo se produce una recopilación?

Cuando se realiza una asignación de tiempo, el GC comprueba si se necesita una colección. Examina el tamaño de la colección, la cantidad de memoria restante y los tamaños de cada generación y, a continuación, usa una heurística para tomar la decisión. Hasta que se produce una colección, la velocidad de asignación de objetos suele ser tan rápida (o más rápida) que C o C++.

¿Qué ocurre cuando se produce una colección?

Vamos a recorrer los pasos que realiza un recolector de elementos no utilizados durante una recolección. El GC mantiene una lista de raíces, que apuntan al montón de GC. Si un objeto está activo, hay una raíz en su ubicación en el montón. Los objetos del montón también pueden apuntarse entre sí. Este gráfico de punteros es lo que el GC debe buscar para liberar espacio. El orden de los eventos es el siguiente:

  1. El montón administrado mantiene todo su espacio de asignación en un bloque contiguo y, cuando este bloque es menor que la cantidad solicitada, se llama a la GC.

  2. El GC sigue cada raíz y todos los punteros siguientes, manteniendo una lista de los objetos que no son accesibles.

  3. Todos los objetos no accesibles desde ninguna raíz se consideran recopilables y se marcan para la colección.

    Figura 1. Antes de la colección: tenga en cuenta que no todos los bloques son accesibles desde las raíces.

  4. Quitar objetos del gráfico de accesibilidad hace que la mayoría de los objetos se puedan recopilar. Sin embargo, algunos recursos deben controlarse especialmente. Al definir un objeto, tiene la opción de escribir un método Dispose() o un método Finalize() (o ambos). Hablaré de las diferencias entre los dos y cuándo usarlas más adelante.

  5. El último paso de una colección es la fase de compactación. Todos los objetos que están en uso se mueven a un bloque contiguo y se actualizan todos los punteros y raíces.

  6. Al compactar los objetos activos y actualizar la dirección inicial del espacio libre, el GC mantiene que todo el espacio libre es contiguo. Si hay suficiente espacio para asignar el objeto, el GC devuelve el control al programa. Si no es así, genera un OutOfMemoryException.

    Ilustración 2. Después de la colección: los bloques accesibles se han compactado. ¡Más espacio libre!

Para obtener más información técnica sobre la administración de memoria, vea capítulo 3 de Aplicaciones de programación para Microsoft Windows por Jeffrey Richter (Microsoft Press, 1999).

Limpieza de objetos

Algunos objetos requieren un control especial antes de que se puedan devolver sus recursos. Algunos ejemplos de estos recursos son archivos, sockets de red o conexione de base de datos. Simplemente liberar la memoria en el montón no va a ser suficiente, ya que desea que estos recursos se cierren correctamente. Para realizar la limpieza de objetos, puede escribir un método Dispose(), un método Finalize() o ambos.

Un método Finalize():

  • Lo llama el GC.
  • No se garantiza que se llame en ningún orden o en un momento predecible.
  • Después de llamar a , libera memoria después del siguiente GC.
  • Mantiene todos los objetos secundarios activos hasta el siguiente GC

Un método Dispose():

  • Lo llama el programador.
  • Está ordenado y programado por el programador.
  • Devuelve recursos tras la finalización del método.

Los objetos administrados que contienen solo recursos administrados no requieren estos métodos. Es probable que el programa use solo unos pocos recursos complejos, y es probable que sepa cuáles son y cuándo los necesita. Si conoce ambas cosas, no hay ninguna razón para confiar en los finalizadores, ya que puede realizar la limpieza manualmente. Hay varias razones por las que desea hacerlo y todas tienen que ver con la cola del finalizador.

En el GC, cuando un objeto que tiene un finalizador se marca como recopilable, y los objetos a los que apunta se colocan en una cola especial. Un subproceso independiente recorre esta cola, llamando al método Finalize() de cada elemento de la cola. El programador no tiene control sobre este subproceso o el orden de los elementos colocados en la cola. El GC puede devolver el control al programa, sin haber finalizado ningún objeto de la cola. Esos objetos pueden permanecer en memoria, escondidos en la cola durante mucho tiempo. Las llamadas para finalizar se realizan automáticamente y no hay ningún impacto directo en el rendimiento de la propia llamada. Sin embargo, el modelo no determinista para la finalización definitivamente puede tener otras consecuencias indirectas:

  • En un escenario en el que tiene recursos que deben liberarse en un momento específico, pierde el control con finalizadores. Supongamos que tiene un archivo abierto y debe cerrarse por motivos de seguridad. Incluso cuando se establece el objeto en NULL y se fuerza inmediatamente un GC, el archivo permanecerá abierto hasta que se llame a su método Finalize() y no tiene idea de cuándo podría ocurrir esto.
  • Es posible que N objetos que requieran eliminación en un orden determinado no se controlen correctamente.
  • Un enorme objeto y sus elementos secundarios pueden tardar demasiado en memoria, requerir colecciones adicionales y dañar el rendimiento. Es posible que estos objetos no se recopilen durante mucho tiempo.
  • Un objeto pequeño que se va a finalizar puede tener punteros a recursos grandes que se podrían liberar en cualquier momento. Estos objetos no se liberarán hasta que el objeto que se va a finalizar se ocupe, creando presión innecesaria de memoria y forzando colecciones frecuentes.

En el diagrama de estado de la figura 3 se muestran las distintas rutas de acceso que el objeto puede tomar en términos de finalización o eliminación.

Figura 3. Rutas de eliminación y finalización que puede tomar un objeto

Como puede ver, la finalización agrega varios pasos a la duración del objeto. Si elimina un objeto usted mismo, el objeto se puede recopilar y la memoria devuelta a usted en el siguiente GC. Cuando es necesario que se produzca la finalización, debe esperar hasta que se llame al método real. Puesto que no se le dan garantías sobre cuándo sucede esto, puede tener mucha memoria vinculada y estar a la misericordia de la cola de finalización. Esto puede ser extremadamente problemático si el objeto está conectado a un árbol completo de objetos y todos se sientan en la memoria hasta que se produzca la finalización.

Elección del recolector de elementos no utilizados que se va a usar

CLR tiene dos GCs diferentes: Estación de trabajo (mscorwks.dll) y Servidor (mscorsvr.dll). Cuando se ejecuta en modo estación de trabajo, la latencia es más preocupante que el espacio o la eficiencia. Un servidor con varios procesadores y clientes conectados a través de una red puede permitir cierta latencia, pero el rendimiento es ahora una prioridad principal. En lugar de zapatear ambos escenarios en un único esquema de GC, Microsoft ha incluido dos recolectores de elementos no utilizados que se adaptan a cada situación.

GC de servidor:

  • Multiprocesador (MP) escalable, paralelo
  • Un subproceso de GC por CPU
  • Programa en pausa durante el marcado

GC de estación de trabajo:

  • Minimiza las pausas al ejecutarse simultáneamente durante las colecciones completas.

El GC del servidor está diseñado para un rendimiento máximo y se escala con un rendimiento muy alto. La fragmentación de memoria en los servidores es un problema mucho más grave que en las estaciones de trabajo, lo que hace que la recolección de elementos no utilizados sea una propuesta atractiva. En un escenario de un uniprocesador, ambos recopiladores funcionan de la misma manera: modo de estación de trabajo, sin recopilación simultánea. En una máquina mp, el GC de estación de trabajo usa el segundo procesador para ejecutar la recopilación simultáneamente, lo que minimiza los retrasos al reducir el rendimiento. El GC de servidor usa varios montones y subprocesos de recopilación para maximizar el rendimiento y escalar mejor.

Puede elegir qué GC se usará al hospedar el tiempo de ejecución. Al cargar el tiempo de ejecución en un proceso, se especifica qué recopilador se va a usar. La carga de la API se describe en la Guía del desarrollador de .NET Framework. Para obtener un ejemplo de un programa sencillo que hospeda el tiempo de ejecución y selecciona el GC del servidor, eche un vistazo al Apéndice.

Mito: La recolección de elementos no utilizados siempre es más lenta que hacerlo a mano

En realidad, hasta que se llama a una colección, el GC es mucho más rápido que hacerlo a mano en C. Esto sorprende a mucha gente, por lo que vale la pena una explicación. En primer lugar, observe que la búsqueda de espacio libre se produce en tiempo constante. Puesto que todo el espacio libre es contiguo, el GC simplemente sigue el puntero y comprueba si hay suficiente espacio. En C, una llamada a malloc() normalmente da como resultado una búsqueda de una lista vinculada de bloques libres. Esto puede llevar mucho tiempo, especialmente si el montón está fragmentado mal. Para empeorar las cosas, varias implementaciones del tiempo de ejecución de C bloquean el montón durante este procedimiento. Una vez asignada o usada la memoria, la lista debe actualizarse. En un entorno recopilado por elementos no utilizados, la asignación es libre y la memoria se libera durante la recolección. Los programadores más avanzados reservarán grandes bloques de memoria y controlarán la asignación dentro de ese bloque. El problema con este enfoque es que la fragmentación de memoria se convierte en un problema enorme para los programadores y las obliga a agregar una gran cantidad de lógica de control de memoria a sus aplicaciones. Al final, un recolector de elementos no utilizados no agrega mucha sobrecarga. La asignación es tan rápida o más rápida, y la compactación se controla automáticamente, lo que libera a los programadores para centrarse en sus aplicaciones.

En el futuro, los recolectores de elementos no utilizados podrían realizar otras optimizaciones que lo hacen aún más rápido. La identificación de puntos calientes y un mejor uso de la memoria caché son posibles y pueden hacer enormes diferencias de velocidad. Un GC más inteligente podría empaquetar páginas de forma más eficaz, lo que minimiza el número de capturas de página que se producen durante la ejecución. Todos ellos podrían hacer que un entorno recolector de elementos no utilizados sea más rápido que hacer cosas a mano.

Algunas personas pueden preguntarse por qué GC no está disponible en otros entornos, como C o C++. La respuesta es tipos. Esos lenguajes permiten la conversión de punteros a cualquier tipo, lo que hace que sea extremadamente difícil saber a qué se refiere un puntero. En un entorno administrado como CLR, podemos garantizar suficientes punteros para que gc sea posible. El mundo administrado también es el único lugar donde podemos detener la ejecución de subprocesos de forma segura para realizar una GC: en C++ estas operaciones no son seguras o muy limitadas.

Ajuste de velocidad

La mayor preocupación para un programa en el mundo administrado es la retención de memoria. Algunos de los problemas que encontrará en entornos no administrados no son un problema en el mundo administrado: las fugas de memoria y los punteros pendientes no son un problema aquí. En su lugar, los programadores deben tener cuidado de dejar los recursos conectados cuando ya no los necesiten.

La heurística más importante para el rendimiento también es la más fácil de aprender para los programadores que se usan para escribir código nativo: realizar un seguimiento de las asignaciones que se van a realizar y liberarlas cuando haya terminado. El GC no tiene ninguna manera de saber que no va a usar una cadena de 20 KB que ha compilado si forma parte de un objeto que se mantiene alrededor. Supongamos que tiene este objeto escondido en un vector en algún lugar y nunca piensa volver a usar esa cadena. Si establece el campo en NULL, el GC recopilará esos 20 KB más adelante, incluso si todavía necesita el objeto para otros fines. Si ya no necesita el objeto, asegúrese de no mantener las referencias a él. (Al igual que en código nativo). En el caso de objetos más pequeños, esto es menor que un problema. Cualquier programador que esté familiarizado con la administración de memoria en código nativo no tendrá ningún problema aquí: se aplican todas las mismas reglas de sentido común. No tienes que ser tan paranoico sobre ellos.

La segunda preocupación importante sobre el rendimiento se ocupa de la limpieza de objetos. Como he mencionado anteriormente, la finalización tiene un impacto profundo en el rendimiento. El ejemplo más común es el de un controlador administrado a un recurso no administrado: debe implementar algún tipo de método de limpieza y aquí es donde el rendimiento se convierte en un problema. Si depende de la finalización, se abre a los problemas de rendimiento que he enumerado anteriormente. Otra cosa que hay que tener en cuenta es que el GC no es consciente en gran medida de la presión de memoria en el mundo nativo, por lo que puede que esté usando una ton de recursos no administrados simplemente manteniendo un puntero alrededor del montón administrado. Un único puntero no ocupa mucha memoria, por lo que podría ser un tiempo antes de que se necesite una colección. Para solucionar estos problemas de rendimiento, mientras sigue jugando a salvo cuando se trata de la retención de memoria, debe elegir un patrón de diseño con el que trabajar para todos los objetos que requieren una limpieza especial.

El programador tiene cuatro opciones al tratar con la limpieza de objetos:

  1. Implementación de ambos

    Este es el diseño recomendado para la limpieza de objetos. Se trata de un objeto con cierta combinación de recursos no administrados y administrados. Un ejemplo sería System.Windows.Forms.Control. Tiene un recurso no administrado (HWND) y recursos potencialmente administrados (DataConnection, etc.). Si no está seguro de cuándo usa recursos no administrados, puede abrir el manifiesto del programa en ILDASM`` y comprobar si hay referencias a bibliotecas nativas. Otra alternativa es usar vadump.exe para ver qué recursos se cargan junto con el programa. Ambos pueden proporcionarle información sobre qué tipo de recursos nativos usa.

    El patrón siguiente proporciona a los usuarios una única manera recomendada en lugar de invalidar la lógica de limpieza (invalidar Dispose(bool)). Esto proporciona máxima flexibilidad, así como catch-all solo en caso de que nunca se llame a Dispose(). La combinación de máxima velocidad y flexibilidad, así como el enfoque de red de seguridad hacen que este sea el mejor diseño que se va a usar.

    Ejemplo:

    public class MyClass : IDisposable {
      public void Dispose() {
        Dispose(true);
        GC.SuppressFinalizer(this);
      }
      protected virtual void Dispose(bool disposing) {
        if (disposing) {
          ...
        }
          ...
      }
      ~MyClass() {
        Dispose(false);
      }
    }
    
  2. Implementación solo de Dispose()

    Esto es cuando un objeto solo tiene recursos administrados y quiere asegurarse de que su limpieza es determinista. Un ejemplo de este objeto es System.Web.UI.Control.

    Ejemplo:

    public class MyClass : IDisposable {
      public virtual void Dispose() {
        ...
      }
    
  3. Implementar solo Finalize()

    Esto es necesario en situaciones extremadamente raras, y recomiendo encarecidamente contra él. La implicación de un objeto Finalize() es que el programador no tiene idea de cuándo se va a recopilar el objeto, pero está usando un recurso lo suficientemente complejo como para requerir una limpieza especial. Esta situación nunca debe producirse en un proyecto bien diseñado, y si se encuentra en él, debe volver atrás y averiguar lo que salió mal.

    Ejemplo:

    public class MyClass {
      ...
      ~MyClass() {
        ...
      }
    
  4. Implementar ninguno

    Esto es para un objeto administrado que apunta solo a otros objetos administrados que no son descartables ni que se van a finalizar.

Recomendación

Las recomendaciones para tratar con la administración de memoria deben estar familiares: liberar objetos cuando haya terminado con ellos y tenga en cuenta que deja punteros a los objetos. En lo que respecta a la limpieza de objetos, implemente un método Finalize() y Dispose() para objetos con recursos no administrados. Esto impedirá un comportamiento inesperado más adelante y aplicará los procedimientos de programación recomendados.

El inconveniente aquí es que obliga a las personas a tener que llamar a Dispose(). Aquí no hay ninguna pérdida de rendimiento, pero algunas personas pueden resultar frustrantes tener que pensar en la eliminación de sus objetos. Sin embargo, creo que vale la pena usar un modelo que tenga sentido. Además, esto obliga a las personas a ser más atentos a los objetos que asignan, ya que no pueden confiar ciegamente en el GC para que siempre se ocupen de ellos. Para los programadores procedentes de un fondo de C o C++, forzar una llamada a Dispose() probablemente será beneficioso, ya que es el tipo de cosas con las que están más familiarizados.

Dispose() debe admitirse en objetos que contengan recursos no administrados en cualquier parte del árbol de objetos debajo de él; Sin embargo, Finalize() solo debe colocarse en los objetos que se mantienen específicamente en estos recursos, como un identificador del sistema operativo o una asignación de memoria no administrada. Sugiero crear objetos administrados pequeños como "contenedores" para implementar Finalize() además de admitir Dispose(), al que llamaría el objeto primario Dispose(). Dado que los objetos primarios no tienen un finalizador, todo el árbol de objetos no sobrevivirá a una colección independientemente de si se llamó a Dispose() o no.

Una buena regla general para los finalizadores es usarlos solo en el objeto más primitivo que requiere finalización. Supongamos que tengo un recurso administrado grande que incluye una conexión de base de datos: haría posible que la propia conexión se finalizara, pero hacer que el resto del objeto fuera descartable. De este modo, puedo llamar a Dispose() y liberar las partes administradas del objeto inmediatamente, sin tener que esperar a que se finalice la conexión. Recuerde: use Finalize() solo donde tenga que hacerlo cuando tenga que hacerlo.

Nota Programadores de C y C++: la semántica del destructor en C# crea un finalizador, no un método de eliminación.

Grupo de subprocesos

Conceptos básicos

El grupo de subprocesos de CLR es similar al grupo de subprocesos NT de muchas maneras y casi no requiere conocimientos nuevos sobre la parte del programador. Tiene un subproceso de espera, que puede controlar los bloques de otros subprocesos y notificarlos cuando necesiten devolverlos, liberándolos para realizar otro trabajo. Puede generar nuevos subprocesos y bloquear otros para optimizar el uso de CPU en tiempo de ejecución, lo que garantiza que se realiza la mayor cantidad de trabajo útil. También recicla los subprocesos cuando se realizan, comenzando de nuevo sin la sobrecarga de matar y generar nuevos. Se trata de un aumento considerable del rendimiento sobre el control manual de subprocesos, pero no es un truco. Saber cuándo usar el grupo de subprocesos es esencial al optimizar una aplicación subprocesada.

Lo que sabe del grupo de subprocesos NT:

  • El grupo de subprocesos controlará la creación y limpieza de subprocesos.
  • Proporciona un puerto de finalización para subprocesos de E/S (solo plataformas NT).
  • La devolución de llamada se puede enlazar a archivos u otros recursos del sistema.
  • Las API de temporizador y espera están disponibles.
  • El grupo de subprocesos determina cuántos subprocesos deben estar activos mediante heurística como retraso desde la última inyección, el número de subprocesos actuales y el tamaño de la cola.
  • Fuente de subprocesos de una cola compartida.

Qué es diferente en .NET:

  • Es consciente del bloqueo de subprocesos en código administrado (por ejemplo, debido a la recolección de elementos no utilizados, espera administrada) y puede ajustar su lógica de inserción de subprocesos en consecuencia.
  • No hay ninguna garantía de servicio para subprocesos individuales.

Cuándo controlar subprocesos usted mismo

El uso eficaz del grupo de subprocesos está estrechamente vinculado a saber lo que necesita de los subprocesos. Si necesita una garantía de servicio, deberá administrarlo usted mismo. En la mayoría de los casos, el uso del grupo le proporcionará el rendimiento óptimo. Si tiene restricciones estrictas y necesita un control estricto de los subprocesos, probablemente tenga más sentido usar subprocesos nativos de todos modos, así que tenga cuidado con el control de subprocesos administrados usted mismo. Si decide escribir código administrado y controlar el subproceso por su cuenta, asegúrese de que no genera subprocesos por conexión: esto solo dañará el rendimiento. Como regla general, solo debe elegir controlar los subprocesos en el mundo administrado en escenarios muy específicos en los que hay una tarea grande y con mucho tiempo que se realiza rara vez. Un ejemplo podría estar rellenando una caché grande en segundo plano o escribir un archivo grande en el disco.

Ajuste de velocidad

El grupo de subprocesos establece un límite en el número de subprocesos que deben estar activos y, si muchos de ellos bloquean, el grupo morirá de hambre. Lo ideal es usar el grupo de subprocesos para subprocesos de corta duración y sin bloqueo. En las aplicaciones de servidor, quiere responder a cada solicitud de forma rápida y eficaz. Si pone en marcha un nuevo subproceso para cada solicitud, se trata de una gran cantidad de sobrecarga. La solución consiste en reciclar los subprocesos, teniendo cuidado de limpiar y devolver el estado de cada subproceso tras la finalización. Estos son los escenarios en los que el grupo de subprocesos es un rendimiento y un resultado de diseño importantes, y dónde debe hacer un buen uso de la tecnología. El grupo de subprocesos controla la limpieza de estado de forma automática y se asegura de que el número óptimo de subprocesos esté en uso en un momento dado. En otras situaciones, puede tener más sentido controlar el subproceso por su cuenta.

Aunque CLR puede usar la seguridad de tipos para garantizar los procesos para garantizar que AppDomains pueda compartir el mismo proceso, no existe dicha garantía con subprocesos. El programador es responsable de escribir subprocesos bien comportados, y todos los conocimientos de código nativo se siguen aplicando.

A continuación, tenemos un ejemplo de una aplicación sencilla que aprovecha el grupo de subprocesos. Crea un montón de subprocesos de trabajo y, a continuación, los hace realizar una tarea sencilla antes de cerrarlos. He extraído alguna comprobación de errores, pero este es el mismo código que se puede encontrar en la carpeta del SDK de Framework en "Samples\Threading\Threadpool". En este ejemplo, tenemos código que crea un elemento de trabajo simple y usa el grupo de subprocesos para que varios subprocesos controlen estos elementos sin que el programador tenga que administrarlos. Consulte el archivo ReadMe.html para obtener más información.

using System;
using System.Threading;

public class SomeState{
  public int Cookie;
  public SomeState(int iCookie){
    Cookie = iCookie;
  }
};


public class Alpha{
  public int [] HashCount;
  public ManualResetEvent eventX;
  public static int iCount = 0;
  public static int iMaxCount = 0;
  public Alpha(int MaxCount) {
    HashCount = new int[30];
    iMaxCount = MaxCount;
  }


   //   The method that will be called when the Work Item is serviced
   //   on the Thread Pool
   public void Beta(Object state){
     Console.WriteLine(" {0} {1} :", 
               Thread.CurrentThread.GetHashCode(), ((SomeState)state).Cookie);
     Interlocked.Increment(ref HashCount[Thread.CurrentThread.GetHashCode()]);

     //   Do some busy work
     int iX = 10000;
     while (iX > 0){ iX--;}
     if (Interlocked.Increment(ref iCount) == iMaxCount) {
       Console.WriteLine("Setting EventX ");
       eventX.Set();
     }
  }
};

public class SimplePool{
  public static int Main(String[] args)   {
    Console.WriteLine("Thread Simple Thread Pool Sample");
    int MaxCount = 1000;
    ManualResetEvent eventX = new ManualResetEvent(false);
    Console.WriteLine("Queuing {0} items to Thread Pool", MaxCount);
    Alpha oAlpha = new Alpha(MaxCount);
    oAlpha.eventX = eventX;
    Console.WriteLine("Queue to Thread Pool 0");
    ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),new SomeState(0));
       for (int iItem=1;iItem < MaxCount;iItem++){
         Console.WriteLine("Queue to Thread Pool {0}", iItem);
         ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),
                                   new SomeState(iItem));
       }
    Console.WriteLine("Waiting for Thread Pool to drain");
    eventX.WaitOne(Timeout.Infinite,true);
    Console.WriteLine("Thread Pool has been drained (Event fired)");
    Console.WriteLine("Load across threads");
    for(int iIndex=0;iIndex<oAlpha.HashCount.Length;iIndex++)
      Console.WriteLine("{0} {1}", iIndex, oAlpha.HashCount[iIndex]);
    }
    return 0;
  }
}

The JIT

Conceptos básicos

Al igual que con cualquier máquina virtual, CLR necesita una manera de compilar el lenguaje intermedio hasta el código nativo. Al compilar un programa para que se ejecute en CLR, el compilador toma el origen de un lenguaje de alto nivel hasta una combinación de MSIL (lenguaje intermedio de Microsoft) y metadatos. Se combinan en un archivo PE, que luego se puede ejecutar en cualquier máquina compatible con CLR. Al ejecutar este archivo ejecutable, JIT inicia la compilación del IL en código nativo y ejecuta ese código en el equipo real. Esto se realiza por método, por lo que el retraso de JITing solo es el tiempo necesario para el código que desea ejecutar.

El JIT es bastante rápido y genera código muy bueno. A continuación se describen algunas de las optimizaciones que realiza (y algunas explicaciones de cada una). Tenga en cuenta que la mayoría de estas optimizaciones tienen límites impuestos para asegurarse de que el JIT no dedica demasiado tiempo.

  • Plegado constante: calcule valores constantes en tiempo de compilación.

    Antes Después
    x = 5 + 7 x = 12
  • Constante y propagación de copia: sustituya hacia atrás a variables libres anteriormente.

    Antes Después
    x = a x = a
    y = x y = a
    z = 3 + y z = 3 + a
  • Inserción de métodos: reemplace los argumentos por los valores pasados en el momento de la llamada y elimine la llamada. A continuación, se pueden realizar muchas otras optimizaciones para cortar el código fallido. Por motivos de velocidad, el JIT actual tiene varios límites en lo que puede insertar. Por ejemplo, solo se insertan métodos pequeños (tamaño de IL inferior a 32) y el análisis de control de flujo es bastante primitivo.

    Antes Después
    ...

    x=foo(4, true);

    ...

    }

    foo(int a, bool b){

    if(b){

    return a + 5;

    } else {

    return 2a + bar();

    }

    ...

    x = 9

    ...

    }

    foo(int a, bool b){

    if(b){

    return a + 5;

    } else {

    return 2a + bar();

    }

  • Elevación de código y dominadores: quite el código de los bucles interiores si está duplicado fuera. El ejemplo "anterior" siguiente es realmente lo que se genera en el nivel il, ya que todos los índices de matriz deben comprobarse.

    Antes Después
    for(i=0; i< a.length;i++){

    if(i < a.length()){

    a[i] = null

    } else {

    raise IndexOutOfBounds;

    }

    }

    for(int i=0; i<a.length; i++){

    a[i] = null;

    }

  • Desenrollamiento de bucles: se puede quitar la sobrecarga de los contadores de incremento y la realización de la prueba, y se puede repetir el código del bucle. Para bucles extremadamente ajustados, esto da como resultado una victoria de rendimiento.

    Antes Después
    for(i=0; i< 3; i++){

    print("flaming monkeys!");

    }

    print("flaming monkeys!");

    print("flaming monkeys!");

    print("flaming monkeys!");

  • Eliminación común de SubExpression: si una variable activa todavía contiene la información que se va a volver a calcular, úsela en su lugar.

    Antes Después
    x = 4 + y

    z = 4 + y

    x = 4 + y

    z = x

  • Registro: no es útil proporcionar un ejemplo de código aquí, por lo que una explicación tendrá que ser suficiente. Esta optimización puede dedicar tiempo a examinar cómo se usan las variables locales y las temporales en una función e intentar controlar la asignación de registros lo más eficaz posible. Esto puede ser una optimización extremadamente costosa y el CLR JIT actual solo tiene en cuenta un máximo de 64 variables locales para el registro. Las variables que no se consideran se colocan en el marco de pila. Este es un ejemplo clásico de las limitaciones de JITing: aunque esto es un 99 % del tiempo, las funciones muy inusuales que tienen más de 100 configuraciones locales se optimizarán mejor con la compilación previa tradicional y lenta.

  • Misc: se realizan otras optimizaciones simples, pero la lista anterior es una buena muestra. El JIT también pasa para código fallido y otras optimizaciones de peephole.

¿Cuándo obtiene el código JITed?

Esta es la ruta de acceso a la que pasa el código cuando se ejecuta:

  1. El programa se carga y se inicializa una tabla de funciones con punteros que hacen referencia al IL.
  2. El método Main es JITed en código nativo, que se ejecuta a continuación. Las llamadas a funciones se compilan en llamadas de función indirectas a través de la tabla.
  3. Cuando se llama a otro método, el tiempo de ejecución examina la tabla para ver si apunta al código JITed.
    1. Si tiene (quizás se ha llamado desde otro sitio de llamada o se ha precompilado), el flujo de control continúa.
    2. Si no es así, el método es JITed y la tabla se actualiza.
  4. A medida que se les llama, se compilan más métodos en código nativo y más entradas en el punto de tabla en el grupo creciente de instrucciones x86.
  5. A medida que se ejecuta el programa, se llama a JIT menos y menos a menudo hasta que se compila todo.
  6. Un método no es JITed hasta que se llama y, a continuación, nunca se vuelve a jiTed durante la ejecución del programa. Solo paga por lo que usa.

Mito: Los programas JITed se ejecutan más lentamente que los programas precompilados

Esto no suele ser así. La sobrecarga asociada a JITing es menor en comparación con el tiempo dedicado a leer en algunas páginas del disco y los métodos son JITed solo cuando son necesarios. El tiempo invertido en el JIT es tan pequeño que casi nunca es notable, y una vez que un método se ha jiTed, nunca se incurre en el costo de ese método de nuevo. Hablaré más sobre esto en la sección Código de precompilación.

Como se mencionó anteriormente, el JIT version1 (v1) realiza la mayoría de las optimizaciones que realiza un compilador y solo obtendrá más rápido en la versión siguiente (vNext), a medida que se agregan optimizaciones más avanzadas. Lo más importante es que JIT puede realizar algunas optimizaciones que un compilador normal no puede, como optimizaciones específicas de cpu y ajuste de caché.

Optimizaciones de JIT-Only

Dado que el JIT se activa en tiempo de ejecución, hay mucha información sobre que un compilador no es consciente. Esto le permite realizar varias optimizaciones que solo están disponibles en tiempo de ejecución:

  • Optimizaciones específicas del procesador: en tiempo de ejecución, JIT sabe si puede usar o no instrucciones de SSE o 3DNow. El ejecutable se compilará especialmente para P4, Athlon o cualquier familia de procesadores futura. Una vez se implementa y el mismo código mejorará junto con el JIT y el equipo del usuario.
  • Optimización de los niveles de direccionamiento indirecto, ya que la función y la ubicación del objeto están disponibles en tiempo de ejecución.
  • JiT puede realizar optimizaciones entre ensamblados, lo que proporciona muchas de las ventajas que obtiene al compilar un programa con bibliotecas estáticas, pero mantener la flexibilidad y la pequeña superficie de uso de las dinámicas.
  • Funciones insertadas agresivamente a las que se llama con más frecuencia, ya que es consciente del flujo de control durante el tiempo de ejecución. Las optimizaciones pueden proporcionar un aumento de velocidad sustancial y hay mucho espacio para mejorar más en vNext.

Estas mejoras en tiempo de ejecución se realizan a costa de un pequeño costo de inicio único y pueden compensar más el tiempo empleado en el JIT.

Precompilar código (mediante ngen.exe)

Para un proveedor de aplicaciones, la capacidad de precompilar código durante la instalación es una opción atractiva. Microsoft proporciona esta opción con el formato ngen.exe, que le permitirá ejecutar el compilador JIT normal en todo el programa una vez y guardar el resultado. Dado que las optimizaciones de solo tiempo de ejecución no se pueden realizar durante la precompilación, el código generado no suele ser tan bueno como el generado por un JIT normal. Sin embargo, sin tener que usar métodos JIT sobre la marcha, el costo de inicio es mucho menor y algunos programas se iniciarán notablemente más rápido. En el futuro, ngen.exe puede hacer más que simplemente ejecutar el mismo JIT en tiempo de ejecución: optimizaciones más agresivas con límites más altos que el tiempo de ejecución, exposición de optimización del orden de carga a los desarrolladores (optimización del modo en que el código se empaqueta en páginas de máquina virtual) y optimizaciones más complejas y lentas que pueden aprovechar el tiempo durante la precompilación.

Reducir el tiempo de inicio ayuda en dos casos, y para todo lo demás, no compite con las optimizaciones de solo tiempo de ejecución que puede hacer jiTing normal. La primera situación es donde llamas a un enorme número de métodos al principio del programa. Tendrá que jiT por adelantado muchos métodos, lo que da lugar a un tiempo de carga inaceptable. Esto no va a ser el caso de la mayoría de las personas, pero la creación previa de JITing podría tener sentido si le afecta. La precompilación también tiene sentido en el caso de bibliotecas compartidas de gran tamaño, ya que paga el costo de cargarlas con mucha más frecuencia. Microsoft precompila los marcos para CLR, ya que la mayoría de las aplicaciones las usarán.

Es fácil de usar ngen.exe para ver si la precompilación es la respuesta para usted, por lo que recomiendo probarlo. Sin embargo, la mayoría de las veces es mejor usar el JIT normal y aprovechar las optimizaciones en tiempo de ejecución. Tienen un enorme pago y compensarán más el costo de inicio único en la mayoría de las situaciones.

Ajuste de velocidad

Para el programador, realmente solo hay dos cosas que vale la pena tener en cuenta. En primer lugar, el JIT es muy inteligente. No intente pensar en el compilador. Codigo la forma en que lo haría normalmente. Por ejemplo, supongamos que tiene el siguiente código:

...

for(int i = 0; i < myArray.length; i++){

...

}

...

...

int l = myArray.length;

for(int i = 0; i < l; i++){

...

}

...

Algunos programadores creen que pueden aumentar la velocidad moviendo el cálculo de longitud y guardarlo en una temporal, como en el ejemplo de la derecha.

La verdad es que las optimizaciones como esta no han sido útiles durante casi 10 años: los compiladores modernos son más que capaces de realizar esta optimización por usted. De hecho, a veces las cosas como esta pueden dañar realmente el rendimiento. En el ejemplo anterior, es probable que un compilador compruebe que la longitud de myArray es constante e inserte una constante en la comparación del bucle for . Pero el código de la derecha podría engañar al compilador para pensar que este valor debe almacenarse en un registro, ya que l está activo en todo el bucle. La línea inferior es: escriba el código más legible y que tenga más sentido. No va a ayudar a probar a pensar en el compilador y, a veces, puede dañarlo.

La segunda cosa de la que hablar es la cola de llamadas. En este momento, los compiladores de C# y Microsoft® Visual Basic® no proporcionan la capacidad de especificar que se debe usar una llamada de cola. Si realmente necesita esta característica, una opción es abrir el archivo PE en un desensamblador y usar la instrucción .tail de MSIL en su lugar. Esta no es una solución elegante, pero las llamadas finales no son tan útiles en C# y Visual Basic, ya que están en lenguajes como Scheme o ML. Personas escribir compiladores para lenguajes que aprovechan realmente las llamadas finales debe asegurarse de usar esta instrucción. La realidad de la mayoría de las personas es que incluso ajustar manualmente el IL para usar las llamadas de cola no proporciona una enorme ventaja de velocidad. A veces, el tiempo de ejecución cambiará realmente a las llamadas normales, por motivos de seguridad. Quizás en versiones futuras se pondrá más esfuerzo para admitir llamadas finales, pero en el momento en que la ganancia de rendimiento es insuficiente para garantizarlo, y muy pocos programadores querrán aprovecharlo.

Dominios de aplicaciones

Conceptos básicos

La comunicación entre procesos es cada vez más común. Por motivos de estabilidad y seguridad, el sistema operativo mantiene las aplicaciones en espacios de direcciones independientes. Un ejemplo sencillo es la manera en que todas las aplicaciones de 16 bits se ejecutan en NT: si se ejecutan en un proceso independiente, una aplicación no puede interferir con la ejecución de otra. El problema aquí es el costo del conmutador de contexto y la apertura de una conexión entre procesos. Esta operación es muy costosa y daña mucho el rendimiento. En las aplicaciones de servidor, que a menudo hospedan varias aplicaciones web, se trata de una purga importante tanto en el rendimiento como en la escalabilidad.

CLR presenta el concepto de appDomain, que es similar a un proceso en el que es un espacio independiente para una aplicación. Sin embargo, los AppDomains no están restringidos a uno por proceso. Es posible ejecutar dos AppDomains completamente no relacionados en el mismo proceso, gracias a la seguridad de tipos proporcionada por el código administrado. La optimización del rendimiento aquí es enorme para situaciones en las que normalmente dedica mucho tiempo de ejecución en sobrecarga de comunicación entre procesos: IPC entre ensamblados es cinco veces más rápido que entre procesos en NT. Al reducir este costo drásticamente, se obtiene un aumento de velocidad y una nueva opción durante el diseño del programa: ahora tiene sentido usar procesos independientes donde antes de que haya sido demasiado caro. La capacidad de ejecutar varios programas en el mismo proceso con la misma seguridad que antes tiene enormes implicaciones para la escalabilidad y la seguridad.

La compatibilidad con AppDomains no está presente en el sistema operativo. Los appDomains se controlan mediante un host CLR, como los presentes en ASP.NET, un ejecutable de shell o Microsoft Internet Explorer. También puede escribir su propio. Cada host especifica un dominio predeterminado, que se carga cuando la aplicación se inicia por primera vez y solo se cierra cuando finaliza el proceso. Al cargar otros ensamblados en el proceso, puede especificar que se carguen en un AppDomain específico y establecer diferentes directivas de seguridad para cada uno de ellos. Esto se describe con más detalle en la documentación del SDK de Microsoft .NET Framework.

Ajuste de velocidad

Para usar AppDomains de forma eficaz, debe pensar en qué tipo de aplicación está escribiendo y qué tipo de trabajo debe hacer. Como buena regla general, AppDomains es más eficaz cuando la aplicación se ajusta a algunas de las siguientes características:

  • Genera una nueva copia de sí misma a menudo.
  • Funciona con otras aplicaciones para procesar información (consultas de base de datos dentro de un servidor web, por ejemplo).
  • Pasa mucho tiempo en IPC con programas que funcionan exclusivamente con su aplicación.
  • Se abre y cierra otros programas.

Un ejemplo de una situación en la que appDomains son útiles se puede ver en una aplicación de ASP.NET compleja. Supongamos que quiere aplicar el aislamiento entre diferentes vRoots: en el espacio nativo, debe colocar cada vRoot en un proceso independiente. Esto es bastante costoso y el cambio de contexto entre ellos es una gran sobrecarga. En el mundo administrado, cada vRoot puede ser un appDomain independiente. Esto conserva el aislamiento necesario al reducir drásticamente la sobrecarga.

AppDomains es algo que debe usar solo si la aplicación es lo suficientemente compleja como para requerir trabajar estrechamente con otros procesos u otras instancias de sí misma. Aunque la comunicación iter-AppDomain es mucho más rápida que la comunicación entre procesos, el costo de iniciar y cerrar un AppDomain puede ser más caro. AppDomains puede acabar dañando el rendimiento cuando se usa por los motivos incorrectos, así que asegúrese de que los usa en las situaciones adecuadas. Tenga en cuenta que solo se puede cargar código administrado en un appDomain, ya que no se puede garantizar la seguridad del código no administrado.

Los ensamblados que se comparten entre varios appDomains deben ser JITed para cada dominio, con el fin de conservar el aislamiento entre dominios. Esto da como resultado una gran cantidad de creación de código duplicado y memoria desperdiciada. Considere el caso de una aplicación que responde a las solicitudes con algún tipo de servicio XML. Si ciertas solicitudes deben mantenerse aisladas entre sí, deberá enrutarlas a diferentes AppDomains. El problema aquí es que cada AppDomain ahora requerirá las mismas bibliotecas XML y el mismo ensamblado se cargará varias veces.

Una manera de hacerlo es declarar un ensamblado para que sea Domain-Neutral, lo que significa que no se permiten referencias directas y se aplica el aislamiento a través de direccionamiento indirecto. Esto ahorra tiempo, ya que el ensamblado es JITed solo una vez. También guarda memoria, ya que no hay nada duplicado. Desafortunadamente, hay un impacto en el rendimiento debido al direccionamiento indirecto necesario. Declarar que un ensamblado es neutro en el dominio da como resultado un resultado de rendimiento cuando la memoria es un problema o cuando se desperdicia demasiado tiempo en el código JITing. Los escenarios como este son comunes en el caso de un ensamblado grande compartido por varios dominios.

Seguridad

Conceptos básicos

La seguridad de acceso al código es una característica eficaz y extremadamente útil. Ofrece a los usuarios una ejecución segura de código de confianza parcial, protege del software malintencionado y de varios tipos de ataques, y permite el acceso controlado basado en identidades a los recursos. En el código nativo, la seguridad es extremadamente difícil de proporcionar, ya que hay poca seguridad de tipos y el programador controla la memoria. En CLR, el tiempo de ejecución conoce lo suficiente sobre la ejecución de código para agregar compatibilidad de seguridad sólida, una característica que es nueva para la mayoría de los programadores.

La seguridad afecta tanto a la velocidad como al tamaño del espacio de trabajo de una aplicación. Y, al igual que con la mayoría de las áreas de programación, el modo en que el desarrollador usa la seguridad puede determinar considerablemente su impacto en el rendimiento. El sistema de seguridad está diseñado teniendo en cuenta el rendimiento y debe, en la mayoría de los casos, funcionar bien con poco o ningún pensamiento dado por el desarrollador de aplicaciones. Sin embargo, hay varias cosas que puede hacer para exprimir el último poco de rendimiento del sistema de seguridad.

Ajuste para velocidad

La realización de una comprobación de seguridad normalmente requiere un recorrido de pila para asegurarse de que el código que llama al método actual tiene los permisos correctos. El tiempo de ejecución tiene varias optimizaciones que ayudan a evitar caminar toda la pila, pero hay varias cosas que el programador puede hacer para ayudar. Esto nos lleva a la noción de seguridad imperativa frente a la seguridad declarativa: la seguridad declarativa adorna un tipo o sus miembros con varios permisos, mientras que la seguridad imperativa crea un objeto de seguridad y realiza operaciones en él.

  • La seguridad declarativa es la forma más rápida de ir a Assert, Deny y PermitOnly. Normalmente, estas operaciones requieren un recorrido de pila para buscar el marco de llamada correcto, pero esto se puede evitar si declara explícitamente estos modificadores. Las demandas son más rápidas si se realizan de forma imperativa.
  • Al realizar la interoperabilidad con código no administrado, puede quitar las comprobaciones de seguridad en tiempo de ejecución mediante el atributo SuppressUnmanagedCodeSecurity. Esto mueve la comprobación a la hora del vínculo, que es mucho más rápida. Como nota de precaución, asegúrese de que el código no expone ningún agujero de seguridad a otro código, lo que podría aprovechar la comprobación eliminada en código no seguro.
  • Las comprobaciones de identidad son más costosas que las comprobaciones de código. Puede usar LinkDemand para realizar estas comprobaciones en el momento del vínculo en su lugar.

Hay dos maneras de optimizar la seguridad:

  • Realice comprobaciones en tiempo de vínculo en lugar de en tiempo de ejecución.
  • Realice comprobaciones de seguridad declarativas, en lugar de imperativas.

Lo primero que debe concentrarse en es mover tantas de estas comprobaciones a tiempo de vinculación como sea posible. Tenga en cuenta que esto puede afectar a la seguridad de la aplicación, por lo que debe asegurarse de que no mueve las comprobaciones al enlazador que dependen del estado en tiempo de ejecución. Una vez que haya movido tanto como sea posible al tiempo de vínculo, debe optimizar las comprobaciones en tiempo de ejecución mediante la seguridad declarativa o imperativa: elija cuál es óptimo para el tipo específico de comprobación que use.

Comunicación remota

Conceptos básicos

La tecnología de comunicación remota en .NET amplía el sistema de tipos enriquecido y la funcionalidad de CLR a través de la red. Con XML, SOAP y HTTP, puede llamar a procedimientos y pasar objetos de forma remota, igual que si estuvieran hospedados en el mismo equipo. Puede considerar esto como la versión de .NET de DCOM o CORBA, en que proporciona un superconjunto de su funcionalidad.

Esto es especialmente útil en un entorno de servidor, cuando tiene varios servidores que hospedan servicios diferentes, todos hablando entre sí para vincular esos servicios sin problemas. También se ha mejorado la escalabilidad, ya que los procesos se pueden dividir físicamente en varios equipos sin perder funcionalidad.

Ajuste para velocidad

Puesto que la comunicación remota suele suponer una penalización en términos de latencia de red, se aplican las mismas reglas en clR que siempre tienen: intentar minimizar la cantidad de tráfico que envía y evitar que el resto del programa espere a que se devuelva una llamada remota. Estas son algunas reglas adecuadas para vivir al usar la comunicación remota para maximizar el rendimiento:

  • Realizar llamadas fragmentadas en lugar de chatty: vea si puede reducir el número de llamadas que tiene que realizar de forma remota. Por ejemplo, supongamos que establece algunas propiedades para un objeto remoto mediante métodos get() y set(). Ahorraría tiempo para volver a crear el objeto de forma remota, con esas propiedades establecidas en la creación. Puesto que esto se puede hacer mediante una sola llamada remota, ahorrará tiempo en el tráfico de red. A veces, es posible que tenga sentido mover el objeto a la máquina local, establecer las propiedades allí y, a continuación, copiarlo de nuevo. En función del ancho de banda y la latencia, a veces una solución tendrá más sentido que la otra.
  • Equilibrar la carga de CPU con carga de red: a veces tiene sentido enviar algo para que se haga a través de la red y, en otras ocasiones, es mejor hacer el trabajo usted mismo. Si pierde mucho tiempo corriendo la red, el rendimiento se verá afectado. Si usa demasiada CPU, no podrá responder a otras solicitudes. Encontrar un buen equilibrio entre estos dos es esencial para escalar la aplicación.
  • Usar llamadas asincrónicas: al realizar una llamada a través de la red, asegúrese de que es asincrónica a menos que realmente necesite lo contrario. De lo contrario, la aplicación se detuvo hasta que reciba una respuesta y que puede ser inaceptable en una interfaz de usuario o en un servidor de gran volumen. Un buen ejemplo para ver está disponible en el SDK de Framework que se incluye con .NET, en "Samples\technologies\remoting\advanced\asyncdelegate".
  • Usar objetos de forma óptima: puede especificar que se crea un nuevo objeto para cada solicitud (SingleCall) o que se usa el mismo objeto para todas las solicitudes (Singleton). Tener un solo objeto para todas las solicitudes es sin duda menos intensivo en recursos, pero tendrá que tener cuidado con la sincronización y configuración del objeto de la solicitud a la solicitud.
  • Hacer uso de canales conectables y formateadores: una característica eficaz de comunicación remota es la capacidad de conectar cualquier canal o formateador a la aplicación. Por ejemplo, a menos que necesite acceder a través de un firewall, no hay ninguna razón para usar el canal HTTP. Al conectar un canal TCP, obtendrá un rendimiento mucho mejor. Asegúrese de elegir el canal o formateador que sea el mejor para usted.

ValueTypes

Conceptos básicos

La flexibilidad que ofrecen los objetos tiene un precio de rendimiento pequeño. Los objetos administrados por montón tardan más tiempo en asignar, acceder y actualizarse que los administrados por la pila. Por este motivo, por ejemplo, una estructura en C++ es mucho más eficaz que un objeto. Por supuesto, los objetos pueden hacer cosas que las estructuras no pueden y son mucho más versátiles.

Pero a veces no necesitas toda esa flexibilidad. A veces quiere algo tan sencillo como una estructura y no quiere pagar el costo de rendimiento. CLR proporciona la capacidad de especificar lo que se denomina ValueType y, en tiempo de compilación, esto se trata como una estructura. ValueTypes se administra mediante la pila y proporciona toda la velocidad de un struct. Como se esperaba, también incluyen la flexibilidad limitada de los structs (por ejemplo, no hay herencia). Pero para las instancias en las que todo lo que necesita es una estructura, ValueTypes proporciona un aumento de velocidad increíble. Puede encontrar información más detallada sobre ValueTypes y el resto del sistema de tipos CLR en MSDN Library.

Ajuste para velocidad

ValueTypes solo son útiles en los casos en los que se usan como estructuras. Si necesita tratar un ValueType como un objeto, el tiempo de ejecución controlará la conversión boxing y unboxing del objeto por usted. Sin embargo, esto es aún más caro que crearlo como un objeto en primer lugar!

Este es un ejemplo de una prueba sencilla que compara el tiempo necesario para crear un gran número de objetos y ValueTypes:

using System;
using System.Collections;

namespace ConsoleApplication{
  public struct foo{
    public foo(double arg){ this.y = arg; }
    public double y;
  }
  public class bar{
    public bar(double arg){ this.y = arg; }
    public double y;
  }
class Class1{
  static void Main(string[] args){
    Console.WriteLine("starting struct loop....");
    int t1 = Environment.TickCount;
    for (int i = 0; i < 25000000; i++) {
      foo test1 = new foo(3.14);
      foo test2 = new foo(3.15);
       if (test1.y == test2.y) break; // prevent code from being 
       eliminated JIT
    }
    int t2 = Environment.TickCount;
    Console.WriteLine("struct loop: (" + (t2-t1) + "). starting object 
       loop....");
    t1 = Environment.TickCount;
    for (int i = 0; i < 25000000; i++) {
      bar test1 = new bar(3.14);
      bar test2 = new bar(3.15);
      if (test1.y == test2.y) break; // prevent code from being 
      eliminated JIT
    }
    t2 = Environment.TickCount;
    Console.WriteLine("object loop: (" + (t2-t1) + ")");
    }

Pruébelo usted mismo. El intervalo de tiempo está en el orden de varios segundos. Ahora vamos a modificar el programa para que el tiempo de ejecución tenga que boxar y desempaquear nuestra estructura. Observe que las ventajas de velocidad de usar un ValueType han desaparecido por completo. La moral aquí es que ValueTypes solo se usa en situaciones extremadamente raras, cuando no se usan como objetos. Es importante buscar estas situaciones, ya que el rendimiento gana a menudo es extremadamente grande cuando se usan correctamente.

using System;
using System.Collections;

namespace ConsoleApplication{
  public struct foo{
    public foo(double arg){ this.y = arg; }
    public double y;
  }
  public class bar{
    public bar(double arg){ this.y = arg; }
    public double y;
  }
  class Class1{
    static void Main(string[] args){
      Hashtable boxed_table = new Hashtable(2);
      Hashtable object_table = new Hashtable(2);
      System.Console.WriteLine("starting struct loop...");
      for(int i = 0; i < 10000000; i++){
        boxed_table.Add(1, new foo(3.14)); 
        boxed_table.Add(2, new foo(3.15));
        boxed_table.Remove(1);
      }
      System.Console.WriteLine("struct loop complete. 
                                starting object loop...");
      for(int i = 0; i < 10000000; i++){
        object_table.Add(1, new bar(3.14)); 
        object_table.Add(2, new bar(3.15));
        object_table.Remove(1);
      }
      System.Console.WriteLine("All done");
    }
  }
}

Microsoft usa ValueTypes de forma grande: todos los primitivos de los marcos son ValueTypes. Mi recomendación es que use ValueTypes cada vez que se sienta picor por una estructura. Siempre que no se boxee o desenboxe, puede proporcionar un aumento de velocidad enorme.

Una cosa muy importante que hay que tener en cuenta es que ValueTypes no requiere serialización en escenarios de interoperabilidad. Dado que la serialización es uno de los mayores éxitos de rendimiento al interoperar con código nativo, usar ValueTypes como argumentos para funciones nativas es quizás el único ajuste de rendimiento más importante que puede hacer.

Recursos adicionales

Entre los temas relacionados sobre el rendimiento de .NET Framework se incluyen:

Vea los artículos futuros que se encuentran actualmente en desarrollo, incluida una visión general de las filosofías de diseño, arquitectura y codificación, un tutorial de herramientas de análisis de rendimiento en el mundo administrado y una comparación de rendimiento de .NET con otras aplicaciones empresariales disponibles actualmente.

Apéndice: Hospedaje del tiempo de ejecución del servidor

#include "mscoree.h"
#include "stdio.h"
#import "mscorlib.tlb" named_guids no_namespace raw_interfaces_only \
no_implementation exclude("IID_IObjectHandle", "IObjectHandle")

long main(){
  long retval = 0;
  LPWSTR pszFlavor = L"svr";

  // Bind to the Run time.
  ICorRuntimeHost *pHost = NULL;
  HRESULT hr = CorBindToRuntimeEx(NULL,
               pszFlavor, 
               NULL,
               CLSID_CorRuntimeHost, 
               IID_ICorRuntimeHost, 
               (void **)&pHost);

  if (SUCCEEDED(hr)){
    printf("Got ICorRuntimeHost\n");
      
    // Start the Run time (this also creates a default AppDomain)
    hr = pHost->Start();
    if(SUCCEEDED(hr)){
      printf("Started\n");
         
      // Get the Default AppDomain created when we called Start
      IUnknown *pUnk = NULL;
      hr = pHost->GetDefaultDomain(&pUnk);

      if(SUCCEEDED(hr)){
        printf("Got IUnknown\n");
            
        // Ask for the _AppDomain Interface
        _AppDomain *pDomain = NULL;
        hr = pUnk->QueryInterface(IID__AppDomain, (void**)&pDomain);
            
        if(SUCCEEDED(hr)){
          printf("Got _AppDomain\n");
               
          // Execute Assembly's entry point on this thread
          BSTR pszAssemblyName = SysAllocString(L"Managed.exe");
          hr = pDomain->ExecuteAssembly_2(pszAssemblyName, &retval);
          SysFreeString(pszAssemblyName);
               
          if (SUCCEEDED(hr)){
            printf("Execution completed\n");

            //Execution completed Successfully
            pDomain->Release();
            pUnk->Release();
            pHost->Stop();
            
            return retval;
          }
        }
        pDomain->Release();
        pUnk->Release();
      }
    }
    pHost->Release();
  }
  printf("Failure, HRESULT: %x\n", hr);
   
  // If we got here, there was an error, return the HRESULT
  return hr;
}

Si tiene preguntas o comentarios sobre este artículo, póngase en contacto con el administrador de programas de .NET Framework para problemas de rendimiento de .NET Framework.