Loading...

进程与线程基础知识

  iwehdio的博客园:https://www.cnblogs.com/iwehdio/

学习自:

进程

  • 我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称为「进程」。

  • 由于从硬盘读取数据很慢,当进程要从硬盘读取数据时,CPU 不需要阻塞等待数据的返回,而是去执行另外的进程。当硬盘数据返回时,CPU 会收到个中断,于是 CPU 再继续运行这个进程。

  • 这种多个程序、交替执行的思想,就有 CPU 管理多个进程的初步想法。

  • 对于一个支持多进程的系统,CPU 会从一个进程快速切换至另一个进程,其间每个进程各运行几十或几百个毫秒。

  • 虽然单核的 CPU 在某一个瞬间,只能运行一个进程。但在 1 秒钟期间,它可能会运行多个进程,这样就产生并行的错觉,实际上这是并发。

  • 并发和并行有什么区别?

    image-20210104201935130
  • CPU 可以从一个进程切换到另外一个进程,在切换前必须要记录当前进程中运行的状态信息,以备下次切换回来的时候可以恢复执行。进程有着「运行 - 暂停 - 运行」的活动规律。

  • 进程的状态:

    • 一般说来,一个进程并不是自始至终连续不停地运行的,它与并发执行中的其他进程的执行是相互制约的。它有时处于运行状态,有时又由于某种原因而暂停运行处于等待状态,当使它暂停的原因消失后,它又进入准备运行状态。

    • 在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。

      image-20210104202708511

      • 运行状态(Runing):该时刻进程占用 CPU;
      • 就绪状态(Ready):可运行,但因为其他进程正在运行而暂停停止;
      • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
      • 创建状态(new):进程正在被创建时的状态;
      • 结束状态(Exit):进程正在从系统中消失时的状态。
    • 进程的状态变迁:

      • NULL -> 创建状态:一个新进程被创建时的第一个状态;
      • 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的;
      • 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;
      • 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理;
      • 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行;
      • 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件;
      • 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态。
    • 另外,还有一个状态叫挂起状态,它表示进程没有占有物理内存空间。这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。

      • 由于虚拟内存管理原因,进程的所使用的空间可能并没有映射到物理内存,而是在硬盘上,这时进程就会出现挂起状态,另外调用 sleep 也会被挂起。
      • 挂起状态可以分为两种:
        • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
        • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行。
  • 进程的控制结构:

    • 在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。

    • PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。

    • PCB 具体包含什么信息呢?

      • 进程描述信息:

        • 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;
        • 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务。
      • 进程控制和管理信息:

        • 进程当前状态,如 new、ready、running、waiting 或 blocked 等;
        • 进程优先级:进程抢占 CPU 时的优先级。
      • 资源分配清单:

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

        • CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。
    • 每个 PCB 是如何组织的呢?

      • 通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。
        • 将所有处于就绪状态的进程链在一起,称为就绪队列;
        • 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列;
        • 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。
      image-20210104203320835
      • 除了链接的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表。
      • 一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活的插入和删除。
  • 进程的控制:

    • 进程的创建、终止、阻塞、唤醒的过程。
    • 创建进程:
      • 操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源,当子进程被终止时,其在父进程处继承的资源应当还给父进程。同时,终止父进程时同时也会终止其所有的子进程。
      • 创建进程的过程如下:
        • 为新进程分配一个唯一的进程标识号,并申请一个空白的 PCB,PCB 是有限的,若申请失败则创建失败;
        • 为进程分配资源,此处如果资源不足,进程就会进入等待状态,以等待资源;
        • 初始化 PCB;
        • 如果进程的调度队列能够接纳新进程,那就将进程插入到就绪队列,等待被调度运行。
    • 终止进程:
      • 进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 kill 掉)。
      • 终止进程的过程如下:
        • 查找需要终止的进程的 PCB;
        • 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程;
        • 如果其还有子进程,则应将其所有子进程终止;
        • 将该进程所拥有的全部资源都归还给父进程或操作系统;
        • 将其从 PCB 所在队列中删除。
    • 阻塞进程:
      • 当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。
      • 阻塞进程的过程如下:
        • 找到将要被阻塞进程标识号对应的 PCB;
        • 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;
        • 将该 PCB 插入该事件的阻塞队列中去。
    • 唤醒进程:
      • 进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。
      • 如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。
      • 唤醒进程的过程如下:
        • 在该事件的阻塞队列中找到相应进程的 PCB;
        • 将其从阻塞队列中移出,并置其状态为就绪状态;
        • 把该 PCB 插入到就绪队列中,等待调度程序调度。
      • 进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。
  • 进程的上下文切换:

    • 各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换。

    • CPU 的上下文切换:

      • 在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。所以,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器。
      • CPU 寄存器是 CPU 内部一个容量小,但是速度极快的内存(缓存)。程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。
      • CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文。
      • CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
      • 系统内核会存储保存下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。
      • 所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。
    • 进程的上下文切换到底是切换什么呢?

      • 进程是由内核管理和调度的,所以进程的切换只能发生在内核态。所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
      • 通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行。
      image-20210104204613974
      • 进程的上下文开销是很关键的,我们希望它的开销越小越好,这样可以使得进程可以把更多时间花费在执行程序上,而不是耗费在上下文切换。
    • 发生进程上下文切换有哪些场景?

      • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行;
      • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
      • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
      • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
      • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。

