可重入函数到底是什么?


198

大多数 时代,再进入的定义转引自维基百科

如果一个计算机程序或例程可以在之前的调用完成之前安全地再次调用(即可以安全地同时执行),则称为可重入 。要重入,可以使用计算机程序或例程:

  1. 必须不包含静态(或全局)非恒定数据。
  2. 不得将地址返回静态(或全局)非恒定数据。
  3. 必须仅对调用方提供的数据起作用。
  4. 绝对不能依赖于对单例资源的锁定。
  5. 不得修改自己的代码(除非在自己的唯一线程存储中执行)
  6. 不得调用非可重入计算机程序或例程。

如何安全定义?

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

在检查代码的可重入功能时,应牢记的六点之间的共同点到底是什么?

也,

  1. 所有递归函数都是可重入的吗?
  2. 所有线程安全函数都可重入吗?
  3. 所有递归和线程安全函数都可以重入吗?

在写这个问题时,会想到一件事:再入线程安全之类的术语是绝对绝对的,即它们是否具有固定的具体定义?因为,如果不是这样的话,这个问题就没有太大意义。


6
实际上,我不同意第一名单中的第二名。您可以通过重入函数将地址返回到任意内容-限制是在调用代码中对该地址的处理方式。

2
@Neil但是,由于可重入函数的编写者无法控制调用者的确定性,因此他们肯定不能为静态(或全局)非恒定数据返回地址以使其真正可重入吗?
Robben_Ford_Fan_boy 2010年

2
@drelihan ANY函数(无论是否可重入)的编写者都不负责控制调用者使用返回值执行的操作。他们当然应该说出呼叫者可以做什么,但是如果呼叫者选择做其他事情-呼叫者很不幸。

除非您还指定线程在做什么以及它们的预期作用是什么,否则“线程安全”是没有意义的。但这也许是一个单独的问题。

可以肯定地说,无论调度如何,行为都是定义明确的行为。
AturSams 2014年

Answers:


191

1.如何安全定义?

语义上。在这种情况下,这不是一个硬性定义的术语。这仅表示“您可以做到,没有风险”。

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

没有。

例如,让我们有一个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;
}

如果互斥锁不是递归的,则在主线程中将发生以下情况:

  1. main会打电话给foo
  2. foo 将获得锁。
  3. foo会打电话bar,会打电话foo
  4. 第二个foo将尝试获取锁定,失败并等待释放。
  5. 僵局。
  6. 糟糕...

好的,我使用回调函数作弊。但是,很容易想象更复杂的代码片段会产生相似的效果。

3.在检查代码中的可重入功能时,应牢记的六个要点之间的共同点到底是什么?

你可以闻到,如果你的函数/可以访问一个可修改的持久性资源,或有/给予一个函数访问的问题气味

好吧,我们的代码中有99%应该有气味,然后……参见上一节以解决该问题……

因此,研究您的代码时,其中之一应提醒您:

  1. 该函数具有状态(即访问全局变量,甚至是类成员变量)
  2. 该函数可以由多个线程调用,也可以在进程执行时在堆栈中出现两次(即该函数可以直接或间接调用自身)。以回调为参数的函数闻起来很多。

请注意,不可重入是病毒式的:可以调用可能的不可重入函数的函数不能视为可重入。

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

4.1。所有递归函数都是可重入的吗?

没有。

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

在单线程情况下,递归函数可以使用非可重入函数(如infamous 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;
        }
    }
};

这样,代码就成为了递归安全的……但是由于多线程问题,它仍然是不可重入的:我们必须确保使用递归互斥锁对of c和of 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();
    }
};

当然,所有这些都假定lots of code本身是可重入的,包括的使用p

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

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

对于意大利面条代码,这是相当正确的。但是,如果您正确地划分了代码,则可以避免重入问题。

7.1。确保所有功能都没有状态

他们只能使用参数,自己的局部变量,其他没有状态的函数,并且如果它们完全返回,则返回数据的副本。

7.2。确保您的对象“递归安全”

对象方法有权访问this,因此它与对象同一实例的所有方法共享状态。

因此,请确保可以在堆栈中的某个位置(即调用方法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在两个线程之间共享a ,则用户必须使用并发原语保护其访问;

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

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


1
稍微说一下,我实际上认为在这种情况下定义了“安全性”-这意味着该功能将仅对提供的​​变量起作用-即,它是下面的定义引用的简写形式。重点是,这可能并不意味着会有其他安全观念。
Joe Soul-bringer 2010年

您是否错过了第一个示例中的互斥传递?
2010年

@paercebal:您的示例是错误的。您实际上不需要费心使用回调,如果有一个简单的递归,则会遇到相同的问题,但是唯一的问题是您忘记确切说明锁的分配位置。
Yttrill 2010年

3
@Yttrill:我假设您正在谈论第一个示例。我使用了“回调”,因为从本质上讲,有回调的味道。当然,递归函数也会有同样的问题,但是通常,人们可以轻松地分析一个函数及其递归性质,从而检测出它是可重入的还是可以递归的。从另一方面讲,回调意味着调用回调的函数的作者没有任何有关回调所做的任何信息,因此该作者很难确定自己的函数是可重入的。这就是我要说明的困难。
paercebal 2010年

1
@Gab是好人:我更正了第一个示例。谢谢!信号处理程序将有其自身的问题,不同于可重入性,通常,当引发信号时,除了更改专门声明的全局变量外,您实际上无能为力。
paercebal '18

21

“安全”的定义恰如常识所定义的-意思是“正确地做事而不干扰其他事情”。您引用的六点很清楚地表达了实现该目标的要求。

您的3个问题的答案是3ד否”。


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

没有!

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


所有线程安全函数都可重入吗?

没有!

如果并发调用某个函数不会出现故障,则该函数是线程安全的。但这可以通过例如使用互斥来阻止第二次调用的执行直到第一次完成来实现,因此一次只能执行一个调用。重入是指在不干扰其他调用的情况下并发执行


所有递归和线程安全函数都可以重入吗?

没有!

往上看。


10

通用线程:

如果在例程被中断时调用该例程,行为是否定义明确?

如果您具有这样的功能:

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

然后,它不依赖于任何外部状态。行为已明确定义。

如果您具有这样的功能:

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

结果在多个线程上定义不正确。如果时机错误,信息可能会丢失。

可重入函数的最简单形式是仅对传递的参数和常量值进行操作。其他任何事情都需要特殊处理,或者通常是不可重入的。当然,这些论点不能引用可变的全局变量。


7

现在,我必须详细说明我以前的评论。@paercebal答案不正确。在示例代码中,没有人注意到实际上并没有传递应该作为参数的互斥锁吗?

我断言我对这一结论表示怀疑:要使一个功能在并发的情况下保持安全,就必须重新进入。因此,并发安全(通常写为线程安全)意味着可重入。

线程安全和可重入都没有关于参数的任何说法:我们正在谈论函数的并发执行,如果使用了不合适的参数,这仍然是不安全的。

例如,memcpy()是线程安全的(通常是可重入的)。显然,如果使用来自两个不同线程的指向相同目标的指针进行调用,它将无法按预期工作。这就是SGI定义的重点,将责任放在客户端上以确保客户端对相同数据结构的访问是同步的。

重要的是要理解,通常来说,让线程安全的操作包含参数是胡说八道。如果您已经完成了任何数据库编程,您将理解。什么是“原子的”概念并可能受到互斥锁或其他某种技术的保护,这必然是一个用户概念:在数据库上处理事务可能需要多次不间断的修改。谁能说哪些需要同步,而客户程序员呢?

关键在于,“损坏”不必通过未序列化的写入来破坏计算机上的内存:即使序列化了所有单独的操作,损坏仍然可能发生。随之而来的是,当您询问一个函数是线程安全的还是可重入的时,该问题意味着所有适当分隔的参数:使用耦合参数并不构成反例。

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

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

malloc(heap*, size_t);

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

一般来说,锁不会使事情变得线程安全。它们实际上是通过不适当地尝试管理客户端拥有的资源来破坏安全性。锁定必须由对象制造商完成,这就是唯一的代码,它知道创建了多少个对象以及如何使用它们。


“因此,并发安全(通常写为线程安全)意味着可重入。” 这与“线程安全但不可重入”的Wikipedia 示例相矛盾。
Maggyero

3

列出的要点中的“普通线程”(双关语是指!

因此,例如,静态数据就成为一个问题,因为它是所有线程所拥有的。如果一个调用修改了一个静态变量,则所有线程都使用修改后的数据,从而影响其行为。自修改代码(尽管很少遇到,并且在某些情况下可以防止)将是一个问题,因为尽管有多个线程,但是代码只有一个副本;代码也是必不可少的静态数据。

从本质上来说,每个线程都必须能够重用,就像它是唯一的用户一样,必须能够使用该函数,如果一个线程可以以不确定性的方式影响另一个线程的行为,则情况并非如此。首先,这涉及到每个线程具有该函数起作用的单独数据或常量数据。

综上所述,第(1)点不一定正确。例如,您可以合法地并通过设计使用静态变量来保留递归计数,以防止过度递归或分析算法。

线程安全函数不必是可重入的。它可以通过专门防止用锁重入来实现线程安全,并且点(6)表示该函数不是可重入的。关于第(6)点,调用锁的线程安全函数的函数在递归中使用是不安全的(它将死锁),因此尽管可以确保并发安全,但不能说是可重入的,并且从多个线程可以同时使它们的程序计数器处于这种功能的意义上来说,它仍然是可重入的(只是不与锁定区域有关)。可能这有助于将线程安全性与重入性区分开(或者可能会增加您的困惑!)。


1

您的“也”问题的答案是“否”,“否”和“否”。仅仅因为函数是递归的和/或线程安全的,就不能使其重入。

这些类型的函数中的每一个都会在您引用的所有点上失败。(尽管我不确定点5的100%)。


1

术语“线程安全”和“可重入”仅表示其定义中所说的内容。在此上下文中,“安全” 表示您在其下面引用的定义所表示的含义。

从更广泛的意义上讲,“安全”在广义上并不意味着在给定上下文中调用给定函数不会完全削弱您的应用程序的安全性。总而言之,一个函数可能会在您的多线程应用程序中可靠地产生预期的效果,但根据定义,该函数既不是可重入的也不是线程安全的。相反,您可以以在多线程应用程序中产生各种不希望的,意外的和/或不可预测的效果的方式调用重入函数。

递归函数可以是任何东西,并且Re-entrant具有比线程安全更强的定义,因此对您的编号问题的答案都为否。

阅读可重入的定义,可以将其概括为一个功能,该功能不会修改您所谓的修改内容。但是您不应该仅依赖摘要。

在一般情况下,多线程编程非常困难。知道一个人的代码重入者只是这一挑战的一部分。线程安全性不是累加的。与其尝试将可重入功能拼凑起来,不如使用整体线程安全的 设计模式并使用该模式来指导您对程序中的每个线程和共享资源的使用。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.