简介:这份资源是重庆大学操作系统实验二的配套资料包,面向计算机专业学生及操作系统自学者,聚焦多线程编程中线程创建这一核心知识点。实验围绕线程库与系统API的调用展开,涉及线程属性初始化、线程函数定义、生命周期管理,以及互斥锁、条件变量、信号量等同步互斥机制,并延伸至线程池的复用策略,帮助读者理解线程作为调度最小单位的运行原理。压缩包共77个文件,约275KB,以35个C源文件和29个头文件为主体,辅以4个汇编文件、3个Makefile及少量说明文档,构成一套可编译运行的实验工程骨架,便于对照实验指导书逐步调试。目前已有53人学习下载。通过该资料,读者可掌握线程创建与管理的完整实现路径,积累同步互斥与竞态条件处理的排错经验,为后续多进程、多线程进阶话题打下实践基础。
1. 线程创建实验到底在考什么:从一次“跑不通”的课程设计说起
很多同学拿到“操作系统实验二(线程的创建)”这个题目时,第一反应是去网上找一份能跑的代码,编译通过、截图交差。但真正动手才会发现,线程创建这件事远不是调一个pthread_create那么简单——为什么打印顺序每次都不一样?为什么加了sleep就“正常”了?为什么主线程一退出,子线程的输出就消失了?这些现象背后,考的是你对线程生命周期、调度不确定性、共享资源竞争的理解。这篇笔记就围绕“线程的创建”这个核心,把从环境准备、最小可运行代码、参数含义,到多线程竞争、同步收尾、常见翻车的完整路径讲清楚。适合正在做操作系统实验、需要交一份能讲明白的代码和报告的同学,也适合想重新梳理 pthread 基础的一线开发者。下面所有代码都在 Linux 环境下用 gcc 验证过,你照着敲就能复现。
2. 把 pthread 最小例子跑起来:环境、编译与第一个可执行文件
线程创建实验的第一步不是写代码,而是把编译环境理顺。Linux 下 pthread 并不是默认链接的,很多人第一次编译就卡在undefined reference to pthread_create,这不是代码写错了,而是链接参数没加。这一章先把环境确认、最小代码、编译命令、运行观察四件事做完,让你手里有一个确定能跑起来的基线版本,后面所有讨论都基于它。
2.1 确认环境与安装编译工具链
先确认你用的是 Linux 或者能跑 gcc 的环境。打开终端执行下面几条命令,看输出是否正常:
# 查看 gcc 版本,确认编译器存在 gcc --version # 查看系统线程库位置,一般能看到 libpthread ls /usr/lib/x86_64-linux-gnu/ | grep pthread # 查看 CPU 核心数,后面分析并发行为会用到 nproc如果gcc --version报 command not found,说明编译工具链没装。Debian/Ubuntu 系一般用sudo apt install build-essential,这条命令会把 gcc、make、libc 开发头文件一起装上。libpthread在新版 glibc 里已经并入主库,但编译时仍然建议显式加-pthread,这是最稳妥的写法。nproc的输出记一下,它决定了你的机器能真正并行跑几个线程,后面观察调度顺序时会用到。
提示:不要用
-lpthread代替-pthread。前者只链接库,后者还会设置必要的编译宏,行为更正确。
2.2 写一个能观察线程 ID 的最小程序
下面这段代码是整个实验的基线。它创建两个线程,每个线程打印自己的线程 ID 和一段文字,主线程等待它们结束:
#include <stdio.h> #include <stdlib.h> #include <pthread.h> #include <unistd.h> // 线程执行体,arg 是创建时传入的参数 void *worker(void *arg) { int id = *(int *)arg; // 取出传入的编号 pthread_t tid = pthread_self(); // 获取当前线程自身的 ID printf("worker %d start, tid=%lu\n", id, (unsigned long)tid); sleep(1); // 模拟一段工作,放大调度差异 printf("worker %d end\n", id); return NULL; // 线程正常退出 } int main(void) { pthread_t t1, t2; int id1 = 1, id2 = 2; // 创建两个线程,第四个参数是传给 worker 的指针 if (pthread_create(&t1, NULL, worker, &id1) != 0) { perror("create t1"); return 1; } if (pthread_create(&t2, NULL, worker, &id2) != 0) { perror("create t2"); return 1; } // 主线程等待两个子线程结束,否则可能提前退出 pthread_join(t1, NULL); pthread_join(t2, NULL); printf("main thread done\n"); return 0; }编译命令必须带-pthread:
gcc -Wall -g -pthread thread_demo.c -o thread_demo ./thread_demo-Wall打开常见警告,-g保留调试信息方便后面用 gdb 看线程状态。运行几次你会发现,两个 worker 的 start 顺序并不固定,有时 1 在前,有时 2 在前。这不是 bug,而是线程调度的正常表现——创建顺序不等于执行顺序。pthread_self()返回的是线程库内部的标识,和系统层面的 TID 不是一回事,想看系统 TID 得用syscall(SYS_gettid),这个后面排查时会用到。
2.3 四个参数逐个说清:pthread_create 到底要什么
pthread_create的原型是int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg)。四个参数各有讲究:
| 参数 | 含义 | 常见取值 | 注意点 |
|---|---|---|---|
| thread | 输出参数,回填线程 ID | &t1 | 必须传有效地址,不能传 NULL |
| attr | 线程属性 | NULL 表示默认属性 | 要设分离态、栈大小才需要填 |
| start_routine | 线程入口函数 | 函数名 | 签名必须是void *(*)(void *) |
| arg | 传给入口函数的参数 | 任意指针 | 不能传局部变量的地址给长生命周期线程 |
最容易翻车的是第四个参数。上面代码里&id1是 main 栈上的变量,因为 main 一直活到 join 结束,所以安全。但如果你在循环里创建线程,把循环变量i的地址传进去,所有线程可能读到同一个最终值。正确做法是每个线程分配独立的内存,或者用intptr_t直接传值。这个坑在实验报告里经常被扣分,因为现象是“所有线程打印的编号都一样”,看起来像玄学,其实是典型的悬空指针问题。
2.4 用返回值判断创建是否成功
pthread_create成功返回 0,失败返回错误码,但它不会设置errno,所以不能用perror直接打印。正确写法是拿返回值去strerror:
int ret = pthread_create(&t1, NULL, worker, &id1); if (ret != 0) { fprintf(stderr, "create failed: %s\n", strerror(ret)); return 1; }失败常见原因有两个:一是线程数超过系统限制,ulimit -u可以看进程数上限;二是栈空间不够,默认每个线程栈 8MB,创建几百个线程就可能失败。实验里一般不会碰到,但如果你写了个循环创建上千线程的测试,就要注意这个边界。把返回值检查加上,是让代码从“能跑”变成“可靠”的第一步。
3. 多线程竞争与同步:为什么加了 sleep 结果就“对”了
基线版本跑通之后,很多人会尝试让多个线程操作同一个变量,然后发现结果不对。这一章就围绕共享资源竞争展开,先复现问题,再讲清楚pthread_join的收尾作用,最后给出互斥锁的修法。理解这一章,你的实验报告才有深度,而不是只贴一段能跑的代码。
3.1 复现计数错误:一个必现的竞争场景
下面这段代码让两个线程各对全局变量加 100 万次,理论上结果应该是 200 万:
#include <stdio.h> #include <pthread.h> long counter = 0; // 全局共享变量 void *add(void *arg) { for (int i = 0; i < 1000000; i++) { counter++; // 非原子操作:读-改-写三步 } return NULL; } int main(void) { pthread_t t1, t2; pthread_create(&t1, NULL, add, NULL); pthread_create(&t2, NULL, add, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("counter = %ld\n", counter); return 0; }编译运行,你会看到结果几乎总是小于 200 万,而且每次都不一样。原因在于counter++在机器层面是“读入寄存器、加一、写回内存”三步,两个线程可能同时读到同一个旧值,各自加一后写回,其中一次加法就丢了。这就是竞争条件。如果你在循环里加一句sleep(0)或者打印,结果可能反而“接近正确”,因为打印拖慢了执行,减少了重叠窗口——但这只是掩盖问题,不是解决问题。
3.2 pthread_join 不只是等待:它决定了主线程何时退出
很多人把pthread_join当成可有可无的收尾,其实它有两个作用:一是阻塞主线程直到子线程结束,二是回收子线程的资源。如果不 join 也不 detach,子线程结束后会留下僵尸状态,占用线程表项。更严重的是,如果主线程先退出,整个进程结束,子线程会被强制终止,输出可能丢失。你可以把上面代码的 join 注释掉再跑,会看到counter打印得很早,甚至子线程还没跑完。
pthread_join的第二个参数可以接收线程返回值。如果线程入口函数return (void *)result,join 时就能拿到。实验里如果要求“线程返回计算结果”,就要用这个机制,而不是靠全局变量。写法是:
void *result; pthread_join(t1, &result); printf("thread returned %ld\n", (long)result);注意返回值是指针类型,不能直接返回栈上变量的地址,否则 join 拿到的是悬空指针。
3.3 用互斥锁把竞争修掉:加锁位置比锁本身更重要
修竞争的标准做法是互斥锁。下面给出加锁版本:
#include <stdio.h> #include <pthread.h> long counter = 0; pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 静态初始化 void *add(void *arg) { for (int i = 0; i < 1000000; i++) { pthread_mutex_lock(&lock); // 进入临界区 counter++; // 现在这三步被保护 pthread_mutex_unlock(&lock); // 离开临界区 } return NULL; }这样结果稳定是 200 万。但要注意,锁的粒度直接影响性能。如果把pthread_mutex_lock放在 for 循环外面,只加一次锁,结果也对,但两个线程就完全串行了,失去了并发的意义。实验报告里可以对比两种写法的耗时,用clock_gettime测一下,能看出锁粒度对性能的影响。另一个常见错误是忘记解锁,尤其是在有return或break的分支里,程序会死锁。建议用pthread_mutex_lock后紧跟unlock的固定结构,中间不要有提前返回。
3.4 条件变量:线程创建实验里最容易被忽略的同步工具
有些实验要求“一个线程生产数据,另一个线程消费数据”,这时候光靠互斥锁不够,还需要条件变量来避免忙等待。下面是一个最小生产者消费者模型:
#include <stdio.h> #include <pthread.h> int ready = 0; // 共享状态 pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; void *producer(void *arg) { pthread_mutex_lock(&m); ready = 1; // 生产数据 pthread_cond_signal(&cond); // 通知消费者 pthread_mutex_unlock(&m); return NULL; } void *consumer(void *arg) { pthread_mutex_lock(&m); while (ready == 0) { // 必须用 while 防止虚假唤醒 pthread_cond_wait(&cond, &m); } printf("consumed\n"); pthread_mutex_unlock(&m); return NULL; }关键点是pthread_cond_wait必须放在while里而不是if里,因为条件变量可能被虚假唤醒。另一个点是pthread_cond_wait会原子地释放锁并阻塞,被唤醒后重新加锁,所以调用前必须已经持有锁。这两个细节在实验报告里写清楚,能体现你对同步机制的理解深度。
4. 避坑与排查:线程创建实验里最常见的五类翻车
这一章集中讲踩坑。下面五条都是我在帮别人看实验代码时反复遇到的,每条按“现象、原因、解决”写清楚,你对照自己的代码排查。
4.1 编译报 undefined reference to pthread_create
现象:代码看起来没问题,gcc 编译时报链接错误,提示找不到pthread_create。原因:编译命令没加线程库链接参数。解决:在 gcc 命令末尾加-pthread,注意是编译和链接都要生效,所以放在命令最后即可。不要只加-lpthread,虽然有时也能过,但-pthread会同时定义_REENTRANT宏,行为更正确。如果你用 CMake,就在target_link_libraries里加pthread。
4.2 所有线程打印的编号都一样
现象:循环创建 5 个线程,每个线程打印传入的编号,结果全是 5 或者全是某个固定值。原因:把循环变量i的地址传给了所有线程,线程真正读取时循环已经结束,i变成了最终值。解决:每个线程单独分配一块内存存编号,或者用intptr_t把值直接转成指针传入。示例:
for (int i = 0; i < 5; i++) { int *p = malloc(sizeof(int)); *p = i; pthread_create(&t[i], NULL, worker, p); } // worker 里用完记得 free(arg)4.3 程序偶尔卡死不动
现象:运行后没有输出,或者输出几行后停住,Ctrl+C 才能结束。原因:大概率是死锁。常见情况有两种:一是同一个线程对普通互斥锁连续 lock 两次;二是两个线程以相反顺序获取两把锁。解决:用pthread_mutex_trylock排查,或者用 gdb attach 上去看各线程栈。gdb -p 进程号然后thread apply all bt能直接看到卡在哪个 lock 上。预防办法是固定加锁顺序,并且绝不在持锁时调用可能再次加锁的函数。
4.4 主线程退出导致子线程输出丢失
现象:子线程明明有 printf,但终端只看到主线程的输出。原因:主线程没 join 就 return 了,进程结束,子线程被强制终止。解决:所有创建的线程都要 join 或者 detach。如果线程是分离态,主线程仍然需要等待足够时间,或者用pthread_exit让主线程退出但不结束进程。实验里最稳妥的做法就是逐个 join。
4.5 加了锁结果反而更慢
现象:加互斥锁后结果正确了,但耗时比单线程还长。原因:锁粒度过大,或者临界区里有耗时操作,导致线程大部分时间在等锁。解决:把不需要保护的代码移出临界区,只保护真正共享的变量。比如计数场景,可以每个线程先本地累加,最后再合并,这样锁只加一次。用clock_gettime(CLOCK_MONOTONIC, ...)测一下加锁前后的耗时,用数据说话,实验报告里这也是个加分项。
5. 进阶技巧:用 gdb 和 strace 看清线程的真实行为
代码能跑、结果正确之后,如果你想让实验报告更有说服力,或者想真正搞懂线程创建背后发生了什么,就需要借助调试工具。这一章讲两个我常用的手段:用 gdb 观察线程创建和切换,用 strace 看 clone 系统调用。最后给一个线程池的简化思路,作为从“创建线程”到“管理线程”的过渡。
5.1 用 gdb 查看线程列表和切换过程
编译时加-g,然后gdb ./thread_demo。在 main 里pthread_create之后打断点,运行到断点后执行:
(gdb) info threads # 列出当前所有线程 (gdb) thread 2 # 切换到 2 号线程 (gdb) bt # 查看该线程调用栈 (gdb) thread apply all bt # 查看所有线程栈,排查死锁很有用info threads输出里带*的是当前线程,每行前面的编号是 gdb 分配的,不是系统 TID。你可以在 worker 里pthread_self()打印的值和 gdb 编号对照,理解两者区别。如果程序卡死,thread apply all bt能一眼看出哪个线程持锁未放、哪个线程在等锁,比盲目加 printf 高效得多。
5.2 用 strace 看 pthread_create 背后的 clone 调用
线程创建在 Linux 上最终走的是clone系统调用。用 strace 跟踪一下:
strace -f -e trace=clone ./thread_demo 2>&1 | head -20-f表示跟踪子进程和线程,-e trace=clone只过滤 clone 调用。你会看到每次pthread_create对应一次clone,后面带一堆标志位,比如CLONE_VM、CLONE_FS、CLONE_FILES、CLONE_THREAD。这些标志正是线程和进程的本质区别:线程共享地址空间和文件描述符,所以能直接读写同一个全局变量。看懂这一层,实验报告里“线程与进程区别”那部分就不用抄书了,直接写你观察到的系统调用。
5.3 从创建线程到线程池:一个简化版的思路
实验只要求创建线程,但实际工程里频繁创建销毁线程开销很大,常见做法是线程池。简化思路是:主线程先创建固定数量的 worker 线程,它们都阻塞在一个任务队列上;有新任务就放进队列并唤醒一个 worker。核心结构是互斥锁加条件变量加队列。下面是一个极简骨架:
typedef struct { void (*func)(void *); void *arg; } task_t; task_t queue[256]; int head = 0, tail = 0, count = 0; pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER; void *worker(void *arg) { while (1) { pthread_mutex_lock(&qlock); while (count == 0) { pthread_cond_wait(¬_empty, &qlock); // 队列空则等待 } task_t t = queue[head]; head = (head + 1) % 256; count--; pthread_mutex_unlock(&qlock); t.func(t.arg); // 在锁外执行任务 } return NULL; }这个骨架省略了退出机制和边界检查,但足以说明线程池的核心:线程只创建一次,任务通过队列分发。如果你在实验里加一个这样的扩展,报告深度会明显不一样。不过要注意,任务执行必须放在解锁之后,否则一个慢任务会阻塞整个队列。
5.4 一个我常犯的错误:忽略返回值检查
最后说一个我自己的血泪教训。早期写多线程代码时,我总觉得pthread_create不会失败,所以从来不检查返回值。直到有一次在资源受限的环境里循环创建线程,程序莫名其妙少跑了很多任务,排查半天才发现是创建失败但被忽略了。从那以后,我养成了一个习惯:凡是返回 int 的 pthread 函数,一律检查返回值并用strerror打印。这个习惯让我在后来的项目里提前发现过好几次资源泄漏。线程创建实验虽然简单,但把返回值检查、join 收尾、锁的边界这三件事做扎实,后面做更复杂的并发程序会少走很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取