线程

  • 在早期的操作系统中都是以进程作为独立运行的基本单位,直到后面,计算机科学家们又提出了更小的能独立运行的基本单位,也就是线程。

  • 为什么使用线程?

    • 假设你要编写一个视频播放器软件,那么该软件功能的核心模块有三个:
      • 从视频文件当中读取数据;
      • 对读取的数据进行解压缩;
      • 把解压缩后的视频数据播放出来。
    • 对于单进程的这种方式,存在以下问题:
      • 播放出来的画面和声音会不连贯,因为当 CPU 能力不够强的时候,Read 的时候可能进程就等在这了,这样就会导致等半天才进行数据解压和播放;
      • 各个函数之间不是并发执行,影响资源的使用效率。
    • 对于多进程的这种方式,依然会存在问题:
      • 进程之间如何通信,共享数据?
      • 维护进程的系统开销较大,如创建进程时,分配资源、建立 PCB;终止进程时,回收资源、撤销 PCB;进程切换时,保存当前进程的状态信息。
    • 需要有一种新的实体,满足以下特性:
      • 实体之间可以并发运行;
      • 实体之间共享相同的地址空间。
    • 这个新的实体,就是线程( Thread ),线程之间可以并发运行且共享相同的地址空间。
  • 什么是线程?

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

    • 线程的优点:
      • 一个进程中可以同时存在多个线程;
      • 各个线程之间可以并发执行;
      • 各个线程之间可以共享地址空间和文件等资源。
    • 线程的缺点:
      • 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃。
  • 线程与进程的比较:

    • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
    • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
    • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
    • 线程能减少并发执行的时间和空间开销。
  • 对于,线程相比进程能减少开销,体现在:

    • 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
    • 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
    • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
    • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了。
    • 所以,线程比进程不管是时间效率,还是空间效率都要高。
  • 线程的上下文切换:

    • 在前面我们知道了,线程与进程最大的区别在于:线程是调度的基本单位,而进程则是资源拥有的基本单位。
    • 所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。
    • 对于线程和进程,可以这么理解:
      • 当进程只有一个线程时,可以认为进程就等于线程;
      • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的;
      • 另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。
  • 线程上下文切换的是什么?

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

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

  • 线程的实现:

    • 用户线程(User Thread):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理。多个用户线程对应同一个内核线程;
    • 内核线程(Kernel Thread):在内核中实现的线程,是由内核管理的线程。一个用户线程对应一个内核线程;
    • 轻量级进程(LightWeight Process):在内核中来支持用户线程。多个用户线程对应到多个内核线程。
  • 用户线程如何理解?

    • 用户线程是基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。
    • 所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。
    image-20210104211032874
  • 用户线程的优点:

    • 每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统;
    • 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快。
  • 用户线程的缺点:

    • 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了。
    • 当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。
    • 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢。
  • 内核线程如何理解?

    • 内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。
    image-20210104211057555
  • 内核线程的优点:

    • 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;
    • 分配给线程,多线程的进程获得更多的 CPU 运行时间。
  • 内核线程的缺点:

    • 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB;
    • 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大。
  • 轻量级进程如何理解?

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

    • 在大多数系统中,LWP与普通进程的区别在于它只有一个最小的执行上下文和调度程序所需的统计信息。

    • 在 LWP 之上也是可以使用用户线程的,那么 LWP 与用户线程的对应关系就有三种:

      • 1 : 1,即一个 LWP 对应 一个用户线程;
        • 一个线程对应到一个 LWP 再对应到一个内核线程,如图中的进程 4,属于此模型。
        • 优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP;
        • 缺点:每一个用户线程,就产生一个内核线程,创建线程的开销较大。
      • N : 1,即一个 LWP 对应多个用户线程;
        • 多个用户线程对应一个 LWP 再对应一个内核线程,如图中的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见。
        • 优点:用户线程要开几个都没问题,且上下文切换发生用户空间,切换的效率较高;
        • 缺点:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用 CPU 的
      • N : N,即多个 LMP 对应多个用户线程。
        • 根据前面的两个模型混搭一起,就形成 M:N 模型,该模型提供了两级控制,首先多个用户线程对应到多个 LWP,LWP 再一一对应到内核线程,如图中的进程 3。
          • 优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核 CPU 的资源。
        • 组合模式:如图中的进程 5,此进程结合 1:1 模型和 M:N 模型。开发人员可以针对不同的应用特点调节内核线程的数目来达到物理并行性和逻辑并行性的最佳方案。
      image-20210104211601836

