dimanche 30 mars 2014

Tentative d'observation d'un COW! (Copy On Write)

Pourquoi? 

Dans l'article "Tribulations d'un processus chez les Jivaros" on a donc construit un petit programme avec une région de code de taille inférieure à une page, et une région de pile. Il est temps de regarder comment ce programme fonctionne, et surtout de tenter d'observer comment fonctionne (du point de vue de ce programme / processus) la gestion de mémoire virtuelle. Comme on a peu de pages, cela devrait être raisonnablement facile à interpréter... 





Ce que l'on va essayer d'observer

  • Quelle est la disposition mémoire de notre processus lors dès son démarrage ? Pour cela on va  bloquer son exécution au moyen d'une lecture sur le terminal.
  • Quelle est la disposition mémoire d'un processus  (et de son père) créé par fork, juste après le retour de fork ?
  • Si on enrichit notre programme avec une région de données, que se passe-t-il quand le fils accède à cette région, en lecture puis en écriture?

Comment?

On va compléter notre programme pour lui faire faire quasiment du pas à pas, et on va utiliser un petit shell script pour accéder aux informations disponibles sur la mémoire via les fichiers /proc/[pid]/maps, /proc/[pid]/pagemap, et /proc/kpagecount, de manière à s'éviter les calculs fastidieux d'offset dans ces fichiers.

Shell script pour observer la mémoire d'un processus

 Ce script (getpg.sh)  n'a pas la prétention d'être parfait. C'est juste un outil "vite fait", pour pouvoir obtenir :
  • le numéro de page physique associé à un adresse virtuelle d'un processus,  
  • un indicateur pour déterminer si la page est présente ou non,
  • un compteur de partage de la page physique.
En fait, cela automatise les calculs faits et utilisés dans l'article : "Linux: Mémoire occupée par un processus" .

