【由浅入深OS】进程间通信和同步

686 阅读28分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

进程间通信和同步

Q:进程间的通信方式有哪些?

先说结论:有管道、消息队列、共享内存、信号量、信号、socket。

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

管道

what?

什么是管道? 管道是两个进程间的一条通道,一端负责投递,另一端负责接收。因此,管道是单向的 IPC,要想实现双向通信,就要创建两个管道。

对于管道,通常是以 FIFO 缓冲区来管理数据的。也就是说,所谓的管道就是内核中的一串缓存。从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。

管道的分类? 管道又分为 匿名管道 和 命名管道。主要的区别在于他们的创建方式。

匿名管道

举个例子:

ps aux | grep target
# 查看当前是否有关键字 target 相关的进程在运行

这里其实是两个命令,通过 Shell 的管道符号 | ,将第一个命令的输出投递到一个管道中,而管道对应的出口是第二个命令来输入。

匿名管道的创建:

#include <unistd.h>
int pipe(int fd[2]);
// fd[2]为两个文件描述符,fd[0]:读端描述符;fd[1]:写端描述符

管道具体是如何进行两进程间的通信的呢?

通常,管道都是要结合 fork 来使用,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0]fd[1]」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信。

image-20220414110139503

注意:在 fork 完成后,父子进程都会同时拥有管道的两端,此时需要父子进程主动关闭多余的端口,否则可能导致通信出错。通常的做法:

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

image-20220414110400208

通信范围:这种方式对于父子进程等有着创建关系的进程间通信比较方便,但对于两个关系比较远的进程不太适用。

命名管道

命名管道是通过 mkfifo 命令来创建的,并且需要指定管道名字:

mkfifo myPipe

接下来,往 myPipe 这个管道写入数据:

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

操作之后会发现命令执行后就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。

于是,我们执行另外一个命令来读取这个管道里的数据:

cat < myPipe    # 读取管道中的数据
hello

可以看到,管道里的内容被读取出来了,并打印在了终端上,另外一方面,echo 那个命令也正常退出了。

通信范围:因为提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。因此它可以在任意两个进程间通信。

管道的优缺点

通过上面的介绍,可以看到,管道这种通信方式效率低,不适合进程间频繁地交换数据。当然,它的好处,自然就是简单,同时也我们很容易得知管道里的数据已经被另一个进程读取了。

加餐

在还没有数据写入时,拿着输出端的进程就开始尝试读取数据,此时会有两种情况:

  1. 如果系统发现当前没有任何进程有这个管道的写端口,就会看到 EOF(End-of-File);
  2. 否则,输出端的进程就会阻塞在这个系统调用上,知道接收到数据。

这里之所以存在第一种情况,是因为管道的两个端口在 UNIX 系列的内核中是以两个独立的文件描述符存在的,写端口可能被进程主动关闭了。

对于第二种情况,进程可以通过配置非阻塞选项来避免阻塞。

消息队列

why?为什么需要消息队列

前面说到管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。对于这个问题,消息队列的通信模式可以解决。

举个例子,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。

what?消息队列的结构是什么样的

消息队列在内核中是以链表队列的形式存在,如下图所示。

image-20220414114820181

在消息的结构体中,除了 “下一个” 指针之外, 就是消息的内容。消息的内容包含两部分:类型 和 数据。类型是用户态程序为每个消息指定的;数据是一段内存数据,和管道中的字节流相似。

在消息队列的设计中,内核不需要知道类型的语义,仅仅是保存,以及基于类型进行简单的查找。

对于消息队列,一旦一个队列被创建,除非内核重新启动或者该队列被主动删除,否则消息队列的数据都会被保留。

消息队列的优缺点

先说结论,一是消息体有大小限制,二是通信不及时

  1. 在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。 在 Linux 内核中,会有两个宏定义 MSGMAXMSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。
  2. 消息在用户态和内核态之间传递时,会有拷贝的开销。 因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。

共享内存

why?为什么有共享内存,解决了什么问题

消息队列的读取和写入过程,都会有发生用户态与内核态之间的消息拷贝过程。而共享内存就很好的解决了这一问题。

共享内存的思路其实就是内核为需要通信的进程建立共享区域。一旦共享区域完成建立,内核就基本上不需要参与进程间通信,大大提高了性能。

