Linux Embedded

Le blog des technologies libres et embarquées

Gestion de fichiers avancée sous Linux

Gestion de fichiers avancée sous Linux

Introduction

Les accès disques sont souvent sources de latences considérables et semblent être un point mal  maîtrisé au risque de pénaliser les performances du système.

Pourtant Linux offre une large gamme d'interfaces permettant d'améliorer les performances des accès, de mieux les sécuriser, de bien stocker les informations associées aux fichiers (les métadonnées) et améliorer l'expérience des utilisateurs.

Nous allons découvrir ensemble certaines de ces fonctionnalités qui permettent d'affiner le contrôle sur tous les accès de vos applications.

Problèmes des I/O en pratique

Le parcours des développeurs C/C++ abordent très peu choses sur la gestion des I/O, cela se constate dans les codes déployés qui se basent toujours sur 2 méthodes d'accès :

  1. La solution classique (sans buffering utilisateur) : on y retrouve les appels systèmes comme open(), read(), write() et close().
  2. La solution miracle (avec buffering utilisateur) : basée sur fopen(), fgets(), fwrite() et fclose().

Les APIs I/O sont pourtant loin d'être limitées aux solutions précédemment décrites. Ces dernières sont trop génériques et parfois inadaptées face à certaines situations :

  • Comment assurer l'accès simultané à un fichier par plusieurs threads en utilisant flockfile() et funlockfile()?
    (j'ai personnellement vu des protections par mutex alors que ça n'est pas fait pour les I/O).
  • Comment autoriser la lecture pour un utilisateur Bob, permettre l'écriture à l'utilisateur Oscar et donner tous les droits à Alice sur un fichier ?
    (là encore, j'ai vu des structures conditionnelles sur les noms d'utilisateurs en dur ou même une base de données avec une table dédiée alors que Linux met déjà à notre disposition des API que nous verrons dans cet article)
  • Comment notifier une application lors du changement d'un fichier de configuration ?
  • Comment ajouter des métadonnées à nos fichiers ?
  • Comment accélérer et rendre les performances I/O bien meilleures ?

Nous allons répondre à toutes ces questions dans cet article.

Améliorer l'expérience utilisateur avec inotify

Il est parfois nécessaire de suivre l'évolution d'un ou plusieurs fichiers dans le but de répondre aux besoins de l'utilisateur. Par exemple :
  • Un processus démon doit être alerté lorsque ses fichiers de configuration ont subi des changements pour pouvoir charger les nouveaux paramètres (sans avoir besoin de redémarrer le programme, une chose non tolérée en production).
  • Une interface graphique doit aussi pouvoir actualiser son affichage selon les changements apportés à différents fichiers.

Tout cela est rendu possible avec le mécanisme inotify.

N.B : inotify succède à dnotify et apporte plus de fonctionnalités et supprime les limitations de ce dernier.

Chaque accès à un fichier tracé par inotify renvoie une (ou plusieurs) structure de type struct inotify_event définies comme ceci :

struct inotify_event {
   int      wd;       /* descripteur du traqueur */
   uint32_t mask;     /* masque événement */
   uint32_t cookie;   /* actuellement, utilisé seulement pour l'événement rename */
   uint32_t len;      /* longueur du champs name */
   char     name[];   /* identifiant du fichier */
};

Important : Le nombre de structures renvoyées dépend du nombre d'événements générés.

Pour bien utiliser inotify, les étapes suivantes sont à suivre :

  1. L'application commence par faire un appel à inotify_init() pour créer une instance inotify :
    int inotify_init(void);
    
  2. Un appel à inotify_add_watch() doit suivre inotify_init() pour ajouter les fichiers à traquer avec inotify.
    int inotify_add_watch(int fd , const char * pathname , uint32_t mask );
    
    ou les éléments sont définis comme ceci :
    • fd : descripteur du fichier retourné par inotify_init().
    • pathname : le fichier à suivre.
    • mask : l'événement à suivre (comme IN_ACCESS, IN_OPEN ou IN_MODIFY qui désigne l'accès, l'ouverture et la modification du fichier respectivement.)
      N.B : IN_ALL_EVENTS permet de suivre tous les événements (souvent utilisé par les explorateurs de fichiers).
  3. L'application peut récupérer le résultat avec un simple : read() qui renvoie une (ou plusieurs ) structure(s) inotify_event.
  4. Tout comme tout autre descripteur de fichier, il faut penser à le fermer à la fin.

Exemple (inotify.c)

Nous allons illustrer la manière d'utiliser inotify pour suivre les accès (lecture, écriture, ouverture, etc.) du dossier courant.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <limits.h>
#include <sys/inotify.h>
#include <fcntl.h>

// Le nombre max d'événements inotify à récupérer en 1 seule lecture.
#define BUF_LEN (10 * (sizeof(struct inotify_event) + NAME_MAX + 1))

