[笔记]C++并发编程实战 《三》线程间共享数据(一)

简介: [笔记]C++并发编程实战 《三》线程间共享数据

前言

本章主要内容

  • 共享数据带来的问题
  • 使用互斥量保护数据
  • 数据保护的替代方案

第3章 线程间共享数据

上一章中,我们已经对线程管理有所了解,现在让我们来看一下“共享数据的那些事”。

想象一下,你和你的朋友合租一个公寓,公寓中只有一个厨房和一个卫生间。当你的朋友在卫生间时,你就会不能使用了(除非你们特别好,可以在同时使用一个房间)。

这个问题也会出现在厨房,假如:厨房里有一个组合式烤箱,当在烤香肠的时候,也在做蛋糕,就可能得到我们不想要的食物(香肠味的蛋糕)。此外,在公共空间将一件事做到一半时,发现某些需要的东西被别人借走,或是当离开的一段时间内有些东西被变动了地方,这都会令我们不爽。

同样的问题,也困扰着线程。当线程在访问共享数据的时候,必须定一些规矩,用来限定线程可访问的数据位。还有,一个线程更新了共享数据,需要对其他线程进行通知。从易用性的角度,同一进程中的多个线程进行数据共享,有利有弊。错误的共享数据使用是产生并发bug的一个主要原因,并且后果要比香肠味的蛋糕更加严重。

本章就以在C++中进行安全的数据共享为主题。避免上述及其他潜在问题的发生的同时,将共享数据的优势发挥到最大。

3.1 共享数据带来的问题

涉及到共享数据时,问题就可能是因为共享数据修改所导致

  • 如果共享数据是只读的,那么操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。
  • 但是,当一个或多个线程要修改共享数据时,就会产生很多麻烦。这种情况下,就必须小心谨慎,才能确保所有线程都工作正常。

不变量(invariants)的概念对开发者们编写的程序会有一定的帮助——对于特殊结构体的描述;

比如,“变量包含列表中的项数”不变量通常会在一次更新中被破坏,特别是比较复杂的数据结构,或者一次更新就要改动很大的数据结构。

双链表中每个节点都有一个指针指向列表中下一个节点,还有一个指针指向前一个节点。其中不变量就是节点A中指向“下一个”节点B的指针,还有前向指针。为了从列表中删除一个节点,其两边节点的指针都需要更新。当其中一边更新完成时,不变量就被破坏了,直到另一边也完成更新;在两边都完成更新后,不变量就又稳定了。

从一个列表中删除一个节点的步骤如下(如图3.1)

  • [a] 找到要删除的节点N
  • [b] 更新前一个节点指向N的指针,让这个指针指向N的下一个节点
  • [c] 更新后一个节点指向N的指针,让这个指正指向N的前一个节点
  • [d] 删除节点N

图3.1 从一个双链表中删除一个节点

图中b和c在相同的方向上指向和原来已经不一致了,这就破坏了不变量。

线程间的问题在于修改共享数据,致使不变量遭到破坏

当在删除过程中不确定是否有其他线程能够进行访问的话,可能就有线程访问到刚刚删除一边的节点;这样的话,线程就读取到要删除节点的数据(因为只有一边的连接被修改,如图3.1(b)),这样不变量就被破坏了。

破坏不变量的后果是不确定的,当其他线程按从左往右的顺序来访问列表时,它将跳过被删除的节点。

在一方面,如有第二个线程尝试删除图中右边的节点,那么可能会让数据结构产生永久性的损坏,使程序崩溃。

无论结果如何,这都是并行中常见错误:

  • 条件竞争(racecondition)。

3.1.1 条件竞争

假如你去电影院买电影票。如果去的是一家大电影院,有很多收银台,很多人就可以在同一时间买电影票。当另一个收银台也在卖你想看的这场电影的电影票,那么你的座位选择范围就取决于在之前已预定的座位。当只有少量的座位剩下,这就意味着,这可能是一场抢票比赛,看谁能抢到最后一张票。

这就是一个条件竞争的例子:

  • 你的座位(或者你的电影票)都取决于两种购买方式的相对顺序。

并发中竞争条件的形成,取决于一个以上线程的相对执行顺序,每个线程都抢着完成自己的任务。大多数情况下,即使改变执行顺序,也是良性竞争,其结果可以接受。例如,有两个线程同时向一个处理队列中添加任务,因为系统提供的不变量保持不变,所以谁先谁后都不会有什么影响。当不变量遭到破坏时,才会产生条件竞争,比如双向链表的例子。并发中对数据的条件竞争通常表示为恶性条件竞争,我们对不产生问题的良性条件竞争不感兴趣。

