前言-为什么需要线程?
多任务并行的需求引出了进程的概念。进程内部并发的需求引出了线程。
沿用上一篇中介绍进程提到的例子,一个进程就像我们在厨房做菜的过程,操作系统会为进程分配资源,就像油盐糖醋。假如有一个进程是要做东坡肉和西红柿炒鸡蛋这两盘菜,如果只有进程的概念,方案一是先等东坡肉做完了再去做西红柿炒鸡蛋(串行),方案二是分配两个厨房和两套厨具调料,可以同时做两道菜(并行,但是分成了两个进程)。如果引入了线程的概念,就可以只用一套厨具调料,在等东坡肉慢慢煮的时候去做西红柿炒鸡蛋(实现了进程内部的并行)。
所以,线程就是为了实现共享进程资源的前提下,实现进程内部的并发执行。
本文中所描述的线程,只是广义上的概念,与实际操作系统实现并不一定相同。比如在 Linux 中,线程相当于是进程,用相同的进程描述符描述,且使用相同的调度算法。
线程
Thread,线程是进程当中的一条执行流程,又被称作轻量级进程。在传统的操作系统中,每个进程都有一个地址空间和一个控制线程(主线程)。
同一个进程中,每个线程共享地址空间、全局变量、打开文件、子进程、即将发生的定时器、信号与信号处理程序、账户信息。每个线程都可以访问进程地址空间内每个内存地址,因此一个线程可以读取、写入甚至擦除另一个线程的堆栈。
每个线程具有各自的程序计数器、寄存器、堆栈、状态。
线程的优点:
- 一个进程中可以同时存在多个线程;
- 各个线程之间可以并发执行;
- 各个线程之间可以共享地址空间和文件等资源;
- 线程比进程更轻量级,创建、撤销、上下文切换的开销更小
- 性能更高(如果存在着大量的计算和大量的 I/O 处理,拥有多个线程能在这些活动中彼此重叠进行,从而会加快应用程序的执行速度)
线程的缺点:
- 当进程中的一个线程崩溃时,可能会导致其所属进程的所有线程崩溃(不同的编程语言有不同的处理方式,比如Java语言中的线程奔溃不会造成进程崩溃)。
线程表
线程表和内核中的进程表类似,不过它仅仅记录各个线程的属性,如每个线程的程序计数器、堆栈指针、寄存器和状态。
- 用户线程的线程表存在每个进程中,在用户空间。
- 内核线程的线程表存在内核空间中。
线程的状态
线程的基本状态和进程一样,但是不同用户线程可以自定义不同的状态。(比如 Java 中的六种状态:新创建、可运行、被阻塞、等待、计时等待、被终止)
线程的上下文切换
- 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
- 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;
进程的上下文切换还可能会导致 TLB 的更新,从而引发 cache miss,造成不必要的开销。
线程的实现
主要有三种线程实现的方式:用户线程、内核线程和轻量级进程。
用户线程
在早期的操作系统中,所有的线程都是在用户空间下实现的,操作系统只能看到线程所属的进程,而不能看到线程。
在用户空间实现的线程,是由用户进程(通过调用用户级的线程库)来完成线程的管理, 内核对线程一无所知。在用户空间管理线程时,每个进程需要有其专用的线程表用来跟踪该进程中的线程。
操作系统只能看到线程所属的进程,而不能看到线程。CPU 调度也是直接调度进程,随后再由进程自定义的调度算法进行线程的调度。
优点:
- 每个进程都有线程表,线程表由用户级线程库函数来维护,可用于不支持线程技术的操作系统;
- 线程调度和管理(创建、销毁、上下文切换)不需要内核的参与,即不需要用户态/内核态的切换,效率更高;
- 允许每个进程有自己定制的调度算法;
缺点:
- 如果一个线程阻塞,则整个进程都会被阻塞(因为中断需要内核态);
- 除非线程自愿放弃使用 CPU,否则该进程中的其他线程都无法运行(因为中断需要内核态);
- 一个进程只能使用一个内核,无法发挥多核优势;
- 与内核的沟通成本大(线程间通信等需要内核参与的情况);
内核线程
几乎所有的现代操作系统都支持内核线程。
内核线程是由操作系统管理的,线程表是放在内核中的,这样线程的管理都是由操作系统负责。
每个内核线程可以视为内核的一个分身。
内核线性没有用户地址空间的概念,使用的是所有进程共享的内核地址空间,但是调度的时候会借用前一个进程的地址空间。
采用一对一的线程模型。
内核线程的祖先线程是 kthreadd,pid 为 2。
优点:
- 不需要切换用户态内核态,可以直接执行;
- 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;
- 时间片分配给线程,多线程的进程获得更多的 CPU 运行时间,可以充分利用多核优势;
缺点:
- 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB;
- 线程的创建、终止和切换都是通过系统调用的方式来进行,开销较大;
- 数量有限;
轻量级进程
Light Weight Process,是内核支持的用户线程,是内核线程的高度抽象。一个进程可有一个或多个 LWP,每个 LWP 由一个单独的内核线程来支持,LWP 是由内核管理并像普通进程一样被调度。
LWP 与父进程共享地址空间和系统资源。
LWP 与普通进程的区别在于它只有一个最小的执行上下文和调度程序所需的统计信息,代表程序的执行线程。
LWP 的许多操作都要进行系统调用,切换用户态和内核态,因此效率不高。
每个 LWP 都会与一个内核线程相关联,以此来实现由内核支持的用户线程。这个关联现在是 NPTL(Next POSIX Thread Library)来做的,实现了对 POSIX 标准的兼容。
Linux 中,一个用户线程对应一个轻量级进程,而一个轻量级进程对应一个特定的内核线程。
在 LWP 之上也可以使用用户线程,LWP 与用户线程的对应关系有三种:一对一、一对多和多对多。
一对一
一个 LWP 对应一个用户线程。
- 优点:实现并行,当一个 LWP 阻塞,不会影响到其他 LWP。
- 缺点:每一个用户线程都需要一个内核线程,创建线程的开销较大。
一对多
一个 LWP 对应多个用户线程。
- 优点:线程管理是在用户空间完成的,效率高。
- 缺点:一个用户线程的阻塞会导致整个进程的阻塞,且无法发挥多核 CPU 的优势。
多对多
多个 LWP 对应多个用户线程。可以综合前两种模式的优势。
用户线程和内核线程区别
- 用户线程是由用户创建的,不需要内核支持;内核线程是由内核创建的,需要内核支持,常用来执行一些后台任务;
- 用户线程是内核不可见的,TCB 存在用户空间中;内核线程是内核可见的,TCB 存在内核空间中;
- 用户线程的创建、撤消和调度不需要内核支持,是由编程语言处理的;内核线程的创建、撤消和调度都需要内核提供支持,而且与进程的创建、撤消和调度大致是相同的。
- 用户线程则既可以运行在内核态,也可以运行在用户态;内核线程只工作在内核态中;
- 用户线程阻塞时将导致其所属的整个进程被阻塞;内核支持线程阻塞时,只导致该线程被阻塞;
- 用户线程的 CPU 调度是以进程为单位,再由用户程序控制进程内线程的轮换运行;内核线程的 CPU 调度是以线程为单位,由内核线程调度程序负责;
- 用户线程不能发挥多核 CPU 优势;内核线程可以发挥多核 CPU 优势;
线程模型
用户线程和内核线程之间的关联方式有三种,即一对一、多对一、多对多。
多对一
多个用户线程对应一个内核线程。内核线程相当于与整个进程相关联,CPU 调度内核线程,即相当于调度进程,然后进程再自定义地调度线程。
线程管理由用户空间的线程库处理,不需要切换内核态,效率高。
但是如果线程阻塞,那么整个进程也会被阻塞,且不能发挥多核 CPU 的优势。
一对一
一个用户线程对应一个内核线程,是在内核空间实现线程所采用的模型。调度内核线程,就相当于调度一个个用户线程。
解决了一对多模型的两个问题,但是开销更大。另外,由于每个用户线程都需要一个内核线程,所以可创建的线程数会受到限制。
Windows/Linux 使用的都是一对一的模型。
多对多
多个用户线程对应多个内核线程。能够综合上两种模型的优点,但是实现较为复杂。
POSIX线程
为了使编写可移植线程程序成为可能,IEEE 在 IEEE 标准 1003.1c 中定义了线程标准。线程包被定义为 Pthreads
。大部分的 UNIX 系统支持它。这个标准定义了 60 多种功能调用。
POSIX 线程(通常称为 pthreads)是一种独立于语言而存在的执行模型,以及并行执行模型。它允许程序控制时间上重叠的多个不同的工作流程。每个工作流程都称为一个线程,可以通过调用 POSIX Threads API 来实现对这些流程的创建和控制。可以把它理解为线程的标准。
POSIX Threads 的实现在许多类似且符合 POSIX 的操作系统上可用,例如 FreeBSD、NetBSD、OpenBSD、Linux、macOS、Android、Solaris,它在现有 Windows API 之上实现了 pthread。
idle线程
每一个 CPU 核心都会有一个 idle 线程,当系统没有调度 CPU 资源的时候,会进入 idle 线程空转 CPU,以达到省电的目的。
idle 线程实质上是内核线程,是被静态创建的。
进程和线程
- 进程是系统进行资源分配和调度的基本单位,线程是 CPU 调度和分派的基本单位;
- 线程依赖于进程而存在,一个进程至少有一个线程;
- 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
- 进程有自己的独立地址空间,线程共享所属进程的地址空间;
- 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如程序计数器,一组寄存器和栈,并和其他线程共享本进程的相关资源如内存、I/O、cpu等;
- 在进程切换时,涉及到整个当前进程 CPU 环境的保存环境的设置以及新被调度运行的 CPU 环境的设置,而线程切换只需保存和设置少量的寄存器的内容,并不涉及存储器管理方面的操作,可见,进程切换的开销远大于线程切换的开销;
- 线程之间的通信更方便,同一进程下的线程共享全局变量等数据,而进程之间的通信需要以进程间通信(IPC)方式进行;
- 多线程程序只要有一个线程崩溃,整个程序就崩溃了,但多进程程序中一个进程崩溃并不会对其它进程造成影响,因为进程有自己的独立地址空间,因此多进程更加健壮
- 进程间是竞争状态;线程间是合作状态
线程相比进程能减少开销,体现在:
- 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
- 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
- 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
- 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;
总结
线程是为了实现共享进程资源的前提下,实现进程内部的并发执行。
线程共享进程的地址空间、数据、文件等信息,且拥有自己的寄存器和堆栈等信息。
同一个进程内的线程的上下文切换代价更小。
线程的实现方式通常有三种:用户线程、内核线程和轻量级进程。