多核程序设计实验全解析:从线程同步到加速比调优

发布时间:2026/9/6 21:38:34
多核程序设计实验全解析:从线程同步到加速比调优 简介燕山大学多核程序设计实验报告以PDF形式提供面向计算机相关专业学生及需要学习Windows多线程与并行计算的开发者。报告覆盖两个典型实验实验一讲解VC 6.0环境下使用CreateThread/_beginthread创建线程、实现线程并发与同步实验二以蒙特卡洛方法求解PI值详细对比串行与并行算法并通过Windows事件对象完成线程同步程序代码与结果分析完整便于学习者直接对照实践。资源包含1个PDF文件压缩包尺寸仅491KB轻量易下载。目前已有250人学习参考适合作为操作系统、并行计算或高性能计算课程的上机辅助材料。读者可借此掌握线程创建、同步机制、随机抽样算法设计以及多核环境下的性能优化思路对完成实验报告或深入理解多核编程原理均有实用价值。 这份《燕山大学多核程序设计实验报告.pdf》在课程群里被转发过很多次但我得先泼一盆冷水拿到报告直接抄代码是最亏的一种用法。我当时修这门课时也参考过几份往届报告代码照抄能跑结果验收时老师一句话把我问住了——“你这段加锁到底在保护什么不加锁会怎样临界区为什么画在那一句”我站在讲台上愣了一下代码跑得再顺也救不回来。多核程序设计这门课实验报告真正检验的不是你打印了多少页结果而是三件事对并行机制的理解、对同步设计的思考、对性能数据的分析。这篇文章我会沿着自己从开题、编码、调优到答辩的完整流程把实验里必须搞懂的核心知识点和常见误区一次讲透给正准备做实验的同学一条能走通的路。1. 写代码前先想清楚多核课程实验到底在考察什么1.1 多核程序与普通程序的分水岭不确定性很多同学做实验时有一个惯性思维在单线程程序里“代码顺序执行顺序”输入一样输出必然一样一切可预期。但多核并发程序的第一课就是打破这种预期。当一个进程里有多个线程同时运行时操作系统调度器在哪个时间片把哪个线程放到哪个 CPU 核心上几乎无法从代码层面预测。更关键的是多核 CPU 有独立的一级二级缓存线程在自己的核心上读写共享变量时还涉及缓存一致性的开销和延迟。所以多核实验里最经典的“灵异事件”是同一份代码连续跑三次第一次结果正确第二次结果错第三次又正确。这不是编译器坏了而是线程调度顺序变了数据竞争data race的表现时隐时现。理解这种不确定性是你后续分析同步需求、解释性能波动的第一块基石。1.2 经典实验题目背后的三项核心能力虽然不同学校、不同年份的实验题会有变化但多核程序设计课程的实验载体基本绕不开下面几个经典方向。我整理了一张对照表你可以用它来判断自己拿到的实验题到底在考什么经典实验载体核心知识点报告里应体现的能力多线程累加 / 计数器线程创建、回收、数据竞争能说清共享变量的保护方式生产者-消费者互斥锁、条件变量能解释等待、唤醒与忙等待的区别并行矩阵乘法任务划分、缓存局部性能分析加速比上不去的原因数值积分 / 计算 πOpenMP 归约、调度策略能讲透 reduction 的底层原理无论是哪一道题背后训练的能力可以收拢成三条第一线程生命周期管理能力——你知道什么时候该创建线程、什么时候该 join、线程退出后资源有没有回收第二临界区保护能力——面对共享数据你能判断要不要加锁、用什么同步原语、锁的范围画多大第三性能模型分析能力——你测得一组数据后能解释加速比为什么不是线性增长、瓶颈到底在什么地方。1.3 实验环境记录从一开始就要做这条建议我放在最前面是因为太多实验报告在这里丢分。做多核实验之前先把你的硬件和软件环境记录完整CPU 型号、物理核心数、是否启用超线程、操作系统版本、编译器版本、编译时是否开了优化选项。多核程序的性能高度依赖硬件同一段并行代码在 4 核老 CPU 和 16 核新 CPU 上跑出来的加速比可能差出一大截。报告里如果只有“运行时间 2.35 秒”没有环境说明这份数据就失去了可复现性。我也建议你在实验报告中单独留一个“实验环境”小节把lscpu或任务管理器里的关键信息贴上去这会立刻让报告显得专业。2. 线程同步是事故高发区数据竞争、临界区与死锁2.1 那段“看起来肯定没问题”的代码是怎么翻车的我第一次做多线程累加实验时写过一段自认为天衣无缝的代码4 个线程每个线程对全局变量counter执行 10 万次counter我预期结果是 40 万。跑第一遍输出 29 万再跑一遍输出 33 万多跑几次几乎没有一次是 40 万而且每次结果都不一样。问题出在一个最基础的事实上counter并不是一条原子指令。它在底层至少拆成三步——从内存读取counter的值到寄存器、寄存器里的值加 1、把新值写回内存。当多个线程同时在各自的 CPU 核心上执行这三步时线程 A 读到的值可能是线程 B 还没来得及写回内存的旧值两个线程的加操作互相覆盖数据就丢了。这个现象就是数据竞争。别小看这个例子它是理解后面一切同步机制的原点。2.2 加锁的位置和粒度正确之后还得谈开销修正数据竞争最直接的办法是给共享变量加互斥锁保证同一时刻只有一个线程进入临界区。基于上面的问题常规写法是这样的#include pthread.h #include stdio.h #define THREADS 4 #define N 100000 int counter 0; pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; void* worker(void* arg) { for (int i 0; i N; i) { pthread_mutex_lock(lock); counter; pthread_mutex_unlock(lock); } return NULL; } int main() { pthread_t tids[THREADS]; for (int i 0; i THREADS; i) { pthread_create(tids[i], NULL, worker, NULL); } for (int i 0; i THREADS; i) { pthread_join(tids[i], NULL); } printf(counter %d\n, counter); return 0; }这段代码是“正确”的但它的性能很糟每次counter都要完成一次加锁和解锁而加锁解锁本身有系统调用和缓存同步的开销。4 线程跑出来的加速比可能只有 1.2 左右甚至比单线程还慢。如果为了“性能”把锁扩大到整个 for 循环外面那么同一时间只有一个线程在干活其他线程干等这就退化成了串行。一个更合理的折中方案是每个线程先把自己负责的那段次数累加到一个局部变量上最后只对全局变量加一次锁合并结果。这种“先局部私有、最后归约”的思想恰恰就是 OpenMP 里reduction子句帮你做的事情实验报告里如果能写出这种演进过程是很加分的。2.3 死锁的典型成因与一个实用习惯比数据竞争更隐蔽的问题是多线程死锁。最常见的死锁场景是“AB-BA 型加锁顺序冲突”线程 1 先拿锁 A 再拿锁 B线程 2 先拿锁 B 再拿锁 A。如果线程 1 持有 A 等待 B线程 2 持有 B 等待 A双方都无法继续程序就卡死了。我在实验里遇到过一次程序不是报错而是停在那里不输出用 gdb 挂上去才发现两个线程互相等待。解决死锁的办法很多但我在实验阶段最推荐的做法是所有线程对多个锁的获取顺序保持一致。如果大家都约定“先锁 A、再锁 B”那么 ABBA 的冲突就从根本上消失了。另一个更偷懒但实用的建议是实验阶段尽量用一把统一的大锁保护所有共享状态先确保功能正确再去考虑锁的细粒度优化。很多时候同学们优化心切一上来就搞“细粒度锁 多把锁”结果把自己绕进死锁里调试时间比省下来的运行时间还多非常不划算。条件变量也值得单独提一嘴。生产者-消费者实验里消费者需要等待缓冲区非空如果只用互斥锁轮询检查CPU 空转浪费严重利用pthread_cond_wait和pthread_cond_signal消费者可以在条件不满足时挂起睡眠生产者放入数据后再唤醒。理解“等待-通知”模型是做好这一类实验的钥匙报告中能用一小段文字解释清楚“为什么忙等待不好”说明你真的吃透了同步机制。3. 并行模型选型pthread 手写细节还是 OpenMP 一行指令3.1 两种主流模型的分工多核程序设计的实现方式五花八门但实验阶段最常用的是 pthread 和 OpenMP 两种。pthread 的优势是“可控”线程怎么创建、怎么回收、锁怎么加全部由你自己决定适合用来理解底层原理。但它的劣势也很明显——代码量大、心智负担重一旦逻辑复杂调 bug 的时间会成倍上升。OpenMP 则走的是另一条路它以编译器指令的方式自动完成线程创建、任务分配和同步使用者只需要在关键代码前加一行#pragma omp parallel for开发效率非常高。我的建议是如果实验目标是让你演示对线程机制的掌握pthread 是首选如果实验目标是让你观察加速比的变化、体会性能调优OpenMP 是更高效的工具。更进阶的玩法是先拿 OpenMP 快速验证算法正确性再对照着写一版 pthread 实现两份代码的性能对比本身就是一个很好的报告素材。3.2 用 OpenMP 算 πreduction 和 schedule 是高频考点下面这段代码是我自己实验里最常用的“标准题”——通过数值积分计算 π它短小但足够说明 OpenMP 的核心机制#include stdio.h #include omp.h #define N 100000000 int main() { double pi 0.0; double h 1.0 / N; #pragma omp parallel for num_threads(4) schedule(static) reduction(:pi) for (int i 0; i N; i) { double x (i 0.5) * h; pi 4.0 / (1.0 x * x); } pi * h; printf(pi %.12f\n, pi); return 0; }这里最容易考到的是reduction(:pi)。它的作用相当于把 pi 在每个线程里复制一份私有副本各线程并行累加自己的副本最后再将所有副本按“”合并到原始的 pi 上。这正是我在第 2 节说的“局部私有、最后归约”思想的编译器化实现有了它你就不需要在循环体内手动加锁了性能比锁内累加高得多。schedule子句也是老师喜欢追问的点。schedule(static)把循环迭代按连续块静态地分配给线程适合每次迭代计算量都差不多的场景schedule(dynamic)则是动态领取任务适合迭代负载不均衡的情况。实验报告里如果能对比“static 和 dynamic 在 8 线程下的运行时间差异”分析出青睐某一个调度的原因会是很棒的分析素材。3.3 用矩阵乘法理解“该并行哪一层”矩阵乘法几乎是并行课程必考的载体。以 C[i][j] C[i][j] A[i][k] * B[k][j] 的标准三重循环为例最容易想到的是把最外层 i 循环并行化每个线程负责若干行结果的计算线程之间完全独立不需要同步。如果把并行放在最内层 j那么多个线程会同时读写同一行的多个元素争抢缓存行性能反而变差如果并行放在 k 层还会引入数据依赖因为求和需要累加。这个例子说明一个重要的并行设计原则找出循环之间没有数据依赖的外层来划分任务而不是盲目地把所有 for 都加上parallel。我在实验报告里写矩阵乘法这一段时专门画了一个简单的“行划分示意图”并用文字说明每个线程处理了哪些行老师看完不需要反推代码意图印象自然会上来。4. 性能分析加速比、Amdahl 定律与测量时的三个大坑4.1 用 Amdahl 定律预估你的优化上限实验报告最核心的数据指标是加速比speedupS T1 / Tp其中 T1 是单线程运行时间Tp 是 p 个线程的并行运行时间。并行效率 E S / p它告诉你每个线程平均被利用了多少。理想情况下4 线程加速比接近 4效率接近 100%但真实数据几乎不可能做到。这里绕不开的理论工具是 Amdahl 定律S_max 1 / ((1 - f) f / p)其中 f 是程序中可并行部分的时间占比(1 - f) 是必须串行执行的部分。这个公式的残酷之处在于即便你的并行部分做到了 90%剩下 10% 串行部分也会把极限加速比锁死在大约 10 倍附近。所以当实验数据出现“8 线程加速比只有 5”时不要急着归咎于“机器不行”先回头算算自己的串行占比——往往你会发现输入输出、数据初始化、结果汇总这些环节才是拖后腿的元凶。4.2 计时、编译、运行次数三个大坑性能测量这件事看起来简单实际上坑非常多。我在实验阶段踩过最典型的三个坑非常值得写进你的操作清单第一编译器优化没开。如果你用 gcc 编译时没有加-O2串行代码可能没有被很好地优化并行版本和串行版本的差距会被缩小甚至出现并行比串行更慢的假象。做性能对比实验时串行和并行代码必须用同一套优化选项编译。第二计时函数选错。clock()返回的是 CPU 时间在多线程程序里它的逻辑是“所有 CPU 核心时间之和”一个 4 线程程序跑 1 秒clock()可能显示 4 秒这是很多同学发现“加速比变成 0.25”的原因。正确做法是用omp_get_wtime()或者clock_gettime(CLOCK_MONOTONIC)这类墙钟时间接口计的是真实流逝的时间。第三跑的次数太少。现代操作系统后台有各种守护进程、系统服务在跑CPU 频率也有动态调节机制单次运行结果波动很大。我自己的习惯是每组配置至少跑 5 次去掉一个最大值和一个最小值剩下的取平均或中位数这样得出来的数据才比较稳定。报告里如果能注明“每组数据取 5 次运行的平均值”可信度会高很多。4.3 性能统计表是报告里的“证据链”性能数据不能只贴一堆代码表格整理是关键。下面是我在一份矩阵乘法实验中用过的统计表格式供你参考线程数运行时间 (s)加速比并行效率18.121.00100%24.201.9396.5%42.353.4586.3%81.605.0863.5%从这个表能明显读出几个信息2 线程效率接近 100%说明此时并行开销还很小8 线程效率掉到 63.5%加速比增长放缓说明串行部分或并行开销开始占据主导。报告里配上这样一张表再补一段分析——比如根据 Amdahl 定律反推串行比例大约在 10% 到 15%——整个性能分析就完整了不是“贴数据”而是“讲数据”。5. 实验报告结构和答辩追问把“调通了”讲成“我懂了”5.1 一份完整报告的六个组成部分实验报告的结构其实是有章法的。从我审过和写过的多份报告来看一个拿得出手的报告至少包含六块实验目的、实验环境、算法设计与并行策略、核心代码与说明、实验结果与性能分析、遇到的问题与解决过程。前面几块大家都差不多差距往往在最后两块。“实验结果与性能分析”要求你有观点、有对比、有解释而不是把 5 组运行时间列完就结束。“遇到的问题与解决过程”更是老师判断你是不是真的动手做了实验的关键。你在实验里遇到过数据竞争死锁性能不升反降把这些真实经历写出来并说明你是怎么定位和解决的比任何漂亮的套话都管用。我在报告里写过一段“第一次不加速是因为我在并行区里打印中间结果串行 I/O 拖垮了效率”老师看完还专门画了个圈。5.2 验收现场的高频问题清单答辩环节是很多同学的噩梦但其实问题高度可预测。结合我自己的经历把高频问题整理成清单你可以照着准备你这把锁保护的是哪个变量能不能去掉临界区为什么从这一行开始、到那一行结束范围再大一点会怎样为什么 4 线程加速比不是 4程序里哪些部分必须串行你统计的性能数据是跑几次的结果波动大不大OpenMP 的 reduction 对你那段代码做了什么它和手动加锁有什么区别如果把线程数加到超过物理核心数加速比会怎么变化回答这些问题的核心思路只有一个你要能讲清楚“数据怎么流动”和“时间花在了哪里”。比如被问到加速比不足时你可以主动说“我观察到一个串行热点是结果输出阶段按 Amdahl 定律反推串行比例约为 12%所以 8 线程的加速比上限大约在 6 倍左右实测 5.08 已经很接近”。这句话一出来老师基本不会再追问了因为你的回答已经包含了现象、理论、计算和实践数据证明你是真懂了。最后再分享一条个人经验多核程序设计这门课给我留下的最大收获并不是 OpenMP 或 pthread 的 API而是写任何一段多线程代码前我会下意识地问自己三个问题——哪些数据是多个线程共享的这个共享变量的变更是不是原子的线程之间有没有必须等待的顺序关系把这个思考习惯带到以后的每一个并发项目里实验报告写得好不好反而没那么重要了。建议你现在做实验时也不妨多跑几组数据、多改几版锁的位置、多记录一点异常现场这些素材会让你的报告和答辩都从容得多。本文还有配套的精品资源点击获取