humid1ch blogs

本篇文章

手机用户建议
PC模式 或 横屏
阅读


Linux系统 2023 年 4 月 11 日

[Linux] 多线程概念相关分析: 什么是线程、再次理解进程、线程的创建与查看、线程异常、线程与进程的对比...

线程可以说是实际区别于进程的一个概念, 但也可以说是实际没有区别于进程的一个概念. 而实际区别与否, 其实 **`与平台有关`**
我们已经了解了Linux操作系统进程部分相关知识:

博主有关Linux进程相关介绍的文章:

  1. 💥[Linux] 系统进程相关概念、系统调用、Linux进程详析、进程查看、fork()初识

  2. 💥[Linux] 进程状态相关概念、Linux实际进程状态、进程优先级

  3. 💥[Linux] 什么是进程地址空间?父子进程的代码时如何继承的?程序是怎么加载成进程的?为什么要有进程地址空间?

  4. 💥[Linux] 详析进程控制:fork子进程运行规则?怎么回收子进程?什么是进程替换?进程替换怎么操作?

通过阅读这几篇文章, 至少可以让我们对Linux系统中的进程 有一个最基本又相对全面的认识.
但是今天这篇文章, 可能又会对之前介绍过的进程多少有一些推翻.
本篇文章的主要内容是:Linux操作系统中, 有关多线程的相关介绍.

Linux线程概念

线程可以说是实际区别于进程的一个概念, 但也可以说是实际没有区别于进程的一个概念.
而实际区别与否, 其实 与平台有关

什么是线程

有关线程的概念, 大概可以通过三个要点介绍:
  1. 线程是在进程内部运行的执行流
  2. 线程相比进程, 粒度更细, 调用成本更低
  3. 线程是CPU调度的基本单位
不过这三个要点只能让你大概的对线程有一个最最最基本的认识:线程比进程要小. 但具体怎么小 是不知道的.
不过可以举个例子来简单的介绍一下, Linux下的线程:
在之前有关进程的介绍中, Linux系统中的进程 = PCB + 被加载到内存中的程序数据, 不过 PCB和内存中的程序数据 并不是直接相映射的.
之间还要通过 进程地址空间和相应的页表, 不过CPU实际只是是通过访问PCB 来实现对进程的调度的:
PCB(task_struct)中描述着进程地址空间, 进程地址空间与物理内存 通过两张页表来相互映射.
这是Linux系统中, 单个进程实际在操作系统中的存在形式.
系统创建进程会创建这所有的格式和数据.
不过我们也介绍过, 如果通过fork创建子进程, 在未作数据修改时 子进程与父进程是共享进程的数据和代码的. (子进程也存在自己的进程地址空间和页表, 只不过指向同一块数据和代码)
而且, 我们可以通过对fork()返回值的判断, 让父子进程执行不同的代码块.
这, 其实说明了一个 细节不同的执行流, 可以做到执行不同的资源, 即 可以做到对特定资源的划分
那么, 如果下次创建进程, 操作系统并不创建有关进程的所有结构, 而是只创建 PCB. 将新的PCB 指向已经存在的进程.
不同PCB指向同一个进程地址空间 - CPU与PCB之间的虚线表示, 此PCB也被CPU调度, 但当前可能没有被调度
不同PCB指向同一个进程地址空间 - CPU与PCB之间的虚线表示, 此PCB也被CPU调度, 但当前可能没有被调度
然后, 以子进程划分程序资源类似的手段, 将进程的代码划分为不同的区域, 并将不同的PCB设置为实际分别负责执行不同的区域的代码:
PCB与代码区之间连接的红色虚线表示, PCB实际负责执行的代码区域
PCB与代码区之间连接的红色虚线表示, PCB实际负责执行的代码区域
最终, 不同的PCB可以访问进程地址空间内代码区的不同区域, 并通过相应的页表来访问到实际的物理内存.
实际上这样就在进程内部创建了多个PCB执行流, 而每个PCB执行流都只能访问一小部分的代码一小部分的页表.
那么, 在Linux操作系统中, 我们就 可以将这样的PCB执行流称作 "线程".