调度

  • 一旦操作系统把进程切换到运行状态,也就意味着该进程占用着 CPU 在执行,但是当操作系统把进程切换到其他状态时,那就不能在 CPU 中执行了,于是操作系统会选择下一个要运行的进程。

  • 选择一个进程运行这一功能是在操作系统中完成的,通常称为调度程序(scheduler)。

  • 调度时机:

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

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

    • 原则一:为了提高 CPU 利用率,在这种发送 I/O 事件致使 CPU 空闲的情况下,调度程序需要从就绪队列中选择一个进程来运行。
    • 原则二:要提高系统的吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量。
    • 原则三:进程的周转时间越小越好,如果进程的等待时间很长而运行时间很短,那周转时间就很长,这不是我们所期望的,调度程序应该避免这种情况发生。
    • 原则四:就绪队列中进程的等待时间也是调度程序所需要考虑的原则
    • 原则五:对于交互式比较强的应用,响应时间也是调度程序需要考虑的原则。
  • 调度指标:

    • CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率;
    • 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;
    • 周转时间:周转时间是进程运行和阻塞时间总和,一个进程的周转时间越小越好;
    • 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;
    • 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。
  • 调度算法:

    • 先来先服务调度算法:

      • 非抢占式。每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。

      image-20210104212825287

      • FCFS 对长作业有利,对短作业不利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。
    • 最短作业优先调度算法:

      • 非抢占式。优先选择运行时间最短的进程来运行。

      image-20210104213046890

      • 有助于提高系统的吞吐量,但对长作业不利。
    • 高响应比优先调度算法:

      • 非抢占式。权衡了短作业和长作业。每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行。
      image-20210104213132551
      • 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行;
      • 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会。
    • 时间片轮转调度算法:

      • 抢占式。每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。
        • 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程;
        • 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换。
      image-20210104213304495
      • 时间片的长度就是一个很关键的点:
        • 如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;
        • 如果设得太长又可能引起对短作业进程的响应时间变长。20ms~50ms 通常是一个比较合理的折中值。
    • 最高优先级调度算法:

      • 希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行。
      • 进程的优先级可以分为,静态优先级或动态优先级:
        • 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
        • 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。
      • 两种处理优先级高的方法,非抢占式和抢占式:
        • 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
        • 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。
      • 可能会导致低优先级的进程永远不会运行。
    • 多级反馈队列调度算法:

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

      image-20210104213608580

      • 如何工作的:
        • 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短;
        • 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
        • 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行。
      • 对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。

互斥和同步

  • 比如,使用两个线程都对i加1一万次,每次得到的结果都不同:

    • 因为加一操作,一般有三个执行过程:

      1. 从内存中取出i后,放入寄存器中。
      2. 对寄存器中的i值加1。
      3. 把寄存器中的i值放回内存。
    • 有可能由于时钟中断,产生不可控的调度:

      image-20210104221117270

  • 竞争条件(race condition),当多线程相互竞争操作共享变量时,由于在执行过程中发生了上下文切换,得到了错误的结果,事实上,每次运行都可能得到不同的结果,因此输出的结果存在不确定性(indeterminate)。

  • 由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行。

  • 我们希望这段代码是互斥(mutualexclusion)的,也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区,说白了,就是这段代码执行过程中,最多只能出现一个线程。

