Khái quát về tiến trình

Tiến trình và chương trình là hai khái niệm liên quan đến nhau và không dễ dàng cho những người mới học hiểu rõ và phân biệt rạch ròi về bản chất. Để hiểu rõ hơn về tiến trình, chúng ta trước hết cần nắm được chương trình là gì.

Chương trình (program) và tiến trình (process)

Chương trình (program) là một file chạy được (executable) chứa các chỉ lệnh (instruction) được viết để thực thi một công việc nào đó trên máy tính. Ví dụ dễ hiểu là một chương trình trên Windows là helloworld.exe mà chắc những người lần đầu tiếp cận lập trình đều biết. Một chương trình được viết bởi lập trình viên có thể bằng nhiều ngôn ngữ khác nhau, và được lưu ở trên ổ cứng của máy tính. Khi được chạy, máy tính sẽ tải chương trình đó vào bộ nhớ RAM để thực thi, vì vậy chương trình cũng có thể được gọi là một thực thể bị động (passive entity).

Khác với chương trình, tiến trình (process) là một phiên bản đang chạy của một chương trình. Ví dụ một chương trình helloworld.exe đang chạy trong máy tính được gọi là một tiến trình. Tiến trình được gọi là thực thể chủ động (active entity) để phân biệt với chương trình, vì nó sẽ được copy từ ổ cứng sang bộ nhớ RAM để chạy trên hệ điều hành máy tính. Máy tính xây dựng một tiến trình từ một chương trình, bằng cách copy chương trình đang nằm trên ổ cứng vào RAM, cấp phát các tài nguyên cần thiết để xây dựng một tiến trình. Bạn có thể tạo được nhiều tiến trình từ một chương trình, ví dụ như khi bạn double click để chạy chương trình helloworld.exe nhiều lần.

Trên góc nhìn từ kernel, một tiến trình bao gồm (1) không gian bộ nhớ người dùng (user-space) chứa mã nguồn chương trình và (2) các cấu trúc dữ liệu của kernel chứa thông tin trạng thái của tiến trình đó. Kernel duy trì một cấu trúc task_struct cho mỗi tiến trình để lưu các thông tin cho mỗi tiến trình đó như các process ID liên quan, virtual memory table, bảng mô tả file (open file descriptor), đường dẫn hiện tại (current working directory)…

Định danh tiến trình (process ID)

Mỗi tiến trình có một số định danh (process ID, hay pid), là một số dương để xác định tiến trình đó là duy nhất trong hệ thống. Lập trình viên có thể tác động lên tiến trình (ví dụ kill tiến trình) bằng một số system call với đối số truyền vào là process ID đó. Có thể lấy process ID của tiến trình đang chạy bằng system call getpid() với prototype như sau:

#include <unistd.h>

pid_t getpid(void);
                   /*Luôn trả về thành công pid của tiến trình*/

System call getpid() trả về process ID với kiểu dữ liệu pid_t là một số nguyên dương được dùng để lưu trữ process ID.

Có thể dùng câu lệnh “ps aux” để liệt kê các tiến trình đang chạy trên hệ thống, ví dụ như sau:

Cũng trong hình vẽ trên ta có thể thấy, trong Linux tiến trình init (/sbin/init) là tiến trình được khởi tạo đầu tiên bởi hệ thống, vì vậy luôn có pid là 1. Tiến trình init sau khi được khởi tạo bởi kernel, sẽ làm nốt các phần việc còn lại là khởi tạo các tiến trình cần thiết khác đề hoàn tất quá trình booting của hệ thống.

Mỗi tiến trình sẽ có một tiến trình cha (parent process), là tiến trình tạo ra nó. Khi một tiến trình trở thành orphan (tạm dịch là tiến trình mồ côi) do tiến trình cha của nó bị kết thúc, nó sẽ trở thành tiến trình con của tiến trình đầu tiên “init”.

Có thể dùng system call getppid() để xác định tiến trình cha của tiến trình hiện tại theo prototype sau:

#include <unistd.h>

pid_t getppid(void);
                    /*Luôn trả về thành công pid của tiến trình cha*/

Cấu trúc bộ nhớ của tiến trình

