【Linux进程(一)】Linux 进程初探:操作系统如何管理进程?fork() 为什么返回两次?

目录

一、前言

二、冯诺依曼体系结构

1.疑问:冯诺依曼体系结构为什么要有内存?

三、操作系统

1.是什么

2.核心功能

3.如何理解"管理"

核心思想:先描述,再组织

4.系统调用和库函数

1.概念

2.关系

四、进程

1.进程是什么

1.操作系统是如何管理进程的

2.什么是PCB

3.task_struct内部都有哪些进程属性?

4.操作系统是如何组织PCB的?

2.怎么查看进程?

1./proc

2.ps命令家族

3.补充:系统调用接口

1.getpid&&getppid

2.chdir

3.getcwd

4.初识fork

返回值问题

疑问:为什么pid既等于0又大于0?


一、前言

从本篇开始,我们就要进入学习【Linux进程】的相关知识,学习目标包括:

1.认识冯诺依曼系统。

2.操作系统概念与定位,理解"管理"。

3.深⼊理解进程概念,了解PCB。

4.学习进程状态,学会创建进程,掌握僵⼫进程和孤⼉进程,及其形成原因和危害。

5.了解进程调度,Linux进程优先级,理解进程竞争性与独⽴性,理解并⾏与并发。

6.理解进程切换,以及Linux2.6 kernel,O(1)调度算法架构。

7.理解环境变量,熟悉常⻅环境变量及相关指令, getenv/setenv函数。

8.理解C内存空间分配规律,了解进程内存映像和应⽤程序区别, 认识虚拟地址空间。

八个学习目标博主会拆分成四篇博客向大家一一交付,本篇博主会向大家介绍前三个目标------冯诺依曼体系结构,操作系统和进程PCB相关内容。

在这篇博客中,我们会从计算机硬件的底层数据流动讲起,探讨操作系统是如何通过"先描述,再组织" 的哲学来管理软硬件资源的。随后,我们将视角拉近,去看看进程的"身份证"------PCB ,以及如何在 Linux 中通过 /proc 目录和 ps 命令来观察它们。

当然,本篇还会重点带大家初识 fork() 系统调用。很多初学者在刚接触 fork() 时,都会被它"调用一次,返回两次"的特性,以及 pid 变量在父子进程中"既等于 0 又大于 0"的现象绕晕。希望通过本篇的拆解,能帮你理清这背后的执行逻辑,为后续更深入的进程控制与管理打下基础。

二、冯诺依曼体系结构

截⾄⽬前,我们所认识的计算机,都是由⼀个个的硬件组件组成
输⼊单元:包括键盘, ⿏标,扫描仪, 写板等
中央处理器(CPU):含有运算器和控制器等
输出单元:显⽰器,打印机等
关于冯诺依曼,必须强调⼏点:
这⾥的存储器指的是内存
不考虑缓存情况,这⾥的CPU能且只能对内存进⾏读写,不能访问外设(输⼊或输出设备)(数据层⾯)
外设(输⼊或输出设备)要输⼊或者输出数据,也只能写⼊内存或者从内存中读取。
⼀句话,所有设备都只能直接和内存打交道。

1.疑问:冯诺依曼体系结构为什么要有内存?

1.数据在体系结构中的流动:本质就是CPU不直接和I/O设备打交道,交换数据需要内存来当中介。

2.存储分级的问题。

3.因为内存的存在,让计算机在成本不是很高的情况下就拥有了不错的效率。

三、操作系统

1.是什么

广义上,操作系统是控制和管理计算机系统全部软硬件资源、合理组织程序运行、提供用户接口和应用程序运行环境的系统软件集合。

狭义上,操作系统特指内核,即直接运行在硬件之上、常驻内存、负责最核心资源管理和硬件抽象的那部分程序。操作系统内核通常进行进程管理、文件管理,内存管理和驱动与设备管理。下文我们会提到。

总而言之,操作系统是一个进行软件资源管理的软件。

2.核心功能

操作系统通过对软硬件资源进行管理,实现为用户提供良好的运行环境。

对下,与硬件交互,管理所有的软硬件资源。

对上,为用户程序提供一个良好的执行环境。

3.如何理解"管理"

