云计算百科
云计算领域专业知识百科平台

【Linux】二十五.线程篇二《Linux 多线程编程:从原理到实战》

在上一篇文章中,我们详细介绍了线程的基本概念,这一章我先把上一节剩余的内容给补充完,线程的周边,接着我们学习线程的控制;


一.线程的周边:

线程与进程切换的对比

线程切换和进程切换最大的区别在于地址空间变不变。

  • 线程切换地址空间不变,所以TLB和Cache不失效,数据继续可用,页表也不需要切换。
  • 进程切换地址空间改变,TLB和Cache全部失效,必须重新加载,页表也得换。
  • 共享资源方面,线程之间共享文件描述符和信号处理器,进程各自独立需要重新加载。上下文保存的量也不一样,线程只需要保存寄存器、栈指针、程序计数器这些少量数据,进程要保存完整映像。
  • 是否进内核方面,线程看实现,用户级不进内核级进,进程必须进。速度上线程快得多,主要开销是保存恢复私有数据,进程那边是地址空间切换加上TLB Cache刷新再加上内核模式切换。

用户级线程和内核级线程的对比

用户级线程切换不进内核,完全在用户态完成,不涉及系统调用,所以速度极快。

内核级线程切换要进内核,需要内核调度器参与,涉及系统调用,速度较快但还是比进程快得多。

共同点是两者地址空间都不变,TLB和Cache都不失效。典型例子,用户级线程比如协程和早期Java的绿色线程,内核级线程比如Linux的pthread和Windows线程。


用户态和内核态的对比

  • 用户态权限受限,不能直接操作硬件,跑的是应用程序代码,切换开销低。
  • 内核态权限全开,能访问所有资源,跑的是操作系统核心代码,切换开销高,因为要保存上下文、权限检查、切换堆栈。
  • 用户态切换到内核态由系统调用、中断或异常触发。

每个线程独有的资源

寄存器组是独立的,包括程序计数器、栈指针和通用寄存器。

栈也是独立的,每个线程有自己的用户栈,内核级线程还有独立的内核栈。

线程控制块存储线程ID、优先级和当前状态。


线程的状态变化

新建状态是线程刚创建还没运行;

就绪状态是等待CPU调度;

运行状态是正在CPU上执行;

阻塞状态是等待I/O、锁或信号;

终止状态是执行完毕或被强制结束。


Cache的关键特性

Cache对程序员不可见,完全透明。进程切换时Cache全部失效,需要重新缓存。线程切换时Cache不失效,继续使用已有缓存。


二. Linux进程VS线程 — 哪些资源共享,哪些独占

  •  进程间具有独⽴性
  •  线程共享地址空间,也就共享进程资源

2.1 进程和线程

  • 进程是资源分配的基本单位
  • 线程是调度的基本单位
  • 线程共享进程数据,但也拥有⾃⼰的⼀部分"私有"数据:
  • 线程ID
  • ⼀组寄存器,线程的上下⽂数据
  • errno
  •  信号屏蔽字
  • 调度优先级

线程ID

每个线程在系统里都有一个唯一的标识符,用来区分同一个进程里的不同线程。不管是用户级线程还是内核级线程,都有自己的ID,方便操作系统或线程库进行管理和调度。

一组寄存器(线程的上下文数据)

线程在运行过程中,CPU里的寄存器保存着当前执行状态,包括程序计数器(记录执行到哪条指令)、栈指针(指向栈顶)和各种通用寄存器。切换线程的时候,这些寄存器内容要保存起来,等切回来的时候再恢复,这就是线程上下文切换的核心工作。

每个线程都有自己独立的栈空间,用来存放函数调用信息、局部变量、返回地址等。为什么必须独立?因为如果共用栈,函数调用就会互相干扰,A线程调用一个函数,返回地址被B线程覆盖了,程序直接崩溃。用户级线程有自己的用户栈,内核级线程还会有独立的内核栈。

errno

errno这个变量比较特殊,它看起来像是全局变量,但实际上在多线程环境下每个线程都有一份独立副本。原因是系统调用或库函数出错时会设置errno,如果所有线程共用同一个errno,A线程报错了还没来得及处理,B线程又发起系统调用把errno覆盖掉了,A线程拿到的是错误信息就是错的。所以必须线程私有,各管各的。

信号屏蔽字

