线程和进程到底什么区别?

RT,面试必问的题

Posted by renchao on July 13, 2017

线程和进程到底什么区别?

维基百科

进程(process),是计算机中已运行程序的实体。进程为曾经是分时系统的基本运作单位。在面向进程设计的系统(如早期的UNIXLinux 2.4及更早的版本)中,进程是程序的基本执行实体;在面向线程设计的系统(如当代多数操作系统、Linux 2.6及更新的版本)中,进程本身不是基本运行单位,而是线程的容器

程序本身只是指令、数据及其组织形式的描述,进程才是程序(那些指令和数据)的真正运行实例。若干进程有可能与同一个程序相关系,且每个进程皆可以同步(循序)或异步(平行)的方式独立运行。现代计算机系统可在同一段时间内以进程的形式将多个程序加载到内存中,并借由时间共享(或称时分复用),以在一个处理器上表现出同时(平行性)运行的感觉。同样的,使用多线程技术(多线程即每一个线程都代表一个进程内的一个独立执行上下文)的操作系统或计算机架构,同样程序的平行线程,可在多CPU主机或网络上真正同时运行(在不同的CPU上)。

线程(thread)操作系统能够进行运算调度的最小单位它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。在Unix System VSunOS中也被称为轻量进程(lightweight processes),但轻量进程更多指内核线程(kernel thread),而把用户线程(user thread)称为线程。

线程是独立调度和分派的基本单位。线程可以操作系统内核调度的内核线程,如Win32线程;由用户进程自行调度的用户线程,如Linux平台的POSIX Thread;或者由内核与用户进程,如Windows 7的线程,进行混合调度。

同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage)。

一个进程可以有很多线程,每条线程并行执行不同的任务。

在多核或多CPU,或支持Hyper-threading的CPU上使用多线程程序设计的好处是显而易见,即提高了程序的执行吞吐率。在单CPU单核的计算机上,使用多线程技术,也可以把进程中负责IO处理、人机交互而常被阻塞的部分与密集计算的部分分开来执行,编写专门的workhorse线程执行密集计算,从而提高了程序的执行效率。

​ — 维基百科

简单总结

其实看了这么多,总结一下就是:

一个程序就是没有生命的代码,只有进程才能让这些代码运行起来,即进程是程序能运行的实体。

一个程序至少要有一个进程,一个进程里至少有一个线程,只有一个线程的进程也就是单线程进程。

进程是操作系统能够将资源分配的最小独立单元,也就是说进程是能够享受独有单元的个体户。进程由程序、数据、进程控制块组成。

线程可以当作是进程里的一个分支,进程里的一个实体,很多线程就相当于工厂里的不同生产线。

线程就像火车的一节车厢,进程则是火车。车厢(线程)离开火车(进程)是无法跑动的,而火车(进程)至少有一节车厢(主线程)。多线程可以看做多个车厢,它的出现是为了提高效率。

线程是进程的实体,是CPU调度和分派的最小基本单元,它是比进程还小的能独立运行的基本单元。但是线程不单独拥有系统资源,只能在进程这个容器里共享资源(如进程代码段、全局变量等),但是会拥有一些在运行中必不可少的资源,比如程序计数器,一组寄存器,堆栈,局部变量等。

在创建和销毁进程时,系统要为其分配或回收资源,因此操作系统付出的开销远大于创建和销毁线程的开销。类似的,进程切换时,需要保留当前cpu环境和新进程环境的设置,而线程切换·只需要保存和设置少量寄存器内容。开销很小。另外因为同一进程的多个线程共享进程的地址空间,因此线程同步与通信比较容易实现,甚至无需操作系统的干预。进程间通信要借助操作系统,线程间可以直接读写进程数据段(如全局变量)来进行通信。

因为进程是个体户,所以进程有独立的地址空间,一个进程崩溃后,对其他进程不会产生影响,但是一个线程死掉了,等于容纳该线程的整个进程死掉了。所以说多进程比多线程程序健壮,但是进程在切换时,消耗资源大,效率要差一些。

对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。

各有利弊,要按实际情况进行选择。

线程共享资源
  • 进程代码段
  • 进程数据段
  • 进程打开的文件描述符
  • 信号处理器
  • 进程的当前目录
  • 进程用户ID和进程组ID