**场景:**校长对上千名学生进行管理

核心思想:先描述,再组织

  • 先描述(抽象提取):现实世界有各种各样的学生,怎么让计算机认识他们?通过提取关键属性,用代码"描述"出来。

    • 看右上角的代码:定义了 struct Stu 结构体。把学生的姓名(name)、学号(number)、性别(sex)、成绩(score)、表现(char 表现[])等作为成员变量。

    • 这样,一个复杂的人,在计算机里就变成了一个由各个字段组成的"数据包"。

  • 再组织(建立关联):只有一个个孤立的"学生数据包"是不够的,还需要把他们管理起来。

    • 看左下角的表头和右侧:通过引入 struct Stu *next 指针,将一个个独立的学生结构体(张三、李四、王五...)像手拉手一样串连起来。

    • 看右中部的图示:画了一个经典的单链表 结构(方框代表结构体,箭头代表 next 指针)。这就把零散的数据"组织"成了一个整体。

计算机管理硬件:先描述再组织。

  1. 描述起来,⽤struct结构体
  2. 组织起来,⽤链表或其他⾼效的数据结构

4.系统调用和库函数

1.概念

系统调用是用户程序请求操作系统内核服务的入口;库函数是用户态代码中可调用的函数,可能封装系统调用,也可能完全在用户态完成计算。

系统调用:

系统调用(System Call)是操作系统内核提供给用户程序的编程接口 。

以银行系统为例,对外:银行是一个整体,对用户仅提供银行窗口与银行系统进行交互,为了安全考虑不允许用户直接进入银行系统。这个窗口就是"系统调用接口",用户程序不能直接操作硬件或内核数据结构,必须通过系统调用"陷入"内核态,请求内核完成受保护的操作。

库:
系统调⽤在使⽤上,功能⽐较基础,对⽤⼾的要求相对也⽐较⾼,所以,有⼼的开发者可以对部
分系统调⽤进⾏适度封装,从⽽形成库,有了库,就很有利于更上层⽤⼾或者开发者进⾏⼆次开
发。

2.关系

库函数和系统调用不是互斥关系,常见关系是:

库函数可以封装系统调用。

例如 C 程序中调用printf()

printf 是库函数,它最终可能通过 write 系统调用把数据写到文件描述符。但 printf 还做了格式化、缓冲区管理等工作,这些是在用户态完成的。

四、进程

1.进程是什么

进程 = PCB + 代码和数据

1.操作系统是如何管理进程的

核心:先描述,再组织

描述:OS内核里使用结构体(PCB)对进程属性进行描述

组织:PCB里有一定的数据结构,通过某种数据结构把进程管理(组织)起来

操作系统管理进程,就是通过创建 PCB 来描述它,通过调度 PCB 所在的链表 来运行它,通过系统调用和中断 来切换它的状态,最后通过销毁 PCB 来终结它。一切管理,最终都落脚在对 PCB 及其所在队列的增删查改上。

2.什么是PCB

PCB(Process Control Block,进程控制块)

简单一句话概括:PCB 就是操作系统用来"描述"和"管理"进程的一个数据结构(结构体),结构体内部包含进程的属性信息和链接字段。

如果把一个运行中的程序(进程)比作一个活生生的人,那么 PCB 就是这个进程的"身份证"+"档案袋" 。在操作系统眼里,没有 PCB 就没有进程。

Linux操作系统中的PCB叫做:task_struct

磁盘中的程序分为代码段和数据段,程序从磁盘加载到内存,操作系统内核就会新建一个PCB,此时静态的程序就变成了动态的进程。

3.task_struct内部都有哪些进程属性?

**标识符(PID):**描述本进程的唯⼀标⽰符,⽤来区别其他进程。

状态(STATE): 任务状态,退出代码,退出信号等。

优先级(PRI): 相对于其他进程的优先级。

程序计数器: 程序中即将被执⾏的下⼀条指令的地址。

内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针

上下⽂数据: 进程执⾏时处理器的寄存器中的数据。

I/O状态信息: 包括显⽰的I/O请求,分配给进程的I/O设备和被进程使⽤的⽂件列表。

**记账信息:**可能包括处理器时间总和,使⽤的时钟数总和,时间限制,记账号等。

其他信息

4.操作系统是如何组织PCB的?

所有运⾏在系统⾥的进程都以 task_struct 双链表的形式存在内核⾥。

2.怎么查看进程?

查看进程通常有两种做法:

1./proc

proc(process进程):/proc 目录下以纯数字命名的子目录,代表的是当前系统中"存在"的所有进程。

  • 你执行 ls /proc,看到的那些纯数字命名的目录(比如 1、1024),每一个数字就是一个进程的 PID。

  • 你执行 cat /proc/1234/status,就能看到 PID 为 1234 的进程的状态、内存占用、父子进程 ID。

  • 你执行 ls -l /proc/1234/cwd,就能看到这个进程当前的工作目录(这正好对应了你上一个问题里的 getcwd 系统调用的底层数据来源)。

1.cwd是什么?

cwd(current work directory)指当前进程启动时的工作路径,也就是我们常说的当前路径。

为什么要有?

答:为了支持进程访问文件时,缺省路径的问题。

2.exe是什么?

exe 是 Executable 的缩写,代表可执行文件。

这是一个软链接,说明启动这个进程的实际程序是 /usr/bin/bash(即 Bash shell)。

2.ps命令家族

ps (Process Status) 是最基础的进程查看工具,它截取的是执行命令那一瞬间的进程状态。

  • ps aux:最常用。以 BSD 风格列出系统中所有进程,并显示详细的资源占用。

  • ps -ef:以 System V 风格列出所有进程,显示PPID(父进程 ID)、C(CPU使用率)、STIME(启动时间)、CMD(完整命令行)。

  • ps -al:只看当前终端下的进程,长格式,侧重调度信息(优先级、等待状态等)。

  • ps axj:看全系统进程的层级关系(PGID 进程组、SID 会话、TPGID 控制终端)。

ps命令通常和管道搭配使用!

3.补充:系统调用接口

1.getpid&&getppid

功能:获取当前进程的id或当前进程的父进程的id

注意:

自定义类型pid_t 本质是一个int类型,不过是在头文件里被重定义了。

使用这两个接口的时候要包含头文件<unistd.h>!

2.chdir

功能:修改当前进程的工作目录

3.getcwd

功能:获取当前进程的当前路径

4.初识fork

功能:创建子进程

翻译 :子进程和父进程运行在独立的内存空间 中。在 fork() 的瞬间,两者的内存空间内容相同 。其中一个进程进行的内存写入不会影响另一个进程。

返回值问题

fork() 是系统编程中最"反直觉"的函数之一,因为它的执行逻辑是:调用一次,返回两次。

返回值 发生的进程 含义与作用
> 0 (大于0) 父进程 返回的是新创建的子进程的 PID (进程ID)。 父进程需要拿到这个 ID,以便后续对子进程进行管理 (比如 wait() 等待它结束,或者用 kill() 杀掉它)。
== 0 (等于0) 子进程 返回 0 表示当前代码正在子进程中运行。 为什么是 0?因为子进程不需要知道自己的 PID(它可以自己调用 getpid() 获取),它只需要知道"我是子进程"。
==-1 出错 创建子进程失败。常见原因:系统内存不足,或者当前用户的进程数达到了上限(比如 ulimit -u 限制)。此时没有子进程产生。

疑问:为什么同一个函数会返回两次?

Fork函数核心要做的几件事:

1.创建子进程

2.拷贝PCB

3.修改个性属性数据(例如PID)

4.放入调度队列

---------------------------此时子进程已经创建好了

5.return(父子进程共享代码块,都要执行return)


场景:

myproc.c文件示例代码:

bash 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>

