小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。
如果你觉得内容对你有帮助的话,不如给个点赞+收藏,鼓励一下更新😂。
进程和线程的概念
区别
-
定义:进程是应用的执行副本
-
作用:进程是操作系统分配和管理资源的最小单位
-
每一个进程都有一个自己的地址空间,至少有 5 种基本状态,它们是:初始态,执行态,等待状态,就绪状态,终止状态。
-
线程是CPU调度和分派的基本单位,它可与同属一个进程的其他的线程共享进程所拥有的全部资源。所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程
- 严格来说我们说的轻量级进程指的:“内核态线程”
-
一个进程在其执行的过程中可以产生多个线程。
分时和调度
因为通常机器中 CPU 核心数量少(从几个到几十个)、进程&线程数量很多(从几十到几百甚至更多),你可以类比为发动机少,而机器多,因此进程们在操作系统中只能排着队一个个执行。每个进程在执行时都会获得操作系统分配的一个时间片段,如果超出这个时间,就会轮到下一个进程(线程)执行。再强调一下,现代操作系统都是直接调度线程,不会调度进程。
分配时间片段
进程 P1 先执行一个时间片段,然后进程 P2 开始执行一个时间片段, 然后进程 P3,然后进程 P4……这个就是分时技术。
线程调度都有哪些方法?
任务调度分成抢占和非抢占的。
非抢占的先到先服务的模型是最朴素的,公平性和吞吐量可以保证。但是因为希望减少用户的平均等待时间,操作系统往往需要实现抢占。操作系统实现抢占,仍然希望有优先级,希望有最短任务优先。
但是这里有个困难,操作系统无法预判每个任务的预估执行时间,就需要使用分级队列。最高优先级的任务可以考虑非抢占的优先级队列。 其他任务放到分级队列模型中执行,从最高优先级时间片段最小向最低优先级时间片段最大逐渐沉淀。这样就同时保证了小任务先行和高任务最先执行。
非抢占
先到先服务(First Come First Service,FCFS)
也就是先到的作业先被计算,后到的作业,排队进行。这里需要用到一个叫作队列的数据结构,具有先入先出(First In First Out,FIFO)性质。先进入队列的作业,先处理,因此从公平性来说,这个算法非常朴素。另外,一个作业完全完成才会进入下一个作业,作业之间不会发生切换,从吞吐量上说,是最优的——因为没有额外开销。
短作业优先
在大多数情况下,应该优先处理用时少的,从而降低平均等待时长。
抢占
抢占就是把执行能力分时,分成时间片段。 让每个任务都执行一个时间片段。如果在时间片段内,任务完成,那么就调度下一个任务。如果任务没有执行完成,则中断任务,让任务重新排队,调度下一个任务。****
优先级队列(PriorityQueue)
优先级队列可以给队列中每个元素一个优先级,优先级越高的任务就会被先执行。
优先级队列的一种实现方法就是用到了堆(Heap)这种数据结构,更最简单的实现方法,就是每次扫描一遍整个队列找到优先级最高的任务。也就是说,堆(Heap)可以帮助你在 O(1) 的时间复杂度内查找到最大优先级的元素。
进程的IO操作
有时候一个进程(线程)会等待磁盘读取数据,或者等待打印机响应,此时进程自己会进入“阻塞”(Block)状态。
因为这时计算机的响应不能马上给出来,而是需要等待磁盘、打印机处理完成后,通过中断通知 CPU,然后 CPU 再执行一小段中断控制程序,将控制权转给操作系统,操作系统再将原来阻塞的进程(线程)置为“就绪”(Ready)状态重新排队。
进程的开销比线程大在了哪里?
创建的开销
Linux 中创建一个进程自然会创建一个线程,也就是主线程。创建进程需要为进程分配计算资源(CPU)、内存资源和文件资源。而创建线程则简单得多,只需要确定PC指针和寄存器的值,并且给线程分配一个栈用于执行程序,同一个进程的多个线程间可以复用堆栈。因此,创建进程比创建线程慢,而且进程的内存开销更大。
上下文切换
进程(线程)在操作系统中是不断切换的,现代操作系统中只有线程的切换。 每次切换需要先保存当前寄存器的值的内存,注意 PC 指针也是一种寄存器。当恢复执行的时候,就需要从内存中读出所有的寄存器,恢复之前的状态,然后执行。
上面讲到的内容,我们可以概括为以下 5 个步骤:
- 当操作系统发现一个进程(线程)需要被切换的时候,直接控制 PC 指针跳转是非常危险的事情,所以操作系统需要发送一个“中断”信号给 CPU,停下正在执行的进程(线程)。
- 当 CPU 收到中断信号后,正在执行的进程(线程)会立即停止。注意,因为进程(线程)马上被停止,它还来不及保存自己的状态,所以后续操作系统必须完成这件事情。
- 操作系统接管中断后,趁寄存器数据还没有被破坏,必须马上执行一小段非常底层的程序(通常是汇编编写),帮助寄存器保存之前进程(线程)的状态。
- 操作系统保存好进程状态后,执行调度程序,决定下一个要被执行的进程(线程)。
- 最后,操作系统执行下一个进程(线程)。
进程和线程上下文切换的区别
- 线程上下文切换和进程上下文切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。
- 进程上下文切换涉及切换内存地址空间。这包括内存地址,映射,页表和内核资源,这是一个相对昂贵的操作。
- 线程切换是指在同一进程中从一个线程切换到另一个线程(跨线程切换仅仅是进程切换),切换处理器状态(例如栈、程序计数器和寄存器内容)通常非常有效。
通信方式(IPC)
进程间的通信方式
- 管道( pipe ): 管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
- 有名管道( namedpipe ): 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
- 本地内存共享( shared memory ) : 内存共享就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。虽然速度很快,但是程序不是很好写,因为这是一种侵入式的开发,因此,只要不是高性能场景,进程间通信通常不考虑共享内存的方式。
- 本地消息/队列( messagequeue ) : 共享内存不太好用,因此本地消息有两种常见的方法。一种是用消息队列——现代操作系统都会提供类似的能力。Unix 系可以使用 POSIX 标准的 mqueue。另一种方式,就是直接用网络请求,比如 TCP/IP 协议,也包括建立在这之上的更多的通信协议。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
- 信号量( semophore ) : 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
- 信号( signal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
- 套接字( socket ) : 套解字也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信。
如果考虑单机模型,有管道、内存共享、消息队列。这三个模型中,内存共享程序最难写,但是性能最高。管道程序最好写,有标准接口。消息队列程序也比较好写,比如用发布/订阅模式实现具体的程序。
如果考虑分布式模型,就有远程调用、消息队列和网络请求。直接发送网络请求程序不好写,不如直接用实现好的 RPC 调用框架。RPC 框架会增加系统的耦合,可以考虑消息队列,以及发布订阅事件的模式,这样可以减少系统间的耦合。
线程间的通信方式
- 锁机制:包括互斥锁、条件变量、读写锁
- 互斥锁提供了以排他方式防止数据结构被并发修改的方法。
- 读写锁允许多个线程同时读共享数据,而对写操作是互斥的。
- 条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
- 信号量机制(Semaphore):包括无名线程信号量和命名线程信号量
- 信号机制(Signal):类似进程间的信号处理
线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
用户态和内核态
什么是用户态和内核态
Kernel 运行在超级权限模式(Supervisor Mode)下,所以拥有很高的权限。按照权限管理的原则,多数应用程序应该运行在最小权限下。因此,很多操作系统,将内存分成了两个区域:
- 内核空间(Kernal Space),这个空间只有内核程序可以访问;
- 用户空间(User Space),这部分内存专门给应用程序使用。
用户空间中的代码被限制了只能使用一个局部的内存空间,我们说这些程序在用户态(User Mode) 执行。内核空间中的代码可以访问所有内存,我们称这些程序在内核态(Kernal Mode) 执行。
Linux 的 PThread API 就是用户级线程,KThread API 则是内核级线程。
系统调用过程
如果用户态程序需要执行系统调用,就需要切换到内核态执行。
如上图所示:内核程序执行在内核态(Kernal Mode),用户程序执行在用户态(User Mode)。当发生系统调用时,用户态的程序发起系统调用。因为系统调用中牵扯特权指令,用户态程序权限不足,因此会中断执行,也就是 Trap(Trap 是一种中断)。发生中断后,当前 CPU 执行的程序会中断,跳转到中断处理程序。内核程序开始执行,也就是开始处理系统调用。内核处理完成后,主动触发 Trap,这样会再次发生中断,切换回用户态工作。
用户态线程和内核态线程
进程可以分成用户态进程和内核态进程两类。用户态进程通常是应用程序的副本,内核态进程就是内核本身的进程。如果用户态进程需要申请资源,比如内存,可以通过系统调用向内核申请。
用户态线程的优点和缺点
用户态线程也称作用户级线程(User Level Thread)。操作系统内核并不知道它的存在,它完全是在用户空间中创建。
用户级线程有很多优势:
- 管理开销小:创建、销毁不需要系统调用。
- 切换成本低:用户空间程序可以自己维护,不需要走操作系统调度。
缺点:
- 与内核协作成本高:比如这种线程完全是用户空间程序在管理,当它进行 I/O 的时候,无法利用到内核的优势,需要频繁进行用户态到内核态的切换。
- 线程间协作成本高:设想两个线程需要通信,通信需要 I/O,I/O 需要系统调用,因此用户态线程需要支付额外的系统调用成本。
- 无法利用多核优势:比如操作系统调度的仍然是这个线程所属的进程,所以无论每次一个进程有多少用户态的线程,都只能并发执行一个线程,因此一个进程的多个线程无法利用多核的优势。
- 操作系统无法针对线程调度进行优化:当一个进程的一个用户态线程阻塞(Block)了,操作系统无法及时发现和处理阻塞问题,它不会更换执行其他线程,从而造成资源浪费。
内核态线程的优点和缺点
可以通过系统调用创造一个内核级线程。
内核级线程有很多优势:
- 可以利用多核 CPU 优势:内核拥有较高权限,因此可以在多个 CPU 核心上执行内核线程。
- 操作系统级优化:内核中的线程操作 I/O 不需要进行系统调用;一个内核线程阻塞了,可以立即让另一个执行。
当然内核线程也有一些缺点。
- 创建成本高:创建的时候需要系统调用,也就是切换到内核态。
- 扩展性差:由一个内核程序管理,不可能数量太多。
- 切换成本较高:切换的时候,也同样存在需要内核操作,需要切换内核态。
用户态线程和内核态线程之间的映射关系
用户态线程怎么用内核态线程执行程序?
程序是存储在内存中的指令,用户态线程是可以准备好程序让内核态线程执行的。
多对一(Many to One)
用户态进程中的多线程复用一个内核态线程。
一对一(One to One)
该模型为每个用户态的线程分配一个单独的内核态线程
多对多(Many To Many)
这种模式下会为 n 个用户态线程分配 m 个内核态线程。m 通常可以小于 n。
两层设计(Two Level)
这种模型混合了多对多和一对一的特点。多数用户态线程和内核线程是 n 对 m 的关系,少量用户线程可以指定成 1 对 1 的关系。
用户态线程和内核态线程的区别?
用户态线程工作在用户空间,内核态线程工作在内核空间。用户态线程调度完全由进程负责,通常就是由进程的主线程负责。相当于进程主线程的延展,使用的是操作系统分配给进程主线程的时间片段。内核线程由内核维护,由操作系统调度。
用户态线程无法跨核心,一个进程的多个用户态线程不能并发,阻塞一个用户态线程会导致进程的主线程阻塞,直接交出执行权限。这些都是用户态线程的劣势。内核线程可以独立执行,操作系统会分配时间片段。因此内核态线程更完整,也称作轻量级进程。内核态线程创建成本高,切换成本高,创建太多还会给调度算法增加压力,因此不会太多。
JVM的线程是用户态线程还是内核态线程?
在早先的版本中, Java线程是使用轻量级进程(LWP, Light Weight Process)实现的。在这种情况下,LWP起到一种桥接的作用,每个Java线程都运行在与操作系统线程不完全对等的LWP上。
然而,随着线程技术的发展,特别是NPTL(Native POSIX Thread Library)和其他原生线程技术的推广,这种通过LWP做桥接的实现方式已经不再常见。
在现代的Java实现中,Java线程直接映射到操作系统的原生线程,例如在Windows和大多数现代Unix/Linux操作系统中,每个Java线程都将是操作系统的一个完全独立的线程,无需通过 LWP。
Java 的线程模型是基于宿主操作系统的线程模型的,通常直接映射到操作系统的内核线程,这意味着Java线程的创建、切换等操作都需要涉及到操作系统内核,属于内核态。
参考
本文总结自拉勾教育的重学操作系统-林䭽。
觉得不错的话,别忘了点赞哦!!!🐱