一、线程概念
线程是进程的一个执行分支,是在进程内部运行的一个执行流。下面将从是什么、为什么、怎么办三个角度来解释线程。
1、什么是线程
上面是一张用户级页表,我们都知道可执行程序在磁盘中无非就是代码或数据,更准确点表述,代码也是一种数据,程序一运行,实际就会将其加载到物理内存,因为每个进程都有地址空间和页表,所以可以通过用户页表映射物理内存的方式,来找到磁盘或内存中的数据。
如果想创建一个进程,那么这个进程也应该有自己独立的 task_struct、mm_struct、用户页表。如果创建 “进程”,而不独立创建 mm_struct、用户页表,也不进行 I/O 将程序的代码和数据加载到内存,只创建 task_struct,然后让新的 PCB 指向和老的 PCB 指向同样的 mm_struct,再把代码分成多份,通过当前进程的资源的合理的分配,让 CPU 执行不同 PCB 时访问的是不同的代码,都能使用进程的一部分资源(在系统层面上这里一定可以做到把不同的代码分配给不同的执行流,只要划分用户级页表就可以让不同的 PCB 看到页表的一部分,此时就只能看到进程资源的一部分)。站在资源的角度来说,地址空间其实是进程 pcb 的资源窗口,之前是只有一个窗户和一个人,现在是有多个窗户和多个人。每个 PCB 被 CPU 调度时,执行的粒度一定是要比原始进程执行的粒度要更小,那么我们就称比进程执行粒度更小的为 Linux 线程,这是 Linux 线程的原理。
什么叫做线程?如何理解线程是在进程的内部运行?什么叫做线程是进程的一个执行分支?
什么是进程呢?
站在数据结构角度,仅仅 PCB 已经代表不了进程了,我们要明确进程就是 task_struct + mm_struct + 用户级页表 + 物理内存中映射的代码和数据。
站在 OS 系统角度谈,进程是承担分配系统资源的基本实体。
在创建第 2 个、第 3 个线程时,对应的线程它们所需的资源早就已经申请好了,而这些资源是曾经进程就已经申请好的资源,这就是所谓承担。OS 在分配资源时,不是以一个独立线程去分配,而是先由进程分配资源,然后每个线程再向进程要资源。
举个生活中的例子,你可以向你爸妈要钱,当你不要时,这个钱也早就被你爸妈赚到了,而你爸妈赚的是社会的钱,社会就相当于整个 OS。而你爸妈构建的这一个家庭就是承担分配社会资源的基本实体,基本实体就像一套房,它不是一人一套,而是一个家庭一套。
所以,解决了这个问题,那么前三个问题就迎刃而解了:子女等是在家庭内部运行,线程是在进程的内部运行。子女等是家庭的基本单位,线程是 OS 调度的基本单位。子女等是一个家庭的执行分支,线程是进程的一个执行分支。
也就是说一个进程被创建好后,后续内存可能存在多个执行流,即多线程。现在再站在数据结构角度上看,还要明确进程就是 task_struct + task_struct + task_struct + … … + mm_struct + 用户级页表 + 物理内存中映射的代码和数据,而其中内部的一个执行流只能称为线程。
所以我们再以现在的角度看以前在进程控制、基础 I/O、进程通信中所讲的进程,其实都没有问题,只不过以前讲的进程,其内部只有一个执行流罢了。
(1)Linux 线程 VS 其它平台的线程
前面所谈的本质就是 Linux 线程的基本原理。站在 CPU 的角度,对比历史的进程当然没有区别,CPU 看到的还是一个个的 PCB,只不过 CPU 执行时,“可能” 执行的 “进程流” 已经比历史的更加轻量化了。这很好理解,同一个效率的不同进程,前者只有 1 个执行流,而后者有 4 个执行流,且进行了合理的资源分配。所以当执行后者时,可能就会比前者更加轻量化(注意 5 个 PCB 在 CPU 的等待队列中排队,CPU 在调度时都是按照 1 个 PCB 为单位正常调用,它不关心你前者和后者有几个 PCB)。再者,假设后者两个执行流要进行切换,上下文数据也少不了切换,但 mm_struct、用户页表、代码和数据完全不用管,这相对历史进程切换就显得更加轻量化了。
Linux 下其实并没有真正意义上的线程概念,而是用进程 pcb 模拟的线程。Linux 并不能直接给我们提供线程相关的接口,只能提供轻量级进程的接口。换而言之,我们 Linux 下的进程往往比其它平台上的进程更加轻量化,是因为它有可能是只有一个线程的进程,也有可能是有多个线程的进程,所以我们把 Linux 下的进程称为轻量级进程。所以,站在 Linux 系统的角度,我们不区分它是线程还是进程,而统一轻量级进程。
Windows 具有真正意义上的线程概念。系统中一定存在着大量的进程,而进程 : 线程 = 1 : n,所以系统也一定存在大量的线程,而且不比进程少,OS 也一定要管理线程,那应该如何管理呢?—— 先描述,再组织。所以,支持真线程的系统一定要先描述线程 TCB(Thread Control Block),其内部一定是 PCB && TCB 共生,系统中已经存在了大量的 PCB,还要存在更大量的 TCB,然后 TCB 还要和 PCB 产生某些关系以证明该线程是该进程内的一个执行流,这样一来 OS 既要进程管理,又要线程管理,就一定会使得该 OS 设计的很复杂,其中在描述 TCB 的时候也一定需要和 PCB 类似的各种属性。
但实际上,可以发现线程和进程一样,也是一种执行流,所以一定的是 PCB 和 TCB 在描述时会存在着大量重复的属性。所以,我们可以看到 Windows 确实存在多线程,只不过代价很大,而 Linux 看到后,无论你是什么线程,同样也是执行流,所以就把进程和线程统一了,所以 Linux Kernel 中就没有线程 TCB 的概念。所以,Windows 在 OS 层面下一定提供了相关线程控制的接口,而 Linux 下虽然设计的更简单了,但它不可能在 OS 层面提供线程控制的相关系统调用接口,最多提供了轻量级进程相关的系统调用接口,如 vfork、clone。实际在应用层 Linux 下有一套系统级别的原生线程库 pthread,原生线程库就是在应用层实现的库。其实 C++、Python 等支持多线程的这些语言是有自己原生写好的线程的,且底层一定是用到下面要讲的 pthread。
注意:在编译时需要 -l 在默认路径下指定 pthread 动态库,否则在编译时会报错,在指定 pthread 库后,ldd 就可以看到成功了。
可以看到主线程每 1 秒执行 while 循环,新线程每 1 秒执行回调函数(严格来说,新线程是指主线程调用 pthread_create 时,pthread_create 再去调用 start_routine 的情况)。之前是 ps ajx 来查看一个进程的相关信息,而现在可以用 ps -aL 来查看轻量级进程,可以发现后者的 PID 是相同的,这就说明两个 mythread 本质是属于一个进程,而每一个线程也需要唯一标识。所以,LWP 用来标识线程的唯一性,PID 标识进程的唯一性。这里还可以看到的是第一组 PID 和 LWP 的值是相同的,如果进程内部是单执行流时,此时 ps ajx 和 ps -aL 查看时 PID 和 LWP 的值是一样的,所以在多线程之前 PID 和 LWP 的意义是一样的。所以,调度进程 CPU 看的是 LWP,那么也就说明了如果 PID 和 LWP 两个值是相同的,那么对应的 LWP 对应的 ID 就是主线程。当我们杀死新线程时,也会影响主线程,说明它们是共生的。
(2)小结
- 在一个程序里的一个执行路线就叫做线程(thread),更准确的定义是:线程是 “一个进程内部的控制序列”。
- 一切进程至少都有一个执行线程。
- 线程在进程内部运行,本质是在进程地址空间内运行。
- 在 Linux 系统中,在 CPU 眼中,看到的 PCB 都要比传统的进程更加轻量化。
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
2、线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多。
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
- 线程占用的资源要比进程少很多。
- 能充分利用多处理器的可并行数量。
- 在等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务。
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
- I/O密集型应用,为了提高性能,将 I/O 操作重叠。线程可以同时等待不同的 I/O 操作。
3、线程的缺点
因为所有 PCB 共享地址空间,在理论上,每个线程都能访问进程的所有资源,所以这里还有一个好处就是线程间通信的成本很低,同样缺点也很明显,其内部一定存在着临界资源,所以可能需要使用各种同步与互斥机制。当然线程并不是越多越好,而是合适就好,如果线程增加的太多,可能 大部分时间 CPU 并不是在计算,而是在进行线程切换。就好比一家公司,每个人都各自清楚的做着事情,但当公司人数过多,可能反而会导致公司效率变低。
(1)性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
(2)健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
(3)缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些 OS 函数会对整个进程造成影响。
(4)编程难度提高
编写与调试一个多线程程序比单线程程序困难得多。
4、线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
5、线程用途
- 合理的使用多线程,能提高 CPU 密集型程序的执行效率。
- 合理的使用多线程,能提高 IO 密集型程序的用户体验。(比如,我们一边写代码一边下载开发工具,就是类似多线程运行的一种表现)
二、进程 VS 线程
1、进程和线程
- 进程是资源分配的基本单位。
- 线程是调度的基本单位。
- 线程共享进程数据,共享的进程数据包括代码区、字符常量区、全局初始化和未初始化数据、堆区、共享区、命令行参数和环境变量、内核区等等。但也独立拥有自己的一部分数据,这一部分数据是不共享的:
- 线程 ID(即 LWP)
- 对应的寄存器数据(CPU 调度是按 PCB 调度的,每个线程都有自己的上下文数据,所以必须保证每个线程的上下文数据是各自私有的,这也体现了线程是可以切换的)
- 栈(每个线程都有自己的栈结构,这也体现了线程是独立运行的)
- errno
- 信号屏蔽字(多线程中 block 表是私有的)
- 调度优先级
进程的多个线程共享同一地址空间, 因此 Text Segment 、 Data Segment 都是共享的, 如果定义一个函数, 在各线程中都可以调用, 如果定义一个全局变量, 在各线程中都可以访问到, 除此之外 各线程还共享以下进程资源和环境:
- 文件描述符表(注意在多进程中不共享文件描述符表,在管道我们说过两个进程可以指向同一个文件,其中并是说它们共享文件描述符表,而是它们表中的内容是一样的,而多线程是可以共享文件描述符表的)
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL 或者自定义的信号处理函数)(handler 表是线程共享的)
- 当前工作目录
- 用户 id 和组 id
为什么线程调度的成本更低呢?
如果在一个进程内调度其若干个线程,首先地址空间和页表也不需要切换。CPU 内部是有硬件级别 L1~L3 的缓存 Cache 根据局部性原理对内存的代码和数据来预读 CPU 内部。
如果进程切换 cache 就立即失效,新进程过来就只能重新缓存。
【进程和线程的关系图 】
如何看待之前学习的单进程?
具有一个线程执行流的进程。
三、线程控制
1、POSIX线程库
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以 pthread_ 打头的。
- 要使用这些函数库,要通过引入头文 。
- 链接这些线程函数库时要使用编译器命令的 -lpthread 选项。
- 编译链接时需要 -l 指定 pthread 库。
- 系统中是默认已经安装了 pthread 库。
POSIX 和 System -V 都是用于系统级接口的 IPC(进程间通信)标准,它们可以用于多进程和多线程之间的通信。POSIX 是可移植操作系统接口,由 IEEE 制定的一系列标准,旨在提高 OS 之间的互操作性。而 System 是 AT&T 公司开发的,它是 Unix 的一种版本。相比 System -V,POSIX 是一个比较新的标准,语法也相对简单,而 System -V 年代久远,不过也因此有许多系统支持,使用更加广泛。
由于没有固定标准,所以不同 OS 之间存在一些差异,在不同通信方式中,两者都有利弊。
- 在信号量方面,POSIX 在无竞争条件下不会陷入内核,而 System -V 则是无论何时都要陷入内核,因此后者性能略差。
- 在消息队列方面,POSIX 实现尚未完善,System -V 仍是主流。
在多线程中,基本使用的是 POSIX,而在多进程中则是 System -V。
pthread 是 POSIX 线程库的一部分,它提供了一组 API,用于在多线程环境中创建和管理线程,是一种轻量级进程。pthread 库囊括的东西很多,最经典的是现在所谈的线程库和后面网络所谈的套接字。
2、创建线程
(1)接口介绍
- thread:输出型参数,返回线程 ID。
- attr:设置线程的属性,attr 为 NULL 表示使用默认属性。
- start_routine:想让线程执行的任务,它是一个返回值 void*,参数 void* 的一个函数指针。
- arg:回调函数的参数,若线程创建成功,在执行 start_routine 时,会把 arg 传入 start_routine。
(2)错误检查
- 传统的一些函数是,成功返回 0,失败返回 -1,并且对全局变量 errno 赋值以指示错误。
- pthreads 函数出错时不会设置全局变量 errno(而大部分其他 POSIX 函数会这样做),而是将错误代码通过返回值返回。
- pthreads 同样也提供了线程内的 errno 变量,以支持其它使用 errno 的代码。对于pthreads 函数的错误,建议通过返回值业判定,因为读取返回值要比读取线程内的 errno 变量的开销更小
(3)代码
【除 0 错误】
这里让新线程执行除 0 操作,我们发现它会影响整个进程。线程是进程的一个执行分支,除 0 错误操作会导致线程退出的同时,也意味着进程触发了该错误,进而导致进程退出。这也就是线程会使用代码健壮性降低的一个表现。
哪个线程先运行跟调度器有关。线程一旦异常,就有可能导致整个进程整体退出。
线程在创建并执行时,线程也是需要进行等待的,如果主进程不等待,就会引起类似于进程的僵尸问题,导致内存泄漏。
主线程可以直接获取新线程退出的结果:
【Linux 系统】多线程(线程控制、线程互斥与同步、互斥量与条件变量)-- 详解(中)https://developer.aliyun.com/article/1515719?spm=a2c6h.13148508.setting.29.11104f0e63xoTy