C++ 中的并行编程:第一部分。






4.76/5 (24投票s)
本文将帮助您开始学习并发编程,并介绍 C++ 支持并发编程的特性。
引言
C++ 最初被设计为仅支持单线程编程。在每个应用程序中,都有一个默认线程。执行控制会直接跳转到 main()
函数(默认线程的入口点),程序会以一种相当顺序的方式执行(一个计算完成后才开始下一个),故事就到这里。但是,那是石器时代的说法了。多处理器和多核系统的出现,使得我们单线程、顺序的程序对它们来说变得不优化。因此,需要有效地利用计算资源,而这正是并发编程出现的地方。并发编程允许多个线程的执行,因此我们可以通过利用计算机系统中可用的任何并行性来编写高效的程序。C++11 承认了多线程程序的存在,后来的标准也带来了一些改进。在本系列文章中,我将不仅讨论并发编程是什么,还将探讨 C++ 标准 11、14 和 17 为支持并发编程带来的特性。如果您对并发编程的概念本身不熟悉,或者只是对 C++ 中的实现方式感兴趣,那么两者都是我的目标读者。所以请继续关注,最终您将领会它的精髓。
注意:本文假设您已经具备 C++ 的知识、熟悉度和舒适度。
并发与并发编程
并发是指在同一时间执行多个任务的想法。这可以通过在单个 CPU 核心上以时间共享的方式(意味着“多任务处理”)实现,或者在多个 CPU 核心的情况下并行实现(并行处理)。
并发程序是指提供一个以上并行运行的执行路径的程序,或者简单地说,是实现并发的程序。这些执行路径通过线程来管理,这些线程并发执行并协同工作以执行某些任务。
线程描述了代码的执行路径。每个线程都属于一个进程(一个正在执行的程序的实例,拥有内存、文件句柄等资源),因为它在执行进程的代码,使用其资源,并且一个进程内部可以同时执行多个线程。每个进程至少必须有一个线程,它表示主要的执行路径。
并发与并行
在讨论计算和处理时,并发和并行似乎非常相似。它们密切相关,因为它们可以利用相同的软件和硬件资源,但又是不同的概念,所以我认为有必要在此强调区别。
并发通过在其程序中分配多个控制流来构建程序。概念上,这些流是并行执行的,尽管事实上,它们可以在一组处理器(核心)上执行,或者在具有上下文切换(将 CPU 从一个进程或线程切换到另一个)的一个处理器上执行。这种方法也称为多线程。而真正的并行是通过并行编程实现的,它旨在通过使用多个设备(处理器、核心或分布式系统中的计算机)来加速特定任务的执行。任务被分成几个可以独立执行并使用自己资源的子任务。一旦所有子任务都完成,它们的将合并结果。实践中,这两种方法可以结合起来互补。例如,分配独立的并发子任务既有助于构建程序,也有助于提高其性能。
为什么要并发?
速度:直到 2002 年,制造更大、更快的处理器是趋势,根据摩尔定律,处理器速度每 18 个月翻一番。但此后,范式转向将较慢的处理器组合在一起(多核处理器)。还引入了 GPU(图形处理器),其核心数量可达数百甚至数千,用于大规模并行架构,旨在同时处理多个功能。现在,如果我们坚持使用完全不并发的旧式程序,那么只会使用一个核心或一个线程,而您的 CPU 的其余部分将处于空闲状态。这就是我们需要考虑并发编程以利用并行硬件的地方。并发程序可以利用计算机系统中可用的任何并行性,从而使执行更有效率。即使在单 CPU 的情况下,并发也能防止一个活动在等待 I/O 时阻塞另一个活动,从而可以极大地提高速度。
可用性与分布:环境也可能施加一些外部驱动力。例如,在现实世界的系统中,许多事情同时发生,因此软件必须“实时”处理。这要求软件具有响应性,并能在随机时间响应外部生成的事件。现在,将系统划分为可以单独处理每个事件的并发块可能更简单。
可控性:并发也提高了系统的可控性,因为函数可以由其他并发函数启动、停止或中断。当处理顺序程序时,这种功能很难实现。
现在我们已经对并发有了基本的了解,并知道为什么应该考虑它,让我们转向 C++ 的具体细节。
线程库
如前所述,在 C++11 之前,C++ 没有标准的支持并发编程的机制。您必须依赖于特定于操作系统的库,例如 Linux 的 pthread
或 Windows 上的Windows API。尽管概念相同,但它们的实现方式却有显著差异。标准 C++11 引入了多线程功能,以满足对可移植并发代码的需求,该功能包含两部分:
标准线程 API 通过 <thread>
头文件为您提供了一种可移植的方式来处理线程,而内存模型则为线程在共享内存数据时应遵循的规则设定了规则。
C++14 没有带来太多新功能,但引入了读写锁以最小化瓶颈。C++17 则进一步丰富了内容,使标准模板库的大部分算法都提供了并行版本。尽管如此,这只是对 C++ 为支持并发编程提供的功能的一个简要概述,并且不要忘记我们稍后将在文章中探讨的同步、原子变量和高级异步编程。让我们开始通过多线程来实际看看。
注意:要进行动手实践,无需任何特殊的环境设置。您只需要一个文本编辑器和一个支持 C++11、14 和 17 功能的最新 C++ 编译器。代码已在 Windows(使用 Visual Studio 2017 社区版)和 Linux(Ubuntu LTS18.04)g++ 编译器平台上进行测试。它应该(也应该)也能正常工作。您也可以使用任何在线编译器来测试代码。
多线程
线程基本上是一个轻量级的子进程。它是程序内部运行的一个独立任务,可以启动、中断和停止。下图说明了线程的生命周期。
一个进程(指拥有数据、代码、资源,如内存、地址空间和每个进程的状态信息等的正在执行的程序实例)中可以同时执行多个线程(这指的是多线程的概念),并且共享数据、代码和资源,例如内存、地址空间和进程状态信息。每个 C++ 应用程序都有一个主线程,即 main
,可以通过创建 std::thread
类的对象来创建额外的线程。
void func() {
std::cout << “Concurrent execution in C++ ” << std::endl;
}
int main() {
std::thread t1(func);
t1.join();
return 0;
}
让我们在此分解一下。我们创建了一个简单的函数 func
,它只是在屏幕上打印一行,没有什么特别之处。std::thread t1(func)
将会启动一个执行我们函数的线程,而 join()
将确保等待直到 t1
终止。让我们对上面的程序做一些修改来检查工作流程。
void func()
{
for (int i=0; i < 5; i++) {
std::cout << "Concurrent execution of thread in C++" << std::endl;
}
}
int main()
{
std::thread t1(func);
std::cout << "Main function thread executing..." << std::endl;
t1.join();
std::cout << "Main function exiting..." << std::endl;
return 0;
}
上面的函数输出看起来是这样的。您的输出可能会略有不同。主函数线程的执行可能在开始时,也可能在任何地方。原因是执行没有严格的“顺序”,而是两个线程同时执行(这不就是并发的意义所在吗!),但 main
函数总会在最后退出。join()
确保 t1
在 main
函数退出之前终止。
连接和分离线程
当您创建线程时,您需要告诉编译器您将与之建立什么样的关系。std::thread
类的析构函数会检查它是否仍有 OS 线程与其关联,如果有,则会中止程序。尝试通过删除 join()
来实验上面的代码,它会编译通过,但在运行时会中止。为了避免中止,您必须连接(join)一个线程或分离(detach)它。
join
:等待线程完成,并阻塞直到线程执行完成。
detach
:让线程自行执行,您不再需要介入。
您可以通过调用 joinable()
成员函数来检查线程是否可连接。如果线程可连接,则此函数返回 true
,否则返回 false
(一个线程不能被连接多次,或者已经被分离)。在连接线程之前可以应用一个简单的检查。观察以下代码:
std::thread t1(func);
if (t1.joinable())
{
t1.join();
}
但是,为什么要费心应用检查,而您还有另一个选项来使执行发生呢?当然,如果我们用 detach()
替换 join()
,我们的示例会正常工作。但问题在于,detach()
会使线程在后台运行,这可能导致守护线程(Linux 术语)。您不知道分离的线程何时会完成,因此在使用引用或指针时需要格外小心(因为当线程完成执行时,对象可能会超出作用域)。这就是为什么在大多数情况下 join()
是首选的原因。
创建线程的其他方法
使用函数只是创建和初始化线程的一种方式。您还可以使用仿函数(重载了 ()
运算符的类函数对象)。这可以这样完成:
class functor_thread
{
public:
void operator()()
{
std::cout << "Thread executing from function object" << std::endl;
}
};
现在,我们只需要通过将上述定义的类的对象传递给线程构造函数来初始化线程。
int main () {
functor_thread func;
std::thread t1(func);
if(t1.joinable())
{
t1.join();
}
return 0;
}
您还可以使用 lambda 表达式(函数式方法)来实现相同的功能。
std::thread t1([]{
std::cout<<"Thread executing from lambda expression…. "<<std::endl;
});
t1.join();
识别线程
现在我们正在处理多个线程,出于锁定和调试的原因,我们需要一种方法来区分不同的线程。每个 std::thread
对象都有一个唯一的标识符,可以使用 std::thread
类的成员函数获取。
std::thread::get_id()
需要注意的是,如果没有关联的线程,get_id()
将返回默认构造的 std::thread::id
对象,而不是任何线程的 ID。让我们看一个例子来阐明这个概念。
void func()
{ }
int main()
{
std::thread t1(func);
std::thread t2(func);
std::cout << "Main Thread ID is " << std::this_thread::get_id() << std::endl;
std::cout << "First Thread ID is " << t1.get_id() << std::endl;
std::cout << "Second Thread ID is " << t2.get_id() << std::endl;
t1.join();
t2.join();
return 0;
}
让我们在这里稍作停顿,谈谈 std::this_thread
。它不是成员函数,而是一个包含与线程相关的全局函数的命名空间。以下是一些属于此命名空间的函数。
get_id() | this_thread::get_id() 返回当前线程的 ID |
yield() | 挂起当前线程,以便另一个线程可以运行 |
sleep_for() | 线程将休眠指定的持续时间 |
sleep_until() | 线程将休眠直到指定的时间点 |
priority() | 获取当前线程的优先级 |
我们已经看到过 get_id()
的实际应用。您可以在此处阅读有关该命名空间的更多信息,或者随时探索该命名空间并尝试其他函数。
向线程传递参数
到目前为止,我们只使用了函数和对象创建了线程,但没有传递任何参数。可以向线程传递参数吗?让我们看看。
void print(int n, const std::string &str)
{
std::cout<<"Printing passed number… "<< n <<std::endl;
std:: cout << “Printing passed string…” << str << std::endl;
}
我们创建了一个函数,向该函数传递一个整数作为参数。让我们尝试像以前一样通过调用线程来调用该函数。
int main()
{
std::thread t1(print, 10, “some string”);
t1.join();
return 0;
}
它奏效了。要将参数传递给线程的可调用对象,只需向线程构造函数传递额外的参数即可。默认情况下,所有参数都会被复制到新线程的内部存储中,以便可以无问题地访问它们。但是,如果我们要通过引用传递它呢?C++ 提供了 std::ref()
包装器来通过引用将参数传递给线程。我将通过一个例子来解释。
void func (int &n)
{
n += 1;
std::cout << “Number inside thread is ” << n << std::endl;
}
int main()
{
int n = 15;
std::cout << “Before executing external thread, number is ” << n << std::endl;
std::thread t1(func, std::ref(n));
t1.join();
std::cout << “After executing eternal thread, number is ” << n << std::endl;
return 0;
}
现在应该清楚的是,如果我们不在上面的情况下按引用传递参数,那么即使在外部线程执行之后,数字仍然是 15
。您可以自己尝试并亲自看看。
移动语义 (std::move())
是时候来点更酷的东西了。让我们比较一下我们最后一个例子中的函数。
void print(int n, const std::string &str)
void func (int &n)
请注意,我们在第一个函数中通过引用传递了我们的 string
字面量,但我们不需要使用 std::ref()
就可以访问它,而我们处理整数的方法则不同。原因是 string
字面量将被用于创建一个临时字符串对象,它满足了对 const
引用的需求,而整数需要用 std::ref()
包装才能按引用传递。
虽然乍一看似乎不需要使用任何包装器很好,但临时对象的创建就像一个顽固的瑕疵,容易拖慢程序的运行速度。C++ 引入了移动语义来代替使用 rvalue
引用(表达式是 rvalue
当它产生一个临时对象时)来复制昂贵的对象并传递临时对象。例如,这是一个期望传递 rvalue
引用的函数。
void func(std::string &&str)
{
std::cout << “Moving semantics in action”<< std::endl;
}
我可以使用如下方式调用它:
std::string str = “Some String”;
std::thread t1(func, std::move(str));
现在,调用 move()
将会把 string
str
移动到函数中,而不是复制它。这里还有另一件有趣的事情:std::thread
本身是可移动的,但不可复制的。这意味着您可以将 OS 线程的所有权在 std::thread
对象之间传递,但任何时候只有一个实例可以拥有该线程。
std::thread t1(func);
std::thread t2 = std::move(t1);
t1
正在执行我们的函数 func
,现在我想创建另一个线程 t2
,move()
将把线程 t1
的所有权移动到 t2
(t2
现在管理着 OS 线程)。如果您尝试将一个已经管理着某个 OS 线程的线程赋给另一个,您将收到一个异常。
但我们能创建多少线程?(hardware_concurrency())
我们一直在讨论创建和操作线程,但具体能创建多少个呢?这就是所谓的硬件并发。您可以在您的机器上获取数字,如下所示:
std::cout << std::thread::hardware_concurrency() << std::endl;
显示的数字可能是您机器上的核心数,对我来说是 4
。但这不重要。理论上,您可以创建任意多的线程,只要您的机器有足够的内存,但那时会出现这种情况,即您的并发代码的执行速度会比顺序代码慢得多,所以真正的问题是您应该创建多少线程。要回答这个问题,需要考虑许多因素。请容我稍稍理论一下。
计算密集型程序
如果您的代码是进行大量计算和处理大量数据的,那么建议的线程数应该小于或等于您机器上的核心数。
No. of threads <= No. of cores
I/O 密集型程序
另一方面,如果您的代码是 I/O 密集型的,例如,将读取或写入大量数据,那么建议的线程数如下:
No. of threads <= No. of cores / 1- blocking factor
其中阻塞因子是任务将被阻塞的时间。我强调这个数字的原因是,如果您超过了这个限制,您的程序性能下降的可能性会更大。仅仅因为您可以创建任意多的线程,并不意味着您就应该这样做。
下一步是什么?
到目前为止,我们只看到了如何创建和操作线程。但我们还没有讨论“数据在线程之间共享”的部分。同时访问同一资源可能会导致许多错误,从而产生混乱。考虑一个简单的场景,您想递增一个数字并输出结果。
void f() {
++n;
}
现在,当并发执行上述函数时,线程将访问相同的内存位置,一个或多个线程可能会修改该内存位置中的数据,从而导致意外结果,并且输出变得不确定。这被称为竞态条件。现在的问题是如何避免、查找和重现上述条件(如果已经发生)。这将在我本系列的下一篇文章中继续介绍。在此之前,请随意自行探索和学习。