问题诊断系列「1」popen后台符号&导致僵尸进程、空间未满却新建文件失败等

记录下最近遇到的一些问题: popen后台符号&导致僵尸进程、空间未满却新建文件失败、tar压缩中返回short read生成破损的压缩包、getifaddrs返回ifa_addr可能为空。

下面是popen执行Shell命令获取命令输出打印到屏幕上的小例子

c++

#include <limits.h>
#include <ostream>
#include <stdio.h>
#include <stdlib.h>
#include <iostream>

using namespace std;

int main(int argc, char *argv[]) {
  string files;
  char buf[PIPE_BUF];
  for (int i = 1; i < argc; i++) {
    files += string(argv[i]) + " ";
  }
  string cmd = files;
  std::cout << cmd << std::endl;
  FILE *fp = popen(cmd.c_str(), "r");
  if (fp == NULL) {
    perror("popen");
    exit(1);
  }
  while (fgets(buf, sizeof(buf), fp) != NULL) {
    std::cout << buf;
    std::flush(std::cout);
  }
  pclose(fp);
}

在车端发现了很多个僵尸进程zomble,排查发现是分别由两个原因导致出现僵尸进程:

  1. 忘记调用pclose函数关闭popen打开的fd导致popen打开的进程未被父进程回收。
  2. popen的命令结尾加&后台运行符号,即类似popen("sleep 60s &", "r");

第一种情况很好理解,第二种情况完全解释清楚需要很强的系统知识,目前我所不具备。

大概来讲,popen会先fork新进程执行/bin/sh -c 提供的命令,而/bin/sh也会新建个进程执行用户指定的命令。当然其中也涉及到一些管道的操作用于拿到命令输出或者设置命令的输入。

如果在命令后边加上&的符号则/bin/sh进程会不等执行的命令而直接结束,此时如果命令还未执行完毕,则/bin/sh进程不会被父进程调用wait函数拿到其退出返回值,从而导致/bin/sh成为僵尸进程。

不过该僵尸进程在用户命令结束时也会跟着被回收,所以不会出现僵尸进程导致pid用尽的情况,不过在代码中最好要避免这种写法。

2024.06.26 在写另一篇文章从音频播放C++类的实现学习Linux系统子进程管理中想到这个问题,基本确认了上述的想法。并且由于用户命令在执行中不会关闭pipe管道,所以fgets会阻塞等待用户命令的输出不能调用pclose,此时/bin/sh成为僵尸进程。

一般车端都需要日志滚删的功能,从而不至于让日志写满磁盘,该功能的实现,一般很自然想到当剩余空间小于某个阈值时,进行日志文件的删除动作,删除按照文件修改时间由小到大,直到空间满足要求。

不过,这次遇到了问题是使用df -h查看磁盘空间未满,但是新建文件时又会报No space left on device类似的错误。

搜索发现可能的原因是文件过多,inode用尽导致的。一般完整的命令行可以直接使用df -i查看文件系统inode的使用情况,如下所示

shell

Filesystem       Inodes  IUsed    IFree IUse% Mounted on
/dev/nvme0n1p4 25493504 965653 24527851    4% /

IUse%达到100%时,此时新建文件则会报错。在我的笔记本上该值有2千万之多,不过在车端上只有30万左右,当出现一些异常情况下是有可能inode用尽。

在车端板子上df命令为阉割版本,不支持-i参数,无法查看inode使用情况,需要写个小程序调用statvfs系统api获取。在文档可以看到该函数的说明。写个如下小程序在车端运行就可以获取到inode使用情况。

c++

#include <sys/statvfs.h>
#include <sys/statvfs.h>
#include <cstdio>
#include <iostream>

using namespace std;

int main(int argc, char **argv) {
    struct statvfs fs;
    if (statvfs(argv[1], &fs) == 0) {
        cout << "f_bsize: " << fs.f_bsize << ", f_frsize: " << fs.f_frsize << ", f_blocks: " << fs.f_blocks
             << ", f_bfree: " << fs.f_bfree << ", f_bavail: " << fs.f_bavail << endl;
        cout << "f_files: " << fs.f_files << ", f_ffree: " << fs.f_ffree << ", f_favail: " << fs.f_favail << endl;
        cout << "f_fsid: " << fs.f_fsid << ", f_flag" << fs.f_flag << ", f_namemax" << fs.f_namemax << endl;
    } else {
        perror("statvfs");
    }
}

主要看f_filesf_ffree两个字段即可。果不其然,确实是板子上的文件数量过多,超过inode的限制导致的该问题。

接下来就是找到是那个模块文件数量过多,使用如下命令,可以统计当前路径下的目录含有的文件数量

shell

find . -type f | cut -d "/" -f 2 | sort | uniq -c | sort -nr

通过该命令就可以找到那个模块文件数量过多,再去分析数量过多的原因。

同时,也说明日志滚删的功能不光需要对文件大小作出限制,同时也应该对文件个数进行限制,防止文件过多inode用尽。

工作中我负责的模块会将车端上其它模块的日志打包上传到云端,使用的是popen执行tar -zcf upload.tar.gz files命令进行打包解压。注意该命令是分为两个步骤,首先是打包:将files文件打包成一个tar文件,最后是压缩:将打包好的tar文件进行gzip算法的压缩生成最终的压缩文件。

本次遇到的问题就是在打包过程中返回short read错误从而终止打包,直接进入压缩步骤,从而导致在解压后将tar还原成各个文件时报错,当然在错误发生之前打包的文件还在。

最后通过google和实验确定是在打包过程中,当前正在被打包的文件大小缩小,就会导致报该错误。通过用vim查看打包进tar文件的标头,发现报错的打包文件名,该文件可能会缩小文件size(有程序会执行覆盖重定向>该文件),所以可以确定该问题发生的原因。由于发生该错误的概率较小,所以解决办法就是遇到该报错时重试。

该问题让我学习到了tar文件的结构,其结构非常简单,即又多个File entry组成表示不同文件,每个file entry由头部和实际文件数据组成。头部的数据结构如下:

c

/* Source: https://www.gnu.org/software/tar/manual/html_node/Standard.html */
/* tar Header Block, from POSIX 1003.1-1990.  */

/* POSIX header.  */

struct posix_header
{                               /* byte offset */
  char name[100];               /*   0 */
  char mode[8];                 /* 100 */
  char uid[8];                  /* 108 */
  char gid[8];                  /* 116 */
  char size[12];                /* 124 */
  char mtime[12];               /* 136 */
  char chksum[8];               /* 148 */
  char typeflag;                /* 156 */
  char linkname[100];           /* 157 */
  char magic[6];                /* 257 */
  char version[2];              /* 263 */
  char uname[32];               /* 265 */
  char gname[32];               /* 297 */
  char devmajor[8];             /* 329 */
  char devminor[8];             /* 337 */
  char prefix[155];             /* 345 */
                                /* 500 */
};

其比较重要的是size表示文件的数据的大小,特别的是存储为ASCII码的八进制表示。字段都是对齐512字节,用0 padding。该格式看起来是个很有历史的格式。

该问题也衍生出一个思考,对于被打包的文件在打包过程中被删除或被移动会影响到打包压缩吗?

答案是不会,首先文件一旦被打开,其即使被删除也会等到打开该文件的最后一个进程被关闭时才会真的删除磁盘文件,所以删除不会有影响。而文件一旦被打开,其就是用inode作为文件标识而不是文件名字,文件被移动是不会改变inode的值,而只是变动存储该inode的目录文件,所以移动也不会有影响。

在工作项目中使用getifaddrs函数获取网卡信息,返回如下数据结构:

c

struct ifaddrs {
    struct ifaddrs  *ifa_next;    /* Next item in list */
    char            *ifa_name;    /* Name of interface */
    unsigned int     ifa_flags;   /* Flags from SIOCGIFFLAGS */
    struct sockaddr *ifa_addr;    /* Address of interface */
    struct sockaddr *ifa_netmask; /* Netmask of interface */
    union {
        struct sockaddr *ifu_broadaddr;
                        /* Broadcast address of interface */
        struct sockaddr *ifu_dstaddr;
                        /* Point-to-point destination address */
    } ifa_ifu;
#define              ifa_broadaddr ifa_ifu.ifu_broadaddr
#define              ifa_dstaddr   ifa_ifu.ifu_dstaddr
    void            *ifa_data;    /* Address-specific data */
};

可以看到返回的是一个链表,可以通过其ifa_flags,如下判断某个wifi网卡(通过ifa_name区分)其是否连接到路由器

c

(ifa->ifa_flags & IFF_UP) && (ifa->ifa_flags & IFF_RUNNING)

但是当时测试时发现同一个网卡会分别返回两次,ifa_addr->sa_family分别是AF_INETAF_INET6即ipv4和ipv6.。由于我的目的仅仅判断wifi是否连接路由器,所以我对AF_INET6进行过滤,防止出现同一个网卡判断两遍结果出现不一致的情况。

需要特别注意其中ifa_addrifa_netmask可能为NULL,所以在调用ifa_addr->sa_family之前必须要判断ifa_addr是否为空指针,否则就会和我一样悲剧了。

The ifa_addr field points to a structure containing the interface address. (The sa_family subfield should be consulted to determine the format of the address structure.) This field may contain a null pointer.

The ifa_netmask field points to a structure containing the netmask associated with ifa_addr, if applicable for the address family. This field may contain a null pointer.

而什么情况下ifa_addr会为空指针呢,通过网上搜索到的文章大概了解到类似虚拟网卡(隧道)就可能返回空指针,具体的情况还有待深入的学习。