Analisi dell’attività di I/O su dispositivi a blocchi in Linux

Introduzione

Dai primi calcolatori elettronici ad oggi, la capacità di storage è aumentata vertiginosamente. Se è vero però che la capacità delle memorie rappresenta raramente un problema, altrettanto non si può dire per le loro prestazioni.

Negli ultimi anni, il gap di performance tra processori e memorie è aumentato sempre di più, a sfavore di queste ultime: ad un aumento di prestazione dei processori è sempre corrisposto un aumento di prestazione delle memorie nettamente inferiore.

Questo gap diventa chiaramente più marcato man mano che scendiamo nei livelli della memory hierarchy. Per dare un’idea, consideriamo che il tempo medio impiegato per leggere 1KB da un SSD moderno è di circa 50 µs, mentre un accesso alla memoria centrale impiega mediamente 100 ns. Una differenza pari ad un fattore 500!

Da questi numeri si capisce l’esigenza di analizzare l’efficienza del pattern di I/O delle nostre applicazioni. Questo tipo di analisi risulta sicuramente indispensabile per DBA e DevOps che devono monitorare l’efficienza dei loro server in produzione. Ma non solo. Nell’ambito dei sistemi embedded, troviamo spesso schede che si affidano a MMC per lo storage non volatile. Le MMC, in Linux, sono gestite come dispositivi a blocchi e il loro tempo di vita dipende dal tasso di scritture che effettuiamo su di esse (e dalla loro composizione).

Saper analizzare il pattern di I/O delle nostre applicazioni ci permette quindi di ottenere, in diversi ambiti, migliori performance e maggiore affidabilità.Per poter effettuare questa analisi in maniera consapevole, è necessario capire prima come si arriva da una richiesta di lettura o scrittura, nel codice delle nostre applicazioni, ad un’analoga operazione eseguita dal dispositivo di storage.

Linux I/O Stack

Iniziamo chiedendoci: cosa succede quando eseguiamo una lettura o una scrittura da un dispositivo a blocchi nel nostro codice?

Per interagire con un dispositivo a blocchi, lo sviluppatore esegue delle chiamate di libreria (libcall), ma queste non si traducono immediatamente in analoghe attività di I/O sul device.
Un primo livello di buffering e aggregazione lo abbiamo proprio all’interno delle librerie standard del linguaggio utilizzato.

In user space, però, è piuttosto immediato vedere cosa succede: su Linux possiamo utilizzare il tool strace per vedere come le libcall si traducono in chiamate di sistema al kernel (syscall). Grazie ad strace, possiamo capire come le librerie standard manipolano le nostre richieste di I/O nel tentativo di renderle più efficienti.

Di seguito, mostriamo un estratto dell’output di strace, che riporta le syscall read e write effettuate da un run di dd:

$ strace dd if=/dev/urandom of=/tmp/test.tmp bs=512K count=8
...
write(1, "5\10\303h\2178>\275\0\254\352\r\202\6\36:\205\345\00258'\37HL\270\244^\374{\24\304"..., 524288) = 524288
read(0, "\245\367\304\215\200\20\304\21\273\241[u\214\324\265\256(V\31\6\31\341\212+z\341\247\205Hq\235R"..., 524288) = 524288
write(1, "\245\367\304\215\200\20\304\21\273\241[u\214\324\265\256(V\31\6\31\341\212+z\341\247\205Hq\235R"..., 524288) = 524288
...

Per ciascuna syscall effettuata, possiamo vedere il numero di byte letti o scritti.

Entrando in kernel space, le cose si complicano. Una richiesta di I/O deve attraversare diversi strati prima di raggiungere il dispositivo fisico:

  1. Virtual File System e Page Cache
  2. File System
  3. Block I/O layer
  4. Device driver

Vediamo quindi lo scopo di ciascun layer, e quali sono gli effetti che si possono avere sulle richieste di I/O effettuate in user space.

Virtual File System

Il kernel Linux fornisce un layer di astrazione sopra alle implementazioni dei vari file system. Questo strato prende il nome di Virtual File System e serve proprio per uniformare l’API che i vari file system presentano agli strati superiori. Secondo la filosofia UNIX “everything is a file”, in user-space dobbiamo poter operare con ogni oggetto attraverso le classiche syscall open, close, read, write, e ioctl. Il VFS si occupa di smistare queste richieste al file system specifico dove risiede il file oggetto della syscall.
Il VFS, inoltre, integra una cache denominata Page Cache. Si tratta di un meccanismo di caching dove pagine di memoria principale vengono utilizzate per mantenere dati provenienti dai dispositivi a blocchi sottostanti, accelerando le operazioni di lettura e scrittura. A questo livello, quindi, il kernel lavora con la granularità di una pagina di memoria. In caso di cache hit, la richiesta di I/O non giunge allo strato di file system vero e proprio, ma viene servita direttamente dalla Page Cache.

È possibile bypassare la Page Cache aprendo un file con il flag O_DIRECT.

Proviamo a vedere in azione la Page Cache, per capire i vantaggi che porta.Utilizzando free possiamo vedere, sotto la colonna “Cache”, la quantità di memoria utilizzata dalla Page Cache:

$ free -w -h
              total        used        free      shared     buffers       cache   available
Mem:           31Gi       4,4Gi        25Gi       519Mi       1,0Mi       1,2Gi        26Gi
Swap:          31Gi          0B        31Gi

Usiamo dd per generare un file con contenuto pseudo-casuale di 512 MB:

$ dd if=/dev/urandom of=/tmp/test.tmp bs=512K count=1024
1024+0 record dentro
1024+0 record fuori
536870912 bytes (537 MB, 512 MiB) copied, 8,71077 s, 61,6 MB/s

Come si vede, il contenuto del file appena creato è stato portato in cache, infatti questa è aumentata di una dimensione pari al file creato:

$ free -w -h
              total        used        free      shared     buffers       cache   available
Mem:           31Gi       4,4Gi        25Gi       530Mi       3,0Mi       1,8Gi        26Gi
Swap:          31Gi          0B        31Gi

Utilizziamo sync per chiedere al kernel di fare un flush della cache, dopodiché eliminiamo il contenuto attuale della page cache:

$ sync; echo 1 > /proc/sys/vm/drop_caches

La porzione di memoria occupata dalla cache è infatti diminuita:

$ free -w -h
              total        used        free      shared     buffers       cache   available
Mem:           31Gi       4,4Gi        25Gi       522Mi       1,0Mi       1,3Gi        26Gi
Swap:          31Gi          0B        31Gi

Proviamo adesso a leggere il contenuto del file, scartando il risultato della lettura:

$ time dd if=/tmp/test.tmp of=/dev/null
1048576+0 record dentro
1048576+0 record fuori
536870912 bytes (537 MB, 512 MiB) copied, 0,452967 s, 1,2 GB/s

real	0m0,459s
user	0m0,087s
sys	0m0,316s

Adesso che la Page Cache è hot, leggiamo nuovamente e valutiamo l’impatto sulle performance di un cache hit:

$ time dd if=/tmp/test.tmp of=/dev/null
1048576+0 record dentro
1048576+0 record fuori
536870912 bytes (537 MB, 512 MiB) copied, 0,358469 s, 1,5 GB/s

real	0m0,359s
user	0m0,096s
sys	0m0,263s

Il tempo impiegato per effettuare la lettura è notevolmente inferiore. In particolare, la differenza risiede nel tempo speso per eseguire codice in kernel space, cioè il codice relativo alle letture: grazie alla Page Cache risultano più veloci.

File System

Il File System si occupa principalmente di organizzare i dati su disco, in maniera strutturata. In questo layer, l’unità fondamentale è il blocco. È possibile vedere la dimensione del blocco di un certo file system (tipicamente 4 KB), usando tune2fs e indicando il device di interesse:

$ tune2fs -l /dev/mapper/bullseye--vg-home | grep -i "block size"
Block size:               4096

Per organizzare in modo strutturato i blocchi di dati, il file system vi associa dei metadati, che hanno lo scopo di descrivere diverse proprietà dei dati stessi, come permessi di accesso, ultima data di modifica, ecc. In Linux, uno dei file system più diffusi è sicuramente ext4. In ext4, i metadati sono conservati negli inode. Una trattazione approfondita di ext4 va oltre gli scopi di questo articolo, ma è importante ricordare che file system diversi utilizzano schemi diversi per allocare i blocchi su disco: questo ha delle ripercussioni sulla quantità di scritture che avvengono sul device fisico. Oltre al tipo di file system adottato, anche alcune opzioni di configurazione di uno stesso file system determinano un’attività diversa per i layer sottostanti. Con riferimento ad ext4, consideriamo, ad esempio, il journaling.

Il journal è un’area dedicata del disco dove viene salvato un log delle operazioni da eseguire, insieme ai dati e metadati oggetto di queste operazioni. Utilizzando il full journaling, prima di apportare effettivamente le modifiche ai file sul file system principale, è necessario che dati e metadati vengano scritti sul journal. In questo modo, in caso di shutdown improvviso della macchina, al successivo riavvio è possibile replicare quanto descritto nel journal, completando le operazioni interrotte e riportando il file system in uno stato consistente. Il journaling riduce il throughput di scrittura, ma garantisce una maggiore affidabilità.

ext4 fornisce 3 impostazioni per il journaling:

La modalità writeback garantisce le performance più elevate, mentre la modalità journal garantisce l’affidabilità massima. Di default, un filesystem ext4 utilizza la modalità ordered.

Block I/O Layer

A questo livello, il kernel deve gestire una coda di richieste per operazioni di lettura e scrittura su blocchi. In Linux, ciascun blocco è rappresentato da un’istanza della struct bio. Queste strutture vengono incapsulate in una struct request, che rappresenta una richiesta da portare a termine.
Per gestire in maniera efficace le richieste, il kernel utilizza un apposito scheduler di I/O.

L’I/O scheduler ha lo scopo di massimizzare il throughput totale da e verso il dispositivo a blocchi, senza introdurre una latenza eccessiva (starvation) per singole richieste di I/O.

Per raggiungere questi obiettivi, lo scheduler può effettuare reordering, merge e split delle richieste di I/O accodate.

In Linux, anche questo componente è configurabile: la strategia di scheduling può cambiare per cercare di ottimizzare l’una o l’altra metrica.

Gli scheduler I/O più comuni sono:

Mediante sysfs possiamo vedere il Block I/O scheduler selezionato per un dato device:

$ cat /sys/block/nvme0n1/queue/scheduler 
[none] mq-deadline

Se vogliamo modificare l’I/O scheduler, ad esempio con mq-deadline:

# echo "mq-deadline" > /sys/block/nvme0n1/queue/scheduler 

$ cat /sys/block/nvme0n1/queue/scheduler  
none [mq-deadline]

Per rendere permanente la modifica, è però necessario modificare il file di configurazione del bootloader.

Device Driver

Il Device Driver del supporto di storage si occupa principalmente di tradurre le richieste I/O per i blocchi in corrispondenti richieste I/O per settori. Utilizzando fdisk possiamo valutare la dimensione dei settori del nostro supporto fisico, riportata alla voce Sector size:

$ fdisk -l /dev/nvme0n1
Disk /dev/nvme0n1: 931,53 GiB, 1000204886016 bytes, 1953525168 sectors
Disk model: Sabrent Rocket 4.0 1TB                  
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
...

Oltre al mapping da blocchi a settori, il device driver si occupa di inviare gli opportuni comandi di basso livello al supporto fisico, per servire effettivamente le varie richieste. La maggior parte della volte, l’analisi del pattern di I/O non riguarda questo strato: i driver sono considerati affidabili e, pur essendo il loro codice open source, la loro comprensione necessita delle specifiche di basso livello del supporto di storage, non sempre facilmente reperibili.
Pertanto, questo layer viene solitamente trattato come una “black box”.

Conclusioni

Analizzando l’I/O stack di Linux, possiamo capire l’importanza del Block I/O Layer. Il supporto di storage ”vede” in ingresso ciò che il suo device driver gli invia, ma questo dipende strettamente da quello che succede nel layer immediatamente superiore, cioè nel Block I/O Layer. Per effettuare un’analisi precisa, diventa rilevante, quindi, capire le caratteristiche del pattern di I/O a livello di blocco, e come possiamo migliorarlo in funzione dei nostri obiettivi: migliori performance, maggiore affidabilità, ecc.

Nella seconda parte dell’articolo vedremo quali strumenti possiamo utilizzare per analizzare l’attività del Block I/O layer, in risposta alle operazioni di I/O in user space delle nostre applicazioni.