精通-C++-多线程(全)

精通 C++ 多线程(全)

原文:annas-archive.org/md5/D8BD7CE4843A1A81E0B93B3CA07CBEC9

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

多线程应用程序在单处理器环境中执行多个线程,以实现。本书充满了实际示例,将帮助您成为在 C++中编写健壮的并发和并行应用程序的专家。在本书中,您将深入了解多线程和并发的基础知识,并了解如何实现它们。在此过程中,您将探索原子操作以优化代码性能,并将并发应用于分布式计算和 GPGPU 处理。

本书涵盖的内容

第一章《重新审视多线程》总结了 C++中的多线程,重新审视了您应该已经熟悉的所有概念,并通过使用 C++ 2011 修订版中添加的本机线程支持进行了多线程的基本示例。

第二章《处理器和操作系统上的多线程实现》在前一章讨论的硬件实现提供的基础上构建,展示了操作系统如何利用这些功能并使其可用于应用程序。它还讨论了进程和线程如何允许使用内存和处理器,以防止应用程序和线程相互干扰。

第三章《C++多线程 API》探讨了各种多线程 API,这些 API 可以作为操作系统级 API(例如 Win32 和 POSIX)提供,也可以作为框架(例如 Boost、Qt 和 POCO)提供。它简要介绍了每个 API,列出了与其他 API 相比的差异,以及它可能对您的应用程序具有的优势和劣势。

第四章《线程同步和通信》将前几章学到的主题,探讨了使用 C++ 14 的本机线程 API 实现的高级多线程实现,允许多个线程在没有任何线程安全问题的情况下进行通信。它还涵盖了许多类型的同步机制之间的区别,包括互斥锁、锁和条件变量。

第五章《本机 C++线程和原语》包括线程、并发、本地存储,以及该 API 支持的线程安全性。在前一章的示例基础上,它讨论并探讨了如何使用 C++ 11 和 C++ 14 提供的完整功能集来扩展和优化线程安全性。

第六章《调试多线程代码》教会您如何使用诸如 Valgrind(Memcheck、DRD、Helgrind 等)之类的工具来分析应用程序的多线程性能,找到热点,并解决或预防由并发访问导致的问题。

第七章《最佳实践》涵盖了常见的陷阱和注意事项,以及如何在它们回来困扰你之前发现它们。它还通过示例探讨了许多常见和不太常见的场景。

第八章《原子操作-与硬件一起工作》详细介绍了原子操作:它们是什么以及如何最好地使用它们。评估了跨 CPU 架构的编译器支持,并评估了在代码中实现原子操作是否值得投入时间。它还探讨了这种优化如何限制代码的可移植性。

第九章,使用分布式计算进行多线程,汲取了前几章的教训,并将它们应用到多系统、集群级别的规模上。使用基于 OpenMPI 的示例,它展示了如何在多个系统上进行多线程处理,比如计算机集群中的节点。

第十章,使用 GPGPU 进行多线程,展示了在 GPGPU 应用程序中使用多线程的情况(例如,CUDA 和 OpenCL)。使用基于 OpenCL 的示例,探讨了一个基本的多线程应用程序,可以并行执行任务。本章汲取了前几章的教训,并将其应用于视频卡和衍生硬件(例如,机架式矢量处理器硬件)上的处理。

您需要什么

要按照本书中的说明,您需要在系统上安装任何操作系统(Windows、Linux 或 macOS)和任何 C++编译器。

本书适用对象

本书适用于希望扩展多线程和并发处理知识的中级 C++开发人员。您应该具有多线程的基本经验,并且能够在命令行上使用 C++开发工具链。

约定

在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是这些样式的一些示例以及它们的含义解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“randGen()方法接受两个参数,定义返回值的范围:”

代码块设置如下:

cout_mtx.lock();
 cout << "Thread " << tid << " adding " << rval << ". New value: " << val << ".\n";
 cout_mtx.unlock();

 values_mtx.lock();
 values.push_back(val);
 values_mtx.unlock();
}

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

cout_mtx.lock();
 cout << "Thread " << tid << " adding " << rval << ". New value: " << val << ".\n";
 cout_mtx.unlock();

 values_mtx.lock();
 values.push_back(val);
 values_mtx.unlock();
}

任何命令行输入或输出都以以下方式编写:

$ make
g++ -o ch01_mt_example -std=c++11 ch01_mt_example.cpp

新术语和重要单词以粗体显示。例如,屏幕上看到的单词,比如菜单或对话框中的单词,会出现在文本中。

警告或重要说明会出现在这样的地方。

提示和技巧会出现在这样的地方。

第一章:重新审视多线程

如果您正在阅读本书,很可能您已经在 C++中进行了一些多线程编程,或者可能是其他语言。本章旨在从 C++的角度纯粹回顾这个主题,通过一个基本的多线程应用程序,同时也涵盖了本书中将要使用的工具。在本章结束时,您将拥有继续阅读后续章节所需的所有知识和信息。

本章涵盖的主题包括以下内容:

  • 使用本地 API 在 C++中进行基本的多线程

  • 编写基本的 makefile 和使用 GCC/MinGW

  • 使用make编译程序并在命令行上执行

入门

在本书的过程中,我们将假设使用基于 GCC 的工具链(在 Windows 上是 GCC 或 MinGW)。如果您希望使用其他工具链(如 clang、MSVC、ICC 等),请查阅这些工具链提供的文档以获取兼容的命令。

为了编译本书提供的示例,将使用 makefile。对于不熟悉 makefile 的人来说,它们是一种简单但功能强大的基于文本的格式,用于与make工具一起自动化构建任务,包括编译源代码和调整构建环境。make于 1977 年首次发布,至今仍然是最受欢迎的构建自动化工具之一。

假设您熟悉命令行(Bash 或等效),并且建议使用 MSYS2(Windows 上的 Bash)。

多线程应用程序

在其最基本的形式中,多线程应用程序由一个具有两个或多个线程的进程组成。这些线程可以以各种方式使用;例如,通过使用一个线程来处理每个传入事件或事件类型,使进程能够以异步方式响应事件,或者通过将工作分配到多个线程中来加快数据处理速度。

对事件的异步响应的示例包括在单独的线程上处理图形用户界面(GUI)和网络事件,以便两种类型的事件都不必等待对方,也不会阻止事件及时得到响应。通常,一个线程执行一个任务,比如处理 GUI 或网络事件,或者处理数据。

对于这个基本示例,应用程序将以一个单一线程开始,然后启动多个线程,并等待它们完成。每个新线程将在完成之前执行自己的任务。

让我们从应用程序的包含和全局变量开始:

#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
#include <random>

using namespace std;

// --- Globals
mutex values_mtx;
mutex cout_mtx;
vector<int> values;

I/O 流和向量头文件对于任何使用过 C++的人来说应该是很熟悉的:前者用于标准输出(cout),而向量用于存储一系列的值。

c++11中的 random 头文件是新的,顾名思义,它提供了用于生成随机序列的类和方法。我们在这里使用它来使我们的线程做一些有趣的事情。

最后,线程和互斥锁的包含是我们多线程应用程序的核心;它们提供了创建线程的基本手段,并允许它们之间进行线程安全的交互。

接下来,我们创建两个互斥锁:一个用于全局向量,一个用于cout,因为后者不是线程安全的。

接下来,我们创建主函数如下:

int main() {
    values.push_back(42);

我们将一个固定值推送到向量实例中;这个值将在我们稍后创建的线程中使用:

    thread tr1(threadFnc, 1);
    thread tr2(threadFnc, 2);
    thread tr3(threadFnc, 3);
    thread tr4(threadFnc, 4);

我们创建新线程,并为它们提供要使用的方法的名称,同时传递任何参数--在这种情况下,只是一个整数:


    tr1.join();
    tr2.join();
    tr3.join();
    tr4.join();

接下来,我们通过在每个线程实例上调用join()来等待每个线程完成:


    cout << "Input: " << values[0] << ", Result 1: " << values[1] << ", Result 2: " << values[2] << ", Result 3: " << values[3] << ", Result 4: " << values[4] << "\n";

    return 1;
}

在这一点上,我们期望每个线程都已经完成了它应该做的事情,并将结果添加到向量中,然后我们读取并向用户显示。

当然,这几乎没有显示应用程序中真正发生的事情,主要只是使用线程的基本简单性。接下来,让我们看看我们传递给每个线程实例的方法内部发生了什么:

void threadFnc(int tid) {
    cout_mtx.lock();
    cout << "Starting thread " << tid << ".\n";
    cout_mtx.unlock();

在前面的代码中,我们可以看到传递给线程方法的整数参数是线程标识符。为了指示线程正在启动,输出包含线程标识符的消息。由于我们在这里使用了非线程安全方法,我们使用cout_mtx互斥实例来安全地执行此操作,确保只有一个线程可以在任何时候写入cout

    values_mtx.lock();
    int val = values[0];
    values_mtx.unlock();

当我们获得向量中的初始值集时,我们将其复制到一个局部变量中,以便我们可以立即释放向量的互斥锁,使其他线程可以使用该向量:

    int rval = randGen(0, 10);
    val += rval;

最后两行包含了线程创建的本质:它们获取初始值,并向其添加一个随机生成的值。randGen()方法接受两个参数,定义返回值的范围:


    cout_mtx.lock();
    cout << "Thread " << tid << " adding " << rval << ". New value: " << val << ".\n";
    cout_mtx.unlock();

    values_mtx.lock();
    values.push_back(val);
    values_mtx.unlock();
}

最后,我们(安全地)记录一条消息,通知用户此操作的结果,然后将新值添加到向量中。在这两种情况下,我们使用相应的互斥锁来确保在使用其他线程访问资源时不会发生重叠。

一旦方法达到这一点,包含它的线程将终止,主线程将少一个要等待重新加入的线程。线程的加入基本上意味着它停止存在,通常会将返回值传递给创建线程的线程。这可以显式发生,主线程等待子线程完成,或者在后台进行。

最后,让我们来看看randGen()方法。在这里,我们可以看到一些多线程特定的添加:

int randGen(const int& min, const int& max) {
    static thread_local mt19937 generator(hash<thread::id>()(this_thread::get_id()));
    uniform_int_distribution<int> distribution(min, max);
    return distribution(generator)
}

前面的方法接受一个最小值和最大值,如前所述,限制了此方法可以返回的随机数的范围。在其核心,它使用基于 mt19937 的generator,它采用了一个具有 19937 位状态大小的 32 位Mersenne Twister算法。这对于大多数应用程序来说是一个常见且合适的选择。

这里需要注意的是thread_local关键字的使用。这意味着即使它被定义为静态变量,其范围也将被限制在使用它的线程中。因此,每个线程都将创建自己的generator实例,在 STL 中使用随机数 API 时这一点很重要。

内部线程标识符的哈希用作generator的种子。这确保每个线程都为其generator实例获得一个相当独特的种子,从而获得更好的随机数序列。

最后,我们使用提供的最小和最大限制创建一个新的uniform_int_distribution实例,并与generator实例一起使用它来生成我们返回的随机数。

Makefile

为了编译前面描述的代码,可以使用 IDE,或者在命令行上输入命令。正如本章开头提到的,我们将在本书的示例中使用 makefile。这样做的重大优势是不必反复输入相同的广泛命令,并且它可以在支持make的任何系统上使用。

进一步的优点包括能够自动删除先前生成的工件,并且只编译那些已更改的源文件,以及对构建步骤的详细控制。

这个示例的 makefile 相当基本:

GCC := g++

OUTPUT := ch01_mt_example
SOURCES := $(wildcard *.cpp)
CCFLAGS := -std=c++11 -pthread

all: $(OUTPUT)

$(OUTPUT):
    $(GCC) -o $(OUTPUT) $(CCFLAGS) $(SOURCES)

clean:
    rm $(OUTPUT)

.PHONY: all

从上到下,我们首先定义我们将使用的编译器(g++),设置输出二进制文件的名称(在 Windows 上的.exe扩展名将自动添加后缀),然后收集源文件和任何重要的编译器标志。

通配符功能允许一次性收集与其后的字符串匹配的所有文件的名称,而无需单独定义文件夹中每个源文件的名称。

对于编译器标志,我们只对启用c++11功能感兴趣,对于这一点,GCC 仍然需要提供这个编译器标志。

对于all方法,我们只需告诉make使用提供的信息运行g++。接下来,我们定义一个简单的清理方法,只需删除生成的二进制文件,最后,我们告诉make不要解释文件夹或文件夹中名为all的任何文件,而是使用带有.PHONY部分的内部方法。

当我们运行这个 makefile 时,我们看到以下命令行输出:

$ make
g++ -o ch01_mt_example -std=c++11 ch01_mt_example.cpp

之后,在同一文件夹中找到一个名为ch01_mt_example(在 Windows 上附加了.exe扩展名)的可执行文件。执行此二进制文件将导致类似以下的命令行输出:

$ ./ch01_mt_example.exe

Starting thread 1.

Thread 1 adding 8\. New value: 50.

Starting thread 2.

Thread 2 adding 2\. New value: 44.

Starting thread 3.

Starting thread 4.

Thread 3 adding 0\. New value: 42.

Thread 4 adding 8\. New value: 50.

Input: 42, Result 1: 50, Result 2: 44, Result 3: 42, Result 4: 50

在这里可以看到线程及其输出的异步性质。虽然线程12似乎是同步运行的,按顺序启动和退出,但线程34显然是异步运行的,因为它们在记录其动作之前同时启动。因此,在长时间运行的线程中,几乎不可能确定日志输出和结果将以何种顺序返回。

虽然我们使用一个简单的向量来收集线程的结果,但无法确定Result 1是否真的来自我们在开始时分配 ID 为 1 的线程。如果我们需要这些信息,我们需要通过使用包含有关处理线程或类似信息的信息结构来扩展我们返回的数据。

例如,可以像这样使用struct

struct result {
    int tid;
    int result;
};

然后,向量将被更改为包含结果实例而不是整数实例。可以直接将初始整数值作为其参数之一传递给线程,或者通过其他方式传递。

其他应用程序

本章的示例主要适用于需要并行处理数据或任务的应用程序。对于前面提到的基于 GUI 的应用程序,具有业务逻辑和网络相关功能,启动所需线程的主应用程序的基本设置将保持不变。但是,每个线程都将是完全不同的方法,而不是每个线程都相同。

对于这种类型的应用程序,线程布局将如下所示:

如图所示,主线程将启动 GUI、网络和业务逻辑线程,后者将与网络线程通信以发送和接收数据。业务逻辑线程还将从 GUI 线程接收用户输入,并发送更新以在 GUI 上显示。

总结

在本章中,我们讨论了使用本机线程 API 在 C++中实现多线程应用程序的基础知识。我们看了如何让多个线程并行执行任务,并探讨了如何在多线程应用程序中正确使用 STL 中的随机数 API。

在下一章中,我们将讨论多线程是如何在硬件和操作系统中实现的。我们将看到这种实现如何根据处理器架构和操作系统而异,以及这如何影响我们的多线程应用程序。

第二章:处理器和操作系统上的多线程实现

任何多线程应用程序的基础是由处理器硬件实现所需功能以及这些功能如何被操作系统转换为应用程序使用的 API 所形成的。了解这个基础对于开发对多线程应用程序的最佳实现方式至关重要。

本章将探讨多年来硬件和操作系统是如何演变到当前的实现和 API 的,展示了前一章的示例代码最终如何转换为对处理器和相关硬件的命令。

本章涵盖的主题包括以下内容:

  • 为了支持多线程概念而发展的处理器硬件的演变

  • 操作系统如何改变以使用这些硬件特性

  • 各种架构中内存安全和内存模型背后的概念

  • 操作系统之间各种进程和线程模型的差异

定义进程和线程

基本上,对于操作系统OS)来说,一个进程由一个或多个线程组成,每个线程处理自己的状态和变量。可以将其视为分层配置,操作系统作为基础,为(用户)进程的运行提供支持。然后,每个进程由一个或多个线程组成。进程之间的通信由操作系统提供的进程间通信IPC)来处理。

在图形视图中,这看起来像下面这样:

操作系统中的每个进程都有自己的状态,进程中的每个线程也有自己的状态,相对于该进程中的其他线程。虽然 IPC 允许进程之间进行通信,但线程可以以各种方式与进程内的其他线程进行通信,我们将在接下来的章节中更深入地探讨这些方式。这通常涉及线程之间的某种共享内存。

应用程序是从特定的可执行格式的二进制数据中加载的,例如,可执行和可链接格式ELF),通常用于 Linux 和许多其他操作系统。对于 ELF 二进制文件,应该始终存在以下数量的部分:

  • .bss

  • .data

  • .rodata

  • .text

.bss部分基本上是分配未初始化的内存,包括空数组,因此在可执行文件中不占用任何空间,因为在可执行文件中存储纯零行是没有意义的。类似地,还有.data部分包含初始化数据。其中包括全局表、变量等。最后,.rodata部分类似于.data,但正如其名称所示,是只读的。其中包含硬编码的字符串等内容。

.text部分,我们找到实际的应用程序指令(代码),这些指令将由处理器执行。整个内容将被操作系统加载,从而创建一个进程。这样的进程布局如下图所示:

这是从 ELF 格式二进制文件启动时进程的样子,尽管在内存中的最终格式在基本上任何操作系统中都大致相同,包括从 PE 格式二进制文件启动的 Windows 进程。二进制文件中的每个部分都加载到它们各自的部分中,BSS 部分分配给指定的大小。.text部分与其他部分一起加载,并且一旦完成,将执行其初始指令,从而启动进程。

在诸如 C++之类的系统语言中,可以看到在这样的进程中变量和其他程序状态信息是如何存储在堆栈(变量存在于作用域内)和堆(使用 new 运算符)中的。堆栈是内存的一部分(每个线程分配一个),其大小取决于操作系统及其配置。在创建新线程时,通常也可以通过编程方式设置堆栈大小。

在操作系统中,一个进程由一块内存地址组成,其大小由其内存指针的大小限制。对于 32 位操作系统,这将限制该块为 4GB。在这个虚拟内存空间中,操作系统分配了一个基本的堆栈和堆,两者都可以增长,直到所有内存地址都被耗尽,进程进一步尝试分配更多内存将被拒绝。

堆栈对操作系统和硬件都是一个概念。本质上,它是一组所谓的堆栈帧的集合,每个堆栈帧由与任务的执行框架相关的变量、指令和其他数据组成。

从硬件角度来看,堆栈是任务(x86)或进程状态(ARM)的一部分,这是处理器定义执行实例(程序或线程)的方式。这个硬件定义的实体包含了一个线程的整个状态。有关此内容的更多详细信息,请参见以下各节。

x86(32 位和 64 位)中的任务

在 Intel IA-32 系统编程指南第 3A 卷中,任务定义如下:

“任务是处理器可以分派、执行和挂起的工作单元。它可以用于执行程序、任务或进程、操作系统服务实用程序、中断或异常处理程序,或内核或执行实用程序。”

“IA-32 架构提供了一种保存任务状态、分派任务执行和从一个任务切换到另一个任务的机制。在保护模式下,所有处理器执行都是在任务内部进行的。即使是简单的系统也必须定义至少一个任务。更复杂的系统可以使用处理器的任务管理设施来支持多任务应用程序。”

IA-32(Intel x86)手册中的这段摘录总结了硬件如何支持和实现对操作系统、进程以及这些进程之间的切换的支持。

重要的是要意识到,对于处理器来说,没有进程或线程这样的东西。它所知道的只是执行线程,定义为一系列指令。这些指令被加载到内存的某个地方,并且当前位置和变量数据(变量)的创建情况都在进程的数据部分中被跟踪,当应用程序在数据部分中执行时。

每个任务也在硬件定义的保护环中运行,操作系统的任务通常在环 0 上运行,用户任务在环 3 上运行。环 1 和 2 很少被使用,除非在 x86 架构的现代操作系统中有特定的用例。这些环是硬件强制执行的特权级别,例如严格分离内核和用户级任务。

32 位和 64 位任务的任务结构在概念上非常相似。它的官方名称是任务状态结构TSS)。对于 32 位 x86 CPU,它的布局如下:

以下是字段:

  • SS0:第一个堆栈段选择器字段

  • ESP0:第一个 SP 字段

对于 64 位 x86_64 CPU,TSS 布局看起来有些不同,因为在这种模式下不支持基于硬件的任务切换:

在这里,我们有类似的相关字段,只是名称不同:

  • RSPn:特权级别 0 到 2 的 SP

  • ISTn:中断堆栈表指针

尽管在 32 位模式下,x86 CPU 支持任务之间的硬件切换,但大多数操作系统将每个 CPU 仅使用单个 TSS 结构,而不管模式如何,并且在软件中实际执行任务之间的切换。这部分是出于效率原因(仅交换变化的指针),部分是由于只有通过这种方式才可能的功能,例如测量进程/线程使用的 CPU 时间,并调整线程或进程的优先级。在软件中执行此操作还简化了代码在 64 位和 32 位系统之间的可移植性,因为前者不支持基于硬件的任务切换。

在基于软件的任务切换期间(通常通过中断),ESP/RSP 等存储在内存中,并用下一个计划任务的值替换。这意味着一旦执行恢复,TSS 结构现在将具有新任务的堆栈指针SP)、段指针、寄存器内容和所有其他细节。

中断的来源可以是基于硬件或软件。硬件中断通常由设备用于向 CPU 发出信号,表示它们需要 OS 的注意。调用硬件中断的行为称为中断请求,或 IRQ。

软件中断可能是由 CPU 本身的异常条件引起的,也可能是 CPU 指令集的一个特性。OS 内核通过触发软件中断来执行任务切换的操作。

ARM 中的进程状态

在 ARM 架构中,应用程序通常在非特权的异常级别 0EL0)级别运行,这与 x86 架构上的 ring 3 相当,而 OS 内核在 EL1 中。ARMv7(AArch32,32 位)架构将 SP 放在通用寄存器 13 中。对于 ARMv8(AArch64,64 位),为每个异常级别实现了一个专用的 SP 寄存器:SP_EL0SP_EL1等。

对于任务状态,ARM 架构使用程序状态寄存器PSR)实例来表示当前程序状态寄存器CPSR)或保存的程序状态寄存器SPSR)程序状态寄存器。PSR 是进程状态PSTATE)的一部分,它是进程状态信息的抽象。

虽然 ARM 架构与 x86 架构有很大不同,但在使用基于软件的任务切换时,基本原则并未改变:保存当前任务的 SP,寄存器状态,并在恢复处理之前将下一个任务的详细信息放入其中。

堆栈

正如我们在前面的部分中看到的,堆栈与 CPU 寄存器一起定义了一个任务。正如前面提到的,这个堆栈由堆栈帧组成,每个堆栈帧定义了该特定任务执行实例的(局部)变量、参数、数据和指令。值得注意的是,尽管堆栈和堆栈帧主要是软件概念,但它是任何现代操作系统的重要特性,在许多 CPU 指令集中有硬件支持。从图形上看,可以像下面这样进行可视化:

SP(x86 上的 ESP)指向堆栈顶部,另有另一个指针(x86 上的扩展基指针(EBP))。每个帧包含对前一个帧的引用(调用者返回地址),由操作系统设置。

在使用调试器与 C++应用程序时,当请求回溯时,基本上就是看到了堆栈的各个帧,显示了一直到当前帧的初始堆栈帧。在这里,可以检查每个单独帧的细节。

定义多线程

在过去的几十年中,与计算机处理任务方式相关的许多不同术语已经被创造并广泛使用。其中许多也被交替使用,正确与否。其中一个例子是多线程与多处理的比较。

在这里,后者意味着在具有多个物理处理器的系统中每个处理器运行一个任务,而前者意味着在单个处理器上同时运行多个任务,从而产生它们都在同时执行的错觉:

多处理和多任务之间的另一个有趣区别是,后者使用时间片来在单个处理器核上运行多个线程。这与多线程不同,因为在多任务系统中,没有任务会在同一 CPU 核上以并发方式运行,尽管任务仍然可以被中断。

从软件角度来看,进程和进程内的线程之间共享的内存空间的概念是多线程系统的核心。尽管硬件通常不知道这一点--只看到操作系统中的单个任务。然而,这样的多线程进程包含两个或多个线程。每个线程都执行自己的一系列任务。

在其他实现中,例如英特尔的 x86 处理器上的超线程HT),这种多线程是在硬件中实现的,通常被称为 SMT(有关详细信息,请参见同时多线程(SMT)部分)。启用 HT 后,每个物理 CPU 核被呈现给操作系统为两个核。硬件本身将尝试同时执行分配给这些所谓的虚拟核心的任务,并安排可以同时使用处理核心的不同元素的操作。实际上,这可以在不需要任何类型的优化的操作系统或应用程序的情况下显着提高性能。

当然,操作系统仍然可以进行自己的调度,以进一步优化任务的执行,因为硬件对其正在执行的指令的许多细节并不知情。

启用 HT 的外观如下所示:

在上述图形中,我们看到内存(RAM)中四个不同任务的指令。其中两个任务(线程)同时执行,CPU 的调度器(在前端)试图安排指令,以便尽可能多地并行执行指令。在这种情况下不可能时,会出现所谓的流水线气泡(白色),表示执行硬件处于空闲状态。

加上内部 CPU 优化,这导致了非常高的指令吞吐量,也称为每秒指令数IPC)。与 CPU 的 GHz 评级不同,这个 IPC 数字通常更重要,用于确定 CPU 的性能。

弗林分类

不同类型的计算机架构使用迈克尔·J·弗林在 1966 年首次提出的系统进行分类。这个分类系统有四个类别,根据处理硬件的输入和输出流的数量来定义其能力:

  • 单指令,单数据SISD):单个指令被提取以操作单个数据流。这是 CPU 的传统模型。

  • 单指令,多数据SIMD):使用这种模型,单个指令可以并行操作多个数据流。这是图形处理单元(GPU)等矢量处理器使用的模型。

  • 多指令,单数据MISD):这个模型最常用于冗余系统,通过不同的处理单元对相同的数据执行相同的操作,最终验证结果以检测硬件故障。这通常由航空电子系统等使用。

  • 多指令,多数据MIMD):对于这个模型,多处理系统非常适用。多个处理器上的多个线程处理多个数据流。这些线程不是相同的,就像 SIMD 一样。

需要注意的一点是,这些类别都是根据多处理来定义的,这意味着它们指的是硬件的固有能力。使用软件技术,几乎可以在甚至是常规的 SISD 架构上近似任何方法。然而,这是多线程的一部分。

对称与非对称多处理

在过去的几十年中,许多系统都包含了多个处理单元。这些可以大致分为对称多处理(SMP)和非对称多处理(AMP)系统。

AMP 的主要特点是将第二处理器作为外围连接到主 CPU。这意味着它不能运行控制软件,而只能运行用户应用程序。这种方法也被用于连接使用不同架构的 CPU,以允许例如在 Amiga,68k 系统上运行 x86 应用程序。

在 SMP 系统中,每个 CPU 都是对等的,可以访问相同的硬件资源,并以合作的方式设置。最初,SMP 系统涉及多个物理 CPU,但后来,多个处理器核心集成在单个 CPU 芯片上:

随着多核 CPU 的普及,SMP 是嵌入式开发之外最常见的处理类型,其中单处理(单核,单处理器)仍然非常普遍。

从技术上讲,系统中的声音、网络和图形处理器可以被视为与 CPU 相关的非对称处理器。随着通用 GPU(GPGPU)处理的增加,AMP 变得更加相关。

松散和紧密耦合的多处理

多处理系统不一定要在单个系统内实现,也可以由多个连接在网络中的系统组成。这样的集群被称为松散耦合的多处理系统。我们在第九章中涵盖了分布式计算,分布式计算中的多线程

这与紧密耦合的多处理系统形成对比,紧密耦合的多处理系统是通过单个印刷电路板(PCB)上使用相同的低级高速总线或类似的方式集成在一起。

将多处理与多线程结合

几乎任何现代系统都结合了多处理和多线程,这要归功于多核 CPU,它将两个或更多处理核心集成在单个处理器芯片上。对操作系统来说,这意味着它必须在多个处理核心之间调度任务,同时也必须在特定核心上调度它们,以提取最大性能。

这是任务调度器的领域,我们稍后会看一下。可以说这是一个值得一本书的话题。

多线程类型

与多处理类似,多线程也不是单一实现,而是两种主要实现。这两者之间的主要区别在于处理器在单个周期内可以同时执行的线程数量。多线程实现的主要目标是尽可能接近 100%的处理器硬件利用率。多线程利用线程级和进程级并行性来实现这一目标。

接下来我们将介绍两种多线程类型。

时间多线程

也被称为超线程,时间多线程(TMT)的主要子类型是粗粒度和细粒度(或交错)。前者在不同任务之间快速切换,保存每个任务的上下文,然后切换到另一个任务的上下文。后者在每个周期中切换任务,导致 CPU 流水线包含来自各种任务的指令,从中得到交错这个术语。

细粒度类型在桶处理器中实现。它们比 x86 和其他架构具有优势,因为它们可以保证特定的时间(对于硬实时嵌入式系统很有用),并且由于可以做出的假设较少,实现起来更不复杂。

同时多线程(SMT)

SMT 实现在超标量 CPU 上(实现指令级并行性),其中包括 x86 和 ARM 架构。SMT 的定义特征也由其名称指示,特别是其能够在每个核心中并行执行多个线程。

通常,每个核心有两个线程是常见的,但某些设计支持每个核心最多八个并发线程。这样做的主要优势是能够在线程之间共享资源,明显的缺点是多个线程的冲突需得到管理。另一个优势是由于缺乏硬件资源重复,使得结果 CPU 更节能。

英特尔的超线程技术本质上是英特尔的 SMT 实现,从 2002 年的一些奔腾 4 CPU 开始提供基本的双线程 SMT 引擎。

调度程序

存在许多任务调度算法,每个算法都专注于不同的目标。有些可能寻求最大化吞吐量,其他人则最小化延迟,而其他人可能寻求最大化响应时间。哪种调度程序是最佳选择完全取决于系统所用于的应用程序。

对于桌面系统,调度程序通常尽可能保持通用,通常优先考虑前台应用程序,以便为用户提供最佳的桌面体验。

对于嵌入式系统,特别是在实时、工业应用中,通常会寻求保证定时。这允许进程在恰当的时间执行,这在例如驱动机械、机器人或化工过程中至关重要,即使延迟几毫秒也可能成本高昂甚至致命。

调度程序类型还取决于操作系统的多任务状态--合作式多任务系统无法提供关于何时可以切换运行中进程的许多保证,因为这取决于活动进程何时让出。

使用抢占式调度程序,进程在不知情的情况下进行切换,允许调度程序更多地控制进程在哪些时间点运行。

基于 Windows NT 的操作系统(Windows NT,2000,XP 等)使用所谓的多级反馈队列,具有 32 个优先级级别。这种类型的优先级调度程序允许对任务进行优先级排序,从而可以微调产生的体验。

Linux 最初(内核 2.4)也使用了基于多级反馈队列的优先级调度程序,类似于具有 O(n)调度程序的 Windows NT。在 2.6 版本中,这被替换为 O(1)调度程序,允许进程在恒定的时间内被调度。从 Linux 内核 2.6.23 开始,默认调度程序是完全公平调度程序CFS),它确保所有任务获得可比较的 CPU 时间份额。

以下是一些常用或知名操作系统使用的调度算法类型:

操作系统 抢占 算法
Amiga OS 优先级轮转调度
FreeBSD 多级反馈队列
Linux 内核 2.6.0 之前 多级反馈队列
Linux 内核 2.6.0-2.6.23 O(1)调度程序
Linux 内核 2.6.23 之后 完全公平调度程序
经典 Mac OS 9 之前 合作式调度程序
Mac OS 9 一些 用于 MP 任务的抢占式调度程序,以及用于进程和线程的合作式调度程序
OS X/macOS 多级反馈队列
NetBSD 多级反馈队列
Solaris 多级反馈队列
Windows 3.1x 合作式调度程序
Windows 95, 98, Me Half 32 位进程使用抢占式调度程序,16 位进程使用合作式调度程序
Windows NT(包括 2000、XP、Vista、7 和 Server) 多级反馈队列

