Ở bài về fork, ta thấy fork() tạo một bản sao của tiến trình. Nhưng bản sao đó thường không phải cái ta muốn chạy — ta muốn chạy một chương trình khác. Đó là việc của execve(): nó thay hoàn toàn ảnh bộ nhớ của tiến trình hiện tại bằng một chương trình mới. Cặp fork + exec là cách Unix khởi chạy mọi thứ. Bài này đo execve bằng cách đọc /proc/self/maps trước và sau — và ngay lần đo đầu, cách đo ngây thơ của tôi bắt được đúng con số không, vì bản chất kỳ lạ của chính syscall này.
execve thay ảnh tiến trình tại chỗ
Điều quan trọng cần nắm: execve không tạo tiến trình mới. Nó giữ nguyên tiến trình đang chạy — cùng PID, cùng các mô tả tệp đang mở — nhưng quét sạch toàn bộ bộ nhớ của nó (mã, dữ liệu, heap, ngăn xếp, các thư viện) và thay bằng nội dung của một chương trình khác. Sau execve, tiến trình vẫn là "tiến trình số 14", nhưng nó đang chạy một chương trình hoàn toàn khác, với bản đồ bộ nhớ mới tinh.
Và nó không nạp cả file thực thi vào bộ nhớ ngay. execve ánh xạ (mmap) file ELF vào không gian địa chỉ rồi nhảy tới điểm vào của chương trình mới; các trang mã và dữ liệu được đọc từ đĩa lười biếng, theo nhu cầu (demand paging), khi chương trình thật sự chạm tới chúng. Nhờ vậy exec nhanh dù file lớn.
Đo: cùng PID, chương trình khác, và không đọc hết file
Tôi viết một launcher in /proc/self/maps của nó rồi execve sang một chương trình target; target cũng in /proc/self/maps của nó. Kết quả:
[launcher] PID=14, file thực thi: .../m/launcher, 16 vùng nhớ
[target] PID=14, file thực thi: .../m/target
Cùng PID 14, nhưng dòng file thực thi trong maps đã đổi từ /m/launcher sang /m/target. Cùng một tiến trình, hai chương trình khác nhau — đúng nghĩa "thay ảnh tại chỗ". strace xác nhận chuỗi: execve("./target", ...) = 0, rồi ngay sau là openat mở libc.so.6 — trình liên kết động của chương trình mới bắt đầu nạp thư viện của nó.
Để kiểm cái "nạp lười", tôi đo thời gian fork + execve cho một file 70 KB và một file 64 MB (nhồi 64 MB dữ liệu khởi tạo), ghim lõi 0, lấy nhỏ nhất trong 11 lần:
| Chương trình | fork + execve |
|---|---|
| tiny (70 KB) | 392 µs |
| bigt (64 MB) | 354 µs |
Gần bằng nhau — file 64 MB exec không chậm hơn file 70 KB. Nếu execve đọc cả 64 MB từ đĩa, nó phải chậm hơn hàng chục lần; thay vào đó nó chỉ ánh xạ file rồi trả điều khiển cho chương trình mới. bigt chỉ chạm một phần tí xíu của mảng, nên gần như toàn bộ 64 MB không bao giờ được nạp.
Một lần tôi đo hớ: bắt được đúng con số không
Cách đo đầu tiên của tôi rất tự nhiên: bọc execve giữa hai lệnh in — một printf trước để ghi trạng thái "before", một printf sau để ghi "after". Chạy xong, tôi nhìn output và... không có cái nào cả. Cả hai dòng đều biến mất.
Phải mất một lúc mới hiểu ra, và lý do đóng đinh đúng bản chất của execve. Dòng printf sau không chạy vì một điều đơn giản mà tôi quên: execve không trở về khi thành công. Khi exec thành công, tiến trình đã trở thành chương trình mới; mã cũ (gồm cả dòng in "after") đã bị quét sạch, không còn tồn tại để mà chạy. Dòng sau execve() chỉ chạy trong đúng một trường hợp: khi exec thất bại (không tìm thấy file, không có quyền). Đây là syscall duy nhất mà "dòng lệnh kế tiếp" mang nghĩa "đã có lỗi".
Còn dòng printf trước biến mất vì một lý do tinh tế hơn: printf ghi vào bộ đệm stdio trong không gian người dùng, và bộ đệm đó chỉ được xả ra khi đầy, khi đóng, hoặc khi chương trình kết thúc bình thường. Nhưng execve không kết thúc chương trình bình thường — nó quét sạch toàn bộ ảnh bộ nhớ, gồm cả cái bộ đệm chứa dòng "before" chưa kịp xả. Dữ liệu bị xóa cùng ảnh cũ, chưa bao giờ tới được màn hình. Cách sửa: gọi fflush(stdout) trước execve để ép dòng "before" ra ngoài trước khi ảnh bị wipe.
Bài học đo lường: máy đo ngây thơ "in trước, in sau" của tôi bắt được đúng con số không, vì execve là thay thế toàn bộ — không dọn dẹp, không trở về. Không phải phép đo sai; chính đối tượng đo phá vỡ giả định của phép đo. Đây là lời nhắc cốt lõi của sê-ri: phải hiểu một syscall thật sự làm gì mới biết cách đo nó. Với execve, "before" phải được xả cưỡng bức trước, và "after" không tồn tại trong cùng chương trình — muốn đo trọn giao dịch, phải đo từ tiến trình cha (fork rồi bấm giờ từ ngoài), đúng như bảng thời gian ở trên.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: luôn xả (flush) hoặc dùng ghi không đệm trước khi exec. Nếu chương trình của bạn fork rồi cho con exec một lệnh khác, mọi thứ con đã printf mà chưa xả sẽ mất khi exec. Tệ hơn, nếu cha có dữ liệu chưa xả trong bộ đệm lúc fork, con thừa hưởng bản sao bộ đệm đó, và cả cha lẫn con có thể cùng xả — in trùng. Quy tắc an toàn: fflush trước mọi fork/exec, hoặc dùng write() trực tiếp (không đệm) cho những thứ phải chắc chắn ra ngoài.
Hệ quả thứ hai: kiểm lỗi ngay sau exec, vì đó là chỗ duy nhất báo lỗi. Vì execve không trở về khi thành công, mọi dòng mã sau nó chỉ chạy khi exec hỏng. Mẫu đúng luôn là execve(...); perror("execve"); _exit(127); — dòng perror và thoát chỉ đến khi có lỗi, và bạn phải xử lý nó, nếu không tiến trình con sẽ chạy tiếp mã của cha một cách vô nghĩa. Đây là một lỗi kinh điển: quên rằng exec thất bại thì code tiếp tục.
Hệ quả thứ ba là bài học đo lường mang theo: hiểu ngữ nghĩa của thứ mình đo trước khi tin công cụ đo. Con số mang theo: execve thay ảnh tiến trình tại chỗ — cùng PID nhưng file thực thi trong /proc/self/maps đổi hẳn — và nạp lười nên exec một file 64 MB (354 µs) nhanh ngang một file 70 KB (392 µs); nhưng nó không trở về khi thành công và vứt sạch bộ đệm chưa xả, nên phải fflush trước exec và đo từ tiến trình cha. Một phép đo "in trước, in sau" tưởng vô hại lại bắt được con số không, chỉ vì đối tượng đo là một syscall xóa sạch mọi thứ và không quay lại.
Thử ba mươi giây
Viết một chương trình in /proc/self/maps (chỉ vài dòng đầu, hoặc grep dòng chứa tên file của chính nó bằng readlink /proc/self/exe), rồi execve sang /bin/cat /proc/self/maps — cùng một tiến trình sẽ in ra bản đồ bộ nhớ của cat, khác hẳn của bạn, mà PID không đổi. Nhớ đặt một dòng printf trước execve không kèm \n và không fflush: bạn sẽ thấy nó biến mất khỏi output, minh chứng execve vứt bộ đệm chưa xả. Thêm fflush(stdout) trước exec và chạy lại — dòng đó hiện ra. Đó là toàn bộ bản chất của execve gói trong một thí nghiệm: thay thế toàn bộ, không dọn, không về.