int main(){
	int inotifyFd, currentDirectoryWatchId;
	char inotifyBufEvents[BUF_LEN];
	ssize_t nbReadEvents;
	char *evRead;
	struct inotify_event *event;
	// inotify doit toujours être initialisé.
	inotifyFd = inotify_init();
	// Ajouter le dossier à liste des fichier à suivre
	currentDirectoryWatchId = inotify_add_watch(inotifyFd,
	                                            ".", IN_ACCESS|
	                                            IN_CLOSE_WRITE|IN_OPEN);
	printf("Current directory watch id %d\n", currentDirectoryWatchId);
	for (;;) {
		/* Récupérer les évenements inotify */
		nbReadEvents = read(inotifyFd, inotifyBufEvents, BUF_LEN);
		if (nbReadEvents == -1)
			printf("read");
		printf("%ld bytes read\n", (long) nbReadEvents);
		/* Parser et afficher chaque événement inotify */
		for (evRead = inotifyBufEvents;
		     evRead < inotifyBufEvents + nbReadEvents; ) {
			event = (struct inotify_event *) evRead;
			printf("watch id =%d; ", event->wd);
			if (event->mask & IN_ACCESS)
				printf("IN_ACCESS ");
			if (event->mask & IN_CLOSE_WRITE)
				printf("IN_CLOSE_WRITE ");
			if (event->mask & IN_OPEN)
				printf("IN_OPEN ");
			evRead += sizeof(struct inotify_event) + event->len;
		}
	}

	return EXIT_SUCCESS;
}

Le code s'exécute comme ceci :

$ gcc inotify.c -o inotify
jugbe@P-NAN-SUCRE:~/Bureau/article_advanced_FS/code/file_attributes/inotify$ ./inotify 
Current directory watch id 1
32 bytes read
watch id =1; IN_OPEN 32 bytes read
watch id =1; IN_ACCESS 32 bytes read
watch id =1; IN_OPEN 32 bytes read
watch id =1; IN_ACCESS 32 bytes read
watch id =1; IN_OPEN 48 bytes read
watch id =1; IN_OPEN 32 bytes read
watch id =1; IN_CLOSE_WRITE 32 bytes read

Important : Il existe certaines limites appliquées à inotify qui sont configurables dans 3 fichiers sous /proc/sys/fs/inotify :

  • max_queued_events : limite le nombre d'événements (struct inotify_event) associés à une instance inotify en attente d'être lus de la file (valeur par défaut : 16.384).
  • max_user_instances : limite le nombre d'instances inotify par utilisateur (valeur par défaut : 128).
  • max_user_watches : limite le nombre de traqueurs par utilisateur (valeur par défaut : 8192).

Gestion des méta-données

Métadonnées et attributs par défaut (inode)

Linux enregistre le contenu d'un fichier séparément de ses métadonnées (propriétaire du fichier, timestamps des accès, droits,  etc.). En effet ces dernières sont maintenues dans une structure appelée l'inode.

Une API est mise à notre disposition pour récupérer le contenu d'une inode pour être utilisé par une application :

int stat(const char * pathname , struct stat * statbuf);
ou la structure stat est définit comme ceci :
struct stat {
	dev_t     st_dev;         /* ID du device contenant le fichier */
	ino_t     st_ino;         /* Numéro de l'inode */
	mode_t    st_mode;        /* Type du fichier */
	nlink_t   st_nlink;       /* Nombre de liens durs */
	uid_t     st_uid;         /* ID utilisateur du propriétaire */
	gid_t     st_gid;         /* ID groupe du propriétaire */
	dev_t     st_rdev;        /* ID du device*/
	off_t     st_size;        /* Taille totale (bytes) */
	blksize_t st_blksize;     /* Taille du bloc I/O du filesystem */
	blkcnt_t  st_blocks;      /* Nombre de blocs (512 bytes) alloués */
	struct timespec st_atim;  /* Date du dernier accès */
	struct timespec st_mtim;  /* Date de dernière modification */
	struct timespec st_ctim;  /* Date du dernier changement d'état */
};

Quelques champs de la structure ci-dessus méritent quelques explications :

  • st_mode : contient les droits d'accès du propriétaire, du groupe et des autres utilisateurs mais également le type du fichier (fichier régulier, socket, dossier, ..., etc).
  • st_nlink : stocke le nombre de liens durs (fichiers qui pointe vers la même inode). Quand ce compteur est à 0, l'inode et le fichier sont supprimés.
  • st_size : retourne la taille totale du fichier (en bytes) occupé sur le disque (y compris les trous dans le fichier).
  • st_blocks : retourne la taille du fichier en nombre de blocks (512 bytes pour des raisons historiques). Ce dernier retourne la taille réelle du fichier (sans compter les trous) et souvent la valeur du fichier dérivée de st_blocks (sans compte les trous) est inférieur à st_size (en comptant les trous).
  • st_blksize : Ce champ ne représente pas la taille du bloc (même si il donne l'impression), mais plutôt la taille optimale des requêtes I/O sur le filesystem (sous EXT4, par exemple : c'est 4096).

Exemple (basic_stats.c)

Nous allons illustrer le processus de lecture des métadonnées avec le code présenté ci-dessous.
Le programme prend un nom de fichier en paramètre et retourne une partie des métadonnées.

#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>

