耗时两天,优化失败
你好,我是雨乐!
在上一篇文章基于线程池的线上服务性能优化中,我们提到了使用线程池进行某个业务功能优化,在上线之后,实时性提高了大概24-30倍样子,基本能够满足实时性要求。在正常运行了几天之后,突然收到了报警,提示popen失败,于是打开了日志,发现有如下提示:
代码语言:javascript代码运行次数:0运行复制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之后,输出如下:
代码语言:javascript代码运行次数:0运行复制...futex(0x7ffdd648a69c, FUTEX_WAKE, 1) = 0futex(0x7ffdd648a69c, FUTEX_WAKE_PRIVATE, 1) = 0pipe2([3, 4], O_CLOEXEC) = 0clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f82fcab37d0) = 27437close(4) = 0fcntl(3, F_SETFD, 0) = 0exit_group(0) = ?...登录后复制
在上面的strace命令输出中,我们能看到一个很重要的函数那就是clone()(fork()函数会调用clone()),看来问题就在这。。。
源码分析为了能够确认是否是因为popen()中的fork()所引起,于是找到了popen()函数的源码实现,如下:
代码语言:javascript代码运行次数:0运行复制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()是否会调用clone,写了一个简单的代码,然后使用strace ./test命令,输出如下:
代码语言:javascript代码运行次数:0运行复制fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 2), ...}) = 0mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f64dcaac000write(1, "1\n", 21) = 2登录后复制
可见,用vfork就并没有调用clone(可能与内核版本有关,待再次验证)。
于是开始着手使用vfork()来优化代码,为了与libc中popen进行区分,在此,以vpopen()和vpclose()来实现之前popen()和pclose()函数的功能,代码如下:
代码语言:javascript代码运行次数:0运行复制//#ifdef OPEN_MAX//static long openmax = OPEN_MAX;//#elsestatic 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 */ }登录后复制
修改现有线上代码如下:
代码语言:javascript代码运行次数:0运行复制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()来实现仅仅是一个临时版本,先让线上功能能够正常使用,后续将继续优化该功能。
生命不息,优化不止!
好了,今天的文章就到这,我们下期见!
以上就是耗时两天,优化失败的详细内容,更多请关注乐哥常识网其它相关文章!