目录
一、进程创建
fork函数
在Linux中fork函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,原进程为父进程,其中返回值:子进程中返回0,父进程返回子进程id,出错返回-1;
测试
cpp
#include <stdio.h>
#include <assert.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
printf("before fork, pid = %d\n", getpid());
pid_t id = fork();
assert(id != -1);//进程创建失败
(void)id;
printf("after fork, pid = %d, fork return %d\n", getpid(), id);
return 0;
}
上面代码执行路径如下图所示
进程调用fork,当控制转移到内核的fork代码后,内核做:
❍ 分配新的内存块和内核数据结构给子进程
❍ 将父进程部分数据结构内容拷贝至子进程
❍ 添加子进程到系统进程列表中
❍ fork返回,开始调度器调度
当父进程调用 fork()
时,会发生以下几件事情:
1.进程复制:操作系统会创建父进程的一个副本,这个副本就是子进程。子进程几乎与父进程完全相同,它们拥有相同的程序文本、数据段、堆栈、文件描述符等。
2.资源共享与复制:尽管子进程是父进程的一个副本,但是它们之间还是有所区别的,例如,它们有不同的进程ID(PID)、不同的父进程ID(PPID)以及一些独立的资源,如虚拟内存等。
3.执行流程 :fork()
调用之后,父进程和子进程都会从 fork()
函数调用后的下一条指令开始执行。
4.返回值 :fork()
在父进程中返回子进程的 PID,在子进程中返回 0,如果出错则返回 -1。
fork常规用法:
-
一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求
-
一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数
fork调用失败的原因
-
系统中有太多的进程
-
实际用户的进程数超过了限制
写时拷贝
写时拷贝(Copy-on-Write,简称COW)是一种计算机程序设计的优化策略。这种策略在多个进程试图写入同一块数据时,才会真正进行数据复制,而不是一开始就为每个进程分配独立的物理内存空间。
工作原理:
1.共享数据 :当父进程通过 fork()
创建子进程时,并不立即为子进程分配一份父进程数据段的副本。相反,父子进程共享同一块物理内存页。
2.写操作检测:操作系统会标记这些共享的内存页为"写时拷贝"。这意味着如果任何一个进程试图写入这些页,操作系统会捕捉到这个写操作。
3.数据复制:当写操作发生时,操作系统会触发页错误(page fault)。操作系统随后会创建一个新的内存页,并将原页的内容复制到新页上,然后将写操作指向新的内存页。对于其他进程,原内存页保持不变。
4.页分离:这个过程称为页分离(page splitting)。之后,每个进程都会有自己的内存页副本,对其中一个进程的修改不会影响到另一个进程。
优点:
-
效率提升 :在
fork()
调用后,不需要立即复制父进程的所有资源,减少了不必要的内存消耗和复制时间。 -
内存使用优化:只有在实际需要时才分配内存,这可以显著减少内存的使用。
-
性能提升:减少了进程创建时的开销,提高了系统的整体性能。
缺点:
-
写操作开销:第一次写操作时会有额外的开销,因为需要复制内存页。
-
复杂性:实现写时拷贝会增加操作系统内核的复杂性。
二、进程终止
想明白:终止是在做什么?
操作系统要释放进程申请的相关内核数据结构和对应的数据和代码(本质就是释放系统资源)。
进程退出场景
- 代码执行完毕,结果正确
cpp
#include <stdio.h>
int Add(int from, int to)
{
int sum = 0;
for(int i = from; i <= to; i++)
{
sum += i;
}
return sum;
}
int main()
{
printf("Add 1 to 100 is %d\n", Add(0, 100));
return 0;
}
cpp
[wuxu@Nanyi lesson16]$ gcc -o test test1.c -std=c99
[wuxu@Nanyi lesson16]$ ./test
Add 1 to 100 is 5050
- 代码运行完毕,结果不正确
cpp
#include <stdio.h>
int Add(int from, int to)
{
int sum = 0;
//此处应该是 i<=to
for(int i = from; i < to; i++)
{
sum += i;
}
return sum;
}
int main()
{
printf("Add 1 to 100 is %d\n", Add(0, 100));
return 0;
}
cpp
[wuxu@Nanyi lesson16]$ gcc -o test test1.c -std=c99
[wuxu@Nanyi lesson16]$ ./test
Add 1 to 100 is 4950
- 代码异常终止。即代码没有跑完,程序崩溃
cpp
#include <stdio.h>
int main()
{
int* p = NULL;
*p = 100;//空指针解引用--->野指针
return 0;
}
cpp
[wuxu@Nanyi lesson16]$ gcc -o test test2.c -std=c99
[wuxu@Nanyi lesson16]$ ./test
Segmentation fault
- 在程序执行结束时,我们会使用return语句返回一个数值作为main函数的返回值,这个返回值有什么用呢?
【例子1】张三参加一场考试,回家后给老爹汇报成绩
如果小明考了100分(满分100)那么他的老爹并不会关心他为什么考了100分;但当小明考了1分,他的老爹则会问他为什么考1分。因此做出如下约定,每个数字标识不同的原因:
状态码 | 描述 |
---|---|
1 | 考试过程中生病了导致没考好 |
2 | 没有好好学习导致没考好 |
... | ... |
在操作系统中,对于程序正常终止我们并不关心(正常程序终止返回状态码0),但程序一旦出现错误(返回码非0),我们就需要知道程序出错的原因。操作系统对于不同的状态码给了不同的错误描述信息,我们可以使用errno.h 下的 errno 变量获取错误码,使用 strerror(errno)
获取错误码的错误描述
cpp
#include <stdio.h>
#include <string.h>
int main()
{
for(int i = 0; i < 200; i++)
{
printf("[%d]->%s\n", i, strerror(i));
}
return 0;
}
- 谁会关心当前进程的退出码呢?-->父进程
父进程为何关心子进程的退出码?
◉ 错误处理:如果子进程因为错误而终止,它通常会返回一个非零的退出码。父进程可以根据这个退出码来决定是否需要采取补救措施,比如重新执行失败的子进程,或者记录错误信息。
◉ 流程控制:在某些情况下,父进程的后续行为可能依赖于子进程的成功执行。如果子进程返回一个表示成功的退出码(通常是0),父进程可以继续执行下一步操作;否则,它可能会停止执行或执行不同的代码路径。
◉ 状态报告:父进程可能需要向用户或其他进程报告子进程的执行结果。退出码是传递这种状态信息的简单方式。
在Linux中,可以使用echo $?
来查看最近一次执行的子进程的退出码
我们在回到刚刚野指针的例子上,重新执行一下程序:
cpp
[wuxu@Nanyi lesson16]$ gcc -o test test2.c -std=c99
[wuxu@Nanyi lesson16]$ ./test
Segmentation fault
[wuxu@Nanyi lesson16]$ echo $?
127
[wuxu@Nanyi lesson16]$ echo $?
0
通过观察我们发现同一个程序,为什么两次的退出码不一样?
其实第一个127是./test的执行码,表示这个程序出现了Segmentation fault错误,第二个执行码表示echo这个命令执行成功,返回0
一旦程序出现异常,退出码就没有意义了
为什么出现了异常?--> 我们可以看进程退出的时候,退出信号是多少,就可以判断进程为什么异常了。
进程出异常本质是因为进程收到了OS发给进程的信号
【示例】我们写一个除0的程序,看会出现什么错误
cpp
#include <stdio.h>
int main()
{
int a = 1 / 0;
return 0;
}
cpp
[wuxu@Nanyi lesson16]$ ./error
Floating point exception
在该程序发生错误时,操作系统给该程序的进程发送了8号信号SIGFPE。我们可以通过 kill -l
查看所有的信号码以及对应信号名
我们来验证一下,上面的程序时接收到8号信号才终止的
cpp
#include <stdio.h>
int main()
{
while(1)
{}
return 0;
}
常见信号码及其含义
信号码 | 信号名称 | 含义 |
---|---|---|
1 | SIGHUP | 挂起,通常在终端关闭或控制进程结束时发送给子进程。 |
2 | SIGINT | 中断,通常在用户按下Ctrl+C时发送。 |
3 | SIGQUIT | 退出,用户按下Ctrl+\时发送,通常会导致进程终止并生成核心转储。 |
4 | SIGILL | 非法指令,执行了非法的机器语言指令。 |
5 | SIGTRAP | 跟踪陷阱,由调试器使用。 |
6 | SIGABRT | 中止,调用abort()函数时发送。 |
7 | SIGBUS | 总线错误,涉及硬件错误。 |
8 | SIGFPE | 浮点异常,如除以零。 |
9 | SIGKILL | 杀死,无法捕获、阻塞或忽略,总是终止进程。 |
10 | SIGUSR1 | 用户定义的信号1,可用于应用程序。 |
11 | SIGSEGV | 段违例,访问非法内存地址。 |
12 | SIGUSR2 | 用户定义的信号2,可用于应用程序。 |
13 | SIGPIPE | 管道破裂,写入无读者的管道时发生。 |
14 | SIGALRM | 报警,由alarm()函数设置的时间到期时发送。 |
15 | SIGTERM | 终止,请求进程终止。 |
信号码 | 信号名称 | 含义 |
---|---|---|
16 | SIGSTKFLT | 栈溢出(Linux特有,在一些系统中不存在) |
17 | SIGCHLD | 子进程结束,子进程处于停止状态或被终止时发送给父进程。 |
18 | SIGCONT | 继续执行,如果进程已停止,则使其继续运行。 |
19 | SIGSTOP | 停止进程的执行,无法被捕获或忽略。 |
20 | SIGTSTP | 停止进程的执行,可以被捕获,通常在用户按下Ctrl+Z时发送。 |
21 | SIGTTIN | 后台进程组尝试读取控制终端时发送。 |
22 | SIGTTOU | 后台进程组尝试写入控制终端时发送。 |
23 | SIGURG | I/O紧急情况,套接字有紧急数据可读。 |
24 | SIGXCPU | 超过CPU时间限制(CPU时间限制超时)。 |
25 | SIGXFSZ | 超过文件大小限制。 |
26 | SIGVTALRM | 虚拟定时器警报(类似于SIGALRM,但是计算的是进程的虚拟时间)。 |
27 | SIGPROF | 性能计数器超时(类似于SIGALRM,但是包括了处理器时间和时钟时间)。 |
28 | SIGWINCH | 窗口大小改变,通常在终端窗口大小改变时发送。 |
29 | SIGIO | I/O可执行(Solaris系统中为SIGPOLL)。 |
30 | SIGPWR | 电源故障(系统关机)。 |
31 | SIGSYS | 系统调用异常(无效的系统调用)。 |
进程退出的常见方法
正常终止与异常终止
正常终止 (可以通过 echo $?
查看进程退出码)
-
从main函数返回
-
调用exit
-
_exit
异常终止
- ctrl + c 信号终止
exit与_exit的区别
-
终止处理程序和I/O缓冲区 :
exit()
会执行终止处理程序和I/O缓冲区的清理,而_exit()
则不会。 -
头文件 :
exit()
在stdlib.h
中定义,而_exit()
在unistd.h
中定义。 -
用途 :由于
_exit()
不会进行清理工作,它通常用于不需要这些清理步骤的底层系统编程。
【例子】
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("1 + 1 = %d", 1 + 1);
_exit(1);
return 0;
}
cpp
[wuxu@Nanyi lesson16]$ vim test5.c
[wuxu@Nanyi lesson16]$ gcc -o test test5.c -std=c99
[wuxu@Nanyi lesson16]$ ./test
[wuxu@Nanyi lesson16]$ echo $?
1
通过结果我们发现,并没有打印1+1=2这个结果,也就是_exit不会刷新缓冲区,故最后并没有打印。
如果换成exit
cpp
[wuxu@Nanyi lesson16]$ gcc -o test test5.c -std=c99
[wuxu@Nanyi lesson16]$ ./test
1 + 1 = 2 [wuxu@Nanyi lesson16]$ echo $?
1
我们会发现它打印出最终结果,顺便提醒一下 exit与_exit 头文件不一样哦
exit最后也会调用_exit
,但在exit除了调用_exit,还做了其他工作:
❍ 执行用户通过atexit或on_exit定义的清理函数
❍ 关闭所有打开的流,所有的缓存数据均被写入(即刷新缓冲区)
❍ 再调用_exit
return退出
return是一种更常见的退出进程方法。执行return n 等同于执行exit(n),因为调用main的运行时函数会将main的返回值当作exit的参数