进程管理

进程

概念

进程,可执行程序运行中形成一个独立的内存体,这个内存体有自己独立的地址空间(Linux会给每个进程分配一个虚拟内存空间32位操作系统为4G, 64位为很多T),有自己的堆,上级挂靠单位是操作系统。操作系统会以进程为单位,分配系统资源(CPU时间片、内存等资源),进程是资源分配的最小单位。 进程

状态

在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。 进程三个基本状态 上图中各个状态的意义:

  • 运行状态(Running):该时刻进程占用CPU;

  • 就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行;

  • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行; 当然,进程还有另外两个基本状态:

  • 创建状态(new):进程正在被创建时的状态;

  • 结束状态(Exit):进程正在从系统中消失时的状态; 一个完整的进程状态的变迁如下图: 8-进程五个状态

  • NULL -> 创建状态:一个新进程被创建时的第一个状态;

  • 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的;

  • 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;

  • 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理;

  • 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行;

  • 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求I/O事件;

  • 阻塞状态 -> 就绪状态:进程要等待的事件完成时,它从阻塞状态变到就绪状态; 阻塞状态的进程无法获得CPU时间片,就绪状态的进程立即获得CPU时间片 在进程发生状态变更时,通常需要保留现场。这称为“上下文切换” 如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间,毕竟物理内存空间是有限的,被阻塞状态的进程占用着物理内存就一种浪费物理内存的行为。 所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。如果内存比较充足进程的物理内存空间通常会保留在内存中,而不换出到硬盘 9-换入换出 那么,就需要一个新的状态,来描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态。 进程挂起可以分为两种情况:内存挂起(Swapped Suspend)和内存中的挂起(In-Memory Suspend)。

  • 内存中的挂起(In-Memory Suspend): 在这种情况下,进程仍然保留在内存中,只是它的执行被暂停了。系统不会将进程的内存内容转移到磁盘,而只是暂停它的运行。这样的挂起通常是临时性的,比如当用户手动暂停了一个程序(如通过 Ctrl+Z),或者系统需要暂时停止进程以进行其他操作。

  • 内存挂起(Swapped Suspend): 在这种情况下,进程不仅被暂停执行,而且它的内存内容被换出(swapped out)到磁盘中。这通常发生在系统内存资源紧张时,操作系统会将某些不活跃的进程数据写入磁盘,以腾出内存给其他进程使用。当进程需要恢复时,操作系统会将其从磁盘中读回内存,再恢复执行。 这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。 另外,挂起状态可以分为两种:

  • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;

  • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行; 这两种挂起状态加上前面的五种状态,就变成了七种状态变迁: 10-进程七中状态 导致进程挂起的原因不只是因为进程所使用的内存空间不在物理内存,还包括如下情况:

  • 通过sleep让进程间歇性挂起,其工作原理是设置一个定时器,到期后唤醒进程。

  • 用户希望挂起一个程序的执行,比如在 Linux 中用 Ctrl+Z 挂起进程;(内存中的挂起)

sleep的工作原理:

  1. 触发阻塞状态

  2. CPU 执行其他进程

  3. 定时器触发唤醒(操作系统内核有一个定时器(通常是硬件定时器),负责追踪 sleep 进程的时间)

  4. 进入就绪状态并等待调度

  5. 重新获取CPU并执行

控制结构

在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。 PCB是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个PCB,如果进程消失了,那么PCB也会随之消失。 PCB通常存储在操作系统的内核空间中,用户进程不能直接访问这些信息。只有操作系统可以管理PCB,保证了系统的安全性和稳定性。 PCB 具体包含的信息:

  1. 进程描述信息:

    • 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;

    • 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;

  2. 进程控制和管理信息:

    • 进程当前状态,如new、ready、running、waiting或blocked等;

    • 进程优先级:进程抢占CPU时的优先级;

  3. 资源分配清单

    • 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的I/O设备信息。

  4. CPU相关信息

    • CPU中各个寄存器的值,当进程被切换时,CPU的状态信息都会被保存在相应的PCB中,以便进程重新执行时,能从断点处继续执行。 PCB通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:

  • 将所有处于就绪状态的进程链在一起,称为就绪队列;

  • 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列;

  • 在操作系统中,正在运行的进程不会存放在队列中,而是被操作系统直接分配给CPU来执行。当一个进程处于运行状态时,它不会在队列中等待,而是占用CPU资源执行指令。 12-PCB状态链表组织 除了链接的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的PCB,不同状态对应不同的索引表。一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活的插入和删除。

控制

  1. 创建进程 操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源。 创建进程的过程如下:

  • 申请一个空白的PCB,并向PCB中填写一些控制和管理进程的信息,比如进程的唯一标识等;

  • 为该进程分配运行时所必需的资源,比如内存资源;

  • 将PCB插入到就绪队列,等待被调度运行;

  1. 终止进程 进程可以有3种终止方式:正常结束、异常结束以及外界干预(信号 kill 掉)。 当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被1号进程收养,并由1号进程对它们完成状态收集工作。 终止进程的过程如下:

  • 查找需要终止的进程的PCB;

  • 如果处于执行状态,则立即终止该进程的执行,然后将CPU资源分配给其他进程;

  • 如果其还有子进程,则应将该进程的子进程交给 1 号进程接管;

  • 将该进程所拥有的全部资源都归还给操作系统;

  • 将其从PCB所在队列中删除;

  1. 阻塞进程 当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。 阻塞进程的过程如下:

  • 找到将要被阻塞进程标识号对应的PCB;

  • 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;

  • 将该PCB插入到阻塞队列中去;

  1. 唤醒进程 进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。 如果某进程正在等待I/O事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。 唤醒进程的过程如下:

  • 在该事件的阻塞队列中找到相应进程的PCB;

  • 将其从阻塞队列中移出,并置其状态为就绪状态;

  • 把该PCB插入到就绪队列中,等待调度程序调度; 进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。

ps -eo pid,stat,comm | grep "D"
cat /proc/sched_debug | grep "runnable tasks"

上下文切换

