Optimisation des processus dans les environnements à ressources limitées

Défis de l'optimisation processus sur systèmes contraints

Dans les systèmes embarqués, les dispositifs IoT ou les nœuds de calcul en périphérie, les ressources matérielles (CPU, RAM, stockage) sont sévèrement limitées. L'exécution simultanée de plusieurs processus nécessite une allocation rigoureuse et une garantie de temps réel pour les tâches critiques. La contention de ressources peut provoquer blocages, latences excessives voire des crashs système. Une optimisation multidimensionnelle s'impose : ordonnancement, gestion mémoire et traitement des entrées/sorties.

Gestion mémoire : compromis et stratégies

Sur des appareils à mémoire restreinte, la mémoire virtuelle classique est souvent indisponible ou trop coûteuse. Les développeurs privilégient alors l'allocation statique ou les pools mémoire pour éliminer la fragmentation. Voici un exemple de pool mémoire en C avec une approche par liste chaînée :

#define MAX_BLOCKS 16
#define BLOCK_BYTES 128

typedef struct MemBlock {
    unsigned char data[BLOCK_BYTES];
    struct MemBlock* next;
    int occupied;
} MemBlock;

static MemBlock pool[MAX_BLOCKS];
static MemBlock* head = NULL;

void pool_init(void) {
    for (int i = 0; i < MAX_BLOCKS; i++) {
        pool[i].next = (i < MAX_BLOCKS - 1) ? &pool[i+1] : NULL;
        pool[i].occupied = 0;
    }
    head = &pool[0];
}

void* pool_alloc(void) {
    MemBlock* b = head;
    while (b) {
        if (!b->occupied) {
            b->occupied = 1;
            return b->data;
        }
        b = b->next;
    }
    return NULL;
}

Cette approche garantit des temps d'allocation déterministes au prix d'une flexibilité réduite, idéale pour des tâches au cycle de vie prévisible.

Limitation des ressources via cgroups

Sous Linux, les cgroups permettent de borner la consommation CPU d'un processus. Exemple avec cgroups v2 :

# Création d'un groupe de contrôle
sudo mkdir /sys/fs/cgroup/constrained
# Plafond CPU : 25% d'un cœur (période 100ms, quota 25ms)
echo "25000 100000" | sudo tee /sys/fs/cgroup/constrained/cpu.max
# Assignation du processus
echo $PID | sudo tee /sys/fs/cgroup/constrained/cgroup.procs
Paramètre Environnement standard Environnement contraint
Mémoire disponible 8 Go 64 Mo
Cœurs CPU 8 1 (fréquence réduite)
Stockage 512 Go SSD 16 Mo Flash

Stratégies de création et de gestion légère de processus

Choix entre processus et threads

La sélection entre processus et threads dépend du type de charge. Les processus offrent une isolation mémoire robuste mais avec un coût de création élevé. Les threads, partageant l'espace d'adressage, sont plus économiques mais exigent une synchronisation soignée.

Critère Processus Thread
Création/destruction Coûteuse Économique
Commutation de contexte Élevée (remapping mémoire) Faible (espace partagé)
Communication IPC (pipes, files de messages) Mémoire partagée, mutex

Exemple de concurrence légère en Go avec un pattern de fan-out :

func runWorker(wid int, tasks <-chan int, done chan<- bool) {
    for t := range tasks {
        log.Printf("Worker %d: tâche %d terminée\n", wid, t)
    }
    done <- true
}

func main() {
    taskCh := make(chan int, 20)
    complete := make(chan bool, 4)

    for w := 0; w < 4; w++ {
        go runWorker(w, taskCh, complete)
    }
    for i := 0; i < 20; i++ {
        taskCh <- i
    }
    close(taskCh)
}

Les goroutines sont ordonnancées en espace utilisateur, évitant le coût d'un changement de contexte noyau. Idéal pour les workloads intensifs en E/S.

vfork() : création optimisée pour exec()

Lorsqu'un processus enfant doit immédiatement exécuter exec(), vfork() évite la duplication des tables de pages. Le processus parent est suspendu jusqu'à ce que l'enfant appelle exec() ou _exit().

#include <unistd.h>
#include <stdlib.h>

pid_t child = vfork();
if (child == 0) {
    // Espace partagé : aucune modification de variables parent
    execlp("grep", "grep", "-r", "pattern", "/var/log", NULL);
    _exit(127);  // exit() proscrit ici
} else if (child > 0) {
    int status;
    waitpid(child, &status, 0);
}

L'appel système clone() sous Linux permet de spécifier précisément quelles ressources sont partagées entre parent et enfant, via des flags.

#include <sched.h>
#include <stdio.h>
#include <sys/wait.h>

#define STACK_BYTES 8192

static int child_entry(void* param) {
    printf("PID enfant : %d\n", getpid());
    return 0;
}

