Spinlock eBPF e l'arte del debugging del kernel
Come si manifestano i bug degli spinlock eBPF nel kernel Linux, perché sono difficili da trovare e cosa rivelano sulla sicurezza di eBPF.

Un bug di uno spinlock in eBPF non si annuncia con uno stack trace o un messaggio d'errore. Si annuncia con una macchina che smette di rispondere. Nessun log, nessun avviso, nessun degrado controllato, solo un core della CPU che gira all'infinito su un lock che non verrà mai rilasciato. Se va bene, il watchdog scatta e ottieni un kernel panic con una traccia. Se va male, la macchina si blocca e devi riavviarla a freddo.
eBPF dovrebbe essere sicuro. Gira nel kernel ma passa attraverso un verifier che dimostra che i programmi terminano, non accedono a memoria non valida e non corrompono lo stato del kernel. Allora come fanno a passare i bug degli spinlock? Perché il verifier controlla i singoli programmi, mentre i bug degli spinlock emergono dalle interazioni tra programmi, mappe e decisioni di scheduling del kernel, cose che nessun verifier statico può prevedere del tutto.
A cosa servono gli spinlock di eBPF
I programmi eBPF girano in contesto kernel, spesso su più CPU contemporaneamente. Quando due programmi eBPF devono aggiornare lo stesso valore in una mappa, un contatore, una struttura dati o una macchina a stati, hanno bisogno di sincronizzazione. eBPF fornisce bpf_spin_lock e bpf_spin_unlock per questo.
Uno spinlock è il lock più semplice possibile: provi ad acquisirlo e, se è occupato, giri in un ciclo stretto finché non viene rilasciato. Niente sleep, niente cessione della CPU, niente code. È adatto a eBPF perché i programmi eBPF girano in contesti in cui dormire è vietato, come gestori di interrupt, softirq e sezioni con preemption disabilitata. Un mutex che mette in sleep il chiamante farebbe crashare il kernel. Uno spinlock, invece, brucia cicli di CPU finché il lock non si libera.
struct map_value {
struct bpf_spin_lock lock;
__u64 counter;
__u32 last_pid;
};
SEC("tp/sched/sched_switch")
int handle_sched_switch(struct trace_event_raw_sched_switch *ctx) {
__u32 key = 0;
struct map_value *val;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
bpf_spin_lock(&val->lock);
val->counter++;
val->last_pid = ctx->next_pid;
bpf_spin_unlock(&val->lock);
return 0;
}
Sembra semplice. E per casi semplici come questo (lock, aggiornamento di un valore, unlock) funziona bene. I problemi iniziano quando i pattern di locking diventano più complessi, o quando le interazioni tra programmi eBPF e i lock del kernel creano dipendenze inattese.
Come si manifestano i bug degli spinlock
Il classico bug degli spinlock è il deadlock: il programma A tiene il lock 1 e prova ad acquisire il lock 2, mentre il programma B tiene il lock 2 e prova ad acquisire il lock 1. Entrambi girano all'infinito aspettando l'altro. In contesto kernel questo significa che quei core della CPU sono persi: non eseguiranno mai più nient'altro.
Il verifier eBPF previene alcuni di questi casi. Rifiuta i programmi che tengono contemporaneamente più di uno spinlock, eliminando il classico deadlock AB-BA. Ma non può prevenire tutti gli scenari di deadlock, perché alcuni nascono dalla relazione tra gli spinlock di eBPF e i lock del kernel.
Considera questo scenario: un programma eBPF agganciato a un tracepoint acquisisce uno spinlock su una mappa. Mentre il lock è tenuto, sulla stessa CPU scatta un interrupt hardware. Il gestore dell'interrupt esegue un altro programma eBPF che prova ad acquisire lo stesso spinlock. Deadlock: l'interrupt non può terminare finché non ottiene il lock, ma il lock non può essere rilasciato finché il programma interrotto non riprende, cosa che non può avvenire finché l'interrupt non termina.
Deadlock scenario (same CPU):
CPU 0 running eBPF program A
→ acquires spinlock on map_value
→ hardware interrupt fires
→ CPU 0 runs interrupt handler
→ interrupt handler runs eBPF program B
→ program B tries to acquire same spinlock
→ spinlock is held by program A
→ program B spins... forever
→ interrupt handler never returns
→ program A never resumes
→ program A never releases lock
→ CPU 0 is permanently stuck
I lock del kernel gestiscono questo caso disabilitando gli interrupt mentre lo spinlock è tenuto (spin_lock_irqsave). Anche gli spinlock di eBPF fanno lo stesso: bpf_spin_lock disabilita la preemption e, sui kernel 5.1+, disabilita i softirq. Ma non tutti i contesti di interrupt sono coperti, e non tutte le versioni del kernel gestiscono la cosa in modo identico. È lì che si nascondono i bug più subdoli.
Il problema del debugging
Il debugging dei problemi di spinlock in eBPF è difficile per motivi precisi.
La riproduzione non è deterministica. I bug degli spinlock dipendono dai tempi: quale CPU esegue quale programma, quando scattano gli interrupt, quanto dura la sezione critica. Un test che funziona 999 volte può andare in deadlock alla millesima. Non puoi riprodurli in modo affidabile in sviluppo, perché la tua macchina di sviluppo ha una topologia di CPU, una frequenza di interrupt e pattern di carico diversi da quelli di produzione.
La modalità di guasto distrugge le prove. Quando si verifica un deadlock su uno spinlock, le CPU coinvolte smettono di eseguire qualsiasi cosa, incluse l'infrastruttura di logging e monitoraggio che ti direbbe cosa è successo. Se il deadlock è su una sola CPU, la macchina potrebbe continuare a trascinarsi. Se più CPU vanno in deadlock (o se la CPU bloccata tiene risorse di cui le altre hanno bisogno), l'intera macchina si blocca.
Gli strumenti di debugging tradizionali non aiutano. Non puoi collegare un debugger al kernel per ispezionare lo stato bloccato (be', puoi usare KGDB, ma devi aver configurato una connessione seriale prima del deadlock). dmesg è inutile perché nulla scrive più su di esso. Le uniche informazioni affidabili arrivano dall'output del lockup detector, se scatta prima che la macchina muoia del tutto.
Strategie che funzionano davvero
Nonostante queste difficoltà, i bug degli spinlock di eBPF si trovano e si risolvono. Ecco come.
Analisi dell'ordine dei lock. Prima di eseguire qualsiasi cosa, analizza il comportamento di locking di ogni programma eBPF. Quali mappe accede ogni programma? Quali lock acquisisce? Due programmi che toccano lo stesso lock possono girare sulla stessa CPU (stesso tracepoint, o tracepoint contro interrupt)? È un'analisi manuale noiosa, ma intercetta i pattern di deadlock più comuni prima che si verifichino.
Monitoraggio del tempo di hold dei lock. Strumenta i tuoi programmi eBPF per misurare per quanto tempo vengono tenuti gli spinlock. Uno spinlock tenuto per più di pochi microsecondi è un campanello d'allarme: aumenta la finestra per i deadlock causati da interrupt e fa sprecare cicli alle altre CPU mentre girano in attesa. Sezioni critiche brevi sono sia più veloci sia più sicure.
// Monitoring lock hold time in eBPF
SEC("tp/sched/sched_switch")
int handle_switch(void *ctx) {
struct map_value *val;
__u64 start, elapsed;
__u32 key = 0;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
start = bpf_ktime_get_ns();
bpf_spin_lock(&val->lock);
// Critical section — keep this minimal
val->counter++;
bpf_spin_unlock(&val->lock);
elapsed = bpf_ktime_get_ns() - start;
// Report if lock was held too long
if (elapsed > 1000) { // > 1 microsecond
bpf_printk("lock held for %llu ns", elapsed);
}
return 0;
}
Strutture dati per CPU. Il miglior bug degli spinlock è quello che non può accadere. Se ogni CPU lavora sulla propria copia dei dati e aggrega in seguito, non c'è contesa e non servono lock. BPF_MAP_TYPE_PERCPU_HASH e BPF_MAP_TYPE_PERCPU_ARRAY sono progettati esattamente per questo. Contatori, istogrammi e accumulatori quasi mai richiedono spinlock per lo stato condiviso.
Operazioni atomiche al posto dei lock. Per operazioni semplici, come incrementare un contatore o confrontare e scambiare un valore, __sync_fetch_and_add e gli atomici simili sono più veloci e privi di deadlock. Non ti serve uno spinlock per incrementare un contatore. Riserva gli spinlock alle operazioni che richiedono davvero l'aggiornamento atomico di più campi.
Cosa rivela il modello di sicurezza di eBPF
La storia della sicurezza di eBPF è impressionante ma sfumata. Il verifier garantisce che i singoli programmi siano sicuri: terminano, non accedono a memoria non valida, non corrompono lo stato del kernel. Ma programmi «sicuri» possono comporsi in sistemi non sicuri quando le loro interazioni creano dipendenze temporali che il verifier non riesce ad analizzare.
Questo è un limite fondamentale dell'analisi statica. Il verifier vede ogni programma in isolamento. Non sa quali altri programmi sono caricati, quali mappe condividono o in quali contesti del kernel girano. Un programma che acquisisce uno spinlock è «sicuro»: lo acquisisce e lo rilascia correttamente. Ma se sia sicuro in combinazione con ogni altro programma eBPF caricato dipende dalle condizioni a runtime.
La community del kernel sta affrontando la questione in modo incrementale. Le patch recenti hanno introdotto restrizioni su quali tipi di programmi eBPF possono usare gli spinlock, quanto possono durare le sezioni critiche e quali operazioni sono vietate mentre uno spinlock è tenuto. Ogni restrizione riduce la finestra per i bug, ma limita anche ciò che i programmi eBPF possono fare.
Regole pratiche per il locking in eBPF
Dopo aver fatto il debugging di abbastanza problemi di spinlock, emergono dei pattern per evitarli.
- Preferisci le mappe per CPU. Se i tuoi dati possono essere partizionati per CPU e aggregati nello spazio utente, fallo. Niente lock, niente contesa, niente deadlock. Questo copre l'80% dei casi d'uso: contatori, log di eventi, istogrammi.
- Preferisci gli atomici agli spinlock. Se ti serve un contatore condiviso o un compare-and-swap, usa le operazioni atomiche. Sono lock-free e non possono andare in deadlock.
- Se devi usare gli spinlock, tieni le sezioni critiche minuscole. Incrementa un contatore, aggiorna un timestamp, scambia un puntatore. Nient'altro. Non chiamare mai funzioni helper mentre tieni un lock: non sai che locking fanno internamente.
- Non tenere mai lo stesso lock da due tipi di programmi eBPF. Se un programma kprobe e un programma tracepoint bloccano lo stesso valore di una mappa, sei a un interrupt di distanza da un deadlock. Usa mappe separate o dati per CPU.
- Testa sotto carico. I bug degli spinlock dipendono dai tempi. Emergono sotto contesa: alto utilizzo della CPU, interrupt frequenti, molti programmi eBPF in esecuzione contemporaneamente. Testa con un carico di produzione realistico, non su macchine di sviluppo inattive.
eBPF ha cambiato profondamente il modo in cui interagiamo con il kernel Linux, dando ai programmi dello spazio utente un accesso sicuro all'osservabilità e al networking a livello kernel. Ma «sicuro» ha dei confini. Il verifier rende eBPF molto più sicuro che scrivere moduli kernel grezzi, e in modo drastico. Non lo rende sicuro nello stesso modo in cui lo è la programmazione in spazio utente. Capire dove passano questi confini, soprattutto per quanto riguarda lo stato condiviso e il locking, è ciò che separa i programmi eBPF che funzionano in produzione da quelli che funzionano solo nei test.


