从零开始学习嵌入式P28----线程
上一篇我们学会了用
fork创建进程:一个程序复制出多个实例,各自独立、互不干扰,实现了"多任务"。但fork是有代价的------每创建一个进程都要复制一整套内存空间,进程之间想交换个数据还得靠专门的 IPC 机制,又重又麻烦。有没有更轻量的多任务方案?有,那就是本篇的主角------线程 。线程被称为"轻量级进程":它住在进程的空间内部,多个线程共享同一份代码、全局变量和堆区,只有栈是自己的。这一篇我们认识线程和进程的本质区别,然后用pthread_create创建线程、用pthread_exit结束线程、用pthread_join回收线程,再学会设置线程的分离属性,最后通过一个四线程综合练习把这套接口跑熟。
本篇目标
学完这一篇,我们应该能够:
- 说清进程和线程的区别:进程是资源分配的最小单元,线程是任务调度的最小单元;
- 画出线程在进程空间里的位置:独立栈空间,共享文本段、数据段和堆区;
- 用
pthread_create创建线程,并给线程函数传参; - 用
pthread_exit结束线程并带出退出状态; - 用
pthread_join阻塞回收线程、取回退出状态,理解什么是僵尸线程; - 用
pthread_attr_setdetachstate设置分离属性,让线程退出后自动回收; - 对比多进程和多线程的优缺点,知道什么场景该选哪个。
一、什么是线程
上一篇说过,进程是"程序动态执行的过程",一个进程从创建、调度到消亡,是操作系统管理多任务的基本单位。但一个进程内部,能不能同时干好几件事?比如一个音乐播放器:一边解码音频、一边刷新界面、一边响应你的点击------这些事如果排成一队串行执行,界面早卡死了。
线程 就是进程内部的执行流。一个进程至少有一个线程(就是跑 main 的那个,叫主线程),还可以创建更多线程,让它们在同一个进程空间里并发执行不同的任务。线程同样经历创建、调度、消亡的过程,所以又叫"轻量级进程"------它轻在哪里,下一节见分晓。
先建立一个对照关系,后面所有接口都能往这张表上套:
| 进程 | 线程 | |
|---|---|---|
| 创建 | fork |
pthread_create |
| 结束 | exit |
pthread_exit |
| 回收 | wait / waitpid |
pthread_join |
进程那三件套是怎么用的,线程这三件套就是怎么用的------学完进程再学线程,一大半是旧知识换新皮。
二、进程和线程的区别
这是面试和笔试的高频题,两句话抓住本质:
- 进程是操作系统资源分配的最小单元:每个进程独占 0-4G 虚拟内存空间,文本段、数据段、堆、栈,各有一套;
- 线程是操作系统任务调度的最小单元:CPU 调度执行的是线程,一个进程里的多个线程轮流上 CPU 跑。
落到内存布局上:线程位于进程空间内部,它只拥有独立的栈空间(默认 8M),其余的文本段、数据段、堆区,全都和同进程的其他线程共享。
这意味着:
- 独享的:局部变量(在各自的栈上)、各自的运行状态;
- 共享的:全局变量、静态变量、字符串常量、堆区。
对比一下上一篇的实验:fork 之后子进程改全局变量,父进程永远看不见;而线程改了全局变量,同进程的其他线程立刻就能看见。共享空间既是线程最大的便利(通信零成本),也是最大的坑(多个线程同时改一份数据会打架)------这个坑的填法是下一篇"线程间通信"的内容,本篇先把线程的创建和回收练熟。
三、线程的调度与消亡
1. 调度:和进程一个套路
线程的调度策略与进程保持一致:时间片轮转,宏观并行、微观串行 。用 ps -eLf 可以看到进程下的线程列表,每个线程也有自己的 ID(TID)。
2. 消亡:不回收就会变成僵尸线程
和进程一样,线程结束后必须回收它的资源,否则会产生僵尸线程 ------栈空间等资源一直占着不放。回收的手段是 pthread_join(相当于进程里的 wait),或者干脆给线程设置分离属性,让它退出后自动回收。
四、pthread_create:创建线程
1. 函数原型
c
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
- 功能:在当前进程中创建一个线程;
- 参数 :
thread:存放线程 ID(TID)的空间首地址;attr:线程属性(栈大小、优先级、分离/加入属性等),用默认属性就传NULL;start_routine:函数指针,指向线程要执行的函数------线程入口;arg:传给线程函数的参数;
- 返回值 :成功返回
0,失败返回错误码。
注意返回值的风格和 fork 不一样:fork 失败返回 -1 并设置 errno,而 pthread_create 失败时直接把错误码作为返回值 ,判断方式是 if (ret != 0)。
线程函数的签名是固定的:
c
void *threadfun(void *arg);
入口是 void *,出口也是 void *------一个指针进、一个指针出,参数和返回值都靠这个"万能指针"传递。
线程相关的接口都在 pthread.h 里,编译时要链接线程库:
bash
gcc demo.c -o demo -lpthread
2. 第一个多线程程序
创建一个线程,让它打印自己的 TID,主线程用 pthread_join 等它结束并回收:
c
#include "head.h"
void *threadfun(void *arg)
{
printf("线程(TID:%#lx)开始执行\n", (unsigned long)pthread_self());
sleep(5);
printf("线程即将退出!\n");
pthread_exit("我退出了");
return NULL;
}
int main(void)
{
pthread_t tid;
int ret = 0;
void *pstatus = NULL;
ret = pthread_create(&tid, NULL, threadfun, NULL);
if (ret != 0)
{
fprintf(stderr, "pthread_create failed, error num = %d\n", ret);
return -1;
}
pthread_join(tid, &pstatus);
printf("回收到线程空间,退出值为:%s\n", (char *)pstatus);
return 0;
}
这个程序把本篇的三大件都用上了,拆开看:
pthread_create(&tid, NULL, threadfun, NULL):默认属性创建线程,线程从threadfun开始执行,不给它传参(NULL);pthread_self():在线程函数里获取自己的 TID ,类比进程里的getpid();pthread_exit("我退出了"):线程结束,顺手把字符串"我退出了"作为退出状态带出去;pthread_join(tid, &pstatus):主线程阻塞 在这里等线程结束,回收资源,并把退出状态接进pstatus。
运行结果:主线程先卡住 5 秒(线程在睡),线程打印"即将退出"后消亡,主线程回收成功,打印 退出值为:我退出了。
注意这里有个和进程完全不同的细节:线程可以直接把退出状态交给同进程的其他线程------因为大家共享同一个地址空间,传个指针就行。进程之间想这么干,门都没有。
五、pthread_exit:结束线程
c
void pthread_exit(void *retval);
- 功能:结束当前线程;
- 参数 :
retval是线程结束时的退出状态,会被pthread_join取走。
线程结束有两条路:从线程函数里 return 返回,或者随时调用 pthread_exit。两者的效果等价------线程函数里 return p; 就相当于 pthread_exit(p);。
有一点要特别强调:主线程从 main 返回(或调用 exit),整个进程就结束了,进程里所有线程跟着陪葬 。所以多线程程序的 main 函数里,通常要用 pthread_join 把各个线程都等回来,或者用一个死循环兜住,保证主线程不先跑完。这是新手最常踩的坑,后面"常见错误"一节还会再提。
六、pthread_join:回收线程
c
int pthread_join(pthread_t thread, void **retval);
- 功能 :回收线程空间,具有阻塞功能;
- 参数 :
thread:要回收的线程的 TID;retval:存放线程退出状态的空间首地址,不关心可以传NULL;
- 返回值 :成功返回
0,失败返回错误码。
pthread_join 就是线程版的 wait:调用它的线程会阻塞在原地,直到目标线程结束,然后回收它的资源并取回退出状态。上一节的例子里,主线程就是靠它干等 5 秒的。
练习:创建三个线程
趁热打铁,创建三个线程,各自打印"线程N(TID:xxx)开始执行"。用函数指针数组可以写得很优雅:
c
#include "head.h"
void *thread1fun(void *arg)
{
printf("线程1(TID:%#lx)开始执行\n", pthread_self());
return NULL;
}
void *thread2fun(void *arg)
{
printf("线程2(TID:%#lx)开始执行\n", pthread_self());
return NULL;
}
void *thread3fun(void *arg)
{
printf("线程3(TID:%#lx)开始执行\n", pthread_self());
return NULL;
}
int main(void)
{
pthread_t tid[3];
int i = 0;
void *(*p[3])(void *) = {thread1fun, thread2fun, thread3fun};
for (i = 0; i < 3; i++)
{
pthread_create(&tid[i], NULL, p[i], NULL);
}
for (i = 0; i < 3; i++)
{
pthread_join(tid[i], NULL);
}
return 0;
}
void *(*p[3])(void *) 是一个函数指针数组,三个元素分别指向三个线程函数------还记得 P13 函数指针的内容吗?for 循环里 pthread_create 把三个线程挨个创建出来,第二个 for 循环挨个 pthread_join 回收。注意运行结果的打印顺序每次可能都不一样------这就是"宏观并行":三个线程谁抢到 CPU 谁先打印。
给线程传参
pthread_create 的第四个参数 arg 是传给线程函数的,类型是 void *。想给三个线程各传一个编号,有两种写法:
c
#include "head.h"
#define DEBUG 0
void *thread(void *arg)
{
#if DEBUG
int *pval = arg;
printf("线程%d(TID:%#lx)开始执行\n", *pval, pthread_self());
#else
int val = (int)arg;
printf("线程%d(TID:%#lx)开始执行\n", val+1, pthread_self());
#endif
return NULL;
}
int main(void)
{
int i = 0;
pthread_t tid[3] = {0};
#if DEBUG
int val[3] = {1, 2, 3};
#endif
for (i = 0; i < 3; i++)
{
#if DEBUG
pthread_create(&tid[i], NULL, thread, &val[i]);
#else
pthread_create(&tid[i], NULL, thread, (void *)i);
#endif
}
for (i = 0; i < 3; i++)
{
pthread_join(tid[i], NULL);
}
return 0;
}
DEBUG分支:传指针 ,把数组每个元素的地址&val[i]传进去,线程里解引用拿到编号。注意必须是每个线程一个独立的存储位置 ------如果图省事传同一个变量的地址(比如&i),三个线程读到的是同一份数据,谁后读谁拿到的是被改过的值,编号就乱了;- 非
DEBUG分支:传值 ,利用void *和整型之间的强转,把i直接塞进指针参数里,线程里再强转回来。不占用额外内存,适合传小型整数。
七、线程的分离属性
pthread_join 是阻塞的------主线程必须停下来等。有些场景下我们根本不在乎线程什么时候结束、退出状态是什么(比如服务器里处理完一个请求就销毁的工作线程),只要求"你干完活自己悄悄消失,别变成僵尸"。这就是分离属性(detached) :线程结束后,系统自动回收线程空间 ,无需 pthread_join。
设置分离属性需要三个函数配合:
c
int pthread_attr_init(pthread_attr_t *attr); // 初始化线程属性
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate); // 设置分离/加入属性
int pthread_attr_destroy(pthread_attr_t *attr); // 销毁线程属性
detachstate 有两个取值:PTHREAD_CREATE_DETACHED(分离)和 PTHREAD_CREATE_JOINABLE(可加入,默认值)。用起来:
c
#include "head.h"
void *thread(void *arg)
{
printf("线程(TID:%#lx)开始执行\n", pthread_self());
return NULL;
}
int main(void)
{
pthread_t tid[3];
int i = 0;
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
for (i = 0; i < 3; i++)
{
pthread_create(&tid[i], &attr, thread, NULL);
}
pthread_attr_destroy(&attr);
while (1)
{
}
return 0;
}
流程是固定的四步:初始化属性 → 设置分离 → 带着属性创建线程 → 销毁属性。设置好以后,一份属性可以反复用于创建多个线程。
结尾的 while (1) 是为了让主线程(整个进程)活着------分离线程没有 pthread_join 兜底,主线程要是先退出,进程一结束,三个线程连打印的机会都没有。
八、综合练习:四线程任务控制台
把上面的接口串起来,做一个有点样子的多线程程序,要求:
- 创建 4 个线程:采集线程 间隔 1 秒打印"正在采集",存储线程 间隔 2 秒打印"正在存储",显示线程 间隔 5 秒打印"正在显示",日志线程间隔 10 秒打印"正在日志记录";
- 主线程从终端接收字符:
a/A控制采集线程停止/开始打印,b/B控制存储线程,c/C控制显示线程,d/D控制日志线程。
分析需求:四个线程干的事结构一样(按各自间隔打印各自的话),只是参数不同------这正是给线程函数传参的典型场景。把每个线程的 TID、名字、间隔秒数、工作标志打包进一个结构体:
c
#include "head.h"
typedef struct pthread_info
{
pthread_t tid; //线程ID号
char threadname[32]; //线程名
int IntervalSec; //间隔秒数
int IsWork; //是否打印标志位
}PthreadInfo_t;
void *threadfun(void *arg)
{
PthreadInfo_t *pthreadinfo = arg;
printf("%s (TID:%#lx)开始执行\n", pthreadinfo->threadname, pthreadinfo->tid);
while (1)
{
if (pthreadinfo->IsWork)
{
printf("%s正在运行\n", pthreadinfo->threadname);
sleep(pthreadinfo->IntervalSec);
}
}
return NULL;
}
int main(void)
{
char ch = 0;
PthreadInfo_t thread[4] = {
{
.threadname = "采集线程",
.IntervalSec = 1,
.IsWork = 0xffffffff,
},
{
.threadname = "存储线程",
.IntervalSec = 2,
.IsWork = 0xffffffff,
},
{
.threadname = "显示线程",
.IntervalSec = 5,
.IsWork = 0xffffffff,
},
{
.threadname = "日志线程",
.IntervalSec = 10,
.IsWork = 0xffffffff,
},
};
int i = 0;
for (i = 0; i < 4; i++)
{
pthread_create(&thread[i].tid, NULL, threadfun, &thread[i]);
}
while (1)
{
scanf(" %c", &ch);
switch (ch)
{
case 'a':
case 'A':thread[0].IsWork = ~thread[0].IsWork;break;
case 'b':
case 'B':thread[1].IsWork = ~thread[1].IsWork;break;
case 'c':
case 'C':thread[2].IsWork = ~thread[2].IsWork;break;
case 'd':
case 'D':thread[3].IsWork = ~thread[3].IsWork;break;
}
}
for (i = 0; i < 4; i++)
{
pthread_join(thread[i].tid, NULL);
}
return 0;
}
拆开看这个程序的设计:
- 结构体传参 :
thread[4]数组的每个元素描述一个线程的全部信息,pthread_create把&thread[i]传进去------每个线程拿到的是各自独立的结构体,不会互相覆盖; - 一个函数四个线程 :四个线程跑的是同一个
threadfun,行为差异全靠参数驱动------这正是arg参数存在的意义; - 主线程当指挥官 :
scanf收到字符后用~IsWork取反标志位,工作线程每轮循环检查标志,为真才打印。这里主线程直接改线程的结构体------因为线程间内存是共享的,改完对方立刻可见。
这个"主线程控制、工作线程干活"的骨架,就是真实嵌入式项目里多线程程序的经典结构:采集、存储、显示、日志各司其职,主线程统一调度。
九、多进程 vs 多线程:该怎么选
学完两套多任务方案,自然要问:什么场景用哪个?四个维度对比:
| 维度 | 胜出方 | 原因 |
|---|---|---|
| 运行效率 | 多线程 | 线程在同一进程空间内部调度,切换不用换内存空间,创建和切换的开销都小得多 |
| 安全性 | 多进程 | 进程空间独立,一个进程崩溃不影响其他进程;一个线程崩溃,整个进程连带其他线程一起死 |
| 通信便利 | 多线程 | 共享全局变量、静态变量、堆区,通信零成本;进程空间独立,必须靠管道、共享内存等 IPC 机制 |
| 通信编程复杂度 | 多进程 | 线程共享数据虽方便,但会引发资源竞争,需要加锁解决;进程没有共享空间,反而没有竞争问题 |
一句话总结:要效率、要频繁交换数据,选多线程;要稳定、要故障隔离,选多进程。实际项目里两者经常混着用------关键服务拆成多进程保平安,进程内部再用多线程提效率。
十、常见错误
1. 主线程先退出,线程还没跑就结束了
c
int main(void)
{
pthread_create(&tid, NULL, threadfun, NULL);
return 0; // 进程结束,threadfun 可能一行都没执行
}
主线程从 main 返回,整个进程就结束了,所有线程随之消亡。必须让主线程等住:要么 pthread_join 等线程回来,要么用循环兜住主线程。
2. 判断 pthread_create 失败用错了方式
c
if (-1 == pthread_create(&tid, NULL, threadfun, NULL)) // 错!
pthread_create 失败时返回的是错误码本身 ,不是 -1,也不设置 errno。正确写法是 if (ret != 0)。
3. 给多个线程传同一个变量的地址
c
for (i = 0; i < 3; i++)
{
pthread_create(&tid[i], NULL, thread, &i); // 三个线程共享同一个 i
}
线程拿到的是同一个变量的地址,等它们真正去读的时候,i 早就变了好几轮了------打印的编号可能全是 3,还每次运行不一样。要么像第六节那样每个线程一个独立的数组元素,要么直接传值((void *)i)。
4. 编译时忘了链接线程库
bash
gcc demo.c -o demo # 报错:undefined reference to `pthread_create'
pthread 不是 C 标准库的一部分,必须显式链接:-lpthread。
5. 线程函数签名写错
c
void threadfun(void) { ... } // 错:签名必须是 void *(*)(void *)
pthread_create 要求线程入口严格是 void *func(void *arg)。参数用不上就忽略它,返回值没有就 return NULL;,但签名一个字符都不能改。
6. 以为线程间也像进程一样空间独立
c
// 线程A: num = 200;
// 线程B: printf("%d", num); // 立刻就能看到 200
上一篇"全局变量改了对方看不见"的结论,到线程这里完全反过来 ------共享空间意味着你改我见。方便的同时也埋着雷:多个线程同时改同一份数据会互相覆盖(资源竞争)。这个雷怎么拆,是下一篇互斥锁和信号量的内容,本篇先记住结论:线程间共享数据要小心,别同时写。
十一、线程篇速查
| 操作 | 接口 | 要点 |
|---|---|---|
| 创建线程 | pthread_create(&tid, NULL, 线程函数, 参数) |
失败返回错误码(非 -1);编译加 -lpthread |
| 获取线程 ID | pthread_self() |
线程版的 getpid(),打印用 %#lx |
| 结束线程 | pthread_exit(retval) |
线程函数 return retval; 等效 |
| 回收线程 | pthread_join(tid, &pstatus) |
阻塞等待,取回退出状态,防僵尸线程 |
| 初始化属性 | pthread_attr_init(&attr) |
配合下面两个使用 |
| 设置分离属性 | pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED) |
线程退出后自动回收,无需 join |
| 销毁属性 | pthread_attr_destroy(&attr) |
创建完线程即可销毁 |
| 对照记忆 | fork/exit/wait |
进程三件套 ↔ 线程三件套 |
小结
这一篇我们在进程的世界里再开了一条战线。线程是轻量级进程,位于进程空间内部:进程是资源分配的最小单元,线程是任务调度的最小单元 ;每个线程只拥有独立的栈空间(默认 8M),文本段、数据段、堆区全部与同进程的其他线程共享------局部变量独享,全局变量、静态变量、字符串常量、堆区共享。调度上与进程一致,时间片轮转、宏观并行微观串行;消亡上同样讲究"有始有终",不回收就会变成僵尸线程。编程接口是工整的三件套:pthread_create 创建线程(失败返回错误码,编译记得 -lpthread),线程函数固定签名 void *func(void *arg),靠 arg 传参;pthread_exit 结束线程并带出退出状态;pthread_join 阻塞回收并取回状态。线程太多、不在乎退出状态时,用 pthread_attr_setdetachstate 设置分离属性,让系统代为回收。最后的四线程练习展示了多线程程序的经典骨架:结构体打包线程信息、一个线程函数服务多个线程、主线程靠共享变量统一指挥。对比选型上,多线程赢在效率和通信便利,多进程赢在安全和隔离。
线程共享空间让通信变得轻而易举,但也带来了新的麻烦:多个线程同时读写同一个全局变量,数据会互相覆盖,结果不可预料------这叫资源竞争 。下一篇我们学习线程间通信与同步------互斥锁和信号量,看看如何给共享资源立规矩,让多线程程序既跑得快又不出错。