(来源:en.wikipedia.org/wiki/Scheduling_(computing)

抢占列指示调度程序是否具有抢占性,下一列提供了更多细节。可以看到,抢占式调度程序非常常见,所有现代桌面操作系统都使用它。

跟踪演示应用程序

在第一章的演示代码中,重新审视多线程,我们看了一个简单的c++11应用程序,它使用四个线程来执行一些处理。在本节中,我们将从硬件和操作系统的角度来看同一个应用程序。

当我们看main函数中的代码开头时,我们看到创建了一个包含单个(整数)值的数据结构:

int main() {
    values.push_back(42);

操作系统创建新任务和相关的堆栈结构后,在堆栈上分配了一个向量数据结构的实例(针对整数类型进行了定制)。这个大小在二进制文件的全局数据部分(ELF 的 BSS)中指定。

当应用程序使用其入口函数(默认为main())开始执行时,数据结构被修改为包含新的整数值。

接下来,我们创建四个线程,为每个线程提供一些初始数据:

    thread tr1(threadFnc, 1);
    thread tr2(threadFnc, 2);
    thread tr3(threadFnc, 3);
    thread tr4(threadFnc, 4);

对于操作系统来说,这意味着创建新的数据结构,并为每个新线程分配一个堆栈。对于硬件来说,如果不使用基于硬件的任务切换,最初不会改变任何东西。

此时,操作系统的调度程序和 CPU 可以结合起来尽可能高效和快速地执行这组任务(线程),利用硬件的特性,包括 SMP、SMT 等。

在此之后,主线程等待其他线程停止执行:

    tr1.join();
    tr2.join();
    tr3.join();
    tr4.join();

这些是阻塞调用,标记主线程被阻塞,直到这四个线程(任务)执行完成。此时,操作系统的调度程序将恢复主线程的执行。

在每个新创建的线程中,我们首先在标准输出上输出一个字符串,确保锁定互斥锁以确保同步访问:

void threadFnc(int tid) {
    cout_mtx.lock();
    cout << "Starting thread " << tid << ".\n";
    cout_mtx.unlock();

互斥锁本质上是一个存储在堆栈或堆上的单个值,然后使用原子操作访问。这意味着需要某种形式的硬件支持。使用这个,任务可以检查它是否被允许继续,还是必须等待并再次尝试。

在这段特定的代码中,这个互斥锁允许我们在标准 C++输出流上输出,而不会受到其他线程的干扰。

在这之后,我们将向一个本地变量复制向量中的初始值,再次确保它是同步完成的:

    values_mtx.lock();
    int val = values[0];
    values_mtx.unlock();

这里发生的事情与之前相同,只是现在互斥锁允许我们读取向量中的第一个值,而不会在我们使用它时有其他线程访问甚至更改它的风险。

接着生成一个随机数如下:

    int rval = randGen(0, 10);
    val += rval;

这使用了以下randGen()方法:

int randGen(const int& min, const int& max) {
    static thread_local mt19937 generator(hash<thread::id>() (this_thread::get_id()));
    uniform_int_distribution<int> distribution(min, max);
    return distribution(generator);
}

这种方法之所以有趣,是因为它使用了线程局部变量。线程局部存储是线程特有的内存部分,用于全局变量,但必须保持限制在特定线程中。

对于像这里使用的静态变量来说,这是非常有用的。generator实例是静态的,因为我们不希望每次使用这种方法时都重新初始化它,但我们也不希望在所有线程之间共享这个实例。通过使用线程局部的静态实例,我们可以实现这两个目标。为每个线程创建并使用一个静态实例。

Thread函数最后以相同的一系列互斥锁结束,并将新值复制到数组中。

    cout_mtx.lock();
    cout << "Thread " << tid << " adding " << rval << ". New value: " << val << ".\n";
    cout_mtx.unlock();

    values_mtx.lock();
    values.push_back(val);
    values_mtx.unlock();
}

在这里,我们看到对标准输出流的同步访问,然后是对值数据结构的同步访问。

互斥实现

互斥是多线程应用程序中数据的线程安全访问的原则。可以在硬件和软件中实现这一点。互斥mutex)是大多数实现中这种功能的最基本形式。

硬件

在单处理器(单处理器核心),非 SMT 系统上最简单的基于硬件的实现是禁用中断,从而防止任务被更改。更常见的是采用所谓的忙等待原则。这是互斥的基本原则--由于处理器获取数据的方式,只有一个任务可以获取和读/写共享内存中的原子值,即与 CPU 寄存器相同(或更小)大小的变量。这在第八章“原子操作-与硬件一起工作”中有进一步详细说明。

当我们的代码尝试锁定互斥锁时,这实际上是读取这样一个原子内存区域的值,并尝试将其设置为其锁定值。由于这是一个单操作,只有一个任务可以在任何给定时间更改该值。其他任务将不得不等待,直到它们可以在这个忙等待周期中获得访问,如图所示:

软件

基于忙等待的软件定义的互斥实现。一个例子是Dekker算法,它定义了一个系统,其中两个进程可以同步,利用忙等待等待另一个进程离开临界区。

该算法的伪代码如下:

    variables
        wants_to_enter : array of 2 booleans
        turn : integer

    wants_to_enter[0] ← false
    wants_to_enter[1] ← false
    turn ← 0 // or 1

p0:
    wants_to_enter[0] ← true
    while wants_to_enter[1] {
        if turn ≠ 0 {
            wants_to_enter[0] ← false
            while turn ≠ 0 {
                // busy wait
            }
            wants_to_enter[0] ← true
        }
    }
    // critical section
    ...
    turn ← 1
    wants_to_enter[0] ← false
    // remainder section

p1:
    wants_to_enter[1] ← true
    while wants_to_enter[0] {
        if turn ≠ 1 {
            wants_to_enter[1] ← false
            while turn ≠ 1 {
                // busy wait
            }
            wants_to_enter[1] ← true
        }
    }
    // critical section
    ...
    turn ← 0
    wants_to_enter[1] ← false
    // remainder section

(引用自:en.wikipedia.org/wiki/Dekker's_algorithm

在上述算法中,进程表明他们打算进入临界区,检查是否轮到他们(使用进程 ID),然后在进入后将其意图设置为 false。只有当进程再次将其意图设置为 true 时,它才会再次进入临界区。如果它希望进入,但turn与其进程 ID 不匹配,它将忙等待直到条件变为真。

软件基础的互斥算法的一个主要缺点是,它们只在禁用代码的乱序OoO)执行时才能工作。 OoO 意味着硬件积极重新排序传入的指令,以优化它们的执行,从而改变它们的顺序。由于这些算法要求各个步骤按顺序执行,它们在 OoO 处理器上不再起作用。

总结

在本章中,我们看到了进程和线程在操作系统和硬件中的实现方式。我们还研究了处理器硬件的各种配置以及涉及调度的操作系统元素,以了解它们如何提供各种类型的任务处理。

最后,我们再次运行了上一章的多线程程序示例,并考虑了在执行过程中操作系统和处理器发生了什么。

在下一章中,我们将看看通过操作系统和基于库的实现提供的各种多线程 API,以及比较这些 API 的示例。

第三章:C++多线程 API

虽然 C++在标准模板库STL)中有本地的多线程实现,但基于操作系统和框架的多线程 API 仍然非常常见。这些 API 的例子包括 Windows 和POSIX可移植操作系统接口)线程,以及QtBoostPOCO库提供的线程。

本章详细介绍了每个 API 提供的功能,以及它们之间的相似之处和不同之处。最后,我们将使用示例代码来查看常见的使用场景。

本章涵盖的主题包括以下内容:

  • 可用多线程 API 的比较

  • 每个 API 的用法示例

API 概述

C++ 2011C++11)标准之前,开发了许多不同的线程实现,其中许多限于特定的软件平台。其中一些至今仍然相关,例如 Windows 线程。其他已被标准取代,其中POSIX ThreadsPthreads)已成为类 UNIX 操作系统的事实标准。这包括基于 Linux 和基于 BSD 的操作系统,以及 OS X(macOS)和 Solaris。

许多库被开发出来,以使跨平台开发更容易。尽管 Pthreads 有助于使类 UNIX 操作系统更或多或少地兼容,但要使软件在所有主要操作系统上可移植,需要一个通用的线程 API。这就是为什么会创建诸如 Boost、POCO 和 Qt 等库。应用程序可以使用这些库,并依赖于库来处理平台之间的任何差异。

POSIX 线程

Pthreads 最初是在 1995 年的POSIX.1c标准(Threads extensions,IEEE Std 1003.1c-1995)中定义的,作为 POSIX 标准的扩展。当时,UNIX 被选择为制造商中立的接口,POSIX 统一了它们之间的各种 API。

尽管有这种标准化的努力,Pthread 在实现它的操作系统之间仍存在差异(例如,在 Linux 和 OS X 之间),这是由于不可移植的扩展(在方法名中标有_np)。

对于pthread_setname_np方法,Linux 实现需要两个参数,允许设置除当前线程以外的线程的名称。在 OS X(自 10.6 起),此方法只需要一个参数,允许设置当前线程的名称。如果可移植性是一个问题,就必须注意这样的差异。

1997 年后,POSIX 标准的修订由奥斯汀联合工作组负责。这些修订将线程扩展合并到主标准中。当前的修订是第 7 版,也被称为 POSIX.1-2008 和 IEEE Std 1003.1,2013 版--标准的免费副本可在线获得。

操作系统可以获得符合 POSIX 标准的认证。目前,这些如表中所述:

名称 开发者 自版本 架构(当前) 备注
AIX IBM 5L POWER 服务器操作系统
HP-UX 惠普 11i v3 PA-RISC, IA-64 (Itanium) 服务器操作系统
IRIX Silicon Graphics(SGI) 6 MIPS 已停产
Inspur K-UX 浪潮 2 X86_64 基于 Linux
Integrity Green Hills Software 5 ARM, XScale, Blackfin, Freescale Coldfire, MIPS, PowerPC, x86. 实时操作系统
OS X/MacOS 苹果 10.5(Leopard) X86_64 桌面操作系统
QNX Neutrino BlackBerry 1 Intel 8088, x86, MIPS, PowerPC, SH-4, ARM, StrongARM, XScale 实时嵌入式操作系统
Solaris Sun/Oracle 2.5 SPARC, IA-32(<11),x86_64,PowerPC(2.5.1) 服务器操作系统
Tru64 DEC, HP, IBM, Compaq 5.1B-4 Alpha 已停产
UnixWare Novell, SCO, Xinuos 7.1.3 x86 服务器操作系统

其他操作系统大多是兼容的。以下是相同的例子:

名称 平台 备注
Android ARM, x86, MIPS 基于 Linux。Bionic C 库。
BeOS (Haiku) IA-32, ARM, x64_64 仅限于 x86 的 GCC 2.x。
Darwin PowerPC、x86、ARM 使用 macOS 基础的开源组件。
FreeBSD IA-32、x86_64、sparc64、PowerPC、ARM、MIPS 等等 基本上符合 POSIX 标准。可以依赖已记录的 POSIX 行为。一般而言,比 Linux 更严格地遵守标准。
Linux Alpha、ARC、ARM、AVR32、Blackfin、H8/300、Itanium、m68k、Microblaze、MIPS、Nios II、OpenRISC、PA-RISC、PowerPC、s390、S+core、SuperH、SPARC、x86、Xtensa 等等 一些 Linux 发行版(见前面的表)被认证为符合 POSIX 标准。这并不意味着每个 Linux 发行版都符合 POSIX 标准。一些工具和库可能与标准不同。对于 Pthreads,这可能意味着在 Linux 发行版之间的行为有时会有所不同(不同的调度程序等),并且与其他实现 Pthreads 的操作系统相比也会有所不同。
MINIX 3 IA-32、ARM 符合 POSIX 规范标准 3(SUSv3, 2004)。
NetBSD Alpha、ARM、PA-RISC、68k、MIPS、PowerPC、SH3、SPARC、RISC-V、VAX、x86 等等 几乎完全兼容 POSIX.1(1990),并且大部分符合 POSIX.2(1992)。
Nuclear RTOS ARM、MIPS、PowerPC、Nios II、MicroBlaze、SuperH 等等 Mentor Graphics 公司推出的专有 RTOS,面向嵌入式应用。
NuttX ARM、AVR、AVR32、HCS12、SuperH、Z80 等等 轻量级的 RTOS,可在 8 到 32 位系统上扩展,且高度符合 POSIX 标准。
OpenBSD Alpha、x86_64、ARM、PA-RISC、IA-32、MIPS、PowerPC、SPARC 等等 1995 年从 NetBSD 分叉出来。具有类似的 POSIX 支持。
OpenSolaris/illumos IA-32、x86_64、SPARC、ARM 与商业 Solaris 发行版兼容认证。
VxWorks ARM、SH-4、x86、x86_64、MIPS、PowerPC 符合 POSIX 标准,并获得用户模式执行环境认证。

由此可见,遵循 POSIX 规范并不是一件明显的事情,也不能保证代码在每个平台上都能编译。每个平台还会有自己的一套标准扩展,用于标准中省略的但仍然有用的功能。然而,Pthreads 在 Linux、BSD 和类似的软件中被广泛使用。

Windows 支持

也可以使用 POSIX API,例如以下方式:

名称 符合度
Cygwin 大部分完整。提供了一个完整的运行时环境,用于将 POSIX 应用程序作为普通的 Windows 应用程序进行分发。
MinGW 使用 MinGW-w64(MinGW 的重新开发版本),对 Pthreads 的支持相当完整,尽管可能会缺少一些功能。
Windows Subsystem for Linux WSL 是 Windows 10 的一个功能,允许 Ubuntu Linux 14.04(64 位)镜像的工具和实用程序在其上本地运行,尽管不能运行使用 GUI 功能或缺少内核功能的程序。否则,它提供了与 Linux 类似的兼容性。这个功能目前需要运行 Windows 10 周年更新,并按照微软提供的说明手动安装 WSL。

一般不建议在 Windows 上使用 POSIX。除非有充分的理由使用 POSIX(例如,大量现有代码库),否则最好使用跨平台 API(本章后面将介绍),以解决任何平台问题。

在接下来的章节中,我们将看一下 Pthreads API 提供的功能。

PThreads 线程管理

这些函数都以 pthread_pthread_attr_ 开头。这些函数都适用于线程本身及其属性对象。

使用 Pthreads 的基本线程看起来像下面这样:

#include <pthread.h> 
#include <stdlib.h> 

#define NUM_THREADS     5 

主要的 Pthreads 头文件是 pthread.h。这样可以访问除了信号量(稍后在本节中讨论)之外的所有内容。我们还在这里定义了希望启动的线程数量的常量:

void* worker(void* arg) { 
    int value = *((int*) arg); 

    // More business logic. 

    return 0; 
} 

我们定义了一个简单的Worker函数,稍后将把它传递给新线程。为了演示和调试目的,可以首先添加一个简单的基于coutprintf的业务逻辑,以打印发送到新线程的值。

接下来,我们定义main函数如下:

int main(int argc, char** argv) { 
    pthread_t threads[NUM_THREADS]; 
    int thread_args[NUM_THREADS]; 
    int result_code; 

    for (unsigned int i = 0; i < NUM_THREADS; ++i) { 
        thread_args[i] = i; 
        result_code = pthread_create(&threads[i], 0, worker, (void*) &thread_args[i]); 
    } 

我们在前面的函数中使用循环创建所有线程。每个线程实例在创建时都会被分配一个线程 ID(第一个参数),并且pthread_create()函数会返回一个结果代码(成功时为零)。线程 ID 是在将来调用中引用线程的句柄。

函数的第二个参数是一个pthread_attr_t结构实例,如果没有则为 0。这允许配置新线程的特性,例如初始堆栈大小。当传递零时,将使用默认参数,这些参数因平台和配置而异。

第三个参数是指向新线程将启动的函数的指针。这个函数指针被定义为一个返回指向 void 数据(即自定义数据)的指针的函数,并接受一个指向 void 数据的指针。在这里,作为参数传递给新线程的数据是线程 ID:

    for (int i = 0; i < NUM_THREADS; ++i) { 
        result_code = pthread_join(threads[i], 0); 
    } 

    exit(0); 
} 

接下来,我们使用pthread_join()函数等待每个工作线程完成。此函数接受两个参数,要等待的线程 ID 和Worker函数的返回值的缓冲区(或零)。

管理线程的其他函数如下:

  • void pthread_exit(void *value_ptr):

这个函数终止调用它的线程,使得提供的参数值可以被任何调用pthread_join()的线程使用。

  • int pthread_cancel(pthread_t thread):

这个函数请求取消指定的线程。根据目标线程的状态,这将调用其取消处理程序。

除此之外,还有pthread_attr_*函数来操作和获取有关pthread_attr_t结构的信息。

互斥锁

这些是以pthread_mutex_pthread_mutexattr_为前缀的函数。它们适用于互斥锁及其属性对象。

Pthreads 中的互斥锁可以被初始化、销毁、锁定和解锁。它们还可以使用pthread_mutexattr_t结构自定义其行为,该结构具有相应的pthread_mutexattr_*函数用于初始化和销毁属性。

使用静态初始化的 Pthread 互斥锁的基本用法如下:

static pthread_mutex_t func_mutex = PTHREAD_MUTEX_INITIALIZER; 

void func() { 
    pthread_mutex_lock(&func_mutex); 

    // Do something that's not thread-safe. 

    pthread_mutex_unlock(&func_mutex); 
} 

在最后一段代码中,我们使用了PTHREAD_MUTEX_INITIALIZER宏,它可以为我们初始化互斥锁,而无需每次都输入代码。与其他 API 相比,人们必须手动初始化和销毁互斥锁,尽管宏的使用在一定程度上有所帮助。

之后,我们锁定和解锁互斥锁。还有pthread_mutex_trylock()函数,它类似于常规锁定版本,但如果引用的互斥锁已经被锁定,它将立即返回而不是等待它被解锁。

在这个例子中,互斥锁没有被显式销毁。然而,这是 Pthreads 应用程序中正常内存管理的一部分。

条件变量

这些函数的前缀要么是pthread_cond_,要么是pthread_condattr_。它们适用于条件变量及其属性对象。

Pthreads 中的条件变量遵循相同的模式,除了具有初始化和destroy函数外,还有用于管理pthread_condattr_t属性结构的相同函数。

这个例子涵盖了 Pthreads 条件变量的基本用法:

#include <pthread.h> 
#include <stdlib.h>
#include <unistd.h>

   #define COUNT_TRIGGER 10 
   #define COUNT_LIMIT 12 

   int count = 0; 
   int thread_ids[3] = {0,1,2}; 
   pthread_mutex_t count_mutex; 
   pthread_cond_t count_cv; 

在前面的代码中,我们获取了标准头文件,并定义了一个计数触发器和限制,其目的将在一会儿变得清晰。我们还定义了一些全局变量:一个计数变量,我们希望创建的线程的 ID,以及一个互斥锁和条件变量:

void* add_count(void* t)  { 
    int tid = (long) t; 
    for (int i = 0; i < COUNT_TRIGGER; ++i) { 
        pthread_mutex_lock(&count_mutex); 
        count++; 
        if (count == COUNT_LIMIT) { 
            pthread_cond_signal(&count_cv); 
        } 

        pthread_mutex_unlock(&count_mutex); 
        sleep(1); 
    } 

    pthread_exit(0); 
} 

前面的函数本质上只是在使用count_mutex获得独占访问权后向全局计数变量添加。它还检查计数触发值是否已达到。如果是,它将发出条件变量的信号。

为了给第二个线程,也运行此函数,一个机会获得互斥锁,我们在循环的每个周期中睡眠 1 秒:

void* watch_count(void* t) { 
    int tid = (int) t; 

    pthread_mutex_lock(&count_mutex); 
    if (count < COUNT_LIMIT) { 
        pthread_cond_wait(&count_cv, &count_mutex); 
    } 

    pthread_mutex_unlock(&count_mutex); 
    pthread_exit(0); 
} 

在这第二个函数中,在检查是否已达到计数限制之前,我们会锁定全局互斥锁。这是我们的保险,以防此函数运行的线程在计数达到限制之前不被调用。

否则,我们在提供条件变量和锁定互斥锁的情况下等待条件变量。一旦收到信号,我们就解锁全局互斥锁,并退出线程。

这里需要注意的一点是,这个示例没有考虑虚假唤醒。Pthreads 条件变量容易受到这种唤醒的影响,这需要使用循环并检查是否已满足某种条件:

int main (int argc, char* argv[]) { 
    int tid1 = 1, tid2 = 2, tid3 = 3; 
    pthread_t threads[3]; 
    pthread_attr_t attr; 

    pthread_mutex_init(&count_mutex, 0); 
    pthread_cond_init (&count_cv, 0); 

    pthread_attr_init(&attr); 
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE); 
    pthread_create(&threads[0], &attr, watch_count, (void *) tid1); 
    pthread_create(&threads[1], &attr, add_count, (void *) tid2); 
    pthread_create(&threads[2], &attr, add_count, (void *) tid3); 

    for (int i = 0; i < 3; ++i) { 
        pthread_join(threads[i], 0); 
    } 

    pthread_attr_destroy(&attr); 
    pthread_mutex_destroy(&count_mutex); 
    pthread_cond_destroy(&count_cv); 
    return 0; 
}  

最后,在main函数中,我们创建三个线程,其中两个运行将计数器增加的函数,第三个运行等待其条件变量被发出信号的函数。

在这种方法中,我们还初始化全局互斥锁和条件变量。我们创建的线程还明确设置了“可连接”属性。

最后,我们等待每个线程完成,然后在退出之前清理,销毁属性结构实例、互斥锁和条件变量。

使用pthread_cond_broadcast()函数,进一步可以向等待条件变量的所有线程发出信号,而不仅仅是队列中的第一个线程。这使得可以更优雅地在某些应用程序中使用条件变量,例如,有很多工作线程在等待新数据集到达,而无需单独通知每个线程。

同步

实现同步的函数以pthread_rwlock_pthread_barrier_为前缀。这些实现读/写锁和同步屏障。

读/写锁rwlock)与互斥锁非常相似,只是它具有额外的功能,允许无限数量的线程同时读取,而只限制写访问一个线程。

使用rwlock与使用互斥锁非常相似:

#include <pthread.h> 
int pthread_rwlock_init(pthread_rwlock_t* rwlock, const pthread_rwlockattr_t* attr); 
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER; 

在最后的代码中,我们包括相同的通用头文件,并使用初始化函数或通用宏。有趣的部分是当我们锁定rwlock时,可以仅用于只读访问:

int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock); 
int pthread_rwlock_tryrdlock(pthread_rwlock_t* rwlock); 

在这里,如果锁已经被锁定,第二种变体会立即返回。也可以按以下方式锁定它以进行写访问:

int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock); 
int pthread_rwlock_trywrlock(pthread_rwlock_t * rwlock); 

这些函数基本上是相同的,只是在任何给定时间只允许一个写入者,而多个读取者可以获得只读锁。

屏障是 Pthreads 的另一个概念。这些是同步对象,对于一些线程起到屏障的作用。在任何一个线程可以继续执行之前,所有这些线程都必须到达屏障。在屏障初始化函数中,指定了线程计数。只有当所有这些线程都使用pthread_barrier_wait()函数调用barrier对象后,它们才会继续执行。

信号量

如前所述,信号量不是原始 Pthreads 扩展到 POSIX 规范的一部分。出于这个原因,它们在semaphore.h头文件中声明。

实质上,信号量是简单的整数,通常用作资源计数。为了使它们线程安全,使用原子操作(检查和锁定)。POSIX 信号量支持初始化、销毁、增加和减少信号量,以及等待信号量达到非零值。

线程本地存储(TLC)

使用 Pthreads,TLS 是通过使用键和方法来设置特定于线程的数据来实现的:

pthread_key_t global_var_key;

void* worker(void* arg) {
    int *p = new int;
    *p = 1;
    pthread_setspecific(global_var_key, p);
    int* global_spec_var = (int*) pthread_getspecific(global_var_key);
    *global_spec_var += 1;
    pthread_setspecific(global_var_key, 0);
    delete p;
    pthread_exit(0);
}

在工作线程中,我们在堆上分配一个新的整数,并将全局密钥设置为其自己的值。在将全局变量增加 1 之后,其值将为 2,而不管其他线程做什么。我们可以在完成此线程的操作后将全局变量设置为 0,并删除分配的值:

int main(void) {
    pthread_t threads[5];

    pthread_key_create(&global_var_key, 0);
    for (int i = 0; i < 5; ++i)
        pthread_create(&threads[i],0,worker,0);
    for (int i = 0; i < 5; ++i) {
        pthread_join(threads[i], 0);
    }
    return 0;
}

设置并使用全局密钥来引用 TLS 变量,然而我们创建的每个线程都可以为此密钥设置自己的值。

虽然线程可以创建自己的密钥,但与本章中正在查看的其他 API 相比,处理 TLS 的这种方法相当复杂。

Windows 线程

相对于 Pthreads,Windows 线程仅限于 Windows 操作系统和类似系统(例如 ReactOS 和其他使用 Wine 的操作系统)。这提供了一个相当一致的实现,可以轻松地由支持对应的 Windows 版本来定义。

在 Windows Vista 之前,线程支持缺少诸如条件变量之类的功能,同时具有 Pthreads 中找不到的功能。根据一个人的观点,使用 Windows 头文件中定义的无数“类型定义”类型可能也会让人感到烦恼。

线程管理

从官方 MSDN 文档示例代码改编的使用 Windows 线程的基本示例如下:

#include <windows.h> 
#include <tchar.h> 
#include <strsafe.h> 

#define MAX_THREADS 3 
#define BUF_SIZE 255  

在包含一系列 Windows 特定的头文件用于线程函数、字符字符串等之后,我们在Worker函数中定义了要创建的线程数以及消息缓冲区的大小。

我们还定义了一个结构类型(通过void 指针:LPVOID传递),用于包含我们传递给每个工作线程的示例数据:

typedef struct MyData { 
 int val1; 
 int val2; 
} MYDATA, *PMYDATA;

DWORD WINAPI worker(LPVOID lpParam) { 
    HANDLE hStdout = GetStdHandle(STD_OUTPUT_HANDLE); 
    if (hStdout == INVALID_HANDLE_VALUE) { 
        return 1; 
    } 

    PMYDATA pDataArray =  (PMYDATA) lpParam; 

    TCHAR msgBuf[BUF_SIZE]; 
    size_t cchStringSize; 
    DWORD dwChars; 
    StringCchPrintf(msgBuf, BUF_SIZE, TEXT("Parameters = %d, %dn"),  
    pDataArray->val1, pDataArray->val2);  
    StringCchLength(msgBuf, BUF_SIZE, &cchStringSize); 
    WriteConsole(hStdout, msgBuf, (DWORD) cchStringSize, &dwChars, NULL); 

    return 0;  
}  

Worker函数中,我们将提供的参数转换为我们自定义的结构类型,然后使用它将其值打印到字符串上,然后在控制台上输出。

我们还验证是否有活动的标准输出(控制台或类似)。用于打印字符串的函数都是线程安全的。

void errorHandler(LPTSTR lpszFunction) { 
    LPVOID lpMsgBuf; 
    LPVOID lpDisplayBuf; 
    DWORD dw = GetLastError();  

    FormatMessage( 
        FORMAT_MESSAGE_ALLOCATE_BUFFER |  
        FORMAT_MESSAGE_FROM_SYSTEM | 
        FORMAT_MESSAGE_IGNORE_INSERTS, 
        NULL, 
        dw, 
        MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), 
        (LPTSTR) &lpMsgBuf, 
        0, NULL); 

        lpDisplayBuf = (LPVOID) LocalAlloc(LMEM_ZEROINIT,  
        (lstrlen((LPCTSTR) lpMsgBuf) + lstrlen((LPCTSTR) lpszFunction) + 40) * sizeof(TCHAR));  
        StringCchPrintf((LPTSTR)lpDisplayBuf,  
        LocalSize(lpDisplayBuf) / sizeof(TCHAR), 
        TEXT("%s failed with error %d: %s"),  
        lpszFunction, dw, lpMsgBuf);  
        MessageBox(NULL, (LPCTSTR) lpDisplayBuf, TEXT("Error"), MB_OK);  

        LocalFree(lpMsgBuf); 
        LocalFree(lpDisplayBuf); 
} 

在这里,定义了一个错误处理函数,该函数获取最后一个错误代码的系统错误消息。在获取最后一个错误的代码之后,将格式化要输出的错误消息,并显示在消息框中。最后,释放分配的内存缓冲区。

最后,main函数如下:

int _tmain() {
         PMYDATA pDataArray[MAX_THREADS];
         DWORD dwThreadIdArray[MAX_THREADS];
         HANDLE hThreadArray[MAX_THREADS];
         for (int i = 0; i < MAX_THREADS; ++i) {
               pDataArray[i] = (PMYDATA) HeapAlloc(GetProcessHeap(),
                           HEAP_ZERO_MEMORY, sizeof(MYDATA));                     if (pDataArray[i] == 0) {
                           ExitProcess(2);
             }
             pDataArray[i]->val1 = i;
             pDataArray[i]->val2 = i+100;
             hThreadArray[i] = CreateThread(
                  NULL,          // default security attributes
                  0,             // use default stack size
                  worker,        // thread function name
                  pDataArray[i], // argument to thread function
                  0,             // use default creation flags
                  &dwThreadIdArray[i]);// returns the thread identifier
             if (hThreadArray[i] == 0) {
                         errorHandler(TEXT("CreateThread"));
                         ExitProcess(3);
             }
   }
         WaitForMultipleObjects(MAX_THREADS, hThreadArray, TRUE, INFINITE);
         for (int i = 0; i < MAX_THREADS; ++i) {
               CloseHandle(hThreadArray[i]);
               if (pDataArray[i] != 0) {
                           HeapFree(GetProcessHeap(), 0, pDataArray[i]);
               }
         }
         return 0;
}

main函数中,我们在循环中创建线程,为线程数据分配内存,并在启动线程之前为每个线程生成唯一数据。每个线程实例都传递了自己的唯一参数。

之后,我们等待线程完成并重新加入。这本质上与在 Pthreads 上调用join函数的单个线程相同--只是这里,一个函数调用就足够了。

最后,关闭每个线程句柄,并清理之前分配的内存。

高级管理

使用 Windows 线程进行高级线程管理包括作业、纤程和线程池。作业基本上允许将多个线程链接到一个单元中,从而可以一次性更改所有这些线程的属性和状态。

纤程是轻量级线程,运行在创建它们的线程的上下文中。创建线程预期自己调度这些纤程。纤程还具有类似 TLS 的纤程本地存储FLS)。

最后,Windows 线程 API 提供了一个线程池 API,允许在应用程序中轻松使用这样的线程池。每个进程也都提供了一个默认的线程池。

同步

使用 Windows 线程,可以使用临界区、互斥体、信号量、轻量级读写器SRW)锁、屏障和变体来实现互斥和同步。

同步对象包括以下内容:

名称 描述
事件 允许使用命名对象在线程和进程之间进行事件信号传递。
互斥体 用于线程间和进程间同步,以协调对共享资源的访问。
信号量 用于线程间和进程同步的标准信号量计数对象。
可等待定时器 可由多个进程使用的定时器对象,具有多种使用模式。
临界区 临界区本质上是限于单个进程的互斥锁,这使得它们比使用互斥锁更快,因为它们不需要内核空间调用。
Slim reader/writer lock SRW 类似于 Pthreads 中的读/写锁,允许多个读取者或单个写入者线程访问共享资源。
原子变量访问 允许对一系列变量进行原子访问,否则不能保证原子性。这使得线程可以共享变量而无需使用互斥锁。

条件变量

使用 Windows 线程实现条件变量是相当简单的。它使用临界区(CRITICAL_SECTION)和条件变量(CONDITION_VARIABLE)以及条件变量函数来等待特定条件变量,或者发出信号。

线程本地存储

线程本地存储TLS)与 Windows 线程类似于 Pthreads,因为首先必须创建一个中央键(TLS 索引),然后各个线程可以使用该全局索引来存储和检索本地值。

与 Pthreads 一样,这涉及到相似数量的手动内存管理,因为 TLS 值必须手动分配和删除。

Boost

Boost 线程是 Boost 库集合中相对较小的一部分。然而,它被用作成为 C++11 中多线程实现基础,类似于其他 Boost 库最终完全或部分地成为新的 C++标准。有关多线程 API 的详细信息,请参阅本章中的 C++线程部分。

C++11 标准中缺少的功能,在 Boost 线程中是可用的,包括以下内容:

  • 线程组(类似于 Windows 作业)

  • 线程中断(取消)

  • 带超时的线程加入

  • 其他互斥锁类型(C++14 改进)

除非绝对需要这些功能,或者无法使用支持 C++11 标准(包括 STL 线程)的编译器,否则没有理由使用 Boost 线程而不是 C++11 实现。

由于 Boost 提供了对本机操作系统功能的封装,使用本机 C++线程可能会减少开销,具体取决于 STL 实现的质量。

Qt

Qt 是一个相对高级的框架,这也反映在其多线程 API 中。Qt 的另一个定义特征是,它包装了自己的代码(QApplication 和 QMainWindow),并使用元编译器(qmake)来实现其信号-槽架构和框架的其他定义特征。

因此,Qt 的线程支持不能直接添加到现有代码中,而是需要调整代码以适应框架。

QThread

在 Qt 中,QThread类不是一个线程,而是一个围绕线程实例的广泛封装,它添加了信号-槽通信、运行时支持和其他功能。这在 QThread 的基本用法中得到体现,如下面的代码所示:

class Worker : public QObject { 
    Q_OBJECT 

    public: 
        Worker(); 
        ~Worker(); 

    public slots: 
        void process(); 

    signals: 
        void finished(); 
        void error(QString err); 

    private: 
}; 

上述代码是一个基本的Worker类,它将包含我们的业务逻辑。它派生自QObject类,这也允许我们使用信号-槽和其他固有的QObject特性。信号-槽架构在其核心本质上只是一种方式,允许侦听器注册(连接到)由 QObject 派生类声明的信号,从而实现跨模块、跨线程和异步通信。

它有一个可以调用以开始处理的单一方法,并且有两个信号——一个用于表示完成,一个用于表示错误。

实现如下所示:

Worker::Worker() { }  
Worker::~Worker() { } 

void Worker::process() { 
    qDebug("Hello World!"); 
    emit finished(); 
} 

构造函数可以扩展以包括参数。任何在process()方法中分配的堆分配变量(使用mallocnew)必须在process()方法中分配,而不是在构造函数中,因为Worker实例将在其中运行线程上下文中操作,我们马上就会看到。

要创建一个新的 QThread,我们将使用以下设置:

QThread* thread = new QThread; 
Worker* worker = new Worker(); 
worker->moveToThread(thread); 
connect(worker, SIGNAL(error(QString)), this, SLOT(errorString(QString))); 
connect(thread, SIGNAL(started()), worker, SLOT(process())); 
connect(worker, SIGNAL(finished()), thread, SLOT(quit())); 
connect(worker, SIGNAL(finished()), worker, SLOT(deleteLater())); 
connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater())); 
thread->start(); 

基本过程是在堆上创建一个新的 QThread 实例(这样它就不会超出范围),以及我们的Worker类的堆分配实例。然后使用其moveToThread()方法将新的工作线程移动到新的线程实例中。

接下来,将连接各种信号到相关的槽,包括我们自己的finished()error()信号。线程实例的started()信号将连接到我们的工作线程上的槽,以启动它。

最重要的是,必须将工作线程的某种完成信号连接到线程上的quit()deleteLater()槽。然后将线程的finished()信号连接到工作线程上的deleteLater()槽。这将确保在工作线程完成时清理线程和工作线程实例。

线程池

Qt 提供线程池。这些需要从QRunnable类继承,并实现run()函数。然后将此自定义类的实例传递给线程池的start方法(全局默认池或新池)。然后线程池会处理此工作线程的生命周期。

同步

Qt 提供以下同步对象:

  • QMutex

  • QReadWriteLock

  • QSemaphore

  • QWaitCondition(条件变量)

这些应该是相当不言自明的。Qt 的信号-槽架构的另一个好处是,它还允许在线程之间异步通信,而无需关注低级实现细节。

QtConcurrent

QtConcurrent 命名空间包含针对编写多线程应用程序的高级 API,旨在使编写多线程应用程序成为可能,而无需关注低级细节。

函数包括并发过滤和映射算法,以及允许在单独线程中运行函数的方法。所有这些都返回一个QFuture实例,其中包含异步操作的结果。

线程本地存储

Qt 通过其QThreadStorage类提供 TLS。它处理指针类型值的内存管理。通常,人们会将某种数据结构设置为 TLS 值,以存储每个线程的多个值,例如在QThreadStorage类文档中描述的那样:

QThreadStorage<QCache<QString, SomeClass> > caches; 

void cacheObject(const QString &key, SomeClass* object) { 
    caches.localData().insert(key, object); 
} 

void removeFromCache(const QString &key) { 
    if (!caches.hasLocalData()) { return; } 

    caches.localData().remove(key); 
} 

POCO

POCO 库是围绕操作系统功能的相当轻量级的包装器。它不需要 C++11 兼容的编译器或任何种类的预编译或元编译。

线程类

Thread类是围绕操作系统级线程的简单包装器。它接受从Runnable类继承的Worker类实例。官方文档提供了一个基本示例如下:

#include "Poco/Thread.h" 
#include "Poco/Runnable.h" 
#include <iostream> 

class HelloRunnable: public Poco::Runnable { 
    virtual void run() { 
        std::cout << "Hello, world!" << std::endl; 
    } 
}; 

int main(int argc, char** argv) { 
    HelloRunnable runnable; 
    Poco::Thread thread; 
    thread.start(runnable); 
    thread.join(); 
    return 0; 
} 

上述代码是一个非常简单的“Hello world”示例,其中一个工作线程只通过标准输出输出一个字符串。线程实例分配在堆栈上,并在入口函数的范围内等待工作线程使用join()函数完成。

在许多线程函数中,POCO 非常类似于 Pthreads,尽管在配置线程和其他对象等方面有明显的偏差。作为 C++库,它使用类方法设置属性,而不是填充结构并将其作为参数传递。

线程池

POCO 提供了一个默认的线程池,有 16 个线程。这个数字可以动态改变。与常规线程一样,线程池需要传递一个从Runnable类继承的Worker类实例:

#include "Poco/ThreadPool.h" 
#include "Poco/Runnable.h" 
#include <iostream> 

class HelloRunnable: public Poco::Runnable { 
    virtual void run() { 
        std::cout << "Hello, world!" << std::endl; 
    } 
}; 

int main(int argc, char** argv) { 
    HelloRunnable runnable; 
    Poco::ThreadPool::defaultPool().start(runnable); 
    Poco::ThreadPool::defaultPool().joinAll(); 
    return 0; 
} 

