【Ucore操作系统】7. 进程间通信

【 0. 引言 】

  • 在上一章中,我们引入了非常重要的进程的概念,以及与进程管理相关的 fork 、 exec 等创建新进程相关的系统调用。虽然操作系统提供新进程的动态创建和执行的服务有了很大的改进,但截止到目前为止,进程在输入和输出方面,还有不少限制。特别是进程能够进行交互的 I/O 资源还非常有限,只能接受用户在键盘上的输入,并将字符输出到屏幕上。我们一般将它们分别称为 标准 输入和 标准 输出。而且进程之间缺少信息交换的能力,这样就限制了通过进程间的合作一起完成一个大事情的能力。
  • 其实在 UNIX 的早期发展历史中,也碰到了同样的问题,每个程序专注在完成一件事情上,但缺少把多个程序联合在一起完成复杂功能的机制。直到1975年UNIX v6中引入了让人眼前一亮的创新机制– I/O重定向 与 管道(pipe) 。基于这两种机制, 操作系统在不用改变应用程序的情况下,可以将一个程序的输出重新定向到另外一个程序的输入中,这样程序之间就可以进行任意的连接,并组合出各种灵活的复杂功能。
  • 本章我们也会引入新操作系统概念 – 管道,并进行实现。除了键盘和屏幕这样的 标准 输入和 标准 输出之外,管道其实也可以看成是一种特殊的输入和输出,而后面一章讲解的 文件系统 中的对持久化存储数据的抽象 文件(file) 也是一种存储设备的输入和输出。所以,我们可以把这三种输入输出都统一在 文件(file) 这个抽象之中。这也体现了在 Unix 操作系统中, ” 一切皆文件 “ (Everything is a file) 重要设计哲学。
  • 在本章中提前引入 文件 这个概念,但本章不会详细讲解,只是先以最简单直白的方式对 文件 这个抽象进行简化的设计与实现。站在本章的操作系统的角度来看, 文件 成为了一种需要操作系统管理的I/O资源。
  • 为了让应用能够基于 文件 这个抽象接口进行I/O操作,我们就需要对 进程 这个概念进行扩展,让它能够管理 文件 这种资源。具体而言,就是要对进程控制块进行一定的扩展。为了统一表示 标准 输入和 标准 输出和管道,我们将 在每个进程控制块中 增加一个 文件描述符表在表中保存着多个 文件 记录信息。每个文件描述符是一个非负的索引值,即对应文件记录信息的条目在文件描述符表中的索引,可方便进程表示当前使用的 标准 输入、 标准 输出和管道(当然在下一章还可以表示磁盘上的一块数据)。用户进程访问文件将很简单,它 只需通过文件描述符,就可以对 文件 进行读写,从而完成接收键盘输入,向屏幕输出,以及两个进程之间进行数据传输的操作
  • 本章我们的主要目的是实现进程间的通信方式。这就意味着一个进程得到的输入和输出不一定是针对标准输入输出流了(也就是fd0 的 stdin 和 fd1 的 stdout),而可能是对应的pipe的新的fd。考虑到lab7我们就需要实现一个比较完成的文件系统,在lab6中乘着引入pipe的机会我们会先实现一个文件系统(fs)的雏形。

【 1. 文件系统扩充 】

1.1 管道的文件抽象

  • 背景
    在Unix操作系统之前,大多数的操作系统提供了各种复杂且不规则的设计实现来处理各种I/O设备(也可称为I/O资源),如键盘、显示器、以磁盘为代表的存储介质、以串口为代表的通信设备等,使得应用程序开发繁琐且很难统一表示和处理I/O设备。随着UNIX的诞生,一个简洁优雅的I/O设备的抽象出现了,这就是 文件 。在 Unix 操作系统中,一切皆文件 (Everything is a file) 是一种重要的设计思想,这种设计思想继承于 Multics 操作系统的 通用性 的设计理念,并进行了进一步的简化。
  • 在本章中,应用程序看到并被操作系统管理的 文件 (File) 就是一系列的字节组合。 操作系统不关心文件内容,只关心如何对文件按字节流进行读写的机制,这就意味着任何程序可以读写任何文件(即字节流),对文件具体内容的解析是应用程序的任务,操作系统对此不做任何干涉。例如,一个Rust编译器可以读取一个C语言源程序并进行编译,操作系统并并不会阻止这样的事情发生。
  • 有了文件这样的抽象后,操作系统内核就可把能读写的I/O资源按文件来进行管理,并把文件分配给进程,让进程以统一的文件访问接口与I/O 资源进行交互。在我们目前涉及到的I/O硬件设备中,大致可以分成以下几种:
    • 键盘设备 是程序获得字符输入的一种设备,也可抽象为一种 只读性质的文件,可以从这个文件中读出一系列的字节序列;
    • 屏幕设备 是展示程序的字符输出结果的一种字符显示设备,可抽象为一种 只写性质的文件,可以向这个文件中写入一系列的字节序列,在显示屏上可以直接呈现出来;
    • 串口设备 是获得字符输入和展示程序的字符输出结果的一种字符通信设备,可抽象为一种 可读写性质的文件,可以向这个文件中写入一系列的字节序列传给程序,也可把程序要显示的字符传输出去;还可以把这个串口设备拆分成两个文件,一个用于获取输入字符的只读文件和一个传出输出字符的只写文件。
  • 文件是提供给应用程序用的,但有操作系统来进行管理。虽然文件可代表很多种不同类型的I/O 资源,但是在进程看来,所有文件的访问都可以通过一个很简洁的统一抽象接口 File 来进行。我们看一下我们OS框架对文件结构的扩充:
// file.h
struct file {
    enum { FD_NONE = 0, FD_PIPE, FD_INODE, FD_STDIO } type;
        int ref; // reference count
        char readable;
        char writable;
        struct pipe *pipe; // FD_PIPE
        struct inode *ip; // FD_INODE
        uint off;
};

1.2 pipe 管道的实现

  • 管道 是一种 进程间通信的方式,它允许管道两端的进程互相传递信息
  • 我们OS框架对于pipe的设计十分简单: 找一块空闲内存作为 pipe 的 data buffer,两端的进程对 pipe 的读写就转化为了对这块内存的读写。虽然逻辑十分简单,但是进程读写管道实际还是通过sys_write和sys_read来实现的,sys_write 还同时需要完成屏幕输出。
  • 一个程序还可以拥有多个 pipe,而且 pipe 还要能够使得其他程序可见来完成进程通讯的功能,对每个 pipe 还要维护一些状态来记录上一次读写到的位置和 pipe 实际可读的 size等。因此我们也需要关注一下我们OS pipe实现的细节,首先,看一下管道的结构体:
    可以看到,管道把数据存在了一个char数组的缓存之中来维护。这里我们以ring buffer的形式管理管道的data buffer。
// file.h,抽象成一个文件了。
#define PIPESIZE 512

struct pipe {
    char data[PIPESIZE];
    uint nread;     // number of bytes read
    uint nwrite;    // number of bytes written
    int readopen;   // read fd is still open
    int writeopen;  // write fd is still open
};
  • 我们来看一下如何创建一个管道:
    管道两端的输入和输出被我们抽象成了两个文件。这两个文件的创建由sys_pipe调用完成。我们在分配时就会设置管道两端哪一端可写哪一端可读,并初始化管道本身的nread和nwrite记录buffer的指针。
:linenos:

int pipealloc(struct file *f0, struct file *f1)
{
    // 这里没有用预分配,由于 pipe 比较大,直接拿一个页过来,也不算太浪费
    struct pipe *pi = (struct pipe*)kalloc();
    // 一开始 pipe 可读可写,但是已读和已写内容为 0
    pi->readopen = 1;
    pi->writeopen = 1;
    pi->nwrite = 0;
    pi->nread = 0;

    // 两个参数分别通过 filealloc 得到,把该 pipe 和这两个文件关连,一端可读,一端可写。读写端控制是 sys_pipe 的要求。
    f0->type = FD_PIPE;
    f0->readable = 1;
    f0->writable = 0;
    f0->pipe = pi;

    f1->type = FD_PIPE;
    f1->readable = 0;
    f1->writable = 1;
    f1->pipe = pi;
    return 0;
}

在内核中,我们是不能 new 一个结构体的,这是由于我们没有实现堆内存管理。但我们可以用一种略显浪费的方式,也就是直接 kalloc() 一个页,只要不大于一整个页的数据结构都可以这样 new 出来。

  • 关闭pipe比较简单。函数其实只关闭了读写端中的一个,如果两个都被关闭,释放 pipe。
:linenos:

void pipeclose(struct pipe *pi, int writable)
{
    if(writable){
        pi->writeopen = 0;
    } else {
        pi->readopen = 0;
    }
    if(pi->readopen == 0 && pi->writeopen == 0){
        kfree((char*)pi);
    }
}
  • 重点是管道的读写:
    由于我们的管道是由ring buffer形式来管理的,其本身的容量只有PAGESIZE大小,因此需要使用nread和nwrite两个指针来记录当前两端分别写到哪里了(它们的绝对值可以大于PAGESIZE,关键是两者的差值)。由于必须写了才能读,因此有关系 nwrite >= nread,相等意味着当前已经读完了,就退出piperead。如果nwrite - nread == PAGESIZE 则说明已经写满了整个PAGESIZE,不能再写了,会覆盖住没读的部分。如果能写入,就会将数据写入data之中,注意由于是环形,如果nwrite % PAGESIZE != 0并且当前指针位置到环尾写不下要写入的数据,会从环头继续写,大家可以仔细阅读write的实现。
:linenos:

int pipewrite(struct pipe *pi, uint64 addr, int n)
{
    // w 记录已经写的字节数
    int w = 0;
    struct proc *p = curr_proc();
    while(w < n){
        // 若不可读,写也没有意义
        if(pi->readopen == 0){
            return -1;
        }

        if(pi->nwrite == pi->nread + PIPESIZE){
            // pipe write 端已满,阻塞
            yield();
        } else {
            // 一次读的 size 为 min(用户buffer剩余,pipe 剩余写容量,pipe 剩余线性容量)
            uint64 size = MIN(
                n - w,
                pi->nread + PIPESIZE - pi->nwrite,
                PIPESIZE - (pi->nwrite % PIPESIZE)
            );
            // 使用 copyin 读入用户 buffer 内容
            copyin(p->pagetable, &pi->data[pi->nwrite % PIPESIZE], addr + w, size);
            pi->nwrite += size;
            w += size;
        }
    }
    return w;
}

int piperead(struct pipe *pi, uint64 addr, int n)
{
    // r 记录已经写的字节数
    int r = 0;
    struct proc *p = curr_proc();
    // 若 pipe 可读内容为空,阻塞或者报错
    while(pi->nread == pi->nwrite) {
        if(pi->writeopen)
            yield();
        else
            return -1;
    }
    while(r < n && size != 0) {
        // pipe 可读内容为空,返回
        if(pi->nread == pi->nwrite)
            break;
        // 一次写的 size 为:min(用户buffer剩余,可读内容,pipe剩余线性容量)
        uint64 size = MIN(
            n - r,
            pi->nwrite - pi->nread,
            PIPESIZE - (pi->nread % PIPESIZE)
        );
        // 使用 copyout 写用户内存
        copyout(p->pagetable, addr + r, &pi->data[pi->nread % PIPESIZE], size);
        pi->nread += size;
        r += size;
    }
    return r;
}

1.3 pipe 相关系统调用

  • 首先是sys_pipe.
    这个系统调用完成了创建一个pipe并记录下两端对应file的功能。并把对应的fd写入user传入的数组地址之中传回user态。
:linenos:

// os/syscall.c
uint64 sys_pipe(uint64 fdarray) {
    struct proc *p = curr_proc();
    // 申请两个空 file
    struct file* f0 = filealloc();
    struct file* f1 = filealloc();
    // 实际分配一个 pipe,与两个文件关联
    pipealloc(f0, f1);
    // 分配两个 fd,并将之与 文件指针关联
    fd0 = fdalloc(f0);
    fd1 = fdalloc(f1);
    size_t PSIZE = sizeof(fd0);
    copyout(p->pagetable, fdarray, &fd0, sizeof(fd0));
    copyout(p->pagetable, fdarray + sizeof(uint64), &fd1, sizeof(fd1));
    return 0;
}
  • sys_close比较简单。就只是释放掉进程的fd并且清空对应file,并且设置其种类为FD_NONE.
:linenos:

uint64 sys_close(int fd)
{
    // 目前不支持 stdio 的关闭,ch7会支持这个
    if (fd <= 2 || fd > FD_BUFFER_SIZE)
        return -1;
    struct proc *p = curr_proc();
    struct file *f = p->files[fd];
    // 目前仅支持关闭 pipe
    if (f->type == FD_PIPE) {
        fileclose(f);
    } else {
        panic("fileclose: unsupported file type %d fd = %d\n", f->type, fd);
    }
    p->files[fd] = 0;
    return 0;
}

void fileclose(struct file *f)
{
    // ref == 0 才真正关闭
    if(--f->ref > 0) {
        return;
    }
    // pipe 类型需要关闭对应的 pipe
    if(f->type == FD_PIPE){
        pipeclose(f->pipe, f->writable);
    }
    // 清空其他数据
    f->off = 0;
    f->readable = 0;
    f->writable = 0;
    f->ref = 0;
    f->type = FD_NONE;
}
  • 原来的 sys_write 更名为 console_write,新 sys_write 根据文件类型分别调用 console_write 和 pipe_write。sys_read 同理。具体的区分是通过判断fd来进行的。
uint64 sys_write(int fd, uint64 va, uint64 len)
{
    if (fd == STDOUT || fd == STDERR) {
        return console_write(va, len);
    }
    if (fd <= 2 || fd > FD_BUFFER_SIZE)
        return -1;
    struct proc *p = curr_proc();
    struct file *f = p->files[fd];
    if (f->type == FD_PIPE) {
        return pipewrite(f->pipe, va, len);
    } else {
        panic("unknown file type %d\n", f->type);
    }
}

uint64 sys_read(int fd, uint64 va, uint64 len)
{
    if (fd == STDIN) {
        return console_read(va, len);
    }
    if (fd <= 2 || fd > FD_BUFFER_SIZE)
        return -1;
    struct proc *p = curr_proc();
    struct file *f = p->files[fd];
    if (f->type == FD_PIPE) {
        return piperead(f->pipe, va, len);
    } else {
        panic("unknown file type %d fd = %d\n", f->type, fd);
    }
}
  • 注意一个文件目前fd最大就是15。

【 2. 进程通讯与fork 】

  • 对fork的文件支持本来应该在chapter6引入,但是为了更好的理解管道的继承机制,我们把它放在了这个章节。 fork 为什么是毒瘤呢?因为你总是要在新增加一个东西以后考虑要不要为新功能增加 fork 支持。这一章的文件就是第一个例子,那么在 fork 语境下,子进程也需要继承父进程的文件资源,也就是PCB之中的指针文件数组。我们应该如何处理呢?我们来看看 fork 在这一个 chapter 的实现:
int fork() {
    // ...
+   for(int i = 3; i < FD_MAX; ++i)
+       if(p->files[i] != 0 && p->files[i]->type != FD_NONE) {
+           p->files[i]->ref++;
+           np->files[i] = p->files[i];
+       }
    // ...
}
  • 可以看到创建子进程时会遍历父进程,继承其所有打开的文件,并且给指定文件的ref + 1。因为我们记录的本身就只是一个指针,只需用ref来记录一个文件还有没有进程使用。此外,进程结束需要清理的资源除了内存之外增加了文件:
void freeproc(struct proc *p)
{
    // ...
+   for (int i = 3; i < FD_BUFFER_SIZE; i++) {
+       if (p->files[i] != NULL) {
+           fileclose(p->files[i]);
+       }
+   }
    // ...
}
  • 你会发现 exec 的实现竟然没有修改,注意 exec 仅仅重新加载进程执行的测例文件镜像,不会改变其他属性,比如文件。也就是说,fork 出的子进程打开了与父进程相同的文件,但是 exec 并不会把打开的文件刷掉,基于这一点,我们可以利用 pipe 进行进程间通信。
// user/src/ch6b_pipetest

char STR[] = "hello pipe!";

int main() {
    uint64 pipe_fd[2];
    int ret = pipe(&pipe_fd);
    if (fork() == 0) {
        // 子进程,从 pipe 读,和 STR 比较。
        char buffer[32 + 1];
        read(pipe_fd[0], buffer, 32);
        assert(strncmp(buffer, STR, strlen(STR) == 0);
        exit(0);
    } else {
        // 父进程,写 pipe
        write(pipe_fd[1], STR, strlen(STR));
        int exit_code = 0;
        wait(&exit_code);
        assert(exit_code == 0);
    }
    return 0;
}
  • 由于 fork 会拷贝所有文件而 exec 不会改变文件,所以父子进程的fd列表一致,可以直接使用创建好的pipe进行通信。
  • 18
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

MR_Promethus

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值