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'adressageCLONE_FILES: partage des descripteurs de fichiersCLONE_SIGHAND: partage des gestionnaires de signauxCLONE_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 |