Tutorial de SDL – Parte 49 – Mutexes e condições

Continuando nossa série de artigos traduzidos do site lazyfoo, veremos agora como definir condições para a liberação de recursos bloqueados por uma thread.
Não apenas é possível bloquear seções críticas em threads, mas com mutexes e condições é possível que threads informem a outras threads quando efetuar o bloqueio.

//Our worker functions
int producer( void* data );
int consumer( void* data );
void produce();
void consume();

No exemplo desse artigo, temos duas threads: uma produtora que preenche um buffer e uma consumidora que esvazia o buffer. Não apenas as duas threads não usam o mesmo buffer ao mesmo tempo, mas a consumidora não pode ler um buffer vazio e a produtora não pode preencher um buffer que já está cheio.

Usaremos um mutex (mutualmente exclusivo) para evitar que as duas threads usem o mesmo dado e condições para que as threads saibam quando podem consumir e produzir dados.

//The protective mutex
SDL_mutex* gBufferLock = NULL;
//The conditions
SDL_cond* gCanProduce = NULL;
SDL_cond* gCanConsume = NULL;
//The "data buffer"
int gData = -1;

Aqui declaramos globalmente o mutex e as condições que serão usadas pelas threads.

bool loadMedia()
{
    //Create the mutex
    gBufferLock = SDL_CreateMutex();
    //Create conditions
    gCanProduce = SDL_CreateCond();
    gCanConsume = SDL_CreateCond();
    //Loading success flag
    bool success = true;
    //Load splash texture
    if( !gSplashTexture.loadFromFile( "49_mutexes_and_conditions/splash.png" ) )
    {
        printf( "Failed to load splash texture!\n" );
        success = false;
    }
    return success;
}

Para alocar as mutex e as condições usamos SDL_CreateMutex e SDL_CreateCond respectivamente.

void close()
{
    //Free loaded images
    gSplashTexture.free();
    //Destroy the mutex
    SDL_DestroyMutex( gBufferLock );
    gBufferLock = NULL;
    //Destroy conditions
    SDL_DestroyCond( gCanProduce );
    SDL_DestroyCond( gCanConsume );
    gCanProduce = NULL;
    gCanConsume = NULL;
    //Destroy window
    SDL_DestroyRenderer( gRenderer );
    SDL_DestroyWindow( gWindow );
    gWindow = NULL;
    gRenderer = NULL;
    //Quit SDL subsystems
    IMG_Quit();
    SDL_Quit();
}

E para desalocar mutex e condições usamos SDL_DestroyMutex e SDL_DestroyCond.

int producer( void *data )
{
    printf( "\nProducer started...\n" );
    //Seed thread random
    srand( SDL_GetTicks() );
    //Produce
    for( int i = 0; i < 5; ++i )
    {
        //Wait
        SDL_Delay( rand() % 1000 );
        //Produce
        produce();
    }
    printf( "\nProducer finished!\n" );
    return 0;
}
int consumer( void *data )
{
    printf( "\nConsumer started...\n" );
    //Seed thread random
    srand( SDL_GetTicks() );
    for( int i = 0; i < 5; ++i )
    {
        //Wait
        SDL_Delay( rand() % 1000 );
        //Consume
        consume();
    }
    printf( "\nConsumer finished!\n" );
    return 0;
}

Aqui temos nossas duas threads. A produtora tenta produzir 5 vezes e a consumidora tenta consumir 5 vezes.

void produce()
{
    //Lock
    SDL_LockMutex( gBufferLock );
    //If the buffer is full
    if( gData != -1 )
    {
        //Wait for buffer to be cleared
        printf( "\nProducer encountered full buffer, waiting for consumer to empty buffer...\n" );
        SDL_CondWait( gCanProduce, gBufferLock );
    }
    //Fill and show buffer
    gData = rand() % 255;
    printf( "\nProduced %d\n", gData );
    //Unlock
    SDL_UnlockMutex( gBufferLock );
    //Signal consumer
    SDL_CondSignal( gCanConsume );
}
void consume()
{
    //Lock
    SDL_LockMutex( gBufferLock );
    //If the buffer is empty
    if( gData == -1 )
    {
        //Wait for buffer to be filled
        printf( "\nConsumer encountered empty buffer, waiting for producer to fill buffer...\n" );
        SDL_CondWait( gCanConsume, gBufferLock );
    }
    //Show and empty buffer
    printf( "\nConsumed %d\n", gData );
    gData = -1;
    //Unlock
    SDL_UnlockMutex( gBufferLock );
    //Signal producer
    SDL_CondSignal( gCanProduce );
}

Aqui temos as funções que produzem e consumem. Produzir um buffer significa gerar um número aleatório e consumir significa resetar o número gerado. A melhor maneira de mostrar como isso funciona é através de um exemplo. Vamos dizer que o produtor é iniciado primeiro e bloqueia o mutex com SDL_LockMutex de forma análoga ao que um semáforo faria com um valor 1:
imagem
O buffer está vazio, de forma que ele segue adiante e produz um valor:
imagem
Então sai da função para desbloquear a seção crítica com SDL_UnlockMutex de forma que o consumidor pode consumir o valor:
imagem
Idealmente, queremos que o consumidor consuma o valor, mas imagine que o produtor seja iniciado novamente:
imagem
E após o produto bloquear a seção crítica o consumidor tenta obter o valor mas a seção crítica já está bloqueada pelo produtor:
imagem
Se tivessemos apenas um semáforo binário, isso seria um problema pois o produto não conseguiria produzir um buffer completo e o consumitor estaria bloqueado peo um mutex. Porém, mutexes tem a habilidade de ser usados com condições.

O que a condição permite que façamos é, se o buffer já estiver cheio, podemos esperar por uma condição com SDL_CondWait e desbloquear o mutex para outras threads:
imagem
Agora que o consumidor está desbloqueado, pode seguir para consumir o buffer:
imagem
E assim que estiver feito, sinaliza para o produtor com SDL_CondSignal para que ele produza novamente:
imagem
E assim pode continuar o processo:
imagem
Com a seção crítica protegida por um mutex, e a habilidade das threads de conversar entre si, as threads irá poder trabalhar mesmo que não  saibamos em que ordem elas serão executadas.

Baixe os arquivos de mídia e do código donte desse artigo aqui aqui.