Published by

Il y a 7 ans -

Temps de lecture 11 minutes

iOS : Multi-threading, concurrence et GCD

GCD

Avez-vous déjà remarqué que les UI de certaines applications bloquent parfois systématiquement à l���exécution de certaines opérations ? C’est un signe alarmant indiquant que l’application devrait faire du multi-threading. 

Une fois ce signe repéré, ce qui est souvent le cas, nous sommes la plupart du temps confronté à des problématiques d’accès concurrentiels.  Afin de mettre en place des mécanismes permettant de gérer ces accès concurrentiels, différentes API sont disponibles : Grand Central Dispatch (GCD) ou encore NSOperation et NSOperationQueue. L’utilisation de GCD permet de disposer d’une flexibilité largement supérieure à NSOperation, nous allons voir dans cet article quelles sont ses forces et comment l’utiliser.

Mais qu’est-ce que c’est exactement GCD ?

GCD (Grand Central Dispatch) est une API bas niveau qui propose une nouvelle méthode afin de réaliser de la programmation concurrente. Cette API peut être comparée à NSOperationQueue dans la mesure où elle permet de diviser le travail d’un processus en différentes tâches individuelles qui sont elles mêmes exécutées simultanément ou séquentiellement dans des files d’attentes. 

L’API de GCD est fortement basée sur l’utilisation de blocks (je vous invite à lire cette introduction pour comprendre plus facilement la suite de cet article).

Pourquoi utiliser GCD ?

L’utilisation de GCD offre de nombreux avantages dans une application multi-thread pour les raisons suivantes :

  • La facilité d’usage : il est incontestablement plus facile de travailler avec GCD que d’utiliser des méthodes comme performSelector:onThread:withObject:waitUntilDone: et de gérer soi-même les threads. En définitif, avec GCD les développeurs créent des blocks et des files d’attentes plutôt que des threads. GCD s’abstrait des threads et l’API étant basée sur des blocks, il est extrêmement facile de communiquer entre deux sections données du code.
  • L’efficacité : L’implémentation de GCD est très légère, ce qui rend son utilisation très facile, rapide et peu couteuse. Ce qui n’est pas forcément le cas lorsque l’on crée des threads dédiés.
  • La scalabilité : GCD scale automatiquement. L’API utilise autant de threads qu’il est nécessaire selon le contexte de l’application.

Les dispatch queues

Avant de voir GCD en pratique, il est important de comprendre les dispatch queues. C’est un concept fondamental de GCD. Une dispatch queue peut être assimilée à un objet qui gère des jobs et qui les exécute.

Une dispatch queue peut être de deux types :

  • Simultanée (concurrent) : une dispatch queue de ce type va exécuter des jobs simultanément.
  • En série (serial) : une dispatch queue de ce type n’exécutera qu’un seul job à la fois.

Parmi ces 2 types, on distingue 3 catégories de dispatch queue :

  • La main queue. Elle est comparable au main thread. En effet, les jobs qui seront confiés à la main queue seront exécutés sur le main thread. La main queue peut être obtenue en appelant dispatch_get_main_queue().
  • Les global queues. Ce sont des files d’attentes simultanées et partagées dans toute l’application. Il existe 3 global queues avec des priorités différentes : high, default et low. Les global queues peuvent être obtenues en appelant dispatch_get_global_queue en passant en paramètre la priorité souhaitée (exemple : dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)).
  • Les custom queues : ce sont des files d’attentes personnelles qui peuvent être créées avec dispatch_queue_create. Ces files d’attentes peuvent être simultanées ou en séries.

GCD en pratique

Maintenant que nous avons vu ce qu’est GCD et ses principes de base, je vous propose de voir comment il est possible de l’utiliser afin d’implémenter un petit composant. Nous allons créer ici un outil qui permet aux développeurs de partager des informations de manière très simpliste et cela quel que soit le contexte de l’application : ce composant doit être accessible à tout moment aussi bien en lecture et en écriture. C’est ici que la puissance de GCD va pouvoir être exploitée afin de gérer les accès concurrents.

L’outil sera sous la forme d’un singleton qui encapsule un NSDictionary afin de partager les informations souhaitées.

Création du singleton