这里只是介绍了一下Linux操作系统中, 线程的 粗粒度 的实现原理


介绍了Linux平台下 线程的粗粒度的实现原理, 我们应该可以理解一个内容:线程 : 进程 = N : 1
操作系统 对 比线程数量要少的进程 都会用 PCB 将进程的所有属性 描述、组织、管理起来, 那么对线程, 毫无疑问也是需要用一个结构体描述、组织、管理起来的. 在大多数的操作系统中, 描述线程的结构体叫做 TCB
如果一个操作系统, 为了描述管理进程和线程, 在内核分别实现了不同的 PCBTCB. 那么 PCBTCB 之间一定存在非常复杂的耦合关系. 因为 PCB 描述一个进程, 而 TCB 描述进程内部的线程. 这两部分一定存在相当一部分的重叠属性, 还有一定的包含关系.
那么, 在以后维护一个进程与其内线程的关系时, 一定是一个非常复杂的维护过程.

其实文章介绍线程概念到现在, 一举到具体的例子, 就在强调 在Linux操作系统下.
什么原因呢? 其实 不同操作系统实现线程的方式可能是不同的.
我们在上面提到, 操作系统会存在一个描述线程属性的结构体, 以维护线程.
但是, 实际上 并不是所有的操作系统都会对线程另外描述一个结构体, 使TCB与PCB之间的关系变得非常复杂.
Linux操作系统 就没有另外实现一个描述线程的结构体, 而是 用task_struct(进程PCB)模拟了线体. 即Linux操作系统中, 描述进程和描述线程的结构体实际上是同一个结构体: task_struct
而我们常用的 Windows操作系统, 则是真正将进程与线程区分开, 分别实现了PCB和TCB 以分别用来维护线程和进程. 这样的被称为 真·线程 操作系统
为什么不同的操作系统会对进程和线程之间的关系, 设计出这样的差别呢?
其实是开发者对 进程和线程在执行流层面的理解不同.
以 Windows 来说, Win为了维护线程真正实现了一个不同于PCB的TCB. 也就是说, Win的开发者认为进程和线程在执行流层面是不同的东西. 进程有自己的执行流, 线程在进程内部也有自己的执行流
而 Linux 则认为 进程和线程在概念上不做区分, 都是执行流. PCB要不要被CPU调度?TCB要不要被CPU调度?PCB调度要不要优先级?TCB要不要?要不要通过PCB找到代码和数据?要不要通过TCB找到代码和数据?进程切换要不要保护进程的上下文数据?线程切换要不要保护上下文数据?……
在Linux看来, 种种迹象表明 PCB和TCB的功能 不从更细节来细分的话, 其实是大致相同的. 无非就是PCB和TCB中描述的代码量和数据量的不同, 所以 进程和线程都只看成一个执行流.
所以 Linux线程, 其实就使用task_struct(进程PCB)模拟实现的.
只不过, 线程的TCB(实际上还是PCB)只能访问执行 整个进程中的一小块的代码和数据

这样做有什么好处?

用进程PCB模拟实现线程, 对线程 可以复用操作系统中已经针对进程实现的各种调度算法, 因为进程和线程的描述结构是相同的.

也不用维护进程和线程之间的关系.

也就是说, Linux操作系统中 线程TCB底层就可以看作进程PCB

Linux复用PCB实现TCB, 那么从CPU的角度看待线程, 其实与进程没有区别. CPU调度线程实际上看到的还是PCB(task_strcut)

Linux进程-再理解

