16.C++11线程—在线程间共享数据(笔记)


内容概要:

  • 线程间共享数据的问题
  • 利用互斥保护共享数据
  • 利用其他工具保护共享数据

1 线程间共享数据的问题

多线程共享数据的问题多由数据改动引发。

为了帮助分析,“不变量”(invariant)被广泛使用,它是一个针对某一特定数据的断言,该断言总是成立的,例如"这个变量的值即为链表元素的数目"。数据更新往往会破坏这些不变量,尤其是涉及的数据结构稍微复杂,或者数据更新需要改动的值不止一个时。

考察双向链表,其每个节点都持有两个指针,分别指向自身前方(previous)和后方(next)的节点。故此,存在一个不变量:若我们从节点A的next指针到达另一个节点B,则B的previous指针应该指向节点A。为了从双向列表中删除某个节点,它两侧相邻节点的指针都要更新,指向彼此。只要其中一个相邻节点先行更新,不变量即别被破坏,直到另一个节点也更新。更新的全部操作完成后,不变量又重新成立。

从双向链表删除一个节点的步骤:

  • 步骤a:识别需要删除的节点,称之为N。
  • 步骤b:更新前驱节点中本来指向N的指针,令其改为指向N的后继节点。
  • 步骤c:更新后继节点中本来指向N的指针,令其改为指向N的前驱节点。
  • 步骤d:删除节点N。

在步骤b和步骤c之间,正向指针和逆向指针不一致,不变量被破坏。

改动线程间的共享数据,可能导致的最简单的问题是破坏不变量。

如某线程正在读取双向链表,另一线程同时在删除节点,而我们没有采取专门的措施保护不变量,那么执行读取操作的线程很可能遇见未被完全删除的节点,不变量遂被破坏。

1.1 条件竞争

在并发编程中,操作由两个或多个线程负责,它们争先让线程执行各自的操作,而结果取决于它们执行的相对次序,所有这种情况都是条件竞争。很多时候,这是良性行为,因为全部可能的结果都可以接受,即便线程变换了相对次序。例如,两个线程向队列添加数据项以待处理,只要维持住系统的不变量,先添加哪个数据项通常并不重要。

C++标准还定义了术语"数据竞争"(data race):并发改动单个对象而形成的特定的条件竞争。

诱发恶性条件竞争的典型场景是,要完成一项操作,却需改动两份或多份不同的数据。因为操作涉及两份独立的数据,而它们只能用单独的指令改动,当其中一份数据完成改动时,别的线程有可能不期而妨。因为满足条件的时间窗口短小,所以条件竞争往往既难以察觉又难复现。

若改动操作是由连续不间断的CPU指令完成的,就不太有机会在任何的单次运行中引发问题,即使其他线程正在并发访问数据。只有按某些次序执行指令才可能引发问题,随着系统负载加重及执行操作的次数增多,这种次序出现的机会也将增加。

1.2 防止恶性条件竞争

防止恶性条件竞争的方法有多种:

  • 最简单的就是采取保护错误包装数据结构,确保不变量被破坏时,中间状态只对执行改动的线程可见。在其他访问同一数据结构的线程的视角中,这种改动要么尚未开始。

  • 还有就是,修改数据结构的设计及其不变量,由一连串不可拆分的改动完成数据变更,每个改动都维持不变量不被破坏。

    这通常被称为无锁编程,难以正确编写。如过从事这一层面的开发工作,就要探究内存模型的微妙细节,以及区分每个线程都可以看到什么数据集,两者都可能很复杂。

  • 将修改数据结构当作事务(transaction)来处理,类似于数据库在一个事务内完成更新:

    把需要执行的数据读写操作视为一个完整序列,先用事务日志存储记录,再把序列当成单一步骤提交运行。

    若别的线程改动了数据而令提交无法完整执行,则事务重新开始。这称为软件事务内存(Software Transactional Memory,STM)。

2 用互斥保护共享数据