getpg.sh prend trois arguments:
  • pid : numéro du processus à observer
  • Numéro de région (indice de la région dans /proc/[pid]/maps ou la chaine 'stack'  si on veut examiner la pile
  • Nombre de pages que l'on veut examiner en partant de l'adresse de base de la région (dans le cas de la pile on part des adresses hautes en décroissant), ou la chaine 'all' si on veut voir les informations associées à toutes les pages d'une région.
Exemple:

fa $ ps -edf | grep fatiny
francois 13885  1981  0 16:56 pts/2    00:00:00 ./fatiny

fa $ cat /proc/13885/maps
08048000-08049000 r-xp 00000000 08:01 132837     /home/fa/fatiny
b776c000-b776d000 r-xp 00000000 00:00 0          [vdso]
bfeaf000-bfed0000 rw-p 00000000 00:00 0          [stack]
fa $ ./getpg.sh 13885 1 all
13885 : Page 1 (rgn 1) present (PFN: 00028a4c)has count 1

fa $

Ici, on a analysé toutes les pages de la première région (région de code qui ne contient qu'une page), et en regardant dans /proc/13885/pagemap, et dans /proc/kpagecount, on a déterminé que l'adresse virtuelle 08048000 correspondait à une page physique de numéro (PFN  ou Page Frame Number) 2814C et que cette page physique n'était référencée qu'une seule fois.

Note: comme le script accède au fichier /proc/kpagecount, il est nécessaire de l'exécuter avec les privilèges appropriés.

Le programme à surveiller

int var = 1;

_start()
{
    int i = 0;
    char buf[8];
    int pid;

    mywrite(1, "before reading var\n", 19);
    myread(0, buf, 2);
    i = var;
    mywrite(1, "after reading var\n", 18);
    myread(0, buf, 2);
    pid = myfork();
    if (pid == 0) {
        mywrite(1, "child after fork\n", 17);
        myread(0, buf, 1);
        i= var;
        mywrite(1, "child will touch data\n", 22);
        myread(0, buf, 1);
        var = 2;
        mywrite(1, "child has touched data\n", 23);
        myread(0, buf, 1);
    } else {
        mywaitpid(pid, (char*)0, 0);
        mywrite(1, "dad out of waitpid\n", 19);
    }
    myexit();
}

Rien d'extraordinaire. Les "appels systèmes"  ont été réécrits en assembleur localement comme illustré dans l'article "Tribulations d'un processus chez les Jivaros", et ont été omis ici, par souci de simplification. 

Le programme a une seule variable globale initialisée à 1. Les messages affichés, suivis d'un lecture vont nous permettre d'examiner le(s) processus pendant leur exécution en utilisant notre script. 
  • Le processus initial référence la variable globale
    • On devrait en profiter pour voir que la page de données n'est pas chargée en mémoire par défaut mais seulement "à la demande"
  • Puis le processus fait un fork()
    • Le père attend la fin d'exécution du fils
    • Le fils accède la variable globale en lecture
    • Puis le fils modifie cette variable globale.
Voilà, tout est prêt! 

Moteur! Silence, on tourne!

fa $ gcc -s -o fatiny fatiny.c -static -nostartfiles -nostdlib
fa $ size ./fatiny
   text       data        bss        dec        hex    filename
    945          4          0        949        3b5    ./fatiny

fa $

Notre programme a bien un code qui tient dans une page, et 4 octets de données statiques qui devraient aussi tenir dans une page.

fa $ ./fatiny
before reading var

Notre processus a exécuté quelques instructions, mais n'a pas encore accédé à une donnée globale. Regardons sa cartographie mémoire.

fa $ cat /proc/14132/maps
08048000-08049000 r-xp 00000000 08:01 132837     /home/fa/fatiny
0804a000-0804b000 rw-p 00001000 08:01 132837     /home/fa/fatiny
b777e000-b777f000 r-xp 00000000 00:00 0          [vdso]
bf9dc000-bf9fd000 rw-p 00000000 00:00 0          [stack]
fa $

Les régions attendues sont définies. La région de code (la première région)  a une taille de 0x1000 : une page, la deuxième ligne indique aussi que la région de données a une taille d'une page.

Que dit notre script?

fa $ ./getpg.sh 14132 1 all
14132 : Page 1 (rgn 1) present (PFN:00003f36) has count 1

fa $ ./getpg.sh 14052 2 all
14132 : Page 1 (rgn 2)not present
fa $ ./getpg.sh 14052 stack 3
14132 : Page 1 (stack) present (PFN: 000084d8) has count 1
14132 : Page 2 (stack) present (PFN: 0003e1d7) has count 1

14132 : Page 3 (stack) not present
fa $
  • La page de code est présente! Rassurant! On vient d'exécuter une partie des instructions.
  • La page de données, n'est pas en mémoire. Personne n'a encore accédé à la variable globale.
  • La pile! Tiens la pile a deux pages! A priori, sans précaution, notre programme a hérité dune flopée de variables d'environnements. Ce qui doit expliquer ces deux pages.
Donc tout est conforme à nos attentes. Laissons notre processus, accéder à sa variable globale!

Premier accès à la variable globale

fa $ ./fatiny
before reading var

after reading var

fa $ ./getpg.sh 14132 1 all
14132 : Page 1 (rgn 1) present (PFN:00003f36) has count 1

fa $ ./getpg.sh 14052 2 all
14132 : Page 1 (rgn 2) present (PFN: 0000e520) has count 1
fa $ ./getpg.sh 14052 stack 3
14132 : Page 1 (stack) present (PFN: 000084d8) has count 1
14132 : Page 2 (stack) present (PFN: 0003e1d7) has count 1

14132 : Page 3 (stack) not present
fa $

Ouf ! Notre page de données est bien chargée en mémoire. C'est la seule différence avec l'étape précédente!

Passons au fork() ! Nous allons avoir deux processus à examiner.

 fork()

fa $ ./fatiny
before reading var

after reading var

child after fork 

Qu'obtient-on pour le père?
fa $ ./getpg.sh 14132 1 all
14132 : Page 1 (rgn 1) present (PFN:00003f36) has count 2

fa $ ./getpg.sh 14052 2 all
14132 : Page 1 (rgn 2) present (PFN: 0000e520) has count 1
fa $ ./getpg.sh 14052 stack 3
14132 : Page 1 (stack) present (PFN: 000084d8) has count 2
14132 : Page 2 (stack) present (PFN: 0000b16c) has count 1

14132 : Page 3 (stack) not present
fa $

Rien de notable? Ah, si! Le compteur d'utilisation de la page de code est passé de 1 à 2! Et celui de la première page de pile également. Pourquoi la deuxième page de pile n'a-t-elle pas son compteur à 2?
  • Nos processus en retour de fork, ont déjà touché leur pile respective (la deuxième page)? Mais ce n'est pas l'explication:  en fait, si on se débrouille pour que le code utilisateur des processus, n'utilise pas la pile, on obtient le même comportement. La page semble être copiée systématiquement lors de l'appel à fork.
  •  ET! La page physique (PFN)  qui était utilisée pour la pile du processus a changé! Au voleur!
Et pourquoi la page de données n'a-t-elle pas son compteur à 2? Serait-elle déjà copiée pour le fils?
Vérifions?
fa $ ./getpg.sh 14470 1 all
14470 : Page 1 (rgn 1)is present (PFN: 00003f36) has count 2
fa $ ./getpg.sh 14470 2 all
14470 : Page 1 (rgn 2)is not present
fa $ ./getpg.sh 14470 stack 3
14470 : Page 1 (stack) present (PFN: 000084d8) has count 2
14470 : Page 2 (stack) present (PFN: 0003e1d7) has count 1
14470 : Page 3 (stack) not present
fa $

On retrouve:
  • Le code du fils utilise la même page physique (PFN) que le père, et le compteur est à 2. Le code est donc partagé.
  • Le fils n'a pas encore de page associée à sa région de données. Le noyau n'a pas jugé utile d'établir un lien entre adresse virtuelle et page physique... dès fois que ce soit inutile. Procrastination quand tu nous tiens ! Non, sagesse efficace.
  • La deuxième page physique de pile du fils est en fait l'ancienne deuxième page de pile du père. Ce n'était pas un vol, c'était un don ou héritage!

 Le fils accède  en lecture la variable globale

fa $ ./fatiny
before reading var

after reading var

child after fork 


child will touch data

fa $ ./getpg.sh 14470 1 all
14470 : Page 1 (rgn 1)is present (PFN: 00003f36) has count 2
fa $ ./getpg.sh 14470 2 all
14470 : Page 1 (rgn 2) present (PFN:0000e520) has count 2
fa $ ./getpg.sh 14470 stack 3
14470 : Page 1 (stack) present (PFN: 000084d8) has count 2
14470 : Page 2 (stack) present (PFN: 0003e1d7) has count 1
14470 : Page 3 (stack) not present
fa $


Le processus père étant bloqué dans le waitpid, son statut ne va pas évoluer. Seuls les compteurs de ses pages physiques pourraient bouger.

Ici après avoir accédé en lecture la  variable globale, on voit que:
  • le processus a maintenant une page physique associée à sa région de données.
  • Cette page est la même (même PFN) que celle utilisée par le père.
  • Et son compteur est donc logiquement 2

Le fils écrit dans la page du père?

fa $ ./fatiny
before reading var

after reading var

child after fork 


child will touch data

child has touched data

fa $ ./getpg.sh 14470 1 all
14470 : Page 1 (rgn 1)is present (PFN: 00003f36) has count 2
fa $ ./getpg.sh 14470 2 all
14470 : Page 1 (rgn 2)is present (PFN: 0000ff12) has count 1
fa $ ./getpg.sh 14470 stack 3
14470 : Page 1 (stack) present (PFN: 000084d8) has count 2
14470 : Page 2 (stack) present (PFN: 0003e1d7) has count 1
14470 : Page 3 (stack) not present
fa $ ./getpg.sh 14132 2 all
14132 : Page 1 (rgn 2) present (PFN: 0000e520) has count 1

Après écriture de la variable par le fils, on voit que le PFN de la page de données a changé et que le compteur de cette page est de 1. Et la dernière commande permet de vérifier que le processus père a toujours accès à la page physique de données initiale dont le compteur est retombé à 1

Copy On Write : je l'ai vu!


On vient donc d'observer un phénomène de Copy On Write (ou  COW) : plutôt que de se précipiter à copier les pages qui doivent être dupliquées entre processus père et fils, partage la page d'origine entre père et fils, et "interdit" les écritures sur cette page partagée, en fait pour détecter ces écritures, et faire une copie "Just In Time".

lundi 17 mars 2014

getc macro ou fonction ?

Pour ceux qui ne liraient pas jusqu'au bout je dois encore remercier Jean-Marie Rifflet pour m'avoir suggéré cette investigation.

Sur ma machine, un MaxOSX 10.9, le manuel pour getc() dit :

The getc() function acts essentially identically to fgetc(), but is a macro that expands in-line.

Soit! Alors essayons le code suivant :

#include <stdio.h>
int main(int argc,char *argv[]) {
  printf("%p\n",&getc);
}

Normalement ceci devrait provoquer une erreur puisque getc est une macro...mais non! Ca compile et un résultat est fourni (bien sûr):

$ ./toto
0x7fff8c01e2bb
$

Bon admettons, vérifions tout de même dans stdio.h :

$ grep getc /usr/include/stdio.h
...
int getc(FILE *);
...
$

Bon là d'accord. Une chose est sûre à ce moment, le manuel est FAUX! Ce n'est pas la première fois que je tombe sur un manuel faux mais bon. Alors existe t-il une fonction sémantiquement équivalente qui soit une vraie macro-définition ?
Une piste : le manuel fait référence à getc_unlocked().
Ah évidemment! Comme toujours on oublie le cas du multithreading. getc() verrouille le flux pour éviter les accès concurrent au buffer concerné. Comme l'opération de verrouillage est longue, faire de getc une fonction n'a donc aucune importance puisque l'impact de l'appel est négligeable comparé à celui de la gestion du verrou. Du coup, on fournit getc_unlocked()! Cool! Essayons :

#include <stdio.h>
int main(int argc,char *argv[]) {
  printf("%p\n",&getc_unlocked);
}

Voilà que ça compile! Argh! Exécutons :

$ ./toto
0x7fff8c01e2bb
$

Bon essayons un appel à la fonction est laissons le compilateur faire l'expansion :

#include <stdio.h>
int main(int argc,char *argv[]) {
  printf("%p\n",&getc_unlocked);
  getc_unlocked(stdin);
}

$ gcc -E -o toto.e toto.c
$ tail -3 toto.e | head -2
 printf("%p\n",&getc_unlocked);
 (--(__stdinp)->_r < 0 ? __srget(__stdinp) : (int)(*(__stdinp)->_p++));
$

Argh! getc_unlocked() a bien une adresse mais elle est macro-étendue! Comment est-ce possible ? C'est une astuce bien maline! En fait, la fonction est déclarée :
int getc_unlocked(FILE *);
PUIS macro-définie :
#define getc_unlocked(f) ...
Ainsi lorsqu'on appelle la fonction :
getc_unlocked(stdin)
elle est nécessairement macro-étendue, mais lorsqu'une expression récupère l'adresse de la fonction
&getc_unlocked
le pré-processeur n'y voit que du feu et le compilateur retrouve l'adresse de la fonction déclarée!
De la sorte on bénéficie des deux avantages : l'optimisation de la macro-expansion mais aussi de la généricité via un pointeur sur fonction!

Reste à regarder sur les autres systèmes... Linux ? Solaris ? Je vous laisse tenter l'expérience et déterminer ce qu'il en est.

Finalement, rien de ce que l'on apprend n'est définitivement figé, encore une leçon que le temps qui passe nous apporte.

DjiBee

samedi 8 mars 2014

Tribulations d'un processus chez les Jivaros (construire un tout petit programme Linux)

Pourquoi?

Dans la suite du billet précédent sur l'occupation mémoire d'un processus, lançons-nous dans la confection d'un petit programme sur Linux . Faire le plus petit programme possible. Enfin pas exactement, mais un petit programme construit à partir d'un fichier C, qui occupe le moins de pages possible en mémoire lors de son exécution et si possible ait une taille réduite sur disque. Avec un tel programme, plus besoin de tronquer les résultats de cat /proc/[pid]/smaps. Confort et paresse.

Digression

D'où l'idée des Jivaros, réducteurs de têtes. Et là surprise! En recherchant le terme Jivaros, on arrive directement sur la page de Wikipedia référençant les Shuars qui sont un des 5 peuples "regroupés" sous le terme Jivaros... Je ne vais pas vous recopier la page Wikipedia, allez-y faire un tour. Ah, encore une chose Jivaros dériverait de l'espagnol Xibaros (barbare, sauvage) utilisé par les envahisseurs espagnols... Mais le dictionnaire là, n'a pas l'air de connaitre le terme...

Excuses

Toujours est-il que c'est pas très gentil... Je m'en excuse auprès des ShuarAchuar,  Shiwiars,  Aguarunas et Huambisas.

Références

Évidemment, je ne suis pas le premier à me lancer sur cette piste aussi sotte que grenue. Voici un site en anglais qui a poussé le bouchon bien plus loin que moi. Confort et paresse (bis). Mais là où l'auteur finit totalement en assembleur, nous allons  rester à mi-chemin entre C et assembleur. A noter que son programme qui fait exit(42) a une taille finale sur disque de 45 octets!

Voici un autre site où on trouve un "hello world" rikiki de 142 octets : Smallest x86 ELF Hello World (That I could achieve) .

But

On ne cherche pas à faire le plus petit programme, juste un programme qui occupe le moins de page possible, disons donc: 1 page de code et 1 page de pile. Si quelqu'un sait faire moins sur un système paginé, je suis tout ouïe.

C'est parti!

Voilà donc mon programme:

fa $ cat tsantzas.c
int main(void) { return 0;}

fa $ $ gcc -Wall -o tsantzas tsantzas.c
fa $ ./tsantzas
fa $ echo $?
0
fa $

Ben oui, c'est un programme qui ne fait rien, sauf se terminer avec une valeur de sortie égale à 0. Ne me dites pas que c'est un programme stupide qui ne sert à rien! Je viens de réécrire la commande true (/bin/true) en plus simple : les arguments --help ni --version ne sont pas traités.

Regardons la(les) taille(s):

fa $ ls -l tsantzas
-rwxrwxr-x 1 francois francois 7332 Mar  8 10:55 tsantzas
fa $ size tsantzas
   text       data        bss        dec        hex    filename
   1053        276          4       1333        535    tsantzas
fa $ getconf PAGESIZE
4096

fa $

Le fichier occupe 7332 octets, le code fait 1053 octets, et il y a 280 octets de données globales. On reviendra plus tard sur la différence entre 1053 + 280 = 1333 et 7332, Je pourrais me satisfaire de cette taille: 1053 est inférieur à la taille d'une page sur mon système, mais on a"hérité" de données globales bien qu'aucune ne soit définie dans le programme, et prenons-nous au jeu en essayant de réduire les tailles du fichier et du segment de code.

Par ailleurs si on regarde quel type de fichier a été créé, on trouve:

fa $ file tsantzas
tsantzas: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24, BuildID[sha1]=0x9c0c6a7f31cc229fd51b6139cbc76329a4f054d6, not stripped
fa $

Première réduction: strip

Faisons simple! Le fichier est dit "non-strippé". Il contient donc une table des symboles, consultable par la commande nm. Si on se débarrasse de cette table, le fichier devrait être logiquement plus petit.

fa $ nm tsantzas | head -n 2
0804a01c B __bss_start
0804a01c b completed.6382
fa $ strip tsantzas
fa $ nm  tsantzas
nm: tsantzas: no symbols
fa $ $ ls -l tsantzas
-rwxrwxr-x 1 francois francois 5600 Mar  8 11:49 tsantzas
fa $ size tsantzas
   text       data        bss        dec        hex    filename
   1053        276          4       1333        535    tsantzas

fa $

La taille du fichier a diminué de 1732 octets. Les tailles des segments de code et de données sont inchangées.

Edition de liens: statique ou dynamique?

La commande file nous a indiqué que le fichier était un binaire construit par édition de liens dynamique, utilisant des bibliothèques partagées... Donc le fichier "tsantzas" ne contient pas l'intégralité du code et des données qui seront présents lors de l'exécution, ce fichier sera complété par l'adjonction (réalisée, pour faire simple, par le système d'exploitation) de bibliothèques partagées. Lesquelles? La commande ldd va nous l'indiquer.

fa $ ldd tsantzas
    linux-gate.so.1 =>  (0xb7700000)
    libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xb753f000)
    /lib/ld-linux.so.2 (0xb7701000)
