Threads na linguagem C

pliniohr

Plínio Ribeiro

Posted on May 6, 2024

Threads na linguagem C

O que são threads?

Vamos aos ensinamentos do mestre Tanenbaum (2016) no seu livro Sistemas Operacionais Modernos que escreve que "Um processo tem um espaço de endereçamento contendo o código e dados do programa, assim, como outros recursos". Em seguida, ele nos apresenta o conceito de threads: "um processo tem uma linha de execução (thread) de execução, normalmente abrevidado para apenas thread. O thread tem um contador de programa que controla qual instrução deve ser executada em seguida".

O autor pontua que "Processos são usados para agrupar recursos; threads são as entidades escalonadas para execução na CPU". Por fim, complementando, "os threads compartilham um espaço de endereçamento e outros recursos" e "processos compartilham memórias físicas, discos, impressoras e outros recursos".

Para este estudo estarei utilizando a Linguagem C e a biblioteca POSIX threads. Que segundo a sua documentação (man pthreads):

POSIX.1 specifies a set of interfaces (functions, header files) for threaded programming commonly known as POSIX threads, or Pthreads. A single
process can contain multiple threads, all of which are executing the same program. These threads share the same global memory (data and heap
segments), but each thread has its own stack (automatic variables).

Para utilizar, adicionamos a flag -pthread ao compilar os nossos códigos.

Exemplo simples de uso de threads na linguagem C.

#include <stdio.h>
#include <pthread.h>

void    *thread_function(void *arg);

int     main(void) {
  pthread_t     thread1;
  pthread       thread2;
  int           i;

  pthread_create(&thread1, NULL, thread_function, NULL);
  pthread_create(&thread2, NULL, thread_function, NULL);

  pthread_join(thread1, NULL);
  pthread_join(thread2, NULL);

  printf("Threads finalizadas.\n");

  return 0;
}

void    *thread_function(void *arg) {
  int       i;

  for (i = 1; i <= 10; i++) {
    printf("Thread ID: %ld, Número: %d\n", pthread_self(), i);
  }

  pthread_exit(NULL);
}
Enter fullscreen mode Exit fullscreen mode

Explicação das funções acima:

Função Descrição
pthread_create Cria uma nova thread
pthread_exit Conclui uma chamada de thread
pthread_join Espera que um thread específico seja abandonado

Sincronização de threads

Race Conditions

Como vimos acima sobre threads, que diferente de processos, threads compartilham o mesmos recursos disponíveis para o processo a qual pertencem. Nesse cenário, pode acontecer comportamentos inesperados quando threads acessam um mesmo recurso ao mesmo tempo ou mesma região da memória.

A esses casos são denominados race conditions.

Podemos dizer que está ocorrendo uma race condition quando "dois ou mais processos estão lendo ou escrevendo alguns dados compartilhados e o resultado final depende de quem executa precisamente e quando", Tanenbaum (2016).

Coleciono também, o conceito presente em POSIX thread (pthread) libraries.

A contention or race condition often occurs when two or more threads need to perform operations on the same memory area, but the results of computations depends on the order in which these operations are performed. Mutexes are used for serializing shared resources such as memory.

Vejamos o exemplo. O código abaixo, temos uma variável global count que será incrementada n vezes por 5 threads "ao mesmo tempo".

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <assert.h>

volatile unsigned int count = 0;
void    *increment(void*);

int     main(int argc, char **argv)
{
    pthread_t       threads[5];
    int             i;
    int             qtd;

    if (argc < 2)
        qtd = 10000;
    else 
        qtd = atoi(argv[1]);
    i = 0;
    while (i < 5) 
    {
        pthread_create(&threads[i], NULL, increment, (void*)&qtd);
        i++;
    }
    i = 0;
    while (i < 5) 
    {
        pthread_join(threads[i], NULL);
        i++;
    }
    printf("Count: %d\n", count);
    return (0);
}

void    *increment(void *ptr)
{
    int         i;
    int         qtd;

    qtd = *(int*)ptr;
    i = 0;
    while(i++ < qtd)
        count++;
    return ((void*)0);
}
Enter fullscreen mode Exit fullscreen mode

Executando o código com valores mais altos podemos ver que os resultados não saem com esperado.

❯ make race
gcc -Wall -Wextra -Werror -pthread race.c -o race 
❯ ./race
Count: 44599
❯ ./race 10
Count: 50
❯ ./race 100
Count: 500
❯ ./race 1000
Count: 5000
❯ ./race 10000
Count: 26448
❯ ./race 100000
Count: 150643
❯ ./race 1000000
Count: 1506256
Enter fullscreen mode Exit fullscreen mode

Aproveitar e colocar uma nota sobre o modificador volatile.

Ele indica para o compilador que variável pode ser modificada por fatores externos, aplicando uma restrição às otimições realizadas pelo compilador.

ThreadSanitizer

Uma ferramenta que pode nos ajudar é ThreadSanitizer.

Para utilizarmos, compilarmos nosso código com flag -fsanitize=thread.
Ao executar, é disparado um aviso que está ocorrendo uma condição de corrida à variável count. Vejamos:

❯ ./race
==================
WARNING: ThreadSanitizer: data race (pid=14464)
  Read of size 4 at 0x56453e338014 by thread T2:
    #0 increment <null> (race+0x141a)

  Previous write of size 4 at 0x56453e338014 by thread T1:
    #0 increment <null> (race+0x1432)

  Location is global 'count' of size 4 at 0x56453e338014 (race+0x000000004014)

  Thread T2 (tid=14467, running) created by main thread at:
    #0 pthread_create ../../../../src/libsanitizer/tsan/tsan_interceptors_posix.cpp:969 (libtsan.so.0+0x605b8)
    #1 main <null> (race+0x1333)

  Thread T1 (tid=14466, finished) created by main thread at:
    #0 pthread_create ../../../../src/libsanitizer/tsan/tsan_interceptors_posix.cpp:969 (libtsan.so.0+0x605b8)
    #1 main <null> (race+0x1333)

SUMMARY: ThreadSanitizer: data race (/mnt/c/Users/win11/lab/conpar/yolinux/race+0x141a) in increment
==================
Count: 27076
ThreadSanitizer: reported 1 warnings
Enter fullscreen mode Exit fullscreen mode

Por mais informações, veja: https://www.cs.columbia.edu/~junfeng/11fa-e6121/papers/thread-sanitizer.pdf

Diante desse cenários que temos a sincronização de *threads*.

Mecanismos para lidar com sincronização de threads

A biblioteca POSIX threads fornece três mecanismo para lidar com sincronização de threads. São:

Mecanismo Descrição
Mutexes (Mutual exclusion lock) Bloqueia o acesso a determinada variável/recurso para outros threads
Joins Faz com que um thread aguarde que outras estejam terminadas
Variáveis de condição Utiliza o tipo pthread_cond_t para controlar o acesso a determinada variável/recurso

Mutexes

Mutexes são utilizados para evitar comportamentos inesperados ou inconsistências com os dados durante operações de vários threadsna mesma área de memória ou recurso. É uma forma de prevenir race conditions.

O seu uso se dá por meio de uma variável do tipo pthread_mutex_t. Quando um thread tentar acessar uma área de memória, primeiro ela tentar bloquear a variável mutex; caso consiga: realiza sua ação; caso contrário: aguarda até que a mutex esteja liberada para uso.

Mais um vez, trago aqui os ensinamentos do mestre Tanenbaum (2016):

Um mutex é uma variável compartilhada que pode estar em um de dois estados: destravado ou travado. Em consequência, apenas 1 bit é necessário para representá-lo, mas na prática muitas vezes um inteiro é usado, com 0 significando destravado e todos os outros valores significando travado.

Vamos utilizar o mesmo código acima e o modificar para implementar mutexes.

// Logo após as importações da bibliotecas 
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

// [...]

// Na função increment modificamos 
    pthread_mutex_lock(&mutex);
    while(i++ < qtd)
        count++;
    pthread_mutex_unlock(&mutex);
    return ((void*)0);
Enter fullscreen mode Exit fullscreen mode

Compilamos e executamos. Como se poder, a versão com mutexes comportou conforme esperado.

❯ ./race 100000
Count: 159154
❯ ./race_mutexes 100000
Count: 500000
Enter fullscreen mode Exit fullscreen mode

Explicando:

Função / tipo Descrição
PTHREAD_MUTEX_INITIALIZER Macro utilizado para inicializar uma mutex
pthread_mutex_lock() Aqui está adquirindo o lock de uma variável mutex
pthread_mutex_unlock(&mutex) Desbloqueia o lock

Join

Utilizamos o mecanismo join quando desejamos aguardar que um thread seja finalizado antes de continuar com a execução do código. É semelhante às chamadas das funções wait e waitpid no contexto de processos.

Para isso fazemos uso da função pthread_join, que atua quando "um thread precisa esperar outro terminar seu trabalho e sair antes de continuar. O que esperando chamada pthread_join para esperar outro thread específico terminar" Tanenbaum (2016);

Indicamos qual thread é para aguardar passando como primeiro argumento da função. O segundo argumento da função é informado quando queremos recuperar algum valor retornado pelo thread. Esse argumento é facultativo, caso não se tenha valor para recuperar, passamos NULL como argumento.

É assinatura da função

int pthread_join(pthread_t thread, void **retval);
Enter fullscreen mode Exit fullscreen mode

Vejamos um exemplo o uso de join

#include <stdio.h>
#include <pthread.h>

#define NTHREADS 10
void                *thread_function(void*);
pthread_mutex_t     mutex = PTHREAD_MUTEX_INITIALIZER;
int                 counter = 0;

int     main(void)
{
    pthread_t   thread_id[NTHREADS];
    int         i;

    i = 0;
    while (i < NTHREADS)
    {
        pthread_create(&thread_id[i], NULL, thread_function, NULL);
        i++;
    }
    i = 0;
    while (i < NTHREADS)
    {
        pthread_join(thread_id[i], NULL);
        i++;
    }
    printf("Counter: %d\n", counter);
    return (0);
}