工作实例被添加到线程池中,并运行它。当我们添加另一个工作实例、更改容量或调用joinAll()时,线程池会清理空闲一定时间的线程。因此,单个工作线程将加入,没有活动线程后,应用程序退出。

线程本地存储(TLS)

在 POCO 中,TLS 被实现为一个类模板,允许人们将其用于几乎任何类型。

根据官方文档的详细说明:

#include "Poco/Thread.h" 
#include "Poco/Runnable.h" 
#include "Poco/ThreadLocal.h" 
#include <iostream> 

class Counter: public Poco::Runnable { 
    void run() { 
        static Poco::ThreadLocal<int> tls; 
        for (*tls = 0; *tls < 10; ++(*tls)) { 
            std::cout << *tls << std::endl; 
        } 
    } 
}; 

int main(int argc, char** argv) { 
    Counter counter1; 
    Counter counter2; 
    Poco::Thread t1; 
    Poco::Thread t2; 
    t1.start(counter1); 
    t2.start(counter2); 
    t1.join(); 
    t2.join(); 
    return 0; 
} 

在前面的工作示例中,我们使用ThreadLocal类模板创建了一个静态 TLS 变量,并定义它包含一个整数。

因为我们将其定义为静态的,它将只在每个线程中创建一次。为了使用我们的 TLS 变量,我们可以使用箭头(->)或星号(*)运算符来访问其值。在这个例子中,我们在for循环的每个周期增加 TLS 值,直到达到限制为止。

这个例子演示了两个线程将生成它们自己的一系列 10 个整数,计数相同的数字而互不影响。

同步

POCO 提供的同步原语如下:

  • 互斥

  • FastMutex

  • 事件

  • 条件

  • 信号量

  • RWLock

这里需要注意的是FastMutex类。这通常是一种非递归互斥类型,但在 Windows 上是递归的。这意味着人们通常应该假设任一类型在同一线程中可以被同一线程多次锁定。

人们还可以使用ScopedLock类与互斥体一起使用,它确保封装的互斥体在当前作用域结束时被释放。

事件类似于 Windows 事件,不同之处在于它们仅限于单个进程。它们构成了 POCO 中条件变量的基础。

POCO 条件变量的功能与 Pthreads 等方式基本相同,不同之处在于它们不会出现虚假唤醒。通常条件变量会出现这些随机唤醒以进行优化。通过不必须明确检查条件变量等待返回时是否满足其条件,减轻了开发者的负担。

C++线程

C++中的本地多线程支持在第五章中得到了广泛的覆盖,本地 C++线程和原语

正如本章中 Boost 部分所述,C++多线程支持在很大程度上基于 Boost 线程 API,几乎使用相同的头文件和名称。API 本身再次让人联想到 Pthreads,尽管在某些方面有显著的不同,例如条件变量。

即将发布的章节将专门使用 C++线程支持作为示例。

整合

在本章涵盖的 API 中,只有 Qt 多线程 API 可以被认为是真正的高级。尽管其他 API(包括 C++11)具有一些更高级的概念,包括线程池和不需要直接使用线程的异步运行器,但 Qt 提供了一个完整的信号-槽架构,使得线程间通信异常容易。

正如本章所述,这种便利也伴随着成本,即必须开发自己的应用程序以适应 Qt 框架。这可能在项目中是不可接受的。

哪种 API 是正确的取决于人们的需求。然而,可以相对公平地说,当人们可以使用诸如 C++11 线程、POCO 等 API 时,直接使用 Pthreads、Windows 线程等并没有太多意义,这些 API 可以在不显著降低性能的情况下简化开发过程,并在各个平台上获得广泛的可移植性。

所有这些 API 在其核心功能上至少在某种程度上是可比较的。

总结

在本章中,我们详细研究了一些较流行的多线程 API 和框架,将它们并列起来,以了解它们的优势和劣势。我们通过一些示例展示了如何使用这些 API 来实现基本功能。

在下一章中,我们将详细讨论如何同步线程并在它们之间进行通信。

第四章:线程同步和通信

虽然通常线程用于相对独立地处理任务,但有许多情况下,人们希望在线程之间传递数据,甚至控制其他线程,比如来自中央任务调度器线程。本章将介绍如何使用 C++11 线程 API 完成这些任务。

本章涵盖的主题包括以下内容:

  • 使用互斥锁、锁和类似的同步结构

  • 使用条件变量和信号来控制线程

  • 在线程之间安全地传递和共享数据

安全第一

并发的核心问题是确保在线程之间进行通信时对共享资源进行安全访问。还有线程能够进行通信和同步的问题。

多线程编程的挑战在于能够跟踪线程之间的每次交互,并确保每种形式的访问都得到保护,同时不会陷入死锁和数据竞争的陷阱。

在本章中,我们将看一个涉及任务调度器的相当复杂的例子。这是一种高并发、高吞吐量的情况,许多不同的要求与许多潜在的陷阱相结合,我们将在下面看到。

调度程序

多线程与大量线程之间的同步和通信的一个很好的例子是任务调度。在这里,目标是尽快接受传入的任务并将它们分配给工作线程。

在这种情况下,有许多不同的方法。通常情况下,有工作线程在活动循环中运行,不断轮询中央队列以获取新任务。这种方法的缺点包括在轮询上浪费处理器周期,并且在使用的同步机制(通常是互斥锁)上形成的拥塞。此外,当工作线程数量增加时,这种主动轮询方法的扩展性非常差。

理想情况下,每个工作线程都会空闲等待直到再次需要。为了实现这一点,我们必须从另一方面解决问题:不是从工作线程的角度,而是从队列的角度。就像操作系统的调度程序一样,调度程序既知道需要处理的任务,也知道可用的工作线程。

在这种方法中,一个中央调度器实例将接受新任务并主动分配给工作线程。该调度器实例还可以管理这些工作线程,例如它们的数量和优先级,这取决于传入任务的数量和任务的类型或其他属性。

高层视图

在其核心,我们的调度程序或调度器非常简单,像一个队列,所有调度逻辑都内置其中,如下图所示:

从前面的高层视图可以看出,实际上并没有太多内容。然而,正如我们将在下面看到的,实际的实现确实有许多复杂之处。

实施

像往常一样,我们从main函数开始,包含在main.cpp中:

#include "dispatcher.h"
#include "request.h"

#include <iostream>
#include <string>
#include <csignal>
#include <thread>
#include <chrono>

using namespace std;

sig_atomic_t signal_caught = 0;
mutex logMutex; 

我们包括的自定义头文件是我们的调度器实现和我们将使用的request类。

全局上,我们定义了一个用于信号处理程序的原子变量,以及一个将同步输出(在标准输出上)的互斥锁,来自我们的日志方法:

void sigint_handler(int sig) {
    signal_caught = 1;
} 

我们的信号处理函数(用于SIGINT信号)只是设置了我们之前定义的全局原子变量:

void logFnc(string text) {
    logMutex.lock();
    cout << text << "\n";
    logMutex.unlock();
} 

在我们的日志函数中,我们使用全局互斥锁来确保对标准输出的写入是同步的:

int main() {
    signal(SIGINT, &sigint_handler);
    Dispatcher::init(10); 

main函数中,我们安装SIGINT的信号处理程序,以允许我们中断应用程序的执行。我们还调用Dispatcher类的静态init()函数来初始化它:

    cout << "Initialised.\n";
        int cycles = 0;
    Request* rq = 0;
    while (!signal_caught && cycles < 50) {
        rq = new Request();
        rq->setValue(cycles);
        rq->setOutput(&logFnc);
        Dispatcher::addRequest(rq);
        cycles++;
    } 

接下来,我们设置循环,在其中我们将创建新的请求。在每个周期中,我们创建一个新的Request实例,并使用其setValue()函数设置一个整数值(当前周期号)。在将此新请求添加到Dispatcher时,我们还在请求实例上设置我们的日志函数,使用其静态的addRequest()函数。

这个循环将继续,直到达到最大周期数,或者使用Ctrl+C或类似方法发出SIGINT信号为止:

        this_thread::sleep_for(chrono::seconds(5));
        Dispatcher::stop();
    cout << "Clean-up done.\n";
    return 0; 
} 

最后,我们使用线程的sleep_for()函数和chronoSTL 头文件中的chrono::seconds()函数等待 5 秒。

我们还在返回之前在Dispatcher上调用stop()函数。

请求类

对于Dispatcher的请求总是派生自纯虚拟的AbstractRequest类:

#pragma once
#ifndef ABSTRACT_REQUEST_H
#define ABSTRACT_REQUEST_H

class AbstractRequest {
    //
    public:
    virtual void setValue(int value) = 0;
    virtual void process() = 0;
    virtual void finish() = 0;
};
#endif 

这个AbstractRequest类定义了一个具有三个函数的 API,派生类始终必须实现这些函数。其中,process()finish()函数是最通用的,可能在任何实际实现中使用。setValue()函数是特定于此演示实现的,可能会被调整或扩展以适应实际情况。

使用抽象类作为请求的基础的优势在于,只要它们都遵循相同的基本 API,Dispatcher类就可以处理许多不同类型的请求。

使用这个抽象接口,我们实现一个基本的Request类如下:

#pragma once
#ifndef REQUEST_H
#define REQUEST_H

#include "abstract_request.h"

#include <string>

using namespace std;

typedef void (*logFunction)(string text);

class Request : public AbstractRequest {
    int value;
    logFunction outFnc;
    public:    void setValue(int value) { this->value = value; }
    void setOutput(logFunction fnc) { outFnc = fnc; }
    void process();
    void finish();
};
#endif 

在头文件中,我们首先定义函数指针的格式。之后,我们实现请求 API,并将setOutput()函数添加到基本 API 中,该函数接受用于记录日志的函数指针。这两个 setter 函数仅将提供的参数分配给它们各自的私有类成员。

接下来,给出类函数的实现如下:

#include "request.h"
void Request::process() {
    outFnc("Starting processing request " + std::to_string(value) + "...");
    //
}
void Request::finish() {
    outFnc("Finished request " + std::to_string(value));
} 

这两个实现都非常基本;它们仅使用函数指针来输出指示工作线程状态的字符串。

在实际实现中,可以将业务逻辑添加到process()函数中,而finish()函数包含完成请求的任何功能,例如将映射写入字符串。

Worker 类

接下来是Worker类。这包含了Dispatcher将调用以处理请求的逻辑。

#pragma once
#ifndef WORKER_H
#define WORKER_H

#include "abstract_request.h"

#include <condition_variable>
#include <mutex>

using namespace std;

class Worker {
    condition_variable cv;
    mutex mtx;
    unique_lock<mutex> ulock;
    AbstractRequest* request;
    bool running;
    bool ready;
    public:
    Worker() { running = true; ready = false; ulock = unique_lock<mutex>(mtx); }
    void run();
    void stop() { running = false; }
    void setRequest(AbstractRequest* request) { this->request = request; ready = true; }
    void getCondition(condition_variable* &cv);
};
#endif 

虽然将请求添加到Dispatcher不需要任何特殊逻辑,但Worker类需要使用条件变量来与调度程序同步。对于 C++11 线程 API,这需要一个条件变量,一个互斥锁和一个唯一的锁。

唯一的锁封装了互斥锁,并且最终将与条件变量一起使用,我们将在下一刻看到。

除此之外,我们定义了启动和停止工作线程的方法,设置新请求进行处理的方法,以及获取其内部条件变量的访问权限。

接下来,其余的实现如下所示:

#include "worker.h"
#include "dispatcher.h"

#include <chrono>

using namespace std;

void Worker::getCondition(condition_variable* &cv) {
    cv = &(this)->cv;
}

void Worker::run() {
    while (running) {
        if (ready) {
            ready = false;
            request->process();
            request->finish();
        }
        if (Dispatcher::addWorker(this)) {
            // Use the ready loop to deal with spurious wake-ups.
            while (!ready && running) {
                if (cv.wait_for(ulock, chrono::seconds(1)) == cv_status::timeout) {
                    // We timed out, but we keep waiting unless  
                    // the worker is 
                    // stopped by the dispatcher. 
                }
            }
        }
    }
} 

除了条件变量的getter函数之外,我们定义了run()函数,dispatcher将在启动每个工作线程时运行。

它的主循环仅检查stop()函数是否已被调用,该函数会将运行布尔值设置为false,并结束工作线程。这在Dispatcher关闭时被使用,允许它终止工作线程。由于布尔值通常是原子的,设置和检查可以同时进行,而无需风险或需要互斥锁。

接下来,对ready变量的检查是为了确保在线程首次运行时实际上有一个请求在等待。在工作线程的第一次运行时,没有请求会等待,因此,尝试处理一个请求将导致崩溃。当Dispatcher设置一个新请求时,这个布尔变量将被设置为true

如果有请求在等待,ready变量将再次设置为false,之后请求实例将调用其process()finish()函数。这将在工作线程的线程上运行请求的业务逻辑,并完成它。

最后,工作线程使用其静态的addWorker()函数将自己添加到调度器。如果没有新请求可用,此函数将返回false,并导致工作线程等待直到有新请求可用。否则,工作线程将继续处理Dispatcher设置的新请求。

如果要求等待,我们进入一个新的循环。这个循环将确保当条件变量被唤醒时,是因为我们得到了Dispatcherready变量设置为true)的信号,而不是因为虚假唤醒。

最后,我们使用之前创建的唯一锁实例和超时进入条件变量的实际wait()函数。如果超时发生,我们可以终止线程,或者继续等待。在这里,我们选择什么都不做,只是重新进入等待循环。

调度器

作为最后一项,我们有Dispatcher类本身:

    #pragma once
    #ifndef DISPATCHER_H
    #define DISPATCHER_H

    #include "abstract_request.h"
    #include "worker.h"

    #include <queue>
    #include <mutex>
    #include <thread>
    #include <vector>

    using namespace std;

    class Dispatcher {
        static queue<AbstractRequest*> requests;
        static queue<Worker*> workers;
        static mutex requestsMutex;
        static mutex workersMutex;
        static vector<Worker*> allWorkers;
        static vector<thread*> threads;
        public:
        static bool init(int workers);
        static bool stop();
        static void addRequest(AbstractRequest* request);
        static bool addWorker(Worker* worker);
     };
     #endif 

大部分内容都会看起来很熟悉。到目前为止,您已经推测到,这是一个完全静态的类。

继续,其实现如下:

    #include "dispatcher.h"

    #include <iostream>
    using namespace std;

    queue<AbstractRequest*> Dispatcher::requests;
    queue<Worker*> Dispatcher::workers;
    mutex Dispatcher::requestsMutex;
    mutex Dispatcher::workersMutex;
    vector<Worker*> Dispatcher::allWorkers;
    vector<thread*> Dispatcher::threads; 

    bool Dispatcher::init(int workers) {
        thread* t = 0;
        Worker* w = 0;
        for (int i = 0; i < workers; ++i) {
            w = new Worker;
            allWorkers.push_back(w);
            t = new thread(&Worker::run, w);
            threads.push_back(t);
        }
   return true;
 } 

在设置静态类成员之后,定义了init()函数。它启动指定数量的工作线程,并在各自的向量数据结构中保留对每个工作线程和线程实例的引用:

    bool Dispatcher::stop() {
        for (int i = 0; i < allWorkers.size(); ++i) {
            allWorkers[i]->stop();
        }
            cout << "Stopped workers.\n";
            for (int j = 0; j < threads.size(); ++j) {
            threads[j]->join();
                    cout << "Joined threads.\n";
        }
    }

stop()函数中,每个工作线程实例都调用其stop()函数。这将导致每个工作线程终止,正如我们在Worker类描述中看到的那样。

最后,我们等待每个线程加入(即完成)后再返回:

    void Dispatcher::addRequest(AbstractRequest* request) {
        workersMutex.lock();
        if (!workers.empty()) {
            Worker* worker = workers.front();
            worker->setRequest(request);
            condition_variable* cv;
            worker->getCondition(cv);
            cv->notify_one();
            workers.pop();
            workersMutex.unlock();
        }
        else {
            workersMutex.unlock();
            requestsMutex.lock();
            requests.push(request);
            requestsMutex.unlock();
        }
    } 

addRequest()函数是有趣的地方。在这个函数中,添加了一个新请求。接下来会发生什么取决于是否有工作线程在等待新请求。如果没有工作线程在等待(工作线程队列为空),则将请求添加到请求队列中。

使用互斥锁确保对这些队列的访问是安全的,因为工作线程将同时尝试访问这两个队列。

这里需要注意的一个重要问题是死锁的可能性。也就是说,两个线程将持有资源的锁,第二个线程在释放自己的锁之前等待第一个线程释放其锁。在单个作用域中使用多个互斥锁的每种情况都具有这种潜力。

在这个函数中,死锁的潜在可能性在于释放工作线程互斥锁,并在获取请求互斥锁时。在这个函数持有工作线程互斥锁并尝试获取请求锁(当没有工作线程可用时),有可能另一个线程持有请求互斥锁(寻找要处理的新请求)同时尝试获取工作线程互斥锁(找不到请求并将自己添加到工作线程队列)。

解决方案很简单:在获取下一个互斥锁之前释放一个互斥锁。在某人觉得必须持有多个互斥锁时,必须仔细检查和测试自己的代码是否存在潜在的死锁。在这种特殊情况下,当不再需要工作线程互斥锁时,或在获取请求互斥锁之前,显式释放工作线程互斥锁,从而防止死锁。

这段代码的另一个重要方面是它如何向工作线程发出信号。正如可以在 if/else 块的第一部分看到的那样,当工作线程队列不为空时,从队列中获取一个工作线程,设置请求,然后引用并发出条件变量的信号,或通知。

在内部,条件变量使用我们在Worker类定义中提供的互斥锁来保证对它的原子访问。当在条件变量上调用notify_one()函数(在其他 API 中通常称为signal())时,它将通知等待条件变量返回并继续的线程队列中的第一个线程。

Worker类的run()函数中,我们将等待这个通知事件。收到通知后,工作线程将继续处理新的请求。然后线程引用将从队列中移除,直到它再次添加自己,一旦它完成了处理请求:

    bool Dispatcher::addWorker(Worker* worker) {
        bool wait = true;
        requestsMutex.lock();
        if (!requests.empty()) {
            AbstractRequest* request = requests.front();
            worker->setRequest(request);
            requests.pop();
            wait = false;
            requestsMutex.unlock();
        }
        else {
            requestsMutex.unlock();
            workersMutex.lock();
            workers.push(worker);
            workersMutex.unlock();
        }
            return wait;
    } 

在这个最后的函数中,工作线程在处理完一个请求后会将自己添加到队列中。它与之前的函数类似,首先会主动匹配等待在请求队列中的任何请求。如果没有可用的请求,工作线程将被添加到工作线程队列中。

这里需要注意的是,我们返回一个布尔值,指示调用线程是否应该等待新的请求,或者在尝试添加自己到队列时是否已经收到了新的请求。

虽然这段代码比之前的函数要简单,但由于在同一范围内处理了两个互斥锁,它仍然存在潜在的死锁问题。在这里,我们首先释放我们持有的互斥锁,然后再获取下一个互斥锁。

Makefile

这个Dispatcher示例的 makefile 非常基本--它收集当前文件夹中的所有 C++源文件,并使用g++将它们编译成一个二进制文件:

    GCC := g++

    OUTPUT := dispatcher_demo
    SOURCES := $(wildcard *.cpp)
    CCFLAGS := -std=c++11 -g3

    all: $(OUTPUT)
        $(OUTPUT):
        $(GCC) -o $(OUTPUT) $(CCFLAGS) $(SOURCES)
        clean:
        rm $(OUTPUT)
        .PHONY: all

输出

编译应用程序后,运行它会产生以下输出,总共有 50 个请求:

    $ ./dispatcher_demo.exe
    Initialised.
    Starting processing request 1...
    Starting processing request 2...
    Finished request 1
    Starting processing request 3...
    Finished request 3
    Starting processing request 6...
    Finished request 6
    Starting processing request 8...
    Finished request 8
    Starting processing request 9...
    Finished request 9
    Finished request 2
    Starting processing request 11...
    Finished request 11
    Starting processing request 12...
    Finished request 12
    Starting processing request 13...
    Finished request 13
    Starting processing request 14...
    Finished request 14
    Starting processing request 7...
    Starting processing request 10...
    Starting processing request 15...
    Finished request 7
    Finished request 15
    Finished request 10
    Starting processing request 16...
    Finished request 16
    Starting processing request 17...
    Starting processing request 18...
    Starting processing request 0...

此时,我们已经清楚地看到,即使每个请求几乎没有时间来处理,请求仍然明显是并行执行的。第一个请求(请求 0)只有在第 16 个请求之后才开始处理,而第二个请求在第九个请求之后就已经完成了。

决定哪个线程,因此,哪个请求首先被处理的因素取决于操作系统调度程序和基于硬件的调度,如第二章中所述,“处理器和操作系统上的多线程实现”。这清楚地显示了即使在单个平台上,也不能对多线程应用程序的执行做出多少假设。

    Starting processing request 5...
    Finished request 5
    Starting processing request 20...
    Finished request 18
    Finished request 20
    Starting processing request 21...
    Starting processing request 4...
    Finished request 21
    Finished request 4   

在前面的代码中,第四个和第五个请求也以相当延迟的方式完成。


    Starting processing request 23...
    Starting processing request 24...
    Starting processing request 22...
    Finished request 24
    Finished request 23
    Finished request 22
    Starting processing request 26...
    Starting processing request 25...
    Starting processing request 28...
    Finished request 26
    Starting processing request 27...
    Finished request 28
    Finished request 27
    Starting processing request 29...
    Starting processing request 30...
    Finished request 30
    Finished request 29
    Finished request 17
    Finished request 25
    Starting processing request 19...
    Finished request 0

此时,第一个请求终于完成了。这可能表明,与后续请求相比,第一个请求的初始化时间总是会延迟。多次运行应用程序可以确认这一点。重要的是,如果处理顺序很重要,这种随机性不会对应用程序产生负面影响。

    Starting processing request 33...
    Starting processing request 35...
    Finished request 33
    Finished request 35
    Starting processing request 37...
    Starting processing request 38...
    Finished request 37
    Finished request 38
    Starting processing request 39...
    Starting processing request 40...
    Starting processing request 36...
    Starting processing request 31...
    Finished request 40
    Finished request 39
    Starting processing request 32...
    Starting processing request 41...
    Finished request 32
    Finished request 41
    Starting processing request 42...
    Finished request 31
    Starting processing request 44...
    Finished request 36
    Finished request 42
    Starting processing request 45...
    Finished request 44
    Starting processing request 47...
    Starting processing request 48...
    Finished request 48
    Starting processing request 43...
    Finished request 47
    Finished request 43
    Finished request 19
    Starting processing request 34...
    Finished request 34
    Starting processing request 46...
    Starting processing request 49...
    Finished request 46
    Finished request 49
    Finished request 45

第 19 个请求也变得相当延迟,再次显示了多线程应用程序有多么不可预测。如果我们在这里并行处理大型数据集,每个请求中都有数据块,我们可能需要在某些时刻暂停以应对这些延迟,否则我们的输出缓存可能会变得太大。

由于这样做会对应用程序的性能产生负面影响,人们可能需要考虑低级优化,以及在特定处理器核心上对线程进行调度,以防止这种情况发生。

    Stopped workers.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Joined threads.
    Clean-up done.

最初启动的所有 10 个工作线程在这里终止,因为我们调用了Dispatcherstop()函数。

共享数据

在本章的示例中,我们看到了如何在线程之间共享信息,除了同步线程之外--这是我们从主线程传递到调度程序的请求的形式,每个请求都会传递到不同的线程中。

在线程之间共享数据的基本思想是要共享的数据以某种方式存在于两个或更多个线程都可以访问的地方。之后,我们必须确保只有一个线程可以修改数据,并且在读取数据时数据不会被修改。通常,我们会使用互斥锁或类似的方法来确保这一点。

使用读/写锁

在这里,读写锁是一种可能的优化,因为它们允许多个线程同时从单个数据源读取。如果一个应用程序中有多个工作线程反复读取相同的信息,使用读写锁比基本互斥锁更有效,因为尝试读取数据不会阻塞其他线程。

读写锁因此可以被用作互斥锁的更高级版本,即,它可以根据访问类型调整其行为。在内部,它建立在互斥锁(或信号量)和条件变量之上。

使用共享指针

共享指针首先通过 Boost 库提供,并在 C++11 中引入,它们是使用引用计数对堆分配实例进行内存管理的抽象。它们在某种程度上是线程安全的,因为可以创建多个共享指针实例,但引用的对象本身并不是线程安全的。

根据应用程序的不同,这可能已经足够了。为了使它们真正线程安全,可以使用原子操作。我们将在第八章中更详细地讨论这个问题,原子操作 - 与硬件一起工作

总结

在本章中,我们讨论了如何以安全的方式在相当复杂的调度程序实现中在线程之间传递数据。我们还研究了所述调度程序的结果异步处理,并考虑了在线程之间传递数据的一些潜在替代方案和优化。

在这一点上,您应该能够安全地在线程之间传递数据,并同步访问其他共享资源。

在下一章中,我们将研究本地 C++线程和基本 API。

第五章:本机 C++线程和原语

从 C++标准的 2011 修订版开始,多线程 API 正式成为 C++标准模板库STL)的一部分。这意味着线程、线程原语和同步机制可用于任何新的 C++应用程序,无需安装第三方库,也无需依赖操作系统的 API。

本章将介绍本机 API 中可用的多线程功能,直到 2014 标准添加的功能。将展示一些示例以详细使用这些功能。

本章的主题包括以下内容:

  • C++ STL 中的多线程 API 涵盖的功能

  • 每个功能的详细示例

STL 线程 API

在第三章,C++多线程 API中,我们看了一下在开发多线程 C++应用程序时可用的各种 API。在第四章,线程同步和通信中,我们使用本机 C++线程 API 实现了一个多线程调度程序应用程序。

Boost.Thread API

通过包含 STL 中的<thread>头文件,我们可以访问std::thread类,并通过进一步的头文件提供互斥(互斥锁等)功能。这个 API 本质上与Boost.Thread的多线程 API 相同,主要区别在于对线程的更多控制(带超时的加入、线程组和线程中断),以及在原语(如互斥锁和条件变量)之上实现的一些额外的锁类型。

一般来说,当 C++11 支持不可用时,或者这些额外的Boost.Thread功能是应用程序的要求,且不容易以其他方式添加时,应该使用Boost.Thread作为后备。由于Boost.Thread是建立在可用的(本机)线程支持之上的,因此与 C++11 STL 实现相比,它也可能增加开销。

2011 标准

C++标准的 2011 修订版(通常称为 C++11)添加了一系列新功能,其中最关键的是添加了本机多线程支持,这使得在 C++中创建、管理和使用线程成为可能,而无需使用第三方库。

这个标准为核心语言规范了内存模型,允许多个线程共存,并启用了诸如线程本地存储等功能。C++03 标准中最初添加了支持,但 C++11 标准是第一个充分利用这一特性的标准。

正如前面所述,实际的线程 API 本身是在 STL 中实现的。C++11(C++0x)标准的目标之一是尽可能多地将新功能放入 STL 中,而不是作为核心语言的一部分。因此,为了使用线程、互斥锁等,必须首先包含相关的 STL 头文件。

参与新多线程 API 的标准委员会各自有自己的目标,因此一些希望加入标准的功能并没有最终实现。这包括一些希望的功能,比如终止另一个线程或线程取消,这些功能受到了 POSIX 代表的强烈反对,因为取消线程可能会导致正在销毁的线程资源清理出现问题。

以下是此 API 实现提供的功能:

  • std::thread

  • std::mutex

  • std::recursive_mutex

  • std::condition_variable

  • std::condition_variable_any

  • std::lock_guard

  • std::unique_lock

  • std::packaged_task

  • std::async

  • std::future

接下来我们将看一下每个功能的详细示例。首先我们将看看 C++标准的下一个修订版本添加了哪些初始功能。

C++14

2014 标准将以下功能添加到标准库中:

  • std::shared_lock

  • std::shared_timed_mutex

这两个都在<shared_mutex>STL 头文件中定义。由于锁是基于互斥锁的,因此共享锁依赖于共享互斥锁。

C++17

2017 年标准向标准库添加了另一组功能,即:

  • std::shared_mutex

  • std::scoped_lock

在这里,作用域锁是一个互斥锁包装器,提供了一种 RAII 风格的机制,以拥有互斥锁来持续一个作用域块的时间。

STL 组织

在 STL 中,我们找到以下头文件组织及其提供的功能:

头文件 提供

| | std::thread 类。std::this_thread 命名空间下的方法:

  • 屈服

  • 获取 ID

  • 睡眠一段时间

  • 睡到

|

| | 类:

  • 互斥

  • 互斥定时器

  • 递归互斥锁

  • 递归定时互斥锁

  • 锁卫

  • 作用域锁(C++17)

  • 唯一锁

函数:

  • 尝试锁

  • call_once

  • std::swap(std::unique_lock)

|

| <shared_mutex> | 类:

  • 共享互斥锁(C++17)

  • 共享定时互斥锁(C++14)

  • 共享锁(C++14)

函数:

  • std::swap(std::shared_lock)

|

| | 类:

  • 承诺

  • 打包任务

  • 未来

  • 共享未来

函数:

  • 异步

  • future_category

  • std::swap(std::promise)

  • std::swap(std::packaged_task)

|

| <condition_variable> | 类:

  • 条件变量

  • condition_variable_any

函数:

  • notify_all_at_thread_exit

|

在上表中,我们可以看到每个头文件提供的功能,以及 2014 年和 2017 年标准引入的功能。在接下来的几节中,我们将详细了解每个函数和类。

线程类

thread 类是整个线程 API 的核心;它包装了底层操作系统的线程,并提供了我们启动和停止线程所需的功能。

通过包括头文件,可以访问这些功能。

基本用法

创建线程后立即启动:

#include <thread> 

void worker() { 
   // Business logic. 
} 

int main () { 
   std::thread t(worker);
   return 0; 
} 

这段代码将启动线程,然后立即终止应用程序,因为我们没有等待新线程完成执行。

为了正确地做到这一点,我们需要等待线程完成,或者按照以下方式重新加入:

#include <thread> 

void worker() { 
   // Business logic. 
} 

int main () { 
   std::thread t(worker); 
   t.join(); 
   return 0; 
} 

这段最后的代码将执行,等待新线程完成,然后返回。

传递参数

也可以将参数传递给新线程。这些参数值必须是可移动构造的,这意味着它是一个具有移动或复制构造函数(用于右值引用)的类型。实际上,对于所有基本类型和大多数(用户定义的)类来说,情况都是如此:

#include <thread> 
#include <string> 

void worker(int n, std::string t) { 
   // Business logic. 
} 

int main () { 
   std::string s = "Test"; 
   int i = 1; 
   std::thread t(worker, i, s); 
   t.join(); 
   return 0; 
} 

在上述代码中,我们将一个整数和一个字符串传递给线程函数。这个函数将接收这两个变量的副本。当传递引用或指针时,生命周期问题、数据竞争等变得更加复杂,可能会成为一个问题。

返回值

由 thread 类构造函数传递的函数返回的任何值都将被忽略。要将信息返回给创建新线程的线程,必须使用线程间同步机制(如互斥锁)和某种共享变量。

移动线程

2011 年标准在头文件中添加了 std::move。使用这个模板方法,可以在对象之间移动资源。这意味着它也可以移动线程实例:

#include <thread> 
#include <string> 
#include <utility> 

void worker(int n, string t) { 
   // Business logic. 
} 

int main () { 
   std::string s = "Test"; 
   std::thread t0(worker, 1, s); 
   std::thread t1(std::move(t0)); 
   t1.join(); 
   return 0; 
} 

在这个代码版本中,我们在将线程移动到另一个线程之前创建了一个线程。因此线程 0 停止存在(因为它立即完成),并且在我们创建的新线程中继续执行线程函数。

因此,我们不必等待第一个线程重新加入,只需要等待第二个线程。

线程 ID

每个线程都有一个与之关联的标识符。这个 ID 或句柄是 STL 实现提供的唯一标识符。可以通过调用 thread 类实例的 get_id()函数来获取它,或者通过调用 std::this_thread::get_id()来获取调用该函数的线程的 ID:

#include <iostream>
 #include <thread>
 #include <chrono>
 #include <mutex>

 std::mutex display_mutex;

 void worker() {
     std::thread::id this_id = std::this_thread::get_id();

     display_mutex.lock();
     std::cout << "thread " << this_id << " sleeping...\n";
     display_mutex.unlock();

     std::this_thread::sleep_for(std::chrono::seconds(1));
 }

 int main() {
    std::thread t1(worker);
    std::thread::id t1_id = t1.get_id();

    std::thread t2(worker);
    std::thread::id t2_id = t2.get_id();

    display_mutex.lock();
    std::cout << "t1's id: " << t1_id << "\n";
    std::cout << "t2's id: " << t2_id << "\n";
    display_mutex.unlock();

    t1.join();
    t2.join();

    return 0;
 } 

这段代码将产生类似于这样的输出:

t1's id: 2
t2's id: 3
thread 2 sleeping...
thread 3 sleeping...

在这里,可以看到内部线程 ID 是一个整数(std::thread::id类型),相对于初始线程(ID 为 1)。这类似于大多数本机线程 ID,比如 POSIX 的线程 ID。这些也可以使用native_handle()获得。该函数将返回底层的本机线程句柄。当希望使用 STL 实现中不可用的特定 PThread 或 Win32 线程功能时,这是非常有用的。

休眠

可以使用两种方法之一延迟线程的执行(休眠)。一种是sleep_for(),它至少延迟指定的持续时间,但可能更长:

#include <iostream> 
#include <chrono> 
#include <thread> 
        using namespace std::chrono_literals;

        typedef std::chrono::time_point<std::chrono::high_resolution_clock> timepoint; 
int main() { 
         std::cout << "Starting sleep.\n"; 

         timepoint start = std::chrono::high_resolution_clock::now(); 

         std::this_thread::sleep_for(2s); 

         timepoint end = std::chrono::high_resolution_clock::now(); 
         std::chrono::duration<double, std::milli> elapsed = end - 
         start; 
         std::cout << "Slept for: " << elapsed.count() << " ms\n"; 
} 

上述代码显示了如何休眠大约 2 秒,使用具有当前操作系统上可能的最高精度的计数器来测量确切的持续时间。

请注意,我们可以直接指定秒数,使用秒后缀。这是 C++14 的一个特性,添加到了<chrono>头文件中。对于 C++11 版本,必须创建 std::chrono::seconds 的实例并将其传递给sleep_for()函数。

另一种方法是sleep_until(),它接受一个类型为std::chrono::time_point<Clock, Duration>的参数。使用这个函数,可以设置线程在达到指定时间点之前休眠。由于操作系统的调度优先级,这个唤醒时间可能不是指定的确切时间。

Yield

可以指示操作系统当前线程可以重新调度,以便其他线程可以运行。为此,使用std::this_thread::yield()函数。这个函数的确切结果取决于底层操作系统的实现和其调度程序。在 FIFO 调度程序的情况下,调用线程可能会被放在队列的末尾。

这是一个高度专业化的函数,具有特殊的用例。在未验证其对应用程序性能的影响之前,不应该使用它。

Detach

启动线程后,可以在线程对象上调用detach()。这实际上将新线程从调用线程中分离出来,这意味着前者将在调用线程退出后继续执行。

Swap

使用swap(),可以作为一个独立的方法或作为线程实例的函数,可以交换线程对象的底层线程句柄:

