进程/线程/协程

259 阅读46分钟

参考

  1. (46条消息) 进程、线程和协程之间的区别和联系_进程和协程区别_青萍之末的博客-CSDN博客
  2. (46条消息) 进程切换开销大的原因_进程切换为什么开销大_68lizi的博客-CSDN博客
  3. (46条消息) cpu上下文切换对性能的影响(实战)_vmstat cs很高_恐龙弟旺仔的博客-CSDN博客
  4. 操作系统常见面试题(2021最新版) - 知乎 (zhihu.com)
  5. 公众号:程序员贺先生——C++八股
  6. (46条消息) (三)Linux进程、fork、wait、exec函数_菠萝柚王子的博客-CSDN博客
  7. 孤儿进程与僵尸进程[总结] - Rabbit_Dale - 博客园 (cnblogs.com)
  8. Linux下Fork与Exec使用 - Jessica程序猿 - 博客园 (cnblogs.com)
  9. [内核同步]浅析Linux内核同步机制 - aaronGao - 博客园 (cnblogs.com)
  10. (43条消息) Linux中的同步机制_linux同步机制有哪些_CallMeRiaan的博客-CSDN博客
  11. Linux线程间同步的几种方式 - 知乎 (zhihu.com)
  12. Linux下Fork与Exec使用 - Jessica程序猿 - 博客园 (cnblogs.com)
  • 怎么申请共享内存
  • 有没有研究过线程池和一些数据库查询调优手段

1. 进程和线程

1.0 进程控制块PCB

PCB是一种用来进程管理、控制信息的数据结构,被称为进程控制块。 每一个进程均有一个PCB,在创建进程时,建立PCB,伴随进程运行的全过程,直到进程撤消而撤消。

1.1 线程、进程、协程区别

  • 对操作系统来说,线程是最小的执行单元,进程是最小的资源管理单元。

  • 一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。

进程,直观点说,保存在硬盘上的程序运行以后,会在内存空间里形成一个独立的内存体,这个内存体有自己独立的地址空间,有自己的堆,上级挂靠单位是操作系统。操作系统会以进程为单位,分配系统资源(CPU时间片、内存等资源),进程是资源分配的最小单位。

  • 协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

  • 在单个线程中,协程一定是串行执行的,不可能存在一个线程同时在执行多个协程的情况。 进程间不会相互影响,但一个线程挂掉将导致整个进程挂掉。

  • 一个进程包括多个线程,且当前进程下的线程共用一个内存空间,进程使用的内存地址可以上锁,就是一个线程使用某些共享内存时,其它线程必须等它结束,才能使用这一块内存。即互斥锁 1678714489360.png 1678714433670.png

1.2 进程、线程、协程切换开销

进程切换:
1、切换页表以使用新的地址空间,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。
2、切换内核栈和硬件上下文。

进程的保护现场:系统要保留有关被切换进程的足够信息,以便以后切换回该进程时,顺利恢复该进程的执行。在系统保留了CPU现场之后,调度程序选择一个新的处于就绪状态的进程、并装配该进程的上下文,使CPU的控制权掌握在被选中进程手中。

上下文资源包括:
上下文资源包括:用户空间数据(虚拟内存、栈、全局变量)和内核空间数据(内核堆栈、寄存器)

(1)具体操作:

  • 保存处理器的上下文,包括程序计数器和其它寄存器
  • 用新状态和其它相关信息更新正在运行进程的PCB
  • 把原来的进程移至合适的队列-就绪、阻塞
  • 选择另一个要执行的进程
  • 更新被选中进程的PCB
  • 从被选中进程中重装入CPU 上下文

线程切换:
切换内核栈和硬件上下文。

一个处理器都只会执行一条线程中的指令,为了让线程切换后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器,进行线程上下文切换时记录程序计数器、CPU寄存器状态(记录有挂起变量的值)等数据即可。

线程切换开销的解决方案:通过线程池或协程来解决。

协程切换:
前面也说了协程不过是一段子程序(其实也就是个函数)罢了,因此只要保存下当前的函数栈状态、寄存器值,就可以描述出这个协程的全部状态,这个结构被称为协程上下文。

切换步骤:

  1. 保存当前寄存器的值到协程上下文中(的regs数组);
  2. 将新协程上下文中的寄存器值(regs数组)取出来赋值给对应的寄存器

关于切换协程变量值的保存:
程序运行的时候其实就是在当前栈空间操作,保存了寄存器,就相当于保存了栈空间。 栈变量直接存放在栈空间上,直接寻址就可访问。堆变量会有指针来指向,指针本身是栈变量,也存放在栈上。通过间接寻址即可访问到堆变量。