int main(void) {
    char stack[STACK_BYTES];
    int flags = CLONE_VM | CLONE_FILES | CLONE_SIGHAND;

    pid_t tid = clone(child_entry, stack + STACK_BYTES, flags, NULL);
    if (tid == -1) {
        perror("clone");
        return 1;
    }
    waitpid(tid, NULL, 0);
    return 0;
}
  • CLONE_VM : partage de l'espace d'adressage
  • CLONE_FILES : partage des descripteurs de fichiers
  • CLONE_SIGHAND : partage des gestionnaires de signaux
  • CLONE_FS : partage des infos du système de fichiers (racine, cwd)

Copy-on-Write : différer la copie mémoire

Le mécanisme COW (Copy-on-Write) permet à plusieurs processus de partager une même zone mémoire tant qu'aucune écriture n'est effectuée. Une copie réelle n'a lieu qu'au moment d'une modification, réduisant drastiquement la consommation mémoire.

Illustration en Go avec rétention de données :

type Snapshot struct {
    values []byte
}

func (s *Snapshot) writeAt(idx int, val byte) *Snapshot {
    // Copie uniquement lors d'une écriture
    newVals := make([]byte, len(s.values))
    copy(newVals, s.values)
    newVals[idx] = val
    return &Snapshot{values: newVals}
}

func (s *Snapshot) read() []byte {
    return s.values  // Pas de copie en lecture
}
Approche Empreinte mémoire Latence d'écriture
Copie immédiate Élevée Faible
COW Réduite Légère pénalité à l'écriture

Pool de processus pour systèmes embarqués

Sur cibles embarquées, les pools de processus classiques sont trop gourmands. Une implémentation simplifiée repose sur une zone mémoire partagée fixe et un mécanisme de polling avec barrières mémoire.

#define TASK_QUEUE_ADDR  ((volatile TaskEntry*)0x20008000)

typedef struct {
    volatile uint32_t ticket;
    void (*execute)(void*);
    void* ctx;
} TaskEntry;

void worker_main(void) {
    volatile TaskEntry* q = TASK_QUEUE_ADDR;
    while (1) {
        uint32_t t = q->ticket;
        if (t != 0) {
            q->execute(q->ctx);
            __DMB();   // Barrière mémoire (ARM)
            q->ticket = 0;
        }
        __WFI();  // Wait For Interrupt
    }
}

Le ticket sert de sémaphore sans mutex. L'instruction __WFI() place le CPU en veille entre deux tâches, réduisant la consommation énergétique. Cette approche convient aux architectures sans MMU.

Optimisation mémoire et gestion des ressources

Allocation de pile sur mesure

L'allocation par défaut des piles de threads gaspille souvent la mémoire. Une approche statique avec dimensionnement précis par profiling réduit significativement l'empreinte mémoire.

// Piles dimensionnées selon le profiling d'utilisation
static uint8_t stack_sensor[384] __attribute__((aligned(32)));
static uint8_t stack_network[2048] __attribute__((aligned(32)));

void init_threads(void) {
    osThreadAttr_t sensor_attr = {
        .stack_mem = stack_sensor,
        .stack_size = sizeof(stack_sensor),
        .priority = osPriorityAboveNormal
    };
    osThreadNew(sensor_task, NULL, &sensor_attr);
}

L'alignement sur 32 octets optimise les accès DMA et cache. Le dimensionnement s'appuie sur une analyse statique (stack high-water mark via uxTaskGetStackHighWaterMark() sous FreeRTOS).

Alternatives à l'allocation dynamique

L'usage intensif de malloc/free engendre fragmentation et imprévisibilité. Un object pool statique constitue une alternative fiable :

#define POOL_CAPACITY 32

typedef struct {
    uint8_t buffer[64];
    uint8_t active;
} Slot;

static Slot slots[POOL_CAPACITY];

Slot* acquire_slot(void) {
    for (int i = 0; i < POOL_CAPACITY; i++) {
        if (!slots[i].active) {
            slots[i].active = 1;
            return &slots[i];
        }
    }
    return NULL;  // Pool épuisé
}

void release_slot(Slot* s) {
    s->active = 0;
}
  • Object pool : élimine les allocations à l'exécution
  • Arena allocator : allocations groupées avec libération unique
  • Stack allocation : pour des objets au cycle de vie borné par la fonction

Libération immédiate des descripteurs de fichiers

Les descripteurs de fichiers sont une ressource finie. Tout oubli de close() peut provoquer un épuisement et un déni de service. En Go, defer garantit la libération :

func processData(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("ouverture impossible: %w", err)
    }
    defer f.Close()  // Exécuté même en cas de panic

    scanner := bufio.NewScanner(f)
    for scanner.Scan() {
        handleLine(scanner.Bytes())
    }
    return scanner.Err()
}

