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. 确保线程安全的代码是递归安全的
这意味着如果您认为同一个资源可以被同一个线程使用两次,就使用递归互斥。