Overview

  • linux kernel overview讲到,kernel的一大重要职责就是作process management。包括两大部分:
    • 为上层提供进程相关操作的API (进程管理)
      • 如fork、exec新建线程;kill、exit停止线程;线程之间的通信和同步;
    • 处理活动进程之间共享CPU的需求,也就是调度

进程基本信息

  • linux内核不存在整真正意义上的线程。linux将所有的执行实体都称之为任务(task),每一个任务都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。
  • 但是,linux下不同任务之间可以选择公用内存空间,因而在实际意义上,共享同一个内存空间的多个任务构成了一个进程,而这些任务就成为这个任务里面的线程。

进程类别

  • 首先,本质上来说linux的进程都是 task_struct。但根据进程的运行空间不同,从而有内核进程和用户进程之分:内核进程运行在内核空间,而用户进程运行在用户空间,但可以通过系统调用从用户态陷入内核态。
  • 然后,最重要的是,为什么要区分用户线程和内核线程
    • 两者最主要的区别在于:线程的调度是在核内还是核外。
    • 区别的目的是考虑效率:内核调度的话可以并发使用多处理器的资源,核外调度的话更多考虑的是上下文切换开销。 [所以说系统调用才是考虑安全性(保护模式),区分用户和内核线程是出于效率考虑。]
    • --> 内核级线程:切换由内核控制,切换时,由用户态转化为内核态,切换完毕时再换回来。优点是可以很好的利用多核cpu
      • 在有内核支持的系统内,CPU调度以线程为单位。 --> 当有多个处理机时,多个线程可以同时执行。   [用户级线程不可]
      • 缺点是上下文切换带来的效率降低。
    • --> 用户级线程:切换不需要内核的干涉,少了进出内核态的消耗,但不能很好的利用多核cpu。 [利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。在语言层面处理,OS不可感知]
      • 关于为什么用户级线程不能利用多核cpu:os内核不知道多线程的存在,因此一个线程阻塞将使得整个进程(其内所有线程)阻塞。而由于处理器时间片分配是以进程为基本单位的,所以每个线程实际执行的时间相对减少。
      • --> 同一个进程中只有一个线程在运行,如果有一个线程使用了系统调用而阻塞,那么整个进程都会被挂起。
      • --> 个人觉得根源就在于,对内核线程是以线程为单位进行调度的,而对用户线程,调度是以进程为单位的。
      • 优点:
        • 线程调度不需内核直接参与,控制简单;创建销毁切换代价低;运行每个进程定制自己的调度算法,线程管理比较灵活。
      • 实现:用户线程运行在一个中间系统上面。目前中间系统实现的方式有两种:
        • 运行时系统(Runtime System): 实质上是用于管理和控制线程的函数集合,这些函数都驻留在用户空间作为用户线程和内核之间的接口。用户线程不能使用系统调用,而是当线程需要系统资源时,将请求传送给运行时,由后者通过相应的系统调用来获取系统资源。
        • 内核控制线程:系统在分给进程几个轻型进程(LWP),LWP可以通过系统调用来获得内核提供的服务,而进程中的用户线程可通过复用来关联到LWP,从而得到内核的服务。
  • 关于什么是轻量级线程
    • 先看wiki or 中文wiki
    • 轻量级线程LWP 是一种实现多任务的方法(在本文下面介绍fork新建线程的时候有提到)
    • 与普通进程相比,LWP与其他进程共享所有or大部分的逻辑地址空间和系统资源

 线程的实现模型

  • 用户线程对应内核线程:(x:y)即x个用户线程对应y个内核调度实体(Kernel Scheduling Entity,这个是内核分配CPU的对象单位)
  • 多对一(用户级线程)模型
      • 该模型中,线程的创建、调度、同步的所有细节全部由进程的用户空间线程库来处理。
      • 用户态线程的很多操作对内核来说都是透明的,因为不需要内核来接管,这意味不需要内核态和用户态频繁切换。线程的创建、调度、同步处理速度非常快。
      • 当然线程的一些其他操作还是要经过内核,如IO读写。
    问题
      • 当多线程并发执行时,如果其中一个线程执行IO操作时,内核接管这个操作,如果IO阻塞,用户态的其他线程都会被阻塞,因为这些线程都对应同一个内核调度实体。(如果一个线程执行了阻塞系统调用,那么整个进程会阻塞)
      • 在多处理器机器上,内核不知道用户态有这些线程,无法把它们调度到其他处理器,也无法通过优先级来调度。(任一时刻只有一个线程能访问内核,多个线程不能并行运行在多处理器上)
  • 一对一(内核级线程)模型
    • 一对一模型中,每个用户线程都对应各自的内核调度实体
    • 内核会对每个线程进行调度,可以调度到其他处理器上。
    • 问题:
      • 线程的每次操作会在用户态和内核态切换。  (一对一模型上,用户级线程基本就等于内核级线程了吧)
      • 内核为每个线程都映射调度实体,如果系统出现大量线程,会对系统性能有影响。但该模型的实用性还是高于多对一的线程模型。
  • 多对多(两级线程)模型
    • 每个线程可以拥有多个调度实体,也可以多个线程对应一个调度实体。
    • 设计上,会结合一对一和多对一的优点,避免它们的缺点。
    • 线程的调度就需要由内核态和用户态一起来实现。 --> 因此,肯定要一些其他的同步机制。用户态和内核态的分工合作导致实现该模型十分复杂。

