在程序是怎么运行中,也讲到进程,但由于篇幅和主题原因,并没有详细介绍;这一次,就要好好介绍一下进程,进程这个概念很多,并且也是操作系统的核心。
8.1 进程
8.1.1 什么是进程
什么是进程?写概念太让人难受了。
我们从第一篇开始,开始写了第一个c语言,然后编译链接,最后生成了一个可执行文件,这个文件叫程序。这个可执行文件里面有什么?可以看这一篇文章重学计算机(三、elf文件布局和符号表),里面包含了各个段,为程序加载做准备。
当我们把这个可执行程序运行起来后,(没有结束之前),它就是一个进程了。
程序是怎么运行的,进程和程序的区别,可以看这一篇:重学计算机(六、程序是怎么运行的)。
从专业的角度来讲:进程是操作系统分配资源的基本单位。
进程拥有自己独立的处理环境:环境变量、程序运行目录、进程组等。
进程拥有自己独立的系统资源:处理器CPU的占用率、存储器、I/O设备,数据、程序。
8.1.2 并行和并发
在以前的操作系统中,是存在单道程序设计。
所谓的单道程序设计是所有进程一个一个排队执行,如果A阻塞了,B也只能等待。
相比之下,现在计算机系统允许加载多个程序到内存,以便于并发执行。并发执行其实就是CPU由一个进程快速切换到另一个进程,使每个进程都可以运行一段时间。
通过这个图就明白了,从时间点来看,CPU只能运行一个程序,但从时间段来看,CPU可以运行多个程序。
正因为需要切换,所以在计算机中时间中断即为进程切换提供了硬件保证。这个下一节再讲。
哪并行是什么呢?
并行是真正的硬件并发,小阳台两个或多个CPU共享同一个物理内存。
看图业明白了,这是正在的并行执行,互不干扰。
8.1.3 进程的创建
在重学计算机(六、程序是怎么运行的)中也提到了fork()。没错,在linux系统下,如果想创建一个进程,就需要调用fork(),fork()是linux的系统API,所有资源都被操作系统管理,当然也包含进程了。(讲到这里是不是也想知道系统调用是怎么调用的?这个我们再讲)
#include <unistd.h>
pid_t fork(void);
/* 功能:
用于从一个已存在的进程中创建一个新进程,新进程称为子进程,原进程称为父进程。
参数:
无
返回值:
成功:子进程中返回0,父进程中返回子进程ID。pid_t为整型
失败:返回-1。
失败的两个主要:
1)当前的进程已经达到了系统规定的上限,这时errno的值被设置为EAGAIN。
2)当系统内存不足,这时errno的值被设置为ENOMEM。
*/
接下来我们就用这个函数来创建第一个子进程,
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
int main(int argc, char **argv)
{
printf("hello fork\n");
pid_t pid = fork();
if(pid < 0)
{
printf("fork fail %d\n", errno);
}
else if(pid == 0) // 这是子进程
{
printf("I am son\n");
}
else // 大于0的为父进程
{
printf("parent %d\n", pid);
}
return 0;
}
输出:
root@ubuntu:~/c_test/08# ./fork
hello fork
parent 1524
I am son
fork之后,对于父子进程,哪个先获取CPU资源呢?
在内核2.6.32开始,在默认情况下,父进程将成为fork之后优先调用的对象。采取这种策略的原因:fork之后,父进程在CPU中处于活跃状态,并且其内存管理信息也被置于硬件单元的转译后备缓冲器(TLB),所以优先调度父进程能提升性能。《linux环境编程:从应用到内核》
但是在POSIX标准和linux都没有保证会优先调度父进程。所以在应用中,不能假设父进程先调用,如果需要按顺序调用,需要用到进程同步。
注意:
fork的返回一定需要处理,如果不处理,返回-1,把-1当做进程号,然后调用kill函数的话,kill(-1, 9)会把除了init以外的所有进程都杀死,当然需要权限。
8.1.4 父子进程内存关系
fork之后的子进程完全拷贝了父进程的地址空间,包括了栈、堆、代码段等。
写一段程序来看一下效果:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int g_a = 10; // 全局变量
int main(int argc, char **argv)
{
int local_b = 20; // 局部变量
int *malloc_c = malloc(sizeof(int));
*malloc_c = 30; // 堆变量
pid_t pid = fork();
if(pid < 0)
{
perror("fork");
return -1;
}
if(pid == 0) {
// 子进程
printf("son g_a:%d p:%p local_b:%d p:%p malloc_c:%d p:%p\n", g_a, &g_a, local_b, &local_b, *malloc_c, malloc_c);
} else if(pid > 0) {
// 父进程
printf("parent g_a:%d p:%p local_b:%d p:%p malloc_c:%d p:%p\n", g_a, &g_a, local_b, &local_b, *malloc_c, malloc_c);
}
if(pid == 0) {
// 子进程
g_a = 11;
local_b = 21;
*malloc_c = 31;
printf("son g_a:%d p:%p local_b:%d p:%p malloc_c:%d p:%p\n", g_a, &g_a, local_b, &local_b, *malloc_c, malloc_c);
} else if(pid > 0) {
// 父进程
sleep(1);
printf("parent g_a:%d p:%p local_b:%d p:%p malloc_c:%d p:%p\n", g_a, &g_a, local_b, &local_b, *malloc_c, malloc_c);
}
while(1);
return 0;
}
这里专门定义了3个变量,一个是数据段中的全局变量,一个是栈上的局部变量,一个是堆里的动态变量。
我们写代码,也基本是使用这3中类型的变量,我们编译运行一下:
root@ubuntu:~/c_test/08# ./test_mem
parent g_a:10 p:0x601060 local_b:20 p:0x7ffe57755668 malloc_c:30 p:0x1aae010
son g_a:10 p:0x601060 local_b:20 p:0x7ffe57755668 malloc_c:30 p:0x1aae010
son g_a:11 p:0x601060 local_b:21 p:0x7ffe57755668 malloc_c:31 p:0x1aae010
parent g_a:10 p:0x601060 local_b:20 p:0x7ffe57755668 malloc_c:30 p:0x1aae010
很明显,前面两行,打印的值都一样,并且虚拟地址都一样,虚拟地址这个内存后面再讲,现在只要去到内存中的值,必须通过虚拟地址映射到物理内存页中,这里指向的哪个物理内存页,我们以后再分析。(感觉又给后面挖坑了)
然后我们在子进程中修改了,3个值,然后继续执行,得出的答案,是父子进程的值不一样了,但虚拟地址还是一样,这个做个标记,以后分析。
下面我们继续查看maps的值:
root@ubuntu:/proc# cat 1522/maps
00400000-00401000 r-xp 00000000 08:01 11672602 /root/c_test/08/test_mem
00600000-00601000 r--p 00000000 08:01 11672602 /root/c_test/08/test_mem
00601000-00602000 rw-p 00001000 08:01 11672602 /root/c_test/08/test_mem
01aae000-01acf000 rw-p 00000000 00:00 0 [heap]
7f6344bba000-7f6344d7a000 r-xp 00000000 08:01 791097 /lib/x86_64-linux-gnu/libc-2.23.so
7f6344d7a000-7f6344f7a000 ---p 001c0000 08:01 791097 /lib/x86_64-linux-gnu/libc-2.23.so
7f6344f7a000-7f6344f7e000 r--p 001c0000 08:01 791097 /lib/x86_64-linux-gnu/libc-2.23.so
7f6344f7e000-7f6344f80000 rw-p 001c4000 08:01 791097 /lib/x86_64-linux-gnu/libc-2.23.so
7f6344f80000-7f6344f84000 rw-p 00000000 00:00 0
7f6344f84000-7f6344faa000 r-xp 00000000 08:01 791108 /lib/x86_64-linux-gnu/ld-2.23.so
7f634519c000-7f634519f000 rw-p 00000000 00:00 0
7f63451a9000-7f63451aa000 r--p 00025000 08:01 791108 /lib/x86_64-linux-gnu/ld-2.23.so
7f63451aa000-7f63451ab000 rw-p 00026000 08:01 791108 /lib/x86_64-linux-gnu/ld-2.23.so
7f63451ab000-7f63451ac000 rw-p 00000000 00:00 0
7ffe57737000-7ffe57758000 rw-p 00000000 00:00 0 [stack]
7ffe57794000-7ffe57797000 r--p 00000000 00:00 0 [vvar]
7ffe57797000-7ffe57799000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
root@ubuntu:/proc# cat 1523/maps
00400000-00401000 r-xp 00000000 08:01 11672602 /root/c_test/08/test_mem
00600000-00601000 r--p 00000000 08:01 11672602 /root/c_test/08/test_mem
00601000-00602000 rw-p 00001000 08:01 11672602 /root/c_test/08/test_mem
01aae000-01acf000 rw-p 00000000 00:00 0 [heap]
7f6344bba000-7f6344d7a000 r-xp 00000000 08:01 791097 /lib/x86_64-linux-gnu/libc-2.23.so
7f6344d7a000-7f6344f7a000 ---p 001c0000 08:01 791097 /lib/x86_64-linux-gnu/libc-2.23.so
7f6344f7a000-7f6344f7e000 r--p 001c0000 08:01 791097 /lib/x86_64-linux-gnu/libc-2.23.so
7f6344f7e000-7f6344f80000 rw-p 001c4000 08:01 791097 /lib/x86_64-linux-gnu/libc-2.23.so
7f6344f80000-7f6344f84000 rw-p 00000000 00:00 0
7f6344f84000-7f6344faa000 r-xp 00000000 08:01 791108 /lib/x86_64-linux-gnu/ld-2.23.so
7f634519c000-7f634519f000 rw-p 00000000 00:00 0
7f63451a9000-7f63451aa000 r--p 00025000 08:01 791108 /lib/x86_64-linux-gnu/ld-2.23.so
7f63451aa000-7f63451ab000 rw-p 00026000 08:01 791108 /lib/x86_64-linux-gnu/ld-2.23.so
7f63451ab000-7f63451ac000 rw-p 00000000 00:00 0
7ffe57737000-7ffe57758000 rw-p 00000000 00:00 0 [stack]
7ffe57794000-7ffe57797000 r--p 00000000 00:00 0 [vvar]
7ffe57797000-7ffe57799000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
仔细观察,是不是每个段的内存地址值都一样,并且各个段的内存都一样。
是这样的,那我们从这里是不是可以推测出fork对内存的操作呢?
在传统Unix系统中:子进程复制父进程的所有资源,包含进程的地址空间,包括进程的上下文(进程执行活动全过程的静态描述)、进程的堆栈等。
看到传统两字了,这种玩法就肯定有缺点,缺点如下:
- 使用大量内存
- 复制操作也耗费很大时间,导致fork效率很低
- 通常情况下,我们会调用exec函数,执行另一个进程,而不会在这个父进程中执行,这样导致大量的复制都在无用功。
所以linux现在使用了写时拷贝(copy-on-write)的技术,这种技术也挺好理解的,在fork过程中,子进程并不需要完全复制父进程的地址空间,而是让父子进程共享同一个地址空间,并且把这些地址空间设置为只读。当父子进程其中有一方尝试修改,就会引发缺页异常,然后内核就会尝试为该页面创建一个新的物理页,并将真正的值写到新的物理页中,这样就是写时拷贝,毕竟靠谱的一个技术。
是不是感觉写到这里就结束了?在仔细看看代码,我们malloc了变量,还没有释放呢?
这里就有一个问题,怎么释放?加入了子进程后,释放问题是如何的?
通过上面的分析,子进程会拷贝一份堆空间,所以说子进程的堆里也是有一个malloc_c的指针的,所以在这种情况,malloc是一次申请,需要两次释放(分别是父子进程)。
大家可以去试试。
8.1.5 父子进程文件关系
执行fork函数,内核会复制父进程所有的文件描述符。所以子进程也是可以操作父进程的所打开的文件。
下面我们来写个代码来测试一下,父子进程文件的关系。
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#define INFILE "./in.txt"
#define OUTFILE "./out.txt"
int main(int argc, char**argv)
{
// 先打开文件
int r_fd = open(INFILE, O_RDONLY);
if(r_fd < 0)
{
printf("open %s\n", INFILE);
return 0;
}
int w_fd = open(OUTFILE, O_WRONLY | O_CREAT | O_TRUNC);
if(w_fd < 0)
{
printf("open %s\n", OUTFILE);
return 0;
}
// 创建子进程
pid_t pid = fork();
if(pid < 0)
{
printf("fork error\n");
return 0;
}
char buf[100];
memset(buf, 0, 100);
// 父子进程一样,读文件,再写文件
while(read(r_fd, buf, 2) > 0)
{
printf("pid:%d buf:%s\n", getpid(), buf);
sprintf(buf, "pid:%d \n", getpid());
write(w_fd, buf, strlen(buf)); // 多个进程操作一个w_fd
sleep(1);
memset(buf, 0, 100);
}
while(1);
close(r_fd);
close(w_fd);
return 0;
}
我们来看一下代码执行的效果:
root@ubuntu:~/c_test/08# ./test_file
pid:1501 buf:1
pid:1502 buf:2
pid:1501 buf:3
pid:1502 buf:4
pid:1502 buf:5
pid:1501 buf:6
通过这个输出发现,父子进程读取共享文件的指针偏移是一个,所以可以顺序读取,如果不是一个,父子进程读取都是从1-6.
root@ubuntu:~/c_test/08# cat out.txt
pid:1501
pid:1502
pid:1501
pid:1502
pid:1501
pid:1502
写文件的时候也是共享一个文件指针,所以才是交替写入。
如果这样子是不是不太安全,那子进程怎么才能不访问到父进程的共享文件。
其实open函数是有一个标志的:O_CLOSEXEC。
这个一看名字就知道了,在执行exec函数之后,会把共享文件关闭,这样子进程就不能访问到父进程打开的文件了。
8.1.6 vfork()
早期没有fork的写时复制的时候,用fork创建进程,是真的慢,所以大佬们创建了一个新的创建进程的函数vfork()。
vfork()的实现:不会拷贝父进程的内存数据,直接共享。
这样共享会不会有问题,当然会了,只不过这个vfork()会保证子进程先运行,并且父进程先挂起,直到子进程调用了_exit、exit或者exec函数之后,父进程再接着运行。
不过这个vfork在fork出现了写时复制的时候,已经被淘汰了,这里就不写例子了,淘汰的函数,也没有必要使用了。
8.1.7 进程树
既然所有的进程都是从父进程fork过来的,那总是有一个祖宗进程,这个祖宗进程就是系统启动的init进程:
这图出自刘超老师的 趣谈操作系统。
这个图,我们下一节讲,哈哈哈。
附:
子进程继承了父进程的属性:
- 整个内存部分。(写时拷贝)
- 打开文件的偏移指针
- 实际用户ID、实际组ID、有效用户ID、有效组ID
- 附加组ID、进程组ID、会话组ID、
- 控制终端
- 设置用户ID标志和设置组ID标志
- 当前工作目录
- 根目录
- 文件模式创建屏蔽字
- 信号屏蔽和安排
- 针对任一打开文件描述符的在执行时关闭标记
- 环境
- 连接的共享存储段
- 存储映射
- 资源限制
父子进程之间的区别:
- fork的返回值
- 进程ID不同
- 两个进程具有不同的父进程ID
- 子进程的tms_utime、tms_stime、tms_cutime以及tms_ustime均被设置为0
- 父进程设置的文件锁不会被子进程继承
- 子进程的未处理的闹钟被清楚
- 子进程的未处理信号集设置为空集
真是太多属性了,好多都不是很清楚,慢慢看吧,加油。