Con la llegada de los procesadores multi núcleo el mundo de la programación a dado un interesante giro para los desarrolladores, imponiendo nuevos retos y sobre todo mayores cuidados a la hora de manejar la sincronización entre hilos y procesos asÃncronos, es por esta razón que escribo este artÃculo con el fin de dar una mirada general de los diferentes métodos de sincronización entre hilos en situaciones especificas.
Diferencias entre sistemas Multi núcleo y mono núcleoEn los sistemas mono núcleo ( P4, AMD K6/7, etc.. ) estábamos acostumbrados a manejar un programa secuencial y aunque la sensación de multitarea todo desarrollador que se precie de serlo sabÃa que no era asÃ, que en realidad los programas seguÃan siendo secuenciales y por ende la sincronización entre diversos hilos y su acceso a memoria compartida no representaba mayor problema; ya que el acceso a la misma se realizaba también de forma secuencial.
Con la llegada de los procesadores multi núcleo la historia cambio, mejoro el performance de las aplicaciones y los sistemas operativos pero.... los programas también comenzaron a fallar. La razón es muy simple, su diseño no estaba preparado para la verdadera multitarea, la inclusión de caches de segundo nivel en los procesadores cambio el método en que se accede a la memoria y las operaciones que antes creÃamos atómicas dejaron de serlo.
Para más información sobre los diferentes modelos de memoria y como son manejados por los procesadores:
http://http://www.intel.com/products/processor/manuals/Sistemas de sincronización entre hilos sobre WindowsEn windows podemos encontrar diferentes métodos para sincronizar hilos como lo pueden ser:
Mutex
Semáforos
Secciones CrÃticas
Eventos
SpinLocks( En Drivers )
La pregunta que sale de estos diversos métodos es cual nos ofrece una implementación sencilla, redituable en performance y totalmente efectiva en cuanto a exclusión mutua se refiere.
En este artÃculo analizaremos una de las más extendidas y redituables en cuanto a performance como lo son las Secciones Criticas y una nueva API introducida con windows vista llamada variables condicionales.
Secciones CrÃticasLas secciones criticas son ampliamente conocidas por los desarrolladores de windows en lenguajes como C y C++, estas proveen el acceso exclusivo a una porción de código ubicada entre "EnterCriticalSection" y "LeaveCriticalSection", aunque también a menudo causan dolores de cabeza con los deadlocks producto tanto de olvidos en el código como de posibles fallos del mismo.
El problema que atañe a todo desarrollador es que el uso de secciones crÃticas puede representarle un golpe fuerte en uso del procesador si no son usadas adecuadamente; el siguiente ejemplo es un código simple de uso de secciones crÃticas que demuestra lo anterior:
Generador:DWORD WINAPI Generator( LPVOID lpvParam )
{
timeval tvInit, tvEnd;
int iAt = 0;
gettimeofday( &tvInit, NULL);
for ( ; iAt < 512; ++iAt) {
EnterCriticalSection( &g_csVar );
g_iVar = rand()%1024;
g_bReady = true;
LeaveCriticalSection( &g_csVar );
// Do Stuff
Sleep( 50 );
}
gettimeofday( &tvEnd, NULL);
g_bIsStarted = false;
printf("Generator time %d\r\n", DiffTime( &tvInit, &tvEnd));
return 0;
}
Trabajador:DWORD WINAPI Worker( LPVOID lpvParam )
{
int lMyVal = 0, iReads = 0;
timeval tvInit, tvEnd;
gettimeofday( &tvInit, NULL);
while (g_bIsStarted == true) {
EnterCriticalSection( &g_csVar );
if (g_bReady == false) {
LeaveCriticalSection( &g_csVar );
continue;
}
lMyVal = g_iVar;
g_bReady = false;
++iReads;
LeaveCriticalSection( &g_csVar );
// do stuff
Sleep( 5 );
}
gettimeofday( &tvEnd, NULL);
printf("Reads %d - Time Elapsed: %d\r\n", iReads, DiffTime( &tvInit, &tvEnd));
return 0;
}
Aclarar que las funciones gettimeofday y DiffTime son de implementación propia y no hacen parte del API de windows.
En el anterior caso el número de workers es 2; ahora si lo ejecutamos podremos ver que el consumo de CPU por parte del programa es sumamente alto ( 100% ). El tiempo "perfecto" de ejecución del proceso deberÃa estar por el orden de los 25.6 Segundos((50 * 512)/1000), si examinamos el resultado arrojado por el programa es:
Reads 293 - Time Elapsed: 29969
Reads 219 - Time Elapsed: 29969
Generator time 29970
Por qué sucede esto?, el bloqueo que generan los Workers sobre el generador hace que este no pueda acceder a la variable para setear su valor, retrasando el proceso de escritura y por ende el que los workers dispongan de información; para validar esto vamos a añadir una variable en el worker que contabilice los fallos registrados al intentar realizar una lectura:
if (g_bReady == false) {
LeaveCriticalSection( &g_csVar );
++iFails;
continue;
}
El resultado ahora es:
Generator time 29785
Reads 187 Fails 176088581 - Time Elapsed: 29785
Reads 325 Fails 166428580 - Time Elapsed: 29785
La cantidad de ciclos desperdiciados es increÃblemente alta, para abolir el consumo de CPU excesivo y el desperdiciar ciclos podemos implementar un Sleep sobre nuestros workers:
if (g_bReady == false) {
LeaveCriticalSection( &g_csVar );
++iFails;
Sleep( 10 );
continue;
}
El resultado es:
Generator time 25691
Reads 135 Fails 2488 - Time Elapsed: 25692
Reads 377 Fails 2351 - Time Elapsed: 25699
Mucho mejor, inclusive se acerca bastante al tiempo "perfecto", pero aun asà no es recomendable el desperdiciar ciclos cuando podemos señalar los eventos, asà que mezclaremos secciones criticas, en este caso "TryEnterCriticalSection" y eventos. Un error muy común al usar secciones criticas y eventos recae en el implementar bien sea eventos Automáticos ( No son apropiados para sincronizar más de 2 hilos ) o el usar eventos manuales pero los cuales el procedimiento de Reset se encuentra por fuera de la sección critica; la razón es muy simple ya que en realidad si se implementan el reseteo del evento fuera de la sección critica se está consiguiendo una condición de carrera al estar el evento señalado y la sección critica libre; dando asà paso para que los otros hilos accedan a información que posiblemente ya fue procesada o simplemente que no halla información. El ejemplo usado en nuestro caso será una pequeña modificación del anterior:
Generador: for ( ; iAt < 10; ++iAt) {
EnterCriticalSection( &g_csVar );
g_iVar = rand()%1024;
SetEvent( g_hEvent );
LeaveCriticalSection( &g_csVar );
// Do Stuff
Sleep( 50 );
}
gettimeofday( &tvEnd, NULL);
g_bIsStarted = false;
SetEvent( g_hEvent );
Trabajador: while (g_bIsStarted == true) {
WaitForSingleObject( g_hEvent, INFINITE);
if (g_bIsStarted == false)
break;
if (TryEnterCriticalSection( &g_csVar ) == FALSE) {
++iFails;
continue;
}
lMyVal = g_iVar;
++iReads;
ResetEvent( g_hEvent );
LeaveCriticalSection( &g_csVar );
// do stuff
Sleep( 5 );
}
El resultado arrojado es:
Reads 218 Fails 11131317 - Time Elapsed: 34848
Generator time 34848
Reads 370 Fails 10569731 - Time Elapsed: 34848
Podemos observar que realmente los tiempos no son los mejores, los fallos al intentar entrar a la sección critica son bastante altos, el consumo de CPU es del 25% y aparte de ello se ha cometido un error que es bastante común en este tipo de implementaciones por lo que se ha dejado abierta la posibilidad de una condición de carrera(a ver si lo descubren).
Viendo todo lo anterior nos hemos podido dar cuenta que algunos de los métodos que utilizan secciones criticas no son una muy buena idea y suelen estar sujetos a fallos muy comunes que bien pueden afectar tanto el performance como el acceso exclusivo a la información.
Variables CondicionalesCon la llegada de windows vista se introdujo una nueva API de sincronización con el fin de evitar los problemas que hemos visto anteriormente, que de por si son sumamente comunes. Esta API tiene como objetivo el facilitar la implementación de métodos que aseguren el acceso exclusivo a porciones de código.
La forma de trabajar con las variables condiciones es muy parecida a los eventos, primeramente necesitamos una sección critica y una CONDITION_VARIABLE, una vez que entramos en la sección critica usamos "SleepConditionVariableCS", esta toma como parámetros la variable de variable de condición, la sección critica y el tiempo a esperar, el tomar la sección critica es debido a que primero debemos entrar en la sección critica, cuando llamemos a SleepConditioVariableCS este saldrá de la sección critica y esperara por el evento, cuando el evento sea señalado retomara la sección critica.
En cuanto al generador este debe utilizar la función WakeConditionVariable para señalar la condición, esta se limita a dejar pasar un solo hilo que se encuentre en espera, si queremos despertar todos los hilos en espera deberemos usar WakeAllConditionVariable.
En el siguiente ejemplo vamos a evaluar el rendimiento de los siguientes métodos de exclusión:
Secciones CrÃticas
Secciones CrÃticas con Sleep
Secciones criticas mediante TryEnterCriticalSection
Secciones criticas mediante TryEnterCriticalSection con Sleep
Variables condicionales
El código usado será el siguiente:
Generador:bool WriteJob( long lJob )
{
DWORD dwWrite = 0;
EnterCriticalSection( &g_csPipe );
WriteFile( g_hPipeWrite, &lJob, sizeof( long ), &dwWrite, NULL);
LeaveCriticalSection( &g_csPipe );
return true;
}
DWORD WINAPI Generator( LPVOID lpvParam )
{
timeval tvInit, tvEnd;
long lIn = 0;
int iAt = 0;
srand( 0x0ABCDL );
gettimeofday( &tvInit, NULL);
while (g_bStarted == true && iAt < 512) {
while ((lIn = rand()) <= -1);
if (WriteJob( lIn ) == false)
continue;
Sleep( 150 );
++iAt;
}
g_bStarted = false;
gettimeofday( &tvEnd, NULL);
printf("Generators ends at %d\r\n", DiffTime( &tvInit, &tvEnd));
return 0;
}
Trabajador:long ReadJob( void )
{
BOOL bOper = FALSE;
DWORD dwRead = 0;
long lRet = -1;
EnterCriticalSection( &g_csPipe );
if (g_bStarted == false) {
LeaveCriticalSection( &g_csPipe );
return -1;
}
bOper = PeekNamedPipe( g_hPipeRead, &lRet, sizeof( long ), &dwRead, NULL, NULL);
if (bOper == TRUE && dwRead == sizeof( long ))
ReadFile( g_hPipeRead, &lRet, sizeof( long ), &dwRead, NULL);
LeaveCriticalSection( &g_csPipe );
return lRet;
}
DWORD WINAPI Worker( LPVOID lpvParam )
{
timeval tvInit, tvEnd;
long lRes = 0;
int iReads = 0;
gettimeofday( &tvInit, NULL);
while (g_bStarted == true) {
if ((lRes = ReadJob()) <= -1) {
Sleep( 10 );
continue;
}
++iReads;
// Do something xD
Sleep( 5 );
}
gettimeofday( &tvEnd, NULL);
printf("Worker ends at %d reads:%d\r\n", DiffTime( &tvInit, &tvEnd), iReads);
return 0;
}
Con las modificaciones propias para usar los diferentes métodos como es el caso de TryEnterCriticalSection:
Trabajador ( Lector ):long ReadJob( void )
{
BOOL bOper = FALSE;
DWORD dwRead = 0;
long lRet = -1;
if (TryEnterCriticalSection( &g_csPipe ) == FALSE)
return -1;
Generador ( Escritor ):bool WriteJob( long lJob )
{
DWORD dwWrite = 0;
if (TryEnterCriticalSection( &g_csPipe ) == FALSE)
return false;
En el caso de las variables condicionales anularemos los sleeps y modificaremos el código de la siguiente forma:
Trabajador ( Lector ):long ReadJob( void )
{
BOOL bOper = FALSE;
DWORD dwRead = 0;
long lRet = -1;
EnterCriticalSection( &g_csPipe );
SleepConditionVariableCS( &g_cvVar, &g_csPipe,INFINITE);
if (g_bStarted == false) {
LeaveCriticalSection( &g_csPipe );
return -1;
}
// Anulamos el peek a la pipe ya que el evento señala la
// disponibilidad de informacion.
ReadFile( g_hPipeRead, &lRet, sizeof( long ), &dwRead, NULL);
LeaveCriticalSection( &g_csPipe );
return lRet;
}
Generador ( Escritor ):bool WriteJob( long lJob )
{
DWORD dwWrite = 0;
EnterCriticalSection( &g_csPipe );
WriteFile( g_hPipeWrite, &lJob, sizeof( long ), &dwWrite, NULL);
LeaveCriticalSection( &g_csPipe );
WakeConditionVariable( &g_cvVar );
return true;
}
Generador: g_bStarted = false;
WakeAllConditionVariable( &g_cvVar );
gettimeofday( &tvEnd, NULL);
printf("Generators ends at %d\r\n", DiffTime( &tvInit, &tvEnd));
return 0;
}
Como podemos ver en el caso de las variables condicionales el código llega a simplificarse debido a que sabemos que nos está señalando bien o el estado de disponibilidad de información "WakeConditionVariable" para un solo hilo o el de salida "WakeAllConditionVariable" para todos los hilos.
Teniendo en cuenta que el resultado "optimo" es de 76.800 Segundos; los resultados de los procedimientos son los siguientes:
http://http://3.bp.blogspot.com/_zWBNoSB1aQA/S24cLMLaGgI/AAAAAAAAAFc/f2e2cXFUdzQ/s400/Untitled2.pngLa información relativa a los workers denota "tiempo/lecturas", es interesante el que en las Variables Condicionales se presenta un balance de carga para el acceso a la información, de igual manera parece ser el método mas efectivo, simple y optimizado en cuanto a operaciones de exclusión mutua se refiere.
ConclusiónCon la llegada de esta nueva API de exclusión en windows vista podemos ahorrarnos múltiples dolores de cabeza además de mejorar el desempeño de nuestro programa, aun asà en el llegado caso de que no podamos utilizarla debido a problemas de compatibilidad o soporte de otras plataformas deberÃamos tener en cuenta las opciones que se nos presentan en estas pruebas como candidatas a desarrollar este trabajo de una buena forma.
Salu2.
Att:
Iker