线程Others

  • 进程描述符:
    • 任务队列(task list):进程的列表,一个双向循环链表。
    • 链表中存的就是进程描述符(process descriptor),类型为task_struct
    • 进程描述符中包含的数据能完整地描述一个正在执行的程序:它打开的文件,进程的地址空间,挂起的信号,线程的状态...
  • 进程状态

    • 五种状态:
      • TASK_RUNNING(运行): 进程可执行 or 正在执行  or 在运行队列中等待执行。   它是进程在用户空间中执行的唯一可能状态。
      • TASK_INTERRUPTIBLE(可中断):进程正在睡眠(被阻塞),等待某些条件的达成。 一旦这些条件达成,内核就会把进程状态设置为运行。
      • TASK_UNINTERRUPTIBLE(不可中断):与可中断的不同在于,即使接收到信号也不会被唤醒或准备投入运行。
        注:这就是你在执行ps命令时,看到那些被标记为D状态而又不能被杀死的进程的原因。由于任务不响应信号,因此,你不可能给它发送SIGKILL信号。
      • __TASK_TRAXED:被其他进程跟踪的进程
      • __TASK_STOPPED(停止):进程停止执行,即进程没有也不能投入运行。

进程管理

进程创建

  • 用户空间调用 fork -->  内核系统调用 sys_fork.  -->  do_fork() 函数:
    1. alloc_pidmap: 分配一个新的PID
    2. 检查调试器是否在跟踪父进程(clone_flags)
    3. 调用copy_process: 传递堆栈、注册表、父进程、PID.  [每个进程都在内存有一片内存空间,包括栈、堆、全局静态区、文本常量区、程序代码区。fork的时候会复制父进程的所有]
    4. dup_task_struct: 分配一个新的task_struct 并将其进程的描述符复制到其内。
  • 程序调用exec  --> 进程清空自身的内存空间,并根据新的程序文件重建程序代码、文本常量、全局静态、堆和栈,并开始运行。
    • exec函数族提供了一个在进程中启动另一个程序执行的方法。
    • 它可以根据指定的文件名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段。
  • clone:“创建线程”
    • 这里为什么打引号呢?因为我们知道linux并不区分进程和线程(术语task任务)。
    • 但是我们可以这么认为:“linux的fork系统调用提供复制进程的传统功能,并采用clone提供创建线程的能力。”
    • 当调用clone时,它被传递一组标志,以决定在父任务和子任务之间发生多少共享。 [所以如果clone不进行特殊设置,跟fork功能是类似的。]
  • 优化:传统Unix中,创建的子进程复制父进程所有资源 --> 效率低,因为子进程需要拷贝父进程的整个地址空间。
    • 但是,子进程几乎不必读or修改父进程拷贝过来的地址空间。
    • 现代Unix提供了三种优化方式:
      • 写时复制(COW): 允许父子进程读相同的物理页
      • 轻量级进程:允许父子进程共享进程在内核的很多数据结构
      • vfork()系统调用:vfork创建的子进程与父进程共享数据段,而且由vfork()创建的子进程将先于父进程运行。
        • 由vfork创建出的子进程还会导致父进程挂起,直到子进程exit or execve才会唤起父进程。(因为子进程共享了父进程的所有地址,包括栈地址,直到子进程使用execve启动新的应用程序为止)

