O que exatamente é uma função reentrante?

198

A maioria dos os tempos , a definição de reentrada é citado de Wikipedia :

Um programa ou rotina de computador é descrito como reentrante, se puder ser chamado com segurança novamente antes que sua chamada anterior seja concluída (isto é, pode ser executada com segurança simultaneamente). Para reentrar, um programa ou rotina de computador:

  1. Não deve conter dados estáticos (ou globais) não constantes.
  2. Não deve retornar o endereço para dados estáticos (ou globais) não constantes.
  3. Deve funcionar apenas nos dados fornecidos pelo responsável pela chamada.
  4. Não deve confiar em bloqueios para recursos singleton.
  5. Não deve modificar seu próprio código (a menos que seja executado em seu próprio armazenamento de encadeamento exclusivo)
  6. Não deve chamar programas ou rotinas de computador não reentrantes.

Como é definido com segurança ?

Se um programa pode ser executado com segurança simultaneamente , isso sempre significa que é reentrante?

Qual é exatamente o encadeamento comum entre os seis pontos mencionados que devo ter em mente ao verificar meu código quanto a recursos de reentrada?

Além disso,

  1. Todas as funções recursivas são reentrantes?
  2. Todas as funções de thread-safe são reentrantes?
  3. Todas as funções recursivas e com thread-safe são reentrantes?

Ao escrever esta pergunta, uma coisa vem à mente: os termos como reentrada e segurança de linha são absolutos, ou seja, eles têm definições concretas fixas? Pois, se não forem, essa questão não é muito significativa.

lazer
fonte
6
Na verdade, eu discordo do número 2 da primeira lista. Você pode retornar um endereço para o que quiser de uma função reentrante - a limitação está no que você faz com esse endereço no código de chamada.
2
@ Neil Mas como o escritor da função reentrante não pode controlar o que o chamador certamente certamente não deve retornar um endereço para dados estáticos (ou globais) não constantes para que ele seja realmente reentrante?
Robben_Ford_Fan_boy
2
@drelihan Não é responsabilidade do gravador de QUALQUER função (reentrante ou não) controlar o que um chamador faz com um valor retornado. Eles certamente devem dizer o que o interlocutor PODE fazer com ele, mas se o interlocutor optar por fazer outra coisa - sorte para o interlocutor.
"thread-safe" não tem sentido, a menos que você também especifique o que os threads estão fazendo e qual é o efeito esperado de suas ações. Mas talvez isso deva ser uma pergunta separada.
Entendo com segurança que o comportamento é bem definido e determinístico, independentemente da programação.
AturSams 31/01

Respostas:

191

1. Como é definido com segurança ?

Semanticamente. Nesse caso, este não é um termo definido de forma rígida. Significa apenas "Você pode fazer isso, sem risco".

2. Se um programa pode ser executado com segurança simultaneamente, isso sempre significa que é reentrante?

Não.

Por exemplo, vamos ter uma função C ++ que aceita um bloqueio e um retorno de chamada como parâmetro:

#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();
}

Outra função pode precisar bloquear o mesmo mutex:

void bar()
{
    foo(nullptr);
}

À primeira vista, tudo parece bem ... Mas espere:

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

Se o bloqueio no mutex não for recursivo, eis o que acontecerá, no thread principal:

  1. mainvai ligar foo.
  2. foo adquirirá o bloqueio.
  3. foochamará bar, que chamará foo.
  4. o segundo footentará adquirir o bloqueio, falhará e esperará que ele seja liberado.
  5. Impasse.
  6. Opa…

Ok, eu traí, usando a coisa de retorno de chamada. Mas é fácil imaginar trechos de código mais complexos tendo um efeito semelhante.

3. Qual é exatamente o encadeamento comum entre os seis pontos mencionados que devo ter em mente ao verificar meu código quanto a recursos de reentrada?

Você pode sentir um problema se sua função tem / dá acesso a um recurso persistente modificável ou tem / dá acesso a uma função que cheira .