void displayStatInfo(struct stat *sb)
{
	printf("File type : ");
	switch (sb->st_mode & S_IFMT) {
	case S_IFREG: printf("regular file\n"); break;
	case S_IFDIR: printf("directory\n"); break;
	case S_IFCHR:
	case S_IFBLK:
	case S_IFLNK:
	case S_IFIFO:
	case S_IFSOCK:
		printf("Special file\n"); break;
	default:
		printf("unknown type\n"); break;
	}

	printf("major=%ld minor=%ld numbers\n",
	       (long) major(sb->st_dev), (long) minor(sb->st_dev));
	printf("Inode number : %ld\n", (long) sb->st_ino);

	printf("File size : %lld bytes\n", (long long) sb->st_size);
	printf("Optimal I/O block size : %ld bytes\n",
	       (long) sb->st_blksize);
	printf("Allocated blocks (512 bytes each) : %lld\n",
	       (long long) sb->st_blocks);
}

int main(int argc, char *argv[]){
	struct stat fileStat;

	// Récupérer les métadonnées du fichier
	if (stat(argv[1], &fileStat) < 0) {
		perror("stat");
		exit(EXIT_FAILURE);
	}

	// Afficher les métadonnées.
	displayStatInfo(&fileStat);

	return EXIT_SUCCESS;
}

Une fois exécuté, nous devrions avoir le résultat suivant :

$ ./basic_stats /bin/ls
File type : regular file
major=253 minor=1 numbers
Inode number : 264524
File size : 126584 bytes
Optimal I/O block size : 4096 bytes
Allocated blocks (512 bytes each) : 248

N.B : Sous Linux, la date de création d'un fichier n'est pas enregistré (nous verrons dans la prochaine section comment ajouter cela si nécessaire).

Attributs de fichiers étendus (EA)

En plus des métadonnées, Linux permet également de créer et attacher à des fichiers (ou dossiers) des attributs personnalisés connus sous le nom d'attributs étendus ou simplement EA (Extended Attributes).

Les EA peuvent servir par exemple :

  • Ajouter un champ "creation_date" à un fichier.
  • Stocker une configuration particulière comme un lien vers une icône ou autres.

L'utilisateur est libre d'ajouter les informations qu'il souhaite. Les attributs sont stockés sous forme de clé-valeur.

Linux divise les EA en quatre "namespace" distincts :
  1. User : peut être manipulé par des processus non privilégié (tant que ces derniers possèdent les droits de lecture et d'écriture sur le fichier).
  2. Trusted : réservé aux processus privilégiés (CAP_SYS_ADMIN).
  3. System : utilisé par le kernel. Pour l'instant, ce namespace contient le support des ACL (qu'on découvrira dans la prochaine section).
  4. Security : créé initialement  pour assister le projet SELinux, il est désormais utilisé par tous les modules de sécurité de Linux (LSM).
Il est temps pour nous d'explorer l'utilisation les EA.

Les outils shell

Les utilitaires setfattr et getfattr (disponible dans le paquet attr) permettent de modifier les EA d'un fichier.
Exemple 1
  • Pour créer une clé "file_creation_date" dans le namespace user avec la valeur "19/08/2020", nous devons procéder comme ceci :
    $ echo "I'm a new file" > testEA.txt
    $ setfattr -n user.file_creation_date -v "19/08/2020" testEA.txt
    		
  • Pour lire la valeur de la clé "file_creation_date" :
    $ getfattr -n user.file_creation_date testEA.txt 
    # file: testEA.txt
    user.file_creation_date="19/08/2020"
    		
Exemple 2
  • Pour lire l'intégralité des clés :
    $ getfattr -d testEA.txt 
    # file: testEA.txt
    user.file_creation_date="19/08/2020"
    user.rotate_secret_key="30"
    
  • Par défaut getfattr affiche seulement les clés du namespace user. Pour afficher les clés de tous les namespace, il suffit d'utiliser $ sudo getfattr -m - -d testEA.txt.

Les appels système

Les applications peuvent également faire usage des appels systèmes pour accéder et modifier les EAs, les APIs sont définis comme suit :
  • Créer et modifier les EA
    int setxattr(const char * pathname , const char * name , const void * value , size_t size , int flags);
    
  • Récupérer la valeur d'un EA
    ssize_t getxattr(const char * pathname , const char * name , void * value , size_t size);
    
  • Supprimer les EA
    int removexattr(const char * pathname , const char * name );
    
  • Lister toutes les EAs
    ssize_t listxattr(const char * pathname , char * list , size_t size );
    
Exemple (attr.c)

L'exemple suivant présente l'usage des APIs pour associer un attribut "creation_date" à un fichier (dans le namespace "user").

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/xattr.h>
#define MAX_ATTR_KEY_LENGTH 255 // Limitation imposée par le VFS.
#define MAX_ATTR_VALUE_LENGTH 50

void printUsage(){
	printf("Usage : $./attr FILENAME ATTR_KEY ATTR_VALUE\n");
	exit(EXIT_SUCCESS);
}