上面已经介绍了, Linux中 线程使用进程PCB来模拟实现的, 那么现在又该如何理解进程呢?
在没有介绍线程之前, 我们可以说 CPU看到的所有task_struct都是一个进程
而现在, CPU看到的所有task_struct都是一个执行流
之前我们说, 进程 = PCB + 内存中对应的代码和数据.
而现在, 我们知道进程内部可以存在许多task_srtuct, 那么又可以怎样理解进程呢?
现在, 不能只认为 PCB + 代码和数据 就是一个进程. 而是 需要理解, 上图中的所有结构加起来才能叫一个进程.
我们可以说, 进程是 承担操作系统资源分配的基本实体. 即 进程是 向系统申请资源的基本单位
在没有介绍线程时, 我们可以说 PCB可以表示一个进程, 因为之前进程只有一个执行流, 即只有一个task_struct.
现在 我们可以称 只有一个执行流的进程 为 但单执行流进程, 称 内部存在多个执行流的进程 为 多执行流进程
那么现在, 以CPU的视角来再次看待 task_struct, 我们 现在理解的CPU看到的task_struct 比 没有介绍线程时CPU看到的task_struct 体量要小
因为 Linux中, 现在我们理解的CPU看到的 task_struct 可能是 线程, 可以看作是 轻量化的进程
进程是向系统申请资源的基本单位, CPU调度进程是通过 PCB(task_struct) 调度的, 所以 现在我们说 线程, 是CPU调度的基本单位
那么此时, 我们应该就可以理解 有关线程的概念三个要点介绍:
  1. 线程是在进程内部运行的执行流

    线程只访问执行进程的一部分数据和代码

  2. 线程相比进程, 粒度更细, 调用成本更低

    进程切换调度, 需要切换PCB、进程地址空间、页表等

    而线程切换调度, 只需要切换TCB(实际还是PCB)就可以

  3. 线程是CPU调度的基本单位

Linux线程的创建、查看

介绍线程介绍了这么多, 那么 Linux中如何创建并查看线程呢?
下面我们来直接简单演示一下, 不做太多的介绍. 只创建和查看线程.

pthread_createpthread_join

Linux操作系统为我们提供了创建线程的系统调用:
int pthread_create(pthread_t *thread, const pthread_attr *attr, void *(*start_routine)(void *), void *arg);
这个接口看起来, 非常的复杂
不过, 实际上也没有太复杂. pthread_t 就是一个无符号长整型:
第一个参数就是此类型的指针, 第一个参数是一个输出型参数, 用于获取线程id
第二个参数, 是线程属性结构体的指针, 暂时不过多介绍 现在我们传入 nullptr
第三个参数, 返回值为空指针 参数为空指针的 函数指针, 用于 传入此线程需要执行的函数.
第四个参数, 一个空指针, 此空指针其实就是 第三个参数(函数指针)所指向的函数的参数
处理创建线程之外, 线程与子进程一样, 还需要等待:
int pthread_join(pthread_t thread, void **retval);
此函数的参数很简单:
  1. 第一个参数传入 需要等待的线程的id
  2. 第二个参数接收退出结果, 暂时不关心. 我们只是看一看现象
简单的了解之后, 我们就可以使用此接口 创建线程:
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
using std::cout;
using std::endl;
using std::string;

void* threadFun1(void* args) {
    string str = (char*)args;
    while (true) {
        cout << str << ": " << getpid() << " " << endl;
        sleep(1);
    }
}

void* threadFun2(void* args) {
    string str = (char*)args;
    while (true) {
        cout << str << ": " << getpid() << " " << endl;
        sleep(1);
    }
}

int main() {
    pthread_t tid1, tid2;

    pthread_create(&tid1, nullptr, threadFun1, (void*)"thread_1");
    pthread_create(&tid2, nullptr, threadFun2, (void*)"thread_2");
    sleep(1);

    while (true) {
        cout << " 主线程运行: " << getpid() << " " << endl;
        sleep(1);
    }

    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);

    return 0;
}
不过, 我们编译时会发现有错误:
是连接错误, 为什么呢?
其实 man 手册中已经说了, 使用pthread_create() 接口, 编译连接时需要链接 pthread 库
因为, pthread 是第三方库, 所以我们需要手动链接:
此时, 编译链接成功.
我们运行程序:
thread_show
thread_show
可以看到线程在分别运行, 所输出的进程pid都是相同的.

输出结果可能会混乱, 可能与线程的优先级与CPU核心、线程数有关

输出结果混乱, 也说明了线程可以并行运行