信号屏蔽字决定了当前线程对哪些信号暂时不处理、哪些信号可以接收。同一个进程里的不同线程可以有不同的屏蔽策略,比如工作线程可以屏蔽大部分信号,专心处理计算任务,主线程专门负责响应信号。这样设计让线程对信号的处理更加灵活可控。

调度优先级

同一个进程的不同线程可以设置不同的调度优先级。优先级高的线程更容易被CPU选中执行,优先级低的后执行。这给了程序员控制线程调度顺序的能力,比如把处理用户交互的线程设高优先级,把后台数据同步的线程设低优先级,提升用户体验。


2.2 进程的多个线程共享

进程的多个线程共享同一地址空间,因此 Text Segment、Data Segment 都是共享的。如果定义一个函数,各线程都可以调用,如果定义一个全局变量,各线程都可以访问到。

除此之外,各线程还共享以下进程资源和环境:

  • 文件描述符表:一个线程打开的文件,其他线程可以直接使用同一个文件描述符来读写,不需要各自重新打开。因为文件描述符表属于进程,所有线程共用这张表。

  • 每种信号的处理方式:信号的处理函数是进程级别的设置。比如进程对SIGINT设置了自定义处理函数,或者设置为忽略或默认动作,所有线程都共用这个设置,不能各线程自己改。注意,这和信号屏蔽字不同,屏蔽字是线程私有的,但处理方式(忽略、默认、自定义函数)是进程全局的。

  • 当前工作目录:进程只有一个当前工作目录,所有线程共享。任何一个线程调用chdir修改了工作目录,其他线程的当前目录也跟着变了。

  • 用户ID和组ID:进程的身份标识属于进程本身,所有线程共用相同的用户ID和组ID。内核检查权限时看的是进程级别的ID,不会区分是哪个线程发起的操作。

知识补充:

文件描述符表是共享的,但每个线程有自己独立的文件偏移指针吗?不,文件偏移也保存在文件描述符表里,所以也是共享的。一个线程用read读了一部分数据,文件偏移往前移了,另一个线程再读就从新位置开始读了。如果要独立的偏移,得各自打开同一个文件拿到不同的文件描述符。

信号处理方式是进程共享的,但信号屏蔽字是线程私有的。这俩容易搞混,记清楚:怎么处理信号听进程的,当前阻塞哪些信号听线程自己的。

当前工作目录只有一个,一个线程改了全进程都受影响,这个好理解,因为目录是进程的上下文属性。

用户ID和组ID是进程级别的,所有线程一视同仁,权限检查不区分线程。

进程和线程的关系如下图:


2.3 关于进程线程的问题

如何看待之前学习的单进程?

之前学的单进程,本质上就是只有一个线程执行流的进程。进程创建后,从main函数开始,沿着一条执行路径顺序往下走,调用函数、分支跳转、循环,整个过程只有一条控制流。

有了多线程的概念之后再回头看单进程,可以这样理解:单进程就是进程里只跑了一个线程,这个线程拥有进程的全部资源,独占地址空间、文件描述符表、信号处理方式等,不存在资源共享和竞争的问题,所以编程模型简单直接,不用考虑锁、互斥、同步这些复杂的东西。

但它也有局限:单核上跑单进程,CPU执行到I/O操作时就得阻塞等待,CPU就闲着,浪费资源。多线程的好处就是可以在一个线程阻塞等待I/O时,让另一个线程继续使用CPU,提升利用率。另外在多核机器上,单进程单线程只能用一个核,其他核白白空着,性能天花板很低。

所以可以这么说:单进程单线程是基础模型,简单可靠,适合逻辑清晰、I/O密集度低或者不需要并行计算的场景。多线程是在这个基础上把执行流拆开,让一个进程能干多件事,但同时也引入了资源竞争和同步的复杂性。两者是取舍关系,没有绝对的好坏,看场景选。


概括一下,方便记忆:单进程单线程 = 一个进程里只有一个执行流,从头跑到尾。多线程 = 同一个进程里分出多条执行流,共享资源各跑各的。


三.Linux线程控制