( Ok, 99% do nosso código deve cheirar, então… Consulte a última seção para lidar com isso… )

Portanto, estudando seu código, um desses pontos deve alertá-lo:

  1. A função possui um estado (ou seja, acessar uma variável global ou mesmo uma variável de membro da classe)
  2. Essa função pode ser chamada por vários threads ou aparecer duas vezes na pilha enquanto o processo está em execução (ou seja, a função pode se chamar, direta ou indiretamente). Função que recebe retornos de chamada, pois os parâmetros cheiram muito.

Observe que a não reentrada é viral: uma função que poderia chamar uma possível função não reentrante não pode ser considerada reentrante.

Observe também que os métodos C ++ têm cheiro porque eles têm acesso this; portanto, você deve estudar o código para garantir que eles não tenham nenhuma interação engraçada.

4.1 Todas as funções recursivas são reentrantes?

Não.

Em casos multithread, uma função recursiva que acessa um recurso compartilhado pode ser chamada por vários threads no mesmo momento, resultando em dados incorretos / corrompidos.

Em casos de leitura única, uma função recursiva pode usar uma função não reentrante (como a infame strtok) ou usar dados globais sem manipular o fato de que os dados já estão em uso. Portanto, sua função é recursiva porque se chama direta ou indiretamente, mas ainda pode ser recursiva-insegura .

4.2 Todas as funções de thread-safe são reentrantes?

No exemplo acima, mostrei como uma função aparentemente segura para threads não era reentrada. OK, trapacei por causa do parâmetro de retorno de chamada. Porém, existem várias maneiras de bloquear um encadeamento, adquirindo o dobro de um bloqueio não recursivo.

4.3 Todas as funções recursivas e com thread-safe são reentrantes?

Eu diria "sim" se por "recursivo" você quer dizer "recursivo-seguro".

Se você puder garantir que uma função possa ser chamada simultaneamente por vários encadeamentos e possa chamar a si mesma, direta ou indiretamente, sem problemas, ela será reentrada.

O problema está avaliando essa garantia… ^ _ ^

5. Os termos como reentrada e segurança de linha são absolutamente absolutos, ou seja, eles têm definições concretas fixas?

Acredito que sim, mas avaliar a função é seguro para threads ou reentrada pode ser difícil. É por isso que usei o termo cheiro acima: Você pode encontrar uma função que não é reentrada, mas pode ser difícil garantir que um pedaço complexo de código seja reentrante

6. um exemplo

Digamos que você tenha um objeto, com um método que precisa usar um recurso:

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

O primeiro problema é que, se de alguma forma essa função for chamada recursivamente (ou seja, se ela se chamar, direta ou indiretamente), o código provavelmente falhará, porque this->pserá excluído no final da última chamada e ainda provavelmente será usado antes do final da primeira chamada.

Portanto, esse código não é recursivo-seguro .

Poderíamos usar um contador de referência para corrigir isso:

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

Dessa forma, o código se torna seguro contra recursividade ... Mas ainda não é reentrante por causa de problemas de multithreading: devemos ter certeza de que as modificações de ce de pserão feitas atomicamente, usando um mutex recursivo (nem todos os mutexes são recursivos):

#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();
    }
};

E, claro, tudo isso pressupõe que lots of codeele próprio reentre, incluindo o uso de p.

E o código acima não é nem remotamente seguro contra exceções , mas essa é outra história… ^ _ ^

7. Ei, 99% do nosso código não é reentrante!

É bem verdade para o código de espaguete. Mas se você particionar corretamente seu código, evitará problemas de reentrada.

7.1 Verifique se todas as funções não têm estado

Eles devem usar apenas os parâmetros, suas próprias variáveis ​​locais, outras funções sem estado e retornar cópias dos dados, se retornarem.

7.2 Verifique se o seu objeto é "seguro contra recursividade"