1.3 线程切换和进程切换区别——虚拟内存:

  • 进程切换方式:切换虚拟地址空间,切换内核栈和硬件上下文
    线程切换方式:切换内核栈和硬件上下文

  • 虚拟地址空间的切换:
    程切换涉及到虚拟地址空间的切换而线程切换则不会,因为每个进程都有自己的虚拟地址空间,而线程是共享所在进程的虚拟地址空间的,因此同一个进程中的线程进行线程切换时不涉及虚拟地址空间的转换。
    进程切换会切换页表,以使用新的地址空间,虚拟内存和物理内存会进行一一对应数据存放,页表(虚拟内存)可以将虚拟地址转换为物理内存地址,从而能够通过页表查找到虚拟地址空间的中的某一数据在物理内存的具体位置。

  • 页表切换的开销:
    通常使用TLB Cache来进行缓存常用的地址映射,用来加速页表查找,当进程切换后,页表页要进行切换,页表切换后TLB就会失效,Cache失效导致查找命中率降低,也就是虚拟地址转换为物理地址就会变慢,程序中其他进程的执行就变慢,表现出来程序运行会慢

为什么虚拟地址空间切换会比较耗时呢? 进程都有自己的虚拟地址空间,把虚拟地址转换为物理地址需要查找页表,页表查找是一个很慢的过程,因此通常使用 Cache 来缓存常用的地址映射,这样可以加速页表查找,这个 Cache 就是 TLB(translation Lookaside Buffer, TLB 本质上就是一个 Cache,是用来加速页表查找的)。

由于每个进程都有自己的虚拟地址空间,那么显然每个进程都有自己的页表, 那么当进程切换后页表也要进行切换,页表切换后 TLB 就失效了,Cache 失效导致命中率降低,那么虚拟地址转换为物理地址就会变慢,表现出来的就是程序运行会变慢,而线程切换则不会导致 TLB 失效,因为线程无需切换地址空间,因此我们通常说线程切换要比较进程切换块,原因就在这里。

虚拟内存:
虚拟内存是为了将物理内存扩充为更大的逻辑内存。为了更方便地管理内存,操作系统将内存抽象成地址空间,每个程序有自己对应地地址空间。地址空间被分为很多块,一块被称为一页 虚拟内存是操作系统为每个进程提供的一种抽象,每个进程都有属于自己的、私有的、地址连续的虚拟内存,当然我们知道最终进程的数据及代码必然要放到物理内存上,那么必须有某种机制能记住虚拟地址空间中的某个数据被放到了哪个物理内存地址上,这就是所谓的地址空间映射,也就是虚拟内存地址与物理内存地址的映射关系,那么操作系统是如何记住这种映射关系的呢,答案就是页表,页表中记录了虚拟内存地址到物理内存地址的映射关系。有了页表就可以将虚拟地址转换为物理内存地址了,这种机制就是虚拟内存。

每个进程都有自己的虚拟地址空间,进程内的所有线程共享进程的虚拟地址空间。

1.4 CPU频繁切换进程, 性能损失主要是因为什么:

CPU要想切换一个进程执行,就需要将当前正在执行的进程任务所对应的上下文资源保存下来,等待后续CPU调度(如果不保存,等下次CPU再次调度该任务时,就不知道从哪里开始执行了)。

这些上下文资源包括:用户空间数据(虚拟内存、栈、全局变量)和内核空间数据(内核堆栈、寄存器)

所以当CPU频繁的把工作都浪费在这些上下文资源的保存、加载上,那么真正投入到任务执行中的时间就少很多了。

至于进程切换开销为什么较大,简单理解是因为进程切换要保存的现场太多如寄存器,栈,代码段,执行位置等,而线程切换只需要上下文切换,保存线程执行的上下文即可。线程的的切换只需要保存线程的执行现场(程序计数器等状态)保存在该线程的栈里,CPU把栈指针,指令寄存器的值指向下一个线程。相比之下线程更加轻量级。

可以说进程面向的主要内容是内存分配管理,而线程主要面向的CPU调度。

1.7 操作系统如何保证每个进程有独立的虚拟空间,

操作系统通过使用虚拟内存机制来保证每个进程具有独立的内存空间。虚拟内存是一种抽象的内存概念,它为每个进程提供了一个独立的地址空间,使得每个进程可以认为它独占整个系统的物理内存。 通过虚拟内存机制,操作系统能够为每个进程提供独立的内存空间,无论是代码、数据还是堆栈,每个进程都认为自己独占系统的整个内存空间。这种内存隔离保证了每个进程的数据安全和保密性,并且允许操作系统有效地管理和保护进程间的内存使用。虚拟内存是一种抽象的内存概念,它为每个进程提供了一个独立的地址空间,使得每个进程可以认为它独占整个系统的物理内存。

步骤:

  • 内存分页:操作系统将物理内存划分为固定大小的页面(通常为4KB)。进程的地址空间同样被划分为页。这样,每个进程可以使用虚拟地址访问内存,而不必关心真正的物理地址。
  • 页表映射:操作系统为每个进程维护一个页表,其中记录了虚拟地址和物理地址之间的映射关系。当进程发出一个内存访问请求时,操作系统会根据页表将虚拟地址转换为相应的物理地址,从而将数据加载到正确的物理内存页面。
  • 内存保护:每个进程的页表中还包括一些额外的标志,用于控制进程对内存的访问权限。这些标志可以分别指定页面是否可读、可写和可执行,从而保护进程的内存空间不受其他进程的非法访问或篡改。
  • 上下文切换:当操作系统切换到一个新的进程时,它会保存当前进程的页表以及其他的上下文信息,并加载下一个进程的页表。这样,每个进程在运行时拥有自己独立的虚拟地址空间,与其他进程的内存空间相隔离。

