耗时两天,优化失败

本文始发于公众号【高性能架构探索】,本公众号致力于分享干货、硬货以及工作上的bug分析,欢迎关注。回复【pdf】免费获取计算机经典书籍

你好,我是雨乐!

在上一篇文章基于线程池的线上服务性能优化中,我们提到了使用线程池进行某个业务功能优化,在上线之后,实时性提高了大概24-30倍样子,基本能够满足实时性要求。在正常运行了几天之后,突然收到了报警,提示popen失败,于是打开了日志,发现有如下提示:

popen file failed, id: abc url: http:xxx.txt errno: 12

于是,开始查看错误提示,如下:

耗时两天,优化失败

看来是内存不足,于是,通过free命令查看所在机器的内存信息,如下:

耗时两天,优化失败

可用内存还有2.7G,不至于分配失败呀。

问题定位

看到popen()提示内存分配失败,首先就开始怀疑是否是wget使用有问题,但经过仔细研究之后,发现问题跟该命令无关,这是因为wget仅仅是将文件下载到本地,并不会占用过多的内存

既然问题与wget命令本身无关,那么问题苗头就指向popen本身了,于是在搜索引擎中搜索popen ENOMEM,其中有一条与本次遇到的问题很像,如下:

耗时两天,优化失败

通过该文内容,得到了一个很重要的信息,那就是popen的实现是fork+execve。熟悉fork()的开发人员都知道,fork()以当前进程作为父进程创建出一个新的子进程,并且将父进程的所有资源拷贝给子进程,这样子进程作为父进程的一个副本存在。既然fork()会生成父进程的一个副本,那么父进程所占用的所有资源,在子进程中也就会被拷贝一份。换句话说,fork()函数为clone父进程的所有资源,这样就能理解为什么当可用内存小于50%的时候,popen()会失败。

于是,为了验证文章的内容是否与本次遇到的问题一致,在本地写了一个简单的测试用例,测试代码中仅仅包含popen()函数,编译,然后使用starce ./test之后,输出如下:

...
futex(0x7ffdd648a69c, FUTEX_WAKE, 1)    = 0
futex(0x7ffdd648a69c, FUTEX_WAKE_PRIVATE, 1) = 0
pipe2([3, 4], O_CLOEXEC)                = 0
clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f82fcab37d0) = 27437
close(4)                                = 0
fcntl(3, F_SETFD, 0)                    = 0
exit_group(0) = ?
...

在上面的strace命令输出中,我们能看到一个很重要的函数那就是clone()(fork()函数会调用clone()),看来问题就在这。。。

分析源码

为了能够确认是否是因为popen()中的fork()所引起,于是找到了popen()函数的源码实现,如下:

FILE *
popen(const char *program, const char *type)
{
    struct pid * volatile cur;
    FILE *iop;
    int pdes[2];
    pid_t pid;
    char *argp[] = {"sh", "-c", NULL, NULL};
    if ((*type != 'r' && *type != 'w') || type[1] != '\0') {
        errno = EINVAL;
        return (NULL);
    }
    if ((cur = malloc(sizeof(struct pid))) == NULL)
        return (NULL);
    if (pipe(pdes) < 0) {
        free(cur);
        return (NULL);
    }
    switch (pid = fork()) {
    case -1:            /* Error. */
        (void)close(pdes[0]);
        (void)close(pdes[1]);
        free(cur);
        return (NULL);
        /* NOTREACHED */
    case 0:             /* Child. */
        {
        struct pid *pcur;
        /*
         * We fork()'d, we got our own copy of the list, no
         * contention.
         */
        for (pcur = pidlist; pcur; pcur = pcur->next)
            close(fileno(pcur->fp));
        if (*type == 'r') {
            (void) close(pdes[0]);
            if (pdes[1] != STDOUT_FILENO) {
                (void)dup2(pdes[1], STDOUT_FILENO);
                (void)close(pdes[1]);
            }
        } else {
            (void)close(pdes[1]);
            if (pdes[0] != STDIN_FILENO) {
                (void)dup2(pdes[0], STDIN_FILENO);
                (void)close(pdes[0]);
            }
        }
        argp[2] = (char *)program;
        execve(_PATH_BSHELL, argp, environ);
        _exit(127);
        /* NOTREACHED */
        }
    }
    /* Parent; assume fdopen can't fail. */
    if (*type == 'r') {
        iop = fdopen(pdes[0], type);
        (void)close(pdes[1]);
    } else {
        iop = fdopen(pdes[1], type);
        (void)close(pdes[0]);
    }
    /* Link into list of file descriptors. */
    cur->fp = iop;
    cur->pid =  pid;
    cur->next = pidlist;
    pidlist = cur;
    return (iop);
}

在上述代码中,我们可以看到popen中使用了fork()函数。当调用完fork()函数后,子进程获得父进程的数据空间、堆和栈,但是这是子进程单独拥有的,并不和父进程共享,因此修改子进程的变量不会影响父进程的变量。父进程和子进程共享正文段。进一步验证了我们之前的观点:由于fork()函数创建的子进程复制了一份父进程的资源,如果父进程内存占用过大,使得剩余内存资源不足以使得子进程进行拷贝的时候,那么popen()函数返回失败

问题解决

既然使用popen会存在fork()函数创建的子进程拷贝父进程资源的情况,那么有没有其它实现方法,能够使得子进程不对父进程的资源进行拷贝呢?

这就是vfork()函数!vfork()的父子进程是共享数据的,也就是说使用vfork()产生的子进程不会复制父进程的资源,而是与父进程共享同一份资源,所以在子程序中修改变量,父进程的变量也会被修改。既然可以使用vfork()能够解决此次遇到的问题,那么,也就可以使用vfork()函数来实现popen()函数的功能了,用以解决此次问题。

vfork()用于创建一个新进程,而新进程的目的是exec一个新程序。vfork()会挂起父进程直到子进程终止或者运行了一个新的可执行文件的映像。vfork()和fork()一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec或exit,于是也就不会存放该地址空间。

为了验证使用vfork()是否会调用clone,写了一个简单的代码,然后使用strace ./test命令,输出如下:

fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 2), ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f64dcaac000
write(1, "1\n", 21)                      = 2

可见,用vfork就并没有调用clone。

于是开始着手使用vfork()来优化代码,为了与libc中popen进行区分,在此,以vpopen()和vpclose()来实现之前popen()和pclose()函数的功能,代码如下:

//#ifdef  OPEN_MAX
//static long openmax = OPEN_MAX;
//#else
static long openmax = 0;
//#endif

/*
 * If OPEN_MAX is indeterminate, we're not
 * guaranteed that this is adequate.
 */
#define OPEN_MAX_GUESS 1024

long open_max(void)
{
    if (openmax == 0) {      /* first time through */
        errno = 0;
        if ((openmax = sysconf(_SC_OPEN_MAX)) < 0) {
           if (errno == 0)
               openmax = OPEN_MAX_GUESS;    /* it's indeterminate */
           else
               printf("sysconf error for _SC_OPEN_MAX");
        }
    }

    return(openmax);
}

static pid_t    *childpid = NULL;  /* ptr to array allocated at run-time */
static int      maxfd;  /* from our open_max(), {Prog openmax} */

FILE *vpopen(const char* cmdstring, const char *type)
{
    int pfd[2];
    FILE *fp;
    pid_t   pid;

    if((type[0]!='r' && type[0]!='w')||type[1]!=0)
    {
        errno = EINVAL;
        return(NULL);
    }

    if (childpid == NULL) {     /* first time through */  
                /* allocate zeroed out array for child pids */  
        maxfd = open_max();  
        if ( (childpid = (pid_t *)calloc(maxfd, sizeof(pid_t))) == NULL)  
            return(NULL);  
    }

    if(pipe(pfd)!=0)
    {
        return NULL;
    }

    if((pid = vfork())<0)
    {
        return(NULL);   /* errno set by fork() */  
    }
    else if (pid == 0) {    /* child */
        if (*type == 'r')
        {
            close(pfd[0]);  
            if (pfd[1] != STDOUT_FILENO) {  
                dup2(pfd[1], STDOUT_FILENO);  
                close(pfd[1]);  
            }           
        }
        else
        {
            close(pfd[1]);  
            if (pfd[0] != STDIN_FILENO) {  
                dup2(pfd[0], STDIN_FILENO);  
                close(pfd[0]);  
            }           
        }

        /* close all descriptors in childpid[] */  
        for (int i = 0; i < maxfd; i++)  
        if (childpid[ i ] > 0)  
            close(i);  

        execl("/bin/sh", "sh", "-c", cmdstring, (char *) 0);  
        _exit(127);     
    }

    if (*type == 'r') {  
        close(pfd[1]);  
        if ( (fp = fdopen(pfd[0], type)) == NULL)  
            return(NULL);  
    } else {  
        close(pfd[0]);  
        if ( (fp = fdopen(pfd[1], type)) == NULL)  
            return(NULL);  
    }

    childpid[fileno(fp)] = pid; /* remember child pid for this fd */  
    return(fp);     
}

int vpclose(FILE *fp)
{
    int     fd, stat;  
    pid_t   pid;  

    if (childpid == NULL)  
        return(-1);     /* popen() has never been called */  

    fd = fileno(fp);  
    if ( (pid = childpid[fd]) == 0)  
        return(-1);     /* fp wasn't opened by popen() */  

    childpid[fd] = 0;  
    if (fclose(fp) == EOF)  
        return(-1);  

    while (waitpid(pid, &stat, 0) < 0)  
        if (errno != EINTR)  
            return(-1); /* error other than EINTR from waitpid() */  

    return(stat);   /* return child's termination status */  

}

修改现有线上代码如下:

std::string cmd = "wget -t 3 -c -r -nd -P /data1/data/ –delete-after -np -A .txt http://url.txt";
auto fp = vpopen(cmd.str().c_str(), "r");
if (!fp) {
  return;
}

编译,运行,然后在线上灰度,开始焦急的等待,此时竟然希望该进程内存占用赶紧超过50%😭。

耗时两天,优化失败

赶紧看了下日志,没有输出错误日志,再通过redis命令查询该订单是否已经被加载:

耗时两天,优化失败

一切正常,看来问题已经解决(至少目前来看😁)

结语

在本次优化中,使用基于vfork()的vpopen()函数来提到之前的基于fork()实现的popen()函数。最重要的一个原因是使用fork()的popen(),在创建子进程的时候会进行资源复制,即使使用写时复制技术,如果没有足够的内存来复制父进程使用的内存,fork也会失败。而之所以采用vfork(),正式因为其创建的子进程与父进程共享同一份资源,省略了资源拷贝这一个过程,进而解决了此次遇到的内存不足的问题。

但是,正式因为vfork()与父进程共享一份资源,使用稍有不慎,就会导致意想不到的后果,因此在某些内核版本中已经将其标记为废弃(obsolescent),所以本次使用vfork()来实现仅仅是一个临时版本,先让线上功能能够正常使用,后续将继续优化该功能。

生命不息,优化不止!

好了,今天的文章就到这,我们下期见!

版权归作者所有,如需转载请联系作者

(2)
上一篇 2022年6月17日 下午1:34
下一篇 2022年6月23日 上午9:36

相关推荐