【Linux】线程概念详析
我们已经了解了Linux操作系统进程部分相关知识:
博主有关Linux进程相关介绍的文章:
💥[Linux] 系统进程相关概念、系统调用、Linux进程详析、进程查看、fork()初识
💥[Linux] 进程状态相关概念、Linux实际进程状态、进程优先级
💥[Linux] 什么是进程地址空间?父子进程的代码时如何继承的?程序是怎么加载成进程的?为什么要有进程地址空间?
💥[Linux] 详析进程控制:fork子进程运行规则?怎么回收子进程?什么是进程替换?进程替换怎么操作?
通过阅读这几篇文章, 至少可以让我们对Linux系统中的进程 有一个最基本又相对全面的认识.
但是今天这篇文章, 可能又会对之前介绍过的进程多少有一些推翻.
本篇文章的主要内容是:Linux操作系统中, 有关多线程的相关介绍.
文章目录
- Linux线程概念
- 什么是线程
- Linux进程-再理解
- Linux线程的创建、查看
- pthread_create 和 pthread_join
- 线程相关概念总结
- 线程的优点
- 线程的缺点
- 线程异常
- Linux进程 VS 线程
Linux线程概念
线程可以说是实际区别于进程的一个概念, 但也可以说是实际没有区别于进程的一个概念.
而实际区别与否, 其实 与平台有关
什么是线程
有关线程的概念, 大概可以通过三个要点介绍:
- 线程是在进程内部运行的执行流
- 线程相比进程, 粒度更细, 调用成本更低
- 线程是CPU调度的基本单位
不过这三个要点只能让你大概的对线程有一个最最最基本的认识:线程比进程要小. 但具体怎么小 是不知道的.
不过可以举个例子来简单的介绍一下, Linux下的线程:
在之前有关进程的介绍中, Linux系统中的进程 = PCB + 被加载到内存中的程序数据, 不过 PCB和内存中的程序数据 并不是直接相映射的.
之间还要通过 进程地址空间和相应的页表, 不过CPU实际只是是通过访问PCB 来实现对进程的调度的:
PCB(task_struct)中描述着进程地址空间, 进程地址空间与物理内存 通过两张页表来相互映射.
这是Linux系统中, 单个进程实际在操作系统中的存在形式.
系统创建进程会创建这所有的格式和数据.
不过我们也介绍过, 如果通过fork创建子进程, 在未作数据修改时 子进程与父进程是共享进程的数据和代码的. (子进程也存在自己的进程地址空间和页表, 只不过指向同一块数据和代码)
而且, 我们可以通过对fork()返回值的判断, 让父子进程执行不同的代码块
.
这, 其实说明了一个 细节:不同的执行流, 可以做到执行不同的资源, 即 可以做到对特定资源的划分
那么, 如果下次创建进程, 操作系统并不创建有关进程的所有结构, 而是只创建 PCB
. 将新的PCB 指向已经存在的进程.
然后, 以子进程划分程序资源类似的手段, 将进程的代码划分为不同的区域, 并将不同的PCB设置为实际分别负责执行不同的区域的代码:
最终, 不同的PCB可以访问进程地址空间内代码区的不同区域, 并通过相应的页表来访问到实际的物理内存.
实际上这样就在进程内部创建了多个PCB执行流, 而每个PCB执行流都只能访问一小部分的代码一小部分的页表.
那么, 在Linux操作系统中
, 我们就 可以将这样的PCB执行流称作 "线程"
.
这里只是介绍了一下Linux操作系统中, 线程的
粗粒度
的实现原理
介绍了Linux平台下 线程的粗粒度的实现原理, 我们应该可以理解一个内容:线程 : 进程 = N : 1
操作系统 对 比线程数量要少的进程 都会用 PCB
将进程的所有属性 描述、组织、管理起来, 那么对线程, 毫无疑问也是需要用一个结构体描述、组织、管理起来的. 在大多数的操作系统中, 描述线程的结构体
叫做 TCB
如果一个操作系统, 为了描述管理进程和线程, 在内核分别实现了不同的 PCB
和 TCB
. 那么 PCB
和 TCB
之间一定存在非常复杂的耦合关系. 因为 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调度的基本单位
那么此时, 我们应该就可以理解 有关线程的概念三个要点介绍:
-
线程是在进程内部运行的执行流
线程只访问执行进程的一部分数据和代码
-
线程相比进程, 粒度更细, 调用成本更低
进程切换调度, 需要切换PCB、进程地址空间、页表等
而线程切换调度, 只需要切换TCB(实际还是PCB)就可以
-
线程是CPU调度的基本单位
Linux线程的创建、查看
介绍线程介绍了这么多, 那么 Linux中如何创建并查看线程呢?
下面我们来直接简单演示一下, 不做太多的介绍. 只创建和查看线程.
pthread_create 和 pthread_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);
此函数的参数很简单:
- 第一个参数传入 需要等待的线程的id
- 第二个参数接收退出结果, 暂时不关心. 我们只是看一看现象
简单的了解之后, 我们就可以使用此接口 创建线程:
#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 是第三方库, 所以我们需要手动链接:
此时, 编译链接成功.
我们运行程序:
可以看到线程在分别运行, 所输出的进程pid都是相同的.
输出结果可能会混乱, 可能与线程的优先级与CPU核心、线程数有关
输出结果混乱, 也说明了线程可以并行运行
我们进程运行时查看系统的进程表:
可以看到, 有关threadTest 的进程只有一个.
即, 只有一个进程但是进程内存在多个线程.
那么如何查看线程呢?
我们可以在命令行使用 ps -aL
命令 来查看线程(a: all, -L: 轻量级进程):
可以看到, 线程列表中 存在三个相同命令名的线程. 且这三个线程同时属于一个PID 23412. 还拥有各自的 LWP 轻量级进程编号
.
并且此 有一个线程的LWP与PID相同, 表示此线程是主线程
有兴趣的话, 可以在创建两个线程之后, 再创建一个子进程.
创建之后, 观察子进程有没有什么地方与之前创建的子进程时的情况不同
线程相关概念总结
上面 从 介绍线程 到 Linux中的线程 再到 Linux线程查看, 已经分析了很多.
但是似乎还是不能对什么是线程、线程的特点做出一个总结, 那么究竟什么是线程呢?
- 线程是程序内部的一个执行线路, 更准确一点的定义是:
线程是进程内部的控制序列
- 一切进程,
至少都有一个线程
- 线程在进程内部运行, 本质是在进程地址空间内运行
- 在
Linux
操作系统中,CPU看到的PCB都比传统意义的PCB要轻量化
. 因为Linux中的PCB可能表示的只是一个线程 - 透过进程地址空间是可以看到进程的,
将进程资源合理的分配给每一个进程内部的执行流, 就形成了线程执行流
虽然我们说 Linux操作系统中的线程使用进程PCB模拟实现的, 不过其实在设计进程PCB时已经考虑到了线程.
也就是说, 其实PCB(task_struct)内部其实是有用来表示线程的东西的:
thread_struct{}
结构体内部存储的大部分都是寄存器相关信息. 与维护不同线程的上下文数据有关系
Linux内核源码中, 有关于task_struct内部的成员其实我们已经可以看懂一部分了. 可以尝试去分辨一下成员都代表什么
线程的优点
Linux操作系统中其实可以创建多进程来分配代码并执行, 就比如我们创建子进程并让其执行指定部分的代码.
那么为什么还要有线程呢?其实是因为, 多线程相比进程有一定的优势:
- 创建一个新线程的成本比创建一个新进程的成本小的多
创建一个新进程, 操作系统需要分别创建PCB、进程地址空间、页表, 如果对数据做了修改还需要写时拷贝等
而创建一个新线程, 则只需要创建一个PCB就可以了, 进程地址空间、页表、数据等都直接使用原进程的就可以
- 与进程之间的切换相比, 线程之间的切换需要操作系统做的工作也会少很多
如果CPU需要切换进程运行, 那么不仅需要切换PCB还需要切换页表等诸多的数据
而切换线程的话, 就只需要切换PCB就可以了
-
线程占用资源比进程要少很多
还是那个原因, 多线程是公用一个进程地址空间和同一页表运行的, 而每个进程都拥有自己的进程地址空间和页表
-
对于计算密集型应用, 为了能在多处理器系统上运行, 会将计算分解到多线程去实现
比如文件加密应用, 可以用多线程将加密工作拆分, 加密完成之后再将文件合并, 就可以完成加密
-
对于I/O密集型应用, 为了提高性能, 将I/O操作重叠. 线程可以同时等待不同的I/O操作
比如一个程序运行时, 需要等待操作系统和网卡之间的I/O操作, 又要等待操作系统和磁盘之间的I/O操作.
如果单线程的话, 这两个I/O操作只能一个一个等, 不过, 如果是多线程的话就可以同时等待不用排队.
不过, 线程并不是越多越好, 与平台有关, 更准确一点就是与 CPU有关
一般 线程数最好小于等于CPU支持的多线程数.
一般来说, 有多少CPU就可以支持多少线程同时工作.
不过现在CPU都可以模拟多线程, 一个CPU也可能模拟出多线程.
线程的缺点
虽然线程有许多的优点, 但是线程也是存在很大的缺点的:
-
可能造成性能损失
比如一个密集计算型线程正在运行, 且很少或不会被其他外部事件阻塞. 那么这类线程往往是无法与其他线程共用一个CPU的.
如果密集计算型线程的数量比CPU支持的多线程数量还要多, 这些线程就可能不停的被CPU调度:不停的换出、换入. 因为这些线程都是要运行一下的, 不会只照着一部分线程一直运行, 而是会这一部分执行执行、那一部分执行执行. 这就会因为不停调度而造成性能损失.
最好线程不要太多.
-
健壮性低
如果是进程, 由于进程地址空间的存在 进程是非常健壮的, 一个进程再怎么运行如果不是刻意为之一般也无法影响另一个进程.
一个多线程程序内, 可能会因为 时间分配的细微偏差、共享了某些不该共享的数据, 而对其他线程或整个程序造成很大的不良影响.
-
缺乏访问控制
操作系统中, 进程是访问控制的基本粒度, 因为进程具有独立性. 多线程访问可能会同时访问同一个数据, 而且很有可能出大问题
-
编程难度高
上面就是多线程的缺点, 不过这些缺点除了第一条, 其他的其实都是对编写者素质的高要求, 什么缺乏访问控制会影响其他线程或整个进程. 其实就是BUG率要高一些. 这就对程序员的素质有较高的要求了.
线程异常
有关线程异常, 其实可以从一个方面理解:
一个多线程进程中, 虽然一般每个线程访问执行的代码和数据不同, 但这些代码和数据都是属于整个进程的, 只有一份.
如果线程出现了异常, 那就说明什么?就说明是进程某处代码出现了异常.
也就是所, 线程出现异常是会影响整个进程
的. 线程出现异常
其实就是进程出现了异常
.
线程出现异常, 操作系统就会像线程发送信号, 然后会将整个进程终止. 整个进程终止, 进程中的其他所有线程也会退出.
Linux进程 VS 线程
文章已经介绍过了Linux下线程的概念, 那么结合之前介绍的Linux进程.
我们来对比一下, 进程和线程有什么是相同的, 什么是不同的.
-
进程是系统资源分配的基本单位
-
线程是调度的基本单位
-
多线程共享进程数据, 不过不同线程也有自己的一部分数据:
-
线程ID
就像每个进程都有自己的ID一样, 每个线程也都有自己的ID
-
一组寄存器
每个线程都有一组寄存器, 用来维护线程的上下文数据
-
线程栈
进程在运行时, 都会有自己的栈结构, 来给函数的压栈、临时变量等数据提供空间
其实每个线程也都会维护自己的栈区, 因为线程也可能会不停的函数调用等操作. 所以是需要维护自己的栈区的.
-
errno
-
信号屏蔽字
上面介绍信号异常时提到, 线程异常 就是 进程异常. 线程异常操作系统会向线程发送信号.
不过线程是与进程共享信号处理方法的, 所以一般情况下线程异常 也就是进程异常
不过, 虽然线程与进程共享信号处理方法, 但是线程是有自己的信号屏蔽字的.
也就是说, 操作系统向线程和进程发送同一信号, 可能进程会递达, 而线程却会阻塞.
-
调度优先级
-
-
线程和进程会共享这些资源:
-
代码和数据
进程中定义的函数, 每个线程都可以调用. 进程中定义的全局变量, 每个线程也都可以访问
-
文件描述符表
虽然 文件描述符表并不是进程地址空间内的数据 而是内核数据(在PCB中维护)
但是 进程的文件描述符表 也是与线程共享的, 线程PCB会指向主线程PCB的文件描述符表
-
信号的处理方法
-
进程当前运行路径
-
用户ID和组ID
-
文章到这里, 其实Linux线程概念的部分 就已经介绍的差不多了.
感谢阅读~