各个进程之间是共享CPU资源的,在不同的时候进程之间需要切换,让不同的进程可以在CPU执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换。 操作系统需要事先帮CPU设置好CPU寄存器和程序计数器。 CPU寄存器和程序计数是CPU在运行任何任务前,所必须依赖的环境,这些环境就叫做CPU上下文。 CPU上下文切换就是先把前一个任务的CPU上下文(CPU寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。 系统内核会存储保持下来的上下文信息,当此任务再次被分配给CPU运行时,CPU会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。 上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把CPU上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。 进程是由内核管理和调度的,所以进程的切换只能发生在内核态。 进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。 通常,会把交换的信息保存在进程的PCB,当要运行另外一个进程的时候,我们需要从这个进程的PCB取出上下文,然后恢复到CPU中,这使得这个进程可以继续执行 13-进程上下文切换 发生进程上下文切换的场景:

  • 为了保证所有进程可以得到公平调度,CPU时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行;

  • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;

  • 当进程通过睡眠函数sleep这样的方法将自己主动挂起时,自然也会重新调度;

  • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;

  • 发生硬件中断时,CPU上的进程会被中断挂起,转而执行内核中的中断服务程序;

线程

基本概念

线程是进程当中的一条执行流程。 同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。 16-多线程内存结构 线程的优点:

  • 一个进程中可以同时存在多个线程;

  • 各个线程之间可以并发执行;

  • 各个线程之间可以共享地址空间和文件等资源; 线程的缺点:

  • 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(这里是针对 C/C++ 语言,Java语言中的线程奔溃不会造成进程崩溃)

比较

线程与进程的比较如下:

  • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;

  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;

  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;

  • 线程能减少并发执行的时间和空间开销; 对于,线程相比进程能减少开销,体现在:

  • 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;

  • 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;

  • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;

  • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了; 不管是时间效率,还是空间效率线程比进程都要高。

上下文切换

线程是调度的基本单位,而进程则是资源拥有的基本单位。 所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。

  • 当进程只有一个线程时,可以认为进程就等于线程;

  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的; 另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

  • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;

  • 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据; 线程的上下文切换相比进程,开销要小很多。

实现

主要有三种线程的实现方式:

  • 用户线程(User Thread):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;

  • 内核线程(Kernel Thread):在内核中实现的线程,是由内核管理的线程;

  • 轻量级进程(LightWeight Process):在内核中来支持用户线程;

在 Linux 中,常说“内核线程”而不是“内核进程”主要是因为以下原因:

  • 轻量级: 内核线程比用户态进程轻量级,切换开销更小。

  • 共享内存空间: 内核线程共享内核的内存空间,而不是像用户进程那样有独立的地址空间。

  • 专注内核任务: 内核线程专门用于执行内核级任务,不需要用户态的上下文。

  • 调度效率: 内核线程能够更高效地与内核资源交互和调度。

用户线程和内核线程的对应关系: 首先,第一种关系是多对一的关系,也就是多个用户线程对应同一个内核线程: 17-内核线程与用户线程-一对多关系 第二种是一对一的关系,也就是一个用户线程对应一个内核线程: 18-内核线程与用户线程-一对一关系 第三种是多对多的关系,也就是多个用户线程对应到多个内核线程: 19-内核线程与用户线程-多对多关系 用户线程是基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB)也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。 用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。 用户级线程的模型,也就类似前面提到的多对一的关系,即多个用户线程对应同一个内核线程, 20-线程PCB-一对多关系 用户线程的优点:

  • 每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB由用户级线程库函数来维护,可用于不支持线程技术的操作系统;

  • 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快; 用户线程的缺点:

  • 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了。

  • 当一个线程开始运行后,除非它主动地交出CPU的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。

  • 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢; 内核线程是由操作系统管理的,线程对应的TCB自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。 一个用户线程对应一个内核线程 21-线程PCB-一对一关系 内核线程的优点:

  • 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;

  • 分配给线程,多线程的进程获得更多的CPU运行时间; 内核线程的缺点:

  • 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB;

  • 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大;

轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个LWP,每个LWP是跟内核线程一对一映射的,也就是LWP都是由一个内核线程支持,而且LWP是由内核管理并像普通进程一样被调度。

在大多数系统中,LWP与普通进程的区别也在于它只有一个**最小的执行上下文和调度程序所需的统计信息。**一般来说,一个进程代表程序的一个实例,而LWP代表程序的执行线程,因为一个执行线程不像进程那样需要那么多状态信息,所以LWP也不带有这样的信息。 在 LWP 之上也是可以使用用户线程的,那么 LWP 与用户线程的对应关系就有三种:

  • 1 : 1,即一个 LWP 对应 一个用户线程;

  • N : 1,即一个 LWP 对应多个用户线程;

  • M : N,即多个 LWP 对应多个用户线程; LWP 模型: 22-LWP

1 : 1 模式 一个线程对应到一个 LWP 再对应到一个内核线程,如上图的进程 4,属于此模型。

  • 优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP;

  • 缺点:每一个用户线程,就产生一个内核线程,创建线程的开销较大。 N : 1 模式 多个用户线程对应一个 LWP 再对应一个内核线程,如上图的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见。

  • 优点:用户线程要开几个都没问题,且上下文切换发生用户空间,切换的效率较高;

  • 缺点:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用CPU 的。 M : N 模式 根据前面的两个模型混搭一起,就形成 M:N 模型,该模型提供了两级控制,首先多个用户线程对应到多个LWP,LWP再一一对应到内核线程,如上图的进程3。

  • 优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核CPU的资源。 组合模式 如上图的进程 5,此进程结合 1:1 模型和 M:N 模型。开发人员可以针对不同的应用特点调节内核线程的数目来达到物理并行性和逻辑并行性的最佳方案。

调度

调度时机

在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。 比如,以下状态的变化都会触发操作系统的调度: 从就绪态 -> 运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行; 从运行态 -> 阻塞态:当进程发生 I/O 事件而阻塞时,操作系统必须选择另外一个进程运行; 从运行态 -> 结束态:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行; 因为,这些状态变化的时候,操作系统需要考虑是否要让新的进程给CPU运行,或者是否让当前进程从CPU上退出来而换另一个进程运行。 另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断 ,把调度算法分为两类:

  • 非抢占式调度算法挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。

  • 抢占式调度算法挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把CPU控制返回给调度程序进行调度,也就是常说的时间片机制。

调度原则

原则一:如果运行的程序,发生了 I/O 事件的请求,那 CPU 使用率必然会很低,因为此时进程在阻塞等待硬盘的数据返回。这样的过程,势必会造成 CPU 突然的空闲。所以,为了提高 CPU 利用率,在这种发送 I/O 事件致使 CPU 空闲的情况下,调度程序需要从就绪队列中选择一个进程来运行。 原则二:有的程序执行某个任务花费的时间会比较长,如果这个程序一直占用着 CPU,会造成系统吞吐量(CPU 在单位时间内完成的进程数量)的降低。所以,要提高系统的吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量。 原则三:从进程开始到结束的过程中,实际上是包含两个时间,分别是进程运行时间和进程等待时间,这两个时间总和就称为周转时间。进程的周转时间越小越好,如果进程的等待时间很长而运行时间很短,那周转时间就很长,这不是我们所期望的,调度程序应该避免这种情况发生。 原则四:处于就绪队列的进程,也不能等太久,当然希望这个等待的时间越短越好,这样可以使得进程更快的在 CPU 中执行。所以,就绪队列中进程的等待时间也是调度程序所需要考虑的原则。 原则五:对于鼠标、键盘这种交互式比较强的应用,我们当然希望它的响应时间越快越好,否则就会影响用户体验了。所以,对于交互式比较强的应用,响应时间也是调度程序需要考虑的原则。 针对上面的五种调度原则,总结成如下:

  • CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率;

  • 系统吞吐量:吞吐量表示的是单位时间内CPU完成进程的数量,长作业的进程会占用较长的CPU资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;

  • 周转时间:周转时间是进程运行+阻塞时间+等待时间的总和,一个进程的周转时间越小越好;

  • 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;

  • 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。

