UNIVERSITÁ DEGLI STUDI DI MODENA E REGGIO EMILIA

Transcript

UNIVERSITÁ DEGLI STUDI DI MODENA E REGGIO EMILIA
UNIVERSITÁ DEGLI STUDI DI
MODENA E REGGIO EMILIA
Dipartimento di Scienze Fisiche,Informatiche e
Matematiche
Corso di Laurea in Informatica
Analisi dei problemi di latenza legati all’I/O nei
sistemi Android, e prime soluzioni.
Relatore:
Dott. Paolo Valente
Candidato:
Samuele Zecchini
Anno Accademico 2014/2015
Indice:
RINGRAZIAMENTI ................................................................................................................................................ 3
1. INTRODUZIONE ................................................................................................................................................. 4
2. IL SISTEMA ANDROID ....................................................................................................................................... 5
2.1 LIVELLO APPLICATIVO .................................................................................................................... 5
2.2 ANDROID RUNTIME ........................................................................................................................... 8
2.3 ANDROID KERNEL ............................................................................................................................10
3. BUDGET FAIR QUEUEING (BFQ) ....................................................................................................................12
4. PROBLEMA: LE APP SPERIMENTANO ALTA LATENZA ............................................................................14
5. PICCOLA SUITE PER RIPRODURRE IL PROBLEMA IN MODO SISTEMATICO E CONTROLLABILE
..................................................................................................................................................................................16
5.1 LO SCRIPT ...........................................................................................................................................16
5.1.1 INTRODUZIONE ALLE OPZIONI ....................................................................................16
5.1.2 GESTIONE TRACCE KERNEL .........................................................................................19
5.1.3 BACKGROUND WORKLOAD ...........................................................................................20
5.1.4 APPLICAZIONI OGGETTO DI STUDIO ..........................................................................21
5.2 LPROBE ...............................................................................................................................................23
6. CAUSE DEL PROBLEMA ..................................................................................................................................26
6.1 CARATTERISTICHE DEL DEVICE UTILIZZATO NEGLI ESPERIMENTI .................................27
6.2 PRIMO INSIEME DI CAUSE ................................................................................................28
6.2.1 JOURNALING .....................................................................................................................28
6.2.2 DIRTY PAGE BALANCING E THROTTLING .................................................................31
6.2.3 RACE CONDITION SEMAFORO INODE ........................................................................34
6.3 SOLUZIONI E RISULTATI SPERIMENTALI ...................................................................................37
6.3.1 SOLUZIONI PROPOSTE ....................................................................................................37
6.3.2 RISULTATI SPERIMENTALI .............................................................................................41
6.4 SECONDO INSIEME DI CAUSE ........................................................................................................43
6.4.1 JOURNALING II .................................................................................................................43
7. CONFRONTO TRA SCHEDULER.....................................................................................................................47
8. CONCLUSIONI E FUTURI SVILUPPI..............................................................................................................49
BIBLIOGRAFIA…………………………………………………………………………………..50
2
Ringraziamenti
Ringrazio Sara Arioli per avermi accompagnato e sostenuto in questo lungo e tortuoso cammino.
Ringrazio la mia famiglia per avermi permesso di intraprendere l’università.
Ringrazio Micaela Verucchi per essere stata un’amica fedele oltre che la compagna di tutti progetti
universitari.
Ringrazio tutto il team di Algodev per essere stato come un seconda famiglia. In particolare
ringrazio Paolo Valente e Arianna Avanzini per l’instancabile sostegno e aiuto.
3
1. Introduzione
In questo elaborato mi propongo di analizzare, studiare e proporre una prima soluzione ai problemi
di alte latenze che si manifestano in un dispositivo Android in presenza di I/O in background. La
latenza ai nostri scopi si può definire distinguere in due classi: quella relativa ai task real-time (non
oggetto di studio nell'elaborato) e quella relativa ai task interattivi, in cui possiamo vederla come la
responsiveness di un'applicazione o dell'interno sistema, se riusciamo a garantire una bassa latenza
possiamo garantire anche una alta responsiveness. Questo problema ha raggiunto un elevata
importanza ed è stato trattato in conferenze internazionali come quella svoltasi al FAST 2015
(conferenza internazionali sullo storage) [1].
Tutti attualmente abbiamo uno smartphone, un tablet o altri dispositivi che sono diventati parte
integrante della nostra quotidianetà. Ognuno di noi in base all'utilizzo che ne fa, più o meno intenso,
ha vissuto la situazione in cui il telefono raggiunge un punto di stallo o non risponde più ai comandi
oppure ha avuto la percezione di tempi di attesa per l'avvio di un'applicazione molto lunghi. Tutte
queste situazioni, in cui si nota appunto un alta latenza del sistema, possono essere dovute a un
grande carico di I/O che alza inesorabilmente i tempi di risposta del dispositivo.
Ad oggi Android è il sistema operativo più utilizzato su device quali smartphone, tablet, smart glass,
inoltre tale sistema si base sul kernel linux che, essendo open source, può essere scaricato e
modificato. I codici sorgenti di Android sono liberamente scaricabili e modificabili tramite
AOSP(Android Open Source Project) fornito da Google. Per queste ragioni si è scelto di lavorare su
questo tipo di sistema.
Il lavoro di tesi è stato svolto interamente all'Università degli studi di Modena e Reggio Emilia sotto
la supervisione del dottor Paolo Valente e con la collaborazione di Riccardo Pizzetti.
Nell'elaborato dopo una breve introduzione del sistema Android e dello scheduler che meglio
sembra affrontare i problemi di latenza, illustrerò la lista delle cause di alta latenza che sono riuscito
a mettere in risalto durante il tirocinio, nonché verranno esposte le soluzioni proposte ed esaminati i
problemi ancora aperti.
Infine ci sarà un confronto dei risultati ottenuti con vari scheduler del disco, basati su
un’applicazione ultra semplificata che rispecchia solo parzialmente una situazione reale.
4
2. Il sistema Android
Analizziamo l'architettura del sistema Android [2].
Illustrazione 1: Architettura android
2.1 Livello applicativo
Iniziamo dal primo strato, il livello applicativo.
Un'applicazione (app) consiste in un insieme di componenti tra loro correlati. I componenti di un
applicazione possono comunicare con quelli di altre applicazioni.
I componenti fondamentali di un app sono:

Activities:
esse sono i blocchi principali che costituiscono una app: gestiscono tutto quel che riguarda la
GUI e l'interattività con l'utente. Un activity prende sempre l'intero schermo, non può essere
massimizzata o minimizzata. Un'applicazione può avviare più activity che vengono impilate
in uno stack una sopra l'altra, permettendo all'utente se lo desidera di tornare alla activity
precedente proprio come in un browser con le pagine web.
5
L'activity stack contiene tutte le activity attive correntemente e viene chiamato TASK.
Illustrazione 2: Activities stack

Services:
I servizi sono l'analogo Android dei demoni presenti nel mondo Unix.
Essenzialmente un servizio è attivo se un altro componente gli richiede di svolgere una
particolare azione, esso rimane attivo per la durata richiesta dal chiamante.
Illustrazione 3: Interazione attività-servizi

Broadcast receivers:
Sono simili agli interrupt handlers. Quando occorre un evento, un broadcast receiver è attivo
e lo gestisce per conto dell'applicazione.

Content providers:
Sono essenzialmente database. Una app includerà un content provider se necessita di rendere
i suoi dati accessibili ad altre applicazioni.
6
Un'applicazione non possiede un singolo “entry point”, un main da cui le applicazione partono. Al
suo posto ci sono un insieme di eventi chiamati Intent. Gli intent sono messaggi asincroni che
permettono di legare i componenti individuali insieme a runtime, può essere visto come un
messaggero che richiede agli altri componenti di compiere un'azione. Ad esempio quando
cerchiamo di avviare un'applicazione il sistema genera un intent il quale comunica al componente
activity dell'applicazione che deve attivarsi. Una volta attivata, l'activity chiama il metodo
onCreate() e visualizzando così la GUI dell'app. Gli intent sono uno dei concetti più importanti
nello sviluppo di applicazioni, permettono ai componenti di interagire tra loro, sono definiti come
una descrizione astratta di una operazione che deve essere eseguita. Di notevole importanza è il
fatto che un intent inviato da un componente può essere ricevuto solo da un componente dello
stesso tipo.
L'utente utilizzando i dispositivi Android si aspetta di aprire tutte le applicazioni che desidera e
passare da una all'altra liberamente. Può quindi creare nuovi task oppure voler accedere ad uno
vecchio creato in precedenza. Come conseguenza di questa scelta di design del sistema,
rapidamente le applicazioni consumano sempre più risorse, il sistema inizia a reclamare le risorse
dei componenti usati meno di recente o di quelli senza priorità per far spazio a nuovi task e
componenti. Questo meccanismo di riciclo delle risorse è completamente trasparente all'utente.
Questo comportamento può essere critico su un smartphone per le caratteristiche hardware che,
nella stragrande maggioranza dei casi, sono di basso livello. I dispositivi dispongono di un
quantitativo di memoria principale limitata, i dispositivi di storage hanno anch'essi dimensioni
limitate e con velocità di lettura e scrittura ridotte come le cpu.
Inoltre all'interno del sistema Android è stato definito uno “standard lifecycle” dei componenti. Lo
sviluppatore di app deve preoccuparsi di gestire una serie di callback che saranno attivate quando si
verificherà un evento inerente al lifecycle di un componente. Esempio: quando un’applicazione
passa da foreground a background viene attivata la callback onPause();
7
Illustrazione 4: Ciclo di vita app android
2.2 Android Runtime
Un altro livello molto importante nell'architettura è quello di Runtime, composto principalmente da
due parti:
 Dalvik:
è la macchina virtuale che esegue le applicazioni scritte per Android. I programmi sono
comunemente scritti in java e all'atto della compilazione vengono trasformati in un bytecode
apposito per la dalvik e salvato in un file .dex (Dalvik Executable). Questo ulteriore livello
di trasformazione è stato pensato appositamente per far fronte hai grossi vincoli di memoria
e velocità di tali dispositivi infatti è più efficiente e portabile.
La dalvik è una macchina virtuale register-based, a differenza di quella di java che è stackbased e ciò richiede sicuramente istruzioni più complicate per la prima ma in compenso in
numero inferiore rispetto a una stack-based. Inoltre la dalvik al suo interno possiede un J
IT(Just-in-time compiler) che permette di tradurre parti mirate di codice in codice macchina
specifico per l'architettura in modo da avere uno speed-up in termini di esecuzione. In
particolare android suppporta un TraceJIT che interpreta il codice profilandolo per capire
quali sono i percorsi di esecuzione “caldi” con il vantaggio che l'impatto sulla memoria è
minimo perchè solo i percorsi più frequenti vengono compilati in codice macchina[3]. La
dalvik contiene un garbage collector che si occupa di recuperare memoria, come nelle
comuni macchine virtuali. Dalvik è stata sostituita nelle ultime versione di Android dalla
ART per cercare di far fronte alle grosse limitazioni di tale macchina virtuale tra cui i cicli di
8
garbage collector molto lunghi. In particolare, nella Dalvik era implementato l'algoritmo di
mark and sweep, sostituito nella ART da un più efficiente tri-color marking.
 Zygote:
è un “demone” che viene eseguito in una virtual machine con lo scopo di avviare le
applicazioni. È alla base del sistema android. È uno dei primi processi che vengono creati
allo start-up. All'atto della sua creazione Zygote carica e inizializza tutte le classi che si
suppone siano utilizzate molto spesso dalle applicazioni. Questo consente un notevole
vantaggio in termini prestazionali perchè, dato che tali classi sono “read-only”, il
caricamento in memoria da parte di Zygote permette di sfruttare la COW(copy on write)
garantendo che le stesse pagine di memoria non siano duplicate per tutte le app, con
conseguente notevole risparmio in termini di risorse [4].
All'atto dell'apertura di un'applicazione l'activity manager comunica al demone tramite
un'opportuna chiamata di sistema (startViaZygote()) che deve essere avviata una nuova app.
Zygote si prende l'incarico e tramite una chiamata alla syscall fork() si duplica e crea la
nuova applicazione.
Illustrazione 5: Funzionamento zygote
Effettuando la chiamata alla funziona fork() (per la precisione forkAndSpecialize()) Zygote
crea una nuova macchina virtuale che viene inizializzata attraverso la chiamata
dvmInitAfterZygote() per diventare la Dalvik virtual machine sulla quale l'applicazione
verrà eseguita [5].
9
2.3 Android Kernel
L'ultimo livello è invece quello inerente al kernel linux. Si nota lavorando con android un kernel
“Android-like” abbastanza diverso rispetto a “vanilla” scaricabile da http://kernel.org.
Al tal proposito elenchiamo le modifiche principali che sono state fatte ai kernel classici per poter
funzionare meglio sui device Android.
Wakelocks:
Per capire l'utilità dei wakelock occorre prima discutere come la gestione della batteria è utilizzata
all'interno dei sistemi Linux. Lo “use case” più comune all'interno di un sistema desktop Linux è
quello dei laptop in cui quando lo schermo è chiuso il sistema va in stato di “sleep” o
“sospensione”. In questa modalità lo stato del sistema è preservato interamente in RAM e gli altri
componenti sono spenti. Così facendo il pc usa meno batteria possibile. Quando l'utente alza lo
schermo, il laptop “si sveglia” e il pc ritorna rapidamente riutilizzabile. Questo modus operandi non
è però funzionale per un device Android. È stato quindi scelto dal team di sviluppo Android di
cambiare leggermente le regole per gestire meglio gli use case dei device. Vista la scarsa durata
della batteria all'interno di questi dispositivi è stato deciso di mettere in stato di “sleep” il sistema il
più spesso possibile, e di mantenerlo sveglio solo per operazioni importanti, come ad esempio
l'attesa dell'input di un utente. I wakelocks consentono appunto un meccanismo per mantenere
“sveglio” il sistema.
A livello applicativo questo non è necessario in quanto l'astrazione fornita dalle API Android
automaticamente si occupa di prendere questi lock. A più basso livello, come può essere lo sviluppo
di driver per il dispositivo, lo sviluppatore deve preoccuparsi di prendere esplicitamente tali lock
prima di effettuare un'operazione potenzialmente critica [2]. I wakelocks sono un argomento molto
discusso all'interno della comunità Linux e la loro inclusione in mainline ha richiesto quasi
2000 email e ancora oggi rimangono solo parzialmente adottati [6].
Low-Memory Killer:
Visto che alla base del sistema c'è una situazione costante di memoria libera limitata, il
comportamento durante un out-of-memory è di vitale importanza. Per questa ragione il team di
Android ha deciso di aggiungere, oltre l'OOM killer standard di linux anche un low-memory killer
che permette di uccidere i processi per liberare risorse.
A differenza dell'OOM killer, il low-memory killer non attende che il sistema non abbia più
memoria, ma superata una certa soglia inizia ad uccidere processi seguendo delle specifiche
euristiche differenti da quelli dell'OOM killer [2].
10
Binder:
È un meccanismo di RPC(remote procedure call)/IPC(inter process comunication).
Questo strumento cerca di creare una capacità di invocazione di oggetti remoti su un sistema
operativo classico. Con i termini “invocazione di oggetti” intendiamo un tentativo degli sviluppatori
Android di creare dei servizi del sistema operativo object-oriented, vista la nuova struttura dei
servizi si è reso necessario creare un nuovo sistema di IPC in grado di comunicare e ragionare ad
oggetti [7]. Un processo in Android può invocare una routine di un altro processo Android
utilizzando il binder per identificare il metodo invocato e passare gli argomenti tra processi.
Tutto ciò rende molto facile per lo sviluppatore estendere le funzionalità del sistema aggiungendo
l'invocazione di oggetti remoti invece di implementare un nuovo demone che svolga tale servizio.
Ad esempio supponiamo di creare un'applicazione alla quale serve sfruttare la fotocamera, può non
essere necessario re-implementare tale funzionalità in quanto se c'è un servizio che già la
implementa si può sfruttare la sua mettendosi in comunicazione attraverso il binder con tale
servizio.
Per invocare un oggetto remoto basta avere la definizione della sua interfaccia e un suo riferimento
[2].
Illustrazione 6: Binder
11
3. Budget Fair Queueing (BFQ)
BFQ è un algoritmo di scheduling dei dispositivi di storage che punta a raggiungere un alta
responsiveness, ed in generale basse latenze a livello applicativo e di sistema, anche sotto un
intenso workload, garantendo allo stesso tempo un throughtput elevato. I risultati delle euristiche
permettono di garantire, anche in presenza di un alto workload di input/output in background, un
tempo di start-up delle applicazioni interattive basso come se il dispositivo fosse in stato di idle[8].
All'interno di tale algoritmo c'è una coda di richieste per applicazione, le richieste sono inserite
invocando l'interfaccia add_request(). L'accesso allo storage è garantito ad una sola applicazione
alla volta, chiamata applicazione in servizio. Ad ogni applicazione è associato un budget che misura
il numero di settori del disco su cui può operare. Quando un’applicazione diventa attiva è servita
fino a quando non esaurisce il suo budget o finché non smette di fare richieste. BFQ sceglie la
prossima applicazione attiva tramite il fair-queueing scheduler interno chiamato B-WF2Q. Una
richiesta è spedita se il dispositivo invoca l'interfaccia dispatch().
Illustrazione 7: Schema logico BFQ
All'interno di BFQ ad ogni applicazione è inoltre associato un peso (weight) che determina la
frazione di throughput garantita all'applicazione: all'applicazione è garantita una frazione di
throughput pari al rapporto tra il suo peso ed il peso delle altre applicazioni che competono per il
dispostivo.
12
Tale scheduler contiene una serie di euristiche che ne migliorano le performance e permettono di
raggiungere obiettivi di notevole importanza. Tra le euristiche troviamo la “low latency heuristic”
dedicata a garantire una bassa latenza ai processi interattivi.
Quando un'applicazione viene creata il peso è moltiplicato per un coefficiente di weight-raising.
Questo garantisce al processo una grande frazione di througthput iniziale permettendo di ottenere
un tempo di start-up basso in quanto vengono rapidamente lette le parti di eseguibile e le librerie
necessarie per avviare l'applicativo. L'euristica lascia che il peso sia costantemente uguale a
orig_weight*Crais per l'intera durata del weight-raising period. Tale periodo è calcolato
automaticamente in base al tipo e alla velocità del dispositivo e in relazione al tempo di caricamento
di una grande applicazione su quel dispositivo, con le cache “fredde” e senza workload in
background. Due concetti fondamentali sono: le richieste sincrone, una richieste di I/O se il
processo che ha effettuato la richiesta deve aspettare il suo completamento per effettuare la
successiva, e device idling cioè il momento in cui il dispositivo di storage non riceve richieste di
I/O. BFQ sfrutta appunto un device-idling timeout (un periodo di tempo in cui si lascia lo storage in
idle e si aspetta) elevato per una coda in weight raising in modo da ridurre la probabilità che
un'applicazione sia tolta dal servizio in caso effettui richieste sincrone e le richieste successive non
arrivino in tempo. Ridurre questa probabilità è fondamentale per essere sicuri che l'applicazione
riceva la frazione di throughput desiderata.
13
4. Problema: le app sperimentano alta
latenza
Il problema della latenza del sistema in questi dispositivi si vive con grande frequenza. Essi sono
molto limitati da un punto di vista hardware. Dispongono infatti di processori con ridotta velocità,
di poca memoria, di capacità di storage limitate e di una durata della batteria decisamente
contenuta. Tutte queste caratteristiche hardware combinate con l'efficacia del software incidono
notevolmente sul sistema.
Supponiamo di voler ricreare un'esperienza di alta latenza di una app o dell'intero sistema, così
come la sperimenterebbe un normale utente. Iniziamo aprendo alcune applicazioni (tra cui giochi
oppure utility di sistema) sul nostro dispositivo senza necessariamente chiuderle, questo genera un
buon carico di lavoro e magari si può avvertire qualche rallentamento nell'operazione di “switch”
tra un'applicazione ed un altra, ma niente di significativo e allarmante. Aggiungiamo ora del carico
al nostro esperimento. Supponiamo che il carico sia rappresentato da una serie di download partiti
in automatico dal play store. Succede ad esempio quando sono disponibili degli aggiornamenti o
quando decidiamo di installare una nuova applicazione. Tale operazione genera un notevole lavoro
per quanto riguarda il dispositivo di storage. Più o meno incuranti della presenza di questi download
iniziamo a riaprire applicazioni una dopo l'altra e già verso la seconda/terza applicazione si inizia a
percepire un ritardo considerevole nel caricamento. Allo stesso modo anche il comando per
spostarci tra una applicazione ed un'altra e il pulsante “home” iniziano a non rispondere. Appena
terminato il download le applicazioni lentamente riprendono a funzionare correttamente, i tempi di
start-up tornano contenuti e il sistema sembra rispondere di nuovo egregiamente.
Esasperiamo ulteriormente il problema. Ad oggi 2 milioni circa di persone (dati presi dal play store
android) hanno scaricato l'applicazione per scaricare file torrent sul proprio smartphone o tablet. Ai
nostri scopi dimostrativi tale applicazione è perfetta. Il download di un torrent genera un carico di
lavoro sul sistema costante e duraturo nel tempo, producendo un consumo di memoria principale
non eccessivo ma generando continue write per scrivere i dati ricevuti sul disco. Supponendo che il
carico di lavoro fornito da tale applicazione sia il nostro background, il tentativo di aprire svariate
applicazioni fallisce rapidamente. Il sistema già verso la seconda/terza app collassa. I tempi di startup si dilatano in maniera esponenziale. Il carico così generato può essere talmente eccessivo da
portare, non solo al crash della applicazione che stiamo aprendo, ma anche un una uccisione totale
della UI del sistema da parte del low memory killer nel tentativo disperato di liberare risorse.
14
Come si può vedere da questi piccoli esperimenti, da noi svolti ad inizio lavoro, è molto semplice
generare un carico di I/O tale da rendere la latenza insopportabile per un normale utente, il quale già
dopo qualche secondo in più di attesa la percepisce sgradevole e fastidiosa.
È chiaro come non tutti i problemi della latenza del sistema siano legati all'I/O, a volte anche
semplicemente cicli di garbage collection o persino l'azione del low memory killer possono portare
ad una diminuzione della responsiveness sia della singola app che dell'intero sistema Android.
Il nostro obbiettivo è quindi quello di migliorare la latenza del sistema in presenza di forte I/O in
background cercando di sfruttare le proprietà fornite da BFQ nel migliorare la responsiveness delle
applicazioni interattive.
Per aiutarci e semplificare lo studio di ciò che avviene internamente al sistema nel momento in cui
si genera un carico di lavoro abbiamo deciso di creare una piccola suite descritta in maniera
approfondita nel prossimo capitolo.
15
5. Piccola suite per riprodurre il
problema in modo sistematico e
controllabile
Tale suite, composta da una applicazione e uno script bash, è stata creata per cercare di riprodurre in
maniera sistematica il problema della alta latenza all'avvio di un'applicazione. È stato scelto di
creare un'applicazione ad-hoc in quanto con le applicazioni attualmente disponibili era difficile
riuscire a gestire, ma soprattutto calcolare i tempi di start-up e capire fino in fondo quali operazioni
venissero svolte all'avvio.
5.1 Lo script
Lo script è il programma principale della suite che permette di decidere quale applicazione far
partire tra le 2 usate per i nostri esperimenti. Permette di settare il carico in background cioè il
numero di processi che effettuano delle write e il numero di processi che effettuano delle read,
permette di cambiare scheduler dello storage ed altre funzionalità illustrate successivamente.
Esso è composto a sua volta da altri script bash che permettono di eseguire specifiche funzionalità.
È stato scelto questo approccio nel tentativo di seguire una modularizzazione del codice che potesse
portare a una più semplice gestione delle funzionalità e degli eventuali errori.
Nel parte iniziale dello script vengono inizializzate alcune variabili tra cui: lo scheduler dello
storage di default (BFQ), un tunable di bfq (per la precisione la device-idle), il numero di processi
reader e writer in background fissato inizialmente a 1, ecc.
5.1.1 Introduzione alle opzioni
Una delle prime function significative è quella che ci mostra un messaggio di errore nell'avvio dello
script in caso il comando con cui si avviata contenesse un errore.
function help {
echo "USAGE: sh StarDemoClean [...]"
echo "[-c] -> Demo doesn't soil the ram"
echo "[-l] -> Demo takes the log of thread in execution"
echo "[-sched][bfq/cfq/noop/deadline] -> Set the scheduler"
16
echo "[-sched][bfq][number] -> Set the value of bfq's slice idle"i
echo "[-t] -> Set tracer"
echo "[-tl] -> Available tracer"
echo "[-r [NumRead]] -> Stat 'NumRead' read (default 1)"
echo "[-w [NumWrite]] -> Start 'NumWrite' write (default 1)"
echo "[-noprio] -> Doesn't set IO prio io candycrushsaga"
echo "[-NOC] -> Start another app"
echo "[-start [num]] -> set time elapsed before start of traces"
echo "[-stop [num]] -> set the duration of the traces"
return 0
}
Tale funzione da subito una visione di quali sono le possibilità dello script. Esso viene lanciato da
una bash con una o più di queste opzioni, permettendoci di modificare liberamente la maggior parte
dei parametri necessari durante gli esperimenti. Il comando -c fa partire lo script in modalità
“clean”. L'esistenza di questa opzione presuppone una spiegazione preliminare. L'idea iniziale era
quella di misurare lo start-up di una applicazione in una casistica reale. Si avviava in primis
l'applicazione oggetto di studio, successivamente si aprivano una serie di applicazioni che
riempissero la memoria e attivassero il low memory killer il quale uccideva il processo oggetto di
studio (al tempo candy crush saga). Il processo veniva poi riaperto e oltre al carico di memoria
notevole gli veniva contrapposto un workload in background fornito dai processi writer e reader,
così facendo si sperimentava la latenza nello start-up in un caso sfortunato ma realistico. A lungo
andare tale scelta è stata parzialmente abbandonata per la difficoltà nel capire cosa avvenisse
all'interno del sistema durante quelle operazioni. L’opzione serve quindi per saltare questa parte che
è rimasta però all'interno dello script in caso sviluppi futuri permettano di studiare il
comportamento. L’opzione -l prende i log del sistema; Android ha un suo sistema specifico di log
di alcuni eventi generati dalle app e dal sistema nel suo complesso. Il meccanismo di log è molto
sofisticato e permette di debuggare il sistema o una applicazione [9]. È implementato tramite un
buffer circolare e può essere visualizzato o filtrato tramite il comando logcat. Questo comando
permette di scegliere tra diversi buffer di output:
 radio: visualizza il buffer contenente i messaggi relativi alle funzioni radio/telefono;
 events: visualizza il buffer contenente i messaggi legati agli eventi del sistema;
 system: visualizza il buffer relativo ai messaggi di sistema;
 main: visualizza il log buffer principale (è quello di default di logcat ed è anche quello da
noi utilizzato);
17
tutti questi buffer sono salvati nella directory /dev/log/. [2]
Illustrazione 8: Android log framework
Le opzioni t e -tl permettono rispettivamente di scegliere quale tracer usare a livello kernel e di
visualizzare l'elenco dei tracer disponibili. Il kernel linux supporta un sistema di tracing noto con il
nome di Ftrace (Function tracer). Ftrace è un sottosistema del kernel linux, creato per aiutare gli
sviluppatori a capire cosa stesse succedendo nel sistema, attraverso output esaustivi e mirati. Ftrace
è un framework molto più ampio di quello che il nome fa pensare, utilizzandolo non solo si possono
tracciare le funzioni invocate nel kernel ma anche tracciare quello che avviene nel block layer
fornendo informazioni su chi stia facendo richieste di I/O, quali funzioni dello scheduler sono
invocate, se la richiesta è sincrona o asincrona ecc.. L'output fornito da Ftrace, e tutte le sue
funzionalità personalizzabili si trovano all'interno della directory /sys/kernel/debug/. I tracer
disponibili variano in base alle impostazioni con cui è stato compilato il kernel custom, ma in
generale sono quelli disponibili su qualunque versione del kernel linux [10].
L’opzione -NOC (no open candy) permette di aprire applicazioni diverse da candy crush saga. Tale
applicazione è stata utilizzata come riferimento nei primi esperimenti e si era pensato di studiarne il
comportamento. Viste però le complicazioni che sorgono nel capire tutte le operazioni svolte nella
fase di start-up è stato scelto di spostarsi su altre applicazioni ma mantenere tale possibilità nello
script per eventuali sviluppi futuri. Le ultime due opzioni sono -start e -stop seguite da un numero
che rappresenta i secondi da aspettare nel primo caso o la durata delle tracce nel secondo. Tali
opzioni sono molto utili per decidere quando iniziare e quando smettere di prendere le tracce. Le
18
tracce sono implementate tramite buffer circolari, ciò spesso porta le operazioni svolte nell'arco di
tempo dell'intero script ad essere troppe e il buffer parzialmente sovrascritto portando a perdite di
informazioni significative. Cosi facendo, attraverso il numero posto a fianco le opzioni, scegliamo
un lasso di tempo preciso in cui prendere le tracce in modo che il buffer circolare non saturi nella
speranza di trovare più informazioni utili.
5.1.2 Gestione tracce kernel
Le tracce a livello kernel sono inizializzate e gestite da due funzioni specifiche, la prima
function init_tracing {
if [ "$TRACE" == "1" ] ; then
if [ ! -d /sys/kernel/debug/tracing ] ; then
mount -t debugfs none /sys/kernel/debug
fi
echo nop > /sys/kernel/debug/tracing/current_tracer
echo 50000 > /sys/kernel/debug/tracing/buffer_size_kb
echo buffer_size: $(cat /sys/kernel/debug/tracing/buffer_size_kb)
if [[ "$tracer" == "blk" ]]
then
echo "${SCHED}*" "__${SCHED}*" >\
/sys/kernel/debug/tracing/set_ftrace_filter
else
echo .
fi
echo $tracer > /sys/kernel/debug/tracing/current_tracer
echo -n "Selected tracer:"
cat /sys/kernel/debug/tracing/current_tracer
fi
}
Se la variabile TRACE è settata ad 1 viene montata la partizione debugfs (in caso non sia già
montata). In tale partizione è presente l'ambiente delle tracce. Lo script inizializza il tracer di default
a nop (no operation) e decide la grandezza del buffer. La grandezza del buffer delle tracce può
essere prefissata dall'utente. Se il tracer è blk e quindi riguarda il block layer aggiunge dei filtri alle
19
tracce, di modo che vengano selezionate solo le operazioni svolte da BFQ. L'ultima operazione è
quella di reale settaggio del tracer scelto all'avvio dello script.
La seconda funzione, che attiva e disattiva le tracce, è molto semplice e quindi non viene riportata.
Essenzialmente se invocata passandogli valore 1 attiva le tracce attraverso il comando:
echo $1 > /sys/block/$HD/trace/enable
Se passata con valore 0 le tracce vengono disattivate.
5.1.3 Background workload
Le ultime due function importanti di tale script sono quelle che si occupano di gestire il carico in
background. Il workload nello script è rappresentato da dei dd che effettuano read e write. L'utilizzo
dei dd simula in maniera semplice l'I/O che può essere generato durante un download o durante l’
installazione di un'applicazione.
#Reader creator
function dd_read {
while [[ "$(cat EndFile)" == "false" ]]
do
echo dd if=/data/tmp/BIGFILE_READ$1 of=/dev/null bs=1048576 count=512
dd if=/data/tmp/BIGFILE_READ$1 of=/dev/null bs=1048576 count=512 &
echo BIGFILE_READ$1 $!
wait
done
}
#Writers creator
function dd_write {
while [[ "$(cat EndFile)" == "false" ]]
do
dd if=/dev/zero of=/data/tmp/BIGFILE_WRITE$1 bs=1048576 count=512 &
echo BIGFILE_WRITE$1 $!
wait
done
}
20
Queste due function fanno, da un punto di vista logico, la stessa cosa. L'unica differenza è che la
prima crea dei dd in lettura mentre la seconda dei dd in scrittura. Descriveremo quindi solo la
prima. La funzione rimane in un ciclo while fino a quando non legge da un file di controllo un
determinato valore che rappresenta la terminazione della demo (questa soluzione è molto semplice e
permette di evitare l'invio di un segnale allo script. I segnali sono comunque utilizzati all'interno
dello script in quanto è gestito un eventuale CTRL+C che chiude in maniera pulita l'esecuzione e fa
terminare i processi reader e writer; questa soluzione è utilizzata di norma solo se lo script si
blocca). All'interno del ciclo crea un dd in lettura/scrittura. Se un dd termina prima della fine dello
script ne viene creato un altro al suo posto di modo che l'I/O continui fino al termine dello script. È
stato scelto di utilizzare questo sistema per controllare il più possibile anche il carico di lavoro
presente in background. Cosi facendo i comportamenti riscontrati sono oltremodo significativi e ci
danno una stima reale di quanto possa essere elevata la latenza con un carico di I/O inteso e
costante. Non utilizzando questo meccanismo l'applicazione, appena terminato il carico di I/O,
viene caricata all'instante generando quindi un valore ingannevole per i tempi di start-up misurati.
Un altro punto dello script che necessita di spiegazioni è un semplice comando:
echo 3 > /proc/sys/vm/drop_caches
Con questo comando andiamo a liberare la pagecache, la dentries e inodes cache, liberando così un
notevole quantitativo di memoria. Questo comando ha due principali funzionalità per i nostri scopi:
la prima ci permette di partire da un situazione omogenea e “standardizzata” ogni volta che
ripetiamo lo script; il secondo è quello di garantirci che tutte le operazioni siano sempre fatte
leggendo informazioni dal dispositivo di storage. Se questo comando non ci fosse potremmo trovare
in memoria parti del programma che stiamo avviando, ciò falserebbe non poco i risultati in quanto
la lettura da memoria principale è estremamente più rapida rispetto a quella dal disco e ciò
abbasserebbe inesorabilmente i tempi di start-up anche in presenza di I/O.
5.1.4 Applicazioni oggetto di studio
Gli ultimi statement analizzati riguardano il caricamento delle applicazioni scelte. Come già detto
sopra con il comando -NOC (No Open Candy) si può scegliere se aprire l'applicazione Lprobe,
costruita per semplificare lo studio delle latenze, o l'applicazione candy crush saga (il gioco scelto
per cercare di studiare le latenze in una caso reale, più vicino all'esperienza dell'utente).
21
if [[ $NoOpenCandy == "false" ]]; then
sh Open_app2.sh 1 d 1000 $set_prio 54000 &
cc=$!
echo cc $cc
else
LProbe
fi
Lo script Open_app2.sh è un modulo dello script che contiene un’euristica che cerca di determinare
quando realmente un'applicazione ha finito di effettuare lo start-up. Questa euristica diventa
necessaria perchè alcune applicazioni vengono visualizzate ma poi effettuano una fase di “loading”
in cui vengono caricate e lette dallo storage le informazioni necessarie. Tale fase di caricamento
deve essere considerata anch'essa nei tempi di start-up perchè, finchè tale fase non è terminata, l'app
risulta inutilizzabile dall'utente anche se già visualizzata sul monitor. L'euristica funziona misurando
quanta memoria il programma ha già occupato. La memoria occupata è misura sfruttando il
comando dumpsys e filtrando il suo output [11]. Viene ora riportata la parte principale dell'euristica
presente nel file Open_app2.sh.
time while [[ $diff -gt $max_diff_mem ]]
do mem_used=$(dumpsys meminfo | grep com.king.candycrushsaga| head -c 9 | tail -c 5)
if [[ $mem_used>$5 ]]
then
diff=$(($mem_used - $previous_mem_used))
if [[ $diff<0 ]]
then
diff=$(($diff*-1))
fi
fi
echo $mem_used $previous_mem_used $diff
previous_mem_used=$mem_used
done
L'euristica misura attraverso il comando time la durata del ciclo while.
Il comando dumpsys è il fulcro dell'euristica [11] e permette di stabilire quanta memoria il processo
interessato, in questo caso candy crush saga, ha già occupato; questo è il primo passo e il valore è
22
salvato nella variabile “mem_used”. Successivamente si sfrutta un valore passato in ingresso al
modulo Open_app2.sh. Tale valore è stato deciso in maniera sperimentale attorno ai 54 megabyte,
quando l'applicazione si è quasi completamente caricata. Se la memoria usata supera questa soglia
allora si calcola la differenza tra la memoria usata(mem_used) e la memoria utilizzata
precedentemente(previous_mem_used vari abile il cui valore è inizializzato a 0 prima del ciclo
while) e la si assegna alla variabile “diff”; se la differenza è negativa si cambia il segno
moltiplicandola per -1. Successivamente si setta “previous_mem_used” al valore di “mem_used” e
si controlla la condizione di terminazione. Il ciclo termina se la diff è maggiore della massima
differenza consentita (max_diff_mem) e tale valore viene passato in ingresso al modulo che è stato
settato a 1 megabyte. Il metodo si è rivelato molto efficace e nella maggior parte dei casi il tempo
calcolato è identico all'effettivo tempo di start-up dell'applicazione.
Il ramo else invece invoca la funzione LProbe che a sua volta invoca lo script Lprob.sh che avvia
l'applicazione ad-hoc Lprobe.
Entrambi gli script Lprobe.sh e Open_app2.sh possono essere utilizzati come programmi a se stanti
e oltre alla funzionalità descritte svolgono entrambi un altro compito importantissimo agli scopi
della ricerca settando l'ioprio del processo che stiamo avviando.
L'ioprio è modificato tramite il comando “ionice” che permette di settare la classe di scheduling
dell'I/O e la priorità di uno specifico programma [12]. Le classi di scheduling possibili sono tre:
 idle: un programma con ioprio idle viene servito solo se nessun altro programma fa I/O per