Bộ nhớ được cấp phát cho một tiến trình bao gồm nhiều thành phần được gọi là các segment. Cụ thể các segment như sau:

  • Text segment: Chứa các chỉ lệnh ngôn ngữ máy (machine-language instruction) của chương trình mà tiến trình đó chạy. Text segment chính là các chỉ lệnh được biên dịch từ source code của lập trình viên cho chương trình đó. Nội dung của phân vùng này không thay đổi trong suốt quá trình process tồn tại nên được kernel thiết lập chế độ read-only để bảo vệ khỏi sự truy cập vô tình hay cố ý của người dùng. Như đã nói ở trên, vì nhiều tiến trình có thể chạy một chương trình nên text segment cũng được thiết lập sharable để các tiến trình có thể sử dụng chung để tiết kiệm tài nguyên.

  • Initialized data segment: Vùng này chứa các biến toàn cục (global) và biến static mà đã được khởi tạo từ code của chương trình. Giá trị của các biến này được đọc từ các file thực thi khi chương trình được tải vào RAM. Ví dụ khi lập trình viên khai báo biến tĩnh “static var = 10;”, biến var này sẽ được lưu vào vùng nhớ của initialized data segent.

  • Uninitialized data segment: Còn được gọi là vùng bss segment. Segment này chứa các biến toàn cục và static mà chưa được khởi tạo từ source code. Ví dụ khi lập trình viên khai báo biến tĩnh “static var;”, biến var sẽ được chứa ở vùng này và được khởi tạo giá trị 0. Nếu bạn thắc mắc tại sao biến var không được lưu vào vùng initialized data segment cho đơn giản. Câu trả lời là khi một chương trình được lưu trữ vào ổ cứng, không cần thiết phải cấp phát tài nguyên cho uninitialized data segment; thay vào đó chương trình chỉ cần nhớ vị trí và kích thước biến được yêu cầu cho vùng này, các biến này sẽ được cấp phát run time khi chương trình được tải vào RAM.

  • Stack segment: Chứa stack frame của tiến trình. Chúng ta sẽ tìm hiểu sâu hơn về stack và stack frame ở phần dưới đây. Tạm thời hiểu là khi 1 hàm được gọi, một stack frame sẽ được cấp phát cho hàm đó (các biến được khai báo trong hàm, các đối số truyền vào hay giá trị return) và sẽ bị thu hồi khi hàm đó kết thúc. Vì vậy, stack segment có thể giãn ra hoặc co lại khi tiến trình cấp phát/thu hồi các stack frame.

  • Heap segment: Là vùng bộ nhớ lưu các biến được cấp phát động (dynamic allocate) tại thời điểm run time. Tương tự như stack segment, heap segment cũng có thể giãn ra hoặc co vào khi một biến được cấp phát hoặc free.

Xét ví dụ về một chương trình rất đơn giản dưới đây, chúng ta sẽ phân tích các biến và các hàm sẽ nằm ở đâu trong không gian bộ nhớ của tiến trình. Lưu ý mục đích của chương trình này là đưa các biến vào để chỉ rõ chúng sẽ được lưu trong bộ nhớ của tiến trình như thế nào, vậy nên chúng ta đừng quan tâm đến nội dung code nhé.

#include<stdio.h>
#include<stdlib.h>

static int number = 5;                 // lưu trong initialized data segment
char str[10];                              // lưu trong uninitialized data segment

int double_num(int num)            // lưu vào stack frame segment
{
	int result = 0;                  // biến result lưu trong stack frame double_num
	result = num *2;

	return result;           // Sau khi return, stack frame double_num bị thu hồi
}

int main(void)                  // stack frame main được lưu vào stack segment
{
	int ret = 0;            // biến ret được lưu vào stack frame main
	char *ret_str = NULL;             // biến ret_str được lưu vào main frame
	ret_str = malloc(128);            // con trỏ đến heap segment

	ret = double_num(number);
	sprintf(ret_str, "Result of doubling number is %d", ret);
	printf("%s", ret_str);

	return ret;
}

