大多数时候,再入的定义引用自维基百科:

A computer program or routine is described as reentrant if it can be safely called again before its previous invocation has been completed (i.e it can be safely executed concurrently). To be reentrant, a computer program or routine: Must hold no static (or global) non-constant data. Must not return the address to static (or global) non-constant data. Must work only on the data provided to it by the caller. Must not rely on locks to singleton resources. Must not modify its own code (unless executing in its own unique thread storage) Must not call non-reentrant computer programs or routines.

如何定义安全?

如果一个程序可以安全地并发执行,那么它是否总是意味着它是可重入的?

在检查代码的重入功能时,我应该记住的这六点之间的共同点究竟是什么?

同时,

是否所有递归函数都是可重入的? 是否所有线程安全的函数都是可重入的? 是否所有递归和线程安全的函数都是可重入的?


在写这个问题的时候,我想到了一件事: 像重入和线程安全这样的术语是绝对的吗?也就是说,它们有固定的具体定义吗?因为,如果不是,这个问题就没有多大意义。


当前回答

术语“线程安全的”和“可重入的”的意思完全是它们定义的意思。在这种情况下,“安全”只是指你在下面引用的定义。

这里的“安全”当然不是指更广泛意义上的安全,即在给定的上下文中调用给定的函数不会完全覆盖您的应用程序。总之,一个函数可能在多线程应用程序中可靠地产生期望的效果,但根据定义,它既不符合可重入性,也不符合线程安全。相反,在多线程应用程序中调用可重入函数的方式会产生各种不需要的、意想不到的和/或不可预测的效果。

递归函数可以是任何东西,重入函数的定义比线程安全的更强,所以你的问题的答案都是否定的。

在阅读reentrant的定义时,人们可能会将其总结为一个函数,该函数不会修改任何超出您所要修改的内容的内容。但你不应该只依赖总结。

多线程编程在一般情况下是非常困难的。知道代码的哪一部分可以重入只是这个挑战的一部分。线程安全不是附加的。与其试图将可重入函数拼凑在一起,不如使用整体线程安全的设计模式,并使用此模式来指导程序中每个线程和共享资源的使用。

其他回答

现在我要详细说明一下我刚才的评论。@paercebal回答不正确。在示例代码中,难道没有人注意到应该是参数的互斥量实际上没有传入吗?

我对这个结论提出异议,我断言:一个函数要在并发性存在的情况下是安全的,它必须是可重入的。因此,并发安全的(通常是编写线程安全的)意味着可重入。

无论是线程安全的还是可重入的,都与参数无关:我们讨论的是函数的并发执行,如果使用了不适当的参数,仍然可能是不安全的。

例如,memcpy()是线程安全的和可重入的(通常)。显然,如果从两个不同的线程调用指向相同目标的指针,它将无法正常工作。这就是SGI定义的要点,将确保对相同数据结构的访问由客户端同步的责任放在了客户端身上。

重要的是要理解,在一般情况下,让线程安全操作包含参数是没有意义的。如果你做过数据库编程,你就会明白。“原子的”概念以及可能由互斥锁或其他技术保护的概念必然是用户概念:在数据库上处理事务可能需要多次不间断的修改。除了客户端程序员,谁能说哪些需要保持同步呢?

关键是,“损坏”并不一定要用非序列化的写入来搞乱您计算机上的内存:即使所有单独的操作都是序列化的,损坏仍然可能发生。因此,当您询问一个函数是否是线程安全的或可重入的时,这个问题意味着所有适当分离的参数:使用耦合参数并不构成反例。

有很多编程系统:Ocaml是其中之一,我认为Python也是,它们有很多不可重入的代码,但是使用全局锁来交错线程访问。这些系统不是可重入的,它们不是线程安全的或并发安全的,它们安全运行只是因为它们阻止了全局并发。

malloc就是一个很好的例子。它不是可重入的,也不是线程安全的。这是因为它必须访问全局资源(堆)。使用锁并不能保证它的安全性:它绝对不能重入。如果malloc的接口设计得当,就有可能使其可重入且线程安全:

