一、进程创建
1. fork
- 头文件:
#include <unistd.h>
- 函数原型:
pid_t fork(void);
- 说明:通过复制调用进程创建新进程,调用进程称为父进程,创建出来的新进程称为子进程,父子进程共用同一个代码段,但是它们的数据并不共用。
- 返回值:对于父进程来说返回值是子进程的PID,对于子进程来说返回值是0,如果创建子进程失败,则返回-1。我们可以通过返回值来判断父子进程,从而进行代码分流。
当一个进程调用fork时,控制会转移到内核中去,在内核中会有以下几个过程:
(1)给子进程分配新的内存块和内核数据结构
(2)将父进程部分数据结构内容拷贝至子进程
(3)将子进程添加到系统进程列表中去
(4)fork返回,调度器开始调度
在fork返回后,会出现两个代码相同的进程,而且它们运行到相同的地方,此时,这两个进程将各自开始执行。
实例:
#include <stdio.h>
#include <unistd.h>
int num = 5; //全局变量num
int main()
{
printf("Before fork:PID=%d\n", getpid());
pid_t pid = fork(); //创建一个新进程
if(pid < 0)
{
printf("fork error\n");
return -1;
}
else if(pid == 0) //子进程,返回值是0
{
num = 10; //子进程修改num值
printf("This is a child process. [PID=%d] [pid=%d] num=%d\n", getpid(), pid, num);
}
else //父进程,返回值是子进程的pid
{
printf("This is a parent process. [PID=%d] [pid=%d] num=%d\n", getpid(), pid, num);
}
while(1)
{}
return 0;
}
运行结果:
通过上面的结果来看,我们可以发现fork之前,父进程独立执行,而fork之后,父子进程分别执行,而谁先执行完全由调度器决定。此外,我们还发现当子进程修改num值后,父进程的值并不变,说明父子进程的数据并不共享。
通常,父子进程用的是相同的物理空间,子进程的代码段、数据段、堆栈段都是指向父进程的物理空间,也就是说,两者的虚拟地址空间不同,但它们对应的物理空间是同一个。但是,当父子进程任意一方中有更改相应段的行为发生时,则再为子进程相应的段分配物理空间。这就是写实拷贝技术。
写时拷贝是一种可以推迟甚至免除拷贝数据的技术。在子进程刚创建完后,内核此时并不复制整个父进程地址空间,而是让父子进程共享同一个物理空间,只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的数据空间。这种技术避免了拷贝大量根本就不会被使用的数据,极大地提高了进程快速执行的能力。
有时在实际创建进程中,我们也会fork失败,这主要有以下两个原因:
- 当前的进程数已经达到了系统规定的上限,这时errno的值被设置为EAGAIN
- 系统内存不足,这时errno的值被设置为ENOMEM
2. vfork
- 头文件:
#include <sys/types.h>
#include <unistd.h>
- 函数原型:
pid_t vfork(void);
- 说明:该函数功能和fork一样,但是两者还是有区别的
(1)vfork用于创建一个子进程,但内核并不会像fork那样给子进程创建独立虚拟地址空间,而是直接共享父进程的虚拟空间,也就是说,父子进程代码共享,数据也共享
(2)vfork保证子进程先运行,在子进程调用 exec 或 exit 之后父进程才可能被调度运行
实例:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int num = 5; //全局变量num
int main()
{
pid_t pid = vfork();
if(pid < 0)
{
printf("vfork error\n");
return -1;
}
else if(pid == 0)
{
num = 10; //在子进程中修改num值
printf("child num:%d\n", num);
exit(0);
}
else
{
printf("parent num:%d\n", num);
}
while(1)
{
sleep(1);
}
return 0;
}
运行结果:
通过上面的结果来看,我们发现,子进程是先于父进程运行的,而且当子进程修改num值后,父进程的num值也跟着变,说明父子进程的数据是共享的。
二、进程终止
1. 进程退出的场景
(1)正常退出
正确退出:代码运行完毕,结果正确
错误退出:代码运行完毕,结果错误
(2)异常退出
代码异常终止
2. 进程退出的方式
(1)_exit函数
- 头文件:
#include <unistd.h>
- 函数原型:
void _exit(int status);
- 说明:立即终止调用进程,属于该进程的任何打开的文件描述符都被关闭
- 参数:status 作为进程的退出状态返回给父进程,父进程可以通过 wait 来获取该值。不过虽然 status 是 int型,但是仅有低8位可以被父进程所用。
实例:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello world!");
_exit(-1);
}
运行结果:
通过上面的结果来看,我们发现 _exit()什么都没有做,而是立即终止程序,并且通过echo $?
来查看进程退出码时,显示结果并不是-1,而是255,这是因为父进程只能使用退出码-1的低8位所导致的。
(2)exit函数
- 函数原型:
void exit(int status);
- 说明:exit 最后也是会调用的 _exit 的,不过它在调用_exit之前,还做了其他一些工作:
(1)执行用户通过atexit或on_exit定义的清理函数
(2)关闭所有打开的流,所有的缓存数据均被写入
(3)调用_exit
实例:
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("hello world!");
exit(-1);
}
运行结果:
在这里,我们可以用下面这幅图去更加容易地理解 _exit 和 exit 的关系:
(3)return
return是一种更为常见的退出进程的方法,但是它只有在main中执行才会退出进程,并且在main中执行 return n 等同于执行 exit(n) 。
三、进程等待
1. 进程为什么需要等待
从我们之前接触的僵尸进程来看,当一个子进程先于父进程退出时,如果父进程没有关心这个子进程的退出状态,那么就有可能形成僵尸进程,进而出现内存泄露的问题。所以为了避免这种问题的出现,我们需要在子进程退出后,让父进程通过进程等待的方式,去获取子进程的退出状态,接着回收子进程的资源。
2. 进程等待的方法
(1)wait
- 头文件:
#include <sys/types.h>
#include <sys/wait.h>
- 函数原型:
pid_t wait(int *status);
- 参数:输出型参数,获取终止子进程的退出状态,若不关心可设置为NULL
- 返回值:成功时,返回终止子进程的PID;出错时,返回-1
- 功能:等待任意一个子进程退出,若没有子进程退出,则一直阻塞等待
(2)waitpid
- 头文件:
#include <sys/types.h>
#include <sys/wait.h>
- 函数原型:
pid_t waitpid(pid_t pid, int *status, int options);
- 参数:
(1)pid
pid=-1时,等待任意一个子进程退出,与 wait 效果一样
pid>0时,等待一个进程ID与pid相等的子进程退出
(2)status
WIFEXITED(status):若子进程正常终止,则返回true
WEXITSTATUS(status):若WIFEXITED为true,则返回子进程的退出状态
(3)options
WNOHANG:若指定的子进程没有结束,则立即返回0,不予以等待;若正常结束,则返回该子进程的PID- 返回值:当终止子进程正常结束的时候,返回该子进程的PID;如果options被设置为 WNOHANG ,并且此时没有子进程退出时,则返回0;若调用中出错,则返回 -1,这时 errno 会被设置为相应的值以指示错误所在。
- 功能:可以等待指定的子进程退出,也可以等待任意一个子进程退出
说明:
- 如果终止子进程已经退出,此时调用 wait 或 waitpid 会立即返回,获得子进程退出信息,并且释放资源;
- 如果在任意时刻调用 wait 或 waitpid 时,子进程存在且正常运行,则父进程可能会处于阻塞等待状态;
- 如果不存在该子进程,则立即出错返回。
3. 获取进程退出状态码
通过上面的介绍后,我们知道,如果父进程不关心子进程的退出状态信息,那么可以将 status 设置为NULL,否则,操作系统会根据 status ,将子进程的退出信息反馈给父进程。
在这个 status 参数中存储了子进程的退出原因以及退出码,而参数中只用了低16位(两个字节)来存储这些信息,我们可以把它当做一个位图来看,如下:
- 正常退出时:高8位存储的是退出码,只有子进程运行完毕退出时才会有,低8位为0
- 异常退出时:低7位存储的是导致子进程异常退出的信号值,第8位存储core dump标志,只有子进程异常退出时才会有,高8位为0
4. 实例
(1)wait
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
pid_t pid = fork(); // 创建进程
if(pid < 0)
{
printf("fork error!\n");
return -1;
}
else if(pid == 0) // 子进程
{
sleep(10);
exit(10);
}
else // 父进程
{
int status;
pid_t ret = wait(&status); // 进程等待
if(ret>0 && (status & 0x7F)==0) // 正常退出
{
printf("exit code:%d\n", (status>>8)&0xFF); // 打印退出状态码
}
else if(ret > 0) // 异常退出
{
printf("signal value:%d\n", status&0x7F); // 打印终止信号值
}
}
return 0;
}
运行结果:
- 等待10s,子进程正常退出:
- 在其他终端 kill -9 掉这个子进程,子进程异常退出:
(2)waitpid
- 阻塞式等待:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
pid_t pid = fork();
if(pid < 0)
{
printf("fork error!\n");
return-1;
}
else if(pid == 0)
{
sleep(10);
exit(10);
}
else
{
int status;
pid_t ret = waitpid(-1, &status, 0); // 阻塞式等待,等待10s
if(ret>0 && WIFEXITED(status)) // 等待成功,子进程正常退出
{
printf("exit code:%d\n", WEXITSTATUS(status)); // 打印退出状态码
}
else // 等待失败,子进程异常退出
{
printf("Waiting for child process to exit failed!\n");
}
}
return 0;
}
运行结果:
- 等待10s,子进程正常退出:
- 在其他终端 kill -9 掉这个子进程,子进程异常退出:
- 非阻塞式等待:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
pid_t pid = fork();
if(pid < 0)
{
printf("fork error!\n");
return-1;
}
else if(pid == 0)
{
sleep(10);
exit(10);
}
else
{
int status;
pid_t ret = 0;
do
{
ret = waitpid(-1, &status, WNOHANG); // 非阻塞式等待,若此时没有子进程退出,则立即返回0
printf("The child process is running!\n");
sleep(1);
}while(ret == 0);
if(ret>0 && WIFEXITED(status)) // 等待成功,子进程正常退出
{
printf("exit code:%d\n", WEXITSTATUS(status)); // 打印退出状态码
}
else // 等待失败,子进程异常退出
{
printf("Waiting for child process to exit failed!\n");
}
}
return 0;
}
运行结果:
- 等待10s,子进程正常退出:
- 在其他终端 kill -9 掉这个子进程,子进程异常退出:
四、进程程序替换
1. 替换原理
大多数时候,我们创建一个进程并不希望子进程跟父进程做相同的事情,而是希望能够做另一件事,这时候就用到了程序替换,子进程会调用一种 exec 函数以执行另一个程序。要注意的是,调用exec函数并不会创建新进程,所以调用exec函数前后该进程的PID并不会改变。
当进程调用exec函数时,该进程的用户空间代码和数据完全被新程序替换,因为只是替换了内容,所以并不会重新创建虚拟地址空间和页表,替换后这个进程将从新程序的入口函数开始运行。如果替换成功,则表示该进程运行的代码段已经不是以前的代码段了,而是新程序,因此原来代码exec函数以后的代码都不会运行,除非替换出错。
2. 替换函数
(1)exec函数族
- 头文件:
#include <unistd.h>
- 函数原型:
(1)int execl(const char *path, const char *arg, ...);
(2)int execlp(const char *file, const char *arg, ...);
(3)int execle(const char *path, const char *arg, ..., char * const envp[]);
(4)int execv(const char *path, char *const argv[]);
(5)int execvp(const char *file, char *const argv[]);
(6)int execve(const char *filename, char *const argv[], char *const envp[]);
- 返回值:如果调用成功,则加载新的程序从启动代码开始执行,不在返回; 如果调用出错,则返回-1。所以可以说:exec函数只有出错的返回值,而没有成功的返回值。
(2)命名理解
- l(list):参数采用列表格式
- v(vector):参数采用数组格式
- p(path):自动搜索环境变量PATH
- e(env):自己维护环境变量
函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 |
---|---|---|---|
execl | 列表 | 是 | 是 |
execlp | 列表 | 否,自动在PATH中寻找 | 是 |
execle | 列表 | 是 | 否,需要自己组装环境变量 |
execv | 数组 | 是 | 是 |
execvp | 数组 | 否,自动在PATH中寻找 | 是 |
execve | 数组 | 是 | 否,需要自己组装环境变量 |
(3)实例:
- execl
原型:
int execl(const char *path, const char *arg, ...);
#include <stdio.h>
#include <unistd.h>
int main()
{
execl("/bin/ls", "ls", "-l", NULL);
// 带全路径
// 参数是以列表格式给出
// 不定参数要以NULL结尾
return 0;
}
执行结果:
- execlp
原型:
int execlp(const char *file, const char *arg, ...);
#include <stdio.h>
#include <unistd.h>
int main()
{
execlp("ls", "ls", "-l", NULL);
// 不需要带上路径,只需要告诉文件名即可,会自动到环境变量PATH中的路径下寻找
// 参数是以列表格式给出
return 0;
}
- execle
原型:
int execle(const char *path, const char *arg, ..., char * const envp[]);
#include <stdio.h>
#include <unistd.h>
int main()
{
char * const envp[] = {"MYENV=666", NULL};
execle("/bin/ls", "ls", "-l", NULL, envp);
// 带全路径
// 参数是以列表格式给出
// 需要自己组装环境变量
return 0;
}
- execv
原型:
int execv(const char *path, char *const argv[]);
#include <stdio.h>
#include <unistd.h>
int main()
{
char * const argv[] = {"ls", "-l", NULL};
execv("/bin/ls", argv);
// 带全路径
// 参数是以数组格式给出
return 0;
}
- execvp
原型:
int execvp(const char *file, char *const argv[]);
#include <stdio.h>
#include <unistd.h>
int main()
{
char * const argv[] = {"ls", "-l", NULL};
execvp("ls", argv);
// 不需要带上路径,只需要告诉文件名即可,会自动到环境变量PATH中的路径下寻找
// 参数是以数组格式给出
return 0;
}
- execve
原型:
int execve(const char *filename, char *const argv[], char *const envp[]);
#include <stdio.h>
#include <unistd.h>
int main()
{
char * const argv[] = {"ls", "-l", NULL};
char * const envp[] = {"MYENV=123", NULL};
execve("/bin/ls", argv, envp);
// 带全路径
// 参数是以数组格式给出
// 需要自己组装环境变量
return 0;
}
事实上,只有execve是真正的系统调用,其它五个函数最终都会调用execve(execve在man手册第2节,其它五个函数在第3节)。