多处理器性能与Linux调试技巧
1. 多处理器相关知识
在如今的计算环境中,多核CPU几乎适用于各种架构。为了简化讨论,这里主要以AMD和英特尔基于英特尔架构的实现为例,不过相关问题适用于所有架构。
1.1 多核CPU概述
英特尔和AMD的第一代多核CPU是双核的。从功能上看,双核CPU相当于两个单核CPU(例如在多处理器主板上)。每个核心都有自己的寄存器、缓存、指令流水线、执行单元、MMU等。原则上,双核处理器的性能与具有两个相同时钟频率单核处理器的SMP系统相当。
未来的双核和四核CPU将在一定程度上共享片上缓存,这既有缺点也有优点。一方面,它限制了单个CPU在不与其他CPU竞争的情况下可访问的缓存量;另一方面,共享缓存减少了同步单独缓存所需的周期数。因此,一些应用程序会从多处理器共享缓存中受益,而另一些则会受到影响,很难简单地判断哪种方法更好。
1.2 SMP机器编程
大多数应用程序无需知道它们运行在多CPU机器上,硬件和操作系统会处理大部分细节。操作系统负责在CPU之间分配任务和平衡负载,但有些应用程序需要了解CPU的数量和类型,以充分利用硬件资源。
-
Linux调度器与SMP :Linux内核2.0版本引入了SMP。SMP调度器试图在CPU之间高效地分配任务和线程,充分利用硬件资源。其启发式算法基于所有CPU平等的假设,这也是对称多处理(SMP)中“对称”的含义。然而,SMT和多核处理器等创新正在挑战这一假设。在高级多处理器架构中,通常需要应用程序了解硬件特性并为调度器提供线索。
SMP调度器倾向于将进程保持在同一个CPU上,因为(由于延迟TLB刷新)进程很可能能够重用TLB。但对于SMT CPU来说,这是一种浪费,因为两个逻辑CPU使用相同的MMU和缓存,这可能导致进程因调度器认为将其排队到另一个CPU成本更高而被迫等待一个CPU。
用户应用程序可以通过亲和掩码为调度器提供线索。调度器为系统中的每个进程(和线程)维护一个亲和掩码,它是一个位图,系统中的每个CPU对应一位。默认的亲和掩码全为1,表示任何处理器都可以执行该进程。当调度器找到一个可运行状态的任务时,它会检查哪些CPU可用于执行代码,然后将其与进程的亲和掩码进行比较,以确定哪个CPU将执行该进程。可以通过适当设置亲和掩码来限制进程在一个或多个处理器上执行。随着内核的成熟,调度器也在不断跟上技术发展。在Linux 2.6.7中,调度器除了支持SMP外,还增加了对SMT的支持,因此调度器在选择CPU执行时可能会做出更明智的选择。 -
使用亲和性强制进程使用特定CPU :schedutils包包含taskset命令,可用于为特定进程设置亲和掩码。它可以应用于正在运行的进程或单个命令。例如,可以使用taskset命令通过设置亲和掩码的第0位(其他位为0)来强制进程仅在SMP系统的第一个处理器上运行:
$ taskset 1 ./myprogram
也可以使用 -p 选项来设置或检查正在运行进程的亲和掩码:
$ taskset -p 1234
Linux允许任何用户检查任何进程的亲和掩码,但只有root用户可以更改进程的亲和掩码,无论进程的所有者是谁。
-
何时以及为何修改进程亲和性 :尽可能让Linux调度器负责调度,避免硬编码进程亲和性,因为这可能会在设计中嵌入许多假设,代码在测试目标上可能运行良好,但在新的或不同的架构上可能不是最优的。只有在少数情况下才需要更改亲和掩码,例如有一个内存密集型应用程序,希望将其保持在单个处理器上,尽管Linux调度器会尝试这样做,但不能保证它会一直留在一个CPU上。如果在双CPU系统上运行两个这样的进程,将它们锁定在单独的处理器上可能是有意义的。另一个适合使用亲和性的情况是有专用硬件,如嵌入式计算机,在这种情况下,系统设计师可以充分了解底层硬件,使用亲和性可以确保最有效地利用硬件。
-
进程亲和性API :进程和线程可以通过为此目的定义的系统调用来检查和修改其亲和掩码,但更改亲和掩码需要root权限。可以使用以下GLIBC扩展来检查自身或其他进程的亲和掩码:
int sched_setaffinity(pid_t pid, size_t setsize, cpu_set_t *cpuset);
int sched_getaffinity(pid_t pid, size_t setsize, cpu_set_t *cpuset);
这些函数成功时返回0,出错时返回 -1。
cpu_set_t
是前面讨论的位掩码,
setsize
参数是掩码的大小。
cpu_set_t
定义为提供一个位掩码,允许比无符号长整型更多的CPU。因此,需要特殊的宏来设置和清除此掩码中的位,定义如下:
CPU_ZERO(p) - Clears the mask pointed to by p.
CPU_SET(n,p) – Sets the bit for CPU n in mask pointed to by p.
CPU_CLR(n,p) – Clears the bit for CPU n in the mask pointed to by p.
CPU_ISSET(n,p) – Returns nonzero when bit n of the mask pointed to by p is set.
要调用其中一个
setaffinity
函数,必须使用这些宏来初始化
cpu_set_t
。进程必须具有root权限,否则函数将返回错误。这些函数不适用于线程,GLIBC为此提供了对POSIX pthreads API的扩展。
- 线程亲和性API :GNU Native POSIX Threads Library (NPTL) 包含支持线程亲和性的函数。使用这些函数,可以将正在运行的线程限制在系统中的一个或多个CPU上,这有助于通过将使用公共内存的线程保持在一个CPU上来提高性能,从而减少缓存未命中。POSIX pthreads标准目前不支持亲和性,因此NPTL中的函数是扩展,通过 _np(非POSIX)后缀表示。这些函数定义如下:
int pthread_setaffinity_np(pthread_t tid, size_t setsize, cpu_set_t *cpuset);
int pthread_setaffinity_np(pthread_t tid, size_t setsize, cpu_set_t *cpuset);
这些函数需要一个正在运行的线程(由
tid
给出)才能正确操作。要影响当前正在运行的线程,调用者可以传递
pthread_self
的返回值作为
tid
的值。如果想在线程启动前初始化亲和性,可以通过线程属性来实现。给定一个正确初始化的
pthread_attr_t
对象,可以使用以下函数设置亲和性:
int pthread_attr_setaffinity_np(pthread_attr_t *attr, size_t setsize, cpu_set_t *cpuset);
int pthread_attr_getaffinity_np(pthread_attr_t *attr, size_t setsize, cpu_set_t *cpuset);
这些函数不接受线程ID作为参数,调用者提供
attr
的存储空间,并将其作为
pthread_create
的参数,创建的线程将具有在属性中设置的亲和掩码。
2. 基本调试工具 - printf
在过去,几乎没有调试器,程序员几乎完全依赖向终端打印消息进行调试。即使在今天的一些嵌入式环境中,由于内存或CPU资源有限,打印消息仍然是唯一可行的调试方法。然而,使用printf进行调试也存在一些问题。
2.1 使用printf的问题
-
性能影响
:根据使用的输出设备,printf语句会影响代码性能。例如,将代码输出到X终端时,由于伪终端的缓冲,实际数据滚动显示的时间可能比程序运行时间长。将相同的命令重定向到
/dev/null会运行得快得多,这表明任何形式的文本输出都会影响性能。
$ time od -v /dev/zero -N200000
real 0m1.257s
user 0m0.052s
sys 0m0.092s
$ time od -v /dev/zero -N200000 > /dev/null
real 0m0.059s
user 0m0.048s
sys 0m0.012s
- 同步问题 :当程序输出定向到屏幕时,通常认为printf语句是同步的,但当输出重定向到其他设备时,情况会发生变化。例如,以下代码:
for (i = 0; i < 3; i++) {
printf("Hello World\n");
sleep(1);
}
直接运行时,每秒会打印一次
Hello World
,但使用
tee
命令将输出保存到文件时,会在3秒后一次性输出三行
Hello World
。这是因为C标准库使用基于普通文件描述符的流,根据输出是否为终端使用不同的缓冲策略。当输出是终端时,使用行缓冲,遇到换行符时才将字符写入设备;当输出不是终端时,字符直到缓冲区满或程序显式调用
fflush
才会发送到设备。
-
缓冲与C文件流
:为了提高效率,通常以字符块的形式将字符发送到设备,用户空间缓冲区允许驱动程序以块的形式将字符发送到输出设备。C文件流允许通过
setvbuf函数选择三种基本的缓冲策略:
int setvbuf(FILE *stream, char *buf, int mode, size_t size);
mode
参数可以取以下值:
| 模式 | 说明 |
| ---- | ---- |
| _IONBF | 无缓冲,字符一次写入一个 |
| _IOLBF | 行缓冲,字符缓冲到第一个换行符 |
| _IOFBF | 全缓冲,字符缓冲到缓冲区满 |
标准输出
stdout
在连接到终端时默认使用行缓冲,重定向到文件或管道时变为全缓冲。如果想强制输出同步,可以使用
setvbuf
将缓冲模式改回行缓冲或使用
fflush
手动刷新缓冲区,但这都会使代码运行变慢,因为会增加系统调用次数。
-
缓冲与文件系统
:除了C库提供的用户空间缓冲区,文件系统在内核中也维护缓冲区。刷新用户空间缓冲区的
fflush不会刷新文件系统缓冲区,数据直到系统需要写入或应用程序调用fsync才会写入磁盘。当从不同计算机查看文件时,文件系统缓冲可能会成为问题,例如媒体位于远程NFS服务器上时,看到的文件内容可能不是最新的。可以使用fsync和fdatasync函数强制更新文件系统:
int fsync(int fd);
int fdatasync(int fd);
这两个函数会将用户数据从文件系统缓存写入设备,并阻塞调用者直到设备驱动程序表明数据已写入。
下面是I/O缓冲的流程图:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px
classDef buffer fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px
classDef disk fill:#FFEBEB,stroke:#E68994,stroke-width:2px
A(Process A):::process -->|fwrite| B(User Space Buffer):::buffer
A -->|fflush| B
B -->|write| C(Kernel Space):::buffer
C -->|fsync| D(Disk):::disk
E(Process B):::process -->|read| C
E -->|fread| B
以上介绍了多处理器性能相关的知识以及使用printf进行调试时可能遇到的问题和解决方法。在后续部分,将继续探讨其他调试工具和技巧。
多处理器性能与Linux调试技巧
3. 调试工具的综合运用与其他调试技巧
在了解了多处理器性能以及
printf
调试的相关问题后,下面将进一步介绍一些常见调试工具的特点和使用方法,以及一些在其他调试技术失效时可用的非常规技巧。
3.1 常见调试工具
| 工具名称 | 用途 |
|---|---|
gprof
、
gcov
| 用于在源代码级别帮助优化代码 |
OProfile
| 强大的系统工具,可用于帮助优化应用程序 |
strace
、
ltrace
| 以最小的侵入性监控代码行为 |
time
、
top
、
vmstat
、
iostat
、
mpstat
| 用于识别内存问题和系统吞吐量问题 |
下面详细介绍其中部分工具的应用场景:
-
gprof
和
gcov
:这两个工具主要针对代码优化。
gprof
可以分析程序运行时各个函数的调用频率和执行时间,帮助找出性能瓶颈函数。使用时,需要在编译程序时加上
-pg
选项,程序运行结束后会生成一个
gmon.out
文件,然后使用
gprof
命令分析该文件即可。
gcov
则侧重于代码覆盖率分析,通过在编译时加上
-fprofile-arcs -ftest-coverage
选项,程序运行后会生成
.gcda
和
.gcno
文件,使用
gcov
命令分析这些文件就能得到代码的覆盖情况。
-
OProfile
:这是一个功能强大的系统级性能分析工具。它可以收集整个系统的性能数据,包括CPU使用率、函数调用关系等。使用时,首先需要启动
opcontrol
守护进程,然后使用
opcontrol --start
开始收集数据,程序运行结束后使用
opcontrol --stop
停止收集,最后使用
opreport
命令查看分析结果。
3.2
gdb
调试器深入解析
gdb
是一款功能强大的调试器,虽然有一些优秀的GUI界面增强了其功能,但文本界面仍然非常强大且功能丰富。以下通过示例详细介绍
gdb
的一些常用功能:
// 示例代码
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
int main() {
int x = 5;
int y = 3;
int result = add(x, y);
printf("The result is: %d\n", result);
return 0;
}
-
启动
gdb:首先使用-g选项编译代码,然后使用gdb命令启动调试器:
$ gcc -g -o test test.c
$ gdb test
-
设置断点
:可以在函数名或行号处设置断点,例如在
add函数处设置断点:
(gdb) break add
-
运行程序
:使用
run命令开始运行程序,程序会在断点处停止:
(gdb) run
-
查看变量值
:使用
print命令查看变量的值,例如查看x的值:
(gdb) print x
-
单步执行
:使用
next命令单步执行代码,不进入函数内部;使用step命令单步执行代码,会进入函数内部:
(gdb) next
(gdb) step
3.3 内存检查工具比较
在调试过程中,内存问题是常见且难以排查的问题之一。以下是几种常见的内存检查工具的比较:
| 工具名称 | 特点 | 局限性 |
| ---- | ---- | ---- |
|
Valgrind
| 功能强大,可检测内存泄漏、越界访问等多种内存问题 | 运行速度慢,对系统资源要求较高 |
|
AddressSanitizer
| 速度快,能快速定位内存错误 | 可能会产生一些误报 |
|
Electric Fence
| 简单易用,能检测堆内存越界访问 | 只能检测堆内存问题,功能相对单一 |
使用
Valgrind
检测内存泄漏的示例:
$ valgrind --leak-check=full ./test
3.4 非常规调试技巧
当其他调试技术都失效时,可以尝试以下非常规技巧:
-
日志记录
:在代码中添加详细的日志记录,记录关键变量的值和程序执行的流程。可以使用不同的日志级别,如
DEBUG
、
INFO
、
WARN
、
ERROR
等,方便在不同情况下查看日志。
-
二分查找法
:对于复杂的程序,可以采用二分查找法逐步缩小问题范围。例如,将程序的一部分代码注释掉,观察问题是否仍然存在,逐步定位问题所在。
-
模拟环境调试
:如果问题只在特定环境下出现,可以尝试在本地模拟该环境进行调试。例如,模拟网络延迟、高负载等情况,观察程序的行为。
4. 总结
本文围绕多处理器性能和Linux调试技巧展开了全面的介绍。在多处理器方面:
- 多核CPU的发展使得计算性能得到了显著提升,但共享缓存等特性也带来了新的问题,需要根据应用场景合理利用。
- 通过进程和线程的亲和性设置,可以优化多CPU系统上的程序性能,但要谨慎使用,避免硬编码带来的兼容性问题。
在调试技巧方面:
-
printf
调试虽然简单直接,但存在性能影响和同步问题,需要合理使用缓冲策略来解决。
- 多种调试工具如
gprof
、
OProfile
、
gdb
等各有其特点和适用场景,应根据具体问题选择合适的工具。
- 内存检查工具可以帮助检测内存问题,而非常规调试技巧则在其他方法失效时提供了新的思路。
通过合理运用这些知识和工具,能够提高程序的性能和稳定性,更高效地解决开发过程中遇到的问题。
下面是一个总结多处理器和调试相关内容的流程图:
graph LR
classDef topic fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px
classDef subtopic fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px
A(多处理器与调试):::topic --> B(多处理器性能):::subtopic
A --> C(调试技巧):::subtopic
B --> B1(多核CPU特性):::subtopic
B --> B2(进程与线程亲和性):::subtopic
C --> C1(基本调试工具 - printf):::subtopic
C --> C2(常见调试工具):::subtopic
C --> C3(内存检查工具):::subtopic
C --> C4(非常规调试技巧):::subtopic
超级会员免费看
1359

被折叠的 条评论
为什么被折叠?