malloc(heap*, size_t);

现在它是安全的,因为它将序列化对单个堆的共享访问的责任转移到客户机。特别是,如果存在单独的堆对象,则不需要做任何工作。如果使用公共堆,客户端必须序列化访问。在函数内部使用锁是不够的:只考虑一个malloc锁定一个堆*,然后一个信号出现并在同一个指针上调用malloc:死锁:信号不能继续,客户端也不能继续,因为它被中断了。

一般来说,锁并不能使事情变得线程安全。它们实际上破坏了安全性,因为它们不恰当地试图管理客户端拥有的资源。锁定必须由对象制造商完成,这是唯一的代码,知道有多少对象被创建和他们将如何使用。

不可重入函数意味着将有一个静态上下文,由函数维护。第一次进入时,将为您创建新的上下文。下一个输入,你不需要发送更多的参数,为了方便标记分析。例如c中的strtok,如果你没有清楚上下文,可能会有一些错误。

/* strtok example */
#include <stdio.h>
#include <string.h>

int main ()
{
  char str[] ="- This, a sample string.";
  char * pch;
  printf ("Splitting string \"%s\" into tokens:\n",str);
  pch = strtok (str," ,.-");
  while (pch != NULL)
  {
    printf ("%s\n",pch);
    pch = strtok (NULL, " ,.-");
  }
  return 0;
}

on the contrary of non-reentrant, reentrant function means calling function in anytime will get the same result without side effect. because there is none of context. in the view of thread safe, it just means there is only one modification for public variable in current time, in current process. so you should add lock guard to ensure just one change for public field in one time. so thread safety and reentrant are two different things in different views.reentrant function safety says you should clear context before next time for context analyze. thread safety says you should keep visit public field order.

你的“同样”问题的答案是“不”,“不”和“不”。仅仅因为函数是递归的和/或线程安全的,并不意味着它是可重入的。

每一种类型的函数都可能在你引用的所有点上失败。(虽然我不是100%确定第五点)。

“安全”的定义完全符合常识——它意味着“正确地做自己的事情,而不干扰其他事情”。你提到的六点很清楚地表达了实现这一目标的要求。

你的三个问题的答案是3ד不”。


是否所有递归函数都是可重入的?

NO!

同时调用一个递归函数很容易把彼此搞砸,如果 例如,它们访问相同的全局/静态数据。


是否所有线程安全的函数都是可重入的?

NO!

如果一个函数在并发调用时不发生故障,那么它就是线程安全的。但这可以通过使用互斥来阻止第二次调用的执行,直到第一次调用完成,这样一次只有一个调用可以工作。可重入性意味着在不干扰其他调用的情况下并发执行。


是否所有递归和线程安全的函数都是可重入的?

NO!

见上图。

1. 如何定义安全?

语义。在这种情况下,这不是一个严格定义的术语。它只是意味着“你可以这样做,没有风险”。

2. 如果一个程序可以安全地并发执行,那么它是否总是意味着它是可重入的?

No.

例如,让我们有一个c++函数,它同时接受锁和回调作为参数:

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

另一个函数很可能需要锁定同一个互斥量:

void bar()
{
    foo(nullptr);
}

乍一看,一切似乎都很好,但等等:

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

如果互斥锁不是递归的,那么在主线程中会发生这样的情况:

Main将调用foo。 Foo将获得锁。 Foo会调用bar, bar会调用Foo。 第二个foo将尝试获取锁,失败并等待它被释放。 死锁。 哎呀……

我作弊了,用回调函数。但是很容易想象更复杂的代码段具有类似的效果。

3.在检查代码的重入功能时,我应该记住的这六点之间的共同点究竟是什么?

如果函数具有/提供对可修改持久资源的访问权限,或者具有/提供对具有气味的函数的访问权限,则可以发现问题。

(好吧,99%的代码都应该有味道,然后……请参阅上一节来处理…)

所以,在研究你的代码时,有一点应该提醒你:

