domingo, mayo 03, 2009

Alternativas de Bloqueo en .Net

Recientemente pude continuar mi interrumpida lectura de Concurrent Programming on Windows y durante la misma se discuten los distintos tipos de Sincronización que se pueden usar con .Net. Como en casi toda lectura que he visto se menciona el costo adicional que puede incorporar el uso de las clases ReaderWriterLock, ReaderWriterLockSlim frente al uso de Monitor.Enter, Monitor.TryEnter (lock o syncLock), en el libro se hace un análisis del porque el impacto adicional en el rendimiento, pero como siempre me quedaban dudas respecto a cual es el verdadero margen de diferencia.

Existen varias comparaciones del impacto en rendimiento siendo casi siempre el comentario la gran diferencia que hay a favor de Monitor/lock frente a ReaderWriterLock , por ejemplo http://is.gd/wrDH, http://blogs.msdn.com/ricom/archive/2006/05/05/590955.aspx (comentarios, no el post en si mismo) sin embargo siempre he encontrado dichos números un poco vagos (espero no caer en el mismo error)

Usualmente toda tarea de sincronizacion siempre la hago con lock por ser la más fácil de implementar y porque nunca he tenido problemas de contención, sin embargo aprovechando que hoy en la tarde tenía tiempo decidí hacer una prueba muy simple para comparar las alternativas en distintos escenarios.

Componentes

Mi prueba es muy simple tengo 3 clases que exponen dos métodos uno de lectura (Shared Lock) y uno de escritura (Exclusive Lock). Ambos métodos sincronizan el acceso a través de cada uno de los mecanismos a considerar en la prueba, la clase TraditionalLock<T> utiliza el tradicional lock sobre una clase creada en el constructor de la clase, la clase ReaderWriteLock<T> utiliza una instancia de la clase del mismo nombre y finalmente ReaderWriteLockSlim<T> utiliza la clase del framework 3.5.

   1:
   2:     class TraditionalLock<T> : ILockTest<T>
   3:     {
   4:         private T data;
   5:         private object sync = new object();
   6:     
   7:         public T  Read()
   8:         {
   9:             lock (sync)
  10:             {
  11:                 // Thread.Sleep(50);
  12:                 return this.data;
  13:             }
  14:         }
  15:  
  16:         public void  Write(T value)
  17:         {
  18:             lock (sync)
  19:             {
  20:                 // Thread.Sleep(50);
  21:                 this.data = value;
  22:             }
  23:         }
  24:     }
  25:  
  26:     class ReaderWriteLock<T> : ILockTest<T>
  27:     {
  28:         private T data;
  29:         private ReaderWriterLock sync = new ReaderWriterLock();
  30:  
  31:         public T Read()
  32:         {
  33:             sync.AcquireReaderLock(System.Threading.Timeout.Infinite);
  34:             try
  35:             {
  36:                 // Thread.Sleep(50);
  37:                 return this.data;
  38:             }
  39:             finally
  40:             {
  41:                 sync.ReleaseReaderLock();
  42:             }
  43:         }
  44:  
  45:         public void Write(T value)
  46:         {
  47:             sync.AcquireWriterLock(System.Threading.Timeout.Infinite);
  48:             try
  49:             {
  50:                 // Thread.Sleep(50);
  51:                 this.data = value;
  52:             }
  53:             finally
  54:             {
  55:                 sync.ReleaseWriterLock();
  56:             }
  57:         }
  58:     }
  59:  
  60:     class ReaderWriteLockSlim<T> : ILockTest<T>
  61:     {
  62:         private T data;
  63:         private ReaderWriterLockSlim sync = new ReaderWriterLockSlim();
  64:  
  65:         public T Read()
  66:         {
  67:             sync.EnterReadLock();
  68:             try
  69:             {
  70:                 // Thread.Sleep(50);
  71:                 return this.data;
  72:             }
  73:             finally
  74:             {
  75:                 sync.ExitReadLock();
  76:             }
  77:         }
  78:  
  79:         public void Write(T value)
  80:         {
  81:             sync.EnterWriteLock();
  82:             try
  83:             {
  84:                 // Thread.Sleep(50);
  85:                 this.data = value;
  86:             }
  87:             finally
  88:             {
  89:                 sync.ExitWriteLock();
  90:             }
  91:         }
  92:     }
  93:  
  94:     class VolatileLock : ILockTest<int> 
  95:     {
  96:         private volatile int data;
  97:  
  98:         public int Read()
  99:         {
 100:             return this.data;
 101:         }
 102:  
 103:         public void Write(int value)
 104:         {
 105:             this.data = value;
 106:         }
 107:     }

Todas estas clases son utilizadas por un controlador que utiliza el ThreadPool de .Net para solicitar la ejecucion de los métodos de escritura y lectura en una proporción de n:1 que varia desde 1:1 hasta 29:1 para estas pruebas. El tiempo total de la ejecución de todos los métodos es medido utilizando la clase StopWatch


   1: static long MeasureLockTest<T>(ILockTest<T> instance,T value, int totalExecutions, int ratio)
   2: {
   3:     Stopwatch sw = new Stopwatch();
   4:     int writerCounter = 0;
   5:     int readerCounter = 0;
   6:  
   7:     ratio++;
   8:     sw.Start();
   9:     for (int counter = 0; counter < totalExecutions; counter++)
  10:     {
  11:         if (counter % ratio == 0)
  12:         {
  13:             ThreadPool.QueueUserWorkItem((state) => instance.Write(value));
  14:             writerCounter++;
  15:         }
  16:         else
  17:         {
  18:             ThreadPool.QueueUserWorkItem((state) => instance.Read());
  19:             readerCounter++;
  20:         }
  21:     }
  22:     ThreadPool.QueueUserWorkItem((state) => sw.Stop());
  23:     while (sw.IsRunning) ;
  24:  
  25:     return sw.ElapsedMilliseconds;
  26: }