Chúng ta sẽ tìm hiểu sâu hơn về không gian bộ nhớ của tiến trình trong chương sau. Hiện giờ chỉ cần hiểu cơ bản là một tiến trình sẽ có một không gian bộ nhớ ảo (virtual memory) 4 GB (trong kiến trúc x86-32). Hình vẽ dưới đây phác thảo một cách đơn giản về các memory segment trong không gian bộ nhớ của một tiến trình:

                                                          

Hình 1: Cấu trúc bộ nhớ của một tiến trình

Stack và stack frame

Stack segment của tiến trình có thể giãn ra hoặc co lại mỗi khi một hàm được gọi hoặc return. Trong kiến trúc x86-32 có một thanh ghi đặc biệt được gọi là stack pointer (sp) lưu thông tin địa chỉ top của stack. Khi một hàm được gọi, một stack frame của hàm đó được cấp phát và push vào stack; và stack frame này sẽ được thu hồi khi hàm đó return.

Mỗi stack frame chứa các thông tin sau:

  • Đối số (argument) của hàm và các biến cục bộ (local variable): Các biến này còn được gọi là biến tự động (automatic variable) vì chúng sẽ tự động được tạo ra khi hàm được gọi và tự động biến mất khi hàm đó return (vì stack frame cũng biến mất).

  • Call linkage information: Các hàm khi chạy sẽ sử dụng các thanh ghi của CPU, ví dụ như thanh ghi program counter (pc) lưu chỉ lệnh tiếp theo được thực thi. Mỗi lần một hàm (ví dụ hàm X) gọi hàm khác (ví dụ hàm Y), giá trị các thanh ghi mà hàm X đang dùng sẽ được lưu vào stack frame của hàm Y; và sau khi hàm Y return, các giá trị thanh ghi này sẽ được phục hồi cho hàm X tiếp tục chạy.

argc và argv

Mọi chương trình C đều phải có hàm main(), được gọi là entry point của chương trình vì là hàm đầu tiên được thực thi khi tiến trình chạy. Thông thường, một hàm main() của chương trình thường có 2 đối số sau:

                 int main (int argc, char *argv[])

Đối số đầu tiên argc chỉ số đối số command-line được truyền nào. Đối số tiếp theo char *argv[] là 1 mảng con trỏ trỏ đến các đối số command-line, mỗi command-line là 1 chuỗi kết thúc bởi ký tự NULL. Trong mảng con trỏ đối số đó, chuỗi đầu tiên là argv[0] luôn phải là tên của chính chương trình. Mảng đối số luôn phải kết thúc bởi con trỏ NULL (argv[argc] = NULL).

Biến môi trường

Mỗi tiến trình có 1 danh sách các biến ở dạng string gắn với nó được gọi là các biến môi trường (environment list). Mỗi chuỗi trong số này được định nghĩa dưới dạng name=value, các biến này được dùng để lưu trữ 1 thông tin bất kỳ mà tiến trình muốn giữ. Hiểu một cách đơn giản, biến môi trường là biến của tiến trình đang chạy đó, không phải là biến của một hàm nào cả và được lưu trữ trong không gian bộ nhớ của tiến trình đó.

Khi 1 chương trình được tạo ra, nó sẽ kế thừa các biến môi trường của tiến trình cha. Vì đặc điểm này, sử dụng biến môi trường cũng có thể coi là 1 cách rất đơn giản cho giao tiếp liên tiến trình (IPC) giữa tiến trình cha và con. Ví dụ khi bạn tạo 1 biến môi trường từ tiến trình cha rồi tạo ra một tiến trình con, tiến trình con này sẽ lưu giữ giá trị của biến đó. Lưu ý là việc lưu giữ này chỉ là một chiều và một lần, nghĩa là sau đó nếu tiến trình cha hoặc con thay đổi giá trị của biến đó thì nó sẽ không được cập nhật sang cho tiến trình còn lại.

Kết luận

Bài học này đã giúp chúng ta hiểu rõ khái niệm về tiến trình, cũng như các thành phần và tổ chức bộ nhớ của tiến trình. Trong bài tiếp theo, chúng ta sẽ bắt tay vào thao tác với tiến trình như tạo, kết thúc và quản lý tiến trình con.

Trả lời

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *