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


198

La plupart des les temps , la définition de reentrance est cité de Wikipédia :

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

  1. Ne doit contenir aucune donnée statique (ou globale) non constante.
  2. Ne doit pas renvoyer l'adresse à des données statiques (ou globales) non constantes.
  3. Doit travailler uniquement sur les données qui lui sont fournies par l'appelant.
  4. Ne doit pas s'appuyer sur des verrous pour singulariser les ressources.
  5. Ne doit pas modifier son propre code (sauf s'il s'exécute dans son propre stockage de thread unique)
  6. Ne doit pas appeler de programmes ou de routines informatiques non réentrants.

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

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

Quel est exactement le fil conducteur entre les six points mentionnés que je dois garder à l'esprit lors de la vérification de mes capacités réentrantes dans 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 comme réentrance et sécurité des threads sont-ils absolus, 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.


6
En fait, je ne suis pas d'accord avec le n ° 2 dans la première liste. Vous pouvez renvoyer une adresse à tout ce que vous voulez à partir d'une fonction rentrante - la limitation est sur ce que vous faites avec cette adresse dans le code d'appel.

2
@Neil Mais comme l'auteur de la fonction réentrante ne peut pas contrôler ce que l'appelant ne doit sûrement pas renvoyer une adresse à des données statiques (ou globales) non constantes pour qu'elle soit vraiment réentrante?
Robben_Ford_Fan_boy

2
@drelihan Il n'est pas de la responsabilité de l'auteur de n'importe quelle fonction (réentrante ou non) de contrôler ce qu'un appelant fait avec une valeur renvoyée. Ils devraient certainement dire ce que l'appelant PEUT en faire, mais si l'appelant choisit de faire autre chose - bonne chance à l'appelant.

"thread-safe" n'a de sens que si vous spécifiez également ce que font les threads et quel est l'effet attendu de leurs actions. Mais cela devrait peut-être être une question distincte.

Je suppose que le comportement est bien défini et déterministe, indépendamment de la programmation.
AturSams

Réponses:


191

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

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

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

Non.

Par exemple, ayons une fonction C ++ qui prend à la fois un verrou et un rappel en 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 ok… Mais attendez:

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

Si le verrou sur mutex n'est pas récursif, voici ce qui se passera, dans le thread principal:

  1. mainva appeler foo.
  2. foo va acquérir la serrure.
  3. fooappellera bar, qui appellera foo.
  4. le 2e footentera d'acquérir le verrou, échouera et attendra qu'il soit libéré.
  5. Impasse.
  6. Oups…

Ok, j'ai triché en utilisant le 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 dois garder à l'esprit lors de la vérification de mes capacités réentrantes dans mon code?

Vous pouvez sentir 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, l'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 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). Les fonctions prenant des rappels comme paramètres sentent beaucoup.

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

Notez également que les méthodes C ++ sentent parce qu'elles y ont accès this, vous devez donc étudier le code pour vous assurer qu'elles n'ont pas d'interaction amusante.

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

Non.

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