image-20210104221231009

  • 同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。
  • 同步与互斥是两种不同的概念:
    • 同步就好比:「操作 A 应在操作 B 之前执行」,「操作 C 必须在操作 A 和操作 B 都完成之后才能执行」等;
    • 互斥就好比:「操作 A 和操作 B 不能在同一时刻执行」。

互斥和同步的实现

  • 主要有锁和信号量两种方法。

  • 锁:

    • 使用加锁操作和解锁操作可以解决并发线程/进程的互斥问题。

    • 任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。

    • 测试和置位(Test-and-Set)指令:

    • 现代 CPU 体系结构提供的特殊原子操作。

    image-20210105190153763
    • 测试并设置指令做了下述事情:

      • old_ptr 更新为 new 的新值
      • 返回 old_ptr 的旧值。
    • 关键是这些代码是原子执行。原子操作就是要么全部执行,要么都不执行,不能出现执行到一半的中间状态。

    • 根据锁的实现不同,可以分为「忙等待锁」和「无忙等待锁」。

    • 运用 Test-and-Set 指令来实现「忙等待锁」:

      image-20210105190335402
      • 第一个场景是,首先假设一个线程在运行,调用 lock(),没有其他线程持有锁,所以 flag 是 0。当调用 TestAndSet(flag, 1) 方法,返回 0,线程会跳出 while 循环,获取锁。同时也会原子的设置 flag 为1,标志锁已经被持有。当线程离开临界区,调用 unlock()flag 清理为 0。
      • 第二种场景是,当某一个线程已经持有锁(即 flag 为1)。本线程调用 lock(),然后调用 TestAndSet(flag, 1),这一次返回 1。只要另一个线程一直持有锁,TestAndSet() 会重复返回 1,本线程会一直忙等。当 flag 终于被改为 0,本线程会调用 TestAndSet(),返回 0 并且原子地设置为 1,从而获得锁,进入临界区。
      • 当获取不到锁时,线程就会一直 wile 循环,不做任何事情,所以就被称为「忙等待锁」,也被称为自旋锁(spin lock)。
      • 这是最简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。在单处理器上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
    • 「无等待锁」的实现:

      • 既然不想自旋,那当没获取到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把 CPU 让给其他线程执行。

      image-20210105190821881

  • 信号量:

    • 信号量是操作系统提供的一种协调共享资源访问的方法。

    • 通常信号量表示资源的数量,对应的变量是一个整型(sem)变量。

    • 两个原子操作的系统调用函数来控制信号量:

      • P 操作:将 sem1,相减后,如果 sem < 0,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞;
      • V 操作:将 sem1,相加后,如果 sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞。
    • P 操作是用在进入临界区之前,V 操作是用在离开临界区之后,这两个操作是必须成对出现的。

    • 如何实现 PV 操作的:

      image-20210105191148236

      • PV 操作的函数是由操作系统管理和实现的,所以操作系统已经使得执行 PV 函数时是具有原子性的。
    • PV 操作如何使用:

      • 信号量不仅可以实现临界区的互斥访问控制,还可以线程间的事件同步。
      • 信号量实现临界区的互斥访问:
        • 为每类共享资源设置一个信号量 s,其初值为 1,表示该临界资源未被占用。
        • 只要把进入临界区的操作置于 P(s)V(s) 之间,即可实现进程/线程互斥。
        • 如果互斥信号量为 1,表示没有线程进入临界区;
        • 如果互斥信号量为 0,表示有一个线程进入临界区;
        • 如果互斥信号量为 -1,表示一个线程进入临界区,另一个线程等待进入。
      • 用信号量实现事件同步:
        • 设置一个信号量,其初值为 0
        • 在“前操作”之后执行V(S)
        • 在“后操作”之前执行P(S)
  • 这里需要理解的是,自旋锁并没有主动交出控制权,需要抢占式调度算法抢占控制器。而非等待锁和信号量,都是在不满足条件时主动交出控制权,将自己放入阻塞队列。