un determinato periodo di tempo;
 best effort: questa è la classe di default di tutti i processi. Ha una priorità che va da 0 a 7 con
i numeri più piccoli che indicano una priorità più elevata;
 real time: un processo in classe RT viene servito prima rispetto agli altri presenti nel sistema.
Come la classe best effort prevede 8 livelli di priorità la cui importanza va in ordine inverso
rispetto alla grandezza numerica (0 priorità più alta -7 priorità più bassa).
L'ioprio è di fondamentale importanza ai nostri scopi in quanto BFQ è stato modificato in modo da
garantire un weight raising persistente a quei processi che hanno priorità 0, questa modifica è stata
fatta ad-hoc per gli scopi della tesi. Così facendo è possibile garantire una frazione di throughtput
molto elevata alle applicazioni oggetto di studio le quali si trovano notevolmente avvantaggiate
rispetto a concorrenti in background. Inoltre il weight raising persistente è sfruttato anche nelle
soluzioni ai problemi descritti nel capitolo 6.
5.2 Lprobe
Lprobe (latency probe) è un'applicazione Android scritta per mettere in maggior evidenza e studiare
23
meglio i problemi di latenza dovuti alla presenza di intenso I/O. La scelta di creare un'applicazione
“ad-hoc” è scaturita dalle notevoli difficoltà riscontrate nel cercare di capire cosa avvenisse
all'avvio di candy crush saga. Non potendo disporre del codice non eravamo sicuri di quali fossero
le operazioni svolte, disponendo solo delle tracce fornite da Ftrace e da eventuali messaggi ricavati
dal logcat di Android. Era quasi impossibile riuscire a districare la grande matassa che tale
applicazione generava interagendo con molti servizi di sistema, facendo richieste di I/O ad un
database SQLite e creando diversi thread per svolgere operazioni in parallelo. Pertanto per avere la
certezza di quello che l'applicazione facesse e riuscire così a carpire cosa avvenisse a basso livello
con chiarezza è stata creata l'applicazione Lprobe.
L'applicazione è molto semplice: all'atto della chiamata della funzione onCreate(), invocata per
effettuare lo start-up, apre un file in lettura (BIGFILE_READ1) e uno in scrittura (FILE_WRITE).
Successivamente crea una matrice di byte in cui ogni riga rappresenta una linea letta dal file, e
effettua un ciclo che va da 0 a dim*4 e riempie la matrice con il valore di ritorno della funzione
Lantecy_probe.
int dim = 13200;
byte[][] lines = new byte[dim][];
for(int j = 0;j<dim*4;j++) {
lines[j%dim]=Latency_probe(input_file,j,outputStream,txt);
}
Tale funzione è il cuore dell'applicazione. Gli sono passati in ingresso come parametri il
FileInputStream necessario per leggere dal file, il FileOutputStream necessario per scrivere sul file
e una TextView.
long t1,t2;
t1=System.currentTimeMillis();
input_file.read(line);
t2=System.currentTimeMillis();
if ((t2-t1)>300) {
txt.append("\nRead Latency " + (t2 - t1));
Log.d("Read Latency", "" + (t2 - t1));
}
if(j % 10 == 0) {
t1 = System.currentTimeMillis();
outputStream.write(line);
24
t2=System.currentTimeMillis();
if ((t2-t1)>300) {
txt.append("\nWrite Latency " + (t2 - t1));
Log.d("Write Latency", "" + (t2 - t1));
}
}
La textview passata alla funzione serve per visualizzare la durata delle singole write e delle singole
read che superano i 300 millisecondi di durata. È stata scelta questa soglia in quanto dopo 300
millisecondi il ritardo inizia ad essere importante ed è facile accorgersene anche visivamente (oltre
questa soglia anche il normale utente percepisce la latenza all'avvio dell'applicazione). La funzione
prepara la linea da leggere allocando un vettore di byte di grandezza 4096 (scelto della grandezza di
una pagina di memoria). Successivamente si va a leggere una linea dall'inputStream e la si inserisce
nel vettore. Effettuata l'operazione di read si valuta se è, o meno, necessario effettuare anche una
write. Una write è effettuata ogni 10 read per non aumentare troppo il carico dell'applicazione stessa
nella fase di avvio. La funzione, dopo aver gestito tutti i casi di errore all'interno di costrutti try
catch, ritorna la linea letta (e scritta se necessario) dal file. All'interno del programma sono inseriti
altri due insiemi di statement che svolgono un ruolo fondamentale. Il primo è:
try {
Thread.sleep(5000);
//1000 milliseconds is one second.
} catch(InterruptedException ex) {
Thread.currentThread().interrupt();
}
Questo sleep è svolto prima di qualsiasi altra cosa. È stato introdotto perchè, utilizzando ionice per
privilegiare l'applicazione all'interno dello scheduler, c'era un problema di sincronizzazione
(approfondito successivamente).
Il secondo insieme di statament invece è una banale scrittura di alcune righe della matrice, creata in
precedenza, sui file di log del sistema. Questa operazione è svolta al fine di evitare che il garbage
collector consideri le nostre linee non utilizzate e quindi le de-allochi alla fine di ogni ciclo for. Così
facendo si alloca molta memoria simulando maggiormente il caricamento di un'applicazione reale.
25
6. Cause del problema
Le cause del problema possono essere notevoli e variegate, oggetto di analisi saranno solo i
principali problemi che siamo riusciti a rilevare a livello kernel durante il tirocinio.
Per studiare tali problemi abbiamo prima di tutto preso e studiato delle traccie a livello di block
layer del programma di esempio Lprobe. Tali tracce diedero però risultati non indicativi a proposito
delle cause scatenanti l'alta latenza, anzi esse inserivano ancora più incognite in quanto si vedeva
uno strano comportamento del processo. Il processo in questione iniziava a fare richieste di I/O e
BFQ lo metteva in servizio prontamente. Si vedeva però, nella finestra di durata dell'applicativo,
che all'interno delle tracce c’erano dei macro “buchi” di 3/4 secondi, che si ripetevano anche più
volte nelle tracce, in cui il processo smetteva di fare richieste di I/O. In tali intervalli di tempo il
dispositivo di storage veniva monopolizzato prima dal DD in lettura poi dal flusher che si occupa di
effettuare le write asincrone per conto dei processi. Di conseguenza la responsiveness del nostro
programma subiva un grosso degrado.
Viene riportato un piccolo trafiletto di traccia del blk in cui si nota il “buco” appena descritto.
<idle>-0
[002]
92.340216: 179,0
m N bfq2151 put_queue: d58a8580 2
LProbe-2151 [001] 95.975945: 179,0
m N bfq2151 add_request 1 1
Si vede subito come i timestamp (terza colonna) siano molto distanti tra loro e la differenza è
davvero notevole in tale esempio (ben 3 secondi). La prima riga riportata rappresenta il momento in
cui BFQ de-attiva la coda di quel processo in quanto non ha più richieste. La successiva invece
mostra il momento in cui BFQ riceve la prossima richiesta da parte del processo studiato. Il tempo
che intercorre è elevatissimo se si pensa alla frequenza con la quale la nostra applicazione fa
operazioni di read e operazioni di write, per la precisione effettua una write ogni dieci read.
I risultati forniti da queste tracce non forniscono ulteriori informazioni utili. Nella sostanza
mostrano solamente che il problema non risiede a livello di block layer o scheduler ma bensì risiede
ad un livello più alto nelle operazioni svolte all'interno del kernel durante una read o una write.
Allo scopo di capire in quale parte del kernel il processo si bloccasse per così tanto tempo, sono
state utilizzate le tracce fornite dal function tracer. Questo tracer è particolarmente espressivo e
fornisce moltissime informazioni. Essenzialmente permette di vedere le varie chiamate a funzioni
effettuate da un processo all'interno del kernel linux [10].
Anche sfruttando queste tracce si nota come siano presenti dei “buchi” analoghi a quelli nel block
layer in cui il nostro processo non effettua alcuna operazione. Questa volta grazie al “function
26
tracer” di Ftrace è stato possibile capire, almeno parzialmente, quale percorso di codice portasse il
processo ad addormentarsi. Infatti partendo dal momento in cui il processo si addormentava è stato
relativamente semplice capire che l'ultima funzione invocata fosse la schedule; è però stato più
complesso andare oltre tale funzione in quanto molte funzioni inline non vengono tracciate dal
tracer rendendo molto complesso seguire i percorsi e capirli alla perfezioni (il buffer di Ftrace è
circolare e abbastanza limitato quindi informazioni importanti molto spesso andavano perdute).
Per capire in maniera definita dove fosse il problema si è sfruttato l'output di Lprobe che stampa la
durata delle write e delle read superiori ai 300ms. Si è così immediatamente visto come il problema
fossero le write, fatte dal processo, che richiedono moltissimo tempo. Inoltre per trovare i percorsi
causa della latenza, all’interno delle operazioni di write, sono state strumentate ad una ad una le
funzioni del kernel facendo stampare al dmesg le funzioni la cui durata superava i 300ms. Ad
esempio:
unsigned long time_t1 = jiffies;
status = a_ops->write_begin(file, mapping, pos, bytes, flags,&page, &fsdata);
if(jiffies - time_t1 >= msecs_to_jiffies(300))
printk(KERN_DEBUG
"write_begin
%u
PID
%u
ioprio
%u
wrais
%u",jiffies_to_msecs(jiffies-time_t1),
current->pid,current->io_context ? current->io_context->ioprio:-1,wrais);
Come si può vedere codice riportato tale meccanismo è stato sfruttato anche per fare debugging e
tracing più approfondito del kernel, facendo stampare altre informazioni utili per la ricerca. Questo
meccanismo genera un stack di chiamate a funzioni molto dettagliato che consente di vedere
l’intero percorso che genera latenza all’interno del kernel.
Confrontando tali risultati con quelli forniti anche dal function tracer sono state individuate le prime
funzioni responsabili del problema.
Tali
funzioni
erano
legate
sia
al
journaling
del
filesystem
sia
a
funzioni
come
“balance_dirty_pages” e “vm_throttle_writeout” che riguardano la gestione della memoria virtuale.
6.1 Caratteristiche del device utilizzato negli
esperimenti
Il device utilizzato per gli esperimenti è un Nexus 7 versione del 2012 con le seguenti caratteristiche
hardware [27]:
27
 Android KitKat 4.4.4
 Kernel versione 3.1.0
 32 GB di storage
 1 GB di RAM
 Processore quad-core NVIDIA tegra 3.