3.1 POSIX线程库

  •  与线程有关的函数构成了⼀个完整的系列,绝⼤多数函数的名字都是以“pthread_”打头的
  •  要使⽤这些函数库,要通过引⼊头⽂ <pthread.h>
  •  链接这些线程函数库时要使⽤编译器命令的“-lpthread”选项
  • 3.2 创建线程

    功能:创建⼀个新的线程
    原型:

    int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
    (*start_routine)(void*), void *arg);

    参数:

    • thread:返回线程ID
    • attr:设置线程的属性,attr为NULL表⽰使⽤默认属性
    • start_routine:是个函数地址,线程启动后要执⾏的函数
    • arg:传给线程启动函数的参数
    • 返回值:成功返回0;失败返回错误码

    接下来讲的是pthread线程库的两个核心知识点:错误检查和线程ID。

    1.错误检查

    传统系统调用和pthreads函数的错误处理方式不同。传统函数比如open、read这些,成功返回0,失败返回-1,并且设置全局变量errno来记录具体错误原因,事后用perror或strerror(errno)来查。但pthreads函数不这么干,它失败时不会碰errno,而是直接把错误码通过返回值告诉调用者。

    虽然pthreads也提供了线程内的errno变量来兼容老代码,但官方建议直接判断返回值,因为读返回值的开销比访问线程内errno要小。

    配套的示例代码演示了正确的错误处理写法:pthread_create的返回值赋给ret,判断ret不等于0就是出错,然后用strerror(ret)把错误码转成可读的字符串输出,而不是用perror。

    代码本身的功能很简单,主线程创建一个子线程,两个线程各自无限循环,每秒打印一条消息,演示了线程的创建和并发执行。


    2.线程ID

    pthread_self函数返回调用线程的ID,类型是pthread_t。这个ID是pthread库在进程范围内维护的唯一标识,只在当前进程内部有效,内核不认它。它和内核的线程ID是两回事——pthread底层通过clone等系统调用创建线程时,内核会给每个线程分配一个系统全局唯一的ID,也就是ps命令里看到的LWP(轻量级进程ID)。

    用ps -L命令查看线程信息时,可以看到同一进程里的多个线程拥有相同的PID,但LWP各不相同。主线程的LWP通常和PID相等,子线程的LWP是另一个独立的值。

    三个关键点:

    • pthreads函数检查错误看返回值,不看errno

    • pthread_t是用户态线程ID,进程内唯一,内核不认识

    • LWP是内核态线程ID,系统全局唯一,ps -L能看到


    代码如下:

    这个程序演示了最简单的多线程编程。主线程启动后创建一个子线程,然后两个线程各自独立运行,每隔一秒打印一条消息,直到用户按Ctrl+C终止程序。

    #include <unistd.h>
    #include <stdlib.h>
    #include <stdio.h>
    #include <string.h>
    #include <pthread.h>

    void *rout(void *arg) {
    while (1) {
    printf("I'm thread 1\\n");
    sleep(1);
    }
    return NULL;
    }

    int main(void) {
    pthread_t tid;
    int ret;

    ret = pthread_create(&tid, NULL, rout, NULL);
    if (ret != 0) {
    fprintf(stderr, "pthread_create error: %s\\n", strerror(ret));
    exit(EXIT_FAILURE);
    }

    while (1) {
    printf("I'm main thread\\n");
    sleep(1);
    }

    return 0;
    }

    • pthread_create 创建线程,成功返回0,失败直接返回错误码(不设 errno)

    • 错误处理用 strerror(ret) 转成可读信息,不用 perror

    • 编译必须加 -pthread 选项

    • 两个线程各自独立运行,调度顺序由操作系统决定

    打印出来的 tid 是通过 pthread 库中有函数 pthread_self 得到的,它返回⼀个 pthread_t 类型的变量,指代的是调⽤ pthread_self 函数的线程的 “ID”。
    怎么理解这个“ID”呢?这个“ID”是 pthread 库给每个线程定义的进程内唯⼀标识,是 pthread 库维持的。由于每个进程有⾃⼰独⽴的内存空间,故此“ID”的作⽤域是进程级⽽⾮系统级(内核不认识)。

    其实 pthread 库也是通过内核提供的系统调⽤(例如clone)来创建线程的,⽽内核会为每个线程创建系统全局唯⼀的“ID”来唯⼀标识这个线程。

    使⽤PS命令查看线程信息

    • ps -aL:显示所有进程的线程信息,-a 显示所有终端下的进程,-L 显示线程信息,每一行代表一个线程

    • | head -1:取第一行,也就是表头(PID LWP TTY TIME CMD 这些列名)

    • &&:前面的命令执行成功后才执行后面的命令

    • ps -aL | grep mythread:在所有线程信息里过滤出包含 mythread 的行


    有以上基础那就开始我们今天的实验

    实验一:C++11 std::thread 跨平台线程代码

    Linux 线程基础:LWP、线程特性与 pthread 编译报错

    1.实验目的

    通过编写简单多线程代码,理解 Linux 下线程的内核实现,验证多线程程序的特点,同时解决 pthread 编程经典编译错误。

    2.核心理论知识点

    a.Linux 线程本质:轻量级进程 LWP

    Linux 内核不存在专门的线程结构,进程、线程统一使用task_struct描述。线程也被叫做LWP(Light Weight Process,轻量级进程)。

    我们可以使用命令查看线程信息:

    ps -aL

    观察现象:

  • 同一进程里所有线程PID 相同,代表属于同一个进程,共享虚拟地址空间、文件资源;
  • 每一个线程拥有独立的 LWP 编号;
  • CPU 调度的基本单位是 LWP,不是 PID。
  • 简单区分: 进程:拥有独立资源的 task_struct 线程:和其他 task_struct 共享资源的轻量级进程

    b. 多线程程序重要特性

  • CPU 时间片分配 操作系统分配给进程的时间片,会分给进程内部所有线程,线程之间轮流调度执行。

  • 健壮性较差 进程中任意一个线程崩溃触发异常,整个进程都会直接退出。 线程之间没有内存隔离,一旦某个线程出错,操作系统回收整个进程资源,所有线程一起结束。写多线程代码时需要考虑更多并发安全问题。

  • 多线程输出混杂问题 主线程和子线程同时调用 cout 打印信息时,输出内容容易交错乱序。 原因:std::cout 输出没有原子保护,多个线程竞争输出缓冲区,导致打印信息穿插。想要规范输出需要借助互斥锁。

  • 3.实验代码

    #include <iostream>
    #include <pthread.h>
    #include <unistd.h>
    #include <string>

    void* threadrun(void* args)
    {
    std::string name = (const char*)args;
    while(true)
    {
    std::cout << "我是新线程: name: " << name << ", pid: " << getpid() << std::endl;
    sleep(1);
    }
    return nullptr;
    }

    int main()
    {
    pthread_t tid;
    pthread_create(&tid, nullptr, threadrun, (void*)"thread-1");

    while(true)
    {
    std::cout << "我是主线程… " << ", pid: " << getpid() << std::endl;
    sleep(1);
    }
    return 0;
    }

    代码解析:

    这个程序用 pthread 库创建了一个子线程,主线程和子线程各自无限循环打印消息,演示最基本的线程创建和并发执行。

    整体功能

    主线程创建子线程后,两个线程各自独立运行,每隔一秒输出一条信息,直到按 Ctrl+C 终止。

    逐部分拆解

    头文件引入 pthread.h 提供线程相关函数,iostream 提供 cout 输出,string 用来处理传入的字符串参数。

    threadrun 是子线程的入口函数,参数是 void* 类型,可以接收任意指针。函数内部把 args 强转成 const char* 再构造为 std::string 对象,拿到传入的 "thread-1"。然后进入死循环,每次打印线程名字后休眠一秒,return nullptr 实际上永远执行不到。

    main 函数里声明 pthread_t 类型的变量 tid 用来存线程 ID。pthread_create 创建线程,第一个参数传 tid 的地址让函数填充线程 ID,第二个参数传 nullptr 表示使用默认线程属性,第三个参数是线程入口函数,第四个参数是传给入口函数的参数,这里传了字符串 "thread-1" 并强转成 void* 类型。创建成功后新线程立即开始执行 threadrun。主线程也进入死循环,打印自己的消息后休眠一秒。

    大家会发现我这个加了static,原因是起初我想验证这个不加-pthread,make进行编译的时候会报错,但我的没有发生报错,因为我用的,时ubuntu22.04,加这个static就是版本高的配置进行报错的,但发现还能运行,原因如下:

    Ubuntu 系统 glibc 版本 ≥ 2.34

    • glibc 2.33 及更早版本:pthread_create 在独立 libpthread.so,不加 -pthread 一定会报 undefined reference
    • glibc 2.34 重大改动:把 pthread 的所有函数整合进 shturl.(系统标准 C 库) libc 默认都会链接,所以链接器自动找到符号,不会报错!

    我加了之后,但是我的这个还可以运行:

    因为的 Ubuntu 系统 glibc 版本 ≥ 2.34。 glibc2.34 做了重大改动:把所有 pthread 函数全部合并进 libc 主库,废除独立的 libpthread 库。 所以:  动态链接、甚至带上-static静态链接,不加 -pthread 也能找到 pthread_create 符号,可以编译运行。

    错误 Makefile(无 – pthread,用来复现链接问题)

    mythread:mythread.cc
    g++ -O0 -o $@ $^ -std=c++11

    .PHONY:clean
    clean:
    rm -f mythread

     正确 Makefile(带上 – pthread)

    mythread:mythread.cc
    g++ -O0 -o $@ $^ -pthread -std=c++11

    .PHONY:clean
    clean:
    rm -f mythread

    补充实验说明

  • 文件名 mythread 是自定义可执行程序名,线程编译参数永远是 -pthread,不能写成 -pmythread
  • 高版本 Ubuntu(glibc≥2.34)动态链接不加 -pthread 不会报错;想要看到经典报错 undefined reference to 'pthread_create',需要在错误版本添加 -static:
  • mythread:mythread.cc
    g++ -O0 -static -o $@ $^ -std=c++11

    .PHONY:clean
    clean:
    rm -f mythread

    但大家在演示的时候一般正常报错时,显示这个:

    /usr/bin/ld: mythread.o: in function `main':
    mythread.cc:(.text+0x45): undefined reference to `pthread_create'
    collect2: error: ld returned 1 exit status
    make: *** [mythread] Error 1

    • undefined reference to 'pthread_create' 核心错误:链接器找不到 pthread_create 函数实现;
    • /usr/bin/ld:代表链接阶段出错,不是编译阶段;
    • make: *** [mythread] Error 1 make 捕获 g++ 异常,终止构建。

    编译运行:

    实验现象与结论

  • 使用ps -aL查看进程线程:主线程、子线程 PID 一致、LWP 不同,验证 Linux 线程是轻量级进程;
  • 主线程与子线程 PID 相同,证明多个线程共享同一份虚拟地址空间;
  • 没有同步保护时,多线程并发打印会出现信息混乱;
  • 使用 POSIX 线程库进行开发,编译时必须加上 -pthread 参数

  • 从编译问题深挖:pthread 线程库底层原理

    面我们做实验发现一个关键问题:使用线程相关接口,如果不规范添加编译参数,在不同 Linux 系统上表现不一样。想要弄懂背后原因,就要搞清楚 pthread 库到底是什么、Linux 线程底层如何实现。

    1.Linux 内核视角:没有 “线程”,只有轻量级进程 LWP

    Linux 内核中统一使用task_struct描述所有执行流,内核不存在专门的线程结构。 内核只提供底层系统调用 clone(),用来创建新执行流。通过传入不同 flags 参数,可以控制新执行流是否共享地址空间、文件描述符等资源:

    • 资源完全不共享 –>普通进程(fork 底层也是依靠 clone)
    • 共享地址空间等资源 –> 轻量级进程 LWP

    我们日常所说的 “线程”,本质就是依靠 LWP 模拟出来的概念。

    视角区分: 内核视角:只有进程、LWP,没有线程; 用户 / 应用视角:我们直接使用线程进行并发开发。

    直接调用clone()参数繁杂、使用门槛极高,不适合上层开发者,于是诞生了 pthread 库(POSIX 原生线程库)。 pthread 属于用户层库,它封装了clone()系统调用,自动完成线程栈分配、线程局部存储 TLS 管理等复杂工作,对外提供pthread_create、pthread_join这类简洁易用的线程接口。

    2.延伸:C++11 std::thread 和 pthread 的关系

    C++11 新增了std::thread,目的是实现语言层面跨平台并发。

    • 在 Linux 平台:std::thread底层依旧封装 pthread 库;
    • 在 Windows 平台:编译器内部封装 Windows 原生线程 API。

    实现思路:编译器针对不同操作系统编写两套底层代码,通过条件编译区分平台,向上统一暴露std::thread接口,以此保证 C++ 代码可移植。 这也侧面说明:Linux 下但凡原生线程,底层离不开 pthread。

    简易代码如下:

    代码解析:

    整体功能

    这个程序用C++11的std::thread创建了一个子线程,主线程和子线程各自每隔一秒打印一条消息和进程PID,无限循环,直到按Ctrl+C终止。验证了线程共享同一个进程ID。

    逐部分拆解

    头文件iostream提供std::cout输出,thread提供std::thread类,unistd.h提供sleep和getpid函数。hello函数是子线程的入口函数,无参无返回值。内部死循环,每次打印"新线程:hello world"和当前进程PID,然后休眠一秒。

    main函数里用std::thread t(hello)创建线程,hello是函数名,传入后新线程立即开始执行hello函数。主线程进入死循环,打印"我是主线程…"和进程PID,每秒一次。t.join()这行永远执行不到,因为主线程在死循环里出不来。return 0同样执行不到。

    运行结果:

    注意:哪怕使用 C++11 std::thread,Linux 环境编译依旧建议带上 -pthread,底层依赖 pthread!

    现象观察

  • 主线程和新线程打印出来的 pid 完全相同(1005197)
  • 输出内容经常出现文字交错、乱序拼接,比如: 我是主线程…新线程:hello world, pid: , pid: 10051971005197
  • 解读一下:

    1.PID 相同代表什么?

    Linux 内核视角:线程本质是轻量级进程 LWP。 多个 LWP 属于同一个进程,共享同一个进程地址空间、同一个 PID。 内核内部靠不同 LWP 标识区分执行流,对外展示的进程 ID 一致。 这直接验证理论:Linux 不存在独立线程内核对象,线程就是共享资源的 LWP。

    2.打印内容混乱交错的原因

    std::cout 输出没有加同步保护。 两个线程同时向终端输出,CPU 调度来回切换,输出语句被打断、穿插,出现打印乱序。

    拓展结论:多线程并发访问同一个输出流 / 共享资源,存在竞争问题,想要有序输出需要互斥锁同步。

    3.ID为啥那么长?

    两个线程同时调用 std::cout 输出,输出中途线程被切换,两段打印内容拼接在一起了。

    拆解这次错乱:

  • 主线程正要打印:我是主线程…, pid: 1005197
  • 输出到一半,CPU 切换到新线程
  • 新线程开始打印:新线程: hello world, pid: 1005197
  • 两段字符串在终端缓冲区混杂,数字连在一起:1005197 + 1005197 → 10051971005197

  • 原因回答:

    真实 PID 只有 1005197。 出现超长数字,是多线程并发输出竞争导致的打印穿插现象。 两个线程不加任何锁保护,同时向标准输出打印内容。操作系统随时可能中断当前线程、切换另一个线程运行。 一条 cout 还写完,另一条 cout 开始输出,两段文本直接拼接在一块,看起来 PID 变成一串超长数字。

    延伸结论: std::cout 本身不具备线程同步能力。多线程同时打印,输出内容乱序、穿插是典型并发竞争问题。想要有序输出,必须使用互斥锁(mutex)保护打印代码段。

    和前面知识点串联

  • 我们使用 C++11 std::thread 创建线程;
  • 在 Linux 平台下,std::thread底层依旧封装 pthread 库;
  • 所以编译命令必须带上 -pthread:
  • 总结

    运行程序后可以看到:主线程与新建线程打印的 PID 一致。 证明多个线程隶属于同一个进程,共享进程资源,印证 Linux 线程基于轻量级进程 LWP 实现。 同时终端打印信息杂乱交错,说明多线程并发输出存在竞争,缺少同步机制时输出会互相穿插。


    补充区分易混接口 clone () 与 vfork ()

    vfork()同样让子进程共享父进程地址空间,但和线程完全无关: vfork 创建子进程后会阻塞父进程,设计初衷只为快速执行 exec 替换程序,不要和 LWP 线程概念混淆。

    实验总结

  • Linux 内核无原生线程,线程是用户层 pthread 库,基于clone()创建 LWP 轻量级进程模拟实现;
  • pthread 库封装底层复杂系统调用,给开发者提供简洁的线程 API;
  • C++11 std::thread 在 Linux 底层依赖 pthread,跨平台依靠编译器条件编译实现;
  • 编译线程程序规范写法必须增加 -pthread,不能依赖新版本 glibc 特性省略参数,保障安全性与兼容性。

  • 现象解释

  • PID 相同:线程本质 LWP,多个线程归属同一个进程,共享进程 ID;
  • 打印混乱:多个线程同时操作std::cout,没有同步保护,线程调度切换导致输出穿插。

  • 实验 二:拓展实验 —— 线程崩溃特

    基于实验一的代码改造,验证:一个线程崩溃,整个进程全部退出

    #include <iostream>
    #include <pthread.h>
    #include <unistd.h>
    #include <string>

    void* threadrun(void* args)
    {
    std::string name = (const char*)args;
    while(true)
    {
    sleep(1);
    std::cout << "我是新线程: name: " << name << ", pid: " << getpid() << std::endl;
    // 触发除零错误,线程崩溃
    int a = 10;
    a /= 0;
    }
    return nullptr;
    }

    int main()
    {
    pthread_t tid;
    pthread_create(&tid, nullptr, threadrun, (void*)"thread-1");

    while(true)
    {
    std::cout << "我是主线程…, pid: " << getpid() << std::endl;
    sleep(1);
    }
    return 0;
    }

    代码解析:

    整体功能

    这个程序用 C 风格的 pthread 库创建线程,然后在子线程里故意触发除零错误,用来验证“线程崩溃会导致整个进程终止”这个结论。

    代码功能

    主线程创建一个子线程,传入参数 "thread-1"。子线程每隔一秒打印自己的名字和进程PID,然后执行 a /= 0 触发除零异常。主线程每隔一秒打印自己的消息和PID。子线程崩溃后整个进程退出,主线程也跟着一起终止。

    逐部分拆解

    头文件引入了 iostream 用于 cout 输出,pthread.h 提供线程相关函数,unistd.h 提供 sleep 和 getpid,string 用于处理字符串参数。

    threadrun 是子线程入口函数,参数是 void* 类型,传进来的是字符串 "thread-1",强转成 const char* 后构造为 std::string。进入死循环后先休眠一秒,然后打印线程名字和当前进程PID。紧接着声明 int a = 10,执行 a /= 0,除零操作会触发硬件异常,CPU 产生陷阱,操作系统向进程发送 SIGFPE 信号,默认动作是终止进程。

    main 函数里声明 pthread_t tid,调用 pthread_create 创建线程,第四个参数传入 "thread-1" 作为线程入口函数的参数。创建成功后主线程进入死循环,每秒打印 "我是主线程…" 和进程PID。

    运行结果:

    现象解读一下:

    • 线程与新线程打印 pid 相同 再次验证:Linux 线程是轻量级进程 LWP,同进程内所有线程共享同一个进程 ID。
    • 新线程执行 a /= 0,触发除零硬件异常(SIGFPE) Linux 信号机制:信号针对整个进程生效。 异常在新线程产生,信号递送给整个进程,操作系统销毁全部执行流。  核心实验结论:进程中任意一个线程触发致命异常,整个进程直接退出,所有线程一同消失。线程之间没有独立故障隔离

    总结:所有线程属于同一个进程,进程资源共享。线程没有独立的信号隔离,任意线程触发致命错误,整个进程都会被操作系统回收。

    这也是线程健壮性很低的一个缺点


    实验三:pthread 线程共享资源、线程等待与返回值观察

    实验代码

    #include <iostream>
    #include <cstdio>
    #include <string>
    #include <unistd.h>
    #include <pthread.h>

    int flag = 100;

    void showtid(pthread_t &tid)
    {
    printf("tid: 0x%lx\\n", tid);
    }

    std::string FormatId(const pthread_t &tid)
    {
    char id[64];
    snprintf(id, sizeof(id), "0x%lx", tid);
    return id;
    }

    void *routine(void *args)
    {
    std::string name = static_cast<const char *>(args);
    pthread_t tid = pthread_self();
    int cnt = 5;
    while (cnt)
    {
    std::cout << "我是一个新线程: 我的名字是: " << name << " 我的Id是: " << FormatId(tid) << std::endl;
    sleep(1);
    cnt–;
    flag++;
    }
    return (void*)123;
    }

    int main()
    {
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, routine, (void *)"thread-1");

    showtid(tid);

    int cnt = 5;
    while (cnt)
    {
    std::cout << "我是main线程: 我的名字是: main thread" << " 我的Id是: "
    << FormatId(pthread_self()) << ", flag: " << flag << std::endl;
    sleep(1);
    cnt–;
    }

    void *ret = nullptr;
    pthread_join(tid, &ret);

    std::cout << "ret is : " << (long long int)ret << std::endl;

    return 0;
    }

    代码解析:

    这个程序比之前的demo多了几个新东西:获取线程ID、线程等待回收、线程退出码、以及共享全局变量的访问。

    整体功能

    主线程创建一个子线程,两个线程各自打印5次消息后退出,子线程修改全局变量flag,主线程等待子线程结束并回收它的退出码。

    逐部分拆解

    开头定义了一个全局变量 int flag = 100,所有线程共享,用来验证线程间可以访问同一份全局数据。

    showtid函数用printf打印线程ID,格式是16进制。FormatId函数把pthread_t类型的线程ID转成16进制字符串,内部用snprintf格式化到字符数组再转成string返回。pthread_t在不同系统上可能是整数也可能是结构体,用16进制打印是常见做法。

    routine是子线程入口函数,参数是void*,传进来后强转成字符串作为线程名字。pthread_t tid = pthread_self()获取当前线程自己的ID。定义cnt=5作为循环次数,每次循环打印线程名字、线程ID,然后sleep(1),cnt减1,flag加1。循环5次后退出,返回(void*)123作为线程退出码。

    main函数里声明pthread_t tid,调用pthread_create创建线程,传入"thread-1"作为名字。创建成功后调用showtid打印子线程的ID。主线程同样循环5次,每次打印自己的信息,包括用FormatId(pthread_self())获取自己的ID,以及读取flag的值。循环结束后调用pthread_join(tid, &ret)等待子线程结束,ret接收子线程返回的退出码。最后打印ret的值,强转成long long int输出。

    makefile

    mythread:mythread.cc
    g++ -O0 -o $@ $^ -std=c++11 -pthread
    .PHONY:clean
    clean:
    rm -f mythread

    运行结果:

    程序运行现象

    运行程序后,终端打印出了两条不一样的线程 ID: 主线程 Id:0x7fc8d6d313c0 新线程 Id:0x7fc8d6d2d640

    全局变量 flag 一开始是 100,随着程序不断运行,可以看到主线程打印的 flag 慢慢变成 102、103、104,数值持续上涨。 等到两个线程循环全部跑完之后,程序最后打印 ret is : 123。

    现象分析

  • 每个线程都有自己独有的 tid,通过pthread_self()可以获取。 这里的 tid 是 pthread 库在用户层给线程设置标识,用来区分同一个进程里不同线程。要注意,这个 tid 不是内核里面的 LWP 编号。之前实验也看到,所有线程调用 getpid 拿到的进程 ID 都是相同的。

  • 新线程修改全局变量 flag,主线程能够看到修改之后的数据。 这说明同一个进程下所有线程共享地址空间,全局变量在线程之间是共享可见的。这也带来一个问题,如果多个线程同时修改同一个变量,不加保护就会出现数据竞争问题。

  • 主线程调用pthread_join阻塞等待新线程运行结束。 新线程正常执行完循环,return 返回 (void*) 123,主线程通过 join 拿到了这个返回值,最后打印出来。 能够看出,pthread_join 的作用就是等待目标线程执行完毕,并且接收线程正常退出时的返回值。

  • 和之前实验串联得到重要结论

    pthread_join 只有在线程正常跑完代码才有用。 如果新线程内部出现除零错误、段错误这类崩溃,系统会发送信号,直接干掉整个进程,代码根本运行不到 pthread_join。 也就是说 join 不能捕获线程崩溃,线程异常崩溃属于进程信号层面的问题,依靠 join 无法处理。

    总结:

  • 同一进程内的各个线程拥有独立的用户层 tid,但是所有线程进程 PID 一致;
  • 线程共享进程地址空间,全局变量多线程之间互相可见;
  • pthread_join 可以阻塞等待线程结束,获取线程正常退出的返回值;
  • join 机制无法处理线程崩溃,单个线程触发致命异常,整个进程都会退出。
  • 赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【Linux】二十五.线程篇二《Linux 多线程编程:从原理到实战》
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!