一、什么是进程?
程序(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。
浙公网安备 33010602011771号