用 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。例如它并没有资源限制,也没有很好地处理裁判程序的异常等等,这只是作为管道应用的一个例子。