void    *thread_function(void*)
{
    printf("Thread number: %ld\n", pthread_self());
    pthread_mutex_lock(&mutex);
    counter++;
    pthread_mutex_unlock(&mutex);
    return ((void*)0);
}
Enter fullscreen mode Exit fullscreen mode

No exemplo acima, inicializamos 10 threads, que chamam a função thread_function, que por sua vez, incrementa o valor de counter, e depois aguardamos o encerramento de cada uma dos threads inicializados para depois continuar com o fluxo de execução do código.

Observa que aqui também utilizamos mutexes para controlar as alterações da variável counter, e assim evitar que o programa se comporte de forma inesperada: seja deixando de fazer uma incrementação, seja incrementando a mais. Bem, que nesse caso, como a quantidade de incrementações é pífia a chance de ocorrer uma condição de corrida é baixa.

Uma explicação à chamada da função pthread_self(): ela retorna o ID da thread chamada, é mesmo valor que retornado por uma chamada pthread_create(3) quando criamos um thread.

Variáveis de Condição

Variáveis de condição são variáveis que suspende a execução do thread até que determinada condição seja atendida. Segundo Tanenbaum (2016), "variáveis de condição permitem que threads sejam bloqueados devido a alguma condição não estar sendo atendida".

Deve-se atentar, que quando estamos lidando com variável de condição, sempre devemos associar ela com um mutex, o qual será responsável por bloquear a região crítica.

Acrescentando, segundo o OSTEP: a "condition variable is an explicit queue that threads can put themselves on when some state of execution (i.e., some condition) is not as desired (by waiting on the condition); some other thread, when it changes said state, can then wake one (or more) of those waiting threads and thus allow them to continue (by signaling on the condition)".

Abaixo temos o código que utiliza variável de condição e mutex. Fazemos uso de dois threads. A execução varia entre a execução de cada thread, cada execução realiza uma atividade de incremento da variável count. Utilizamos a variável de condição para controlar a execução dos threads.

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

pthread_mutex_t count_mutex         = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t  condition_var       = PTHREAD_COND_INITIALIZER;

#define     COUNT_DONE      10
#define     COUNT_HALT1     3
#define     COUNT_HALT2     6

void        *fcount1(void*);
void        *fcount2(void*);

int     main(void)
{
    volatile int    count;
    pthread_t       th1;
    pthread_t       th2;

    count = 0;
    pthread_create(&th1, NULL, fcount1, (void*)&count);
    pthread_create(&th2, NULL, fcount2, (void*)&count);

    pthread_join(th1, NULL);
    pthread_join(th2, NULL);

    printf("Final count: %d\n", count);
    return (0);
}

void        *fcount1(void *ptr) 
{
    int     *to_increment;

    to_increment = (int*)ptr;
    while (1)
    {
        pthread_mutex_lock(&count_mutex);
        pthread_cond_wait(&condition_var, &count_mutex);
        *to_increment += 1;
        printf("[fcount1] value: %d\n", *to_increment);
        pthread_mutex_unlock(&count_mutex);
        if (*to_increment >= COUNT_DONE)
            pthread_exit(NULL);
    }
}

void        *fcount2(void *ptr) 
{
    int     *to_increment;

    to_increment = (int*)ptr;
    while (1)
    {
        pthread_mutex_lock(&count_mutex);
        if (*to_increment < COUNT_HALT1 || *to_increment > COUNT_HALT2)
            pthread_cond_signal(&condition_var);
        else 
        {
            *to_increment += 1;
            printf("[fcount2] value: %d\n", *to_increment);
        }
        pthread_mutex_unlock(&count_mutex);
        if (*to_increment >= COUNT_DONE)
            pthread_exit(NULL);
    }
} 
Enter fullscreen mode Exit fullscreen mode

Explicando as funções acima uti

O macro PTHREAD_COND_INITIALIZER:

A função pthread_cond_wait:
http://man.yolinux.com/cgi-bin/man2html?cgi_command=pthread_cond_wait

A função pthread_cond_signal
http://man.yolinux.com/cgi-bin/man2html?cgi_command=pthread_cond_signal

Uma observação quanto ao uso de while e porque não podemos utilizar if.

Semáforos

Outro mecanismo para controle de threads, são os semáforos. Bem o que são?

Desenvolvidos pelo cara, o E. W. Dijkstra, é a utilização de uma variável inteira para contar o número de sinais de acordar salvos para uso futuro, assim ensina o Tanenbaum (2016).

Realizamos duas operações básicas sobre semáforos: incrementar ou decrementar.
Quando desejamos sinalizar que operações estão disponíveis para serem realizar, incrementamos o valor do semáforo.
E a cada vez que um operação é realizada, o decrementamos até chegar o valor de zero.
O ato de decrementar o semáforo dever ser realizado antes de realizar alguma atividade. Funciona como uma verificação de disponibilidade.

Se após, o valor for superior a (-1), o thread segue o fluxo de execução, caso contrário, ela vai dormir até ser acordado novamente para uma nova tentativa.

Referências

💖 💪 🙅 🚩
pliniohr
Plínio Ribeiro

Posted on May 6, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related