访问一个数据结构前,先锁住与数据相关的互斥;访问结束后,再解锁互斥。C++线程库保证了,一旦有线程锁住了某个互斥,若其他线程试图再给它加锁,则须等待,直至最初成功加锁的线程把该互斥解锁。这确保了全部线程所见到的共享数据是自洽的(self-consistent),不变量没有被破坏。

互斥是C++最通用的共享数据保护措施之一,但非万能的灵丹妙药。务必妥善地组织和编排代码,从而正确地保护共享数据,同时避免接口固有的条件竞争。

互斥本身也有问题,表现形式是:

  • 死锁
  • 对数据的过保护或欠保护

2.1 在C++中使用互斥

通过构造std::mutex的实例来创建互斥,调用成员函数lock()对其加锁,调用unlock()解锁。但是若直接调用成员函数的做法,那我们就必须记住,在函数以外的每条代码路径上都要调用unlock(),包括由于异常导致退出的路径。

C++标准库提供了类模板std::lock_guard<>,针对互斥类融合实现了RAII手法:在构造时给互斥加锁,在析构时解锁,从而保证互斥总被正确解锁。

如下代码,演示了用互斥保护链表

#include <list>
#include <mutex>
#include <algorithm>

std::list<int> some_list;			⑴
std::mutex some_mutex;				⑵
void add_to_list(int new_value)
{
    std::lock_guard<std::mutex> guard(some_mutex);		⑶
    some_list.push_back(new_value);
}
bool list_contains(int value_to_find)
{
    std::lock_guard<std::mutex> guard(some_mutex);		⑷
    return std::find(some_list.begin(),some_list.end(),value_to_find) != some_list.end();
}

上述代码,有一个独立的全局变量(1),由对应的std::mutex实例保护(另一个全局变量)(2)。函数add_to_list() (3)和函数list_contains() (4)内部都使用了std::lock_guard<std::mutex>,其意义是使这两个函数对链表的访问互斥:假定add_to_list()正在改动链表,若操作进行到一半,则list_contians()绝对看不见该状态。

C++17引入了一个新特性,名为类模板参数推导(class template argument deduction),对于std::lock_guard<>这种简单的类模板,模板参数列表可以忽略。如果编译器支持C++17标准,则

std::lock_guard<std::mutex> guard(some_mutex);

能简化为

std::lock_guard guard(some_mutex);

对于大多数场景下的普遍做法是,将互斥与受保护的数据组成一个类。这是面向对象设计准则的典型运用:将两者放在同一个类里,我们清楚表明它们互相联系,还能封装函数以增加保护。

但需要注意:

如果成员函数返回指针或引用,指向受保护的共享数据,那么即便成员函数全部按良好、有序的方式锁定互斥,也无济于事,因为保护已经被打破,出现了大漏洞。只要存在任何能访问该指针和引用的代码,它就可以访问受保护的共享数据(也可以修改),而无须锁定互斥。

所以,若利用互斥保护共享数据,则需谨慎设计程序接口,从而确保互斥已先行确定,再对受保护的共享数据进行访问,并保证不留后门。

2.2 组合和编排代码以保护共享数据

危险情况:

  • 成员函数通过各种"形式"——无论是返回值,还是输出参数(out parameter)——向调用者返回指针或引用,指向受保护的共享数据,就会危及共享数据安全。
  • 成员函数在自身内部调用了别的函数,而这些函数却不受我们掌控,那么,也不得向它们传递这些指针或引用。这些函数有可能将指针或引用暂存别处,等到以后脱离了互斥保护再投入使用。

如下代码,演示了意外地向外传递引用,指向受保护共享数据

class some_data
{
    int a;
    std::string b;
public:
    void do_something();
};

class data_wrapper
{
private:
    some_data data;
    std::mutex m;
public:
    template<typename Function>
    void process_data(Function func)
    {
        std::lock_guard<std::mutex> l(m);
        func(data);					//⑴ 向使用者提供的函数传递受保护共享数据
    }
};

some_data* unprotected;

void malicious_function(some_data& protected_data)
{
    unprotected = &protected_data;
}

data_wrapper x;

void foo()
{
    x.process_data(malicious_function);		//⑵ 传入恶意函数
    unprotected->do_something();			//⑶ 以无保护方式访问本应受保护的共享数据
}

在本例中,process_data()函数内的代码显然没有问题,由std::lock_guard很好地保护,然而,其参数func是使用者提供的函数(1)。换言之,foo()中的代码可以传入malicious_function (2),接着,就能绕过保护,调用do_something(),而没有锁住互斥(3)

无奈,C++线程库对这个问题无能为了;只有靠我们自己——程序员——正确地锁定互斥,借此保护共享数据。

但只要遵循下列指引即可应对上述情形:不得向锁所在的作用域之外传递指针和引用,指向受保护的共享数据,无论是通过函数返回值将它们保存到对外可见的内存,还是将它们作为参数传递给使用者提供的函数。

2.3 发现接口固有的条件竞争

std::stack有5种操作:

  • push()压入新元素
  • pop()弹出栈顶元素
  • top()读取栈顶元素
  • empty()检测栈是否为空
  • size()读取栈内元素的数量

容器接口如下:

template<typename T,typename Container=std::deque<T>>
class stack
{
public:
    explicit stack(const Container&);
    explicit stack(Container&& = Container());
    template <class Alloc> explicit stack(const Alloc&);
    template <class Alloc> stack(const Container&,const Alloc&);
    template <class Alloc> stack(Container&&,const Alloc&);
    template <class Alloc> stack(stack&&,const Alloc&);
    bool empty() const;
    size_t size() const;
    T& top();
    T const& top() const;
    void push(T const&);
    void push(T&&);
    void pop();
    void swap(stack&&);
    template <class... Args> void emplace(Args&&.. args);	//⑴C++14的新特性
};

这里的问题是,empty()size()的结果不可信。尽管,在某线程调用empty()size()时,返回值可能是正确的。然而,一旦函数返回,其他线程就不再受限,从而能自由的访问栈容器,可能马上有新元素入栈,或者,现有的元素立刻出栈,令前面的线程得到的结果失效而无法使用。

如:

stack<int> s;
if(!s.empty())
{
    int const value = s.top();
    s.pop();
    do_something(value);
}

对单线程而言,它既安全,又符合预期。可是,只要涉及共享,这一连串调用便不再安全。因为,在empty()top()之间,可能有另一个线程调用pop(),弹出栈顶元素。毫无疑问,这正是典型的条件竞争。它的根本原因在于函数接口,即使在内部使用互斥保护栈容器中的元素,也无法防范。

这要求从根本上更改接口设计,其中一种改法是把top()pop()组成一个成员函数,再采取互斥保护。对于栈容器中存储的对象,若其拷贝构造函数抛出异常,则可能导致该合成的成员函数出现问题。

我们可以假设pop()函数的定义是:返回栈顶元素的值,并从栈上将其移除。隐患由此而来:只有在栈被改动之后,弹出的元素才返回给调用者,然而在向调用者赋值数据的过程中,有可能抛出异常。万一弹出的元素已从栈上移除,但复制却不成功,就会令数据丢失!

2.3.1 消除条件竞争的方法

  • 方法1:传入引用

    借一个外部变量接收栈容器弹出的元素,将指涉它的引用通过参数传入pop()调用。

    std::vector<int> result;
    some_stack.pop(result);
    

    这在许多情况下行之有效,但还是有明显的短处:

    • 要调用pop(),则须先依据栈容器中的元素型别构造一个实例,将其充当接收目标传入函数内。对于某些型别,构建实例的时间代价高昂或耗费资源过多,所以不太实用。
    • 一些型别的栈元素,其构造函数不一定带有参数,故此法也并不总是可行。
    • 还要求栈容器存储的型别是可赋值的(
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值