函数有一个状态(即访问一个全局变量,甚至是一个类成员变量) 这个函数可以被多个线程调用,也可以在进程执行时在堆栈中出现两次(也就是说,函数可以直接或间接地调用自己)。将回调函数作为参数的函数很臭。

请注意,不可重入性是病毒式传播的:可以调用可能的不可重入函数的函数不能被认为是可重入的。

还要注意的是,c++方法之所以有这种味道,是因为它们可以访问它,所以您应该研究一下代码,以确保它们没有有趣的交互。

4.1. 是否所有递归函数都是可重入的?

No.

在多线程的情况下,访问共享资源的递归函数可能同时被多个线程调用,从而导致坏的/损坏的数据。

在单线程情况下,递归函数可以使用不可重入函数(如臭名昭著的strtok),或者使用全局数据而不处理数据已经在使用的事实。你的函数是递归的,因为它直接或间接地调用自己,但它仍然是递归不安全的。

4.2. 是否所有线程安全的函数都是可重入的?

在上面的例子中,我展示了一个明显的线程安全函数是如何不可重入的。因为回调参数,我作弊了。但是,通过让线程获得两次非递归锁,可以有多种方法使线程死锁。

4.3. 是否所有递归和线程安全的函数都是可重入的?

如果你说的“递归”是指“递归安全”,我会说“是的”。

如果可以保证一个函数可以被多个线程同时调用,并且可以直接或间接地调用自身,没有问题,那么它就是可重入的。

问题是如何评估这个担保…^_^

5. 像重入和线程安全这样的术语是绝对的吗?也就是说,它们有固定的具体定义吗?

我相信它们是这样的,但是,计算一个函数是线程安全的还是可重入的可能会很困难。这就是为什么我在上面使用术语嗅觉:您可以发现一个函数是不可重入的,但是很难确定一段复杂的代码是可重入的

6. 一个例子

假设你有一个对象,其中一个方法需要使用资源:

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

第一个问题是,如果这个函数以某种方式递归调用(即这个函数直接或间接地调用自己),代码可能会崩溃,因为this->p将在最后一次调用结束时被删除,并且仍然可能在第一次调用结束前被使用。

因此,这段代码不是递归安全的。

我们可以使用引用计数器来纠正这一点:

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

这样,代码就变得递归安全了…但由于多线程问题,它仍然不能重入:我们必须确保对c和p的修改将使用递归互斥锁(不是所有的互斥锁都是递归的)来原子地完成:

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

当然,这一切都假定大量代码本身是可重入的,包括p的使用。

上面的代码甚至不是异常安全的,但这是另一个故事…^_^

7. 嘿,99%的代码是不可重入的!

对于意大利式代码来说,这是非常正确的。但是如果正确划分代码,就可以避免重入问题。

7.1. 确保所有函数都有NO状态

它们必须只使用参数、自己的局部变量、其他没有状态的函数,如果要返回数据,则返回数据的副本。

7.2. 确保你的对象是“递归安全的”

一个对象方法可以访问它,所以它与对象的同一个实例的所有方法共享一个状态。

因此,确保对象可以在堆栈中的一个点上使用(即调用方法A),然后,在另一个点上使用(即调用方法B),而不会破坏整个对象。设计您的对象,以确保在退出方法时,对象是稳定和正确的(没有悬空指针,没有矛盾的成员变量,等等)。

7.3. 确保所有对象都被正确封装

任何人都不能访问他们的内部数据:

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

如果用户检索了数据的地址,即使返回一个const引用也可能是危险的,因为代码的其他部分可以在不告知持有const引用的代码的情况下修改它。

7.4. 确保用户知道您的对象不是线程安全的

因此,用户有责任使用互斥来使用线程间共享的对象。

来自STL的对象被设计成不是线程安全的(因为性能问题),因此,如果用户想在两个线程之间共享std::string,用户必须用并发原语保护它的访问;

7.5. 确保线程安全的代码是递归安全的

这意味着如果您认为同一个资源可以被同一个线程使用两次,就使用递归互斥。