在程序是怎么运行中,也讲到进程,但由于篇幅和主题原因,并没有详细介绍;这一次,就要好好介绍一下进程,进程这个概念很多,并且也是操作系统的核心。
什么是进程?写概念太让人难受了。
我们从第一篇开始,开始写了第一个c语言,然后编译链接,最后生成了一个可执行文件,这个文件叫程序。这个可执行文件里面有什么?可以看这一篇文章重学计算机(三、elf文件布局和符号表),里面包含了各个段,为程序加载做准备。
当我们把这个可执行程序运行起来后,(没有结束之前),它就是一个进程了。
程序是怎么运行的,进程和程序的区别,可以看这一篇:重学计算机(六、程序是怎么运行的)。
从专业的角度来讲:进程是操作系统分配资源的基本单位。
进程拥有自己独立的处理环境:环境变量、程序运行目录、进程组等。
进程拥有自己独立的系统资源:处理器CPU的占用率、存储器、I/O设备,数据、程序。
在以前的操作系统中,是存在单道程序设计。
所谓的单道程序设计是所有进程一个一个排队执行,如果A阻塞了,B也只能等待。
相比之下,现在计算机系统允许加载多个程序到内存,以便于并发执行。并发执行其实就是CPU由一个进程快速切换到另一个进程,使每个进程都可以运行一段时间。
通过这个图就明白了,从时间点来看,CPU只能运行一个程序,但从时间段来看,CPU可以运行多个程序。
正因为需要切换,所以在计算机中时间中断即为进程切换提供了硬件保证。这个下一节再讲。
哪并行是什么呢?
并行是真正的硬件并发,小阳台两个或多个CPU共享同一个物理内存。
看图业明白了,这是正在的并行执行,互不干扰。
在重学计算机(六、程序是怎么运行的)中也提到了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以外的所有进程都杀死,当然需要权限。
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系统中:子进程复制父进程的所有资源,包含进程的地址空间,包括进程的上下文(进程执行活动全过程的静态描述)、进程的堆栈等。
看到传统两字了,这种玩法就肯定有缺点,缺点如下:
所以linux现在使用了写时拷贝(copy-on-write)的技术,这种技术也挺好理解的,在fork过程中,子进程并不需要完全复制父进程的地址空间,而是让父子进程共享同一个地址空间,并且把这些地址空间设置为只读。当父子进程其中有一方尝试修改,就会引发缺页异常,然后内核就会尝试为该页面创建一个新的物理页,并将真正的值写到新的物理页中,这样就是写时拷贝,毕竟靠谱的一个技术。
是不是感觉写到这里就结束了?在仔细看看代码,我们malloc了变量,还没有释放呢?
这里就有一个问题,怎么释放?加入了子进程后,释放问题是如何的?
通过上面的分析,子进程会拷贝一份堆空间,所以说子进程的堆里也是有一个malloc_c的指针的,所以在这种情况,malloc是一次申请,需要两次释放(分别是父子进程)。
大家可以去试试。
执行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函数之后,会把共享文件关闭,这样子进程就不能访问到父进程打开的文件了。
早期没有fork的写时复制的时候,用fork创建进程,是真的慢,所以大佬们创建了一个新的创建进程的函数vfork()。
vfork()的实现:不会拷贝父进程的内存数据,直接共享。
这样共享会不会有问题,当然会了,只不过这个vfork()会保证子进程先运行,并且父进程先挂起,直到子进程调用了_exit、exit或者exec函数之后,父进程再接着运行。
不过这个vfork在fork出现了写时复制的时候,已经被淘汰了,这里就不写例子了,淘汰的函数,也没有必要使用了。
既然所有的进程都是从父进程fork过来的,那总是有一个祖宗进程,这个祖宗进程就是系统启动的init进程:
这图出自刘超老师的 趣谈操作系统。
这个图,我们下一节讲,哈哈哈。
子进程继承了父进程的属性:
父子进程之间的区别:
真是太多属性了,好多都不是很清楚,慢慢看吧,加油。