Um método de objeto tem acesso this, portanto, ele compartilha um estado com todos os métodos da mesma instância do objeto.

Portanto, verifique se o objeto pode ser usado em um ponto da pilha (ou seja, chamando o método A) e, em seguida, em outro ponto (ou seja, chamando o método B), sem danificar o objeto inteiro. Projete seu objeto para garantir que, ao sair de um método, ele esteja estável e correto (sem ponteiros oscilantes, sem variáveis ​​de membros contraditórias, etc.).

7.3 Verifique se todos os seus objetos estão encapsulados corretamente

Ninguém mais deve ter acesso aos seus dados internos:

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

Até retornar uma referência const pode ser perigoso se o usuário recuperar o endereço dos dados, pois alguma outra parte do código pode modificá-lo sem que o código que contém a referência const seja informado.

7.4 Verifique se o usuário sabe que seu objeto não é seguro para threads

Assim, o usuário é responsável por usar mutexes para usar um objeto compartilhado entre threads.

Os objetos do STL foram projetados para não serem seguros para threads (devido a problemas de desempenho) e, portanto, se um usuário deseja compartilhar um std::stringentre dois threads, ele deve proteger seu acesso com primitivas de simultaneidade;

7.5 Certifique-se de que seu código de thread-safe seja recursivo-safe

Isso significa usar mutexes recursivos se você acredita que o mesmo recurso pode ser usado duas vezes pelo mesmo encadeamento.

paercebal
fonte
1
Para questionar um pouco, eu realmente acho que neste caso "segurança" está definida - significa que a função atuará apenas nas variáveis ​​fornecidas - ou seja, é uma abreviação para a definição de citação abaixo dela. E o ponto é que isso pode não implicar outras idéias de segurança.
Joe Soul-bringer
Você perdeu a passagem no mutex no primeiro exemplo?
detly
@ paercebal: seu exemplo está errado. Você realmente não precisa se preocupar com o retorno de chamada, uma recursão simples teria o mesmo problema se houver um; no entanto, o único problema é que você esqueceu de dizer exatamente onde o bloqueio está alocado.
Yttrill
3
@ Yttrill: Suponho que você esteja falando do primeiro exemplo. Eu usei o "retorno de chamada" porque, por essência, um retorno de chamada cheira. Obviamente, uma função recursiva teria o mesmo problema, mas geralmente, pode-se analisar facilmente uma função e sua natureza recursiva e, assim, detectar se está reentrante ou se está ok para recursividade. O retorno de chamada, por outro lado, significa que o autor da função que está chamando o retorno de chamada não possui informações sobre o que o retorno de chamada está fazendo, portanto, esse autor pode achar difícil garantir que sua função seja reentrada. Essa é a dificuldade que eu queria mostrar.
paercebal
1
@Gab 好人 好人: corrigi o primeiro exemplo. Obrigado! Um manipulador de sinal viria com seus próprios problemas, diferente da reentrada, como geralmente, quando um sinal é gerado, você não pode fazer nada além de alterar uma variável global declarada especificamente.
paercebal
21

"Com segurança" é definido exatamente como dita o senso comum - significa "agir corretamente, sem interferir em outras coisas". Os seis pontos que você cita expressam claramente os requisitos para conseguir isso.

As respostas para suas 3 perguntas são 3 × "não".


Todas as funções recursivas são reentrantes?

NÃO!

Duas invocações simultâneas de uma função recursiva podem facilmente estragar um ao outro, se eles acessarem os mesmos dados globais / estáticos, por exemplo.


Todas as funções de thread-safe são reentrantes?

NÃO!

Uma função é segura para threads se não funcionar corretamente se for chamada simultaneamente. Mas isso pode ser conseguido, por exemplo, usando um mutex para bloquear a execução da segunda invocação até a primeira terminar, portanto, apenas uma invocação funciona por vez. Reentrada significa executar simultaneamente sem interferir com outras invocações .


Todas as funções recursivas e com thread-safe são reentrantes?

