谈谈进程间通信

我们在一个操作系统之上可以同时登录多个用户使用计算机,同一个用户可以一边使用音乐软件听歌,一边使用 IDE 写代码。这都离不开操作系统中的进程。

进程和线程

进程是操作系统中最核心的概念,是正在运行程序的一种抽象。音乐软件、IDE 都对应一个或多个进程。不管是 UNIX 还是 Windows,进程各自拥有不同的地址空间。然而进程间不免有共享数据的需要,这也是进程间通信机制的用武之地。

除了多进程模型,每个进程中建立了多线程的机制。线程可以类似于进程进行并行运行,不同之处是线程间共享内存空间;线程也更加轻量,创建和销毁成本更低;单个应用中可以通过多线程实现一些并行运行,防止 I/O 等操作耗时阻塞程序运行的情况。

进程间通信

进程经常需要和其他进程进行通信。而进程的内存空间私有、进程并发运行的特性,进程间通信要克服以下三个问题:

  1. 进程间如何传递数据?
  2. 多个进程在进行关键活动时如何互斥?
  3. 生产者生产数据和消费者消费数据的顺序如何保证?

对于线程来说,线程间共享一块内存空间,所以线程间传递数据的问题迎刃而解。而问题 2、3 是线程间通信仍然需要解决的。进程间通信机制也可以拥有线程间通信。

进程间如何传递数据?

对于第一个问题,我们知道数据是在硬盘、内存和网络中存储传输。所以我们可以建立一个公共数据区域,进程通过写入和读取该区域来实现进程间通信;或者直接通过网络通信,传输数据。进程间通信都包含如下情况:

  1. 共享内存
  2. 共享文件
  3. 网络传输

对于共享内存,Linux 的管道、Android 的 Parcelable 序列化都是在内存中储存。

对于共享文件,通过文件读写数据、Java 的 Serializable 序列化是在磁盘中存储数据。

对于 Linux 的套接字 Socket 通信则是通过网络传输进行进程间操作。

多个进程在进行关键活动时如何互斥和保证顺序?

进程间通信存在数据写入方和数据读取方,即生产者和消费者。如果多个生产者进程需要生产数据时,我们需要保证同一时间只有一个生产者在进行写入操作,否则就会出现数据出错的问题。这一操作的限制我们称之为互斥,进程中写入数据部分的代码区域我们称之为临界区。

对于保证多个进程在关键活动时的互斥,我们可以采取以下方式。

1. 屏蔽中断

在进程进入临界区,进行关键操作之后,我们屏蔽所有中断。CPU 在发生时钟中断或其他中断时才会切换进程,这也保证了进程间的互斥。

然而,屏蔽中断后其他进程将处于阻塞状态,如果当前进程操作耗时较长,严重时会出现操作系统终止的情况。同时屏蔽中断只在当前 CPU 中起作用,无法在多个 CPU 时起到进程互斥的效果。

2. 锁变量

我们可以通过共享变量的值,来代表是否已经有进程在操作共享内存区域。但是共享变量也存在进程切换时,对锁变量读取失效的问题。即进程 1 读取共享变量值为 0,因此判断可以进行内存操作;此时 CPU 切换进程 2 开始执行,进程 2 读取的变量值仍为 0,也进入了临界区。此时有两个进程进入了临界区,锁变量实现多进程互斥的计划就泡汤了。

3. 忙等待

解决锁变量的问题我们可以通过循环读取变量值的方式来保证值失效的问题。其代码如下。

// process 1
while(TRUE) {
    while (turn != 0);

    enter_region();
    turn = 1;
    leave_region();
}

// process 2
while(TRUE) {
    while (turn != 1);

    enter_region();
    turn = 0;
    leave_region();
}

忙等待的缺点是循环等待浪费了 CPU 时间,同时在两个不同优先级的进程间通信时,可能出现优先级反转问题。假如低优先级的进程 L 处在临界区时,高优先级进程 H 就绪开始运行,H 进程一直处于忙等待,此时又无法切到低优先级进程 L 中执行让 L 离开临界区,导致通信无法进行。

对此,我们可以使用 sleep、wakeup 操作替换忙等待。即进程 1 在发现内存区域被其他进程占用时,通过系统调用进入阻塞状态;进程 2 在操作完成后唤醒其他进程。其代码如下:

int N = 100;
int count = 0;

// producer
while(TRUE) {
    int item = produce_item();
    if (count == N) sleep();
    insert_item(item);
    count = count + 1;
    if (count == 1) wakeup(cousumer);
}

// consumer
while(TRUE) {
    if (count == 0) sleep();
    int item = remove_item();
    count = count - 1;
    if (count == N - 1) wakeup(producer);
    consume_item(item);
}

4. 信号量

对于上述代码,存在这样一种情况:

  1. 消费者 consumer 进程,第一行代码读取 count 值为 0 之后,切换到 producer 进程执行;
  2. producer 生产了数据并放入缓冲区,然后调用 wakeup 去唤醒 consumer,但此时 consumer 还未 sleep,所以 wakeup 信号发生丢失;
  3. 切回 consumer 进程时,count 此时仍为 0,consumer 进入 sleep 状态;
  4. 生产者一直生产,直到缓冲区满,进入睡眠。

此时生产者、消费者进程都处于睡眠状态,原因是 wakeup 信号丢失了。

该问题可以通过新增一个信号量来解决,即 wakeup 信号的数量。把信号量值的读取、更新和睡眠操作作为原子操作,也就是不要在读取信号量之后切换进程,以防止出现上述问题。而防止切换进程的操作可以通过屏蔽中断来保证,由于此时信号量的读取、更新、进程睡眠操作耗时较短,所以不存在之前的问题。另外,对于多个 CPU 的情况,可以通过锁变量 + TSL 机制保证多 CPU 多进程对信号量的互斥。

5. 管程

在 Java 中的线程同步可以使用 synchronized 关键字。经过关键字修饰的代码编译后的字节码会自动增加 monitorenter、monitorexit 指令。

Java 是一种支持管程的语言,开发者无需关心管程的实现,它完全由编译器完成。该指令的实现也结合了锁、信号量、等待和唤醒机制。

总结

本次我们了解了进程间通信的三个问题和可选的解决方法及其缺点。通过共享内存、文件来传输数据,通过锁、信号量等机制保证内存读取的互斥和有序。

Copyright © qingeneral.github.io 2023 all right reserved,powered by Gitbook该文章修订时间: 2023-05-28 13:25:06

results matching ""

    No results matching ""