什么是可重入函数?

大多数 时间,重入的定义引自 维基百科:

计算机程序或例程是 如果可以的话被描述为可重入的 安全地再次调用之前的 以前的调用已经完成 (即可以安全地执行 要重入,a 计算机程序或例行程序:

  1. 必须不保持静态(或全局) 非常数数据非常数数据。
  2. 不能把地址寄回 静态(或全局)非常数 资料。
  3. 必须只在提供的数据上工作 通过来电显示。
  4. 不能依赖于对单件的锁 资源。
  5. 不得修改自己的代码(除非 在它自己的唯一线程中执行 储存)
  6. 不能调用不可重入的计算机 程序或例行公事。

安全是如何定义的?

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

在检查我的代码是否具有可重入功能时,我应该牢记的六点之间的共同点是什么?

还有,

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

在写这个问题的时候,我想到了一件事: 像 重新入场螺纹安全这样的术语是绝对的吗? 例如,它们有固定的具体定义吗?因为,如果他们不是,这个问题就没有什么意义。

101264 次浏览

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

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

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

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


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

不!

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


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

不!

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


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

不!

见上图。

共同点是:

如果在中断时调用例程,行为是否定义良好?

如果你有这样一个函数:

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

这样它就不依赖于任何外部状态。行为定义良好。

如果你有这样一个函数:

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

结果在多个线程上没有很好地定义。如果时机不对,信息可能会丢失。

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

所列出的要点中的“共同主线”(双关语!?)是函数不能做任何会影响对同一函数的任何递归或并发调用的行为的事情。

比如静态数据就是个问题因为它属于所有线程;如果一个调用修改了一个静态变量,那么所有线程都会使用修改后的数据,从而影响它们的行为。自修改代码(虽然很少遇到,在某些情况下被阻止)将是一个问题,因为尽管有多个线程,但只有一个代码副本;代码也是基本的静态数据。

从本质上讲,要实现可重入,每个线程必须能够使用函数,就像它是唯一的用户一样,如果一个线程可以以不确定的方式影响另一个线程的行为,则情况就不是这样了。这主要涉及到每个线程都有函数所处理的独立数据或常量数据。

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

线程安全函数不需要是可重入的;它可以通过专门防止锁的重入来实现线程安全,而第(6)点指出这样的函数是不可重入的。关于第(6)点,调用线程安全的锁函数的函数在递归中使用是不安全的(它会死锁),因此不能说它是可重入的,尽管它对于并发来说仍然是安全的,并且仍然是可重入的,因为多个线程可以同时在这样的函数中拥有它们的程序计数器(只是不在锁定区域)。这可能有助于区分线程安全与重溯(或者可能会增加您的困惑!)

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.在检查代码的重入功能时,我应该记住的这六点之间的共同点究竟是什么?

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

(好吧,99%的代码都应该有味道,然后……)

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

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

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

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

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

不。

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

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

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将在最后一次调用结束时被删除,并且仍然可能在第一次调用结束前被使用。

因此,此代码不是recursive-safe

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

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

但是由于多线程问题,它仍然是不可重入的:我们必须确保对cp的修改将使用递归互斥锁原子地完成(并不是所有的互斥锁都是递归的):

#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. 确保所有函数都有NO状态

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

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,用户必须用并发原语保护其访问;

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

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

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

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

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

在阅读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;
}
  • 与不可重入函数相反,可重入函数意味着在任何时候调用函数都将得到相同的结果而没有副作用。因为没有上下文。
  • 从线程安全的角度来看,它只是意味着在当前时间、当前进程中对公共变量只有一次修改。因此,您应该添加锁保护,以确保在同一时间只对公共字段进行一次更改。
  • 所以线程安全性和可重入在不同的视图中是两个不同的东西。可重入函数安全说你应该在下次进行上下文分析之前清除上下文。线程安全说你应该保持参观公共场所的秩序。