1.7.1 进程之间的物理空间是独立的吗?

虚拟内存机制保证每个进程具有独立的虚拟内存空间,那实际的物理空间是相对独立的吗? 不是,例子,父子进程写时复制技术

1.7.2 线程有自己的堆区,栈区、代码区和数据区空间码,还是不同的线程都共享进程资源

线程在某些方面与进程共享资源,但在其他方面也有自己的独立空间。具体来说:

  • 共享相同进程的代码区(也称为文本区)和数据区。

    这意味着它们执行相同的程序代码,并且可以访问相同的全局变量和静态变量。这些共享的资源存储在进程的地址空间中,不会为每个线程单独分配。

  • 每个线程都有自己独立栈区和堆区内存空间

    堆区用于动态内存分配,每个线程可以在堆上分配和释放内存; 栈区用于存储局部变量和函数调用信息,

1.8 虚拟内存

每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过内存交换技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)

虚拟地址与物理地址的映射关系
可以有分段和分页的方式,同时两者结合都是可以的。 内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致外部内存碎片和内存交换效率低的问题。

于是,就出现了内存分页,把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 4KB。由于分了页后,就不会产生细小的内存碎片,解决了内存分段的外部内存碎片问题。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率

虚拟内存的作用:

  1. 进程对于运行内存超过物理内存的大小,因为局部性原理,CPU访问内存会有明显重复倾向,所以对于没有被经常使用到的内存,可以换出到物理内存之外,比如硬盘的swap区域。
  2. 保证每个内存的虚拟内存空间是相互独立的,由于每个进程都有自己的页表,页表是私有的并且进程也没有办法访问其他进程的页表,解决了多进程之间地址冲突的问题。
  3. 在内存访问方面,操作系统提供了更好的安全性。页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。

1.5 临界区的保护——解决冲突?

每个进程中访问临界资源的那段程序称为临界区,一次仅允许一个进程使用的资源称为临界资源。临界资源表示一种公共资源或共享数据,可以被多个线程使用。但是每一次只能有一个线程使用它。一旦临界区资源被占用,想使用该资源的其他线程必须等待。

解决冲突的办法:

  • 如果有若干进程要求进入空闲的临界区,一次仅允许一个进程进入,如已有进程进入自己的临界区,则其它所有试图进入临界区的进程必须等待;
  • 进入临界区的进程要在有限时间内退出。
  • 如果进程不能进入自己的临界区,则应让出CPU,避免进程出现“忙等”现象。

1.6 进程和线程痛点——生产者/消费者模式:

考虑情景:若干个生产者线程向队列中写入数据,若干个消费者线程从队列中消费数据。
生产者类循环100次,向同步队列当中插入数据。消费者循环监听同步队列,当队列有数据时拉取数据。如果队列满了(达到5个元素),生产者阻塞。如果队列空了,消费者阻塞。

存在问题:并非高性能实现

  1. 同步锁
  2. 线程阻塞状态和可运行状态之间切换
  3. 涉及到线程上下文的切换

image.png

1.9 什么时候多进程,什么时候多线程

多进程适合CPU密集型任务,如机器学习:
因为每个进程都有自己独立的内存空间,这意味着每个进程可以拥有自己的数据和变量,不需要考虑线程之间的数据同步和互斥问题。对于CPU密集型任务,可能会生成大量的中间数据和计算结果,这些数据需要占用内存,多进程可以更好地管理这些数据。

多进程适合I/O密集型任务,如文件读写、网络通信: 因为在I/O操作器件,CPU通常处于等待状态,多线程可以允许同时处理多个IO操作,从而提高系统的响应速度不会浪费CPU资源。因为它们可以更好地利用CPU空闲时间,提高并行性,减少资源消耗

1.10 进行有哪些状态以及状态之间的切换

1698686515484.png

进程控制

进程概述

Linux系统是一个多进程的系统,它的进程之间具有并行性、互不干扰等特点。每个进程都是一个独立的运行单位,拥有各自的权利和责任。其中,各个进程都运行在独立的虚拟地址空间,因此,即使一个进程发生异常,它也不会影响到系统中的其他进程。是程序的一次执行的过程资源分配的最小单元

进程的状态:

  • 执行态:该进程正在运行,即进程正在占用CPU。
  • 就绪态:进程已经具备执行的一切条件,正在等待分配CPU的处理时间片。
  • 等待态:进程不能使用CPU,若等待事件发生(等待的资源分配到)则可将其唤醒。 image.png

系统通过进程控制块PCB来管理进程,PCB包含: 。。。。。。。。。。

进程的执行,可以看作是在它的上下文中执行.一个进程的上下文(context)由三部分组成:

  • 用户级上下文:正文,数据,用户栈和共享存储区
  • 寄存器上下文:非常重要的程序计数器(传说中的)PC,还有栈指针和通用寄存器等
  • 系统级上下文:系统级上下文分静态和动态,PCB中进程表项, U区,还有本进程的表项,页表,系统区表项等都属于静态部分,而核心栈等则属于动态部分.

Linux下进程管理命令

image.png

Linux下进程创建

fock创建子进程

在Linux中创建一个新进程的方法是使用fork()函数。fork()函数用于从已存在的进程中创建一个新进程。新进程称为子进程,而原进程称为父进程。子进程是父进程的副本,它将获得父进程数据空间、堆、栈等资源的副本。它们再要交互信息时,只有通过进程间通信来实现,
两个进程分别获得其所属fork()的返回值,其中在父进程中的返回值是子进程的进程号,而在子进程中返回0。 image.png getpid()返回进程的id号,发现子进程号是父进程号+1,

原来的进程中父进程先结束,子进程变成孤儿进程,但linux不允许孤儿进程出现,它的父进程会变成pid为1的进程,就直接返回终端。

注意,在fork()的调用处,整个父进程空间会原模原样地复制到子进程中,包括指令,变量值,程序调用栈,环境变量,缓冲区,等等。 子进程持有的是上述存储空间的“副本”,这意味着父子进程间不共享这些存储空间,它们之间共享的存储空间只有代码段。

wait(0);

作用是让子进程结束,父进程再结束,防止子进程变成孤儿进程。 父进程等待子进程结束,并销毁子进程,如果父进程不调用wait函数,子进程就会一直留在linux内核中,变成了僵尸进程。 调⽤了 wait 的⽗进程将会发⽣阻塞,直到 有⼦进程状态改变,执⾏成功返回 0,错误返回 -1。

exec

exec是将本进程的映像给替换掉了 exec函数族就提供了一个在进程中启动另一个程序执行的方法。 函数族exec( )用来启动另外的进程以取代当前运行的进程。 它可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段,在执行完之后,原调用进程的内容除了进程号外,其他全部被新的进程替换了。 exec 执⾏成功则⼦进程从新的程序开始运⾏,⽆返回值,执⾏失败返回 -1。
一个进程一旦调用exec类函数,它本身就"死亡"了,系统把代码段替换成新的程序的代码,废弃原有的数据段和堆栈段,并为新程序分配新的数据段与堆栈段,唯一留下的,就是进程号,也就是说,对系统而言,还是同一个进程,不过已经是另一个程序了

如果一个大程序在运行中,它的数据段和堆栈都很大,一次fork就要复制一次,那么fork的系统开销不是很大吗?

其实UNIX自有其解决的办法,大家知道,一般CPU都是以"页"为单位来分配内存空间的,每一个页都是实际物理内存的一个映像,象INTEL的CPU,其一页在通常情况下是 4086字节大小,而无论是数据段还是堆栈段都是由许多"页"构成的,fork函数复制这两个段,只是"逻辑"上的,并非"物理"上的,也就是说,实际执行fork时,物理空间上两个进程的数据段和堆栈段都还是共享着的,当有一个进程写了某个数据时,这时两个进程之间的数据才有了区别,系统就将有区别的" 页"从物理上也分开。系统在空间上的开销就可以达到最小。

孤儿进程和僵尸进程

我们知道在unix/linux中,正常情况下,子进程是通过父进程创建的,子进程在创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程 到底什么时候结束。 当一个 进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。

  孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

  僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。

  • 孤儿进程危害:孤儿进程是没有父进程的进程,孤儿进程这个重任就落到了init进程身上,init进程就好像是一个民政局,专门负责处理孤儿进程的善后工作。每当出现一个孤儿进程的时候,内核就把孤 儿进程的父进程设置为init,而init进程会循环地wait()它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init进程就会代表党和政府出面处理它的一切善后工作。因此孤儿进程并不会有什么危害。

  • 僵尸进程危害:unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到。这种机制就是: 在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。 但是仍然为其保留一定的信息(包括进程号the process ID,退出状态the termination status of the process,运行时间the amount of CPU time taken by the process等)。直到父进程通过wait / waitpid来取时才释放。 但这样就导致了问题,如果进程不调用wait / waitpid的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。

    任何一个子进程(init除外)在exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。 这是每个 子进程在结束时都要经过的阶段。如果子进程在exit()之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是“Z”。如果父进程能及时 处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。  如果父进程在子进程结束之前退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。

  • 僵尸进程危害场景及解决:
    例如有个进程,它定期的产 生一个子进程,这个子进程需要做的事情很少,做完它该做的事情之后就退出了,因此这个子进程的生命周期很短,但是,父进程只管生成新的子进程,至于子进程 退出之后的事情,则一概不闻不问,这样,系统运行上一段时间之后,系统中就会存在很多的僵死进程,倘若用ps命令查看的话,就会看到很多状态为Z的进程。 严格地来说,僵死进程并不是问题的根源,罪魁祸首是产生出大量僵死进程的那个父进程。因此,当我们寻求如何消灭系统中大量的僵死进程时,答案就是把产生大 量僵死进程的那个元凶枪毙掉(也就是通过kill发送SIGTERM或者SIGKILL信号啦)。枪毙了元凶进程之后,它产生的僵死进程就变成了孤儿进 程,这些孤儿进程会被init进程接管,init进程会wait()这些孤儿进程,释放它们占用的系统进程表中的资源,这样,这些已经僵死的孤儿进程 就能瞑目而去了。

线程太多的缺点

  1. 资源耗尽:每个线程都需要一定的系统资源,包括内存和线程栈。过多的线程可能导致系统资源耗尽,尤其是在资源受限的环境中。
  2. 性能下降:线程数目增加,上下文切换次数也会增加。上下文切换涉及保存和恢复寄存器状态,这会引入额外的开销,影响程序性能。
  3. 复杂性:多线程程序通常更难调试和维护、上下文切换涉及保存和恢复寄存器状态,这会引入额外的开销,影响程序性能。

进程通信

进程概述

进程 每个进程都拥有自己的用户地址空间,但是内核空间是公共的。所以进程之间要交换数据必须通过内核空间进行。 在内核中开辟一块缓冲区,进程1把数据从用户空间拷贝到内核缓冲区,进程2再从内核缓冲区把数据读取走,内核提供的这种机制称之为进程间通信(IPC,InterProcess Communication)。 进程之间拥有独立的地址空间,不能直接访问对方的内存。因此,进程之间的同步通常需要借助进程间通信

Linux下进程间通信:

说明:主要复制于参考里面的文章。用作个人复习用

  1. 管道(Pipe)
    只能用于父子进程或者兄弟进程之间(具有亲缘关系的进程),管道的本质实际上就是一个内核缓冲区,进程以先进先出(FIFO)的方式从缓冲区中存取数据。管道一端的进程将数据写入缓冲区中,写入的内容每次都添加在缓冲区的末尾;管道的另一端则从缓冲区中读取数据,每次读取的时候都是从缓冲区的头部读取数据 管道是半双工的,数据只能向一个方向流动。当双方都需要进行通信时,就需要建立起两个管道。

    • 匿名管道 对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。

    • shell中 管道通信 进程间不存在父子关系

    • 命名管道 匿名管道,由于没有名字,只能用于父子进程间的通信。为了克服这个缺陷,**命名管道(FIFO)**应运而生。命名管道与匿名管道之间的区别在于,命令管道提供了一个路径名与之相关联,从而以文件的形式存在于文件系统中。这样,即使与创建命名管道进程不存在父子关系的进程,只要可以访问该路径,就能够通过彼此该命名管道进行通信。这样就实现了不存在父子进程间的通信。**命名管道的名字存在于文件系统当中,而内容存在于内存当中。

  2. 信号(Signal)
    在异常情况下,需要使用信号这种方式来通知进程。信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件。

    在Linux操作系统中,为了响应各种各样的事件,提供了很多种信号,分别代表不同的含义,如常见的SIGINT信号,表示终止该进程;SIGTSTP信号,表示停止该进程,但还未结束。

    Linux中有两种方式触发信号,一种是通过键盘组合键(如CTRL+C)的方式,一种是命令(如KILL)的方式。

  信号是进程间通信唯一的异步通信机制,因为可以在任何时候发送信号给某一个进程。

  1. 消息队列(Message)
    现有两个进程,AB,使用消息队列进行数据传输时,A进程只需要把数据放到相应的消息队列即可返回;B进程在需要的时候只需要去消息队列中读取相关数据即可。同理,B进程给A进程发送消息也是如此。
    image.png 消息队列本质上是保存在内核当中的消息链表,在发送数据时,会被分成一个一个独立的数据单元,称之为消息体。消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型。每个消息体都是固定大小的存储块。如果进程从消息队列中读取了消息,则内核会将该消息从消息队列中移除。

    与管道的不同

    • 生命周期不同:
      • 管道的生命周期随着进程的创建而建立,随着进程的结束而销毁;
      • 消息队列的生命周期与内核有关,除非是重启内核或者是显示地删除一个消息队列,否则消息队列一直存在;
    • 数据不同:
      • 管道传输的是无格式的字节流;
      • 消息队列传输的是消息体,消息体都是固定大小的存储块;

    缺点:

    1. 通信不及时
    2. 消息体大小有限制
    3. 用户态和内核态之间的数据拷贝开销
  2. 共享内存
    消息队列的读取过程会发生用户态和内核态之间的消息拷贝过程,使用共享内存这种方式就可以很好地解决这个问题。

    共享内存可以使得多个进程可以直接读写在同一块内存空间中,这是效率最高的进程间通信方式。 现代操作系统中,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己的独立虚拟空间。不同进程的虚拟内存映射到不同的物理内存。因而为了在多个进程间交换信息,内核专门开辟了一块内存区域,不同的进程将其映射到自己的私有地址空间。这样A进程写入的数据,另一个B进程立刻就可以看到,避免了数据的拷贝,大大提高了进程间的通信速度。

    共享内存的通信方式是通过将共享的内存缓冲区直接附加到进程的虚拟地址空间中来实现的,因此,这些进程之间的读写操作的同步问题操作系统无法实现。必须由各进程利用其他同步工具解决。并且Linux无法严格保证提供对共享内存块的独占访问,使用时需要注意。

  3. 信号量(semaphore)
    虽然共享内存大大提高进程间通信的速度,但也带来了新的问题。假如某个时刻多个进程同时对同一个变量进行修改就会产生冲突。为了防止多进程竞争共享资源,需要一些进程间同步机制,使得在某一时刻只有一个进程可以访问共享资源。信号量就是其中之一。

    信号量其实就是一个整型的计数器,主要用于实现进程间的互斥和同步,而不是用于缓存进程间的通信数据。   信号量表示资源的数量,控制信号量的方式有两种原子操作:

    • P操作:该操作会把信号量减去1。相减后如果信号量<0,表示该资源已被占用,进程需要被阻塞进行等待;相减后如果信号量>=0,表明资源还可被访问和使用,进程正常执行操作即可;
    • V操作:该操作会把信号量加上1。相加后信号量**>=0**,表明当前有被阻塞着的进程,将该进程唤醒运行;相加后如果信号量>0,表明当前没有被阻塞的进程;

    P操作用在进入共享资源之前,V操作用在离开共享资源之后,这两个操作必须成对出现。

  4. 套接字(Socket)
    Socket不仅可以用于跨网络与不同主机间的主机进行通讯,还可以在同主机上进程间通信。

    根据创建socket类型的不同,通信方式不同: 1. 实现TCP字节流通信:绑定 IP 地址和端口 2. 实现UDP数据报通信:绑定 IP 地址和端口 3. 实现本地进程间通信:绑定本地文件

各种通信方式的比较和优缺点:

  1. 管道:速度慢,容量有限,只有父子进程能通讯
  2. 有名管道(named pipe):任何进程间都能通讯,但速度慢
  3. 消息队列:容量受到系统限制,且要注意第一次读的时候,要考虑上一次没有读完数据的问题
  4. 信号量:不能传递复杂消息,只能用来同步
  5. 共享内存:能够很容易控制容量,速度快,但要保持同步,比如一个进程在写的时候,另一个进程要注意读写的问题,相当于线程中的线程安全,当然,共享内存区同样可以用作线程间通讯,不过没这个必要,线程间本来就已经共享了同一进程内的一块内存。缺点需要其他手段同步保证数据一致性。

各种通信方式的适用场景:

  1. 管道:父子进程通信
  2. 有名管道(named pipe):无关联进程
  3. 消息队列:无关联进程异步通信,多对多
  4. 信号量:进程间同步,实现临界区保护
  5. 共享内存: 大量数据的高性能共享
  6. Socket:适用于跨网络的进程通信,也可用于本地通信。

线程间通信

  • 互斥锁和条件变量:
  • 共享内存+锁:
  • 管道(Pipe)和消息队列(Message Queue):管道和消息队列是进程间通信的方式,但也可以用于线程间通信。通过管道或消息队列,线程可以在彼此之间传递数据

协程间通信

进程同步

为什么需要同步?

互斥与同步

  • 互斥与同步机制是计算机系统中,用于控制进程对某些特定资源的访问的机制。
  • 同步是指用于实现控制多个进程按照一定的规则或顺序访问某些系统资源的机制。
  • 互斥是指用于实现控制某些系统资源在任意时刻只能允许一个进程访问的机制。互斥是同步机制中的一种特殊情况。
  • 同步机制是linux操作系统可以高效稳定运行的重要机制。

Linux为什么需要同步机制
在操作系统引入了进程概念,进程成为调度实体后,系统就具备了并发执行多个进程的能力,但也导致了系统中各个进程之间的资源竞争和共享。另外,由于中断、异常机制的引入,以及内核态抢占都导致了这些内核执行路径(进程)以交错的方式运行。对于这些交错路径执行的内核路径,如不采取必要的同步措施,将会对一些关键数据结构进行交错访问和修改,从而导致这些数据结构状态的不一致,进而导致系统崩溃。因此,为了确保系统高效稳定有序地运行,linux必须要采用同步机制。

防止共享资源被并发访问。所谓并发访问,就是指多个内核路径同时访问和操作相同地址的数据,有可能发生相互覆盖共享数据的情况,造成被访问数据的不一致,可能会造成系统不稳定或产生错误

Linux并发的主要来源:(临界资源与并发源) 在linux系统中,我们把对共享的资源进行访问的代码片段称为临界区。把导致出现多个进程对同一共享资源进行访问的原因称为并发源。

  • 中断处理:例如,当进程在访问某个临界资源的时候发生了中断,随后进入中断处理程序,如果在中断处理程序中,也访问了该临界资源。虽然不是严格意义上的并发,但是也会造成了对该资源的竞态。
  • 内核态抢占:例如,当进程在访问某个临界资源的时候发生内核态抢占,随后进入了高优先级的进程,如果该进程也访问了同一临界资源,那么就会造成进程与进程之间的并发。
  • 多处理器的并发:多处理器系统上的进程与进程之间是严格意义上的并发,每个处理器都可以独自调度运行一个进程,在同一时刻有多个进程在同时运行 。

如前所述可知:采用同步机制的目的就是避免多个进程并发并发访问同一临界资源。

线程和进程之间区别:

  1. 调度:线程是调度的基本单位;进程是拥有资源的基本单位。
  2. 并发性:一个进程内多个线程可以并发(最好和CPU核数相等);多个进程可以并发
  3. 拥有资源:线程不拥有系统资源。但一个进程的多个线程可以共享隶属进程的资源;进程是拥有资源的独立单位。
  4. 系统开销:线程创建销毁只需要处理(PC值、状态码、通用寄存器值、线程栈以及栈指针)。进程创建和销毁需要重新分配。

自旋锁 + 原子操作

应用背景: 自旋锁的最初设计目的是在多处理器系统中提供对共享数据的保护。

自旋锁的设计思想:在多处理器之间设置一个全局变量V,表示锁。并定义当V=1时为锁定状态,V=0时为解锁状态。自旋锁同步机制是针对多处理器设计的,属于忙等机制。自旋锁机制只允许唯一的一个执行路径持有自旋锁。如果处理器A上的代码要进入临界区,就先读取V的值。如果V!=0说明是锁定状态,表明有其他处理器的代码正在对共享数据进行访问,那么此时处理器A进入忙等状态(自旋);如果V=0,表明当前没有其他处理器上的代码进入临界区,此时处理器A可以访问该临界资源。然后把V设置为1,再进入临界区,访问完毕后离开临界区时将V设置为0。

为什么叫自旋,因为忙等,一直在询问锁的情况。

注意:必须要确保处理器A“读取V,半段V的值与更新V”这一操作是一个原子操作。所谓的原子操作是指,一旦开始执行,就不可中断直至执行结束。

信号量:可用于进程同步,也可用于线程同步

信号量强调的是线程(或进程)间的同步。

自旋锁是实现一种忙等待 锁,信号量则允许进程进入睡眠状态。是操作系统中最常用的同步原语之一 简单来说, 信号量是一个计数器,它支持两个操作,PV,分别表示减少和增加,现在改成了downup信号量适合用于一些情况复杂、加锁时间比较长的应用场景。

应用背景: 前面介绍的自旋锁同步机制是一种“忙等”机制,在临界资源被锁定的时间很短的情况下很有效。但是在临界资源被持有时间很长或者不确定的情况下,忙等机制则会浪费很多宝贵的处理器时间。针对这种情况,linux内核中提供了信号量机制,此类型的同步机制在进程无法获取到临界资源的情况下,立即释放处理器的使用权,并睡眠在所访问的临界资源上对应的等待队列上;在临界资源被释放时,再唤醒阻塞在该临界资源上的进程。另外,信号量机制不会禁用内核态抢占,所以持有信号量的进程一样可以被抢占,这意味着信号量机制不会给系统的响应能力,实时能力带来负面的影响。

信号量设计思想: 除了初始化之外,信号量只能通过两个原子操作P()和V()访问,也称为down()和up()。down()原子操作通过对信号量的计数器减1,来请求获得一个信号量。如果操作后结果是0或者大于0,获得信号量锁,任务就可以进入临界区。如果操作后结果是负数,任务会放入等待队列,处理器执行其他任务;对临界资源访问完毕后,可以调用原子操作up()来释放信号量,该操作会增加信号量的计数器。如果该信号量上的等待队列不为空,则唤醒阻塞在该信号量上的进程。

互斥锁mutex + 条件变量:只能用于线程同步

互斥体是一个类似信号量的实现。根据书籍上著名的“洗手间理论”,信号量相当于一个可以同时容纳N个人的洗手间,只要人不满就可以进去,如果人满了就要在外面等待。 互斥体类似街边的移动洗手间,每次只能进去一个人,里面的人出来后才能让排队的下一个人使用。那么问题来了,互斥体和信号量这么类似,为什么还要重新开发互斥体,而不是复用信号量的机制呢? 总的来说就是:互斥锁比信号量的实现要高效地多。

信号量不一定是锁定某一个资源,而是流程上的概念,比如:有A,B两个线程,B线程要等A线程完成某一任务以后再进行自己下面的步骤,这个任务并不一定是锁定某一资源,还可以是进行一些计算或者数据处理之类。而线程互斥量则是“锁住某一资源”的概念,在锁定期间内,其他线程无法对被保护的数据进行操作。在有些情况下两者可以互换。

互斥锁(又名互斥量)强调的是资源的访问互斥:互斥锁是用在多线程多任务互斥的,一个线程占用了某一个资源,那么别的线程就无法访问,直到这个线程unlock,其他的线程才开始可以利用这个资源。比如对全局变量的访问,有时要加锁,操作完了,在解锁。

条件变量常与互斥锁同时使用,达到线程同步的目的,允许线程以无竞争的方式等待特定的条件发生。条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足。在发送信号时,如果没有线程等待在该条件变量上,那么信号将丢失;

互斥锁是为上锁而优化的;条件变量是为等待而优化的; 信号量既可用于上锁,也可用于等待,因此会有更多的开销和更高的复杂性

只能用于线程同步原因:
linux下每个进程都有自己的独立进程空间,假设A进程和B进程各有一个互斥锁,这个锁放在进程的全局静态区,那么AB进程都是无法感知对方的互斥锁的。
线程同步和进程同步的本质区别在于锁放在哪,放在私有的进程空间还是放在多进程共享的空间,并且看锁是否具备进程共享的属性,

RCU

RCU概念: RCU全称是Read-Copy-Update(读/写-复制-更新),是linux内核中提供的一种免锁的同步机制。RCU与前面讨论过的读写自旋锁rwlock,读写信号量rwsem,顺序锁一样,它也适用于读取者、写入者共存的系统。但是不同的是,RCU中的读取和写入操作无须考虑两者之间的互斥问题。但是写入者之间的互斥还是要考虑的。

RCU原理: 简单地说,是将读取者和写入者要访问的共享数据放在一个指针p中,读取者通过p来访问其中的数据,而读取者则通过修改p来更新数据。要实现免锁,读写双方必须要遵守一定的规则。

读取者的操作(RCU临界区) 对于读取者来说,如果要访问共享数据。首先要调用rcu_read_lock和rcu_read_unlock函数构建读者侧的临界区(read-side critical section),然后再临界区中获得指向共享数据区的指针,实际的读取操作就是对该指针的引用。

读取者要遵守的规则是:(1)对指针的引用必须要在临界区中完成,离开临界区之后不应该出现任何形式的对该指针的引用。(2)在临界区内的代码不应该导致任何形式的进程切换(一般要关掉内核抢占,中断可以不关)。

写入者的操作
对于写入者来说,要写入数据,首先要重新分配一个新的内存空间做作为共享数据区。然后将老数据区内的数据复制到新数据区,并根据需要修改新数据区,最后用新数据区指针替换掉老数据区的指针。写入者在替换掉共享区的指针后,老指针指向的共享数据区所在的空间还不能马上释放(原因后面再说明)。写入者需要和内核共同协作,在确定所有对老指针的引用都结束后才可以释放老指针指向的内存空间。为此,写入者要做的操作是调用call_rcu函数向内核注册一个回调函数,内核在确定所有对老指针的引用都结束时会调用该回调函数,回调函数的功能主要是释放老指针指向的内存空间。

内核确定没有读取者对老指针的引用是基于以下条件的:系统中所有处理器上都至少发生了一次进程切换。因为所有可能对共享数据区指针的不一致引用一定是发生在读取者的RCU临界区,而且临界区一定不能发生进程切换。所以如果在CPU上发生了一次进程切换切换,那么所有对老指针的引用都会结束,之后读取者再进入RCU临界区看到的都将是新指针。

RCU特点:由前面的讨论可以知道,RCU实质上是对读取者与写入者自旋锁rwlock的一种优化。RCU的可以让多个读取者和写入者同时工作。但是RCU的写入者操作开销就比较大。在驱动程序中一般比较少用。

为了在代码中使用RCU,所有RCU相关的操作都应该使用内核提供的RCU API函数,以确保RCU机制的正确使用,这些API主要集中在指针和链表的操作。

怎么控制线程执行先后顺序(线程同步问题)

  1. join等待线程执行完成后再继续主线程
  #include <iostream>
#include <thread>
void threadFunction(int id) {
    // 线程执行的代码
    std::cout << "Thread " << id << " is running." << std::endl;
}
int main() {
    std::thread t1(threadFunction, 1);
    std::thread t2(threadFunction, 2);

    t1.join();  // 等待t1执行完成
    t2.join();  // 等待t2执行完成

    // 主线程继续执行
    std::cout << "Main thread is running." << std::endl;

    return 0;
}

2. 互斥量和条件变量 在线程执行函数中通过条件变量等待

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void threadFunction(int id) {
    {
        // 建了一个独占互斥量的 `unique_lock` 对象,它会在作用域结束时释放互斥量。
        std::unique_lock<std::mutex> lock(mtx);  
        // 过条件变量等待,线程在此阻塞直到条件 `ready` 为 `true`,同时会自动释放锁。这种等待方式叫做条件变量的“等待-通知”模式。
        cv.wait(lock, [] { return ready; });  
    }
    // 线程执行的代码
    std::cout << "Thread " << id << " is running." << std::endl;
}

int main() {
    std::thread t1(threadFunction, 1);
    std::thread t2(threadFunction, 2);
    // 某个时刻设置条件变量,使得线程开始执行
    {
        std::lock_guard<std::mutex> lock(mtx);
        ready = true;
        cv.notify_all(); // 通知等待的线程条件已满足。
    }
    t1.join();
    t2.join();
    // 主线程继续执行
    std::cout << "Main thread is running." << std::endl;
    return 0;
}

1701419660039.png