3.3用于共享数据保护的替代工具

用于共享数据保护的替代工具

虽然互斥元是最通用的机制,但提到保护共享数据时,它们并不是唯一的选择;还有别的替代品,可以在特定情况下提供更恰当的保护。

一个特别极端(但却相当常见)的情况,就是共享数据只在初始化时才需要并发访问的保护,但在那之后却不需要显式同步。这可能是因为数据是一经创建就是只读的,所以就不存在可能的同步问题,或者是因为必要的保护作为数据上操作的一部分被隐式地执行。在任一情况中,在数据被初始化之后锁定互斥元,纯粹是为了保护初始化,这是不必要的,并且对性能会产生的不必要的打击。为了这个原因,C++标准提供了一种机制,纯粹为了在初始化过程中保护共享数据。

在初始化时保护共享数据

假设你有一个构造起来非常昂贵的共享资源,只有当实际需要时你才会要这样做。也许,它会打开一个数据库连接或分配大量的内存。像这样的延迟初始化(lazyinitialization)在单线程代码中是很常见的——每个请求资源的操作首先检查它是否已经初始化,如果没有就在使用之前初始化之。

std::shared_ptr<some_resource> resource_ptr;
void foo()
{
    
    
     if(!resource_ptr)
     {
    
    
         resource_ptr.reset(new some_resource); //❶
     }
     resource_ptr->do_something();
}

如果共享资源本身对于并发访问是安全的,当将其转换为多线程代码时唯一需要保护的部分就是初始化❶,但是像清单3.11中这样的朴素的转换,会引起使用该资源的线程产生不必要的序列化。这是因为每个线程都必须等待互斥元,以检查资源是否已经被初始化。

//清单3.11 使用互斥元进行线程安全的延迟初始化
#include <memory>
#include <mutex>

struct some_resource
{
    
    
    void 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();
}

int main()
{
    
    
    foo();
}

这段代码是很常见的,不必要的序列化问题已足够大,以至于许多人都试图想出一个更好的方法来实现,包括臭名昭著的二次检查锁定(Double-Checked Locking)模式,在不获取锁❶(在下面的代码中)的情况下首次读取指针,并仅当此指针为NULL时获得该锁。一旦已经获取了锁,该指针要被再次检查❷(这就是二次检查的部分),以防止在首次检查和这个线程获取锁之间,另一个线程就已经完成了初始化。

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

不幸的是,这种模式因某个原因而臭名昭著。它有可能产生恶劣的竞争条件,因为在锁外部的读取❶与锁内部由另一线程完成的写入不同步❸。这就因此创建了一个竞争条件,不仅涵盖了指针本身,还涵盖了指向的对象。就算一个线程看到另一个线程写入的指针,它也可能无法看到新创建的 some_resource 实例,从而导致do_something()❹的调用在不正确的值上运行。这是一个竞争条件的例子,该类型的竞争条件被C++标准定义为数据竞争(data race),因此被定为未定义行为。因此,这是肯定需要避免的。

C++标准委员会也发现这是一个重要的场景,所以C++标准库提供了std::once_flag和 std::call_once 来处理这种情况。与其锁定互斥元并且显式地检查指针,还不如每个线程都可以使用std::call_once,到 std::call_once返回时,指针将会被某个线程初始化(以完全同步的方式),这样就安全了。使用std::call_once比显式使用互斥元通常会有更低的开销,特别是初始化已经完成的时候,所以在std::call_once符合所要求的功能时应优先使用之。下面的例子展示了与清单3.11相同的操作,改写为使用std::call_once。在这种情况下,通过调用函数来完成初始化,但是通过一个带有函数调用操作符的类实例也可以很容易地完成初始化。与标准库中接受函数或者断言作为参数的大部分函数类似,std::call_once可以与任意函数或可调用对象合作。

std::shared_ptr<some_resource> resource_ptr;
std::once_flag resource_flag; //❶

void int_resource()
{
    
    
    resource_ptr.reset(new some_resource);
}
void foo()
{
    
    
    std::call_once(resource_flag, init_resource); //初始化会被正好调用一次
    resource_ptr->do_something();
}

在这个例子中,std::once_flag❶和被初始化的数据都是命名空间作用域的对象,但是std::call_once()可以容易地用于类成员的延迟初始化,如清单3.12所示。