NÃO!

Veja acima.

preguiçoso
fonte
10

O segmento comum:

O comportamento está bem definido se a rotina for chamada enquanto for interrompida?

Se você tem uma função como esta:

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

Então não depende de nenhum estado externo. O comportamento está bem definido.

Se você tem uma função como esta:

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

O resultado não está bem definido em vários encadeamentos. As informações podem ser perdidas se o tempo estiver errado.

A forma mais simples de uma função reentrante é algo que opera exclusivamente nos argumentos passados ​​e nos valores constantes. Qualquer outra coisa exige tratamento especial ou, muitas vezes, não é reentrada. E é claro que os argumentos não devem fazer referência a globais mutáveis.

desenhado
fonte
7

Agora eu tenho que elaborar meu comentário anterior. A resposta @paercebal está incorreta. No código de exemplo, ninguém notou que o mutex que deveria ser parâmetro não foi realmente transmitido?

Eu discuto a conclusão, afirmo: para uma função ser segura na presença de simultaneidade, ela deve ser reentrada. Portanto, o concorrente-seguro (geralmente escrito por thread-safe) implica em reentrante.

Thread thread safe e reentrante não têm nada a dizer sobre argumentos: estamos falando de execução simultânea da função, que ainda pode ser insegura se parâmetros inapropriados forem usados.

Por exemplo, memcpy () é seguro para threads e reentrante (geralmente). Obviamente, não funcionará como esperado se chamado com ponteiros para os mesmos destinos de dois threads diferentes. Esse é o ponto da definição da SGI, colocando o ônus no cliente para garantir que os acessos à mesma estrutura de dados sejam sincronizados pelo cliente.

É importante entender que, em geral, é um absurdo ter uma operação segura para threads incluir os parâmetros. Se você fez alguma programação de banco de dados, entenderá. O conceito do que é "atômico" e pode ser protegido por um mutex ou alguma outra técnica é necessariamente um conceito do usuário: o processamento de uma transação em um banco de dados pode exigir várias modificações ininterruptas. Quem pode dizer quais precisam ser mantidas em sincronia, exceto o programador do cliente?

O ponto é que "corrupção" não precisa atrapalhar a memória do seu computador com gravações não serializadas: a corrupção ainda pode ocorrer mesmo se todas as operações individuais forem serializadas. Daqui resulta que, quando você pergunta se uma função é segura para thread ou reentrada, a pergunta significa para todos os argumentos separados adequadamente: o uso de argumentos acoplados não constitui um contra-exemplo.

Existem muitos sistemas de programação por aí: o Ocaml é um deles, e acho que o Python também, que possui muitos códigos não reentrantes, mas que usa uma trava global para intercalar acessos de threads. Esses sistemas não são reentrantes e não são seguros para threads ou simultâneos, eles operam com segurança simplesmente porque evitam a concorrência globalmente.

Um bom exemplo é o malloc. Não é reentrante e não é seguro para threads. Isso ocorre porque ele precisa acessar um recurso global (a pilha). Usar bloqueios não o torna seguro: definitivamente não é reentrante. Se a interface para o malloc tivesse o design adequado, seria possível torná-lo reentrante e seguro para threads:

malloc(heap*, size_t);

Agora pode ser seguro, pois transfere a responsabilidade de serializar o acesso compartilhado a um único heap para o cliente. Em particular, nenhum trabalho é necessário se houver objetos de heap separados. Se uma pilha comum for usada, o cliente precisará serializar o acesso. Usando uma fechadura dentro da função não é suficiente: basta considerar um malloc bloqueando uma pilha * e, em seguida, um sinal aparece e chama malloc no mesmo ponteiro: deadlock: o sinal não pode continuar e o cliente também não pode. é interrompido.

De um modo geral, os bloqueios não tornam as coisas seguras contra threads. Na verdade, elas destroem a segurança tentando inadequadamente gerenciar um recurso pertencente ao cliente. O bloqueio deve ser feito pelo fabricante do objeto, esse é o único código que sabe quantos objetos são criados e como serão usados.