fa $ 

On ne rentrera pas ici dans l'analyse de ces dépendances et de ces fichiers. Mais il certain que lors de l'exécution notre programme aura une taille (virtuelle, mais aussi probablement une occupation en RAM) qui sera finalement éloignée de notre "cible". Pour vérifier, il faudrait accéder au fichier /proc/[pid]/smaps pendant l'exécution du programme tsantzas.... Vu son activité, sa durée d'exécution ne vas pas nous le permettre, à moins d'insérer avant le return, un appel à pause().

Une édition de liens statique ne devrait importer dans notre programme  rikiki, que le strict nécessaire de la bibliothèque C. Comme le programme ne fait aucun appel à la bibliothèque C, on devrait y gagner. Essayons!

Edition de liens statique

fa $ gcc -s -Wall -o tsantzas tsantzas.c -static
fa $ file tsantzas
tsantzas: ELF 32-bit LSB executable, Intel 80386, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.24, BuildID[sha1]=0x183763c254775f6065616b2a9372255df51b30ea, stripped
fa $ ls -l tsantzas -rwxrwxr-x 1 francois francois 684720 Mar  8 12:03 tsantzas
fa $ size tsantzas
   text       data        bss        dec        hex    filename
 675915       3276       5892     685083      a741b    tsantzasfa $

