popen() / fgets() 间歇性返回不完整的输出

3
我在Linux系统中遇到了一个奇怪的问题,涉及到popenfgets库函数。下面是一个简短的程序示例:
  1. 安装SIGUSR1信号处理程序。
  2. 创建一个辅助线程来反复向主线程发送SIGUSR1信号。
  3. 在主线程中,通过popen()执行一个非常简单的shell命令,通过fgets()获取输出,并检查输出是否符合预期长度。
输出会出现间歇性截断,为什么? 命令行调用示例:
$ gcc -Wall test.c -lpthread && ./a.out 
iteration 0
iteration 1
iteration 2
iteration 3
iteration 4
iteration 5
unexpected length: 0

我的机器详情(该程序也可以使用这个在线 C 编译器编译和运行):

$ cat /etc/redhat-release
CentOS release 6.5 (Final)

$ uname -a
Linux localhost.localdomain 2.6.32-431.17.1.el6.x86_64 #1 SMP Wed May 7 23:32:49 UTC 2014 x86_64 x86_64 x86_64 GNU/Linux

# gcc 4.4.7
$ gcc --version
gcc (GCC) 4.4.7 20120313 (Red Hat 4.4.7-4)
Copyright (C) 2010 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

# glibc 2.12
$ ldd --version
ldd (GNU libc) 2.12
Copyright (C) 2010 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.

该程序:
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <pthread.h>
#include <errno.h>

void dummy_signal_handler(int signal);
void* signal_spam_task(void* arg);
void echo_and_verify_output();
char* fgets_with_retry(char *buffer, int size, FILE *stream);

static pthread_t main_thread;

/**
 * Prints an error message and exits if the output is truncated, which happens
 * about 5% of the time.
 *
 * Installing the signal handler with the SA_RESTART flag, blocking SIGUSR1
 * during the call to fgets(), or sleeping for a few milliseconds after the
 * call to popen() will completely prevent truncation.
 */
int main(int argc, char **argv) {

    // install signal handler for SIGUSR1
    struct sigaction sa, osa;
    sa.sa_handler = dummy_signal_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    sigaction(SIGUSR1, &sa, &osa);

    // create a secondary thread to repeatedly send SIGUSR1 to main thread
    main_thread = pthread_self();
    pthread_t spam_thread;
    pthread_create(&spam_thread, NULL, signal_spam_task, NULL);

    // repeatedly execute simple shell command until output is unexpected
    unsigned int i = 0;
    for (;;) {
        printf("iteration %u\n", i++);
        echo_and_verify_output();
    }

    return 0;
}

void dummy_signal_handler(int signal) {}

void* signal_spam_task(void* arg) {
    for (;;)
        pthread_kill(main_thread, SIGUSR1);
    return NULL;
}

void echo_and_verify_output() {

    // run simple command
    FILE* stream = popen("echo -n hello", "r");
    if (!stream)
        exit(1);

    // count the number of characters in the output
    unsigned int length = 0;
    char buffer[BUFSIZ];
       while (fgets_with_retry(buffer, BUFSIZ, stream) != NULL)
        length += strlen(buffer);

    if (ferror(stream) || pclose(stream))
        exit(1);

    // double-check the output
    if (length != strlen("hello")) {
        printf("unexpected length: %i\n", length);
        exit(2);
    }
}

// version of fgets() that retries on EINTR
char* fgets_with_retry(char *buffer, int size, FILE *stream) {
    for (;;) {
        if (fgets(buffer, size, stream))
            return buffer;
        if (feof(stream))
            return NULL;
        if (errno != EINTR)
            exit(1);
        clearerr(stream);
    }
}

我认为read系统调用被信号中断了。除非你能在某个地方找到这种行为的文档,否则这似乎是fgets中的一个错误。 - Dark Falcon
这一定是你的内核(不太可能)或者libc中的某种错误。经过一些更正,我在OS X上运行了它,在未经修改的情况下在RHEL 6上也没有问题。 - Sergey L.
谢谢提供信息。我打算在几个不同的操作系统/ glibc 版本上运行一下,并回报结果。 - Josh Johnson
在 Fedora 20 上使用 glibc 2.18 也观察到了相同(错误的)行为。 - Josh Johnson
我在strace下运行了程序,并观察到fgets调用之一的read系统调用返回了完整的“hello”文本。这似乎意味着我要么使用fgets不正确,要么fgets的实现中存在错误。 - Josh Johnson
在Ubuntu 14.04上,使用eglibc 2.19也是同样的情况。 - Josh Johnson
1个回答

2
如果在使用fgets读取FILE流时发生错误,不确定在fgets返回 NULL 之前是否会将某些已读字节传输到缓冲区中(根据C99规范的7.19.7.2节)。因此,如果在fgets调用中出现SIGUSR1信号并导致EINTR,则有可能从流中丢失一些字符。

总之,如果底层系统调用可能具有可恢复的错误返回(如EINTREAGAIN),则无法使用stdio函数来读写FILE对象,因为没有保证标准库在执行这些操作时不会丢失缓冲区中的一些数据。您可以声称这是标准库实现中的“bug”,但这是C标准允许的一个“bug”。


C99 §7.19.7.2只是说“在读取错误的情况下[...]数组内容是不确定的”。与fread(§7.19.8.1)的文档相比,后者明确指出:“如果发生错误,则流的文件位置指示器的结果值是不确定的。”从fgets文档中如何推断底层stdio缓冲区的任何信息? - Josh Johnson
此外,POSIX 对于 fgets 返回 EINTR 的解释是:“读取操作由于接收到信号而终止,并且没有传输任何数据。” - Josh Johnson
@JoshuaJohnson:由于规范没有说明可以这样做,那么隐含着就不可以这样做。POSIX语句是关于fgetc而非fgets的,并且带有注释:“此参考页面描述的功能与ISO C标准对齐。这里描述的需求与ISO C标准之间的任何冲突均为无意的。POSIX.1-2008的这一卷推迟到ISO C标准”。 - Chris Dodd
Chris,感谢你的来往。我还是不太确定 :)POSIX中的那个语句在fgetc页面上,但它也适用于fgets。fgets页面说:“错误-请参阅fgetc”。C99没有提到EINTR。我不知道POSIX是否将一个错误代码的限制放在C99没有提到的地方是否会产生冲突。无论如何,我会在glibc邮件列表上提出这个问题,看看他们有什么想法。 - Josh Johnson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接