#include <iostream> 
#include <thread> 
#include <chrono> 

void worker() { 
   std::this_thread::sleep_for(std::chrono::seconds(1)); 
} 

int main() { 
         std::thread t1(worker); 
         std::thread t2(worker); 

         std::cout << "thread 1 id: " << t1.get_id() << "\n"; 
         std::cout << "thread 2 id: " << t2.get_id() << "\n"; 

         std::swap(t1, t2); 

         std::cout << "Swapping threads..." << "\n"; 

         std::cout << "thread 1 id: " << t1.get_id() << "\n"; 
         std::cout << "thread 2 id: " << t2.get_id() << "\n"; 

         t1.swap(t2); 

         std::cout << "Swapping threads..." << "\n"; 

         std::cout << "thread 1 id: " << t1.get_id() << "\n"; 
         std::cout << "thread 2 id: " << t2.get_id() << "\n"; 

         t1.join(); 
         t2.join(); 
} 

此代码的可能输出如下:

thread 1 id: 2
thread 2 id: 3
Swapping threads...
thread 1 id: 3
thread 2 id: 2
Swapping threads...
thread 1 id: 2
thread 2 id: 3

这样做的效果是,每个线程的状态与另一个线程的状态交换,实质上是交换它们的身份。

互斥锁

<mutex>头文件包含多种类型的互斥锁。互斥锁类型是最常用的类型,提供基本的锁定/解锁功能,没有任何进一步的复杂性。

基本用法

在其核心,互斥锁的目标是排除同时访问的可能性,以防止数据损坏,并防止由于使用非线程安全例程而导致崩溃。

需要使用互斥锁的一个例子如下所示:

#include <iostream> 
#include <thread> 

void worker(int i) { 
         std::cout << "Outputting this from thread number: " << i << "\n"; 
} 

int main() { 
         std::thread t1(worker, 1);
         std::thread t2(worker, 2); 

         t1.join(); 
   t2.join(); 

   return 0; 
} 

如果按原样运行上述代码,会注意到两个线程的文本输出会混在一起,而不是依次输出。原因是标准输出(无论是 C 还是 C++风格)都不是线程安全的。虽然应用程序不会崩溃,但输出会混乱。

这个问题的解决方法很简单,如下所示:

#include <iostream> 
#include <thread> 
#include <mutex> 

std::mutex globalMutex; 

void worker(int i) { 
   globalMutex.lock(); 
         std::cout << "Outputting this from thread number: " << i << "\n"; 
   globalMutex.unlock(); 
} 

int main() { 
         std::thread t1(worker, 1);
         std::thread t2(worker, 2); 

         t1.join(); 
   t2.join(); 

   return 0; 
} 

在这种情况下,每个线程首先需要访问mutex对象。由于只有一个线程可以访问mutex对象,另一个线程将等待第一个线程完成对标准输出的写入,两个字符串将按预期依次出现。

非阻塞锁定

可能不希望线程阻塞并等待mutex对象变为可用:例如,当一个只想知道另一个线程是否已经处理请求,并且没有必要等待它完成时。

为此,互斥带有try_lock()函数,它正是这样做的。

在下面的示例中,我们可以看到两个线程尝试增加相同的计数器,但一个在无法立即访问共享计数器时增加自己的计数器:

#include <chrono> 
#include <mutex> 
#include <thread> 
#include <iostream> 

std::chrono::milliseconds interval(50); 

std::mutex mutex; 
int shared_counter = 0;
int exclusive_counter = 0; 

void worker0() { 
   std::this_thread::sleep_for(interval);

         while (true) { 
               if (mutex.try_lock()) { 
                     std::cout << "Shared (" << job_shared << ")\n"; 
                     mutex.unlock(); 
                     return; 
               } 
         else { 
                     ++exclusive_counter; 
                           std::cout << "Exclusive (" << exclusive_counter << ")\n"; 
                           std::this_thread::sleep_for(interval); 
               } 
         } 
} 

void worker1() { 
   mutex.lock(); 
         std::this_thread::sleep_for(10 * interval); 
         ++shared_counter; 
         mutex.unlock(); 
} 

int main() { 
         std::thread t1(worker0); 
         std::thread t2(worker1); 

         t1.join(); 
         t2.join(); 
}

在前面的示例中,两个线程运行不同的worker函数,但它们都有一个共同点,即它们都在一段时间内休眠,并在醒来时尝试获取共享计数器的互斥。如果成功,它们将增加计数器,但只有第一个 worker 会输出这个事实。

第一个 worker 还记录了当它没有获得共享计数器时,但只增加了它的独占计数器。结果输出可能看起来像这样:

Exclusive (1)
Exclusive (2)
Exclusive (3)
Shared (1)
Exclusive (4)

定时互斥

定时互斥是常规互斥类型,但具有一些额外的函数,可以控制在尝试获取锁期间的时间段,即try_lock_fortry_lock_until

前者在指定的时间段(std::chrono对象)内尝试获取锁,然后返回结果(true 或 false)。后者将等待直到将来的特定时间点,然后返回结果。

这些函数的使用主要在于提供阻塞(lock)和非阻塞(try_lock)方法之间的中间路径。一个可能希望使用单个线程等待一定数量的任务,而不知道何时任务将变为可用,或者任务可能在某个时间点过期,此时等待它就没有意义了。

锁保护

锁保护是一个简单的互斥包装器,它处理在mutex对象上获取锁以及在锁保护器超出范围时释放锁。这是一个有用的机制,可以确保不会忘记释放互斥锁,并且在必须在多个位置释放相同的互斥时,有助于减少代码的混乱。

例如,重构大型 if/else 块可以减少需要释放互斥锁的实例,但最好只是使用这个锁保护包装器,不必担心这些细节:

#include <thread> 
#include <mutex> 
#include <iostream> 

int counter = 0; 
std::mutex counter_mutex; 

void worker() { 
         std::lock_guard<std::mutex> lock(counter_mutex); 
   if (counter == 1) { counter += 10; } 
   else if (counter >= 10) { counter += 15; } 
   else if (counter >= 50) { return; } 
         else { ++counter; } 

   std::cout << std::this_thread::get_id() << ": " << counter << '\n'; 
} 

int main() { 
    std::cout << __func__ << ": " << counter << '\n'; 

    std::thread t1(worker); 
    std::thread t2(worker); 

    t1.join(); 
    t2.join(); 

    std::cout << __func__ << ": " << counter << '\n'; 
} 

在前面的示例中,我们看到一个小的 if/else 块,其中一个条件导致worker函数立即返回。没有锁保护,我们必须确保在返回函数之前在这种情况下也解锁互斥。

然而,使用锁保护,我们不必担心这些细节,这使我们能够专注于业务逻辑,而不是担心互斥管理。

唯一锁

唯一锁是一个通用的互斥包装器。它类似于定时互斥,但具有附加功能,主要是所有权的概念。与其他锁类型不同,唯一锁不一定拥有它包装的互斥,如果有的话。互斥可以在唯一锁实例之间传输,同时使用swap()函数传输所述互斥的所有权。

唯一锁实例是否拥有其互斥,并且是否已锁定,是在创建锁时首先确定的,可以从其构造函数中看到。例如:

std::mutex m1, m2, m3; 
std::unique_lock<std::mutex> lock1(m1, std::defer_lock); 
std::unique_lock<std::mutex> lock2(m2, std::try_lock); 
std::unique_lock<std::mutex> lock3(m3, std::adopt_lock); 

最后一个代码中的第一个构造函数不锁定分配的互斥(延迟)。第二个尝试使用try_lock()锁定互斥。最后,第三个构造函数假定它已经拥有提供的互斥。

除此之外,其他构造函数允许定时互斥功能。也就是说,它会等待一段时间直到达到一个时间点,或者直到锁被获取。

最后,通过使用release()函数来断开锁与互斥锁之间的关联,并返回一个指向mutex对象的指针。然后,调用者负责释放互斥锁上的任何剩余锁,并进一步处理它。

这种类型的锁通常不会单独使用,因为它非常通用。大多数其他类型的互斥锁和锁都要简单得多,并且可能在 99%的情况下满足所有需求。因此,独特锁的复杂性既是一种好处,也是一种风险。

然而,它通常被 C++11 线程 API 的其他部分使用,例如条件变量,我们稍后将看到。

独特锁可能有用的一个领域是作为作用域锁,允许在不依赖 C++17 标准中的本机作用域锁的情况下使用作用域锁。请参阅以下示例:

#include <mutex>
std::mutex my_mutex
int count = 0;
int function() {
         std::unique_lock<mutex> lock(my_mutex);
   count++;
}  

当我们进入函数时,我们使用全局互斥锁实例创建一个新的 unique_lock。在这一点上,互斥锁被锁定,之后我们可以执行任何关键操作。

当函数作用域结束时,unique_lock 的析构函数被调用,这将导致互斥锁再次解锁。

作用域锁

作为 2017 年标准首次引入的,作用域锁是一个互斥锁包装器,它获取对(锁定)提供的互斥锁的访问,并确保在作用域锁超出作用域时解锁它。它与锁卫不同之处在于它是不是一个,而是多个互斥锁的包装器。

当一个作用域内处理多个互斥锁时,这可能会很有用。使用作用域锁的一个原因是为了避免意外引入死锁和其他不愉快的复杂情况,例如,一个互斥锁被作用域锁锁定,另一个锁仍在等待,另一个线程实例具有完全相反的情况。

作用域锁的一个特性是它试图避免这种情况,从理论上讲,使得这种类型的锁不会发生死锁。

递归互斥锁

递归互斥锁是互斥锁的另一种子类型。尽管它与常规互斥锁具有完全相同的功能,但它允许最初锁定互斥锁的调用线程重复锁定同一互斥锁。通过这样做,互斥锁在拥有线程解锁它的次数与锁定它的次数相同之前,不会对其他线程可用。

例如,使用递归互斥锁的一个很好的理由是在使用递归函数时。使用常规互斥锁时,需要发明某种进入点,在进入递归函数之前锁定互斥锁。

对于递归互斥锁,递归函数的每次迭代都会再次锁定递归互斥锁,并在完成一次迭代后解锁互斥锁。因此,互斥锁将被锁定和解锁相同次数。

这里可能会出现一个潜在的复杂情况,即递归互斥锁可以被锁定的最大次数在标准中没有定义。当达到实现的限制时,如果尝试锁定它,将抛出std::system_error,或者在使用非阻塞的try_lock函数时返回 false。

递归定时互斥锁

递归定时互斥锁是一个功能上与定时互斥锁和递归互斥锁相结合的混合体,正如其名称所示。因此,它允许使用定时条件函数递归锁定互斥锁。

尽管这增加了确保互斥锁解锁的次数与线程锁定它的次数相同的挑战,但它仍然为更复杂的算法提供了可能性,例如前面提到的任务处理程序。

共享互斥锁

<shared_mutex>头文件首次在 2014 年标准中添加,添加了shared_timed_mutex类。在 2017 年标准中,还添加了shared_mutex类。

自 C++17 以来,共享互斥体头文件就存在了。除了通常的互斥访问之外,这个mutex类还添加了提供互斥体的共享访问的功能。这允许例如,多个线程对资源进行读访问,而写线程仍然能够获得独占访问。这类似于 Pthreads 的读写锁。

添加到这种互斥体类型的函数有:

  • lock_shared()

  • try_lock_shared()

  • unlock_shared()

这种互斥体的共享功能的使用应该是相当容易理解的。理论上,无限数量的读者可以获得对互斥体的读访问,同时确保只有一个线程可以写入资源。

共享定时互斥体

自 C++14 以来就有了这个头文件。它通过以下函数向定时互斥体添加了共享锁定功能:

  • lock_shared()

  • try_lock_shared()

  • try_lock_shared_for()

  • try_lock_shared_until()

  • unlock_shared()

这个类本质上是共享互斥体和定时互斥体的结合,正如其名称所示。这里有趣的是,它在更基本的共享互斥体之前被添加到了标准中。

条件变量

实质上,条件变量提供了一种通过另一个线程控制线程执行的机制。这是通过一个共享变量来实现的,一个线程将等待直到被另一个线程发出信号。这是我们在第四章中查看的调度器实现的一个基本部分,线程同步和通信

对于 C++11 API,条件变量及其相关功能在<condition_variable>头文件中定义。

条件变量的基本用法可以从第四章的调度器代码中总结出来,线程同步和通信

 #include "abstract_request.h"

 #include <condition_variable>
 #include <mutex> 

using namespace std;

 class Worker {
    condition_variable cv;
    mutex mtx;
    unique_lock<mutex> ulock;
    AbstractRequest* request;
    bool running;
    bool ready;
    public:
    Worker() { running = true; ready = false; ulock = unique_lock<mutex>(mtx); }
    void run();
    void stop() { running = false; }
    void setRequest(AbstractRequest* request) { this->request = request; ready = true; }
    void getCondition(condition_variable* &cv);
 }; 

在前面的Worker类声明中的构造函数中,我们可以看到 C++11 API 中条件变量的初始化方式。步骤如下:

  1. 创建一个condition_variablemutex实例。

  2. 将互斥体分配给一个新的unique_lock实例。使用我们在此处用于锁定的构造函数,分配的互斥体也在分配时被锁定。

  3. 条件变量现在可以使用了:

#include <chrono>
using namespace std;
void Worker::run() {
    while (running) {
        if (ready) {
            ready = false;
            request->process();
            request->finish();
        }
        if (Dispatcher::addWorker(this)) {
            while (!ready && running) {
                if (cv.wait_for(ulock, chrono::seconds(1)) == 
                cv_status::timeout) {
                    // We timed out, but we keep waiting unless the 
                    worker is
                    // stopped by the dispatcher.
                }
            }
        }
    }
} 

在这里,我们使用条件变量的wait_for()函数,并传递我们之前创建的唯一锁实例和我们想要等待的时间量。这里我们等待 1 秒。如果我们在这个等待中超时,我们可以自由地重新进入等待(就像这里所做的那样)在一个连续的循环中,或者继续执行。

也可以使用简单的wait()函数执行阻塞等待,或者使用wait_for()等待到特定的时间点。

正如注意到的那样,当我们首次查看这段代码时,这个 worker 的代码使用ready布尔变量的原因是为了检查是否真的是另一个线程发出了条件变量的信号,而不仅仅是一个虚假的唤醒。这是大多数条件变量实现(包括 C++11)都容易受到的不幸复杂性。

由于这些随机唤醒事件的结果,有必要确保我们确实是有意唤醒的。在调度器代码中,这是通过唤醒 worker 线程的线程设置一个Boolean值来完成的。

无论是超时,还是被通知,还是遭受了虚假唤醒,都可以通过cv_status枚举来检查。这个枚举知道这两种可能的情况:

  • timeout

  • no_timeout

信号,或通知,本身非常简单:

void Dispatcher::addRequest(AbstractRequest* request) {
    workersMutex.lock();
    if (!workers.empty()) {
          Worker* worker = workers.front();
          worker->setRequest(request);
          condition_variable* cv;
          worker->getCondition(cv);
          cv->notify_one();
          workers.pop();
          workersMutex.unlock();
    }
    else {
          workersMutex.unlock();
          requestsMutex.lock();
          requests.push(request);
          requestsMutex.unlock();
    }
          } 

Dispatcher类的前面的函数中,我们尝试获取一个可用的 worker 线程实例。如果找到,我们按如下方式获取 worker 线程的条件变量的引用:

void Worker::getCondition(condition_variable* &cv) {
    cv = &(this)->cv;
 } 

设置工作线程上的新请求也会将ready变量的值更改为 true,从而允许工作线程检查它是否确实被允许继续。

最后,条件变量被通知,任何等待它的线程现在可以继续使用notify_one()。这个特定的函数将会通知条件变量中的第一个线程继续。在这里,只有一个线程会被通知,但如果有多个线程等待相同的条件变量,调用notify_all()将允许 FIFO 队列中的所有线程继续。

Condition_variable_any

condition_variable_any类是condition_variable类的一般化。它与后者不同之处在于,它允许使用除unique_lock<mutex>之外的其他互斥机制。唯一的要求是所使用的锁符合BasicLockable要求,这意味着它提供了lock()unlock()函数。

Notify all at thread exit

std::notify_all_at_thread_exit()函数允许(分离的)线程通知其他线程它已经完全完成,并且正在销毁其范围内的所有对象(线程本地)。它通过将提供的锁移动到内部存储,然后发出提供的条件变量的信号来实现。

结果就像锁被解锁并且在条件变量上调用了notify_all()一样。

一个基本(非功能性)示例可以如下所示:

#include <mutex> 
#include <thread> 
#include <condition_variable> 
using namespace std; 

mutex m; 
condition_variable cv;
bool ready = false; 
ThreadLocal result;

void worker() { 
   unique_lock<mutex> ulock(m); 
   result = thread_local_method(); 
         ready = true; 
         std::notify_all_at_thread_exit(cv, std::move(ulock)); 
} 

int main() { 
         thread t(worker); 
         t.detach(); 

         // Do work here. 

         unique_lock<std::mutex> ulock(m); 
         while(!ready) { 
               cv.wait(ulock); 
         } 

         // Process result 
} 

在这里,工作线程执行一个创建线程本地对象的方法。因此,主线程首先等待分离的工作线程完成是至关重要的。如果当主线程完成其任务时后者尚未完成,它将使用全局条件变量进入等待。在工作线程中,在设置ready布尔值后调用std::notify_all_at_thread_exit()

这样做的目的是双重的。在调用函数后,不允许更多的线程等待条件变量。它还允许主线程等待分离的工作线程的结果变得可用。

Future

C++11 线程支持 API 的最后一部分在<future>中定义。它提供了一系列类,这些类实现了更高级的多线程概念,旨在更容易地进行异步处理,而不是实现多线程架构。

在这里,我们必须区分两个概念:future 和 promise。前者是最终结果(未来的产品),将被读取者/消费者使用。后者是写入者/生产者使用的。

一个 future 的基本示例可以如下所示:

#include <iostream>
#include <future>
#include <chrono>

bool is_prime (int x) {
  for (int i = 2; i < x; ++i) if (x%i==0) return false;
  return true;
}

int main () {
  std::future<bool> fut = std::async (is_prime, 444444443);
  std::cout << "Checking, please wait";
  std::chrono::milliseconds span(100);
  while (fut.wait_for(span) == std::future_status::timeout) {               std::cout << '.' << std::flush;
   }

  bool x = fut.get();
  std::cout << "\n444444443 " << (x?"is":"is not") << " prime.\n";
  return 0;
}

这段代码异步调用一个函数,传递一个参数(可能是素数)。然后它进入一个活动循环,同时等待从异步函数调用中收到的 future 完成。它在等待函数上设置了 100 毫秒的超时。

一旦 future 完成(在等待函数上没有返回超时),我们就获得了结果值,这种情况告诉我们,我们提供给函数的值实际上是一个素数。

在本章的async部分,我们将更深入地研究异步函数调用。

Promise

promise允许在线程之间传递状态。例如:

#include <iostream> 
#include <functional>
#include <thread> 
#include <future> 

void print_int (std::future<int>& fut) {
  int x = fut.get();
  std::cout << "value: " << x << '\n';
}

