有时,不太好确定锁定的粒度究竟应该多大,因为并不是所有对共享数据结构的请求都在一个层级上。此时,另一种机制也许可以代替普通的mutex。
一种极端的情况是,共享数据仅仅在初始化时需要同步,初始化之后就再也不需要做同步操作了。这也许是因为:要么这是一种只读数据;要么它的所有操作都隐含着相应的保护机制。再者,纯粹为了保护数据初始化而使用mutex是没必要的,也是有损性能的。为此,C++标准提供了一种机制,它纯粹就是为了初始化。
想象一下这种情况,你有一种共享数据资源,它的构造很耗时,以至于你只想在真的需要它时才去构造,例如:数据库连接,或者申请大块内存。惰性初始化(Lazy initialization)经常用于单线程开发中:每个请求资源的操作首先检查它是否已经初始化,如果没有就去初始化它,然后再使用:
std::shared_ptr<some_resource> resource_ptr;
void foo()
{
if (!resource_ptr)
{
resource_ptr.reset(new some_resource);
}
resource_ptr->do_something();
}
如果想将上面的过程改为线程安全的,那么唯一需要修改的就是初始化那句。但是一种天真的做法可能会导致意想不到的问题:
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo()
{
std::unique_lock<std::mutex> lk(resource_mutex);
if (!resource_ptr)
{
resource_ptr.reset(new some_resource);
}
lk.unlock();
resource_ptr->do_something();
}
这样改会导致多线程访问这个函数时产生不必要的序列化。每个线程都需要等待锁定mutex,以检查资源是否已经初始化。
很多人尝试解决这个问题,包括声名狼藉的双重检查锁(Double-Checked Locking)模式:
void undefined_behaviour_with_double_checked_locking()
{
if (!resource_ptr)
{
std::lock_guard<std::mutex> lk(resource_mutex);
if (!resource_ptr)
{
resource_ptr.reset(new some_resource);
}
}
resource_ptr->do_something();
}
原因在于,第一次检查resource_ptr的时候以及给resource_ptr初始化时没有进行同步。看下面的分析:
obj *p = new obj()
为了执行这句代码,机器需要做三样事儿:1.为obj对象分配空间。2.在分配的空间中构造对象。3.使p指向分配的空间。遗憾的是编译器并不是严格按照上面的顺序来执行的。可以交换2和3。
如果编译器将执行顺序更改了,可想而知,上面的程序有可能导致指针已经被赋值,但是对象还未构造,却被另一个线程认为可用并且直接使用了。
C++标准委员会注意到了这种情况,因此提供了std::once_flag和std::call_once来解决这类问题。call_once()函数接受一个once_flag对象参数,接受一个谓词参数或函数,以及不定个数的参数。它保证指定的谓词或函数只执行一次,而且其效率要远远高于直接使用mutex。看下面的代码:
std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag;
void init_resource()
{
resource_ptr.reset(new some_resource);
}
void foo()
{
std::call_once(resource_flag, init_resource);
resource_ptr->do_something();
}
它保证init_resource()只被执行了一次,不管多少线程同时调用。而且代码很简单。call_once()可以很容易的包装到类中,看下面的代码:
class X
{
private:
connection_info connection_details;
connection_handle connection;
std::once_flag connection_init_flag;
void open_connection()
{
connection = connection_manager.open(connection_details);
}
public:
X(connection_info const& connection_details_) :
connection_details(connection_details_)
{}
void send_data(data_packet const& data)
{
std::call_once(connection_init_flag, &X::open_connection, this);
connection.send_data(data);
}
data_packet receive_data()
{
std::call_once(connection_init_flag, &X::open_connection, this);
return connection.receive_data();
}
};
std::mutex和std::once_flag都是不可以copy,也不可以move的,因此当将它们作为成员变量时,如果需要copy或者move操作,则需要手动定义对应的成员函数。
还有一种情况可以导致数据竞争问题,就是在局部static变量。在比C++11早些的编译器上,如果多个线程同时访问带有第定义局部static变量的代码,则它们可能都会去初始化它,或者试图访问一个变量,这个变量被另一个线程刚初始化但是还未完成。这个问题在C++11中已经被解决了:初始化过程被定义在一个确定的线程中,在初始化完成前,其他线程无法处理数据。例如下面这样:
class my_class;
my_class& get_my_class_instance()
{
static my_class instance;
return instance;
}
现在唯一需要担心的就是,在初始化过程中,如果有涉及到共享数据更新,则需要做同步保护。