//清单3.12 使用std::call_one的线程安全的类成员延迟初始化
#include <mutex>

struct connection_info
{
    
    };

struct data_packet
{
    
    };

struct connection_handle
{
    
    
    void send_data(data_packet const&)
    {
    
    }
    data_packet receive_data()
    {
    
    
        return data_packet();
    }
};

struct remote_connection_manager
{
    
    
    connection_handle open(connection_info const&)
    {
    
    
        return connection_handle();
    }
} connection_manager;


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

int main()
{
    
    }

在这个例子中,初始化由首次调用 send_data()❶或是由首次调用receive_data()来完成。使用成员函数open_connection()来初始化数据,同样需要将this指针传入函数。和标准库中其他接受可调用对象的函数一样,比如std::thread 和std::bind()的构造函数,这是通过传递一个额外的参数给std::call_once()来完成的❷。

值得注意的是,像std::mutex、std::once_flag的实例是不能被复制或移动的,所以如果想要像这样把它们作为类成员来使用,就必须显式定义这些你所需要的特殊成员函数。

一个在初始化过程中可能会有竞争条件的场景,是将局部变量声明为static的。这种变量的初始化,被定义为在时间控制首次经过其声明时发生。对于多个调用该函数的线程,这意味着可能会有针对定义“首次”的竞争条件。在许多C++11之前的编译器上,这个竞争条件在实践中是有问题的,因为多个线程可能都认为它们是第一个,并试图去初始化该变量,又或者线程可能会在初始化已在另一个线程上启动但尚未完成之时试图使用它。在C++11中,这个问题得到了解决。初始化被定义为只发生在一个线程上,并且其他线程不可以继续直到初始化完成,所以竞争条件仅仅在于哪个线程会执行初始化,而不会有更多别的问题。对于需要单一全局实例的场合,这可以用作std::call_once的替代品。

class my_class;
my_class& get_my_class_instance()
{
    
    
     static my_class instance; //❶初始化保证线程是安全的
     return instance;
}

多个线程可以继续安全地调用get_my_class_instance()❶,而不必担心初始化时的竞争条件。

保护仅用于初始化的数据是更普遍的场景下的一个特例,那些很少更新的数据结构。对于大多数时间而言,这样的数据结构是只读的,因而可以毫无顾忌地被多个线程同时读取,但是数据结构偶尔可能需要更新。这里我们所需要的是一种承认这一事实的保护机制。

保护很少更新的数据结构

假设有一个用于存储DNS条目缓存的表,它用来将域名解析为相应的P地址。通常,一个给定的DNS条目将在很长一段时间里保持不变——在许多情况下,DNS条目会保持数年不变。虽然随着用户访问不同的网站,新的条目可能会被不时地添加到表中,但这一数据却将在其整个生命中基本保持不变。定期检查缓存条目的有效性是很重要的,但是只有在细节已有实际改变的时候才会需要更新。

虽然更新是罕见的,但它们仍然会发生,并且如果这个缓存可以从多个线程访问,它就需要在更新过程中进行适当的保护,以确保所有线程在读取缓存时都不会看到损坏的数据结构。

在缺乏完全符合预期用法并且为并发更新与读取专门设计的专用数据结构的情况下,这种更新要求线程在进行更新时独占访问数据结构,直到它完成了操作。一旦更新完成,该数据结构对于多线程并发访问又是安全的了。使用std::mutex来保护数据结构就因而显得过于悲观,因为这会在数据结构没有进行修改时消除并发读取数据结构的可能,我们需要的是另一种互斥元。这种新的互斥元通常称为读写(reader-writer)互斥元,因为它考虑到了两种不同的用法:由单个“写”线程独占访问或共享,由多个“读”线程并发访问。

新的C++标准库并没有直接提供这样的互斥元,尽管已向标准委员会提议。由于这个建议未被接纳,本节中的例子使用由Boost库提供的实现,它是基于这个建议的。在后面你会看到,使用这样的互斥元并不是万能药,性能依赖于处理器的数量以及读线程和更新线程的相对工作负载。因此,分析代码在目标系统上的性能是很重要的,以确保额外的复杂度会有实际的收益。

你可以使用 boost::shared_mutex的实例来实现同步,而不是使用std::mutex的实例。对于更新操作,std::lock_guard<boost::shared_mutex>std::unique_lock<boost::shared_mutex>可用于锁定,以取代相应的std::mutex特化。这确保了独占访问,就像std::mutex那样。那些不需要更新数据结构的线程能够转而使用boost::shared_lock<boost::shared_mutex>来获得共享访问。这与std::unique_lock用起来正是相同的,除了多个线程在同一时间、同一boost::share_mutex上可能会具有共享锁。唯一的限制是,如果任意一个线程拥有一个共享锁,试图获取独占锁的线程会被阻塞,直到其他线程全都撤回它们的锁,同样地,如果任意一个线程具有独占锁,其他线程都不能获取共享锁或独占锁,直到第一个线程撤回了它的锁。

清单3.13展示了一个简单的如前面所描述的DNS缓存,使用std::map来保存缓存数据,用boost::share_mutex进行保护。

//清单3.13 使用boost::share_mutex保护数据结构
#include <map>
#include <string>
#include <mutex>
#include <boost/thread/shared_mutex.hpp>

class dns_entry
{
    
    };

class dns_cache
{
    
    
    std::map<std::string,dns_entry> entries;
    boost::shared_mutex entry_mutex;
public:
    dns_entry find_entry(std::string const& domain)
    {
    
    
        boost::shared_lock<boost::shared_mutex> lk(entry_mutex); //❶
        std::map<std::string,dns_entry>::const_iterator const it=
            entries.find(domain);
        return (it==entries.end())?dns_entry():it->second;
    }
    void update_or_add_entry(std::string const& domain,
                             dns_entry const& dns_details)
    {
    
    
        std::lock_guard<boost::shared_mutex> lk(entry_mutex); //❷
        entries[domain]=dns_details;
    }
};

int main()
{
    
    }

在清单3.13中,find_entry()使用一个boost::share_lock<>实例来保护它,以供共享、只读的访问❶:多个线程因而可以毫无问题地同时调用find_entry()。另一方面,update_or_add_entry()使用一个 std::lock_guard<>实例,在表被更新时提供独占访问❷;不仅在调用update_or_add_entry()中其他线程被阻止进行更新,调用find_entry()的线程也会被阻塞。

递归锁

在使用std::mutex的情况下,一个线程试图锁定其已经拥有的互斥元是错误的,并且试图这么做将导致未定义行为(undefined behavior)。然而,在某些情况下,线程多次重新获取同一个互斥元却无需事先释放它是可取的。为了这个目的,C++标准库提供了std::recursive_mutex。它就像std::mutex一样,区别在于你可以在同一个线程中的单个实例上获取多个锁。在互斥元能够被另一个线程锁定之前,你必须释放所有的锁,因此如果你调用lock()三次,你必须也调用unlock()三次。正确使用std::lock_guard<std::recursive_mutex>std::unique_lock<std::recursive_mutex>将会为你处理。

大多数时间,如果你觉得需要一个递归互斥元,你可能反而需要改变你的设计。递归互斥元常用在一个类被设计成多个线程并发访问的情况中,因此它具有一个互斥元来保护成员数据。每个公共成员函数锁定互斥元,进行工作,然后解锁互斥元。然而,有时一个公共成员函数调用另一个函数作为其操作的一部分是可取的。在这种情况下,第二个成员函数也将尝试锁定该互斥元,从而导致未定义行为。粗制滥造的解决方案,就是将互斥元改为递归互斥元。这将允许在第二个成员函数中对互斥元的锁定成功进行,并且函数继续。

然而,这样的用法是不推荐的,因为它可能导致草率的想法和糟糕的设计。特别地,类的不变量在锁被持有时通常是损坏的,这意味着第二个成员函数需要工作,即便在被调用时使用的是损坏的不变量。通常最好是提取一个新的私有成员函数,该函数是从这两个成员函数中调用的,它不锁定互斥元(它认为互斥元已经被锁定)。然后,你可以仔细想想在什么情况下可以调用这个新函数以及在那些情况下数据的状态。

猜你喜欢

转载自blog.csdn.net/qq_36314864/article/details/132202824