int main () {
  std::promise<int> prom;
  std::future<int> fut = prom.get_future();
  std::thread th1 (print_int, std::ref(fut));
  prom.set_value (10);                            
  th1.join();
  return 0;

在前面的代码中,使用了一个传递给工作线程的promise实例来将一个值传递给另一个线程,这里是一个整数。新线程等待我们从主线程创建的并从 promise 接收的 future 完成。

当我们在 promise 上设置值时,promise 就完成了。这完成了 future 并结束了工作线程。

在这个特定的例子中,我们对future对象进行了阻塞等待,但也可以使用wait_for()wait_until(),分别等待一段时间或特定时间点,就像我们在前面的 future 示例中看到的那样。

共享 future

shared_future就像一个常规的future对象,但可以被复制,这允许多个线程读取其结果。

创建shared_future与创建常规的future类似。

std::promise<void> promise1; 
std::shared_future<void> sFuture(promise1.get_future()); 

最大的区别是常规的future被传递给它的构造函数。

之后,所有可以访问future对象的线程都可以等待它,并获取其值。这也可以用来以类似于条件变量的方式向线程发出信号。

Packaged_task

packaged_task是任何可调用目标(函数、绑定、lambda 或其他函数对象)的包装器。它允许异步执行,并将结果可用于future对象。它类似于std::function,但会自动将其结果传输到future对象。

例如:

#include <iostream> 
#include <future> 
#include <chrono>
#include <thread>

using namespace std; 

int countdown (int from, int to) { 
   for (int i = from; i != to; --i) { 
         cout << i << '\n'; 
         this_thread::sleep_for(chrono::seconds(1)); 
   } 

   cout << "Finished countdown.\n"; 
   return from - to; 
} 

int main () { 
   packaged_task<int(int, int)> task(countdown);
   future<int> result = task.get_future();
   thread t (std::move(task), 10, 0);

   //  Other logic. 

   int value = result.get(); 

   cout << "The countdown lasted for " << value << " seconds.\n"; 

   t.join(); 
   return 0; 
} 

这段代码实现了一个简单的倒计时功能,从 10 倒数到 0。创建任务并获取其future对象的引用后,我们将其与worker函数的参数一起推送到一个线程中。

倒计时工作线程的结果在完成后立即可用。我们可以使用future对象的等待函数,就像对promise一样。

Async

std::async()中可以找到promisepackaged_task的更简单的版本。这是一个简单的函数,它接受一个可调用对象(函数、绑定、lambda 等)以及其任何参数,并返回一个future对象。

以下是async()函数的基本示例:

#include <iostream>
#include <future>

using namespace std; 

bool is_prime (int x) { 
   cout << "Calculating prime...\n"; 
   for (int i = 2; i < x; ++i) { 
         if (x % i == 0) { 
               return false; 
         } 
   } 

   return true; 
} 

int main () { 
   future<bool> pFuture = std::async (is_prime, 343321); 

   cout << "Checking whether 343321 is a prime number.\n"; 

   // Wait for future object to be ready. 

   bool result = pFuture.get(); 
   if (result) {
         cout << "Prime found.\n"; 
   } 
   else { 
         cout << "No prime found.\n"; 
   } 

   return 0; 
} 

上述代码中的worker函数确定提供的整数是否为质数。正如我们所看到的,结果代码比使用packaged_taskpromise要简单得多。

启动策略

除了基本版本的std::async()之外,还有第二个版本,允许将启动策略作为其第一个参数进行指定。这是一个std::launch类型的位掩码值,可能的值有:

* launch::async 
* launch::deferred 

async标志意味着立即创建一个新线程和worker函数的执行上下文。deferred标志意味着这将延迟到在future对象上调用wait()get()时。指定两个标志会导致函数根据当前系统情况自动选择方法。

std::async()版本,如果没有明确指定位掩码值,默认为后者,自动方法。

原子操作

在多线程中,原子操作也非常重要。C++11 STL 提供了一个<atomic>头文件来实现这一点。这个主题在第八章原子操作-与硬件交互中有详细介绍。

总结

在本章中,我们探讨了 C++11 API 中的整个多线程支持,以及 C++14 和 C++17 中添加的功能。

我们通过描述和示例代码看到了如何使用每个功能。现在我们可以使用本机 C++多线程 API 来实现多线程、线程安全的代码,以及使用异步执行功能来加速并并行执行函数。

在下一章中,我们将看一下多线程代码实现中不可避免的下一步:调试和验证生成应用程序的结果。

第六章:调试多线程代码

理想情况下,自己的代码第一次就能正常工作,并且不包含等待崩溃应用程序、损坏数据或引起其他问题的隐藏错误。当然,这是不可能的。因此,开发了一些工具,使得检查和调试多线程应用程序变得容易。

在本章中,我们将研究其中一些内容,包括常规调试器以及 Valgrind 套件的一些工具,特别是 Helgrind 和 DRD。我们还将研究如何对多线程应用程序进行性能分析,以找出设计中的热点和潜在问题。

本章涵盖的主题包括以下内容:

  • 介绍 Valgrind 工具套件

  • 使用 Helgrind 和 DRD 工具

  • 解释 Helgrind 和 DRD 分析结果

  • 分析应用程序的性能,并分析结果

何时开始调试

理想情况下,每次达到某个里程碑时,无论是针对单个模块、多个模块还是整个应用程序,都应该测试和验证自己的代码。重要的是要确定自己的假设是否与最终功能相匹配。

特别是在多线程代码中,巧合的因素很大,特定的错误状态不能保证在每次运行应用程序时都会达到。实现不正确的多线程应用程序的迹象可能导致似乎随机崩溃的症状。

当应用程序崩溃时,留下核心转储时,可能会得到的第一个提示是,有些地方不正确。这是一个包含应用程序在崩溃时的内存内容的文件,包括堆栈。

这个核心转储可以以几乎与运行进程调试器相同的方式使用。它特别有用的是检查我们崩溃的代码位置以及线程。我们也可以通过这种方式检查内存内容。

处理多线程问题时最好的指标之一是应用程序从未在相同位置崩溃(不同的堆栈跟踪),或者总是在执行互斥操作的地方崩溃,例如操作全局数据结构。

首先,我们将首先更深入地研究使用调试器进行诊断和调试,然后再深入 Valgrind 工具套件。

谦逊的调试器

开发人员可能会有的所有问题中,“为什么我的应用程序刚刚崩溃?”这个问题可能是最重要的之一。这也是最容易用调试器回答的问题之一。无论是实时调试进程还是分析崩溃进程的核心转储,调试器都可以(希望)生成回溯,也称为堆栈跟踪。这个跟踪包含自应用程序启动以来调用的所有函数的时间顺序列表,就像它们在堆栈上找到的那样(有关堆栈工作原理的详细信息,请参见第二章,处理器和操作系统上的多线程实现)。

这个回溯的最后几个条目将告诉我们代码的哪个部分出了问题。如果调试信息被编译到二进制文件中,或者提供给调试器,我们还可以看到该行的代码以及变量的名称。

更好的是,因为我们正在查看堆栈帧,我们还可以检查该堆栈帧中的变量。这意味着传递给函数的参数以及任何局部变量及其值。

为了使调试信息(符号)可用,必须使用适当的编译器标志编译源代码。对于 GCC,可以选择一系列调试信息级别和类型。最常见的是使用-g标志,后面附加一个指定调试级别的整数,如下所示:

  • -g0:不生成调试信息(否定-g

  • -g1:函数描述和外部变量的最小信息

  • -g3:包括宏定义在内的所有信息

此标志指示 GCC 以 OS 的本机格式生成调试信息。也可以使用不同的标志以特定格式生成调试信息;但是,这对于 GCC 的调试器(GDB)以及 Valgrind 工具并不是必需的。

GDB 和 Valgrind 都将使用这些调试信息。虽然在没有调试信息的情况下使用两者是技术上可能的,但最好留给真正绝望的时候去练习。

GDB

用于基于 C 和 C++的代码的最常用的调试器之一是 GNU 调试器,简称 GDB。在下面的示例中,我们将使用这个调试器,因为它被广泛使用并且免费提供。最初于 1986 年编写,现在与各种编程语言一起使用,并且已成为个人和专业使用中最常用的调试器。

GDB 的最基本接口是命令行 shell,但它可以与图形前端一起使用,这些前端还包括一些 IDE,如 Qt Creator、Dev-C++和 Code::Blocks。这些前端和 IDE 可以使管理断点、设置监视变量和执行其他常见操作变得更加容易和直观。但是,它们的使用并不是必需的。

在 Linux 和 BSD 发行版上,gdb 可以轻松从软件包中安装,就像在 Windows 上使用 MSYS2 和类似的类 UNIX 环境一样。对于 OS X/MacOS,可能需要使用 Homebrew 等第三方软件包管理器安装 gdb。

由于在 MacOS 上通常不对 gdb 进行代码签名,因此它无法获得正常操作所需的系统级访问权限。在这里,可以以 root 身份运行 gdb(不建议),或者按照与您的 MacOS 版本相关的教程进行操作。

调试多线程代码

如前所述,有两种使用调试器的方法,一种是从调试器内启动应用程序(或附加到运行中的进程),另一种是加载核心转储文件。在调试会话中,可以中断运行进程(使用Ctrl+C发送SIGINT信号),或者加载加载的核心转储的调试符号。之后,我们可以检查此帧中的活动线程:

Thread 1 received signal SIGINT, Interrupt.
0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylib
(gdb) info threads
Id   Target Id         Frame 
* 1    Thread 0x1703 of process 72492 0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylib
3    Thread 0x1a03 of process 72492 0x00007fff8a406efa in kevent_qos () from /usr/lib/system/libsystem_kernel.dylib
10   Thread 0x2063 of process 72492 0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylibs
14   Thread 0x1e0f of process 72492 0x00007fff8a405d3e in __pselect () from /usr/lib/system/libsystem_kernel.dylib
(gdb) c
Continuing.

在前面的代码中,我们可以看到在向应用程序发送SIGINT信号后(在 OS X 上运行的基于 Qt 的应用程序),我们请求此时存在的所有线程的列表,以及它们的线程编号、ID 和它们当前正在执行的函数。根据后者的信息,这也清楚地显示了哪些线程可能正在等待,这在像这样的图形用户界面应用程序中经常发生。在这里,我们还看到当前活动的线程在应用程序中由其编号前的星号标记(线程 1)。

我们还可以使用thread <ID>命令随意在线程之间切换,并在线程的堆栈帧之间移动updown。这使我们能够检查每个线程的每个方面。

当完整的调试信息可用时,通常还会看到线程正在执行的确切代码行。这意味着在应用程序的开发阶段,有尽可能多的调试信息是有意义的,以使调试变得更加容易。

断点

对于我们在第四章中查看的调度器代码,线程同步和通信,我们可以设置断点以允许我们检查活动线程:

$ gdb dispatcher_demo.exe 
GNU gdb (GDB) 7.9 
Copyright (C) 2015 Free Software Foundation, Inc. 
Reading symbols from dispatcher_demo.exe...done. 
(gdb) break main.cpp:67 
Breakpoint 1 at 0x4017af: file main.cpp, line 67\. 
(gdb) run 
Starting program: dispatcher_demo.exe 
[New Thread 10264.0x2a90] 
[New Thread 10264.0x2bac] 
[New Thread 10264.0x2914] 
[New Thread 10264.0x1b80] 
[New Thread 10264.0x213c] 
[New Thread 10264.0x2228] 
[New Thread 10264.0x2338] 
[New Thread 10264.0x270c] 
[New Thread 10264.0x14ac] 
[New Thread 10264.0x24f8] 
[New Thread 10264.0x1a90] 

正如我们在上面的命令行输出中所看到的,我们使用应用程序的名称作为参数启动 GDB,这里是在 Windows 下的 Bash shell 中。之后,我们可以在这里设置一个断点,使用源文件的文件名和我们希望在其后中断的行号,然后运行应用程序。接着是由 GDB 报告的由调度程序创建的新线程的列表。

接下来,我们等待直到断点被触发:

Breakpoint 1, main () at main.cpp:67 
67              this_thread::sleep_for(chrono::seconds(5)); 
(gdb) info threads 
Id   Target Id         Frame 
11   Thread 10264.0x1a90 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
10   Thread 10264.0x24f8 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
9    Thread 10264.0x14ac 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
8    Thread 10264.0x270c 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
7    Thread 10264.0x2338 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
6    Thread 10264.0x2228 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
5    Thread 10264.0x213c 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
4    Thread 10264.0x1b80 0x0000000064942eaf in ?? () from /mingw64/bin/libwinpthread-1.dll 
3    Thread 10264.0x2914 0x00000000775c2385 in ntdll!LdrUnloadDll () from /c/Windows/SYSTEM32/ntdll.dll 
2    Thread 10264.0x2bac 0x00000000775c2385 in ntdll!LdrUnloadDll () from /c/Windows/SYSTEM32/ntdll.dll 
* 1    Thread 10264.0x2a90 main () at main.cpp:67 
(gdb) bt 
#0  main () at main.cpp:67 
(gdb) c 
Continuing. 

达到断点后,info threads命令列出了活动线程。在这里,我们可以清楚地看到条件变量的使用,其中一个线程在ntdll!ZwWaitForMultipleObjects()中等待。正如第三章中所介绍的,C++多线程 API,这是 Windows 使用其本地多线程 API 实现条件变量的一部分。

当我们创建一个回溯(bt命令)时,我们可以看到线程 1(当前线程)的当前堆栈只有一个帧,只有主方法,因为我们从这个起始点的这一行没有调用其他函数。

回溯

在正常的应用程序执行过程中,比如我们之前看过的 GUI 应用程序,向应用程序发送SIGINT也可以跟随着创建回溯的命令,就像这样:

Thread 1 received signal SIGINT, Interrupt.
0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylib
(gdb) bt
#0  0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylib
#1  0x00007fff8a3ff3b3 in mach_msg () from /usr/lib/system/libsystem_kernel.dylib
#2  0x00007fff99f37124 in __CFRunLoopServiceMachPort () from /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
#3  0x00007fff99f365ec in __CFRunLoopRun () from /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
#4  0x00007fff99f35e38 in CFRunLoopRunSpecific () from /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
#5  0x00007fff97b73935 in RunCurrentEventLoopInMode ()
from /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/HIToolbox
#6  0x00007fff97b7376f in ReceiveNextEventCommon ()
from /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/HIToolbox
#7  0x00007fff97b735af in _BlockUntilNextEventMatchingListInModeWithFilter ()
from /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/HIToolbox
#8  0x00007fff9ed3cdf6 in _DPSNextEvent () from /System/Library/Frameworks/AppKit.framework/Versions/C/AppKit
#9  0x00007fff9ed3c226 in -[NSApplication _nextEventMatchingEventMask:untilDate:inMode:dequeue:] ()
from /System/Library/Frameworks/AppKit.framework/Versions/C/AppKit
#10 0x00007fff9ed30d80 in -[NSApplication run] () from /System/Library/Frameworks/AppKit.framework/Versions/C/AppKit
#11 0x0000000102a25143 in qt_plugin_instance () from /usr/local/Cellar/qt/5.8.0_1/plugins/platforms/libqcocoa.dylib
#12 0x0000000100cd3811 in QEventLoop::exec(QFlags<QEventLoop::ProcessEventsFlag>) () from /usr/local/opt/qt5/lib/QtCore.framework/Versions/5/QtCore
#13 0x0000000100cd80a7 in QCoreApplication::exec() () from /usr/local/opt/qt5/lib/QtCore.framework/Versions/5/QtCore
#14 0x0000000100003956 in main (argc=<optimized out>, argv=<optimized out>) at main.cpp:10
(gdb) c
Continuing.

在上述代码中,我们可以看到线程 ID 1 的执行,从创建开始,通过入口点(main)。每个后续的函数调用都被添加到堆栈中。当一个函数结束时,它就会从堆栈中移除。这既是一个好处,也是一个缺点。虽然它确实保持了回溯的干净整洁,但也意味着在最后一个函数调用之前发生的历史已经不复存在。

如果我们使用核心转储文件创建一个回溯,没有这些历史信息可能会非常恼人,并且可能会让人在试图缩小崩溃原因的范围时陷入困境。这意味着需要一定水平的经验才能成功调试。

在应用程序崩溃的情况下,调试器会将我们带到遭受崩溃的线程。通常,这是有问题代码的线程,但也可能是真正的错误在于另一个线程执行的代码,甚至是变量的不安全使用。如果一个线程改变了另一个线程当前正在读取的信息,后者可能会得到垃圾数据。这可能导致崩溃,甚至更糟糕的是,在应用程序的后续过程中出现损坏。

最坏的情况是堆栈被覆盖,例如被野指针。在这种情况下,堆栈上的缓冲区或类似的东西被写入超出其限制,从而通过填充新数据来擦除堆栈的部分。这就是缓冲区溢出,可能导致应用程序崩溃,或者(恶意)利用应用程序。

动态分析工具

尽管调试器的价值难以忽视,但有时需要不同类型的工具来回答关于内存使用、泄漏以及诊断或预防线程问题等问题。这就是 Valgrind 动态分析工具套件中的工具可以提供极大帮助的地方。作为构建动态分析工具的框架,Valgrind 发行版目前包含以下我们感兴趣的工具:

  • Memcheck

  • Helgrind

  • DRD

Memcheck 是一种内存错误检测器,它允许我们发现内存泄漏、非法读写,以及分配、释放等与内存相关的问题。

Helgrind 和 DRD 都是线程错误检测器。这基本上意味着它们将尝试检测任何多线程问题,如数据竞争和互斥锁的不正确使用。它们的区别在于 Helgrind 可以检测锁定顺序的违规,而 DRD 支持分离的线程,同时使用的内存比 Helgrind 少。

限制

动态分析工具的一个主要限制是它们需要与主机操作系统紧密集成。这是 Valgrind 专注于 POSIX 线程的主要原因,目前不适用于 Windows。

Valgrind 网站(valgrind.org/info/platforms.html)描述了该问题如下:

“Windows 不在考虑范围之内,因为将其移植到 Windows 将需要很多更改,几乎可以成为一个单独的项目。(但是,Valgrind + Wine 可以通过一些努力来实现。)此外,非开源操作系统很难处理;能够看到操作系统和相关(libc)源代码使事情变得更容易。但是,Valgrind 在与 Wine 一起使用时非常有用,这意味着可以通过一些努力在 Valgrind 下运行 Windows 程序。”

基本上,这意味着 Windows 应用程序可以在 Linux 下使用 Valgrind 进行调试,但是使用 Windows 作为操作系统不会很快发生。

Valgrind 在 OS X/macOS 上可以工作,从 OS X 10.8(Mountain Lion)开始。由于苹果公司所做的更改,对最新版本的 macOS 的支持可能会有些不完整。与 Valgrind 的 Linux 版本一样,最好始终使用最新版本的 Valgrind。与 gdb 一样,使用发行版的软件包管理器,或者在 MacOS 上使用 Homebrew 等第三方软件包管理器。

替代方案

在 Windows 和其他平台上替代 Valgrind 工具的选择包括以下表中列出的工具:

名称 类型 平台 许可证
Dr. Memory 内存检查器 所有主要平台 开源
gperftools(Google) 堆,CPU 和调用分析器 Linux(x86) 开源
Visual Leak Detector 内存检查器 Windows(Visual Studio) 开源
Intel Inspector 内存和线程调试器 Windows,Linux 专有
PurifyPlus 内存,性能 Windows,Linux 专有
Parasoft Insure++ 内存和线程调试器 Windows,Solaris,Linux,AIX 专有

Memcheck

当没有在其可执行文件的参数中指定其他工具时,Memcheck 是默认的 Valgrind 工具。 Memcheck 本身是一个内存错误检测器,能够检测以下类型的问题:

  • 访问已分配边界之外的内存,堆栈溢出以及访问先前释放的内存块

  • 使用未定义值,即未初始化的变量

  • 错误释放堆内存,包括重复释放块

  • 不匹配使用 C 和 C++风格的内存分配,以及数组分配器和解除分配器(new[]delete[]

  • 在诸如memcpy之类的函数中重叠源和目标指针

  • 将无效值(例如负值)作为malloc或类似函数的大小参数传递

  • 内存泄漏;即,没有任何有效引用的堆块

使用调试器或简单的任务管理器,几乎不可能检测到前面列表中给出的问题。 Memcheck 的价值在于能够在开发的早期检测和修复问题,否则可能会导致数据损坏和神秘崩溃。

基本用法

使用 Memcheck 非常容易。如果我们使用第四章中创建的演示应用程序,线程同步和通信,我们知道通常我们使用以下方式启动它:

$ ./dispatcher_demo

要使用默认的 Memcheck 工具运行 Valgrind,并将生成的输出记录到日志文件中,我们将如下启动它:

$ valgrind --log-file=dispatcher.log --read-var-info=yes --leak-check=full ./dispatcher_demo

通过上述命令,我们将 Memcheck 的输出记录到一个名为dispatcher.log的文件中,并且还启用了对内存泄漏的全面检查,包括详细报告这些泄漏发生的位置,使用二进制文件中可用的调试信息。通过读取变量信息(--read-var-info=yes),我们可以获得更详细的关于内存泄漏发生位置的信息。

不能将日志记录到文件中,但除非是一个非常简单的应用程序,否则 Valgrind 生成的输出很可能太多,可能无法适应终端缓冲区。将输出作为文件允许我们稍后使用它作为参考,并使用比终端通常提供的更高级的工具进行搜索。

运行完这个命令后,我们可以按照以下方式检查生成的日志文件的内容:

==5764== Memcheck, a memory error detector
==5764== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==5764== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==5764== Command: ./dispatcher_demo
==5764== Parent PID: 2838
==5764==
==5764==
==5764== HEAP SUMMARY:
==5764==     in use at exit: 75,184 bytes in 71 blocks
==5764==   total heap usage: 260 allocs, 189 frees, 88,678 bytes allocated
==5764==
==5764== 80 bytes in 10 blocks are definitely lost in loss record 1 of 5
==5764==    at 0x4C2E0EF: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==5764==    by 0x402EFD: Dispatcher::init(int) (dispatcher.cpp:40)
==5764==    by 0x409300: main (main.cpp:51)
==5764==
==5764== 960 bytes in 40 blocks are definitely lost in loss record 3 of 5
==5764==    at 0x4C2E0EF: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==5764==    by 0x409338: main (main.cpp:60)
==5764==
==5764== 1,440 (1,200 direct, 240 indirect) bytes in 10 blocks are definitely lost in loss record 4 of 5
==5764==    at 0x4C2E0EF: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==5764==    by 0x402EBB: Dispatcher::init(int) (dispatcher.cpp:38)
==5764==    by 0x409300: main (main.cpp:51)
==5764==
==5764== LEAK SUMMARY:
==5764==    definitely lost: 2,240 bytes in 60 blocks
==5764==    indirectly lost: 240 bytes in 10 blocks
==5764==      possibly lost: 0 bytes in 0 blocks
==5764==    still reachable: 72,704 bytes in 1 blocks
==5764==         suppressed: 0 bytes in 0 blocks
==5764== Reachable blocks (those to which a pointer was found) are not shown.
==5764== To see them, rerun with: --leak-check=full --show-leak-kinds=all
==5764==
==5764== For counts of detected and suppressed errors, rerun with: -v
==5764== ERROR SUMMARY: 3 errors from 3 contexts (suppressed: 0 from 0) 

在这里,我们可以看到我们总共有三个内存泄漏。其中两个是在第 38 行和第 40 行的dispatcher类中分配的:

w = new Worker; 

另一个问题是:

t = new thread(&Worker::run, w); 

我们还看到了在main.cpp的第 60 行分配的内存泄漏:

rq = new Request(); 

虽然这些分配本身没有问题,但是如果我们在应用程序生命周期中跟踪它们,我们会注意到我们从未在这些对象上调用delete。如果我们要修复这些内存泄漏,我们需要在完成后删除这些Request实例,并在dispatcher类的析构函数中清理Workerthread实例。

由于在这个演示应用程序中,整个应用程序在运行结束时由操作系统终止并清理,所以这并不是一个真正的问题。对于一个应用程序,在这个应用程序中,同一个调度程序以一种不断生成和添加新请求的方式被使用,同时可能还动态地扩展工作线程的数量,这将是一个真正的问题。在这种情况下,必须小心解决这些内存泄漏问题。

错误类型

Memcheck 可以检测到各种与内存相关的问题。以下部分总结了这些错误及其含义。

非法读取/非法写入错误

这些错误通常以以下格式报告:

Invalid read of size <bytes>
at 0x<memory address>: (location)
by 0x<memory address>: (location)
by 0x<memory address>: (location)
Address 0x<memory address> <error description>

在前面错误消息的第一行将告诉我们是无效的读取还是写入访问。接下来的几行将是一个回溯,详细说明了发生无效读取或写入的位置(可能还包括源文件中的行),以及调用该代码的位置。

最后一行将详细说明发生的非法访问类型,比如读取已经释放的内存块。

这种类型的错误表明写入或读取一个不应该访问的内存部分。这可能是因为访问了一个野指针(即引用一个随机的内存地址),或者是由于代码中的早期问题导致了错误的内存地址计算,或者是没有尊重内存边界,读取了数组或类似结构的边界之外。

通常情况下,当报告这种类型的错误时,应该非常重视,因为它表明存在一个基本问题,不仅可能导致数据损坏和崩溃,还可能导致其他人可以利用的错误。

使用未初始化的值

简而言之,这是一个变量的值在没有被赋值的情况下被使用的问题。在这一点上,很可能这些内容只是刚刚分配的 RAM 部分中的任意字节。因此,每当使用或访问这些内容时,可能会导致不可预测的行为。

当遇到时,Memcheck 会抛出类似于这样的错误:

$ valgrind --read-var-info=yes --leak-check=full ./unval
==6822== Memcheck, a memory error detector
==6822== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==6822== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==6822== Command: ./unval
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E87B83: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Use of uninitialised value of size 8
==6822==    at 0x4E8476B: _itoa_word (_itoa.c:179)
==6822==    by 0x4E8812C: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E84775: _itoa_word (_itoa.c:179)
==6822==    by 0x4E8812C: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E881AF: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E87C59: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E8841A: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E87CAB: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E87CE2: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== 
==6822== HEAP SUMMARY:
==6822==     in use at exit: 0 bytes in 0 blocks
==6822==   total heap usage: 1 allocs, 1 frees, 1,024 bytes allocated
==6822== 
==6822== All heap blocks were freed -- no leaks are possible
==6822== 
==6822== For counts of detected and suppressed errors, rerun with: -v
==6822== Use --track-origins=yes to see where uninitialised values come from
==6822== ERROR SUMMARY: 8 errors from 8 contexts (suppressed: 0 from 0)

这一系列特定的错误是由以下一小段代码引起的:

#include <cstring>
 #include <cstdio>

 int main() {
    int x;  
    printf ("x = %d\n", x); 
    return 0;
 } 

正如我们在前面的代码中所看到的,我们从未初始化我们的变量,这将被设置为任意的随机值。如果幸运的话,它将被设置为零,或者一个同样(希望如此)无害的值。这段代码展示了我们的任何未初始化的变量如何进入库代码。

未初始化变量的使用是否有害很难说,这在很大程度上取决于变量的类型和受影响的代码。然而,简单地分配一个安全的默认值要比追踪和调试可能由未初始化变量(随机)引起的神秘问题要容易得多。

要了解未初始化变量的来源,可以向 Memcheck 传递-track-origins=yes标志。这将告诉它为每个变量保留更多信息,这将使追踪此类问题变得更容易。

未初始化或不可寻址的系统调用值

每当调用一个函数时,可能会传递未初始化的值作为参数,甚至是指向不可寻址的缓冲区的指针。在任何一种情况下,Memcheck 都会记录这一点:

$ valgrind --read-var-info=yes --leak-check=full ./unsyscall
==6848== Memcheck, a memory error detector
==6848== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==6848== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==6848== Command: ./unsyscall
==6848== 
==6848== Syscall param write(buf) points to uninitialised byte(s)
==6848==    at 0x4F306E0: __write_nocancel (syscall-template.S:84)
==6848==    by 0x4005EF: main (unsyscall.cpp:7)
==6848==  Address 0x5203040 is 0 bytes inside a block of size 10 alloc'd
==6848==    at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==6848==    by 0x4005C7: main (unsyscall.cpp:5)
==6848== 
==6848== Syscall param exit_group(status) contains uninitialised byte(s)
==6848==    at 0x4F05B98: _Exit (_exit.c:31)
==6848==    by 0x4E73FAA: __run_exit_handlers (exit.c:97)
==6848==    by 0x4E74044: exit (exit.c:104)
==6848==    by 0x4005FC: main (unsyscall.cpp:8)
==6848== 
==6848== 
==6848== HEAP SUMMARY:
==6848==     in use at exit: 14 bytes in 2 blocks
==6848==   total heap usage: 2 allocs, 0 frees, 14 bytes allocated
==6848== 
==6848== LEAK SUMMARY:
==6848==    definitely lost: 0 bytes in 0 blocks
==6848==    indirectly lost: 0 bytes in 0 blocks
==6848==      possibly lost: 0 bytes in 0 blocks
==6848==    still reachable: 14 bytes in 2 blocks
==6848==         suppressed: 0 bytes in 0 blocks
==6848== Reachable blocks (those to which a pointer was found) are not shown.
==6848== To see them, rerun with: --leak-check=full --show-leak-kinds=all
==6848== 
==6848== For counts of detected and suppressed errors, rerun with: -v
==6848== Use --track-origins=yes to see where uninitialised values come from
==6848== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)

前面的日志是由这段代码生成的:

#include <cstdlib>
 #include <unistd.h> 

 int main() {  
    char* arr  = (char*) malloc(10);  
    int*  arr2 = (int*) malloc(sizeof(int));  
    write(1, arr, 10 ); 
    exit(arr2[0]);
 } 

与前一节详细介绍的未初始化值的一般用法类似,传递未初始化或其他可疑的参数,至少是有风险的,最坏的情况下可能导致崩溃、数据损坏或更糟的情况。

非法释放

非法的释放或删除通常是指尝试重复调用free()delete()来释放已经释放的内存块。虽然不一定有害,但这表明设计不良,绝对需要修复。

当尝试使用不指向该内存块开头的指针释放内存块时,也可能会发生这种情况。这是为什么我们永远不应该对从malloc()new()调用获得的原始指针进行指针算术运算,而是使用副本的主要原因之一。

不匹配的释放

内存块的分配和释放应该始终使用匹配的函数。这意味着当我们使用 C 风格函数分配时,我们使用相同 API 的匹配函数释放。对于 C++风格的分配和释放也是如此。

简而言之,这意味着以下内容:

  • 如果我们使用malloccallocvallocreallocmemalign分配,我们使用free释放

  • 如果我们使用 new 分配,我们使用delete释放

  • 如果我们使用new[]分配,我们使用delete[]释放

混淆这些不一定会导致问题,但这样做是未定义的行为。后一种类型的分配和释放是特定于数组的。对使用new[]分配的数组不使用delete[]可能会导致内存泄漏,甚至更糟。

重叠的源和目的地

这种类型的错误表明传递给源和目的地内存块的指针重叠(基于预期大小)。这种错误的结果通常是一种形式的损坏或系统崩溃。

可疑的参数值

对于内存分配函数,Memcheck 会验证传递给它们的参数是否真的有意义。其中一个例子是传递负大小,或者它将远远超出合理的分配大小:例如,请求分配一百万兆字节的内存。这些值很可能是代码中早期错误计算的结果。

Memcheck 会像 Memcheck 手册中的这个例子一样报告这个错误:

==32233== Argument 'size' of function malloc has a fishy (possibly negative) value: -3
==32233==    at 0x4C2CFA7: malloc (vg_replace_malloc.c:298)
==32233==    by 0x400555: foo (fishy.c:15)
==32233==    by 0x400583: main (fishy.c:23)

在这里,尝试将值-3 传递给malloc,这显然没有多大意义。由于这显然是一个荒谬的操作,这表明代码中存在严重的错误。

内存泄漏检测

对于 Memcheck 报告的内存泄漏,最重要的是,许多报告的泄漏实际上可能并不是泄漏。这反映在 Memcheck 报告它发现的任何潜在问题的方式如下:

  • 绝对丢失

  • 间接丢失

  • 可能丢失

在三种可能的报告类型中,绝对丢失类型是唯一一种绝对确定所涉及的内存块不再可达的情况,没有剩余的指针或引用,这使得应用程序永远无法释放内存。

间接丢失类型的情况下,我们并没有丢失这些内存块本身的指针,而是丢失了指向这些块的结构的指针。例如,当我们直接丢失对数据结构的根节点(如红/黑树或二叉树)的访问权限时,就会发生这种情况。结果,我们也失去了访问任何子节点的能力。

最后,可能丢失是一个综合类型,Memcheck 并不完全确定是否仍然有对内存块的引用。这可能发生在存在内部指针的情况下,比如特定类型的数组分配的情况。它也可能通过多重继承的使用发生,其中 C++对象使用自引用。

如同之前在 Memcheck 的基本用法部分提到的,建议始终使用--leak-check=full来运行 Memcheck,以获取关于内存泄漏发生的具体位置的详细信息。

Helgrind

Helgrind 的目的是检测多线程应用程序中同步实现的问题。它可以检测对 POSIX 线程的错误使用,由于错误的锁定顺序而导致的潜在死锁问题,以及数据竞争--在没有线程同步的情况下读取或写入数据。

基本用法

我们以以下方式启动 Helgrind 应用程序:

$ valgrind --tool=helgrind --read-var-info=yes --log-file=dispatcher_helgrind.log ./dispatcher_demo

与运行 Memcheck 类似,这将运行应用程序并将所有生成的输出记录到日志文件中,同时明确使用二进制中所有可用的调试信息。

运行应用程序后,我们检查生成的日志文件:

==6417== Helgrind, a thread error detector
==6417== Copyright (C) 2007-2015, and GNU GPL'd, by OpenWorks LLP et al.
==6417== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==6417== Command: ./dispatcher_demo
==6417== Parent PID: 2838
==6417== 
==6417== ---Thread-Announcement------------------------------------------
==6417== 
==6417== Thread #1 is the program's root thread 

在关于应用程序和 Valgrind 版本的初始基本信息之后,我们被告知根线程已经创建:

==6417== 
==6417== ---Thread-Announcement------------------------------------------
==6417== 
==6417== Thread #2 was created
==6417==    at 0x56FB7EE: clone (clone.S:74)
==6417==    by 0x53DE149: create_thread (createthread.c:102)
==6417==    by 0x53DFE83: pthread_create@@GLIBC_2.2.5 (pthread_create.c:679)
==6417==    by 0x4C34BB7: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x4EF8DC2: std::thread::_M_start_thread(std::shared_ptr<std::thread::_Impl_base>, void (*)()) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x403AD7: std::thread::thread<void (Worker::*)(), Worker*&>(void (Worker::*&&)(), Worker*&) (thread:137)
==6417==    by 0x4030E6: Dispatcher::init(int) (dispatcher.cpp:40)
==6417==    by 0x4090A0: main (main.cpp:51)
==6417== 
==6417== ----------------------------------------------------------------

第一个线程是由调度程序创建并记录的。接下来我们得到了第一个警告:

==6417== 
==6417==  Lock at 0x60F4A0 was first observed
==6417==    at 0x4C321BC: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x401CD1: __gthread_mutex_lock(pthread_mutex_t*) (gthr-default.h:748)
==6417==    by 0x402103: std::mutex::lock() (mutex:135)
==6417==    by 0x40337E: Dispatcher::addWorker(Worker*) (dispatcher.cpp:108)
==6417==    by 0x401DF9: Worker::run() (worker.cpp:49)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x53DF6B9: start_thread (pthread_create.c:333)
==6417==  Address 0x60f4a0 is 0 bytes inside data symbol "_ZN10Dispatcher12workersMutexE"
==6417== 
==6417== Possible data race during write of size 1 at 0x5CD9261 by thread #1
==6417== Locks held: 1, at address 0x60F4A0
==6417==    at 0x403650: Worker::setRequest(AbstractRequest*) (worker.h:38)
==6417==    by 0x403253: Dispatcher::addRequest(AbstractRequest*) (dispatcher.cpp:70)
==6417==    by 0x409132: main (main.cpp:63)
==6417== 
==6417== This conflicts with a previous read of size 1 by thread #2
==6417== Locks held: none
==6417==    at 0x401E02: Worker::run() (worker.cpp:51)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x53DF6B9: start_thread (pthread_create.c:333)
==6417==  Address 0x5cd9261 is 97 bytes inside a block of size 104 alloc'd
==6417==    at 0x4C2F50F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x40308F: Dispatcher::init(int) (dispatcher.cpp:38)
==6417==    by 0x4090A0: main (main.cpp:51)
==6417==  Block was alloc'd by thread #1
==6417== 
==6417== ----------------------------------------------------------------

在前面的警告中,Helgrind 告诉我们线程 ID 1 和 2 之间发生了大小为 1 的冲突读取。由于 C++11 线程 API 使用了大量模板,跟踪可能有些难以阅读。关键在于以下几行:

==6417==    at 0x403650: Worker::setRequest(AbstractRequest*) (worker.h:38) ==6417==    at 0x401E02: Worker::run() (worker.cpp:51) 

这对应以下代码行:

void setRequest(AbstractRequest* request) { this->request = request; ready = true; }
while (!ready && running) { 

这些代码行中唯一大小为 1 的变量是布尔变量ready。由于这是一个布尔变量,我们知道这是一个原子操作(详见第八章,原子操作-与硬件一起工作)。因此,我们可以忽略这个警告。

接下来,我们为这个线程得到另一个警告:

==6417== Possible data race during write of size 1 at 0x5CD9260 by thread #1
==6417== Locks held: none
==6417==    at 0x40362C: Worker::stop() (worker.h:37)
==6417==    by 0x403184: Dispatcher::stop() (dispatcher.cpp:50)
==6417==    by 0x409163: main (main.cpp:70)
==6417== 
==6417== This conflicts with a previous read of size 1 by thread #2 ==6417== Locks held: none
==6417==    at 0x401E0E: Worker::run() (worker.cpp:51)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x53DF6B9: start_thread (pthread_create.c:333)
==6417==  Address 0x5cd9260 is 96 bytes inside a block of size 104 alloc'd
==6417==    at 0x4C2F50F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x40308F: Dispatcher::init(int) (dispatcher.cpp:38)
==6417==    by 0x4090A0: main (main.cpp:51)
==6417==  Block was alloc'd by thread #1 

与第一个警告类似,这也涉及一个布尔变量--这里是Worker实例中的running变量。由于这也是一个原子操作,我们可以再次忽略这个警告。

在这个警告之后,我们看到其他线程重复了这些警告。我们还多次看到了这个警告的重复:

==6417==  Lock at 0x60F540 was first observed
==6417==    at 0x4C321BC: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x401CD1: __gthread_mutex_lock(pthread_mutex_t*) (gthr-default.h:748)
==6417==    by 0x402103: std::mutex::lock() (mutex:135)
==6417==    by 0x409044: logFnc(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) (main.cpp:40)
==6417==    by 0x40283E: Request::process() (request.cpp:19)
==6417==    by 0x401DCE: Worker::run() (worker.cpp:44)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==  Address 0x60f540 is 0 bytes inside data symbol "logMutex"
==6417== 
==6417== Possible data race during read of size 8 at 0x60F238 by thread #1
==6417== Locks held: none
==6417==    at 0x4F4ED6F: std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4F4F236: std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x403199: Dispatcher::stop() (dispatcher.cpp:53)
==6417==    by 0x409163: main (main.cpp:70)
==6417== 
==6417== This conflicts with a previous write of size 8 by thread #7
==6417== Locks held: 1, at address 0x60F540
==6417==    at 0x4F4EE25: std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x409055: logFnc(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) (main.cpp:41)
==6417==    by 0x402916: Request::finish() (request.cpp:27)
==6417==    by 0x401DED: Worker::run() (worker.cpp:45)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==  Address 0x60f238 is 24 bytes inside data symbol "_ZSt4cout@@GLIBCXX_3.4"  

这个警告是由于在线程之间没有使用标准输出同步引起的。尽管这个演示应用程序的日志记录函数使用互斥锁来同步工作线程记录的文本,但在一些位置我们也以不安全的方式写入标准输出。

通过使用一个中央、线程安全的日志记录函数,这相对容易修复。尽管这不太可能引起任何稳定性问题,但很可能会导致任何日志输出最终变成一团乱七八糟、无法使用的混乱。

滥用 pthreads API

Helgrind 检测到了大量涉及 pthreads API 的错误,如其手册所总结的,并列在下面:

  • 解锁无效的互斥锁

  • 解锁未锁定的互斥锁

  • 解锁由不同线程持有的互斥锁

  • 销毁无效或锁定的互斥锁

  • 递归锁定非递归互斥锁

  • 释放包含锁定互斥锁的内存

  • 将互斥锁参数传递给期望读写锁参数的函数,反之亦然

  • POSIX pthread 函数的失败会返回必须处理的错误代码

  • 线程在仍持有锁定的锁时退出

  • 使用未锁定的互斥锁、无效的互斥锁或被不同线程锁定的互斥锁调用pthread_cond_wait

  • 条件变量与其关联的互斥锁之间的不一致绑定

  • 无效或重复初始化 pthread 屏障

  • 在仍有线程等待的 pthread 屏障上进行初始化

  • 销毁从未初始化或仍有线程等待的 pthread 屏障对象

  • 等待未初始化的 pthread 屏障

此外,如果 Helgrind 本身没有检测到错误,但 pthread 库本身对 Helgrind 拦截的每个函数返回错误,那么 Helgrind 也会报告错误。

锁定顺序问题

锁定顺序检测使用的假设是一旦一系列锁以特定顺序被访问,它们将永远以这种顺序使用。例如,想象一下,一个由两个锁保护的资源。正如我们在第四章中看到的调度程序演示,线程同步和通信,我们在其 Dispatcher 类中使用两个互斥锁,一个用于管理对工作线程的访问,另一个用于请求实例。

在该代码的正确实现中,我们始终确保在尝试获取另一个互斥锁之前解锁一个互斥锁,因为另一个线程可能已经获得了对第二个互斥锁的访问权,并尝试获取对第一个互斥锁的访问权,从而创建死锁情况。

虽然有用,但重要的是要意识到,在某些领域,这种检测算法目前还不完善。这在使用条件变量时尤为明显,条件变量自然使用的锁定顺序往往会被 Helgrind 报告为错误

这里的要点是,人们必须检查这些日志消息并判断它们的价值,但与多线程 API 的直接误用不同,报告的问题是否是误报还不够清晰。

数据竞争

实质上,数据竞争是指两个或更多线程在没有任何同步机制的情况下尝试读取或写入相同的资源。在这里,只有并发读取和写入,或两个同时写入,才会真正有害;因此,只有这两种类型的访问会被报告。

在早期关于基本 Helgrind 使用的部分中,我们在日志中看到了这种类型错误的一些示例。这里涉及到对变量的同时写入和读取。正如我们在该部分中也提到的,Helgrind 并不关心写入或读取是否是原子的,而只是报告潜在问题。

与锁定顺序问题类似,这意味着人们必须根据每个数据竞争报告的价值来判断,因为许多报告可能是误报。

DRD

DRD 与 Helgrind 非常相似,因为它也检测应用程序中的线程和同步问题。DRD 与 Helgrind 的主要区别在于:

  • DRD 使用的内存较少

  • DRD 无法检测锁定顺序违规

  • DRD 支持分离线程

通常,人们希望同时运行 DRD 和 Helgrind,以便将两者的输出进行比较。由于许多潜在问题非常不确定,使用两种工具通常有助于确定最严重的问题。

基本用法

启动 DRD 与启动其他工具非常相似-我们只需指定我们想要的工具,如下所示:

$ valgrind --tool=drd --log-file=dispatcher_drd.log --read-var-info=yes ./dispatcher_demo

应用程序完成后,我们检查生成的日志文件内容。

==6576== drd, a thread error detector
==6576== Copyright (C) 2006-2015, and GNU GPL'd, by Bart Van Assche.
==6576== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==6576== Command: ./dispatcher_demo
==6576== Parent PID: 2838
==6576== 
==6576== Conflicting store by thread 1 at 0x05ce51b1 size 1
==6576==    at 0x403650: Worker::setRequest(AbstractRequest*) (worker.h:38)
==6576==    by 0x403253: Dispatcher::addRequest(AbstractRequest*) (dispatcher.cpp:70)
==6576==    by 0x409132: main (main.cpp:63)
==6576== Address 0x5ce51b1 is at offset 97 from 0x5ce5150\. Allocation context:
==6576==    at 0x4C3150F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so)
==6576==    by 0x40308F: Dispatcher::init(int) (dispatcher.cpp:38)
==6576==    by 0x4090A0: main (main.cpp:51)
==6576== Other segment start (thread 2)
==6576==    at 0x4C3818C: pthread_mutex_unlock (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so)
==6576==    by 0x401D00: __gthread_mutex_unlock(pthread_mutex_t*) (gthr-default.h:778)
==6576==    by 0x402131: std::mutex::unlock() (mutex:153)
==6576==    by 0x403399: Dispatcher::addWorker(Worker*) (dispatcher.cpp:110)
==6576==    by 0x401DF9: Worker::run() (worker.cpp:49)
==6576==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6576==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6576==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6576==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6576==    by 0x4F04C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6576==    by 0x4C3458B: ??? (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so)
==6576==    by 0x53EB6B9: start_thread (pthread_create.c:333)
==6576== Other segment end (thread 2)
==6576==    at 0x4C3725B: pthread_mutex_lock (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so)
==6576==    by 0x401CD1: __gthread_mutex_lock(pthread_mutex_t*) (gthr-default.h:748)
==6576==    by 0x402103: std::mutex::lock() (mutex:135)
==6576==    by 0x4023F8: std::unique_lock<std::mutex>::lock() (mutex:485)
==6576==    by 0x40219D: std::unique_lock<std::mutex>::unique_lock(std::mutex&) (mutex:415)
==6576==    by 0x401E33: Worker::run() (worker.cpp:52)
==6576==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6576==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6576==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6576==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6576==    by 0x4F04C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6576==    by 0x4C3458B: ??? (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so) 

前面的总结基本上重复了我们在 Helgrind 日志中看到的内容。我们看到了相同的数据竞争报告(冲突存储),由于原子性,我们可以安全地忽略它。至少对于这个特定的代码,使用 DRD 并没有增加任何我们从使用 Helgrind 已经知道的东西。

无论如何,最好同时使用两种工具,以防一种工具发现了另一种工具没有发现的问题。

特性

DRD 将检测以下错误:

  • 数据竞争

  • 锁争用(死锁和延迟)

  • 错误使用 pthreads API

对于第三点,根据 DRD 手册,DRD 检测到的错误列表与 Helgrind 的非常相似:

  • 将一种类型的同步对象(例如互斥锁)的地址传递给期望指向另一种类型同步对象(例如条件变量)的 POSIX API 调用

  • 尝试解锁未被锁定的互斥锁

  • 尝试解锁被另一个线程锁定的互斥锁

  • 尝试递归锁定PTHREAD_MUTEX_NORMAL类型的互斥锁或自旋锁

  • 销毁或释放被锁定的互斥锁

  • 在与条件变量相关的互斥锁未被锁定时向条件变量发送信号

  • 在未锁定的互斥锁上调用pthread_cond_wait,即由另一个线程锁定,或者已被递归锁定

  • 通过pthread_cond_wait将两个不同的互斥锁与条件变量关联

  • 销毁或释放正在等待的条件变量

  • 销毁或释放被锁定的读写同步对象

  • 尝试解锁被调用线程未锁定的读写同步对象

  • 尝试递归锁定独占的读写同步对象

  • 尝试将用户定义的读写同步对象的地址传递给 POSIX 线程函数

  • 尝试将 POSIX 读写同步对象的地址传递给用户定义的读写同步对象的注释之一

  • 重新初始化互斥锁、条件变量、读写锁、信号量或屏障

  • 销毁或释放正在等待的信号量或屏障

  • 屏障等待和屏障销毁之间的同步丢失

  • 在不先解锁由该线程锁定的自旋锁、互斥锁或读写同步对象的情况下退出线程

  • pthread_joinpthread_cancel传递无效的线程 ID

如前所述,DRD 还支持分离线程,这里有帮助。锁定顺序检查是否重要取决于应用程序。

C++11 线程支持

DRD 手册中包含了关于 C++11 线程支持的这一部分。

如果要使用c++11std::thread,则需要对该类实现中使用的std::shared_ptr<>对象进行注释:

  • 在公共头文件的开头或每个源文件的开头添加以下代码,然后再包含任何 C++头文件:
    #include <valgrind/drd.h>
    #define _GLIBCXX_SYNCHRONIZATION_HAPPENS_BEFORE(addr)
    ANNOTATE_HAPPENS_BEFORE(addr)
    #define _GLIBCXX_SYNCHRONIZATION_HAPPENS_AFTER(addr)
    ANNOTATE_HAPPENS_AFTER(addr)

  • 下载 GCC 源代码,从源文件libstdc++-v3/src/c++11/thread.cc中复制execute_native_thread_routine()std::thread::_M_start_thread()函数的实现到一个与应用程序链接的源文件中。确保在此源文件中也正确定义_GLIBCXX_SYNCHRONIZATION_HAPPENS_*()宏。

使用 DRD 与使用 C++11 线程 API 的应用程序可能会出现许多误报,这将通过前面的修复来解决。

然而,当使用 GCC 5.4 和 Valgrind 3.11(可能也适用于旧版本)时,这个问题似乎不再存在。然而,当使用 C++11 线程 API 时,如果突然看到 DRD 输出中出现大量误报,这是需要记住的事情。

总结

在本章中,我们看了如何处理多线程应用程序的调试。我们探讨了在多线程环境中使用调试器的基础知识。接下来,我们看到了如何使用 Valgrind 框架中的三种工具,这些工具可以帮助我们追踪多线程和其他关键问题。

在这一点上,我们可以拿取应用程序,使用前面章节中的信息,并分析它们是否存在需要修复的问题,包括内存泄漏和不正确使用同步机制。

在下一章中,我们将运用我们所学到的知识,探讨多线程编程和一般开发中的一些最佳实践。

第七章:最佳实践

与大多数事物一样,最好是避免犯错误,而不是事后纠正。本章介绍了多线程应用程序中的许多常见错误和设计问题,并展示了避免常见和不太常见问题的方法。

本章的主题包括:

  • 常见的多线程问题,如死锁和数据竞争。

  • 正确使用互斥锁、锁和陷阱。

  • 在使用静态初始化时可能出现的问题。

适当的多线程

在前面的章节中,我们已经看到了编写多线程代码时可能出现的各种潜在问题。这些问题从明显的问题,比如两个线程无法同时写入同一位置,到更微妙的问题,比如互斥锁的不正确使用。

还有许多与多线程代码直接相关的元素的问题,但这些问题可能导致看似随机的崩溃和其他令人沮丧的问题。其中一个例子是变量的静态初始化。在接下来的章节中,我们将看到所有这些问题以及更多问题,以及避免不得不处理它们的方法。

就像生活中的许多事情一样,它们是有趣的经历,但通常你不想重复它们。

错误的期望-死锁

死锁的描述已经非常简洁了。当两个或更多进程试图访问另一个进程持有的资源,而另一个线程同时正在等待访问它持有的资源时,就会发生死锁。

例如:

  1. 线程 1 获得对资源 A 的访问

  2. 线程 1 和 2 都想要访问资源 B

  3. 线程 2 获胜,现在拥有 B,而线程 1 仍在等待 B

  4. 线程 2 现在想要使用 A,并等待访问

  5. 线程 1 和 2 都永远等待资源

在这种情况下,我们假设线程最终将能够在某个时刻访问每个资源,而事实正好相反,因为每个线程都持有另一个线程需要的资源。

可视化,这个死锁过程看起来像这样:

这清楚地表明了在防止死锁时有两个基本规则:

  • 尽量不要同时持有多个锁。

  • 尽快释放任何持有的锁。

在第四章中,我们看到了一个现实生活中的例子,线程同步和通信,当我们看了调度程序演示代码时。这段代码涉及两个互斥锁,以保护对两个数据结构的访问:

void Dispatcher::addRequest(AbstractRequest* request) {
    workersMutex.lock();
    if (!workers.empty()) {
          Worker* worker = workers.front();
          worker->setRequest(request);
          condition_variable* cv;
          mutex* mtx;
          worker->getCondition(cv);
          worker->getMutex(mtx);
          unique_lock<mutex> lock(*mtx);
          cv->notify_one();
          workers.pop();
          workersMutex.unlock();
    }
    else {
          workersMutex.unlock();
          requestsMutex.lock();
          requests.push(request);
          requestsMutex.unlock();
    }
 } 

这里的互斥锁是workersMutexrequestsMutex变量。我们可以清楚地看到,在尝试获取另一个互斥锁之前,我们从不持有互斥锁。我们明确地在方法的开始处锁定workersMutex,以便我们可以安全地检查工作数据结构是否为空。

如果不为空,我们将新请求交给工作线程。然后,当我们完成了工作,数据结构,我们释放了互斥锁。此时,我们保留零个互斥锁。这里没有太复杂的地方,因为我们只使用了一个互斥锁。

有趣的是在 else 语句中,当没有等待的工作线程并且我们需要获取第二个互斥锁时。当我们进入这个范围时,我们保留一个互斥锁。我们可以尝试获取requestsMutex并假设它会起作用,但这可能会导致死锁,原因很简单:

bool Dispatcher::addWorker(Worker* worker) {
    bool wait = true;
    requestsMutex.lock();
    if (!requests.empty()) {
          AbstractRequest* request = requests.front();
          worker->setRequest(request);
          requests.pop();
          wait = false;
          requestsMutex.unlock();
    }
    else {
          requestsMutex.unlock();
          workersMutex.lock();
          workers.push(worker);
          workersMutex.unlock();
    }
          return wait;
 } 

先前的函数的伴随函数也使用了这两个互斥锁。更糟糕的是,这个函数在一个单独的线程中运行。结果,当第一个函数在尝试获取requestsMutex时持有workersMutex,而第二个函数同时持有后者并尝试获取前者时,我们就会陷入死锁。

然而,在这里看到的函数中,这两条规则都已成功实现;我们从不同时持有多个锁,并且尽快释放我们持有的任何锁。这可以在两个 else 情况中看到,在进入它们时,我们首先释放不再需要的任何锁。

在任何一种情况下,我们都不需要再分别检查工作者或请求数据结构;我们可以在做其他任何事情之前释放相关的锁。这导致了以下可视化效果:

当然,我们可能需要使用两个或更多数据结构或变量中包含的数据;这些数据同时被其他线程使用。很难确保在生成的代码中没有死锁的机会。

在这里,人们可能想考虑使用临时变量或类似的东西。通过锁定互斥量,复制相关数据,并立即释放锁,就不会与该互斥量发生死锁的机会。即使必须将结果写回数据结构,也可以在单独的操作中完成。

这在预防死锁方面增加了两条规则:

  • 尽量不要同时持有多个锁。

  • 尽快释放任何持有的锁。

  • 永远不要持有锁的时间超过绝对必要的时间。

  • 当持有多个锁时,请注意它们的顺序。

粗心大意 - 数据竞争

数据竞争,也称为竞争条件,发生在两个或更多线程试图同时写入同一共享内存时。因此,每个线程执行的指令序列期间和结束时共享内存的状态是非确定性的。

正如我们在第六章中看到的,调试多线程代码,数据竞争经常被用于调试多线程应用程序的工具报告。例如:

    ==6984== Possible data race during write of size 1 at 0x5CD9260 by thread #1
 ==6984== Locks held: none
 ==6984==    at 0x40362C: Worker::stop() (worker.h:37)
 ==6984==    by 0x403184: Dispatcher::stop() (dispatcher.cpp:50)
 ==6984==    by 0x409163: main (main.cpp:70)
 ==6984== 
 ==6984== This conflicts with a previous read of size 1 by thread #2
 ==6984== Locks held: none
 ==6984==    at 0x401E0E: Worker::run() (worker.cpp:51)
 ==6984==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
 ==6984==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
 ==6984==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
 ==6984==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
 ==6984==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
 ==6984==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
 ==6984==    by 0x53DF6B9: start_thread (pthread_create.c:333)
 ==6984==  Address 0x5cd9260 is 96 bytes inside a block of size 104 alloc'd
 ==6984==    at 0x4C2F50F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
 ==6984==    by 0x40308F: Dispatcher::init(int) (dispatcher.cpp:38)
 ==6984==    by 0x4090A0: main (main.cpp:51)
 ==6984==  Block was alloc'd by thread #1

生成前面警告的代码如下:

bool Dispatcher::stop() {
    for (int i = 0; i < allWorkers.size(); ++i) {
          allWorkers[i]->stop();
    }
          cout << "Stopped workers.\n";
          for (int j = 0; j < threads.size(); ++j) {
          threads[j]->join();
                      cout << "Joined threads.\n";
    }
 } 

考虑一下Worker实例中的这段代码:

   void stop() { running = false; } 

我们还有:

void Worker::run() {
    while (running) {
          if (ready) {
                ready = false;
                request->process();
                request->finish();
          }
                      if (Dispatcher::addWorker(this)) {
                while (!ready && running) {
                      unique_lock<mutex> ulock(mtx);
                      if (cv.wait_for(ulock, chrono::seconds(1)) == cv_status::timeout) {
                      }
                }
          }
    }
 } 

在这里,running是一个布尔变量,被设置为false(从一个线程写入),向工作者线程发出信号,告诉它应该终止其等待循环,其中从不同进程(主线程与工作者线程)读取布尔变量:

这个特定示例的警告是由于一个布尔变量同时被写入和读取。当然,这种特定情况之所以安全,是因为原子性,如在第八章中详细解释的那样,原子操作 - 与硬件交互

即使像这样的操作也存在潜在风险的原因是,读取操作可能发生在变量仍在更新过程中。例如,对于一个 32 位整数,在硬件架构上,更新这个变量可能是一次完成,或者多次完成。在后一种情况下,读取操作可能读取一个具有不可预测结果的中间值:

更有趣的情况是,当多个线程写入一个标准输出而不使用,例如,cout时。由于这个流不是线程安全的,结果输出流将包含输入流的片段,每当任何一个线程有机会写入时:

因此,预防数据竞争的基本规则是:

  • 永远不要对未锁定的、非原子的共享资源进行写入

  • 永远不要从未锁定的、非原子的共享资源中读取

这基本上意味着任何写入或读取都必须是线程安全的。如果一个线程写入共享内存,那么其他线程就不能同时写入它。同样,当我们从共享资源中读取时,我们需要确保最多只有其他线程也在读取共享资源。

这种级别的互斥自然是通过互斥实现的,正如我们在前面的章节中看到的那样,读写锁提供了一种改进,允许同时进行读取,同时将写入作为完全互斥的事件。

当然,互斥也有一些陷阱,我们将在接下来的部分中看到。

互斥并非魔法

互斥基本上是所有形式的互斥 API 的基础。从本质上讲,它们似乎非常简单,只有一个线程可以拥有一个互斥,其他线程则整齐地排队等待获取互斥的锁。

可以将这个过程想象成下面这样:

现实当然没有那么美好,主要是由于硬件对我们施加的实际限制。一个明显的限制是同步原语并不是免费的。即使它们是在硬件中实现的,也需要多次调用才能使它们工作。

在硬件中实现互斥的两种最常见方式是使用测试和设置(TAS)或比较和交换(CAS)CPU 特性。

测试和设置通常被实现为两个汇编级指令,它们是自主执行的,意味着它们不能被中断。第一条指令测试某个内存区域是否设置为 1 或零。只有当值为零(false)时,第二条指令才会执行。这意味着互斥尚未被锁定。第二条指令将内存区域设置为 1,从而锁定互斥。

在伪代码中,这看起来像这样:

bool TAS(bool lock) { 
   if (lock) { 
         return true; 
   } 
   else { 
         lock = true; 
         return false; 
   } 
} 

比较和交换是对此的一个较少使用的变体,它对内存位置和给定值执行比较操作,只有在前两者匹配时才替换该内存位置的内容:

bool CAS(int* p, int old, int new) { 
   if (*p != old) { 
               return false; 
         } 

   *p = new; 
         return true; 
} 

在任何情况下,都必须积极重复函数,直到返回正值:

volatile bool lock = false; 

 void critical() { 
     while (TAS(&lock) == false); 
     // Critical section 
     lock = 0; 
 } 

在这里,使用简单的 while 循环不断轮询内存区域(标记为 volatile 以防止可能有问题的编译器优化)。通常,使用的算法会逐渐减少轮询的频率,以减少对处理器和内存系统的压力。

这清楚地表明互斥的使用并非免费,每个等待互斥锁的线程都会主动使用资源。因此,一般规则是:

  • 确保线程等待互斥和类似锁的时间尽可能短。

  • 对于较长的等待时间使用条件变量或定时器。

锁是花哨的互斥

正如我们在互斥一节中看到的,使用互斥时需要牢记一些问题。当然,这些问题也适用于使用基于互斥的锁和其他机制,即使这些 API 可能会弥补其中一些问题。

当首次使用多线程 API 时,人们可能会困惑的一件事是不同同步类型之间的实际区别。正如我们在本章前面讨论的那样,互斥在几乎所有同步机制中起着基础作用,只是在使用互斥实现所提供功能的方式上有所不同。

重要的是,它们不是独立的同步机制,而只是基本互斥类型的特殊化。无论是使用常规互斥、读/写锁、信号量,甚至像可重入(递归)互斥或锁这样奇特的东西,完全取决于要解决的特定问题。

对于调度器,我们首先在第四章中遇到,线程同步和通信,我们使用常规互斥锁来保护包含排队工作线程和请求的数据结构。由于对任何数据结构的访问可能不仅涉及读取操作,还涉及结构的操作,因此在那里使用读/写锁是没有意义的。同样,递归锁也不会对谦虚的互斥锁产生任何作用。

对于每个同步问题,因此必须问以下问题:

  • 我有哪些要求?

  • 哪种同步机制最适合这些要求?

因此,选择复杂类型是有吸引力的,但通常最好坚持满足所有要求的更简单的类型。当涉及调试自己的实现时,与花哨的实现相比,可以节省宝贵的时间。

线程与未来

最近,有人开始建议不要使用线程,而是倡导使用其他异步处理机制,如promise。背后的原因是使用线程和涉及的同步是复杂且容易出错的。通常,人们只想并行运行一个任务,而不关心如何获得结果。

对于只运行短暂的简单任务,这当然是有意义的。基于线程的实现的主要优势始终是可以完全定制其行为。使用promise,可以发送一个要运行的任务,并在最后从future实例中获取结果。这对于简单的任务很方便,但显然不能涵盖很多情况。

这里的最佳方法是首先充分了解线程和同步机制,以及它们的限制。只有在那之后,才真正有意义去考虑是否希望使用 promise、packaged_task或完整的线程。

对于这些更复杂的、基于未来的 API,另一个主要考虑因素是它们严重依赖模板,这可能会使调试和解决可能发生的任何问题变得比使用更直接和低级的 API 更不容易。

静态初始化顺序

静态变量是只声明一次的变量,基本上存在于全局范围,尽管可能只在特定类的实例之间共享。也可能有完全静态的类:

class Foo { 
   static std::map<int, std::string> strings; 
   static std::string oneString; 

public: 
   static void init(int a, std::string b, std::string c) { 
         strings.insert(std::pair<int, std::string>(a, b)); 
         oneString = c; 
   } 
}; 

std::map<int, std::string> Foo::strings; 
std::string Foo::oneString; 

正如我们在这里看到的,静态变量以及静态函数似乎是一个非常简单但强大的概念。虽然在其核心是如此,但在静态变量和类的初始化方面,存在一个会让不慎的人陷入困境的主要问题。这是初始化顺序的形式。

想象一下,如果我们希望从另一个类的静态初始化中使用前面的类,就像这样:

class Bar { 
   static std::string name; 
   static std::string initName(); 

public: 
   void init(); 
}; 

// Static initializations. 
std::string Bar::name = Bar::initName(); 

std::string Bar::initName() { 
   Foo::init(1, "A", "B"); 
   return "Bar"; 
} 

虽然这看起来可能会很好,但向类的映射结构添加第一个字符串作为键意味着这段代码很有可能会崩溃。其原因很简单,没有保证在调用Foo::init()Foo::string已经初始化。因此,尝试使用未初始化的映射结构将导致异常。

简而言之,静态变量的初始化顺序基本上是随机的,如果不考虑这一点,就会导致非确定性行为。

这个问题的解决方案相当简单。基本上,目标是使更复杂的静态变量的初始化显式化,而不是像前面的例子中那样隐式化。为此,我们修改 Foo 类:

class Foo { 
   static std::map<int, std::string>& strings(); 
   static std::string oneString; 

public: 
   static void init(int a, std::string b, std::string c) { 
         static std::map<int, std::string> stringsStatic = Foo::strings(); 
         stringsStatic.insert(std::pair<int, std::string>(a, b)); 
         oneString = c; 
   } 
}; 

std::string Foo::oneString; 

std::map<int, std::string>& Foo::strings() { 
   static std::map<int, std::string>* stringsStatic = new std::map<int, std::string>(); 
   return *stringsStatic; 
} 

从顶部开始,我们看到我们不再直接定义静态地图。相反,我们有一个同名的私有函数。这个函数的实现可以在这个示例代码的底部找到。在其中,我们有一个指向具有熟悉地图定义的地图结构的静态指针。

当调用这个函数时,如果还没有实例,就会创建一个新的地图,因为它是一个静态变量。在修改后的init()函数中,我们看到我们调用strings()函数来获得对这个实例的引用。这是显式初始化的部分,因为调用函数将始终确保在我们使用它之前初始化地图结构,解决了我们之前遇到的问题。

我们还在这里看到了一个小优化:我们创建的stringsStatic变量也是静态的,这意味着我们只会调用一次strings()函数。这样做可以避免重复的函数调用,并恢复我们在先前简单但不稳定的实现中所拥有的速度。

因此,静态变量初始化的基本规则是,对于非平凡的静态变量,始终使用显式初始化。

总结

在本章中,我们看了一些编写多线程代码时需要牢记的良好实践和规则,以及一些建议。到目前为止,人们应该能够避免一些编写此类代码时的重大陷阱和主要混淆源。

在下一章中,我们将看到如何利用原子操作和 C++11 引入的<atomics>头文件来利用底层硬件。

第八章:原子操作 - 与硬件一起工作

许多优化和线程安全性取决于对底层硬件的理解:从某些架构上的对齐内存访问,到知道哪些数据大小和因此 C++类型可以安全地进行访问而不会有性能惩罚或需要互斥锁等。

本章将介绍如何利用多个处理器架构的特性,例如防止使用互斥锁,其中原子操作将防止任何访问冲突。还将研究诸如 GCC 中的特定于编译器的扩展。

本章主题包括:

  • 原子操作的类型以及如何使用它们

  • 如何针对特定的处理器架构

  • 基于编译器的原子操作

原子操作

简而言之,原子操作是处理器可以用单个指令执行的操作。这使得它在没有任何干扰(除了中断)的情况下是原子的,或者可以更改任何变量或数据。

应用包括保证指令执行顺序,无锁实现以及相关用途,其中指令执行顺序和内存访问保证是重要的。

在 2011 年之前的 C++标准中,对处理器提供的原子操作的访问仅由编译器使用扩展提供。

Visual C++

对于微软的 MSVC 编译器,有 interlocked 函数,从 MSDN 文档总结而来,从添加功能开始:

Interlocked 函数 描述
InterlockedAdd 对指定的LONG值执行原子加法操作。
InterlockedAddAcquire 对指定的LONG值执行原子加法操作。该操作使用获取内存排序语义执行。
InterlockedAddRelease 对指定的LONG值执行原子加法操作。该操作使用释放内存排序语义执行。
InterlockedAddNoFence 对指定的LONG值执行原子加法操作。该操作是原子执行的,但不使用内存屏障(在本章中介绍)。

这些是该特性的 32 位版本。API 中还有其他方法的 64 位版本。原子函数往往专注于特定的变量类型,但本摘要中省略了此 API 中的变体,以保持简洁。

我们还可以看到获取和释放的变体。这些提供了保证,即相应的读取或写入访问将受到内存重排序(在硬件级别)的保护,以及任何后续的读取或写入操作。最后,无栅栏变体(也称为内存屏障)执行操作而不使用任何内存屏障。

通常,CPU 执行指令(包括内存读取和写入)是为了优化性能而无序执行的。由于这种行为并不总是理想的,因此添加了内存屏障以防止此指令重排序。

接下来是原子AND特性:

Interlocked 函数 描述
InterlockedAnd 对指定的LONG值执行原子AND操作。
InterlockedAndAcquire 对指定的LONG值执行原子AND操作。该操作使用获取内存排序语义执行。
InterlockedAndRelease 对指定的LONG值执行原子AND操作。该操作使用释放内存排序语义执行。
InterlockedAndNoFence 对指定的LONG值执行原子AND操作。该操作是原子执行的,但不使用内存屏障。

位测试功能如下:

Interlocked 函数 描述
InterlockedBitTestAndComplement 测试指定的LONG值的指定位并对其进行补码。
InterlockedBitTestAndResetAcquire 测试指定的LONG值的指定位,并将其设置为0。该操作是原子的,并且使用获取内存排序语义执行。
InterlockedBitTestAndResetRelease 测试指定的LONG值的指定位,并将其设置为0。该操作是原子的,并且使用内存释放语义执行。
InterlockedBitTestAndSetAcquire 测试指定的LONG值的指定位,并将其设置为1。该操作是原子的,并且使用获取内存排序语义执行。
InterlockedBitTestAndSetRelease 测试指定的LONG值的指定位,并将其设置为1。该操作是原子的,并且使用释放内存排序语义执行。
InterlockedBitTestAndReset 测试指定的LONG值的指定位,并将其设置为0
InterlockedBitTestAndSet 测试指定的LONG值的指定位,并将其设置为1

比较功能可以列举如下:

Interlocked function 描述
InterlockedCompareExchange 对指定的值执行原子比较和交换操作。该函数比较两个指定的 32 位值,并根据比较的结果与另一个 32 位值进行交换。
InterlockedCompareExchangeAcquire 对指定的值执行原子比较和交换操作。该函数比较两个指定的 32 位值,并根据比较的结果与另一个 32 位值进行交换。操作使用获取内存排序语义执行。
InterlockedCompareExchangeRelease 对指定的值执行原子比较和交换操作。该函数比较两个指定的 32 位值,并根据比较的结果与另一个 32 位值进行交换。交换使用释放内存排序语义执行。
InterlockedCompareExchangeNoFence 对指定的值执行原子比较和交换操作。该函数比较两个指定的 32 位值,并根据比较的结果与另一个 32 位值进行交换。操作是原子的,但不使用内存屏障。
InterlockedCompareExchangePointer 对指定的指针值执行原子比较和交换操作。该函数比较两个指定的指针值,并根据比较的结果与另一个指针值进行交换。
InterlockedCompareExchangePointerAcquire 对指定的指针值执行原子比较和交换操作。该函数比较两个指定的指针值,并根据比较的结果与另一个指针值进行交换。操作使用获取内存排序语义执行。
InterlockedCompareExchangePointerRelease 对指定的指针值执行原子比较和交换操作。该函数比较两个指定的指针值,并根据比较的结果与另一个指针值进行交换。操作使用释放内存排序语义执行。
InterlockedCompareExchangePointerNoFence 对指定的值执行原子比较和交换操作。该函数比较两个指定的指针值,并根据比较的结果与另一个指针值进行交换。操作是原子的,但不使用内存屏障。

递减功能如下:

Interlocked function 描述
InterlockedDecrement 递减(减少一个)指定 32 位变量的值作为原子操作。
InterlockedDecrementAcquire 递减(减少一个)指定 32 位变量的值作为原子操作。操作使用获取内存排序语义执行。
InterlockedDecrementRelease 将指定的 32 位变量的值减 1 作为原子操作。操作使用释放内存排序语义执行。
InterlockedDecrementNoFence 将指定的 32 位变量的值减 1 作为原子操作。操作是原子执行的,但不使用内存屏障。

交换(交换)功能包括:

Interlocked function 描述
---
InterlockedExchange 将 32 位变量设置为指定值作为原子操作。
InterlockedExchangeAcquire 将 32 位变量设置为指定值作为原子操作。操作使用获取内存排序语义执行。
InterlockedExchangeNoFence 将 32 位变量设置为指定值作为原子操作。操作是原子执行的,但不使用内存屏障。
InterlockedExchangePointer 原子交换一对指针值。
InterlockedExchangePointerAcquire 原子交换一对指针值。操作使用获取内存排序语义执行。
InterlockedExchangePointerNoFence 原子交换一对地址。操作是原子执行的,但不使用内存屏障。
InterlockedExchangeSubtract 执行两个值的原子减法。
InterlockedExchangeAdd 执行两个 32 位值的原子加法。
InterlockedExchangeAddAcquire 执行两个 32 位值的原子加法。操作使用获取内存排序语义执行。
InterlockedExchangeAddRelease 执行两个 32 位值的原子加法。操作使用释放内存排序语义执行。
InterlockedExchangeAddNoFence 执行两个 32 位值的原子加法。操作是原子执行的,但不使用内存屏障。

增量功能包括:

Interlocked function 描述
---
InterlockedIncrement 将指定的 32 位变量的值增加 1 作为原子操作。
InterlockedIncrementAcquire 将指定的 32 位变量的值增加 1 作为原子操作。操作使用获取内存排序语义执行。
InterlockedIncrementRelease 将指定的 32 位变量的值增加 1 作为原子操作。操作使用释放内存排序语义执行。
InterlockedIncrementNoFence 将指定的 32 位变量的值增加 1 作为原子操作。操作是原子执行的,但不使用内存屏障。

OR功能:

Interlocked function 描述
---
InterlockedOr 对指定的LONG值执行原子OR操作。
InterlockedOrAcquire 对指定的LONG值执行原子OR操作。操作使用获取内存排序语义执行。
InterlockedOrRelease 对指定的LONG值执行原子OR操作。操作使用释放内存排序语义执行。
InterlockedOrNoFence 对指定的LONG值执行原子OR操作。操作是原子执行的,但不使用内存屏障。

最后,独占ORXOR)功能包括:

Interlocked function 描述
---
InterlockedXor 对指定的LONG值执行原子XOR操作。
InterlockedXorAcquire 对指定的LONG值执行原子XOR操作。操作使用获取内存排序语义执行。
InterlockedXorRelease 对指定的LONG值执行原子XOR操作。操作使用释放内存排序语义执行。
InterlockedXorNoFence 对指定的LONG值执行原子XOR操作。操作是原子执行的,但不使用内存屏障。

GCC

与 Visual C++一样,GCC 也带有一组内置的原子函数。这些函数根据 GCC 版本和标准库的底层架构而异。由于 GCC 在许多平台和操作系统上的使用要比 VC++多得多,这在考虑可移植性时绝对是一个重要因素。

例如,在 x86 平台上提供的每个内置原子函数都可能不会在 ARM 上可用,部分原因是由于架构差异,包括特定 ARM 架构的变化。例如,ARMv6、ARMv7 或当前的 ARMv8,以及 Thumb 指令集等。

在 C++11 标准之前,GCC 使用__sync-prefixed扩展来进行原子操作:

type __sync_fetch_and_add (type *ptr, type value, ...) 
type __sync_fetch_and_sub (type *ptr, type value, ...) 
type __sync_fetch_and_or (type *ptr, type value, ...) 
type __sync_fetch_and_and (type *ptr, type value, ...) 
type __sync_fetch_and_xor (type *ptr, type value, ...) 
type __sync_fetch_and_nand (type *ptr, type value, ...) 

这些操作从内存中获取一个值并对其执行指定操作,返回内存中的值。这些操作都使用内存屏障。

type __sync_add_and_fetch (type *ptr, type value, ...) 
type __sync_sub_and_fetch (type *ptr, type value, ...) 
type __sync_or_and_fetch (type *ptr, type value, ...) 
type __sync_and_and_fetch (type *ptr, type value, ...) 
type __sync_xor_and_fetch (type *ptr, type value, ...) 
type __sync_nand_and_fetch (type *ptr, type value, ...) 

这些操作与第一组类似,只是在指定操作后返回新值。

bool __sync_bool_compare_and_swap (type *ptr, type oldval, type newval, ...) 
type __sync_val_compare_and_swap (type *ptr, type oldval, type newval, ...) 

如果旧值与提供的值匹配,这些比较操作将写入新值。布尔变体在新值被写入时返回 true。

__sync_synchronize (...) 

该函数创建一个完整的内存屏障。

type __sync_lock_test_and_set (type *ptr, type value, ...) 

这种方法实际上是一种交换操作,与名称所示不同。它更新指针值并返回先前的值。这不使用完整的内存屏障,而是使用获取屏障,这意味着它不会释放屏障。

void __sync_lock_release (type *ptr, ...) 

该函数释放了先前方法获得的屏障。

为了适应 C++11 内存模型,GCC 添加了__atomic内置方法,这也大大改变了 API:

type __atomic_load_n (type *ptr, int memorder) 
void __atomic_load (type *ptr, type *ret, int memorder) 
void __atomic_store_n (type *ptr, type val, int memorder) 
void __atomic_store (type *ptr, type *val, int memorder) 
type __atomic_exchange_n (type *ptr, type val, int memorder) 
void __atomic_exchange (type *ptr, type *val, type *ret, int memorder) 
bool __atomic_compare_exchange_n (type *ptr, type *expected, type desired, bool weak, int success_memorder, int failure_memorder) 
bool __atomic_compare_exchange (type *ptr, type *expected, type *desired, bool weak, int success_memorder, int failure_memorder) 

首先是通用的加载、存储和交换函数。它们都相当容易理解。加载函数读取内存中的值,存储函数将值存储在内存中,交换函数将现有值与新值交换。比较和交换函数使交换有条件。

type __atomic_add_fetch (type *ptr, type val, int memorder) 
type __atomic_sub_fetch (type *ptr, type val, int memorder) 
type __atomic_and_fetch (type *ptr, type val, int memorder) 
type __atomic_xor_fetch (type *ptr, type val, int memorder) 
type __atomic_or_fetch (type *ptr, type val, int memorder) 
type __atomic_nand_fetch (type *ptr, type val, int memorder) 

这些函数基本上与旧 API 中的函数相同,返回特定操作的结果。

type __atomic_fetch_add (type *ptr, type val, int memorder) 
type __atomic_fetch_sub (type *ptr, type val, int memorder) 
type __atomic_fetch_and (type *ptr, type val, int memorder) 
type __atomic_fetch_xor (type *ptr, type val, int memorder) 
type __atomic_fetch_or (type *ptr, type val, int memorder) 
type __atomic_fetch_nand (type *ptr, type val, int memorder) 

再次,相同的函数,针对新 API 进行了更新。这些函数返回原始值(在操作之前获取)。

bool __atomic_test_and_set (void *ptr, int memorder) 

与旧 API 中同名的函数不同,该函数执行的是真正的测试和设置操作,而不是旧 API 函数的交换操作,后者仍然需要在之后释放内存屏障。测试是针对某个定义的值。

void __atomic_clear (bool *ptr, int memorder) 

该函数清除指针地址,将其设置为0

void __atomic_thread_fence (int memorder) 

可以使用该函数在线程之间创建同步内存屏障(栅栏)。

void __atomic_signal_fence (int memorder) 

该函数在线程和同一线程内的信号处理程序之间创建内存屏障。

bool __atomic_always_lock_free (size_t size, void *ptr) 

该函数检查指定大小的对象是否总是为当前处理器架构创建无锁原子指令。

bool __atomic_is_lock_free (size_t size, void *ptr) 

这基本上与以前的函数相同。

内存顺序

在 C++11 内存模型中,并不总是使用内存屏障(栅栏)进行原子操作。在 GCC 内置的原子 API 中,这反映在其函数中的memorder参数中。此参数的可能值直接映射到 C++11 原子 API 中的值:

  • __ATOMIC_RELAXED:意味着没有线程间的排序约束。

  • __ATOMIC_CONSUME:由于 C++11 对memory_order_consume的语义存在缺陷,目前使用更强的__ATOMIC_ACQUIRE内存顺序来实现。

  • __ATOMIC_ACQUIRE:从释放(或更强)语义存储到此获取加载创建线程间的 happens-before 约束

  • __ATOMIC_RELEASE:创建一个线程间 happens-before 约束,以获取(或更强)语义加载,从此发布存储读取

  • __ATOMIC_ACQ_REL:结合了 __ATOMIC_ACQUIRE__ATOMIC_RELEASE 的效果。

  • __ATOMIC_SEQ_CST:强制与所有其他 __ATOMIC_SEQ_CST 操作进行完全排序。

上述列表是从 GCC 手册的关于 GCC 7.1 版本原子的章节中复制的。连同该章节中的注释,这清楚地表明在实现 C++11 原子支持及编译器实现中都做出了权衡。

由于原子依赖于底层硬件支持,永远不会有一个使用原子的代码可以在各种架构上运行。

其他编译器

当然,C/C++ 有很多其他编译器工具链,不仅仅是 VC++ 和 GCC,包括英特尔编译器集合(ICC)和其他通常是专有工具。所有这些都有自己的内置原子函数集。幸运的是,由于 C++11 标准,我们现在在编译器之间有了一个完全可移植的原子标准。一般来说,这意味着除了非常特定的用例(或维护现有代码)之外,人们会使用 C++ 标准而不是特定于编译器的扩展。

C++11 原子

为了使用本机 C++11 原子特性,所有人只需包含 <atomic> 头文件。这样就可以使用 atomic 类,它使用模板来使自己适应所需的类型,并具有大量预定义的 typedef:

类型定义名称 完全特化
std::atomic_bool std::atomic<bool>
std::atomic_char std::atomic<char>
std::atomic_schar std::atomic<signed char>
std::atomic_uchar std::atomic<unsigned char>
std::atomic_short std::atomic<short>
std::atomic_ushort std::atomic<unsigned short>
std::atomic_int std::atomic<int>
std::atomic_uint std::atomic<unsigned int>
std::atomic_long std::atomic<long>
std::atomic_ulong std::atomic<unsigned long>
std::atomic_llong std::atomic<long long>
std::atomic_ullong std::atomic<unsigned long long>
std::atomic_char16_t std::atomic<char16_t>
std::atomic_char32_t std::atomic<char32_t>
std::atomic_wchar_t std::atomic<wchar_t>
std::atomic_int8_t std::atomic<std::int8_t>
std::atomic_uint8_t std::atomic<std::uint8_t>
std::atomic_int16_t std::atomic<std::int16_t>
std::atomic_uint16_t std::atomic<std::uint16_t>
std::atomic_int32_t std::atomic<std::int32_t>
std::atomic_uint32_t std::atomic<std::uint32_t>
std::atomic_int64_t std::atomic<std::int64_t>
std::atomic_uint64_t std::atomic<std::uint64_t>
std::atomic_int_least8_t std::atomic<std::int_least8_t>
std::atomic_uint_least8_t std::atomic<std::uint_least8_t>
std::atomic_int_least16_t std::atomic<std::int_least16_t>
std::atomic_uint_least16_t std::atomic<std::uint_least16_t>
std::atomic_int_least32_t std::atomic<std::int_least32_t>
std::atomic_uint_least32_t std::atomic<std::uint_least32_t>
std::atomic_int_least64_t std::atomic<std::int_least64_t>
std::atomic_uint_least64_t std::atomic<std::uint_least64_t>
std::atomic_int_fast8_t std::atomic<std::int_fast8_t>
std::atomic_uint_fast8_t std::atomic<std::uint_fast8_t>
std::atomic_int_fast16_t std::atomic<std::int_fast16_t>
std::atomic_uint_fast16_t std::atomic<std::uint_fast16_t>
std::atomic_int_fast32_t std::atomic<std::int_fast32_t>
std::atomic_uint_fast32_t std::atomic<std::uint_fast32_t>
std::atomic_int_fast64_t std::atomic<std::int_fast64_t>
std::atomic_uint_fast64_t std::atomic<std::uint_fast64_t>
std::atomic_intptr_t std::atomic<std::intptr_t>
std::atomic_uintptr_t
std::atomic_size_t
std::atomic_ptrdiff_t
std::atomic_intmax_t
std::atomic_uintmax_t

这个atomic类定义了以下通用函数:

函数 描述
operator= 将值赋给原子对象。
is_lock_free 如果原子对象是无锁的,则返回 true。
store 用非原子参数原子地替换原子对象的值。
load 原子地获取原子对象的值。
operator T 从原子对象中加载值。
exchange 原子地用新值替换对象的值,并返回旧值。
compare_exchange_weak``compare_exchange_strong 原子地比较对象的值,如果相等则交换值,否则返回当前值。

使用 C++17 更新,添加了is_always_lock_free常量。这允许我们查询类型是否总是无锁。

最后,我们有专门的atomic函数:

函数 描述
fetch_add 原子地将参数添加到存储在atomic对象中的值,并返回旧值。
fetch_sub 原子地从存储在atomic对象中的值中减去参数并返回旧值。
fetch_and 原子地执行参数和atomic对象的值之间的按位AND操作,并返回旧值。
fetch_or 原子地执行参数和atomic对象的值之间的按位OR操作,并返回旧值。
fetch_xor 原子地执行参数和atomic对象的值之间的按位XOR操作,并返回旧值。
operator++``operator++(int)``operator--``operator--(int) 将原子值增加或减少一。
operator+=``operator-=``operator&=``operator&#124;=``operator^= 增加、减少或执行按位ANDORXOR操作与原子值。

示例

使用fetch_add的基本示例如下:

#include <iostream> 
#include <thread> 
#include <atomic> 

std::atomic<long long> count; 
void worker() { 
         count.fetch_add(1, std::memory_order_relaxed); 
} 

int main() { 
         std::thread t1(worker); 
         std::thread t2(worker); 
         std::thread t3(worker); 
         std::thread t4(worker); 
         std::thread t5(worker); 

         t1.join(); 
         t2.join(); 
         t3.join(); 
         t4.join(); 
         t5.join(); 

         std::cout << "Count value:" << count << '\n'; 
} 

这个示例代码的结果将是5。正如我们在这里看到的,我们可以用原子方式实现一个基本的计数器,而不必使用任何互斥锁或类似的东西来提供线程同步。

非类函数

除了atomic类之外,在<atomic>头文件中还定义了许多基于模板的函数,我们可以以更类似于编译器内置的原子函数的方式使用。

函数 描述
atomic_is_lock_free 检查原子类型的操作是否是无锁的。
atomic_storeatomic_store_explicit 原子地用非原子参数替换atomic对象的值。
atomic_load``atomic_load_explicit 原子地获取存储在atomic对象中的值。
atomic_exchange``atomic_exchange_explicit 原子地用非原子参数替换atomic对象的值,并返回atomic的旧值。
atomic_compare_exchange_weak``atomic_compare_exchange_weak_explicit``atomic_compare_exchange_strong``atomic_compare_exchange_strong_explicit 原子地比较atomic对象的值与非原子参数,并在相等时执行原子交换,否则执行原子加载。
atomic_fetch_add``atomic_fetch_add_explicit 将非原子值添加到atomic对象中并获取atomic的先前值。
atomic_fetch_sub``atomic_fetch_sub_explicit atomic对象中减去非原子值并获取atomic的先前值。
atomic_fetch_and``atomic_fetch_and_explicit 用非原子参数的逻辑AND结果替换atomic对象,并获取原子的先前值。
atomic_fetch_or``atomic_fetch_or_explicit 用非原子参数的逻辑OR结果替换atomic对象,并获取atomic的先前值。
atomic_fetch_xor``atomic_fetch_xor_explicit 用非原子参数的逻辑XOR结果替换atomic对象,并获取atomic的先前值。
atomic_flag_test_and_set``atomic_flag_test_and_set_explicit 原子地将标志设置为true并返回其先前的值。
atomic_flag_clear``atomic_flag_clear_explicit 原子地将标志的值设置为false
atomic_init 默认构造的atomic对象的非原子初始化。
kill_dependency std::memory_order_consume依赖树中移除指定的对象。
atomic_thread_fence 通用的内存顺序相关的栅栏同步原语。
atomic_signal_fence 线程和在同一线程中执行的信号处理程序之间的栅栏。

常规和显式函数之间的区别在于后者允许设置要使用的内存顺序。前者总是使用memory_order_seq_cst作为内存顺序。

例子

在这个使用atomic_fetch_sub的例子中,一个索引容器被多个线程同时处理,而不使用锁:

#include <string> 
#include <thread> 
#include <vector> 
#include <iostream> 
#include <atomic> 
#include <numeric> 

const int N = 10000; 
std::atomic<int> cnt; 
std::vector<int> data(N); 

void reader(int id) { 
         for (;;) { 
               int idx = atomic_fetch_sub_explicit(&cnt, 1, std::memory_order_relaxed); 
               if (idx >= 0) { 
                           std::cout << "reader " << std::to_string(id) << " processed item " 
                                       << std::to_string(data[idx]) << '\n'; 
               }  
         else { 
                           std::cout << "reader " << std::to_string(id) << " done.\n"; 
                           break; 
               } 
         } 
} 

int main() { 
         std::iota(data.begin(), data.end(), 1); 
         cnt = data.size() - 1; 

         std::vector<std::thread> v; 
         for (int n = 0; n < 10; ++n) { 
               v.emplace_back(reader, n); 
         } 

         for (std::thread& t : v) { 
               t.join(); 
         } 
} 

这个例子代码使用了一个大小为N的整数向量作为数据源,用 1 填充它。原子计数器对象设置为数据向量的大小。之后,创建了 10 个线程(使用向量的emplace_back C++11 特性在原地初始化),运行reader函数。

在那个函数中,我们使用atomic_fetch_sub_explicit函数从内存中读取索引计数器的当前值,这使我们能够使用memory_order_relaxed内存顺序。这个函数还从这个旧值中减去我们传递的值,将索引减少 1。

只要我们以这种方式获得的索引号大于或等于零,函数就会继续,否则它将退出。一旦所有线程都完成,应用程序就会退出。

原子标志

std::atomic_flag是一种原子布尔类型。与atomic类的其他特化不同,它保证是无锁的。但它不提供任何加载或存储操作。

相反,它提供了赋值运算符,并提供了清除或test_and_set标志的函数。前者将标志设置为false,后者将测试并将其设置为true

内存顺序

这个属性在<atomic>头文件中被定义为一个枚举:

enum memory_order { 
    memory_order_relaxed, 
    memory_order_consume, 
    memory_order_acquire, 
    memory_order_release, 
    memory_order_acq_rel, 
    memory_order_seq_cst 
}; 

在 GCC 部分,我们已经简要涉及了内存顺序的主题。如前所述,这是底层硬件架构特性的一部分。

基本上,内存顺序决定了如何对原子操作周围的非原子内存访问进行排序(内存访问顺序)。这会影响不同线程在执行其指令时如何看到内存中的数据:

枚举 描述
memory_order_relaxed 松散操作:对其他读取或写入没有同步或排序约束,只保证了这个操作的原子性。
memory_order_consume 具有这个内存顺序的加载操作在受影响的内存位置上执行consume 操作:当前加载之前不能对当前线程中依赖当前加载的值的读取或写入进行重新排序。释放相同原子变量的其他线程对数据相关变量的写入在当前线程中可见。在大多数平台上,这只影响编译器优化。
memory_order_acquire 具有这种内存顺序的加载操作在受影响的内存位置上执行获取操作:在此加载之前,当前线程中的任何读取或写入都不能被重新排序。释放相同原子变量的其他线程中的所有写入在当前线程中是可见的。
memory_order_release 具有这种内存顺序的存储操作执行释放操作:在此存储之后,当前线程中的任何读取或写入都不能被重新排序。当前线程中的所有写入对于获取相同原子变量的其他线程是可见的,并且对原子变量进行依赖的写入对于消费相同原子的其他线程是可见的。
memory_order_acq_rel 具有这种内存顺序的读取-修改-写入操作既是获取操作又是释放操作。当前线程中的任何内存读取或写入都不能在此存储之前或之后重新排序。释放相同原子变量的其他线程中的所有写入在修改之前可见,并且在获取相同原子变量的其他线程中修改是可见的。
memory_order_seq_cst 具有这种内存顺序的任何操作既是获取操作又是释放操作,并且存在一个单一的总顺序,所有线程以相同的顺序观察到所有修改。

松散排序

在松散内存排序中,不对并发内存访问强制执行任何顺序。这种类型的排序仅保证原子性和修改顺序。

这种类型的排序的典型用途是用于计数器,无论是递增还是递减,就像我们在上一节的示例代码中看到的那样。

释放-获取排序

如果线程 A 中的原子存储被标记为memory_order_release,并且线程 B 中从相同变量的原子加载被标记为memory_order_acquire,则从线程 A 的视角来看,所有在原子存储之前发生的内存写入(非原子和松散原子)都会在线程 B 中变为可见副作用。也就是说,一旦原子加载完成,线程 B 将保证看到线程 A 写入内存的所有内容。

这种类型的操作在所谓的强排序架构上是自动的,包括 x86、SPARC 和 POWER。弱排序架构,如 ARM、PowerPC 和 Itanium,将需要在这里使用内存屏障。

这种类型的内存排序的典型应用包括互斥机制,如互斥锁或原子自旋锁。

释放-获取排序

如果线程 A 中的原子存储被标记为memory_order_release,并且线程 B 中从相同变量的原子加载被标记为memory_order_consume,则从线程 A 的视角来看,所有在原子存储之前依赖排序的内存写入(非原子和松散原子)都会在线程 B 的操作中变为可见副作用,这些操作使用了从加载操作中获得的值。也就是说,一旦原子加载完成,线程 B 中使用从加载中获得的值的运算符和函数将保证看到线程 A 写入内存的内容。

这种类型的排序在几乎所有架构上都是自动的。唯一的主要例外是(过时的)Alpha 架构。这种类型排序的典型用例是对很少更改的数据进行读取访问。

截至 C++17,这种内存排序正在进行修订,暂时不鼓励使用memory_order_consume

顺序一致性排序

标记为memory_order_seq_cst的原子操作不仅以与释放-获取排序相同的方式对内存进行排序(在一个线程中存储之前发生的所有事情都成为可见副作用在执行加载的线程中),而且还建立了所有标记为这种方式的原子操作的单一总修改顺序

这种排序可能在所有消费者必须以完全相同的顺序观察其他线程所做的更改的情况下是必要的。这会导致在多核或多 CPU 系统上需要完整的内存屏障。

由于这种复杂的设置,这种排序比其他类型要慢得多。它还要求每个原子操作都必须带有这种类型的内存排序标记,否则顺序排序将会丢失。

Volatile 关键字

volatile关键字对于任何曾经编写过复杂多线程代码的人来说可能非常熟悉。它的基本用途是告诉编译器相关变量应始终从内存中加载,不要对其值进行任何假设。它还确保编译器不会对变量进行任何激进的优化。

对于多线程应用程序来说,它通常是无效的,因此不建议使用。volatile关键字的主要问题在于它没有定义多线程内存模型,这意味着这个关键字的结果可能在不同平台、CPU 甚至工具链上都不确定。

在原子操作领域,不需要使用这个关键字,事实上,使用它可能并不会有帮助。为了确保获取在多个 CPU 核心和它们的缓存之间共享的变量的当前版本,必须使用像atomic_compare_exchange_strongatomic_fetch_addatomic_exchange这样的操作,让硬件获取正确和当前的值。

对于多线程代码,建议不要使用volatile关键字,而是使用原子操作来保证正确的行为。

总结

在本章中,我们看了原子操作以及它们是如何被集成到编译器中的,以使代码尽可能地与底层硬件配合。读者现在将熟悉原子操作的类型、内存屏障(fencing)的使用,以及各种内存排序及其影响。

读者现在能够在自己的代码中使用原子操作来实现无锁设计,并正确使用 C++11 内存模型。

在下一章中,我们将把迄今为止学到的一切都放在一起,远离 CPU,转而看看 GPGPU,即在显卡(GPU)上对数据进行通用处理。

第九章:多线程与分布式计算

分布式计算是多线程编程的最初应用之一。在每台个人计算机只包含单个处理器和单个核心的时代,政府和研究机构,以及一些公司会拥有多处理器系统,通常是集群的形式。它们可以进行多线程处理;通过在处理器之间分割任务,它们可以加速各种任务,包括模拟、CGI 电影渲染等。

如今,几乎每台桌面级或更高级别的系统都有多个处理器核心,并且使用廉价的以太网布线很容易将多台系统组装成一个集群。结合 OpenMP 和 Open MPI 等框架,很容易将基于 C++(多线程)的应用程序扩展到分布式系统上运行。

本章的主题包括:

  • 在多线程 C++应用程序中集成 OpenMP 和 MPI

  • 实现分布式、多线程应用程序

  • 分布式、多线程编程的常见应用和问题

分布式计算,简而言之

当涉及并行处理大型数据集时,如果能够将数据分割成许多小部分,并将其推送到许多线程中,从而显著缩短处理所述数据的总时间,那将是理想的。

分布式计算的理念正是这样:在分布式系统的每个节点上运行我们的应用程序的一个或多个实例,这个应用程序可以是单线程或多线程。由于进程间通信的开销,使用多线程应用程序通常更有效,也由于其他可能的优化--由于资源共享。

如果已经有一个多线程应用程序准备好使用,那么可以直接使用 MPI 使其在分布式系统上运行。否则,OpenMP 是一个编译器扩展(用于 C/C++和 Fortran),可以相对轻松地使应用程序成为多线程,而无需重构。

为了做到这一点,OpenMP 允许标记一个通用的代码段,以便在所有从属线程上执行。主线程创建了一些从属线程,它们将同时处理相同的代码段。一个基本的“Hello World” OpenMP 应用程序看起来像这样:

/******************************************************************************
 * FILE: omp_hello.c
 * DESCRIPTION:
 *   OpenMP Example - Hello World - C/C++ Version
 *   In this simple example, the master thread forks a parallel region.
 *   All threads in the team obtain their unique thread number and print it.
 *   The master thread only prints the total number of threads.  Two OpenMP
 *   library routines are used to obtain the number of threads and each
 *   thread's number.
 * AUTHOR: Blaise Barney  5/99
 * LAST REVISED: 04/06/05
 ******************************************************************************/
 #include <omp.h>
 #include <stdio.h>
 #include <stdlib.h>

 int main (int argc, char *argv[])  {
    int nthreads, tid;

    /* Fork a team of threads giving them their own copies of variables */
 #pragma omp parallel private(nthreads, tid) {
          /* Obtain thread number */
          tid = omp_get_thread_num();
          printf("Hello World from thread = %d\n", tid);

          /* Only master thread does this */
          if (tid == 0) {
                nthreads = omp_get_num_threads();
                printf("Number of threads = %d\n", nthreads);
                }

    }  /* All threads join master thread and disband */ 
} 

