Thao tác với tiến trình Linux
Linux
111
White

Khó viết ngày 09/11/2018

B. Thao tác với tiến trình Linux

1. Tổng quan về các system call fork(), exit(), wait()execve()

  • fork(): cho phép một process (gọi là process cha) tạo ra một process mới (process con). Process con được tạo ra gần như là một bản sao của process cha: lấy từ process cha stack, data, heap và text segment.
  • exit(status): kết thúc một process, và giải phóng các tài nguyên (bộ nhớ, file descriptor được mở,...). Tham số status là một số nguyên xác định trạng thái kết thúc của process. Process cha có thể lấy được trạng thái này từ process con nếu dùng system call wait().
  • wait(&status) có 2 mục đích. Đầu tiên, nếu một process con vẫn chưa bị kết thúc bằng cách gọi exit(), thì hàm wait() sẽ ngừng việc thực thi của process cha lại cho đến khi một trong những process con kết thúc. Thứ hai, khi process kết thúc thì trạng thái kết thúc sẽ được trả về vào tham số &status của wait().
  • execve(pathname, argv, envp) load chương trình mới ở đường dẫn pathname với tham số argv và danh sách biến môi trường envp vào bộ nhớ của một process. Thao tác này giống như tạo một process mới. Về sau ta sẽ được thấy một số hàm thư viện được xây dựng dựa trên system call này. Việc kết hợp hàm fork()execve() sẽ giúp ta tạo một process mới và thực thi một chương trình cụ thể nào đó.

alt text

Hình trên cung cấp cái nhìn tổng quan về cách sử dụng các system call trên.
Việc sử dụng execve() là không bắt buộc vì thỉnh thoảng ta cũng cần process con thực thi giống như process cha. Sau cùng process con sẽ kết thúc việc thực thi bằng cách gọi exit() và thông báo trạng thái kết thúc cho process cha ( process cha đang đợi bằng hàm wait()).
Thực ra việc gọi hàm wait() cũng không bắt buộc. Process cha không cần quan tâm đến việc kết thúc của process con mà chỉ việc tiếp tục thực thi. Tuy nhiên vẫn khuyến khích sử dụng hàm wait(), và thường được sử dụng cùng SIGCHLD signal - tín hiệu báo một trong những process con của nó đã kết thúc.

2. Tạo process mới bằng fork()

Trong nhiều ứng dụng, việc tạo nhiều process là cần thiết để chia nhỏ một công việc lớn ra. Ví dụ một process chạy network server cần phải nghe yêu cầu từ các client, nó tạo các process con để xử lý mỗi các yêu cầu đó, trong khi process server vẫn tiếp tục nghe ngóng những kết nối tiếp theo.
Hàm fork() tạo process con mới, gần như là bản sao của process cha:

#include <unistd.h>
pid_t fork(void);

Ở process cha: hàm trả về ID của process con nếu thành công, trả về -1 nếu lỗi.
Ở process con: luôn trả về 0 nếu thành công.
Sau khi fork() kết thúc công việc, có 2 process tồn tại, mỗi process sẽ tiếp tục thực thi tại nơi fork() trả về. 2 process này thực thi cùng một chương trình, nhưng chúng lại có stack, data, và heap segment riêng biệt. Ban đầu thì stack, data, heap của process con được copy y hệt như của process cha. Sau khi fork() xong thì mỗi process duy trì các stack, data, heap của chúng một cách độc lập.
Khi lập trình, ta có thể phân biệt 2 process bằng giá trị mà fork() trả về. Nếu cần thiết thì process con có thể lấy được ID của nó bằng hàm getpid() và ID của cha nó bằng hàm getppid().
Dưới đây là mẫu code hay được sử dụng khi dùng fork():

pid_t childPid; /* Used in parent after successful fork() to record PID of child */
switch (childPid = fork()) {
    case -1: /* fork() failed */
     /* Handle error */
    case 0: /* Child of successful fork() comes here */
    /* Perform actions specific to child */
    default: /* Parent comes here after successful fork() */
    /* Perform actions specific to parent */
}

Một điều khá quan trọng nữa là sau khi fork(), ta không biết được process nào tiếp theo sẽ được lập lịch để sử dụng CPU. Vậy nên nếu lập trình không cẩn thận có thể gây ra race condition.

Chia sẻ file giữa process cha và con

Sau khi thực hiện xong fork() thì process con cũng có được tất cả những file descriptor mà process cha đang có. Những thông tin của file descriptor được mở như là file offset, các cờ trạng thái của file cũng được chia sẻ giữa cha và con. Ví dụ nếu process con cập nhật file offset thì process cha cũng được cập nhật thông tin này.