6.2 Primo insieme di cause
Nel primo insieme di cause vengono illustrati nello specifico i problemi riscontrati a livello kernel,
approfondendo quelli già citati sopra e descrivendo gli ulteriori problemi risolti.
6.2.1 Journaling
Sfruttando l'output fornito dal nostro programma, che traccia la durata delle singole write e delle
singole read, si nota subito come il problema della latenza risieda nella elevata durata delle write.
Modificando il codice del kernel e tracciando le chiamate a funzione che hanno latenza più elevata
abbiamo trovato il seguente percorso critico di invocazioni:
1)sys_write()
2)vfs_write()
3)file->f_op->write
→
new_sync_write()
4)filp->f_op->write_iter
→
ext4_file_write_iter()
5)__generic_file_write_iter()
6)file_update_time()
7)update_time()
9)__mark_inode_dirty()
→
10)sb->s_op->dirty_inode
11)ext4_journal_start()
→
ext4_dirty_inode()
__ext4_journal_start_sb()
12)jbd2__journal_start()
13)start_this_handle()
14)add_transaction_credits()
15)wait_transaction_locked()
Tale percorso riguarda l'aggiornamento del tempo di modifica del file, che coinvolge il journal in
quanto in tal modo vengono modificati i metadati del file.
28
Il dispositivo Android fornito per gli esperimenti è basato sul filesystem ext4 il quale, come l'ext3,
prevede l'utilizzo del journal per proteggere il filesystem stesso da eventuali corruzioni in caso di
crash del sistema. Per ragioni di performance è stato deciso che ext4 attraverso il journal scrivesse,
di default, solo metadati dei file.
Il journaling avviene con un meccanismo a transazioni ovvero per un certo periodo di tempo il
sistema accumula modifiche da registrare sul journal. L'insieme di queste modifiche costituisce una
transazione. Ad un certo punto un kernel thread adibito al journaling (e chiamato, nel caso di ext4,
jbd2) prende il lock sul journal, svuota le modifiche su disco (commit della transazione), e rilascia il
lock sul journal. Quando nel contesto di un processo si effettua journaling, il processo richiede
un handle, ovvero un aggancio, alla transazione corrente. Se la transazione è ancora aperta, il
processo può inserirsi in quella. Altrimenti, normalmente, se la transazione è in fase di commit
(quindi il lock è preso), il processo si addormenta su una waitqueue per aspettare che ci sia di nuovo
una finestra utile per agganciarsi.
Le transazioni non sono però strutture dati monolitiche. Esse sono invece suddivise in unità più
piccole: gli handle già menzionati ed i log record [13].
Illustrazione 9: Struttura transazione
I log record sono la più piccola unità che può essere trattata dal journal. Essi rappresentano le
singole modifiche ai metadati di un file, svolte da un determinato processo, che devono essere
registrate dal journal. Ognuno di essi rappresenta un aggiornamento individuale a un blocco. Ad
ogni log record è associata una struttura dati chiamata buffer_head (vedi capitolo 6.3).
Gli handle come già detto posso essere definiti come un aggancio alla transazione e raggruppano
insieme svariati log record. Ad esempio se si invoca una write usando la sua syscall, tutti i log
record coinvolti in tale operazione sono raggruppati in un handle. Ogni handle è sempre associato
29
ad uno specifico processo, tale associazione è assicurata dal campo journal_info presente nella
task_struct che il JBD2 converte in un puntatore ad un handle_t. La struttura dati handle_t è un
tipo di dato opaco infatti è un typedef della più specifica jbd2_journal_handle [14] la quale è
definita nel file /include/linux/jbd2.h alla riga 419 [15] che contiene due campi di notevole
importanza:
transaction_t
int
*h_transaction;
h_buffer_credits;
Il primo è il puntatore alla transazione al quale l’handle appartiene, mentre il secondo è il numero di
buffer liberi ancora disponibili per le operazioni di journaling.
Ogni transazione raggruppa più handle per assicurare migliori perfomance. Se così non fosse e il
journal facesse le operazione sui singoli handle, ci sarebbe un grosso overhead dovuto alle piccole
ma continue richieste del journal. Essendo ogni handle associato ad un processo specifico il numero
di handle presenti nel sistema può essere notevole generando un carico di lavoro eccessivo se
fossero considerati singolarmente. La transazione è definita dal tipo di dato opaco transaction_t il
quale è un typedef della struttura dati transaction_s definita [16]. Tale struttura contiene un
puntatore alla struttura journal_t la quale rappresenta il journal per questa transazione. Contiene una
serie di stati in cui la transazione si può trovare che sono:
 T_RUNNING: in questo stato gli handle possono essere aggiunti alla transazione;
 T_FLUSH: indica che il journal sta scrivendo i log record;
 T_COMMIT: indica una fase in cui i metadati devono ancora essere processati;
 T_FINISHED: tutti i dati sono stati scritti sul disco correttamente;
 T_LOCKED: la transazione è bloccata per la scrittura sul disco
Il resto sono una serie di liste doppiamente concatenate utilizzate per distinguere i buffer della
transazione in svariate casistiche [16].
Dato che il percorso di codice che entra nel journal, nel contesto di una write, prevede transazioni
normali, e dato l'elevato carico indotto sul journaling dai writer di background durante il test, lprobe
si trova spesso ad aspettare perché la transazione attuale è già in fase di commit. Il processo si
blocca nella funzione start_this_handle() [26]:
if (transaction->t_state == T_LOCKED) {
DEFINE_WAIT(wait);
prepare_to_wait(&journal->j_wait_transaction_locked,&wait,
TASK_UNINTERRUPTIBLE);
30
read_unlock(&journal->j_state_lock);
schedule();
finish_wait(&journal->j_wait_transaction_locked, &wait);
goto repeat;
}
Se la transazione è nello stato T_LOCKED non è più possibile aggiungere la handle all’attuale
transazione così il processo viene de schedulato e si “addormenta” in attesa che finisca il commit
sullo storage. Inoltre, come si può notare dalle tracce prese con blktrace, l'attesa può diventare
davvero di grandi dimensioni in quanto jbd2 effettua write asincrone le quali sono penalizzate da
BFQ perchè lo scheduler non da weight raising alle write asincrone che quindi si trovano ad avere
un frazione di throughput molto limitata. In questo modo i tempi di completamento della richiesta
del jbd2 si dilatano e si ripercuotono per lo stesso tempo sul programma Lprobe che si blocca,
aumentando così notevolmente la latenza dell'applicazione.
6.2.2 Dirty page balancing e throttling
Con Balance e throttle intendiamo due funzioni nel percorso delle write che possono portare al
blocco dell'applicazione LProbe. Entrambe fanno parte della gestione della memoria all'interno del
kernel. La prima balance_dirty_pages() è una funzione presente nel file mm/page-writeback.c [17].
Questa funzione è invocata da balance_dirty_pages_ratelimited_nr [17] passandogli un
address_space
_t
e
un
long
int
ratelimit,
valore
che
è
calcolato
dalla
funzione
sync_writeback_pages() che fornisce un numero di quante pagine si cercheranno di scrivere in caso
si decida di effettuare il writeback non in background [17].
Tale funzione deve essere invocata dal processo il quale ha generato i dati dirty. La funzione guarda
il numero di pagine sporche nel sistema e forzerà ad effettuare il writeback se si sta superando la
vm_dirty_ratio. Per maggiore precisione il valore di vm_dirty_ratio è il quantitativo massimo di
memoria che può essere riempito con pagine sporche prima che tutto venga scritto sul disco.
Quando il sistema raggiunge questa soglia ogni nuova richiesta di I/O deve essere scritta sullo
storage direttamente, diventando così bloccante. Questo è spesso causa di lunghe latenze dovute
all'I/O ma, in compenso, consente una maggiore sicurezza dei dati evitando un eccessivo caching.
Tornando alla funzione se invece si supera la “background_thresh” viene svegliato il thread che si
occupa di effettuare il writeback e l'attuale processo si “addormenta” all'atto della deschedulazione.
Quando scatta il timeout, lo scheduler della cpu rimette il processo in esecuzione, ma se persiste la
situazione di superamento della soglia da parte del sistema il processo torna a riaddomentarsi, e
31
così via. Tale ciclo di risvegli e riaddormentamenti immediati può durare per svariati secondi
aumentando la latenza. Viene ora riportata e spiegata la parte di codice principale della funzione.
for (;;) {
nr_reclaimable = global_page_state(NR_FILE_DIRTY) +
global_page_state(NR_UNSTABLE_NFS);
nr_dirty = nr_reclaimable + global_page_state(NR_WRITEBACK);
global_dirty_limits(&background_thresh, &dirty_thresh);
if (nr_dirty <= (background_thresh + dirty_thresh) / 2)
break;
….
__set_current_state(TASK_UNINTERRUPTIBLE);
io_schedule_timeout(pause);
….
}
Notiamo subito anche in tale funzione il ciclo for, che viene eseguito all'infinito, all'interno del
quale vengono subito calcolati la background_thresh e la dirty_thresh tramite la funzione
global_dirty_limits(...). Viene poi calcolato il numero di pagine sporche come somma tra le pagine
reclamabili e le pagine di cui fare il writeback.
nr_dirty = nr_reclaimable + global_page_state(NR_WRITEBACK);
Successivamente si va a valutare se il numero nr_dirty è inferiore a (backgroung_thresh +
dirty_tresh)/2; se si, si effettua un break che rompe il ciclo infinito altrimenti si prosegue col ciclo.
Gli altri casi di break riguardano specifiche soglie del bdi. Dopo questi possibili casi di break si
vedono le due istruzioni che addormentano il processo corrente, tra cui troviamo le function
__set_current_state(TASK_UNINTERRUPTIBLE) con la quale si setta lo stato del processo
corrente a task non interrompibile e io_schedule_timeout(...) che de schedula il processo.
Tale funzione prevede un meccanismo per effettuare il writeback direttamente in caso si superi la
sogli di pagine sporche del bdi. Come si può vedere da questi statement presenti nel ciclo for:
if (bdi_nr_reclaimable > task_bdi_thresh) {
pages_written += writeback_inodes_wb(&bdi->wb,write_chunk);
trace_balance_dirty_written(bdi, pages_written);
if (pages_written >= write_chunk)
32
break;
}
la funzione writeback_inodes_wb() [32] permette di fare il writeback sullo storage delle pagine di
memoria. Tale operazioni è di notevole importanza in quanto in tal caso è il processo stesso a fare
direttamente il writeback mentre di norma tale operazione è effettuata da un demone di sistema.
Il problema del balance_dirty_pages() è uno dei più frequenti. Si può giustificare tale
comportamento anche dalle scelte di design fatte alla base del sistema Android. Esso fa un utilizzo
esteso e intensivo della memoria, quasi sempre mantenendola satura, per attuare un meccanismo di
caching delle applicazioni di modo da rendere più reattivo il loro avvio e utilizzo. Questa scelta non
è però supportata a livello kernel in quanto, in situazioni in cui la disponibilità di memoria è bassa,
si attivano percorsi critici che possono portare ad altissime latenze e a perdere tutto il beneficio di
un eventuale caching.
La seconda throttle_vm_writeout() è una funzione presente nel file mm/page-writeback.c [17]
invocata anch'essa in uno dei possibili percorsi di una write.
Della funzione throttle_vm_writeout, descritta parzialmente sopra, viene riportata la parte
principale:
for ( ; ; ) {
global_dirty_limits(&background_thresh, &dirty_thresh);
dirty_thresh += dirty_thresh / 10;
/* wheeee... */
if (global_page_state(NR_UNSTABLE_NFS) +
global_page_state(NR_WRITEBACK) <= dirty_thresh)
break;
congestion_wait(BLK_RW_ASYNC, HZ/10);
…
}
In questo piccolo blocco di codice si vede chiaramente il funzionamento descritto sopra. Un ciclo
infinito all'interno del quale viene subito invocata la funzione global_dirty_limits() che calcola la
dirty thresholds basandosi non soltanto su vm_dirty_ratio ma anche su altri parametri ritornandoli
all'interno delle variabili passate in ingresso alla funzione. Alla dirty_thresh così calcolata viene
aggiunto un decimo del suo valore; l'obiettivo è quello di aumentare la soglia cercando di prevenire
che dei writers intensi provochino un “denial of service”. Successivamente si va a controllare se il
numero di pagine sporche presenti in memoria è inferiore alla soglia; se si, avviene il break e si esce
33
dal ciclo for (siamo nel caso in cui si stata già liberata abbastanza memoria) altrimenti si invoca la
funzione
congestion_wait().
La
congestion_wait
prepara
una
waitqueue
e
invoca
io_schedule_timeout per de schedulare correttamente l'attuale processo. Da questo momento il
processo si “addormenta”. Anche in questo caso, come nel precedente, quando scatta il timeout si
risveglia il processo e si controlla se le condizioni di uscita sono valide altrimenti si riaddormenta.
Queste due funzioni sembrano svolgere compiti analoghi in realtà sono molto differenti tra loro. La
balance_dirty_pages può essere invocata potenzialmente sempre nel percorso della write: infatti la
funzione generic_perform_write() [18] alla riga 2424 invoca balance_dirty_pages_ratelimited() la
quale invoca a sua volta balance_dirty_pages_ratelimited_nr() che come già detto invoca la
funzione interessata, in quanto si deve occupare di bilanciare le pagine sporche presenti in memoria.
La throttle_vm_writeout invece presuppone una situazione più critica nella quale si ha urgenza di
liberare memoria. Tale funzione è invocata dalla shrink_zone la quale a sua volta è invocata da
shrink_zones che viene invocata da do_try_to_free_pages() [19]. Questo è il principale entry point
per reclamare direttamente le pagine. Se tale funzione nel reclamare pagine fallisse il sistema si
troverebbe “out of memory” e qualche processo verrebbe ucciso. In questo secondo caso ci
troviamo quindi in una situazione più critica rispetto a quella della balance_dirty_pages() e, cosa
ancora più importante, in questa funziona il processo non si occupa di effettuare il writeback
personalmente come nella balance_dirty_pages(), qui viene semplicemente de schedulato per
lasciare spazio al demone di sistema che lo fa per lui.
6.2.3 Race condition semaforo inode
L'ultima causa qui illustrata riguarda un percorso caldo e molto importante durante l'esecuzione di
una write. Infatti il percorso critico di invocazioni prima del blocco è:
down_read 5050ms
ext4_map_blocks: 5050ms
get_block: 5050ms
for cicle inside __block_write_begin: 5050ms
__block_write_begin: 5060ms
ext4_da_write_begin: 5060ms
write_begin 5060ms
generic_perform_write: 5060ms
generic_file_buffered_write_inside_else: 5060ms
__generic_file_aio_write 5060ms
34
blk,generic,unlock 5060ms
generic_file_aio_write 5060ms
filp->f_op_aio_write 5060ms
f_op->write duration: 5060ms
La latenza in realtà è generata dalla funzione down_read() [20].
Come si nota dal codice della down_read, la causa delle alte latenze in questo caso è un corsa critica
su un semaforo, questa race condition è tra l’applicazione LProbe e il flusher (il cui ruolo verrà
spiegato successivamente):
void __sched down_read(struct rw_semaphore *sem) {
might_sleep();
rwsem_acquire_read(&sem->dep_map, 0, 0, _RET_IP_);
LOCK_CONTENDED(sem, __down_read_trylock, __down_read);
}
Tale funzione non da molte informazioni riguardo a cosa sia la causa della corsa critica. Essa è
infatti solamente un front-end alla vera funzione che si occupa di gestire il semaforo che è la
__down_read [21], che è invocata dalla macro LOCK_CONTENDED. È proprio all'interno di tale
funzione che il processo si addormenta.
Analizzandola nello specifico si nota come la funzione prenda in ingresso un puntatore ad una
struttura rw_semaphore:
__down_read(struct rw_semaphore *sem)
Tale semaforo è passato dal down_read che a sua volta lo riceve dalla funzione ext4_map_blocks()
[22].
Brevemente, tale funzione si occupa di vedere se un blocco di memoria richiesta è già mappato; in
caso non lo sia si occupa di prendere un write lock della struttura i_data_sem, di allocare il blocco,
salvarlo in un buffer e marcarlo come mappato.
Di questa funzione interessano quattro invocazioni a funzione:
 down_read();
 up_read();
 down_write();
 up_write();
Tutte queste quattro invocazioni sono tra loro correlate in quanto gestiscono appunto i semafori in
35
questione. Tutte prendono in ingresso lo stesso attributo, i_data_sem menzionato sopra.
Tale parametro è un rw_semaphore appartenente alla struct ext4_inode [23] che si ricava partendo
dallo struct inode. Come meccanismo di mutua esclusione è stato scelto il semaforo in quanto
permette di addormentare il processo senza che la CPU rimanga in un'attesa attiva. Inoltre questo
semaforo permette di gestire in maniera più flessibile chi debba usufruire dell'inode. Ad esempio la
funzione __up_write() invoca la funzione __rwsem_do_wake() [24] la quale gestisce in maniera
diversa il risveglio dei processi. Può scegliere se svegliare un processo che sta aspettando nella
condizione “RWSEM_WAITING_FOR_WRITE” e quindi svegliare chi si trova bloccato nella
__down_write() oppure decidere se svegliare tutti quei processi bloccati nella __down_read() nella
condizione “RWSEM_WAITING_FOR_READ”. Alla funzione __rwsem_do_wake() è passato un
parametro wakewriter, il quale passando per la __up_write() assume sempre valore 1. Quindi questo
if nella funzione:
if (!wakewrite) {
if (waiter->flags & RWSEM_WAITING_FOR_WRITE)
goto out;
goto dont_wake_writers;
}
non viene mai eseguito tramite questo percorso, ma se il primo nella lista del semaforo è bloccato
sulla __down_write(), allora viene risvegliato dagli statement successivi:
if (waiter->flags & RWSEM_WAITING_FOR_WRITE) {
sem->activity = -1;
list_del(&waiter->list);
tsk = waiter->task;
smp_mb();
waiter->task = NULL;
wake_up_process(tsk);
put_task_struct(tsk);
goto out;
}
Un oggetto inode rappresenta tutte le informazioni necessarie al kernel per manipolare un file o una
directory. Questa struttura è la rappresentazione di ogni file all'interno del file system. A differenza
36
dei file però gli inode sono costruiti solo in memoria quando il file interessato viene acceduto [25].
Fatta questa piccola spiegazione su cosa sia un inode si vede come la causa della latenza, essendo il
semaforo i_data_sem legato proprio all'inode di un file, possa essere dovuta alla corsa critica sul
semaforo quando due processi distinti stanno lavorando sullo stesso inode. Per trovare il processo
che provoca questa corsa critica sono state utilizzate molte printk in punti specifici delle quattro
funzioni elencate sopra, all'interno delle quali è stato fatto stampare il pid del processo, l'indirizzo
del semaforo, il nome del processo e dove possibile l'inode. Dopo svariati test si è notato come il
processo sia in competizione con il flusher. Il flusher è un demone, presente anche nei sistemi linux
classici, che si occupa di effettuare il writeback delle pagine di memoria sullo storage. Durante le
operazioni di writeback il flusher prende il semaforo dell'inode. Il flusher effettuando write
asincrone viene molto penalizzato da BFQ. Tale penalizzazione comporta però lunghe latenze in
quanto finchè la sua write non è stata completata mantiene il semaforo occupato. Come già spiegato
precedentemente per il caso del jorunaling, BFQ non da weight raising alle write asincrone
garantendogli così una frazione di throughput ridotta che ne dilata i tempi di completamento.
6.3 Soluzioni e risultati sperimentali
Vengono ora elencate le soluzioni alle tre cause descritte sopra.
6.3.1 Soluzioni proposte
Prima di spiegare come i vari problemi delineati sopra sono stati risolti è necessario spiegare come è
stato risolto un problema preliminare cioè quello dell'identificare uno specifico processo all'interno
del kernel risolve questo problema è un punto focale delle soluzioni proposte in quanto permette di
variare il comportamento del codice in base a chi lo sta eseguendo. A tale scopo si è scelto di
utilizzare una caratteristica di BFQ utilizzando il weight raising [8], identificare l’applicazione in
questo particolare stato significa scoprire se chi sta eseguendo una particolare funzione è da
privilegiare o meno creando percorsi di codice che risolvano la latenza. Durante gli esperimenti
abbiamo utilizzato l'ionice per modificare l'ioprio e far si che un certo processo fosse in weight
raising persistente. È stato così aggiunto un nuovo header file all'interno della parte di interface di
linux nel quale sono state definite delle funzioni che permettono di vedere se un processo
(identificato dalla sua task struct) sia in weight raising anche al di fuori della parte dedicata allo
scheduler. Così, in qualsiasi punto del kernel, è possibile sapere se il processo che sta eseguendo
attualmente quella funzione (identificato dal current) è, o meno, in weight raising distinguendolo da
tutti gli altri.
In tutte le soluzioni è presente la variabile booleana wrais la quale ha valore vero se l'attuale
processo in esecuzione è in weight raising, falso altrimenti. Tale valore è ricavato da due particolari
37
funzioni:
wrais = bfq_ioc_is_wraised(get_bfq_cic(bdi,current));
se nella funzione interessata abbiamo il bdi oppure:
wrais = bfq_is_wraised(current);
se non disponiamo del bdi. Entrambe le funzioni le sono definite nell’header file descritto sopra.
Per quanto riguarda la prima causa delle latenze (journaling) è stata risolta inserendo una piccola
modifica alla funzione start_this_handle() [26];
/*
* If the current transaction is locked down for commit, wait for the
* lock to be released.
*/
if (transaction->t_state == T_LOCKED && !wrais) {
DEFINE_WAIT(wait);
prepare_to_wait(&journal->j_wait_transaction_locked,
&wait, TASK_UNINTERRUPTIBLE);
read_unlock(&journal->j_state_lock);
schedule();
finish_wait(&journal->j_wait_transaction_locked, &wait);
goto repeat;
}
La condizione dell'if, dove il processo oggetto degli esperimenti si andava poi addormentando, è
stata modificata aggiungendo in and la condizione !wrais.
Così facendo forziamo l'handle ad attaccarsi ad una transazione in commit. Il meccanismo di
aggiunta delle handle è gestito da un sistema di crediti, se l’attuale transazione ha ancora crediti
allora vuol dire che ha spazio libero e le handle possono essere aggiunte. Si è notato come molto
spesso la transazione non esaurisca i crediti ciò permette alla nostra soluzione di risolvere il
problema forzando l’aggiunta. Questa soluzione si potrebbe pensare porti a problemi in caso la
transazione originata sia troppo grande o non ci sia più spazio per la handle. In realtà anche tale
problema sembra essere mitigato dal codice della funzione in quanto, se la transazione è troppo
grande, il processo viene “addormentato”, si inizia subito il commit della transction e la handle in
più è attaccata alla transazione successiva.
38
La
seconda
causa,
quella
riguardante
le
funzioni
balance_dirty_pages()
[17]
e
throttle_vm_writeout() [17], è stata risolta molto semplicemente forzando il break dei cicli infiniti
presenti in entrambe le funzioni se un certo processo è in weight raising.
All'interno del ciclo è stato quindi aggiunta tale modifica:
for (;;) {
…...
if (wrais)
break;
…..
}
Anche in questo caso la piccola modifica apportata risolve efficacemente il problema e non si
notano più problemi di latenza all'interno di queste funzioni. Tali funzioni inoltre sono percorsi
preventivi che il sistema operativo attiva se si supera una certa soglia di memoria il che non implica
che essa sia completamente satura. La soluzione sicuramente spreme ulteriormente la memoria ma
essendo solo un processo a farlo, se la sua impronta in memoria è minima, non si dovrebbe arrivare
a condizioni di saturazione completa della memoria centrale, anche se il rischio permane.
L'ultima causa, relativa alla corsa critica sul semaforo dell'inode, è stata risolta cercando di mitigare
l'effetto del flusher che tiene occupato il semaforo per molto tempo. Non potendo decidere come
gestire il semaforo a vantaggio del programma è stato scelto di agire sulle operazioni di writeback,
forzando il flusher a saltare le pagine relative all'inode interessato dall'applicazione Lprobe.
Per fare questo è stato aggiunto un campo alla struttura inode [25]:
atomic_t counter;
tale valore può assumere valori 0 o 1 e funge quindi come una sorta di variabile booleana. È stato
scelto di utilizzare una variabile atomic_t per essere sicuri che le operazioni di lettura e modifica
della variabile fossero svolte in maniera atomica e sincronizzate, evitando accessi multipli.
La variabile è settata a 1 nella funzione generic_perform_write(), il più in basso possibile nello
stack di funzioni di una write, attraverso la funzione atomic_set(). È importante notare come tale
variabile sia settata dal processo in weight raising ciò permette di legare il processo agli inode che
sta utilizzando distinguendo così gli inode del processo in wrais dagli altri esistenti:
if(wrais){
39
atomic_set(&inode->counter,1);
}
Per riuscire ad effettuare lo “skip” delle pagine da parte del flusher è stata modificata la funzione
write_cache_pages() all'interno del file mm/page-writeback.c [17]. Tale funzione già parzialmente
prevede un meccanismo attraverso il quale sia possibile saltare della pagine senza creare problemi
al resto del sistema. La funzione è strutturata con un ciclo “while(!done && (index <= end))”
principale che continua o fino a quando non viene settata la variabile “done” a vero oppure fino a
quando, utilizzando gli offset delle pagina, non si è finito di scorrere le pagine delle quali occorre
fare il writeback. All'interno del ciclo, come già detto, è stato utilizzato il meccanismo preesistente
modificandone le condizioni:
if (unlikely(page->mapping != mapping) || atomic_read(&inode->counter)) {
continue_unlock:
unlock_page(page);
if(atomic_read(&inode->counter)){
break;
}
continue;
}
è stata aggiunta la condizione in or logico dove viene letto il valore nella variabile atomica
dell'inode e in caso sia già stata settata a 1 per quell’inode allora la sua pagina viene saltata e
sbloccata tramite l'unlock_page(..) (all'interno della quale vengono modificati degli specifici flag
relativi alla pagina), facendo inoltre un break del ciclo while.
Il contatore viene resettato a zero quando l'applicazione ha finito di lavorare sul file e decide di
chiuderlo nella funzione ext4_release_file(..) dove attraverso le istruzioni:
if(atomic_read(&inode->counter) > 0){
atomic_set(&inode->counter,0);
}
si setta il counter dell'inode a zero.
Tale soluzione è efficace e permette di saltare le pagine mantenendo comunque consistente il
sistema, in tal modo non si verifica più il blocco del processo sul semaforo e la latenza in tale
40
funzione ritorna bassa. L'unico possibile svantaggio di questa funzione è un overhead dovuto al
break prematuro del ciclo while perchè così facendo possono essere necessarie più invocazioni del
flusher per fare il writeback della pagine di memoria invece che una sola che le scorre dall'inizio
alla fine e le scrive sul dispositivo di storage.
Tutte le soluzioni sopra proposte hanno però un problema di fondo non banale e ancora
parzialmente irrisolto. Come spiegato a inizio capitolo all'interno degli esperimenti è stato utilizzato
il comando ionice per privilegiare il processo in questione durante l'I/O e poi grazie a tale privilegio
distinguerlo all'interno delle altre parti del kernel. Il problema di questo meccanismo risiede a
monte nell'utilizzo di ionice, tale tool può richiedere notevole tempo non solo per partire ma anche
per essere recepito a livello dello scheduler e apportare tutte le modifiche necessarie. Questo
comporta inevitabilmente che se il processo fa richieste prima che l'ionice arrivi, il suo destino sarà
quello di bloccarsi all'interno di uno dei percorsi precedentemente descritti, provocando un'alta
latenza su quelle richieste rimaste fuori. Come già spiegato nel capitolo 5 è stato aggiunto
all'applicazione Lprobe uno sleep iniziale nel tentativo di sincronizzare il programma e il tool ma
comunque capita che alcune richieste iniziali rimangano escluse anche se la frequenza è molto
ridotta. Non è stata investigata oltre l’esistenza di altri comandi che potessero avviare direttamente
il processo con ionice decisa a priori. Inoltre, eventualmente, si potrebbe modificare BFQ in modo
che sia più reattivo ai cambiamenti di ioprio.
6.3.2 Risultati sperimentali
Riportiamo ora i risultati ottenuti inserendo le quattro soluzioni proposte nei capitoli precedenti. Si
effettuerà un confronto tra i risultati ottenuti con un kernel senza le modifiche (sempre contenente
BFQ) e il kernel con le modifiche apportate. Verranno confrontati anche vari carichi di lavoro:
prima il confronto con carico solo reader (rispettivamente prima uno poi due) poi il carico di lavoro
con più reader e writer in contemporanea mettendo anche in risalto il tempo impiegato quando il
dispositivo di storage è in idle. I valori misurano lo start-up time medio di LProbe su un campione
di 10 tentativi per ognuna delle tipologie di background workload.
41
Confronto tra versioni
14,00
12,50
11,59
12,00
Secondi
10,00
9,94
8,00
6,67
BFQ-no
mod
BFQ-mod
6,31
6,00
4,00
2,00
0,00
0,00
1r
1w
1r1w
Illustrazione 10: Confronto risultati ottenuti prima e dopo le modifiche al kernel
Confrontando lo start-up time ottenuto con una versione di un kernel contente BFQ standard e un
kernel contenente le modifiche apportate sopra si vede come i tempi di BFQ-mod siano, nei primi
due casi di workload, molto vicini ai tempi in idle rappresentati dalla linea tratteggiata rossa il cui
valore è 6,28 secondi. Mentre nell'ultimo caso BFQ-mod ha un start-up circa doppio rispetto a
quello in idle ma comunque incredibilmente migliore di quello di BFQ-no mod, il tempo così
elevato della versione BFQ-mod è quasi sicuramente dovuto al secondo insieme di cause, descritto
precedentemente. È possibile però che tale latenza sia dovuta anche ad alcune richieste effettuate
prima della modifica dell’ioprio. Il valore 0.00 presente nel caso 1r1w rappresenta concettualmente
“l'infinito”, in quanto nella versione di BFQ-no mod lo start-up dell'applicazione continua fino a
quando il workload non finisce. Il termine è stato stabilito attorno ai 50/60 secondi, un tempo
infinitamente grande per il sistema.
In conclusione in confronto alle versioni di BFQ precedenti questa ottiene ottimi risultati in termini
di start-up di Lprobe sotto vari tipi di workload.
Per quanto riguarda invece l’applicazione candy crush saga attualmente non si vendono
miglioramenti, questo può essere dovuto alla presenza di operazioni su un database che esercitano
notevole pressione sul journal aumentando così la probabilità che il secondo insieme di cause si
verifichi. Tali condizioni necessitano uno studio più approfondito di ciò che avviene a livello kernel
quando si interagisce con un database.
42
6.4 Secondo insieme di cause
Viene ora riportata l'ultima causa delle latenze nelle operazioni di write. Tale problema è descritto in
un capitolo differente, agli altri esposti sopra, in quanto di esso non è ancora stata trovata una
soluzione definitiva.
6.4.1 Journaling II
Anche questa causa come la prima descritta è parzialmente colpa del journaling. Osservando lo
stack di invocazioni che passa per la write end creando la latenza:
infinite_loop_inside_do_get_write_access 11380
do_get_write_acces 11380
jdb2_journal_get_write_access 11380
ext4_journal_get_write_access 11380
ext4_reserve_inode_write 11380
ext4_mark_inode_dirty 11380
ext4_dirty_inode 11380
sb->s_op->dirty_inode 11380
mark_inode_dirty 11380
generic_write_end: 11380
ext4_da_write_end: 11380
write_end 11380
…..(Il resto dello stack è identico al percorso già inserito nella write begin)
si nota immediatamente come ci sia la presenza di alcune funzioni del journal. In questo percorso è
proprio il processo stesso a cercare di avviare la fase di journaling per effettuare il commit di alcuni
metadati sul disco. Concettualmente, in questa causa di latenza, il problema si annida nel tentativo
da parte del processo di avviare il journaling su un certo buffer che però è bloccato in quanto già
oggetto di un commit precedente non ancora terminato, il processo si trova quindi ad attendere la
terminazione del commit sullo storage con conseguente aumento della latenza per i motivi già citati
nelle altre casistiche.
La funzione do_get_write_access() [28] si occupa di verificare se il buffer (identificato dalla
struttura buffer_head illustrata in seguito) è già parte della transazione corrente del journal; se lo è
non si fa nulla; altrimenti ci sono una serie di casistiche in cui ci possiamo trovare che possono
portare o ad effettuare il copy-out del buffer oppure ad un “corner case” che è proprio quello in cui
43
Lprobe si addormenta. Preliminarmente illustriamo cos'è la struttura dati buffer_head e il
meccanismo del copy-out.
La buffer_head è una struttura dati utilizzata per mappare un blocco all'interno di una pagina. Il
buffer di una pagina di memoria viene fatto mappare col blocco corrispondente [29]. Il buffer della
pagina rappresenta il suo contenuto mentre il blocco rappresenta un insieme di settori contigui sullo
storage. Tale struttura è stata l'unità di I/O tra il filesystem e il block layer anche se attualmente è
stata sostituita dalle struct bio. La struttura buffer_head rimane comunque utilizzata per estrarre la
mappatura dei blocchi, per tracciare lo stato delle pagine e per retrocompatibilità dopo
l'introduzione delle bio.
La struttura buffer_head è composta da diversi campi:
struct buffer_head {
unsigned long b_state;
struct buffer_head *b_this_page;
struct page *b_page;
sector_t b_blocknr;
size_t b_size;
char *b_data;
struct block_device *b_bdev;
bh_end_io_t *b_end_io;
void *b_private;
struct list_head b_assoc_buffers;
struct address_space *b_assoc_map;
atomic_t b_count;
};
Tra i campi più importanti della struttura dati troviamo il puntatore alla buffer_head b_this_page,
utilizzato per implementare una lista circolare dei buffer delle pagine; troviamo il puntatore b_page
il quale punta alla pagina cui è riferita la buffer_head e troviamo un puntatore a char b_data che
individua i dati all'interno della pagina.
44
Illustrazione 11: Struttura dati buffer_head
Il copy-out è invece uno specifico meccanismo messo in atto dal journal attraverso il quale si
effettua una copia del buffer allo scopo di evitare che, in alcuni casi molto specifici, la copia
originale che sta andando sullo storage venga modificata. Effettuando del tracing a livello kernel si
vede come tale meccanismo venga utilizzato davvero di rado, ma è interessante in quanto può
essere di spunto per un'eventuale soluzione.
Fatte
queste
brevi
ma
dovute
spiegazioni
preliminari
analizziamo
la
funzione
do_get_write_access(). Tale funzione prende in ingresso tre parametri: il primo è un puntatore ad
una handle_t (handle_t *handle), il secondo un puntatore alla struct journal_head(struct
journal_head *jh) e il terzo un intero (force_copy) il quale permette di decidere se forzare il copyout o meno. La struttura dati journal_head è definita nel file /include/linux/journal-head.h [30] e
contiene svariati campi tra cui, degni di un'analisi approfondita, troviamo: un puntatore ad una
struct buffer_head, un unsigned b_jlist, un char pointer b_frozen_data all'interno del quale viene
salvata la copia del buffer fatta durante i copy-out, due puntatori alla stuct journal_head b_next e
b_prev utilizzati per implementare una lista doppiamente concatenata.
Il campo b_jlist viene settato nella funzione __jbd2_journal_file_buffer() [31] e può assumere sette
diversi valori: BJ_None, BJ_Metadata, BJ_Forget, BJ_IO, BJ_Shadow, BJ_LogCtl, BJ_Reserved.
Tutti questi possibili valori rappresentano una diversa lista presente nella struttura transaction_s
[16].
Analizzando il punto in cui il Lprobe si “addormenta” all'interno della do_get_write_access():
if (jh->b_jlist == BJ_Shadow) {
DEFINE_WAIT_BIT(wait, &bh->b_state, BH_Unshadow);
45
wait_queue_head_t *wqh;
wqh = bit_waitqueue(&bh->b_state, BH_Unshadow);
JBUFFER_TRACE(jh, "on shadow: sleep");
jbd_unlock_bh_state(bh);
/* commit wakes up all shadow buffers after IO */
for ( ; ; ) {
prepare_to_wait(wqh, &wait.wait,
TASK_UNINTERRUPTIBLE);
if (jh->b_jlist != BJ_Shadow)
break;
schedule();
}
goto repeat;
}
vediamo subito come se entra nell'if viene preparata una waitqueue e poi si fa entrare il processo in
un ciclo infinito in cui controlla se la condizione di uscita è valida altrimenti viene de schedulato.
Tale casistica all'interno della do_get_write_access() è molto delicata. Se la transazione che sta
effettuando il commit sta scrivendo il buffer sul dispositivo di storage, e non è stato
precedentemente fatto il copy-out, allora in questo momento non può essere modificato il contenuto
del buffer. L'essenza del copy-out è quella di avere una copia extra, distinta da quella originale della
quale si fa il journaling. Se la copia principale sta già andando verso lo storage il copy-out non può
essere effettuato, quindi il processo deve attendere che il commit sia completato ma, come già
spiegato, il jdb2 fa richieste asincrone che sono molto penalizzate da BFQ e quindi causano un
lungo stallo dell'applicativo.
A tale problema non è ancora stata trovata una soluzione definitiva. Sono state provate svariate
modifiche che non hanno però dato i risultati sperati. In linea di massimo stiamo cercando di forzare
il meccanismo del copy-out, da parte del journal thread, per quei buffer sui quali il processo si
blocca. Al momento tale soluzione è ancora in fase embrionale e non viene riportata nell’elaborato.
46
7. Confronto tra scheduler
Vengono ora confrontati i risultati ottenuti con le modifiche apportate al kernel, aggiungendo le
soluzioni proposte nel capitolo 6, e i risultati ottenuti utilizzando gli scheduler di default presenti in
qualsiasi sistema. Anche in questo caso la linea tratteggiata rossa nel grafico rappresenta il tempo di
start-up quando lo storage è in idle, mentre il valore 0,00 indica che il processo termina lo start-up
quando termina il workload in background, quindi potenzialmente un tempo infinito. I valori
rappresentano il tempo di start-up medio su un campione di 10 test dell’applicazione LProbe. Sono
stati utilizzati i valori medi anche se il rapporto tra varianza e valori medi era elevato. In tutti questi
test è stata assegnata ioprio 0 e classe real time indistintamente dallo scheduler utilizzato, ciò
avvantaggia CFQ, mentre gli scheduler non risentono della modifica. Successivamente vengono
riportati anche i dati reali relativi a CFQ senza ioprio modificata.
Start-up time con 1 reader
30,00
23,82
25,00
Secondi
20,00
15,00
12,72
10,00
8,48
6,67
5,00
0,00
1r
Illustrazione 12: Tempo di start-up LProbe con un reader come workload
47
BFQ-mod
CFQ
DEADLINE
NOOP
Start-up time con 1 writer
25,00
21,99
20,00
BFQ-mod
CFQ
DEADLINE
NOOP
secondi
15,00
10,00
6,31
6,84
5,00
0,00
0,00
1w
Illustrazione 13: Tempo di start-up LProbe con un writer come workload
Start-up time con 1 reader e 1 writer
25,00
21,08
20,00
secondi
15,00
BFQ-mod
CFQ
DEADLINE
NOOP
12,50
10,00
5,00
0,00
0,00
0,00
1r1w
Illustrazione 14: Tempo di start-up LProbe con workload un reader e un writer
48
Start-up time LProbe
30,00
23,82
25,00
21,99
21,08
secondi
20,00
15,00
12,72
10,00
BFQ-mod
CFQ
DEADLINE
NOOP
12,50
8,48
6,31 6,84
6,67
5,00
0,00
0,00
1r
1w
0,00 0,00
1r1w
Illustrazione 15: Grafico riassunto Start-up LProbe con diversi workload
Come si può vedere dai grafici riportati, BFQ con le modifiche al kernel ha performance
decisamente migliori degli altri scheduler disponibili. In caso di background workload cospicui,
come 1 reader e 1 writer, permangono però i problemi legati al journal illustrati nel capitolo 6.3 che
dilatano i tempi notevolmente e allontanano BFQ dai tempi di start-up ottenuti con il dispositivo di
storage in idle.
Vengono ora riportati i dati “reali” in rapporto ai differenti workload. Con reali intendiamo senza
ioprio modificata per lo scheduler CFQ. DEADLINE e NOOP restano uguali in quanto non
utilizzano il concetto di ioprio. Questi dati ci permetto di capire la reale validità delle soluzioni da
noi proposte confrontandole con l’utilizzo degli scheduler disponibili senza avvantaggiare
l’applicazione.
49
Start-up time LProbe senza ionice CFQ
30
27,21
23,82
25
21,99
BFQ-mod
CFQ
DEADLINE
NOOP
Secondi
20
15
13,09
10
12,72
12,5
6,31 7,03
6,67
5
0,00
0
1r
1w
0,00 0,00
1r1w
Illustrazione 16: Start-up LProbe senza ionice CFQ
come si può vedere togliendo ioprio da CFQ le sue prestazioni peggiorano notevolmente tanto che
nel caso di background workload composto da un reader e un writer lo start-up time medio è più del
doppio rispetto a quello di BFQ modificato e quattro volte il tempo con il dispositivo di storage in
idle.
50
8. Conclusioni e futuri sviluppi
In conclusione il lavoro di tesi svolto all'Università degli studi di Modena e Reggio Emilia ha dato
risultati soddisfacenti. Come mostrato nel capitolo 7 le modifiche apportate al kernel hanno portato
un ottimo miglioramento delle prestazioni tracciando una strada da seguire in futuro. Il problema
della latenza delle applicazioni in presenza di I/O è molto dibattuto non solo per quanto riguarda
sistemi Android ma anche nei sistemi desktop. È un problema ampio e importante che, come
dimostrato nell'elaborato, esula dal solo scheduler dello storage e si annida in parti diverse del
kernel, come la gestione della memoria virtuale e il journaling. Le soluzioni fornite non hanno lo
scopo di risolvere il problema nella sua completezza ma l'obbiettivo è quello di mitigare la latenza il
più possibile rendendola accettabile per l'utente. L'elaborato ha anche l'obbiettivo di aiutare e
fornire le informazioni basilari a chi decida di continuare il lavoro di ricerca qui iniziato portando
avanti la battaglia contro la latenza.
Nell'immediato futuro ci sono molte strade aperte. Il primo passo da svolgere sarebbe quello di
sistemare in maniera efficace la causa journaling per riuscire a portare lo start-up time vicino ai
tempi ottenuti quando il dispositivo di storage è in idle, riuscendo a mantenere il filesystem in un
stato consistente. Successivamente occorrerà iniziare a testare le soluzioni su applicazioni diverse
da Lprobe e vedere se anche in quel caso l'improvement alla responsivness dell'applicazione è
davvero così elevato. Il codice andrà rivisto, sistemato per distribuirlo alle comunità internazionali e
dovranno essere svolti controlli di non regressione per verificare che le nuove modifiche introdotte
non porti alla formazione di bug in alcune parti del sistema.
L'argomento è davvero complesso ma spero, con questo elaborato, di stimolare altri giovani
universitari a intraprendere questa strada e portare avanti un lavoro di notevole importanza che ha
bisogno di continue nuove forze per poter essere risolto definitivamente.
51
Bibliografia
[1] https://www.usenix.org/conference/fast15/technical-sessions/presentation/jeong
[2] Karim Yaghmour (2013) - Embedeed Android
[3] https://dl.google.com/googleio/2010/android-jit-compiler-androids-dalvik-vm.pdf
[4] https://os.itec.kit.edu/downloads/sa_2010_braehler-stefan_android-architecture.pdf
[5] Wen Hu, Yanli Zhao - Analysis on Process Code schedule of Android Davil Virtual Machine
[6] http://lwn.net/Articles/318611/
[7] http://events.linuxfoundation.org/images/stories/slides/abs2013_gargentas.pdf
[8] Paolo Valente, Arianna Avanzini (2015) - Evolution of BFQ storage-I/O scheduler
[9] http://developer.android.com/tools/debugging/debugging-log.html
[10] https://www.kernel.org/doc/Documentation/trace/ftrace.txt
[11] https://developer.android.com/tools/debugging/debugging-memory.html#ViewingAllocations
[12] http://linux.die.net/man/1/ionice
[13] Wolfagang Mauerer (2008) - Professional linux kernel architecture
[14] http://androidxref.com/kernel_3.0/xref/include/linux/jbd2.h#97
[15] http://androidxref.com/kernel_3.0/xref/include/linux/jbd2.h#419
[16] http://androidxref.com/kernel_3.0/xref/include/linux/jbd2.h#513
[17] http://androidxref.com/kernel_3.0/xref/mm/page-writeback.c
[18] http://androidxref.com/kernel_3.0/xref/mm/filemap.c#2342
[19] http://androidxref.com/kernel_3.0/xref/mm/vmscan.c#2085
[20] http://androidxref.com/kernel_3.0/xref/kernel/rwsem.c#19
[21] http://androidxref.com/kernel_3.0/xref/lib/rwsem-spinlock.c#142
[22] http://androidxref.com/kernel_3.0/xref/fs/ext4/inode.c#1279
[23] http://androidxref.com/kernel_3.0/xref/fs/ext4/ext4.h#578
[24] http://androidxref.com/kernel_3.0/xref/lib/rwsem-spinlock.c#302
[25] Robert Love - Linux kernel Development third edition
[26] http://androidxref.com/kernel_3.0/xref/fs/jbd2/transaction.c#117
[27]https://support.google.com/nexus/answer/2841846?hl=it&ref_topic=2841129&vid=1635775064708029292-4080615270
[28] http://androidxref.com/kernel_3.0/xref/fs/jbd2/transaction.c#583
[29] http://androidxref.com/kernel_3.0/xref/include/linux/buffer_head.h#59
[30] http://androidxref.com/kernel_3.0/xref/include/linux/journal-head.h#19
[31] http://androidxref.com/kernel_3.0/xref/fs/jbd2/transaction.c#1962
[32] http://androidxref.com/kernel_3.0/xref/fs/fs-writeback.c#583
52