更新时间: 2018-08-17 17:14:12       分类: 系统编程


背景

开发OJ的在线判题功能,需要开一个子进程在沙箱中进行代码的编译、运行并计算使用的cpu资源和内存资源。

但实际上在实现计进程消耗的资源这一步上踩了无数的坑,特别是内存。在和一群大佬们探讨了1个月之后才找到了一个还不错的解决方案,所以特此记录一下。

RLimit

说到子进程的资源管理,接触过Linux系统的编程的人第一反应往往都是RLimit,这是由Linux系统提供的进程资源限制API,具体包括以下两个函数:

int getrlimit( int resource, struct rlimit *rlptr );
int setrlimit( int resource, const struct rlimit *rlptr );

getrlimit()用于获取当前进程的RLimit, setrlimit()则用于设置当前进程的RLimit。

resource资源有如下几种选择:

RLIMIT_AS //进程的最大虚内存空间,字节为单位。
RLIMIT_CORE //内核转存文件的最大长度。
RLIMIT_CPU //最大允许的CPU使用时间,秒为单位。当进程达到软限制,内核将给其发送SIGXCPU信号,这一信号的默认行为是终止进程的执行。然而,可以捕捉信号,处理句柄可将控制返回给主程序。如果进程继续耗费CPU时间,核心会以每秒一次的频率给其发送SIGXCPU信号,直到达到硬限制,那时将给进程发送 SIGKILL信号终止其执行。
RLIMIT_DATA //进程数据段的最大值。
RLIMIT_FSIZE //进程可建立的文件的最大长度。如果进程试图超出这一限制时,核心会给其发送SIGXFSZ信号,默认情况下将终止进程的执行。
RLIMIT_LOCKS //进程可建立的锁和租赁的最大值。
RLIMIT_MEMLOCK //进程可锁定在内存中的最大数据量,字节为单位。
RLIMIT_MSGQUEUE //进程可为POSIX消息队列分配的最大字节数。
RLIMIT_NICE //进程可通过setpriority() 或 nice()调用设置的最大完美值。
RLIMIT_NOFILE //指定比进程可打开的最大文件描述词大一的值,超出此值,将会产生EMFILE错误。
RLIMIT_NPROC //用户可拥有的最大进程数。
RLIMIT_RTPRIO //进程可通过sched_setscheduler 和 sched_setparam设置的最大实时优先级。
RLIMIT_SIGPENDING //用户可拥有的最大挂起信号数。
RLIMIT_STACK //最大的进程堆栈,以字节为单位

传入不同的resource值,便是对当前进程的不同运行资源设定限制。

再说每种资源设定时所使用的rlimit,分为两个层次,rlim_cur软限制值,rlim_max硬限制值,如下:

struct rlimit {
    rlim_t    rlim_cur;    /* soft limit: current limit */
    rlim_t    rlim_max;    /* hard limit: maximum value for rlim_cur */
};

这两者的区别在于:

  1. 软限制的值必须小于硬限制的值
  2. 硬限制只能由具有超级权限的进程(uid为0的用户起的进程)提升,其他普通进程可以减低硬限制的值,但不能提升
  3. 任何一个进程都可以调整自己软限制的值

另外需要提出的一点是,父进程所设定的RLIMIT将会被其子进程继承。借助这一点,我们便可以实现对于子进程沙箱运行时的资源消耗限制了。

Rusage

上面的API看起来很简洁、很好用。但实际上用过RLimit的程序员都知道,RLimit对于资源的限定并不是特别准确,只能起到一个粗糙的限制。

另外在使用RLimit对内存等资源做限定时,在超过硬限制值的时候很有可能会引发段错误等异常,这会导致很多复杂的信号处理。

仅对于在线编译和判题而言,使用RLimit便不再合适了:无法实现精确的内存控制,以及引发信号异常时的复杂处理使得这样的限制基本无法满足我们的需求。

参考目前主流的开源OJ,大多数采用了一种相对比较折中的办法:

  1. 使用setrlimit设置一个相对较大的限制,保证子进程的运行不会对系统产生危害。

  2. 在这个较大的限制下,让用户提交的程序能够完整的运行完毕,然后再去计算实际的使用量,跟题目给定的限定量做对比,最终做出内存是否超限的判断。

  3. 运行时间的限制,使用一个后台的计时线程,在线程计时结束后将子进程kill掉。

于是我们将用到rusgae这个结构体,这是Linux用于计算进程资源使用的一套API,其中主要的函数和结构体定义如下(Linux man):

int getrusage(int who, struct rusage *r_usage);

who可以取如下值:

这个函数将会把该进程的资源使用情况写入r_usgae指向的结构体中,下面是rusage的定义:

struct rusage {
             struct timeval ru_utime; /* user time used */
             struct timeval ru_stime; /* system time used */
             long ru_maxrss;          /* max resident set size */
             long ru_ixrss;           /* integral shared text memory size */
             long ru_idrss;           /* integral unshared data size */
             long ru_isrss;           /* integral unshared stack size */
             long ru_minflt;          /* page reclaims */
             long ru_majflt;          /* page faults */
             long ru_nswap;           /* swaps */
             long ru_inblock;         /* block input operations */
             long ru_oublock;         /* block output operations */
             long ru_msgsnd;          /* messages sent */
             long ru_msgrcv;          /* messages received */
             long ru_nsignals;        /* signals received */
             long ru_nvcsw;           /* voluntary context switches */
             long ru_nivcsw;          /* involuntary context switches */
     };

对于内存的计算,主要会使用ru_maxrss这个变量,其大小的含义是一个进程在运行期间,所使用过的最大驻留集大小,特别的,当who被指定为RUSAGE_CHILDREN时,返回各子进程最大驻留集的大小中最大的一个,而不是进程树中最大的最大驻留集。

之所以要强调最大这一点,是因为这里是一个很严重的坑,接下来展开解释为什么这样的特性会带来问题。

写时复制引发的残留

熟悉Linux进程的人都知道,Linux中所有的进程都是通过fork()复制来实现的,而为了减少创建进程带来的堆栈消耗和性能影响,Linux使用了写时复制机制来快速创建进程。

有关写时复制和进程的更多内容,可参考此文

也就是说,一个子进程刚刚产生时,它的堆栈空间和父进程是完全一致的,那么从一开始它就拥有和父进程同样的ru_maxrss,如果父进程的ru_maxrss比较大,那么由于rusage计算值取最大值,就算在触发写时复制后,子进程使用的实际最大驻留集大小被更新,我们获得的也还是父进程的那个值,也就是说我们永远拿不到子进程真实使用的内存。

那么难道没有什么办法将这个ru_maxrss清零重新计算吗?答案是有的,那就是exec系函数,这个系别的函数通过将当前进程的使用权转交给另一个程序,这时候进程原有的所有运行堆栈等数据将全部被销毁,因此ru_maxrss也会被清零计算。

似乎有点绕,如此看来execfork都是“产生新程序”的手段,但这两者在概念上其实有着非常本质的不同,不妨对比一下:

要想理解上面的内容,必须要明白进程和程序的区别,简单的来说可以把进程想象做是程序的载体:同一个进程可以运行不同的程序,而同一个程序可以运行于多个不同的进程。

好了,现在我们可以设计出能够正确计算出内存使用的方案了,接下来我们来分析和对比一下之前错误的方案以及正确的方案差别在哪里。

(整个方案的流程设计到的细节会很多,流程也比较长,所以这里采用图示的方案大概展示一下)

错误方案

在这个设计中,沙箱主程序直接由用户的request进程通过dlopen()的方式加载进来,这个过程既没有发生fork也没有发生exec,因此沙箱主程序所在的进程就是request对应的进程,而其父进程是体积庞大的server daemon进程,这样子就自然继承了来自daemon的maxrss

接着沙箱会fork出子进程来运行用户提交的程序,但是这个子进程还是继承了那个庞大的maxrss,而最终沙箱就是通过计算这个子进程的maxrss来返回实际内存使用的。

虽然child进程通过execve清洗过一次数据堆栈,但是这没有改变它在一开始所拥有的那个庞大的maxrss(因为getrusage是在watcher里调用,所以是从fork开始就计算的,而不是从execve开始计算)。所以我们最后得到的结果基本永远是哪个很大的内存值,而不是程序的实际使用值。

正确方案

如果保持沙箱的代码结构不变,就必须要降低watcher进程的maxrss值,这样child进程就不会继承一个很大的maxrss初始值,我们才能够计算到程序的真实内存用量。

当然也可以修改child部分的逻辑,不再使用fork来产生child,而是通过exec方式来产生,但这样child就会和watcher完全分离。实际上child和watcher之间存在大量的通信,这样做就需要借助跨进程通信来实现,复杂度大大提高了。

所以最简单的解决方案是,不再使用dlopen来加载沙箱,而是通过exec来加载,这样的话我们需要一个命令版本的沙箱,通过命令行传参来调用沙箱,这样的改造相对来说还是比较小的。

于是我们有了下面的方案(其实就改了一个地方):

鸣谢

此文的沙箱和解决思路均参考自青岛大学OnlineJudge开发团队。

特别感谢沙箱作者李同学给予的技术指导和方案!

如有侵权或是错误,请联系我修改相关内容。


评论

还没有评论