从这个基本示例中可以很容易地看出,OpenMP 通过<omp.h>头文件提供了一个基于 C 的 API。我们还可以看到每个线程将执行的部分,由#pragma omp预处理宏标记。

与我们在前面章节中看到的多线程代码示例相比,OpenMP 的优势在于可以轻松地将代码部分标记为多线程,而无需进行任何实际的代码更改。这带来的明显限制是,每个线程实例将执行完全相同的代码,并且进一步的优化选项有限。

MPI

为了在特定节点上安排代码的执行,通常使用MPI(消息传递接口)。Open MPI 是这种的免费库实现,被许多高级超级计算机使用。MPICH 是另一个流行的实现。

MPI 本身被定义为并行计算编程的通信协议。它目前处于第三个修订版(MPI-3)。

总之,MPI 提供了以下基本概念:

  • 通信器:通信器对象连接了 MPI 会话中的一组进程。它为进程分配唯一标识符,并在有序拓扑中安排进程。

  • 点对点操作:这种操作允许特定进程之间直接通信。

  • 集体函数:这些函数涉及在一个进程组内进行广播通信。它们也可以以相反的方式使用,从一个组中的所有进程获取结果,并且例如在单个节点上对它们进行求和。更有选择性的版本将确保特定的数据项被发送到特定的节点。

  • 派生数据类型:由于 MPI 集群中的每个节点都不能保证具有相同的定义、字节顺序和数据类型的解释,MPI 要求指定每个数据段的类型,以便 MPI 可以进行数据转换。

  • 单边通信:这些操作允许在远程内存中写入或读取,或者在多个任务之间执行归约操作,而无需在任务之间进行同步。这对于某些类型的算法非常有用,比如涉及分布式矩阵乘法的算法。

  • 动态进程管理:这是一个功能,允许 MPI 进程创建新的 MPI 进程,或者与新创建的 MPI 进程建立通信。

  • 并行 I/O:也称为 MPI-IO,这是分布式系统上 I/O 管理的抽象,包括文件访问,方便与 MPI 一起使用。

其中,MPI-IO、动态进程管理和单边通信是 MPI-2 的特性。从基于 MPI-1 的代码迁移以及动态进程管理与某些设置不兼容,以及许多应用程序不需要 MPI-2 的特性,意味着 MPI-2 的采用相对较慢。

实现

MPI 的最初实现是MPICH,由阿贡国家实验室ANL)和密西西比州立大学开发。它目前是最受欢迎的实现之一,被用作 MPI 实现的基础,包括 IBM(蓝色基因)、英特尔、QLogic、Cray、Myricom、微软、俄亥俄州立大学(MVAPICH)等的 MPI 实现。

另一个非常常见的实现是 Open MPI,它是由三个 MPI 实现合并而成的:

  • FT-MPI(田纳西大学)

  • LA-MPI(洛斯阿拉莫斯国家实验室)

  • LAM/MPI(印第安纳大学)

这些,以及斯图加特大学的 PACX-MPI 团队,是 Open MPI 团队的创始成员。Open MPI 的主要目标之一是创建一个高质量的开源 MPI-3 实现。

MPI 实现必须支持 C 和 Fortran。C/C++和 Fortran 以及汇编支持非常普遍,还有其他语言的绑定。

使用 MPI

无论选择哪种实现,最终的 API 都将始终符合官方 MPI 标准,只有选择的库支持的 MPI 版本会有所不同。任何 MPI 实现都应该支持所有 MPI-1(修订版 1.3)的特性。

这意味着规范的 Hello World(例如,在 MPI 教程网站上找到的mpitutorial.com/tutorials/mpi-hello-world/)对于 MPI 应该在选择哪个库时都能工作:

#include <mpi.h> 
#include <stdio.h> 

int main(int argc, char** argv) { 
         // Initialize the MPI environment 
         MPI_Init(NULL, NULL); 

         // Get the number of processes 
         int world_size; 
         MPI_Comm_size(MPI_COMM_WORLD, &world_size); 

         // Get the rank of the process 
         int world_rank; 
         MPI_Comm_rank(MPI_COMM_WORLD, &world_rank); 

         // Get the name of the processor 
         char processor_name[MPI_MAX_PROCESSOR_NAME]; 
         int name_len; 
         MPI_Get_processor_name(processor_name, &name_len); 

         // Print off a hello world message 
         printf("Hello world from processor %s, rank %d" 
                     " out of %d processors\n", 
                     processor_name, world_rank, world_size); 

         // Finalize the MPI environment. 
         MPI_Finalize(); 
} 

阅读这个基于 MPI 的应用程序的基本示例时,熟悉 MPI 使用的术语非常重要,特别是:

  • World:此作业的注册 MPI 进程

  • 通信器:连接会话中所有 MPI 进程的对象

  • :通信器内进程的标识符

  • 处理器:物理 CPU,多核 CPU 的单个核心,或系统的主机名

在这个 Hello World 的例子中,我们可以看到我们包含了<mpi.h>头文件。这个 MPI 头文件将始终相同,无论我们使用哪种实现。

初始化 MPI 环境只需要调用一次MPI_Init(),这个调用可以有两个参数,但在这一点上都是可选的。

接下来是获取世界的大小(即可用进程数)。这是使用MPI_Comm_size()完成的,它接受MPI_COMM_WORLD全局变量(由 MPI 定义供我们使用)并使用第二个参数更新该世界中的进程数。

然后我们获得的等级基本上是 MPI 分配给此进程的唯一 ID。获取此 UID 是使用MPI_Comm_rank()执行的。同样,这需要MPI_COMM_WORLD变量作为第一个参数,并将我们的数字等级作为第二个参数返回。此等级对于自我识别和进程之间的通信很有用。

获取正在运行的特定硬件的名称也可能很有用,特别是用于诊断目的。为此,我们可以调用MPI_Get_processor_name()。返回的字符串将具有全局定义的最大长度,并且将以某种方式标识硬件。该字符串的确切格式由实现定义。

最后,我们打印出我们收集的信息,并在终止应用程序之前清理 MPI 环境。

编译 MPI 应用程序

为了编译 MPI 应用程序,使用mpicc编译器包装器。这个可执行文件应该是已安装的任何 MPI 实现的一部分。

然而,使用它与使用例如 GCC 完全相同:

    $ mpicc -o mpi_hello_world mpi_hello_world.c

这可以与以下进行比较:

    $ gcc mpi_hello_world.c -lmsmpi -o mpi_hello_world

这将把我们的 Hello World 示例编译和链接成一个二进制文件,准备执行。然而,执行此二进制文件不是直接启动它,而是使用启动器,如下所示:

    $ mpiexec.exe -n 4 mpi_hello_world.exe
    Hello world from processor Generic_PC, rank 0 out of 4 processors
    Hello world from processor Generic_PC, rank 2 out of 4 processors
    Hello world from processor Generic_PC, rank 1 out of 4 processors
    Hello world from processor Generic_PC, rank 3 out of 4 processors

前面的输出来自在 Windows 系统上运行的 Bash shell 中的 Open MPI。正如我们所看到的,总共启动了四个进程(4 个等级)。每个进程的处理器名称报告为主机名(“PC”)。

用于启动 MPI 应用程序的二进制文件称为 mpiexec 或 mpirun,或者 orterun。这些是相同二进制文件的同义词,尽管并非所有实现都具有所有同义词。对于 Open MPI,所有三者都存在,可以使用其中任何一个。

集群硬件

MPI 基于或类似应用程序将运行的系统由多个独立系统(节点)组成,每个系统都使用某种网络接口连接到其他系统。对于高端应用程序,这些往往是具有高速、低延迟互连的定制节点。在光谱的另一端是所谓的 Beowulf 和类似类型的集群,由标准(台式)计算机组成,通常使用常规以太网连接。

在撰写本文时,根据 TOP500 榜单,最快的超级计算机是中国无锡国家超级计算中心的 Sunway TaihuLight 超级计算机。它使用了总共 40960 个中国设计的 SW26010 多核 RISC 架构 CPU,每个 CPU 有 256 个核心(分为 4 个 64 核心组),以及四个管理核心。术语“多核”是指一种专门的 CPU 设计,它更注重显式并行性,而不是大多数 CPU 核心的单线程和通用重点。这种类型的 CPU 类似于 GPU 架构和矢量处理器。

每个节点都包含一个 SW26010 和 32GB 的 DDR3 内存。它们通过基于 PCIe 3.0 的网络连接,本身由三级层次结构组成:中央交换网络(用于超级节点),超级节点网络(连接超级节点中的所有 256 个节点)和资源网络,提供对 I/O 和其他资源服务的访问。节点之间的网络带宽为 12GB/秒,延迟约为 1 微秒。

以下图表(来自“Sunway TaihuLight 超级计算机:系统和应用”,DOI:10.1007/s11432-016-5588-7)提供了该系统的视觉概述:

对于预算不允许这样一个复杂和高度定制的系统的情况,或者特定任务不需要这样的方法的情况,总是可以采用“Beowulf”方法。Beowulf 集群是指由普通计算机系统构建的分布式计算系统。这些可以是基于 Intel 或 AMD 的 x86 系统,现在也变得流行的是基于 ARM 的处理器。

通常有助于使集群中的每个节点与其他节点大致相同。虽然可能有不对称的集群,但当可以对每个节点进行广泛的假设时,管理和作业调度变得更加容易。

至少,希望匹配处理器架构,具有一定级别的 CPU 扩展,如 SSE2/3,也许还有 AVX 等,这些在所有节点上都是通用的。这样做可以让我们在节点上使用相同的编译二进制文件,以及相同的算法,大大简化作业的部署和代码库的维护。

对于节点之间的网络,以太网是一个非常受欢迎的选择,通信时间以十到几百微秒计,成本只是更快选项的一小部分。通常,每个节点都会连接到一个单独的以太网网络,就像这张图中的情况:

还有一个选择,就是为每个或特定节点添加第二甚至第三个以太网链接,以便它们可以访问文件、I/O 和其他资源,而无需在主要网络层上竞争带宽。对于非常大的集群,可以考虑一种类似于 Sunway TaihuLight 和许多其他超级计算机所使用的方法:将节点分割成超级节点,每个节点都有自己的节点间网络。这将允许通过将流量限制在相关节点上来优化网络流量。

一个优化的 Beowulf 集群的示例如下:

很明显,基于 MPI 的集群有各种可能的配置,可以利用定制的、现成的,或两种类型硬件的组合。集群的预期用途通常决定了特定集群的最佳布局,比如运行模拟,或处理大型数据集。每种类型的作业都有自己的一系列限制和要求,这也反映在软件实现中。

安装 Open MPI

在本章的其余部分,我们将专注于 Open MPI。为了获得 Open MPI 的工作开发环境,需要安装其头文件和库文件,以及支持工具和二进制文件。

Linux 和 BSD

在具有软件包管理系统的 Linux 和 BSD 发行版上,这很容易:只需安装 Open MPI 软件包,一切都应该设置和配置好,准备好使用。查阅特定发行版的手册,了解如何搜索和安装特定软件包。

在基于 Debian 的发行版上,可以使用:

    $ sudo apt-get install openmpi-bin openmpi-doc libopenmpi-dev

上述命令将安装 Open MPI 二进制文件、文档和开发头文件。最后两个软件包可以在计算节点上省略。

Windows

在 Windows 上情况会稍微复杂一些,主要是因为 Visual C++和相关的编译器工具链的主导地位。如果希望在 Linux 或 BSD 上使用 MinGW 作为开发环境,就需要采取一些额外的步骤。

本章假设使用 GCC 或 MinGW。如果希望在 Visual Studio 环境下开发 MPI 应用程序,请查阅相关文档。

最容易使用和最新的 MinGW 环境是 MSYS2,它提供了一个 Bash shell,以及大多数在 Linux 和 BSD 下熟悉的工具。它还配备了 Pacman 软件包管理器,就像 Linux Arch 发行版中所知的那样。使用这个环境,很容易安装 Open MPI 开发所需的软件包。

msys2.github.io/安装 MSYS2 环境后,安装 MinGW 工具链:

    $ pacman -S base-devel mingw-w64-x86_64-toolchain

这假设安装了 64 位版本的 MSYS2。对于 32 位版本,选择 i686 而不是 x86_64。安装这些软件包后,我们将安装 MinGW 和基本开发工具。为了使用它们,使用 MinGW 64 位后缀的名称启动一个新的 shell,可以通过开始菜单中的快捷方式,或者通过 MSYS2 install文件夹中的可执行文件来启动。

准备好 MinGW 后,现在是时候安装 MS-MPI 版本 7.x 了。这是微软在 Windows 上使用 MPI 的最简单的方法。它是 MPI-2 规范的实现,与 MPICH2 参考实现大部分兼容。由于 MS-MPI 库在不同版本之间不兼容,我们使用这个特定的版本。

尽管 MS-MPI 的第 7 版已被存档,但仍可以通过 Microsoft 下载中心下载,网址为www.microsoft.com/en-us/download/details.aspx?id=49926

MS-MPI 版本 7 带有两个安装程序,msmpisdk.msiMSMpiSetup.exe。都需要安装。之后,我们应该能够打开一个新的 MSYS2 shell,并找到以下环境变量设置:

    $ printenv | grep "WIN\|MSMPI"
    MSMPI_INC=D:\Dev\MicrosoftSDKs\MPI\Include\
    MSMPI_LIB32=D:\Dev\MicrosoftSDKs\MPI\Lib\x86\
    MSMPI_LIB64=D:\Dev\MicrosoftSDKs\MPI\Lib\x64\
    WINDIR=C:\Windows

printenv 命令的输出显示 MS-MPI SDK 和运行时已正确安装。接下来,我们需要将静态库从 Visual C++ LIB 格式转换为 MinGW A 格式:

    $ mkdir ~/msmpi
    $ cd ~/msmpi
    $ cp "$MSMPI_LIB64/msmpi.lib" .
    $ cp "$WINDIR/system32/msmpi.dll" .
    $ gendef msmpi.dll
    $ dlltool -d msmpi.def -D msmpi.dll -l libmsmpi.a
    $ cp libmsmpi.a /mingw64/lib/.

首先,我们将原始 LIB 文件复制到我们的主文件夹中的一个新临时文件夹中,以及运行时 DLL。接下来,我们使用 DLL 上的 gendef 工具来创建我们需要的定义,以便将其转换为新格式。

这最后一步是使用 dlltool 完成的,它接受定义文件和 DLL,并输出一个与 MinGW 兼容的静态库文件。然后我们将这个文件复制到 MinGW 在链接时可以找到的位置。

接下来,我们需要复制 MPI 头文件:

    $ cp "$MSMPI_INC/mpi.h" .

复制完这个头文件后,我们必须打开它并找到以下部分的开头:

typedef __int64 MPI_Aint 

在那一行的上面,我们需要添加以下行:

    #include <stdint.h>

这个包含了__int64的定义,这是我们在代码中需要为了正确编译。

最后,将头文件复制到 MinGW 的include文件夹中:

    $ cp mpi.h /mingw64/include

现在我们已经准备好了用 MinGW 进行 MPI 开发所需的库和头文件,可以编译和运行之前的 Hello World 示例,并继续本章的其余部分。

跨节点分发作业

为了在集群中的节点之间分发 MPI 作业,必须将这些节点作为mpirun/mpiexec命令的参数指定,或者使用主机文件。这个主机文件包含网络上将用于运行的节点的名称,以及主机上可用插槽的数量。

在远程节点上运行 MPI 应用程序的先决条件是在该节点上安装了 MPI 运行时,并且已为该节点配置了无密码访问。这意味着只要主节点安装了 SSH 密钥,它就可以登录到每个节点上以在其上启动 MPI 应用程序。

设置 MPI 节点

在节点上安装 MPI 后,下一步是为主节点设置无密码 SSH 访问。这需要在节点上安装 SSH 服务器(在基于 Debian 的发行版中是ssh软件包的一部分)。之后我们需要生成并安装 SSH 密钥。

一个简单的方法是在主节点和其他节点上都有一个共同的用户,并使用 NFS 网络共享或类似的方式在计算节点上挂载主节点上的用户文件夹。这样所有节点都将拥有相同的 SSH 密钥和已知主机文件。这种方法的一个缺点是缺乏安全性。对于连接到互联网的集群,这不是一个很好的方法。

然而,确实很明智的做法是以相同的用户在每个节点上运行作业,以防止可能出现的权限问题,特别是在使用文件和其他资源时。通过在每个节点上创建一个公共用户帐户,并生成 SSH 密钥,我们可以使用以下命令将公钥传输到节点上:

    $ ssh-copy-id mpiuser@node1

或者,我们可以在设置节点时将公钥复制到节点系统上的authorized_keys文件中。如果要创建和配置大量节点,最好使用一个镜像复制到每个节点的系统驱动器上,使用设置脚本,或者可能通过 PXE 引导从镜像引导。

完成了这一步,主节点现在可以登录到每个计算节点上运行作业。

创建 MPI 主机文件

如前所述,为了在其他节点上运行作业,我们需要指定这些节点。最简单的方法是创建一个文件,其中包含我们希望使用的计算节点的名称,以及可选参数。

为了让我们能够使用节点的名称而不是 IP 地址,我们首先必须修改操作系统的主机文件:例如,在 Linux 上是/etc/hosts

    192.168.0.1 master
    192.168.0.2 node0
    192.168.0.3 node1

接下来我们创建一个新文件,这将是用于 MPI 的主机文件:

    master
    node0
    node1

有了这个配置,作业将在计算节点和主节点上执行。我们可以从这个文件中删除主节点以防止这种情况发生。

如果没有提供任何可选参数,MPI 运行时将使用节点上的所有可用处理器。如果需要,我们可以限制这个数字:

    node0 slots=2
    node1 slots=4

假设两个节点都是四核 CPU,这意味着只有 node0 上的一半核心会被使用,而 node1 上的所有核心都会被使用。

运行作业

在多个 MPI 节点上运行 MPI 作业基本上与仅在本地执行相同,就像本章前面的示例一样:

    $ mpirun --hostfile my_hostfile hello_mpi_world

这个命令将告诉 MPI 启动器使用一个名为my_hostfile的主机文件,并在该主机文件中找到的每个节点的每个处理器上运行指定的 MPI 应用程序的副本。

使用集群调度程序

除了使用手动命令和主机文件在特定节点上创建和启动作业之外,还有集群调度程序应用程序。这些通常涉及在每个节点以及主节点上运行一个守护进程。使用提供的工具,可以管理资源和作业,安排分配并跟踪作业状态。

最流行的集群管理调度程序之一是 SLURM,它是 Simple Linux Utility for Resource management 的缩写(尽管现在更名为 Slurm Workload Manager,网站是slurm.schedmd.com/)。它通常被超级计算机以及许多计算机集群使用。其主要功能包括:

  • 为特定用户分配对资源(节点)的独占或非独占访问权限,使用时间段

  • 在一组节点上启动和监视作业,例如基于 MPI 的应用程序

  • 管理挂起作业的队列,以调解对共享资源的争用

设置集群调度程序对于基本的集群操作并不是必需的,但对于更大的集群、同时运行多个作业或希望运行自己的作业的多个用户来说,它可能非常有用。

MPI 通信

在这一点上,我们有一个功能齐全的 MPI 集群,可以用来并行执行基于 MPI 的应用程序(以及其他应用程序)。虽然对于一些任务来说,只需发送几十个或几百个进程并等待它们完成可能是可以的,但很多时候,这些并行进程能够相互通信是至关重要的。

这就是 MPI(即“消息传递接口”)的真正意义所在。在 MPI 作业创建的层次结构中,进程可以以各种方式进行通信和共享数据。最基本的是,它们可以共享和接收消息。

MPI 消息具有以下属性:

  • 一个发送者

  • 一个接收者

  • 消息标签(ID)

  • 消息中的元素计数

  • MPI 数据类型

发送方和接收方应该是相当明显的。消息标签是发送方可以设置的数字 ID,接收方可以使用它来过滤消息,例如,允许对特定消息进行优先处理。数据类型确定消息中包含的信息的类型。

发送和接收函数如下所示:

int MPI_Send( 
         void* data, 
         int count, 
         MPI_Datatype datatype, 
         int destination, 
         int tag, 
         MPI_Comm communicator) 

int MPI_Recv( 
         void* data, 
         int count, 
         MPI_Datatype datatype, 
         int source, 
         int tag, 
         MPI_Comm communicator, 
         MPI_Status* status) 

这里需要注意的有趣的事情是,发送函数中的计数参数指示函数将发送的元素数,而接收函数中的相同参数指示此线程将接受的最大元素数。

通信器指的是正在使用的 MPI 通信器实例,接收函数包含一个最终参数,可以用来检查 MPI 消息的状态。

MPI 数据类型

MPI 定义了许多基本类型,可以直接使用:

MPI 数据类型 C 等效
MPI_SHORT short int
MPI_INT int
MPI_LONG long int
MPI_LONG_LONG long long int
MPI_UNSIGNED_CHAR unsigned char
MPI_UNSIGNED_SHORT unsigned short int
MPI_UNSIGNED unsigned int
MPI_UNSIGNED_LONG unsigned long int
MPI_UNSIGNED_LONG_LONG unsigned long long int
MPI_FLOAT float
MPI_DOUBLE double
MPI_LONG_DOUBLE long double
MPI_BYTE char

MPI 保证使用这些类型时,接收方始终以其期望的格式获取消息数据,而不受字节顺序和其他与平台相关的问题的影响。

自定义类型

除了这些基本格式之外,还可以创建新的 MPI 数据类型。这些使用了许多 MPI 函数,包括MPI_Type_create_struct

int MPI_Type_create_struct( 
   int count,  
   int array_of_blocklengths[], 
         const MPI_Aint array_of_displacements[],  
   const MPI_Datatype array_of_types[], 
         MPI_Datatype *newtype) 

使用此函数,可以创建一个包含结构的 MPI 类型,就像基本的 MPI 数据类型一样:

#include <cstdio> 
#include <cstdlib> 
#include <mpi.h> 
#include <cstddef> 

struct car { 
        int shifts; 
        int topSpeed; 
}; 

int main(int argc, char **argv) { 
         const int tag = 13; 
         int size, rank; 

         MPI_Init(&argc, &argv); 
         MPI_Comm_size(MPI_COMM_WORLD, &size); 

         if (size < 2) { 
               fprintf(stderr,"Requires at least two processes.\n"); 
               MPI_Abort(MPI_COMM_WORLD, 1); 
         } 

         const int nitems = 2; 
         int blocklengths[2] = {1,1}; 
   MPI_Datatype types[2] = {MPI_INT, MPI_INT}; 
         MPI_Datatype mpi_car_type; 
         MPI_Aint offsets[2]; 

         offsets[0] = offsetof(car, shifts); 
         offsets[1] = offsetof(car, topSpeed); 

         MPI_Type_create_struct(nitems, blocklengths, offsets, types, &mpi_car_type); 
         MPI_Type_commit(&mpi_car_type); 

         MPI_Comm_rank(MPI_COMM_WORLD, &rank); 
         if (rank == 0) { 
               car send; 
               send.shifts = 4; 
               send.topSpeed = 100; 

               const int dest = 1; 

         MPI_Send(&send, 1, mpi_car_type, dest, tag, MPI_COMM_WORLD); 

               printf("Rank %d: sent structure car\n", rank); 
         } 

   if (rank == 1) { 
               MPI_Status status; 
               const int src = 0; 

         car recv; 

         MPI_Recv(&recv, 1, mpi_car_type, src, tag, MPI_COMM_WORLD, &status); 
         printf("Rank %d: Received: shifts = %d topSpeed = %d\n", rank, recv.shifts, recv.topSpeed); 
    } 

    MPI_Type_free(&mpi_car_type); 
    MPI_Finalize(); 

         return 0; 
} 

在这里,我们看到了一个名为mpi_car_type的新 MPI 数据类型是如何定义和用于在两个进程之间传递消息的。要创建这样的结构类型,我们需要定义结构中的项目数,每个块中的元素数,它们的字节位移以及它们的基本 MPI 类型。

基本通信

MPI 通信的一个简单示例是从一个进程向另一个进程发送单个值。为了做到这一点,需要使用以下列出的代码,并运行编译后的二进制文件,以启动至少两个进程。这些进程是在本地运行还是在两个计算节点上运行都无所谓。

以下代码感谢从mpitutorial.com/tutorials/mpi-hello-world/借用:

#include <mpi.h> 
#include <stdio.h> 
#include <stdlib.h> 

int main(int argc, char** argv) { 
   // Initialize the MPI environment. 
   MPI_Init(NULL, NULL); 

   // Find out rank, size. 
   int world_rank; 
   MPI_Comm_rank(MPI_COMM_WORLD, &world_rank); 
   int world_size; 
   MPI_Comm_size(MPI_COMM_WORLD, &world_size); 

   // We are assuming at least 2 processes for this task. 
   if (world_size < 2) { 
               fprintf(stderr, "World size must be greater than 1 for %s.\n", argv[0]); 
               MPI_Abort(MPI_COMM_WORLD, 1); 
   } 

   int number; 
   if (world_rank == 0) { 
         // If we are rank 0, set the number to -1 and send it to process 1\. 
               number = -1; 
               MPI_Send(&number, 1, MPI_INT, 1, 0, MPI_COMM_WORLD); 
   }  
   else if (world_rank == 1) { 
               MPI_Recv(&number, 1, MPI_INT, 0, 0,  
                           MPI_COMM_WORLD,  
                           MPI_STATUS_IGNORE); 
               printf("Process 1 received number %d from process 0.\n", number); 
   } 

   MPI_Finalize(); 
} 

这段代码并不复杂。我们通过常规的 MPI 初始化,然后检查确保我们的世界大小至少有两个进程。

然后,等级为 0 的进程将发送一个数据类型为MPI_INT且值为-1的 MPI 消息。等级为1的进程将等待接收此消息。接收进程指定MPI_Status MPI_STATUS_IGNORE,表示进程不会检查消息的状态。这是一种有用的优化技术。

最后,预期的输出如下:

    $ mpirun -n 2 ./send_recv_demo
    Process 1 received number -1 from process 0

在这里,我们使用两个进程开始编译后的演示代码。输出显示第二个进程从第一个进程接收了 MPI 消息,并且值是正确的。

高级通信

对于高级 MPI 通信,可以使用MPI_Status字段来获取有关消息的更多信息。可以使用MPI_Probe在接受MPI_Recv之前发现消息的大小。这对于事先不知道消息大小的情况很有用。

广播

广播消息意味着世界中的所有进程都将接收到它。这简化了广播函数相对于发送函数:

int MPI_Bcast( 
   void *buffer,  
   int count,  
   MPI_Datatype datatype, 
         int root,    
   MPI_Comm comm) 

接收进程将简单地使用普通的MPI_Recv函数。广播函数所做的一切只是优化使用算法发送多条消息,该算法同时使用多个网络链接,而不仅仅是一个。

散射和收集

散射与广播消息非常相似,但有一个非常重要的区别:它不是在每条消息中发送相同的数据,而是将数组的不同部分发送给每个接收者。其函数定义如下:

int MPI_Scatter( 
         void* send_data, 
         int send_count, 
         MPI_Datatype send_datatype, 
         void* recv_data, 
         int recv_count, 
         MPI_Datatype recv_datatype, 
         int root, 
         MPI_Comm communicator) 

每个接收进程将获得相同的数据类型,但我们可以指定将发送到每个进程的项目数(send_count)。这个函数在发送和接收方都会用到,后者只需要定义与接收数据相关的最后一组参数,提供根进程的世界等级和相关的通信器。

收集是散射的反向过程。在这里,多个进程将发送的数据最终到达单个进程,并且这些数据按发送它的进程的等级进行排序。其函数定义如下:

int MPI_Gather( 
         void* send_data, 
         int send_count, 
         MPI_Datatype send_datatype, 
         void* recv_data, 
         int recv_count, 
         MPI_Datatype recv_datatype, 
         int root, 
         MPI_Comm communicator) 

人们可能会注意到,这个函数看起来与散射函数非常相似。这是因为它基本上是以相同的方式工作,只是这一次发送节点必须填写与发送数据相关的参数,而接收进程必须填写与接收数据相关的参数。

这里需要注意的是,recv_count参数与从每个发送进程接收的数据量有关,而不是总大小。

这两个基本函数还有进一步的专业化,但这里不会涉及到。

MPI 与线程

有人可能会认为,在每个集群节点的单个 CPU 核心上分配一个 MPI 应用程序的实例使用 MPI 可能是最简单的方法,这是正确的。然而,这并不是最快的解决方案。

尽管在跨网络的进程间通信方面,MPI 可能是在这种情况下最佳的选择,在单个系统(单个或多 CPU 系统)中使用多线程是非常有意义的。

这样做的主要原因是线程之间的通信明显比进程间通信快得多,特别是在使用诸如 MPI 之类的通用通信层时。

可以编写一个使用 MPI 在集群网络上进行通信的应用程序,其中为每个 MPI 节点分配一个应用程序实例。应用程序本身将检测该系统上的 CPU 核心数量,并为每个核心创建一个线程。因此,混合 MPI 通常被广泛使用,因为它提供了以下优势:

  • 更快的通信 - 使用快速的线程间通信。

  • 更少的 MPI 消息 - 更少的消息意味着带宽和延迟的减少。

  • 避免数据重复 - 数据可以在线程之间共享,而不是向一系列进程发送相同的消息。

实现这一点可以通过前几章中所见的方式来完成,即使用 C++11 和后续版本中找到的多线程特性。另一个选择是使用 OpenMP,就像我们在本章开头看到的那样。

使用 OpenMP 的明显优势在于它对开发人员的工作量几乎没有要求。如果我们需要运行相同例程的更多实例,只需要对代码进行少量修改,标记用于工作线程的代码即可。

例如:

#include <stdio.h>
#include <mpi.h>
#include <omp.h>

int main(int argc, char *argv[]) {
  int numprocs, rank, len;
  char procname[MPI_MAX_PROCESSOR_NAME];
  int tnum = 0, tc = 1;

  MPI_Init(&argc, &argv);
  MPI_Comm_size(MPI_COMM_WORLD, &numprocs);
  MPI_Comm_rank(MPI_COMM_WORLD, &rank);
  MPI_Get_processor_name(procname, &len);

  #pragma omp parallel default(shared) private(tnum, tc) {
      np = omp_get_num_threads();
      tnum = omp_get_thread_num();
      printf("Thread %d out of %d from process %d out of %d on %s\n", 
      tnum, tc, rank, numprocs, procname);
  }

  MPI_Finalize();
}

上述代码将 OpenMP 应用程序与 MPI 结合起来。要编译它,我们可以运行如下命令:

$ mpicc -openmp hellohybrid.c -o hellohybrid

接下来,要运行该应用程序,我们将使用 mpirun 或等效工具:

$ export OMP_NUM_THREADS=8
$ mpirun -np 2 --hostfile my_hostfile -x OMP_NUM_THREADS ./hellohybrid

mpirun 命令将使用 hellohybrid 二进制文件运行两个 MPI 进程,并向每个新进程传递我们使用-x 标志导出的环境变量。然后,OpenMP 运行时将使用该变量中包含的值来创建相应数量的线程。

假设我们的 MPI 主机文件中至少有两个 MPI 节点,我们将在两个节点上分别运行两个 MPI 进程,每个进程运行八个线程,这将适合具有超线程的四核 CPU 或八核 CPU。

潜在问题

在编写基于 MPI 的应用程序并在多核 CPU 或集群上执行时,可能会遇到的问题与我们在前几章中遇到的多线程代码问题非常相似。

然而,MPI 的另一个问题是,它依赖于网络资源的可用性。由于用于MPI_Send调用的发送缓冲区在网络堆栈处理缓冲区之前无法回收,并且此调用是阻塞类型,因此发送大量小消息可能导致一个进程等待另一个进程,而另一个进程又在等待调用完成。

在设计 MPI 应用程序的消息传递结构时,应该牢记这种死锁类型。例如,可以确保一方没有发送调用积累,这将导致这种情况。提供有关队列深度和类似信息的反馈消息可以用来减轻压力。

MPI 还包含使用所谓的屏障的同步机制。这是用于在 MPI 进程之间进行同步的,例如在任务上。使用 MPI 屏障(MPI_Barrier)调用与互斥锁类似,如果 MPI 进程无法实现同步,一切都将在此时挂起。

概要

在本章中,我们详细研究了 MPI 标准,以及其中一些实现,特别是 Open MPI,并且我们看到了如何设置集群。我们还看到了如何使用 OpenMP 轻松地为现有代码添加多线程。

此时,读者应该能够建立一个基本的 Beowulf 或类似的集群,为 MPI 进行配置,并在其上运行基本的 MPI 应用程序。应该知道如何在 MPI 进程之间进行通信以及如何定义自定义数据类型。此外,读者将意识到在为 MPI 编程时可能遇到的潜在问题。

在下一章中,我们将把前几章的知识结合起来,看看如何在最后一章中进行通用计算机图形处理器(GPGPU)的计算。

第十章:使用 GPGPU 进行多线程处理

最近的一个发展是使用视频卡(GPU)进行通用计算(GPGPU)。使用诸如 CUDA 和 OpenCL 之类的框架,可以加速例如在医疗、军事和科学应用中并行处理大型数据集的处理。在本章中,我们将看看如何使用 C++和 OpenCL 来实现这一点,以及如何将这样的功能集成到 C++中的多线程应用程序中。

本章的主题包括:

  • 将 OpenCL 集成到基于 C++的应用程序中

  • 在多线程中使用 OpenCL 的挑战

  • 延迟和调度对多线程性能的影响

GPGPU 处理模型

在第九章中,使用分布式计算进行多线程处理,我们看到在集群系统中跨多个计算节点运行相同的任务。这样设置的主要目标是以高度并行的方式处理数据,从理论上讲,相对于具有较少 CPU 核心的单个系统,可以加快处理速度。

GPGPU(图形处理单元上的通用计算)在某些方面与此类似,但有一个主要区别:虽然只有常规 CPU 的计算集群擅长标量任务--即在一组数据上执行一个任务(SISD)--GPU 是擅长 SIMD(单输入,多数据)任务的矢量处理器。

基本上,这意味着一个人可以将大型数据集发送到 GPU,以及单个任务描述,GPU 将继续在其数百或数千个核上并行执行该数据的部分相同任务。因此,人们可以将 GPU 视为一种非常专业化的集群:

实施

当 GPGPU 的概念首次被提出(大约在 2001 年左右),编写 GPGPU 程序的最常见方式是使用 GLSL(OpenGL 着色语言)和类似的着色器语言。由于这些着色器语言已经针对 SIMD 任务(图像和场景数据)进行了优化,因此将它们调整为更通用的任务相对比较简单。

自那时起,出现了许多更专业的实现:

名称 所有者 备注
CUDA 2006 NVidia 这是专有的,仅在 NVidia GPU 上运行
Close to Metal 2006 ATi/AMD 这被放弃,支持 OpenCL
DirectCompute 2008 Microsoft 这是随 DX11 发布的,可以在 DX10 GPU 上运行,仅限于 Windows 平台
OpenCL 2009 Khronos Group 这是开放标准,适用于所有主流平台上的 AMD、Intel 和 NVidia GPU,以及移动平台

OpenCL

在各种当前的 GPGPU 实现中,由于没有限制,OpenCL 是迄今为止最有趣的 GPGPU API。它适用于几乎所有主流 GPU 和平台,甚至在某些移动平台上也得到支持。

OpenCL 的另一个显着特点是它不仅限于 GPGPU。作为其名称的一部分(开放计算语言),它将系统抽象为所谓的计算设备,每个设备都有自己的功能。GPGPU 是最常见的应用,但这个特性使得在 CPU 上首先进行测试实现变得相当容易,以便进行简单的调试。

OpenCL 的一个可能的缺点是它对内存和硬件细节采用了高度抽象,这可能会对性能产生负面影响,尽管它增加了代码的可移植性。

在本章的其余部分,我们将专注于 OpenCL。

常见的 OpenCL 应用

许多程序包括基于 OpenCL 的代码,以加快操作。这些包括旨在进行图形处理的程序,以及 3D 建模和 CAD、音频和视频处理。一些例子包括:

  • Adobe Photoshop

  • GIMP

  • ImageMagick

  • Autodesk Maya

  • Blender

  • Handbrake

  • Vegas Pro

  • OpenCV

  • Libav

  • Final Cut Pro

  • FFmpeg

在办公应用程序中,包括 LibreOffice Calc 和 Microsoft Excel 中,还发现了某些操作的进一步加速。

也许更重要的是,OpenCL 通常用于科学计算和密码学,包括 BOINC 和 GROMACS 以及许多其他库和程序。

OpenCL 版本

自 2008 年 12 月 8 日发布 OpenCL 规范以来,迄今已经有五次更新,将其升级到 2.2 版本。这些更新中的重要变化如下。

OpenCL 1.0

首次公开发布是由苹果作为 macOS X Snow Leopard 发布的一部分于 2009 年 8 月 28 日发布。

与此同时,AMD 宣布将支持 OpenCL 并淘汰其自己的 Close to Metal(CtM)框架。 NVidia,RapidMind 和 IBM 还为其自己的框架添加了对 OpenCL 的支持。

OpenCL 1.1

OpenCL 1.1 规范于 2010 年 6 月 14 日由 Khronos Group 批准。它为并行编程和性能增加了额外的功能,包括以下内容:

  • 包括 3 组分向量和额外的图像格式在内的新数据类型

  • 处理来自多个主机线程的命令,并在多个设备上处理缓冲区

  • 对缓冲区的区域进行操作,包括读取、写入和复制 1D、2D 或 3D 矩形区域

  • 增强事件的使用来驱动和控制命令执行

  • 额外的 OpenCL 内置 C 函数,如整数夹紧、洗牌和异步步进(不连续,但数据之间有间隙)复制

  • 通过有效共享图像和缓冲区来改进 OpenGL 互操作性,通过链接 OpenCL 和 OpenGL 事件

OpenCL 1.2

OpenCL 1.2 版本于 2011 年 11 月 15 日发布。其最重要的功能包括以下内容:

  • 设备分区:这使应用程序能够将设备分成子设备,直接控制对特定计算单元的工作分配,为高优先级/延迟敏感任务保留设备的一部分,或有效地使用共享硬件资源,如缓存。

  • 对象的分离编译和链接:这提供了传统编译器的功能和灵活性,使得可以创建 OpenCL 程序的库,供其他程序链接。

  • 增强的图像支持:这包括对 1D 图像和 1D 和 2D 图像数组的增强支持。此外,OpenGL 共享扩展现在可以从 OpenGL 1D 纹理和 1D 和 2D 纹理数组创建 OpenCL 图像。

  • 内置内核:这代表了专门或不可编程硬件及相关固件的功能,如视频编码器/解码器和数字信号处理器,使得这些定制设备可以从 OpenCL 框架中驱动并与之紧密集成。

  • DX9 媒体表面共享:这使得 OpenCL 和 DirectX 9 或 DXVA 媒体表面之间的有效共享成为可能。

  • DX11 表面共享:实现 OpenCL 和 DirectX 11 表面之间的无缝共享。

OpenCL 2.0

OpenCL2.0 版本于 2013 年 11 月 18 日发布。此版本具有以下重大变化或增加:

  • 共享虚拟内存:主机和设备内核可以直接共享复杂的、包含指针的数据结构,如树和链表,提供了重要的编程灵活性,并消除了主机和设备之间昂贵的数据传输。

  • 动态并行性:设备内核可以在没有主机交互的情况下将内核排队到同一设备,从而实现灵活的工作调度范例,并避免在设备和主机之间传输执行控制和数据,通常显著减轻主机处理器瓶颈。

  • 通用地址空间:函数可以在不指定参数的命名地址空间的情况下编写,特别适用于声明为指向类型的指针的参数,消除了需要为应用程序中使用的每个命名地址空间编写多个函数的需要。

  • 图像:改进的图像支持,包括 sRGB 图像和 3D 图像写入,内核可以从同一图像读取和写入,以及从 mip-mapped 或多采样 OpenGL 纹理创建 OpenCL 图像以改进 OpenGL 互操作性。

  • C11 原子操作:C11 原子操作和同步操作的子集,可以使一个工作项中的赋值对设备上执行的其他工作项或在设备和主机之间共享数据的工作组可见。

  • 管道:管道是以 FIFO 形式存储数据的内存对象,OpenCL 2.0 提供了内核读取或写入管道的内置函数,可以直接编程管道数据结构,这可以由 OpenCL 实现者进行高度优化。

  • Android 可安装客户端驱动扩展:使得可以在 Android 系统上发现和加载 OpenCL 实现作为共享对象。

OpenCL 2.1

OpenCL 2.1 标准于 2015 年 11 月 16 日发布,这个版本最显著的特点是引入了 OpenCL C++内核语言,就像 OpenCL 语言最初是基于带有扩展的 C 一样,C++版本是基于 C++14 的子集,同时向后兼容 C 内核语言。

OpenCL API 的更新包括以下内容:

  • 子组:这些使得对硬件线程的更精细控制现在已经成为核心,还有额外的子组查询操作,以增加灵活性。

  • 内核对象和状态的复制:clCloneKernel 可以复制内核对象和状态,以安全地实现包装类中的复制构造函数

  • 低延迟设备定时器查询:这允许在设备和主机代码之间对齐分析数据

  • 运行时的中间 SPIR-V 代码

  • LLVM 到 SPIR-V 之间的双向翻译器,以便在工具链中灵活使用这两种中间语言。

  • 通过上述翻译生成 SPIR-V 的 OpenCL C 到 LLVM 编译器。

  • SPIR-V 汇编器和反汇编器。

标准可移植中间表示(SPIR)及其后继者 SPIR-V,是为了在 OpenCL 设备上提供设备无关的二进制文件的一种方式。

OpenCL 2.2

2017 年 5 月 16 日,现在的 OpenCL 版本发布。根据 Khronos Group 的说法,它包括以下更改:

  • OpenCL 2.2 将 OpenCL C++内核语言纳入核心规范,显著增强了并行编程的生产力

  • OpenCL C++内核语言是 C++14 标准的静态子集,包括类、模板、Lambda 表达式、函数重载和许多其他用于通用和元编程的构造

  • 利用全面支持 OpenCL C++内核语言的新 Khronos SPIR-V 1.1 中间语言

  • OpenCL 库函数现在可以利用 C++语言来提供更高的安全性和减少未定义行为,同时访问原子操作、迭代器、图像、采样器、管道和设备队列内置类型和地址空间

  • 管道存储是 OpenCL 2.2 中的一种新的设备端类型,对于 FPGA 实现非常有用,因为它可以在编译时知道连接大小和类型,并能够在内核之间实现高效的设备范围通信

  • OpenCL 2.2 还包括增强生成代码的功能:应用程序可以在 SPIR-V 编译时提供特化常量的值,新的查询可以检测程序范围全局对象的非平凡构造函数和析构函数,用户回调可以在程序释放时设置

  • 可在任何支持 OpenCL 2.0 的硬件上运行(只需要更新驱动程序)

设置开发环境

无论您使用哪个平台和 GPU,进行 OpenCL 开发最重要的部分是从制造商那里获取适用于自己 GPU 的 OpenCL 运行时。在这里,AMD、Intel 和 Nvidia 都为所有主流平台提供 SDK。对于 Nvidia,OpenCL 支持包含在 CUDA SDK 中。

除了 GPU 供应商的 SDK 之外,人们还可以在他们的网站上找到有关该 SDK 支持哪些 GPU 的详细信息。

Linux

在按照提供的说明安装供应商的 GPGPU SDK 后,我们仍然需要下载 OpenCL 头文件。与供应商提供的共享库和运行时文件不同,这些头文件是通用的,可以与任何 OpenCL 实现一起使用。

对于基于 Debian 的发行版,只需执行以下命令行:

    $ sudo apt-get install opencl-headers

对于其他发行版,软件包可能被称为相同的名称,或者是不同的名称。请查阅发行版的手册,了解如何找到软件包的名称。

安装 SDK 和 OpenCL 头文件后,我们就可以编译我们的第一个 OpenCL 应用程序了。

Windows

在 Windows 上,我们可以选择使用 Visual Studio(Visual C++)或 Windows 版的 GCC(MinGW)进行开发。为了与 Linux 版本保持一致,我们将使用 MinGW 以及 MSYS2。这意味着我们将拥有相同的编译器工具链、相同的 Bash shell 和实用程序,以及 Pacman 软件包管理器。

在安装供应商的 GPGPU SDK 后,如前所述,只需在 MSYS2 shell 中执行以下命令行,即可安装 OpenCL 头文件:

    $ pacman -S mingw64/mingw-w64-x86_64-opencl-headers

或者,在使用 32 位 MinGW 版本时,执行以下命令行:

    mingw32/mingw-w64-i686-opencl-headers 

有了这个,OpenCL 头文件就位了。现在我们只需要确保 MinGW 链接器可以找到 OpenCL 库。使用 NVidia CUDA SDK,您可以使用CUDA_PATH环境变量,或浏览 SDK 的安装位置,并将适当的 OpenCL LIB 文件从那里复制到 MinGW lib 文件夹中,确保不要混淆 32 位和 64 位文件。

现在共享库也已经就位,我们可以编译 OpenCL 应用程序。

OS X/MacOS

从 OS X 10.7 开始,OS 中提供了 OpenCL 运行时。安装 XCode 以获取开发头文件和库后,就可以立即开始 OpenCL 开发。

一个基本的 OpenCL 应用程序

一个常见的 GPGPU 应用程序的例子是计算快速傅里叶变换(FFT)。这个算法通常用于音频处理等领域,允许您将例如从时域到频域进行转换,以进行分析。

它的作用是对数据集应用分治法,以计算 DFT(离散傅里叶变换)。它通过将输入序列分成固定的小数量的较小子序列,计算它们的 DFT,并组装这些输出,以组成最终序列。

这是相当高级的数学,但可以说它之所以非常适合 GPGPU,是因为它是一个高度并行的算法,采用数据的分割来加速 DFT 的计算,如图所示:

每个 OpenCL 应用程序至少由两部分组成:设置和配置 OpenCL 实例的 C++代码,以及实际的 OpenCL 代码,也称为内核,例如基于维基百科 FFT 演示示例的这个。

// This kernel computes FFT of length 1024\.  
// The 1024 length FFT is decomposed into calls to a radix 16 function,  
// another radix 16 function and then a radix 4 function
 __kernel void fft1D_1024 (__global float2 *in,  
                     __global float2 *out,  
                     __local float *sMemx,  
                     __local float *sMemy) {
          int tid = get_local_id(0);
          int blockIdx = get_group_id(0) * 1024 + tid;
          float2 data[16];

          // starting index of data to/from global memory
          in = in + blockIdx;  out = out + blockIdx;

          globalLoads(data, in, 64); // coalesced global reads
          fftRadix16Pass(data);      // in-place radix-16 pass
          twiddleFactorMul(data, tid, 1024, 0);

          // local shuffle using local memory
          localShuffle(data, sMemx, sMemy, tid, (((tid & 15) * 65) + (tid >> 4)));
          fftRadix16Pass(data);               // in-place radix-16 pass
          twiddleFactorMul(data, tid, 64, 4); // twiddle factor multiplication

          localShuffle(data, sMemx, sMemy, tid, (((tid >> 4) * 64) + (tid & 15)));

          // four radix-4 function calls
          fftRadix4Pass(data);      // radix-4 function number 1
          fftRadix4Pass(data + 4);  // radix-4 function number 2
          fftRadix4Pass(data + 8);  // radix-4 function number 3
          fftRadix4Pass(data + 12); // radix-4 function number 4

          // coalesced global writes
    globalStores(data, out, 64);
 } 

这个 OpenCL 内核表明,与 GLSL 着色器语言一样,OpenCL 的内核语言本质上是 C 语言,具有许多扩展。虽然可以使用 OpenCL C++内核语言,但这个语言仅在 OpenCL 2.1(2015 年)之后才可用,因此对它的支持和示例比 C 内核语言更少。

接下来是 C++应用程序,使用它,我们运行前面的 OpenCL 内核:

#include <cstdio>
 #include <ctime>
 #include "CL\opencl.h"

 #define NUM_ENTRIES 1024

 int main() { // (int argc, const char * argv[]) {
    const char* KernelSource = "fft1D_1024_kernel_src.cl"; 

在这里,我们可以看到,我们只需要包含一个头文件,就可以访问 OpenCL 函数。我们还要指定包含我们 OpenCL 内核源代码的文件的名称。由于每个 OpenCL 设备可能是不同的架构,当我们加载内核时,内核会被编译为目标设备:

          const cl_uint num = 1;
    clGetDeviceIDs(0, CL_DEVICE_TYPE_GPU, 0, 0, (cl_uint*) num); 

   cl_device_id devices[1];
    clGetDeviceIDs(0, CL_DEVICE_TYPE_GPU, num, devices, 0);

接下来,我们必须获取可以使用的 OpenCL 设备列表,并通过 GPU 进行过滤:

    cl_context context = clCreateContextFromType(0, CL_DEVICE_TYPE_GPU,  
                                                   0, 0, 0); 

然后,我们使用找到的 GPU 设备创建一个 OpenCLcontext。上下文管理一系列设备上的资源:

    clGetDeviceIDs(0, CL_DEVICE_TYPE_DEFAULT, 1, devices, 0);
    cl_command_queue queue = clCreateCommandQueue(context, devices[0], 0, 0); 

最后,我们将创建包含要在 OpenCL 设备上执行的命令的命令队列:

    cl_mem memobjs[] = { clCreateBuffer(context, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR, sizeof(float) * 2 * NUM_ENTRIES, 0, 0),              
   clCreateBuffer(context, CL_MEM_READ_WRITE, sizeof(float) * 2 * NUM_ENTRIES, 0, 0) }; 

为了与设备通信,我们需要分配缓冲区对象,这些对象将包含我们将复制到它们的内存中的数据。在这里,我们将分配两个缓冲区,一个用于读取,一个用于写入:

    cl_program program = clCreateProgramWithSource(context, 1, (const char **)& KernelSource, 0, 0); 

现在我们已经将数据放在设备上,但仍需要在设备上加载内核。为此,我们将使用前面查看的 OpenCL 内核源代码创建一个内核,使用我们之前定义的文件名:

    clBuildProgram(program, 0, 0, 0, 0, 0); 

接下来,我们将按以下方式编译源代码:

   cl_kernel kernel = clCreateKernel(program, "fft1D_1024", 0); 

最后,我们将从我们创建的二进制文件中创建实际的内核:

    size_t local_work_size[1] = { 256 };

    clSetKernelArg(kernel, 0, sizeof(cl_mem), (void *) &memobjs[0]);
    clSetKernelArg(kernel, 1, sizeof(cl_mem), (void *) &memobjs[1]);
    clSetKernelArg(kernel, 2, sizeof(float) * (local_work_size[0] + 1) * 16, 0);
    clSetKernelArg(kernel, 3, sizeof(float) * (local_work_size[0] + 1) * 16, 0); 

为了将参数传递给我们的内核,我们必须在这里设置它们。在这里,我们将添加指向我们缓冲区的指针和工作大小的维度:

    size_t global_work_size[1] = { 256 };
          global_work_size[0] = NUM_ENTRIES;
    local_work_size[0]  =  64;  // Nvidia: 192 or 256
    clEnqueueNDRangeKernel(queue, kernel, 1, 0, global_work_size, local_work_size, 0, 0, 0); 

现在我们可以设置工作项维度并执行内核。在这里,我们将使用一种内核执行方法,允许我们定义工作组的大小:

          cl_mem C = clCreateBuffer(context, CL_MEM_WRITE_ONLY, (size), 0, &ret);
                      cl_int ret = clEnqueueReadBuffer(queue, memobjs[1], CL_TRUE, 0, sizeof(float) * 2 * NUM_ENTRIES, C, 0, 0, 0); 

执行内核后,我们希望读取生成的信息。为此,我们告诉 OpenCL 将分配的写缓冲区复制到新分配的缓冲区中。现在我们可以自由地使用这个缓冲区中的数据。

然而,在这个例子中,我们不会使用这些数据:

    clReleaseMemObject(memobjs[0]);
    clReleaseMemObject(memobjs[1]); 
   clReleaseCommandQueue(queue); 
   clReleaseKernel(kernel); 
   clReleaseProgram(program); 
   clReleaseContext(context); 
   free(C);
 } 

最后,我们释放分配的资源并退出。

GPU 内存管理

在使用 CPU 时,我们必须处理多层内存层次结构,从主内存(最慢)到 CPU 缓存(更快),再到 CPU 寄存器(最快)。GPU 也是如此,我们必须处理一个可能会显著影响应用程序速度的内存层次结构。

在 GPU 上最快的也是寄存器(或私有)内存,我们拥有的比平均 CPU 多得多。之后是本地内存,这是一种由多个处理单元共享的内存。GPU 本身上最慢的是内存数据缓存,也称为纹理内存。这是卡上的一个内存,通常被称为视频 RAM(VRAM),使用高带宽,但相对高延迟的内存,比如 GDDR5。

绝对最慢的是使用主机系统的内存(系统 RAM),因为这需要通过 PCIe 总线和其他各种子系统传输数据。相对于设备内存系统,主机设备通信最好称为“冰川”。

对于 AMD、Nvidia 和类似的专用 GPU 设备,内存架构可以像这样进行可视化:

由于这种内存布局,建议以大块传输任何数据,并在可能的情况下使用异步传输。理想情况下,内核将在 GPU 核心上运行,并将数据流式传输到它,以避免任何延迟。

GPGPU 和多线程

将多线程代码与 GPGPU 结合使用要比尝试管理在 MPI 集群上运行的并行应用程序容易得多。这主要是由于以下工作流程:

  1. 准备数据:准备要处理的数据,比如大量的图像或单个大图像,将其发送到 GPU 的内存中。

  2. 准备内核:加载 OpenCL 内核文件并将其编译为 OpenCL 内核。

  3. 执行内核:将内核发送到 GPU 并指示它开始处理数据。

  4. 读取数据:一旦我们知道处理已经完成,或者已经达到特定的中间状态,我们将读取我们作为 OpenCL 内核参数传递的缓冲区,以获取我们的结果。

由于这是一个异步过程,可以将其视为一种“发射并忘记”的操作,只需有一个专用线程来监视活动内核的过程。

在多线程和 GPGPU 应用方面最大的挑战不在于基于主机的应用程序,而是在于运行在 GPU 上的 GPGPU 内核或着色器程序,因为它必须在本地和远程处理单元之间协调内存管理和处理,确定根据数据类型使用哪种内存系统,而不会在处理其他地方引起问题。

这是一个需要大量试错、分析和优化的细致过程。一个内存复制优化或使用异步操作而不是同步操作可能会将处理时间从几个小时减少到几分钟。对内存系统的良好理解对于防止数据饥饿和类似问题至关重要。

由于 GPGPU 通常用于加速持续时间显著的任务(几分钟到几小时甚至更长),因此最好从多线程的角度来看待它,尽管存在一些重要的复杂性,主要是延迟的形式。

延迟

正如我们在早期关于 GPU 内存管理的部分中提到的,最好首先使用最接近 GPU 处理单元的内存,因为它们是最快的。这里的最快主要意味着它们具有较低的延迟,意味着从内存请求信息到接收响应所花费的时间。

确切的延迟会因 GPU 而异,但以 Nvidia 的 Kepler(Tesla K20)架构为例,可以期望延迟为:

  • 全局内存:450 个周期。

  • 常量内存缓存:45-125 个周期。

  • 本地共享)内存:45 个周期。

这些测量都是在 CPU 本身上进行的。对于 PCIe 总线,一旦开始传输多兆字节的缓冲区,一个传输可能需要几毫秒的时间。例如,填充 GPU 的内存以千兆字节大小的缓冲区可能需要相当长的时间。

对于通过 PCIe 总线的简单往返,延迟可以用微秒来衡量,对于以 1+ GHz 运行的 GPU 核心来说,似乎是一段漫长的时间。这基本上定义了为什么主机和 GPU 之间的通信应该绝对最小化并且高度优化。

潜在问题

GPGPU 应用的一个常见错误是在处理完成之前读取结果缓冲区。在将缓冲区传输到设备并执行内核之后,必须插入同步点以通知主机处理已经完成。这些通常应该使用异步方法实现。

正如我们在延迟部分中所介绍的,重要的是要记住请求和响应之间可能存在非常大的延迟,这取决于内存子系统或总线。不这样做可能会导致奇怪的故障、冻结和崩溃,以及数据损坏和似乎永远等待的应用程序。

对于 GPGPU 应用进行分析是至关重要的,以便了解 GPU 利用率如何,以及流程是否接近最佳状态。

调试 GPGPU 应用

GPGPU 应用的最大挑战是调试内核。CUDA 出于这个原因带有一个模拟器,它允许在 CPU 上运行和调试内核。OpenCL 允许在 CPU 上运行内核而无需修改,尽管这可能不会得到与在特定 GPU 设备上运行时相同的行为(和错误)。

一个稍微更高级的方法涉及使用专用调试器,例如 Nvidia 的 Nsight,它有适用于 Visual Studio(developer.nvidia.com/nvidia-nsight-visual-studio-edition)和 Eclipse(developer.nvidia.com/nsight-eclipse-edition)的版本。

根据 Nsight 网站上的营销宣传:

NVIDIA Nsight Visual Studio Edition 将 GPU 计算引入了 Microsoft Visual Studio(包括 VS2017 的多个实例)。这个 GPU 的应用程序开发环境允许您构建、调试、分析和跟踪使用 CUDA C/C++、OpenCL、DirectCompute、Direct3D、Vulkan API、OpenGL、OpenVR 和 Oculus SDK 构建的异构计算、图形和虚拟现实应用程序。

以下截图显示了一个活跃的 CUDA 调试会话:

这样一个调试工具的一个很大的优势是,它允许用户通过识别瓶颈和潜在问题来监视、分析和优化自己的 GPGPU 应用程序。

总结

在本章中,我们看了如何将 GPGPU 处理集成到 C++应用程序中,以 OpenCL 的形式。我们还研究了 GPU 内存层次结构以及这如何影响性能,特别是在主机设备通信方面。

现在你应该熟悉 GPGPU 的实现和概念,以及如何创建一个 OpenCL 应用程序,以及如何编译和运行它。如何避免常见错误也应该是已知的。

作为本书的最后一章,希望所有主要问题都已得到解答,并且前面的章节以及本章在某种程度上都是有益的和有帮助的。

从这本书开始,读者可能对更详细地探究其中任何一个主题感兴趣,而在线和离线都有许多资源可用。多线程和相关领域的主题非常广泛,涉及到许多应用,从商业到科学、艺术和个人应用。

读者可能想要建立自己的 Beowulf 集群,或者专注于 GPGPU,或者将两者结合起来。也许有一个复杂的应用程序他们想要写一段时间了,或者只是想玩编程。

posted @ 2024-05-05 00:04  绝不原创的飞龙  阅读(188)  评论(0编辑  收藏  举报