SE2 : Introduction au multithreading

SE2: Système d'exploitation  

Printemps 2024

Instructrices: GUEALIA Nadia

The Stanford University logo
A graphic "words that will never be the same after CS110".  It has various drawings below it of: fork (labeled "fork"), needle and thread (labeled "thread"), a bundled baby (labeled "child"), a shovel (labeled "farm"), a seashell (labeled "shell"), an open padlock (labeled "lock"), a knife (labeled "kill"), a paper document (labeled "permit"), a dead face with two Xs for eyes and a stuck-out tongue (labeled "zombie"), a pipe (labeled "pipe") and a hat (labeled "workers").  Below the diagram is the author's name and a smiley face: "Ecy :)"

Illustration courtesy of Ecy King, CS110 Champion, Spring 2021

Comment pouvons-nous avoir de la concurrence au sein d’un même processus ?

Objectifs

  • Découvrez comment les threads permettent la concurrence au sein d'un même processus
  • Comprenez les différences entre les threads et les processus
  • Découvrez comment le partage du même espace d'adressage virtuel entre différent threads peut être dangereux

From Processes to Threads

  • Le multitraitement (multiprocessing) nous a permis de générer d'autres processus pour effectuer des tâches ou exécuter des programmes
  • Puissant ; peut exécuter/attendre d'autres programmes, sécurisé (espace mémoire séparé), communiquer avec des tuyaux (pipes) et des signaux
  • Mais limité ; la communication interprocessus est lourde, il est difficile de partager des données/de coordonner
  • Existe-t-il un autre moyen d’obtenir une concurrence au-delà du multitraitement qui gère ces compromis différemment ?

Nous pouvons avoir une concurrence au sein d'un même processus en utilisant des threads : des séquences d'exécution indépendantes au sein d'un même processus.

  • Les threads nous permettent d'exécuter simultanément plusieurs fonctions de notre programme
  • Le multithreading est très courant pour paralléliser les tâches, en particulier sur plusieurs cores
  • En C: générez un thread en utilisant pthread_create et spécifiez la fonction que vous souhaitez que le thread exécute (en passant éventuellement des paramètres !)
  • Le gestionnaire de threads bascule entre les threads en cours d'exécution comme le scheduler  du système d'exploitation bascule entre les processus en cours d'exécution
  • Chaque thread fonctionne dans le même processus, ils partagent donc le même espace d'adressage virtuel (!) (Variables globales, texte, données et segments de tas)
  • Le segment de pile des processus « stack » est divisé en « ministack » pour chaque thread.
  • Il existe de nombreuses similitudes entre les threads et les processus ; en fait, les threads sont souvent appelés processus légers "lightweight processes" .

Multithreading

Processus:

  • isoler les espaces d'adressage virtuels (bon : sécurité et stabilité, mauvais : partage d'informations plus difficile)
  • peut exécuter facilement des programmes externes (fork-exec) (bien)
  • plus difficile de coordonner plusieurs tâches au sein du même programme (mauvais)

Threads:

  • partager l'espace d'adressage virtuel (mauvais : sécurité et stabilité, bon : partage d'informations plus facile)
  • ne peut pas exécuter facilement des programmes externes (mauvais)
  • plus facile de coordonner plusieurs tâches au sein d'un même programme (bien)

Threads vs. Processus

C thread

Un thread peut être généré pour exécuter la fonction spécifiée avec les arguments donnés.

#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                          void *(*start_routine) (void *), void *arg);
//Compile and link with -pthread.
  • La fonction pthread_create() démarre un nouveau thread dans le processus appelant. Le nouveau thread démarre l'exécution en appelant start_routine(); arg est passé comme seul argument de start_routine().
  • start_routine(): la fonction que le thread doit exécuter de manière asynchrone
  • arg: une liste d'arguments (zéro ou n'importe quelle longueur ) à passer à la fonction lors de l'exécution
  • Une fois créer le thread peut s'exécuter à tout moment !

C thread

Pour plusieurs threads, nous devons attendre un thread spécifique à la fois :

thread treads[5];

...

for (size_t i = 0; i < 5; i++) {
	    pthread_join(treads[i], NULL);

Pour attendre la fin d'un thread, utilisez la fonction pthread_join :

static void * threadFunc(void *arg)
{// instructions
    return NULL;
}
//
pthread_t mythread;
pthread_create(&mythread, NULL,threadFunc, NULL);
... // do some work
// Wait for thread to finish (blocks)
pthread_join(mythread, NULL);

C thread

Le nouveau thread se termine de l'une des manières suivantes :
  • Il appelle pthread_exit(3), en spécifiant une valeur d'état de sortie qui est disponible pour un autre thread dans le même processus qui appelle pthread_join(3).		          
              
  • Il revient de start_routine(). Cela équivaut à appeler pthread_exit(3) avec la valeur fournie dans l'instruction return.
    
  • Il est annulé (voir pthread_cancel(3)).
    
  • L'un des threads du processus appelle exit(3), ou le thread principal effectue un retour depuis main(). Cela provoque la fin de tous les threads du processus.
    

Terminaison d'un thread:

Notre premier programme Thread

#include <stdio.h>
#include <pthread.h>         // for C thread support


static const int kNumFriends = 6;

static void *                   /* Loop 'arg' times incrementing 'glob' */
greeting(void *arg)
{
    printf("Hello, world!\n");
    return NULL;
}

int main(int argc, char *argv[]) {
  printf("Let's hear from  %d threads \n",kNumFriends);
   

   // declare array of empty thread handles
  pthread_t friends[kNumFriends];

  // Spawn threads
  for (size_t i = 0; i < kNumFriends; i++) {
      pthread_create(&friends[i], NULL, greeting, NULL);
  }

  // Wait for threads
  for (size_t i = 0; i < kNumFriends; i++) {
     pthread_join(friends[i], NULL);
  }

  printf("Everyone's said hello!\n");
  return 0;
}

Our First Threads Program

Our First Threads Program

#include <stdio.h>
#include <pthread.h>         // for C thread support


static const int kNumFriends = 6;

static void *                   /* Loop 'arg' times incrementing 'glob' */
greeting(void *arg)
{
    int * iptr =(int*)arg;
    printf("Hello, world! I am thread %i \n",*iptr);
    return NULL;
}

int main(int argc, char *argv[]) {
  printf("Let's hear from  %d threads \n",kNumFriends);
   

   // declare array of empty thread handles
  pthread_t friends[kNumFriends];

  // Spawn threads
  for (size_t i = 0; i < kNumFriends; i++) {
      pthread_create(&friends[i], NULL, greeting, &i);
  }

  // Wait for threads
  for (size_t i = 0; i < kNumFriends; i++) {
     pthread_join(friends[i], NULL);
  }

  printf("Everyone's said hello!\n");
  return 0;
}

Our First Threads Program

C thread

// declare array of empty thread handles
  pthread_t friends[kNumFriends];

  // Spawn threads
  for (size_t i = 0; i < kNumFriends; i++) {
      pthread_create(&friends[i], NULL, greeting, &i);
  }

Nous pouvons créer un tableau de threads comme suit :

Race Conditions (Conditions de course)

  • Comme pour les processus, les threads peuvent s’exécuter dans des ordres imprévisibles.
  • Une condition de course est un ordre imprévisible d'évènements où certains ordres peuvent provoquer un comportement indésirable.
  • Une fonction thread-safe est une fonction qui s'exécutera toujours correctement, même lorsqu'elle est appelée simultanément à partir de plusieurs threads.
  • printf n'est pas une fonction thread-safe. Cela signifie par exemple que les affichage peuvent être entrelacées !

Threads Program(en c++)

static const size_t kNumFriends = 6;

static void greeting(size_t i) {
    cout << oslock << "Hello, world! I am thread " << i << endl << osunlock;
}

int main(int argc, char *argv[]) {
  cout << "Let's hear from " << kNumFriends << " threads." << endl;  

   // declare array of empty thread handles
  thread friends[kNumFriends];

  // Spawn threads
  for (size_t i = 0; i < kNumFriends; i++) {
      friends[i] = thread(greeting, i); 
  }

  // Wait for threads
  for (size_t i = 0; i < kNumFriends; i++) {
     friends[i].join();    
  }

  cout << "Everyone's said hello!" << endl;
  return 0;
}

Les threads partagent la mémoire

  • Contrairement aux processus parents/enfants, les threads s'exécutent dans le même espace d'adressage virtuel!
  • Cela signifie que nous pouvons par exemple transmettre des paramètres par référence et permettre à tous les threads d'y accéder/de les modifier !
  • Pour passer une valeur par référence avec pthread_create, nous devons utiliser  le quatrième arguments:
greeting(void *arg)
{
    int * iptr =(int*)arg;

...

}
// Spawn threads
  for (size_t i = 0; i < kNumFriends; i++) {
      pthread_create(&friends[i], NULL, greeting, &i);
  }

Threads Share Memory

  for (size_t i = 0; i < kNumFriends; i++) {
      pthread_create(&friends[i], NULL, greeting, &i);
  }

_start

greeting

main

argc

argv

i

args

args

args

args

args

args

piles de threads

main stack

Ici, nous pouvons simplement passer par copie. Mais faites attention aux conséquences de la mémoire partagée !

Threads Share Memory

  • Les threads permettent à un processus de paralléliser un problème sur plusieurs cores
  • Considérons un scénario où nous voulons vendre 250 billets et disposons de 10 cœurs
  • Simulation : laissez chaque thread aider à vendre des tickets jusqu'à ce qu'il n'en reste plus
int main(int argc, const char *argv[]) {
    thread ticketAgents[kNumTicketAgents];
    size_t remainingTickets = 250;
    
    for (size_t i = 0; i < kNumTicketAgents; i++) {
        ticketAgents[i] = thread(sellTickets, i, ref(remainingTickets));
    }
    for (thread& ticketAgent: ticketAgents) {
        ticketAgent.join();
    }
    
    cout << "Ticket selling done!" << endl;
    return 0;
}

Parallélisme au niveau des threads

Demo: confused-ticket-agents.cc

  • There is a race condition in this code caused by multiple threads accessing remainingTickets.
static void sellTickets(size_t id, size_t& remainingTickets) {
    while (remainingTickets > 0) {
        sleep_for(500);  // simulate "selling a ticket"
        remainingTickets--;
        cout << oslock << "Thread #" << id << " sold a ticket (" << remainingTickets
             << " remain)." << endl << osunlock;
    }
    cout << oslock << "Thread #" << id << " sees no remaining tickets to sell and exits."
         << endl << osunlock;
}

Overselling Tickets

thread #1

thread #2

thread #3

remainingTickets = 1

  • There is a race condition in this code caused by multiple threads accessing remainingTickets.
static void sellTickets(size_t id, size_t& remainingTickets) {
    while (remainingTickets > 0) {
        sleep_for(500);  // simulate "selling a ticket"
        remainingTickets--;
        cout << oslock << "Thread #" << id << " sold a ticket (" << remainingTickets
             << " remain)." << endl << osunlock;
    }
    cout << oslock << "Thread #" << id << " sees no remaining tickets to sell and exits."
         << endl << osunlock;
}

Overselling Tickets

thread #1

thread #2

thread #3

Line 2: checking if there are tickets left.  Yep!

remainingTickets = 1

  • There is a race condition in this code caused by multiple threads accessing remainingTickets.
static void sellTickets(size_t id, size_t& remainingTickets) {
    while (remainingTickets > 0) {
        sleep_for(500);  // simulate "selling a ticket"
        remainingTickets--;
        cout << oslock << "Thread #" << id << " sold a ticket (" << remainingTickets
             << " remain)." << endl << osunlock;
    }
    cout << oslock << "Thread #" << id << " sees no remaining tickets to sell and exits."
         << endl << osunlock;
}

Overselling Tickets

thread #1

thread #2

thread #3

Line 2: checking if there are tickets left.  Yep!

remainingTickets = 1

          z

        z

    z

  • There is a race condition in this code caused by multiple threads accessing remainingTickets.
static void sellTickets(size_t id, size_t& remainingTickets) {
    while (remainingTickets > 0) {
        sleep_for(500);  // simulate "selling a ticket"
        remainingTickets--;
        cout << oslock << "Thread #" << id << " sold a ticket (" << remainingTickets
             << " remain)." << endl << osunlock;
    }
    cout << oslock << "Thread #" << id << " sees no remaining tickets to sell and exits."
         << endl << osunlock;
}

Overselling Tickets

thread #1

thread #2

thread #3

Line 2: checking if there are tickets left.  Yep!

remainingTickets = 1

          z

        z

    z

          z

        z

    z

  • There is a race condition in this code caused by multiple threads accessing remainingTickets.
static void sellTickets(size_t id, size_t& remainingTickets) {
    while (remainingTickets > 0) {
        sleep_for(500);  // simulate "selling a ticket"
        remainingTickets--;
        cout << oslock << "Thread #" << id << " sold a ticket (" << remainingTickets
             << " remain)." << endl << osunlock;
    }
    cout << oslock << "Thread #" << id << " sees no remaining tickets to sell and exits."
         << endl << osunlock;
}

Overselling Tickets

thread #1

thread #2

thread #3

Line 4: Selling ticket!

remainingTickets = 0

          z

        z

    z

          z

        z

    z

  • There is a race condition in this code caused by multiple threads accessing remainingTickets.
static void sellTickets(size_t id, size_t& remainingTickets) {
    while (remainingTickets > 0) {
        sleep_for(500);  // simulate "selling a ticket"
        remainingTickets--;
        cout << oslock << "Thread #" << id << " sold a ticket (" << remainingTickets
             << " remain)." << endl << osunlock;
    }
    cout << oslock << "Thread #" << id << " sees no remaining tickets to sell and exits."
         << endl << osunlock;
}

Overselling Tickets

thread #1

thread #2

thread #3

Line 4: Selling ticket!

remainingTickets = <really large number>

          z

        z

    z

  • There is a race condition in this code caused by multiple threads accessing remainingTickets.
static void sellTickets(size_t id, size_t& remainingTickets) {
    while (remainingTickets > 0) {
        sleep_for(500);  // simulate "selling a ticket"
        remainingTickets--;
        cout << oslock << "Thread #" << id << " sold a ticket (" << remainingTickets
             << " remain)." << endl << osunlock;
    }
    cout << oslock << "Thread #" << id << " sees no remaining tickets to sell and exits."
         << endl << osunlock;
}

Overselling Tickets

thread #1

thread #2

thread #3

Line 4: Selling ticket!

remainingTickets = <really large number - 1>

There is a race condition here!

  • Problem: threads could interrupt each other in between checking tickets and selling them.

 

 

 

  • If a thread evaluates remainingTickets > 0 to be true and commits to selling a ticket, another thread could come in and sell that same ticket before this thread does.
  • This can happen because remainingImages > 0 test and remainingImages-- aren't atomic.
  • Atomicity: externally, the code has either executed or not; external observers do not see any intermediate states mid-execution.
  • We want a thread to do the entire check-and-sell operation uninterrupted.

Overselling Tickets

static void sellTickets(size_t id, size_t& remainingTickets) {
    while (remainingTickets > 0) {
        sleep_for(500);  // simulate "selling a ticket"
        remainingTickets--;
        ...
    }
  • C++ statements aren't inherently atomic. 
  • We assume that assembly instructions are atomic; but even single C++ statements like remainingTickets-- take multiple assembly instructions.

 

 

Atomicity

  • Even if we altered the code to be something like this, it still wouldn't fix the problem:
static void sellTickets(size_t id, size_t& remainingTickets) {
    while (remainingTickets-- > 0) {
        sleep_for(500);  // simulate "selling a ticket"
        ...
    }
// gets remainingTickets
0x0000000000401a9b <+36>:    mov    -0x20(%rbp),%rax
0x0000000000401a9f <+40>:    mov    (%rax),%eax

// Decrements by 1
0x0000000000401aa1 <+42>:    lea    -0x1(%rax),%edx

// Saves updated value
0x0000000000401aa4 <+45>:    mov    -0x20(%rbp),%rax
0x0000000000401aa8 <+49>:    mov    %edx,(%rax)
  • Each core has its own registers that it has to read from
  • Each thread makes a local copy of the variable before operating on it
  • Problem: What if multiple threads do this simultaneously?  They all think there's only 128 tickets remaining and process #128 at the same time!

Atomicity

// gets remainingImages
0x0000000000401a9b <+36>:    mov    -0x20(%rbp),%rax
0x0000000000401a9f <+40>:    mov    (%rax),%eax

// Decrements by 1
0x0000000000401aa1 <+42>:    lea    -0x1(%rax),%edx

// Saves updated value
0x0000000000401aa4 <+45>:    mov    -0x20(%rbp),%rax
0x0000000000401aa8 <+49>:    mov    %edx,(%rax)

It would be nice if we could put the check-and-sell operation behind a "locked door" and say "only one thread may enter at a time to do this block of code".

Recap

  • Introducing multithreading
  • Example: greeting friends
  • Race conditions
  • Threads share memory
  • Completing tasks in parallel
  • Example: selling tickets

 

Next time:  introducing mutexes

Introduction to Multithreading

By NadGy

Introduction to Multithreading

  • 36