Ordonnancement et performance à l'exécution

Stratégies de调度 adaptées

Le choix de l'algorithme d'ordonnancement impacte directement la latence et le débit. Pour les tâches critiques en temps réel, une approche préemptive par priorité est souvent nécessaire.

// Configuration du runtime Go pour optimiser l'ordonnancement
func init() {
    // Limiter le nombre de P selon la charge CPU réelle
    runtime.GOMAXPROCS(runtime.NumCPU())
}

func dispatchConcurrent(tasks []func()) {
    var wg sync.WaitGroup
    sem := make(chan struct{}, runtime.NumCPU()*2)  // Backpressure

    for _, t := range tasks {
        wg.Add(1)
        sem <- struct{}{}
        go func(task func()) {
            defer wg.Done()
            defer func() { <-sem }()
            task()
        }(t)
    }
    wg.Wait()
}

Le sémaphore canal sem applique une backpressure, évitant la prolifération de goroutines et la saturation du scheduler.

Priorité et affinité CPU

Ajuster la valeur nice d'un processus influence son temps CPU. L'affinité CPU (pinning) réduit les cache misses en fixant un processus sur un cœur spécifique.

# Lancement avec priorité élevée
nice -n -10 ./critical_service

# Épinglage sur les cœurs 0 et 2
taskset -c 0,2 ./critical_service

# Vérification de l'affinité
taskset -p $(pidof critical_service)
Outil Fonction
nice/renice Ajustement de priorité statique
taskset Configuraton de l'affinité CPU
chrt Stratégie temps réel (FIFO/RR)

Réduction des commutations de contexte

Chaque commutation de contexte coûte environ 5 à 10 µs sur un processeur embarqué courant. L'utilisation de coroutines et le contrôle du parallélisme réduisent significativement ce coût.

// Worker pool avec goroutines bornées
func NewWorkerPool(size int) *WorkerPool {
    return &WorkerPool{
        queue: make(chan func(), 100),
        wg:    sync.WaitGroup{},
    }
}

func (p *WorkerPool) Start(size int) {
    for i := 0; i < size; i++ {
        p.wg.Add(1)
        go func() {
            defer p.wg.Done()
            for job := range p.queue {
                job()
            }
        }()
    }
}

Chaque goroutine démarre avec une pile de 2 Ko, extensible. Pour 1 000 workers simultanés, l'empreinte initiale reste sous 2 Mo, là où des threads POSIX consommeraient au minimum 8 Mo.

IPC légers : remplacer les pipes traditionnels

Les pipes anonymes impliquent deux copies de données et des commutations de contexte. Les sockets Unix et la mémoire partagée offrent des latences bien inférieures.

// Serveur Unix domain socket
func runUnixServer(socketPath string) {
    os.Remove(socketPath)
    ln, err := net.Listen("unix", socketPath)
    if err != nil {
        log.Fatalf("listen: %v", err)
    }
    defer ln.Close()

    conn, err := ln.Accept()
    if err != nil {
        log.Fatalf("accept: %v", err)
    }
    defer conn.Close()

    decoder := json.NewDecoder(conn)
    var msg struct{ Action string `json:"action"` }
    decoder.Decode(&msg)
    log.Printf("Action reçue : %s", msg.Action)
}
Mécanisme IPC Latence (µs) Débit (Mo/s)
Pipe anonyme 80 450
Socket Unix 35 980
Mémoire partagée + sémaphore 12 3 200

Surveillance et reprise sur panne

Un mécanisme de watchdog garantit la continuité de service. Le surveillant vérifie périodiquement l'état du processus cible et le redémarre en cas de défaillance.

#include <signal.h>
#include <syslog.h>
#include <unistd.h>

#define CHECK_INTERVAL_SEC 5
#define RESTART_CMD "/usr/bin/monitoring_agent"

void watchdog_loop(pid_t target) {
    while (1) {
        if (kill(target, 0) != 0) {
            syslog(LOG_WARNING, "PID %d inaccessible, redémarrage", target);
            pid_t p = fork();
            if (p == 0) {
                execl(RESTART_CMD, RESTART_CMD, "--auto-recover", NULL);
                _exit(127);
            }
            target = p;
        }
        sleep(CHECK_INTERVAL_SEC);
    }
}

Ce modèle, déployé sur des terminaux de télémétrie, a maintenu un fonctionnement ininterrompu sur plus de six mois, avec un temps moyen de récupération inférieur à 8 secondes.

Indicateur Avant optimisation Après optimisation
Pic CPU (%) 92 67
Commutations contexte/s 1 840 620
Latence processus critique (ms) 45 12

Étiquettes: Linux embedded systems Cgroups vfork clone

Publié le 25 juillet à 09h23