进程销毁

  • 进程销毁可以通过几个事件驱动:通过正常的进程结束、通过信号、通过exit函数调用。
  • 但是,不论进程如何退出,进程的结束都要借助对内核函数 do_exit 的调用。(进程终止后,需要通知内核以便内核释放进程所拥有的资源,包括内存、打开文件以及其他资源,如信号量。)
  • do_exit
    • 目的:将所有对当前进程的引用从OS删除。
    • 过程:
      1. 通过设置PF_EXITING标志来表明进程正在退出。(内核的其他方面会利用它来避免在进程被删除时还试图处理此进程)
      2. 将进程从它在其生命期间获取的资源分离开 --> 通过一系列系统调用实现:
        • exit_mm 删除内存页
        • exit_keys 释放线程会话和进程安全键
      3. 调用exit_notify执行一系列通知(eg: 告知父进程其子进程正在退出)
      4. 进程状态改为PF_DEAD
  • 父子进程:
    • 当父进程结束时,还未结束的子进程会成为孤儿进程,系统会把init进程(PID=1)的进程作为其父进程

进程切换

  • 又称为上下文切换:挂起当前在CPU上允许的线程,并恢复以前挂起的某个进程的执行。

进程调度

  • 调度是指os分配CPU给不同任务。
  • os进程调度要考虑的点很多
    • (调度)时间复杂度  [2.5版本内核的根本改变在于提高了一种以常量时间复杂度运行的调度算法]   --> 高效性
    • 在系统相当的负载下,也要报这个系统的响应时间    -->  加强交互性能
    • 对SMP的支持,包括处理器亲和性、负载均衡和维护任务的公平性。
  •  linux有两类不同的进程调度算法:
    • 一个是多进程中的公平抢占调度的分时算法
      • 在分时调度中,不同优先级的进程之间之间在一定程度上还是可以相互竞争的。
    • 另一个是为实时任务设计的,其中绝对优先级比公平更重要。
      • 在实时调度中,调度程序始终运行最高优先级的进程。
  • Linux调度算法是一种抢占式的、基于优先级的算法。
  • 基于上面的介绍,linux提供了两种优先级
    • 普通的进程优先级   [100~140]
    • 实时优先级   [任何时候,实时进程的优先级都高于普通进程,实时进程只会被更高级的实时进程抢占。  (这就实现了上述两种类型的进程调度算法)]   [范围是0~99]
  • linux的优先级也分为:静态优先级 & 动态优先级
    • 静态优先级:实时进程只有静态优先级
    • 动态优先级:具体去看下面分时调度那部分

 实时调度

  • Linux实时调度实现了两个实时调度类:FCFS和round-robin调度。
  • 上面提到了,实时调度始终运行具有最高优先级的进程。 [如果进程优先级相同,调度程序就运行等待时间最长的进程]
  • FCFS和round-robin的唯一区别就在于:FCFS进程持续运行直到退出or堵塞,而round-robin进程运行一定时间后被抢占,并且被放置在调度等待队列的最后。
  • 但是要注意的!上述两种调度类是针对相同优先级的进程而言,如果优先级为b的实时进程B运行过程中,来了一个优先级大于b的进程A,这时系统将中断B的执行,而优先执行B。
  • -->  由此看出,对实时进程而言,高优先级的进程就是大爷