int main(int argc, char *argv[]){
	char userKey[MAX_ATTR_KEY_LENGTH];
	char userValue[MAX_ATTR_VALUE_LENGTH];
	char retrievedValue[MAX_ATTR_VALUE_LENGTH];

	if(argc != 4)
		printUsage();

	// L'attribut sera sauvegardé dans le namespace "user".
	snprintf(userKey, MAX_ATTR_KEY_LENGTH,
	         "user.%s", argv[2]);
	snprintf(userValue, MAX_ATTR_VALUE_LENGTH,
	         argv[3]);

	printf("User input extended attribute key = %s\n"
	       "User input extended attribute value = %s\n",
	       userKey, userValue);

	// Association des attributs étendus au fichier.
	if (setxattr(argv[1], userKey, userValue,
	             strlen(userValue), 0) == -1)
		perror("setxattr");

	// Lecture des attributs étendus.
	if(getxattr(argv[1], userKey,
	            retrievedValue,
	            MAX_ATTR_VALUE_LENGTH) < 0) {
		perror("getxattr");
		exit(EXIT_FAILURE);
	}

	printf("EA read from file : key %s = %s\n", userKey, retrievedValue);

	return EXIT_SUCCESS;
}

Le résultat suivant est obtenu après exécution :

$ ./attr test.txt creation_date 29/09/2020
User input extended attribute key = user.creation_date
User input extended attribute value = 29/09/2020
EA read from file : key user.creation_date = 29/09/2020
$ getfattr -d test.txt
# file: test.txt
user.creation_date="29/09/2020"

Liste de contrôle d'accès (ACL)

Dans le contexte des droits d'accès traditionnels aux fichiers; on parle de : propriétaire du fichier, du groupe et autres utilisateurs. Cette gestion de droits est suffisante dans la majorité des cas.

Cependant, dans certaines situations, on a besoin d'appliquer différentes permissions sur un fichier selon les utilisateurs et les groupes (associer des droits distincts à chaque utilisateur et groupe), une chose qu'il est impossible de faire avec les droits classiques.

Pour cela, Linux dispose d'une fonctionnalité nommée ACL (Access Control List).

Les ACLs sont stockées en tant qu'EAs (vu dans la section précédente) et résident dans le namespace system.

La théorie des ACL

Les ACL sont une suite d'entrées qui autorisent un utilisateur (voire plus) ou groupe (voire plus) à effectuer certaines actions sur un fichier.

