Efficacite des IPC : sémaphore et mémoire partagée

Publié par cpb
Oct 09 2011

La méthode de communication entre processus la plus performante lorsqu’on doit transférer des données volumineuses est l’emploi de zones de mémoires partagées. Standardisé par Posix, il s’agit d’un mécanisme extrémement efficace. Toutefois, il faut penser à synchroniser les accès, afin d’éviter les modifications concurrentes des données partagées ou la modification d’une zone pendant sa consultation par un autre processus.

Partage de mémoire

Pour partager une zone de mémoire entre deux processus, chacun doit d’abord obtenir un descripteur avec l’appel-système suivant.

#include <sys/mman.h>
int shm_open(const char * nom, int flags, mode_t mode);

Le nom est celui que nous attribuons à la zone mémoire. Il doit être fourni identique dans les deux processus. La seule contrainte est que ce nom doit commencer par un caractère slash /.
Les flags sont les mêmes que ceux de l’appel-système open(), en général on emploie O_CREAT | O_RDWR.
Le mode enfin correspond aux droits d’accès en lecture, écriture, exécution par le propriétaire, le groupe, et les autres utilisateurs. La valeur octale 0600 représente les droits classiques pour un partage entre processus appartenant tous à un même utilisateur.

L’appel shm_open() renvoie un descripteur au même titre que les descripteurs de fichiers obtenus avec open(), socket(), pipe(), etc. Nous allons donc dimensionner le bloc de mémoire partagée en utilisant un appel-système habituellement réservé aux fichiers.

int ftruncate(int fd, off_t longueur);

Ici fd est le descripteur fourni par shm_open(). Cette opération n’est nécessaire que pour dimensionner initialement le segment de mémoire, mais elle est inoffensive si on la répète à chaque ouverture.

Enfin, nous allons projeter le bloc de mémoire partagée dans notre espace d’adressage en utilisant l’appel-suivant.

void * mmap(void * adresse, size_t longueur, int droits,
            int flags, int fd, off_t offset);

L’argument adresse sera laissé NULL, le noyau cherchera une place libre dans notre espace d’adressage, et nous renverra l’adresse en retour de mmap(). La longueur est celle de la zone partagée. Les droits vaudront PROT_READ | PROT_WRITE pour permettre lecture et écriture sur la mémoire. L’argument flags contiendra MAP_SHARED pour indiquer que la projection doit être partagée entre processus. Enfin fd et offset représenteront le descripteur fourni par shm_open() et le décalage de la projection par rapport au début de la zone de mémoire partagée (zéro bien sûr).

Dans le programme suivant, nous partagerons une simple structure timeval contenant l’heure. Cette zone de mémoire pourrait être beaucoup plus grande, des kilo-octets voire des méga-octets sans différence notable de performance.

Sémaphores

Pour protéger les accès concurrents, il est d’usage d’employer un sémaphore avec un compteur initialisé à 1, chaque processus prenant le sémaphore (la prise est bloquante quant le compteur est nul), effectuant ses modifications et restituant le sémaphore. C’est le même principe que celui des mutex dont on se sert entre threads du même processus.

Pour partager un sémaphore entre processus, on emploie généralement un sémaphore nommé. Celui-ci s’obtient avec l’appel-système suivant.

int sem_open(const char * nom, int flags,
             mode_t mode, int valeur);

Le nom est soumis aux mêmes règles que celui utilisé par shm_open(). Les flags seront O_CREAT | O_RDWR, le mode 0600 et la valeur est celle du sémaphore s’il y a création (sinon l’argument est ignoré).

La prise et le relâchement du sémaphore se font avec

int sem_wait(sem_t * sem);
int sem_post(sem_t * sem);

Il existe des variantes non bloquantes ou temporisées également.

Dans le programme suivant nous allons utiliser le sémaphore d’une manière un peu dévoyée, mais souvent employée dans les systèmes temps-réel : un processus va réaliser uniquement des sem_wait() (ce qui le bloquera régulièrement), et l’autre uniquement des sem_post() (pour débloquer son partenaire).

Destructions des ressources partagées

Dans les deux programmes que nous emploierons ci-dessous, nous avons pris soin juste après ouverture d’une ressource partagée de détruire son nom avec shm_unlink() ou sem_unlink(). Comme les processus qui utiliserons ces ressources sont deux fils du processus original, ils disposeront des descripteurs (de mémoire ou de sémaphore) déjà ouverts. La destruction des ressources ne se produira effectivement que lorsqu’il n’y aura plus de processus utilisateur – autrement dit à la fin de nos programmes.

Communications rares par mémoire partagée

Comme dans nos articles précédents sur les files de messages et signaux temps-réel, nous allons commencer par un échange sporadique de données (toutes les 0,5 secondes).

Un processus écrit l’heure actuelle dans la mémoire partagée et débloque le sémaphore.

L’autre processus qui était bloqué sur le sémaphore est réveillé, et compare l’heure actuelle avec celle mesurée par son confrère. La durée est affichée en micro-secondes sur la sortie standard.

Les deux processus sont placés sur deux CPU séparés (si possible) afin d’éviter les effets d’ordonnancement.

Les codes-source des exemples se trouvent dans cette archive.

 

memoire-partagee-01.c
#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <semaphore.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/time.h>
#include <sys/wait.h>

static sem_t * semaphore = NULL;
static struct timeval * avant = NULL;

void producteur (void)
{
	sleep(1);
	while (1) {
		gettimeofday(avant, NULL);
		sem_post(semaphore);
		usleep(5000000);
	}
}

void consommateur(void)
{
	struct timeval apres;
	long int duree;
	while (1) {
		sem_wait(semaphore);
		gettimeofday(& apres, NULL);
		duree  = apres.tv_sec - avant->tv_sec;
		duree *= 1000000;
		duree += apres.tv_usec - avant->tv_usec;
		printf("%ldn", duree);
	}
}

int main(void)
{
	int md;
	cpu_set_t ensemble;

	semaphore = sem_open("/semaphore", O_CREAT | O_RDWR, 0600, 0);
	if (semaphore == SEM_FAILED) {
		perror("/semaphore");
		exit(EXIT_FAILURE);
	}
	sem_unlink("/semaphore");

	md = shm_open("/memoire", O_CREAT | O_RDWR, 0600);
	if (md < 0) {
		perror("/memoire");
		exit(EXIT_FAILURE);
	}
	ftruncate(md, sizeof(struct timeval));
	avant = mmap(NULL, sizeof(struct timeval), PROT_READ | PROT_WRITE, MAP_SHARED, md, 0);
	if (avant == MAP_FAILED) {
		perror("/memoire");
		exit(EXIT_FAILURE);
	}
	shm_unlink("/memoire");

	CPU_ZERO(& ensemble);

	if (fork() == 0) {
		/* Premier fils */
		CPU_SET(1, & ensemble);
		sched_setaffinity(0, sizeof(cpu_set_t), & ensemble);
		producteur();
		exit(EXIT_SUCCESS);
	}

	if (fork() == 0) {
		/* Second fils */
		CPU_SET(0, & ensemble);
		sched_setaffinity(0, sizeof(cpu_set_t), & ensemble);
		consommateur();
		exit(EXIT_SUCCESS);
	}

	/* pere */
	waitpid(-1 , NULL, 0);
	waitpid(-1 , NULL, 0);

	return EXIT_SUCCESS;
}

Exécutons notre programme :

$ ./memoire-partagee-01
[...]
12
12
12
12
12
12
11
11
12
13
13
11
11
  (Contrôle-C)
$

Communication à haut débit

Cette fois nous allons modifier le programme pour que l’échange se fasse aussi vite que possible. Dès que le producteur a écrit l’heure dans la mémoire partagée et a incrémenté le sémaphore initial, il va se mettre en attente sur un second sémaphore partagé.

Le deuxième thread sera réveillé sur le sémaphore original comme précédemment, comparera les heures et stockera les résultats, puis incrémentera le second sémaphore afin de réveiller le premier processus. Une fois la table de résultats remplie, il affichera les valeurs sur sa sortie standard.

#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <semaphore.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/time.h>
#include <sys/wait.h>

static sem_t * semaphore_1 = NULL;
static sem_t * semaphore_2 = NULL;
static struct timeval * avant = NULL;

void producteur (void)
{
	sleep(1);
	while (1) {
		gettimeofday(avant, NULL);
		sem_post(semaphore_1);
		sem_wait(semaphore_2);
	}
}

#define NB_MESURES 1000