我们进程运行时查看系统的进程表:
系统中只有一个有关threadTest的进程
系统中只有一个有关threadTest的进程
可以看到, 有关threadTest 的进程只有一个.
即, 只有一个进程但是进程内存在多个线程.
那么如何查看线程呢?
我们可以在命令行使用 ps -aL 命令 来查看线程(a: all, -L: 轻量级进程):
可以看到, 线程列表中 存在三个相同命令名的线程. 且这三个线程同时属于一个PID 23412. 还拥有各自的 LWP 轻量级进程编号.
并且此 有一个线程的LWP与PID相同, 表示此线程是主线程

有兴趣的话, 可以在创建两个线程之后, 再创建一个子进程.

创建之后, 观察子进程有没有什么地方与之前创建的子进程时的情况不同

线程相关概念总结

上面 从 介绍线程 到 Linux中的线程 再到 Linux线程查看, 已经分析了很多.
但是似乎还是不能对什么是线程、线程的特点做出一个总结, 那么究竟什么是线程呢?
  1. 线程是程序内部的一个执行线路, 更准确一点的定义是:线程是进程内部的控制序列
  2. 一切进程, 至少都有一个线程
  3. 线程在进程内部运行, 本质是在进程地址空间内运行
  4. 在Linux操作系统中, CPU看到的PCB都比传统意义的PCB要轻量化. 因为Linux中的PCB可能表示的只是一个线程
  5. 透过进程地址空间是可以看到进程的, 将进程资源合理的分配给每一个进程内部的执行流, 就形成了线程执行流

虽然我们说 Linux操作系统中的线程使用进程PCB模拟实现的, 不过其实在设计进程PCB时已经考虑到了线程.
也就是说, 其实PCB(task_struct)内部其实是有用来表示线程的东西的:
task_struct内部, 线程专用的结构体
task_struct内部, 线程专用的结构体
thread_struct{}结构体内部存储的大部分都是寄存器相关信息. 与维护不同线程的上下文数据有关系

Linux内核源码中, 有关于task_struct内部的成员其实我们已经可以看懂一部分了. 可以尝试去分辨一下成员都代表什么

线程的优点

Linux操作系统中其实可以创建多进程来分配代码并执行, 就比如我们创建子进程并让其执行指定部分的代码.
那么为什么还要有线程呢?其实是因为, 多线程相比进程有一定的优势:
  1. 创建一个新线程的成本比创建一个新进程的成本小的多

    创建一个新进程, 操作系统需要分别创建PCB、进程地址空间、页表, 如果对数据做了修改还需要写时拷贝等

    而创建一个新线程, 则只需要创建一个PCB就可以了, 进程地址空间、页表、数据等都直接使用原进程的就可以

  2. 与进程之间的切换相比, 线程之间的切换需要操作系统做的工作也会少很多

    如果CPU需要切换进程运行, 那么不仅需要切换PCB还需要切换页表等诸多的数据

    而切换线程的话, 就只需要切换PCB就可以了

  3. 线程占用资源比进程要少很多

    还是那个原因, 多线程是公用一个进程地址空间和同一页表运行的, 而每个进程都拥有自己的进程地址空间和页表

  4. 对于计算密集型应用, 为了能在多处理器系统上运行, 会将计算分解到多线程去实现

    比如文件加密应用, 可以用多线程将加密工作拆分, 加密完成之后再将文件合并, 就可以完成加密

  5. 对于I/O密集型应用, 为了提高性能, 将I/O操作重叠. 线程可以同时等待不同的I/O操作

    比如一个程序运行时, 需要等待操作系统和网卡之间的I/O操作, 又要等待操作系统和磁盘之间的I/O操作.

    如果单线程的话, 这两个I/O操作只能一个一个等, 不过, 如果是多线程的话就可以同时等待不用排队.

不过, 线程并不是越多越好, 与平台有关, 更准确一点就是与 CPU有关
一般 线程数最好小于等于CPU支持的多线程数.

一般来说, 有多少CPU就可以支持多少线程同时工作.

不过现在CPU都可以模拟多线程, 一个CPU也可能模拟出多线程.

线程的缺点