C++ 标准中也定义了数据竞争这个术语,一种特殊的条件竞争:

  • 并发的去修改一个独立对象(参见5.1.2节),数据竞争是(可怕的)未定义行为的起因。

恶性条件竞争通常发生于完成对多于一个的数据块的修改时,例如:对两个连接指针的修改

(如图3.1)。因为操作要访问两个独立的数据块,独立的指令将会对数据块将进行修改,并且其中一个线程可能正在进行时,另一个线程就对数据块进行了访问。因为出现的概率太低,条件竞争很难查找,也很难复现。如CPU指令连续修改完成后,即使数据结构可以让其他并发线程访问,问题再次复现的几率也相当低。当系统负载增加时,随着执行数量的增加,执行序列的问题复现的概率也在增加,这样的问题只可能会出现在负载比较大的情况下。条件竞争通常是时间敏感的,所以程序以调试模式运行时,它们常会完全消失,因为调试模式会影响程序的执行时间(即使影响不多)。

当你以写多线程程序为生,条件竞争就会成为你的梦魇;编写软件时,我们会使用大量复杂的操作,用来避免恶性条件竞争。

3.1.2 避免恶性条件竞争

这里提供一些方法来解决恶性条件竞争,最简单的办法就是:

  • 对数据结构采用某种保护机制,确保只有进行修改的线程才能看到不变量被破坏时的中间状态。从其他访问线程的角度来看,修改不是已经完成了,就是还没开始。

C++ 标准库提供很多类似的机制,下面会逐一介绍。

另一个选择是:

  • 对数据结构和不变量的设计进行修改,修改完的结构必须能完成一系列不可分割的变化,也就是保证每个不变量保持稳定的状态这就是所谓的无锁编程
    不过,这种方式很难得到正确的结果。如果到这个级别,无论是内存模型上的细微差异,还是线程访问数据的能力,都会让工作量变的很大。内存模型将在第5章讨论,无锁编程将在第7章讨论。

另一种处理条件竞争的方式是:

  • 使用事务的方式去处理数据结构的更新(这里的"处理"就如同对数据库进行更新一样)。
    所需的一些数据和读取都存储在事务日志中,然后将之前的操作合为一步,再进行提交。当数据结构被另一个线程修改后,或处理已经重启的情况下,提交就会无法进行,这称作为“软件事务内存”(software transactional memory (STM))。

理论研究中,这是一个很热门的研究领域。这个概念将不会在本书中再进行介绍,因为在 C++ 中没有对STM进行直接支持(尽管C++有事务性内存扩展的技术规范[1])。但是,基本思想会在后面提及。

保护共享数据结构的最基本的方式,是使用C++标准库提供的互斥量。

[1] SO/IEC TS 19841:2015—Technical Specification for C++ Extensions for Transactional

Memory http://www.iso.org/iso/home/store/catalogue_tc/catalogue_detail.htm?

csnumber=66343 .

3.2 使用互斥量保护共享数据

当程序中有共享数据时,你肯定不想让程序其陷入条件竞争,或是出现不变量被破坏的情况。

将所有访问共享数据结构的代码都标记为互斥是否是一种更好的办法呢?

这样,任何一个线程在执行时,其他线程试图访问共享数据时,就必须进行等待。除非该线程就在修改共享数据,否则任何线程都不可能会看到被破坏的不变量。

当访问共享数据前,将数据锁住,在访问结束后,再将数据解锁。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后,才能进行访问。这就保证了所有线程都能看到共享数据,并而不破坏不变量。

互斥量一种数据保护通用机制,但它不是什么“银弹”;需要编排代码来保护数据的正确性(见3.2.2节),并避免接口间的竞争条件(见3.2.3节)也非常重要。不过,互斥量自身也有问题,也会造成死锁(见3.2.4节),或对数据保护的太多(或太少)(见3.2.8节)。

3.2.1 C++中使用互斥量

C++中通过实例化 std::mutex 创建互斥量实例,通过成员函数lock()对互斥量上锁,unlock()进行解锁。不过,实践中不推荐直接去调用成员函数,调用成员函数就意味着,必须在每个函数出口都要去调用unlock(),也包括异常的情况。C++标准库为互斥量提供了一个RAII语法的模板类 std::lock_guard ,在构造时就能提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁互斥量能被正确解锁。下面的程序清单中,展示了如何在多线程应用中,使用 std::mutex 构造的 std::lock_guard 实例,对一个列表进行访问保护。 std::mutex 和 std::lock_guard 都在 头文件中声明。

