文章目录
进程环境
main() 函数
int main (int argc, char *argv[]);
int main (int argc, char **argv);
进程终止
Linux 系统一共有 8 种进程终止方式,其中 5 种为正常终止方式:
- 从 main() 函数返回;
- 调用 exit(3) 函数;
- 调用 _exit(2) 或 _Exit(2) 函数;
- 最后一个线程从其启动例程返回;
- 从最后一个线程调用 pthread_exit(3) 函数。
另外3 种为异常终止方式:
- 调用 abort(3) 函数;
- 接收到一个信号;
- 最后一个线程对取消请求作出响应。
逐条解释一下:
第 1 条:在 main() 函数中执行 return 语句,可以将一个 int 值作为程序的返回值返回给调用者,一般是 shell。返回 0 表示程序正常结束,返回 非零值 表示程序异常结束。
第 2 条:在 main() 函数中执行 return 语句相当于调用 exit(3) 函数,exit(3) 是专门用于结束进程的,它依赖于 _exit(2) 或 _Exit(2) 系统调用。程序中任何地方调用 exit(3) 都会退出,但 return 语句只有在 main() 函数中才能结束进程,在其它函数中执行 return 语句只能退出当前函数。
第 3 条:_exit(2) 和 _Exit(2) 函数都是系统调用,在程序中的任何地方调用它们,程序都会立即结束。
上面三条有两点需要注意,先把问题提出来大家思考一下,下面会有讲解:
(1) return 、exit(3)、_exit(2) 和 _Exit(2) 的返回值取值范围是多少呢?
(2) exit(3)、_exit(2) 和 _Exit(2) 之间有什么区别呢?
第 4、5 条 等到讨论线程的时候再说,总之进程就是线程的容器,最后一个线程的退出会导致整个进程的消亡。
第 6 条:abort(3) 函数一般用在程序中出现不可预知的错误时,为了避免异常影响范围扩大,直接调用 abort(3) 函数自杀,实际上 abort(3) 函数也是通过信号实现的。
第 7 条:信号有很多种,有些默认动作是被忽略的,有些默认动作则是杀死进程。比如程序接收到 SIGINT(Ctrl+C)信号就会结束,Ctrl + C 是 SIGINT 的一个快捷方式,而不是 Ctrl + C 触发了 SIGINT 信号。
第 8 条 等到讨论线程的时候再详细说。
exit(2)
exit - cause normal process termination
#include <stdlib.h>
void exit(int status);
status 参数的取值范围并非是所有 int 的取值范围,计算方法是 status & 0377,相当于一个有符号的 char 型数据,取值范围是 -128~127,最多256种可能。
所有通过 atexit(3) 和 on_exit(3) 注册的函数会被以注册的逆序来调用。
它在执行完钩子函数之后再执行IO清理,然后才使进程结束。
atexit(3)
atexit - register a function to be called at normal process termination
#include <stdlib.h>
int atexit(void (*function)(void));
用该函数注册过的函数会在程序正常终止之前被调用,被注册的函数称为“钩子函数”。
注册的钩子函数形式必须是这样:void (*function)(void),因为它不会接收任何参数,也没有任何机会返回什么值,所以是一个无参数无返回值的函数。
当多次调用 atexit(3) 函数注册了多个钩子函数的时候,程序结束时钩子函数是以注册的逆序被调用的。
比如按照 a()、b()、c()、d() 的形式注册了 4 个钩子函数,那么程序结束时,它们的调用顺序是:d()、c()、b()、a()。
下面j说明举例说明逆序调用是怎么回事。
#include <stdio.h>
#include <stdlib.h>
void f1 (void) {
puts("f1");
}
void f2 (void) {
puts("f2");
}
void f3 (void) {
puts("f3");
}
int main (void)
{
puts("Begin!");
atexit(f1); // 只是声明一个函数,相当于把一个函数挂在钩子上,并不调用
atexit(f2);
atexit(f3);
puts("End!");
exit(0);
}
结果
>$ gcc -Wall atexit.c -o atexit
>$ ./atexit
Begin!
End!
f3
f2
f1
>$
为什么 “End!” 先输出了,而 “f3” 后输出了呢?因为使用 atexit(3) 函数注册钩子函数的时候并不会调用钩子函数,仅仅是注册而已,只有在程序正常结束的时候钩子函数才会被调用。
还记得我们上面提到的什么情况是正常结束吧?注意是只有正常结束才会调用钩子哟,异常结束是不会调用钩子函数的。
下面写几段伪代码来举例说明什么场景更适合使用钩子函数。
/*
* 这段代码要表现的是,当我们的程序需要申请很多资源的时候,
* 比如打开文件、申请堆内存等。
* 如果有一个资源申请失败时需要释放之前所有成功申请的资源并退出程序,
* 那么就需要在申请每个资源之后都进行错误判断,并手工填写所有的资源释放代码。
*/
fd0 = open("", "");
if (fd0 < 0) {
perror("open(0)");
exit(1);
}
fd1 = open("", "");
if (fd1 < 0) {
perror("open(1)");
close(fd0);
exit(1);
}
fd2 = open("", "");
if (fd2 < 0) {
perror("open(1)");
close(fd2);
close(fd1);
exit(1);
}
......
fd10000 = open("", "");
if (fd10000 < 0) {
perror("open(10000)");
close(fd9999);
......
close(fd3);
close(fd2);
close(fd1);
close(fd0);
exit(1);
}
这还只是打开文件而已,如果中间有夹杂着 malloc(3) 和 free(3) 呢?
其实想要解决也很简单,钩子函数帮你轻松搞定!下面是改版之后的伪代码:
fd0 = open("", "");
if (fd0 < 0) {
perror("open(0)");
exit(1);
}
atexit(closefd0); // 一切都交给钩子函数来处理吧,它会以注册顺序的逆序逐一被调用。
fd1 = open("", "");
if (fd1 < 0) {
perror("open(1)");
exit(1);
}
atexit(closefd1);
fd2 = open("", "");
if (fd2 < 0) {
perror("open(2)");
exit(1);
}
atexit(closefd2);
......
fd10000 = open("", "");
if (fd10000 < 0) {
perror("open(10000)");
exit(1);
}
atexit(fd10000);
_exit(2)、_Exit(2)
_exit, _Exit - terminate the calling process
#include <unistd.h>
void _exit(int status);
#include <stdlib.h>
void _Exit(int status);
在程序的任何地方调用 _exit(2) 或 _Exit(2) 函数程序都会立即结束,任何钩子函数都不会被调用。
_exit(2)、_Exit(2) 与 exit(3) 的区别就是 _exit(2) 和 _Exit(2) 函数不会调用钩子函数,也不会做 IO 清理。
那么什么时候该用 exit(3),什么时候该用 _exit(2)、_Exit(2) 呢?
下面我们写一段伪代码来查看 _exit(2) 函数的常用场景。
int function (.........)
{
if ()
return 0;
if ()
return 1;
else
return 2;
}
int main (void)
{
int ret = 0;
ret = function();
...... // 假定没有任何地方修改过 ret
switch(ret) {
case 0:
...
break;
case 1:
...
break;
case 2:
...
break;
default:
// 出现这种情况,一定是上面的代码出现逻辑问题,或程序中出现越界等问题,
// 所以不能调用钩子函数执行清理,为了防止故障扩散,一定要让程序立即结束。
//exit(1);
_exit(1);
}
exit(0);
}
命令行参数
我们在使用 shell 命令的时候经常为传递各种参数来完成不同的工作。这个参数实际上就是传递到程序 main() 函数的 argc 和 argv 两个参数中去了。
我们再来看一下 main() 函数的原型:
int main (int argc, char **argv)
参数列表:
argc:argv 中字符串的数量,也就是传递给程序的命令行参数的数量。
argv:在 shell 中传递给进程的命令行参数列表,argv[0] 永远是命令本身,第一个参数从 argv[1] 开始。
这是一个二维数组,其实就是一个字符串数组而已。
字符串本身就是一个 char 数组,而保存多个字符串的数组自然就是一个 char 型二维素组了。
getopt(3)
getopt, optind - Parse command-line options
#include <unistd.h>
int getopt(int argc, char * const argv[], const char *optstring);
extern int optind;
该函数用于解析短格式参数。
参数列表:
argc、argv:就是 main() 函数的 argc 和 argv 参数;
optstring:想要从 argv 中解析所有选项列表,不用加 - 前导符;例如程序支持 -y -m -d -h -M -s 参数,则 optstring 填写 “y:mdh:Ms” 即可。
加冒号表示某个选项后面要带参数,比如 y 和 h 后面都需要带参数,需要用到全局变量
extern char *optarg;
extern int optind;
optarg:表示选项后面的参数,也就是 -y 和 -h 后面的参数,例如:-y 4 -h 24。
optind:用于记录 getopt(3) 函数目前读到了 argv 的哪个下标。
下面伪代码演示了如何解析选项以及带参选项:
while (1)
{
op = getopt(argc, argv, "y:mdh:Ms"); // 支持的选项是 -y <2|4> -m -d -h <12|24> -M -s
if (op < 0) {
break;
}
switch (op) {
case 1: // 非选项传参,op 的值为 1。optstring参数的第一个字符必须是'-'才支持。
// >$ ls /etc/ -a
// 当进入这个 case 的时候,optind 已经 +1 了,所以想要通过 optind 在 argv 中得到对应的参数应,
// 应该进行 -1,但是不要去修改 optind 本身的值,否则下次读取就不准确了。
fp = fopen(argv[optind-1], "w");
......
break;
case 'y':
if (0 == strcmp(optarg, "2")) { // y 后面的参数是 2
......
} else if (0 == strcmp(optarg, "4")) { // y 后面的参数是 4
......
} else { // y 后面的参数即不是 2 也不是 4
......
}
break;
case 'm':
break;
......
default:
// 通常传入了不支持的参数,不响应就可以了,没必要结束程序,因为没有达到那种严重的程度。
break;
}
}
参数可以连写,但带参数的选项必须和参数是挨着的,不能分开,举几个栗子:
# 1. -x -z -v -f 可以连写
# 2. -x -z -v 是不带参数的选项,-f 是带参数的选项,所以 -f 必须和后面的参数挨着
tar -xzvf xxx.tar.gz /home
# 下面这几个是错误的用法
tar -fxzv xxx.tar.gz /home
tar -xzvf xxxtar.gz /home
tar f xxxtar.gz xzvf /home
# 下面这种用法是可以的
tar -f xxx.tar.gz -xzv /home
getopt_long(3) 用于解析长格式参数,函数原型就不列出来了。
关于命令行参数要再补充一点,经常考运维人员的一道面试题大概是这样的:如何使用 touch(1) 命令在当前目录创建一个名字叫做 -a 的文件?
通常有两个办法可以实现:
- touch – -a 当命令行遇到两个 - 和空格时(-- ),会认为后面不会有任何选项,也就不会将 - 再作为参数的前导符。
- touch ./-a ./ 表示当前目录
环境表
export(1) 命令可以查看当前所有的环境变量或设置某个环境变量。
访问环境变量可以使用 getenv(3) 和 putenv(3) 函数,下面我们会提到它们。
环境表就是将环境变量保存在一个字符指针数组中,很多 Unix 系统都支持三个参数的 main() 函数,第三个参数就是环境表。
环境变量是为了保存常用的数据,以当前 terminal 为例,把 terminal 当作是一个大的程序来跑,就可以将环境变量看作是这个程序的全局变量。
环境变量相当于在某个位置声明 extern char **environ
上面说了,环境表就是一个字符指针数组,所以使用环境变量就相当于 environ[i] - >name = value
C程序的存储空间布局
通常 malloc(3) 失败有两种情况,一种是内存真的耗尽了;另一种是不断的申请小的内存,即使堆上全部存放指针也有放满了的情况。
在 Linux 环境中内存是延时分配的,也就是说当 malloc(3) 分配内存时并没有真正的分配物理内存给你,只是给了你一个非空指针,当你真正使用内存的时候通过引发一个缺页异常,内核才真正分配内存给你。
好比有人跟你借100块钱,你也承诺了可以借,但是他并不马上要钱,等到当他跟你要的时候你已经花掉了50块钱,这时候你有两个选择:一是把借钱的人杀掉,这样就不用借钱给他了;二是去抢钱,抢够了足够的钱再给他。
如果让你选择,你会采用哪种方式呢?
内核采用的是第二种方式,当它发现内存不足够它承诺给你的容量时,它会结束某些不常用的后台进程,再将释放出来的内存分配给你。
使用 pmap(1) 命令可以查看进程的内存分配情况,查看的必须是正在运行的进程,详情见 man 手册。
补充资料:https://www.cnblogs.com/wuchanming/p/4339770.html
共享库
类似于插件,当一个模块失败时不会影响其它模块。
内核采用插件的这种形式是有好处的,比如系统启动的时候,如果某个服务(如 ftp 服务、DHCP 等服务)启动未成功,系统会继续启动其它服务而不会立即关机。
否则如果因为 ftp 服务启动失败就关机那就坏了,想要修复 ftp 服务需要先开机,而开机需要成功启动 ftp 服务,那么系统就无法启动了。
内核中任何一个模块的加载都要以插件的形式运行,也就是尝试加载,即使加载失败也不能影响其它模块。
dlopen(3)
dlopen - programming interface to dynamic linking loader
#include <dlfcn.h>
void *dlopen(const char *filename, int flag);
该函数就是用来手工装载共享库的。
参数列表:
filename:加载的共享库文件路径
flag:打开方式
man 手册中有使用示例,详情见 man 手册。
存储空间分配
这部分理论内容详情见 《APUE》 P165
环境变量
操作环境变量的两个函数如何使用?环境变量的作用到底是什么?
就像我们使用 ls(1) 命令的时候是在任何位置都可以使用的,而没有用 /bin/ls 的方式来使用 ls(1),是因为有 PATH 环境变量的存在,它会保存所有常用的可执行文件的路径。
其实环境变量无非就是一个字符串而已,它由 key(变量名) 和 value 两部分组成,我们可以通过 key 来读写 value。
我们通常所说的环境变量就是环境表,也就是由多个环境变量组成的一个字符指针数组,它的存在是为了方便我们在程序中获得一些经常使用的变量数据,仅此而已。
在 windows 中有很多程序严重依赖于它,尤其是系统应用,一旦把环境变量改坏了就玩儿完了。
getenv(3)
getenv - get an environment variable
#include <stdlib.h>
char *getenv(const char *name);
这个函数的作用是获取一个环境变量,用法很简单,就是通过 name 获得 value, value 是返回值。
这里补充一点,在程序中获得当前工作路径有两种办法,一种是通过环境变量,一种是通过专门的函数:
puts (getevn(“PWD”)); // 通过环境变量获取当前路径,也可以使用 getcwd(3) 函数获得当前路径。
setenv(3)
setenv - change or add an environment variable
#include <stdlib.h>
int setenv(const char *name, const char *value, int overwrite);
这个函数和 getenv(3) 函数的作用正好相反,是将 value 赋给 name 环境变量。
如果 name 不存在,则添加新的环境变量。
如果 name 存在:如果 overwrite 为真,用 value 覆盖 name 原来的值;如果 overwrite 为假,则保留 name 原来的值。
putenv(3)
putenv - change or add an environment variable
#include <stdlib.h>
int putenv(char *string);
用 “name = value” 的形式添加或修改环境变量的值,如果 name 已存在则会用新值覆盖原来的值。
注意:参数不是 const 的,所以某些情况下可能会修改参数的值,所以还是使用 setenv(3) 更保险。
大家思考一个问题:如 内存分配图 所示,环境表是存放在堆与内核空间之间的薄层中的,如果新字符串比原字符串长怎么办,会不会出现越界的情况呢?
其实不用担心这个问题,因为无论新的值与原来的值谁长谁短,都会先将原来的空间释放,在堆上新申请一块空间来存放新的值。
函数 setjmp(3) 和 longjmp(3)
goto 语句想必大家都很熟悉了吧,但是它们有一个缺点,就是不能跨函数跳转。C 标准 给我们提供了两个函数增强了程序跳转的能力,它们可以使程序跨函数跳转。
很多人都忌讳跳转,认为它会破坏程序的结构,跳转是把利刃,好钢要用到刀刃上,所以一定要把跳转用在合适的地方,这样能让你的程序写得非常漂亮。
那么长跳转的好处是什么呢?
如图,如果 a() b() c() d() 是同一个函数,则是递归调用。
当利用递归在一个树状结构中查找一个数据时,查找到最深的层次发现没有找到想要的数据,这时候没有必要一层一层的返回,可以直接跳转回递归点。goto是做不到的,需要用 setjmp(3) 或 longjmp(3) 函数安全返回。
下面我们来看看这两个函数怎么使用。
setjmp - save stack context for nonlocal goto
#include <setjmp.h>
int setjmp(jmp_buf env);
longjmp, siglongjmp - nonlocal jump to a saved stack context
#include <setjmp.h>
void longjmp(jmp_buf env, int val);
首先通过 setjmp(3) 设置一个跳转点,然后可以通过 longjmp(3) 跳转到 setjmp(3) 所在的位置。
setjmp(3) 设置跳转点时返回值为0,被跳转过来时返回值为非零,也就是 longjmp(3) 的 val 参数。所以 setjmp(3) 下面一定跟着一组分支语句来根据不同的返回值做不同的操作。
longjmp(3) 无需返回值,因为执行的时候程序已经跳转了,无法获得返回值。
参数列表:
env: 是指定条准到哪
val:带回去的值,如果值为 0,则 setjmp(3) 收到的返回值是 1,避免跳转出现死循环。
下面来看个栗子:
#include <stdio.h>
#include <stdlib.h>
#include <setjmp.h>
static jmp_buf save;
void d(void)
{
printf("%s():Begin.\n",__FUNCTION__);
printf("%s():Jump now.\n",__FUNCTION__);
longjmp(save,8); // 跳
printf("%s():End.\n",__FUNCTION__);
}
void c(void)
{
printf("%s():Begin.\n",__FUNCTION__);
printf("%s():Call d().\n",__FUNCTION__);
d();
printf("%s():d() returned.\n",__FUNCTION__);
printf("%s():End.\n",__FUNCTION__);
}
void b(void)
{
printf("%s():Begin.\n",__FUNCTION__);
printf("%s():Call c().\n",__FUNCTION__);
c();
printf("%s():c() returned.\n",__FUNCTION__);
printf("%s():End.\n",__FUNCTION__);
}
void a(void)
{
int ret;
printf("%s():Begin.\n",__FUNCTION__);
ret = setjmp(save);
if(ret == 0) // 设置跳转点
{
printf("%s():Call b().\n",__FUNCTION__);
b();
printf("%s():b() returned.\n",__FUNCTION__);
}
else // 跳回到这
{
printf("%s():Jumped back here with code %d\n",__FUNCTION__,ret);
}
printf("%s():End.\n",__FUNCTION__);
}
int main()
{
printf("%s():Begin.\n",__FUNCTION__);
printf("%s():Call a().\n",__FUNCTION__);
a();
printf("%s():a() returned.\n",__FUNCTION__);
printf("%s():End.\n",__FUNCTION__);
return 0;
}
结果
>$ gcc jmp.c -Wall -o jmp
>$ ./jmp
main():Begin.
main():Call a().
a():Begin.
a():Call b().
b():Begin.
b():Call c().
c():Begin.
c():Call d().
d():Begin.
d():Jump now.
a():Jumped back here with code 8
a():End.
main():a() returned.
main():End.
>$
注意:setjmp(3) 和 longjmp(3) 函数不能从信号处理函数中跳转,具体原因我们在后面讨论信号的时候会说明。
函数 getrlimit(2) 和 setrlimit(2)
getrlimit, setrlimit - get/set resource limits
#include <sys/time.h>
#include <sys/resource.h>
int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, const struct rlimit *rlim);
每个进程都有一组对资源使用的上限,通过这两个函数可以获取或设置这些上限。
ulimit§ 命令就是使用这两个函数封装的。
getrlimit(2) 获取 resource 资源,并且把读取结果回填到 rlptr 中。
setrlimit(2) 设置 resource 资源,设置的值由用户填在 rlimit 中。
rlimit 结构体的内容也很简单,当然这些资源上限也不是随便可以修改的,下面的规则同样适用于 ulimit§ 命令。
struct rlimit {
rlim_t rlim_cur; /* 软限制。普通用户能提高和降低软限制,但是不能高过硬限制。超级用户也一样。*/
rlim_t rlim_max; /* 硬限制。普通用户只能降低自己的硬限制,不能提高硬限制。超级用户能提高硬限制也能降低硬限制。 */
};
进程控制
进程控制这一章主要围绕着 fork(2)、exec(2)、wait(2) 这三个函数来讨论 *nix 系统是如何管理进程的。
ps(1) 命令可以帮助我们分析一些示例,简单介绍一些参数的组合方式,更详细的信息请查阅 man 手册。
ps axf
主要用于查看当前系统中进程的 PID 以及执行终端(tty)和状态等信息,
更重要的是它能显示出进程的父子关系。
ps axj
主要用于查看当前系统中进程的 PPID、PID、PGID、SID、TTY 等信息。
ps axm
显示进程的详细信息,PID 列下面的减号(-)是这个进程中的线程。
ps ax -L 以 Linux 的形式显示当前系统中的进程列表。
PID 是系统中进程的唯一标志,在系统中使用 pid_t 类型表示,它是一个非负整型。
1号 init 进程是所有进程的祖先进程(但不一定是父进程),内核启动后会启动 init 进程,然后内核就会守在后台等待出现异常等情况的时候再出来处理一下,其它事情都由 init 进程创建子进程来完成。
进程号是不断向后使用的,当进程号达到最大值的时候,再回到最小一个可用的数值重新使用。
getpid(2) 获得当前进程 ID。
getppid(2) 获得父进程 ID。
getpid, getppid - get process identification
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);
frok(2)
fork - create a child process
#include <unistd.h>
pid_t fork(void);
fork(2) 函数的作用就是创建子进程。
调用 fork(2) 创建子进程的时候,刚开始父子进程是一模一样的,就连代码执行到的位置都是一模一样的。
fork(2) 执行一次,但返回两次。它在父进程中的返回值是子进程的 PID,在子进程中的返回值是 0。子进程想要获得父进程的 PID 需要调用 getppid(2) 函数。
一般来说调用fork后会执行 if (依赖fork的返回值) 分支语句,用来区分下面哪些代码由父进程执行,哪些代码由子进程执行,画幅图解释一下。
图1 fork(2) 与写时拷贝
结合上图,聊一聊 fork(2) 的前世今生,最初的 frok(2) 函数在创建子进程的时候会把父进程的数据空间、堆和栈的副本等数据统统给子进程拷贝一份,如果父进程携带的数据量特别大,那么这种情况创建子进程就会比较耗费资源。
这还不是最要命的,万一费这么大劲创建一个子进程出来,结果子进程没有使用父进程给它的数据,而只是打印一句 “Hello World!” 就结束退出了,岂不是白白浪费了之前的资源开销?
有一个优化方案:让父子进程共享同一块数据空间,这样创建子进程的时候就不必担心复制数据耗费的资源较高的问题了,这就是传说中的 vfork(2) 函数实现的效果。
那么问题来了,如果子进程修改了数据会发生什么情况呢?Sorry,这个标准里没说,天知道会发生什么事情,所以 vfork(2) 通常被认为是过时的函数,不推荐大家使用。
既然上面两个方案都不完美,程序猿们只好再次改良 fork(2) 函数,虽然效率稍微比 vfork(2) 低了一点,但是可以保证安全性,这就是写时拷贝技术。
写时复制(Copy-On-Write,COW)就是 图1 里下面的部分,fork(2) 函数刚刚创建子进程的时候父子进程的数据指向同一块物理内存,但是内核将这些内存的访问变为只读的了,当父子进程中的任何一个想要修改数据的时候,内核会为修改区域的那块内存制作一个副本,并将自己的虚拟地址映射到物理地址的指向修改为副本的地址,从此父子进程自己玩自己的,谁也不影响谁,效率也提高了许多,新分配的副本大小通常是虚拟存储系统中的一“页”。
当然,写时复制技术中所谓制作一个副本,这个是在物理地址中制作的,并非是我们在程序中拿到的那个指针所指向的地址,我们的指针所指向的地址其实是虚拟地址,所以这些动作对用户态程序员是透明的,不需要我们自己进行管理,内核会自动为我们打点好一切。
综上,父进程通过复制一份自己的资源,创建了子进程,难道父子进程就是一模一样的吗?
父子进程之间有五点差异:
(1) fork(2) 的返回值不同;
(2) 父子进程的 PID 不相同;
(3) 父子进程的 PPID 不相同; // PPID 就是父进程 PID
(4) 在子进程中资源的利用量清零,否则如果父进程打开了很多资源,子进程能使用的资源量就很少了;
(5) 未决信号和文件锁不继承。
父进程与子进程谁先运行是不确定的,这个执行顺序是由进程调度器决定的,不过 vfork(2) 会保证子进程先运行,进程调度器不是一个工具,是在内核中的一块代码。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main (void)
{
pid_t pid;
printf("[%d] Start!\n", getpid());
pid = fork();
if (pid < 0) {
perror("fork()");
exit(1);
} else if (0 == pid) { // child
printf("[%d] Child process.\n", getpid());
} else { // parent
printf("[%d] Parent process.\n", getpid());
}
sleep(1000);
puts("End!");
return 0;
}
// 执行结果
>$ make 1fork
cc 1fork.c -o 1fork
>$ ./1fork
[3713] Start!
[3714] Child process.
[3713] Parent process.7 [3713] End!8 [3714] End!
重新打开一个终端,验证它们的父子进程关系:
1 >$ ps axf
2 3565 pts/1 Ss 0:00 \_ bash
3 3713 pts/1 S+ 0:00 | \_ ./1fork
4 3714 pts/1 S+ 0:00 | \_ ./1fork
从 ps(1) 命令可以看出来,3713 进程确实产生了一个子进程 3714。
但是这里面有一个问题,我们重新执行一遍程序,将输出结果重定向到文件中。
>$ ./1fork > result.txt
>$ cat result.txt
[3807] Start!
[3807] Parent process.
End!
[3807] Start!
[3808] Child process.
End!
父进程竟然输出了两次 Start!,这是为什么呢?
其实第二次 Start! 并不是父进程输出的,而是子进程输出的。但是为什么 PID 却是父进程的呢?
其实这是因为行缓冲变成全缓冲导致的,之前我们讲过,标准输出是行缓冲模式,而系统默认的是全缓冲模式。所以当我们将它输出到控制台的时候是可以得到预期结果的,但是一旦重定向到文件的时候就由行缓冲模式变成了全缓冲模式,而子进程产生的时候是会复制父进程的缓冲区的数据的,所以子进程刷新缓冲区的时候子进程也会将从父进程缓冲区中复制到的内容刷新出来。因此,在使用 fork(2) 产生子进程之前一定要使用 fflush(NULL) 刷新所有缓冲区!
再考虑一个问题,当程序运行的时候,为什么子进程的输出结果是在当前 shell 中,为什么不打开一个新的 shell 呢?
这是因为子进程被创建的时候会复制父进程所有打开的文件描述符,所谓的“复制”是指就像执行了 dup(2) 函数一样,父子进程每个相同的打开的文件描述符共享一个文件表项。
而父进程默认开启了 0(stdin)、1(stdout)、2(stderr) 三个文件描述符,所以子进程中也同样存在这三个文件描述符。
既然子进程会复制父进程的文件描述符,也就是说如果父进程在创建子进程之前关闭了三个标准的文件描述符,那么子进程也就没有这三个文件描述符可以使用了。
从上面的 ps(1) 命令执行结果可以看出来,我们的父进程是 bash 的子进程,所以我们父进程的三个标准文件描述符是从 bash 中复制过来的。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
int main (void)
{
pid_t pid;
int i = 0;
for (i = 0; i < 10; i++) {
fflush(NULL);
pid = fork();
if (pid < 0) {
perror("fork()");
exit(1);
} else if (0 == pid) {
printf("pid = %d\n", getpid());
exit(0);
}
}
sleep(100);
return 0;
}
// 结果
>$ make 2fork
cc 2fork.c -o 2fork
>$ ./2fork
pid = 5101
pid = 5103
pid = 5105
pid = 5107
pid = 5108
pid = 5106
pid = 5104
pid = 5102
pid = 5110
pid = 5109
# ... 这里父进程处于 sleep 状态,便于我们新打开一个终端查看进程状态
>$ ps axf
3565 pts/1 Ss 0:00 \_ bash
5100 pts/1 S+ 0:00 | \_ ./2fork
5101 pts/1 Z+ 0:00 | \_ [2fork] <defunct>
5102 pts/1 Z+ 0:00 | \_ [2fork] <defunct>
5103 pts/1 Z+ 0:00 | \_ [2fork] <defunct>
5104 pts/1 Z+ 0:00 | \_ [2fork] <defunct>
5105 pts/1 Z+ 0:00 | \_ [2fork] <defunct>
5106 pts/1 Z+ 0:00 | \_ [2fork] <defunct>
5107 pts/1 Z+ 0:00 | \_ [2fork] <defunct>
5108 pts/1 Z+ 0:00 | \_ [2fork] <defunct>
5109 pts/1 Z+ 0:00 | \_ [2fork] <defunct>
5110 pts/1 Z+ 0:00 | \_ [2fork] <defunct>
>$
从执行结果来看,子进程的状态已经变为 Z+ 了,说明子进程执行完成之后变成了“僵尸进程”。
为什么子进程会变为僵尸进程呢?是因为子进程比父进程先结束了,它们必须得等待父进程为其“收尸”才能彻底释放。
在现实世界中白发人送黑发人是件不吉利的事情,但是在计算机的世界中,父进程是需要为子进程收尸的。
如果父进程先结束了,那么这些子进程的父进程会变成 1 号 init 进程,当这些子进程运行结束时会变成僵尸进程,然后 1 号 init 进程就会及时为它们收尸。
修改上面的例子,将 sleep(100) 这行代码移动到子进程中,让父进程创建完子进程后直接退出,使子进程变成孤儿进程,直接贴测试的结果。
>$ make 2fork
cc 2fork.c -o 2fork
>$ ./2fork
pid = 5245
pid = 5247
pid = 5251
pid = 5254
>$ pid = 5252 # 这里会输出一个提示符,是因为父进程退出了,shell 已经为我们的父进程收尸
了,所以提示符被输出了。而我们的父进程没有为子进程收尸,所以子进程会继续输出。
pid = 5250
pid = 5253
pid = 5248
pid = 5249
pid = 5246
# 下面我们打开一个新的 shell 查看进程状态
>$ ps -axj
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
1 5296 5295 3565 pts/1 3565 S 501 0:00 ./2fork
1 5297 5295 3565 pts/1 3565 S 501 0:00 ./2fork
1 5298 5295 3565 pts/1 3565 S 501 0:00 ./2fork
1 5299 5295 3565 pts/1 3565 S 501 0:00 ./2fork
1 5300 5295 3565 pts/1 3565 S 501 0:00 ./2fork
1 5301 5295 3565 pts/1 3565 S 501 0:00 ./2fork
1 5302 5295 3565 pts/1 3565 S 501 0:00 ./2fork
1 5303 5295 3565 pts/1 3565 S 501 0:00 ./2fork
1 5304 5295 3565 pts/1 3565 S 501 0:00 ./2fork
1 5305 5295 3565 pts/1 3565 S 501 0:00 ./2fork
>$
从上面 ps(1) 命令的执行结果来看,所有子进程的父进程都变成了 1 号 init 进程。
很多人会认为僵尸进程不应该出现,它们会占用大量的资源。其实不然,它们在内核中仅仅保留一个结构体,也就是自身的状态信息,其它的资源都释放了。但是它占用了一个重要的系统资源:PID,因为系统中 PID 的数量是有限的,所以及时释放僵尸进程还是很有必要的。
我们的父进程没有对子进程进行收尸,所以才会出现这样的情况。其实对于这种转瞬即逝的程序而言不会有什么危害,但是假设父进程是一个要不断执行一个月的程序,而它却又不为子进程收尸,那么子进程就会占用这些 PID 一个月之久,那么就可能出现问题了。
所以在一个完善的程序中,父进程是要为子进程收尸的,至于如何为子进程收尸,下面我们会讲,fork(2) 函数就先讨论到这里。
vfork(2)
vfork - create a child process and block parent
#include <sys/types.h>
#include <unistd.h>
pid_t vfork(void);
Feature Test Macro Requirements for glibc (see feature_test_macros(7)):
vfork():
Since glibc 2.12:
_BSD_SOURCE ||
(_XOPEN_SOURCE >= 500 ||
_XOPEN_SOURCE && _XOPEN_SOURCE_EXTENDED) &&
!(_POSIX_C_SOURCE >= 200809L || _XOPEN_SOURCE >= 700)
Before glibc 2.12:
_BSD_SOURCE || _XOPEN_SOURCE >= 500 ||
_XOPEN_SOURCE && _XOPEN_SOURCE_EXTENDED
vfork(2) 函数在上面介绍写时拷贝技术的时候我们就提到了它的工作方式,并且也说了这是一个过时的函数,不推荐大家使用了,简单了解一下就可以了。
使用 vfork(2) 函数创建的子进程除了与父进程共享数据外,vfork(2) 还保证子进程先运行,在子进程调用 exec(3) 函数族 或 exit(3)(_exit(2)、_Exit(2)) 函数前父进程处于休眠状态。
另外,使用 vfork(2) 创建的子进程不允许使用 return 语句返回,只能使用 exit(3) 函数族的函数结束,否则会被信号杀死,父进程则不受这个限制。
wait(2)
wait, waitpid, waitid - wait for process to change state
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
wait(2) 阻塞等待子进程资源的释放,相当于上面提到的“收尸”。
每次调用 wait(2) 函数会为一个子进程收尸,而 wait(2) 函数并没有让我们指定是哪个特定的子进程。如果想要为特定的子进程收尸,需要调用 waitpid(2) 函数。
收尸这件事只能是父进程对子进程做,而且只能对自己的子进程做。子进程是不能为父进程收尸的,父进程也不能为别人的子进程收尸。
参数列表:
status:由函数回填,表示子进程的退出状态。如果填 NULL,表示仅回收资源,并不关心子进程的退出状态。
status 参数可以使用以下的宏进行解析:
表1 解析 wait(2) 函数 status 参数的宏
宏 | 描述 |
---|---|
WIFEXITED(status) | 返回真表示子进程正常终止,返回假表示子进程异常终止。正常与异常终止的8种方式上面讲过。 |
WEXITSTATUS(status) | 返回子进程的退出码。只有上一个宏返回正常终止时才能使用,异常终止是不会有返回值的。 |
WTERMSIG(status) | 可以获得子进程具体被哪个信号杀死了。 |
WIFSTOPPED(status) | 子进程是否被信号 stop 了。stop 和杀死是不同的,stop 的进程可以被恢复(resumed)。 |
WSTOPSIG(status) | 如果子进程是被信号 stop 了,可以查看具体是被哪个信号 stop 了。 |
WIFCONTINUED(status) | 如果子进程被 stop 了,可以查看它是否被 resumed 了。 |
pid:一共分为四种情况:
< -1 为归属于进程组 ID 为 pid 参数的绝对值的进程组中的任何一个子进程收尸
== -1 为任意一个子进程收尸
== 0 为与父进程同一个进程组中的任意一个子进程收尸
> 0 为一个 PID 等于参数 pid 的子进程收尸
options:为特殊要求;这个参数是这个函数的设计精髓。可以通过 WNOHANG 宏要求 waitpid(2) 函数以非阻塞的形式为子进程收尸,这个也是最常用的特殊要求。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main (void)
{
pid_t pid;
int i = 0;
for (i = 0; i < 10; i++) {
fflush(NULL);
pid = fork();
if (pid < 0) {
perror("fork()");
exit(1);
} else if (0 == pid) {
printf("pid = %d\n", getpid());
exit(0);
}
}
// 为所有的子进程收尸
for (i = 0; i < 10; i++) {
wait(NULL);
}
return 0;
}
为什么要父进程为子进程收尸呢?为什么不让子进程结束后自动释放所有资源?如果没有 收尸 会发生什么?
假设父进程需要创建一个子进程并且要让它做 3 秒钟的事情,很不巧子进程刚启动就出现了一个异常然后就挂掉了,并且直接释放了自己的资源。
而此时系统 PID 资源紧张,已死掉的子进程的 PID 被分配给其它进程,而父进程此时并不知道手里的子进程的 PID 已经不属于它了。
如果这时候父进程后悔执行子进程,它要 kill 这个子进程,后果就是系统大乱。
而使用了收尸技术之后,子进程状态改变时会给父进程发送一个 SIGCHLD 信号,wait(2) 函数其实就是阻塞等待被这个信号打断,然后为子进程收尸。
系统通过收尸这种机制来保证父进程未执行收尸动作之前,手里拿到的子进程 PID 一定是有效的了(即使子进程已死掉,但是这个 PID 依然是属于父进程的子进程的,而不会归属于别人)。
exec(3)
execl, execlp, execle, execv, execvp, execvpe - execute a file
#include <unistd.h>
extern char **environ;
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg,
..., 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[]);
Feature Test Macro Requirements for glibc (see feature_test_macros(7)):
execvpe(): _GNU_SOURCE
参数列表:
path:要执行的二进制程序路径
arg:传递给 path 程序的 argv 参数,第一个是 argv[0],其它参数从第二个开始。
…:argv 的后续参数,最后一个参数是 NULL,表示变长参数列表的结束。
看上去 execl(3)、execlp(3) 像是变参函数,execle(3) 像是定参函数,其实正好是反过来的,execl(3) 和 execlp(3) 是定参的,而 execle(3) 函数是变参的。
再来看一下 fork(2) 代码执行时使用 ps -axf 命令查看父子依赖关系的结果:
>$ ps axf
3565 pts/1 Ss 0:00 \_ bash
3713 pts/1 S+ 0:00 | \_ ./1fork
3714 pts/1 S+ 0:00 | \_ ./1fork
>$
fork(2) 创建出来的子进程是通过复制父进程的形式实现的,但是我们的父进程又是 bash 的子进程,为什么 bash 没有创建出来一个与自己一模一样的子进程呢?
这就是 exec(3) 函数族的功劳了。
它可以使调用它的进程“外壳”不变,“内容物”变为新的东西。“外壳”就是父子关系、PID 等,“内容物”其实是指一个新的可执行程序。也就是说 exec(3) 函数会将调用它的进程完全(整个4GB虚拟内存空间,即代码段、数据段、堆栈等等)变成另一个可执行程序,但父子关系、PID 等东西不会改变。
在执行了 exec(3) 函数族的函数之后,整个进程的地址空间会立即被替换,所以 exec(3) 下面的代码一律不会执行,取而代之的是新程序的代码段。
缓冲区也会被新程序所替换,所以在执行 exec(3) 之前要使用 fflush(NULL) 刷新所有的缓冲区。这样父进程才会让它的缓冲区中的数据到达它们该去的地方,而不是在数据到达目的地之前缓冲区就被覆盖掉。
一个 fork(2) + exec(3) + wait(2) 的经典用法
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
/* 创建子进程 date,参数是 +%s
* 相当于在 shell 中执行 date +%s 命令
*/
int main()
{
pid_t pid;
puts("Begin!");
fflush(NULL);
pid = fork();
if(pid < 0) {
perror("fork()");
exit(1);
}
if(pid == 0) // child
{
execl("/bin/date","date","+%s",NULL);
perror("execl()");
exit(1);
}
wait(NULL);
puts("End!");
exit(0);
}
fork(2)、exec(3)、wait(2) 函数可以让我们创建任何进程来执行任何命令,如此看来,整个 *nix 世界都是由 fork(2)、exec(3)、wait(2) 这三个函数搭建起来的。
shell 的内部命令与外部命令
像 cd(1)、exit(2)、|、> 牵涉到环境变量改变等动作这样的命令叫做内部命令,而使用 which(1) 命令能查询到的在磁盘上存在的命令就是外部命令。
学会了 fork(2)、exec(3)、wait(2) 函数的使用,可以尝试编写一个 shell 程序,基本可以执行所有的外部命令。
但是 shell 不仅支持外部命令,还支持很多内部命令,对内部命令的支持才是 shell 的难点。
关于内部命令的内容多数都在《APUE》第三版的第九章,自行查阅。
更改用户 ID 和更改组 ID
在 *nix 系统中,特权和访问控制是基于用户 ID 和用户组 ID 的,所以当我们需要使用特权或访问无权访问的文件时需要切换用户 ID 或用户组 ID。
uid
r(real) 用于保存用户权限
e(effective) 鉴定用户权限时使用
s 与 real 相同,所以有些系统不支持
gid
r(real) 用于保存用户组权限
e(effective) 鉴定用户组权限时使用
s 与 real 相同,所以有些系统不支持
普通用户没有查看 /etc/shadow 文件,但是为什么有权限修改自己的密码呢?
>$ which passwd
/usr/bin/passwd
>$ ls -l /usr/bin/passwd
-rwsr-xr-x 1 root root 47032 2月 17 2014 /usr/bin/passwd
$ ls -l /etc/shadow
---------- 1 root root 1899 Apr 1 16:25 /etc/shadow
这是因为 passwd(1) 命令是具有 U+S 权限的,用户在使用这个程序的时候身份会切换为这个程序文件所有者的身份。
G+S 与 U+S 类似,只不过执行的瞬间身份会切换为与程序归属用户组相同的组权限。
改变用户 ID 和组 ID 可以使用 setuid(2) 和 setgid(2) 函数实现,这两个函数使用起来很简单,自己查阅 main 手册。
解释器文件其实就是脚本
做一个系统级开发工程师需要具备的素质至少精通2门语言,一门面向过程,一门面向对象,还要精通至少一门脚本语言,如 shell、python等,还要具备扎实的网络知识和一点硬件知识。
解释器是一个二进制的可执行文件。就是为了用一个可执行的二进制文件解释执行解释器文件中的命令。
#! 用于装载解释器
例如:
#!/bin/shell 装载了 /bin/shell 作为解释器
#!/bin/cat 装载了 /bin/cat 作为解释器
那么装载解释器之后为什么不会递归执行装载解释器这行代码呢?因为根据约定,脚本中的 # 表示注释,所以解释器在解析这个脚本的时候不会看到这行装载解释器的命令。
装载解释器的步骤由内核 exec(3) 系统调用来完成,如果使用 exec(3) 函数来调用解释器文件,实际上 exec(3) 函数并没有执行解释器文件,而是执行了解释器文件中装载的解释器,由它来执行解释器文件中的指令。
system(3)
system - execute a shell command
#include <stdlib.h>
int system(const char *command);
该函数可执行一条系统命令,是通过调用 /bin/sh -c command 实现的。
其实我们可以猜测一下 system(3) 命令是如何实现的,下面是伪代码:
pid_t pid;
pid = fork();
if (pid < 0) {
perror("fork()");
exit(1);
}
if (pid == 0) // child
{
// system("date +%s");
execl("/bin/sh","sh","-c","date +%s",NULL);
perror("execl()");
exit(1);
}
wait(NULL);
exit(0);
进程会计
连 POSIX 标准都不支持,是方言中的方言。
它是典型的事实标准,各个系统的实现都不统一,所以建议少用为妙,作用是将进程的相关信息写入到 filename 所指定的文件中。
acct - switch process accounting on or off
#include <unistd.h>
int acct(const char *filename);
用户标识
getlogin, getlogin_r, cuserid - get username
#include <unistd.h>
char *getlogin(void);
int getlogin_r(char *buf, size_t bufsize);
能够不受任何因素的影响,获取当前终端的用户名。
不受任何因素影响是指,比如我们用 su(1) 等命令切换了用户,getlogin(3) 函数获得到的仍然是原始的用户名。
进程调度
用于控制进程调度优先级,一般不会调整进程的优先级。
进程时间
times - get process and waited-for child process times
#include <sys/times.h>
clock_t times(struct tms *buffer);
该函数获得的是进程的执行时间,clock_t 是滴答数,位于秒级以下,具体的与秒的换算值需要通过 sysconf(_SC_CLK_TCK) 宏获得。
进程关系 和 守护进程
进程关系是《APUE》第三版的第九章,终篇贯穿着一个概念,叫做“终端”,现在已经无法见到真正的终端了。
不了解这章就无法解释第13章(守护进程)的内容,大家要了解终端和进程之间关系才能了解守护进程是个什么东西。
进程关系与守护进程这两章内容不多,而且有诸多关联,所以把这两章的内容放到一起讨论。
先来看看几个概念。
1.终端
真正意义上的终端是“笨设备”,只能接收命令的输入并返回结果。你问它 1+1=? 它也不知道,它只能把你的问题传给计算机,再把计算机返回的结果显示给你。
它出现在计算机既昂贵又庞大的年代。那时候的计算机昂贵到了只有一部分公司买得起、另一部分公司买不起,而且有些公司只能买一台,买第二台就要破产了的程度。
所以这么昂贵的设备如果只能给一个人使用太浪费了,于是为了让计算机可以被多人使用,就出现了终端这种设备。
接下来简单聊聊 Linux 是如何使用户登录的。
如图 Linux 用户登录过程 所示,内核自举时创建 1 号 init 进程,init 对每一个终端执行 fork(2) + exec(3) + getty(1) 命令,getty(1) 命令的作用是要求用户输入用户名。
等待用户输入完成用户名后,getty(1) 会 exec(3) + login(1)。
login(1) 命令首先根据用户名通过 getpwnam(3) 函数得到用户的口令文件登录项,然后调用 getpass(3) 函数以屏蔽回显的形式获得用户的密码,最后再通过 crypt(3) 函数将加密后的用户口令与阴影口令文件用户登录项中的 pw_passwd 字段相比较,认证失败几次之后就会回到上图的第一步,init 进程将重新执行 fork(2) + exec(3) + getty(1)。
如果认证成功则启动用户登录 shell,并使用 chown(2) 更改当前登录终端的所有权,使登录用户成为它的所有者。登录之前的步骤都是 root 身份,所以真正用户权限被降下来就是在这个时候发生的。
当然 login 要做的事情不仅仅只有这点儿,它还要做许多其它需要为用户初始化的事情。
说句题外话,如果获取用户名或密码的时候函数的编写者敢使用类似 scanf(3) 这样的函数读取用户输入,则很容易遭受缓冲区溢出攻击。
会话(Session)
一次成功的终端登录就是一个会话。现在一次 shell 的成功登录,相当于那时候终端的成功登录。会话相当于是进程组的容器,它能承载一个或多个进程组。
进程组
进程组用来承载进程,一个进程组中有一个或多个进程,它是一个或多个进程的集合(也可以看作是容器)。一个进程不但拥有唯一的 PID,同时也属于一个进程组。
如何产生一个进程组呢?
# 使用管道可以用一条命令产生一个进程组。
ls | more
进程组分为前台进程组和后台进程组。
一个会话中只能有一个前台进程组,也可以没有前台进程组。
终端设备(如键盘)只能与前台进程通讯,不能与后台进程通讯,根据约定,如果终端设备与一个后台进程关联,就会杀掉这个后台进程。
什么是前台进程组呢?比如你正在使用 tar 命令进行打包的时候是无法再输入其它命令的。如果 tar 命令执行的时间很长,我们就会在命令后面添加一个 & 参数,把它放到后台去运行。
ps(1) 命令的 SID(Session ID)列 就是程序运行的会话ID。
进程是先出现的,后来人们发现进程可以拆分为多个小任务分别执行,于是便出现了线程的概念,如今进程已经退化为容器了,它的存在就是为了承载线程。PID 看似是进程号,实际上是线程在消耗它。
进程和线程只是我们的说法,内核中只能看到线程,内核所谓的进程管理其实就是线程管理,内核永远以线程为单位执行任务。
总结来说:会话用来承载进程组,进程组用来承载进程,进程用来承载线程。
第九章了解这几个概念就差不多了,还记得我们前面提到的 myshell 吗,用 fork(2) + exec(3) + wait(2) 来实现一个可以执行外部命令的 shell。如果你想实现一个支持内部命令的 shell 那么可以仔细学习一下第九章的内容,shell 内部命令处理的主要知识点都在第九章。
setsid(2)
setsid - create session and set process group ID
#include <unistd.h>
pid_t setsid(void);
创建一个会话并设置进程组的ID。这个函数是我们在第 9 章最有价值的函数,没有这个函数,我们后面就无法创建守护进程。
调用者不能是进程组组长,调用者在调用之后自动变为新进程组组长,并且脱离控制终端,进程 ID 将被设为进程组 ID 和会话 ID,所以守护进程通常 PID、PGID、SID 是相同的。通常的用法是父进程 fork(2) 一个子进程,然后子进程调用 setsid(2) 将自己变成守护进程,父进程退出即可。
守护进程
《APUE》第三版 P375 图13-1 已经有详细的代码了,总结一下守护进程的特点:
1) 脱离控制终端,ps(1) axj tty 为问号(?)。
2)是进程组的 leader,也就是 PID 和 PGID 相同。
3)通常没有父进程,由 1 号 init 接管。
4)创建了一个新会话,是 session 的 leader,所以 PID 与 SID 相同。
使用 ps(1) axj 命令查看,PID、PGID、SID 相同的进程就是守护进程。
守护进程也可以使用标准输出,但是不符合常理,因为守护进程没有控制终端,所以守护进程一般会关闭或重定向标准输入输出流。
写守护进程的时候我们会切换工作路径,把它切换到一个一定会存在的路径,比如 /。因为假设你的守护进程是在一个可卸载设备(如U盘)上被启动的,如果不修改工作路径,该设备无法被卸载。
调用 umask(2) 是为了将文件模式创建掩码设置为一个已知值,因为通过继承得来的掩码可能会被设置为拒绝某些权限,如果守护进程中需要这些权限则要设置它。
对于书上的栗子,有两点要吐槽:
1)SIGHUP 信号用于通知服务进程软重启,比如修改了某服务的配置文件之后可以通过给服务进程发 SIGHUP 信号使它重新读取配置文件,所以如果没有特殊要求不必忽略该信号。
2)如果没有特殊要求,不必关闭所有的文件描述符,仅关闭标准输入、标准输出和标准错误即可。
系统日志
守护进程不应使用标准输出,那么当守护进程需要记录一些事件或者是错误的时候怎么办?那就要采用系统日志了。
系统日志一般保存在 /var/log/ 目录下,但是这个目录下的日志文件权限几乎都是只有 root 才能读写,那么普通用户的日志如何写入呢?这就需要借助系统日志函数来写日志了。
root 用户授权给 syslogd 服务专门写日志,然后其它程序都需要通过封装好的一系列函数调用 syslogd 服务来记录日志。这样就提高日志的安全性,可以防止日志文件被非法篡改。
closelog, openlog, syslog - send messages to the system logger
#include <syslog.h>
void openlog(const char *ident, int option, int facility);
void syslog(int priority, const char *format, ...);
void closelog(void);
openlog(3) 函数并非是打开日志文件,而是与 syslogd 服务建立链接,表示当前进程要写日志。
参数列表:
ident:表明自己的身份,由程序员自行指定,写什么都行。
option:要在日志中附加什么内容,多个选项用按位或链接。LOG_PID 是附加 PID,这个是最常用的。
facility:消息来源。一般只能指定一个。
消息来源 | 含义 |
---|---|
LOG_CRON | 消息来自定时任务 |
LOG_DAEMON | 消息来自守护进程 |
LOG_FTP | 消息来自 FTP 服务 |
LOG_KERN | 消息来自内核 |
LOG_USER | 默认,常规用户级别消息 |
syslog(3) 函数用于提交日志内容,priority:优先级。详见下表:
级别 | 含义 |
---|---|
LOG_EMERG | 「严重」导致系统不可用的问题 |
LOG_ALERT | 「严重」必须立即处理的情况 |
LOG_CRIT | 「严重」临界条件 |
LOG_ERR | 「严重」错误 |
LOG_WARNING | 警告 |
LOG_NOTICE | 正常 |
LOG_INFO | 信息 |
LOG_DEBUG | 调试 |
以 LOG_ERR 为分界线,如果遇到了程序无法继续运行的问题,要报 LOG_ERR 以上的级别(包括 LOG_ERR)。
如果遇到的问题不会影响程序继续运行,报 LOG_ERR 以下级别的就可以了。
日志太多肯定对磁盘空间的要求就比较高,而且无用的日志太多会影响日志审计。日志文件中会记录哪些级别的日志是在配置文件中配置的,默认的情况是 LOG_DEBUG 以上级别的日志都会被记录。
format:类似于 printf(3) 函数的格式化字符串。注意不要使用转义字符 \n,否则日志中会记录一个字符串"\n"而不是记录一个换行符。
…:format 中占位符的参数。
closelog(3) 表示日志写入结束。
单实例守护进程
有些守护进程需要在同一时间只能有一个实例在运行,它们称为单实例守护进程。它们在启动的时候会在 /var/run 下面创建一个进程锁文件,守护进程启动的时候会先判断这个锁文件是否存在,如果已存在就报错并退出,如果不存在就继续运行并创建一个锁文件,退出的时候再删除它。
守护进程如果想要开机自动启动,可以配置到自动启动脚本中:/etc/rc.d/rc.local