gcc est invoqué avec l'option -s (stripped) pour éviter d'avoir à exécuter la commande strip en suite, et l'option -static pour se "débarrasser" des bibliothèques dynamiques.

Résultat:le fichier est beaucoup plus gros ainsi que les sections de code et de données! Pourtant le programme ne fait pas (explicitement) appel à la bibliothèque C! En fait, le programme commence son exécution avant main  dans une fonction fournie par la bibliothèque C et de nom "_start".

fa $ gcc -Wall -o tsantzas tsantzas.c -static
fa $ readelf -h tsantzas | grep -e 'Entry point address:'
  Entry point address:               0x8048e08

fa $  nm tsantzas | grep -e ' main$'
08048f14 T main

fa $  nm tsantzas | grep -e ' _start$'
08048e08 T _start

fa $

Tout ce qui est fait avant main, relève un peu de la "magie noire" propre à la bibliothèque C (mise en place des "FILE" pour stdin, stdout, stderr; appel éventuel de constructeurs statiques,....), il faut aussi faire en sorte que lorsque le programme quitte la fonction main, on fasse un appel à exit, qui va, entre autre, fermer tous les "FILE" ouverts.

Plus de main!

Comme notre programme n'utilise pas de "FILE" ni d'arguments, essayons de nous débarrasser de ce qui précède le main, et de tout ce qui suit main! On va donc rebaptiser main en _start et faire un appel à "_exit" plutôt que return.