虽然线程有许多的优点, 但是线程也是存在很大的缺点的:
  1. 可能造成性能损失

    比如一个密集计算型线程正在运行, 且很少或不会被其他外部事件阻塞. 那么这类线程往往是无法与其他线程共用一个CPU的.

    如果密集计算型线程的数量比CPU支持的多线程数量还要多, 这些线程就可能不停的被CPU调度:不停的换出、换入. 因为这些线程都是要运行一下的, 不会只照着一部分线程一直运行, 而是会这一部分执行执行、那一部分执行执行. 这就会因为不停调度而造成性能损失.

    最好线程不要太多.

  2. 健壮性低

    如果是进程, 由于进程地址空间的存在 进程是非常健壮的, 一个进程再怎么运行如果不是刻意为之一般也无法影响另一个进程.

    一个多线程程序内, 可能会因为 时间分配的细微偏差、共享了某些不该共享的数据, 而对其他线程或整个程序造成很大的不良影响.

  3. 缺乏访问控制

    操作系统中, 进程是访问控制的基本粒度, 因为进程具有独立性. 多线程访问可能会同时访问同一个数据, 而且很有可能出大问题

  4. 编程难度高

上面就是多线程的缺点, 不过这些缺点除了第一条, 其他的其实都是对编写者素质的高要求, 什么缺乏访问控制会影响其他线程或整个进程. 其实就是BUG率要高一些. 这就对程序员的素质有较高的要求了.

线程异常

有关线程异常, 其实可以从一个方面理解:
一个多线程进程中, 虽然一般每个线程访问执行的代码和数据不同, 但这些代码和数据都是属于整个进程的, 只有一份.
如果线程出现了异常, 那就说明什么?就说明是进程某处代码出现了异常.
也就是所, 线程出现异常是会影响整个进程 的. 线程出现异常其实就是进程出现了异常.
线程出现异常, 操作系统就会像线程发送信号, 然后会将整个进程终止. 整个进程终止, 进程中的其他所有线程也会退出.

Linux进程 VS 线程

文章已经介绍过了Linux下线程的概念, 那么结合之前介绍的Linux进程.
我们来对比一下, 进程和线程有什么是相同的, 什么是不同的.
  1. 进程是系统资源分配的基本单位

  2. 线程是调度的基本单位

  3. 多线程共享进程数据, 不过不同线程也有自己的一部分数据:

    1. 线程ID

      就像每个进程都有自己的ID一样, 每个线程也都有自己的ID

    2. 一组寄存器

      每个线程都有一组寄存器, 用来维护线程的上下文数据

    3. 线程栈

      进程在运行时, 都会有自己的栈结构, 来给函数的压栈、临时变量等数据提供空间

      其实每个线程也都会维护自己的栈区, 因为线程也可能会不停的函数调用等操作. 所以是需要维护自己的栈区的.

    4. errno

    5. 信号屏蔽字

      上面介绍信号异常时提到, 线程异常 就是 进程异常. 线程异常操作系统会向线程发送信号.

      不过线程是与进程共享信号处理方法的, 所以一般情况下线程异常 也就是进程异常

      不过, 虽然线程与进程共享信号处理方法, 但是线程是有自己的信号屏蔽字的.

      也就是说, 操作系统向线程和进程发送同一信号, 可能进程会递达, 而线程却会阻塞.

      调度优先级

  4. 线程和进程会共享这些资源:

    1. 代码和数据

      进程中定义的函数, 每个线程都可以调用. 进程中定义的全局变量, 每个线程也都可以访问

    2. 文件描述符表

      虽然 文件描述符表并不是进程地址空间内的数据 而是内核数据(在PCB中维护)

      但是 进程的文件描述符表 也是与线程共享的, 线程PCB会指向主线程PCB的文件描述符表

    3. 信号的处理方法

    4. 进程当前运行路径

    5. 用户ID和组ID


文章到这里, 其实Linux线程概念的部分 就已经介绍的差不多了.
感谢阅读~
版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)

作者: 哈米d1ch 发表日期:2023 年 4 月 11 日