清单3.1 使用互斥量保护列表

#include <list>
#include <mutex>
#include <algorithm>
std::list<int> some_list; // 1
std::mutex some_mutex; // 2
void add_to_list(int new_value)
{
  std::lock_guard<std::mutex> guard(some_mutex); // 3
  some_list.push_back(new_value);
}
bool list_contains(int value_to_find)
{
  std::lock_guard<std::mutex> guard(some_mutex); // 4
  return
  std::find(some_list.begin(),some_list.end(),value_to_find) !=
  some_list.end();
}

清单3.1中有一个全局变量①,这个全局变量被一个全局的互斥量保护②。add_to_list()③和list_contains()④函数中使用 std::lock_guardstd::mutex ,使得这两个函数中对数据的访问是互斥的:list_contains()不可能看到正在被add_to_list()修改的列表。

C++17中添加了一个新特性,称为模板类参数推导,这样类似 std::locak_guard 这样简单的模板类型的模板参数列表可以省略。③和④的代码可以简化成:

std::lock_guard guard(some_mutex);

具体的模板参数类型推导则交给C++17的编译器完成。3.2.4节中,会介绍C++17中的一种加强版数据保护机制—— std::scoped_lock ,所以在C++17的环境下,上面的这行代码也可以写成:

std::scoped_lock guard(some_mutex);

为了让代码更加清晰,并且兼容只支持之C++11标准的编译器,我将会继续使用 std::lock_guard ,并在代码清代中写明模板参数的类型。

某些情况下使用全局变量没问题,但在大多数情况下,互斥量通常会与需要保护的数据放在同一类中,而不是定义成全局变量。这是面向对象设计的准则:将其放在一个类中,就可让他们联系在一起,也可对类的功能进行封装,并进行数据保护。这种情况下,函数add_to_listlist_contains可以作为这个类的成员函数。互斥量和需要保护的数据,在类中都定义为private成员,这会让访问数据的代码更清晰,并且容易看出在什么时候对互斥量上锁。当所有成员函数都会在调用时对数据上锁,结束时对数据解锁,这就保证了访问时数据不变量不被破坏。

当然,事情也不是总是那么理想,聪明的你一定注意到了:当其中一个成员函数返回的是保护数据的指针或引用时,会破坏数据。具有访问能力的指针或引用可以访问(并可能修改)被保护的数据,而不会被互斥锁限制。这就需要对接口有相当谨慎的设计,要确保互斥量能锁住数据的访问,并且不留后门。

3.2.2 用代码来保护共享数据

使用互斥量来保护数据,并不是仅仅在每一个成员函数中都加入一个 std::lock_guard 对象那么简单;一个指针或引用,也会让这种保护形同虚设。不过,检查指针或引用很容易,只要没有成员函数通过返回值或者输出参数的形式,向其调用者返回指向受保护数据的指针或引用,数据就是安全的。如果你还想深究,就没这么简单了。确保成员函数不会传出指针或引用的同时,检查成员函数是否通过指针或引用的方式来调用也是很重要的(尤其是这个操作不在你的控制下时)。函数可能没在互斥量保护的区域内,存储着指针或者引用,这样就很危险。更危险的是:将保护数据作为一个运行时参数,如同下面清单中所示那样。

清单3.2 无意中传递了保护数据的引用

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); // 1 传递“保护”数据给用户函数
  }
};
some_data* unprotected;
void malicious_function(some_data& protected_data)
{
  unprotected=&protected_data;
}
data_wrapper x;
void foo()
{
  x.process_data(malicious_function); // 2 传递一个恶意函数
  unprotected->do_something(); // 3 在无保护的情况下访问保护数据
}

例子中process_data看起来没有任何问题, std::lock_guard 对数据做了很好的保护,但调用用户提供的函数func①,就意味着foo能够绕过保护机制将函数 malicious_function 传递进去②,在没有锁定互斥量的情况下调用 do_something() 。

这段代码的问题在于根本没有保护,只是将所有可访问的数据结构代码标记为互斥。函

数 foo() 中调用 unprotected->do_something() 的代码未能被标记为互斥。

这种情况下,C++线程库无法提供任何帮助,只能由开发者使用正确的互斥锁来保护数据。从乐观的角度上看,还是有方法可循的:切勿将受保护数据的指针或引用传递到互斥锁作用域之外,无论是函数返回值,还是存储在外部可见内存,亦或是以参数的形式传递到用户提供的函数去。