Cơ chế thật sự của fork()

Trên lý thuyết thì hàm fork() sẽ tạo ra các bản copy của code, data, heap, stack từ process cha cho process con. Tuy nhiên việc copy này có thể gây ra lãng phí tài nguyên, điển hình trong trường hợp nếu ta chạy exec() ngay sau fork() thì những tài nguyên data, heap, stack của process con sẽ được khởi tạo lại mà không dùng đến những tài nguyên cũ của process cha. Để tránh hiện tượng này, các hệ UNIX mới triển khai 2 kĩ thuật:

  • Kernel đánh dấu những vùng text của mỗi process là read-only để các process khác không thể được code của nó. Do đó process cha và con có thể chia sẻ vùng text này. Fork() tạo vùng text cho process con bằng cách xây dựng một tập các chỉ mục bảng trang (page-table entry), tập này sẽ trỏ vào cùng một khung trang vật lý mà process cha đang trỏ vào.
  • Với những trang nhớ trên data, heap và stack của process cha, kernel thực hiên kĩ thuật copy-on-write. Ban đầu kernel sẽ cài đặt các chỉ mục bảng trang cho các phân vùng trên sao cho chúng trỏ vào cùng một trang nhớ vật lý như trên process cha. Sau fork(), mỗi lần ta chỉnh sửa dữ liệu trên các phân vùng này, thì chỉ mục bảng trang cho process con sẽ được trỏ đến một trang nhớ vật lý khác và dữ liệu được chỉnh sửa trên đó.

alt text

3. Race condition sau khi fork()

Sau khi fork() xong. ta không thể xác định được process nào sẽ chiếm dụng CPU tiếp theo (ở các hệ thống nhiều core thì chúng có thể đồng thời được xử lý). Những ứng dụng mà phụ thuộc vào việc thực thi tuần tự một công việc rất dễ xảy ra lỗi do race condition. Những bug như thế rất khó để soát vì chúng xảy ra phụ thuộc vào quyết định lập lịch của kernel, lúc đúng lúc sai...
Do đó ta cần một công cụ để thực hiện đồng bộ hóa, ngăn chặn race condition. Đó là signal.
Cơ chế này sẽ bắt một process A phải ngưng thực thi và đợi cho đến khi một process B khác hoàn thành một công việc nào đó . Sau khi process B hoàn thành công việc, nó phát ra signal để báo hiệu cho process A, process A nhận được tín hiệu mới tiếp tục thực thi công việc của nó.
Dùng hàm signal() để đợi tín hiệu.

#include <signal.h>
void (*signal(int sig, void (*func)(int)))(int);

Dùng hàm kill() để gửi tín hiệu

#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

Ví dụ:

#include <sys/types.h>
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
static int alarm_fired = 0;
void ding(int sig)
{
alarm_fired = 1;
}
/* In main, you tell the child process to wait for five seconds before sending a SIGALRM signal to its parent. */
int main()
{
    pid_t pid;
    printf(“alarm application starting\n”);
    pid = fork();
    switch(pid) {
        case -1:
            /* Failure */
            perror(“fork failed”);
            exit(1);
        case 0:
            /* child */
            sleep(5);
            kill(getppid(), SIGALRM);
            exit(0);
    }
    /* if we get here we are the parent process */
    printf(“waiting for alarm to go off\n”);
    (void) signal(SIGALRM, ding);
    pause();
    if (alarm_fired)
    printf(“Ding!\n”);
    printf(“done\n”);
    exit(0);
}

Ngoài ra Linux còn hỗ trợ một cách dùng signal mới hơn là sigaction:

#include <signal.h>
int sigaction(int sig, const struct sigaction *act, struct sigaction *oact);

4. Kết thúc process: _exit()exit()

Một process có thể kết thúc bằng 2 cách: bình thường và bất thường.
Kết thúc bất thường xảy ra do process nhận tín hiệu, báo phải kết thúc process.
Kết thúc bình thường nếu ta sử dụng system call _exit().

#include <unistd.h>
void _exit(int status);

Tham số status xác định trạng thái kết thúc của process, trạng thái này sẽ được báo cho process cha biết khi cha nó gọi wait(). Mặc dù status có kiểu int nhưng chỉ có 8 bit dưới của status là process cha có thể đọc được. Theo quy tắc thì trạng thái 0 có nghĩa là process hoàn thành thành công, trả về khác 0 nghĩa là không thành không.
Một process luôn kết thúc thành công khi gọi _exit(). Tuy nhiên khi lập trình, ta thường sẽ không gọi hàm _exit() này, mà thay vào đó gọi exit(). Exit() sẽ làm một số công việc khác trước khi gọi _exit().