调度算法

在单核 CPU 系统中常见的调度算法

  • 先来先服务调度算法 最简单的一个调度算法,就是非抢占式的先来先服务(First Come First Serve, FCFS)算法了。 FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。

  • 最短作业优先调度算法 最短作业优先(Shortest Job First, SJF)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。 这显然对长作业不利,很容易造成一种极端现象。比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。

  • 高响应比优先调度算法 前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。 高响应比优先(Highest Response Ratio Next, HRRN)调度算法主要是权衡了短作业和长作业。 每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式: 优先权 = (等待时间+要求服务时间)/ 要求服务时间

  • 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行;

  • 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会; 一个进程要求服务的时间是不可预估的,所以高响应比优先调度算法是「理想型」的调度算法,现实中是实现不了的。

  • 时间片轮转调度算法 最古老、最简单、最公平且使用最广的算法就是时间片轮转(Round Robin, RR)调度算法。 27-时间片轮询 每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配给另外一个进程; 如果该进程在时间片结束前阻塞或结束,则CPU立即进行切换; 另外,时间片的长度就是一个很关键的点:

  1. 如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;

  2. 如果设得太长又可能引起对短作业进程的响应时间变长。 一般来说,时间片设为 20ms~50ms 通常是一个比较合理的折中值。

  • 最高优先级调度算法 前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。 但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(Highest Priority First,HPF)调度算法。 进程的优先级可以分为,静态优先级和动态优先级:

  1. 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;

  2. 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。

  • 多级反馈队列调度算法 多级反馈队列(Multilevel Feedback Queue)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列; 28-多级队列

  1. 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短;

  2. 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;

  3. 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行; 对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也变更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。 类别银行: 34-银行-多级反馈

  • 银行设置了多个排队(就绪)队列,每个队列都有不同的优先级,各个队列优先级从高到低,同时每个队列执行时间片的长度也不同,优先级越高的时间片越短。

  • 新客户(进程)来了,先进入第一级队列的末尾,按先来先服务原则排队等待被叫号(运行)。如果时间片用完客户的业务还没办理完成,则让客户进入到下一级队列的末尾,以此类推,直至客户业务办理完成。

  • 当第一级队列没人排队时,就会叫号二级队列的客户。如果客户办理业务过程中,有新的客户加入到较高优先级的队列,那么此时办理中的客户需要停止办理,回到原队列的末尾等待再次叫号,因为要把窗口让给刚进入较高优先级队列的客户。 可以发现,对于要办理短业务的客户来说,可以很快的轮到并解决。对于要办理长业务的客户,一下子解决不了,就可以放到下一个队列,虽然等待的时间稍微变长了,但是轮到自己的办理时间也变长了,也可以接受,不会造成极端的现象,可以说是综合上面几种算法的优点。

进程之间的通信

每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。 4-进程空间

管道

「|」表示的管道称为匿名管道,用完了就销毁。 管道还有另外一个类型是命名管道,也被叫做FIFO,因为数据是先进先出的传输方式。 在使用命名管道前,先需要通过mkfifo命令来创建,并且指定管道名字:

mkfifo myPipe

myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,所以管道也是以文件的方式存在,我们可以用 ls 看一下,这个文件的类型是p,也就是 pipe(管道) 的意思: 接下来,我们往 myPipe 这个管道写入数据:

echo "hello" > myPipe  // 将数据写进管道
                       // 停住了 ...

操作了后,你会发现命令执行后就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。 于是,我们执行另外一个命令来读取这个管道里的数据:

cat < myPipe  // 读取管道里的数据

管道这种通信方式效率低,不适合进程间频繁地交换数据。 匿名管道的创建,需要通过下面这个系统调用:

int pipe(int fd[2])

这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。 5-管道-pipe **所谓的管道,就是内核里面的一串缓存。**从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。 可以使用fork创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0] 与 fd[1]」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。 6-管道-pipe-fork 管道只能一端写入,另一端读出,所以上面这种模式容易造成混乱,因为父进程和子进程都可以同时写入,也都可以读出。那么,为了避免这种情况,通常的做法是:

  • 父进程关闭读取的 fd[0],只保留写入的 fd[1];

  • 子进程关闭写入的 fd[1],只保留读取的 fd[0]; 7-管道-pipe-fork-单向通信 所以说如果需要双向通信,则应该创建两个管道。 在 shell 里面执行 A | B命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell。 8-管道-pipe-shell 在 shell 里通过「|」匿名管道将多个命令连接在一起,实际上也就是创建了多个子进程,那么在我们编写 shell 脚本时,能使用一个管道搞定的事情,就不要多用一个管道,这样可以减少创建子进程的系统开销。 **对于匿名管道,它的通信范围是存在父子关系的进程。**因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。 **对于命名管道,它可以在不相关的进程间也能相互通信。**因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。 不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

消息队列

管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。 消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。 消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。 消息这种模型,两个进程之间的通信就像平时发邮件一样,你来一封,我回一封,可以频繁沟通了。 但邮件的通信方式存在不足的地方有两点,一是通信不及时,二是附件也有大小限制,这同样也是消息队列通信不足的点。 消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAX 和 MSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。 消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。

共享内存

现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。 共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。 9-共享内存

信号量

为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制。 信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。 信号量表示资源的数量,控制信号量的方式有两种原子操作: 一个是P(Proberen)操作,这个操作会把信号量减去1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。 另一个是 V(Verhogen) 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程; P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。 如果要使得两个进程互斥访问共享内存,我们可以初始化信号量为 1 10-信号量-互斥 具体的过程如下:

  • 进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。

  • 若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。

  • 直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。 可以发现,信号初始化为 1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。 另外,在多进程里,每个进程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个进程能密切合作,以实现一个共同的任务。 例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。 那么这时候,就可以用信号量来实现多进程同步的方式,我们可以初始化信号量为 0。 11-信号量-同步 具体过程:

  • 如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;

  • 接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;

  • 最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。 可以发现,信号初始化为 0,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。

信号

对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。 在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过 kill -l 命令,查看所有的信号: 运行在 shell 终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。

  • Ctrl+C 产生 SIGINT 信号,表示终止该进程;

  • Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束; 如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如:

  • kill -9 1050,表示给PID为1050 的进程发送SIGKILL信号,用来立即结束该进程; 信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令) 信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。

  • 执行默认操作。Linux对每种信号都规定了默认操作,例如,上面列表中的SIGTERM信号,就是终止进程的意思。

  • 捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。

  • 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用 进程无法捕捉和忽略的,即SIGKILL和SEGSTOP,它们用于在任何时候中断或结束某一进程。

Socket

要想跨网络与不同主机上的进程之间通信,就需要Socket通信了。实际上,Socket通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。