线程独享资源
  • 线程的栈 栈是独享的
  • 寄存器组的值 这个可能会误解,因为电脑的寄存器是物理的,每个线程去取值难道不一样吗?其实线程里存放的是副本,包括程序计数器PC。
  • 线程ID
  • 线程的优先级
  • 线程的信号屏蔽码
  • 错误返回码
详细来说

进程是对计算机的一种抽象:

  1. 进程表示一个逻辑控制流,就是一种计算过程,它造成一个假象,好像这个进程一直在独占CPU资源
  2. 进程拥有一个独立的虚拟内存地址空间,它造成一个假象,好像这个进程一致在独占存储器资源

img

上图是进程的虚拟内存地址空间的分配模型图,可以看到进程的虚拟内存地址空间分为用户空间和内核空间。用户空间从低端地址往高端地址发展,内核空间从高端地址往低端地址发展。用户空间存放着这个进程的代码段和数据段,以及运行时的堆和用户栈。堆是从低端地址往高端地址发展,栈是从高端地址往低端地址发展。

内核空间存放着内核的代码和数据,以及内核为这个进程创建的相关数据结构,比如页表数据结构,task数据结构,area区域数据结构等等。

fork系统调用

操作系统利用fork系统调用来创建一个子进程。fork所创建的子进程会复制父进程的虚拟地址空间。

要理解“复制”和“共享”的区别,复制的意思是会真正在物理内存复制一份内容,会真正消耗新的物理内存。共享的意思是使用指针指向同一个地址,不会真正的消耗物理内存。理解这两个概念的区别很重要,这是进程和线程的根本区别之一。

那么有人问了如果我父进程占了1G的物理内存,那么fork会再使用1G的物理内存来复制吗,相当于一下用了2G的物理内存?

答案是早期的操作系统的确是这么干的,但是这样性能也太差了,所以现代操作系统使用了 写时复制(Copy on write)的方式来优化fork的性能,fork刚创建的子进程采用了共享的方式,只用指针指向了父进程的物理资源。当子进程真正要对某些物理资源写操作时,才会真正的复制一块物理资源来供子进程使用。这样就极大的优化了fork的性能,并且从逻辑来说子进程的确是拥有了独立的虚拟内存空间。

fork不只是复制了页表结构,还复制了父进程的文件描述符表,信号控制表,进程信息,寄存器资源等等。它是一个较为深入的复制。

从逻辑控制流的角度来说,fork创建的子进程开始执行的位置是fork函数返回的位置。这点和线程是不一样的,我们知道Java中的Thread需要写run方法,线程开始后会从run方法开始执行。

既然我们知道了内核为进程维护了这么多资源,那么当内存进行进程调度时进行的进程上下文切换就容易理解了,一个进程运行要依赖这么些资源,那么进程上下文切换就要把这些资源都保存起来写回到内存中,等下次这个进程被调度时再把这些资源再加载到寄存器和高速缓存硬件。

进程上下文切换保存的内容有:

  1. 页表 – 对应虚拟内存资源
  2. 文件描述符表/打开文件表 – 对应打开的文件资源
  3. 寄存器 – 对应运行时数据
  4. 信号控制信息/进程运行信息

进程间通信

虚拟内存机制为进程管理存储资源带来了种种好处,但是它也给进程带来了一些小麻烦,我们知道每个进程拥有独立的虚拟内存地址空间,看到一样的虚拟内地址空间视图,所以对不同的进程来说,一个相同的虚拟地址意味着不同的物理地址。我们知道CPU执行指令时采用了虚拟地址,对应一个特定的变量来说,它对应着一个特定的虚拟地址。这样带来的问题就是两个进程不能通过简单的共享变量的方式来进行进程间通信,也就是说进程不能通过直接共享内存的方式来进行进程间通信,只能采用信号,管道等方式来进行进程间通信。这样的效率肯定比直接共享内存的方式差。

什么是线程?

上面说了一堆内核为进程分配了哪些资源,我们知道进程管理了一堆资源,并且每个进程还拥有独立的虚拟内存地址空间,会真正地拥有独立与父进程之外的物理内存。并且由于进程拥有独立的内存地址空间,导致了进程之间无法利用直接的内存映射进行进程间通信。