fa $ $ cat tsantzas_nomain.c
void _start(void) {_exit(0);}

fa $ gcc -s -o tsantzas_nomain tsantzas_nomain.c -static
tsantzas_nomain.c: In function ‘_start’:
tsantzas_nomain.c:1:20: warning: incompatible implicit declaration of built-in function ‘_exit’ [enabled by default]
/tmp/cclCyeeH.o: In function `_start':
tsantzas_nomain.c:(.text+0x0): multiple definition of `_start'
/usr/lib/gcc/i686-linux-gnu/4.7/../../../i386-linux-gnu/crt1.o:(.text+0x0): first defined here
/usr/lib/gcc/i686-linux-gnu/4.7/../../../i386-linux-gnu/crt1.o: In function `_start':
(.text+0x18): undefined reference to `main'
collect2: error: ld returned 1 exit status

fa $

Oups! La définition de _start de notre programme rentre en conflit avec celle de la bibliothèque. Corrigeons le tir!


fa $ gcc -s -o tsantzas_nomain tsantzas_nomain.c -static -nostartfiles
tsantzas_nomain.c: In function ‘_start’:
tsantzas_nomain.c:1:20: warning: incompatible implicit declaration of built-in function ‘_exit’ [enabled by default]

fa $

 L'option -nostartfiles indique à l'éditeur de liens de laisser tomber les fichiers avant main. Bon, on a oublié le prototype de _exit. C'est pas très grave, pour notre propos.

fa $ ./tsantzas_nomain
fa $ echo $?
0
fa $

Et les tailles?

fa $ file ./tsantzas_nomain
./tsantzas_nomain: ELF 32-bit LSB executable, Intel 80386, version 1 (GNU/Linux), statically linked, BuildID[sha1]=0xf66d036a4e9ada03c3a40dfa1af1148e58c565ad, stripped


fa $ ls -l ./tsantzas_nomain
-rwxrwxr-x 1 francois francois 680432 Mar  8 14:16 ./tsantzas_nomain
fa $ size ./tsantzas_nomain
   text       data        bss        dec        hex    filename
 672293       3260       5284     680837      a6385    ./tsantzas_nomain

fa $

Oups. Toujours imposant! La taille du fichier est passée de 684720à 680432! Nous sommes loin du compte! En fait, notre programme fait référence à la fonction _exit. Et qui sait ce que cela entraine en terme de volume de code amené depuis la bibliothèque?
Et si on essayait de fournir notre propre biliothèque _exit()?

Définissons _exit()

fa $ cat tsantzas_exit.c
void myexit()
{
__asm__
    (
     "  movl     $1,%eax   \n"
     "  movl     $42,%ebx  \n"
     "  int      $0x80     \n"
     );
}
_start()
{
  myexit();
}
fa $

Ouch! On a défini une fonction, codée en assembleur dans le fichier .c. Cette fonction invoque le noyau Linux (via la dernière instruction assembleur  int 0x80)  en lui demandant de procéder à l'appel système _exit (pour ça on a mis la valeur 1 dans le registre %eax dans la première ligne d'assembleur). Et on a indiqué que l'on voulait sortir avec la valeur 42 (en mettant 42 dans le registre %ebx  sur la 2ème ligne d'assembleur).

