Một diễn viên đóng thế bước vào đúng chỗ diễn viên chính, mặc đúng bộ đồ, mang đúng số hiệu trong danh sách đoàn phim — nhưng từ giây đó trở đi là một con người hoàn toàn khác, và diễn viên cũ biến mất không để lại gì. Nếu bạn dặn diễn viên cũ "nói câu này sau khi đổi vai", câu đó không bao giờ được nói, vì người nói nó đã không còn. execve làm đúng cú tráo người đó với một tiến trình, và cái bẫy đo của tôi rơi thẳng vào chỗ này. Ở 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. 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.

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.

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.

Điều rút ra: 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ề đến code hằng ngày

Điều đầ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.

Điều 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.

Và cả hai bẫy — "output chưa flush mất khi tiến trình bị thay/kết thúc đột ngột" và "một thao tác không-trở-về đảo ngược ý nghĩa dòng lệnh kế tiếp" — vọng thẳng vào code cấp cao. Trong Java, System.out (PrintStream) có buffer, và một Runtime.halt() hay bị OOM-kill sẽ nuốt log chưa flush — đó là lý do log tới hạn nên flush/sync ngay, và ProcessBuilder khi thay tiến trình con cần lưu ý cùng chuyện đệm. Trong Go, output qua bufio.Writer mất nếu os.Exit() gọi trước Flush() (vì os.Exit bỏ qua cả defer). Và cái "không trở về khi thành công" có họ hàng với exec trong shell, os.Exit, hay một longjmp — dòng sau chúng mang ngữ nghĩa bất thường. Ngay cả CSDL: một transaction chưa COMMIT mà tiến trình chết đột ngột thì mất hết, y như buffer chưa flush. Mẫu số chung: dữ liệu nằm trong một bộ đệm chưa đẩy ra là dữ liệu chưa tồn tại với thế giới bên ngoài — trước bất kỳ thao tác nào thay thế/kết thúc/không-trở-về, phải flush; và một lời gọi không trở về khi thành công biến "dòng kế tiếp" thành nhánh xử lý lỗi, đừng viết logic bình thường ở đó. Gói lại bằng số: execve thay ảnh tiến trình tại chỗ — cùng PID 14 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.

Muốn tự thấy thì 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ề.