Même dans le processus de création du singleton, GCD nous facilite la tâche. En effet, GCD dispose de la méthode dispatch_once qui permet de s’assurer qu’un block ne sera exécuté qu’une seule et unique fois pendant l’exécution de notre application. La méthode dispatch_once prend en paramètre un predicate (qui est en réalité un long, qui est plutôt utilisé comme un booléen) afin de savoir si le block a déjà été exécuté ou pas. Le deuxième paramètre, quant à lui, est le block en question que l’on souhaite exécuter qu’une seule fois. Voici comment dispatch_once est utilisé pour la création de notre singleton :

+ (instancetype)sharedObject
{
 static id object;
 static dispatch_once_t predicate;


 dispatch_once(&predicate, ^{
  object = [[self alloc] init];
 });

 return object;
}

 En plus de s’assurer que le block n’est exécuté qu’une seule fois, dispatch_once est aussi thread-safe.

Création de la custom queue

Maintenant que nous disposons d’un singleton, plongeons nous dans le vif du sujet avec la création de notre NSDictionary et de la custom queue qui sera utilisée pour accéder au dictionnaire :

@property (strong, nonatomic) dispatch_queue_t queue;
@property (strong, nonatomic) NSMutableDictionary *cacheDictionary;

Voici à quoi ressemble la création de la custom queue. Dans notre exemple, un bon endroit pour la créer est dans la méthode init appelée depuis le block du dispatch_once vu ci-dessus :

- (id)init
{
    self = [super init];
    if (self) {
        _queue = dispatch_queue_create([@"fr.xebia.XBMemoryCache" UTF8String], DISPATCH_QUEUE_CONCURRENT);
        _cacheDictionary = [[NSMutableDictionary alloc] init];
    }
    return self;
}

dispatch_queue_create prends en paramètre un label permettant d’identifier la file d’attente et un attribut permettant de spécifier si l’on souhaite une file en série ou simultanée. 

Dans notre cas nous choisissons une file d’attente simultanée. À cette étape on peut se demander pourquoi choisir une file simultanée si l’on souhaite gérer des accès concurrents à notre NSDictionary. La raison est très simple : un NSDictionary gère les accès concurrents en lecture, le fait de disposer d’une file simultanée va nous permettre d’autoriser les accès concurrents en lecture. C’est donc afin d’optimiser notre composant que nous choisissons ce type de file d’attente. La partie intéressante et subtile concerne les opérations d’écriture qui vont devoir s’assurer qu’aucun accès en lecture n’est en train d’être effectué pendant l’écriture.

 Concernant le premier paramètre de dispatch_queue_create, Apple recommande d’adopter un style de label type ‘reverse-DNS’

Les méthodes de lecture et d’écriture version asynchrone

Maintenant que nous disposons du singleton, il est temps d’écrire les différentes méthodes afin d’accéder en lecture et en écriture au NSDictionary de notre objet. Comme indiqué dans le chapitre précédent, il est important de s’assurer qu’aucun accès n’est effectué lorsque l’on souhaite écrire une nouvelle valeur dans notre NSDictionary. Cela implique que lorsque l’on va écrire une nouvelle valeur, il va falloir attendre que toutes les autres lectures et écritures soient terminées mais il faudra aussi empêcher toute nouvelle opération de lecture ou d’écriture jusqu’à ce que la notre se termine.

Cet ensemble de contraintes induit que de potentiels blocages (par GCD) vont avoir lieu lors des opérations de lecture ou d’écriture. De ce fait, nous allons implémenter les méthodes de lecture et d’écriture de manière asynchrone et la fin des opérations sera signalée grâce à un block :

typedef void (^XBBlock)(NSString *key, id object);
La lecture

Ici, nous allons nous assurer que chaque opération de lecture passe par notre custom queue afin qu’elle puisse être éventuellement bloquée (en cas d’écriture concurrente par exemple). Pour cela, nous allons utiliser la fonction dispatch_async qui prend en paramètre une file d’attente et un block à exécuter dans cette file. Le block en paramètre de dispatch_async sera exécuté quand son tour sera venu.

- (void)objectForKey:(NSString *)aKey onFinishBlock:(XBBlock)block
{
    dispatch_async(_queue, ^{
        id object = [_cacheDictionary objectForKey:aKey];
 
        block(aKey, object);
    });
}

Contrairement à dispatch_sync, dispatch_async n’attend pas l’exécution du block avant d’exécuter la suite, vous comprenez mieux pourquoi nous avons besoin d’appeler un block callback afin de notifier l’utilisateur de la fin de la lecture.

L’écriture avec les dispatch barriers