Pas de magie, ici. Il s'agit juste de conventions.
Pour savoir comment utiliser de l'assembleur en C, voir Using Inline Assembly in C/C++.
Pour connaitre la correspondance entre appels systèmes et numéro à mettre dans %eax, on peut par exemple visiter: Linux System Call Table.

Essayons avec notre _exit

fa $ gcc -s -o tsantzas_exit tsantzas_exit.c -static -nostartfiles
fa $ ./tsantzas_exit ; echo $?
42
 
fa $ ls -l tsantzas_exit
-rwxrwxr-x 1 francois francois 640 Mar  8 15:03 tsantzas_exit

fa $ size tsantzas_exit
   text       data        bss        dec        hex    filename
    151          0          0        151         97    tsantzas_exit

fa $

Yippie! Notre programme retourne bien la valeur 42! La taille du fichier est passée à 640 octets. La taille du code est de 151 octets, et il n'y a plus de données globales!

 Encore plus petit?

Le fichier binaire exécutable ELF, qui contient notamment une entête (readelf -h tsantzas_exit), une table d'entête de programme (readelf -l tsantzas_exit) et une table d'entête de sections (readelf -S tsantzas_exit). Lors de l'exécution, en général seules les entêtes de programme sont utiles, on doit donc pouvoir se débarrasser des (de certaines des) entêtes de sections. Essayons grâce à la commande strip.

fa $ strip -R .shstrtab   -R .comment  -R .eh_frame -R .note.gnu.build-i ./tsantzas_exit
fa $ ls -l ./tsantzas_exit
-rwxrwxr-x 1 francois francois 408 Mar  8 15:29 ./tsantzas_exit
fa $ size ./tsantzas_exit
   text       data        bss        dec        hex    filename
     63          0          0         63         3f    ./tsantzas_exit
fa $ ./tsantzas_exit; echo $?
42

fa $

L'argument -R "toto" indique à strip d'éliminer les sections correspondantes. Bon, j'ai en fait laissé une section ".text" La taille du fichier a réduit de 640 octets à 408!
Mais plus surprenant, une partie du code a aussi disparu! Le programme marche-t-il encore?
Oui!

Elfkickers!

Le site elfkickers (commun avec le programme qui fait exit(42) en 45 octets) propose une suite d'outils pour réduire les tailles de fichiers ELF. Plutôt que de rechercher toutes les astuces utilisées dans les 2 sites déjà mentionnés, voyaons voir ce que donnent ces outils. Confort et paresse.

fa $ ./ELFkickers-3.0a/bin/sstrip -z ./tsantzas_exit
fa $ ./tsantzas_exit; echo $?
42 

fa $ ls -l ./tsantzas_exit
-rwxrwxr-x 1 francois francois 220 Mar  8 15:45 ./tsantzas_exit

fa $

Yippie bis! 220 octets au lieu de 408! Bon je ne suis pas aux 45 octets du record, mais je pars d'un programme encore partiellement en C!

Mais le plus drôle est à venir!  La commande Elfkickers sstrip fait dans la prestidigitation! Du genre accroche-toi aux entêtes, j'enlève le code!

fa $ size ./tsantzas_exit
   text       data        bss        dec        hex    filename
      0          0          0          0          0    ./tsantzas_exit

fa $

La commande size (après passage de sstrip) dit qu'il n'y a plus de segment de code! Et pourtant, il tourne! Le programme fonctionne encore! Je n'ai pas encore fouillé en détail. Soit sstrip met à 0 des champs lu par la commande size, soit il essaye de mettre le code dans les parties inutilisées des entêtes ELF... Attention, l'usage non contrôlé de la commande sstrip conduit relativement facilement à des programmes qui ne fonctionnent pas!

On pourrait encore gagner en taille de fichier. Si on analyse avec readelf le fichier retravaillé par sstrip, on voit qu'il y a encore  des entêtes probablement peu utiles:

fa $ $ readelf -S ./tsantzas_exit
There are no sections in this file.
fa $ readelf -l ./tsantzas_exit

Elf file type is EXEC (Executable file)
Entry point 0x80480c9
There are 3 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x000000 0x08048000 0x08048000 0x000d3 0x000d3 R E 0x1000
  NOTE           0x000094 0x08048094 0x08048094 0x00024 0x00024 R   0x4
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x4

fa $

Il est à peu près certain que l'entête "NOTE" pourrait être supprimée sans dommage.

Conclusion

Étonnant, non?
Comme souvent, ne pas croire ce qu'on voit! "size" dit qu'il n'y a pas de code... mais c'est "size" qui semble se fourvoyer, sur ce cas.
On a maintenant de quoi produire des petits programmes, on pourra tenter de s'en servir pour regarder la gestion mémoire Linux en action.

Tsantzas

Pourquoi, j'ai appelé mon programme tsantzas?

samedi 1 mars 2014

Linux: Mémoire occupée par un processus




On peut obtenir des informations sur chaque processus Linux en allant fouiner dans /proc. Chaque processus a une arborescence propre dans /proc/[pid].On peut ainsi obtenir des informations sur l'occupation et l'activité mémoire de ce processus.

Prenons un exemple. Lançons un processus qui ne fait rien d'autre que dormir. Ce processus exécute un binaire créé par édition de liens dynamique.

 

 

 

/proc/[pid]/status

