背景和介绍
几十年来,C和C ++标准将多线程和并发视为标准领域之外存在的东西 - 在标准所针对的“抽象机器”所涵盖的“依赖于目标”的阴影世界中。立即冷血的回复“C ++不知道什么是线程”在邮件列表的山区和处理并行性的新闻组问题将永远作为对过去的提醒。
但所有这些都是用C ++ 11结束的。C ++标准委员会意识到语言将无法长时间保持相关性,除非它与时间保持一致并最终认识到线程,同步机制,原子操作和内存模型的存在 - 就在标准中,迫使C ++编译器和库供应商为所有支持的平台实现这些。这就是恕我直言,这是该语言的C ++ 11版本提供的大量改进的最大积极变化之一。
这篇文章不是关于C ++ 11线程的教程,但它使用它们作为主要的线程机制来演示它的观点。它从一个基本的例子开始,然后迅速转向线程亲和力,硬件拓扑和超线程的性能影响的专业领域。它在可移植的C ++中尽可能地做到了,清楚地标记了对特定于平台的调用的偏差。
逻辑CPU,内核和线程
大多数现代机器都是多CPU的。当然,这些CPU是否分为插槽和硬件核心取决于机器,但操作系统会看到许多可以同时执行任务的“逻辑”CPU。
在Linux上获取此信息的最简单方法是cat / proc / cpuinfo,它按顺序列出系统的CPU,提供有关每个CPU的一些信息(例如当前频率,缓存大小等)。在我的(8-CPU)机器上:
$ cat /proc/cpuinfo
processor : 0
vendor_id : GenuineIntel
cpu family : 6
model : 60
model name : Intel(R) Core(TM) i7-4771 CPU @ 3.50GHz
[...]
stepping : 3
microcode : 0x7
cpu MHz : 3501.000
cache size : 8192 KB
physical id : 0
siblings : 8
core id : 0
cpu cores : 4
apicid : 0
[...]
processor : 1
vendor_id : GenuineIntel
cpu family : 6
[...]
[...]
processor : 7
vendor_id : GenuineIntel
cpu family : 6
可以从lscpu获取摘要输出:
$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 8
On-line CPU(s) list: 0-7
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 1
NUMA node(s): 1
Vendor ID: GenuineIntel
CPU family: 6
Model: 60
Stepping: 3
CPU MHz: 3501.000
BogoMIPS: 6984.09
Virtualization: VT-x
L1d cache: 32K
L1i cache: 32K
L2 cache: 256K
L3 cache: 8192K
NUMA node0 CPU(s): 0-7
每个CPU启动一个线程
C ++ 11线程库优雅地提供了一个实用程序函数,我们可以使用它来查找机器有多少CPU,以便我们可以规划并行策略。该函数称为hardware_concurrency,这是一个完整的示例,使用它来启动适当数量的线程。以下只是一个代码片段; 可以在此存储库中找到此帖子的完整代码示例以及适用于Linux的Makefile 。
int main(int argc, const char** argv) {
unsigned num_cpus = std::thread::hardware_concurrency();
std::cout << "Launching " << num_cpus << " threads\n";
// A mutex ensures orderly access to std::cout from multiple threads.
std::mutex iomutex;
std::vector<std::thread> threads(num_cpus);
for (unsigned i = 0; i < num_cpus; ++i) {
threads[i] = std::thread([&iomutex, i] {
{
// Use a lexical scope and lock_guard to safely lock the mutex only for
// the duration of std::cout usage.
std::lock_guard<std::mutex> iolock(iomutex);
std::cout << "Thread #" << i << " is running\n";
}
// Simulate important work done by the tread by sleeping for a bit...
std::this_thread::sleep_for(std::chrono::milliseconds(200));
});
}
for (auto& t : threads) {
t.join();
}
return 0;
}
甲的std ::线程是围绕一个平台特定的线程对象的薄包装纸; 这是我们很快就会利用的东西。所以当我们启动一个std :: thread时,会启动实际的OS线程。这是相当低级别的线程控制,但在本文中,我不会绕过基于任务的并行性等更高级别的构造,将此留待将来发布。
线程亲和力
因此,我们知道如何查询系统的CPU数量,以及如何启动任意数量的线程。现在让我们做一些更先进的事情。
所有现代操作系统都支持为每个线程设置CPU 关联。亲和性意味着不要在任何CPU上自由运行线程,而是要求OS调度程序仅将给定线程调度到单个CPU或预定义的CPU集。默认情况下,关联性涵盖系统中的所有逻辑CPU,因此操作系统可以根据其调度注意事项为任何线程选择任何一个。此外,如果对调度程序有意义,操作系统有时会在CPU之间迁移线程(尽管它应该尝试通过丢失迁移线程的核心上的热缓存来减少迁移)。让我们用另一个代码示例观察这个:
int main(int argc, const char** argv) {
constexpr unsigned num_threads = 4;
// A mutex ensures orderly access to std::cout from multiple threads.
std::mutex iomutex;
std::vector<std::thread> threads(num_threads);
for (unsigned i = 0; i < num_threads; ++i) {
threads[i] = std::thread([&iomutex, i] {
while (1) {
{
// Use a lexical scope and lock_guard to safely lock the mutex only
// for the duration of std::cout usage.
std::lock_guard<std::mutex> iolock(iomutex);
std::cout << "Thread #" << i << ": on CPU " << sched_getcpu() << "\n";
}
// Simulate important work done by the tread by sleeping for a bit...
std::this_thread::sleep_for(std::chrono::milliseconds(900));
}
});
}
for (auto& t : threads) {
t.join();
}
return 0;
}
此示例启动四个无限循环的线程,休眠并报告它们运行的CPU。报告是通过sched_getcpu函数完成的(特定于glibc - 其他平台将具有其他具有类似功能的API)。这是一个示例运行:
$ ./launch-threads-report-cpu
Thread #0: on CPU 5
Thread #1: on CPU 5
Thread #2: on CPU 2
Thread #3: on CPU 5
Thread #0: on CPU 2
Thread #1: on CPU 5
Thread #2: on CPU 3
Thread #3: on CPU 5
Thread #0: on CPU 3
Thread #2: on CPU 7
Thread #1: on CPU 5
Thread #3: on CPU 0
Thread #0: on CPU 3
Thread #2: on CPU 7
Thread #1: on CPU 5
Thread #3: on CPU 0
Thread #0: on CPU 3
Thread #2: on CPU 7
Thread #1: on CPU 5
Thread #3: on CPU 0
^C
一些观察结果:线程有时被安排在同一个CPU上,有时安排在不同的CPU上。此外,还有很多迁移正在进行中。最终,调度程序设法将每个线程放在不同的CPU上,并将其保留在那里。当然,不同的约束(例如系统负载)可能导致不同的调度。
现在让我们重新运行相同的示例,但这次使用taskset将进程的关联性限制为仅两个CPU - 5和6:
$ taskset -c 5,6 ./launch-threads-report-cpu
Thread #0: on CPU 5
Thread #2: on CPU 6
Thread #1: on CPU 5
Thread #3: on CPU 6
Thread #0: on CPU 5
Thread #2: on CPU 6
Thread #1: on CPU 5
Thread #3: on CPU 6
Thread #0: on CPU 5
Thread #1: on CPU 5
Thread #2: on CPU 6
Thread #3: on CPU 6
Thread #0: on CPU 5
Thread #1: on CPU 6
Thread #2: on CPU 6
Thread #3: on CPU 6
^C
正如预期的那样,虽然这里发生了一些迁移,但所有线程仍按照指示忠实地锁定到CPU 5和6。
绕道 - 线程ID和本机句柄
即使C ++ 11标准添加了一个线程库,它也无法标准化 所有内容。操作系统在实现和管理线程方面有所不同,并且在C ++标准中公开每个可能的线程实现细节可能过于严格。相反,除了以标准方式定义许多线程概念之外,线程库还允许我们通过公开本机句柄来与特定于平台的线程API进行交互。然后可以将这些句柄传递到特定于低级平台的API(例如Linux上的POSIX线程或Windows上的Windows API),以对程序进行更精细的控制。
这是一个启动单个线程的示例程序,然后查询其线程ID以及本机句柄:
int main(int argc, const char** argv) {
std::mutex iomutex;
std::thread t = std::thread([&iomutex] {
{
std::lock_guard<std::mutex> iolock(iomutex);
std::cout << "Thread: my id = " << std::this_thread::get_id() << "\n"
<< " my pthread id = " << pthread_self() << "\n";
}
});
{
std::lock_guard<std::mutex> iolock(iomutex);
std::cout << "Launched t: id = " << t.get_id() << "\n"
<< " native_handle = " << t.native_handle() << "\n";
}
t.join();
return 0;
}
我机器上一个特定运行的输出是:
$ ./thread-id-native-handle
Launched t: id = 140249046939392
native_handle = 140249046939392
Thread: my id = 140249046939392
my pthread id = 140249046939392
主线程(在条目上运行main的默认线程)和生成的线程都获取线程的ID - 我们可以打印的opaque类型的标准定义概念,保存在容器中(例如,将其映射到hash_map中的某些内容) ),但除此之外别无其他。此外,线程对象具有native_handle方法,该 方法为将由平台特定API识别的句柄返回“实现定义类型”。在上面显示的输出中,有两件事值得注意:
线程ID实际上等于本机句柄。
而且,无论是等于由返回的数字并行线程ID pthread_self。
虽然native_handle与pthread ID 的相等是标准明确暗示的东西[1],但第一个令人惊讶。它看起来像一个绝对不应该依赖的实现工件。我检查了最近的libc ++的源代码,发现 pthread_t id既用作“本机”句柄又用作线程对象的实际“id”[2]。
所有这些都让我们远离本文的主题,所以让我们回顾一下。这个绕行部分最重要的一点是,底层特定于平台的线程句柄可以通过std :: thread的 native_handle方法获得。事实上,POSIX平台上的这个本机句柄是线程的pthread_t ID,因此在线程本身内调用 pthread_self是获得相同句柄的完全有效的方法。
以编程方式设置CPU关联
正如我们之前看到的,命令行工具(如taskset)让我们可以控制整个过程的CPU亲和力。但是,有时我们希望做一些更细致和设置特定线程的亲和力内的程序。我们怎么做?
在Linux上,我们可以使用pthread特定的pthread_setaffinity_np函数。这是一个例子,它重现了我们之前做过的事情,但这次是从程序内部完成的。事实上,让我们更加花哨,通过设置它的亲和力将每个线程固定到一个已知的CPU:
int main(int argc, const char** argv) {
constexpr unsigned num_threads = 4;
// A mutex ensures orderly access to std::cout from multiple threads.
std::mutex iomutex;
std::vector<std::thread> threads(num_threads);
for (unsigned i = 0; i < num_threads; ++i) {
threads[i] = std::thread([&iomutex, i] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
while (1) {
{
// Use a lexical scope and lock_guard to safely lock the mutex only
// for the duration of std::cout usage.
std::lock_guard<std::mutex> iolock(iomutex);
std::cout << "Thread #" << i << ": on CPU " << sched_getcpu() << "\n";
}
// Simulate important work done by the tread by sleeping for a bit...
std::this_thread::sleep_for(std::chrono::milliseconds(900));
}
});
// Create a cpu_set_t object representing a set of CPUs. Clear it and mark
// only CPU i as set.
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(i, &cpuset);
int rc = pthread_setaffinity_np(threads[i].native_handle(),
sizeof(cpu_set_t), &cpuset);
if (rc != 0) {
std::cerr << "Error calling pthread_setaffinity_np: " << rc << "\n";
}
}
for (auto& t : threads) {
t.join();
}
return 0;
}
注意我们如何使用前面讨论的native_handle方法将底层本机句柄传递给pthread调用(它将pthread_t ID作为其第一个参数)。我的机器上的这个程序的输出是:
$ ./set-affinity
Thread #0: on CPU 0
Thread #1: on CPU 1
Thread #2: on CPU 2
Thread #3: on CPU 3
Thread #0: on CPU 0
Thread #1: on CPU 1
Thread #2: on CPU 2
Thread #3: on CPU 3
Thread #0: on CPU 0
Thread #1: on CPU 1
Thread #2: on CPU 2
Thread #3: on CPU 3
^C
线程完全按照要求固定到单个CPU。
与超线程共享核心
现在是真正有趣的东西的时候了。我们已经了解了一些CPU拓扑,然后使用C ++线程库和POSIX调用逐步开发更复杂的程序,以微调我们在给定机器中使用CPU,直到选择哪个线程在哪个CPU上运行。
但为什么这些重要呢?为什么要将线程固定到某些CPU?让操作系统做你擅长的事情并为你管理线程是不是更有意义?嗯,在大多数情况下是的,但并非总是如此。
请注意,并非所有CPU都是相同的。如果您的计算机中有一个现代处理器,它很可能有多个核心,每个核心都有多个硬件线程 - 通常为2.例如,正如我在文章开头所示,我的(Haswell)处理器有4个核心,每个核心2个线程,总共8个线程 - 操作系统的8个逻辑CPU。我可以使用优秀的lstopo工具来显示我的处理器的拓扑:
查看哪些线程共享同一核心的另一种非图形方式是查看每个逻辑CPU存在的特殊系统文件。例如,对于CPU 0:
$ cat /sys/devices/system/cpu/cpu0/topology/thread_siblings_list
0,4
更强大的(服务器级)处理器将具有多个插槽,每个插槽都具有多核CPU。例如,在工作中我有一台带有2个插槽的机器,每个插槽都是一个启用了超线程的8核CPU:总共32个硬件线程。更常见的情况通常是在NUMA的umberlla下进行,其中操作系统可以负责多个非常松散连接的CPU,这些CPU甚至不共享相同的系统内存和总线。
问重要的问题是-什么做硬件线程共享,以及它如何影响我们所编写的程序。再看一下 上面显示的lstopo图。很容易看出L1和L2缓存在每个核心的两个线程之间共享。L3在所有核心之间共享。适用于多插座机器。同一个套接字上的核心共享L3,但每个套接字通常都有自己的L3。在NUMA中,每个处理器通常可以访问其自己的DRAM,并且一些通信机制用于一个处理器以访问另一个处理器的DRAM。
然而,缓存并不是核心共享中唯一的线程。它们还共享许多核心的执行资源,如执行引擎,系统总线接口,指令获取和解码单元,分支预测器等 [3]。
因此,如果您想知道为什么超级线程有时被认为是CPU供应商所采用的技巧,现在您知道了。由于核心上的两个线程共享如此之多,因此它们在一般意义上不是完全独立的CPU。诚然,对于某些工作负载而言,这种安排是有益的,但对某些人来说并非如此。有时它甚至可能是有害的,因为“如何禁用超线程以提高应用程序X的性能”线程意味着在线。
核心共享与单独核心的性能演示
我已经实现了一个基准测试,它允许我在并行线程中的不同逻辑CPU上运行不同的浮点“工作负载”,并比较这些工作负载完成所需的时间。每个工作负载都有自己的大型浮点数 组,并且必须计算单个浮点数结果。基准测试指出要运行的工作负载和用户输入的CPU,准备输入,然后在不同的线程中并行释放所有工作负载,使用我们之前看到的API将每个线程的精确CPU亲和性设置为请求。如果您有兴趣,可以在此处获得完整的基准测试以及 适用于Linux 的Makefile。在帖子的其余部分,我只需粘贴短代码片段和结果。
我将专注于两个工作负载。第一个是简单的累加器:
void workload_accum(const std::vector<float>& data, float& result) {
auto t1 = hires_clock::now();
float rt = 0;
for (size_t i = 0; i < data.size(); ++i) {
rt += data[i];
}
result = rt;
// ... runtime reporting code
}
它将输入数组中的所有浮点数加在一起。这类似于 std :: accumulate会做的事情。
现在我将进行三项测试:
在单个CPU上运行accum,以获取基准性能编号。测量需要多长时间。
在不同的核心上运行两个accum实例。测量每个实例需要多长时间。
在同一个核心的两个线程上运行两个accum实例[4]。测量每个实例需要多长时间。
报告的数字(此处和后面的内容)是1亿个浮点数组的执行时间,作为单个工作负载的输入。我将他们平均几次运行:
这清楚地表明,当运行accum的线程与另一个运行accum的线程共享一个核心时,它的运行时根本不会改变。这有好消息和坏消息。好消息是这个特定的工作负载非常适合超线程,因为显然在同一个核心上运行的两个线程不会相互干扰。坏消息是,正是由于同样的原因,它不是一个很好的单线程实现,因为很明显它并没有最佳地使用处理器的资源。
为了提供更多细节,让我们看一下workload_accum的内部循环的反汇编 :
4028b0: f3 41 0f 58 04 90 addss (%r8,%rdx,4),%xmm0
4028b6: 48 83 c2 01 add $0x1,%rdx
4028ba: 48 39 ca cmp %rcx,%rdx
4028bd: 75 f1 jne 4028b0
非常直截了当。编译器使用addss SSE指令在SSE(128位)寄存器的低32位中一起添加浮点数。在Haswell上,该指令的延迟为3个周期。延迟而不是吞吐量在这里很重要,因为我们不断添加到xmm0。所以一个补充必须在下一个开始之前完全完成[5]。此外,虽然Haswell有8个执行单元,但addss只使用其中一个。这对硬件的利用率相当低。因此,在同一个核心上运行的两个线程管理不会相互践踏是有道理的。
作为一个不同的例子,考虑一个稍微复杂的工作量:
void workload_sin(const std::vector<float>& data, float& result) {
auto t1 = hires_clock::now();
float rt = 0;
for (size_t i = 0; i < data.size(); ++i) {
rt += std::sin(data[i]);
}
result = rt;
// ... runtime reporting code
}
在这里,我们不再仅仅添加数字,而是添加它们的正弦值。现在, std :: sin是一个非常复杂的函数,它运行一个简化的泰勒级数多项式逼近,并且里面有很多数字运算(通常还有一个查找表)。这应该使核心的执行单元比简单的添加更加繁忙。让我们再次检查三种不同的运行模式:
这更有趣。虽然在不同的内核上运行并不会损害单个线程的性能(因此计算可以很好地并行化),但在同一个内核上运行确实会损害它 - 很多(超过75%)。
再次,这里有好消息和坏消息。好消息是,即使在同一个核心上,如果你想要尽可能多地处理数字,两个线程放在一起会比一个线程更快(945毫秒来处理两个输入数组,而一个线程需要540个* 2 = 1080 ms实现相同)。坏消息是,如果你关心延迟,在同一个核心上运行多个线程实际上会伤害它 - 线程在核心的执行单元上竞争并相互减慢速度。
关于可移植性的说明到目前为止,本文中的示例都是特定于Linux的。但是,我们在这里所经历的所有内容都可用于多个平台,并且可以使用便携式库来利用它。使用它们比原生API更麻烦和冗长,但如果您需要跨平台可移植性,那么这不是一个很大的代价。我觉得有用的一个好的可移植库是hwloc,它是Open MPI项目的一部分。它非常便携 - 在Linux,Solaris,* BSD,Windows上运行,你可以命名。事实上,我之前提到的lstopo工具是基于hwloc构建的。
hwloc是一个通用的C API,它使人们能够查询系统的拓扑结构(包括套接字,内核,高速缓存,NUMA节点等),以及设置和查询关联性。我不会花太多时间在上面,但我确实 在本文的源代码库中包含了一个 简单的例子。它显示了系统的拓扑结构,并将调用线程绑定到某个逻辑处理器。它还展示了如何使用hwloc构建程序。如果你关心可移植性,我希望你会发现这个例子很有用。如果您知道hwloc的任何其他很酷的用途,或者为此目的了解其他便携式库 - 请给我留言!
关闭的话所以我们学了什么?我们已经看到了如何检查和设置线程亲和性。我们还学习了如何通过将C ++标准线程库与POSIX调用结合使用来控制逻辑CPU上的线程放置,以及为此目的由C ++线程库公开的桥接本机句柄。接下来我们已经看到了我们如何能够找出处理器的确切硬件拓扑结构,并选择共享核心的线程,以及在不同核心上运行的线程,以及为什么这非常重要。
与性能关键代码一样,结论是测量是最重要的事情。在现代性能调优中有很多变量需要控制,因此很难提前预测哪些更快,以及为什么。不同的工作负载具有非常不同的CPU利用率特性,这使得它们或多或少地适合于共享CPU核心,共享套接字或共享NUMA节点。是的,操作系统在我的机器上看到8个CPU,标准的线程库甚至让我以便携的方式查询这个数字; 但并非所有这些CPU都是相同的 - 为了从机器中挤出最佳性能,这一点很重要。
我没有深入分析两个呈现的工作负载的微操作级别性能,因为这实际上不是本文的重点。也就是说,我希望本文提供另一个角度来弄清楚多线程性能的重要性。在确定如何并行化算法时,并不总是考虑物理资源共享 - 但正如我们在这里看到的,它确实应该。
另外本人从事在线教育多年,将自己的资料整合建了一个QQ群,对于有兴趣一起交流学习C/C++的可以加群:825414254,里面有大神会给予解答,也会有许多的资源可以供大家学习分享,欢迎大家前来一起学习进步!