#include <stdlib.h>
void exit(int status);

Các công việc bao gồm:

  • Thực hiện exit handler ( là hàm đăng kí bởi atexit()on_exit()).
  • Flush các buffer của stdio.
  • Gọi _exit().

5. Đợi process con

Một process gọi system call wait() sẽ đợi một trong các process con của nó kết thúc, trả về trạng thái kết thúc vào con trỏ status:

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

Hàm wait() trả về ID của process con bị kết thúc nếu thành công, trả về -1 nếu lỗi.
Hàm wait() làm các công việc sau đây:

  • Nếu không có process con nào kết thúc, hàm sẽ block cho đến khi một process con kết thúc. Nếu trước đó đã có process con kết thúc thì hàm wait() trả về ngay lập tức.
  • Nếu status khác NULL, thông tin về trạng thái process con kết thúc sẽ được trả về số int mà status trỏ vào.
  • Kernel lấy các thông tin như thời gian sử dụng CPU của process và các thông số sử dụng tài nguyên .
  • Trả về giá trị process ID của process con kết thúc.

Tuy nhiên system call wait() có một số hạn chế, hàm waitpid() được thiết kế để giải quyết các vấn đề đó:

  • Nếu một process cha tạo nhiều process con, thì hàm wait() sẽ không thể đợi được một process con nào cụ thể nào kết thúc, ta chỉ có thể đợi process con tiếp theo kết thúc.
  • Nếu chưa có process con nào kết thúc, wait() sẽ luôn block().
  • Nếu sử dụng wait() thì ta chỉ biết được về process con vừa kết thúc mà không biết liệu process con đó có bị dừng bởi một tín hiệu ( như SIGTOP hoặc SIGTTIN) hoặc được tiếp túc khi nhận tín hiệu SIGCONT hay không. c #include <sys/wait.h> pid_t waitpid(pid_t pid, int *status, int options);

Hàm trả về PID của process con, 0 hoặc -1 nếu lỗi.
Tham số truyền vào pid:

  • Nếu pid > 0 thì sẽ đợi process con có ID bằng pid.
  • Nếu pid = 0 thì sẽ đợi bất kì process con nào có cùng group với process cha.
  • Nếu pid < -1 thì sẽ đợi bất kì process con nào có id của process group bằng giá trị tuyệt đối của pid.
  • Nếu pid = -1 thì sẽ đợi bất kì process con nào. Hàm wait(&status) tương đương với waitpid(-1,&status,0). Tham số option là một bit mask có thể là các flag sau, hoặc OR của các flag đó: WUNTRACED, WCONTINUE,WNOHANG.

Process orphans và zombies

Do thời gian tồn tại của process cha và process con là khác nhau, điều đó gây nên 2 vấn đề:

  • Nếu process cha kết thúc trước thì ai sẽ là process cha của các process con? Vấn đề này kernel giải quyết bằng cách cho process init (process có PID = 1 ) làm cha của các process orphan này.
  • Điều gì xảy ra nếu process con kết thúc trước khi process cha gọi hàm wait()? Kernel giải quyết vấn đề này bằng cách đưa process con về trạng thái zombie. Điều này có nghĩa là hầu hết tài nguyên của process con đều đã được giải phóng, ngoại trừ một bảng ghi thông tin process như ID, trạng thái kết thúc, thông số sử dụng tài nguyên của process con. Sau khi process cha thực hiện wait() thì kernel sẽ xóa zombie đi. Mặt khác nếu process cha kết thúc mà không gọi wait(), init process sẽ nhận process con làm con và tự động gọi wait() khi process con kết thúc để xóa zombie ra khỏi hệ thống. Nếu một process tạo process con mà không gọi wait() khi process con kết thúc thì thông tin của zombie sẽ được lưu mãi mãi ở bảng process của kernel. Khi một số lượng lớn zombie tồn tại, vượt quá bảng process của kernel thì sẽ không tạo được process mới nữa. Do zombie không thể bị tắt bởi signal nên chỉ có một cách để xóa nó, đó là kết thúc process cha (hoặc đợi process cha kết thúc). Việc kết thúc của process con xảy ra là không đồng bộ nên process cha không thể dự đoán được khi nào các process con của nó kết thúc. Ta đã có 2 cách sử dụng wait() để tránh zombie là:
  • Process cha gọi wait() hoặc waitpid() không kèm cờ WNOHANG, trong trường hợp này thì system call sẽ block nếu process con chưa kết thúc.
  • Process cha định kì kiểm tra một process cụ thể bằng hàm waitpid() kèm theo cờ WNOHANG. Tuy nhiên hai cách này khá bất tiện vì hoặc process cha sẽ bị block để đợi process con, hoặc process cha phải kiểm tra nhiều lần gây tốn CPU. Để khắc phục thì ta có thể sử dụng SIGCHL signal. SIGCHL signal sẽ được gửi đến process cha khi một process con của nó kết thúc. Mặc định thì tín hiệu này bị bỏ qua, nhưng ta có thể catch nó bằng signal handler.

6. Thực thi chương tình mới: execve()

System call execve() load một chương trình mới vào bộ nhớ của process. Ở quá trình này, chương trình cũ bị bỏ đi, stack, data và heap bị thay thế bởi program mới. Ta thường sử dụng execve() sau khi tạo process con bằng fork().
Nhiều hàm thư viện khác, có tên bắt đầu bằng exec được wrap system call này.

#include <unistd.h>
int execve(const char *pathname, char *const argv[], char *const envp[]);

Hàm này sẽ không trả về nếu thành công, trả về -1 nếu lỗi.
Và họ hàm được xây dựng trên execve():

#include <unistd.h>
int execle(const char *pathname, const char *arg, ...
        /* , (char *) NULL, char *const envp[] */ );
int execlp(const char *filename, const char *arg, ...
         /* , (char *) NULL */);
int execvp(const char *filename, char *const argv[]);
int execv(const char *pathname, char *const argv[]);
int execl(const char *pathname, const char *arg, ...
        /* , (char *) NULL */);

Ví dụ:

#include <unistd.h>
/* Example of an argument list */
/* Note that we need a program name for argv[0] */
char *const ps_argv[] =
{“ps”, “ax”, 0};
/* Example environment, not terribly useful */
char *const ps_envp[] =
{“PATH=/bin:/usr/bin”, “TERM=console”, 0};
/* Possible calls to exec functions */
execl(“/bin/ps”, “ps”, “ax”, 0); /* assumes ps is in /bin */
execlp(“ps”, “ps”, “ax”, 0); /* assumes /bin is in PATH */
execle(“/bin/ps”, “ps”, “ax”, 0, ps_envp); /* passes own environment */
execv(“/bin/ps”, ps_argv);
execvp(“ps”, ps_argv);
execve(“/bin/ps”, ps_argv, ps_envp);
Bình luận


White
{{ comment.user.name }}
Bỏ hay Hay
{{comment.like_count}}
Male avatar
{{ comment_error }}
Hủy
   

Hiển thị thử

Chỉnh sửa

White

Khó

12 bài viết.
35 người follow
Kipalog
{{userFollowed ? 'Following' : 'Follow'}}
Cùng một tác giả
White
8 2
Device Tree trong Linux Device Tree (DT) là một file mô tả phần cứng, có kiểu định dạng giống JSON, nó mô tả một cấu trúc cây, ở đó thì các device...
Khó viết gần 2 năm trước
8 2
White
5 1
Những công cụ và hàm hay dùng trong Linux Device Driver (Phần 1) _Tham khảo từ Linux Device Drivers Development_ Bản thân kernel là một phần tác...
Khó viết gần 2 năm trước
5 1
White
4 0
Context trong Linux Process context, interrupt context, user(space) context, system call context, atomic context, nonatomic context,... là những k...
Khó viết gần 2 năm trước
4 0
Bài viết liên quan
White
1 0
sudo du sh
t viết hơn 4 năm trước
1 0
White
36 10
Thời kỳ mới đi làm tôi nghĩ cứ phải gõ thật nhiều cho quen cho nhớ nhưng lâu dần việc đó cho cảm giác thật nhàm chán. Hiện giờ, những gì tôi hay là...
manhdung viết hơn 5 năm trước
36 10
White
1 0
Sử dụng option I với xargs Với option I thì bạn có thể sử dụng place holder với biến được lấy ra từ xargs man của option này: I replacestr R...
LinhPT viết hơn 4 năm trước
1 0
{{like_count}}

kipalog

{{ comment_count }}

bình luận

{{liked ? "Đã kipalog" : "Kipalog"}}


White
{{userFollowed ? 'Following' : 'Follow'}}
12 bài viết.
35 người follow

 Đầu mục bài viết

Vẫn còn nữa! x

Kipalog vẫn còn rất nhiều bài viết hay và chủ đề thú vị chờ bạn khám phá!