分时调度

  • linux对普通的进程,根据动态优先级进行调度。 [动态优先级是由静态优先级调整而来的。]
  • 但linux下,静态优先级是对用户不可见的,隐藏在内核中。内核提供给用户一个可以影响优先级的接口,就是nice值
  • -->  意义是:系统调度时,还会考虑其他因素,因而最终会计算出进程“动态优先级”,据此来实施调度。
    • 考虑进程的属性:对交互式进程,可以适当提高其优先级。  [linux2.6认为,交互式进程可以从平均睡眠时间来判断:进程过去的睡眠时间越多,则越有可能属于交互式进程,则系统调度时,会给该线程更多的bonus。]
  •  系统会严格按照动态优先级高低的顺序来安排进程执行。动态优先级高的进程进入非运行状态or时间片消耗完后才会轮到动态优先级较低的进程执行。   [此外,实时进程总是在普通进程之前被调度。]
  • 但是!系统对普通进程不能简单的只看优先级,还要考虑公平性,否则很容易出现进程饥饿。
    • 这就出现了CFS调度器Completely Fair Scheduler。 [从linux2.6开始,内核使用CFS作为默认调度器]
  • 以上,其实linux调度器是将进程分为三类:交互式进程 & 批处理进程 & 实时进程
    • 对实时进程,用的是实时调度来保证低延迟
    • 对交互式进程,则通过动态优先级来提高其优先级
    • 交互式和批处理同属于普通进程,使用的是CFS和RSDL等新的调度器,这类调度器的核心思想是“完全公平”。

Others

  • 调度程序的效率十分重要, 如果效率不高就会浪费很多CPU时间,导致系统性能下降。
    • 版本变迁:
      • linux 2.4中,可执行状态的进程被挂在一个链表中。每次调度,调度程序需要扫描整个链表,以找出最优的那个进程来运行。 O(N) time
      • linux 2.6早期,可执行状态的进程被挂在N(N=140)个链表中,每一个链表代表一个优先级。每次调度,调度程序只需要从第一个不为空的链表中取出位于链表头的进程即可。O(1) time
      • linux 2.6近期,可执行状态的进程按照优先级顺序被挂在一个红黑树(可以想象成平衡二叉树)中。每次调度,调度程序需要从树中找出优先级最高的进程。O(logN) time
      • -->  这里比较神奇的是,为什么之后又会选择更高时间复杂度的红黑树呢? 看网上资料是说:主要是为了使用红黑树来减小选择下一个进程,以及插入一个进程的开销。
  • 调度触发的时机
    • 当前进程状态变为非可执行状态:如睡眠、exit、挂起在io上等
    • 抢占:即进程运行时,非预期地被剥夺CPU的使用权。比如出现了优先级更高的进程。
  • 内核抢占:理想情况下,我们说只要出现更高优先级的进程,当前进程就该立即被抢占。  
    • 但实际上,内核也存在临界区,不能随时随地接收抢占。 [实际上也就是内核同步的问题]
      • 比如说,一个内核任务正在访问某一数据结构,此时被抢占而执行一个中断服务程序,那么该服务程序就不能访问或修改相同的数据。
    • 版本变迁:
      • linux2.4的设计:内核不支持抢占。即进程允许在内核态时是不允许抢占的,必须等待返回用户态才会触发调度(确切的说,是在返回用户态之前,内核会专门检查下是否需要调度)。
      • linux2.6:实现了内核抢占,但在很多地方还是为了保护临界区资源而需要临时性的禁用内核抢占。
        • 同时,该版本下Linux内核提供了自旋锁或信号量来在内核中加锁。
  • 多处理器下的负载均衡
    • 因为多个处理器之间并不会共用可执行队列  
      • 因为如果共享的话,每个CPU在执行调度程序时都需要锁定队列,不能并行,会影响性能。
      • 同时,多个可执行队列可以使得一个进程在一段时间内总是在同一个CPU上执行,那么很可能这个CPU的各级缓存中都存在该进程的数据,有利于系统性能的提升。
    • 但这又引出了“多处理器负载均衡”这个问题了:内核需要关注各个CPU可执行队列中的进程数目,在数目不均衡时做出适当调整。

 

