Clarifications sur dispatch_queue, reentrancy et deadlocks

J'ai besoin de clarifications sur la façon dont dispatch_queue s est lié à la réentrance et aux blocages.

En lisant ce post de blog sur Thread Safety Basics sur iOS / OS X , j'ai rencontré cette phrase:

Toutes les files d'attente de répartition ne sont pas réentrantes, ce qui signifie que vous serez bloqué si vous tentez d'envoyer dispatch_sync dans la queue actuelle.

Alors, quelle est la relation entre la réentrance et l'impasse? Pourquoi, si un dispatch_queue est non-réentrant, un blocage se produit-il lorsque vous utilisez appel dispatch_sync ?

À ma connaissance, vous pouvez avoir un blocage en utilisant dispatch_sync seulement si le thread que vous exécutez est le même thread où le bloc est envoyé.

Un exemple simple est le suivant. Si je cours le code dans le fil principal, puisque le dispatch_get_main_queue() attrapera également le fil principal et je finirai dans un interblocage.

 dispatch_sync(dispatch_get_main_queue(), ^{ NSLog(@"Deadlock!!!"); }); 

Des éclaircissements?

Toutes les files d'attente de répartition ne sont pas réentrantes, ce qui signifie que vous serez bloqué si vous tentez d'envoyer dispatch_sync dans la queue actuelle.

Alors, quelle est la relation entre la réentrance et l'impasse? Pourquoi, si un dispatch_queue est non-réentrant, un blocage se produit-il lorsque vous utilisez appel dispatch_sync?

Sans avoir lu cet article, j'imagine que cette déclaration faisait reference aux files d'attente en série, parce que c'est autrement faux.

Considérons maintenant une vue conceptuelle simplifiée du fonctionnement des files d'attente de répartition (dans un pseudo-langage inventé). Nous supposons également une queue série, et ne considérons pas les files d'attente cibles.

Dispatch File d'attente

Lorsque vous créez une queue de répartition, vous obtenez essentiellement une queue FIFO, une structure de données simple dans laquelle vous pouvez pousser des objects à la fin et retirer des objects au premier plan.

Vous disposez également de mécanismes complexes pour gérer les pools d'unités d'exécution et effectuer la synchronisation, mais la plupart de ces fonctions sont destinées aux performances. Supposons simplement que vous obtenez également un thread qui exécute simplement une boucle infinie, en traitant les messages de la queue.

 void processQueue(queue) { for (;;) { waitUntilQueueIsNotEmptyInAThreadSaveManner(queue) block = removeFirstObject(queue); block(); } } 

dispatch_async

Prenant la même vue simplist de dispatch_async donne quelque chose comme ça …

 void dispatch_async(queue, block) { appendToEndInAThreadSafeManner(queue, block); } 

Tout ce qu'il fait, c'est prendre le bloc et l'append à la queue. C'est pourquoi il revient immédiatement, il ajoute simplement le bloc à la fin de la structure de données. À un certain point, cet autre thread retirera ce bloc de la queue et l'exécutera.

Notez que c'est là que la garantie FIFO entre en jeu. Le thread tirant des blocs de la queue et les exécutant les prend toujours dans l'ordre où ils ont été placés dans la queue. Il attend ensuite que ce bloc soit entièrement exécuté avant de retirer le bloc suivant de la queue

dispatch_sync

Maintenant, une autre vue simplist de dispatch_sync . Dans ce cas, l'API garantit qu'il attendra la fin du bloc avant de le renvoyer. En particulier, l'appel de cette fonction ne viole pas la garantie FIFO.

 void dispatch_sync(queue, block) { bool done = false; dispatch_async(queue, { block(); done = true; }); while (!done) { } } 

Maintenant, cela est fait avec des sémaphores donc il n'y a pas de loops cpu et de drapeau boolean, et il n'utilise pas de bloc séparé, mais nous essayons de le garder simple. Vous devriez avoir l'idée.

Le bloc est placé dans la queue, puis la fonction attend jusqu'à ce qu'il sache avec certitude que "l'autre thread" a exécuté le bloc jusqu'à la fin.

Réinput