并发的本质是在时间上重叠的多个逻辑流,也就是说同时运行的多个逻辑流。并发编程要解决的一个很重要的问题就是对资源的并发访问的问题,也就是共享资源的问题。而两个进程恰恰很难在逻辑上表示共享资源。

线程解决的最大问题就是它可以很简单地表示共享资源的问题,这里说的资源指的是存储器资源,资源最后都会加载到物理内存,一个进程的所有线程都是共享这个进程的同一个虚拟地址空间的,也就是说从线程的角度来说,它们看到的物理资源都是一样的,这样就可以通过共享变量的方式来表示共享资源,也就是直接共享内存的方式解决了线程通信的问题。而线程也表示一个独立的逻辑流,这样就完美解决了进程的一个大难题。

从存储资源的角度理解了线程之后,就不难理解计算资源的分配了。从计算资源的角度来说,对内核而言,进程和线程没有什么区别,所以内核用内核调度实体(KSE)来表示一个调度的单元。

clone系统调用

在Linux系统中,线程是使用clone系统调用,clone是一个轻量级的fork,它提供了一系列的参数来表示线程可以共享父类的哪些资源,比如页表,打开文件表等等。我们上面说过了共享和复制的区别,共享只是简单地用指针指向同一个物理地址,不会在父进程之外开辟新的物理内存。

clone系统调用可以指定创建的线程开始执行代码位置,也就是Java中的Thread类的run方法。

Linux内核只提供了clone这个系统调用来创建类似线程的轻量级进程的概念。C语言利用了Pthreads库来真正创建了线程这个数据结构。Linux采用了1:1的模型,即c语言的Pthreads库创建的线程实体1:1对应着内核创建的一个KSE。Pthreads运行在用户空间,KSE运行在内核空间。

既然线程共享了进程的资源,那么线程的上下文切换就好理解了。对操作系统来说,它看到要被调度进来的线程和刚运行的线程是同一个进程的,那么线程的上下文切换只需要保存线程的一些运行时的数据,比如线程的id、寄存器中的值、栈数据等。

而不需要像进程上下文切换那样要保存页表,文件描述符表,信号控制数据和进程信息等数据。页表是一个很重的资源,我们之前说过,如果采用一级页表的结构,那么32位机器的页表要达到4MB的物理空间。 所以线程上下文切换是很轻量级的。

进程采用父子结构,init进程是最顶端的父进程,其他进程都是从init进程派生出来的。这样就很容易理解进程是如何共享内核的代码和数据的了。

而线程采用对等结构,即线程没有父子的概念,所有线程都属于同一个线程组,线程组的组号等于第一个线程的线程号。

我们来看看Java的线程到底是如何实现的。Java语言层面提供了java.lang.Thread这个类来表示Java语言层面的线程,并提供了run方法表示线程运行的逻辑控制流。

我们知道JVM是C++/C写的,JVM本身利用了Pthreads库来创建操作系统的线程。JVM还要支持Java语言创建的线程的概念。

再次总结一下
  1. 进程采用fork创建,线程采用clone创建
  2. 进程fork创建的子进程的逻辑流位置在fork返回的位置,线程clone创建的KSE的逻辑流位置在clone调用传入的方法位置,比如Java的Thread的run方法位置
  3. 进程拥有独立的虚拟内存地址空间和内核数据结构(页表,打开文件表等),当子进程修改了虚拟页之后,会通过写时拷贝创建真正的物理页。线程共享进程的虚拟地址空间和内核数据结构,共享同样的物理页
  4. 多个进程通信只能采用进程间通信的方式,比如信号,管道,而不能直接采用简单的共享内存方式,原因是每个进程维护独立的虚拟内存空间,所以每个进程的变量采用的虚拟地址是不同的。多个线程通信就很简单,直接采用共享内存的方式,因为不同线程共享一个虚拟内存地址空间,变量寻址采用同一个虚拟内存
  5. 进程上下文切换需要切换页表等重量级资源,线程上下文切换只需要切换寄存器等轻量级数据
  6. 进程的用户栈独享栈空间,线程的用户栈共享虚拟内存中的栈空间,没有进程高效
  7. 一个应用程序可以有多个进程,执行多个程序代码,多个线程只能执行一个程序代码,共享进程的代码段
  8. 进程采用父子结构,线程采用对等结构