web-dev-qa-db-fra.com

L'immutabilité élimine-t-elle entièrement le besoin de verrous dans la programmation multiprocesseur?

Partie 1

De toute évidence, l'immuabilité minimise le besoin de verrous dans la programmation multiprocesseur, mais élimine-t-elle ce besoin, ou existe-t-il des cas où l'immuabilité seule ne suffit pas? Il me semble que vous ne pouvez différer le traitement et encapsuler l'état que si la plupart des programmes doivent réellement FAIRE quelque chose (mettre à jour un magasin de données, produire un rapport, lever une exception, etc.). Ces actions peuvent-elles toujours être effectuées sans verrou? La simple action de jeter chaque objet et d'en créer un nouveau au lieu de changer l'original (une vision grossière de l'immuabilité) offre-t-elle une protection absolue contre les conflits entre processus, ou y a-t-il des cas d'angle qui nécessitent toujours un verrouillage?

Je connais beaucoup de programmeurs fonctionnels et de mathématiciens qui aiment parler "pas d'effets secondaires" mais dans le "monde réel" tout a un effet secondaire, même si c'est le temps qu'il faut pour exécuter une instruction machine. Je m'intéresse à la fois à la réponse théorique/académique et à la réponse pratique/réelle.

Si l'immuabilité est sûre, compte tenu de certaines limites ou hypothèses, je veux savoir quelles sont exactement les frontières de la "zone de sécurité". Quelques exemples de limites possibles:

  • E/S
  • Exceptions/erreurs
  • Interactions avec des programmes écrits dans d'autres langues
  • Interactions avec d'autres machines (physiques, virtuelles ou théoriques)

Un merci spécial à @JimmaHoffa pour son commentaire qui a commencé cette question!

Partie 2

La programmation multiprocesseur est souvent utilisée comme technique d'optimisation - pour accélérer l'exécution du code. Quand est-il plus rapide d'utiliser des verrous que des objets immuables?

Compte tenu des limites énoncées dans loi d'Amdahl , quand pouvez-vous obtenir de meilleures performances globales (avec ou sans le garbage collector pris en compte) avec des objets immuables contre des objets mutables verrouillables?

Sommaire

Je combine ces deux questions en une seule pour essayer de déterminer où se situe la boîte englobante pour l'immuabilité comme solution aux problèmes de thread.

39
GlenPeterson

Il s'agit d'une question étrangement formulée qui est vraiment, vraiment large si elle répond pleinement. Je vais me concentrer sur la clarification de certains des détails que vous posez.

L'immuabilité est un compromis de conception. Cela rend certaines opérations plus difficiles (modification rapide de l'état dans de grands objets, construction d'objets au coup par coup, maintien d'un état en cours d'exécution, etc.) au profit d'autres (débogage plus facile, raisonnement plus facile sur le comportement du programme, ne pas avoir à se soucier des choses qui changent sous vous lorsque vous travaillez simultanément, etc.). C'est cette dernière qui nous intéresse avec cette question, mais je tiens à souligner qu'il s'agit d'un outil. Un bon outil qui résout souvent plus de problèmes qu'il n'en provoque (dans la plupart des programmes modernes ), mais pas une solution miracle ... Pas quelque chose qui change l'intrinsèque comportement des programmes.

Maintenant, qu'est-ce que ça vous apporte? L'immuabilité vous apporte une chose: vous pouvez lire l'objet immuable librement, sans vous soucier de son état qui change en dessous de vous (en supposant qu'il soit vraiment profondément immuable ... Avoir un objet immuable avec des membres mutables est généralement un facteur de rupture). C'est ça. Cela vous évite d'avoir à gérer la concurrence (via des verrous, des instantanés, un partitionnement de données ou d'autres mécanismes; la question initiale sur les verrous est ... Incorrect compte tenu de la portée de la question).

Il s'avère cependant que beaucoup de choses lisent des objets. IO le fait, mais IO lui-même a tendance à ne pas bien gérer l'utilisation simultanée. Presque tous les traitements le font, mais d'autres objets peuvent être mutables, ou le traitement lui-même peut utiliser un état qui n'est pas convivial pour la concurrence. La copie d'un objet est un gros problème caché dans certaines langues car une copie complète n'est (presque) jamais une opération atomique. C'est là que les objets immuables vous aident.

Quant aux performances, cela dépend de votre application. Les verrous sont (généralement) lourds. Les autres mécanismes de gestion des accès concurrents sont plus rapides mais ont un impact important sur votre conception. En général, une conception hautement concurrente qui utilise des objets immuables (et évite leurs faiblesses) fonctionnera mieux qu'une conception hautement concurrente qui verrouille les objets mutables. Si votre programme est légèrement simultané, cela dépend et/ou n'a pas d'importance.

Mais les performances ne devraient pas être votre plus grande préoccupation. L'écriture de programmes simultanés est difficile . Le débogage de programmes simultanés est difficile. Les objets immuables contribuent à améliorer la qualité de votre programme en éliminant les possibilités d'erreur lors de l'implémentation manuelle de la gestion des accès concurrents. Ils facilitent le débogage car vous n'essayez pas de suivre l'état dans un programme simultané. Ils rendent votre conception plus simple et éliminent ainsi les bogues.

Donc, pour résumer: l'immuabilité aide mais n'élimine pas les défis nécessaires pour gérer correctement la concurrence. Cette aide a tendance à être omniprésente, mais les gains les plus importants sont du point de vue de la qualité plutôt que de la performance. Et non, l'immuabilité ne vous excuse pas comme par magie de gérer la concurrence dans votre application, désolé.

35
Telastyn

Une fonction qui accepte une certaine valeur et renvoie une autre valeur, et ne dérange rien en dehors de la fonction, n'a aucun effet secondaire et est donc thread-safe. Si vous voulez considérer des choses comme la façon dont la fonction s'exécute affecte la consommation d'énergie, c'est un problème différent.

Je suppose que vous faites référence à une machine complète de Turing qui exécute une sorte de langage de programmation bien défini, où les détails de mise en œuvre ne sont pas pertinents. En d'autres termes, peu importe ce que fait la pile, si la fonction que j'écris dans le langage de programmation de mon choix peut garantir l'immuabilité dans les limites du langage. Je ne pense pas à la pile quand je programme dans un langage de haut niveau, et je ne devrais pas avoir à le faire.

Pour illustrer comment cela fonctionne, je vais vous proposer quelques exemples simples en C #. Pour que ces exemples soient vrais, nous devons faire quelques hypothèses. Premièrement, que le compilateur suit la spécification C # sans erreur, et deuxièmement, qu'il produit des programmes corrects.

Disons que je veux une fonction simple qui accepte une collection de chaînes et renvoie une chaîne qui est une concaténation de toutes les chaînes de la collection séparées par des virgules. Une implémentation simple et naïve en C # pourrait ressembler à ceci:

public string ConcatenateWithCommas(ImmutableList<string> list)
{
    string result = string.Empty;
    bool isFirst = false;

    foreach (string s in list)
    {
        if (isFirst)
            result += s;
        else
            result += ", " + s;
    }
    return result;
} 

Cet exemple est immuable, à première vue. Comment le sais-je? Parce que l'objet string est immuable. Cependant, la mise en œuvre n'est pas idéale. Étant donné que result est immuable, un nouvel objet chaîne doit être créé à chaque fois dans la boucle, remplaçant l'objet d'origine vers lequel result pointe. Cela peut affecter négativement la vitesse et exercer une pression sur le ramasse-miettes, car il doit nettoyer toutes ces chaînes supplémentaires.

Maintenant, disons que je fais ceci:

public string ConcatenateWithCommas(ImmutableList<string> list)
{
    var result = new StringBuilder();
    bool isFirst = false;

    foreach (string s in list)
    {
        if (isFirst)
            result.Append(s);
        else
            result.Append(", " + s);
    }
    return result.ToString();
} 

Notez que j'ai remplacé stringresult par un objet modifiable, StringBuilder. C'est beaucoup plus rapide que le premier exemple, car une nouvelle chaîne n'est pas créée à chaque fois dans la boucle. Au lieu de cela, l'objet StringBuilder ajoute simplement les caractères de chaque chaîne à une collection de caractères et génère le tout à la fin.

Cette fonction est-elle immuable, même si StringBuilder est modifiable?

Oui, ça l'est. Pourquoi? Parce que chaque fois que cette fonction est appelée, un nouveau StringBuilder est créé, juste pour cet appel. Nous avons donc maintenant une fonction pure qui est thread-safe, mais contient des composants mutables.

Et si je faisais ça?

public class Concatenate
{
    private StringBuilder result = new StringBuilder();
    bool isFirst = false;

    public string ConcatenateWithCommas(ImmutableList<string> list)
    {
        foreach (string s in list)
        {
            if (isFirst)
                result.Append(s);
            else
                result.Append(", " + s);
        }
        return result.ToString();
    } 
}

Cette méthode est-elle compatible avec les threads? Non, ça ne l'est pas. Pourquoi? Parce que la classe détient maintenant l'état dont dépend ma méthode. Une condition de concurrence critique est maintenant présente dans la méthode: un thread peut modifier IsFirst, mais un autre thread peut exécuter la première Append(), auquel cas j'ai maintenant une virgule au début de ma chaîne qui n'est pas censée être là.

Pourquoi pourrais-je vouloir le faire comme ça? Eh bien, je pourrais vouloir que les threads accumulent les chaînes dans mon result sans égard à l'ordre, ou dans l'ordre dans lequel les threads entrent. Peut-être que c'est un enregistreur, qui sait?

Quoi qu'il en soit, pour y remédier, j'ai mis une instruction lock autour des entrailles de la méthode.

public class Concatenate
{
    private StringBuilder result = new StringBuilder();
    bool isFirst = false;
    private static object locker = new object();

    public string AppendWithCommas(ImmutableList<string> list)
    {
        lock (locker)
        {
            foreach (string s in list)
            {
                if (isFirst)
                    result.Append(s);
                else
                    result.Append(", " + s);
            }
            return result.ToString();
        }
    } 
}

Maintenant, il est à nouveau thread-safe.

La seule façon dont mes méthodes immuables pourraient ne pas réussir à être thread-safe est si la méthode laisse en quelque sorte fuir une partie de son implémentation. Cela pourrait-il arriver? Pas si le compilateur est correct et que le programme est correct. Aurai-je jamais besoin de verrous sur de telles méthodes? Non.

Pour un exemple de la façon dont l'implémentation pourrait éventuellement être divulguée dans un scénario de concurrence, voir ici .

13
Robert Harvey

Je ne sais pas si j'ai compris vos questions.

À mon humble avis, la réponse est oui. Si tous vos objets sont immuables, vous n'avez pas besoin de verrous. Mais si vous devez conserver un état (par exemple, vous implémentez une base de données ou vous devez agréger les résultats de plusieurs threads), vous devez utiliser la mutabilité et donc également les verrous. L'immutabilité élimine le besoin de verrous, mais vous ne pouvez généralement pas vous permettre d'avoir des applications complètement immuables.

Réponse à la partie 2 - les verrous doivent toujours être plus lents que pas de verrous.

4
Maros

Vous commencez avec

clairement l'immuabilité minimise le besoin de verrous dans la programmation multiprocesseur

Faux. Vous devez lire attentivement la documentation de chaque classe que vous utilisez. Par exemple, const std :: string en C++ est pas sûr pour les threads. Les objets immuables peuvent avoir un état interne qui change lors de leur accès.

Mais vous regardez cela d'un point de vue totalement faux. Peu importe qu'un objet soit immuable ou non, ce qui compte, c'est de le changer. Ce que vous dites, c'est comme dire "si vous ne passez jamais un examen de conduite, vous ne pouvez jamais perdre votre permis de conduire pour conduite avec facultés affaiblies". C'est vrai, mais il manque plutôt le point.

Maintenant, dans l'exemple de code que quelqu'un a écrit avec une fonction nommée "ConcatenateWithCommas": Si l'entrée était modifiable et que vous utilisiez un verrou, que gagneriez-vous? Si quelqu'un d'autre essaie de modifier la liste pendant que vous essayez de concaténer les chaînes, un verrou peut vous empêcher de planter. Mais vous ne savez toujours pas si vous concaténez les chaînes avant ou après que l'autre thread les a modifiées. Votre résultat est donc plutôt inutile. Vous avez un problème qui n'est pas lié au verrouillage et ne peut pas être résolu avec le verrouillage. Mais si vous utilisez des objets immuables et que l'autre thread remplace l'objet entier par un nouveau, vous utilisez l'ancien objet et non le nouvel objet, donc votre résultat est inutile. Vous devez penser à ces problèmes à un niveau fonctionnel réel.

4
gnasher729

L'encapsulation d'un groupe d'états liés dans une seule référence mutable à un objet immuable peut permettre d'effectuer de nombreux types de modifications d'état sans verrouillage à l'aide du modèle:

do
{
   oldState = someObject.State;
   newState = oldState.WithSomeChanges();
} while (Interlocked.CompareExchange(ref someObject.State, newState, oldState) != oldState;

Si deux threads essaient tous les deux de mettre à jour someObject.state simultanément, les deux objets liront l'ancien état et détermineront ce que serait le nouvel état sans changements l'un de l'autre. Le premier thread à exécuter CompareExchange stockera ce qu'il pense que l'état suivant devrait être. Le deuxième thread constatera que l'état ne correspond plus à ce qu'il avait lu précédemment, et recalculera donc le bon état suivant du système avec les modifications du premier thread prises en compte.

Ce modèle présente l'avantage qu'un thread qui est guidé ne peut pas bloquer la progression des autres threads. Il a en outre l'avantage que, même en cas de conflit intense, certains threads progressent toujours. Cependant, il a l'inconvénient qu'en présence de conflits, un grand nombre de threads peuvent passer beaucoup de temps à faire un travail qu'ils finiront par jeter. Par exemple, si 30 threads sur des processeurs séparés essaient tous de changer un objet simultanément, celui-ci réussira à sa première tentative, un à sa deuxième, un à sa troisième, etc. de sorte que chaque thread finit en moyenne par faire environ 15 tentatives pour mettre à jour ses données. L'utilisation d'un verrou "consultatif" peut améliorer considérablement les choses: avant qu'un thread ne tente une mise à jour, il doit vérifier si un indicateur de "contention" est défini. Si tel est le cas, il doit acquérir un verrou avant d'effectuer la mise à jour. Si un thread effectue quelques tentatives infructueuses de mise à jour, il doit définir l'indicateur de contention. Si un thread qui essaie d'acquérir le verrou trouve qu'il n'y avait personne d'autre en attente, il devrait effacer l'indicateur de contention. Notez que le verrou ici n'est pas requis pour "l'exactitude"; le code fonctionnerait correctement même sans lui. Le but du verrou est de minimiser la quantité de temps que le code passe sur les opérations qui ne sont pas susceptibles de réussir.

4
supercat