Chương 25 · Kết thúc tiến trình Chapter 25 · Process Termination
Một tiến trình kết thúc như thế nào — và vì sao có hai cánh cửa ra Khi một chương trình dừng lại: hai lối thoát khác nhau How a process ends — and why there are two ways out
Kerrisk dành chương này cho một hành động nghe thì đơn giản mà ẩn nhiều chi tiết: một tiến trình kết thúc ra sao. Có hai cách kết thúc bình thường (normal termination): system call _exit() cắt đứt tiến trình ngay lập tức, còn hàm thư viện exit() làm một loạt việc dọn dẹp — chạy các trình xử lý thoát (exit handler) và xả (flush) các vùng đệm stdio — rồi mới gọi _exit().123 Chương khép lại bằng một hệ quả bất ngờ: khi fork() gặp vùng đệm stdio chưa xả, tiến trình con có thể in ra trùng những gì tưởng như đã in.1
Chương này nói về lúc một chương trình "tắt máy". Có hai cách tắt gọn gàng: một cách tắt phụt ngay (như rút phích), một cách dọn dẹp trước rồi mới tắt (tắt máy đúng quy trình). Cách dọn dẹp sẽ gọi vài "việc cuối cùng" bạn dặn trước và đẩy nốt chữ còn kẹt trong bộ nhớ ra. Nhớ nhé: tắt gọn gàng khác tắt phụt — và chọn nhầm có thể làm chữ in ra hai lần.
Kerrisk gives this chapter to an act that sounds simple but hides much detail: how a process terminates. There are two ways to end normally: the _exit() system call cuts the process off immediately, while the exit() library function does a round of cleanup — it runs the registered exit handlers and flushes the stdio buffers — before finally calling _exit().123 It closes on a surprising consequence: when fork() meets an unflushed stdio buffer, the child may duplicate output that seemed already printed.1
Điểm phương pháp đáng lưu ý: chương 25 phân biệt rạch ròi tầng nhân và tầng thư viện C trong việc kết thúc. _exit() là một system call thuộc về nhân; exit() là một hàm trong thư viện C bọc quanh nó, cộng thêm phần dọn dẹp ở không gian người dùng. Hiểu đúng ranh giới này giải thích gần như mọi hành vi còn lại: vì sao exit handler không chạy khi gọi _exit(), vì sao vùng đệm stdio (nằm trong bộ nhớ tiến trình, do thư viện quản) lại bị fork() nhân đôi, và vì sao chỉ 8 bit thấp của trạng thái tới được tiến trình cha.12
Luận đề của chương: kết thúc tiến trình có hai tầng — _exit() ở tầng nhân kết thúc tức thì, còn exit() ở tầng thư viện thêm một lớp dọn dẹp (exit handler + xả stdio) rồi mới gọi _exit(); nắm được ranh giới ấy, cùng quy ước "chỉ 8 bit thấp của trạng thái tới được cha", là chìa khóa để tránh những lỗi tinh vi như đầu ra bị in trùng sau fork().123
Ý chính: có hai nút tắt. Nút "dọn dẹp rồi tắt" làm vài việc cuối và đẩy chữ ra; nút "tắt ngay" thì bỏ qua hết. Biết dùng nút nào, và biết rằng cha chỉ nhận được một con số nhỏ báo kết quả, là đủ để tránh phần lớn rắc rối. Nhớ nhé: chọn đúng nút tắt thì không bị in trùng, không mất chữ.
The chapter's thesis: process termination has two layers — _exit() at the kernel layer ends immediately, while exit() at the library layer adds a cleanup pass (exit handlers + stdio flush) before calling _exit(); grasping that boundary, together with the rule that "only the low 8 bits of the status reach the parent," is the key to avoiding subtle bugs such as duplicated output after a fork().123
Cụm khái niệm 01 Concept cluster 01
_exit() kết thúc tức thì; exit() dọn dẹp trước rồi mới gọi _exit() Tắt ngay với _exit(), hay dọn dẹp rồi tắt với exit() _exit() ends at once; exit() cleans up, then calls _exit()
Có thể kết thúc một tiến trình theo hai kiểu: bất thường (abnormal) do một tín hiệu (signal) nào đó, hoặc bình thường (normal) qua _exit(). System call _exit(status), khai báo trong <unistd.h>, kết thúc tiến trình ngay và không bao giờ trả về.12 Đối số status định nghĩa trạng thái kết thúc của tiến trình, mà tiến trình cha có thể lấy về khi gọi wait(); nhưng chỉ 8 bit thấp (tức status & 0xFF) thật sự tới được cha.12
Một chương trình có thể dừng vì bị "đá ra" (gặp tín hiệu) hoặc tự dừng một cách đàng hoàng. Cách tự dừng cơ bản nhất là gọi _exit(): tắt ngay, không quay lại. Khi tắt, nó để lại một con số nhỏ cho "chương trình cha" biết mình xong xuôi hay gặp lỗi. Nhớ nhé: _exit() là tắt ngay và để lại một con số báo kết quả.
A process can terminate in two ways: abnormally due to a signal, or normally via _exit(). The _exit(status) system call, declared in <unistd.h>, terminates the process at once and never returns.12 The status argument defines the process's termination status, which the parent can collect via wait(); but only the low 8 bits (that is, status & 0xFF) actually reach the parent.12
Định nghĩa. exit(status) — khai báo trong <stdlib.h> — là hàm thư viện được xây trên _exit(). Khi gọi, nó lần lượt: (1) chạy các exit handler theo thứ tự ngược với lúc đăng ký; (2) xả và đóng mọi luồng stdio đang mở, đồng thời xóa các tệp do tmpfile() tạo; (3) gọi _exit() với chính status đó.13
Nói đơn giản. exit() là nút "tắt máy đúng quy trình": làm nốt việc dặn trước, đẩy hết chữ còn kẹt ra ngoài, rồi mới thật sự tắt. Nhớ nhé: exit() = dọn dẹp xong mới gọi _exit().
Definition. exit(status) — declared in <stdlib.h> — is a library function built on top of _exit(). When called it, in turn: (1) runs the exit handlers in reverse order of registration; (2) flushes and closes all open stdio streams and removes files created by tmpfile(); (3) calls _exit() with that same status.13
exit(status):
1. chạy exit handler (thứ tự ngược)
2. xả + đóng stdio; xóa tmpfile()
3. _exit(status)
cha nhận: status & 0xFF
exit(status):
làm việc dặn trước
đẩy chữ ra
rồi tắt (_exit)
exit(status):
1. run exit handlers (reverse order)
2. flush + close stdio; remove tmpfile()
3. _exit(status)
parent sees: status & 0xFF
-
status & 0xFF
Chỉ 8 bit thấp tới được cha — nên exit(-1) hóa 255 Cha chỉ nhận một byte — exit(-1) thành 255 Only the low byte reaches the parent — so exit(-1) becomes 255
Dù
statuslà mộtint, chỉ 8 bit thấp của nó được trao cho cha. Vì thếexit(-1)không tới cha dưới dạng −1 mà là 255 (vì-1 & 0xFF == 255). Quy ước phổ biến: trạng thái 0 nghĩa là thành công, khác 0 nghĩa là có lỗi.127Chương trình chỉ gửi cho "cha" một con số rất nhỏ (một byte) để báo kết quả. Nếu bạn gửi −1, cha lại thấy 255 chứ không phải −1. Thường thì 0 là "ổn", khác 0 là "có trục trặc". Nhớ nhé: đừng dùng số âm làm trạng thái thoát — cha sẽ hiểu sai.
Although
statusis anint, only its low 8 bits are handed to the parent. Soexit(-1)does not reach the parent as −1 but as 255 (since-1 & 0xFF == 255). The common convention: a status of 0 means success, nonzero means an error occurred.127Bạn gặp nó ở đâuWhere you meet it Mỗi khi đọc$?trong shell sau khi một lệnh chạy xong — con số bạn thấy chính làstatus & 0xFFcủa tiến trình đó.7 Khi gõecho $?trong terminal để xem lệnh vừa rồi thành công hay lỗi. Every time you read$?in the shell after a command finishes — the number you see is that process'sstatus & 0xFF.7Hằng chuẩnStandard constants
Thay vì viết 0 và một số khác 0 tùy ý,
<stdlib.h>định nghĩaEXIT_SUCCESS(0) vàEXIT_FAILURE(1) — khả chuyển hơn, kể cả sang các môi trường không phải UNIX.3Instead of writing 0 and some arbitrary nonzero value,<stdlib.h>definesEXIT_SUCCESS(0) andEXIT_FAILURE(1) — more portable, even to non-UNIX environments.3Vì sao chỉ một byteWhy just one byte
Trạng thái kết thúc được mã hóa chung với thông tin "chết vì tín hiệu nào" trong một giá trị
intmàwait()trả về; phần dành cho trạng thái thoát bình thường chỉ là 8 bit — đó là lý do kỹ thuật của mặt nạ0xFF.17The termination status is packed, together with "killed by which signal" information, into oneintthatwait()returns; the field for a normal exit status is only 8 bits — the technical reason for the0xFFmask.17 -
return from main
return n trong main thường tương đương exit(n) Viết return trong main gần như gọi exit return n in main is usually the same as exit(n)
Viết
return n;trong hàmmain()nhìn chung tương đương với gọiexit(n), bởi chính hàm khởi động chạy thời (run-time startup) đã gọimain()sẽ dùng giá trị trả về đó làm đối số choexit().1 Vìexit()được gọi, phần dọn dẹp (exit handler, xả stdio) vẫn diễn ra.13Trong hàm chính
main(), viếtreturn 0;gần như y hệt gọiexit(0). Lý do: có một đoạn mã nền chạy trướcmain()sẽ cầm con số bạn trả về và đưa thẳng choexit(). Nên phần dọn dẹp vẫn được làm. Nhớ nhé: return trong main cũng kích hoạt dọn dẹp như exit.Writing
return n;inmain()is generally equivalent to callingexit(n), because the run-time startup function that invokedmain()uses that return value as the argument toexit().1 Becauseexit()is called, the cleanup (exit handlers, stdio flush) still happens.13Bạn gặp nó ở đâuWhere you meet it Khi một chương trình C "rơi xuống cuối"main()mà không córeturn: theo C99, nếumain()kiểuint, điều này tương đươngreturn 0;— tức thoát với trạng thái thành công.1 Khi code không viết return ở cuối main — với chuẩn C mới, máy tự coi như "return 0", tức là ổn. When a C program "falls off the end" ofmain()with noreturn: under C99, ifmain()is of typeint, this is equivalent toreturn 0;— a success exit.1 -
_exit vs exit
Khi nào dùng _exit() thay cho exit() Lúc nào nên tắt ngay thay vì dọn dẹp When to use _exit() instead of exit()
Phần lớn chương trình nên dùng
exit()để được dọn dẹp tử tế. Nhưng có lúc ta cố ý dùng_exit()để bỏ qua dọn dẹp — điển hình là trong tiến trình con saufork(), khi con không muốn xả lại các vùng đệm stdio mà nó thừa hưởng từ cha (sẽ bàn ở cụm 04).12Thường thì cứ dùng
exit()cho sạch sẽ. Nhưng đôi khi ta muốn tắt phụt bằng_exit()để không dọn dẹp — nhất là ở tiến trình con vừa sinh ra, để nó khỏi in lại đống chữ mà cha đã có sẵn trong bộ đệm. Nhớ nhé: con sau fork() thường nên tắt bằng _exit() để khỏi in trùng.Most programs should use
exit()to be cleaned up properly. But sometimes we deliberately use_exit()to skip the cleanup — typically in a child process afterfork(), when the child does not want to re-flush the stdio buffers it inherited from the parent (discussed in cluster 04).12Bạn gặp nó ở đâuWhere you meet it Trong gần như mọi khung xử lýfork()+exec(): nếuexec()thất bại, nhánh con thường gọi_exit()để thoát mà không làm rối đầu ra của cha. Trong các đoạn code tạo tiến trình con để chạy chương trình khác — con hay tắt bằng _exit() nếu lỡ hỏng. In almost everyfork()+exec()skeleton: ifexec()fails, the child branch usually calls_exit()to leave without disturbing the parent's output.
Cụm khái niệm 02 Concept cluster 02
Dù kết thúc kiểu gì, nhân vẫn dọn một danh sách tài nguyên cố định Khi tắt, nhân luôn thu dọn một loạt thứ giống nhau However it ends, the kernel tears down a fixed list of resources
Dù một tiến trình kết thúc bình thường (qua _exit()) hay bất thường (do tín hiệu), các bước dọn dẹp ở tầng nhân là giống nhau. Nhân đóng mọi mô tả tệp (file descriptor) đang mở, các luồng thư mục (directory stream), các bộ mô tả danh mục thông điệp và bộ mô tả chuyển đổi đang mở.12
Dù chương trình tự tắt hay bị "đá ra", phần nhân máy đều làm giống nhau: đóng mọi tệp đang mở, dẹp mọi thứ nó đang giữ hộ. Bạn không phải tự tay đóng từng cái — nhân lo hết lúc tiến trình chấm dứt. Nhớ nhé: nhân luôn đóng giúp những gì còn mở khi tiến trình kết thúc.
Whether a process terminates normally (via _exit()) or abnormally (due to a signal), the kernel-level cleanup steps are the same. The kernel closes all open file descriptors, directory streams, message catalog descriptors, and conversion descriptors that were open.12
Danh sách rút gọn. Khi tiến trình kết thúc, nhân còn: giải phóng mọi khóa tệp (file lock) mà tiến trình đang giữ; tách (detach) các đoạn bộ nhớ chia sẻ System V; giảm đếm tham chiếu cho các phân đoạn bộ nhớ chia sẻ POSIX và đóng các semaphore, hàng thông điệp POSIX đang mở; gỡ các ánh xạ bộ nhớ (memory mapping); và hủy mọi vùng địa chỉ của tiến trình.12
Nói gọn. Nhân trả lại tất cả những gì tiến trình mượn: khóa, vùng nhớ chung, bộ nhớ... đều được thu hồi sạch. Nhớ nhé: tắt là trả hết đồ mượn, nhân tự làm việc đó.
A short list. On termination the kernel also: releases any file locks the process held; detaches System V shared memory segments; decrements reference counts on POSIX shared memory and closes open POSIX semaphores and message queues; unmaps memory mappings; and destroys the process's address space.12
kết thúc (bình thường | bất thường):
đóng fd, directory stream
giải phóng file lock
tách/đóng IPC (SysV, POSIX)
gỡ memory mapping
=> gửi SIGCHLD cho cha
tắt:
đóng tệp
trả khóa, trả bộ nhớ
báo cho cha biết
termination (normal | abnormal):
close fds, directory streams
release file locks
detach/close IPC (SysV, POSIX)
unmap memory mappings
=> send SIGCHLD to parent
-
normal vs abnormal
Khác biệt nằm ở tầng thư viện, không ở tầng nhân Chỗ khác nhau là phần dọn dẹp của thư viện The difference is at the library layer, not the kernel
Điểm phân biệt giữa kết thúc bình thường và bất thường không nằm ở các bước dọn tài nguyên của nhân (chúng giống nhau), mà ở chỗ: kết thúc bất thường (bởi tín hiệu) có thể còn sinh một tệp core dump, và nó không chạy các exit handler hay xả stdio.12
Hai kiểu tắt chỉ khác nhau ở phần "mềm": tắt vì bị đá ra có thể để lại một tệp "ảnh chụp lúc hỏng" (core dump) và bỏ qua mọi việc dọn dẹp của thư viện. Còn phần nhân thì xử giống nhau. Nhớ nhé: tắt bất thường thì bỏ qua dọn dẹp và có thể để lại core dump.
The distinction between normal and abnormal termination lies not in the kernel's resource cleanup (which is the same) but here: abnormal termination (by a signal) may additionally produce a core dump file, and it does not run exit handlers or flush stdio.12
Bạn gặp nó ở đâuWhere you meet it Khi một chương trình bịSIGSEGVvà bạn thấy tệpcorexuất hiện — đó là dấu hiệu kết thúc bất thường, không quaexit(). Khi chương trình "sập" và sinh ra tệp core — đó là tắt bất thường. When a program is hit bySIGSEGVand you see acorefile appear — that is abnormal termination, not viaexit(). -
SIGCHLD
Kết thúc cũng là một sự kiện gửi tới cha Tắt xong, cha được báo tin Termination is also an event sent to the parent
Kết thúc không phải chỉ là "biến mất". Khi một tiến trình con chấm dứt, nhân gửi tín hiệu
SIGCHLDcho tiến trình cha, và trạng thái kết thúc được giữ lại để cha thu về quawait(). Cho tới khi cha thu, tiến trình con đã chết vẫn tồn tại ở dạng zombie.12Khi một tiến trình con tắt, "cha" của nó được báo tin và con số kết quả được giữ lại chờ cha đến nhận. Nếu cha chưa nhận, con đã chết vẫn còn "lảng vảng" như một bóng ma (zombie). Nhớ nhé: con tắt thì cha được báo, và phải đi "nhận" kết quả.
Termination is not just "disappearing." When a child process terminates, the kernel sends a
SIGCHLDsignal to the parent, and the termination status is kept for the parent to collect viawait(). Until collected, the dead child persists as a zombie.12Bạn gặp nó ở đâuWhere you meet it Đây chính là cầu nối sang Chương 26 (Monitoring Child Processes): cách cha dùngwait()/waitpid()để thu trạng thái và dọn zombie.1 Phần này nối thẳng sang chương sau: cha "nhận kết quả" của con như thế nào. This is exactly the bridge to Chapter 26 (Monitoring Child Processes): how the parent useswait()/waitpid()to reap the status and clear zombies.1 -
don't rely on cleanup order
Đừng dựa vào việc nhân "tự đóng" để bỏ qua logic của bạn Nhân đóng hộ không có nghĩa bạn khỏi lo Don't lean on auto-close to skip your own logic
Việc nhân tự đóng mô tả tệp lúc kết thúc là tiện lợi, nhưng không thay được logic của chương trình. Dữ liệu còn nằm trong vùng đệm người dùng (stdio) sẽ mất nếu bạn gọi
_exit()trực tiếp, vì nhân chỉ đóng fd chứ không biết tới vùng đệm thư viện. Chỉexit()(hoặcfflush()) mới đẩy phần đó ra.123Nhân đóng tệp hộ bạn, nhưng nó không biết tới đống chữ còn kẹt trong bộ đệm của thư viện. Nếu bạn tắt phụt bằng
_exit(), đống chữ đó mất luôn. Muốn chắc chữ ra hết thì dùngexit()hoặc đẩy tay bằngfflush(). Nhớ nhé: nhân đóng tệp, nhưng không đẩy chữ hộ bạn.The kernel auto-closing file descriptors on termination is convenient, but it does not replace your program's logic. Data still sitting in the user-space (stdio) buffer is lost if you call
_exit()directly, because the kernel only closes the fd and knows nothing of the library buffer. Onlyexit()(orfflush()) pushes that out.123Bạn gặp nó ở đâuWhere you meet it Khi một tiến trình con gọi_exit()và dòng log cuối cùng (in bằngprintf()) biến mất — vì nó còn trong vùng đệm chưa được xả. Khi dòng log cuối cùng "bốc hơi" vì con tắt phụt trước khi kịp đẩy chữ ra. When a child calls_exit()and its last log line (printed withprintf()) vanishes — because it was still in an unflushed buffer.
Cụm khái niệm 03 Concept cluster 03
Exit handler — việc cuối cùng bạn dặn, chạy theo thứ tự ngược Việc dặn làm lúc thoát — và chúng chạy từ mới đến cũ Exit handlers — your last-wish tasks, run in reverse order
Một exit handler (trình xử lý thoát) là một hàm do lập trình viên cung cấp, được đăng ký trước rồi tự động gọi khi tiến trình kết thúc bình thường qua exit().14 Cách truyền thống để đăng ký là hàm atexit() trong <stdlib.h>: nó nhận một con trỏ hàm (hàm không đối số, không trả giá trị) và trả về 0 nếu thành công, khác 0 nếu lỗi.14
Exit handler là "việc cuối cùng" bạn dặn chương trình làm trước khi nó thật sự tắt — ví dụ lưu lại tiến độ, đóng kết nối. Bạn "ghi danh" các việc đó bằng atexit(). Khi chương trình thoát đàng hoàng, những việc này được gọi tự động. Nhớ nhé: atexit() để dặn việc làm lúc thoát.
An exit handler is a programmer-supplied function that is registered in advance and then called automatically when the process terminates normally via exit().14 The traditional way to register one is the atexit() function in <stdlib.h>: it takes a function pointer (a function taking no arguments and returning no value) and returns 0 on success, nonzero on error.14
Quy tắc gọi. Có thể đăng ký nhiều exit handler; chúng được gọi theo thứ tự ngược với lúc đăng ký (cái đăng ký sau chạy trước — ngăn xếp LIFO). SUSv3/POSIX yêu cầu một hệ ít nhất phải cho phép 32 handler (ATEXIT_MAX); có thể hỏi giới hạn thực tế bằng sysconf(_SC_ATEXIT_MAX).14
Thứ tự. Dặn nhiều việc thì việc dặn sau cùng lại được làm đầu tiên — như xếp đĩa: đĩa đặt lên sau lấy ra trước. Và bạn được dặn ít nhất 32 việc. Nhớ nhé: việc dặn sau chạy trước.
The calling rule. You may register many exit handlers; they are called in the reverse order of registration (last registered runs first — a LIFO stack). SUSv3/POSIX requires a system to allow at least 32 handlers (ATEXIT_MAX); you can query the real limit with sysconf(_SC_ATEXIT_MAX).14
atexit(f1); atexit(f2); atexit(f3);
exit(0);
// gọi theo thứ tự: f3, f2, f1
#include <stdlib.h>
int atexit(void (*func)(void));
dặn: f1, rồi f2, rồi f3
khi thoát chạy: f3, f2, f1
(dặn sau chạy trước)
atexit(f1); atexit(f2); atexit(f3);
exit(0);
// called in order: f3, f2, f1
#include <stdlib.h>
int atexit(void (*func)(void));
-
on_exit()
on_exit() — bản linh hoạt hơn, nhận trạng thái và một đối số on_exit() — mạnh hơn, biết kết quả thoát on_exit() — the flexible variant, gets status and an argument
Hàm
on_exit()của GNU (có gốc từ SunOS 4) linh hoạt hơnatexit(): hàm handler bạn đăng ký nhận hai đối số — trạng thái màexit()được gọi với, và một con trỏvoid *bạn gửi kèm lúc đăng ký. Nhờ vậy handler biết được chương trình thoát với trạng thái nào và có thể dùng dữ liệu riêng.15Có một anh em họ của
atexit()tên làon_exit(): nó mạnh hơn vì cho việc-cuối-cùng của bạn biết chương trình thoát với kết quả gì, và cho bạn gửi kèm một gói dữ liệu để dùng. Nhớ nhé: on_exit() biết kết quả thoát, atexit() thì không.The GNU
on_exit()function (originating in SunOS 4) is more flexible thanatexit(): the handler you register receives two arguments — the status with whichexit()was called, and avoid *pointer you supplied at registration time. So the handler knows the exit status and can use its own data.15Bạn gặp nó ở đâuWhere you meet it Khi cần một handler làm việc khác nhau tùy chương trình thoát thành công hay lỗi —on_exit()cho bạn đọc trạng thái, điềuatexit()không làm được. Khi muốn việc-cuối xử khác nhau lúc thành công và lúc lỗi. When a handler must behave differently depending on whether the program exited with success or failure —on_exit()lets you read the status, whichatexit()cannot.Cảnh báo khả chuyểnPortability warning
Kerrisk lưu ý
on_exit()không được SUSv3 chuẩn hóa và không có mặt trên nhiều hệ UNIX khác; vì tính khả chuyển, nên ưu tiênatexit().15Kerrisk notes thaton_exit()is not standardized by SUSv3 and is absent on many other UNIX systems; for portability, preferatexit().15Trộn lẫn hai cáchMixing the two
Có thể đăng ký handler bằng cả
atexit()lẫnon_exit()trong cùng chương trình; mọi handler, bất kể đăng ký bằng cách nào, đều được gọi theo đúng một thứ tự ngược chung với lúc đăng ký.15You may register handlers with bothatexit()andon_exit()in the same program; all handlers, however registered, are called in a single combined reverse order of registration.15 -
not always called
Exit handler KHÔNG chạy nếu bạn gọi _exit() hay bị tín hiệu Tắt phụt hay bị đá ra thì việc dặn không chạy Handlers do NOT run on _exit() or on a signal
Đây là điểm dễ sai: exit handler chỉ chạy khi tiến trình kết thúc bình thường qua
exit(). Nếu chương trình gọi thẳng_exit(), hoặc kết thúc bất thường do một tín hiệu, thì các handler không được gọi — và cả stdio cũng không được xả.14Chỗ này hay nhầm: việc dặn-lúc-thoát chỉ chạy khi chương trình thoát đàng hoàng bằng
exit(). Nếu nó tắt phụt bằng_exit()hay bị "đá ra" bởi một tín hiệu, mấy việc dặn đó bị bỏ qua hết. Nhớ nhé: chỉ exit() mới kích hoạt việc dặn; _exit() và tín hiệu thì không.A common mistake: exit handlers run only when the process terminates normally via
exit(). If the program calls_exit()directly, or terminates abnormally due to a signal, the handlers are not called — and stdio is not flushed either.14Bạn gặp nó ở đâuWhere you meet it Khi một hàm dọn dẹp quan trọng (lưu dữ liệu, xóa tệp tạm) không chạy vì chương trình bịSIGKILLhoặc bị buộc thoát bằng_exit()— handler là không đủ cho mọi tình huống.4 Khi việc dọn dẹp quan trọng không chạy vì chương trình bị giết đột ngột. When a critical cleanup (saving data, deleting a temp file) does not run because the program was hit bySIGKILLor forced out via_exit()— handlers are not enough for every situation.4 -
fork / exec
fork() giữ bản sao đăng ký; exec() xóa sạch Con fork() thừa hưởng; exec() xóa hết fork() copies registrations; exec() wipes them
Một tiến trình con sinh ra bằng
fork()thừa hưởng một bản sao các đăng ký exit handler của cha. Ngược lại, khi tiến trình gọiexec()thành công để nạp một chương trình mới, mọi đăng ký exit handler bị xóa — hợp lý, vì chương trình cũ (cùng mã các handler) đã bị thay thế hoàn toàn.14Khi một chương trình đẻ ra con bằng
fork(), con chép lại luôn danh sách việc-dặn của cha. Nhưng khi chương trình "lột xác" thành chương trình khác bằngexec(), danh sách việc-dặn bị xóa sạch — vì mã cũ không còn nữa. Nhớ nhé: fork() chép việc dặn, exec() xóa việc dặn.A child process created by
fork()inherits a copy of the parent's exit handler registrations. Conversely, when a process callsexec()successfully to load a new program, all exit handler registrations are removed — sensibly, since the old program (and the handler code) has been entirely replaced.14Bạn gặp nó ở đâuWhere you meet it Trong mẫufork()+exec(): handler đăng ký ở cha vẫn còn trong con cho tới khiexec()chạy; nếuexec()thất bại và con gọiexit(), handler của cha sẽ chạy trong con — một bất ngờ cần lường trước. Khi exec() lỡ thất bại, con có thể vô tình chạy lại việc-dặn của cha — dễ gây rối. In thefork()+exec()pattern: handlers registered in the parent survive in the child untilexec()runs; ifexec()fails and the child callsexit(), the parent's handlers will run in the child — a surprise to anticipate.Không gỡ được handlerYou can't deregister
SUSv3 không định nghĩa cách gỡ bỏ (deregister) một exit handler đã đăng ký. Một handler có thể tự vô hiệu hóa bằng cách kiểm tra một cờ toàn cục khi được gọi, nhưng không có hàm chuẩn để "rút tên" khỏi danh sách.14SUSv3 defines no way to deregister a registered exit handler. A handler can disable itself by checking a global flag when invoked, but there is no standard function to "remove its name" from the list.14
Handler gọi exit() là nguy hiểmA handler calling exit() is dangerous
SUSv3 nói nếu một exit handler tự gọi
exit()thì kết quả là không xác định (undefined). Tránh làm vậy. Nếu handler gọi_exit(), các handler còn lại không chạy và stdio có thể không được xả.14SUSv3 says that if an exit handler itself callsexit(), the result is undefined. Avoid it. If a handler calls_exit(), the remaining handlers do not run and stdio may not be flushed.14
Cụm khái niệm 04 Concept cluster 04
fork() nhân đôi vùng đệm stdio — và đó là lý do đầu ra in ra trùng Vì sao sau fork() một dòng chữ lại in ra hai lần fork() duplicates the stdio buffer — that is why output doubles
Vùng đệm stdio nằm trong bộ nhớ của tiến trình (user space), do thư viện C quản lý — nhân không biết tới nó. Vì fork() tạo ra một bản sao toàn bộ không gian bộ nhớ của tiến trình, mọi dữ liệu còn chưa xả trong vùng đệm stdio cũng bị chép sang con. Khi cả cha lẫn con sau đó gọi exit(), mỗi bên xả bản đệm của mình — và cùng một dòng chữ bị in ra hai lần.16
Chữ bạn in bằng printf() không ra ngoài ngay, mà nằm chờ trong một "giỏ" trong bộ nhớ chương trình. Khi fork() đẻ ra con, nó chép luôn cả cái giỏ còn đầy chữ sang con. Thế là cả cha và con đều đổ giỏ của mình ra — một dòng chữ hóa thành hai. Nhớ nhé: fork() chép luôn giỏ chữ chưa đổ, nên dễ bị in trùng.
The stdio buffer lives in the process's memory (user space), managed by the C library — the kernel knows nothing of it. Because fork() makes a copy of the process's entire memory space, any data still unflushed in the stdio buffer is also copied into the child. When parent and child later both call exit(), each flushes its own copy — and the same line of text is printed twice.16
Vì sao chỉ xảy ra khi ghi vào tệp. Khi đầu ra nối với terminal, stdio mặc định đệm theo dòng (line-buffered): ký tự xuống dòng \n làm xả ngay, nên vùng đệm rỗng trước khi fork(). Khi đầu ra chuyển hướng vào tệp hay pipe, stdio đệm theo khối (block-buffered): \n không xả, dữ liệu kẹt lại — và bị nhân đôi.18
Vì sao chỉ hỏng khi ghi ra tệp. In ra màn hình thì mỗi lần xuống dòng là chữ bay ra ngay, giỏ rỗng. Nhưng in vào tệp thì chữ nằm lại trong giỏ cho tới khi đầy — nên lúc fork() giỏ vẫn còn chữ, và bị chép đôi. Nhớ nhé: in ra màn hình thường không sao; chuyển hướng vào tệp mới lộ lỗi.
Why only when writing to a file. When output goes to a terminal, stdio is by default line-buffered: a newline \n flushes immediately, so the buffer is empty before fork(). When output is redirected to a file or pipe, stdio is block-buffered: \n does not flush, data lingers — and gets duplicated.18
printf("Hello world\n"); // vào giỏ (block-buf)
write(STDOUT_FILENO, "Ciao\n", 5); // ra ngay
fork(); // chép luôn giỏ còn chữ
exit(0); // cha & con đều xả giỏ
// ra tệp: Ciao / Hello world / Hello world
printf("Hello world\n");// nằm trong giỏ
write(..., "Ciao\n");// ra ngay
fork(); exit(0);
// tệp: Ciao, rồi Hello world 2 lần
printf("Hello world\n"); // into buffer (block-buf)
write(STDOUT_FILENO, "Ciao\n", 5); // immediate
fork(); // copies the still-full buffer
exit(0); // parent & child each flush
// to file: Ciao / Hello world / Hello world
-
write() vs printf()
Vì sao "Ciao" ra đúng một lần còn "Hello world" ra hai write() ra ngay, printf() thì chờ trong giỏ Why "Ciao" appears once but "Hello world" twice
Trong ví dụ của Kerrisk,
write()là system call ghi thẳng ra tệp, không qua đệm stdio — nên"Ciao"ra đúng một lần, và ra trước. Còn"Hello world"in bằngprintf()thì nằm trong vùng đệm stdio; saufork()cả hai tiến trình cùng mang nó, nên khi cùngexit()nó bị xả hai lần.16write()đẩy chữ ra tệp ngay lập tức, nên"Ciao"chỉ ra một lần.printf()lại bỏ chữ vào giỏ chờ; fork() chép giỏ sang con, rồi cả hai cùng đổ giỏ, nên"Hello world"ra hai lần. Nhớ nhé: write() ra ngay, printf() chờ giỏ — nên chỉ printf() bị nhân đôi.In Kerrisk's example,
write()is a system call that writes straight to the file, bypassing the stdio buffer — so"Ciao"appears exactly once, and first. But"Hello world", printed withprintf(), sits in the stdio buffer; afterfork()both processes carry it, so when they bothexit()it is flushed twice.16Bạn gặp nó ở đâuWhere you meet it Khi một chương trình chạy đúng lúc in ra màn hình, nhưng khi bạn làm./prog > out.txtthì tệp lại có dòng lặp — kinh điển của trộnprintf()vớifork(). Khi chạy thường thì ổn, mà ghi ra tệp lại thấy dòng bị lặp. When a program is fine printing to the screen, but./prog > out.txtleaves a duplicated line in the file — the classic of mixingprintf()withfork(). -
two fixes
Hai cách sửa: xả trước khi fork(), hoặc con dùng _exit() Sửa: đổ giỏ trước khi fork, hoặc con tắt phụt Two fixes: flush before fork(), or have the child use _exit()
Kerrisk nêu hai hướng xử lý. Một: xả vùng đệm trước khi
fork()— gọifflush()ngay sauprintf(), hoặc tắt chế độ đệm bằngsetvbuf()/setbuf(), để không còn gì trong giỏ lúc rẽ nhánh. Hai: cho tiến trình con thoát bằng_exit()thay vìexit(), để con không xả bản sao vùng đệm mà nó thừa hưởng.128Có hai cách chữa. Cách 1: đổ sạch giỏ trước khi fork() (dùng
fflush()hay tắt chế độ đệm) — giỏ rỗng thì chẳng có gì để chép. Cách 2: cho con tắt phụt bằng_exit()— con sẽ không đổ cái giỏ nó mượn của cha. Nhớ nhé: đổ giỏ trước khi fork, hoặc cho con tắt bằng _exit().Kerrisk gives two approaches. One: flush the buffer before
fork()— callfflush()right afterprintf(), or turn off buffering withsetvbuf()/setbuf(), so nothing is in the buffer at the split. Two: have the child exit with_exit()rather thanexit(), so the child does not flush the inherited copy of the buffer.128Bạn gặp nó ở đâuWhere you meet it Đây là lý do rất nhiều khungfork()cho nhánh con kết thúc bằng_exit(), không phảiexit()— một quy ước bạn sẽ gặp lại suốt phần tiến trình của sách.1 Đó là lý do nhiều ví dụ cho con tắt bằng _exit() chứ không exit(). This is why so manyfork()skeletons end the child branch with_exit(), notexit()— a convention you meet throughout the book's process chapters.1 -
same with exec
exec() cũng cần xả trước — vì nó thay cả bộ nhớ exec() cũng vậy: nhớ đổ giỏ trước exec() needs a flush too — it replaces all memory
Một hệ quả song song: khi gọi
exec(), toàn bộ bộ nhớ chương trình cũ — kể cả vùng đệm stdio chưa xả — bị thay thế bởi chương trình mới. Dữ liệu trong giỏ sẽ mất nếu không được xả trước khiexec(). Vì vậy cùng một kỷ luật:fflush()(hoặc đểexit()lo) trước khi rẽ sang chương trình khác.16Chuyện tương tự với
exec(): khi chương trình "lột xác" thành chương trình khác, cái giỏ chữ cũ bị vứt đi cùng bộ nhớ cũ. Chữ chưa đổ thì mất luôn. Nên nhớ đổ giỏ trước khi exec(). Nhớ nhé: trước khi exec(), đổ giỏ chữ kẻo mất.A parallel consequence: when you call
exec(), the old program's entire memory — including an unflushed stdio buffer — is replaced by the new program. Data in the buffer is lost if not flushed beforeexec(). So the same discipline applies:fflush()(or letexit()handle it) before switching to another program.16Bạn gặp nó ở đâuWhere you meet it Khi một dòng log in ngay trướcexecvp()không bao giờ xuất hiện — vì nó còn trong giỏ và bị chương trình mới ghi đè lên. Khi dòng log ngay trước exec() biến mất — vì chưa kịp đổ ra. When a log line printed just beforeexecvp()never shows — it was still in the buffer and got overwritten by the new program.
Ba ngộ nhận thường gặp Three common misconceptions
“exit() và _exit() dùng thay nhau cũng được” “exit() and _exit() are interchangeable”
Ngộ nhận: gọi cái nào cũng kết thúc tiến trình như nhau.
Thực tế: exit() chạy exit handler và xả stdio trước; _exit() bỏ qua cả hai và kết thúc tức thì — nên chữ trong vùng đệm có thể mất.123
Myth: either one ends the process the same way.
Reality: exit() runs exit handlers and flushes stdio first; _exit() skips both and ends at once — so buffered output can be lost.123
“exit(-1) báo cho cha đúng −1” “exit(-1) tells the parent exactly −1”
Ngộ nhận: cha nhận nguyên con số bạn truyền cho exit().
Thực tế: chỉ 8 bit thấp tới cha, nên exit(-1) hiện ra là 255; nên dùng EXIT_SUCCESS/EXIT_FAILURE hoặc số 0–255.127
Myth: the parent gets the whole value you passed to exit().
Reality: only the low 8 bits reach the parent, so exit(-1) shows up as 255; use EXIT_SUCCESS/EXIT_FAILURE or values 0–255.127
“Exit handler luôn chạy dù tiến trình kết thúc kiểu gì” “Exit handlers always run no matter how it ends”
Ngộ nhận: đăng ký handler rồi thì chắc chắn nó được gọi.
Thực tế: handler chỉ chạy khi kết thúc bình thường qua exit(); gọi _exit() hoặc bị tín hiệu thì không — nên đừng đặt dọn dẹp sống-còn chỉ vào handler.14
Myth: once you register a handler it is sure to run.
Reality: handlers run only on normal termination via exit(); a _exit() call or a signal skips them — so don't put survival-critical cleanup in a handler alone.14
Tự kiểm Self-check
Năm câu hỏi để biết bạn đã nắm cách tiến trình kết thúc Năm câu hỏi kiểm tra nhanh Five questions to check you grasped process termination
Trả lời được những câu này nghĩa là bạn đã phân biệt được hai tầng kết thúc và lường trước được cạm bẫy đầu ra sau fork().
Nếu trả lời được, nghĩa là bạn đã hiểu chương này.
Answering these means you can tell the two termination layers apart and anticipate the post-fork() output trap.
-
Chính xác thì
exit()làm những gì mà_exit()không làm, và theo thứ tự nào?Gợi ý sâu hơn: nếu một exit handler tự gọi
_exit()thì các handler còn lại có chạy không? -
Vì sao
exit(-1)lại tới tiến trình cha dưới dạng 255? Quy ước nào an toàn hơn để báo thành công/thất bại?Gợi ý sâu hơn: con số bạn thấy khi gõ
echo $?liên quan thế nào tớistatus & 0xFF? -
Exit handler được đăng ký bằng
atexit()chạy theo thứ tự nào, và trong tình huống nào chúng không được gọi?Gợi ý sâu hơn: điều gì xảy ra với các đăng ký handler sau
fork()và sauexec()? -
Vì sao một chương trình trộn
printf()vớifork()lại in ra trùng khi ghi vào tệp mà không trùng khi in ra terminal?Gợi ý sâu hơn: vì sao
"Ciao"viết bằngwrite()chỉ ra một lần và ra trước"Hello world"? -
Nêu hai cách khác nhau để tránh đầu ra bị nhân đôi sau
fork().Gợi ý sâu hơn: vì sao cùng một kỷ luật xả vùng đệm cũng cần thiết trước khi gọi
exec()?
Hai nút tắt
exit()và_exit()khác nhau ở chỗ nào?Vì sao gửi số âm làm trạng thái thoát là ý tồi?
Việc-dặn-lúc-thoát (exit handler) chạy khi nào, và khi nào bị bỏ qua?
Vì sao sau
fork()một dòng chữ có thể in ra hai lần khi ghi vào tệp?Kể một cách để tránh bị in trùng sau
fork().
What exactly does
exit()do that_exit()does not, and in what order?Why does
exit(-1)reach the parent as 255? Which convention is safer for signalling success/failure?In what order do
atexit()handlers run, and in which situations are they not called?Why does a program mixing
printf()withfork()duplicate output to a file but not to a terminal?Give two different ways to avoid duplicated output after
fork().
Khép lại chương Closing the chapter
Kết thúc một tiến trình có hai tầng: nhân với
_exit()dừng ngay, thư viện vớiexit()dọn dẹp trước. Nắm được ranh giới ấy — ai xả vùng đệm, ai chạy exit handler, và vì sao chỉ 8 bit thấp của trạng thái tới được cha — bạn sẽ không còn ngạc nhiên khifork()làm đầu ra in ra hai lần.1Có hai nút tắt: một nút dọn dẹp rồi mới tắt, một nút tắt ngay. Hiểu nút nào làm gì, và nhớ rằng cha chỉ nhận một con số nhỏ báo kết quả, là đủ để tránh in trùng và mất chữ. Nhớ nhé: chọn đúng nút tắt, chữ sẽ ra đúng một lần.
Ending a process has two layers: the kernel's
_exit()stops at once, the library'sexit()cleans up first. Grasp that boundary — who flushes the buffer, who runs the exit handlers, and why only the low 8 bits of the status reach the parent — and you will no longer be surprised whenfork()prints your output twice.1