Quản lý và Điều khiển Tiến trình trong Hệ điều hành

1. Hàm fork()

1.1 Vì sao fork() có hai giá trị trả về?

Hàm fork() là một giao diện do hệ điều hành cung cấp cho người dùng. Bên trong nhân hệ điều hành, việc triển khai fork() bao gồm các bước chính như: tạo PCB cho tiến trình con, sao chép ngữ cảnh từ tiến trình cha, thiết lập bảng trang, và đưa tiến trình con vào hàng đợi lập lịch.

Điểm then chốt nằm ở chỗ: trước khi thực hiện lệnh return, cả hai luồng thực thi (tiến trình cha và con) đã tồn tại song song. Do đó, lệnh return được thực thi hai lần — một lần trong ngữ cảnh tiến trình cha và một lần trong ngữ cảnh tiến trình con — dẫn đến hai giá trị trả về khác nhau.

1.2 Tại sao tiến trình cha nhận PID của con, còn con nhận 0?

Mỗi tiến trình con chỉ có đúng một tiến trình cha, nhưng một tiến trình cha có thể có nhiều con. Do đó, tiến trình cha cần biết PID của từng tiến trình con để quản lý chúng. Trong khi đó, tiến trình con không cần biết PID của chính nó từ góc nhìn "cha", nên hệ thống quy ước trả về 0 cho tiến trình con để dễ phân biệt.

1.3 Làm thế nào cùng một biến lại có hai giá trị khác nhau?

Xét đoạn mã:

pid_t id = fork();

Ban đầu, cả hai tiến trình chia sẻ cùng một vùng nhớ vật lý (do cơ chế copy-on-write chưa kích hoạt). Khi fork() thực hiện return, thao tác ghi giá trị PID vào biến id sẽ kích hoạt cơ chế copy-on-write. Kết quả là mỗi tiến trình có bản sao riêng của biến id với giá trị khác nhau, dù địa chỉ ảo vẫn giống nhau.

2. Kết thúc tiến trình

2.1 Mã thoát (exit code)

Khi chương trình kết thúc bằng return 0; trong main(), giá trị 0 chính là mã thoát, biểu thị thành công. Các giá trị khác 0 biểu thị lỗi cụ thể. Có thể kiểm tra mã thoát gần nhất bằng lệnh:

echo $?

Lưu ý: $? chỉ lưu mã thoát của lệnh thực thi ngay trước đó.

2.2 Các cách kết thúc tiến trình

Tiến trình có thể kết thúc theo hai cách:

  • Bình thường: hoàn thành mã nguồn và trả về mã thoát (qua return, exit(), hoặc _exit()).
  • Bất thường: bị gián đoạn do tín hiệu (signal) như SIGSEGV (truy cập bộ nhớ bất hợp pháp), SIGFPE (chia cho 0), hoặc người dùng nhấn Ctrl+C (SIGINT).

2.2.3 Sự khác biệt giữa exit()_exit()

exit() là hàm thư viện (libc), trong khi _exit() là lời gọi hệ thống (system call). Khi gọi exit(), bộ đệm I/O của người dùng (ví dụ: stdout) sẽ được flush trước khi tiến trình kết thúc. Ngược lại, _exit() kết thúc ngay lập tức mà không flush bộ đệm, do đó dữ liệu chưa ghi ra có thể bị mất.

3. Chờ tiến trình con (Process Waiting)

Để tránh trạng thái "zombie" (tiến trình đã kết thúc nhưng vẫn giữ tài nguyên), tiến trình cha cần chờ và thu dọn tài nguyên của tiến trình con thông qua cơ chế chờ đợi.

3.1 Hàm wait()

#include <sys/wait.h>
pid_t wait(int *status);

Hàm này chặn (block) tiến trình cha cho đến khi một tiến trình con kết thúc. Tham số status (kiểu con trỏ) được dùng để lấy thông tin trạng thái thoát. Nếu không quan tâm, truyền NULL.

3.2 Hàm waitpid()

pid_t waitpid(pid_t pid, int *status, int options);

Cho phép linh hoạt hơn: có thể chờ tiến trình con cụ thể (theo PID), hoặc dùng pid = -1 để chờ bất kỳ con nào. Tham số options thường là 0 (chặn) hoặc WNOHANG (không chặn).

3.2.1 Phân tích tham số status

Giá trị *status chứa thông tin trạng thái dưới dạng bit:

  • 7 bit thấp: mã tín hiệu nếu tiến trình bị giết bởi tín hiệu (khác 0 → bất thường).
  • 8 bit tiếp theo: mã thoát nếu tiến trình kết thúc bình thường (7 bit thấp = 0).

Có thể dùng macro như WIFEXITED(status)WEXITSTATUS(status) để trích xuất thông tin một cách an toàn.

4. Thay thế chương trình (Program Replacement)

Mục đích tạo tiến trình con thường là để chạy một chương trình hoàn toàn mới. Cơ chế này được thực hiện qua họ hàm exec.

4.1 Các hàm thay thế

#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);

Ví dụ:

execl("/bin/ls", "ls", "-l", "--color=auto", NULL);

Sau khi gọi thành công, toàn bộ mã và dữ liệu của tiến trình hiện tại bị thay thế. Dòng lệnh sau execl sẽ không bao giờ được thực thi.

4.2 Nguyên lý thay thế

Khi gọi hàm exec, hệ điều hành không tạo tiến trình mới. Thay vào đó, nó nạp chương trình mới vào vùng nhớ của tiến trình hiện tại (kích hoạt copy-on-write nếu cần), cập nhật bảng trang, và bắt đầu thực thi từ điểm vào của chương trình mới.

4.3 Hành vi của hàm exec

- Nếu thành công: không bao giờ trả về (vì luồng thực thi đã chuyển sang chương trình mới).
- Nếu thất bại: trả về -1 và đặt errno.

4.4 Quy ước đặt tên

Tên hàm phản ánh cách truyền tham số và xử lý môi trường:

  • l (list): tham số được liệt kê trực tiếp (dùng ...).
  • v (vector): tham số truyền qua mảng argv[].
  • p: tự động tìm chương trình trong PATH.
  • e: cho phép truyền biến môi trường tùy chỉnh qua envp[].

Trong đó, execve() là lời gọi hệ thống gốc; các hàm còn lại là bao bọc (wrapper) dựa trên nó.

4.4.1 Hàm execle()

execle("/usr/bin/env", "env", NULL, custom_env);

Hàm này cho phép truyền cả danh sách tham số và mảng biến môi trường tùy chỉnh. Lưu ý rằng khi một chương trình được thực thi (dù qua dòng lệnh hay exec), hàm execve luôn được gọi trước khi main() khởi chạy. Các tham số dòng lệnh và biến môi trường được truyền từ execve tới main() thông qua các đối số argc, argv[], và envp[].

Thẻ: fork process-control exec wait exit

Đăng vào ngày 14 tháng 7 lúc 00:18