什么是共享内存

现代操作系统对于内存管理,采用的是虚拟内存技术,每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。

共享内存,就是拿出一块内存空间,允许一个或多个进程所在的虚拟内存空间映射过去,从而实现通信。 这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。

image-20220414151239058

信号量

why?为什么需要信号量

用了共享内存通信方式后,又带来了新的问题:如果多个进程同时修改同一个共享内存,很有可能会发生冲突(比如,两个进程同时写入一个地址,那先写的那个进程会发现内容被别人覆盖)。

为了防止多进程竞争共享资源而造成数据错乱,所以需要保护机制,使得共享资源在任意时刻只能被一个进程访问。信号量就实现了这一保护机制。

what?

和消息队列这类明确的 “传递消息” 的方案不同,信号量其实是一个整型计数器,在实际使用中主要用于实现进程间的互斥和同步。

信号量的主要操作是两个原语:P 和 V。

  • P 表示尝试一个操作,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
  • V 操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;

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

how?实际应用示例

同步信号量:

对于两个进程 A 和 B,我们希望在 A 执行完相关代码后,B 再执行(如,A 负责生产数据,B 负责读取数据)。那么此时它们可以共享一个信号量,使信号量初始化为 0

A 进程会在执行完代码之后,执行一个对共享信号量的 V 操作,而 B 进程会在执行代码前,执行一个对共享信号量的 P 操作。

  • 如果内核先调度了进程 B,使其 P 操作最先发生,此时会导致信号量的结果为 -1,而这是不被允许的,因此内核将会阻塞 B 进程。
  • 当 A 进程执行完自己的代码后,执行 V 操作,此时会将信号量的值更新为 0。同时,内核会发现此时 B 的 P 操作已经可以成功了,因此内核会唤醒 B,并执行 B 的操作。

image-20220414185932450

互斥信号量

对于两个进程 A 和 B,我们希望两个进程互斥访问共享内存,我们可以初始化信号量为 1

image-20220414191047617

  • 进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。
  • 若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。
  • 直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。

因此:

  • 同步信号量:信号初始化为 0
  • 互斥信号量:信号初始化为 1

信号

why?为什么需要信号

前面说的管道、消息队列、共享内存等方式都是在常规状态下的工作模式,主要关注的是数据传输设计,而对于异常情况下的工作模式,就需要用「信号」的方式通知进程。

信号和信号量对比:

  • 信号量也有通知能力,但需要进程主动去查询计数器状态或陷入阻塞状态(来等待通知)。
  • 使用信号,一个进程可以随时发送一个事件到特定的进程、线程或进程组等,并且接收事件的进程不需要阻塞等待该事件,内核会帮助其切换到对应的处理函数中响应信号事件,并在处理完成后恢复之前的上下文。
what?信号的基本介绍

信号是进程间通信机制中唯一的异步通信机制,它传递的信息很短,只有一个编号。在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过 kill -l 命令,查看所有的信号:

$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令):

运行在 shell 终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。例如

  • Ctrl+C 产生 SIGINT 信号,表示终止该进程;
  • Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束。

如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如:

  • kill -9 1050 ,表示给 PID 为 1050 的进程发送 SIGKILL 信号,用来立即结束该进程。
信号的响应和处理

信号得到处理的时机通常是内核执行完异常、中断、系统调用等返回到用户态的时刻。

内核对信号的处理一般有三种方式:

  1. 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILLSEGSTOP,它们用于在任何时候中断或结束某一进程。
  2. 捕捉信号。我们可以为信号定义一个信号处理函数,当信号发生时,我们就执行相应的信号处理函数。
  3. 执行内核默认处理函数。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。

socket

why?为什么需要套接字通信

前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,要想跨网络与不同主机上的进程之间通信,就需要 socket 通信。

what?

socket 是一种既可用于本地,又可跨网络使用的通信机制。

Linux 中创建 socket 的系统调用:

#include <sys/socket.h>
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 编程模型:socket 类型是 AF_INET 和 SOCK_STREAM

image-20220414234254719

  • 服务端和客户端初始化 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 编程模型:socket 类型是 AF_INET 和 SOCK_DGRAM

image-20220414234452202

UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind。

对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。

另外,每次通信时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。

针对本地进程间通信的 socket 编程模型

  • 对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。
  • 对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。
  • 另外,AF_UNIX 和 AF_LOCAL 是等价的,所以 AF_UNIX 也属于本地 socket;

本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。

加餐

几种 IPC 通信方式对比

image-20220414120108901

匿名管道 顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「|」竖线就是匿名管道,通信的数据是无格式的流并且大小受限,通信的方式是单向的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道,再来匿名管道是只能用于存在父子关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。

命名管道 突破了匿名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

消息队列 克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。

共享内存 可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,它直接分配一个共享空间,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。

那么,就需要 信号量 来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。信号量不仅可以实现访问的互斥性,还可以实现进程间的同步,信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作

与信号量名字很相似的叫 信号,它俩名字虽然相似,但功能一点儿都不一样。信号是进程间通信机制中唯一的异步通信机制,信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令),一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILLSEGSTOP,这是为了方便我们能在任何时候结束或停止某个进程。

前面说到的通信机制,都是工作于同一台主机,如果要与不同主机的进程间通信,那么就需要 socket 通信了。socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。

Q:线程间的通信方式有哪些?

注:这一部分内容主要是依照小林哥的文章做的笔记:xiaolincoding.com/os/4_proces…

前言:同个进程下的线程之间都是共享进程的资源,只要是共享变量都可以做到线程间通信,比如全局变量,所以对于线程间关注的不是通信方式,而是关注多线程竞争共享资源的问题,信号量也同样可以在线程间实现互斥与同步:

  • 互斥的方式,可保证任意时刻只有一个线程访问共享资源;
  • 同步的方式,可保证线程 A 应在线程 B 之前执行。

简单说一下同步和互斥的概念:

所谓同步,就是并发进程 / 线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通消息称为进程 / 线程同步。

而互斥,由于多线程执行操作共享变量的代码段时可能会导致竞争状态,我们要保证任意时刻一个线程在临界区执行,其他线程都被阻止进入,称这种方式为互斥。

为了实现进程 / 线程间正确的协作,主要有以下两种方法:

  • 锁:加锁、解锁操作;
  • 信号量:P、V操作。

其中,信号量不仅能实现 进程/线程 的互斥,还能方便的实现 进程/线程 同步。

加锁的目的就是保证共享资源在任意时间内,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。根据锁的实现不同,可以分为「忙等待锁」和「无忙等待锁」。

最常用的就是互斥锁,还有很多种不同的锁,比如自旋锁、读写锁、乐观锁、悲观锁等,不同种类的锁适用于不同的场景(因为如果选择了错误的锁,那么在一些高并发的场景下,可能会降低系统的性能,这样用户体验就会非常差)。

几种锁的介绍在 Q:介绍一些几种经典的锁以及应用场景 中。

条件变量

信号量

what?

信号量在不同的线程之间充当信号灯,其根据剩余资源数量控制不同线程的执行或等待。对应的变量是一个整型变量(下面用 sem 表示)。

除了初始化之外,信号量只能通过两个操作来进行更新:

  • P 操作(wait):将 sem 减 1,相减后,如果 sem < 0,则 进程/线程 进入阻塞等待,否则继续,表明 P 操作可能会阻塞;
  • V 操作(signal):将 sem 加 1,相加后,如果 sem <= 0,唤醒一个等待中的 进程/线程,表面 V 操作不会阻塞。

P 操作用在进入临界区之前,V 操作用在临界区之后,这两个操作必须成对出现。

信号量的实现
// 信号量数据结构
type struct sem_t {
    int sem;    // 资源个数
    queue_t *q; // 等待队列
} sem_t;
​
// 初始化信号量
void init(sem_t *s, int sem) {
    s->sem = sem;
    queue_init(s->q);
}
​
// P 操作
void P(sem_t *s) {
    s->sem--;
    if (s->sem < 0) {
        1. 保留调用线程 CPU 现象;
        2. 将该线程的 TCB 插入到 s 的等待队列中;
        3. 设置该线程为等待状态;
        4. 执行调度程序;
    }
}
​
// V 操作
void V(sem_t *s) {
    s->sem++;
    if (s->sem <= 0) {
        1. 移出 s 等待队列首元素;
        2. 将该线程的 TCB 插入就绪队列;
        3. 设置该线程为就绪状态;
    }
}

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

how?如何使用 PV 操作

信号量实现临界区的互斥访问 示例

为每类共享资源设置一个信号量 s,其初值为 1,表示该临界资源未被占用。只要把进入临界区的操作置于 P(s)V(s) 之间,就可以实现 进程/线程 互斥:

image-20220417003100728

过程:

任何想进入临界区的线程,必先在互斥信号量上执行 P 操作,在完成对临界资源的访问后再执行 V 操作。由于互斥信号量的初始值为 1,故在第一个线程执行 P 操作后 s 值变为 0,表示临界资源为空闲,可分配给该线程,使之进入临界区。

若此时又有第二个线程想进入临界区,也应先执行 P 操作,结果使 s 变为负值,这就意味着临界资源已被占用,因此,第二个线程被阻塞。

并且,直到第一个线程执行 V 操作,释放临界资源而恢复 s 值为 0 后,才唤醒第二个线程,使之进入临界区,待它完成临界资源的访问后,又执行 V 操作,使 s 恢复到初始值 1。

信号量实现事件同步 示例

同步的方式是设置一个信号量,其初值为 0

拿「吃饭 - 做饭」的例子,用代码实现:

semaphore s1 = 0;   // 表示不需要吃饭
semaphore s2 = 0;   // 表示饭还没做完// 儿子线程函数
void son {
    while (true) {
        肚子饿;
        V(s1);  // 叫妈妈做饭
        P(s2);  // 等待妈妈做完饭
        吃饭;
    }
}
​
// 妈妈线程函数
void mom {
    while (true) {
        P(s1);  // 询问是否需要做饭
        做饭;
        V(s2);  // 做完饭,通知儿子吃饭
    }
}

妈妈一开始询问儿子要不要做饭时,执行的是 P(s1) ,相当于询问儿子需不需要吃饭,由于 s1 初始值为 0,此时 s1 变成 -1,表明儿子不需要吃饭,所以妈妈线程就进入等待状态。

当儿子肚子饿时,执行了 V(s1),使得 s1 信号量从 -1 变成 0,表明此时儿子需要吃饭了,于是就唤醒了阻塞中的妈妈线程,妈妈线程就开始做饭。

接着,儿子线程执行了 P(s2),相当于询问妈妈饭做完了吗,由于 s2 初始值是 0,则此时 s2 变成 -1,说明妈妈还没做完饭,儿子线程就等待状态。

最后,妈妈终于做完饭了,于是执行 V(s2)s2 信号量从 -1 变回了 0,于是就唤醒等待中的儿子线程,唤醒后,儿子线程就可以进行吃饭了。

加餐

什么是原子操作?

原子操作指的是不可被打断的一个或一系列操作。即要么这一系列指令都执行完成,要么这一系列指令一条都没有执行,不会出现执行到一半的状态。

最常见的原子操作包括比较与置换(Compare-Add-Swap,CAS)、拿取并累加(Fetch-Add-Add,FAA)等。

下面用 C 语言分别展示两种原子操作的基本逻辑(实际上这段代码本身并不具备原子性):

// CAS
int CAS(int *addr, int expected, int new_value) {
    int tmp = *addr;
    if (*addr == expected) {
        *addr = new_value;
    }
    return tmp;
}
​
// FAA
int FAA(int *addr, int add_value) {
    int tmp = *addr;
    *addr = tmp + add_value;
    return tmp;
}

Q:介绍一些几种经典的锁以及应用场景

下面的内容是基于小林哥的文章写的笔记:xiaolincoding.com/os/4_proces…

另外再推荐一个通俗易懂的理解:www.zhihu.com/question/66…

几种经典的锁有:互斥锁、自旋锁、读写锁、悲观锁、乐观锁。

互斥锁与自旋锁

区别

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

  • 互斥锁加锁失败后,线程会释放 CPU,给其他线程;
  • 自旋锁加锁失败后,线程会处于忙等待状态,直到拿到锁。
互斥锁举例

举个例子,当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 就会加锁失败,于是就会释放 CPU 让给其他线程,自然线程 B 加锁的代码就会被阻塞。

互斥锁加锁失败时内核过程

对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。

因此,当互斥锁加锁失败时,线程会从用户态陷入内核态,帮内核帮我们切换线程,虽然简化了使用锁的难度,却存在一定的性能开销成本。而这个性能开销成本,指两次线程上下文切换的成本:

  • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
  • 当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
互斥锁适用场景

这里注意:线程的上下文切换耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果锁住的代码执行时间比较短,那可能上下文切换的时间都比我们锁住的代码执行时间还要长。

所以,如果能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。

什么是自旋锁

自旋锁是通过 CPU 提供的 CAS 原子指令,在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。

自旋锁利用一个变量 lock 来表示锁的状态。lock 为 1 表示已经有人拿锁,为 0 表示该锁空闲。

在加锁时,线程会通过 CAS 判断 lock 是否空闲,如果空闲则上锁,否则将一遍一遍重试。而放锁时,直接将 lock 设置为 0 表示其空闲。

void lock_init(int *lock) {
    // 初始化自旋锁
    *lock = 0;
}
​
void lock(int *lock) {
    while (atomic_CAS(lock, 0, 1) != 0)
        ;   // 循环忙等
}
​
void unlock(int *lock) {
    *lock = 0;
}
自旋锁使用场景

自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 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 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。

乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

加餐

总结一下:

开发过程中,最常见的就是互斥锁的了,互斥锁加锁失败时,会用「线程切换」来应对,当加锁失败的线程再次加锁成功后的这一过程,会有两次线程上下文切换的成本,性能损耗比较大。

如果我们明确知道被锁住的代码的执行时间很短,那我们应该选择开销比较小的自旋锁,因为自旋锁加锁失败时,并不会主动产生线程切换,而是一直忙等待,直到获取到锁,那么如果被锁住的代码执行时间很短,那这个忙等待的时间相对应也很短。

如果能区分读操作和写操作的场景,那读写锁就更合适了,它允许多个读线程可以同时持有读锁,提高了读的并发性。根据偏袒读方还是写方,可以分为读优先锁和写优先锁,读优先锁并发性很强,但是写线程会被饿死,而写优先锁会优先服务写线程,读线程也可能会被饿死,那为了避免饥饿的问题,于是就有了公平读写锁,它是用队列把请求锁的线程排队,并保证先入先出的原则来对线程加锁,这样便保证了某种线程不会被饿死,通用性也更好点。

互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。

另外,互斥锁、自旋锁、读写锁都属于悲观锁,悲观锁认为并发访问共享资源时,冲突概率可能非常高,所以在访问共享资源前,都需要先加锁。

相反的,如果并发访问共享资源时,冲突概率非常低的话,就可以使用乐观锁,它的工作方式是,在访问共享资源时,不用先加锁,修改完共享资源后,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。

但是,一旦冲突概率上升,就不适合使用乐观锁了,因为它解决冲突的重试成本非常高。

不管使用的哪种锁,我们的加锁的代码范围应该尽可能的小,也就是加锁的粒度要小,这样执行速度会比较快。再来,使用上了合适的锁,就会快上加快了。

Q:多线程的同步与互斥的方法

Q:线程间的通信方式有哪些? 已经写过了

Q:管道与消息队列的对比

Q:进程间的通信方式有哪些? 的 加餐 部分已写

Q:介绍一下信号,你对信号的理解?

Q:进程间的通信方式有哪些? 的 信号 部分已经写过

Q:信号与中断的相似点?区别?

转自:blog.csdn.net/wsx199397/a…

信号与中断的相似点:

  1. 采用了相同的异步通信方式;
  2. 当检测出有信号或中断请求时,都暂停正在执行的程序而转去执行相应的处理程序;
  3. 都在处理完毕后返回到原来的断点;
  4. 对信号或中断都可进行屏蔽。

信号与中断的区别:

  1. 中断有优先级,而信号没有优先级,所有的信号都是平等的;
  2. 信号处理程序是在 用户态 下运行的,而中断处理程序是在 核心态 下运行;
  3. 中断响应是及时的,而信号响应通常都有较大的时间延迟。

\