用 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\n");
14         return 1;
15     }
16 
17     pid = fork();
18     if (pid < 0) {
19         printf("Fail to fork\n");
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\n", 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\n", 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\n");
13         return 1;
14     }
15 
16     pid = fork();
17     if (pid < 0) {
18         printf("Fail to fork\n");
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\n", 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\n");
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\n", 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\n");
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\n", 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\n", 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\n", 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\n");
 50                 break;
 51             case WRONG_ANSWER:
 52                 printf("Wrong answer\n");
 53                 break;
 54             default:
 55                 printf("Invalid judge exit code\n");
 56                 break;
 57             }
 58         } else if (WIFSIGNALED(stat_j) && WTERMSIG(stat_j) == SIGPIPE) {
 59             // 裁判程序由于 SIGPIPE 退出
 60             printf("Wrong answer\n");
 61         } else {
 62             // 裁判程序异常退出
 63             printf("Judge exit abnormally\n");
 64         }
 65     } else {
 66         // 用户程序运行时错误
 67         printf("Runtime error\n");
 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.\n");
 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.\n");
 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。例如它并没有资源限制,也没有很好地处理裁判程序的异常等等,这只是作为管道应用的一个例子。

posted @ 2018-03-03 13:40  TsReaper  阅读(1022)  评论(0编辑  收藏  举报