web-dev-qa-db-fra.com

Qu'est-ce qu'une fonction réentrante?

La plupartofthetimes , la définition de la réentrance est citée de Wikipedia :

Un programme ou une routine informatique est décrit comme étant réentrant s'il peut être appelé en toute sécurité avant la fin de son invocation précédente. (c’est-à-dire qu’il peut être exécuté simultanément en toute sécurité). Pour être réentrant, programme ou routine informatique:

  1. Ne doit contenir aucune donnée statique (ou globale) non constante.
  2. Ne doit pas renvoyer l'adresse à des données statiques non constantes (ou globales).
  3. Ne doit fonctionner que sur les données fournies par l'appelant.
  4. Ne doit pas dépendre de verrous pour ressources singleton.
  5. Ne doit pas modifier son propre code (à moins de l'exécuter dans son propre stockage de thread unique)
  6. Ne doit pas appeler des programmes ou des routines informatiques non réentrants.

Comment est défini en toute sécurité?

Si un programme peut être exécuté simultanément et en toute sécurité, cela signifie-t-il toujours qu'il est réentrant?

Quel est exactement le fil conducteur entre les six points mentionnés que je devrais garder à l'esprit lors de la vérification des capacités réentrantes de mon code?

Aussi,

  1. Toutes les fonctions récursives sont-elles réentrantes?
  2. Toutes les fonctions thread-safe sont-elles réentrantes?
  3. Toutes les fonctions récursives et thread-safe sont-elles réentrantes?

En écrivant cette question, une chose me vient à l’esprit: les termes tels que sont-ils réentrance et thread safety absolu c'est-à-dire ont-ils des définitions concrètes fixes? Car, s’ils ne le sont pas, cette question n’a pas beaucoup de sens.

182
Lazer

1. Comment est sans encombre défini?

Sémantiquement. Dans ce cas, ce n'est pas un terme difficile à définir. Cela signifie simplement "Vous pouvez le faire, sans risque".

2. Si un programme peut être exécuté simultanément en toute sécurité, cela signifie-t-il toujours qu'il est réentrant?

Non.

Par exemple, utilisons une fonction C++ qui prend un verrou et un rappel en tant que paramètre:

#include <mutex>

typedef void (*callback)();
std::mutex m;

void foo(callback f)
{
    m.lock();
    // use the resource protected by the mutex

    if (f) {
        f();
    }

    // use the resource protected by the mutex
    m.unlock();
}

Une autre fonction pourrait bien avoir besoin de verrouiller le même mutex:

void bar()
{
    foo(nullptr);
}

À première vue, tout semble aller pour le mieux… Mais attendez:

int main()
{
    foo(bar);
    return 0;
}

Si le verrou sur mutex n'est pas récursif, alors voici ce qu'il va se passer dans le thread principal:

  1. main appellera foo.
  2. foo va acquérir le verrou.
  3. foo appellera bar, qui appellera foo.
  4. le 2nd foo tentera d'acquérir le verrou, échouera et attendra qu'il soit libéré.
  5. Impasse.
  6. Oops…

Ok, j'ai triché, en utilisant le truc du rappel. Mais il est facile d'imaginer des morceaux de code plus complexes ayant un effet similaire.

3. Quel est exactement le fil conducteur entre les six points mentionnés que je devrais garder à l'esprit lors de la recherche de capacités réentrantes dans mon code?

Vous pouvez odeur un problème si votre fonction a/donne accès à une ressource persistante modifiable, ou a/donne accès à une fonction qui sent.

(Ok, 99% de notre code devrait sentir, alors… Voir la dernière section pour gérer ça…)

Ainsi, en étudiant votre code, un de ces points devrait vous alerter:

  1. La fonction a un état (c'est-à-dire accéder à une variable globale ou même à une variable de membre de classe)
  2. Cette fonction peut être appelée par plusieurs threads, ou peut apparaître deux fois dans la pile pendant l’exécution du processus (c’est-à-dire que la fonction peut s’appeler elle-même, directement ou indirectement). Fonction prenant les callbacks comme paramètres odeur beaucoup.

Notez que la non-réentrance est virale: une fonction pouvant appeler une fonction non-réentrante éventuelle ne peut pas être considérée comme réentrante.

Notez aussi que les méthodes C++ odeur parce qu’ils ont accès à this, vous devriez donc étudier le code pour vous assurer qu’ils n’ont pas d’interaction amusante.

4.1. Toutes les fonctions récursives sont-elles réentrantes?

Non.

Dans les cas multithread, une fonction récursive accédant à des ressources partagées peut être appelée par plusieurs threads au même moment, ce qui entraîne des données incorrectes/corrompues.

Dans les cas simples, une fonction récursive pourrait utiliser une fonction non réentrante (comme le fameux strtok), ou utiliser des données globales sans gérer le fait que les données sont déjà utilisées. Donc, votre fonction est récursive car elle s’appelle directement ou indirectement, mais elle peut toujours être récursif-dangereux.

4.2. Toutes les fonctions thread-safe sont-elles réentrantes?

Dans l'exemple ci-dessus, j'ai montré comment une fonction apparemment thread-safe n'était pas réentrante. Ok J'ai triché à cause du paramètre callback. Mais il existe plusieurs façons de bloquer un thread en lui faisant acquérir deux fois un verrou non récursif.

4.3. Toutes les fonctions récursives et thread-safe sont-elles réentrantes?

Je dirais "oui" si par "récursif" vous voulez dire "récursif-sûr".

Si vous pouvez garantir qu'une fonction peut être appelée simultanément par plusieurs threads et qu'elle peut s'appeler elle-même, directement ou indirectement, sans problème, alors elle est réentrante.

Le problème est d'évaluer cette garantie… ^ _ ^

5. Les termes tels que réentrance et sécurité du fil sont-ils absolus, c’est-à-dire qu’ils ont une définition concrète?

Je pense que oui, mais ensuite, évaluer une fonction est thread-safe ou réentrant peut être difficile. C'est pourquoi j'ai utilisé le terme odeur ci-dessus: vous pouvez trouver une fonction non réentrante, mais il peut être difficile de vous assurer qu'un morceau de code complexe est réentrant

6. Un exemple

Supposons que vous avez un objet, avec une méthode nécessitant l’utilisation de ressources:

struct MyStruct
{
    P * p;

    void foo()
    {
        if (this->p == nullptr)
        {
            this->p = new P();
        }

        // lots of code, some using this->p

        if (this->p != nullptr)
        {
            delete this->p;
            this->p = nullptr;
        }
    }
};

Le premier problème est que si d’une manière ou d’une autre cette fonction est appelée de manière récursive (c’est-à-dire que cette fonction s’appelle elle-même, directement ou indirectement), le code plantera probablement, car this->p sera supprimé à la fin du dernier appel et sera probablement encore utilisé avant la fin du premier appel.

Ainsi, ce code n'est pas récursif-sûr.

Nous pourrions utiliser un compteur de références pour corriger ceci:

struct MyStruct
{
    size_t c;
    P * p;

    void foo()
    {
        if (c == 0)
        {
            this->p = new P();
        }

        ++c;
        // lots of code, some using this->p
        --c;

        if (c == 0)
        {
            delete this->p;
            this->p = nullptr;
        }
    }
};

De cette façon, le code devient récursif-sûr… Mais il n'est toujours pas réentrant à cause de problèmes de multithreading: Nous devons être sûrs que les modifications de c et de p seront effectuées de manière atomique, en utilisant récursif mutex (tous les mutex ne sont pas récursifs):

#include <mutex>

struct MyStruct
{
    std::recursive_mutex m;
    size_t c;
    P * p;

    void foo()
    {
        m.lock();

        if (c == 0)
        {
            this->p = new P();
        }

        ++c;
        m.unlock();
        // lots of code, some using this->p
        m.lock();
        --c;

        if (c == 0)
        {
            delete this->p;
            this->p = nullptr;
        }

        m.unlock();
    }
};

Et bien sûr, tout cela suppose le lots of code est lui-même réentrant, incluant l’utilisation de p.

Et le code ci-dessus n’est même pas distant exception-sauf , mais c’est une autre histoire… ^ _ ^

7. Hé, 99% de notre code n'est pas réentrant!

C'est tout à fait vrai pour le code spaghetti. Mais si vous partitionnez correctement votre code, vous éviterez les problèmes de réentrance.

7.1. Assurez-vous que toutes les fonctions n'ont pas d'état

Ils ne doivent utiliser que les paramètres, leurs propres variables locales, d'autres fonctions sans état et renvoyer des copies des données, le cas échéant.

7.2. Assurez-vous que votre objet est "récursif-sûr"

Une méthode d'objet a accès à this, elle partage donc un état avec toutes les méthodes de la même instance de l'objet.

Donc, assurez-vous que l’objet peut être utilisé en un point de la pile (c’est-à-dire en appelant la méthode A), puis en un autre point (c’est-à-dire en appelant la méthode B), sans corrompre l’objet en entier. Concevez votre objet pour vous assurer qu’à la sortie d’une méthode, il est stable et correct (pas de pointeurs en suspens, pas de variables de membre contradictoires, etc.).

7.3. Assurez-vous que tous vos objets sont correctement encapsulés

Personne d'autre ne devrait avoir accès à leurs données internes:

    // bad
    int & MyObject::getCounter()
    {
        return this->counter;
    }

    // good
    int MyObject::getCounter()
    {
        return this->counter;
    }

    // good, too
    void MyObject::getCounter(int & p_counter)
    {
        p_counter = this->counter;
    }

Même renvoyer une référence const pourrait être dangereux si l'utilisation récupérait l'adresse des données, car une autre partie du code pourrait la modifier sans que le code contenant la référence const ne soit annoncé.

7.4. Assurez-vous que l'utilisateur sait que votre objet n'est pas thread-safe

Ainsi, l'utilisateur est responsable d'utiliser les mutex pour utiliser un objet partagé entre les threads.

Les objets de la STL sont conçus pour ne pas être thread-safe (à cause de problèmes de performances), et donc, si un utilisateur veut partager un std::string entre deux threads, l’utilisateur doit protéger son accès avec des primitives de concurrence;

7.5. Assurez-vous que votre code thread-safe est récursif-safe

Cela signifie utiliser des mutex récursifs si vous pensez qu'une même ressource peut être utilisée deux fois par le même thread.

178
paercebal

"En toute sécurité" est défini exactement comme le veut le bon sens - cela signifie "faire son travail correctement sans interférer avec autre chose". Les six points que vous citez expriment très clairement les exigences pour y parvenir.

La réponse à vos 3 questions est 3 × "non".


Toutes les fonctions récursives sont-elles réentrantes?

NON!

Deux invocations simultanées d'une fonction récursive peuvent facilement se perdre, si elles accèdent aux mêmes données globales/statiques, par exemple.


Toutes les fonctions thread-safe sont-elles réentrantes?

NON!

Une fonction est thread-safe si elle ne fonctionne pas mal si elle est appelée simultanément. Mais cela peut être réalisé par exemple en utilisant un mutex pour bloquer l'exécution de la deuxième invocation jusqu'à la fin de la première, de sorte qu'une seule invocation fonctionne à la fois. Réentrance signifie s'exécuter simultanément sans interférer avec d'autres invocations .


Toutes les fonctions récursives et thread-safe sont-elles réentrantes?

NON!

Voir au dessus.

21
slacker

Le dénominateur commun:

Le comportement est-il bien défini si la routine est appelée alors qu'elle est interrompue?

Si vous avez une fonction comme celle-ci:

int add( int a , int b ) {
  return a + b;
}

Ensuite, il ne dépend d'aucun état externe. Le comportement est bien défini.

Si vous avez une fonction comme celle-ci:

int add_to_global( int a ) {
  return gValue += a;
}

Le résultat n'est pas bien défini sur plusieurs threads. Des informations pourraient être perdues si le moment était mal choisi.

La forme la plus simple d'une fonction réentrante est quelque chose qui opère exclusivement sur les arguments passés et les valeurs constantes. Tout le reste nécessite un traitement spécial ou, souvent, n'est pas réentrant. Et bien sûr, les arguments ne doivent pas faire référence à des globaux mutables.

10
drawnonward

Maintenant, je dois élaborer sur mon commentaire précédent. La réponse @paercebal est incorrecte. Dans l'exemple de code, personne n'a-t-il remarqué que le mutex, qui était supposé être un paramètre, n'était pas réellement transmis?

Je conteste la conclusion, je l'affirme: pour qu'une fonction soit sûre en présence de concurrence, elle doit être ré-entrante. Par conséquent, simultané-sûr (généralement écrit thread-safe) implique une ré-entrée.

Ni les threads sûrs ni les réentrants n'ont rien à dire sur les arguments: nous parlons d'une exécution simultanée de la fonction, qui peut toujours être dangereuse si des paramètres inappropriés sont utilisés.

Par exemple, memcpy () est thread-safe et ré-entrant (généralement). Évidemment, cela ne fonctionnera pas comme prévu si vous l'appelez avec des pointeurs sur les mêmes cibles à partir de deux threads différents. C’est là l’intérêt de la définition SGI: il incombe au client de s’assurer que les accès à la même structure de données sont synchronisés par le client.

Il est important de comprendre qu’en général, il est absurde d’avoir un fonctionnement thread-safe incluant les paramètres. Si vous avez fait une programmation de base de données, vous comprendrez. Le concept de ce qui est "atomique" et qui pourrait être protégé par un mutex ou une autre technique est nécessairement un concept d'utilisateur: le traitement d'une transaction sur une base de données peut nécessiter plusieurs modifications non interrompues. Qui peut dire lesquels doivent être synchronisés, à l'exception du programmeur client?

Le fait est que la "corruption" n'a pas besoin de perturber la mémoire de votre ordinateur avec des écritures non sérialisées: une corruption peut toujours se produire même si toutes les opérations individuelles sont sérialisées. Il s'ensuit que lorsque vous demandez si une fonction est thread-safe ou ré-entrante, la question signifie pour tous les arguments séparés de manière appropriée: l'utilisation d'arguments couplés ne constitue pas un contre-exemple.

Il existe de nombreux systèmes de programmation: Ocaml en est un, et je pense aussi Python), qui contient beaucoup de code non réentrant, mais qui utilise un verrou global pour entrelacer les accès aux threads. Ces systèmes ne sont pas réentrants et ils ne sont ni sécurisés pour les threads ni les concurrentes, ils fonctionnent en toute sécurité simplement parce qu'ils empêchent la simultanéité.

Malloc est un bon exemple. Ce n'est pas ré-entrant et pas thread-safe. En effet, il doit accéder à une ressource globale (le tas). L'utilisation de verrous ne rend pas la sécurité sûre: ce n'est certainement pas réentrant. Si l'interface vers malloc avait été correctement conçue, il serait possible de la rendre ré-entrante et thread-safe:

malloc(heap*, size_t);

Désormais, il peut être sécurisé, car il transfère la responsabilité de la sérialisation de l'accès partagé à un segment de mémoire unique au client. En particulier, aucun travail n'est requis s'il existe des objets de segment distincts. Si un segment de mémoire commun est utilisé, le client doit sérialiser l'accès. Utiliser un verrou à l'intérieur la fonction ne suffit pas: considérons simplement un malloc verrouillant un tas * puis un signal arrive et appelle malloc sur le même pointeur: deadlock: le signal ne peut pas continuer et le le client ne peut pas non plus parce qu'il est interrompu.

En règle générale, les verrous ne rendent pas les choses fil-safe. Ils détruisent la sécurité en essayant de manière inappropriée de gérer une ressource appartenant au client. Le verrouillage doit être effectué par le fabricant d’objets, c’est le seul code qui sait combien d’objets sont créés et comment ils seront utilisés.

7
Yttrill

Le "dénominateur commun" (jeu de mots prévu!?) Parmi les points énumérés est que la fonction ne doit rien faire qui puisse affecter le comportement d'appels récursifs ou simultanés à la même fonction.

Ainsi, par exemple, les données statiques sont un problème car elles appartiennent à tous les threads. si un appel modifie une variable statique, tous les threads utilisent les données modifiées, ce qui affecte leur comportement. Un code à modification automatique (bien que rarement rencontré et, dans certains cas, évité) poserait problème, car, même s'il existe plusieurs threads, il n'y a qu'une seule copie du code; le code est aussi une donnée statique essentielle.

Essentiellement, pour être ré-entrants, chaque fil doit pouvoir utiliser la fonction comme s'il s'agissait du seul utilisateur. Ce n'est pas le cas si un fil peut affecter le comportement d'un autre d'une manière non déterministe. Cela implique principalement que chaque thread possède des données distinctes ou constantes sur lesquelles la fonction fonctionne.

Cela dit, le point (1) n'est pas nécessairement vrai; Par exemple, vous pouvez légitimement et par nature utiliser une variable statique pour conserver un nombre de récursions afin de vous protéger contre une récursivité excessive ou de profiler un algorithme.

Une fonction thread-safe n'a pas besoin d'être réentrante; il peut assurer la sécurité du fil en empêchant spécifiquement la réentrance avec un verrou, et le point (6) indique qu'une telle fonction n'est pas réentrante. En ce qui concerne le point (6), une fonction qui appelle une fonction thread-safe qui se verrouille n’est pas sécurisée pour une utilisation en récursivité (elle sera bloquée), et n’est donc pas dite réentrante, bien qu’elle puisse néanmoins être sécurisée en accès simultané, et serait toujours ré-entrant dans le sens où plusieurs threads peuvent avoir leurs compteurs de programme dans une telle fonction simultanément (mais pas avec la région verrouillée). Peut-être cela aide-t-il à distinguer la sécurité du fil de la réintégration (ou ajoute peut-être à votre confusion!).

3
Clifford

Les réponses à vos questions "Aussi" sont "Non", "Non" et "Non". Le fait qu'une fonction soit récursive et/ou thread-safe ne la rend pas ré-entrante.

Chacun de ces types de fonctions peut échouer sur tous les points que vous citez. (Bien que je ne sois pas sûr à 100% du point 5).

1
ChrisF

Les termes "thread-safe" et "ré-entrant" signifient seulement et exactement ce que disent leurs définitions. "Sécuritaire" dans ce contexte signifie seulement ce que la définition que vous citez ci-dessous est expliquée.

"Sécurisé" ne signifie certainement pas sûr au sens large, qu'appeler une fonction donnée dans un contexte donné ne ralentira pas totalement votre application. Globalement, une fonction peut produire de manière fiable un effet souhaité dans votre application multithread mais ne peut être qualifiée de ré-entrante ni de thread-safe selon les définitions. De manière opposée, vous pouvez appeler les fonctions réentrantes de manière à produire divers effets indésirables, imprévus et/ou imprévisibles dans votre application multithread.

La fonction récursive peut être n'importe quoi et Re-entrante a une définition plus forte que celle du thread-safe, donc les réponses aux questions numérotées sont toutes non.

En lisant la définition de rentrant, on pourrait la résumer comme signifiant une fonction qui ne modifiera rien au-delà de ce que vous appelez modifier. Mais vous ne devriez pas compter uniquement sur le résumé.

La programmation multithread est juste extrêmement difficile dans le cas général. Savoir quelle partie de son code réentrant n'est qu'une partie de ce défi. La sécurité du fil n'est pas un additif. Plutôt que d'essayer d'assembler des fonctions réentrantes, il vaut mieux utiliser un ensemble thread-safemotif de conception et utiliser ce motif pour guider votre utilisation de chaque fil et ressources partagées dans votre programme.

1