The Linux Programming Interface Chương 25 · Process Termination

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

  1. 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ù status là một int, 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.127

    Chươ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 status is an int, only its low 8 bits are handed to the parent. So exit(-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.127

    Bạ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 & 0xFF củ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's status & 0xFF.7

    Hằng chuẩnStandard constants

    Thay vì viết 0 và một số khác 0 tùy ý, <stdlib.h> định nghĩa EXIT_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> defines EXIT_SUCCESS (0) and EXIT_FAILURE (1) — more portable, even to non-UNIX environments.3

    Vì 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ị int mà 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 one int that wait() returns; the field for a normal exit status is only 8 bits — the technical reason for the 0xFF mask.17

  2. 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àm main() nhìn chung tương đương với gọi exit(n), bởi chính hàm khởi động chạy thời (run-time startup) đã gọi main() sẽ dùng giá trị trả về đó làm đối số cho exit().1 Vì exit() được gọi, phần dọn dẹp (exit handler, xả stdio) vẫn diễn ra.13

    Trong hàm chính main(), viết return 0; gần như y hệt gọi exit(0). Lý do: có một đoạn mã nền chạy trước main() sẽ cầm con số bạn trả về và đưa thẳng cho exit(). 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; in main() is generally equivalent to calling exit(n), because the run-time startup function that invoked main() uses that return value as the argument to exit().1 Because exit() is called, the cleanup (exit handlers, stdio flush) still happens.13

    Bạ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ếu main() kiểu int, điều này tương đương return 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" of main() with no return: under C99, if main() is of type int, this is equivalent to return 0; — a success exit.1
  3. _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 sau fork(), 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).12

    Thườ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 after fork(), when the child does not want to re-flush the stdio buffers it inherited from the parent (discussed in cluster 04).12

    Bạn gặp nó ở đâuWhere you meet it Trong gần như mọi khung xử lý fork() + exec(): nếu exec() 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 every fork() + exec() skeleton: if exec() 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

  1. 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ị SIGSEGV và bạn thấy tệp core xuất hiện — đó là dấu hiệu kết thúc bất thường, không qua exit(). 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 by SIGSEGV and you see a core file appear — that is abnormal termination, not via exit().
  2. 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 SIGCHLD cho tiến trình cha, và trạng thái kết thúc được giữ lại để cha thu về qua wait(). Cho tới khi cha thu, tiến trình con đã chết vẫn tồn tại ở dạng zombie.12

    Khi 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 SIGCHLD signal to the parent, and the termination status is kept for the parent to collect via wait(). Until collected, the dead child persists as a zombie.12

    Bạ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ùng wait()/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 uses wait()/waitpid() to reap the status and clear zombies.1
  3. 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ặc fflush()) mới đẩy phần đó ra.123

    Nhâ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ùng exit() hoặc đẩy tay bằng fflush(). 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. Only exit() (or fflush()) pushes that out.123

    Bạ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ằng printf()) 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 with printf()) 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));

  1. 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ơn atexit(): 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.15

    Có 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 than atexit(): the handler you register receives two arguments — the status with which exit() was called, and a void * pointer you supplied at registration time. So the handler knows the exit status and can use its own data.15

    Bạ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ều atexit() 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, which atexit() 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ên atexit().15Kerrisk notes that on_exit() is not standardized by SUSv3 and is absent on many other UNIX systems; for portability, prefer atexit().15

    Trộn lẫn hai cáchMixing the two

    Có thể đăng ký handler bằng cả atexit() lẫn on_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 both atexit() and on_exit() in the same program; all handlers, however registered, are called in a single combined reverse order of registration.15

  2. 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ả.14

    Chỗ 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.14

    Bạ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ị SIGKILL hoặ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 by SIGKILL or forced out via _exit() — handlers are not enough for every situation.4
  3. 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ọi exec() 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.14

    Khi 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ằng exec(), 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 calls exec() 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.14

    Bạn gặp nó ở đâuWhere you meet it Trong mẫu fork() + exec(): handler đăng ký ở cha vẫn còn trong con cho tới khi exec() chạy; nếu exec() thất bại và con gọi exit(), 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 the fork() + exec() pattern: handlers registered in the parent survive in the child until exec() runs; if exec() fails and the child calls exit(), 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 calls exit(), 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

  1. 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ằng printf() thì nằm trong vùng đệm stdio; sau fork() cả hai tiến trình cùng mang nó, nên khi cùng exit() nó bị xả hai lần.16

    write() đẩ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 with printf(), sits in the stdio buffer; after fork() both processes carry it, so when they both exit() it is flushed twice.16

    Bạ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.txt thì tệp lại có dòng lặp — kinh điển của trộn printf() với fork(). 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.txt leaves a duplicated line in the file — the classic of mixing printf() with fork().
  2. 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ọi fflush() ngay sau printf(), hoặc tắt chế độ đệm bằng setvbuf()/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.128

    Có 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() — call fflush() right after printf(), or turn off buffering with setvbuf()/setbuf(), so nothing is in the buffer at the split. Two: have the child exit with _exit() rather than exit(), so the child does not flush the inherited copy of the buffer.128

    Bạn gặp nó ở đâuWhere you meet it Đây là lý do rất nhiều khung fork() cho nhánh con kết thúc bằng _exit(), không phải exit() — 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 many fork() skeletons end the child branch with _exit(), not exit() — a convention you meet throughout the book's process chapters.1
  3. 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 khi exec(). 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.16

    Chuyệ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 before exec(). So the same discipline applies: fflush() (or let exit() handle it) before switching to another program.16

    Bạn gặp nó ở đâuWhere you meet it Khi một dòng log in ngay trước execvp() 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 before execvp() 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.

  1. 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?

  2. 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ới status & 0xFF?

  3. 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à sau exec()?

  4. Vì sao một chương trình trộn printf() với fork() 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ằng write() chỉ ra một lần và ra trước "Hello world"?

  5. 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()?

  1. Hai nút tắt exit() và _exit() khác nhau ở chỗ nào?

  2. Vì sao gửi số âm làm trạng thái thoát là ý tồi?

  3. Việc-dặn-lúc-thoát (exit handler) chạy khi nào, và khi nào bị bỏ qua?

  4. Vì sao sau fork() một dòng chữ có thể in ra hai lần khi ghi vào tệp?

  5. Kể một cách để tránh bị in trùng sau fork().

  1. What exactly does exit() do that _exit() does not, and in what order?

  2. Why does exit(-1) reach the parent as 255? Which convention is safer for signalling success/failure?

  3. In what order do atexit() handlers run, and in which situations are they not called?

  4. Why does a program mixing printf() with fork() duplicate output to a file but not to a terminal?

  5. 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ới exit() 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 khi fork() làm đầu ra in ra hai lần.1

Có 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's exit() 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 when fork() prints your output twice.1