1、概述
进程是一个动态过程,而非静态文件,它是程序的一次运行过程,当应用程序被加载到内存中运行之后 它就成为了一个进程
程序和进程区别和联系
- 程序是静态的,进程是动态的:程序是存储在某种介质上的二进制代码,进程对应了程序的执行过程,系统不需要为一个不执行的程序创建进程,一旦进程被创建,就处于不断变化的动态过程中,对应了一个不断变化的上下文环境
- 程序是永久的,进程是暂时存在的:程序的永久性是相对于进程而言的,只要不去删除它,它可以永久的存储在介质当中
2、进程的环境变量
每一个进程都有一组与其相关的环境变量,这些环境变量以字符串形式存储在一个字符串数组列表中, 把这个数组称为环境列表
环境变量的作用
环境变量常见的用途之一是在 shell 中,每一个环境变量都有它所表示的含义,譬如 HOME 环境变量表示用户的家目录,USER 环境变量表示当前用户名,SHELL 环境变量表示 shell 解析器名称,PWD 环境变量表示当前所在目录等,在自己的应用程序当中,也可以使用进程的环境变量
使用 env 命令查看到 shell 进程的所有环境变量
export LINUX_APP=123456 # 添加 LINUX_APP 环境变量
export -n LINUX_APP # 删除 LINUX_APP 环境变量
2.1 应用程序中获取环境变量
进程的环境变量是从其父进程中继承过来的
,譬如在 shell 终端下执行一个应用程序,那么该进程的环境变量就是从其父进程(shell 进程)中继承过来的
环境变量存放在一个字符串数组中,在应用程序中,通过 environ
变量指向它,environ 是一个全局变
量,在应用程序中只需申明它即可使用
extern char **environ; // 申明外部全局变量 enviro
如果只想要获取某个指定的环境变量,可以使用库函数 getenv()
#include <stdlib.h>
char *getenv(const char *name);
/*
* name:指定获取的环境变量名称
* 返回值:如果存在该环境变量,则返回该环境变量的值对应字符串的指针;不存在,返回 NULL
*/
注意:不应该去修改其返回的字符串,修改该字符串意味着修改了环境变量对应的值
2.2 添加/删除/修改环境变量
C 语言函数库
提供了用于修改、添加、删除环境变量的函数,譬如 putenv()、setenv()、unsetenv()、
clearenv()函数等
putenv
可向进程的环境变量数组中添加一个新的环境变量,或者修改一个已经存在的环境变量对应的值
#include <stdlib.h>
int putenv(char *string);
/*
* string:一个字符串指针,指向 name=value 形式的字符串。
* 返回值:成功返回 0;失败将返回非 0 值,并设置 errno
*/
注意:putenv() 函数将设定 environ 变量(字符串数组)中的某个元素(字符串指针)指向该 string 字符串,而不是指向它的复制副本,因此不能随意修改参数 string 所指向的内容,出于这种原因,参数 string 不应为自动变量(即在栈中分配的字符数组)
setenv
可以替代 putenv()函数,用于向进程的环境变量列表中添加一个新的环境变量或修改现有环境变量对应的值
#include <stdlib.h>
int setenv(const char *name, const char *value, int overwrite);
/*
* name:需要添加或修改的环境变量名称
* value:环境变量的值
* overwrite:
* -overwrite!=0, 若参数 name 标识的环境变量已经存在,则覆盖,不存在则表示添加新的环境变量
* -overwrite==0, 若参数 name 标识的环境变量已经存在,将不改变现有环境变量的值
*/
注意:setenv()函数为形如 name=value 的字符串分配一块内存缓冲区,并将参数 name 和参数 value 所指向的 字符串复制到此缓冲区中,以此来创建一个新的环境变量。
setenv和putenv的区别:
- putenv()函数并不会为 name=value 字符串分配内存;
- setenv()可通过参数overwrite控制是否需要修改现有变量的值而仅以添加变量为目的,显然putenv()并不能进行控制
推荐使用 setenv()函数,这样使用自动变量作为 setenv()的参数也不会有问题
除了两种函数外,还可通过一种更简单地方式向进程环境变量中添加环境变量,NAME=value ./app
在执行程序的时候,在其路径前面添加环境变量,以 name=value 的形式添加,如果是多个环境变量,则在./app 前面放置多对 name=value 即可,以空格分隔
unsetenv
可以从环境变量表中移除参数 name 标识的环境变量
#include <stdlib.h>
int unsetenv(const char *name);
2.3 清空环境变量
有时,需要清除环境变量表中的所有变量,然后再进行重建,可以通过将全局变量 environ 赋值为 NULL来清空所有变量。environ = NULL;
也可通过 clearenv()函数来操作
#include <stdlib.h>
int clearenv(void<img src=");" alt="" width="30%" />
注意:在某些情况下,使用setenv()函数和clearenv()函数可能会导致程序内存泄漏,,setenv()函数会为环境变量分配一块内存缓冲区;而调用 clearenv()函数时没有释放该缓冲区(clearenv()调用并不知晓该缓冲区的存在,故而也无法将其释放),反复调用这两个函数的程序,会不断产生内存泄漏
3、fork()创建子进程
使用了fork,就产生了另一个进程,于是进程就"分叉"了
在一个大型的应用程序任务中,创建子进程通常会简化应用程序的设计,同时提高了系统的并发性(即同时能够处理更多的任务或请求,多个进程在宏观上
实现同时运行)。
一个现有的进程可以调用 fork(系统调用)创建一个新的进程,调用 fork()函数的进程称为父进程,由 fork()函数创建出来的进程被称为子进程,
#include <unistd.h>
pid_t fork(void);
完成对其调用后将存在两个进程,一个是原进程(父进程)、另一个则是创建出来的子进程,并且每个进程都会从 fork()函数的返回处继续执行,会导致调用 fork()返回两次值,子进程返回一个值、父进程返回一个值。在程序代码中,可通过返回值来区分是子进程还是父进程
- fork()调用成功后,将会
在父进程中返回子进程的 PID,而在子进程中返回值是 0
- 如果调用失败,父进程返回值-1,不创建子进程,并设置 errno。
子进程是父进程的一个副本
,譬如子进程拷贝了父进程的数据段、堆、栈以及继承了父进程打开的文件描述符,父进程与子进程并不共享这些存储空间,这是子进程对父进程相应部分存储空间的完全复制,执行 fork()之后,每个进程均可修改各自的栈数据以及堆段中的变量,而并不影响另一个进程。但是对于程序代码段来说,两个进程执行相同的代码段,在内存中只存在一份代码段数据
在现代Linux中,子进程使用了和父进程一样的页表,导致两个进程所有数据都是一模一样的,没有任何差别。当然,在子进程的页表里会有特殊标记,使得当子进程需要写这段内存时(读的时候任何事情都不会发生),内核会将要写的这一页复制一份新的给子进程。是谓「写时复制
」
测试:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(){
pid_t pid;
pid = fork();
switch (pid){
case -1:
perror("fork error");
exit(-1);
case 0:
printf("子进程打印变量pid:%d\n",pid);
printf("子进程:<pid: %d, 父进程 pid: %d>\n",getpid(), getppid());
_exit(0); //子进程使用_exit()退出
default:
printf("父进程打印变量pid:%d\n",pid);
printf("父子进程:<pid: %d, 子进程 pid: %d>\n",getpid(), pid);
exit(0);
}
}
fork()函数调用完成之后,父进程、子进程会各自继续执行 fork()之后的指令,最终父进程会执行到 exit() 结束进程,而子进程则会通过_exit()结束进程
从上图中可以知道,打印的 pid 值并不相同,0 表示子进程打印出来的,3411 表示的是父进程打印出来的,可以发现,父进程、子进程它们共享代码段,但并不共享数据段、堆、栈等
,而是子进程拥有父进程数据段、堆、栈等副本,所以对于同一个局部变量,它们打印出来的值是不相同的,因为 fork()调用返回值不同,在父、子进程中赋予了 pid 不同的值。
4、父、子进程间的文件共享
调用 fork()函数之后,子进程会获得父进程所有文件描述符的副本,这些副本的创建方式类似于 dup(),这也意味着父、子进程对应的文件描述符均指向相同的文件表
父、子进程中对应的文件描述符指向了相同的文件表,也意味着父、子进程中对应的文件描述符指向了磁盘中相同的文件,因而这些文件在父、子进程间实现了共享,譬如,如果子进程更新了文件偏移量,那么这个改变也会影响到父进程中相应文件描述符的位置偏移量
测试
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main(void){
pid_t pid;
int fd;
int i;
fd = open("./test.txt", O_RDWR | O_TRUNC);
if (0 > fd){
perror("open error");
exit(-1);
}
pid = fork();
switch (pid)
{
case -1:
perror("fork error");
close(fd);
exit(-1);
case 0:
/* 子进程 */
for (i = 0; i < 4; i++) //循环写入 4 次
write(fd, "1122", 4);
close(fd);
_exit(0);
default:
/* 父进程 */
for (i = 0; i < 4; i++) //循环写入 4 次
write(fd, "AABB", 4);
close(fd);
exit(0);
}
}
子进程了继承了父进程打开的文件描述符 fd,此种情况下,父、子进程分别对同一个文件进行写入操作,结果是接续写,不管是父进程,还是子进程,在每次写入时都是从文件的末尾写入,很像使用了 O_APPEND 标志的效果
但是:父进程和子进程都去打开同一个文件,然后再对文件进行写入操作,则会出现覆盖的情况,父、子 进程的这两个文件描述符分别指向的是不同的文件表,有各自的文件偏移量
5、fork之后的竞争竞争条件
调用 fork()之后,子进程成为了一个独立的进程,可被系统调度运行,而父进程也继续被系统调度运行,这里出现了一个问题,调用 fork 之后,无法确定父、子两个进程谁将率先访问 CPU,也就是说无法确认谁先被系统调用运行。如果不加处理,两种情况都有可能发生。
可以通过采用采用某种同步技术来实现,譬如信号技术
,如果要让子进程先运行,则可使父进程被阻塞,等到子进程来唤醒它
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
void sig_handler(int sig){
printf("接收到信号\n");
}
int main(void){
struct sigaction sig = {0};
sigset_t wait_mask;
/* 初始化信号集 */
sigemptyset(&wait_mask);
/* 设置信号处理方式 */
sig.sa_handler = sig_handler;
sig.sa_flags = 0;
if (-1 == sigaction(SIGUSR1, &sig, NULL)){
perror("sigaction error");
exit(-1);
}
switch (fork()){
case -1:
perror("fork error");
exit(-1);
case 0:
/* 子进程 */
printf("子进程执行\n");
sleep(2);
kill(getppid(), SIGUSR1); //发送信号给父进程、唤醒它
_exit(0);
default:
/* 父进程 */
if (-1 != sigsuspend(&wait_mask)) //挂起、等待信号唤醒,wait_mask为空可被任意唤醒
exit(-1);
printf("父进程执行\n");
exit(0);
}
}
这里我们希望子进程先运行打印相应信息,之后再执行父进程打印信息,在父进程分支中,直接调用了 sigsuspend()使父进程进入挂起状态,由子进程通过 kill 命令发送信号唤醒
6、进程的创建和结束
6.1创建
Linux系统下的所有进程都是由其父进程创建而来,譬如在 shell 终端通过命令的方式执行一个程序./app,那么 app 进程就是由 shell 终端进程创建出来的,shell 终端就是该进程的父进程
进程号为 1 的进程便是所有进程的父进程,通常称为 init 进程,它是 Linux 系统启动之后运行的第一个进程,它管理着系统上所有其它进程,init 进程是由内核启动,因此理论上说它没有父进程
6.2结束
程序结束其实就是进程终止,通常,进程有两种终止方式:异常终止和正常终止
6.2.1 进程终止处理函数
atexit()库函数用于注册一个进程在正常终止时要调用的函数
#include <stdlib.h>
int atexit(void (*function)(void));
/*
* function:函数指针,指向注册的函数,此函数无需传入参数、无返回值。
* 返回值:成功返回 0;失败返回非 0。
*/
static void bye(void){
puts("Goodbye!");
}
int main(int argc, char *argv[])
{
if (atexit(bye)) {
fprintf(stderr, "cannot set exit function\n");
exit(-1);
}
exit(0);
}
如果程序当中使用了_exit()或_Exit()终止进程而并非是 exit()函数,那么将不会执行终止处理函数
6.2.2 正常终止
进程正常退出除了可以使用 return 之外,还可以使用 exit()
、_exit()
以及_Exit()
_exit():main 函数中使用 return 后返回,return 执行后把控制权交给调用函数,结束该进程。调用_exit()函数会清除其使用的内存空间,并销毁其在内核中的各种数据结构,关闭进程的所有文件描述符,并结束进程、将控制权交给操作系统。_exit()和_Exit()两者等价
exit() exit()函数_exit()函数都是用来终止进程的,exit()是一个标准 C 库函数,而_exit()和_Exit()是系统调用。 执行 exit()会执行一些清理工作,最后调用_exit()函数
_exit()函数和 exit()函数的 status 参数定义了进程的终止状态,父进程可以调用 wait()函数以获取该状态 一般来说,终止状态为 0 表示进程成功终止,而非 0 值则表示进程在执行过程中出现了一些错误而终止,譬如文件打开失败、读写失败等等,对非 0 返回值的解析并无定例
一般使用 exit()库函数而非_exit()系统调用,原因在于 exit()最终也会通过_exit()终止进程,但在此之前,它将会完成一些其它的工作,exit()函数会执行的动作如下:
- 如果程序中注册了进程终止处理函数,那么会调用终止处理函数
刷新 stdio 流缓冲区
(标准I/0维护的缓冲区)- 执行_exit()系统调用
前面提到:父、子进程不应都使用 exit()终止,只能有一个进程使用 exit()、而另一个则使用_exit()退出,一般推荐的是子进程使用_exit()退出、而父进程则使用 exit()退出。其原因就在于调用 exit()函数终止进程时会刷新进程的 stdio 缓冲区
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
//printf("Hello World!"); // 当只执行这句的时候,控制台输出:Hello World!Hello World!
printf("Hello World!\n"); // 当只执行这句的时候,控制台输出:Hello World!
switch (fork())
{
case -1:
perror("fork error");
exit(-1);
case 0:
/* 子进程 */
exit(0);
default:
/* 父进程 */
exit(0);
}
}
在程序当中明明只使用了 printf 打印了一次字符串却,在没有换行的时候却输出了两次?
由于进程的用户空间内存中维护了 stdio 缓冲区,通过
fork()创建子进程时会复制这些缓冲区
。标准输出设备默认使用的是行缓冲
,当检测到换行符\n
时会立即显示函数 printf()输出的字符串(1)printf 输出的字符串中包含了换行符,所以会立即读走缓冲区中的数据并显示,读走之后此时缓冲区就空了,子进程虽然拷贝了父进程的缓冲区,但是空的,虽然父、子进程使用 exit()退出时会刷新各自的缓冲区,但对于空缓冲区自然无数据可读
(2)printf()没有添加换行符\n,当调用 printf()时并不会立即读取缓冲区中的数据进行显示,由此 fork()之后创建的子进程也自然拷贝了缓冲区的数据,当它们调用 exit()函数时,都会刷新各自的缓冲区、显示字符串,所以就会看到打印出了两次相同的字符串
解决方法:
- 对于行缓冲设备,可以加上对应换行符,譬如 printf 打印输出字符串时在字符串后面添加\n 换行符,对于 puts()函数来说,本身会自动添加换行符
- 在调用 fork()之前,使用函数 fflush()来刷新 stdio 缓冲区,当然,作为另一种选择,也可以使用setvbuf()和 setbuf()来关闭 stdio 流的缓冲功能
- 子进程调用_exit()退出进程、而非使用 exit(),调用_exit()在退出时便不会刷新 stdio 缓冲区,这也解释前面为什么要在子进程中使用_exit()退出这样做的一个原因
6.2.3 异常终止
异常终止也有多种不同的方式,譬如在程序当中调用 abort()函数异常终止进程、当进程接收到某些 信号导致异常终止等
7、监视子进程
在很多应用程序的设计中,父进程需要知道子进程于何时被终止,并且需要知道子进程的终止状态信息,是正常终止、还是异常终止亦或者被信号终止等,意味着父进程会对子进程进行监视
7.1 wait函数
系统调用 wait()可以等待进程的任一子进程终止,同时获取子进程的终止状态信息
wait()函数的作用除了获取子进程的终止状态信息之外,更重要的一点,就是回收子进程的一些资源,俗称为子进程“收尸”
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
/*
* status:参数 status 用于存放子进程终止时的状态信息,参数 status 可以为 NULL,表示不接收子进程终止时的状态信息
* - WIFEXITED(status):如果子进程正常终止,则返回 true;
* - WIFSIGNALED(status):如果子进程被信号终止,则返回 true;
* - WTERMSIG(status):返回导致子进程终止的信号编号。如果子进程是被信号所终止,则可以通过此宏获取终止子进程
* - WCOREDUMP(status):如果子进程终止时产生了核心转储文件,则返回 true;的信号;
* 返回值:若成功则返回终止的子进程对应的进程号;失败则返回-1
*/
wait函数的执行动作:
- 如果进程调用 wait(),如果其所有子进程都在运行,则会一直阻塞等待,直到某一个子进程终止
- 如果进程调用 wait(),但是该进程并没有子进程,也就意味着该进程并没有需要等待的子进程,那么 wait()将返回错误,并且会将 errno 设置为 ECHILD
- 如果进程调用 wait()之前,它的子进程当中已经有一个或多个子进程已经终止了,那么调用 wait()也不会阻塞。意味着正等待着父进程为其“收尸”,所以调用 wait()将不会阻塞,而是会立即替该子进程“收尸”、处理它的“后事”,然后返回到正常的程序流程中,一次 wait()调用只能处理一次。
7.2 waitpid函数
使用 wait()系统调用存在着一些限制
- 如果父进程创建了多个子进程,使用 wait()将无法等待某个特定的子进程的完成,只能按照顺序等待下一个子进程的终止,一个一个来、谁先终止就先处理谁
- 如果子进程没有终止,正在运行,那么 wait()总是保持阻塞,有时我们希望执行非阻塞等待,是否有子进程终止,通过判断即可得知
- 使用 wait()只能发现那些被终止的子进程,对于子进程因某个信号(譬如 SIGSTOP 信号)而停止(注意,这里停止指的暂停运行),或是已停止的子进程收到 SIGCONT 信号后恢复执行的情况就无能为力了
waitpid()则可以突破这些限制
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
/*
* pid:参数 pid 用于表示需要等待的某个具体子进程
* - 如果 pid 大于 0,表示等待进程号为 pid 的子进程;
* - 如果 pid 等于 0,则等待与调用进程(父进程)同一个进程组的所有子进程;
* - 如果 pid 小于-1,则会等待进程组标识符与 pid 绝对值相等的所有子进程;
* - 如果 pid 等于-1,则等待任意子进程。wait(&status)与 waitpid(-1, &status, 0)等价。
* status:与 wait()函数的 status 参数意义相同
* options:参数 options 是一个位掩码,可以包括 0 个或多个如下标志
* - WNOHANG:如果pid指定的子进程没有结束,则waitpid()函数立即返回0,而不是阻塞在这个函数上等待;如果结束了,则返回该子进程的进程号。
* - WUNTRACED:除了返回终止的子进程的状态信息外,还返回因信号而停止(暂停运行)的子进程状态信息;
* - WCONTINUED:返回那些因收到 SIGCONT 信号而恢复运行的子进程的状态信息。
7.3 僵尸进程与孤儿进程
当一个进程创建子进程之后,父进程与子进程的生命周期往往是不相同的,这里就会出现两个问题
- 父进程先于子进程结束。
- 子进程先于父进程结束。
孤儿进程
父进程先于子进程结束
,在 Linux 系统当中,所有的孤儿进程通常都自动成为 init 进程(进程号为 1)的子进程(在使用upstart作为init system的系统上,或者在某些配置中使用systemd的系统上情况不同)
僵尸进程
进程结束之后,通常需要其父进程为其“收尸”,回收子进程占用的一些内存资源,父进程通过调用wait()函数回收子进程资源,归还给系统。
如果子进程先于父进程结束,此时父进程还未来得及给子进程“收尸”
,那么此时子进程就变成了一个僵尸进程。
- 如果父进程调用 wait()为子进程“收尸”后,僵尸进程就会被内核彻底删除。
- 如果父进程并没有调用 wait()函数然后就退出了,那么此时 init 进程将会接管它的子进程并自动调用 wait(),故而从系统中移除僵尸进程。
注意:僵尸进程是无法通过信号将其杀死的,即使是“一击必杀”信号 SIGKILL 也无法将其杀死,那么这种情况下,只能杀死僵尸进程的父进程(或等待其父进程终止),这样 init 进程将会接管这些僵尸进程,从而将它们从系统中清理掉!所以,在程序设计中,一定要监视子进程的状态变化,如果子进程终止了,要及时调用 wait()将其回收,避免僵尸进程。(如果不处理系统中存在大量的僵尸进程,可能会填满内核进程表,从而阻碍新进程的创建)
进程113456就是僵尸进程,状态为“Z”(zombie,僵尸),表示它是一个僵尸进程。僵尸进程无法被信号杀死,,要么等待其父进程终止、要么杀死其父进程,让 init 进程来处理。
处理僵尸进程
当发生以下两种情况时,父进程会收到SIGCHLD:
- 当父进程的某个子进程终止时,父进程会收到 SIGCHLD 信号;
- 当父进程的某个子进程因收到信号而停止(暂停运行)或恢复时,内核也可能向父进程发送该信号
子进程的终止属于异步事件,父进程事先是无法预知的,如果父进程有自己需要做的事情,它不能一直wait()阻塞等待子进程终止(或轮训),这样父进程将啥事也做不了,SIGCHLD 信号可以解决这种情况
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
void signale_handler(int signo){
pid_t pid;
int stat;
pid = wait(&stat);
printf("子进程 %d terminated.\n", pid);
}
void sigchld_zombie(void)
{
pid_t pid;
struct sigaction sa;
sa.sa_handler = signale_handler;
sa.sa_flags = SA_NOCLDSTOP; // 使父进程在它的子进程暂停或继续运行时不会收到 SIGCHLD 信号
sigemptyset(&sa.sa_mask);
if (sigaction(SIGCHLD, &sa, NULL) == -1) // 捕获子进程终止
{
perror("sigaction");
}
int n = 3;
while (n--) // 连续有三个子进程退出
{
pid = fork();
if (pid == -1)
{
printf("fork error.\n");
exit(1);
}
else if (pid == 0) //子进程
{
printf("子进程:pid = %d.\n子进程退出..\n", getpid());
exit(0);
}
}
sleep(5);
printf("父进程退出..\n");
return;
}
int main()
{
sigchld_zombie();
return 0;
}
但是这里只处理了一个僵尸进程
因为当调用信号处理函数时,会暂时将引发调用的信号添加到进程的信号掩码中(除非 sigaction()指定了 SA_NODEFER 标志),这样一来,当 SIGCHLD 信号处理函数正在为一个终止的子进程“收尸”时,如果相继有两个子进程终止,即使产生了两次 SIGCHLD 信号,父进程也只能捕获到一次 SIGCHLD 信号,可以在信号处理函数中循环以非阻塞方式来调用 waitpid(),直至再无其它终止的子进程需要处理
void signale_handler(int signo)
{
pid_t pid;
int stat;
//处理僵尸进程,以非阻塞方式来调用 waitpid() ,避免在信号处理过程中,有其他子进程终止而被忽略
// WNOHANG:如果pid指定的子进程没有结束,则waitpid()函数立即返回0,而不是阻塞在这个函数上等待;如果结束了,则返回该子进程的进程号。
while ((pid = waitpid(-1, &stat, WNOHANG)) > 0)
{
printf("子进程 %d terminated.\n", pid);
}
}
8、exec函数族
当子进程的工作不再是运行父进程的代码段,而是运行另一个新程序的代码,那么这个时候子进程可以通过 exec 函数来实现运行另一个新的程序
8.1 execve函数
系统调用 execve()可以将新程序加载到某一进程的内存空间,通过调用 execve()函数将一个外部的可执行文件加载到进程的内存空间运行,使用新的程序替换旧的程序,而进程的栈、数据、以及堆数据会被新程序的相应部件所替换
,然后从新程序的 main()函数开始执行,进程ID并不改变,称调用exec的进程为调用进程(calling process),称新执行的程序为新程序(new program)。
#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp[]);
/*
* filename:参数 filename 指向需要载入当前进程空间的新程序的路径名
* argv:参数 argv 则指定了传递给新程序的命令行参数。是一个字符串数组,该数组对应于 main(int argc, char *argv[])函数的第二个参数 argv,以NULL结尾
* envp:参数 envp 也是一个字符串指针数组,指定了新程序的环境变量列表,参数 envp 其实对应于新程序的 environ 数组,以NULL结尾
* 返回值:execve 调用成功将不会返回;失败将返回-1,并设置 errno
*/
test1.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
char *arg_arr[5];
char *env_arr[5] = {"NAME=app", "AGE=22",
"SEX=man", NULL};
if (2 > argc)
exit(-1);
arg_arr[0] = argv[1];
arg_arr[1] = "Hello";
arg_arr[2] = "World";
arg_arr[3] = NULL;
execve(argv[1], arg_arr, env_arr);
perror("execve error");
exit(-1);
}
test2.c
#include <stdio.h>
#include <stdlib.h>
extern char **environ;
int main(int argc, char *argv[])
{
for (int j = 0; j < argc; j++)
printf("argv[%d]: %s\n", j, argv[j]);
puts("env:");
for (char **ep = environ; *ep != NULL; ep++)
printf(" %s\n", *ep);
exit(0);
}
运行:
8.2 exec 库函数
exec 族函数包括多个不同的函数,这些函数命名都以 exec 为前缀, 下面的库函数都是基于系统调用 execve()而实现的
#include <unistd.h>
extern char **environ;
int execl(const char *path, const char *arg, ... /* (char *) NULL */);
int execlp(const char *file, const char *arg, ... /* (char *) NULL */);
int execle(const char *path, const char *arg, ... /*, (char *) NULL, char * const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
区别:
- execle()和 execvpe()这两个函数在命名上加了一个 e,这个 e 其实表示的是 environment 环境变量,意味着这两个函数可以指定自定义的环境变量列表给新程序
- execl()和 execv()不同的在于第二个参数,execv()的argv 参数与 execve()的 argv 参数相同,也是字符串指针数组;而 execl()把参数列表依次排列,使用可变参数形式传递,本质上也是多个字符串,以 NULL 结尾
// execv 传参
char *arg_arr[5];
arg_arr[0] = "./newApp";
arg_arr[1] = "Hello";
arg_arr[2] = "World";
arg_arr[3] = NULL;
execv("./newApp", arg_arr);
// execl 传参
execl("./newApp", "./newApp", "Hello", "World", NULL);
- execlp()和 execvp()在 execl()和 execv()基础上加了一个 p,这个
p 表示 PATH
,允许只提供新程序文件名,系统会在由环境变量 PATH 所指定的目录列表中寻找相应的可执行文件,如果执行的新程序是一个 Linux 命令,这将很有用。同时也兼容相对路径和绝对路径的方式
使用以上6 个 exec 库函数运行 ls 命令,并加入参数-a 和-l:
1、execl
execl("/bin/ls", "ls", "-a", "-l", NULL);
2、execv
char *arg_arr[5] = {"ls","-a","-l",NULL};
execv("/bin/ls", arg_arr);
3、execlp
execlp("ls", "ls", "-a", "-l", NULL);
4、execvp
char *arg_arr[5] = {"ls","-a","-l",NULL};
execvp("ls", arg_arr);
5、execle
extern char **environ;
execle("/bin/ls", "ls", "-a", "-l", NULL, environ);
6、execvpe
extern char **environ;
char *arg_arr[5] = {"ls","-a","-l",NULL};
execvpe("ls", arg_arr, environ);
8.3 system函数
使用 system()库函数可以很方便地在程序当中执行任意 shell 命令
#include <stdlib.h>
int system(const char *command);
/*
* command:参数 command 指向需要执行的 shell 命令,以字符串的形式提供,譬如"ls -al"、"echo HelloWorld"等
* 返回值:
* -当参数 command 为 NULL,如果 shell 可用则返回一个非 0 值,若不可用则返回 0;针对一些非UNIX 系统,该系统上可能是没有 shell 的,这样就会导致 shell 不可能;如果 command 参数不为NULL,则返回值从以下的各种情况所决定。
* -如果无法创建子进程或无法获取子进程的终止状态,那么 system()返回-1;
* -如果子进程不能执行 shell,则 system()的返回值就好像是子进程通过调用_exit(127)终止了;
* -如果所有的系统调用都成功,system()函数会返回执行 command 的 shell 进程的终止状态
system()的主要优点在于使用上方便简单,编程时无需自己处理对 fork()、exec 函数、waitpid()以及 exit() 等调用细节
虽然简单,但是效率有所降低,system()运行 shell命令需要至少创建两个进程,一个进程用于运行 shell、另外一个或多个进程则用于运行参数 command 中解析出来的命令,每一个命令都会调用一次 exec 函数来执行
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
int ret;
if (2 > argc)
exit(-1);
ret = system(argv[1]);
if (-1 == ret)
fputs("system error.\n", stderr);
else{
if (WIFEXITED(ret) && (127 == WEXITSTATUS(ret)))
fputs("could not invoke shell.\n", stderr);
}
exit(0);
}
8.4 系统调用 vfork
Linux 系统还提供了 vfork()系统调用用于创建子进程,在一些细节上存在区别
#include <sys/types.h>
#include <unistd.h>
pid_t vfork(void);
fork会对父进程的数据段、堆段、栈段以及其它一些数据结构创建拷贝,如果子进程使用exec函数执行其他新程序,此时就会导致浪费时间、效率降低。,现代 Linux 系统采用了一些技术来避免这种浪费,其中很重要的一点就是内核采用了写时复制(copy-on-write)技术
出于这一原因,引入了 vfork()系统调用,虽然在一些细节上有所不同,但其效率要高于 fork()函数。vfork()是为子进程立即执行 exec()新的程序而专门设计的
vfork()与 fork()函数主要有以下两个区别:
- vfork()与 fork()一样都创建了子进程,但 vfork()函数并不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用 exec(或_exit),于是也就不会引用该地址空间的数据。不过在子进程调用 exec 或_exit 之前,它在父进程的空间中运行、子进程共享父进程的内存。这种优化工作方式的实现提高的效率;但如果子进程修改了父进程的数据(除了 vfork 返回值的变量)、进行了函数调用、或者没有调用 exec 或_exit 就返回将可能带来未知的结果
- 另一个区别在于,vfork()保证子进程先运行,子进程调用 exec 之后父进程才能被调度运行
9、进程状态和进程关系
9.1 进程状态
Linux 系统下进程通常存在 6 种不同的状态
就绪态
(Ready):指该进程满足被 CPU 调度的所有条件但此时并没有被调度执行,只要得到 CPU 就能够直接运行;当一个进程的时间片到达,操作系统调度程序会从就绪态链表中调度一个进程;运行态
:指该进程当前正在被 CPU 调度运行,处于就绪态的进程得到 CPU 调度就会进入运行态;僵尸态
:僵尸态进程其实指的就是僵尸进程,指该进程已经结束、但其父进程还未给它“收尸”;等待态
:-
- 可中断睡眠状态:可中断睡眠也称为浅度睡眠,表示睡的不够“死”,还可以被唤醒,一般来说可以通过信号来唤醒;
-
- 不可中断睡眠状态:不可中断睡眠称为深度睡眠,深度睡眠无法被信号唤醒,只能等待相应的条件成立才能结束睡眠状态。把浅度睡眠和深度睡眠统称为等待态(或者叫阻塞态),表示进程处于一种等待状态,等待某种条件成立之后便会进入到就绪态;
暂停态
:暂停并不是进程的终止,表示进程暂停运行,一般可通过信号将进程暂停,譬如 SIGSTOP信号;处于暂停态的进程是可以恢复进入到就绪态的,譬如收到 SIGCONT 信号
9.2 进程关系
包括:无关系(相互独立)、父子进程关系、进程组以及会话
1、父子进程关系
两个进程间构成父子进程关系,譬如一个进程 fork()创建出了另一个进程,那么这两个进程间就构成了 父子进程关系,调用 fork()的进程称为父进程、而被 fork()创建出来的进程称为子进程
2、进程组
每个进程除了有一个进程 ID、父进程 ID 之外,还有一个进程组 ID,用于标识该进程属于哪一个进程组,进程组是一个或多个进程的集合
进程组实质上是为了方便对进程进行管理。假设为了完成一个任务,需要并发运行 100个进程,若没有进程组就需要一个一个去终止,这样非常麻烦
- 每个进程必定属于某一个进程组、且只能属于一个进程组;
- 每一个进程组有一个组长进程,组长进程的 ID 就等于进程组 ID;
- 在组长进程的 ID 前面加上一个负号即是操作进程组;
- 组长进程不能再创建新的进程组;
- 只要进程组中还存在一个进程,则该进程组就存在,这与其组长进程是否终止无关;
- 一个进程组可以包含一个或多个进程,进程组的生命周期从被创建开始,到其内所有进程终止或离开该进程组;
- 默认情况下,新创建的进程会继承父进程的进程组 ID。
通过系统调用 getpgrp()或 getpgid()可以获取进程对应的进程组 ID
#include <unistd.h>
pid_t getpgid(pid_t pid); // 指定获取对应进程的进程组 ID
pid_t getpgrp(void); // 调用者进程对应的进程组
调用系统调用 setpgid()或 setpgrp()可以加入一个现有的进程组或创建一个新的进程组
#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);
int setpgrp(void);
/*
* setpgid:将参数 pid 指定的进程的进程组 ID 设置为参数 gpid。如果这两个参数相等(pid==gpid),则由 pid 指定的进程变成为进程组的组长进程
* 如果参数 pid 等于 0,则使用调用者的进程 ID
* 如果参数 gpid 等于 0,则创建一个新的进程组,由参数 pid 指定的进程作为进程组组长进程
* setpgrp()函数等价于 setpgid(0, 0)
*/
3、会话
会话是一个或多个进程组的集合
一般一个用户登录后新建一个会话,每个会话有一个ID来标识(SID)。登录后的第一个进程叫做会话首领进程(session leader),通常是一个shell/bash。对于会话首领进程,其PID=SID。
登录流程:
- 通过终端用户登录后,会创建一个会话
- 启动第一个进程,即shell进程,也就是这个会话的首领进程,也是会话的控制进程。
- 在终端没有输入命令(执行程序)的时候,shell暂时处于前台进程组。
- 当有命令输入时,shell进程将会被置为后台进程组,然后将执行的命令的进程置为前台进程组。
- 当终端再次没有输入时(没有通过运行程序时),shell再被置为前台进程组。
注意:
- 每个进程组必定属于某一个会话,并且只能在一个会话中。
- 一个会话可包含一个或多个进程组,但只能有一个前台进程组(拥有终端控制权限),其它的是后台进程组
- 每个会话都有一个会话首领(leader),即创建会话的进程
- 一个会话最多只有一个控制终端(也可以没有),这通常是登录到其上的终端设备(在终端登录情况下)或伪终端设备(譬如通过 SSH 协议网络登录)
可以通过系统调用 getsid()获取进程的会话 ID
#include <unistd.h>
pid_t getsid(pid_t pid); // 如果参数 pid 为 0,则返回调用者进程的会话 ID;如果参数 pid不为 0,则返回参数 pid 指定的进程对应的会话 ID
使用系统调用 setsid()可以创建一个会话
#include <unistd.h>
pid_t setsid(void);
注意:
不能使用组长进程来创建新会话
进程创建新会话后,SID=PID=PGID
调用 setsid()创建的会话将没有控制终端
10、守护进程
守护进程:是运行在后台
的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生
特点:
- 长期运行:守护进程是一种生存期很长的一种进程,它们一般在系统启动时开始运行,除非强行终止,否则直到系统关机都会保持运行。与守护进程相比,普通进程都是在用户登录或运行程序时创建,在运行结束或用户注销时终止,但守护进程不受用户登录注销的影响,它们将会一直运行着、直到系统关机。
- 与控制终端脱离:当控制终端被关闭的时候,该会话就会退出,由控制终端运行的所有进程都会被终止,这使得普通进程都是和运行该进程的终端相绑定的;但守护进程能突破这种限制,它脱离终端并且在后台运行,脱离终端的目的是为了避免进程在运行的过程中的信息在终端显示并且进程也不会被任何终端所产生的信息所打断。
TTY 一栏是问号?表示该进程没有控制终端,也就是守护进程,其中 COMMAND 一栏使用中括号[]括 起来的表示内核线程,这些线程是在内核里创建,没有用户空间代码,因此没有程序文件名和命令行,通常 采用 k 开头的名字,表示 Kernel。
10.1 编写守护进程
步骤:
- 创建子进程、终止父进程(确保子进程不是一个进程组的组长进程)
- 子进程调用 setsid 创建会话。子进程成为新会话的首领进程,同样也创建了新的进程组、子进程成为组长进程,此时创建的会话将没有控制终端(让子进程摆脱原会话、原进程组、原控制终端的控制)
- 将工作目录更改为根目录。子进程是继承了父进程的当前工作目录,由于在进程运行中,当前目录所在的文件系统是不能卸载的,这对以后使用会造成很多的麻烦。因此通常的做法是让“/”作为守护进程的当前目录
- 重设文件权限掩码 umask。由于使用 fork 函数新建的子进程继承了父进程的文件权限掩码,把文件权限掩码设置为 0,确保子进程有最大操作权限、这样可以大大增强该守护进程的灵活性
- 关闭不再需要的文件描述符。子进程继承了父进程的所有文件描述符,这些被打开的文件可能永远不会被守护进程读或写,但它们一样消耗系统资源,可能导致所在的文件系统无法卸载
- 将文件描述符号为 0、1、2 定位到/dev/null。将守护进程的标准输入、标准输出以及标准错误重定向到/dev/null,这使得守护进程的输出无处显示、也无处从交互式用户那里接收输入
- 忽略 SIGCHLD 信号(非必须)。对于比如并发服务器进程往往是特别重要的,服务器进程在接收到客户端请求时会创建子进程去处理该请求,如果子进程结束之后,父进程没有去 wait 回收子进程,则子进程将成为僵尸进程;如果父进程 wait 等待子进程退出,将又会增加父进程的负担、也就是增加服务器的负担,影响服务器进程的并发性能。(将 SIGCHLD 信号的处理方式设置为SIG_IGN,也就是忽略该信号,可让内核将僵尸进程转交给 init 进程去处理)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
int main(int argc, char const *argv[])
{
pid_t pid;
int i;
/* 创建子进程 */
pid = fork();
if (0 > pid)
{
perror("fork error");
exit(-1);
}
//父进程,直接退出
else if (0 < pid)
exit(0);
// 子进程
// 1.创建新的会话、脱离控制终端
if (0 > setsid())
{
perror("setsid error");
exit(-1);
}
// 2.设置当前工作目录为根目录
if (0 > chdir("/"))
{
perror("chdir error");
exit(-1);
}
// 3.重设文件权限掩码 umask
umask(0);
// 4.关闭所有文件描述符
for (i = 0; i < sysconf(_SC_OPEN_MAX); i++) // sysconf(_SC_OPEN_MAX)用于获取当前系统允许进程打开的最大文件数量
close(i);
// 5.将文件描述符号为 0、1、2 定位到/dev/null
open("/dev/null", O_RDWR);
dup(0);
dup(0);
// 6.忽略 SIGCHLD 信号
signal(SIGCHLD, SIG_IGN);
/* 正式进入到守护进程 */
for (;;)
{
sleep(1);
puts("守护进程运行中......");
}
exit(0);
}
运行之后,没有任何打印信息输出,原因在于守护进程已经脱离了控制终端,它的打印信息并不会输出 显示到终端,在代码中已经将标准输入、输出以及错误重定位到了/dev/null,/dev/null 是一个黑洞文件,自然看不到输出息
守护进程可以通过终端命令行启动,但通常它们是由系统初始化脚本进行启动,譬如/etc/rc*或/etc/init.d/*等。
10.2 SIGHUP 信号
当用户准备退出会话时,系统向该会话发出 SIGHUP 信号,会话将 SIGHUP 信号发送给所有子进程,子进程接收到 SIGHUP 信号后,便会自动终止,当所有会话中的所有进程都退出时,会话也就终止了;因为程序当中一般不会对 SIGHUP 信号进行处理,所以对应的处理方式为系统默认方式,SIGHUP 信号的系统默认处理方式便是终止进程。
如果让会话的进程忽略 SIGHUP 信号,然后结束会话?
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
int main(int argc, char const *argv[])
{
signal(SIGHUP, SIG_IGN); // 忽略 SIGHUP 信号
while (1){
sleep(1);
puts("进程运行中......");
}
exit(0);
}
进程依然还在运行,但此时它已经变成了守护进程,脱离了控制终端,事实上,控制终端只是会话中的一个进程,只有会话中的所有进程退出后,会话才会结束;很显然当程序中忽略了 SIGHUP 信号,导致该进程不会终止,所以会话也依然会存在,从上图可知,其会话 ID 等于 2659,但此时会话已经没有控制终端了
10.3 单例模式
守护进程一般都是服务器进程,服务器程序只需要运行一次即可,能够在系统整个的运行过程中提供相应的服务支持,多次同时运行并没有意义、甚至还会带来错误,这种运行模式是单例模式。
单例模式简单实现:
用一个文件的存在与否来做标志,在程序运行正式代码之前,先判断一个特定的文件是否存在,如果存在则表明进程已经运行,此时应该立马退出;如果不存在则表明进程没有运行,然后创建该文件,当程序结束时通过终止函数删除该文件即可!
存在问题:
- 如果程序中使用_exit()退出,那么将无法执行终止函数,意味着无法删除这个特定的文件;
- 程序异常退出。程序异常同样无法执行到进程终止处理函数,同样将导致无法删除这个特定的文件;
- 计算机掉电关机。这种情况就更加直接了,计算机可能在程序运行到任意位置时发生掉电关机的情况,这是无法预料的;如果文件没有删除就发生了这种情况,计算机重启之后文件依然存在,导致程序无法执行。(可以把文件放置到系统/tmp 目录下,/tmp 是一个临时文件系统,当系统重启之后/tmp 目录下的文件就会被销毁)
使用文件锁
也通过一个特定的文件来实现,当程序启动之后,首先打开该文件,当文件不存在则创建该文件,然后尝试去获取文件锁,若是成功,则将程序的进程号(PID)写入到该文件中,写入后不要关闭文件或解锁(释放文件锁),保证进程一直持有该文件锁
;若是程序获取锁失败,代表程序已经被运行、则退出本次启动。(当程序退出或文件关闭之后,文件锁会自动解锁)
#include <stdio.h>
#include <stdlib.h>
#include <sys/file.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <string.h>
#define LOCK_FILE "./testApp.pid"
int main(int argc, char const *argv[])
{
char str[20] = {0};
int fd;
/* 打开 lock 文件,如果文件不存在则创建 */
fd = open(LOCK_FILE, O_WRONLY | O_CREAT, 0666);
if (-1 == fd)
{
perror("open error");
exit(-1);
}
/* 以非阻塞方式获取文件锁 */
if (-1 == flock(fd, LOCK_EX | LOCK_NB)) // LOCK_NB,意味着同时只能有一个进程拥有该锁
{
fputs("不能重复执行该程序!\n", stderr);
close(fd);
exit(-1);
}
puts("程序运行中...");
ftruncate(fd, 0); //将文件长度截断为 0
sprintf(str, "%d\n", getpid());
write(fd, str, strlen(str)); //写入 pid
for (;;)
sleep(1);
exit(0);
}
在 Linux 系统中/var/run/目录下有很多以.pid 为后缀结尾的文件,这个实际上是为了保证程序以单例模 式运行而设计的,作为程序实现单例模式运行所需的特定文件
11、进程地址
对于 C/C++
来说,程序中的内存包括这几部分:栈区
、堆区
、静态区
等,其中各个部分功能都不相同,比如函数的栈帧位于 栈区
,动态申请的空间位于 堆区
,全局变量和常量位于 静态区
,区域划分的意义是为了更好的使用和管理空间
虚拟地址
创建子进程,使得子进程和父进程共同使用一个变量
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
int val = 10;
pid_t id = fork();
if (id == 0){
val *= 2; //刻意改变共享值
printf("子进程,pid:%d ppid:%d 共享值:%d 共享值地址:%p\n", getpid(), getppid(), val, &val);
exit(0);
}
waitpid(id, 0, 0);
printf("父进程,pid:%d ppid:%d 共享值:%d 共享值地址:%p\n", getpid(), getppid(), val, &val);
return 0;
}
同一个地址,出现了不同的值
语言层面的地址都是虚拟地址
,用户无法看到真实的物理地址,由 OS 统一管理
原因:
- 对于同一个变量,如果未改写,则两者的虚拟地址通过 页表 + MMU 转换后指向同一块空间
- 发生改写行为,此时会在真实空间中再开辟一块空间,拷贝变量值,让其中一个进程的虚拟地址空间映射改变,这种行为称为 写时拷贝
进程地址空间
在早期程序中,是没有虚拟地址空间的,对于数据的写入和读取,是直接在物理地址上进行的,存在如下问题:
- 假设存在野指针问题,此时可能直接对物理内存造成越界读写
- 程序运行时,每次都需要大小为 4GB 的内存使用,当进程过多时,资源分配就会很紧张,引起进程阻塞,导致执行效率下降
- 动态申请内存后,需要依次释放,影响整体效率
为了解决各种问题,提出了虚拟地址
空间这个概念,有了虚拟空间后,当进程创建时,系统会为其分配属于自己的虚拟空间,需要使用内存时,通过寻址的方式(如Linux中的页表+MMU),使用物理地址上的空间即可
页表+MMU
页表本质上就是一张表,操作系统会为每个进程 分配一个页表,该页表使用物理地址存储。当进程使用类似 malloc 等需要映射代码或数据的操作时,操作系统 会在随后马上修改页表以加入新的物理内存。当进程完成退出时,内核会将相关的页表项删除掉,以便分配给新的进程(简言之:页表记录信息,通过MMU机制进行寻址使用内存)
值得注意的是,在进行动态内存申请时,OS 也并非直接去申请好内存,而是先判断是否有足够的内存,如果有,就在 页表 中记录相应信息(这种行为叫做 缺页中断),当程序实际使用到这块空间时,OS 才会去申请内存给程序使用。OS不允许任何空间浪费或低效率行为,假设没有 缺页中断 机制,给程序分配空间后,程序又不用,此时空间属于闲置状态,这是不被 OS 认可的低效浪费行为
采用虚拟地址的好处:
- 进程与进程、进程与内核相互隔离。一个进程不能读取或修改另一个进程或内核的内存数据,这是因为每一个进程的虚拟地址空间映射到了不同的物理地址空间。提高了系统的安全性与稳定性
- 在某些应用场合下,两个或者更多进程能够共享内存。因为每个进程都有自己的映射表,可以让不同进程的虚拟地址空间映射到相同的物理地址空间中。通常,共享内存可用于实现进程间通信
- 便于实现内存保护机制。譬如在多个进程共享内存时,允许每个进程对内存采取不同的保护措施,例如,一个进程可能以只读方式访问内存,而另一进程则能够以可读可写的方式访问
- 编译应用程序时,无需关心链接地址