经典互斥同步问题

  • 生产者与消费者:

    • 问题描述:

      • 生产者在生成数据后,放在一个缓冲区中;
      • 消费者从缓冲区取出数据处理;
      • 任何时刻,只能有一个生产者或消费者可以访问缓冲区。
    • 需要三个信号量,分别是:

      • 互斥信号量 mutex:用于互斥访问缓冲区,初始化值为 1;
      • 资源信号量 fullBuffers:用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为 0(表明缓冲区一开始为空);
      • 资源信号量 emptyBuffers:用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为 n (缓冲区大小)。

    image-20210105192423122

  • 哲学家就餐问题:

    • 方案一:每根筷子都是一个信号量初值为1的资源。可以用PV实现互斥,但是会导致死锁,比如所有人都拿左边的筷子。

      image-20210105193238924

    • 方案二:在拿叉子前,加个互斥信号量,同时只能有一个人拿到筷子吃饭。避免了死锁,但是效率太低。因为有5根筷子,应该同时支持两个人吃饭才对。

      image-20210105193439329

    • 方案三:让偶数编号的哲学家「先拿左边的叉子后拿右边的叉子」,奇数编号的哲学家「先拿右边的叉子后拿左边的叉子」。这种办法的本质是改变了循环等待的方向,而且是一半正向等待,一半反向等待。

      image-20210105193808643

    • 方案四:一个哲学家只有在两个邻居都没有进餐时,才可以进入进餐状态。

      • 本质是,哲学家可以进餐,其两个邻居没有进餐是充要条件。而为了避免资源错乱,同时只能有一个线程改变进餐状态。
      • 在这里没有显式的拿筷子和放筷子操作,而是用两个哲学家的状态组合,如果两个哲学家中有一个正在进餐,则这两个哲学家之间的筷子是被拿起的,否则则是被放下的。
      • 如果你是HUNGRY状态,表示请求两边的筷子,如果两边的哲学家不是正在使用筷子,那就表示这一边的筷子是可用的,或者说至少是可以“让出来”的。
      • 如果HUNGRY状态获取到两根筷子,就进入EATING,并且吃完后,唤醒其左右两侧阻塞在HUNGRY状态的哲学家。
      • 这种思路类似于,在资源有限竞争时,不是直接对资源进行互斥访问,而是测试与其竞争的线程是否获取到所有资源开始运行。如果是,则所要请求的资源必定已经被其他线程占有了。

      img

  • 读者-写者问题:

    • 问题描述:

      • 「读-读」允许:同一时刻,允许多个读者同时读
      • 「读-写」互斥:没有写者时读者才能读,没有读者时写者才能写
      • 「写-写」互斥:没有其他写者时,写者才能写。
    • 方案一:

      • 读者优先的策略,只要有读者正在读的状态,后来的读者都可以直接进入,如果读者持续不断进入,则写者会处于饥饿状态。

        图片

    • 方案二:

      • 写者优先策略,只要有写者准备要写入,写者应尽快执行写操作,后来的读者就必须阻塞。如果有写者持续不断写入,则读者就处于饥饿。

      • 这里 rMutex 的作用,开始有多个读者读数据,它们全部进入读者队列,此时来了一个写者,执行了 P(rMutex) 之后,后续的读者由于阻塞在 rMutex 上,都不能再进入读者队列,而写者到来,则可以全部进入写者队列,因此保证了写者优先。
      • 同时,第一个写者执行了 P(rMutex) 之后,也不能马上开始写,必须等到所有进入读者队列的读者都执行完读操作,通过 V(wDataMutex) 唤醒写者的写操作。
    • 方案三:

      • 公平策略:
        • 优先级相同;
        • 写者、读者互斥访问;
        • 只能一个写者访问临界区;
        • 可以有多个读者同时访问临界资源。

      • 加了一个信号量 flag,就实现了公平竞争:所有的操作无论读写,都需要先竞争flag。
      • 如果写者竞争到了flag,则它要么等待前边所有读者读完,要么等待前一个写者写完;而且在这个写者写完之前,也不会有其他读者或写着竞争到flag。
      • 如果读者竞争到了flag,它要么等前一个写着写完,要么与之前的读者一起读;如果与之前的读者一起读,则可以又有一次竞争flag的机会。

进程间通信

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

image-20210105202601803

管道

  • 管道传输数据是单向的,如果想相互通信,我们需要创建两个管道才行。

  • 所谓的管道,就是内核里面的一串缓存。从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

  • 匿名管道的创建,需要通过下面这个系统调用:

    int pipe(int fd[2])
    
    • 这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]
    • 注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。
    image-20210105203109561
  • 对于匿名管道,它的通信范围是存在父子关系的进程,因为匿名管道没有实体,也就是没有管道文件。

  • 对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。

消息队列

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

共享内存

  • 消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。共享内存的方式,就很好的解决了这一问题。
  • 共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。
image-20210105203946149
  • 用了共享内存通信方式,带来新的问题,那就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。
  • 可以使用信号量,实现对共享内存的互斥写。

信号

  • 上面说的进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。

  • 在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过 kill -l 命令,查看所有的信号。

  • 信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,有下面这几种,用户进程对信号的处理方式:

    1. 执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。Core 的意思是 Core Dump,也即终止进程后,通过 Core Dump 将当前进程的运行状态保存在文件里面,方便程序员事后进行分析问题在哪里。
  1. 捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
  2. 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILLSEGSTOP,它们用于在任何时候中断或结束某一进程。

Socket

  • 前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要 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 编程模型:

    image-20210105205947547

    • 服务端和客户端初始化 socket,得到文件描述符;
    • 服务端调用 bind,绑定本机的 IP 地址和端口;
    • 服务端调用 listen,进行监听;
    • 服务端调用 accept,等待客户端连接;
    • 客户端调用 connect,向服务器端的地址和端口发起连接请求;
    • 服务端 accept 返回用于传输的 socket 的文件描述符;
    • 客户端调用 write 写入数据;服务端调用 read 读取数据;
    • 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭。
  • 这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。

  • 所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。

  • UDP 协议通信的 socket 编程模型:

    image-20210105210254404
    • UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind。
    • 对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。
    • 每次通信时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。
  • 本地进程间通信的 socket 编程模型:

    • 本地 socket 的编程接口和 IPv4 、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议;
    • 本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现
    • 本地 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。

  • 多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,所以我们通常为了解决这一问题,都会在访问共享资源之前加锁。
  • 为了选择合适的锁,不仅需要清楚知道加锁的成本开销有多大,还需要分析业务场景中访问的共享资源的方式,再来还要考虑并发访问共享资源时的冲突概率。
  • 不管使用的哪种锁,我们的加锁的代码范围应该尽可能的小,也就是加锁的粒度要小,这样执行速度会比较快。再来,使用上了合适的锁,就会快上加快了。

互斥锁与自旋锁

  • 最底层的两种就是「互斥锁和自旋锁」,有很多高级的锁都是基于它们实现的,可以认为它们是各种锁的地基。

  • 加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。

  • 当已经有一个线程加锁后,其他线程加锁就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:

    • 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
    • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁。
  • 互斥锁:

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

    • 这个开销成本是什么呢?会有两次线程上下文切换的成本:

      • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
      • 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
    • 所以,如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。

  • 自旋锁:

    • 自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
    • 一般加锁的过程,包含两个步骤:
      • 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
      • 第二步,将锁设置为当前线程持有。
    • CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。
    • 使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
    • 自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器。
    • 自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源。
    • 所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系。
  • 自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。

  • 它俩是锁的最基本处理方式,更高级的锁都会选择其中一个来实现,比如读写锁既可以选择互斥锁实现,也可以基于自旋锁实现

读写锁

  • 如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。所以,读写锁适用于能明确区分读操作和写操作的场景。

  • 读写锁的工作原理是:

    • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
    • 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。
  • 所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。

  • 读写锁在读多写少的场景,能发挥出优势。

  • 另外,根据实现的不同,读写锁可以分为「读优先锁」和「写优先锁」。主要区别是,当写锁被读锁阻塞时,后来的读操作是能获取到读锁还是被阻塞。

  • 读优先锁期望的是,读锁能被更多的线程持有,以便提高读线程的并发性。

    • 它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取读锁。
    image-20210105212959605
  • 写优先锁是优先服务写线程。

    • 工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取读锁。
    image-20210105213109794
  • 读优先锁对于读线程并发性更好,但可能造成了写线程「饥饿」。写优先锁可以保证写线程不会饿死,但是如果一直有写线程获取写锁,读线程也会被「饿死」。

  • 公平读写锁:

    • 比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。

悲观锁与乐观锁

  • 前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。
  • 悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
  • 那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。
  • 乐观锁做事比较乐观,它假定冲突的概率很低。
    • 工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源(是否发生了冲突),就放弃本次操作。
    • 放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的
    • 乐观锁全程并没有加锁,所以它也叫无锁编程。
  • 怎么验证是否冲突了呢?通常方案如下:
    • 由于发生冲突的概率比较低,所以先修改共享资源,但是在获取共享资源时会记录一个获取资源时刻的版本号;
    • 当用户提交修改时,会比较当前版本号和获取资源时的版本号,如果版本号一致则修改成功,否则提交失败。
  • 乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

posted @ 2021-01-05 22:06  iwehdio  阅读(378)  评论(0编辑  收藏  举报