从零开始学习嵌入式P28----线程

从零开始学习嵌入式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 兜底,主线程要是先退出,进程一结束,三个线程连打印的机会都没有。


八、综合练习:四线程任务控制台

把上面的接口串起来,做一个有点样子的多线程程序,要求:

  1. 创建 4 个线程:采集线程 间隔 1 秒打印"正在采集",存储线程 间隔 2 秒打印"正在存储",显示线程 间隔 5 秒打印"正在显示",日志线程间隔 10 秒打印"正在日志记录";
  2. 主线程从终端接收字符: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;
}

拆开看这个程序的设计:

  1. 结构体传参thread[4] 数组的每个元素描述一个线程的全部信息,pthread_create&thread[i] 传进去------每个线程拿到的是各自独立的结构体,不会互相覆盖;
  2. 一个函数四个线程 :四个线程跑的是同一个 threadfun,行为差异全靠参数驱动------这正是 arg 参数存在的意义;
  3. 主线程当指挥官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 设置分离属性,让系统代为回收。最后的四线程练习展示了多线程程序的经典骨架:结构体打包线程信息、一个线程函数服务多个线程、主线程靠共享变量统一指挥。对比选型上,多线程赢在效率和通信便利,多进程赢在安全和隔离。

线程共享空间让通信变得轻而易举,但也带来了新的麻烦:多个线程同时读写同一个全局变量,数据会互相覆盖,结果不可预料------这叫资源竞争 。下一篇我们学习线程间通信与同步------互斥锁和信号量,看看如何给共享资源立规矩,让多线程程序既跑得快又不出错。

相关推荐
luj_176817 分钟前
合法避税与投资评估实战指南
c语言·开发语言·网络·经验分享·算法
Bruce_Liuxiaowei1 小时前
驴滑块拼图游戏:从19世纪的纸片谜题到数学博弈论
人工智能·算法
MartinYeung51 小时前
[论文学习]MAC:多智能体宪章学习
人工智能·学习·macos
一直C1 小时前
Linux系统编程|信号进阶 + SystemV IPC(消息队列、共享内存)
linux·c语言·开发语言·算法·青少年编程·vim
小+不通文墨1 小时前
使用vscode里的U8G2 库编写OLED菜单控制
经验分享·笔记·单片机·嵌入式硬件·学习
新时代牛马2 小时前
SPL-TPL 链式启动:spl.c 与CONFIG_TPL 三阶段加载
c语言·开发语言
布莱克6052 小时前
链表详解:定义、作用、应用场景及与数组的区别(C/C++ 实战)
c语言·开发语言·c++·链表
摆烂老大2 小时前
论文学习 | HESS2025 | 中亚寒区季节水文预报
学习·机器学习·水文
西西弗Sisyphus2 小时前
模型训练 不同批次大小与学习率下的训练损失对比(1)
学习·batchsize·learning rate