Giornata Tecnica da Piave Servizi, 11 aprile 2024 | DI DOMENICO Simone
Summary of “The Case for Writing Network Drivers in High-Level Programming Languages"
1. UNIVERSITÀ DEGLI STUDI DI TRIESTE
DIPARTIMENTO DI INGEGNERIA E ARCHITETTURA
Corso di Studi in Ingegneria Elettronica e Informatica
Summary of “The Case for Writing Network Drivers in
High-Level Programming Languages" [1]
Tesi di Laurea Triennale
Laureando:
Leonardo IURADA
Relatore:
prof. Marco TESSAROTTO
ANNO ACCADEMICO 2019/2020
3. 1 Introduzione
C è stato il linguaggio di programmazione prediletto per scrivere i kernel sin dal
suo concepimento. Anche la maggior parte dei driver dei dispositivi sono scritti
in C, o con un sottoinsieme ristretto di C++ fornendo praticamente nessuna
funzionalità di sicurezza aggiuntiva. Sono stati trovati 65 bug di sicurezza in
Linux nel 2017 di cui: 8 sono bug use-after-free o bug double-free, 32 sono bug
che permettono di eseguire accessi out-of-bounds, 14 sono bug logici e 11 sono
bug il cui effetto rimane ancora poco chiaro. I driver rappresentano il 66% del
codice in Linux, ma 39 su 40 di questi bug di sicurezza relativi alla memory
safety sono localizzati nei driver e possono essere mitigati usando un linguaggio
di programmazione che imponga dei controlli di sicurezza.
Questo studio si propone di valutare la possibilità di utilizzare linguaggi
di programmazione ad alto livello per scrivere driver a livello di spazio utente,
scegliendo come punto di partenza di studiare il comportamento di tali linguaggi
nei driver di rete. I linguaggi ad alto livello infatti sono più sicuri (meno bug,
più controlli di sicurezza), ma i safety checks al runtime riducono il throughput e
la garbage collection porta a picchi di latenza. Quindi, questo studio si propone
di far luce su due aspetti principali di questo problema: valutare quali linguaggi
siano adatti allo sviluppo di user space driver e confrontare costi e benefici dei
vari aspetti di sicurezza apportati da tali linguaggi.
La scelta di passare da driver kernel a user space driver è dettata dal fatto
che i driver sono anche le superfici d’attacco più grandi (in termini di righe di
codice) nei moderni sistemi operativi e continuano a crescere in complessità man
mano che vengono aggiunte ulteriori funzionalità. Gli user space driver inoltre
sono molto più isolati dal resto del sistema rispetto ai kernel driver: eseguire un
accesso out-of-bounds è ancora un bug in alcuni linguaggi memory-safe, ma fa
sì che il programma vada in crash se non gestito; declassando una vulnerabilità
riguardante l’esecuzione arbitraria di codice in un denial of service (DoS). Gli
user space driver possono semplicemente essere riavviati dopo un crash, mentre
un crash di un driver kernel di solito fa sì che l’intero sistema collassi.
La decisione di studiare il comportamento dei driver di rete non è casuale:
negli ultimi anni, i driver dei dispositivi di rete sono riusciti a sfuggire dal-
le grinfie del kernel grazie agli user space driver (esempio, DPDK, framework
per costruire applicativi in grado di processare pacchetti in maniera rapida,
costituito da diversi user space driver).
1
4. 2 Metodo d’indagine
Per indagare la questione, centro di questa ricerca, è stato scelto di utilizzare
Linux x86 e sono state presentate implementazioni di uno user space driver
ottimizzato per le prestazioni per la famiglia di controller di rete “Intel ixgbe”
(82599ES, X540, X550, e X552) in 9 linguaggi ad alto livello (Rust, Go, C, Java,
OCaml, Haskell, Swift, JavaScript, e Python) le cui caratteristiche principali
sono riassunte nella Tabella 1. Tutte le implementazioni sono scritte da zero
da programmatori esperti nei rispettivi linguaggi di programmazione in stile
idiomatico e seguono la stessa architettura di base, permettendo un confronto
in termini di prestazioni tra i linguaggi ad alto livello e l’implementazione di
riferimento in C.
Linguaggio Paradigma principale∗
Memory mgmt. Compilazione
Rust Imperative Ownership/RAII†
Compilato‡
Go Imperative Garbage collection Compilato
C# Object-oriented Garbage collection JIT
Java Object-oriented Garbage collection JIT
OCaml Functional Garbage collection Compilato
Haskell Functional Garbage collection Compilato‡
Swift Protocol-oriented Reference counting Compilato‡
JavaScript Imperative Garbage collection JIT
Python Imperative Garbage collection Interpretato
∗
Tutti i linguaggi selezionati sono multi-paradigma
†
L’acquisizione delle risorse è l’Inizializzazione
‡
Usando LLVM
Tabella 1: Linguaggi usati nelle implementazioni
2.1 Architettura del Device Driver
L’architettura del driver ixy (la cui versione di riferimento è scritta in C) è
ispirata al DPDK, vale a dire, usa driver in modalità polling senza interrupts,
tutte la API si basano sul processare pacchetti in batch, e sfruttano l’UIO
subsystem. Nei linguaggi in cui le syscall mmap e mlock non sono disponibili,
vengono sfruttate piccole funzioni in C.
Inoltre, in ogni linguaggio sono stati pensati wrapper specifici per lo spazio
d’indirizzi MMIO PCIe e per i DMA buffer in modo da poter fornire un mecca-
nismo di boundary checking e permettere l’accesso alla memoria senza causare
violazioni. In ogni caso, sia l’uso di mmap che l’uso di struct costruiti a partire
da un puntatore e una lunghezza sono operazioni intrinsecamente non sicure.
L’obiettivo è quello di ridurre queste operazioni a meno aree di codice possibile
per ridurre la quantità di codice da monitorare manualmente per evitare errori
di memoria.
Ultima considerazione sull’architettura del driver: mentre in C è prevista
la keyword volatile per evitare che le ottimizzazioni introdotte dal compila-
tore vadano ad introdurre comportamenti non voluti nell’esecuzione del codice
(esempio, leggere ripetutamente lo stesso registro in un loop mentre si attende
che un valore venga modificato dal dispositivo sembra un’opportunità per l’ot-
timizzatore di sollevare la lettura dal loop), nei linguaggi ad alto livello possono
essere usate utility di gestione della concorrenza.
2
5. 2.2 Implementazioni
Tutte le implementazioni nei linguaggi ad alto livello hanno richiesto più righe
di codice rispetto a C. La Tabella 2 riassume le protezioni contro le classi di
bug disponibili sia per le implementazioni dei driver che per le applicazioni
basate su di essi. Il vantaggio qui è che linguaggi ad alto livello non aumentano
necessariamente la difficoltà d’implementazione per il programmatore ottenendo
vantaggi in termini di sicurezza.
Memoria Generale Packet Buffer
Ling. OoB1
Use after free OoB1
Use after free Int overflows
C
Rust ()2
()5
Go ()2
()4
C# ()2
()4
()5
Java ()2
()4
OCaml ()2
()4
Haskell ()2
()4
()6
Swift 3
()4
JavaScript ()2
()4
()6
Python ()2
()4
()6
1
Accesso Out of Bounds.
2
Bounds imposti da wrapper, constructor in codice unsafe o C.
3
Bounds imposti solo in debug mode.
4
Buffer mai liberati/garbage collected, solo ritornati ad una memory pool.
5
Disabilitato di default.
6
Di default usa floating point precision anche per gli integer.
Tabella 2: Protezioni a livello di linguaggio contro le classi di bug nei
driver.
L’unica implementazione non ottimizzata per alte prestazioni è quella in Py-
thon: essa è stata pensata come un semplice strumento educativo. Nella Tabella
3 riassumiamo i principali metodi di wrapping usati nelle implementazioni.
Linguaggio Gestione ed Accesso in Memoria Wrapper Memoria Esterna
Rust Ownership system std::slice objects
ptr module Rust’s struct
Go atomic package slice data type
C# Unsafe mode Marshal in System.Runtime.InteropServices
Java sun.misc.Unsafe Object Java Class
OCaml C helper functions OCaml’s Bigarrays
Cstruct library
Haskell System.Posix.Memory Foreign package
Swift Automatic Reference counting UnsafeBufferPointers
JavaScript Node.js module in C JS’ ArrayBuffers
Python Cython Python Class
Tabella 3: Metodi di wrapping in ogni implementazione
2.3 Test Setup
Per stimare le prestazioni del routing (misurate in Mpps), i driver vengono
eseguiti su una CPU Xeon E3-1230 v2 con clock a 3.3GHz con due 10Gbit/s
Intel X520 NIC. Il traffico di test è generato con MoonGen. I due NIC eseguono
3
6. un’applicazione di forwarding che modifica un byte nell’header del pacchetto.
Tutti i test sono ristretti all’uso di un singolo core della CPU.
Per valutare la latenza invece, viene eseguito il mirroring di tutti i pacchetti
per mezzo di uno splitter in fibra ottica su un server che esegue MoonSniff con
timestamp registrati a livello hardware su una NIC integrata Xeon D (precisione
misurata 24 ns, senza perdite di pacchetti). Il dispositivo sotto test usa un Intel
Xeon E5-2620 v3 a 2.40 GHz e un dual-port Intel X520 NIC. Tutte le latenze
sono state misurate con una dimensione batch di 32 sotto carico bidirezionale
con traffico a bit rate costante.
3 Risultati
3.1 Prestazioni
Processare pacchetti in batch è un concetto chiave in tutti i veloci driver di
rete. Ogni batch ricevuta o inviata richiede la sincronizzazione con la scheda di
rete, batch più grandi incrementano quindi le prestazioni. Batch troppo grandi
riempiono le cache della CPU quindi si hanno pochi miglioramenti o addirittura
una riduzione delle prestazioni. Da 32 a 128 pacchetti per batch è la scelta
ottima per gli user space driver. La Figura 1 mostra le prestazioni massime
raggiunte nel forwarding bidirezionale dalle varie implementazioni. Haskell e
OCaml allocano una nuova lista/array per ogni batch di pacchetti che viene
processata mentre tutti gli altri linguaggi riutilizzano gli array. Riciclare gli
array in questi linguaggi funzionali violerebbe la volontà di scrivere codice in
stile idiomatico, questa è una delle ragioni per le loro prestazioni più basse.
Figura 1: Forwarding Rate on 3.3 GHz CPU.
3.2 Il costo delle funzionalità di sicurezza
Rust è l’implementazione più rapida che raggiunge oltre il 90% delle prestazioni
di C. È anche l’unico linguaggio di alto livello senza costi generali per la gestione
della memoria, che lo rende un candidato ideale per indagini ulteriori.
4
7. Eventi per pacchetto C Rust
Cicli 94 100
Istruzioni 127 209
Istr. per ciclo 1.35 2.09
Branches 18 24
Branches mispredicts 0.05 0.08
Store µops 21.8 37.4
Load µops 30.1 77.0
Load L1 hits 24.3 75.9
Load L2 hits 1.1 0.05
Load L3 hits 0.9 0.0
Load L3 misses 0.3 0.1
Tabella 4: Prestazioni in eventi per
pacchetto. Batch 32, 1.6 GHz.
Lo svantaggio di performance
principale è dato dal boundary chec-
king. La Tabella 4 elenca i risulta-
ti in eventi per pacchetto inoltrato.
Rust richiede il 65% di istruzioni in
più per inoltrare un singolo pacchet-
to a una dimensione di batch di 32.
Il numero di branch eseguite aumen-
ta del 33%, il numero di load anche
del 150%. Tuttavia, il codice Rust ri-
chiede solo il 6% di cicli in più per
pacchetto nel complesso nonostante
faccia più lavoro. Una moderna su-
perscalar CPU con esecuzione out-of-
order può effettivamente nascondere
l’overhead introdotto dai controlli di
sicurezza: il processore è in grado di
prevedere correttamente (il tasso di errore del branch è allo 0,2% - 0,3%) ed ese-
guire speculativamente il percorso corretto. Un’altra caratteristica di sicurezza
di Rust sono i controlli sugli integer overflow: devono tuttavia essere attiva-
ti esplicitamente con una flag al momento della compilazione. Facendo così si
decrementa il throughput di solo lo 0.8% con batch di dimensione 8, nessuna
deviazione statistica significativa è stata misurata con batch di dimensione più
grande. Anche in questo caso la speculative execution nascondendo il costo delle
caratteristiche di sicurezza.
3.3 Latenza
La latenza è dominata dal tempo impiegato nel buffer, non dal tempo impiegato
nella gestione di un pacchetto dalla CPU. I driver inoltrano i pacchetti in centi-
naia di cicli, ovvero entro centinaia di nanosecondi. Pertanto, un driver con un
throughput inferiore non è automaticamente uno con una latenza più elevata
durante il funzionamento al limite massimo del carico di lavoro. I principali fat-
tori che determinano l’aumento della latenza sono le pause dovute alla garbage
collection e alle dimensioni della batch. I driver funzionano con un buffer di
dimensione 512 di default e configurano la NIC per eliminare i pacchetti se il
buffer di ricezione è pieno.
La Figura 2 mostra le latenze di coda dei driver durante l’inoltro di pacchetti
a velocità diverse. I dati vengono tracciati come CCDF per concentrarsi sulle
latenze nel caso peggiore.
5
8. Figura 2: Latenze di coda durante l’inoltro dei pacchetti.
La loro latenza è semplicemente una funzione della dimensione del buffer di
ricezione man mano che esso si riempie completamente. Nessuna latenza mas-
sima osservata è significativamente diversa dal 99.9999-esimo percentile. Java e
JavaScript perdono i pacchetti durante l’avvio a causa della compilazione JIT,
pertanto escludiamo i primi 5 secondi dell’esecuzione del test per questi due
linguaggi. Tutti i test mostrati sono stati eseguiti senza perdita di pacchetti.
4 Conclusione
Riscrivere l’intero sistema operativo in un linguaggio di alto livello è uno sforzo
lodevole ma difficilmente rimpiazzerà l’utilizzo dei sistemi operativi desktop e
server tradizionali nel prossimo futuro. Si propone di iniziare a riscrivere i driver
come user space driver in linguaggi di alto livello poiché 39 dei 40 bug di sicurezza
della memoria di Linux elencati si trovano nei driver, a dimostrazione del fatto
che la maggior parte dei miglioramenti in termini di sicurezza possono essere
ottenuti senza sostituire l’intero sistema operativo. I driver di rete sono un buon
punto di partenza per questo sforzo: i driver di rete a livello di spazio utente
scritti in C sono già all’ordine del giorno (esempio, DPDK). Dai test eseguiti
emerge che Rust è il primo candidato come linguaggio per driver più sicuri:
il suo ownership system previene bug di memoria anche in aree di memoria
personalizzate non allocate dal runtime del linguaggio. Il costo di questi aspetti
di sicurezza sono solo il 2% - 10% del throughput su moderne superscalar CPU
out-of-order. L’ownership system di Rust fornisce più funzionalità di sicurezza
rispetto ai linguaggi basati sulla garbage collection senza influenzare la latenza.
Anche Go e C# sono dei linguaggi adatti se il sistema è in grado di far fronte a
picchi di latenza inferiori al millisecondo dovuti alla garbage collection. Gli altri
linguaggi discussi qui possono anche essere utili se le prestazioni sono meno
critiche rispetto ad avere un sistema sicuro e corretto, ad esempio Haskell e
OCaml sono più adatti alla verifica formale.
Riferimenti bibliografici
[1] Paul Emmerich, Simon Ellmann, Fabian Bonk, Alex Egger, Esaú García Sánchez-
Torija, Thomas Günzel, Sebastian Di Luzio, Alexandru Obada, Maximilian Stadl-
meier, Sebastian Voit, Georg Carle The Case for Writing Network Drivers in
High-Level Programming Languages ANCS’ 19, 13 September 2019
6