Dans les cas de thread unique, une fonction récursive peut utiliser une fonction non réentrante (comme l'infâme strtok), ou utiliser des données globales sans gérer le fait que les données sont déjà utilisées. Votre fonction est donc récursive car elle s'appelle directement ou indirectement, mais elle peut toujours être non sécurisée récursive .

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

Dans l'exemple ci-dessus, j'ai montré comment une fonction apparemment threadsafe n'était pas réentrante. OK, j'ai triché à cause du paramètre de rappel. Mais alors, 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 «sûr récursif».

Si vous pouvez garantir qu'une fonction peut être appelée simultanément par plusieurs threads, et 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 comme réentrance et sécurité des fils sont-ils absolus, c'est-à-dire ont-ils des définitions concrètes fixes?

Je crois qu'ils le font, mais alors, é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 qu'une fonction n'est pas réentrante, mais il peut être difficile d'être sûr qu'un morceau de code complexe est réentrant

6. Un exemple

Disons que vous avez un objet, avec une méthode qui doit utiliser une ressource:

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 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 se bloquera probablement, car this->pil sera supprimé à la fin du dernier appel, et sera probablement utilisé avant la fin du premier appel.

Par conséquent, ce code n'est pas récursif .

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

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 ... Mais il n'est toujours pas rentrant à cause de problèmes de multithreading: Nous devons être sûrs que les modifications de cet de pse feront atomiquement, en utilisant un mutex récursif (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 que le lots of codeest lui-même réentrant, y compris l'utilisation de p.

Et le code ci-dessus n'est même pas protégé contre les exceptions à distance , 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 doivent uniquement utiliser les paramètres, leurs propres variables locales, d'autres fonctions sans état et renvoyer des copies des données si elles reviennent.

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

Une méthode 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é à un point de la pile (c'est-à-dire en appelant la méthode A), puis à un autre point (c'est-à-dire en appelant la méthode B), sans corrompre tout l'objet. Concevez votre objet pour vous assurer qu'à la sortie d'une méthode, l'objet est stable et correct (pas de pointeurs pendants, pas de variables membres 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'utilisateur récupère 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 informé.

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

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

Les objets de la STL sont conçus pour ne pas être thread-safe (en raison de problèmes de performances), et donc, si un utilisateur souhaite partager un std::stringentre 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

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


1
Pour chipoter un peu, je pense en fait que dans ce cas, la "sécurité" est définie - cela signifie que la fonction n'agira que sur les variables fournies - c'est-à-dire, c'est un raccourci pour la citation de définition en dessous. Et le fait est que cela pourrait ne pas impliquer d'autres idées de sécurité.
Joe Soul-bringer

Avez-vous manqué de passer dans le mutex dans le premier exemple?
detly

@paercebal: votre exemple est faux. Vous n'avez pas vraiment besoin de vous soucier du rappel, une simple récursion aurait le même problème s'il y en a un, mais le seul problème est que vous avez oublié de dire exactement où le verrou est alloué.
Yttrill

3
@Yttrill: Je suppose que vous parlez du premier exemple. J'ai utilisé le "rappel" parce que, par essence, un rappel sent. Bien sûr, une fonction récursive aurait le même problème, mais généralement, on peut facilement analyser une fonction et sa nature récursive, et ainsi détecter si elle est réentrante ou si elle est correcte pour la récursivité. Le rappel, d'autre part, signifie que l'auteur de la fonction appelant le rappel n'a aucune information sur ce que fait le rappel, donc cet auteur peut avoir du mal à s'assurer que sa fonction est réentrante. C'est cette difficulté que je voulais montrer.
paercebal

1
@Gab 是 好人: J'ai corrigé le premier exemple. Merci! Un gestionnaire de signaux viendrait avec ses propres problèmes, distincts de la réentrance, comme d'habitude, quand un signal est élevé, vous ne pouvez rien faire au-delà de changer une variable globale spécifiquement déclarée.
paercebal

21

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

Les réponses à vos 3 questions sont 3 × "non".


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

NON!

Deux invocations simultanées d'une fonction récursive peuvent facilement se gâcher, 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, donc une seule invocation fonctionne à la fois. La réentrance signifie l' exécution simultanée sans interférer avec d'autres invocations .


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

NON!

Voir au dessus.


10

Le fil conducteur:

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;
}

Il ne dépend alors 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 une manipulation spéciale ou, souvent, n'est pas réentrant. Et bien sûr, les arguments ne doivent pas faire référence à des globaux mutables.


7

Je dois maintenant développer 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 censé être un paramètre n'était pas réellement passé?

Je conteste la conclusion, j'affirme: pour qu'une fonction soit sûre en présence de simultanéité, elle doit être rentrante. Par conséquent, la sécurité simultanée (généralement écrite en thread-safe) implique la rentrée.

Ni thread safe ni rentrant n'ont rien à dire sur les arguments: nous parlons de l'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 rentrant (généralement). Évidemment, cela ne fonctionnera pas comme prévu s'il est appelé avec des pointeurs vers les mêmes cibles à partir de deux threads différents. C'est le point de la définition SGI, plaçant le fardeau sur le client pour garantir 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 inclure les paramètres. Si vous avez fait une programmation de base de données, vous comprendrez. Le concept de ce qui est "atomique" et pourrait être protégé par un mutex ou une autre technique est nécessairement un concept utilisateur: le traitement d'une transaction sur une base de données peut nécessiter de multiples modifications sans interruption. Qui peut dire lesquels doivent être synchronisés, à part le programmeur client?

Le fait est que la «corruption» n'a pas à gâcher la mémoire de votre ordinateur avec des écritures non sérialisées: la 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 rentrante, la question signifie pour tous les arguments correctement séparés: 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 que Python également, 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 pas sûrs pour les threads ou les concurrents, ils fonctionnent en toute sécurité simplement parce qu'ils empêchent la concurrence à l'échelle mondiale.

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

malloc(heap*, size_t);

Désormais, il peut être sûr car il transfère la responsabilité de la sérialisation de l'accès partagé à un seul tas au client. En particulier, aucun travail n'est requis s'il existe des objets de tas distincts. Si un segment de mémoire commun est utilisé, le client doit sérialiser l'accès. Utiliser un verrou à l' intérieur de la fonction n'est pas suffisant: il suffit de considérer 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 client ne peut pas non plus parce qu'il est interrompu.

De manière générale, les verrous ne rendent pas les choses sécuritaires pour les threads. Ils détruisent en fait la sécurité en essayant de manière inappropriée de gérer une ressource qui appartient 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.


"Par conséquent, la sécurité simultanée (généralement écrite en thread-safe) implique la rentrée." Cela contredit l' exemple de Wikipédia "Thread-safe mais pas réentrant" .
Maggyero

3

Le «fil conducteur» (jeu de mots voulu!?) Parmi les points énumérés est que la fonction ne doit rien faire qui puisse affecter le comportement des 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 affectant ainsi leur comportement. Le code à modification automatique (bien que rarement rencontré, et dans certains cas évité) serait un problème, car bien qu'il existe plusieurs threads, il n'y a qu'une seule copie du code; le code est aussi des données statiques essentielles.

Essentiellement, pour être rentrant, chaque thread doit pouvoir utiliser la fonction comme s'il était le seul utilisateur, et ce n'est pas le cas si un thread peut affecter le comportement d'un autre de manière non déterministe. Cela implique principalement que chaque thread dispose de 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 conception utiliser une variable statique pour conserver un nombre de récursions pour vous prémunir contre une récursivité excessive ou pour profiler un algorithme.

Une fonction thread-safe n'a pas besoin d'être réentrante; il peut assurer la sécurité du filetage 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ûre pour une utilisation en récursion (elle sera verrouillée) et n'est donc pas dite réentrante, bien qu'elle puisse néanmoins être sûre pour la concurrence, et serait encore rentrant 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 que cela aide à distinguer la sécurité des threads de la réincarnation (ou peut-être ajoute à votre confusion!).


1

Les réponses à vos questions "Aussi" sont "Non", "Non" et "Non". Juste parce qu'une fonction est récursive et / ou thread-safe, elle ne la fait pas rentrer.

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


1

Les termes «Thread-safe» et «rentrant» signifient uniquement et exactement ce que disent leurs définitions. «Sûr» dans ce contexte ne signifie que ce que dit la définition que vous citez ci-dessous.

"Sûr" ici ne signifie certainement pas sûr au sens large du fait qu'appeler une fonction donnée dans un contexte donné ne sera pas totalement flexible pour votre application. Au total, une fonction peut produire de manière fiable un effet souhaité dans votre application multithread mais ne peut pas être qualifiée de rentrante ou de thread-safe selon les définitions. À l'opposé, vous pouvez appeler des fonctions réentrantes de manière à produire une variété d'effets indésirables, inattendus et / ou imprévisibles dans votre application multithread.

La fonction récursive peut être n'importe quoi et le rentrant a une définition plus forte que le thread-safe, donc les réponses à vos 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 devez pas vous fier uniquement au résumé.

La programmation multithread est tout simplement extrêmement difficile dans le cas général. Savoir quelle partie de son code rentrant n'est qu'une partie de ce défi. La sécurité des fils n'est pas additive. Plutôt que d'essayer de reconstituer des fonctions réentrantes, il est préférable d'utiliser un modèle de conception global adapté aux threads et d'utiliser ce modèle pour guider votre utilisation de chaque thread et des ressources partagées dans votre programme.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.