Maintenant, nous pouvons get un appel réentrant de plusieurs façons différentes. Considérons le plus évident.

 block1 = { dispatch_sync(queue, block2); } dispatch_sync(queue, block1); 

Cela placera block1 dans la queue, et attendez qu'il s'exécute. Finalement, le thread qui traite la queue supprimera block1 et commencera à l'exécuter. Lorsque block1 est exécuté, il place block2 dans la queue, puis attend que l'exécution se termine.

Ceci est une signification de réentrance: lorsque vous ré-entrez un appel à dispatch_sync d'un autre appel à dispatch_async

Impasse de ré-entrer dispatch_sync

Cependant, block1 est maintenant en cours d'exécution dans la boucle for de la queue. Ce code exécute block1 et ne traitera plus rien de la file jusqu'à la fin de block1.

Block1, cependant, a placé block2 dans la queue, et attend qu'il se termine. Block2 a en effet été placé dans la queue, mais il ne sera jamais exécuté. Block1 "attend" que block2 soit terminé, mais block2 est placé dans une queue, et le code qui le retire de la queue et l'exécute ne s'exécutera pas jusqu'à la fin de block1.

Impasse de ne pas reentrer dispatch_sync

Maintenant, que faire si nous changeons le code à ceci …

 block1 = { dispatch_sync(queue, block2); } dispatch_async(queue, block1); 

Nous ne rentrons pas techniquement à dispatch_sync . Cependant, nous avons toujours le même scénario, c'est juste que le thread qui a démarré block1 n'attend pas qu'il finisse.

Nous sums toujours en train d'exécuter block1, en attendant que block2 se termine, mais le thread qui va exécuter block2 doit finir par block1 en premier. Cela n'arrivera jamais car le code à traiter block1 attend que block2 soit retiré de la queue et exécuté.

Ainsi, la réentrance des files d'attente d'expédition ne réintègre pas techniquement la même fonction, mais réintroduit le même traitement de queue.

Deadlocks de ne pas retenter la queue du tout

Dans son cas le plus simple (et le plus courant), supposons que [self foo] soit appelé sur le thread principal, comme c'est souvent le cas pour les callbacks de l'interface user.

 -(void) foo { dispatch_sync(dispatch_get_main_queue(), ^{ // Never gets here }); } 

Cela ne "ressaisit" pas l'API de la queue, mais cela a le même effet. Nous courons sur le fil principal. Le fil principal est où les blocs sont retirés de la queue principale et traités. Le thread principal exécute actuellement foo et un bloc est placé dans la queue principale, et foo attend alors que ce bloc soit exécuté. Cependant, il ne peut être retiré de la queue et exécuté qu'après que le thread principal ait terminé son travail en cours.

Cela n'arrivera jamais parce que le thread principal ne progressera pas jusqu'à ce que `foo se termine, mais il ne se terminera jamais tant que ce bloc n'attendra pas … ce qui n'arrivera pas.

À ma connaissance, vous pouvez avoir un blocage en utilisant dispatch_sync seulement si le thread que vous exécutez est le même thread où le bloc est envoyé.

Comme l'illustre l'exemple ci-dessus, ce n'est pas le cas.

En outre, il existe d'autres scénarios similaires, mais pas si évidents, en particulier lorsque l'access de sync est masqué dans les couches d'appels de méthode.

Éviter les blocages

Le seul moyen sûr d'éviter les blocages est de ne jamais appeler dispatch_sync (ce n'est pas tout à fait vrai, mais c'est assez proche). Cela est particulièrement vrai si vous exposez votre queue aux users.

Si vous utilisez une queue autonome et contrôlez ses files d'attente d'utilisation et de cible, vous pouvez conserver un certain contrôle lors de l'utilisation de dispatch_sync .

Il y a, en effet, des utilisations valides de dispatch_sync sur une queue en série, mais la plupart sont probablement imprudentes, et ne devraient être faites que lorsque vous savez que vous ne serez pas 'sync' accédant à la même ressource ou à une autre comme une étreinte mortelle).

MODIFIER

Jody, Merci beaucoup pour ta réponse. J'ai vraiment compris tous vos trucs. Je voudrais append plus de points … mais maintenant je ne peux pas. 😢 Avez-vous de bons conseils pour apprendre cela sous le capot? – Lorenzo B.

Malheureusement, les seuls livres sur GCD que j'ai vus ne sont pas très avancés. Ils passent en revue les trucs de surface faciles à utiliser pour des cas simples d'utilisation générale (ce que je suppose est ce qu'un livre de marché de masse est censé faire).

Cependant, GCD est open source. Voici la page web pour cela , qui comprend des liens vers leurs repositorys svn et git. Cependant, la page Web semble vieille (2010) et je ne suis pas sûr de la date à laquelle le code est récent. La plus récente validation dans le repository git date du 9 août 2012.

Je suis sûr qu'il y a des mises à jour plus récentes; mais je ne sais pas où ils seraient.

Quoi qu'il en soit, je doute que les frameworks conceptuels du code aient beaucoup changé au fil des ans.

En outre, l'idée générale des files d'attente d'expédition n'est pas nouvelle, et a été sous de nombreuses forms depuis très longtime.

Il y a plusieurs mois, j'ai passé mes jours (et nuits) à écrire du code kernel (j'ai travaillé sur ce que nous pensions être la toute première implémentation multiprocesseur symésortingque de SVR4), puis quand j'ai finalement brisé le kernel, Pilotes SVR4 STREAMS (enveloppés par des bibliothèques d'espace user). Finalement, je l'ai fait entièrement dans l'espace user, et construit certains des tout premiers systèmes HFT (bien que ce n'était pas appelé à l'époque).

Le concept de la queue était répandu dans chaque partie de cela. Son émergence en tant que bibliothèque d'espace user disponible généralement n'est qu'un développement relativement récent.

Modifier # 2

Jody, merci pour votre édition. Donc, pour récapituler une queue d'expédition en série n'est pas réentrante car elle pourrait produire un état invalide (un interblocage). Au contraire, une fonction réentrante ne le produira pas. Ai-je raison? – Lorenzo B.

Je suppose que vous pourriez le dire, car il ne prend pas en charge les appels réentrants.

Cependant, je pense que je préférerais dire que l'impasse est le résultat de la prévention d'un état invalide. Si quelque chose d'autre se produisait, alors l'état serait compromis ou la définition de la queue serait violée.

performBlockAndWait Core Data

Considérez -[NSManagedObjectContext performBlockAndWait] . C'est non asynchronous, et c'est réentrant. Il y a de la poussière de pixie répandue autour de l'access à la queue de sorte que le second bloc fonctionne immédiatement, lorsqu'il est appelé depuis la "queue". Ainsi, il a les traits que j'ai décrits ci-dessus.

 [moc performBlock:^{ [moc performBlockAndWait:^{ // This block runs immediately, and to completion before returning // However, `dispatch_async`/`dispatch_sync` would deadlock }]; }]; 

Le code ci-dessus ne "produit pas un blocage" à partir de la réinput (mais l'API ne peut pas éviter complètement les blocages).

Cependant, en fonction de la personne à qui vous parlez, cela peut produire un état invalide (ou imprévisible / inattendu). Dans cet exemple simple, il est clair que ce qui se passe, mais dans des parties plus compliquées, cela peut être plus insidieux.

À tout le less, vous devez faire très attention à ce que vous faites dans un performBlockAndWait .

Maintenant, en pratique, ce n'est qu'un problème réel pour les MOC de queue principale, car la boucle d'exécution principale est en cours d'exécution dans la queue principale, donc performBlockAndWait reconnaît et exécute immédiatement le bloc. Cependant, la plupart des applications ont un MOC attaché à la queue principale et répondent aux events de sauvegarde de l'user dans la queue principale.

Si vous souhaitez observer comment les files d'attente d'envoi interagissent avec la boucle d'exécution principale, vous pouvez installer un CFRunLoopObserver sur la boucle d'exécution principale et observer comment les différentes sources d'input sont traitées dans la boucle d'exécution principale.

Si vous n'avez jamais fait cela, c'est une expérience intéressante et éducative (même si vous ne pouvez pas supposer que ce que vous observez sera toujours comme ça).

Quoi qu'il en soit, j'essaie généralement d'éviter à la fois dispatch_sync et performBlockAndWait .