9. 进程
- 1. 进程与程序
- 1.1 main() 函数由谁调用
- 1.2 程序如何结束
- 1.2.1 注册进程终止处理函数 atexit()
- 1.3 何为进程
- 1.4 进程号
- 2. 进程的环境变量
- 2.1 应用程序中获取环境变量
- 2.1.1 获取指定环境变量
- 2.2 添加/删除/修改环境变量
- 2.2.1 putenv()
- 2.2.2 setenv()
- 2.2.3 命令行式添加
- 2.2.4 unsetenv()
- 2.3 清空环境变量
- 2.4 环境变量的作用
- 3. 进程的内存布局
- 4. 进程的虚拟地址空间
- 4.1 为什么要引入虚拟地址
- 4.2 虚拟地址的优点
- 5. fork() 创建子进程
- 6. 父子进程间的文件共享
- 7. 系统调用 vfork()
- 8. fork() 之后的竞争条件
- 9. 进程的诞生与终止
- 9.1 进程的诞生
- 9.2 进程的终止
- 10. 监视子进程
- 10.1 wait()
- 10.2 waitpid()
- 10.3 僵尸进程与孤儿进程
- 10.3.1 孤儿进程
- 10.3.2 僵尸进程
- 10.4 SIGCHLD 信号
- 11. 执行新程序
- 11.1 execve()
- 11.2 exec 库函数
- 11.3 exec 族函数使用
- 11.4 system()
- 12. 进程状态与进程关系
- 12.1 进程状态
- 12.2 进程关系
- 13. 守护进程 Daemon
- 13.1 何为守护进程
- 13.2 编写守护进程程序
- 13.3 SIGHUP 信号
- 14. 单例模式运行
- 14.1 通过文件存在与否进行判断
- 14.2 使用文件锁
1. 进程与程序
1.1 main() 函数由谁调用
事实上,操作系统下的应用程序在运行主函数之前需要执行一段引导代码,最终由这一段引导代码去调用主函数。在编译链接时,由链接器将引导代码链接到主函数中,一起构成可执行文件。程序运行需要通过操作系统的加载器来实现,加载器时操作系统中的程序,当执行程序时,加载器负责将此应用程序加载到内存中去执行。而传参是在命令行中,命令行参数由 shell 进程逐一进行解析,将这些参数传递给加载器,加载器加载应用程序时会将其传递给应用程序引导代码,当引导程序调用主函数时,最终传递给 main() 函数
1.2 程序如何结束
程序结束就是进程终止,可以分为正常终止和异常终止,正常的有 return、exit 和 _exit;异常终止可以是调用 abort() 或者进程收到一个信号
1.2.1 注册进程终止处理函数 atexit()
用于注册一个进程在正常终止时调用的函数
#include <stdlib.h>
int atexit(void (*function)(void));
// 参数是函数指针,指向注册的函数
// 调用成功返回0,失败返回-1
static void func()
{cout << "nihao"<<endl;
}
int main()
{exit(0);
}
此代码运行后,会打印出 nihao,但是如果程序中使用了 _exit() 或 _Exit() 终止,而非是 exit(),那么将不会执行注册的终止处理函数。
1.3 何为进程
进程就是一个可执行程序的实例。可执行程序就是一个可执行文件,文件存放在磁盘中,如果可执行文件没有被执行,就不会产生作用,当它被运行后,就会有影响。而进程是一个动态的概念,它是程序的一次运行过程,当应用程序被加载到内存中运行后,就被称为了一个进程,程序运行结束后,进程就会终止,这就是一个进程的生命周期
1.4 进程号
每一个进程都对应一个进程号,也就是 PID,用于唯一标识一个进程。使用ps -aux
可以查看系统中的进程相关信息,包括进程号。通过进程号,可以使用kill -9 进程号
就可以杀掉对应进程。还可以通过系统调用获取自己的进程号,进程号就是一个正数
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(); // 获取当前进程的进程号
pid_t getppid(); // 获取当前进程的父进程的进程号
2. 进程的环境变量
每一个进程都有一组与其相关的环境变量,这些环境变量以字符串的形式存储在一个字符串数组列表中,把这个数组称为环境列表。而环境变量都是以 名称=值 形式定义,所以环境变量是 名称-值 的成对集合。使用env
可以看到 shell 进程的所有环境变量。使用export DEF=111
可以添加一个新的环境变量,而使用export -n DEF
可以删除环境变量。
2.1 应用程序中获取环境变量
在应用程序中也可以获取当前进程的环境变量,事实上,进程的环境变量是从其父进程继承来的,新的进程在创建之前,会继承其父进程的环境变量副本。
环境变量存放在一个字符串数组中,在应用程序中,通过 environ 变量指向它,是一个全局变量,在程序中只需要声明就可以extern char **environ
。
// 获取进程所有环境变量
extern char **environ;
int main()
{int i=0;for(i=0;NULL!=environ[i];i++){cout << environ[i]<<endl;}
}
2.1.1 获取指定环境变量
#include <stdlib.h>
char *getenv(const char *name);
// 参数是获取的环境变量名
// 如果存在,就返回对应的字符串的指针;如果不存在就返回NULL
使用该函数,不应该去修改返回的字符串
2.2 添加/删除/修改环境变量
2.2.1 putenv()
向进程的环境变量数组中添加一个新的环境变量,或者修改一个已经存在的环境变量的值
#include <stdlib.h>
int putenv(char *string);
// string是一个字符串指针,指向name=value形式的字符串,成功返回0,失败返回非零
函数调用成功之后,string 指向的字符串就成为了进程环境变量的一部分,就是说,environ 中的某个元素就指向了该字符串,而不是副本,所以不可随意修改
2.2.2 setenv()
用于向进程的环境变量列表中添加一个新的环境变量或修改现有环境变量对应的值
#include <stdlib.h>
int setenv(const char *name, const char *value, int overwrite);
// name表示需要添加或修改的环境变量名
// value表示环境变量的值
// overwrite:若参数name标识的环境变量已经存在,如果此参数为0,那么不会修改现有环境变量的值;如果非0,并且存在,就覆盖,不存在表示添加新的环境变量
这个函数会为形如 name=value 的字符串分配一块内存空间,并将 name 和 value 所指向的字符串复制到此缓冲区中,以此来创建一个新的环境变量。
2.2.3 命令行式添加
在运行程序时name=value ./app
,在路径前添加 name=value 形式,如果是多个就以空格分隔
2.2.4 unsetenv()
从环境变量表中移除 name 标识的
#include <stdlib.h>
int unsetenv(const char *name);
2.3 清空环境变量
可以通过 environ=NULL
来清空所有环境变量,也可以使用 clearenv()
#include <stdlib.h>
int clearenv();
使用 setenv() 和 clearenv() 函数可能会导致内存泄露,因为 setenv() 会分配一块内存缓冲区,而调用这个函数时没有释放缓冲区,就会发生内存泄漏
2.4 环境变量的作用
环境变量常见的用途之一是在 shell 中, 每一个环境变量都有它所表示的含义,譬如 HOME 环境变量表示用户的家目录, USER 环境变量表示当前用户名, SHELL 环境变量表示 shell 解析器名称, PWD 环境变量表示当前所在目录等, 在我们自己的应用程序当中,也可以使用进程的环境变量。
3. 进程的内存布局
- 正文段: 也可以称为代码段,这是 CPU 执行的机器语言指令部分,文本段具有只读属性,以防止程序由于意外而修改其指令;正文段是可以共享的,即使多个进程间也可以同时运行同一段程序
- 初始化数据段: 通常又称为数据段,包含了显示初始化的全局变量和静态变量,当程序加载到内存中时,从可执行文件中读取这些变量的值
- 未初始化数据段: 包含了未进行显示初始化的全局变量和静态变量,又称为 bss 段,意思是由符号开始的块。在程序开始执行之前,系统会将本段内所有内存初始化为 0,可执行文件并没有为 bss 段变量分配内存空间,在可执行文件中只需记录 bss 段的位置及其所需大小,直到程序运行时,由加载器来分配这一段内存空间、
- 栈: 局部变量以及每次函数调用时所需保存的信息都放在此段中,函数传递的参数以及返回值等也在。栈是一个动态增长和收缩的段,由栈帧组成。系统会为每个当前调用的函数分配一个栈帧
- 堆: 可在运行时动态分配的一块区域
可以使用size 文件名
查看二进制可执行文件的文本段、数据段、bss 段的大小
4. 进程的虚拟地址空间
4.1 为什么要引入虚拟地址
如果没有引入虚拟地址,那么所有应用程序访问的就是实际的计算机物理地址,就会出现一些问题
- 当多个程序需要运行时,必须保证这些程序用到的内存总量小于计算机实际物理内存
- 内存使用效率低。
- 进程地址空间不隔离。
- 无法确定程序的链接地址,因为将程序代码加载到内存中的地址是随机分配的,无法预知
4.2 虚拟地址的优点
通过逻辑地址映射到真正的物理内存上
- 进程与进程、进程与内核相互隔离,一个进程不能读取或修改另一个进程或内核的内存数据,因为每一个进程的虚拟地址映射到了不同的物理内存空间,提高了系统的安全性和稳定性
- 在某些应用场合下,两个或更多的进程能够共享文件。因为每个进程都有自己的映射表,可以让不同进程的虚拟地址空间映射到相同的物理内存中
- 便于实现内存保护机制
- 编译应用程序时,不需要关心链接地址。
5. fork() 创建子进程
#include <unistd.h>
pid_t fork();
调用成功后,将会在父进程中返回子进程的 PID,而子进程中的返回值是 0;如果调用失败,父进程返回 -1。父进程就是调用 fork() 函数的进程。
调用成功后,子进程和父进程会继续执行 fork() 之后的指令,子进程和父进程在各自的进程空间中运行。事实上,子进程是父进程的一个拷贝,比如数据段、堆、栈以及继承了父进程打开的文件描述符,但是,父进程与子进程不共享这些内存空间,而是各自维护自己的空间。之后,每个进程均可以修改各自的栈数据以及堆段中的变量,而不影响另一个进程。
虽然子进程是父进程的一个副本,但是对于程序代码段来说,两个进程执行相同的代码段,因为代码段是只读的,也就是说父子进程共享代码段,在内存中只有一份代码数据段
int main()
{pid_t pid = fork();if(pid==0){cout << "我是子进程: "<<getpid()<<"我的父进程是: "<<getppid()<<endl;}cout << "我是父进程: "<<getpid()<<endl;
}
6. 父子进程间的文件共享
子进程会继承父进程打开的所有文件描述符,也就意味着父子进程对应的文件描述符均指向相同的文件表,就可以实现磁盘中文件的共享,那么如果子进程更新了文件偏移量,这个改变也会影响到父进程中相应的文件偏移量。但是如果父子进程各自打开同一个文件,那么就不会出现相互影响的情况,而是会各自写入。继承就像追加写入,而打开就像覆盖写入。
7. 系统调用 vfork()
#include <sys/types.h>
#include <unistd.h>
pid_t vfork();
fork() 会将父进程进行拷贝,但是会消耗大量的时间,而且如果子进程需要进程替换函数去执行新的程序就不需要用到父进程拷贝来的。vfork() 函数是为了子进程立即执行 exec() 新的程序而专门设计的
- vfork() 并不会将父进程的地址空间完全复制到子进程中,因为子进程会立即执行 exec 或 _exit,于是也就不会引用该地址空间的数据。不过在子进程调用 exec 或 _exit 之前,它在父进程的空间中运行,子进程共享父进程的内存。但是如果子进程修改了父进程的数据、进行函数调用或者没有调用 exec 或 _exit 就返回可能会有未知的结果
- vfork() 保证子进程先运行,调用 exec 之后父进程才可能被调用运行
- 但是这个函数可能会导致一些难以察觉的问题,所以尽量少使用,除非速度绝对重要,因为现代 Linux 已经实现了写时拷贝,解决了之前的问题
8. fork() 之后的竞争条件
调用子进程与父进程的先后顺序是不确定的,不同的机器可能有不同的结果。有些程序可能有要求谁先调用,就可以让某一个进程先阻塞,等待另一个进程将它唤醒
static void sig_handler(int sig)
{cout << "接收到信号:" << sig<<endl;
}
int main()
{struct sigaction sig={0};sigset_t wait_mask;sigemptyset(&wait_mask);sig.sa_handler=sig_handler;sig.sa_flags=0;sigaction(SIGUSR1,&sig,NULL);pid_t pid=fork();if(pid==0){cout << "子进程"<<endl;cout << "------"<<endl;sleep(2);kill(getppid(),SIGUSR1);// 发送信号给父进程唤醒_exit(0);}sigsuspend(&wait_mask);// 挂起cout << "父进程"<<endl;cout << "~~~~~~~"<<endl;return 0;
}
9. 进程的诞生与终止
9.1 进程的诞生
Linux 系统下的所有进程都是由其父进程创建而来的,使用ps -aux
命令可以查看系统下所有进程的信息,可以看到一个 PID 为 1 的进程,就是 init 进程,是所有进程的父进程,由内核启动,
9.2 进程的终止
进程的终止分正常终止和异常终止,一般使用 exit() 而不是 _exit() 系统调用来终止。exit 函数会执行以下动作:
- 如果程序中注册了进程终止函数,那么会调用终止处理函数
- 刷新 stdio 流缓冲区,这个缓冲区是由库维护的,不是系统的
- 执行 _exit() 系统调用
但是父子进程不能够都调用 exit() 函数,只能有一个进程使用,而另一进程使用 _exit()。
int main()
{printf("nihao");pid_t pid=fork();if(pid==0){exit(0);}exit(0);
}
可以看到 nihao 被答应了两遍,因为没有使用换行符来刷新缓冲区,子进程将父进程缓冲区拷贝了一份,而父子进程退出时,就会刷新各自的缓冲区。可以使用以下方式来避免:
- 对于行缓冲设备,可以加上换行符
- 在调用 fork() 之前,可以使用 fflush() 来强制刷新缓冲区,也可以使用 setvbuf() 和 setbuf() 来关闭 stdio 流的缓冲功能
- 子进程调用 _exit() 退出进程,就不会刷新缓冲区
10. 监视子进程
父进程需要知道子进程的终止状态等等
10.1 wait()
等待任意子进程终止,同时获取子进程的退出状态
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
statuc 就是用来存放子进程退出信息的,可以设置为 NULL,表示不接收,如果成功返回终止的子进程对应的进程号,失败返回 -1.
该函数会执行以下动作:
- 这个函数是阻塞式等待,如果所有子进程都还在运行,就会一直等,直到有一个进程终止
- 如果进程调用 wait() 函数,但是该进程没有子进程,也就意味着没有需要等待的,就返回错误,并且设置 errno 为 ECHILD
- 如果调用之前,它的子进程当中已经有子进程终止,那么调用 wait() 也不会阻塞。因为该函数还可以回收子进程的一些资源
可以使用一些宏来检查 status: - WIFEXITED(status): 如果子进程正常终止,返回 true
- WEXITSTATUS(status): 返回子进程退出状态,就是 _exit() 中的值
- WIFSIGNALED(status): 如果子进程被信号终止,返回 true
- WTERMSIG(status): 返回导致子进程终止的信号
- WCOREDUMP(status): 如果子进程终止时发生了核心转储就返回 true
10.2 waitpid()
wait() 不能等待指定进程退出,而且只能阻塞式等待,wait() 只能发现那些已经终止的进程,而不能发现因信号暂停的进程,或者暂停后恢复的进程
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
/* pid:* pid>0,表示等待进程为pid的子进程* pid=0,表示等待与父进程同一个进程组的所有子进程* pid<-1,等待进程组标识符与pid绝对值相等的所有子进程* pid=-1,等待任意子进程* //* options:位掩码,包括0和以下,而0表示阻塞等待* WHOHANG:如果子进程没有发生状态改变,则立即返回,也就是执行非阻塞等待,可以实现轮询检查。如果返回值为0,表示没有发生改变* WUNTRACED:处理返回终止的子进程的状态信息,还返回因信号而停止的* WCONTINUED:返回因收到SIGCONT信号而恢复运行的子进程的状态信息* /
10.3 僵尸进程与孤儿进程
10.3.1 孤儿进程
父进程先于子进程结束,所有的孤儿进程都会被 init 进程领养
int main()
{pid_t pid=fork();if(pid==0){cout << "子进程: "<<getpid()<<" 的父进程是: " << getppid() <<endl;sleep(3); // 休眠,等待父进程结束cout << "子进程: "<<getpid()<<" 的父进程是: " << getppid() <<endl;}sleep(1);cout << "父进程结束"<<endl;return 0;
}
10.3.2 僵尸进程
子进程结束后,父进程会通过 wait() 和 waitpid() 回收子进程占用的一些资源。如果子进程先于父进程结束,父进程来不及回收,子进程就会处于僵尸状态,直到父进程调用 wait() 或 waitpid() 回收。而如果父进程没有调用等待函数就结束,那么子进程就会被 init 进程领养,然后调用等待函数回收。
10.4 SIGCHLD 信号
- 当父进程的某个子进程终止时,父进程会收到 SIGCHLD 信号
- 当父进程的某个子进程因收到信号而停止或恢复时,父进程也可能会收到该信号
所以在该信号的处理函数中,可以设置轮询检查,这样,就不会漏掉僵尸进程。
static void wait_child(int sig)
{cout << "父进程回收子进程" << endl;while(waitpid(-1, NULL, WNOHANG)>0)continue;
}
int main()
{struct sigaction sig={0};sigemptyset(sig.sa_mask);sig.sa_handler=wait_child;sig.sa_flags=0;pid_t pid=fork();if(pid==0){cout << "子进程: " << endl;sleep(1);cout << "子进程结束" <<endl;_exit(0);}sleep(3);return 0;
}
11. 执行新程序
当子进程的工作不再是运行父进程的代码段,而是运行另一个新程序,那么这个时候子进程可以通过 exec 函数来实现运行另一个新的程序。
11.1 execve()
将新程序加载到某一进程的内存空间,使用新的程序替换旧的程序,而进程的栈、数据以及堆数据会被新进程的相应部件所替换,然后从新程序的 main 函数开始执行
#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp[]);
/* * filename:指向需要载入当前进程空间的新程序的路径名* argv:命令行参数,和main函数的第二个参数一样,以NULL结尾* envp:指定新程序的环境变量列表,对应于新程序的environ数组,也是以NULL为结尾,所指向的字符串格式为name=value* /
// 成功不会返回,失败返回-1,并设置errno
11.2 exec 库函数
关于这一部分函数可以看另一篇文章:Linux进程控制
11.3 exec 族函数使用
extern char **environ;
char *arr[5];
arr[0]="ls";
arr[1]="-a";
arr[2]="-l";
arr[3]="NULL";
execl("bin/ls","ls","-a","-l",NULL); // 需要传递
execve("bin/ls",arr); // 将命令行参数通过数组传递
execlp("ls","ls","-a","-l",NULL); // 自动查找文件
execvp("ls",arr); // 自动查找路径,并将命令行参数通过数组传递
execle("bin/ls","ls","-a","-l",NULL,environ); // 需要传递环境变量
execvpe("ls",arr,environ); // 自动查找文件,以数组传递,需要环境变量
11.4 system()
使用该函数可以很方便地在程序当中执行任意 shell 命令。
#include <stdlib.h>
int system(const char *command);
// command:指向需要执行的 shell 命令,以字符串形式提供,譬如"ls -al"等
/* 返回值:* 当参数为 NULL,如果 shell 可用则返回一个非 0 值,不可用返回 0。参数不为空时,如下* 如果无法创建子进程或无法获取子进程的终止状态,返回 -1* 如果子进程不能执行 shell,返回值就类似子进程通过调用 _exit(127) 终止了* 如果所有的系统调用都成功了,返回执行 command 的 shell 进程的终止状态* /
优点是使用上简单,但是会牺牲效率。使用 system() 至少要创建两个进程,一个用于运行 shell,另一个运行参数 command 中解析出来的命令。每一个命令都是一次进程替换。
12. 进程状态与进程关系
12.1 进程状态
进程状态有以下六种:
- 就绪态:指该进程满足被 CPU 调度的所有条件但此时并没有被调度执行,只要得到 CPU 就能够直接运行;意味着已经准备好被执行,当一个进程的时间片到达,操作系统调度程序会从就绪态链表中调度一个进程。一个新创建的进程就处于就绪态。
- 运行态:指该进程当前正在被 CPU 调度运行,处于就绪态的进程得到 CPU 调度就会进入运行态
- 僵尸态:就是指僵尸进程,该进程已经结束,但是父进程未对其收尸
- 可中断睡眠态:不可中断睡眠称为深度睡眠,深度睡眠无法被信号唤醒,只能等待相应的条件才能结束睡眠状态。浅度睡眠和深度睡眠统称为等待态,或者叫做阻塞态。表示进程处于一种等待状态,等待某种条件成立后便会进入到就绪态。所以,处于等待态的进程无法参与进程系统调度。
- 暂停态:暂停并不是进程的终止,表示进程暂停运行,一般可通过信号将进程暂停。处于暂停态的进程是可以恢复进入到就绪态的,譬如收到 SIGCONT 信号。
12.2 进程关系
- 无关系
两个进程间没有任何关系,相互独立 - 父子关系
- 进程组
进程有一个组 ID,用于标识该进程属于哪一个进程组,进程组是一个或多个进程的集合,这些进程不是鼓励的,它们之间存在父子、兄弟关系,或者功能上有联系。设置进程组实质上是为了方便对进程进行管理。
使用进程组有以下要注意:- 每个进程必定属于且只能属于一个进程组
- 每一个进程有一个组长进程,组长进程的 ID 就是进程组 ID
- 在组长进程的 ID 前面加上一个负号就是操作进程组
- 组长进程不能再创建新的进程组
- 只要进程组中还存在一个进程,则进程组就存在,与组长进程是否终止无关
- 一个进程组可以包含多个进程,进程组的生命周期从被创建开始,到其内所有进程终止或离开
- 默认情况下,新创建的进程会继承父进程的进程组 ID
#include <unistd.h> pid_t getpgid(pid_t pid); pid_t getgrp(void);
- 会话
会话是一个或多个进程组的集合
会话可以包含多个进程,但只能有一个前台进程组,其它的是后台进程组。每个会话都有一个会话首领,即创建会话的进程,一个会话可以有控制终端,也可以没有控制终端,在有终端的情况下也只能连接一个控制终端,这通常是登录到其上的终端设备(在终端登录的情况下)或伪终端设备(譬如通过 SSH 协议网络登录)。
会话的首领进程连接一个终端后,该终端就成为会话的控制终端,与控制终端建立连接的会话首领进程被称为控制进程。产生在终端上的输入和信号将发送给会话的前台进程组中的所有进程。
当用户在某个终端登录时,一个新的会话就开始了;当我们在 Linux 系统下打开了多个终端窗口时,实际上就是创建了多个终端会话。
一个进程组有组长进程的 ID 标识,而对于会话来说,会话的首领进程的进程组 ID 将作为该会话的标识,也就是会话 ID(sid)。在默认情况下,新创建的进程会继承父进程的会话 ID,通过系统调用 getsid() 可获取。#include <unistd.h> pid_t getsid(pid_t pid);// 0 表示获取当前进程的会话 ID pid_t setsid(void); // 创建一个会话,但是没有控制终端
13. 守护进程 Daemon
13.1 何为守护进程
守护进程也称为精灵进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生,主要表现为以下两个特点:
- 长期运行:守护进程是一种生存周期很长的一种进程,它们一般在系统启动时开始运行,除非强行终止,否则直到系统关机都会保持运行。而普通进程在用户登录或运行程序时创建,在运行结束或用户注销时终止,但守护进程不受用户登录注销的影响
- 与控制终端脱离:在 Linux 中,系统与用户交互的界面称为终端,每一个从终端开始运行的进程都会依附于这个终端,也就是会话的控制终端。当控制终端被关闭的时候,该会话就会退出,有控制终端运行的所有进程都会被终止,这使得普通进程都是和运行该进程的终端相绑定的。但守护进程能突破这种限制,它脱离终端并且在后台运行,脱离终端的目的是为了避免进程在运行的过程中的信息在终端显示并且进程也不会被任何终端所产生的信息打断。
守护进程与终端无任何关系,用户的登录与注销与守护进程无关,不受其影响,守护进程自成进程组、自成会话
13.2 编写守护进程程序
- 创建子进程、终止父进程
父进程调用 fork() 创建子进程,然后父进程 exit() 退出。这样做实现了以下几点:如果该守护进程是作为一条简单的 shell 命令启动,那么父进程终止会让 shell 认为这条命令已经执行完毕;虽然子进程继承了父进程的进程组 ID,但它有自己独立的进程 ID,这保证了子进程不是一个进程组的组长进程,这是下面将要调用 setsid() 的先决条件 - 子进程调用 setsid() 创建会话
用于之前子进程并不是进程组的组长进程,所以调用 setsid() 会使得子进程创建一个新的会话,子进程称为新会话的首领进程,同样也创建了新的进程组、子进程成为组长进程,此时创建的会话将没有控制终端。所以这里调用该函数有三个作用:让子进程摆脱原会话的控制,让子进程摆脱原进程组的控制和让子进程摆脱原控制终端的控制。
在创建子进程时,子进程继承了父进程的会话、进程组、控制终端等,虽然父进程退出了,但原先的会话期、进程组、控制终端等并没有改变,因此不是真正意义上的独立。setsid() 能够使子进程完全独立出来,从而脱离所有其他进程的控制。 - 将工作目录更改为根目录
子进程继承了父进程的当前工作目录,由于在进程运行中,当前目录所在的文件系统是不能卸载的,这对以后使用会造成很多的麻烦。因此通常的做法就是让根目录作为守护进程的当前目录,当然也可以指定其他。 - 重设文件权限掩码 umask
文件权限掩码用于对新建文件的权限位进行屏蔽。子进程会继承父进程的文件权限掩码,因此将文件权限掩码设置为 0,可以确保子进程有最大的操作权限,增强守护进程的灵活性。 - 关闭不需要的文件描述符
子进程继承了父进程的所有文件描述符,被打开的文件可能永远不会被守护进程读或写,但它们一样消耗系统资源,导致所在的文件系统无法卸载,所以需要关闭。 - 将文件描述符号为 0、1、2 定位到 /dev/null
将守护进程的标准输入、标准输出以及标准错误重定向到 /dev/null,这使得守护进程的输出无处显示,也无处从交互式用户那里接受输入。 - 其他:忽略 SIGCHLD 信号
让内核将僵尸进程转交给 init 进程去处理,这样既不会产生僵尸进程,也省去了服务器进程回收子进程所占用的时间。
#include <iostream>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>int main()
{pid_t pid;int i;pid=fork();if(pid>0){exit(0); // 父进程直接退出}// 子进程// 1. 创建新会话,脱离控制终端if(setsid()<0){perror("setsid error");exit(-1);}// 2. 设置当前工作目录为根目录if(chdir("/")<0) // 更改当前工作目录函数,参数是字符串形式的路径{perror("chdir error");exit(-1);}// 3. 重设文件权限掩码umask(0);// 4. 关闭所有文件描述符for(i=0;i<syscong(_SC_OPEN_MAX);i++){close(i);}// 5. 将012文件定位open("/dev/null",)_RDWR);dup(0);dup(0);// 6. 忽略 SIGCHLD 信号signal(SIGCHLD,SIG_IGN);// 进入守护进程for(;;){sleep(1);puts("守护进程运行中");}exit(0);
}
运行之后,没有任何打印信息输出,原因在于守护进程已经脱离了控制终端,它的打印信息不会显示到终端,/dev/null 是一个黑洞文件,自然看不到输出信息。
13.3 SIGHUP 信号
当用户准备退出会话时,系统会向该会话发出 SIGHUP 信号,会话将 SIGHUP 信号发送给所有子进程,子进程收到 SIGHUP 信号后,便会自动终止,当所有会话中的所有进程都退出时,会话也就终止了。因为程序当中一般不会对 SIGHUP 信号进行处理,所以对应的处理方式为系统默认方式,SIGHUP 信号的系统默认处理方式就是终止进程。如果忽略这个信号,终端退出时,进程不会退出。
控制终端只是会话的一个进程,只有会话中的所有进程退出后,会话才会结束。但是忽略了该信号,导致进程不会终止,会话也依然会存在。
14. 单例模式运行
通常情况下,一个程序可以被多次执行,即程序在还没有结束的情况下,又再次执行该程序,也就是系统中同时存在多个该进程的实例化对象。
但是对于有些程序设计来说,不允许出现这种情况,程序只能被执行一次,只要该程序没有结束,就无法再次运行,叫做单例模式。
14.1 通过文件存在与否进行判断
用一个文件存在与否来做标志,在程序运行正式代码之前,先判断一个特定的文件是否存在,存在表示进程已经运行,此时应立即退出,不存在表示没有进程运行,然后创建该文件,程序结束时再删除该文件。
#include <stdio.h>
#include <stdlib.h>
#include <sys/file.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>#define LOCK_FILE "./testApp.lock"
static void delete_file(void)
{remove(LOCK_FILE);
}
int main(void)
{/* 打开文件 */int fd = open(LOCK_FILE, O_RDONLY | O_CREAT | O_EXCL, 0666);if (-1 == fd) {fputs("不能重复执行该程序!\n", stderr);exit(-1);}// 注册进程终止处理函数if (atexit(delete_file))exit(-1);puts("程序运行中...");sleep(10);puts("程序结束");close(fd); //关闭文件exit(0);
}
但是上面的程序有问题,程序中使用 _exit() 退出,那么将无法执行删除函数,也就无法删除这个特定文件;程序异常退出,也无法删除文件;计算机掉电关机,重启后文件依旧存在。
14.2 使用文件锁
也需要通过一个特定的文件来实现,当程序启动后,首先打开该文件,以 O_WRONLY|O_CREAT,当文件不存在就创建,然后尝试去获取文件锁,若是成功,则将程序的进程号写入到该文件中,写入后不要关闭或解锁,保证进程一直持有该文件锁,若是获取锁失败,代表程序已经被运行,则退出本次启动。
当程序退出或文件关闭后,文件锁会自动解锁。
#define LOCK_FILE "./testApp.pid"
int main()
{char str[20]={0};int fd=open(LOCK_FILE,O_WRONLY|O_CREAT,0666);if(fd==-1){perror("open error");exit(-1);}// 以非阻塞方式获取文件锁if(-1==flock(fd,LOCK_EX|LOCK_NB)){fputs("不能重复执行该程序\n",stderr);close(fd);exit(-1);}puts("程序运行中");ftruncate(fd,0); // 将文件长度截断为0sprintf(str,"%d\n",getpid());write(fd,str,strlen(str)); // 写入pidfor(;;)sleep(1);exit(0);
}
Linux 系统中,/var/run 目录下有很多以 pid 为后缀的文件,就是单例模式运行而设计的。
文件锁后面再详细介绍。