Escenarios

Claramente la prueba es muy simple pues mide actividad sobre operaciones muy simples. Sin embargo, si miran el código de todas las clases en ellas esta comentado código que hace Sleep, esta inserción es la poco imaginatica implementacion de cierto tipo de costo en uso de recursos en una operación (la elección de Sleep es intencional pues dada la carga de requerimientos en el thread obliga a evaluar la ejecución de otro thread lo que también puede jugar un factor en escenarios de concurrencia). Con esta simple linea me permitió crear cuatro escenarios de ejemplo:

  • Operaciones sin costo: Tipicamente son aquellas operaciones donde el bloqueo tiene por unico sentido la sincronización y brindar propiedades thread-safe. En este caso, uno de los más comunes, la operación es muy corta y con muy poco impacto. Por ello para obtener un grado de medida adecuado esta operación tuvo que realizarse 100000 veces.
  • Operaciones con costo en acceso exclusivo: En este escenario la operación que requiere el bloqueo exclusivo (tipicamente la escritura) tiene un costo alto en tiempo (50 milisegundos), mientras que la operación con bloqueo compartido es bastante simple y rápida. Un escenario típico de este tipo de bloqueo son los caches implementados en consultas a servicios o fuentes de datos remotas donde la búsqueda inicial tiene un costo alto pero subsecuentes accesos son de bajo costo.
  • Operaciones con costo en acceso compartido: En este escenario es la operación de acceso compartido la que tiene un costo alto mientras que la operación con costo exclusivo es de corta duración. Este tipo de escenario es un poco más dificil de darse y la verdad solo lo hice como un ejercicio.
  • Operaciones con costo: Finalmente en este escenario ambas operaciones tiene un costo alto que es igual para ambas operaciones. Ejemplos de este tipo de escenarios se refieren principalmente al acceso sincronizado a recursos externos.

Nota: En ninguno de los casos analice el uso de recursos de sistema que puede ser un factor en el cual Monitor/lock salen faavorecidos por no usar objetos de sincronización de Kernel

Resultados

A continuación se presenta los resultados de las pruebas en un grafico que muestra el tiempo promedio de las operaciones ejecutadas durante la prueba

Operaciones sin costo: 10000 iteraciones sin costo,

Gráfico de linea de tiempo promedio

Como verán en este caso ReaderWriterLock es claramente un perdedor pero no en la proporción abismal que siempre leí.

Operaciones con costo en acceso exclusivo: 100 iteraciones con costo de 50 milisegundos

Gráfico de linea de tiempo promedio

En este caso la diferencia entre todos los mecanismos es muy baja y en distintas ejecuciones demostró ser prácticamente nulo

Operaciones con costo en acceso compartidos: 100 iteraciones con costo de 50 milisegundos

Gráfico de linea de tiempo promedio

En este escenario es muy claro que la ventaja la tienen los criterios de bloqueo compartido con una ventaja inicial a favor de la nueva clase del framework 3.5, Como dije anteriormente no se me ocurre un escenario común.

Operaciones con costo: 100 iteraciones con costo de 50 milisegundos

Gráfico de linea de tiempo promedio

Al igual que en el caso anterior Monitor/lock son claros perdedores conforme mayor proporción de lecturas vs. escrituras existen. Es más en este caso la ventaja es muy notoria aún con una baja proporción de distribución (2:1).

Volatile

Al terminar la prueba se me ocurrió que para el primero de los escenarios (propiedades thread-safe) tambíen pueden utilizarse variables volatiles así que hice una prueba y aqui estan los resultados de 100000 ejecuciones (dado que volatile no es una sección protegida solo el primer escenario es aplicable)

image

Como muestra el gráfico sacrificar el uso del cache de thread tiene un impacto en rendimiento bastante importante por lo que a pesar de su facilidad de uso y menor uso de recursos esta opción no es válida si lo que se busca es el mejor rendimiento.

Conclusiones

Considerando solo los tiempos y sin incluir el uso de recursos (que como mencioné anteriormente brinda una ventaja a Monitor/lock) las siguientes conclusiones vienen a mi cabeza:

  • Para sincronización en acceso a propiedades y cache claramente no hay ganancia en rendimiento apreciable en ninguno de los casos por lo que considerando el uso de recursos y facilidad de uso y lectura Monitor/lock/SyncLock parece ser la mejor opcion.
  • En aquellos casos donde la sincronización se realiza sobre una sección que puede tener un costo medio o alto en tiempo es recomendable el uso de ReadWriterLockSym en Framework 3.5 o superior, o ReadWriterLock en versiones anteriores. En particular el acceso sincronizado a recursos de red puede ser un muy escenario de uso

No hay comentarios.: