Linux中的进程
认识冯诺依曼体系结构
冯诺依曼体系结构是大部分计算机系统需要遵循的规范,冯诺依曼体系结构认为在计算机系统的输入设备和输出设备进行交互的时候,需要有内存作为中介,即输入设备与输出设备的交互要通过内存间接完成。
存储器指的就是内存。计算机是由一个一个的硬件单元组成的,这些硬件单元都可被抽象为输入设备、输出设备、中央处理器(CPU).
- 输入设备:包括键盘,鼠标,写字板等,计算机可以从这些设备中获取我们输入的信息
- 中央处理器:中央处理器即CPU,CPU中存在运算器和控制器,还存在一套寄存器。经常提到的栈顶寄存器esp、栈底寄存器ebp、程序计数器(PC指针)都是CPU中的寄存器。
- 输出设备:包括显示器、打印机,等等
输入设备和输出设备统称为外设,在不考虑缓存的情况下,所有的外设只能从内存中读/写数据,CPU也只能直接和内存进行交互。以发送消息为例:我们通过键盘输入数据,输入的数据均被写入到内存,经过CPU对输入数据进行处理以后写回内存,网卡在从内存中读取经过CPU处理的数据,将消息发送到网络。在这个过程中键盘充当输入设备,网卡充当输出设备,它们都只能直接和内存交互。
冯诺依曼体系结构中内存的作用是解决因为不同硬件的运算速度差异导致的效率问题,由于外设的运算速度远远小于CPU,根据木桶原理,如果没有内存作为中介的话,CPU的使用率会大大下降,长时间处于等待状态。
认识操作系统
基本概念
操作系统是一个搞管理的软件,操作系统的管理对象是各种硬件和软件,包括磁盘、显示器、键盘、网卡、显卡、CPU、驱动程序等等。设计操作系统的目的就是管理好各种软硬件资源,使得它们能够正常、稳定、高效的工作。
操作系统的管理
一般而言,操作系统的管理分为4大模块
- 内存管理。内存管理要求操作系统能够合理的分配好各个进程对内存的使用,让内存能够稳定、高效的运行。既要保证用户的正常使用,又要保证内存的合理分配
- 进程管理。操作系统要管理好在内存中的每一个进程
- 文件管理。操作系统要管理好系统中的文件,包括已经打开的文件(在内存中)和没有被打开的文件(在磁盘上)
- 驱动管理。操作系统要管理好各种驱动程序,包括网卡驱动,硬盘驱动……
操作系统管理的方法:操作系统进行管理工作,是把各种抽象的软硬件资源通过特定的方式转换为内核中的数据结构进行管理。把对抽象资源的管理转化为对特定数据结构的增删查改操作。
操作系统的定位
操作系统是一款搞管理的软件,它对下管理好软硬件资源,对上要给用户提供一个良好的使用环境。管理软硬件资源是手段,给用户提供良好的使用环境是目的。
操作系统接口和语言接口
操作系统接口
操作系统接口又称系统调用,是由Linux操作系统给我们提供的函数接口,我们可以通过这些接口,实现对操作系统的访问,例如操作系统提供的文件接口open,close,read,write
可以让我们对系统中已经打开的文件进行操作。
查询操作系统的接口使用man 2 ...
,例如man 2 open
操作系统接口可以让我们近距离的与操作系统交互,通过这种方式访问操作系统是更加接近底层的。
库函数
库函数一般指的是语言库为我们提供的各种函数接口,例如C语言库和C++库,这些库所提供的各种函数是语言层面的接口。语言层面的接口具有的特点:
-
使用较为简单,例如使用C语言的fopen打开文件
FILE *fopen(const char *path, const char *mode); int main() { FILE* pf=fopen("./test.txt","r"); fclose(pf); return 0; }
-
任何库函数都是系统调用接口的封装,区别在于封装的深和浅。例如fopen是系统调用接口open的封装
库函数与系统调用的比较
系统调用接口的功能比较基础,更贴近操作系统,在使用上具有一定的难度,而库函数的功能比较完整,使用难度较系统调用低,更接近上层(用户层)。任何用户层使用的接口都是底层系统接口的封装。
进程
进程的基本认识
进程是担当分配系统资源的实体,进程是一个比较抽象的概念,在Linux系统存在专门的进程管理模块,一般认为进程=管理进程的数据结构+进程的代码和数据。
操作系统要对进程进行管理,需要先将进程描述为具体的数据结构,然后在把这些数据结构组织起来,简而言之就是先描述、在组织。
描述进程-PCB
描述进程的数据结构称为进程控制块PCB(process control block).在Linux中,描述进程的PCB是task_struct
。task_struct是Linux内核的一种数据结构,task_struct里面包含了进程的所有属性,操作系统通过对进程的task_struct进行操作,可以完成对进程的管理工作。
task_struct
-
task_struct是Linux中描述进程的结构体
-
task_struct是Linux内核维护的一种数据结构,只要创建一个进程,内核一定会为它创建一个task_struct,进程的task_struct会被装载到RAM(内存)中,并且task_struct包含进程的所有属性。
-
task_struct的内容。task_struct包含进程的所有属性,其中常见的有
struct task_struct { pid_t id;//描述进程的唯一标识符,用来区分该进程与其他进程,process id pid_t pid;//进程的父进程的标识符 状态;//包括进程的任务状态,进程的退出码,进程的退出信号 优先级; 内存指针;//指向进程在内存中对应的代码和数据 上下文数据;//进程在执行的时候CPU寄存器中存放的进程的临时数据。例如PC指针,记录当前进程需要执行的下一条指令的地址 I/O状态信息; //...... };
组织进程
由于Linux系统中存在大量的进程,一个task_struct只能管理一个进程,操作系统要管理所有的进程,就要把所有的进程的task_struct组织起来。Linux系统组织所有进程task_struct的方式是把所有进程的task_struct以双向链表的形式链接起来,实现对所有进程的管理。在进程的task_struct里面存在类似于下述的结构:
struct task_struct
{
struct task_struct* prev;
struct task_struct* next;
//......
};
查看进程
/proc
在Linux系统中,所有的进程的信息都放在/proc文件夹下。
使用命令ls /proc
可以查看系统中的进程。
[slowstep@localhost review]$ ll /proc
total 0
dr-xr-xr-x. 9 root root 0 Nov 3 15:54 1
dr-xr-xr-x. 9 root root 0 Nov 3 15:54 10
dr-xr-xr-x. 9 root root 0 Nov 3 15:54 1030
dr-xr-xr-x. 9 root root 0 Nov 3 15:54 1031
dr-xr-xr-x. 9 root root 0 Nov 3 15:54 1033
dr-xr-xr-x. 9 root root 0 Nov 3 15:54 1037
这个目录下的目录存放对应进程的信息,例如目录1031下存放进程id为1031的这个进程的信息。
[slowstep@localhost review]$ sudo ls -al /proc/1031
[sudo] password for slowstep:
total 0
dr-xr-xr-x. 9 root root 0 Nov 3 15:54 .
dr-xr-xr-x. 248 root root 0 Nov 3 15:54 ..
-r--r--r--. 1 root root 0 Nov 3 15:54 cmdline
lrwxrwxrwx. 1 root root 0 Nov 3 19:31 cwd -> /
lrwxrwxrwx. 1 root root 0 Nov 3 15:55 exe -> /usr/sbin/cupsd
其中cwd->/
表示id=1031这个进程的进程工作目录为根目录,exe->/usr/sbin/cupsd
表示这个进程对应的可执行二进制文件在磁盘的位置为/usr/sbin/cupsd。
ps命令
查看进程的信息可以通过ps命令。比较常用的2条ps命令
ps -axj | grep "关键字"
while : ; do ps -axj | head -1 && ps -axj | grep -v "grep" | grep "关键字" ; sleep 1 ; done #动态检测指定进程的相关信息
[slowstep@localhost review]$ while : ; do ps -axj | head -1 && ps -axj | grep "mybin" | grep -v "grep"; sleep 1 ; done;
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
3929 7842 7842 3929 pts/0 7842 S+ 1000 0:00 ./mybin
PID就是进程的id,PPID就是这个进程的父进程的id。STAT是这个进程的状态
创建进程-初识fork
fork函数是Linux系统为我们提供的创建子进程的系统调用,它的作用就是创建一个子进程。可以使用man fork
查看fork的相关介绍。
fork函数的返回值是整形,fork函数如果创建子进程失败会返回-1,如果创建子进程成功会给父进程返回子进程的pid,会给子进程返回0。
fork函数创建子进程失败
当系统中存在大量的进程,内存空间不够用时,fork函数会创建子进程失败,此时返回-1
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
while (1)
{
pid_t id = fork();
if (id == -1)
{
cout << "创建子进程失败" << endl;
exit(0);
}
else if (id == 0)
sleep(100000);
else // do nothing
;
}
return 0;
}
fork函数成功创建子进程
fork函数成功创建子进程有2个返回值
- 给父进程返回子进程的pid
- 给子进程返回0
因此在fork函数之后,可以使用if/else对fork函数的返回值进行判断,让父子进程执行不同的代码。
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
pid_t id = fork();
if (id == -1)
{
cout << "创建子进程失败" << endl;
exit(0);
}
else if (id == 0)
cout << "我是子进程,我的pid是" << getpid() << ",我的父进程的id是" << getppid() << endl;
else
{
sleep(3);
cout << "我是父进程,我的pid是" << getpid() << endl;
}
return 0;
}
fork函数的返回值
fork函数创建子进程成功有2个返回值,这两个返回值的地址一样,值不一样。
int main()
{
pid_t id = fork();
if (id == -1)
{
cout << "创建子进程失败" << endl;
exit(0);
}
else if (id == 0)
printf("fork给子进程返回的id的地址为%p,给子进程返回的id的值为%d\n", &id, id);
else
printf("fork给父进程返回的id的地址为%p,给父进程返回的id的值为%d\n", &id, id);
return 0;
}
[slowstep@localhost review]$ ./mybin
fork给父进程返回的id的地址为0x7fffc838f8ac,给父进程返回的id的值为3160
fork给子进程返回的id的地址为0x7fffc838f8ac,给子进程返回的id的值为0
fork函数内部的逻辑
pid_t fork()
{
创建子进程;
return id;
}
int main()
{
pid_t id=fork();
return 0;
}
fork函数在进行返回之前就已经把子进程创建出来了,父子进程都能执行到fork函数的return语句,同时由于父子进程有各自独立的进程地址空间和页表,在刚创建完成子进程时,父子进程的代码和数据是共享的,父子进程共享同一份id,在fork函数进行return的时候,对id进行了写入,发生了写时拷贝,导致父子进程的id值不同,但它们的id虚拟地址相同。
进程状态
task_state_array
Linux源码中有一个定义进程状态的数组task_state_array
,其定义位于源码的array.c
中。该数组中定义了进程的各种状态
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char *task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"T (tracing stop)", /* 8 */
"Z (zombie)", /* 16 */
"X (dead)" /* 32 */
};
-
R
R表示进程处于运行状态,说明进程在CPU的运行队列中,可能正在被CPU执行,也可能在等待CPU资源就绪。运行状态并非表示进程正在被CPU运行调度,而是表示进程处于CPU的运行队列中,在等待CPU资源的就绪
int main() { while (1) ; return 0; }
[slowstep@localhost day25]$ while : ; do ps -axj | head -1 && ps -axj | grep "mybin" | grep -v "grep"; sleep 1 ; done; PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 2906 3428 3428 2906 pts/0 3428 R+ 1000 0:09 ./mybin
该进程的STAT为R+,其中+表示进程是前台进程。
./mybin & #让进程以后台进程的方式运行
前台进程与后台进程的区别:前台进程在运行的时候不能执行其他命令,后台进程在运行的时候可以执行其它命令
-
S
S状态表示进程处于休眠状态(可以中断的休眠状态),处于休眠状态的进程是在等待某些非CPU资源的就绪,例如进程A准备向磁盘中写入数据,但是此时磁盘正在被其它进程访问,此时进程A就需要等待磁盘资源就绪。在系统中可能同时存在大量进程在等待非CPU资源的就绪(例如磁盘,可能有很多进行要向磁盘中写入数据),此时每一个进程需要在阻塞队列中排队。进程等待非CPU资源需要排队,这种队列叫做阻塞队列,进程在阻塞队列中排队,此时进程就处于S状态。
处于S状态的进程在等待非CPU资源就绪,也就是说,在短期内,可能不会立马被CPU调度,此时可以把这个进程在内存中对应的代码和数据换出到磁盘的SWAP分区,等到这个进程等待非CPU资源完毕,可以被CPU调度的时候在把它的代码和数据换入到内存,从而起到高效的利用内存资源的目的。这种方式称为挂起。
演示S状态的进程
int main() { sleep(100); return 0; }
[slowstep@localhost day25]$ while : ; do ps -axj | head -1 && ps -axj | grep "mybin" | grep -v "grep"; sleep 1 ; done; PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 2906 4153 4153 2906 pts/0 4153 S+ 1000 0:00 ./mybin
-
D
处于S状态的进程称为睡眠状态,这个睡眠状态是可中断睡眠,处于S状态的进程是可以通过向该进程发送信号来终止该进程的,另外处于S状态的进程,当系统的内存空间不足时,操作系统为了腾出内存资源可能会直接终止掉该进程,由此可能引发数据丢失等问题。因此进程除了S状态,还有D状态,D状态的进程称为磁盘睡眠,也被称为深度睡眠,是不可中断的睡眠,处于D状态的进程,具有和S状态进程相同的特性:也是在等待非CPU资源的就绪,也有可能被挂起。但是D状态的进程不能通过外力终止,只能自己主动终止,这也就意味着D状态进程可能长期占用内存资源,因此应当尽量避免系统中存在过多D状态进程
-
T/t
T/t表示进程处于暂停状态,最常见的场景是使用gdb进行调试时,通过打断点,让进程暂停。
[slowstep@localhost review]$ gdb mybin (gdb) l 1 #include <iostream> 2 #include <unistd.h> 3 using namespace std; 4 int main() 5 { 6 cout<<"hello world"<<endl; 7 return 0; 8 }(gdb) b 6 #在第6行打断点让进程暂停 Breakpoint 1 at 0x4007c6: file process.cc, line 6. (gdb) r #进程运行到第6行暂停 Starting program: /home/slowstep/mydir/day25/review/mybin Breakpoint 1, main () at process.cc:6 6 cout<<"hello world"<<endl; (gdb)
查看该进程的信息
[slowstep@localhost day25]$ ps -axj | head -1 && ps -axj | grep "mybin" | grep -v "grep" | grep -v "gdb" PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 6979 7020 7020 2906 pts/0 6979 t 1000 0:00 /home/slowstep/mydir/day25/review/mybin
-
Z
Z状态是进程处于僵尸状态。意思是这个进程的代码和数据已经执行完了,或者这个进程异常终止,此时,该进程就处于Z状态,处于Z状态的进程需要被操作系统或者这个进程的父进程来回收它的资源(即该进程在内存中的相关内核数据结构),否则该进程就一直处于Z状态,长期占用内存资源。处于Z状态的进程需要它的父进程进行进程等待来完成对这个进程的资源回收工作。
模拟Z状态的进程
int main() { pid_t id = fork(); if (id == 0) ; else if (id > 0) sleep(100); else exit(0); return 0; }
[slowstep@localhost day25]$ ps -axj | head -1 && ps -axj | grep "mybin" | grep -v "grep" PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 2906 7371 7371 2906 pts/0 7371 S+ 1000 0:00 ./mybin 7371 7372 7371 2906 pts/0 7371 Z+ 1000 0:00 [mybin] <defunct> #defunct:失效的
-
X
X状态表示进程已经正常终止,可以立马被操作系统或者这个进程的父进程回收,一般而言,在系统中无法查看到处于X状态的进程,因为X状态的进程会被它的父进程或者操作系统瞬间回收。任何进程在变为X状态之前都是Z状态进程,一个进程不可能代码和数据执行完毕以后直接变为X状态,如果是这样的话,也就不需要有进程等待等操作了。
孤儿进程
孤儿进程指的是一个进程的父进程已经终止,但是该进程还未终止,仍在运行。把这样的进程称为孤儿进程,孤儿进程会被一号进程init领养,等到其执行完毕变为Z状态,会由init来对它进行鉴定并完成资源的回收工作。孤儿进程一定要被1号进程init领养,否则等到其执行完毕或异常终止变为Z状态,会没有办法回收它的资源,从而造成操作系统层面的内存泄漏。
演示孤儿进程
int main()
{
pid_t id = fork();
if (id == 0)
{
while (true)
{
printf("我是子进程,我的id是%d,我的父进程id是%d\n", getpid(), getppid());
sleep(2);
}
}
else if (id > 0)
{
int cnt = 2;
while (cnt--)
{
printf("我是父进程,我的id是%d\n", getpid());
sleep(2);
}
}
else
exit(0);
return 0;
}
[slowstep@localhost review]$ ./mybin
我是父进程,我的id是8401
我是子进程,我的id是8402,我的父进程id是8401
我是子进程,我的id是8402,我的父进程id是8401
我是父进程,我的id是8401
我是子进程,我的id是8402,我的父进程id是8401
[slowstep@localhost review]$ 我是子进程,我的id是8402,我的父进程id是1 #子进程变为孤儿进程
^C
[slowstep@localhost review]$ 我是子进程,我的id是8402,我的父进程id是1
我是子进程,我的id是8402,我的父进程id是1
子进程一旦成为孤儿进程,会由前台进程变为后台进程。
进程的优先级
由于在系统中存在大量的进程,这些进程需要访问CPU资源,那么就会产生谁先访问,谁后访问的问题,调度器需要依照一个统一的标准来决定谁先访问,谁后访问,这个标准就是进程的优先级。
使用ps -l
命令查询进程的信息
[slowstep@localhost review]$ ps -l
F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
0 S 1000 2906 2541 0 80 0 - 29289 do_wai pts/0 00:00:00 bash
0 R 1000 8622 2906 0 80 0 - 38333 - pts/0 00:00:00 ps
其中PRI就是指的进程的优先级,默认进程的优先级的数值是80。NI表示nice值,进程实际的PRI=80+NI,进程的PRI的值越小,表示进程的优先级越高。NI的取值范围是[-20,19],这样取值的目的是为了保证系统中每一个进程都尽可能的得到公平的调度,防止恶意篡改进程的优先级导致的调度不公平问题。
更改进程的优先级可以使用top命令(需要root权限)
- top
- 在top的对话框输入r
- 输入进程的id
- 输入要设置的nice值
进程的性质
竞争性
CPU只有一个,进行与进程之间会竞争CPU资源,为了保证竞争的相对合理性,才有了进程优先级的概念。进程与进程之间的竞争不仅仅体现在对CPU资源的竞争,还体现在对非CPU资源的竞争。
独立性
进程与进程之间是相互独立的,一个进程运行奔溃不会影响另外一个进程,父子进程也是如此。
int main()
{
pid_t id = fork();
if (id == 0)
{
int *p = nullptr;
*p = 10;
}
else if (id > 0)
{
while (1)
{
cout << "hello world" << endl;
sleep(4);
}
}
else
exit(0);
return 0;
}
子进程对指针进行非法操作,父进程依然可以打印出"hello world"。进程独立性的一个保证就是每一个进程都有它们各自独立的内核数据结构,包括进程的task_struct,进程的进程地址空间mm_struct,进程的页表。
并发
并发指的是有多个进程,多个CPU,在同一时刻,有多个进行同时在被不同的CPU调度
并行
并行指的是只有一个CPU,每一个进程被CPU执行的时间只有一个时间片,一个时间片执行完毕,进程立马从CPU下来,同时带走自己的上下文数据(内含PC指针,记录该进程要执行的下一条指令的地址),让下一个进程在被CPU执行。由于进程在CPU上执行的时间片很短,所以在宏观看来,一段时间内(可以认为1s),有多个进程在被执行。
并发的CPU各自是并行的。
进程地址空间
语言层面的虚拟地址
在c/c++语言中,认为不同类型的变量存放在不同的区域,一般使用下面的图表示
此图反应了语言层面上理解的内存布局,它表示的是虚拟内存,而非物理内存。
int g_val = 0;
int main()
{
pid_t id = fork();
if (id == 0)
{
int cnt = 5;
while (cnt)
{
printf("子进程:g_val=%d,&g_val=%p\n", g_val, &g_val);
cnt--;
if (cnt == 3)
{
g_val = 3;
printf("子进程更改g_val为3\n");
}
sleep(1);
}
}
else if (id > 0)
{
int cnt = 5;
while (cnt)
{
printf("父进程:g_val=%d,&g_val=%p\n", g_val, &g_val);
cnt--;
sleep(1);
}
}
else
exit(0);
return 0;
}
运行结果
[slowstep@localhost review]$ ./mybin
父进程:g_val=0,&g_val=0x601070
子进程:g_val=0,&g_val=0x601070
父进程:g_val=0,&g_val=0x601070
子进程:g_val=0,&g_val=0x601070
子进程更改g_val为3
父进程:g_val=0,&g_val=0x601070
子进程:g_val=3,&g_val=0x601070
子进程:g_val=3,&g_val=0x601070
父进程:g_val=0,&g_val=0x601070
子进程:g_val=3,&g_val=0x601070
父进程:g_val=0,&g_val=0x601070
子进程更改g_val之后,父子进程的g_val的地址还是一样的,但是它们的值不一样,因为这个地址是进程地址空间的虚拟地址。我们在c语言中使用的指针,其指向的地址也是虚拟地址,包括函数的地址也是虚拟地址,在c/c++语言层面提及的地址均为进程地址空间的虚拟地址。
进程地址空间
mm_struct
进程地址空间是Linux内核抽象出来的一种数据结构,目的是为了避免进程对物理内存的直接访问,对于每一个进程,都有它各自独立的进程地址空间。进程地址空间在内核中是一个名为mm_struct
的结构体。
struct mm_struct {
//......
unsigned long total_vm, locked_vm, shared_vm, exec_vm;
unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
//start_code和end_code标定进程地址空间中代码区的起始地址和结束地址
//start_data和end_data标定数据段的起始和结束
unsigned long start_code, end_code, start_data, end_data;
//brk标定堆区
unsigned long start_brk, brk, start_stack;
//arg标定命令行参数,env标定环境变量
unsigned long arg_start, arg_end, env_start, env_end;
//......
};
每一个进程的进程控制块task_struct中存在一个指针mm,指向进程的mm_struct,用以维护进程地址空间的相关数据。
struct task_struct
{
//......
struct mm_struct *mm, *active_mm;
//......
};
由于进程地址空间中的地址均为虚拟地址,无法直接使用,需要与物理地址建立关联才能正常使用。把进程地址空间的虚拟地址与内存物理地址建立映射的数据结构称为页表。
页表
页表负责把进程地址空间的虚拟地址与内存的物理地址建立映射,把进程在进程地址空间的虚拟地址映射到它的代码和数据所在的物理内存地址,让进程所有访问物理内存的操作通过进程地址空间+页表间接完成,保证了物理内存的安全性。
例如
int main()
{
int* p=NULL;
*p=10;
return 0;
}
虚拟地址为NULL通过页表映射可以得到一个物理地址,页表不仅完成了映射,还维护了对于映射到的物理的读写权限。页表对于虚拟地址为NULL映射到的物理地址设置了不可读不可写的权限,因此,当尝试对NULL映射到的物理地址的数据进行访问(r or w)时,页表会阻止,同时操作系统也能根据页表作出的反应向该进程发送信号终止该进程,实现了对物理内存及其相关数据的保护工作。我们在语言上定义的常量字符串不可被修改也是这个原因
int main()
{
const char* p="abcdef";
*p='1';
return 0;
}
"abcdef"是常量字符串,存放在字符常量区。我们拿到它的虚拟地址,通过页表映射到物理地址想要对其修改时,页表会阻止我们的行为,因为它认为我们对于要访问的那块物理内存没有w权限,只有r权限。
任何我们在用户层的非法行为(例如使用野指针)能够被阻止,都是因为页表给我们设置了相关权限,拒绝了我们的读取 or 写入请求。页表+进程地址空间的这种机制,可以有效的保护好物理内存及物理内存中的相关数据,当用户尝试进行非法操作时,页表可以识别并直接拒绝映射。
物理内存是一个硬件,它本身是可以直接被读写的,只不过操作系统为了安全,设置了进程地址空间+页表阻止我们直接读写内存,另外,进程地址空间+页表的机制除了解决访问物理内存的安全问题,还能有效的提高物理内存的利用率,解决物理内存的碎片问题。因为映射机制的存在,进程的代码和数据可以加载到物理内存的任意位置。
进程地址空间与编译器
每当系统中多了一个进程,操作系统会首先在内存中为该进程创建它的task_struct、mm_struct、页表等内核数据结构。其中mm_struct结构内部的数据反应进程地址空间中区域的划分,这些数据是根据编译器给每一个程序提供的逻辑地址来初始化的。
c/c++语言属于编译型语言,需要编译器将其转化为汇编语言,最终转化为二进制才能变成可执行二进制程序,在这个过程中,编译器会给程序的每一个字段分配一个逻辑地址,当一个可执行二进制文件在磁盘上,还没有被加载到内存变成进程的时候,这个程序内部的每一个字段都已经有了一个逻辑地址,这个逻辑地址是由编译器分配的,编译器知道操作系统存在进程地址空间+页表的机制,它在编译程序的时候,就已经在程序内部把各个区域的地址划分好了,编译器提供的地址叫做逻辑地址,可以使用objdump -afh
查看。
[slowstep@localhost review]$ objdump -afh mybin
mybin: file format elf64-x86-64
mybin
architecture: i386:x86-64, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0000000000400560
#VMA virtual memory address
Sections:
Idx Name Size VMA LMA File off Algn
0 .interp 0000001c 0000000000400238 0000000000400238 00000238 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
在把程序加载到内存时,操作系统直接根据编译器给程序的每一个字段提供的逻辑地址来初始化进程的mm_struct。页表把mm_struct中的虚拟地址填到它的左侧,把进程的代码和数据在物理内存中的地址填到它的右侧,并建立映射关系,这个过程都是由操作系统完成的。
编译器给程序的每一个字段分配的地址称为逻辑地址,而进程地址空间中的地址又称为虚拟地址或线性地址。在Linux系统中,这三个地址是等价的,没有区别。
写时拷贝
当父进程使用fork创建子进程时,在刚开始,父子进程共享父进程在物理内存中的代码和数据,当子进程要对某些数据进行写入操作时,操作系统会为子进程在物理内存中拷贝一份要写入的数据,并更改子进程页表的映射关系,这种行为称为写时拷贝。
int g_val = 0;
int main()
{
pid_t id = fork();
if (id == 0)
{
int cnt = 5;
while (cnt)
{
printf("子进程:g_val=%d,&g_val=%p\n", g_val, &g_val);
cnt--;
if (cnt == 3)
{
g_val = 3;
printf("子进程更改g_val为3\n");
}
sleep(1);
}
}
else if (id > 0)
{
int cnt = 5;
while (cnt)
{
printf("父进程:g_val=%d,&g_val=%p\n", g_val, &g_val);
cnt--;
sleep(1);
}
}
else
exit(0);
return 0;
}
写时拷贝的技术可以使内存更加高效的使用,避免了不必要的数据拷贝,提高了效率。
进程控制
进程创建
创建子进程使用fork系统调用,当创建出一个进程时,操作系统应该完成下面的工作
- 为子进程创建相关的内核数据结构,包括task_struct、mm_struct、页表
- 以父进程的task_struct、mm_struct、页表为原型,对子进程的task_struct、mm_struct、页表的内容进行初始化。初始时,子进程的页表和父进程的页表映射的是同一块物理内存。以父进程为原型对子进程的内核数据结构进行初始化并非是把父进程的task_struct、mm_struct、页表的内容全部拷贝给子进程,只是把大部分内容拷贝给子进程。子进程的task_struct、mm_struct、页表内容大部分与父进程一致,子进程继承父进程的相关数据,必定是有所继承,有所修改。
- 把子进程加入到系统的进程列表中,并由调度器开始调度子进程
- 如果子进程要对数据进行写入,便发生写时拷贝,在内存中拷贝一份子进程要写入的数据,并修改子进程页表的相关映射
进程创建的目的是为了进行进程替换,父进程创建一个子进程,是希望子进程能够完成和它不一样的工作。
进程终止
进程终止指的是一个进程的代码和数据执行完毕,正常终止或进程出现异常被操作系统强行终止。进程终止的时候,操作系统要释放进程申请的相关内核数据结构和进程在物理内存中对应的代码和数据,这一工作本质上是在释放系统内存资源。
进程退出的方式
进程退出的常见方式:
- 进程的代码运行完毕,且运行结果正确
- 代码运行完毕,但结果不正确
- 代码异常终止,进程崩溃了
对于1和2,我们要关心的问题是运行结果不正确的原因。对于3,我们需要知道这个进程为什么会崩溃。
进程的代码全部执行完毕
对于代码跑完的情况,进程终止时会有对应的退出码。
int main()
{
printf("hello world\n");
return 0;
}
main函数里面的return 0就是进程的退出码,退出码为0表示该进程正常退出。
可以使用echo $?
来获取最近一次执行完毕的进程的退出码。
[slowstep@localhost day05]$ ./hello.out
hello world
[slowstep@localhost day05]$ echo $?
0
main函数的退出码可以是0,也可以是1,2,3……不同的退出码表示不同的含义。
[slowstep@localhost day05]$ cat hello.c
#include<stdio.h>
int main()
{
printf("hello world\n");
return 2;
}
[slowstep@localhost day05]$ ./hello.out
hello world
[slowstep@localhost day05]$ echo $?
2
进程的退出码是返回给上一级进程的,让上一级进程来评判该进程的执行结果。上面hello.out这个进程的父进程是命令行解释器bash,hello.out将自己的退出码返回给bash,供bash对这个进程的执行结果进行评判,如果该进程的父进程对这个进程的执行结果不关心的话,那么进程的退出码可以忽略。
进程的退出码除了区分进程执行的结果正确或者不正确之外,还能反应不正确的原因。如果进程在运行结束以后,结果结果不正确,可以通过退出码定位错误的原因。使用strerror
函数来得到退出码所对应的错误信息,strerror函数是c语言提供的库函数,头文件是<string.h>
SYNOPSIS
#include <string.h>
char *strerror(int errnum);
int main()
{
int i=0;
for(i=0;i<150;i++)
printf("退出码为%d对应的错误原因是%s\n",i,strerror(i));
return 0;
}
[slowstep@localhost day05]$ ./e_code.out
退出码为0对应的错误原因是Success
退出码为1对应的错误原因是Operation not permitted
退出码为2对应的错误原因是No such file or directory
一共有133个有效的退出码,这些退出码的含义是操作系统默认的,我们可以使用这些退出码和含义,也可以自己设计一套退出码方案。
有的进程的退出码含义和系统一致
[slowstep@localhost day05]$ ls asdfff
ls: cannot access asdfff: No such file or directory
[slowstep@localhost day05]$ echo $?
2
有的进程退出码含义与系统不一致
[slowstep@localhost day05]$ kill -9 1111111
-bash: kill: (1111111) - No such process
[slowstep@localhost day05]$ echo $?
1 #系统1号退出码对应的错误原因是Operation not permitted
程序崩溃,进程异常退出
程序崩溃,进程异常退出,那么进程的退出码是没有意义的,这时应该关心的问题是程序为什么会崩溃。
int main()
{
int* a=(int*)0x12345678;
*a=100;
return 0;
}
[slowstep@localhost day05]$ ./unnormal.out
Segmentation fault (core dumped)
[slowstep@localhost day05]$ echo $?
139 #程序崩溃,这个退出码没有意义
exit与_exit
exit与_exit可以在程序的任意位置终止进程,return只能在main函数里终止进程,main函数调用完毕,return表示进程终止,其他普通函数return都是表示正常返回。
exit是c语言提供的库函数,_exit是系统调用接口。
SYNOPSIS
#include <stdlib.h>
void exit(int status);//参数是进程的退出码
_EXIT(2) Linux
NAME
_exit, _Exit - terminate the calling process
SYNOPSIS
#include <unistd.h>
void _exit(int status);
exit和_exit可以在代码的任何地方调用,都表示直接终止进程。
int GetSum(int n)
{
int sum=0;
for(int i=0;i<n;i++)
sum+=i;
exit(6);
return sum;
}
int main()
{
int a=GetSum(10);
return 0;
}
[slowstep@localhost day05]$ ./testexit.out
[slowstep@localhost day05]$ echo $?
6
_exit
的作用与exit类似,区别为_exit
是系统调用,exit是c语言库函数,exit在终止进程的时候会刷新缓冲区,_exit
不会。c语言库函数exit是系统调用接口_exit
的封装,exit底层调用_exit
.在终止进程的时候,推荐使用exit,exit在终止进程的时候会做更多的事情,例如刷新缓冲区。
int main()
{
printf("you can see me");
sleep(1);
exit(0);//把exit换成_exit,不会刷新缓冲区
}
调用_exit
不会刷新缓冲区,_exit
是系统调用接口。通过这个例子也可以说明在C语言层面提及的缓冲区是由C标准库来维护的,不是由操作系统维护的
程序自动终止
如果不调用exit或_exit函数,在main函数中也不进行return返回退出码,那么进程在代码跑也会自动终止。这种情况下,默认退出码为0
int main()
{
printf("hello world\n");
}
[slowstep@localhost day05]$ ./hello.out
hello world
[slowstep@localhost day05]$ echo $?
0
进程等待
进程等待指的是父进程要调用wait/waitpid函数来回收处于僵尸状态的子进程
进程等待的意义
- 当一个父进程创建子进程时,如果父进程不管子进程,那么子进程就会处于Z状态,导致内存泄漏(操作系统级别的内存泄漏,而非语言层面的内存泄漏)。事实上,如果父进程不管子进程,也不关心派发给子进程的任务执行的怎么样了,而且父进程也不是常驻在内存的进程,那么父进程可以不管子进程,因为在父进程退出以后,处于Z状态的子进程会被操作系统回收并释放相关资源。但是在服务器端,许多进程都是从开始创建就一直执行,不会停止,这样的进程如果创建子进程而且不回收的话,一定会造成内存泄漏,随着时间的累计,服务器的可用内存空间会越来越小。
- c/c++语言中的malloc/new在堆区申请空间不释放会造成内存泄漏,这个内存泄漏会随着进程的退出而消失,但是操作系统层面的内存泄漏并不会,操作系统层面的内存泄漏只要父进程不调用wait/waitpid,并且父进程是服务器上永远不会退出的进程,那么内存泄漏问题就会一直存在
- 父进程创建子进程的目的是让子进程执行任务,父进程需要关心子进程任务执行的情况如何,而且还要关心子进程的状况如何,是把派发的任务执行完了,结果正确?或者结果不正确?还是说子进程在执行派发的任务时崩溃了?直接被操作系统终止了?这些问题父进程都要关心,因此,父进程要调用wait/waitpid来得到这些信息。
- 处于Z状态的进程只有操作系统和该进程的父进程可以处理它,而父进程一般是常驻内存的进程,所以处于Z状态的进程就只有依靠父进程来处理。一个处于Z状态的进程是刀枪不入的,即使是使用kill -9命令也无法杀掉这个进程,因为kill -9命令也没有办法杀掉一个已经死掉的进程。所以父进程有执行进程等待的必要性
- 任何一个进程,在执行完毕自己的代码和数据以后,都是由Z状态变为X状态的(它的父进程或者操作系统愿意回收它的情况下),并不是一个进程的代码执行完毕就直接变成X状态了(如果是这样,也就不需要进行进程等待了)
进程等待的方式
进程等待的方式:父进程调用wait/waitpid函数。wait/waitpid函数是系统调用接口。
WAIT(2) Linux
NAME
wait, waitpid, waitid - wait for process to change state
SYNOPSIS
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
wait
pid_t wait(int* status)
,wait函数是进行阻塞式等待,调用wait函数时,父进程会一直阻塞到wait函数的调用位置,直到子进程的状态发生变化才会继续执行后面的代码。wait函数的status是输入型参数,用来获取子进程的退出码和退出信号等信息。如果不想获取这些信息,传入NULL即可。
wait函数的返回值:wait函数调用成功,返回子进程的pid,调用失败的话,返回-1,如果父进程没有子进程,就会调用失败,返回-1.
int main()
{
pid_t id=fork();
if(id==-1)
{
printf("创建子进程失败\n");
exit(0);//调用exit终止进程
}
else if(id==0)
{
int cnt=3;
while(cnt--)
{
printf("我是子进程,pid是%d,ppid是%d\n",getpid(),getppid());
sleep(1);
}
exit(0);
}
else
{
printf("我是父进程,我的pid是%d\n",getpid());
sleep(5);
pid_t ret=wait(NULL);
if(ret==-1)
printf("调用wait函数失败\n");
else
printf("捕获到一个子进程,pid是%d\n",ret);
sleep(2);
printf("这是父进程wait函数之后的代码\n");
exit(0);
}
}
运行结果
[slowstep@localhost day05]$ ./wait.out
我是父进程,我的pid是7275
我是子进程,pid是7276,ppid是7275
我是子进程,pid是7276,ppid是7275
我是子进程,pid是7276,ppid是7275
捕获到一个子进程,pid是7276
这是父进程wait函数之后的代码
监测的结果
[slowstep@localhost day05]$ while : ;do ps -axj | head -1 && ps -axj | grep "wait.out" | grep -v "grep" ;echo "------------------------------" ;sleep 1;done
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
5677 7275 7275 5677 pts/2 7275 S+ 1000 0:00 ./wait.out
7275 7276 7275 5677 pts/2 7275 S+ 1000 0:00 ./wait.out
------------------------------
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
5677 7275 7275 5677 pts/2 7275 S+ 1000 0:00 ./wait.out
7275 7276 7275 5677 pts/2 7275 S+ 1000 0:00 ./wait.out
------------------------------
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
5677 7275 7275 5677 pts/2 7275 S+ 1000 0:00 ./wait.out
7275 7276 7275 5677 pts/2 7275 S+ 1000 0:00 ./wait.out
------------------------------
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
5677 7275 7275 5677 pts/2 7275 S+ 1000 0:00 ./wait.out
7275 7276 7275 5677 pts/2 7275 Z+ 1000 0:00 [wait.out] <defunct> #子进程变为Z状态
------------------------------
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
5677 7275 7275 5677 pts/2 7275 S+ 1000 0:00 ./wait.out
7275 7276 7275 5677 pts/2 7275 Z+ 1000 0:00 [wait.out] <defunct>
------------------------------
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
5677 7275 7275 5677 pts/2 7275 S+ 1000 0:00 ./wait.out #父进程调用wait函数成功回收子进程
------------------------------
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
5677 7275 7275 5677 pts/2 7275 S+ 1000 0:00 ./wait.out
------------------------------
初始时,父子进程都正常运行,为S+状态,3秒后,子进程执行完毕,变为Z状态,此时父进程还没有调用wait函数来回收子进程,又过了2秒,父进程调用wait对子进程进行回收,并释放子进程的相关资源,返回子进程的pid,又过2秒,父进程执行完毕,被操作系统回收。
调用wait函数可以实现对Z状态子进程的回收工作,这里调wait函数传入的参数为NULL,表示不关心子进程执行结果正确与否,也不关心子进程是否异常终止,目的只有回收处于Z状态的子进程。
wait函数调用失败
int main()
{
pid_t ret=wait(NULL);//不存在子进程,调用wait函数会失败,返回-1
printf("ret=%d\n",ret);
return 0;
}
当子进程崩溃的时候也会处于Z状态,等待父进程回收,此时调用wait函数是成功的。
int main()
{
pid_t id=fork();
if(id==-1)
exit(0);
else if(id==0)
{
int* p=(int*)0x12345678;
*p=10;//子进程崩溃,被操作系统终止.变为Z状态,等待父进程回收
exit(0);
}
else
{
sleep(2);
pid_t ret=wait(NULL);
printf("ret=%d\n",ret);
exit(0);
}
}
[slowstep@localhost day05]$ while : ;do ps -axj | head -1 && ps -axj | grep "wait.out" | grep -v "grep" ;echo "------------------------------" ;sleep 1;done
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
5677 8016 8016 5677 pts/2 8016 S+ 1000 0:00 ./wait3.out
8016 8017 8016 5677 pts/2 8016 Z+ 1000 0:00 [wait3.out] <defunct>
------------------------------
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
5677 8016 8016 5677 pts/2 8016 S+ 1000 0:00 ./wait3.out
8016 8017 8016 5677 pts/2 8016 Z+ 1000 0:00 [wait3.out] <defunct>
------------------------------
wait函数的不足是当父进程调用wait函数的时候,是在阻塞式的等子进程,此时父进程在阻塞队列中,处于挂起状态。父进程在调用wait函数的时候不能执行wait函数后面的代码。
waitpid
pid_t waitpid(pid_t pid,int* status,int options);
waitpid函数也是回收子进程的系统调用函数。
参数
-
pid_t pid
,想要等待的子进程的pid.pid=-1表示等待任意一个子进程。waitpid(-1,NULL,0);//表示等待任意一个子进程,相当于wait(NULL) waitpid(1234,NULL,0);//表示等待pid为1234的子进程,如果没有这个子进程,直接返回-1
-
int* status
,输出型参数,拿到子进程的退出结果。status为NULL表示不关心子进程的退出结果。int main() { pid_t id=fork(); if(id==-1) exit(0); else if(id==0) { printf("我是子进程,我的退出码是10\n"); exit(10); } else { int status=0; int ret=waitpid(-1,&status,0); printf("子进程的退出结果为%d\n",status); exit(0); } }
结果
[slowstep@localhost day05]$ ./waitpid.out 我是子进程,我的退出码是10 子进程的退出结果为2560
status的值与子进程的退出码并不一致。原因在于status虽然是输出型参数,但是它并不完全表示子进程的退出码,它还存储子进程收到的信号信息,还存储core dump标志.status的使用是按照比特位使用的。这里只说明最低16个比特位的作用。
所以子进程的退出码应该用
(status>>8)&0x000000FF->(status>>8)&0xFF
,子进程的退出信号应该用status&0x0000007F->status&0x7F
,core dump标志用(status>>7)&1
int main() { pid_t id=fork(); if(id==-1) exit(0); else if(id==0) { printf("我是子进程,我的退出码是10\n"); exit(10); } else { int status=0; int ret=waitpid(-1,&status,0); printf("子进程的码为%d,退出信号为%d,core dump为%d\n",(status>>8)&0xFF,status&0x7F,(status>>7)&1); exit(0); } }
结果
[slowstep@localhost day05]$ ./waitpid.out 我是子进程,我的退出码是10 子进程的退出码为10,退出信号为0,core dump为0
如果子进程崩溃,那么操作系统会给子进程发信号,并且杀掉子进程,让其直接变为Z状态。父进程在调用waitpid时可以传入status来拿到子进程的退出信号,判断子进程是运行崩溃被操作系统杀掉变成Z状态的,还是自己运行完毕然后变成Z状态的。
获取退出码并不需要每一次都使用
(status>>8)&0xFF
,有准备好的宏可以使用。#define WEXITSTATUS(status) ((status>>8)&0xFF) //获取退出码 #define WTERMSIG(status) (status&0x7F) //获取退出信号 #define WIFEXITED(status) (status&0x7F==0)//判断是否正常退出。这三个宏都在<stdlib.h>和<waitstatus.h中>中
一般父进程可以这样拿子进程的退出结果
if(WIFEXITED(status)) printf("子进程正常退出,退出码为%d\n",WEXITSTATUS(status)); else printf("子进程异常退出,退出信号为%d\n",WTERMSIG(status));
常用的退出信号有31个,退出信号没有0号,0是表示正常退出。
[slowstep@localhost day05]$ kill -l 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP 21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR 31) SIGSYS #没有32和33号退出信号 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3 38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8 43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13 48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12 53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7 58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2 63) SIGRTMAX-1 64) SIGRTMAX
1~31号退出信号是常见的退出信号。
int main() { pid_t id=fork(); if(id==-1) exit(0); else if(id==0) { printf("我是子进程,我的退出码是10\n"); int a=10/0;//子进程崩溃,被操作系统杀掉,变为Z状态 exit(10); } else { int status=0; int ret=waitpid(-1,&status,0); printf("子进程的退出码为%d,退出信号为%d,core dump为%d\n",WIFSTATUS(status),WTERMSIG(status),(status>>7)&1); exit(0); } }
结果
[slowstep@localhost day05]$ ./waitpid.out 我是子进程,我的退出码是10 子进程的退出码为0,退出信号为8,core dump为1 #退出信号非0,异常退出。退出码无意义
8号信号SIGFPE,float point exception,浮点异常。当退出信号不为0时,表示子进程崩溃,异常退出,变为Z状态,此时子进程的退出码没有任何意义。
子进程异常退出,可能是内部代码出了问题,也可能是外力直接把子进程杀掉了,例如使用kill -9把子进程杀掉,子进程也属于异常退出,此时子进程收到的退出信号是9.
waitpid函数是通过子进程的task_struct来拿到子进程的退出结果(退出码和退出信号)的。当子进程退出(代码执行完毕正常退出或者被操作系统杀掉异常退出)变为Z状态时,子进程会把自己的退出码和退出信号写入到自己的task_struct结构体,供操作系统来读取。在内核源代码sched.h头文件中可以看到,进程的task_struct里面有成员变量
exit_state,exit_code(退出码),exit_signle(退出信号)
//截取部分task_struct struct task_struct { struct mm_struct *mm, *active_mm;//指向进程地址空间mm_struct的指针 /* task state */ int exit_state; int exit_code, exit_signal;//存放子进程退出时的退出码和退出信号 };
wait和waitpid函数就是通过把子进程task_struct里面的exit_code和exit_signle经过位操作写入到status中供父进程查看。
子进程的exit_code, exit_signal属于子进程task_struct中的重要内容,wait和waitpid函数是系统调用,操作系统是有权利去拿子进程task_struct里面的数据的,因为子进程的task_struct就是由操作系统创建的。进程具有独立性,父进程没有权利直接拿到子进程task_struct里面的数据,但是父进程可以使用系统调用接口waitpid间接拿到。
-
int options
,options为0表示默认父进程是在阻塞状态去等待子进程状态变化。waitpid(-1,&status,0)等价于wait(&status)
options为0的话,只有子进程退出,变为Z状态的时候,父进程调用的waitpid函数才会返回。这种设置可以保证父子进程的退出具有一定的顺序性,一定是子进程先退出,然后父进程才退出,可以让父进程进行更多的收尾工作,但是父进程在等待的时候,会被放入阻塞队列,可能被挂起。如果想要父进程进行非阻塞式等待,可以把最后一个参数options设置为1,表示非阻塞式等待。
waitpid(-1,&status,1)//1表示非阻塞式等待。
类似于0和1这种魔术数字一般可以替代的宏,表意更加清晰
#define WNOHANG 1
WNOHANG
表示这个父进程在调用waitpid时是进行非阻塞式等待,可以被CPU调度。
waitpid函数的返回值
- 如果父进程调用waitpid函数成功的回收了处于Z状态的子进程,返回子进程的pid.
- 如果子进程根本就不存在或者waitpid调用出错,返回-1
- 如果options=0,表示阻塞等待,直到子进程变为Z状态,然后回收子进程,返回子进程的pid
- options=
WNOHANG
.调用waitpid,如果子进程没有执行完毕,直接返回0,如果子进程执行完毕变为Z状态,那么回收子进程并且返回子进程的pid,如果子进程不存在或者调用出错,返回-1. - options=
WNOHANG
就意味着要多次调用waitpid,因为父进程也不知道子进程什么时候执行完毕变成Z状态。不过这样的话父进程就可以不用一直处于阻塞状态了,父进程非阻塞等待,可以执行其他的任务
简单演示waitpid函数的使用
#include <unistd.h>
#include <iostream>
#include <vector>
#include <sys/wait.h>
#include <sys/types.h>
#include <stdlib.h>
using std::vector;
typedef void (*func_pointer)(); //函数指针类型typedef
void Func1()
{
printf("这是功能1\n");
}
void Func2()
{
printf("这是功能2\n");
}
int main()
{
vector<func_pointer> v{Func1, Func2};
pid_t id = fork();
if (id == -1)
exit(0);
else if (id == 0)
{
int cnt = 10;
while (cnt--)
{
printf("我是子进程\n");
sleep(1);
}
}
else
{
int status = 0;
int ret = 0;
while (1)
{
printf("---------------------------------------------\n");
printf("我是父进程\n");
ret = waitpid(id, &status, WNOHANG); //非阻塞等待
if (ret == -1)
{
printf("waitpid函数调用失败,可能没有子进程\n");
break;
}
else if (ret == 0)
{
printf("子进程还没有执行完毕,waitpid函数已返回0,父进程可继续执行其他代码\n");
printf("父进程要调用一些功能了\n");
for (auto e : v)
e();
}
else
{
printf("回收子进程成功!waitpid函数返回了子进程的pid,是%d\n", ret);
if (WIFEXITED(status))
printf("子进程是正常退出的,退出码为%d\n", WEXITSTATUS(status));
else
printf("子进程是异常退出的,退出信号为%d\n", WTERMSIG(status));
break; //回收子进程成功了就退出while循环,父进程继续执行其他代码
}
sleep(1);
}
printf("父进程继续执行其他代码\n");
exit(0);
}
}
wait/waitpid函数的内部实现逻辑
pid_t wait(int* status)
{
检测父进程是否有子进程;
if(没有子进程)
return -1;
else
{
阻塞父进程,将父进程挂起,持续检测子进程的状态,直到子进程变成Z状态;
检测到子进程变为Z状态以后,将父进程从阻塞队列调度到运行队列;
if(status!=NULL)
*status=从子进程的task_struct里面找到它的退出码和退出信号并进行位操作;
return 子进程的pid;
}
}
wait函数是系统调用接口,可以控制父进程的进程状态。
pid_t waitpid(pid_t pid,int* status,int options)
{
if(pid!=-1&&父进程没有进程id为pid的子进程)
return -1;
if(options==0)
执行wait函数的逻辑;
if(options==WNOHANG)//非阻塞等待
{
检测子进程是否执行完毕变为Z状态;
if(子进程没有执行完毕)
return 0;
else
{
if(status!=NULL)
*status=从子进程的task_struct里面找到它的退出码和退出信号并进行位操作;
return 子进程的pid;
}
}
}
进程替换
进程替换的基本概念
父进程调用fork函数创建子进程,一般而言,父子进程代码共享,数据写时拷贝。即子进程只能执行父进程代码的一部分,这种情况下,一般使用if/else分流来控制父子进程各自能够执行的代码。如果想让子进程执行一个全新的程序,有自己的代码和数据,就要使用进程替换。
进程替换是指通过使用特定的系统调用接口,加载磁盘上一个程序的代码和数据到内存中,让进程的页表重新映射物理内存。
一般情况下父子进程的代码共享,数据写时拷贝
现在想要达到的效果是加载磁盘中一个程序的代码和数据到内存中,然后子进程的页表重新建立映射,不在使用父进程的代码和数据,达到进程替换的目的。
进程替换不是创建一个新的进程,是把已有进程的页表重新建立映射关系,操作系统并没有给B建立它的内核数据结构。进程替换需要使用到exec系列的函数,exec系列的函数可以把程序从磁盘加载到内存,并且完成进程替换的工作,exec系列函数的功能就是加载器,可以加载任何程序,包括系统的可执行二进制文件,例如可执行命令,也能加载我们自己写的执行程序。
exec系列函数
exec系列的函数有execl, execlp, execle, execv, execvp, execvpe
它们的头文件都是unistd.h
EXEC(3)
NAME
execl, execlp, execle, execv, execvp, execvpe - execute a file
SYNOPSIS
#include <unistd.h>
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[]);
exec系列的函数会把命令行参数以...(可变参数)
或指针数组
的方式传递给main函数,这里的main函数指的是进程在调用exec系列函数后页表重新建立映射以后对应的代码和数据里的main函数。
execl
int execl(const char* path,const char* arg,...)
参数...
表示的是可变参数。execl的使用举例:
execl("usr/bin/ls","ls","-l","-a",NULL);//表示把ls这个可执行程序的代码和数据加载到内存,当前进程的页表重新映射,"-l","-a"是可变参数,可变参数以NULL结束。表示当前进程经过页表重新映射以后进程执行的任务是ls -l -a
execl("usr/bin/top","top",NULL);
int main()
{
printf("开始\n");
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
printf("结束\n");
return 0;
}
运行结果
[slowstep@localhost day06]$ ./mybin
开始
total 20
drwxrwxr-x. 2 slowstep slowstep 49 Sep 23 15:27 .
drwxrwxr-x. 10 slowstep slowstep 118 Sep 23 14:52 ..
-rw-rw-r--. 1 slowstep slowstep 226 Sep 23 15:27 exec.c
-rw-rw-r--. 1 slowstep slowstep 63 Sep 23 15:07 makefile
-rwxrwxr-x. 1 slowstep slowstep 11024 Sep 23 15:27 mybin
在调用execl以后,进程的代码和数据全部被替换,包括进程已经被执行的代码和数据都会被替换,"开始"能被打印出来的原因是execl是在第一个printf之后才调用的。
execl函数一旦执行成功,该进程后续的所有代码都不会在被执行。execl函数调用失败返回-1,调用成功没有返回值,也不需要返回值,因为execl函数一旦调用成功,该进程的所有代码和数据都会被替换,包括调用的execl本身也会被替换。所以execl函数调用成功没有返回值。
使用execl也可以替换自己写的程序,也可以使用c语言调用fork创建子进程,让子进程使用execl函数把自己的页表映射到其它程序的代码和数据上,并执行这些程序。
test.c
int main(int argc, char *argv[], char *env[])
{
printf("这是新功能\n");
if (strcmp(argv[1], "-a") == 0)
printf("提供-a参数的功能\n");
if (strcmp(argv[2], "-b") == 0)
printf("提供-b参数的功能\n");
return 0;
}
execl("/home/slowstep/mydir/day06/test.out", "./test.out", "-a", "-b", NULL);
一般使用execl的方法都是父进程创建子进程,让子进程去调用execl函数。父进程通过创建子进程,并让子进程调用execl函数,可以让子进程的代码和数据进行替换,执行其它任务,父进程则可以专注于读数据,取数据和解析数据。
子进程调用execl函数加载新程序,会实现父子进程代码和数据的分离。一般情况下,父子进程代码共享,数据写时拷贝。但是调用execl可以把父子进程的代码和数据都分离。
execv
int execv(const char* path,char* const argv[])
,execv函数的使用与execl类似,execl的’l’可以理解为list,表示参数在execl函数的参数列表一串传进去。execv的’v’可以理解为vector,表示把参数以数组的方式传进去。execl与execv的区别在于传参的方式不同
char* _argv[]={"ls","--color=auto","-l","-a",NULL};
execv("usr/bin/ls",_argv);
execlp
int execlp(const char* file,const char*arg,...)
p表示只需要说明可执行程序的名称,不用指定路径,会自动到环境变量中去找,p表示PATH,一般使用exec系列的函数替换系统的可执行程序使用execlp.
execlp("ls","ls","--color=auto","-l","-a");
execvp
int execvp(const char* file,char* const argv[])
char* _argv[]={"ls","--color=auto","-i","-a","-l",NULL};
execvp("ls",_argv);
execle
int execle(const char* path,const char* arg,...,char* const envp[])
参数envp表示环境变量。execle会把命令行参数和环境变量进行传递。execl,execv,execlp,execvp只会把命令行参数进行传递。execle中最后一个’e’指的是env环境变量。
int main(int argc,char* argv[],char* env[])
{
char* myenv[]={"slowstep=100","SLOWSTEP=20",NULL};
execle("./a.out","a.out","-a",NULL,myenv);//环境变量具有具有全局属性的原因是execle把main函数中的env环境变量传递了下去
exit(0);
}
execvpe
int execvpe(const char* file,char* const argv[],char* const envp[])
char* _argv[]={"ls","--color=auto","-l","-a",NULL};
char* _envp[]={"SLOWSTEP=15","slowstep=20"};
execvpe("ls",_argv,_envp);
execle,execvpe,最后一个字母是e表示需要自己维护环境变量。环境变量具有全局属性,本质上是因为在调用该函数的时候把环境变量作为参数传递了下去。
execve
execl,execlp,execle,execv,execvp,execvpe
都不是系统调用,而是对系统调用的封装。execve
才是系统调用。int execve(const char* filename,char* const argv[],char* const envp[])
,对execve系统调用接口的封装是为了满足上层不同的调用场景。
利用进程替换实现一个简单的shell
原理:父进程创建子进程,让子进程执行各种命令。父进程等待子进程并且进行解析。
#include <stdio.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#define NUM 1024
#define SEP " " //定义空格为分割标志
int main()
{
while (1) //命令行解释器一定是一个死循环
{
printf("[root@localhost root]# ");
fflush(stdout); //刷新到屏幕
static char str[NUM] = {0}; //存放读取的字符串
memset(str, 0, sizeof str);
while (fgets(str, sizeof str, stdin) == NULL) //不使用scanf,scanf不能读取空格
continue; // fgets读取失败会返回NULL
str[strlen(str) - 1] = 0; // fgets会读取\n,要把最后的\n变成\0 ls\n
static char *_argc[] = {0}; //用来保存解析的字符串
memset(_argc, 0, sizeof _argc);
int index = 0;
_argc[index++] = strtok(str, SEP); //使用strtok拆分串 "ls -a -l" -> "ls" "-a" "-l"
if (strcmp(_argc[0], "ls") == 0)
_argc[index++] = "--color=auto";
if (strcmp(_argc[0], "ll") == 0)
{
_argc[--index] = "ls";
index++;
_argc[index++] = "-l";
_argc[index++] = "--color=auto";
}
while (_argc[index++] = strtok(NULL, SEP))
;
if (strcmp(_argc[0], "cd") == 0) //子进程进行cd父进程目录没有发生变化
{
chdir(_argc[1]); //使父进程目录变化
continue;
}
pid_t id = fork();
if (id == -1)
exit(0);
else if (id == 0) //让子进程执行各种命令
execvp(_argc[0], _argc); //选择execvp函数,直接到环境变量中去找
else //每一次父进程负责回收子进程
{
int status = 0;
pid_t ret = waitpid(id, &status, 0); //父进程要调用waitpid等待回收子进程,阻塞式等待
}
}
exit(0);
}