进程同步

  • 现代os中,同一时间可能有多个内核执行流在执行,因此内核其实像多线程编程一样也需要一些同步机制来同步各执行单元对共享数据的访问。尤其在多处理器系统上,更需要一些同步机制来同步不同处理器上的执行单元对共享数据的访问。
  • 主流linux内核中包含了几乎所有现代os所具有的同步机制,包括:原子操作、信号量semaphore、读写信号量rw_semaphore、自旋锁spinlock、BKL(Big Kernel Lock)、rwlock、brlock、RCU、seqlock。

原子操作

  • 所谓原子操作,就是该操作绝不会在执行完毕前被任何其他任务or事件打断,也即,它是最小的执行单元
  • 原子操作需要硬件的支持。(因此是架构相关的,其API和原子类型的定义都定义在内核源码树的include/asm/atomic.h文件中,由汇编语言实现。)
    • 原子定义如下:
      • typedef struct { volatile int counter; } atomic_t;
      • volatile修饰符告诉gcc不要对该类型的数据做优化处理,对它的访问都是对内存的访问,而不是对寄存器的访问
    • 原子操作API包括
      • 读:对原子类型的变量进行原子读操作
      • 加减法...
  • 原子操作主要用于实现资源计数,很多引用计数(refcnt)就是通过原子操作实现的。

信号量Semaphore

  • (linux内核的信号量在概念和原理上与用户态的System V的IPC机制信号量是一样的,但它绝不可能在内核之外使用,因此它与System V的IPC机制信号量毫不相干。)
  • 信号量在创建时需要设置一个初始值,表示同时有几个任务可以访问该信号量保护的共享资源,初始值为1则变为互斥锁Mutex
  • 若无法获取信号量,该任务必须挂起在该信号量的等待队列,等待该信号量可用。

实现

  • 数据结构
    • struct semaphore {
        atomic_t count;
        int sleppers;
        wait_queue_head_t wait;
      }
    • count是共享计数值,是一个原子变量
    • sleepers是等待当前信号量进入睡眠的进程的个数;
    • wait是当前信号的等待队列
  • API
    • static inline void down(struct sempahore *sem)  // 获取信号量,获取失败则进入睡眠状态
    • static inline void up(struct semaphore *sem)   // 释放信号量,并唤醒等待状态中的第一个进程
  • 使用
    • down(sem)
      ... critical section ...
      up(sem)
  • 内核保证正在访问临界区的进程数 <= 初始化的共享计数值,获取信号量失败的进程进入不可中断的睡眠状态,在信号量的等待队列中进行等待。

读写信号量 rw_semaphore

  • 读写信号量对访问者进行了细分,分为读者和写者。(写者是排他&独占的。)
  • 有两种实现:
    • 通用的不依赖于硬件架构的实现:性能低,获得和释放读写信号量的开销大
    • 架构相关的实现:需要增加新的架构实现。
  • 适合读多写少的情况。

自旋锁spinlock

  • 功能上类似互斥锁
  • 自旋锁不会导致调用者睡眠,若自旋锁已经被别的执行单元保持,那么调用者会一直循环在那里看该自旋锁是否已被释放。
  • 由于自旋锁的使用者一般保持锁的时间非常短,因此选择自旋而不是睡眠能大大提高效率。 

进程通信IPC

  • 最初的Unix IPC包括:管道、FIFO(命名管道)、信号
  • System V IPC包括:System V 消息队列、System V 信号灯、System V 共享内存区。 (System V是Unix os众多版本中的一支)
    • 提供对Unix早期的进程间通信手段进行了系统的改进和扩充,形成了"System V IPC"
    • 早期Unix IPC中管道和信号都是随着进程持续而存在(IPC一直存在直到打开IPC对象的最后一个进程关闭该对象为止),如果进程结束了,管道和信号都会关闭or丢失。
    • 而System V IPC的通信机制的特点是:它是对着内核的持续而存在(IPC一支持续到内核重启or显式删除该对象为止)
  • Posix IPC包括:Posix消息队列、Posix信号灯、Posix共享内存区。

Unix IPC

信号Signal

  • 信号是在软件层次上对硬件的中断机制的一种模拟在原理上,一个进程接收到一个信号和一个处理器接收到中断请求可以说是一样。(全称:软中断信号)
  • 信号是异步的,一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号什么时候到达。
  • 信号是进程间通信机制中唯一的异步通信机制,可以看做是异步通知。
  • 信号机制通过POSIX实时扩展后,功能更加强大,除了基本通知外,还可以传递附加信息。
    • 这里就是用于IPC,但是可以看到传递的信息比较粗糙,只是一个整数。
    • 但正是由于传递的信息量少,信号也便于管理和使用,可以用于系统管理相关的任务,例如通知进程终结、终止or恢复等。
  • 信号事件的来源:信号由内核管理,要么是由内核直接产生,也可以由其他进程产生并发送给内核,再由内核传递给目标进程。
    • 硬件来源:比如按下键盘or其他硬件故障
      • 比如在terminal上按下组合键ctrl+c,产生SIGINT信号
      • 硬件异常:CPU检测到内存非法访问等异常,通知内核生成相应信号,并发送给发送时间的进程。
    • 软件来源:最常用发送信号的系统函数是kill,raise,alarm和signal函数。除此之外,还包括一些非法运算等操作。
      • java: 使用Procees.sendSignal()等
  • 一些信号例子
    • 与进程终止有关的信号:当进程退出,或者子进程终止时,发出这类信号。
    • 与进程例外事件相关的信号:如进程越界,or企图写一个只读信号的内存区域(如程序正文区),or执行一个特权指令及其他硬件错误。
    • 与在系统调用期间遇到不可恢复条件相关的信号:如执行系统调用exec时,原有资源已经释放,而目前系统资源又已经耗尽。
    • 执行系统调用时遇到非预测错误条件相关的信号: 如执行一个并不存在的系统调用。
    • 在用户态下的进程发出的信号: 如进程调用系统调用kill向其他进程发送信号。
    • 与终端交互相关的信号: 如用户关闭一个终端,或按下break键等情况。
    • 跟踪进程执行的信号
  • 内核对信号的基本处理方法
    • 内核给一个进程发送软中断信号的方法,是在进程所在的进程表项的信号域设置对应于该信号的位。
    • 若信号发送给一个正在睡眠的进程,那么要看该进程进入睡眠的优先级。若进程睡眠在可被中断的优先级上,则唤醒进程;否则仅设置进程表中信号域相应的位,而不唤醒进程。(这里其实就把信号当成硬件中断一样对待。)
    • 进程处理一个信号收到的时机是在一个进程从内核态返回用户态时。所以当一个进程处于内核态下运行时,信号并不立即起作用。进程只有在处理完信号才会返回用户态,进程在用户态下不会有未处理完的信号。

信号处理

  • 处理方式,进程可以通过三种方式来响应一个信号:
    • 忽略信号SIG_IGN。有两个不可忽略信号:SIGKILL和SIGSTOP
    • 捕捉信号:定义信号处理函数,当信号发生时,执行相应的处理函数
    • 执行缺省操作SIG_DFL。linux对每种信号都规定了默认操作。
  • 处理时机:内核态 -> signal信号处理 -> 用户态
    • 在内核态,signal信号不起作用
    • 在用户态,signal所有未被屏蔽的信号都被处理完毕

管道Pipe

// TODO: https://segmentfault.com/a/1190000009528245

  • 管道和命名管道,允许进程间交换更多的数据。
  • 管道指的从一个进程连接数据流到另一个进程,它有一下特点:
    • 管道是半双工的,数据只能向一个方向流动。需要双方通信时,需要建立两个管道
    • 只能用于父子进程or兄弟进程之间(具有亲缘关系的进程)  --> 下面命名管道会讲到,这是因为普通的管道没有名字。
    • 单独构成一种独立的文件系统:管道对于管道两端的进程而言,就是一个文件,但它不是普通的文件,它不属于某种文件系统,并且只存在于内存中。
    • 数据的读出和写入:一个进程向管道中写的内容被管道另一端的进程读出。写入在管道缓冲区末尾,读出在缓冲区的头部。

命名管道FIFO

  • 管道应用的一个重大限制是它没有名字,因此,只能用于具有亲缘关系的进程间通信。
  • FIFO不同于管道之处就在于它提供一个路径名与之关联,以FIFO的文件形式存在于文件系统中。
  • -->  这样,即使与FIFO的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能提供FIFO相互通信。
  • FIFO严格遵循first in first out。

System V IPC

  • System V IPC的通信都是基于内核来实现的。

消息队列

  • 消息队列就是一个消息的链表。可以把消息看作一个记录,具有特定的格式以及特定的优先级,
  • 消息队列是随内核持续的,记录消息队列的数据结构位于内核中,只有在内核重启or显式删除一个消息队列时,该消息队列才会被真正删除。
  • 消息队列结构:
    • struct list_head 类型的消息队列
    • 当前处于阻塞状态的消息接受者和发送者

信号灯

  • 信号灯与其他IPC不太相同,它主要提供对进程间共享资源的访问控制机制。
  • 相当于内存中的标志,进程可以根据它来判断是否能够访问某些共享资源。
  • 进程可以修改该标志
  • 信号灯除了用于访问控制之外,还可以用于进程同步
  • 两种类型
    • 二值信号灯:类似于互斥锁,但两者的关注内容不同。信号灯强调资源共享,只要共享资源可用,其他进程照样可以修改信号灯的值。而互斥锁必须由进程本身来解锁。
    • 计算信号灯

共享内存

  • 共享内存是最快的IPC方式
  • 两个不同进程A、B共享内存的意思是:同一块物理内存被映射到进程A、B格子的进程地址空间。
  • 进程A、B都可以即时看到对方对共享内存的更新。
  • 但由于多个进程共享同一块内存区,必然需要某种同步机制(信号量就可以)。
  • 效率高:不需要任何数据的拷贝。而管道和消息队列等需要在内核和用户空间进行四次数据拷贝。而共享内存只需要两次(输入文件到共享内存区,从共享内存区到输出文件)。

协程

  • 协程被称为“轻量级线程”或者“用户态线程”。
  • 协程的切换:协程的切换是通过保存旧协程的上下文和替换新协程的上下文来实现的。
    • 在Libtask库中,保存协程上下文通过getcontext()实现  【把当前寄存器的值保存到参数ctx中】
    • 替换协程上下文是通过setcontext()实现。这两个函数都是使用汇编语言实现的。 
  • 优点:
    • 从函数角度看:协程避免了传统的函数调用栈,几乎可以无限递归。
    • 从线程的角度看:
      • 协程没有上下文切换   [不需要内核态的参与]   --> 协程始终运行在一个线程之内,完全没有上下文切换,以为它的上下文是维护在用户态开辟的一块内存里,而它的任务调度是在代码里显式处理的。
      • 协程在用户态显示的任务调度,可以把异步操作转化为同步操作,也意味着无需额外的加锁
  • 缺点:
    • 无法利用多核资源:本质是个单线程
    • 进行blocking操作时会阻塞掉整个程序
  • 任务调度
    • 进程和线程的任务调度由内核控制,是抢占式的;而协程的任务调度在用户态完成,需要在代码里显式的把CPU交给其他协程,是协作式的。   【也减少了很多无谓的上下文切换】
    • -->  由于我们可以再用户态调度协程任务,所以可以把一组互相依赖的任务设计成协程。这样当一个协程任务完成之后,可以手动进行任务调度,把自己挂起(yield),切换到另一个协程执行。
    • -->  这样,由于我们可以控制程序主动让出资源,很多情况下将不需要对资源加锁。