Linux C语言进程创建详解
进程概念基础
进程的定义
在Linux系统中,进程是程序的一次执行过程,是系统进行资源分配和调度的基本单位。简单来说,当你在系统中运行一个可执行程序时,系统就会为其创建一个进程。进程拥有自己独立的地址空间、文件描述符、内存映射等资源。
例如,当你在终端输入 ls
命令时,系统会创建一个进程来执行 ls
程序,该进程负责读取当前目录下的文件和目录信息,并将其显示在终端上。
进程的状态
- 运行态(Running):进程正在CPU上运行,或者准备好在CPU上运行。在一个多任务操作系统中,由于CPU资源的有限性,多个进程会竞争CPU,正在使用CPU的进程处于运行态,而等待CPU资源的进程则处于就绪态,就绪态也被视为广义的运行态一部分。
- 就绪态(Ready):进程已经具备了运行的所有条件,只等待CPU资源分配。例如,一个进程刚刚被创建,或者之前因为时间片用完而被暂停,此时它就处于就绪态,一旦获得CPU时间片,就可以进入运行态。
- 阻塞态(Blocked):进程由于等待某些事件的发生(如I/O操作完成、信号的到来等)而暂时无法运行。例如,当一个进程执行文件读取操作时,由于磁盘I/O速度相对较慢,在数据读取完成之前,进程会进入阻塞态,此时它不会占用CPU资源,直到数据读取完毕,进程才会从阻塞态转换为就绪态。
- 停止态(Stopped):进程被暂停执行,通常是由于接收到某些信号(如
SIGSTOP
)。例如,在调试程序时,调试器可以发送SIGSTOP
信号使进程暂停,以便查看进程的状态和变量值。 - 僵死态(Zombie):子进程已经结束运行,但其父进程还没有调用
wait
或waitpid
系统调用来获取其结束状态,此时子进程就处于僵死态。僵死态的进程虽然已经不再运行,但它的进程描述符仍然保留在系统中,直到父进程调用相应的函数来清理它,否则会造成资源浪费。
进程标识符(PID)
每个进程在系统中都有一个唯一的标识符,称为进程ID(PID)。PID是一个非负整数,系统通过PID来标识和管理进程。在Linux系统中,init
进程(系统初始化进程)的PID为1,它是所有其他进程的祖先。
获取当前进程PID的函数是 getpid
,获取其父进程PID的函数是 getppid
。以下是一个简单的代码示例:
#include <stdio.h>
#include <unistd.h>
int main() {
printf("当前进程的PID: %d\n", getpid());
printf("父进程的PID: %d\n", getppid());
return 0;
}
在上述代码中,通过调用 getpid
和 getppid
函数,分别获取当前进程及其父进程的PID,并将其打印出来。
Linux C语言进程创建
fork函数
- fork函数的基本概念
fork
函数是Linux系统中用于创建新进程的系统调用。它的作用是创建一个与调用进程(父进程)几乎完全相同的新进程(子进程)。子进程是父进程的副本,它拥有父进程的代码段、数据段、堆、栈等资源的拷贝,但它们拥有各自独立的地址空间。
fork
函数的原型如下:
#include <unistd.h>
pid_t fork(void);
pid_t
是一个整数类型,用于表示进程ID。fork
函数调用成功时,会在父进程中返回子进程的PID,而在子进程中返回0;如果调用失败,会在父进程中返回 -1,并设置 errno
来表示错误原因。
-
fork函数的执行过程 当
fork
函数被调用时,内核会执行以下操作:- 为子进程分配新的进程控制块(PCB),其中包含了进程的各种信息,如PID、进程状态、资源使用情况等。
- 为子进程分配独立的地址空间,将父进程的地址空间内容拷贝到子进程的地址空间中。这里需要注意的是,现代Linux系统采用了写时复制(Copy - On - Write,COW)技术,即在子进程没有对数据进行修改之前,父子进程共享相同的物理内存页面,只有当子进程或父进程对某个页面进行写操作时,才会真正复制该页面,以保证父子进程数据的独立性。
- 子进程继承父进程的大部分属性,如打开的文件描述符、工作目录、用户ID和组ID等,但也有一些属性是不同的,例如子进程的PID、父进程的PID(子进程的父进程PID为调用
fork
函数的父进程的PID)等。
-
fork函数代码示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
pid_t pid;
pid = fork();
if (pid == -1) {
perror("fork error");
exit(EXIT_FAILURE);
} else if (pid == 0) {
printf("我是子进程,我的PID是 %d,父进程的PID是 %d\n", getpid(), getppid());
} else {
printf("我是父进程,我创建的子进程PID是 %d,我的PID是 %d\n", pid, getpid());
}
return 0;
}
在上述代码中,首先调用 fork
函数创建子进程。如果 fork
调用失败,通过 perror
函数打印错误信息并退出程序。在子进程中,pid
的值为0,因此会执行 else if
分支中的代码,打印子进程自己的PID和父进程的PID;在父进程中,pid
的值为子进程的PID,会执行 else
分支中的代码,打印子进程的PID和自己的PID。
vfork函数
- vfork函数的基本概念
vfork
函数也是用于创建新进程的系统调用,它与fork
函数有一些区别。vfork
函数创建的子进程与父进程共享地址空间,也就是说,子进程对数据的修改会直接影响到父进程的数据。这种共享机制使得vfork
创建子进程的速度比fork
更快,因为它不需要复制父进程的地址空间。
vfork
函数的原型如下:
#include <unistd.h>
pid_t vfork(void);
vfork
函数的返回值与 fork
函数类似,在父进程中返回子进程的PID,在子进程中返回0;如果调用失败,返回 -1并设置 errno
。
-
vfork函数的执行过程 当
vfork
函数被调用时,内核同样会为子进程创建一个新的进程控制块。但与fork
不同的是,子进程并不会复制父进程的地址空间,而是直接共享父进程的地址空间。此外,在子进程调用exec
系列函数(用于执行新的程序)或exit
函数之前,父进程会被阻塞,以保证子进程优先执行,避免父进程在子进程使用共享资源之前对其进行修改。 -
vfork函数代码示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
pid_t pid;
int num = 10;
pid = vfork();
if (pid == -1) {
perror("vfork error");
exit(EXIT_FAILURE);
} else if (pid == 0) {
num++;
printf("子进程中num的值为 %d\n", num);
_exit(EXIT_SUCCESS);
} else {
printf("父进程中num的值为 %d\n", num);
}
return 0;
}
在上述代码中,定义了一个变量 num
并初始化为10。调用 vfork
创建子进程后,在子进程中对 num
进行加1操作,然后通过 _exit
函数退出子进程。由于子进程和父进程共享地址空间,在子进程修改 num
后,父进程中 num
的值也会相应改变,因此父进程打印出的 num
值为11。这里使用 _exit
而不是 exit
,是因为 exit
函数在退出时会执行一些清理操作(如刷新缓冲区等),而 _exit
函数直接终止进程,更适合在 vfork
创建的子进程中使用,以避免潜在的问题。
exec系列函数
- exec系列函数的基本概念
exec
系列函数用于在当前进程的地址空间中加载并执行一个新的程序。当一个进程调用exec
系列函数时,该进程的代码段、数据段、堆和栈等会被新程序的相应部分所替换,而进程的PID保持不变。这意味着调用exec
系列函数后,当前进程实际上已经变成了另一个程序的执行实例。
exec
系列函数有多个变体,常见的有 execl
、execlp
、execle
、execv
、execvp
和 execve
。它们的主要区别在于参数的传递方式和查找可执行文件的方式。
- exec系列函数的参数说明
- execl函数:
#include <unistd.h>
int execl(const char *path, const char *arg, ... /* (char *) NULL */);
path
是要执行的可执行文件的路径名。arg
是传递给新程序的参数列表,以 NULL
结尾。例如,要执行 ls -l
命令,可以这样调用 execl
:
execl("/bin/ls", "ls", "-l", NULL);
- **execlp函数**:
#include <unistd.h>
int execlp(const char *file, const char *arg, ... /* (char *) NULL */);
file
是要执行的可执行文件的文件名,它会在环境变量 PATH
所指定的目录中查找该文件。例如,同样执行 ls -l
命令,可以这样调用 execlp
:
execlp("ls", "ls", "-l", NULL);
- **execle函数**:
#include <unistd.h>
int execle(const char *path, const char *arg, ... /*, (char *) NULL, char * const envp[] */);
与 execl
类似,但它允许传递一个自定义的环境变量数组 envp
给新程序。例如:
char *myenv[] = {"PATH=/usr/local/bin:/bin:/usr/bin", NULL};
execle("/bin/ls", "ls", "-l", NULL, myenv);
- **execv函数**:
#include <unistd.h>
int execv(const char *path, char *const argv[]);
path
是可执行文件的路径名,argv
是一个指向字符串数组的指针,数组中的每个元素是传递给新程序的参数,最后一个元素必须为 NULL
。例如:
char *args[] = {"ls", "-l", NULL};
execv("/bin/ls", args);
- **execvp函数**:
#include <unistd.h>
int execvp(const char *file, char *const argv[]);
与 execv
类似,但它会在 PATH
环境变量指定的目录中查找可执行文件。例如:
char *args[] = {"ls", "-l", NULL};
execvp("ls", args);
- **execve函数**:
#include <unistd.h>
int execve(const char *path, char *const argv[], char *const envp[]);
这是 exec
系列函数中最基本的函数,其他函数最终都会调用 execve
。path
是可执行文件的路径名,argv
是参数数组,envp
是环境变量数组。
- exec系列函数代码示例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
pid_t pid;
pid = fork();
if (pid == -1) {
perror("fork error");
exit(EXIT_FAILURE);
} else if (pid == 0) {
char *args[] = {"ls", "-l", NULL};
if (execvp("ls", args) == -1) {
perror("execvp error");
_exit(EXIT_FAILURE);
}
} else {
wait(NULL);
printf("子进程已执行完毕\n");
}
return 0;
}
在上述代码中,首先调用 fork
创建子进程。在子进程中,使用 execvp
函数执行 ls -l
命令。如果 execvp
调用失败,通过 perror
打印错误信息并使用 _exit
退出子进程。在父进程中,调用 wait
函数等待子进程结束,然后打印提示信息。
进程创建中的常见问题及解决方法
资源耗尽问题
-
问题描述 在创建大量进程时,可能会遇到资源耗尽的问题,例如系统内存不足、文件描述符耗尽等。当系统内存不足时,新进程的创建可能会失败,因为系统无法为其分配足够的内存空间;当文件描述符耗尽时,进程可能无法打开新的文件或建立新的网络连接等。
-
解决方法
- 内存资源管理:在创建进程前,可以通过系统调用(如
sysconf
函数)获取系统的可用内存等资源信息,根据实际情况合理控制进程的创建数量。同时,在进程内部,要注意及时释放不再使用的内存,避免内存泄漏。例如,使用malloc
分配的内存,要及时使用free
进行释放。 - 文件描述符管理:合理使用文件描述符,及时关闭不再使用的文件描述符。可以通过
fcntl
函数来获取和设置文件描述符的标志,例如设置文件描述符为非阻塞模式,以提高I/O效率并减少文件描述符的占用时间。此外,还可以通过修改系统参数(如/proc/sys/fs/file - max
)来增加系统允许打开的最大文件描述符数量,但要谨慎操作,以免影响系统的稳定性。
- 内存资源管理:在创建进程前,可以通过系统调用(如
父子进程同步问题
-
问题描述 在使用
fork
创建父子进程后,父子进程的执行顺序是不确定的,这可能会导致一些同步问题。例如,父进程可能在子进程完成某些初始化操作之前就试图访问子进程的数据或执行依赖于子进程完成的任务,从而导致程序出错。 -
解决方法
- 使用信号:父子进程可以通过信号进行同步。例如,子进程在完成初始化操作后,可以向父进程发送一个信号(如
SIGUSR1
),父进程在接收到该信号后再执行相应的操作。可以使用signal
函数或sigaction
函数来注册信号处理函数。 - 使用等待函数:父进程可以使用
wait
或waitpid
函数等待子进程结束或达到某个特定状态。wait
函数会阻塞父进程,直到任意一个子进程结束;waitpid
函数可以指定等待某个特定PID的子进程,并且可以设置非阻塞模式。例如:
- 使用信号:父子进程可以通过信号进行同步。例如,子进程在完成初始化操作后,可以向父进程发送一个信号(如
pid_t pid;
pid = fork();
if (pid == -1) {
perror("fork error");
exit(EXIT_FAILURE);
} else if (pid == 0) {
// 子进程执行任务
_exit(EXIT_SUCCESS);
} else {
int status;
waitpid(pid, &status, 0);
// 父进程在子进程结束后执行后续操作
}
僵死进程问题
-
问题描述 如前文所述,当子进程结束运行但父进程没有调用
wait
或waitpid
来获取其结束状态时,子进程会进入僵死态,占用系统资源。如果大量产生僵死进程,会影响系统的性能。 -
解决方法
- 父进程及时等待:父进程在创建子进程后,要及时调用
wait
或waitpid
函数来等待子进程结束,并获取其结束状态。这样可以避免子进程成为僵死进程。 - 使用信号处理:可以在父进程中注册
SIGCHLD
信号的处理函数。当子进程状态发生改变(如结束运行)时,系统会向父进程发送SIGCHLD
信号,父进程在信号处理函数中调用wait
或waitpid
函数来清理子进程。例如:
- 父进程及时等待:父进程在创建子进程后,要及时调用
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
void sigchld_handler(int signum) {
pid_t pid;
int status;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
printf("子进程 %d 已结束\n", pid);
}
}
int main() {
pid_t pid;
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction error");
exit(EXIT_FAILURE);
}
pid = fork();
if (pid == -1) {
perror("fork error");
exit(EXIT_FAILURE);
} else if (pid == 0) {
// 子进程执行任务
_exit(EXIT_SUCCESS);
} else {
// 父进程继续执行其他任务
while (1) {
sleep(1);
}
}
return 0;
}
在上述代码中,定义了一个 SIGCHLD
信号的处理函数 sigchld_handler
,在处理函数中使用 waitpid
函数以非阻塞方式等待子进程结束。在 main
函数中,通过 sigaction
函数注册信号处理函数,然后创建子进程。父进程在创建子进程后可以继续执行其他任务,当子进程结束时,系统会调用信号处理函数来清理子进程,避免产生僵死进程。