int socket(int domain, int type, int protocal)
  • domain 参数用来指定协议族,比如 AF_INET 用于 IPV4、AF_INET6 用于 IPV6、AF_LOCAL/AF_UNIX 用于本机;

  • type 参数用来指定通信特性,比如 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM 表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字;

  • protocal 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可; 根据创建 socket 类型的不同,通信的方式也就不同:

  • 实现 TCP 字节流通信: socket 类型是 AF_INET 和 SOCK_STREAM;

  • 实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM;

  • 实现本地进程间通信: 本地字节流 socket 」类型是 AF_LOCAL 和 SOCK_STREAM,本地数据报 socket 」类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以 AF_UNIX 也属于本地 socket; 针对 TCP 协议通信的 socket 编程模型: 12-TCP编程模型

  • 服务端和客户端初始化 socket,得到文件描述符;

  • 服务端调用 bind,将绑定在 IP 地址和端口;

  • 服务端调用 listen,进行监听;

  • 服务端调用 accept,等待客户端连接;

  • 客户端调用 connect,向服务器端的地址和端口发起连接请求;

  • 服务端 accept 返回用于传输的 socket 的文件描述符;

  • 客户端调用 write 写入数据;服务端调用 read 读取数据;

  • 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭。 这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。 所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。 成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。 针对 UDP 协议通信的 socket 编程模型: 13-UDP编程模型 UDP 是没有连接的,所以不需要三次握手,也就不需要像TCP调用listen和connect,但是UDP的交互仍然需要 IP 地址和端口号,因此也需要bind。 对于UDP来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。 另外,每次通信时,调用sendto和recvfrom,都要传入目标主机的IP地址和端口。 针对本地进程间通信的socket编程模型: 本地socket被用于在同一台主机上进程间通信的场景:

  • 本地socket的编程接口和IPv4 、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议;

  • 本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现; 对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。 对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。 本地字节流socket和本地数据报socket在bind的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。 Socket是网络通信的基础,而HTTP通过套接字实现客户端和服务器之间的数据传输。Socket提供了建立连接、发送和接收数据的机制,是HTTP协议通信的底层支撑。

多线程冲突

对于共享资源,如果没有上锁,在多线程的环境里,那么就可能会发生翻车现场。

竞争与协作

在单核CPU系统里,为了实现多个程序同时运行的假象,操作系统通常以时间片调度的方式,让每个进程执行每次执行一个时间片,时间片用完了,就切换下一个进程运行,由于这个时间片的时间很短,于是就造成了「并发」的现象。 3-并发 另外,操作系统也为每个进程创建巨大、私有的虚拟内存的假象,这种地址空间的抽象让每个程序好像拥有自己的内存,而实际上操作系统在背后秘密地让多个地址空间「复用」物理内存或者磁盘。 4-内存交换 如果一个程序只有一个执行流程,也代表它是单线程的。当然一个程序可以有多个执行流程,也就是所谓的多线程程序,线程是调度的基本单位,进程则是资源分配的基本单位。 线程之间是可以共享进程的资源,比如代码段、堆空间、数据段、打开的文件等资源,但每个线程都有自己独立的栈空间。 多个线程如果竞争共享资源,如果不采取有效的措施,则会造成共享数据的混乱。 8-汇编语句赋值过程 可以发现,只是单纯给i加上数字1,在CPU运行的时候,实际上要执行3条指令。 设想我们的线程1进入这个代码区域,它将i的值(假设此时是 50 )从内存加载到它的寄存器中,然后它向寄存器加1,此时在寄存器中的i值是51。现在,一件不幸的事情发生了:时钟中断发生。因此,操作系统将当前正在运行的线程的状态保存到线程的线程控制块TCB。现在更糟的事情发生了,线程2被调度运行,并进入同一段代码。它也执行了第一条指令,从内存获取i值并将其放入到寄存器中,此时内存中 i 的值仍为 50,因此线程 2 寄存器中的i值也是50。假设线程2执行接下来的两条指令,将寄存器中的i值+ 1,然后将寄存器中的i值保存到内存中,于是此时全局变量i值是51。 最后,又发生一次上下文切换,线程1恢复执行。还记得它已经执行了两条汇编指令,现在准备执行最后一条指令。回忆一下,线程1寄存器中的i值是51,因此,执行最后一条指令后,将值保存到内存,全局变量i的值再次被设置为51。 9-汇编语句-赋值过程-竞争

互斥的概念

上面展示的情况称为竞争条件(race condition),当多线程相互竞争操作共享变量时,由于运气不好,即在执行过程中发生了上下文切换,我们得到了错误的结果,事实上,每次运行都可能得到不同的结果,因此输出的结果存在不确定性(indeterminate)。 由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行。 我们希望这段代码是互斥(mutualexclusion)的,也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区,说白了,就是这段代码执行过程中,最多只能出现一个线程。 10-临界区 另外,说一下互斥也并不是只针对多线程。在多进程竞争共享资源的时候,也同样是可以使用互斥的方式来避免资源竞争造成的资源混乱。

同步的概念

互斥解决了并发进程/线程对临界区的使用问题。这种基于临界区控制的交互作用是比较简单的,只要一个进程/线程进入了临界区,其他试图想进入临界区的进程/线程都会被阻塞着,直到第一个进程/线程离开了临界区。 在多线程里,每个线程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个线程能密切合作,以实现一个共同的任务。 例子,线程1是负责读入数据的,而线程2是负责处理数据的,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程1的唤醒通知时,就会一直阻塞等待,当线程1读完数据需要把数据传给线程2时,线程1会唤醒线程2,并把数据交给线程2处理。 所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。 11-吃饭同步

  • 同步就好比:「操作A应在操作B之前执行」,「操作C必须在操作A和操作B都完成之后才能执行」等;

  • 互斥就好比:「操作A和操作B不能在同一时刻执行」;

互斥与同步的实现和使用

在进程/线程并发执行的过程中,进程/线程之间存在协作的关系,例如有互斥、同步的关系。为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法有两种:

  • 锁:加锁、解锁操作;

  • 信号量:P、V操作; 这两个都可以方便地实现进程/线程互斥,而信号量比锁的功能更强一些,它还可以方便地实现进程/线程同步。

