Hàm fork hoạt động như thế nào stack heap

Trong bài học này, chúng ta sẽ tìm hiểu cách hệ thống tạo ra hoặc kết thúc một tiến trình cũng như các hàm hoặc system call thích hợp cho các tác vụ đó. Đồng thời, chúng ta cũng sẽ nắm được cách hệ điều hành quản lý 1 tiến trình như thế nào.

Một tiến trình Linux tạo ra các tiến trình con nhằm mục đích chia công việc của mình cho các tiến trình con. Ví dụ 1 tiến trình network server lắng nghe và phục vụ nhiều client khác nhau, nó có thể tạo ra các tiến trình con làm nhiệm vụ xử lý và phục vụ cho từng client; nhờ đó nó rảnh tay tiếp tục lắng nghe kết nối từ các client khác. Ý tưởng thiết kế chia việc cho tiến trình con này khá đơn giản, đồng thời đáp ứng được yêu cầu thực hiện nhiều tác vụ đồng thời.

Tiến trình con được tạo ra bằng system call fork(), có prototype như sau:

#include pid_t fork(void);

System call fork() làm việc bằng cách tạo ra 1 tiến trình mới với PID mới, và nhân bản dữ liệu từ tiến trình cha sang tiến trình con (các segment stack, data và heap), riêng text segment không cần sao chép mà được sử dụng chung (sharable) bởi cả 2 tiến trình.

Vì vậy, tiến trình con sẽ kế thừa toàn bộ các biến (bao gồm cả biến môi trường), giá trị hiện tại của các biến, các mô tả file và stack frame của tiến trình cha. Sau lời gọi fork(), 2 tiến trình sẽ đồng thời tồn tại và chúng sẽ tiếp tục chạy sau thời điểm fork() return.

Câu hỏi đặt ra là sau fork() cả 2 tiến trình cha và con đồng thời đang tồn tại, vậy làm cách nào để biết chúng ta đang lập trình cho tiến trình cha hay con? Việc phân biệt này dựa trên giá trị return của fork(). Với tiến trình cha, fork() sẽ return 1 số nguyên dương là PID của tiến trình con mà nó vừa tạo ra. Với tiến trình con, fork() sẽ return giá trị 0, tiến trình con có thể lấy dược PID của mình bằng system call getpid(). System call fork() trả về -1 khi bị lỗi.

Vì vậy, lập trình tạo ra 1 tiến trình mới bằng fork() thường có mẫu viết code như sau:

pid_t childPid; childPid = fork(); switch (childPid) {        case -1:                /* fork() bị lỗi                    Code xử lý lỗi */        case 0:                /* fork() return 0, bạn đang ở trong tiến trình con                    Code làm việc trên tiến trình con                    Ví dụ: xử lý yêu cầu từ client, nạp 1 chương trình mới */        default:                /* Trường hợp còn lại, return PID mới, bạn đang ở trong tiến trình cha                    Code làm việc trên tiến trình cha                    Ví dụ: tiếp tục lắng nghe các kết nối khác */ }

Về mặt lý thuyết, chúng ta có thể hiểu đơn giản fork() tạo ra 1 bản sao về dữ liệu của tiến trình cha. Nghĩa là hệ thống sẽ copy các memory segment của tiến trình cha sang tiến trình con. Tuy nhiên trong lập trình thực tế, tiến trình con sau khi được tạo ra thường sớm được nạp chương trình và dữ liệu mới vào bằng hàm exec() mà chúng ta sẽ học ở phần dưới đây. Điều này dẫn đến việc tạo ra bản copy mới dữ liệu của tiến trình cha cho con là khá tốn kém và không cần thiết. Vì vậy, hầu hết các hệ thống Unix bao gồm cả Linux sử dụng 2 kỹ thuật sau để tránh lãng phí tài nguyên khi copy:

  • Với text segment: kernel đánh dấu text segment của mỗi tiến trình ở dạng read-only. Do đó cả tiến trình cha và con đều có thể chia sẻ segment này. System call fork() chỉ cần tạo ra page-table mới cho tiến trình con và map đến text segment này mà không cần copy.

  • Với data, stack và heap segment: kernel sử dụng kỹ thuật copy-on-write. Ý tưởng của kỹ thuật này là: sau khi gọi fork(), kernel sẽ set vùng nhớ của các segment này thành read-only; và các segment của cả tiến trình cha và con đều trỏ đến các địa chỉ giống nhau. Chỉ khi 1 trong 2 tiến trình thay đổi giá trị thuộc các vùng đó, kernel mới copy dữ liệu đó sang một vùng nhớ riêng biệt cho tiến trình con, các dữ liệu không thay đổi thì không cần thiết phải sao lưu.

Như đã nói ở trên, tiến trình con khi được tạo ra bằng system call fork() sẽ gần như là 1 bản copy từ tiến trình cha. Để tiến trình con chạy 1 chương trình khác, bạn phải thực hiện thêm bước tải file thực thi của chương trình mới vào và chạy chương trình đó trên tiến trình con. Việc này được thực hiện bởi mộ tập các system call gần giống nhau được gọi là exec family.

Tập các hàm thực thi chương trình mới của exec family bao gồm các hàm sau:

#include int execle(const char *pathname, const char *arg, ...); int execlp(const char *filename, const char *arg, ...); int execvp(const char *filename, char *const argv[]); int execv(const char *pathname, char *const argv[]); int execve (const char *pathname, char *const argv[], char *const envp[]); int execl(const char *pathname, const char *arg, ...); /*None of the above returns on success; all return –1 on error*/

Các hàm exec thay thế binary thực thi hiện tại của tiến trình bằng file binary thực thi mới ở đường dẫn “pathname”. Biến “arg” là đối số đầu tiên của chương trình mới này. Như đã nói ở bài trước về việc chạy 1 chương trình trong Linux, đối số đầu tiên luôn là tên của chương trình được truyền vào hàm main() của chương trình đó. Prototype của các hàm này có dấu “...”, nghĩa là có thể truyền vào chương trình mới số lượng các đối số khác nhau tùy vào chương trình mới đó. Và cần chú ý rằng, đối số cuối cùng của các hàm trên luôn là NULL.

Ví dụ về việc dùng hàm execl() để chạy 1 chương trình như sau:

execl(“/usr/bin/vi”, “vi”, “home/work/hello.txt”, NULL);

Câu lệnh trên có nội dung yêu cầu đến hệ điều hành là: hãy tải file thực thi của chương trình tên là “vi” ở đường dẫn “usr/bin/vi” và chạy chương trình “vi” để mở 1 file ở đường dẫn “home/work/hello.txt”.

Thông thường, các hàm họ exec không return. Các hàm exec sau khi chạy thành công sẽ nhảy đến entry point của chương trình mới; ngược lại khi thất bại sẽ return -1 và ghi nguyên nhân lỗi vào biến toàn cục errno.

Để ý các hàm họ exec trên có ký tự đi sau “exec” là “l” (ví dụ: execl, execle và execlp) hoặc “v” (ví dụ execv và execvp). Trong đó, “l” chỉ việc các đối số truyền vào ở dạng list còn “v” nghĩa là các đối số truyền vào ở dạng vector (array). Ví dụ về hàm execl() ở trên, các đối số truyền vào ở dạng list lần lượt là “vi”, “home/work/hello.txt” và NULL. Nếu muốn sử dụng hàm execv(), ta phải xây dựng 1 mảng lưu các đối số đó, ví dụ như sau:

const char *args[] = { "vi" , "/home/work/hello.txt" , NULL }; ret = execv ("usr/bin/vi" , args);

Lại để ý các hàm họ exec trên, ký tự theo sau “v” hoặc “l” có thể là “p” hoặc “e”. Trong đó, “p” sẽ yêu cầu hệ điều hành tìm kiếm đường dẫn chương trình từ đường dẫn gốc, ví dụ bạn chỉ cần truyền vào “hello.txt”, hệ thống sẽ tìm từ đường dẫn gốc để thấy file đó trong “home/work/hello.txt”. Cuối cùng, ký tự “e” cho phép truyền cả biến môi trường vào tiến trình con sử dụng mảng envp[].

Một tiến trình được kết thúc khi nó kết thúc công việc của mình hoặc có lỗi xảy ra (ví dụ segfault) với tiến trình đó. Khi tiến trình kết thúc, các tài nguyên của nó (bộ nhớ, các mô tả file đang mở,...) bị thu hồi và có thể được cấp phát cho tiến trình khác.

Một tiến trình có thể bị kết thúc bằng system call _exit(), với prototype sau:

#include void _exit(int status);

Đối số status truyền vào _exit() định nghĩa trạng thái kết thúc (termination status) của tiến trình, nó có thể được tiến trình cha dùng khi gọi system call wait() mà chúng ta sẽ học dưới đây. Theo quy ước, giá trị của đối số status là 0 nghĩa là tiến trình được kết thúc thành công, khác 0 nghĩa là kết thúc không thành công.

Trong thực tế, lập trình viên thường không dùng trực tiếp system call _exit() mà dùng hàm thư viện exit(), hàm này sẽ làm 1 số việc cần thiết (thoát các hàm handler và flush các stdio buffer) trước khi gọi system call _exit(). Prototype như sau:

#include void exit(int status);

Tiến trình cũng có thể kết thúc bằng câu lệnh return trong hàm main() hoặc khi chương trình chạy đến cuối hàm main() của nó. Thực tế việc gọi “return n;” trong lập trình cũng tương tự như gọi exit(n)

Trong bài này, chúng ta đã nắm được nguyên lý cũng như các hàm/system call cần thiết để tạo ra 1 tiến trình con, nạp và chạy 1 chương trình mới vào tiến trình con và kết thúc 1 tiến trình. Trong bài sau, chúng ta sẽ tìm hiểu về cách quản lý cũng như theo dõi trạng thái và tài nguyên của tiến trình con.