重学计算机(八、进程与创建进程)

在程序是怎么运行中,也讲到进程,但由于篇幅和主题原因,并没有详细介绍;这一次,就要好好介绍一下进程,进程这个概念很多,并且也是操作系统的核心。

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系统中:子进程复制父进程的所有资源,包含进程的地址空间,包括进程的上下文(进程执行活动全过程的静态描述)、进程的堆栈等。

看到传统两字了,这种玩法就肯定有缺点,缺点如下:

  1. 使用大量内存
  2. 复制操作也耗费很大时间,导致fork效率很低
  3. 通常情况下,我们会调用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进程:

重学计算机(八、进程与创建进程)

这图出自刘超老师的 趣谈操作系统。

这个图,我们下一节讲,哈哈哈。

附:

子进程继承了父进程的属性:

  1. 整个内存部分。(写时拷贝)
  2. 打开文件的偏移指针
  3. 实际用户ID、实际组ID、有效用户ID、有效组ID
  4. 附加组ID、进程组ID、会话组ID、
  5. 控制终端
  6. 设置用户ID标志和设置组ID标志
  7. 当前工作目录
  8. 根目录
  9. 文件模式创建屏蔽字
  10. 信号屏蔽和安排
  11. 针对任一打开文件描述符的在执行时关闭标记
  12. 环境
  13. 连接的共享存储段
  14. 存储映射
  15. 资源限制

父子进程之间的区别:

  1. fork的返回值
  2. 进程ID不同
  3. 两个进程具有不同的父进程ID
  4. 子进程的tms_utime、tms_stime、tms_cutime以及tms_ustime均被设置为0
  5. 父进程设置的文件锁不会被子进程继承
  6. 子进程的未处理的闹钟被清楚
  7. 子进程的未处理信号集设置为空集

真是太多属性了,好多都不是很清楚,慢慢看吧,加油。

上一篇:关于结构体中指针的一些探讨


下一篇:php – 使用fsockopen上传多个文件并发布变量