ירושת משאבים בזמן יצירת תהליך¶
נניח ששרת פותח קובץ רגיש ובמקביל מפעיל כלי עזר. אם המתאר דולף לתהליך החדש, כלי העזר עשוי להחזיק גישה גם כאשר אין לו הרשאה לפתוח את הנתיב בעצמו. היכולת כבר קיימת במתאר. לכן יצירת תהליך אינה רק בקשה להריץ קובץ, אלא החלטה אילו משאבים עוברים את הגבול.
הדגל close-on-exec אומר למערכת לסגור מתאר כאשר exec מצליחה. הוא אינו מונע את הירושה ברגע fork, ואינו סוגר את המתאר כאשר exec נכשלת. ההבחנה הזאת מאפשרת לבנות צינור דיווח שגיאה שנסגר אוטומטית בהצלחה, אבל מחייבת אותנו להבין באיזה שלב נמצא הילד.
נבדוק את הדגל של קובץ שנפתח דרך ספריית התקן. הדוגמה הבאה דורשת חבילת libc בפרויקט Cargo.
use std::{fs::File, io};
use std::os::fd::{AsFd, AsRawFd, BorrowedFd};
fn close_on_exec(fd: BorrowedFd<'_>) -> io::Result<bool> {
let flags = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFD) };
if flags == -1 { Err(io::Error::last_os_error()) }
else { Ok(flags & libc::FD_CLOEXEC != 0) }
}
fn main() -> io::Result<()> {
let file = File::open("letters.txt")?;
println!("cloexec={}", close_on_exec(file.as_fd())?);
Ok(())
}
הבדיקה מדווחת מה נקבע למתאר בסביבה הנוכחית. כדי לחקור את הדרך שבה נוצר, הריצו את הבינארי תחת strace -e trace=openat,fcntl. בדרך כלל נרצה שהדגל ייקבע כחלק מפעולת היצירה, באמצעות O_CLOEXEC או חלופה מתאימה של הפעולה.
הרצף open ואז fcntl(F_SETFD) משאיר חלון זמן. תהליכון אחר יכול ליצור ילד ולהפעיל exec בין שתי הפעולות, והילד יירש את המתאר לפני שהדגל הוגדר. אפילו אם כל שורה בודדת נכונה, החוזה הכולל אינו אטומי. אותה בעיה קיימת ביצירת צינורות, sockets ושכפול מתארים, ולכן נבדוק עבור כל פעולה אם יש גרסה שיוצרת את המשאב עם הדגל הנדרש.
הפעולה fork משכפלת את מרחב הכתובות מבחינה לוגית, בדרך כלל עם copy-on-write שמאפשר לדחות העתקת עמודים. האב והבן אינם ממשיכים לשתף כל משתנה רגיל כאילו היו שני תהליכונים. לעומת זאת, המתארים שהועתקו מפנים לאותם open file descriptions, ולכן מצב כמו offset יכול להישאר משותף גם בין תהליכים.
ניסוי סי קצר עוזר להפריד בין שתי התופעות. הקוד הבא הוא תוכנית עצמאית וסינכרונית שמבצעת רק פעולות פשוטות בבן לאחר fork.
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
#include <stdio.h>
int main(void) {
int value = 10;
pid_t child = fork();
if (child < 0) return 1;
if (child == 0) {
value = 20;
const char text[] = "child changed its memory\n";
if (write(STDOUT_FILENO, text, sizeof(text) - 1) < 0) _exit(2);
_exit(value == 20 ? 0 : 3);
}
int status;
while (waitpid(child, &status, 0) < 0) {
if (errno != EINTR) return 4;
}
printf("parent value=%d\n", value);
return 0;
}
האב עדיין רואה 10, משום שהכתיבה של הבן אינה עדכון של אותו משתנה משותף. ההדפסה של האב נעשית אחרי איסוף הבן. הבן משתמש ב־_exit ולא ב־exit, כדי לא להריץ ניקוי של ספריית סי שעלול להתייחס למצב ששוכפל מהאב.
בתהליך רב־תהליכוני הבעיה עמוקה יותר. רק התהליכון שקרא ל־fork ממשיך בבן, אבל מצב הזיכרון כולל גם נעילות שהוחזקו בידי תהליכונים אחרים. ניסיון לבצע הקצאה או הדפסה דרך ספרייה שמשתמשת בנעילה כזאת יכול להיתקע. לכן בין fork ל־exec בבן רב־תהליכוני מוגבלים לפעולות שמוגדרות async-signal-safe, ולא מפעילים קוד ראסט כללי שמקצה או נועל.
הספרייה Command מנהלת עבורנו חלק גדול מהפרוטוקול, ולכן נעדיף אותה להפעלת תוכניות רגילה. הממשק pre_exec הוא unsafe בדיוק משום שקל להכניס אליו פעולה שאינה חוקית בחלון הזה. הכנת מחרוזות, קביעת תצורה ואיסוף נתונים צריכות להתרחש לפני יצירת הילד, ולא בתוך callback שנראה כמו קוד אפליקציה רגיל.
הגבלות של מספר מתארים ושל משאבים אחרים יכולות לגרום לכשל עוד לפני טעינת התוכנית. תכננו מסלול שמנקה צינורות ומתארים שכבר נוצרו כאשר שלב מאוחר יותר נכשל. הבעלות המפורשת של ראסט מסייעת בניקוי, אבל רק אם כל משאב הועבר לבעלים מיד לאחר שנוצר.
פרטי הירושה מתועדים בעמוד fork, וממשק ההכנה של ראסט מתועד בתיעוד CommandExt. קראו את מגבלות חלון הבן לפני הוספת פעולה שנראית קטנה כמו כתיבת הודעת לוג.