使用加锁操作和解锁操作可以解决并发线程/进程的互斥问题。任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。 12-互斥锁 根据锁的实现不同,可以分为「忙等待锁」和「无忙等待锁」。 现代 CPU 体系结构提供的特殊原子操作指令—— 测试和置位(Test-and-Set)指令。 13-TestAndSet 测试并设置指令做了下述事情:

  • 把old_ptr更新为new的新值

  • 返回old_ptr的旧值; 关键是这些代码是原子执行。因为既可以测试旧值,又可以设置新值,所以我们把这条指令叫作「测试并设置」。 原子操作就是要么全部执行,要么都不执行,不能出现执行到一半的中间状态 可以运用 Test-and-Set 指令来实现「忙等待锁」,代码如下 14-自旋锁

  • 第一个场景是,首先假设一个线程在运行,调用 lock(),没有其他线程持有锁,所以flag是0。当调用 TestAndSet(flag, 1) 方法,返回0,线程会跳出while循环,获取锁。同时也会原子的设置 flag 为1,标志锁已经被持有。当线程离开临界区,调用unlock()将flag清理为 0。

  • 第二种场景是,当某一个线程已经持有锁(即 flag 为1)。本线程调用lock(),然后调用TestAndSet(flag, 1),这一次返回 1。只要另一个线程一直持有锁,estAndSet() 会重复返回 1,本线程会一直忙等。当 flag 终于被改为 0,本线程会调用 TestAndSet(),返回 0 并且原子地设置为 1,从而获得锁,进入临界区。 很明显,当获取不到锁时,线程就会一直 while 循环,不做任何事情,所以就被称为「忙等待锁」,也被称为自旋锁(spin lock)。 这是最简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。在单处理器上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。 无等待锁顾明思议就是获取不到锁的时候,不用自旋。 既然不想自旋,那当没获取到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把CPU让给其他线程执行。 15-无等待锁

信号量

信号量是操作系统提供的一种协调共享资源访问的方法。通常信号量表示资源的数量,对应的变量是一个整型(sem)变量。另外,还有两个原子操作的系统调用函数来控制信号量的,分别是:

  • P 操作:将sem减1,相减后,如果sem < 0,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞;

  • V 操作:将 sem 加 1,相加后,如果 sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞; P 操作是用在进入临界区之前,V 操作是用在离开临界区之后,这两个操作是必须成对出现的。举个类比,2 个资源的信号量,相当于2条火车轨道,PV 操作如下图过程: 16-火车PV操作 信号量数据结构与 PV 操作的算法描述如下图: 17-操作系统PV算法描述 PV 操作的函数是由操作系统管理和实现的,所以操作系统已经使得执行 PV 函数时是具有原子性的。 信号量不仅可以实现临界区的互斥访问控制,还可以线程间的事件同步。为每类共享资源设置一个信号量 s,其初值为1,表示该临界资源未被占用。只要把进入临界区的操作置于 P(s) 和 V(s) 之间,即可实现进程/线程互斥: 18-互斥信号量 任何想进入临界区的线程,必先在互斥信号量上执行 P 操作,在完成对临界资源的访问后再执行 V操作。由于互斥信号量的初始值为 1,故在第一个线程执行 P 操作后 s 值变为 0,表示临界资源为空闲,可分配给该线程,使之进入临界区。 若此时又有第二个线程想进入临界区,也应先执行 P 操作,结果使 s 变为负值,这就意味着临界资源已被占用,因此,第二个线程被阻塞。 并且,直到第一个线程执行 V 操作,释放临界资源而恢复 s 值为 0 后,才唤醒第二个线程,使之进入临界区,待它完成临界资源的访问后,又执行 V 操作,使 s 恢复到初始值 1。 对于两个并发线程,互斥信号量的值仅取 1、0 和 -1 三个值,分别表示

  • 如果互斥信号量为 1,表示没有线程进入临界区;

  • 如果互斥信号量为 0,表示有一个线程进入临界区;

  • 如果互斥信号量为 -1,表示一个线程进入临界区,另一个线程等待进入。 通过互斥信号量的方式,就能保证临界区任何时刻只有一个线程在执行,就达到了互斥的效果。 如何使用信号量实现事件同步。 同步的方式是设置一个信号量,其初值为 0。 19-互斥信号量同步实现-吃饭例子 妈妈一开始询问儿子要不要做饭时,执行的是 P(s1) ,相当于询问儿子需不需要吃饭,由于 s1 初始值为 0,此时 s1 变成 -1,表明儿子不需要吃饭,所以妈妈线程就进入等待状态。 当儿子肚子饿时,执行了 V(s1),使得 s1 信号量从 -1 变成 0,表明此时儿子需要吃饭了,于是就唤醒了阻塞中的妈妈线程,妈妈线程就开始做饭。 接着,儿子线程执行了 接着,儿子线程执行了 P(s2),相当于询问妈妈饭做完了吗,由于 s2 初始值是 0,则此时 s2 变成-1,说明妈妈还没做完饭,儿子线程就等待状态。 最后,妈妈终于做完饭了,于是执行 V(s2),s2 信号量从 -1 变回了 0,于是就唤醒等待中的儿子线程,唤醒后,儿子线程就可以进行吃饭了。

生产者-消费者问题

20-生产者消费者 生产者-消费者问题描述:

  • 生产者在生成数据后,放在一个缓冲区中;

  • 消费者从缓冲区取出数据处理;

  • 任何时刻,只能有一个生产者或消费者可以访问缓冲区; 我们对问题分析可以得出:

  • 任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥;

  • 缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者需要同步。 那么我们需要三个信号量,分别是:

  • 互斥信号量 mutex:用于互斥访问缓冲区,初始化值为 1;

  • 资源信号量 fullBuffers:用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为 0(表明缓冲区一开始为空);

  • 资源信号量 emptyBuffers:用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为 n(缓冲区大小); 21-生产者消费者代码示例 如果消费者线程一开始执行 P(fullBuffers),由于信号量 fullBuffers 初始值为 0,则此时 fullBuffers 的值从 0 变为 -1,说明缓冲区里没有数据,消费者只能等待。 接着,轮到生产者执行 P(emptyBuffers),表示减少 1 个空槽,如果当前没有其他生产者线程在临界区执行代码,那么该生产者线程就可以把数据放到缓冲区,放完后,执行 V(fullBuffers) ,信号量fullBuffers 从 -1 变成 0,表明有「消费者」线程正在阻塞等待数据,于是阻塞等待的消费者线程会被唤醒。 消费者线程被唤醒后,如果此时没有其他消费者线程在读数据,那么就可以直接进入临界区,从缓冲区读取数据。最后,离开临界区后,把空槽的个数 + 1。

经典同步问题

哲学家就餐问题

23-哲学家进餐模型 先来看看哲学家就餐的问题描述:

  • 5 个老大哥哲学家,闲着没事做,围绕着一张圆桌吃面;

  • 巧就巧在,这个桌子只有 5 支叉子,每两个哲学家之间放一支叉子;

  • 哲学家围在一起先思考,思考中途饿了就会想进餐;

  • 奇葩的是,这些哲学家要两支叉子才愿意吃面,也就是需要拿到左右两边的叉子才进餐;

  • 吃完后,会把两支叉子放回原处,继续思考; 如何保证哲 学家们的动作有序进行,而不会出现有人永远拿不到叉子呢? 方案一: 用信号量的方式,也就是 PV 操作来尝试解决它,代码如下: 24-哲学家进餐-方案一示例 上面的程序,好似很自然。拿起叉子用 P 操作,代表有叉子就直接用,没有叉子时就等待其他哲学家放回叉子。 25-哲学家进餐-方案一问题 这种解法存在一个极端的问题:假设五位哲学家同时拿起左边的叉子,桌面上就没有叉子了, 这样就没有人能够拿到他们右边的叉子,也就说每一位哲学家都会在 P(fork[(i + 1) % N ])这条语句阻塞了,很明显这发生了死锁的现象。 方案二: 既然「方案一」会发生同时竞争左边叉子导致死锁的现象,那么我们就在拿叉子前,加个互斥信号量,代码如下: 26-哲学家进餐-方案二示例 上面程序中的互斥信号量的作用就在于,只要有一个哲学家进入了「临界区」,也就是准备要拿叉子时,其他哲学家都不能动,只有这位哲学家用完叉子了,才能轮到下一个哲学家进餐。 27-哲学家进餐-方案二问题 方案二虽然能让哲学家们按顺序吃饭,但是每次进餐只能有一位哲学家,而桌面上是有 5 把叉子,按道理是能可以有两个哲学家同时进餐的,所以从效率角度上,这不是最好的解决方案。 方案三: 方案一的问题在于,会出现所有哲学家同时拿左边刀叉的可能性,那我们就避免哲学家可以同时拿左边的刀叉,采用分支结构,根据哲学家的编号的不同,而采取不同的动作。 即让偶数编号的哲学家「先拿左边的叉子后拿右边的叉子」,奇数编号的哲学家「先拿右边的叉子后拿左边的叉子」。 28-哲学家进餐-方案三示例 上面的程序,在 P 操作时,根据哲学家的编号不同,拿起左右两边叉子的顺序不同。另外,V 操作是不需要分支的,因为 V 操作是不会阻塞的。 29-哲学家进餐-方案三-图解 方案三即不会出现死锁,也可以两人同时进餐。 方案四: 我们用一个数组 state 来记录每一位哲学家的三个状态,分别是在进餐状态、思考状态、饥饿状态(正在试图拿叉子)。 那么,一个哲学家只有在两个邻居都没有进餐时,才可以进入进餐状态。 第 i 个哲学家的左邻右舍,则由宏 LEFT 和 RIGHT 定义:

  • LEFT : ( i + 5 - 1 ) % 5

  • RIGHT : ( i + 1 ) % 5 比如 i 为 2,则 LEFT 为 1,RIGHT 为 3。 30-哲学家进餐-方案四示例 上面的程序使用了一个信号量数组,每个信号量对应一位哲学家,这样在所需的叉子被占用时,想进餐的哲学家就被阻塞。 每个进程/线程将 smart_person 函数作为主代码运行,而其他 take_forks、put_forks 和 test 只是普通的函数,而非单独的进程/线程。 31-哲学家进餐-方案四-图解 方案四同样不会出现死锁,也可以两人同时进餐。

读者-写者问题

「哲学家进餐问题」对于互斥访问有限的竞争问题(如 I/O 设备)一类的建模过程十分有用。 读者只会读取数据,不会修改数据,而写者即可以读也可以修改数据。 读者-写者的问题描述:

  • 「读-读」允许:同一时刻,允许多个读者同时读

  • 「读-写」互斥:没有写者时读者才能读,没有读者时写者才能写

  • 「写-写」互斥:没有其他写者时,写者才能写 方案一: 使用信号量的方式来尝试解决:

  • 信号量 wMutex:控制写操作的互斥信号量,初始值为 1 ;

  • 读者计数 rCount:正在进行读操作的读者个数,初始化为 0;

  • 信号量 rCountMutex:控制对 rCount 读者计数器的互斥修改,初始值为 1; 32-读者写者-方案一示例 上面的这种实现,是读者优先的策略,因为只要有读者正在读的状态,后来的读者都可以直接进入,如果读者持续不断进入,则写者会处于饥饿状态。 方案二: 那既然有读者优先策略,自然也有写者优先策略:

  • 只要有写者准备要写入,写者应尽快执行写操作,后来的读者就必须阻塞;

  • 如果有写者持续不断写入,则读者就处于饥饿; 在方案一的基础上新增如下变量:

  • 信号量 rMutex:控制读者进入的互斥信号量,初始值为 1;

  • 信号量 wDataMutex:控制写者写操作的互斥信号量,初始值为 1;

  • 写者计数 wCount:记录写者数量,初始值为 0;

  • 信号量 wCountMutex:控制 wCount 互斥修改,初始值为 1; 33-读者写者-方案二示例 这里 rMutex 的作用,开始有多个读者读数据,它们全部进入读者队列,此时来了一个写者,执行了 P(rMutex) 之后,后续的读者由于阻塞在 rMutex 上,都不能再进入读者队列,而写者到来,则可以全部进入写者队列,因此保证了写者优先。 同时,第一个写者执行了 P(rMutex) 之后,也不能马上开始写,必须等到所有进入读者队列的读者都执行完读操作,通过 V(wDataMutex) 唤醒写者的写操作。 方案三 既然读者优先策略和写者优先策略都会造成饥饿的现象,那么我们就来实现一下公平策略。 公平策略:

  • 优先级相同;

  • 写者、读者互斥访问;

  • 只能一个写者访问临界区;

  • 可以有多个读者同时访问临界资源; 34-读者写者-方案三示例 看完代码不知你是否有这样的疑问,为什么加了一个信号量 flag,就实现了公平竞争? 对比方案一的读者优先策略,可以发现,读者优先中只要后续有读者到达,读者就可以进入读者队列, 而写者必须等待,直到没有读者到达。 没有读者到达会导致读者队列为空,即 rCount==0,此时写者才可以进入临界区执行写操作。 而这里 flag 的作用就是阻止读者的这种特殊权限(特殊权限是只要读者到达,就可以进入读者队列)。 比如:开始来了一些读者读数据,它们全部进入读者队列,此时来了一个写者,执行 P(falg) 操作,使得后续到来的读者都阻塞在 flag 上,不能进入读者队列,这会使得读者队列逐渐为空,即 rCount 减为 0。 这个写者也不能立马开始写(因为此时读者队列不为空),会阻塞在信号量 wDataMutex 上,读者队列中的读者全部读取结束后,最后一个读者进程执行 V(wDataMutex),唤醒刚才的写者,写者则继续开始进行写操作。

死锁

死锁的概念

在多线程编程中,我们为了防止多线程竞争共享资源而导致数据错乱,都会在操作共享资源之前加上互斥锁,只有成功获得到锁的线程,才能操作共享资源,获取不到锁的线程就只能等待,直到锁被释放。 那么,当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成两个线程都在等待对方释放锁,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了死锁。 死锁只有同时满足以下四个条件才会发生:

  • 互斥条件;

  • 持有并等待条件;

  • 不可剥夺条件;

  • 环路等待条件;

互斥条件

互斥条件是指多个线程不能同时使用同一个资源。 比如下图,如果线程 A 已经持有的资源,不能再同时被线程 B 持有,如果线程 B 请求获取线程 A 已经占用的资源,那线程 B 只能等待,直到线程 A 释放了资源。 互斥条件

持有并等待条件

持有并等待条件是指,当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2 已经被线程 C 持有了,所以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1。 持有并等待条件

不可剥夺条件

不可剥夺条件是指,当线程已经持有了资源 ,在自己使用完之前不能被其他线程获取,线程 B 如果也想使用此资源,则只能在线程 A 使用完并释放后才能获取。 不可剥夺条件

环路等待条件

环路等待条件指的是,在死锁发生的时候,两个线程获取资源的顺序构成了环形链。 比如,线程 A 已经持有资源 2,而想请求资源 1, 线程 B 已经获取了资源 1,而想请求资源 2,这就形成资源请求等待的环形图。 环路等待条件

模拟死锁问题的产生

利用工具排查死锁问题

如果你想排查你的 Java 程序是否死锁,则可以使用 jstack 工具,它是 jdk 自带的线程堆栈分析工具。 C 写的,在 Linux 下,我们可以使用 pstack + gdb 工具来定位死锁问题。 pstack 命令可以显示每个线程的栈跟踪信息(函数调用过程),它的使用方式也很简单,只需要 pstack 就可以了。 那么,在定位死锁问题时,我们可以多次执行 pstack 命令查看线程的函数调用过程,多次对比结果,确认哪几个线程一直没有变化,且是因为在等待锁,那么大概率是由于死锁问题导致的。

避免死锁问题的发生

产生死锁的四个必要条件是:互斥条件、持有并等待条件、不可剥夺条件、环路等待条件。 那么避免死锁问题就只需要破环其中一个条件就可以,最常见的并且可行的就是使用资源有序分配法,来破环环路等待条件。 线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。 我们使用资源有序分配法的方式来修改前面发生死锁的代码,我们可以不改动线程 A 的代码。 我们先要清楚线程 A 获取资源的顺序,它是先获取互斥锁 A,然后获取互斥锁 B。 所以我们只需将线程 B 改成以相同顺序的获取资源,就可以打破死锁了。 资源有序分配

悲观锁和乐观锁

多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,所以我们通常为了解决这一问题,都会在访问共享资源之前加锁。 最常用的就是互斥锁,当然还有很多种不同的锁,比如自旋锁、读写锁、乐观锁等,不同种类的锁自然适用于不同的场景。 为了选择合适的锁,我们不仅需要清楚知道加锁的成本开销有多大,还需要分析业务场景中访问的共享资源的方式,再来还要考虑并发访问共享资源时的冲突概率。

互斥锁与自旋锁

最底层的两种就是会「互斥锁和自旋锁」,有很多高级的锁都是基于它们实现的,可以认为它们是各种锁的地基,我们必须清楚它俩之间的区别和应用。 加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。 当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:

  • 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;

  • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁; 互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。 对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。如下图: 互斥锁工作流程 所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。 那这个开销成本是什么呢?会有两次线程上下文切换的成本:

  • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;

  • 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。 线程的上下文切换的是什么?当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。 上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。 所以,如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。 自旋锁是通过 CPU 提供的 CAS 函数Compare And Swap,在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。 一般加锁的过程,包含两个步骤:

  • 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;

  • 第二步,将锁设置为当前线程持有; CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。 比如,设锁为变量 lock,整数 0 表示锁是空闲状态,整数 pid 表示线程 ID,那么 CAS(lock, 0, pid) 就表示自旋锁的加锁操作,CAS(lock, pid, 0) 则表示解锁操作。 使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。 自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。 自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系,我们需要清楚的知道这一点。 自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。 它俩是锁的最基本处理方式,更高级的锁都会选择其中一个来实现,比如读写锁既可以选择互斥锁实现,也可以基于自旋锁实现。

读写锁

读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。 所以,读写锁适用于能明确区分读操作和写操作的场景。 读写锁的工作原理是:

  • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。

  • 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。 所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。 读写锁在读多写少的场景,能发挥出优势。 根据实现的不同,读写锁可以分为「读优先锁」和「写优先锁」。 读优先锁期望的是,读锁能被更多的线程持有,以便提高读线程的并发性,它的工作方式是:当读线程 A先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取写锁。如下图: 读优先锁工作流程 而「写优先锁」是优先服务写线程,其工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取写锁。如下图: 写优先锁工作流程 读优先锁对于读线程并发性更好,但也不是没有问题。我们试想一下,如果一直有读线程获取读锁,那么写线程将永远获取不到写锁,这就造成了写线程「饥饿」的现象。 写优先锁可以保证写线程不会饿死,但是如果一直有写线程获取写锁,读线程也会被「饿死」。 既然不管优先读锁还是写锁,对方可能会出现饿死问题,那么我们就不偏袒任何一方,搞个「公平读写锁」。 公平读写锁比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。 互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。

乐观锁与悲观锁

前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。 悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。 那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。 放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。 乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程。 这里举一个场景例子:在线文档。 我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。 那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。 怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交早,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。 服务端要怎么验证是否冲突了呢?通常方案如下:

  • 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;

  • 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号不一致则提交失败,如果版本号一致则修改成功,然后服务端版本号更新到最新的版本号。 实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。 乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

一个进程最多可以创建多少个线程

在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址 空间的范围也不同。比如最常⻅的 32 位和 64 位系统,如下所示: 20210715092026648 通过这里可以看出:

  • 32 位系统的内核空间占用 1G ,位于最高处,剩下的 3G 是用户空间;

  • 64 位系统的内核空间和用户空间都是 128T ,分别占据整个内存空间的最高和最低处,剩下的中 间部分是未定义的。 一个进程最多可以创建多少个线程,这个问题跟两个东西有关系:

  • 进程的虚拟内存空间上限,因为创建一个线程,操作系统需要为其分配一个栈空间,如果线程数量越多,所需的栈空间就要越大,那么虚拟内存就会占用的越多。

  • 系统参数限制,虽然 Linux 并没有内核参数来控制单个进程创建的最大线程个数,但是有系统级别的参数来控制整个系统的最大线程个数。 在进程里创建一个线程需要消耗多少虚拟内存大小? 可以执行 ulimit -a 这条命令,查看进程创建线程时默认分配的栈空间大小,比如我这台服务器默认分配给线程的栈空间大小为 8M。 20210715092041211 在 32 位 Linux 系统里,一个进程的虚拟空间是 4G,内核分走了1G,留给用户用的只有 3G。 那么假设创建一个线程需要占用 10M 虚拟内存,总共有 3G 虚拟内存可以使用。于是我们可以算出,最多可以创建差不多 300 个(3G/10M)左右的线程。 如果想使得进程创建上千个线程,那么我们可以调整创建线程时分配的栈空间大小,比如调整为 512k: ulimit -s 512 64 位系统意味着用户空间的虚拟内存最大值是 128T,这个数值是很大的,如果按创建一个线程需占用 10M 栈空间的情况来算,那么理论上可以创建 128T/10M 个线程,也就是 1000多万个线程,有点魔幻! 所以按 64 位系统的虚拟内存大小,理论上可以创建无数个线程。 事实上,肯定创建不了那么多线程,除了虚拟内存的限制,还有系统的限制。 比如下面这三个内核参数的大小,都会影响创建线程的上限:

  • /proc/sys/kernel/threads-max,表示系统支持的最大线程数,默认值是 14553;

  • /proc/sys/kernel/pid_max,表示系统全局的 PID 号数值的限制,每一个进程或线程都有 ID,ID 的值超过这个数,进程或线程就会创建失败,默认值是 32768;

  • /proc/sys/vm/max_map_count,表示限制一个进程可以拥有的VMA(虚拟内存区域)的数量,如果它的值很小,也会导致创建线程失败,默认值是 65530 为什么物理内存只有 2G,进程的虚拟内存却可以使用 25T 呢? 因为虚拟内存并不是全部都映射到物理内存的,程序是有局部性的特性,也就是某一个时间只会执行部分代码,所以只需要映射这部分程序就好。

  • 32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。

  • 64 位系统,用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制。

