• 用 Linux 管道实现 online judge 的交互题功能


    想给 OJ 增加一个交互题的功能。这一篇博客中,我们首先介绍 Linux 管道,之后使用 Linux 管道实现一个简单的交互题功能。

    Linux 管道

    最近了解了一种 Linux 进程间通信的方法:管道。就像现实生活中的管道一样,Linux 的管道也有两头,一头输入,一头输出。

    管道其实就是一块进程间共享的缓冲区,缓冲区的大小是固定的。如果管道内没有数据,那么从管道 read 的操作就会暂时被 block 住,直到另一个进程往管道中写入数据;如果管道的缓冲区已经被塞满了,那么向管道 write 的操作也会被 block 住,直到另一个进程从管道里读数据(其实就是 OS 课上学的生产者消费者模型)。

    匿名管道

    Linux 中有两种管道:匿名管道和命名管道。

    C 语言中,匿名管道使用 unistd.h 下的 pipe 函数创建,函数的原型是  int pipe(int filedes[2]); 。传入一个大小为 2 的 int 数组,管道创建后,数组的第 0 位就存入管道读取端的 file descriptor,第 1 位就存入管道写入端的 file descriptor。若管道创建成功函数返回 0,失败返回 -1。

    下面是一个创建匿名管道,父进程向子进程发送信息的例子:

     1 #include <stdio.h>
     2 #include <string.h>
     3 #include <unistd.h>
     4 #include <sys/types.h>
     5 #include <wait.h>
     6 
     7 int main() {
     8     pid_t pid;
     9     int my_pipe[2];
    10 
    11     // 创建管道。创建后,my_pipe[0] 是读取管道的 fd,my_pipe[1] 是写入管道的 fd
    12     if (pipe(my_pipe) < 0) {
    13         printf("Fail to create pipe
    ");
    14         return 1;
    15     }
    16 
    17     pid = fork();
    18     if (pid < 0) {
    19         printf("Fail to fork
    ");
    20         return 1;
    21     } else if (pid == 0) {
    22         // 由于 fork 后 file descriptor 仍然保留,要关闭不使用的管道写入端
    23         close(my_pipe[1]);
    24 
    25         // 从管道读取数据
    26         char buf[256];
    27         read(my_pipe[0], buf, 256);
    28         printf("Child process received: %s
    ", buf);
    29 
    30         // 读取完毕,关闭管道
    31         close(my_pipe[0]);
    32     } else {
    33         close(my_pipe[0]);
    34 
    35         // 向管道写入数据
    36         char buf[256] = {0};
    37         strcpy(buf, "Hello world");
    38         write(my_pipe[1], buf, 256);
    39         printf("Parent process sent: %s
    ", buf);
    40 
    41         // 写入完毕,关闭管道
    42         close(my_pipe[1]);
    43 
    44         // 等待子进程结束
    45         wait(NULL);
    46     }
    47 
    48     return 0;
    49 }

    如果管道写入端已经关闭,那么管道读取端继续读取,将会读到 EOF。感觉很合理。

    但是如果管道读取端已经关闭,管道写入端继续写入时,将会收到 SIGPIPE 信号。

     1 #include <stdio.h>
     2 #include <string.h>
     3 #include <unistd.h>
     4 #include <sys/types.h>
     5 #include <wait.h>
     6 
     7 int main() {
     8     pid_t pid;
     9     int my_pipe[2];
    10 
    11     if (pipe(my_pipe) < 0) {
    12         printf("Fail to create pipe
    ");
    13         return 1;
    14     }
    15 
    16     pid = fork();
    17     if (pid < 0) {
    18         printf("Fail to fork
    ");
    19         return 1;
    20     } else if (pid == 0) {
    21         close(my_pipe[0]);
    22 
    23         // 为了尽量保证父进程管道先关闭,先 sleep 1 秒
    24         sleep(1);
    25 
    26         // 向管道写入数据
    27         char buf[256] = {0};
    28         strcpy(buf, "Hello world");
    29         write(my_pipe[1], buf, 256);
    30 
    31         close(my_pipe[1]);
    32     } else {
    33         close(my_pipe[1]);
    34         close(my_pipe[0]);
    35 
    36         // 读取子进程退出状态
    37         int status;
    38         wait(&status);
    39         printf("Child process exit due to signal %d
    ", WTERMSIG(status));
    40     }
    41 
    42     return 0;
    43 }

    程序执行的结果是 

    Child process exit due to signal 13

    13 号信号正是 SIGPIPE。进程对 SIGPIPE 的默认处理是退出,但是这样做对很多服务进程是不合理的。试想一下,服务进程不知道客户进程意外断开,继续给客户进程写信息,结果收到了 SIGPIPE 信号让自己退出了,后续服务也就无法继续了。所以对于服务进程来说,一般会无视 SIGPIPE 信号。此时 write 将返回 -1,并且 errno 被设置为 EPIPE。

    命名管道

    从匿名管道的创建和应用中我们可以看出,匿名管道可以用于父子进程之间的通信。不过,如果两个没有父子关系的进程也想要用管道通信该怎么办呢?这时候就要使用命名管道了。

    我们通过 sys/stat.h 中的 mkfifo 这个函数创建命名管道,该函数的原型是  int mkfifo(const char *pathname, mode_t mode) ,其中 pathname 是要创建管道文件的路径,mode 则是这个特殊文件的访问权限。调用该函数后,会在文件系统中创建一个管道文件作为命名管道的入口。如果使用 ls -l 查看这个文件的详细信息,会发现代表文件类型的那个字母是 p,说明它是管道文件。要注意的是,管道文件只是作为命名管道的入口,命名管道中的信息传递是直接通过内核进行的,并不会对文件系统进行读写(不然进程间通信该有多慢啊...)。所以说这个管道文件更像一个“标记”,文件本身是没有内容的。

    完成管道文件的创建后,我们通过 open 函数,像打开普通文件一样打开管道文件,就可以进行管道的读写了。不过,只有当读取方和写入方都尝试打开管道文件时,才能在管道中读写数据,否则 open 函数阻塞(当然也可以设置 open 函数不阻塞,不过这里就不详细介绍了)。管道打开后,命名管道的特性就和匿名管道是一样的了。

     1 #include <stdio.h>
     2 #include <string.h>
     3 #include <unistd.h>
     4 #include <fcntl.h>
     5 #include <sys/stat.h>
     6 #include <sys/types.h>
     7 #include <wait.h>
     8 
     9 int main() {
    10     pid_t pid[2];
    11 
    12     // 创建管道文件
    13     mkfifo("test.fifo", 0644);
    14 
    15     pid[0] = fork();
    16     if (pid[0] < 0) {
    17         printf("Fail to fork child process #0
    ");
    18         return 1;
    19     } else if (pid[0] == 0) {
    20         // 打开管道文件读取端
    21         int in = open("test.fifo", O_RDONLY);
    22 
    23         // 读取数据
    24         char buf[256];
    25         read(in, buf, 256);
    26         printf("Child process #0 received: %s
    ", buf);
    27 
    28         // 读取完毕,关闭管道
    29         close(in);
    30         return 0;
    31     }
    32 
    33     pid[1] = fork();
    34     if (pid[1] < 0) {
    35         printf("Fail to fork child process #1
    ");
    36         return 1;
    37     } else if (pid[1] == 0) {
    38         // 打开管道文件写入端
    39         int out = open("test.fifo", O_WRONLY);
    40 
    41         // 写入数据
    42         char buf[256] = {0};
    43         strcpy(buf, "Hello world");
    44         write(out, buf, 256);
    45 
    46         // 写入完毕,关闭管道
    47         close(out);
    48         return 0;
    49     }
    50 
    51     // 等待子进程退出
    52     while (wait(NULL) > 0);
    53     return 0;
    54 }

    可以看到,命名管道和匿名管道相比,主要是有了一个“名字”。这样,互相没有关系的进程就可以通过名字打开同一个管道,进行进程间通信。

    我们也可以在 shell 中使用 mkfifo 命令创建管道文件,并进行命名管道的读写。

    1 tsreaper@TsReaper-VBox:~$ mkfifo test.fifo -m644 # -m 选项用于设置文件权限
    2 tsreaper@TsReaper-VBox:~$ cat test.fifo

    使用 cat 命令打开 test.fifo 后,由于还没有其它进程向管道中写入信息,cat 命令暂时被阻塞。我们可以打开另一个终端,输入下面的命令。

    1 tsreaper@TsReaper-VBox:~$ echo "Hello world" > test.fifo

    可以发现,之前使用 cat 命令的终端马上收到了 Hello world 的信息并输出,cat 命令成功退出。

    使用管道实现交互题

    下面我们使用命名管道,实现一个简单的交互题功能。

    需求分析

    我们需要实现裁判进程和用户进程之间的通信。

    裁判进程先向用户进程输出测试数据组数 $T$,之后随机生成 $T$ 个 A + B Problem,并一个一个向用户提问,每提问一次就等待用户的回答,再进行下一个提问。裁判进程用 exit code 的方式向父进程告知用户程序的正确与否。

    用户进程需要从裁判进程读入测试数据组数和相应的问题,计算出结果后将结果输出给裁判进程。

    裁判程序和用户程序

    裁判程序和用户程序的书写都非常简单,不再详加描述。有一点需要注意:裁判程序和用户程序输出后,需要马上“冲刷”(flush)标准输出的缓存区(在 C++ 里是  fflush(stdout) ),这样才能让另一方马上读到数据。

    首先是裁判程序。编译后可执行文件名为 judge。

     1 #include <stdio.h>
     2 #include <stdlib.h>
     3 #include <time.h>
     4 
     5 #define CASE_NUM 5
     6 
     7 #define OK 0
     8 #define WRONG_ANSWER 1
     9 
    10 int main() {
    11     // 用时间作为随机数种子
    12     srand(time(0));
    13 
    14     // 输出测试数据组数
    15     printf("%d
    ", CASE_NUM);
    16     fflush(stdout);
    17 
    18     for (int i = 0; i < CASE_NUM; i++) {
    19         int a = rand() % 100;
    20         int b = rand() % 100;
    21         int c;
    22 
    23         // 输出提问并等待回答
    24         printf("%d %d
    ", a, b);
    25         fflush(stdout);
    26         scanf("%d", &c);
    27 
    28         // 判定答案
    29         if (a + b != c) {
    30             return WRONG_ANSWER;
    31         }
    32     }
    33 
    34     return OK;
    35 }

    其次是用户程序。编译后可执行文件名为 user。

     1 #include <stdio.h>
     2 
     3 int main() {
     4     int cas;
     5     
     6     scanf("%d", &cas);
     7     while (cas--) {
     8         int a, b;
     9         scanf("%d%d", &a, &b);
    10         printf("%d
    ", a + b);
    11         fflush(stdout);
    12     }
    13     
    14     return 0;
    15 }

    交互主程序

    主程序的编写也非常简单。我们只需要通过父进程创建两个子进程,让它们分别打开命名管道的两端,再使用 dup2 函数将命名管道的 file descriptor 与标准输入/输出绑定,最后使用 exec 函数分别在两个进程中执行已编译的裁判程序和用户程序即可。

    但有一些判断需要注意:如果裁判程序提前退出导致用户程序收到 SIGPIPE(例如裁判程序认为用户答案错误,不再进行提问),此时应根据裁判程序的结果进行判定,而不是判定用户程序出现运行时错误;如果用户程序提早退出,那么裁判程序向用户程序写入时会收到 SIGPIPE 信号而退出,此时应看用户程序是否正常退出,若正常退出则判定答案错误,否则判定运行时错误。

      1 #include <stdio.h>
      2 #include <unistd.h>
      3 #include <signal.h>
      4 #include <fcntl.h>
      5 #include <sys/types.h>
      6 #include <sys/stat.h>
      7 #include <sys/wait.h>
      8 
      9 #define OK 0
     10 #define WRONG_ANSWER 1
     11 
     12 void run_judge() {
     13     // 打开管道文件。注意打开顺序,否则会造成死锁!
     14     int in = open("u2j.fifo", O_RDONLY);
     15     int out = open("j2u.fifo", O_WRONLY);
     16 
     17     // 重定向标准输入输出
     18     dup2(in, 0);
     19     dup2(out, 1);
     20     close(in);
     21     close(out);
     22 
     23     // 执行裁判程序
     24     execl("judge", "judge", NULL);
     25 }
     26 
     27 void run_user() {
     28     // 打开管道文件。注意打开顺序,否则会造成死锁!
     29     int out = open("u2j.fifo", O_WRONLY);
     30     int in = open("j2u.fifo", O_RDONLY);
     31 
     32     // 重定向标准输入输出
     33     dup2(in, 0);
     34     dup2(out, 1);
     35     close(in);
     36     close(out);
     37 
     38     // 执行用户程序
     39     execl("user", "user", NULL);
     40 }
     41 
     42 void verdict(int stat_j, int stat_u) {
     43     if (WIFEXITED(stat_u) || (WIFSIGNALED(stat_u) && WTERMSIG(stat_u) == SIGPIPE)) {
     44         // 用户程序正常退出,或由于 SIGPIPE 退出,需要裁判程序判定
     45         if (WIFEXITED(stat_j)) {
     46             // 裁判程序正常退出
     47             switch (WEXITSTATUS(stat_j)) {
     48             case OK:
     49                 printf("Accepted
    ");
     50                 break;
     51             case WRONG_ANSWER:
     52                 printf("Wrong answer
    ");
     53                 break;
     54             default:
     55                 printf("Invalid judge exit code
    ");
     56                 break;
     57             }
     58         } else if (WIFSIGNALED(stat_j) && WTERMSIG(stat_j) == SIGPIPE) {
     59             // 裁判程序由于 SIGPIPE 退出
     60             printf("Wrong answer
    ");
     61         } else {
     62             // 裁判程序异常退出
     63             printf("Judge exit abnormally
    ");
     64         }
     65     } else {
     66         // 用户程序运行时错误
     67         printf("Runtime error
    ");
     68     }
     69 }
     70 
     71 int main() {
     72     // 创建管道文件
     73     mkfifo("j2u.fifo", 0644);
     74     mkfifo("u2j.fifo", 0644);
     75     
     76     pid_t pid_j, pid_u;
     77 
     78     // 创建裁判进程
     79     pid_j = fork();
     80     if (pid_j < 0) {
     81         printf("Fail to create judge process.
    ");
     82         return 1;
     83     } else if (pid_j == 0) {
     84         run_judge();
     85         return 0;
     86     }
     87 
     88     // 创建用户进程
     89     pid_u = fork();
     90     if (pid_u < 0) {
     91         printf("Fail to create user process.
    ");
     92         return 1;
     93     } else if (pid_u == 0) {
     94         run_user();
     95         return 0;
     96     }
     97 
     98     // 等待进程运行结束,并判定结果
     99     int stat_j, stat_u;
    100     waitpid(pid_j, &stat_j, 0);
    101     waitpid(pid_u, &stat_u, 0);
    102     verdict(stat_j, stat_u);
    103     
    104     return 0;
    105 }

    这样我们就完成了简单的交互题功能。可以将用户程序改为错误的答案,或不输出答案直接退出,或故意制造一个运行时错误等等进行测试。这个交互提功能虽然简单,但还是能覆盖这些情况的。

    当然啦,这个交互题功能还不能直接用于 online judge。例如它并没有资源限制,也没有很好地处理裁判程序的异常等等,这只是作为管道应用的一个例子。

  • 相关阅读:
    2014复习提纲
    string库与char[]
    扩展欧几里得算法及其应用
    1002 [FJOI2007]轮状病毒
    node源码详解(三)—— js代码在node中的位置,process、require、module、exports的由来
    node源码详解(二 )—— 运行机制 、整体流程
    node源码详解 (一)
    html 第一天随记(个人不常用的标签)
    Siebel电信业务流程
    Siebel层次架构
  • 原文地址:https://www.cnblogs.com/tsreaper/p/pipe-interactive.html
Copyright © 2020-2023  润新知