虽然这是在使用互斥量保护共享数据时常犯的错误,但绝不仅仅是一个潜在的陷阱而已。下一节中,你将会看到,即便是使用了互斥量对数据进行了保护,条件竞争依旧可能存在。


相关文章
|
16天前
|
C++
C++ 语言异常处理实战:在编程潮流中坚守稳定,开启代码可靠之旅
【8月更文挑战第22天】C++的异常处理机制是确保程序稳定的关键特性。它允许程序在遇到错误时优雅地响应而非直接崩溃。通过`throw`抛出异常,并用`catch`捕获处理,可使程序控制流跳转至错误处理代码。例如,在进行除法运算或文件读取时,若发生除数为零或文件无法打开等错误,则可通过抛出异常并在调用处捕获来妥善处理这些情况。恰当使用异常处理能显著提升程序的健壮性和维护性。
33 2
|
13天前
|
Java 程序员 调度
【JAVA 并发秘籍】进程、线程、协程:揭秘并发编程的终极武器!
【8月更文挑战第25天】本文以问答形式深入探讨了并发编程中的核心概念——进程、线程与协程,并详细介绍了它们在Java中的应用。文章不仅解释了每个概念的基本原理及其差异,还提供了实用的示例代码,帮助读者理解如何在Java环境中实现这些并发机制。无论你是希望提高编程技能的专业开发者,还是准备技术面试的求职者,都能从本文获得有价值的见解。
32 1
|
16天前
|
存储 算法 C++
C++ STL应用宝典:高效处理数据的艺术与实战技巧大揭秘!
【8月更文挑战第22天】C++ STL(标准模板库)是一组高效的数据结构与算法集合,极大提升编程效率与代码可读性。它包括容器、迭代器、算法等组件。例如,统计文本中单词频率可用`std::map`和`std::ifstream`实现;对数据排序及找极值则可通过`std::vector`结合`std::sort`、`std::min/max_element`完成;而快速查找字符串则适合使用`std::set`配合其内置的`find`方法。这些示例展示了STL的强大功能,有助于编写简洁高效的代码。
29 2
|
16天前
|
数据采集 Java Python
Python并发编程:多线程(threading模块)
本文详细介绍了Python的threading模块,包括线程的创建、线程同步、线程池的使用,并通过多个示例展示了如何在实际项目中应用这些技术。通过学习这些内容,您应该能够熟练掌握Python中的多线程编程,提高编写并发程序的能力。 多线程编程可以显著提高程序的并发性能,但也带来了新的挑战和问题。在使用多线程时,需要注意避免死锁、限制共享资源的访问,并尽量使用线程池来管理和控制线程。
|
17天前
|
C++ 容器
【C/C++笔记】迭代器
【C/C++笔记】迭代器
13 1
|
7天前
|
图形学 C++ C#
Unity插件开发全攻略:从零起步教你用C++扩展游戏功能,解锁Unity新玩法的详细步骤与实战技巧大公开
【8月更文挑战第31天】Unity 是一款功能强大的游戏开发引擎,支持多平台发布并拥有丰富的插件生态系统。本文介绍 Unity 插件开发基础,帮助读者从零开始编写自定义插件以扩展其功能。插件通常用 C++ 编写,通过 Mono C# 运行时调用,需在不同平台上编译。文中详细讲解了开发环境搭建、简单插件编写及在 Unity 中调用的方法,包括创建 C# 封装脚本和处理跨平台问题,助力开发者提升游戏开发效率。
17 0
|
15天前
|
Java C语言 C++
并发编程进阶:线程同步与互斥
并发编程进阶:线程同步与互斥
25 0
|
15天前
|
存储 安全 Unix
并发编程基础:使用POSIX线程(pthread)进行多线程编程。
并发编程基础:使用POSIX线程(pthread)进行多线程编程。
47 0
|
17天前
|
存储 安全 程序员
【C/C++笔记】迭代器范围
【C/C++笔记】迭代器范围
40 0
|
17天前
|
存储 编译器 C++
C++多态实现的原理:深入探索与实战应用
【8月更文挑战第21天】在C++的浩瀚宇宙中,多态性(Polymorphism)无疑是一颗璀璨的星辰,它赋予了程序高度的灵活性和可扩展性。多态允许我们通过基类指针或引用来调用派生类的成员函数,而具体调用哪个函数则取决于指针或引用所指向的对象的实际类型。本文将深入探讨C++多态实现的原理,并结合工作学习中的实际案例,分享其技术干货。
31 0