线程崩溃了,进程也会崩溃吗

为什么 C/C++ 语言里,线程崩溃后,进程也会崩溃,而 Java 语言里却不会呢?

线程崩溃,进程一定会崩溃吗

一般来说如果线程是因为非法访问内存引起的崩溃,那么进程肯定会崩溃,为什么系统要让进程崩溃呢,这主要是因为在进程中,各个线程的地址空间是共享的,既然是共享,那么某个线程对地址的非法访问就会导致内存的不确定性,进而可能会影响到其他线程,这种操作是危险的,操作系统会认为这很可能导致一系列严重的后果,于是干脆让整个进程崩溃。 1-进程崩溃

进程是如何崩溃的-信号机制简介

那么线程崩溃后,进程是如何崩溃的呢,这背后的机制到底是怎样的,答案是信号。实际上 kill 执行的是系统调用,将控制权转移给了内核(操作系统),由内核来给指定的进程发送信号。 那么发个信号进程怎么就崩溃了呢,这背后的原理到底是怎样的? 其背后的机制如下

  1. CPU 执行正常的进程指令

  2. 调用 kill 系统调用向进程发送信号

  3. 进程收到操作系统发的信号,CPU 暂停当前程序运行,并将控制权转交给操作系统

  4. 调用 kill 系统调用向进程发送信号(假设为 11,即 SIGSEGV,一般非法访问内存报的都是这个错误)

  5. 操作系统根据情况执行相应的信号处理程序(函数),一般执行完信号处理程序逻辑后会让进程退出 如果进程没有注册自己的信号处理函数,那么操作系统会执行默认的信号处理程序(一般最后会让进程退出),但如果注册了则会执行自己的信号处理函数,这样的话就给了进程一个垂死挣扎的机会,它收到 kill 信号后,可以调用 exit() 来退出,但也可以使用 sigsetjmp,siglongjmp 这两个函数来恢复进程的执行 如何让正在运行的 Java 工程的优雅停机? 其实是 JVM 自己定义了信号处理函数,这样当发送 kill pid 命令(默认会传 15 也就是 SIGTERM)后,JVM 就可以在信号处理函数中执行一些资源清理之后再调用 exit 退出。 这种场景显然不能用 kill -9,不然一下把进程干掉了资源就来不及清除了。

为什么线程崩溃不会导致 JVM 进程崩溃

现在我们再来看看开头这个问题,相信你多少会心中有数,想想看在 Java 中有哪些是常见的由于非法访想想看在 Java 中有哪些是常见的由于非法访想想看在 Java 中有哪些是常见的由于非法访(NullPointerException),NPE 我们都了解,属于是访问了不存在的内存。 但为什么栈溢出(Stackoverflow)也属于非法访问内存呢,这得简单聊一下进程的虚拟空间,也就是前面提到的共享地址空间。 现代操作系统为了保护进程之间不受影响,所以使用了虚拟地址空间来隔离进程,进程的寻址都是针对虚拟地址,每个进程的虚拟空间都是一样的,而线程会共用进程的地址空间。 以 32 位虚拟空间,进程的虚拟空间分布如下: 虚拟内存空间 那么stackoverflow是怎么发生的呢? 进程每调用一个函数,都会分配一个栈桢,然后在栈桢里会分配函数里定义的各种局部变量。 假设现在调用了一个无限递归的函数,那就会持续分配栈帧,假设现在调用了一个无限递归的函数,那就会持续分配栈帧,如果无限递归很快栈就会分配完了,此时再调用函数试图分配超出栈的大小内存,就会发生段错误,也就是 stackoverflowError。 ![StackoverflowError ](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/StackoverflowError .webp) 既然 StackoverflowError 或者 NPE 都属于非法访问内存,JVM 为什么不会崩溃呢? 其实就是因为 JVM 自定义了自己的信号处理函数,拦截了 SIGSEGV 信号,针对这两者不让它们崩溃。 怎么证明这个推测呢,我们来看下 JVM 的源码来一探究竟

openJDK 源码解析

HotSpot 虚拟机目前使用范围最广的 Java 虚拟机,据 R 大所述,Oracle JDK 与 OpenJDK 里的 JVM 都是HotSpot VM,从源码层面说,两者基本上是同一个东西。 OpenJDK 是开源的,所以我们主要研究下 Java 8 的 OpenJDK 即可,地址如下: https://github.com/AdoptOpenJDK/openjdk-jdk8u 我们只要研究 Linux 下的 JVM,为了便于说明,也方便大家查阅,关于信号处理的关键流程整理了下(忽略其中的次要代码)。 JVM信号处理 可以看到,在启动 JVM 的时候,也设置了信号处理函数,收到 SIGSEGV,SIGPIPE 等信号后最终会调用JVM_handle_linux_signal 这个自定义信号处理函数,再来看下这个函数的主要逻辑。 ![JVM_handle_linux_signal ](https://cdn.jsdelivr.net/gh/zysok2023/cloudImg/blogs/picture/JVM_handle_linux_signal .png) 从以上代码我们可以知道以下信息:

  1. 发生 stackoverflow 还有空指针错误,确实都发送了 SIGSEGV,只是虚拟机不选择退出,而是自己内部作了额外的处理,其实是恢复了线程的执行,并抛出 StackoverflowError 和 NPE,这就是为什么 JVM 不会崩溃且我们能捕获这两个错误/异常的原因

  2. 如果针对 SIGSEGV 等信号,在以上的函数中 JVM 没有做额外的处理,那么最终会走到 report_and_die这个方法,这个方法主要做的事情是生成 hs_err_pid_xxx.log crash 文件记录了一些堆栈信息或错误),然后退出 原因其实就是虚拟机内部定义了信号处理函数,而在信号处理函数中对这两者做了额外的处理以让 JVM不崩溃,另一方面也可以看出如果 JVM 不对信号做额外的处理,最后会自己退出并产生 crash 文件hs_err_pid_xxx.log可以通过 -XX:ErrorFile=/var/log/hs_err.log 这样的方式指定这个文件记录了虚拟机崩溃的重要原因。