fa $ ./memory &
[1] 3522
fa $

Regardons son état (résultat tronqué)
fa $ cat /proc/3522/status
Name:    memory
...
Pid:    3522
...
VmSize:        2128 kB
...
VmRSS:         312 kB
...

fa $

On apprend ici que la taille cumulée des régions mémoires valides dans l'espace d'adressage de ce processus est de 2128 KB. Et qu'il occupe actuellement 312 KB de mémoire. Les  2128 KB - 312 KB soit 1816 KB "manquants" correspondent à des pages qui n'ont pas encore été référencées au cours de l'exécution de ce programme.

/proc/[pid]/maps

On peut regarder de manière plus détaillée la disposition de l'espace d'adressage de ce processus. Là encore le résultat est volontairement tronqué.
fa $ cat /proc/3522/maps
08048000-0804b000 r-xp 00000000 08:01 260689     /home/fa/memory
0804b000-0804c000 r--p 00002000 08:01 260689     /home/fa/memory
0804c000-0804d000 rw-p 00003000 08:01 260689     /home/fa/memory
....
bfc74000-bfc95000 rw-p 00000000 00:00 0          [stack]

fa $

Pour chaque région (chaque plage d'adresses définie) on trouve:
  • une adresse de début
  • une adresse de fin (l'adresse du premier caractère après la région)
  • des indicateurs déterminant si la région est accessible en lecture (r) écriture (w), exécution(x) et si elle est privée à ce processus (p) ou partagée (s).
  • un déplacement (offset) par rapport au début du fichier si cette région projette (mappe) un fichier.
  • le numéro (08:01) de périphérique où est stocké le fichier
  • le numéro d'inode (260689) du fichier
  • le chemin d'accès au fichier
Ici on a trois régions correspondant dans l'ordre au segment de code, à un segment de données en lecture seule, et aux données statiques du programmes.
Si on prend la région de code elle s'étend de l'adresse 08048000 à l'adresse0804B000. Elle a donc une taille de 3 pages (B - 8 = 3).

Il est à noter que la région de code est considérée comme "mappée" de manière privative... Tiens? Ne m'a-t-on pas dit, redit etc à l'envie que le code était partagé entre processus issus d'un même binaire?

Oui. Le code est partagé mais ça ne se voit pas!  En fait, si un peu comme on le verra plus bas. La région de code est considérée comme "privée". Ça permet par exemple de placer le processus sous contrôle d'un débogueur (gdb quoi) qui peut modifier le code pour insérer un point d'arrêt sans que ce point d'arrêt soit appliqué à tous les processus partagent ce code, ce qui serait le cas si la région de code était "mappée - partagée".

/proc/[pid]/smaps

On peut obtenir plus de détails sur ces régions grâce au fichier /proc/[pid]/smaps. La sortie est là encore tronquée pour se limiter aux champs qui nous intéressent:

fa $ cat /proc/3522/smaps
08048000-0804b000 r-xp 00000000 08:01 260689     /home/fa/memory
Size:                 12 kB
Rss:                  12 kB
Pss:                  12 kB

Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:        12 kB
Private_Dirty:         0 kB
...

fa $

On retrouve en première ligne la description de région obtenue en regardant /proc/[pid]/maps.Puis:
  • Size: la taille de la région virtuelle
  • Rss : la taille résidente en mémoire pour cette région
  • Pss : la taille résidente rapportée au prorata des régions partagent une page physique
La taille résidente compte toutes les pages physiques associées à cette région. Ici visiblement, les 3 pages ont été accédées et ont donc été chargées en mémoire depuis le fichier binaire exécutable.
Si on lance un autre processus depuis le même binaire exécutable, on obtient pour le nouveau processus:

fa $ cat/proc/3826/smaps
08048000-0804b000 r-xp 00000000 08:01 260689     /home/fa/memory
Size:                 12 kB
Rss:                  12 kB
Pss:                   6 kB
Shared_Clean:         12 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB

...
fa $

RSS versus PSS

On trouve donc la même valeur pour le champ RSS.  Si on prend une collection de processus, par exemple, tous les processus Oracle, ou toutes les JVM, sur un système et que l'on additionne "bêtement" leurs champs RSS... on peut se retrouver avec une valeur de somme qui dépasse la taille de la mémoire physique!

Pas d'histoire de "swap" ici, on parle bien de mémoire résidente donc présente dans la RAM du système. L'approche qui consiste à ajouter des champs RSS, ignore le fait que les pages physiques accessibles depuis une région d'un processus peuvent être "partagées", accessibles depuis une autre région d'un autre du processus, voire du même processus.

Si l'on regarde attentivement, le résultat pour le premier processus, les 3 pages étaient comptabilisées aussi sous le champ : "Private_Clean", à savoir pages non modifiées en mémoire (leur contenu est identique à ce qui se trouve sur disque dans le fichier) et référencées uniquement par cette région.

Le résultat pour le 2ème processus  montre 3 pages comptabilisées sous le champ: "Shared_Clean". D'ailleurs si on ré-exécute la commande sur le premier processus, les 3 pages ne sont plus vues comme "Private_Clean" mais comme "Shared_Clean".

Comme exécute deux fois le même binaire exécutable, l'OS (feignant mais surtout efficace) réutilise les pages déjà chargées. La mémoire résidente pour cette région de code vue de chaque processus est légitimement de 12KB, mais  il n'y a pas 24 KB occupés en mémoire mais seulement 12KB . Le champ RSS est donc valide pour un processus mais doit être considéré avec circonspection quand on regarde plusieurs processus.

PSS

Le champ PSS est là pour remédier à ce problème. On va regarder page par page si chacune des pages est référencée par une seule ou plusieurs régions, et n'imputer au processus que la taille de la page au prorata du nombre de références vers cette page.

D'ailleurs, on voit que le champ PSS du deuxième processus n'est pas de 12KB mais seulement de 6KB. Si l'on regarde à nouveau le fichiers "smaps" du premier processus, on voit qu'il est passé à 6KB aussi.

On a donc bien 6KB + 6KB, 12 KB chargés en mémoire.

Vérification de partage

dans un système Linux, on peut s'amuser à vérifier le partage des pages. A chaque processus est associé un fichier /proc/[pid]/pagemap.
Voir http://lxr.free-electrons.com/source/Documentation/vm/pagemap.txt

Pour une fois il ne s'agit pas d'un fichier "ASCII" dans /proc, mais d'un fichier binaire. Chaque entrée de 64 bits décrit une page (par défaut  4KB) de l'espace d'adressage virtuel du processus. Sachant qu'une entrée fait donc 8 bytes, et que l'adresse du premier octet de  notre région est d'après le fichier maps: 08048000

La page correspondante a donc le numéro  0x08048 . En faisant la conversion en décimal,  en multipliant par 8 et en utilisant  la commande od, on obtient:

fa $ od -A d -j 262720 -N 8 -t x8 /proc/3832/pagemap
0262720 a600000000008614

fa $

La page physique correspondant à notre première page de code est ldonc la page N° 0x8614. On va donc pouvoir vérifier si cette page est effectivement partagée ou non. Comment? Linux fournit un fichier /proc/kpagecount qui fournit ce genre d'information.On reprend la valeur 8614, on la convertit en décimal, on multiplie par 8, et hop:

fa $ sudo od -A d -j 274592 -N 8 -t x8 /proc/kpagecount
0274592 0000000000000002

fa $

On peut bien sûr vérifier sur l'autre processus et s'amuser à répéter l'opération avec 3, 4, ...6, ... 12 processus ou plus.

Le fichier kpagecount est documenté avec le fichier pagemap. Il faut être super-utilisateur pour pouvoir y accéder.

"Anomalies"

Poursuivons notre expérimentation, en chargeant un nouvel exemplaire de notre programme.

fa $ memory &
08048000-0804b000 r-xp 00000000 08:01 260689     /home/fa/memory
Size:                 12 kB
Rss:                  12 kB
Pss:                   3 kB
...

fa $

Les deux instances précédentes soient aussi leur PSS passer  de 6KB à 3KB!
Euh?!
12 / 3 ne font-ils pas 4? Oui, presque, mais en fait les calculs arrondissent cette valeur à l'entier inférieur.  Si on lance 6 exemplaires de notre processus, on va retrouver 6 processus ayant pour leur région de code un PSS de 1KB. Et si on en lance 12, on va trouver un PSS de 0!

Si vous voulez regarder le détail du calcul il est disponible pour le noyau 3.13 ici:
http://lxr.free-electrons.com/source/fs/proc/task_mmu.c#L483
 

Problème d'échelle!

Supposons maintenant que j'ai un système avec beaucoup de processus (disons entre 1 500 et 45 000). Et que je veuille accéder à l'intégralité des champs PSS de toutes les régions mémoires de tous mes processus...

Par exemple:

fa $ time cat /proc/[0-9]*/smaps >/dev/null
fa $

La commande ci-dessus est évidemment peu utile, mais si je fais "time" de cette commande, cela me donnera  une idée du cout induit. C'est aussi pas très futé, mais si je veux calculer les PSS de mes processus, il semblerait que ce soit ma seule source d'information.

Sur 1 500 processus, représentant environ 800 000 régions mémoire, la commande ci-dessus consomme environ 20 secondes de temps CPU système. Sur 45 000 processus, j'ai mesuré des temps de l'ordre de 47 secondes!

L'explication se trouve là : http://lxr.free-electrons.com/source/fs/proc/task_mmu.c#L483!

Pour chaque région le système va parcourir l'ensemble des pages et déterminer pour chaque page quel est son compteur de partage... Le temps de calcul est donc proportionnel au nombre de pages accessibles depuis les régions des processus.

Évidemment la question qui vient à l'esprit est : "Mais pourquoi faire ce calcul lorsqu'on accède à cette information au lieu de maintenir un compteur PSS par région"?

En fait, il est probable que l'on ait peu de choix!  Dans une même région, une première page peut-être partagée par deux processus, et la suivante par 4 ou 12... Maintenir un compteur par région semble donc inutile. Un compteur par page... et bien, c'est ce que fait déjà le système.

Conclusion?

Pfiouh.

RSS: intéressant mais le cumul sur plusieurs processus n'a pas (forcément) de sens
PSS: information sémantiquement plus juste, mais les biais de calcul peuvent être déroutants et le cout d'accès peut devenir prohibitif.

Comme d'habitude, quand on a des outils à sa disposition, on a toujours intérêt à bien les considérer pour connaitre leurs limites et savoir dans quelles conditions ils sont utiles et au delà de quelle limite, ils nous désinforment plutôt qu'ils ne nous informent.