1. 程式人生 > >設計實現OJ平臺的遇到的一些問題和解決方法

設計實現OJ平臺的遇到的一些問題和解決方法

需求

畢業設計,實現一個能夠自動編譯、執行、監測程式執行使用資源、惡意系統呼叫的監控的一個OJ平臺。
在設計實現的過程中的想法、碰到的問題、求解的過程以及方法,在這裡記錄下來。

基礎結構

OJ主要由前端系統(WEB)和後端的判題程式構成,想法是後端的裁判程式做通用點,減少和前端系統的耦合,所以把後端給分離出來成一個獨立的程式,大概的結構圖是這樣的。
系統結構圖
解釋下:
1. 前端其實可以由任何流行的web語言來實現。
2. 這裡的代理可有可無,代理在這裡可以實現很多功能,比如負載均衡、資料庫的業務邏輯等都可以在這裡實現。
3. 裁判模組主要實現程式的編譯、執行、監控、給出判定結果,是無狀態的,所以整個系統的擴充套件性高。

後端使用到的技術

作業系統選的是linux,原因是工具多,穩定,系統API豐富。
裁判模組用的語言是C++,主要原因是效能、系統程式設計方便。
裁判模組的網路i/o用的是cheetah,一個事件驅動的網路庫。
模組間的通訊採用的是protobuf。

裁判模組的設計

這裡借鑑了nginx的設計,單執行緒多程序的方式,由master程序和minion程序(數量可配置)組成。

  1. master管理minion程序的生命週期,在minion程序掛掉之後會重啟。minion程序,在初始化的時候建立一個監聽socket,fork出minion程序。
  2. minion程序共享master程序的監聽socket,每accept一個連線,加入到reactor裡,每讀取到一個請求執行一次判題的過程。
  3. 判題的過程是編譯程式,fork一個子程序,將標準輸入、標準輸出、標準錯誤輸出重定向到幾個檔案上,然後呼叫execv將編譯好的程式替換掉當前的binary,執行完成後比對輸出檔案和答案檔案,給出結果。

碰到的問題

  1. cpu時間、記憶體使用量的監控,限制程式的cpu時間、記憶體使用量。
  2. 系統呼叫的監控。
  3. execv系統呼叫的一些pitfalls。

問題的思考的過程和解決方法

1.cpu時間、記憶體使用量的監控,限制程式的cpu時間、記憶體使用量。

如何做到cpu時間和記憶體使用量的測量呢?linux作業系統上有很多方法可以獲得一個程序的這些資訊,這裡列舉幾個方法以及各自的優缺點。

  1. 用system系統呼叫定期呼叫top,ps等工具,來分析這些工具的輸出。優點,方便。缺點,這個定期的頻率不好選擇,如果頻率低了程式的效能降低,高了的話監測的精準度不夠,比如某個程式在1ms就執行完畢了,這樣的話這些工具甚至都捕捉不到這個程序執行過。
  2. 定期掃描/proc檔案系統裡被監測程式的資訊。優點,/proc檔案系統提供了非常詳細的資訊,一些top、ps的實現都是讀取這個系統。缺點,同1。
  3. 使用wait系統呼叫,在子程序結束的時候讀取下程式的資源使用資訊,具體使用方法可以參考manpage。優點,精準,在程式結束的時候,父程序會收到子程序的SIGCHLD訊號,在這個時候使用wait系統呼叫能獲得比較精準的cpu和記憶體使用量。缺點,由於對程式資源做了限制(見下一個問題),在記憶體超出了限制之後,程式malloc記憶體會失敗,在C的程式裡會導致SIGSEGV,errno被設定為ENOMEM,在c++程式裡也可能會導致SIGABRT、SIGKILL(由於new失敗丟擲的bad_alloc沒有被捕獲導致abort的呼叫),但是也有其他的情況會導致SEGFAULT,比如訪問不存在的記憶體地址,這樣做無法準確區別到底是記憶體超了,還是程式訪問了不存在的記憶體的情況。

在獲得程式的資源使用量之後,可以通過一下結合這些方法方式來實現時間、記憶體的限制。

  1. 當top,ps返回的結果中發現記憶體、時間的使用量超出了限制之後,傳送SIGKILL給子程序。這樣做還是需要頻率的問題,間隔時間大了,子程序可能分配了大量的記憶體,對整個系統安全造成威脅。
  2. 使用setrlimit系統呼叫來實現cpu、記憶體使用量的限制,當使用量超出限制就傳送SIGXCPU(超時),malloc呼叫失敗(記憶體超了,errno設定為ENOMEM)。這種做法比較清爽,因為程序的資源使用量作業系統是最清楚不過了。缺點是無法得到到底超出了多少記憶體,如果一次malloc呼叫申請大量的記憶體(如記憶體限制的一半),超了,但是程序的記憶體使用量卻不會被作業系統維護(因為malloc失敗了)。

在經過一段時間研究,資料搜尋,找不到一種完美、簡單的方式完成這個功能。最後經過權衡之後還是選擇了waitpid獲取cpu使用資源,使用setrlimit限制CPU和記憶體使用量,這裡通過一種比較醜陋的hack來判斷是否是記憶體超了,通過setrlimit系統呼叫將程序的記憶體限制設定為限制的125%,然後在子程式結束時,呼叫waitpid之前,分析/proc檔案系統,讀取/proc/pid/status檔案,解析出VmSize這行,然後在通過VmSize(虛擬記憶體空間)的大小判斷是否超出了記憶體限制,這樣做可以緩解問題,但是不能完美地解決。

    ...
    if (fork() == 0) {
        /* 子程序 */
        rlimit r;
        getrlimit(RLIMIT_CPU, &r);
        r.rlim_cur = time_limit() / 1000;
        setrlimit(RLIMIT_CPU, &r);

        getrlimit(RLIMIT_AS, &r);
        r.rlim_cur = mem_limit() * 1024 * 1.25;
        setrlimit(RLIMIT_AS, &r);
    }
    ...

    /* 這個時候子程序還沒退出,在呼叫wait前先去/proc檔案系統裡讀VmSize */
    struct meminfo m = retrieve_mem_usage_from_proc(pid);
    struct rusage usage;
    /* 收割子程序,順便獲取cpu使用量 */
    wait4(pid, &s, 0, &usage); 

    cpu_usage = usage.ru_utime.tv_sec * 1000 + usage.ru_utime.tv_usec / 1000 + usage.ru_stime.tv_sec * 1000 +usage.ru_stime.tv_usec / 1000;

    mem_usage = m.VmSize;

    if (WIFEXITED(s)) {
        fprintf(stderr, "WEXITSTATUS %d\n", WEXITSTATUS(s)); /* 正常退出 */
    } else if (WIFSIGNALED(s)) { /* 程式被訊號結束了 */
        fprintf(stderr, "WTERMSIG %d\n", WTERMSIG(s));
        if (WTERMSIG(s) == SIGSEGV) {
            if (mem_usage > mem_limit)
                mf = 1; /* 超出記憶體限制 */
            else
                sf = 1;
        } else if (WTERMSIG(s) == SIGXCPU) { //SIGXCPU indicates the process used up the CPU time assigned to it
            tf = 1; /* 超出時間限制 */
        } else if (WTERMSIG(s) == SIGKILL ||
                   WTERMSIG(s) == SIGABRT) {
            if (mem_usage> mem_limit)
                mf = 1; /* 超出記憶體限制 */
            else
                of = 1; /* 執行時錯誤 */
        }  else {
            of = 1; /* 執行時錯誤 */
        }
    }
2.惡意系統呼叫的監控

使用者提交上來的程式可能會包含惡意的系統呼叫,如操作檔案系統(unlink),程序複製(fork),網路(socket, sendto, recvfrom)等系統呼叫。如何限制這些呼叫呢?

  1. 使用strace工具來檢視程式的系統呼叫,一旦發現非法的系統呼叫,就傳送SIGKILL,這樣做的一個很大的缺點是實時性,當一個子程序系統呼叫呼叫完畢之後,我們的程式才可能反應過來。
  2. 我們想要的是實時的監控子程序的系統呼叫過程,在嘗試呼叫禁止的系統呼叫的時候我們就需要把程式殺死,好在linux提供了ptrace系統呼叫,原理是在程式trap進系統呼叫之前會先檢查當前程序是否被traced了,如果是的話,通過SIGTRAP暫停當前程序,通知tracer,給tracer一個機會來做一些事情,如恢復子程序執行、殺死程序等。strace就是基於ptrace來實現的。

綜上所述,我採用了ptrace的方式來實現監控系統呼叫,關於ptrace系統呼叫可以參考manpage。

 ...
 if (fork() == 0) {
     ...
    /* 這裡設定當前程序要被trace */
    if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) == -1) {
        fprintf(stderr, "failed to ptrace : %s\n", strerror(errno));
        exit(1);
    }
    ...
}
...
/* 等到execv呼叫後的第一個訊號 */
if (waitpid(pid, &s, 0) == -1) {
    fprintf(stderr, "waitpid(%d, 0, 0) error %s\n", pid, strerror(errno));
    v.set_status(verdict_result::UNKNOWN_ERROR);;
    return std::move(v);
}

/* 設定trace選項,在子程序退出的時候傳送EXIT事件,監視syscall,在父程序發生錯誤退出的時候殺死所有的子程序 */
if (ptrace(PTRACE_SETOPTIONS, pid, 0, PTRACE_O_TRACESYSGOOD | PTRACE_O_TRACEEXIT | PTRACE_O_EXITKILL ) == -1) {
    fprintf(stderr, "ptrace(PTRACE_SETOPTIONS, %d, 0, PTRACE_O_TRACEEXIT | PTRACE_O_EXITKILL) error %s\n", pid, strerror(errno));
    v.set_status(verdict_result::UNKNOWN_ERROR);;
    return std::move(v);
}

while (true) {
    /* 恢復執行但是監視syscall */
    if (ptrace(PTRACE_SYSCALL, pid, 0, 0) == -1) {
        fprintf(stderr, "ptrace(PTRACE_SYSCALL, %d, 0, 0) error %s\n", pid, strerror(errno));
        v.set_status(verdict_result::UNKNOWN_ERROR);;
        return std::move(v);
    }
    /* 獲取子程序的狀態 */
    if (waitpid(pid, &s, 0) == -1) {
        fprintf(stderr, "waitpid(%d, &s, 0); error %s\n",pid, strerror(errno));
        v.set_status(verdict_result::UNKNOWN_ERROR);;
        return std::move(v);
    }

    /* 這裡判斷程序是否退出了 */
    if (WIFEXITED(s) || (WSTOPSIG(s) == SIGTRAP && (s & (PTRACE_EVENT_EXIT << 8))))break;

    fprintf(stderr, "WSTOPSIG %d\n", WSTOPSIG(s));

    if (WSTOPSIG(s) == SIGSEGV) {
        fprintf(stderr, "child process %d received SIGSEGV, killing... ", pid);

        if (kill(pid, SIGKILL) == -1)
            fprintf(stderr, "failed. %s\n", strerror(errno));
        else
            fprintf(stderr, "done.\n");
    } else if (WIFSTOPPED(s) && (WSTOPSIG(s) & 0x80)) {
        /* 這裡的WORD_SIZE和ORIG_EAX根據機器字長不同需要做特殊處理 */
        #ifdef __x86_64__
        #define ORIG_EAX ORIG_RAX
        #define WORD_SIZE 8
        #else
        #define WORD_SIZE 4
        #endif

        /* 在進入或退出系統呼叫前子程序被暫停下來, 暫停訊號的第7位被置1, 也就是0x80中斷號*/
        long call_num = ptrace(PTRACE_PEEKUSER, pid, WORD_SIZE * ORIG_EAX);/* 拿到系統呼叫號*/
        assert(call_num < NR_syscalls);
        fprintf(stderr, "child process calling syscall, number: %ld\n", call_num);

        if (syscall_mask[call_num]) {
            /* 呼叫了禁用的系統呼叫, 傳送SIGKILL */
            fprintf(stderr, "child process %d is trying to invoke the banned system call, killing it... ", pid);

            if (kill(pid, SIGKILL) == -1)
                fprintf(stderr, "failed. %s\n", strerror(errno));
            else
                fprintf(stderr, "done.\n");

            v.set_status(verdict_result::RUNTIME_ERROR);

            return std::move(v);
        }
    }
}
3.execv系統呼叫的一些pitfalls。
這是我在實現的時候碰到的一個問題,提交的程式定義了一個超過記憶體限制的全域性陣列,當呼叫setrlimit給程式設定了記憶體使用限制之後,呼叫execv,本以為execv會返回ENOMEM表示記憶體不夠,但是卻出現了程序直接被殺死(SIGKILL)的情況。而父程序在卻在等待子程序執行execv後傳送的第一個訊號,waitpid返回了ENOCHLD的錯誤,表示不存在子程序。在網路上搜索很長的時間,並沒有發現這類的問題,只好看下linux kernel的實現了,版本是2.6.32.65的,後面的版本這塊的實現變化不大。
首先是找到elf載入器的實現,在fs/binfmt_elf.c裡,定位到load_elf_binary函式。注意到714行和719行的關鍵註釋。
    ...讀取可執行檔案,一致性檢查,在這裡可以通過返回值來返回錯誤...
    /* Flush all traces of the currently running executable */
    retval = flush_old_exec(bprm);
    if (retval)
        goto out_free_dentry;

    /* OK, This is the point of no return */
    current->flags &= ~PF_FORKNOEXEC;
    current->mm->def_flags = def_flags;
    ...載入可執行檔案裡的elf格式資訊,計算程式的bss(未初始化資料段,全域性變數)大小...

    /* 
     * Calling set_brk effectively mmaps the pages that we need
     * for the bss and break sections.  We must do this before
     * mapping in the interpreter, to make sure it doesn't wind
     * up getting placed where the bss needs to go.
     */
    retval = set_brk(elf_bss, elf_brk);
    if (retval) {
        send_sig(SIGKILL, current, 0);
        goto out_free_dentry;
    }

也就是說在執行完flush_old_exec之後,如果沒有返回錯誤,那麼就不能使用返回值了,因為老的executable的資訊已經被完全的清理了,但是預設訊號處理程式沒有被清空。所以接下來的操作出現了錯誤之後只能通過傳送訊號來處理了。注意到874行呼叫set_brk來擴大堆空間,如果這個大小超過了rlimit,那麼就會失敗,然後這個程式就被殺掉了。

所以父程序在等待子程序的第一個訊號時,要判斷程式是否被kill掉了。當然也有可能是其他情況導致程式被KILL掉,無法用SIGKILL來斷定程式記憶體超了,所以這種情況實在是沒有什麼好的方法來解決,希望有這方面經驗的大神能指點一二。

    //wait for the first signal caused by execv
    if (waitpid(pid, &s, 0) == -1) {
        fprintf(stderr, "waitpid(%d, 0, 0) error %s\n", pid, strerror(errno));
        v.set_status(verdict_result::UNKNOWN_ERROR);;
        return std::move(v);
    }

    /* 
    * there might be a case where the child process failed to execv
    * due to virtual memory limit caused by huge global variables,
    * in that case the child process is killed by kernel by sending a SIGKILL.
    */
    if (WIFEXITED(s)) {
        fprintf(stderr, "child process exited, WEXITSTATUS %d\n", WEXITSTATUS(s));
        v.set_status(verdict_result::UNKNOWN_ERROR);
        return std::move(v);
    } else if (WIFSIGNALED(s)) {
        fprintf(stderr, "child process terminated at execv, WTERMSIG %d(", WTERMSIG(s));
        if (WTERMSIG(s) == SIGSEGV) {
            fprintf(stderr, "SIGSEGV)\n");
            v.set_status(verdict_result::SEGMENTATION_FAULT);
        } else if (WTERMSIG(s) == SIGKILL) {
            fprintf(stderr, "SIGKILL) runtime error.\n");
            v.set_status(verdict_result::RUNTIME_ERROR);
        } else {
            v.set_status(verdict_result::UNKNOWN_ERROR);;
            fprintf(stderr, ")\n");
        }

        return std::move(v);
    }

潛在優化點

  1. 目前的minion程序在執行判題的的過程中是需要等待子程序的訊號或者子程序執行完畢的,可以將這個等待的過程用事件的方式加入到reactor裡面來提高併發度。
  2. 多個程序共享一個監聽socket,可能會出現驚群效應。