פתרון מנוע סריקה עם מקור יציב¶
הפתרון הבא דורש libc ופועל בלינוקס עם תמיכה ב־memfd וב־seals. אם הפעולה אינה זמינה או שהאטימה נכשלת, המצב הממופה מחזיר שגיאה ואינו ממשיך תחת הבטחת יציבות שאינה קיימת.
use std::{env, fs::File, io::{self, Read}, ptr::NonNull, time::Instant};
use std::os::fd::{AsRawFd, FromRawFd};
fn invalid(text: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, text)
}
fn summarize(bytes: &[u8]) -> (u64, u64) {
let sum = bytes.iter().fold(0u64, |n, &b| n.wrapping_add(u64::from(b)));
(bytes.len() as u64, sum)
}
fn buffered(mut file: File, buffer_size: usize) -> io::Result<(u64, u64)> {
if buffer_size == 0 { return Err(invalid("zero buffer")); }
let mut buffer = Vec::new();
buffer.try_reserve_exact(buffer_size).map_err(|_| io::Error::other("allocation failed"))?;
buffer.resize(buffer_size, 0);
let (mut count, mut sum) = (0u64, 0u64);
loop {
let size = match file.read(&mut buffer) {
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
result => result?,
};
if size == 0 { break; }
count = count.checked_add(size as u64).ok_or_else(|| invalid("length overflow"))?;
sum = sum.wrapping_add(summarize(&buffer[..size]).1);
}
Ok((count, sum))
}
struct Snapshot {
pointer: Option<NonNull<u8>>,
len: usize,
_file: File,
}
impl Snapshot {
fn copy_from(source: &mut File, limit: usize) -> io::Result<Self> {
if limit == 0 || limit > isize::MAX as usize { return Err(invalid("invalid limit")); }
let fd = unsafe { libc::memfd_create(c"scan-snapshot".as_ptr(),
libc::MFD_CLOEXEC | libc::MFD_ALLOW_SEALING) };
if fd == -1 { return Err(io::Error::last_os_error()); }
let mut file = unsafe { File::from_raw_fd(fd) };
let mut buffer = [0u8; 8192];
let mut copied = 0usize;
loop {
let size = match source.read(&mut buffer) {
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
result => result?,
};
if size == 0 { break; }
let next = copied.checked_add(size).ok_or_else(|| invalid("length overflow"))?;
if next > limit { return Err(invalid("snapshot exceeds budget")); }
std::io::Write::write_all(&mut file, &buffer[..size])?;
copied = next;
}
let seals = libc::F_SEAL_WRITE | libc::F_SEAL_GROW |
libc::F_SEAL_SHRINK | libc::F_SEAL_SEAL;
if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_ADD_SEALS, seals) } == -1 {
return Err(io::Error::last_os_error());
}
if copied == 0 { return Ok(Self { pointer: None, len: 0, _file: file }); }
let raw = unsafe { libc::mmap(std::ptr::null_mut(), copied, libc::PROT_READ,
libc::MAP_SHARED, file.as_raw_fd(), 0) };
if raw == libc::MAP_FAILED { return Err(io::Error::last_os_error()); }
let Some(pointer) = NonNull::new(raw.cast::<u8>()) else {
unsafe { libc::munmap(raw, copied); }
return Err(io::Error::other("null mapping unsupported"));
};
Ok(Self { pointer: Some(pointer), len: copied, _file: file })
}
fn bytes(&self) -> &[u8] {
match self.pointer {
None => &[],
Some(pointer) => unsafe { std::slice::from_raw_parts(pointer.as_ptr(), self.len) },
}
}
}
impl Drop for Snapshot {
fn drop(&mut self) {
if let Some(pointer) = self.pointer {
unsafe { libc::munmap(pointer.as_ptr().cast(), self.len); }
}
}
}
fn main() -> io::Result<()> {
let mut args = env::args_os().skip(1);
let mode = args.next().and_then(|x| x.into_string().ok()).ok_or_else(|| invalid("mode"))?;
let budget: usize = args.next().and_then(|x| x.into_string().ok())
.and_then(|x| x.parse().ok()).ok_or_else(|| invalid("budget"))?;
let path = args.next().ok_or_else(|| invalid("path"))?;
let mut file = File::open(path)?;
let start = Instant::now();
if mode == "buffered" {
let result = buffered(file, budget)?;
println!("result={result:?} total={:?}", start.elapsed());
} else if mode == "mapped" {
let snapshot = Snapshot::copy_from(&mut file, budget)?;
let prepared = Instant::now();
let result = summarize(snapshot.bytes());
println!("result={result:?} prepare={:?} scan={:?}",
prepared.duration_since(start), prepared.elapsed());
} else { return Err(invalid("mode must be buffered or mapped")); }
Ok(())
}
הבעלות על memfd עוברת מיד ל־File, ולכן כל כשל בזמן העתקה או אטימה סוגר אותו. המיפוי נוצר רק אחרי האטימה שמונעת כתיבה ושינוי אורך. הפרוסה קשורה ל־&self, וההשמדה מבטלת את המיפוי לפני ששדות האובייקט מושמדים אוטומטית.
הגרסה הממופה משלמת על עותק מלא ועל באפר הכנה של שמונה קילובייט בנוסף לתקציב העותק. היא אינה zero-copy מהמקור. היתרון האפשרי שלה הוא מקור יציב שניתן לסרוק שוב בלי להעתיק בכל סריקה, והמחיר הזה חייב להופיע בהשוואה. עבור מעבר יחיד על קובץ גדול, הזרמה עם באפר קטן יכולה להיות הבחירה הטובה יותר.
האטימה אינה יוצרת snapshot אטומי של קובץ המקור בזמן ההעתקה. אם כותב אחר משנה אותו תוך כדי קריאה, העותק יכול להכיל שילוב של מצבים. אחרי ההעתקה הוא יציב, אבל משמעותו כתמונה של גרסה אחת דורשת פרוטוקול נוסף עם הכותב, קובץ immutable או מנגנון snapshot של מערכת הקבצים. ההבחנה הזאת היא חלק מתשובת הפרויקט ולא הערת שוליים.
קובץ שגדל בלי גבול אינו מגדיל כאן את העותק מעבר לתקציב: לפני כל כתיבה ל־memfd נבדק האורך המצטבר. הבדיקה אינה מונעת קובץ שממשיך להשתנות באותו גודל, ולכן אימות תוכן עקבי עדיין דורש חוזה מתאים. במקרה של מקור יציב, שתי הגרסאות צריכות להחזיר בדיוק את אותו זוג תוצאות.
בדיקת האינווריאנטים כוללת גם כשל באמצע ההעתקה וכשל אטימה. אפשר להזריק שגיאת קריאה דרך מקור מדומה בגרסה גנרית של פונקציית ההעתקה, ולבדוק שמספר המתארים חוזר לרמתו הקודמת. בדיקת מספר מתארים היא ראיה שימושית לניקוי, אבל אינה מחליפה את טיעון הבעלות שמסביר מדוע כל מסלול מכוסה.
החוזה של מקור הזיכרון והאטימה מתועד בעמוד memfd_create. השוואת הביצועים תציג בנפרד את שלב ההכנה, הסריקה הראשונה והסריקות החוזרות, כך שלא נסתיר את המחיר שהעברנו מחלק אחד של המערכת לאחר.