Comme expliqué en début de chapitre, la méthode d’écriture doit attendre la fin de l’ensemble des opérations en cours et doit empêcher toute autre écriture ou lecture pendant qu’une écriture s’exécute. C’est ici que la magie opère grâce aux dispatch barriers.

Les dispatch barriers sont étroitement liées aux files d’attentes concurrentes. Elles peuvent être utilisées via deux fonctions : dispach_barrier_async et dispach_barrier_sync. Ces fonctions se comportent comme dispatch_async et dispatch_sync sauf que le block passé en paramètre n’est pas exécuté de manière concurrente. Au contraire, la barrière attend que tous les autres jobs concurrents se finissent, et ensuite elle bloque tout nouveau job de la queue en question tant que le block de la barrière s’exécute.

 Les fonctions barriers (dispach_barrier_async et dispach_barrier_sync) sont inutiles dans le cadre des files d’attentes en série car les jobs sont executés les uns après les autres.

Voici comment utiliser dispatch_barrier_async afin d’implémenter notre méthode d’écriture :

- (void)setObject:(id)object forKey:(NSString *)aKey onFinishBlock:(XBBlock)block
{
    dispatch_barrier_async(_queue, ^{
        [_cacheDictionary setObject:object forKey:aKey];
        
        if (block) {
            dispatch_async(_queue, ^{
                block(aKey, object);
            });
        }
    });
}

Ainsi, grâce à la fonction "barrier", un accès exclusif au NSDictionary est assuré pendant que le block est exécuté. Non seulement toutes les autres écritures sont bloquées pendant que le block de la barrière s’exécute, mais toutes les autres lectures le sont aussi ce qui rend la modification du NSDictionary totalement sûre.

La même approche peut être utilisée pour n’importe quelle opération modifiant le NSDictionary, comme la suppression d’un objet :

- (void)removeObjectForKey:(NSString *)aKey onFinishBlock:(XBBlock)block
{
    if (!aKey) {
        return;
    }
    
    dispatch_barrier_async(_queue, ^{
        [_cacheDictionary removeObjectForKey:aKey];
        
        if (block) {
            dispatch_async(_queue, ^{
                block(aKey, nil);
            });
        }
    });
}

Lecture et écriture version synchrone

Nous avons vu jusqu’à maintenant comment rendre complètement thread-safe la lecture et l’écriture à notre NSDictionary, mais cela de manière asynchrone. Parfois il est plus pratique de disposer de méthodes synchrones. Pour cela, nous allons simplement utiliser des sémaphores afin d’attendre que nos lectures ou écritures finissent.

Voici comment wrapper les méthodes asynchrones :

- (id)objectForKey:(NSString *)aKey
{
    if (!aKey) {
        return nil;
    }
 
    __block id result = nil;
 
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
 
    [self objectForKey:aKey onFinishBlock:^(NSString *key, id object) {
        result = object;
        dispatch_semaphore_signal(semaphore);
    }];
 
    dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
 
    return result;
}

La fonction dispatch_semaphore_wait permet d’attendre que le job asynchrone finisse complètement afin de continuer. La fin du job est signalée grâce à la méthode dispatch_semaphore_signal. De la même manière, il est possible de rendre l’opération d’écriture synchrone :

- (void)setObject:(id)object forKey:(NSString *)aKey
{
    if (!aKey || !object) {
        return;
    }
    
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
    
    [self setObject:object forKey:aKey onFinishBlock:^(NSString *key, id object) {
        dispatch_semaphore_signal(semaphore);
    }];
    
    dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
}

Conclusion

GCD est une technologie incroyable qui permet de faciliter un grand nombre de choses. Vous savez maintenant comment créer des dispatch queues (en séries ou concurrentes), comment soumettre des jobs à ces dispatch queues et surtout comment utiliser ces queues à la place de traditionnels locks dans des applications multithreadées. Vous avez aussi pu voir comme utiliser les dispatch semaphores dans le cadre de la création d’un singleton. 

Grâce à cet article, vous disposez maintenant des bases de Grand Central Dispatch. Pour aller plus loin, je vous invite à consulter les vidéos d’Apple traitant de GCD : les WWDC de 2012, 2011 ou encore 2010 abordent ce sujet, alors bonne visualisation !

 

Published by

Commentaire

1 réponses pour " iOS : Multi-threading, concurrence et GCD "

  1. Published by , Il y a 5 ans

    Merci pour ce cours.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.