Chương 24 · Tạo tiến trình Chapter 24 · Process Creation
Một lời gọi, hai tiến trình: cách Linux nhân bản một chương trình đang chạy Gọi fork() một lần, tự nhiên có hai chương trình chạy song song One call, two processes: how Linux clones a running program
Chương 24 mở ra cụm bốn chương về vòng đời tiến trình bằng một bộ tứ system call: fork() tạo tiến trình con, exit() kết thúc một tiến trình, wait() để cha chờ con, và execve() nạp một chương trình mới đè lên tiến trình hiện tại.1 Trọng tâm của chương là fork(): nó trả về hai lần, tiến trình con là một bản sao gần như y hệt của cha, cha và con dùng chung các mô tả tệp, còn bộ nhớ thì không chép ngay mà dùng kỹ thuật copy-on-write.12
Hãy tưởng tượng chương trình của bạn tự tách làm đôi: từ một lệnh fork(), bỗng có hai chương trình giống hệt nhau cùng chạy tiếp — một "cha", một "con". Cả hai chia nhau các tệp đang mở, còn bộ nhớ thì Linux khéo léo chỉ chép khi thật sự cần. Chương này dạy bạn làm chuyện tách đôi đó và tránh những cái bẫy đi kèm. Nhớ nhé: fork() biến một tiến trình thành hai.
Chapter 24 opens a cluster of chapters on the process life cycle with a quartet of system calls: fork() creates a child process, exit() terminates a process, wait() lets a parent wait for a child, and execve() loads a new program over the current process.1 The chapter's heart is fork(): it returns twice, the child is an almost exact duplicate of the parent, parent and child share open file descriptors, and memory is not copied up front but handled by copy-on-write.12
Điểm phương pháp đáng chú ý: cách UNIX tách tạo tiến trình (fork) khỏi nạp chương trình (exec) thành hai bước riêng — khác với một số hệ điều hành gộp chúng thành một thao tác "spawn" duy nhất. Sự tách đôi này làm API đơn giản hơn (fork() không nhận đối số nào) và cho chương trình khoảng trống để làm việc giữa hai bước — ví dụ đóng/mở lại mô tả tệp trước khi exec. SUSv3 vẫn định nghĩa tùy chọn posix_spawn() gộp hiệu ứng của fork() và exec(), dành cho các kiến trúc phần cứng (thường là hệ nhúng) không có swap hay đơn vị quản lý bộ nhớ, nơi một fork() truyền thống khó hoặc không thể hiện thực.16
Luận đề của chương: fork() tạo ra một bản sao gần như hoàn hảo của tiến trình gọi rồi trả về trong cả hai tiến trình — và chính sự nhân bản đó sinh ra ba bài toán cốt lõi: chia sẻ tài nguyên (mô tả tệp dùng chung), hiệu quả bộ nhớ (copy-on-write, và lối tắt vfork()), và tính bất định của thứ tự thực thi (race condition phải được đồng bộ một cách tường minh).1
Ý chính: tách một tiến trình thành hai thì dễ, nhưng đi kèm ba câu hỏi: hai bên dùng chung những gì, có tốn gấp đôi bộ nhớ không, và ai chạy trước? Chương này trả lời cả ba. Nhớ nhé: nhân bản tiến trình kéo theo chuyện chia sẻ, chuyện bộ nhớ, và chuyện thứ tự.
The chapter's thesis: fork() makes a nearly perfect copy of the calling process and returns in both — and that duplication raises three core problems: resource sharing (shared file descriptors), memory efficiency (copy-on-write, plus the vfork() shortcut), and the indeterminacy of execution order (a race condition that must be synchronized explicitly).1
Cụm khái niệm 01 Concept cluster 01
Bốn system call làm nên vòng đời của một tiến trình Bốn lời gọi điều khiển cả đời một tiến trình The four system calls that shape a process's life cycle
Trước khi đào sâu, Kerrisk giới thiệu ngắn gọn bốn lời gọi thường đi cùng nhau: fork() cho một tiến trình (cha) tạo ra một tiến trình mới (con) là bản sao gần như y hệt; exit(status) kết thúc một tiến trình và trả tài nguyên về cho nhân; wait(&status) cho cha tạm dừng tới khi một con kết thúc và lấy về trạng thái kết thúc đó; và execve(pathname, argv, envp) nạp một chương trình mới vào bộ nhớ của tiến trình, vứt bỏ đoạn text cũ.1
Tác giả mở đầu bằng bốn "động từ" của đời một tiến trình: fork() là sinh con, exit() là qua đời, wait() là cha chờ con, và execve() là thay da đổi thịt — khoác lên mình một chương trình hoàn toàn mới. Bốn động từ này quay vòng trong gần như mọi chương trình UNIX. Nhớ nhé: sinh con, thay chương trình, chờ con, rồi kết thúc — đó là nhịp sống của tiến trình.
Before digging in, Kerrisk briefly introduces four calls that usually travel together: fork() lets one process (the parent) create a new process (the child) that is an almost exact duplicate; exit(status) terminates a process and returns its resources to the kernel; wait(&status) suspends the parent until a child terminates and retrieves that termination status; and execve(pathname, argv, envp) loads a new program into the process's memory, discarding the old text.1
Định nghĩa. UNIX tách tạo tiến trình và nạp chương trình thành hai bước riêng biệt — khác với mô hình "spawn" của một số hệ khác. Nhờ đó fork() không nhận đối số nào, và chương trình có khoảng trống để thu xếp mọi thứ giữa fork() và exec(); đôi khi ta fork() mà không cần exec() theo sau.1
Nói đơn giản. Sinh con và đổi chương trình là hai việc tách rời, không gộp làm một. Vì tách rời nên bạn có thể fork rồi vẫn chạy chương trình cũ, hoặc fork rồi mới đổi sang chương trình khác. Nhớ nhé: tạo tiến trình và nạp chương trình là hai bước, không phải một.
Definition. UNIX separates process creation and program loading into two distinct steps — unlike the "spawn" model of some other systems. This is why fork() takes no arguments, and a program has room to arrange everything between fork() and exec(); sometimes we fork() with no following exec().1
parent "A"
--fork()--> child "A" (bản sao bộ nhớ)
child: execve("B") --> chạy chương trình B
parent: wait(&status) <-- child: exit(status)
cha chạy "A"
--fork()--> con cũng là "A"
con: đổi sang chạy "B"
cha: chờ con --- con: kết thúc
parent "A"
--fork()--> child "A" (memory copy)
child: execve("B") --> runs program B
parent: wait(&status) <-- child: exit(status)
-
fork()
fork() — nhân đôi tiến trình gọi fork() — tách một thành hai fork() — duplicate the calling process
fork()cho một tiến trình tạo một bản sao gần như y hệt chính nó. Tên "fork" (chạc cây) gợi hình ảnh tiến trình cha tách nhánh thành hai bản của chính mình. Con nhận bản sao các đoạn ngăn xếp, dữ liệu, heap và text của cha.12Hãy nghĩ tới việc photocopy một người đang làm việc: bản photo tiếp tục công việc từ đúng chỗ bản gốc dừng lại.
fork()làm vậy với một tiến trình. Nhớ nhé: fork() là bản photocopy một chương trình đang chạy.fork()lets a process create a nearly exact copy of itself. The name "fork" evokes the parent branching into two copies of itself. The child obtains copies of the parent's stack, data, heap, and text segments.12Bạn gặp nó ở đâuWhere you meet it Một máy chủ mạng nghe kết nối rồifork()một tiến trình con để phục vụ mỗi khách — mẫu thiết kế kinh điển của chương.1 Máy chủ web tạo một tiến trình con cho mỗi người truy cập — đó chính là fork(). A network server listens for connections thenfork()s a child to serve each client — the chapter's classic design pattern.1 -
exit() / _exit()
exit() — kết thúc và trả tài nguyên exit() — kết thúc một tiến trình exit() — terminate and release resources
Hàm thư viện
exit(status)kết thúc một tiến trình, khiến mọi tài nguyên (bộ nhớ, mô tả tệp…) được trả về cho nhân để tái cấp phát. Đối sốstatuslà một số nguyên định trạng thái kết thúc mà cha có thể lấy về quawait().exit()được xây trên system call_exit(); sau mộtfork(), thường chỉ một trong hai tiến trình gọiexit(), còn bên kia nên dùng_exit().14exit()là cách một tiến trình "nói lời tạm biệt": nó trả lại mọi thứ đã mượn và để lại một con số cho cha biết nó kết thúc ra sao. Nhớ nhé: exit() đóng cửa và trả chìa khoá lại cho nhân.The library function
exit(status)terminates a process, making all its resources (memory, open file descriptors, and so on) available to the kernel for reallocation. Thestatusargument is an integer defining the termination status the parent can retrieve viawait().exit()is layered on the_exit()system call; after afork(), generally only one of the two processes should terminate by callingexit(), while the other uses_exit().14Bạn gặp nó ở đâuWhere you meet it Sự khác nhau giữaexit()và_exit()— nhất là chuyện xả vùng đệmstdio— được Chương 25 giải thích kỹ.1 Vì sao có hai cách kết thúc (exit và _exit)? Chương 25 nói rõ. The difference betweenexit()and_exit()— especially about flushingstdiobuffers — is explained in Chapter 25.1 -
wait() / execve()
wait() chờ con, execve() thay chương trình wait() chờ con, execve() đổi chương trình wait() awaits a child, execve() replaces the program
wait(&status)có hai vai trò: nếu một con chưa kết thúc, nó tạm dừng cha cho tới khi một con kết thúc; và nó trả về trạng thái kết thúc của con trong đối sốstatus.17execve(pathname, argv, envp)nạp một chương trình mới vào bộ nhớ tiến trình: đoạn text cũ bị vứt bỏ, còn ngăn xếp, dữ liệu, heap được dựng lại mới cho chương trình mới. Nhiều hàm thư viện (execlp(),execvp()…) được xây trên nó; sách gọi chung cả họ làexec(), dù không có lời gọi nào mang đúng tên đó.18wait()là cha ngồi chờ con làm xong rồi hỏi "con kết thúc thế nào?".execve()là tiến trình lột xác: giữ nguyên "thân xác" nhưng thay toàn bộ "linh hồn" bằng một chương trình khác. Nhớ nhé: wait() là chờ, execve() là thay toàn bộ chương trình.wait(&status)has two purposes: if a child has not yet terminated, it suspends the parent until one does; and it returns the child's termination status in thestatusargument.17execve(pathname, argv, envp)loads a new program into the process's memory: the old text is discarded, while the stack, data, and heap are freshly created for the new program. Many library functions (execlp(),execvp(), …) are layered on it; the book refers to the whole family generically asexec(), though no call bears exactly that name.18Chi tiết của tác giả
Hình 24-1 trong sách phác lại đúng vòng lặp của một shell: đọc lệnh,
fork()một con, conexec()lệnh đó, còn chawait()cho tới khi con xong rồi mới in dấu nhắc kế tiếp.1Vì sao điều này quan trọng
Cả
wait()lẫnexecve()đều tùy chọn sau mộtfork(): cha có thể bỏ qua con và chạy tiếp; con có thể tiếp tục chạy chính chương trình của cha thay vìexec()chương trình mới.1
Cụm khái niệm 02 Concept cluster 02
fork() trả về hai lần, và con thừa kế cả những tệp đang mở của cha fork() trả lời hai lần, và con dùng chung tệp với cha fork() returns twice, and the child inherits the parent's open files
Chìa khoá để hiểu fork() là: sau khi nó hoàn tất, có hai tiến trình, và trong mỗi tiến trình, dòng lệnh chạy tiếp từ ngay chỗ fork() trả về. Ta phân biệt hai tiến trình nhờ giá trị trả về: ở cha, fork() trả về PID của con mới; ở con, nó trả về 0; nếu không tạo được con, nó trả về −1.12
Điều lạ của fork(): bạn gọi một lần nhưng nó "trả lời" hai lần — một lần trong cha, một lần trong con. Làm sao biết mình là ai? Nhìn con số nó trả về: cha nhận số định danh của con (một số dương), còn con nhận số 0. Lỗi thì nhận −1. Nhớ nhé: con số 0 nghĩa là "tôi là con", số dương nghĩa là "tôi là cha".
The key to understanding fork() is that after it completes, there are two processes, and in each, execution continues from the point where fork() returned. We tell them apart by the return value: in the parent, fork() returns the PID of the new child; in the child, it returns 0; if no child could be created, it returns −1.12
Định nghĩa. Hai tiến trình chạy cùng đoạn text nhưng có bản sao riêng của ngăn xếp, dữ liệu và heap. Sau fork(), mỗi bên sửa biến trong các đoạn này mà không ảnh hưởng bên kia. Con có thể tự hỏi PID của mình bằng getpid(), và PID của cha bằng getppid().12
Nói đơn giản. Sau khi tách, con có "bản nháp" bộ nhớ riêng: sửa gì trong bản nháp của mình cũng không đụng tới bản của bên kia. Nhớ nhé: cha con dùng bộ nhớ riêng, sửa ai nấy chịu.
Definition. The two processes run the same program text but have separate copies of the stack, data, and heap. After fork(), each may modify variables in these segments without affecting the other. The child can learn its own PID via getpid() and its parent's via getppid().12
pid_t childPid;
switch (childPid = fork()) {
case -1: /* fork lỗi */
case 0: /* con chạy ở đây */
default: /* cha chạy ở đây */
}
switch (childPid = fork()) {
case -1: /* lỗi */
case 0: /* là con */
default: /* là cha */
}
pid_t childPid;
switch (childPid = fork()) {
case -1: /* fork failed */
case 0: /* child runs here */
default: /* parent runs here */
}
-
returns twice
Giá trị trả về: cách duy nhất để biết mình là cha hay con Nhìn con số trả về để biết mình là ai The return value: the only way to know parent from child
Cha cần PID của con để về sau theo dõi nó (qua
wait()), nênfork()trả PID đó cho cha; con thì không cần, nên nhận 0 — và nếu cần, con tự tra PID bằnggetpid(). Nếu không tạo được tiến trình,fork()trả −1; lý do thường gặp là vượt giới hạnRLIMIT_NPROCvề số tiến trình của người dùng, hoặc chạm trần số tiến trình toàn hệ.12Cha phải nhớ "mặt" con để sau này còn chờ, nên nó nhận số định danh của con. Con thì tự biết mình nên chỉ nhận số 0. Nếu máy quá tải, không đẻ thêm được, fork trả về −1. Nhớ nhé: luôn kiểm tra fork() có trả về −1 không.
The parent needs the child's PID to track it later (via
wait()), sofork()returns it to the parent; the child does not, so it gets 0 — and if needed, it reads its PID withgetpid(). If no process can be created,fork()returns −1; common reasons are exceeding theRLIMIT_NPROClimit on the user's process count, or hitting the system-wide process ceiling.12Bạn gặp nó ở đâuWhere you meet it LỗiEAGAINkhifork()thất bại dưới tải nặng chính là cái trầnRLIMIT_NPROC(được bàn kỹ ở Phần 36.3) đang lên tiếng.2 Khi máy báo không fork được vì quá nhiều tiến trình — đó là chạm trần giới hạn. TheEAGAINerror whenfork()fails under heavy load is theRLIMIT_NPROCceiling (discussed in Section 36.3) speaking up.2 -
separate memory
Bản sao bộ nhớ riêng — minh hoạ bằng t_fork Mỗi bên có bộ nhớ riêng — ví dụ t_fork Separate memory copies — shown by t_fork
Chương trình ví dụ
t_fork.c(Listing 24-1) đặt một biến dữ liệuidata = 111và một biến ngăn xếpistack = 222, rồi trong con nhân cả hai lên ba lần. Khi chạy, kết quả là con inidata=333 istack=666còn cha vẫnidata=111 istack=222— bằng chứng rằng con có bản sao riêng và sửa được mà không đụng tới cha.1Sách thử nghiệm: cho hai con số là 111 và 222, rồi bảo con nhân ba. Kết quả: con khoe 333 và 666, còn cha vẫn giữ 111 và 222. Vậy là con sửa bản của mình thôi, không chạm vào cha. Nhớ nhé: con sửa bộ nhớ của con, cha chẳng hề hấn gì.
The example
t_fork.c(Listing 24-1) sets a data variableidata = 111and a stack variableistack = 222, then the child multiplies both by three. When run, the child printsidata=333 istack=666while the parent still showsidata=111 istack=222— proof the child has its own copy and can modify it without affecting the parent.1Bạn gặp nó ở đâuWhere you meet it Ví dụ này dùngsleep(3)ở cha để con chạy trước — một cách không chắc chắn để sắp thứ tự, mà Phần 24.5 sẽ thay bằng cách đúng đắn.1 Ví dụ bắt cha ngủ vài giây cho con chạy trước — mẹo tạm bợ, chưa chắc ăn. This example usessleep(3)in the parent so the child runs first — an unreliable way to order things, which Section 24.5 replaces with a correct one.1 -
shared file offset
Mô tả tệp dùng chung — và cùng một con trỏ đọc/ghi Cha con dùng chung tệp và cùng vị trí đọc/ghi Shared descriptors — and one shared file offset
Khi
fork(), con nhận bản sao của mọi mô tả tệp của cha. Nhưng điều tinh tế: các mô tả bản sao trong con trỏ tới cùng một bản ghi tệp mở (open file description) như ở cha — nghĩa là chúng chia sẻ cả con trỏ vị trí tệp (file offset) lẫn các cờ trạng thái mở. Nếu con dịch con trỏ hoặc đổi cờ, cha nhìn thấy thay đổi đó qua mô tả tương ứng của mình.12Con "thừa kế" các tệp đang mở của cha, nhưng không phải bản sao tách rời: hai bên dùng chung một cây bút đánh dấu "đang đọc tới đâu". Con đọc tới dòng 1000 thì cha cũng thấy đã tới dòng 1000. Nhớ nhé: cha con viết chung một cuốn sổ, lật trang bên này thì bên kia cũng thấy.
On
fork(), the child receives copies of all the parent's file descriptors. The subtlety: those copied descriptors in the child refer to the same open file description as in the parent — so they share the file offset and the open file status flags. If the child moves the offset or changes a flag, the parent sees that change through its corresponding descriptor.12Bạn gặp nó ở đâuWhere you meet it Ví dụfork_file_sharing.c(Listing 24-2): con dịch offset tới 1000 và bật cờO_APPEND; sau khi con thoát, cha in ra "File offset in parent: 1000" và "O_APPEND flag in parent is: on" — đúng những thay đổi con đã làm.1 Trong ví dụ của sách, con dời vị trí tới 1000 rồi thoát; cha kiểm tra lại thấy đúng 1000 — vì dùng chung. The examplefork_file_sharing.c(Listing 24-2): the child moves the offset to 1000 and turns onO_APPEND; after the child exits, the parent prints "File offset in parent: 1000" and "O_APPEND flag in parent is: on" — exactly the child's changes.1Khi nào chia sẻ là có lợi
Nếu cha và con cùng ghi vào một tệp, việc chia sẻ offset bảo đảm hai bên không ghi đè lên nhau — đây là lý do shell chờ (
wait()) lệnh con xong rồi mới in dấu nhắc, trừ khi bạn chạy nền bằng dấu&.1Khi nào nên tách ra
Nếu không muốn chia sẻ, mỗi bên nên đóng ngay những mô tả tệp mình không dùng sau
fork(); khi một bênexec(), cờ close-on-exec (Phần 27.4) cũng hữu ích để tự động đóng.1
Cụm khái niệm 03 Concept cluster 03
Copy-on-write: chép bộ nhớ chỉ khi thật sự cần — và lối tắt vfork() Chỉ chép bộ nhớ khi phải chép, và cách đi tắt vfork() Copy-on-write: copy memory only when needed — and the vfork() shortcut
Về mặt khái niệm, có thể hình dung fork() tạo bản sao các đoạn text, dữ liệu, heap, ngăn xếp của cha. Nhưng chép nguyên khối bộ nhớ ảo vào con sẽ lãng phí — một phần vì fork() thường đi liền với exec(), thao tác sẽ vứt bỏ text cũ và dựng lại dữ liệu, heap, ngăn xếp. Nên các hiện thực UNIX hiện đại, gồm Linux, dùng hai mẹo để tránh chép thừa.1
Nếu mỗi lần tách đôi đều chép sạch bộ nhớ thì vừa chậm vừa phí — nhất là khi con sắp đổi sang chương trình khác và bỏ hết. Vì vậy Linux dùng mẹo để khỏi chép khi chưa cần. Nhớ nhé: Linux lười chép bộ nhớ một cách thông minh.
Conceptually, fork() can be seen as copying the parent's text, data, heap, and stack segments. But copying the whole virtual memory into the child would be wasteful — partly because fork() is often followed by exec(), which discards the old text and rebuilds data, heap, and stack. So modern UNIX implementations, including Linux, use two tricks to avoid needless copying.1
Hai mẹo. (1) Đoạn text được đánh dấu chỉ-đọc, nên cha và con dùng chung cùng các khung trang vật lý. (2) Với dữ liệu, heap, ngăn xếp, nhân dùng copy-on-write: ban đầu các trang được chia sẻ và đánh dấu chỉ-đọc; khi một bên định ghi, nhân bẫy lại, chép riêng trang đó cho tiến trình gây lỗi, rồi hai bên mới có bản riêng.12
Hai mẹo. Phần mã lệnh thì dùng chung vì chẳng ai sửa. Phần dữ liệu thì để chung tạm thời; chỉ khi có ai định sửa, máy mới lặng lẽ chép riêng trang đó ra. Nhớ nhé: dùng chung cho tới lúc có người cầm bút sửa.
Two tricks. (1) The text segment is marked read-only, so parent and child share the same physical page frames. (2) For data, heap, and stack, the kernel uses copy-on-write: initially the pages are shared and marked read-only; when either side tries to write, the kernel traps, copies just that page for the faulting process, and only then do the two have private copies.12
trước khi ghi: cha & con --> cùng Frame 1998 (RO)
con ghi vào trang 211:
nhân bẫy --> chép ra Frame 2038
con --> Frame 2038, cha --> Frame 1998
ban đầu: chung một trang (chỉ đọc)
ai đó ghi:
máy chép riêng trang đó ra
từ đó mỗi bên một bản
before write: parent & child --> same Frame 1998 (RO)
child writes page 211:
kernel traps --> copies to Frame 2038
child --> Frame 2038, parent --> Frame 1998
-
copy-on-write
Copy-on-write — vì sao fork() rẻ hơn ta tưởng Copy-on-write — vì sao fork() không tốn kém Copy-on-write — why fork() is cheaper than it looks
Nhờ copy-on-write, cái giá duy nhất của
fork()trên Linux là thời gian và bộ nhớ để nhân đôi bảng trang (page tables) của cha, chứ không phải chép toàn bộ dữ liệu. Các trang chỉ được sao thực sự khi — và nếu — một bên ghi vào chúng. Cơ chế này được mô tả trong [Bach, 1986] và [Bovet & Cesati, 2005].12Vì chỉ chép khi sửa, nên fork gần như "miễn phí": máy chỉ phải dựng lại cái "bản đồ" trỏ tới bộ nhớ, chứ chưa chép nội dung. Phần lớn trang không bao giờ bị chép. Nhớ nhé: fork chép bản đồ, không chép hàng.
Thanks to copy-on-write, the only penalty of
fork()on Linux is the time and memory to duplicate the parent's page tables, not to copy all the data. Pages are truly copied only when — and if — one side writes to them. The mechanism is described in [Bach, 1986] and [Bovet & Cesati, 2005].12Bạn gặp nó ở đâuWhere you meet it Có thể kết hợpfork()+wait()để "kiểm soát dấu chân bộ nhớ": cho một hàm hay rò rỉ bộ nhớ chạy trong con; khi con kết thúc, dấu chân bộ nhớ của cha trở về nguyên trạng (Listing 24-3).1 Mẹo hay: cho một việc hay rò rỉ bộ nhớ chạy trong con; con xong thì cha lại sạch sẽ như cũ. You can combinefork()+wait()to control a process's memory footprint: run a leaky function in the child; when it exits, the parent's footprint is back to what it was (Listing 24-3).1 -
vfork()
vfork() — lối tắt nhanh nhưng đầy cạm bẫy vfork() — nhanh hơn nhưng dễ sai vfork() — a fast but trap-laden shortcut
vfork()(gốc từ BSD) nhanh hơnfork()cổ điển nhờ hai đặc điểm khác thường: (1) không nhân bản trang bộ nhớ ảo hay bảng trang — con dùng chung bộ nhớ của cha cho tới khi nóexec()hoặc_exit(); (2) cha bị tạm dừng cho tới khi conexec()hoặc_exit(). Nó được thiết kế riêng cho trường hợp conexec()ngay lập tức.13vfork()là bản fork "ăn gian cho nhanh": con mượn tạm bộ nhớ của cha và cha đứng yên chờ, cho tới khi con đổi chương trình hoặc kết thúc. Nhanh, nhưng dễ gây lỗi khó hiểu. Nhớ nhé: vfork() nhanh nhưng nguy hiểm — chỉ dùng khi con sắp exec() ngay.vfork()(originally from BSD) is faster than classicfork()thanks to two unusual features: (1) it does not duplicate virtual memory pages or page tables — the child shares the parent's memory until itexec()s or_exit()s; (2) the parent is suspended until the childexec()s or_exit()s. It is expressly designed for the case where the child immediatelyexec()s.13Bạn gặp nó ở đâuWhere you meet it Ví dụt_vfork.c(Listing 24-4): con đổi biếnistackthành 666 rồi_exit(); vì dùng chung bộ nhớ, cha in ra đúngistack=666— thay đổi của con "lọt" sang cha.1 Trong ví dụ, con sửa một biến rồi thoát; vì dùng chung bộ nhớ, cha thấy luôn thay đổi đó. The examplet_vfork.c(Listing 24-4): the child changesistackto 666 then_exit()s; because memory is shared, the parent prints exactlyistack=666— the child's change leaks into the parent.1Luật chơi nghiêm ngặt
SUSv3 nói hành vi là không xác định nếu con: sửa bất kỳ dữ liệu nào khác ngoài biến
pid_tdùng để nhận giá trị trả về củavfork(); hoặc trở về khỏi hàm đã gọivfork(); hoặc gọi bất kỳ hàm nào khác trước khi_exit()hayexec()thành công. Con còn phải_exit()chứ khôngexit(), vìexit()sẽ xả và đóng vùng đệmstdiocủa cha.13 -
text shared RO
Đoạn text dùng chung — vì mã lệnh không tự sửa Mã lệnh dùng chung vì chẳng ai sửa nó The text segment is shared — code does not modify itself
Nhân đánh dấu đoạn text của mỗi tiến trình là chỉ-đọc để một tiến trình không thể sửa mã lệnh của chính nó. Chính vì chỉ-đọc nên cha và con có thể an toàn dùng chung cùng các khung trang text:
fork()chỉ dựng cho con các mục bảng trang trỏ tới đúng những khung trang vật lý mà cha đang dùng.1Phần "công thức" (mã lệnh) của chương trình không bao giờ tự sửa, nên cha con cứ dùng chung một bản, khỏi tốn chỗ chép lại. Nhớ nhé: công thức chung thì chép làm gì cho phí.
The kernel marks each process's text segment read-only so a process cannot modify its own code. Precisely because it is read-only, parent and child can safely share the same text page frames:
fork()just builds page-table entries for the child that point at the very physical frames the parent already uses.1Bạn gặp nó ở đâuWhere you meet it Đây là lý do nhiều tiến trình chạy cùng một chương trình (ví dụ nhiều bản shell) chỉ tốn một bản mã lệnh trong RAM — một ý nối thẳng với chủ đề bộ nhớ ảo và thư viện chia sẻ.1 Nhờ vậy, mở mười cửa sổ cùng một chương trình cũng chỉ tốn một bản mã trong bộ nhớ. This is why many processes running the same program (say, many shells) cost only one copy of the code in RAM — an idea that connects straight to virtual memory and shared libraries.1
Cụm khái niệm 04 Concept cluster 04
Sau fork(), thứ tự chạy là bất định — và ta phải tự dàn xếp Sau fork() không biết ai chạy trước — phải tự sắp xếp After fork(), order is indeterminate — and we must synchronize it ourselves
Sau một fork(), không thể biết trước cha hay con sẽ được nhân xếp lịch dùng CPU trước; trên hệ đa xử lý, cả hai có thể cùng chạy. Chương trình nào ngầm dựa vào một thứ tự thực thi nhất định sẽ dính race condition — lỗi khó tìm vì sự xuất hiện của nó phụ thuộc vào quyết định xếp lịch mà nhân đưa ra tùy tải hệ thống.1
Khi một tiến trình tách đôi, cái máy có thể cho cha chạy trước, cũng có thể cho con chạy trước — không hứa hẹn gì cả. Nếu bạn lỡ cho rằng "chắc chắn cha chạy trước" thì thỉnh thoảng chương trình sẽ sai, và lỗi kiểu đó cực khó bắt. Nhớ nhé: đừng bao giờ đoán ai chạy trước sau fork().
After a fork(), it is indeterminate whether the parent or the child will be scheduled onto the CPU first; on a multiprocessor, both may run at once. Any program that implicitly relies on a particular execution order suffers a race condition — a bug that is hard to find because its occurrence depends on scheduling decisions the kernel makes according to system load.1
Quy tắc. Không được giả định thứ tự. Nếu cần một thứ tự nhất định, phải dùng một kỹ thuật đồng bộ tường minh — semaphore, khoá tệp (file lock), gửi thông điệp qua pipe, hoặc gửi signal. Mẹo sleep() chỉ là may rủi, không phải bảo đảm.1
Quy tắc. Muốn ai chạy trước thì phải nói rõ bằng một "tín hiệu hẹn nhau", chứ đừng trông vào may mắn hay vào lệnh ngủ. Nhớ nhé: cần thứ tự thì phải hẹn nhau rõ ràng.
The rule. Do not assume an order. If a specific order is needed, use an explicit synchronization technique — a semaphore, a file lock, messages over a pipe, or a signal. The sleep() trick is luck, not a guarantee.1
/* đồng bộ bằng signal, Listing 24-6 */
chặn SYNC_SIG trước fork()
fork()
con: làm việc --> kill(getppid(), SYNC_SIG)
cha: sigsuspend(&emptyMask) /* chờ tín hiệu */
chặn tín hiệu trước khi fork
fork()
con: xong việc thì báo cha
cha: ngồi chờ tới khi nghe báo
/* synchronize with a signal, Listing 24-6 */
block SYNC_SIG before fork()
fork()
child: do work --> kill(getppid(), SYNC_SIG)
parent: sigsuspend(&emptyMask) /* wait for signal */
-
race condition
Race condition — một thí nghiệm bằng con số Race condition — thử bằng một triệu lần fork The race condition — an experiment in numbers
Để minh hoạ, chương trình
fork_whos_on_first.c(Listing 24-5) tạo nhiều con và ghi lại ai in thông điệp trước. Khi tạo 1 triệu con trên một hệ Linux/x86-32 chạy nhân 2.2.19, cha in trước trong tất cả trừ 332 lần — tức 99,97% số lần. Nhưng không được dựa vào điều này: nó khác nhau giữa các bản nhân và các hệ UNIX.15Sách chạy thử một triệu lần tách đôi để xem ai nhanh tay in trước. Trên một máy cũ, cha thắng gần như mọi lần (chỉ thua 332 lần). Nhưng con số đó đổi theo từng máy, từng phiên bản — nên đừng tin vào nó. Nhớ nhé: "thường thường đúng" không phải là "luôn luôn đúng".
To illustrate, the program
fork_whos_on_first.c(Listing 24-5) creates many children and records who prints its message first. Creating 1 million children on a Linux/x86-32 system running kernel 2.2.19, the parent printed first in all but 332 cases — that is, 99.97% of the time. But you must not rely on this: it varies across kernel versions and UNIX systems.15Bạn gặp nó ở đâuWhere you meet it Nếu mã của bạn trông cậy "cha luôn chạy trước" mà đúng 2999/3000 lần, thì cái lần thứ 3000 sẽ sinh ra một lỗi gần như không thể tái hiện khi gỡ.1 Lỗi chỉ xuất hiện một lần trong vài nghìn lần chạy là loại lỗi khổ sở nhất khi đi tìm. If your code counts on "the parent always runs first" and is right 2999 times out of 3000, that 3000th time yields a bug that is nearly impossible to reproduce while debugging.1 -
sched_child_runs_first
Mặc định "ai chạy trước" đã đổi nhiều lần qua các nhân Luật "ai chạy trước" từng đổi tới đổi lui The "who runs first" default has flipped across kernels
Chính các nhà phát triển nhân cũng đổi ý nhiều lần. Trong loạt nhân 2.4 từng có bản thử "con chạy trước", rồi bị gỡ; 2.6 lại áp dụng nó. Một thử nghiệm 1 triệu con trên Linux/x86-32 nhân 2.6.30 cho thấy con in trước trong 99,98% số lần. Nhưng từ nhân 2.6.32, mặc định quay lại là cha chạy trước; có thể đổi lại bằng cách gán giá trị khác 0 cho tệp
/proc/sys/kernel/sched_child_runs_first.15Chuyện "ai chạy trước" từng bị đổi qua đổi lại giữa các phiên bản Linux: lúc thì con trước, lúc thì cha trước. Từ nhân 2.6.32 thì mặc định là cha trước, nhưng người quản trị vẫn chỉnh được bằng một công tắc trong hệ thống. Nhớ nhé: mặc định còn đổi được, nên đừng xây mã dựa trên nó.
Even the kernel developers changed their minds repeatedly. The 2.4 series briefly tried "child runs first," then dropped it; 2.6 adopted it. An experiment with 1 million children on Linux/x86-32 kernel 2.6.30 showed the child printed first in 99.98% of cases. But from kernel 2.6.32, the default is once more that the parent runs first; it can be changed by writing a nonzero value to
/proc/sys/kernel/sched_child_runs_first.15Hai lập luận trái chiều
Lập luận cho "con trước": với copy-on-write, nếu con
exec()ngay thì cho con chạy trước sẽ tránh được việc nhân phải chép các trang mà cha sắp sửa. Lập luận cho "cha trước" (thắng ở 2.6.32): trạng thái của cha vẫn đang nóng trong CPU và trong TLB của đơn vị quản lý bộ nhớ, nên cho cha chạy trước thường nhanh hơn một chút.15Kết luận thực dụng
Sách nhấn mạnh chênh lệch hiệu năng giữa hai hành vi là nhỏ và không ảnh hưởng tới phần lớn ứng dụng — bài học rút ra không phải "chọn bên nào" mà là "đừng viết mã phụ thuộc vào bên nào".1
-
signal sync
Đồng bộ bằng signal — cách làm đúng đắn Dùng tín hiệu để hẹn nhau cho chắc Synchronizing with signals — the correct way
Nếu một bên cần chờ bên kia làm xong một việc, bên đang làm có thể gửi một signal khi xong, còn bên kia chờ signal đó. Điểm mấu chốt trong
fork_sig_sync.c(Listing 24-6): phải chặn (block) signal đồng bộ trước khifork(). Nếu cha chặn saufork(), nó lại dính đúng race condition ta đang muốn tránh — vì con có thể gửi signal trước khi cha kịp chặn và chờ.1Muốn chắc chắn ai xong trước thì hai bên hẹn nhau bằng một "tiếng còi": làm xong thì thổi còi, bên kia nghe còi mới đi tiếp. Quan trọng: phải chuẩn bị "cái tai nghe còi" trước khi tách đôi, kẻo lỡ mất tiếng còi. Nhớ nhé: chuẩn bị nghe tín hiệu trước khi fork, đừng để lỡ.
If one side must wait for the other to finish an action, the active side can send a signal when done, and the other waits for it. The crucial point in
fork_sig_sync.c(Listing 24-6): you must block the synchronization signal before thefork(). If the parent blocks it afterfork(), it is exposed to the very race it is trying to avoid — the child might send the signal before the parent manages to block and wait.1Bạn gặp nó ở đâuWhere you meet it [Stevens & Rago, 2005] đề xuất đóng gói các bước đồng bộ này (chặn, gửi, bắt signal) thành một bộ hàm chuẩn, để sau có thể thay signal bằng một cơ chế IPC khác mà không đổi logic.1 Nên gói các bước "hẹn nhau" này thành hàm dùng lại, để sau đổi cách báo hiệu cũng dễ. [Stevens & Rago, 2005] suggests packaging these steps (block, send, catch the signal) into a standard set of functions, so you can later swap signals for another IPC mechanism without changing the logic.1
Ba ngộ nhận thường gặp Three common misconceptions
“fork() tạo ra con từ đầu chương trình” “fork() starts the child from the top of the program”
Ngộ nhận: con bắt đầu chạy lại main() từ dòng đầu tiên.
Thực tế: cả cha và con đều chạy tiếp từ ngay sau chỗ fork() trả về; chúng chỉ khác nhau ở giá trị trả về (PID của con cho cha, 0 cho con).12
Myth: the child re-runs main() from the first line.
Reality: both parent and child continue just after the point where fork() returned; they differ only in the return value (child's PID to the parent, 0 to the child).12
“fork() chép toàn bộ bộ nhớ nên rất tốn kém” “fork() copies all memory, so it's expensive”
Ngộ nhận: mỗi fork() đều nhân đôi dữ liệu, heap, ngăn xếp của cha.
Thực tế: Linux dùng copy-on-write; cái giá duy nhất là nhân đôi bảng trang, còn các trang chỉ được chép khi một bên thật sự ghi vào.12
Myth: every fork() duplicates the parent's data, heap, and stack.
Reality: Linux uses copy-on-write; the only penalty is duplicating the page tables, and pages are copied only when a side actually writes.12
“vfork() luôn nhanh hơn nên cứ dùng cho chắc” “vfork() is always faster, so just use it”
Ngộ nhận: vfork() nhanh hơn thì luôn là lựa chọn tốt hơn.
Thực tế: với copy-on-write, fork() đã gần nhanh bằng vfork(); SUSv3 đánh dấu vfork() lỗi thời và SUSv4 gỡ bỏ nó, nên chỉ nên dùng khi tốc độ là tuyệt đối thiết yếu.13
Myth: vfork() is faster, so it is always the better choice.
Reality: with copy-on-write, fork() is nearly as fast as vfork(); SUSv3 marks vfork() obsolete and SUSv4 removes it, so use it only where speed is absolutely critical.13
Tự kiểm Self-check
Năm câu hỏi để biết bạn đã nắm việc tạo tiến trình Năm câu hỏi kiểm tra nhanh Five questions to check you grasped process creation
Trả lời được những câu này nghĩa là bạn đã nắm cơ chế fork() và các hệ quả của nó — nền cho bốn chương về tiến trình kế tiếp.
Nếu trả lời được, nghĩa là bạn đã hiểu chương này.
Answering these means you have the fork() mechanism and its consequences — the basis for the next four chapters on processes.
-
Vì sao nói
fork()"trả về hai lần"? Ở cha và ở con, giá trị trả về là gì, và bạn dùng nó thế nào để tách luồng xử lý?Gợi ý sâu hơn: nếu
fork()trả về −1 thì hai nguyên nhân thường gặp là gì? -
Sau
fork(), cha và con dùng chung gì về mặt mô tả tệp, và điều đó dẫn tới hệ quả gì nếu cả hai cùng đọc/ghi một tệp?Gợi ý sâu hơn: ví dụ
fork_file_sharingchứng minh điều này bằng những con số nào (offset, cờ O_APPEND)? -
Copy-on-write là gì, và vì sao nó khiến
fork()rẻ hơn nhiều so với việc chép toàn bộ bộ nhớ? Đoạn text được xử lý khác ra sao?Gợi ý sâu hơn: cái giá thực sự còn lại của một
fork()trên Linux là gì? -
Hai đặc điểm nào khiến
vfork()khácfork(), và vì sao con củavfork()phải gọi_exit()chứ khôngexit()?Gợi ý sâu hơn: SUSv3 và SUSv4 nói gì về vị thế chuẩn của
vfork()? -
Vì sao không được giả định cha hay con chạy trước sau
fork(), và một cách đúng đắn để ép thứ tự là gì?Gợi ý sâu hơn: trong cách đồng bộ bằng signal, vì sao phải chặn signal trước khi
fork()?
Sau khi gọi
fork(), làm sao để biết mình đang là cha hay là con?Cha và con có dùng chung các tệp đang mở không? Nếu con dời vị trí đọc, cha có thấy không?
Vì sao
fork()không tốn gấp đôi bộ nhớ dù con là bản sao của cha?vfork()khácfork()ở chỗ nào, và vì sao nên cẩn thận khi dùng?Sau
fork(), ai chạy trước — cha hay con? Muốn chắc chắn thứ tự thì phải làm gì?
Why does
fork()"return twice"? What is the return value in the parent and in the child, and how do you use it to split the flow?After
fork(), what do parent and child share in terms of file descriptors, and what follows if both read/write one file?What is copy-on-write, and why does it make
fork()far cheaper than copying all memory? How is the text segment handled differently?Which two features distinguish
vfork()fromfork(), and why must avfork()child call_exit()rather thanexit()?Why must you not assume whether parent or child runs first after
fork(), and what is a correct way to force an order?
Khép lại chương Closing the chapter
fork()biến một tiến trình thành hai bằng một bản sao gần như hoàn hảo, rồi trả về trong cả hai. Bộ nhớ không chép ngay mà chỉ copy-on-write khi bị sửa; cha con dùng chung tệp đang mở; và vì không ai hứa ai chạy trước, muốn một thứ tự nhất định thì phải đồng bộ tường minh. Nắm ba điều đó, bạn đã sẵn sàng cho exit, wait và exec ở các chương sau.1
fork()tách một chương trình thành hai, bộ nhớ chỉ chép khi thật sự sửa, và không biết ai chạy trước nên phải tự hẹn nhau. Hiểu vậy là đủ đi tiếp. Nhớ nhé: tách đôi, lười chép, và tự dàn xếp thứ tự.
fork()turns one process into two by making a nearly perfect copy, then returns in both. Memory is not copied up front but only copy-on-write when modified; parent and child share open files; and because neither is promised to run first, a specific order needs explicit synchronization. Grasp those three, and you are ready for exit, wait, and exec in the chapters ahead.1