Yttrill
fonte
"Portanto, a segurança simultânea (geralmente segura por thread por escrito) implica em reentrante." Isso contradiz o exemplo da Wikipedia "Segmento de thread, mas não reentrante" .
Maggyero
3

O "encadeamento comum" (trocadilho intencional !?) entre os pontos listados é que a função não deve fazer nada que afete o comportamento de quaisquer chamadas recursivas ou simultâneas para a mesma função.

Por exemplo, dados estáticos são um problema porque pertencem a todos os threads; se uma chamada modifica uma variável estática, todos os threads usam os dados modificados, afetando seu comportamento. O código de modificação automática (embora raramente seja encontrado e, em alguns casos, evitado) seria um problema, porque, embora haja vários encadeamentos, existe apenas uma cópia do código; o código também é um dado estático essencial.

Essencialmente, para ser reentrante, cada encadeamento deve poder usar a função como se fosse o único usuário, e esse não é o caso se um encadeamento puder afetar o comportamento de outro de maneira não determinística. Principalmente, isso envolve que cada thread tenha dados separados ou constantes nos quais a função trabalha.

Tudo isso dito, o ponto (1) não é necessariamente verdadeiro; por exemplo, você pode legitimamente e por design usar uma variável estática para reter uma contagem de recursão para evitar recursões excessivas ou criar um perfil para um algoritmo.

Uma função de thread-safe não precisa ser reentrada; ele pode alcançar a segurança do encadeamento ao impedir especificamente a reentrada com uma trava, e o ponto (6) diz que essa função não é reentrante. Em relação ao ponto (6), uma função que chama uma função de thread-safe que bloqueia não é segura para uso em recursão (ela será travada) e, portanto, não se diz que é reentrante, embora possa, no entanto, ser segura para simultaneidade, e ainda seria reentrante no sentido de que vários encadeamentos podem ter seus contadores de programas em uma função simultaneamente (mas não na região bloqueada). Pode ser que isso ajude a distinguir a segurança do encadeamento da reentrada (ou talvez aumente sua confusão!).

Clifford
fonte
1

As respostas às suas perguntas "Também" são "Não", "Não" e "Não". Só porque uma função é recursiva e / ou segura para threads, não a torna reentrante.

Cada um desses tipos de função pode falhar em todos os pontos citados. (Embora eu não esteja 100% certo do ponto 5).

ChrisF
fonte
1

Os termos "thread-safe" e "reentrante" significam apenas e exatamente o que dizem suas definições. "Seguro" neste contexto significa apenas o que a definição que você cita abaixo diz.

"Seguro" aqui certamente não significa seguro, no sentido mais amplo, de que chamar uma determinada função em um determinado contexto não será totalmente sua aplicação. No total, uma função pode produzir de maneira confiável o efeito desejado em seu aplicativo multithread, mas não se qualifica como reentrante ou thread safe de acordo com as definições. Por outro lado, você pode chamar funções reentrantes de maneiras que produzam uma variedade de efeitos indesejados, inesperados e / ou imprevisíveis em seu aplicativo multithread.

A função recursiva pode ser qualquer coisa e o Re-participante tem uma definição mais forte do que o thread-safe, de modo que as respostas às suas perguntas numeradas são todas negativas.

Lendo a definição de reentrante, pode-se resumir como uma função que não modificará nada além do que você chama de modificação. Mas você não deve confiar apenas no resumo.

A programação multithread é extremamente difícil no caso geral. Saber qual parte do código reentrante é apenas parte desse desafio. A segurança da linha não é aditiva. Em vez de tentar reunir as funções reentrantes, é melhor usar um padrão geral de design seguro para threads e usar esse padrão para orientar o uso de todos os threads e recursos compartilhados no seu programa.

Joe Soul-portador
fonte