Lors de la tentative d'accès, Linux parcourt les entrées pour vérifier si l'utilisateur dispose des droits nécessaires (si ce n'est pas le cas, une erreur est renvoyée).

Voici un exemple plus parlant, qui nous fera découvrir la syntaxe des ACL.

Chaque entrée ACL est constituée de 3 colonnes :
  • Type du tag : désigne un utilisateur, un groupe ou autres acteurs. On y retrouve 6 types :
    • ACL_USER_OBJ : désigne le propriétaire du fichier (Il ne peut y avoir qu'une seule entrée avec ce type dans une ACL).
    • ACL_USER : pour référencer un utilisateur.
    • ACL_GROUP_OBJ : représente le groupe auquel appartient le propriétaire du fichier (Il ne peut y avoir qu'une seule entrée avec ce type dans une ACL).
    • ACL_GROUP : désigne un groupe.
    • ACL_MASK : plafonne les droits sur le fichier. Ce dernier est constitué par l'union des droits de tous les ACL_USER_OBJ, ACL_USER, ACL_GROUP_OBJ et ACL_GROUP. (nous verrons plus tard son utilité).
    • ACL_OTHER : représente le reste des utilisateurs (Il ne peut y avoir qu'une seule entrée avec ce type dans une ACL).
  • Qualificatif du tag : comporte l'identifiant utilisateur (si Type du tag représente un ACL_USER) ou identifiant d'un groupe (si Type du tag désigne un ACL_GROUP)
  • Permissions : représente les actions classiques que peut effectuer un utilisateur sur le fichier (la syntaxe est toujours RWX).
Les ACLs minimal
Ceci désigne les ACLs les plus simplistes qui sont équivalentes aux droits traditionnels sous Linux, voici un exemple :
Type du tag qualificatif du tag Permissions
ACL_USER_OBJ - rwx
ACL_GROUP_OBJ - rw-
ACL_OTHER - r--

N.B : le qualificatif du tag n'est pas requis dans ce cas.

Les ACLs étendu
Les ACLs en version minimale n'ont pas grand intérêt par rapport aux permissions traditionnelles, c'est lorsqu'elles sont étendues qu'elles sont réellement intéressantes. Voici un exemple d'utilisation :
Type du tag qualificatif du tag Permissions
ACL_USER_OBJ - rwx
ACL_USER Bob r--
ACL_USER Alice rw-
ACL_USER Oscar rwx
ACL_GROUP_OBJ - rw
ACL_GROUP Geeks rwx
ACL_MASK - rwx
ACL_OTHER - r--
Quelques remarques sont à noter :
  • L'ACL attribue à l'utilisateur Oscar tous les droits contrairement à Bob qui ne bénéficie que d'un accès en lecture
  • Les membres du groupe Geeks disposent également de tous les droits.
  • L'entrée ACL_MASK est obligatoire lors de la présence de ACL_USER ou ACL_GROUP.

Important : même si l'utilisateur Alice appartient au groupe Geeks, elle ne disposera pas des droits d'exécution.

Les ACLs en pratique

Les utilitaires setfacl et getfacl permettent de manipuler les ACLs :

Exemples
  • Lire l'ACL : lors de la création d'un nouveau fichier, l'ACL est minimal et identique aux droits traditionnels.
    $ ls -l fileACL 
    -rw-r--r-- 1 jugurtha 10000 0 août  20 11:39 fileACL
    
    $ getfacl fileACL 
    # file: fileACL
    # owner: jugurtha
    # group: 10000
    user::rw-
    group::r--
    other::r--
    
  • Modifier l'ACL : nous pouvons ajouter de nouvelles entrées comme ceci :
    $ setfacl -m u:Oscar:rw,g:Smile:r fileACL 
    
    $ getfacl fileACL # file: fileACL
    # owner: jugurtha
    # group: 10000
    user::rw-
    user:Oscar:rw-
    group::r--
    group:Smile:r--
    mask::rw-
    other::r--
    
Le masque ACL_MASK

Le masque a été ajouté aux ACLs étendues pour les protéger des programmes non conçus pour être compatible avec elles.

Par exemple : sans l'existence du masque, l'utilisation de la commande chmod va complètement détruire le contenu de l'ACL comme ceci :

Avant chmod Chmod(7, 0, 0) sans existence du masque Chmod(7, 0, 0) avec masque
user::rw- user::rw- user::rw-
user:Oscar:rw- user:Oscar:--- user:Oscar:rw-
group::rw- group::--- group::rw-
group:Smile:r-- group:Smile:--- group:Smile:r--
mask::rw-   mask::---
other::r-- other::--- other::r--

Avec le masque, seul ce dernier a été changé et le contenu de la ACL n'est pas perdu.

Cependant, l'effet du chmod n'est pas sans  conséquences; tous les accès au fichier par les types : ACL_USER, ACL_GROUP_OBJ et ACL_GROUP sont combinés (par un ET logique "&") avec le contenu du masque. Dans notre exemple Oscar et Smile vont donc perdre tout accès (la solution est positionner le masque avec : $ setfacl -m mask::rw).

Encore plus loin avec les fichiers

La technique scatter/Gather

Cette méthode est capable d'écrire plusieurs buffers à la fois sur un fichier ou récupérer le contenu de ce dernier pour l'éparpiller dans plusieurs tableaux (le tout en un seul appel système).
Pour celle, Linux défini deux APIs comme présenté ci-dessous :

  • readv() pour lire le nombre "count" segments (tableaux) de types struct iovec à partir d'un descripteur de fichier :
    ssize_t readv (int fd, const struct iovec *iov, int count);
    			
  • writev() pour écrire le nombre "count" segments (tableaux) de types struct iovec dans un descripteur de fichier :
    ssize_t writev (int fd, const struct iovec *iov, int count);
    			

 

Pour bien comprendre le principe, nous allons prendre un simple exemple.

Problème : dans le cadre d'un test d'algorithmes de chiffrement, nous avons besoin de charger le contenu d'un fichier de 3 lignes et appliquer divers chiffrements sur chacune d'entre elles. Le résultat doit être stocké sur un autre fichier.
La problématique est résumée sur le schéma suivant :

scatter/gather example

 

Solution naïve : effectuer 3 read() dans 3 buffers différents et faire la sauvegarde avec 3 write() sur le fichier "data_output.txt".
N.B : Chaque appel système engendre un coup important car les accès disque sont toujours long.

Solution moderne avec la méthode scatter/gather: Nous allons diviser le code en plusieurs parties pour plus de clarté (scatter_gather_io.c).
  • Partie 1 : Algorithmes de chiffrement.
    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    #include <string.h>
    #include <sys/uio.h>
    #include <fcntl.h>
    
    #define MAX_CHARS_IN_ROW_INPUT_FILE 51
    #define MAX_NUMBER_SCATTER_GATHER 3
    #define CAESAR_SHIFT_KEY 3
    #define XOR_ENCRYPTION_KEY "XknPBiwhKAeXzjhZRrYZWmUoPgpvunMeIUTQpVCXmhqvztnmQR"
    
    /* Chiffrement de Caesar */
    void applyCaesarCipher(char dataToEncrypt[]){
    	for(int i = 0; i < MAX_CHARS_IN_ROW_INPUT_FILE - 1; i++)
    		dataToEncrypt[i] += CAESAR_SHIFT_KEY;
    }
    
    /* Chiffrement XOR */
    void applyXorCipher(char dataToEncrypt[]){
    	for(int i = 0; i < MAX_CHARS_IN_ROW_INPUT_FILE - 1; i++)
    		dataToEncrypt[i] ^= XOR_ENCRYPTION_KEY[i];
    }
    
  • Partie 2 : Fonction de lecture du fichier avec la méthode Scatter/gather.
    void loadFileContent(char noEnryptionBuf[],
                         char caesarEnryptionBuf[],
                         char xorEnryptionBuf[]){
    	// Déclarer le nombre de scatter/gather.
    	struct iovec iov[MAX_NUMBER_SCATTER_GATHER];
    	ssize_t scatterGatherReturn;
    	int fd;
    	fd = open("data_input.txt", O_RDONLY);
    	if(fd == -1) {
    		perror ("open");
    		exit(EXIT_FAILURE);
    	}
    	/* configuration des structures iovec */
    	iov[0].iov_base = noEnryptionBuf;
    	iov[0].iov_len = MAX_CHARS_IN_ROW_INPUT_FILE;
    	iov[1].iov_base = caesarEnryptionBuf;
    	iov[1].iov_len = MAX_CHARS_IN_ROW_INPUT_FILE;
    	iov[2].iov_base = xorEnryptionBuf;
    	iov[2].iov_len = MAX_CHARS_IN_ROW_INPUT_FILE;
    	/* Lire le fichier avec 1 seul appel système */
    	scatterGatherReturn = readv(fd, iov, MAX_NUMBER_SCATTER_GATHER);
    	if (scatterGatherReturn == -1) {
    		perror ("readv");
    		exit(EXIT_FAILURE);
    	}
    
    	for (int i = 0; i < MAX_NUMBER_SCATTER_GATHER; i++)
    		printf ("%s\n", (char *) iov[i].iov_base);
    
    	if (close (fd)) {
    		perror ("close");
    		exit(EXIT_FAILURE);
    	}
    }
    
  • Partie 3 : Sauvegarde des données avec 1 seul appel système.
    void saveFileContent(char noEnryptionBuf[],
                         char caesarEnryptionBuf[],
                         char xorEnryptionBuf[]){
    	struct iovec iov[MAX_NUMBER_SCATTER_GATHER];
    	ssize_t scatterGatherReturn;
    	int fd;
    	fd = open("data_output.txt", O_WRONLY | O_CREAT | O_TRUNC,
    	          S_IWUSR | S_IRUSR | S_IWGRP |
    	          S_IRGRP | S_IROTH);
    
    	if(fd == -1) {
    		perror ("open");
    		exit(EXIT_FAILURE);
    	}
    
    	iov[0].iov_base = noEnryptionBuf;
    	iov[0].iov_len = MAX_CHARS_IN_ROW_INPUT_FILE+1;
    	iov[1].iov_base = caesarEnryptionBuf;
    	iov[1].iov_len = MAX_CHARS_IN_ROW_INPUT_FILE+1;
    	iov[2].iov_base = xorEnryptionBuf;
    	iov[2].iov_len = MAX_CHARS_IN_ROW_INPUT_FILE+1;
    
    	/* Ecriture du contenu avec 1 seul appel système */
    	scatterGatherReturn = writev(fd, iov, MAX_NUMBER_SCATTER_GATHER);
    	if (scatterGatherReturn == -1) {
    		perror ("writev");
    		exit(EXIT_FAILURE);
    	}
    	printf ("wrote %d bytes\n", scatterGatherReturn);
    	if (close (fd)) {
    		perror ("close");
    		exit(EXIT_FAILURE);
    	}
    }
    
  • Partie 4 : la fonction main.
    int main(int argc, char *argv[]){
    	// Stocke la 1ère ligne du fichier.
    	char noEnryptionBuf[MAX_CHARS_IN_ROW_INPUT_FILE+1];
    	// Stocke la 2ème ligne du fichier.
    	char caesarEnryptionBuf[MAX_CHARS_IN_ROW_INPUT_FILE+1];
    	// Stocke la 3ème ligne du fichier.
    	char xorEnryptionBuf[MAX_CHARS_IN_ROW_INPUT_FILE+1];
    
    
    	memset(noEnryptionBuf, '\0', MAX_CHARS_IN_ROW_INPUT_FILE+1);
    	memset(caesarEnryptionBuf, '\0', MAX_CHARS_IN_ROW_INPUT_FILE+1);
    	memset(xorEnryptionBuf, '\0', MAX_CHARS_IN_ROW_INPUT_FILE+1);
    
    	// Lecture du fichier et sauvegarde de chaque ligne dans un buffer.
    	loadFileContent(noEnryptionBuf, caesarEnryptionBuf, xorEnryptionBuf);
    
    	// Chiffrement de 2ème et 3ème ligne.
    	applyCaesarCipher(caesarEnryptionBuf);
    	applyXorCipher(xorEnryptionBuf);
    
    	// Sauvegarde des données dans un autre fichier (data_output.txt).
    	saveFileContent(noEnryptionBuf, caesarEnryptionBuf, xorEnryptionBuf);
    
    	return EXIT_SUCCESS;
    }
    

Le programme retourne le résultat suivant :

$ ./scatter_gather_io
KlbtaDdAFVBPfsehlUluTXZZemcvtpTgNlPIOFdyKRcEoHENqj

vfyMoeQyAnITMJwIENWALEZgaXVKOuaSzwTpXifcHRlwRyGoVz

RCGhbASHBlCyuobzpZUFTduxdJVFPsLGqjslFFMKYUsRUsyLXb

wrote 156 bytes
$ cat data_output.txt
KlbtaDdAFVBPfsehlUluTXZZemcvtpTgNlPIOFdyKRcEoHENqj
yi|PrhT|DqLWPMzLHQZDOH]jd[YNRxdV}zWs[lifKUozU|JrY}

()8 ($ 	-&!
 "(
   	 4-&0%"8?'=64=$/!	0

Comme l'illustre l'exécution, la première ligne n'est pas chiffrée contrairement aux 2 autres.

Accès asynchrones aux fichiers

Pour comprendre cette notion, nous devons distinguer la différence qui existe entre des opérations synchrones et des opérations synchronisées.

  • Ecriture synchrone : ne retourne pas jusqu'à ce que les données soient envoyées vers le buffer du noyau (à l'inverse, une écriture asynchrone retourne immédiatement avant même que les données ne quittent l'espace utilisateur).
  • Lecture synchrone : ne retourne pas jusqu'à ce que les données sont bien recopiées dans le buffer fourni par l'application utilisateur (à l'inverse, une lecture asynchrone retourne  avant que les données ne soient disponible dans le buffer utilisateur).
  • Opération synchronisée : est plus stricte que le mode synchrone. Une écriture synchronisée assure que les données sont bien sauvegardées sur le disque (et pas seulement dans le buffer kernel), et une lecture synchronisée assura la récupération des données à jour.

Par défaut; sous Linux, l'écriture est synchrone et la lecture est synchronisée.

Ce comportement (bloquant jusqu'à complétion) est acceptable pour la plupart des applications. Cependant, certains besoins font l'objet d'exception et requièrent des appels I/O asynchrones. Linux répond à ce besoin et définit plusieurs APIs dont les principales sont les suivantes :

int aio_read(struct aiocb *aiocbp); // Lance une lecture I/O asynchrone.
int aio_write(struct aiocb *aiocbp); // Lance une écriture I/O asynchrone.
int aio_error (const struct aiocb *aiocbp); // Renvoie le status des erreurs I/O.

La structure aiocb est définit comme ceci :

struct aiocb {
	/* L'ordre des champs est spécifique à l'implémentation */

	int             aio_fildes;     /* Descripteur du fichier */
	off_t           aio_offset;     /* Offset du fichier */
	volatile void  *aio_buf;        /* Buffer de données */
	size_t          aio_nbytes;     /* Longueur des données */
	int             aio_reqprio;    /* Priorité de la requête */
	struct sigevent aio_sigevent;   /* Méthode de notification */

};

Le fonctionnement des appels I/O asynchrone repose entièrement sur la gestion des signaux. Une fois, l'opération asynchrone achevée, un signal est généré (donc l'application doit  déclarer le handler à exécuter).

Exemple (aiocb.c)

Dans cet exemple, le programme va procéder avec les étapes suivantes:
  • L'utilisateur entre une chaîne de caractères.
  • Le programme lance une écriture asynchrone sur un fichier.
  • Une fois l'écriture asynchrone achevée, une lecture asynchrone sera lancée.
  • Une fois la lecture asynchrone terminée, on affiche le contenu du fichier.

Nous allons également diviser le code en 2 parties :

  • Partie 1 : Définition des callbacks à appeler lors de la fin de la lecture et de l'écriture.
    #include <fcntl.h>
    #include <stdlib.h>
    #include <unistd.h>
    #include <stdio.h>
    #include <errno.h>
    #include <aio.h>
    #include <string.h>
    #include <signal.h>
    
    #define MAX_AIOCB_OPERATIONS 2 // Lecture + Ecriture.
    #define MAX_USER_INPUT_STRING_LENGTH 50
    #define FILENAME "aiocb.txt" // Fichier d'écriture.
    
    struct aiocb cb[MAX_AIOCB_OPERATIONS];
    
    
    char bufToWriteOut[MAX_USER_INPUT_STRING_LENGTH];
    char bufToReadIn[MAX_USER_INPUT_STRING_LENGTH];
    
    FILE * file;
    volatile bool writeReadComplet = false;
    
    void printUsage(){
    	printf("Usage : $./aiocb INPUT_STRING\n");
    	exit(EXIT_SUCCESS);
    }
    
    // Callback des opérations asynchrones lecture et écriture.
    void aio_handler(int signal, siginfo_t *info, void*uap)
    {
    	int cbNumber = info->si_value.sival_int;
    	if(cbNumber==0) { // Si l'écriture est achevée.
    		printf("AIO wrote %s completed to file %s\n",
    		       bufToWriteOut, FILENAME);
    		aio_read(&cb[1]); // Lancer la lecture.
    	}
    	else{ // Si l'écriture est terminée.
    		printf("AIO read %s from file %s\n",
    		       bufToReadIn, FILENAME);
    		fclose(file);
    		writeReadComplet = true; // Arrêter le programme.
    	}
    }
    
  • Partie 2 : La fonction main (qui lance l'écriture).
    int main(int argc, char *argv[]){
    	struct sigaction action;
    
    	if(argc != 2) {
    		printUsage();
    	}
    	memset(bufToWriteOut, '\0', MAX_USER_INPUT_STRING_LENGTH);
    	memset(bufToReadIn, '\0', MAX_USER_INPUT_STRING_LENGTH);
    
    	// Récupérer la chaine de charactères utilisateur.
    	snprintf(bufToWriteOut, MAX_USER_INPUT_STRING_LENGTH, argv[1]);
    	file = fopen(FILENAME, "w+");
    
    	// Configuration des IOs asynchrones.
    	for(int i = 0; i < MAX_AIOCB_OPERATIONS; i++) {
    		cb[i].aio_fildes = fileno(file);
    		if(i==0) // Le cas d'une écriture
    			cb[i].aio_buf = bufToWriteOut;
    		else // Le cas d'une lecture
    			cb[i].aio_buf = bufToReadIn;
    		cb[i].aio_nbytes = MAX_USER_INPUT_STRING_LENGTH;
    		action.sa_sigaction = aio_handler;
    		action.sa_flags = SA_SIGINFO;
    		sigemptyset(&action.sa_mask);
    		cb[i].aio_sigevent.sigev_notify = SIGEV_SIGNAL;
    		cb[i].aio_sigevent.sigev_signo = SIGIO;
    		cb[i].aio_sigevent.sigev_value.sival_int = i;
    	}
    
    	sigaction(SIGIO, &action, NULL);
    
    	aio_write(&cb[0]); // Lancer l'écriture
    
    	// En production, la boucle doit être remplacée car le but
    	// des IO asynchrones est de permettre au programme de continuer
    	// son cycle de vie sans aucun bloquage.
    	while(!writeReadComplet) {sleep(1);}
    
    	return EXIT_SUCCESS;
    }
    

Le programme devra s'exécuter de la manière suivante :

$ gcc aiocb.c -o aiocb -lrt
$ ./aiocb hello_world
AIO wrote hello_world completed to file aiocb.txt
AIO read hello_world from file aiocb.txt

Assister le kernel avec des recommandations I/O

Il est possible pour les applications utilisateur d'informer le noyau sur la manière dont elles souhaitent  accéder aux fichiers.
En effet, Linux peut choisir à tout moment d'appliquer des optimisations pour réduire l'impacte des accès I/O sur le système.

Par exemple : si un processus demande une lecture sur une partie d'un fichier, il est probable qu'il va redemander la suite. Linux peut choisir de charger l'autre partie dans le cache, une chose qui fait que le prochain read() sera servi depuis la mémoire (mais si aucune autre demande n'est faites, des cycles CPU sont utilisés pour rien et la page contient des données qui ne seront jamais utilisées).

C'est pour cela qu'il est toujours recommandé aux applications de manifester la manière d'accèder aux fichiers auprès du kernel avec l'API posix_fadvise suivante :

int posix_fadvise(int fd,
		  off_t offset,
		  off_t len,
		  int advice);

N.B : Généralement 0 est passé pour les champs offset et len afin d'inclure tout le contenu du fichier.

Le champ advice est susceptible de prendre plusieurs valeurs comme : POSIX_FADV_SEQUENTIAL (charger la prochaine partie du fichier avant demande mais après le premier accès), POSIX_FADV_RANDOM (inutile de charger la suite car l'accès est aléatoire) ou même POSIX_FADV_WILLNEED (charger le fichier d'une manière asynchrone avant tout appel à read()).

L'ordonnanceur I/O et les priorités des accès

Nous avons abordé précédemment dans l'article Ordonnancement temps réel souple et affinité CPU sous Linux Vanilla la manière utilisée pour assurer la virtualisation des ressources CPUs et permettre de faire du multi-tâche.

En plus de ce dernier, il existe aussi un ordonnanceur pour gérer les ressources disque et la synchronisation des flux I/O. à l'instar du scheduler des processus, l'ordonnanceur I/O possède aussi plusieurs politiques comme : Linux elevator, Deadline, Anticipatory et CFQ (le plus utilisé).

L'ordonnanceur I/O est fortement influencé par la priorité d'ordonnancement du processus :
  • Si le processus est soumis à une politique d'ordonnancement temps partagé, la priorité des I/O de ce dernier est dérivée de sa valeur "nice".
    Linux nous permet de booster la priorité I/O manuellement de 2 manières principales :
    • ionice du package util-linux
    • Les appels systèmes fournit ci dessous
      int ioprio_get (int which, int who)
      int ioprio_set (int which, int who, int ioprio)
      
  • Si la politique est temps réel (SCHED_FIFO, SCHED_RR), les I/O sont toujours plus prioritaires.
    N.B : attention, les processus temps réel peuvent pénaliser les autres processus lors des accès disque et créer un problème de famine (les I/O doivent être modérés).

Important : seul l'ordonnanceur I/O de type CFQ supporte le concept de priorité.

Comment changer la politique d'ordonnancement I/O?

Chaque disque possède son fichier de configuration qui lui est propre sur : /sys/block/[disque]/queue/scheduler. 

$ cat /sys/block/sda/queue/scheduler
noop deadline [cfq]

Il suffit d'écrire une autre valeur dans ce fichier pour changer la politique; par exemple, le mode "noop" (qui ne fait pas de tri sur les requêtes I/O) est plus intéressant dans certains cas sur un disque SSD (# echo noop > /sys/block/[disque_ssd]/queue/scheduler) car les temps d'accès sont quasiment pareils sur toutes les cellules (plus besoin de la complexité du trie).

Conclusion

Dans cet article, nous avons découvert les fonctionnalités avancées de la gestion I/O qui permettent de traquer, d'ajouter des métadonnées, de sécuriser et même d'augmenter les performances pour moins pénaliser le reste du système.

Il faut toujours garder à l'esprit que seuls une petite partie des applications ont besoin de bénéficier de ce genre de techniques. Il faut également prendre le temps de bien les mettre en place sinon elles peuvent s'avérer dangereuses si mal intégrées (des ACLs qui bloquent des utilisateurs après un mauvais chmod, des lectures asynchrones en erreurs, etc.).

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée.