心宇

FDF24DCED8C6738F8313A6D3B073C3F4

首页 管理

一、什么是进程?

程序(Program)和进程(Process)是两个不同概念。

程序是存储在磁盘上的静态文件,例如:

/bin/bash
/usr/bin/ls
/usr/bin/python
./a.out

程序本身只是:

机器指令
+
数据
+
ELF 信息

当程序被加载并开始执行以后,就形成:

进程(Process)。

可以简单理解:

Program
   ↓
加载并执行
   ↓
Process

因此:

进程是程序的一次运行实例,也是操作系统进行资源管理、隔离和调度的重要对象。

一个程序可以同时产生多个进程。

例如:

./test &
./test &
./test &

虽然磁盘上只有:

一个 ./test

但系统中可以存在:

Process 1
Process 2
Process 3

它们拥有不同的 PID 和各自的运行状态。


二、进程拥有什么?

一个 Linux 进程运行时涉及大量资源和状态,例如:

PID
进程状态
CPU 调度信息
虚拟地址空间
页表相关信息
打开的文件
当前工作目录
根目录
环境变量
命令行参数
UID / GID
信号处理状态
资源限制
Namespace
线程
...

因此:

进程不是简单的“正在执行的程序代码”,而是程序代码 + 执行状态 + 地址空间 + 打开的文件 + 安全身份 + 各类内核资源的集合。


三、PCB:Process Control Block

操作系统需要记录每个进程的运行状态。

教材中通常把这种数据结构称为:

PCB(Process Control Block)

即:

进程控制块。

PCB 是一个抽象概念。

在 Linux 内核中,与一个任务最核心相关的数据结构是:

struct task_struct

定义在 Linux 内核相关头文件中。

因此可以理解:

操作系统理论
    ↓
PCB

Linux 具体实现
    ↓
task_struct

四、task_struct 记录什么?

Linux 的 task_struct 会直接或间接关联大量进程状态,例如:

PID
进程状态
调度信息
父子关系
CPU 时间
内存管理信息
文件相关信息
信号
Credentials
Namespace
...

可以概念化:

task_struct
│
├── PID
├── State
├── Scheduler
├── Parent / Children
├── Memory
├── Files
├── Signal
├── Credentials
├── Namespace
└── ...

注意:

Linux 中很多复杂资源并不是全部直接“塞进 task_struct”。

task_struct 中大量成员实际上是:

指针
        ↓
其他内核数据结构

例如:

task_struct
     │
     ├── mm
     │    ↓
     │  内存管理结构
     │
     └── files
          ↓
       打开文件相关结构

这样多个任务之间也可以共享部分资源。


五、PID

每个进程都有进程 ID:

PID
Process ID

例如:

ps -ef

可能看到:

UID      PID   PPID
root       1      0
root     500      1
user    1000    500

其中:

PID
→ 当前进程 ID

PPID
→ Parent Process ID
→ 父进程 ID

例如:

systemd
PID 1
  │
  └── sshd
       │
       └── bash
            │
            └── ls

Linux 进程之间可以形成父子关系。


六、进程与文件描述符

Linux 中:

进程访问普通文件、管道、终端、Socket 等对象时,经常通过文件描述符进行操作。

文件描述符:

File Descriptor

简称:

FD

它本质上是:

一个非负整数。

例如:

0
1
2
3
4
5
...

七、文件描述符表

可以概念化理解为:

Process
   │
   ▼
File Descriptor Table
   │
   ├── FD 0
   ├── FD 1
   ├── FD 2
   ├── FD 3
   └── FD 4

Linux 内核中实际涉及多层数据结构,而不是简单:

“PCB 中直接放了一个普通数组。”

但学习阶段可以先抽象成:

进程
 ↓
FD Table
 ↓
FD
 ↓
Open File Description
 ↓
具体文件 / Socket / Pipe / Device

例如:

FD Table

0 ─────────→ Terminal
1 ─────────→ Terminal
2 ─────────→ Terminal
3 ─────────→ test.txt
4 ─────────→ Socket

八、文件描述符如何分配?

调用:

open()

成功后,内核通常返回:

当前进程中最小的可用文件描述符。

例如当前:

0 → occupied
1 → occupied
2 → occupied

执行:

int fd = open("hello.txt", ...);

可能返回:

fd = 3

再次打开:

fd = 4

如果:

close(3);

然后再次:

open(...)

新的 FD 很可能重新使用:

3

因此不能简单理解成:

“每打开一个文件,文件描述符永远 +1。”

准确来说:

内核通常选择当前最低的可用 FD。


九、标准输入、标准输出、标准错误

一个典型的 Unix/Linux 命令行进程启动时,通常已经继承三个打开的文件描述符:

FD 0 → Standard Input
FD 1 → Standard Output
FD 2 → Standard Error

对应:

STDIN_FILENO
STDOUT_FILENO
STDERR_FILENO

定义通常来自:

#include <unistd.h>

即:

STDIN_FILENO  = 0

STDOUT_FILENO = 1

STDERR_FILENO = 2

注意正确拼写是:

STDOUT_FILENO

而不是:

STDOU_FILENO

十、0、1、2 不一定指向键盘和屏幕

默认交互式 Shell 环境中,可能:

FD 0
 ↓
Terminal

FD 1
 ↓
Terminal

FD 2
 ↓
Terminal

但 Shell 可以进行:

I/O Redirection(I/O 重定向)

例如:

ls > result.txt

此时 ls 进程:

FD 1
 ↓
result.txt

而不是终端。

例如:

command 2> error.log

则:

FD 2
 ↓
error.log

所以:

0、1、2 表示标准输入、标准输出和标准错误的文件描述符编号,而不是固定代表某个具体硬件设备。


十一、open() 与 fopen() 的区别

这是非常重要的一组概念。

open()

属于:

POSIX / Unix 风格的底层文件接口。

例如:

int fd = open("hello.txt", O_RDONLY);

返回:

int

即:

File Descriptor

而:

fopen()

属于:

C Standard I/O Library(stdio)

例如:

FILE *fp = fopen("hello.txt", "r");

返回:

FILE *

十二、系统调用与 C 标准库

可以理解成:

Application
     │
     ▼
C Standard I/O
fopen / fread / fwrite / fprintf
     │
     ▼
libc
     │
     ▼
read / write / open 等接口
     │
     ▼
System Call
     │
     ▼
Linux Kernel
     │
     ▼
Filesystem

因此:

fopen() 不是“单纯操作内存缓冲区”。

更准确地说:

fopen() 是 C 标准 I/O 接口,它建立 FILE 流,并通常在底层使用操作系统文件描述符和系统调用完成真正的 I/O。


十三、stdio 的用户态缓冲

C 标准 I/O:

FILE *

通常带有:

User-Space Buffering

例如:

printf()
fprintf()
fwrite()
fputs()

很多情况下不会每调用一次就立即执行一次:

write system call

而可能:

程序
 ↓
fprintf()
 ↓
stdio Buffer
 ↓
积累一定数据
 ↓
write()
 ↓
Kernel

这样可以减少系统调用次数,提高效率。


十四、常见 stdio 缓冲方式

C 标准 I/O 通常涉及:

Unbuffered
Line Buffered
Fully Buffered

即:

无缓冲
行缓冲
全缓冲

例如交互终端中的:

stdout

常见情况下可能使用行缓冲。

而普通文件输出经常使用全缓冲。

stderr 在常见实现中通常设计为能够及时输出错误信息,但具体行为应以标准和 libc 实现为准。


十五、read/write 与 stdio 缓冲的区别

例如:

read(fd, buf, size);

和:

fread(buf, 1, size, fp);

不是完全同一个层次。

可以理解:

fread()
   ↓
stdio 用户态缓冲
   ↓
read()
   ↓
Kernel

因此通常所说:

read()/write() 是无缓冲 I/O。

更准确的意思是:

它们没有 C stdio 的那一层用户态 FILE 缓冲。

但这不意味着:

“数据一定直接从磁盘进入程序。”

因为 Linux Kernel 本身还有:

Page Cache
Block Layer
Device Cache
Filesystem Cache

等缓存机制。

因此一定要区分:

stdio 用户态缓冲
        ≠
Linux Kernel Page Cache

十六、fork()

Unix/Linux 创建新进程的经典方式之一:

fork()

执行:

pid_t pid = fork();

以后:

Parent Process
      │
      │ fork()
      │
      ├─────────────┐
      ▼             ▼
Parent Process   Child Process

于是系统中存在:

父进程
+
子进程

十七、fork() 的返回值

fork() 一个非常重要的特点:

调用一次,成功时在两个进程中分别返回。

例如:

pid_t pid = fork();

if (pid > 0) {
    // Parent
}
else if (pid == 0) {
    // Child
}
else {
    // fork failed
}

其中:

Parent:
fork() 返回 Child PID

Child:
fork() 返回 0

失败:
返回 -1

十八、fork() 后父子进程有什么关系?

fork 之后,子进程会继承父进程的大量进程状态。

例如:

虚拟地址空间内容
打开的文件描述符
当前工作目录
环境变量
信号相关状态
...

但:

父子进程是两个不同的进程。

它们拥有不同:

PID

并且拥有逻辑上独立的虚拟地址空间。


十九、fork() 并不是简单复制全部物理内存

现代 Linux 通常使用:

Copy-On-Write

简称:

COW

fork 以后,不需要立即把父进程所有内存完整复制一份。

最初可以:

Parent Virtual Page
        ↓
   Physical Page
        ↑
Child Virtual Page

父子进程暂时共享相同物理页。

当某一方尝试修改时:

Write
 ↓
Page Fault
 ↓
复制物理页
 ↓
修改自己的副本

因此:

fork()
+
Copy-On-Write

能够避免大量不必要的立即内存复制。


二十、fork() 后文件描述符

fork 以后,子进程会继承父进程打开的文件描述符。

例如 fork 前:

Parent

FD 0 → Terminal
FD 1 → Terminal
FD 2 → Terminal
FD 3 → file.txt

fork 后:

Parent                Child

FD 0 ─────┐       ┌── FD 0
FD 1 ─────┤       ├── FD 1
FD 2 ─────┤       ├── FD 2
FD 3 ─────┴───────┴── FD 3

父子进程的 FD 表是各自的,但继承的 FD 可以引用相同的底层 open file description。

因此可能共享:

File Offset
File Status Flags

等状态。


二十一、exec()

fork 创建子进程以后,经常紧接着执行:

exec

严格来说:

Linux/POSIX 中并不存在一个单独叫 exec() 的函数。

通常所说的:

exec family

包括:

execl()
execlp()
execle()

execv()
execvp()
execve()
...

其中 Linux 系统调用层面最核心的是:

execve()

二十二、exec 的真正作用

exec 不会:

“再创建一个新的进程。”

它的作用是:

使用新的程序映像替换当前进程的用户空间程序映像。

例如:

Child Process
当前正在运行父程序复制出来的执行环境
        │
        │ execve("/bin/ls", ...)
        ▼
Child Process
开始运行 /bin/ls

PID 通常:

不会因为 exec 而改变

所以:

fork()
→ 创建新进程

exec()
→ 替换当前进程执行的程序

这是必须分清的一组概念。


二十三、什么是 Process Image?

所谓:

Process Image(进程映像)

可以理解为当前进程执行程序所对应的用户空间运行内容,例如:

代码映射
数据映射
BSS
Heap
Stack
mmap
程序入口
动态链接环境

exec 成功以后:

Old Process Image
        ↓
     被替换
        ↓
New Process Image

例如:

bash
 ↓
fork()
 ↓
Child
 ↓
execve("/usr/bin/ls")
 ↓
ls

二十四、exec 成功后不会返回到原程序

例如:

printf("before exec\n");

execve(...);

printf("after exec\n");

如果:

execve()

成功:

after exec

不会继续按照原程序逻辑执行。

因为:

原来的程序映像已经被新程序替换。

只有 exec:

失败

才会返回错误。


二十五、Shell 如何执行命令?

理解:

fork + exec

以后,就能理解 Shell 的基本工作方式。

例如在 Bash 中输入:

ls -l

可以简化理解:

bash
 │
 │ fork()
 ▼
child
 │
 │ execve()
 ▼
/usr/bin/ls
 │
 │ 执行
 ▼
exit()
 │
 ▼
Parent bash
继续等待下一条命令

这就是 Unix/Linux 进程模型最经典的设计之一。


二十六、环境变量

每个进程都有自己的:

Environment

其中包含一组:

KEY=VALUE

形式的字符串。

例如:

PATH=/usr/local/bin:/usr/bin:/bin

HOME=/home/user

LANG=zh_CN.UTF-8

USER=userA

SHELL=/bin/bash

查看:

env

或者:

printenv

二十七、环境变量不等于“计算机资源”

环境变量更准确的定义是:

进程运行环境中的一组字符串形式的配置信息。

程序可以通过环境变量获得一些运行参数,例如:

PATH
→ 去哪些目录搜索命令

HOME
→ 用户 Home Directory

LANG
→ Locale

http_proxy
→ HTTP Proxy 配置

它不是:

CPU
RAM
Disk
File
Socket

这类系统资源本身。

因此:

Environment Variables
=
进程运行环境中的配置数据

更加准确。


二十八、环境变量的继承

fork 后:

Parent
  ↓
fork()
  ↓
Child

子进程会获得父进程环境的副本。

因此 Shell:

export NAME=hello

然后:

./test

test 进程就可以获得:

NAME=hello

但是:

子进程修改自己的环境变量,不会反过来直接修改父进程的环境。

因为父子进程拥有各自的地址空间和环境。


二十九、什么是线程?

Thread:

线程

是进程内部的执行流。

可以简单理解:

Process
│
├── Thread 1
├── Thread 2
├── Thread 3
└── Thread 4

传统教材经常概括:

进程
→ 资源拥有和隔离的基本单位

线程
→ CPU 调度和执行的基本单位

在 Linux 内核实际实现中,进程和线程的关系更加统一:

Linux 内核把它们都作为 task 进行调度,并通过共享哪些资源来形成线程组。


三十、Linux 如何理解线程?

Linux 创建线程常常最终涉及:

clone()

或现代相关接口。

线程与同一进程中的其他线程共享大量资源。

例如:

Process
│
├── Address Space
├── Heap
├── Global Variables
├── Open Files
├── Current Directory
│
├── Thread 1
├── Thread 2
└── Thread 3

因此:

同一进程中的线程可以直接访问同一地址空间中的共享数据。


三十一、线程共享哪些资源?

同一个进程中的线程通常共享:

进程虚拟地址空间

.text

.data

.bss

Heap

mmap

全局变量

打开的文件描述符

部分信号处理状态

当前工作目录等进程级资源

可以概念化:

             Process
┌─────────────────────────────┐
│                             │
│ Text                        │
│ Data                        │
│ BSS                         │
│ Heap                        │
│ mmap                        │
│ Open Files                  │
│                             │
│  Thread A    Thread B       │
│                             │
└─────────────────────────────┘

三十二、每个线程也有自己的私有状态

虽然线程共享进程大量资源,但每个线程仍然需要自己的执行状态。

例如:

TID

Stack

CPU Registers

Program Counter

Stack Pointer

Thread-Local Storage

调度状态

Signal Mask

因此:

Process
│
├── Shared
│    ├── Code
│    ├── Data
│    ├── Heap
│    └── Open Files
│
├── Thread A
│    ├── TID
│    ├── Stack A
│    └── Registers A
│
└── Thread B
     ├── TID
     ├── Stack B
     └── Registers B

三十三、为什么每个线程必须有自己的 Stack?

假设:

Thread A

正在执行:

funcA()

Thread B:

funcB()

两个线程拥有不同:

函数调用链
局部变量
返回地址

因此必须拥有各自独立的:

Stack

例如:

Process

Shared Heap
Shared Global Data

Thread A              Thread B

Stack A               Stack B
│                     │
funcA()               funcB()
│                     │
localA                localB

三十四、PID 和 TID

线程拥有:

TID
Thread ID

Linux 内核中,每一个 task 都有自己的 ID。

对于一个多线程进程,可以概念化:

Process
TGID = 1000

Main Thread
TID = 1000

Worker Thread A
TID = 1001

Worker Thread B
TID = 1002

Linux 中通常:

主线程 TID
=
进程的 TGID

用户平时看到的“PID”,对于线程组而言通常对应:

TGID

三十五、线程共享内存带来的问题

共享内存是线程的优势,也是线程编程最大的风险之一。

例如:

int counter = 0;

Thread A:

counter++

Thread B:

counter++

如果同时执行:

Thread A ──┐
           ├── counter
Thread B ──┘

可能产生:

Race Condition(竞态条件)。

因此多线程程序经常需要:

Mutex
Semaphore
Read-Write Lock
Condition Variable
Atomic Operation

等同步机制。


三十六、线程的 Joinable 与 Detached

POSIX Thread 创建以后,线程通常涉及两种资源回收状态:

Joinable
Detached

这不是:

“线程有两种执行方式。”

而是:

线程结束以后,其终止状态和相关线程资源由谁负责回收的问题。


三十七、Joinable Thread

默认创建的 pthread 通常是:

Joinable

例如:

pthread_create(&tid, NULL, worker, NULL);

线程运行:

Main Thread
     │
     ├──────── Worker Thread
     │              │
     │              │ 工作
     │              │
     │              ▼
     │             Exit
     │
     ▼
pthread_join()

主线程可以:

pthread_join(tid, NULL);

等待目标线程结束,并完成相应资源回收。


三十八、pthread_join()

例如:

pthread_t tid;

pthread_create(&tid, NULL, worker, NULL);

pthread_join(tid, NULL);

执行关系:

Main
 │
 │ pthread_create
 ├───────────────→ Worker
 │                   │
 │                   │ Running
 │                   │
 │ pthread_join      │
 │      ↓            │
 │    Wait           │
 │                   ▼
 │                 Exit
 │                   │
 │◀──────────────────┘
 │
 ▼
Continue

如果主线程执行:

pthread_join()

时 Worker 已经结束:

pthread_join() 可以直接取得其终止状态并完成资源回收,而不需要继续等待它运行。

因此你原来所说的:

“如果子线程先到达汇合点,主线程不用等待。”

更准确应该表述为:

如果目标线程在 pthread_join() 调用前已经终止,调用者不需要等待其继续执行,可以直接完成 join。


三十九、Detached Thread

线程也可以设置:

Detached

例如:

pthread_detach(tid);

或者在线程属性中创建为 detached。

Detached Thread 结束以后:

其不再需要另一个线程通过 pthread_join() 来回收对应的线程终止资源。

因此:

Joinable
→ 其他线程 join

Detached
→ 结束后自动进行相应资源清理

四十、Detached 不等于“后台线程”

这是一个常见误区。

Detached

并不是:

Background Thread

它只是表示:

这个线程不能再通过 pthread_join() 等待并取得其终止状态。

它仍然属于当前进程。

如果整个进程退出:

Process Exit

那么其中其他线程也不会继续作为独立进程存在。


四十一、进程退出与线程退出

线程执行:

pthread_exit()

通常只终止:

当前线程

而进程执行:

exit()

或者:

_exit()

会终止整个进程,其他线程也随之结束。

因此:

Thread Exit
→ 当前线程结束

Process Exit
→ 整个进程结束
→ 所有线程结束

四十二、进程与线程最核心的区别

可以建立如下模型:

Process A
┌──────────────────────────────┐
│ Address Space A              │
│                              │
│ Heap                         │
│ Global Data                  │
│ Open Files                   │
│                              │
│ Thread A1      Thread A2     │
│ Stack A1       Stack A2      │
└──────────────────────────────┘


Process B
┌──────────────────────────────┐
│ Address Space B              │
│                              │
│ Heap                         │
│ Global Data                  │
│ Open Files                   │
│                              │
│ Thread B1                    │
│ Stack B1                     │
└──────────────────────────────┘

Process A 和 Process B:

默认地址空间隔离

Thread A1 和 Thread A2:

共享 Process A 地址空间

但:

Thread A1 有自己的 Stack / Registers / TID

Thread A2 有自己的 Stack / Registers / TID

四十三、进程、线程与 CPU 的关系

可以把整体结构理解为:

Program
   ↓
Process
   │
   ├── Address Space
   ├── Files
   ├── Environment
   ├── Security Context
   │
   ├── Thread 1
   │      ├── Stack
   │      ├── Registers
   │      └── TID
   │
   ├── Thread 2
   │      ├── Stack
   │      ├── Registers
   │      └── TID
   │
   └── Thread 3
          ├── Stack
          ├── Registers
          └── TID

CPU 调度执行的是这些:

Task / Thread

四十四、最终完整模型

把这一章串起来:

磁盘上的 ELF Program
        │
        ▼
      execve()
        │
        ▼
      Process
        │
        ├── PID
        ├── Virtual Address Space
        ├── Environment
        ├── Credentials
        ├── FD Table
        │      │
        │      ├── 0 → stdin
        │      ├── 1 → stdout
        │      ├── 2 → stderr
        │      └── 3... → Files / Socket / Pipe
        │
        └── Threads
               │
               ├── Thread 1
               │     ├── TID
               │     ├── Stack
               │     └── Registers
               │
               └── Thread 2
                     ├── TID
                     ├── Stack
                     └── Registers

创建新进程:

Parent
  │
  │ fork()
  ▼
Parent + Child
          │
          │ execve()
          ▼
     New Program Image

因此最经典的 Unix 模型:

fork()
   ↓
创建新进程

execve()
   ↓
替换进程映像

wait()/waitpid()
   ↓
父进程等待并回收子进程

而线程模型:

pthread_create()
      ↓
创建线程

pthread_join()
      ↓
等待 Joinable Thread 并回收相关资源

pthread_detach()
      ↓
设置为 Detached
      ↓
线程结束后无需其他线程 join

四十五、一句话总结

进程是程序的一次运行实例,拥有独立的虚拟地址空间、文件描述符、环境、安全身份等运行资源;Linux 内核通过 task_struct 等数据结构管理任务。进程通过文件描述符访问文件、终端、Socket、Pipe 等内核对象,fork() 可以创建子进程,execve() 可以用新的程序映像替换当前进程。一个进程内部可以存在多个线程,同一进程中的线程共享地址空间和大量进程资源,但每个线程拥有自己的 TID、栈和 CPU 寄存器状态;POSIX 线程又可以根据资源回收方式分为 Joinable 和 Detached。

posted on 2026-10-01 08:19  心宇16969  阅读(12)  评论(0)    收藏  举报