int main() {
    pid_t pid;

    printf("调用 fork() 之前,只有一个进程运行。PID: %d\n", getpid());
    printf("----------------------------------------\n");

    // 见证奇迹的时刻:调用一次,返回两次
    pid = fork();

    if (pid < 0) {
        // 1. 创建失败
        perror("fork 失败");
        exit(1);
    }
    else if (pid == 0) {
        // 2. 这里是子进程的代码(返回 0)
        printf("【子进程】我被创建了!\n");
        printf("【子进程】fork() 的返回值是: %d\n", pid);
        printf("【子进程】我的真实 PID 是: %d\n", getpid());
        printf("【子进程】我的父进程 PID 是: %d\n", getppid());
        exit(0); // 子进程执行完就退出
    }
    else {
        // 3. 这里是父进程的代码(返回子进程的 PID,大于 0)
        printf("【父进程】我创建了一个子进程!\n");
        printf("【父进程】fork() 的返回值是: %d\n", pid);
        printf("【父进程】我的真实 PID 是: %d\n", getpid());

        wait(NULL); // 父进程等待子进程结束,回收资源
        printf("【父进程】子进程执行完毕了。\n");
    }

    return 0;
}

运行结果:

奇怪的是:同时进入了两个互斥的条件代码块!

疑问:为什么pid既等于0又大于0?

1. 准备阶段(只有父进程)

cpp 复制代码
pid_t pid;      // 在父进程的内存里,开辟了一块4字节的空间,叫 pid
pid = fork();   // 准备调用 fork

此时,世界上只有一个进程(父),内存里只有一个 pid 变量。

2. 发生奇迹(内核的复制魔法)

当我们调用 fork() 时,我们上一张图里的"拷贝 PCB"和"分配内存"发生了。

操作系统不仅复制了父进程的代码,还把父进程的整个内存空间(包括那个叫做 pid 的变量)原封不动地复制了一份给子进程。

这时候,内存里有了两个 pid 变量:

  • 父进程内存里有一个 pid(假设地址是 0x1000)

  • 子进程内存里也有一个 pid(地址也是 0x1000,但它们是两块独立的物理内存)

记住前面 fork 手册里的那句红线:两个进程运行在独立的内存空间中。

3. 赋值阶段(分别塞小纸条)

现在,内核开始执行返回操作(也就是图里的 return XXX),准备让它们继续往下跑:

  • 针对父进程 :内核把父进程内存里的那个 pid 变量,填上了子进程的 PID(例如 12345,大于 0)。

  • 针对子进程 :内核把子进程内存里的那个 pid 变量,填上了 0。

4. 执行阶段(各回各家,各找各妈)

现在两个进程同时从 fork() 出来,继续往下执行:

cpp 复制代码
if (pid == 0) {
    // 只有子进程会走进来,因为子进程内存里的 pid 是 0
} else {
    // 只有父进程会走进来,因为父进程内存里的 pid 是 12345 (>0)
}

父进程看自己内存里的 pid,是大于 0 的;

子进程看自己内存里的 pid,是等于 0 的。

解读(核心原理:写时复制 Copy-On-Write, COW):

1.刚 fork() 完,系统为了节省资源,并没有真的把父进程的内存全部拷贝一份给子进程,而是让子进程的虚拟内存指向父进程的物理内存(此时"内容相同")。

2.但是,一旦父进程或子进程尝试修改 内存中的变量(比如执行 x = 10),系统内核会立刻拦截这个操作,并给修改内存的那个进程分配一块新的物理内存。

相关推荐
共享家95271 小时前
408 操作系统第一章
服务器·数据库·windows
AI+程序员在路上1 小时前
RV1126B 上支持的模型及全流程应用--从CNN及语音到 LLMVLM 与跨平台选型
linux·c语言·人工智能
默|笙2 小时前
【Linux网络】传输层协议TCP(1)---TCP报文介绍
linux·网络·tcp/ip
相思难忘成疾2 小时前
从零搭建 Kubernetes 1.30 集群:kubeadm 部署一主多从实战(RHEL 9.3 + Docker + flannel)
linux·运维·kubernetes
PellyKoo2 小时前
【docker】cifs卷改smb密码后容器起不来的解决方法
运维·docker·容器
用户4107192013252 小时前
用 systemd 从零做一个系统健康巡检服务:脚本、沙箱与磁盘满排查实战
linux·ubuntu
云飞云共享云桌面2 小时前
1台SolidWorks服务器如何承载10个设计并发?
运维·服务器·网络·3d·制造
未来之窗软件服务2 小时前
WAF负载均衡+蜜罐混淆安全体系-东方仙盟
运维·安全·负载均衡·仙盟创梦ide·东方仙盟·阿雪技术观·网关开发