void consommateur(void)
{
	struct timeval apres;

	long int * durees = NULL;
	durees = calloc(NB_MESURES, sizeof(long int));
	if (durees == NULL) {
		perror("malloc");
		exit(EXIT_FAILURE);
	}
	int n = 0;
	while (n < NB_MESURES) {
		sem_wait(semaphore_1);
		gettimeofday(& apres, NULL);
		durees[n]  = apres.tv_sec - avant->tv_sec;
		durees[n] *= 1000000;
		durees[n] += apres.tv_usec - avant->tv_usec;
		n++;
		sem_post(semaphore_2);
	}
	for (n=0; n < NB_MESURES; n ++)
		printf("%ldn", durees[n]);
}

int main(void)
{
	int md;
	cpu_set_t ensemble;

	semaphore_1 = sem_open("/semaphore-1", O_CREAT | O_RDWR, 0600, 0);
	if (semaphore_1 == SEM_FAILED) {
		perror("/semaphore-1");
		exit(EXIT_FAILURE);
	}
	sem_unlink("/semaphore-1");

	semaphore_2 = sem_open("/semaphore-2", O_CREAT | O_RDWR, 0600, 0);
	if (semaphore_2 == SEM_FAILED) {
		perror("/semaphore-2");
		exit(EXIT_FAILURE);
	}
	sem_unlink("/semaphore-2");

	md = shm_open("/memoire", O_CREAT | O_RDWR, 0600);
	if (md < 0) {
		perror("/memoire");
		exit(EXIT_FAILURE);
	}
	ftruncate(md, sizeof(struct timeval));
	avant = mmap(NULL, sizeof(struct timeval), PROT_READ | PROT_WRITE, MAP_SHARED, md, 0);
	if (avant == MAP_FAILED) {
		perror("/memoire");
		exit(EXIT_FAILURE);
	}
	shm_unlink("/memoire");

	CPU_ZERO(& ensemble);

	if (fork() == 0) {
		/* Premier fils */
		CPU_SET(1, & ensemble);
		sched_setaffinity(0, sizeof(cpu_set_t), & ensemble);
		producteur();
		exit(EXIT_SUCCESS);
	}

	if (fork() == 0) {
		/* Second fils */
		CPU_SET(0, & ensemble);
		sched_setaffinity(0, sizeof(cpu_set_t), & ensemble);
		consommateur();
		exit(EXIT_SUCCESS);
	}

	/* pere */
	waitpid(-1 , NULL, 0);
	waitpid(-1 , NULL, 0);

	return EXIT_SUCCESS;
}

Voici un exemple d’exécution.

$ ./memoire_partagee_02
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
3
3
3
3
3
3
3
3
3
3
3
3
3
3
4
3
3
3
3
3
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
4
3
3
3
3
3
3
3
3
3
3
3
3
3
3
4
3
3
3
3
3
4
4
  (Contrôle-C)
$

Nous nous rapprochons des valeurs obtenues dans l’article précédent avec les signaux temps-réel. Mais cette fois la taille des données transmises n’est pas limitée.

Conclusion

Entre une douzaine de micro-secondes si le processeur était au repos (et demandait un temps de réveil non négligeable) et 4 micro-secondes s’il est actif, la communication par zone de mémoire partagée est apparemment la plus rapide entre deux processus, à condition bien entendu de gérer correctement les cas d’accès concurrents, à l’aide de sémaphores par exemple.

2 Réponses

  1. alcim dit :

    pourquoi ne pas juste faire quelque chose comme ceci ?
    quel avantage ?

    #include
    #include
    #include
    #include
    #include

    void pere();
    void fils();
    void new_com();

    int pid[2];
    int p_f_pipe[2];
    int f_p_pipe[2];
    char *str_shared;

    int main()
    {
    pipe(p_f_pipe);
    pipe(f_p_pipe);
    pid[0] = getpid();

    str_shared = (char *)mmap(NULL, 8, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANON, -1, 0);

    if((pid[1] = fork()))
    pere();
    else
    fils();
    }

    void pere()
    {
    signal(SIGUSR1, new_com);
    pause();
    write(2, str_shared, 8);
    }

    void fils()
    {
    strncpy(str_shared, ":salut:\n", 8);
    kill(pid[0], SIGUSR1);
    }

    void new_com()
    {
    return;
    }

    • cpb dit :

      Bonjour,

      Le but de l’expérience était de mesurer approximativement la durée de communication en se synchronisant sur des sémaphores et en partageant la mémoire. Il faut donc boucler et mesurer le temps de transmission.

      J’ai préféré utiliser une mémoire nommée plutôt qu’anonyme pour que ce code puisse servir d’exemple pour une communication entre deux processus indépendants (pas nécessairement père et fils).

URL de trackback pour cette page