Linux 02 进程核心——printf 缓冲区、fork 与写时拷贝
·
一、printf 隐藏的缓冲区
printf 函数并非直接把内容输出到屏幕,而是默认将数据写入用户态缓冲区,只有缓冲区刷新时,内容才会真正输出。这也是很多新手遇到 “printf 不打印” 的核心原因。
1. 缓冲区的刷新规则
| 触发条件 | 说明 |
|---|---|
遇到换行符 \n |
行缓冲模式下,遇到 \n 会自动刷新缓冲区,这是最常见的刷新场景。 |
| 程序正常结束 | main 函数 return 时,所有缓冲区会被强制刷新。 |
| 缓冲区写满 | 缓冲区空间不足时,会自动刷新内容。 |
主动调用 fflush(stdout) |
手动强制刷新标准输出缓冲区。 |
| 进程退出(异常 / 正常) | 进程退出时,内核会自动刷新所有打开的流。 |
2. 代码示例:缓冲区不刷新的 “坑”
#include <stdio.h>
#include <unistd.h> // sleep函数
int main() {
printf("Hello, World!"); // 没有换行符
sleep(3); // 睡眠3秒
printf("\n"); // 刷新缓冲区
return 0;
}
现象:运行程序时,程序会先睡眠 3 秒,然后一次性输出 Hello, World! 并换行,而不是先打印再睡眠。原因:printf 写入的内容先存在了缓冲区,直到遇到 \n 或程序结束才刷新。
3. 缓冲区与 fork 的经典问题
#include <stdio.h>
#include <unistd.h>
int main() {
printf("Before fork: "); // 无换行符
fork();
printf("After fork\n");
return 0;
}
现象:程序输出:
Before fork: After fork
Before fork: After fork
原因:fork() 会复制父进程的用户态内存,包括缓冲区。父进程的 Before fork: 被留在缓冲区,子进程复制了这份缓冲区,两个进程退出时各自刷新,导致重复输出。
二、fork () 复制进程(重点)
fork() 是 Linux 创建新进程的系统调用,它会创建一个和父进程几乎完全相同的子进程,实现进程的复制。
1. fork () 的基础用法
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
pid_t pid = fork();
if (pid == -1) {
perror("fork error");
return 1;
} else if (pid == 0) {
// 子进程执行的代码
printf("我是子进程,pid: %d, ppid: %d\n", getpid(), getppid());
} else {
// 父进程执行的代码
printf("我是父进程,pid: %d, 子进程pid: %d\n", getpid(), pid);
}
return 0;
}
运行结果:
我是父进程,pid: 1234, 子进程pid: 1235
我是子进程,pid: 1235, ppid: 1234
2. fork () 的核心特点
- 一次调用,两次返回:父进程返回子进程的 PID,子进程返回 0,失败返回 -1。
- 父子进程是独立的进程:有独立的进程 ID、地址空间,执行流互不干扰。
- 执行顺序不确定:父子进程谁先运行由操作系统调度决定,无固定顺序。
三、fork () 补充知识点
1. 父子进程共享与复制的资源
| 资源类型 | 处理方式 |
|---|---|
| 代码段 | 只读共享,父子进程共用同一份物理内存 |
| 数据段 / 堆 / 栈 | 初始时共享,修改时触发写时拷贝 |
| 文件描述符 | 复制一份,引用计数 + 1,指向同一个文件对象 |
| 缓冲区 | 复制父进程的用户态缓冲区(解释了前面的重复输出问题) |
2. 僵尸进程与孤儿进程
- 僵尸进程:子进程先退出,父进程未调用
wait()/waitpid()回收子进程资源,子进程会变成僵尸进程,占用进程号。 - 孤儿进程:父进程先退出,子进程会被
init进程(pid=1)收养,成为孤儿进程,退出时会被自动回收。
3. 如何避免僵尸进程
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
printf("子进程运行,即将退出\n");
return 0;
} else {
wait(NULL); // 父进程阻塞,等待子进程退出并回收资源
printf("父进程回收了子进程\n");
}
return 0;
}
四、写时拷贝(Copy-On-Write, COW)技术
fork() 早期的实现会直接复制父进程的整个地址空间,效率极低。现代 Linux 采用写时拷贝技术优化,只有当进程需要修改数据时,才会真正复制物理内存。
1. 写时拷贝的原理
fork()刚完成时,子进程的虚拟地址空间和父进程映射到同一块物理内存,且内存被标记为 “只读”。- 当父进程或子进程尝试修改这块内存时,会触发页错误(Page Fault)。
- 内核捕获错误后,为修改方分配新的物理内存,复制原数据,修改页表映射关系,之后再允许写入。
2. 代码示例:验证写时拷贝
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
int val = 100;
pid_t pid = fork();
if (pid == 0) {
// 子进程修改val
printf("子进程修改前: val=%d, &val=%p\n", val, &val);
val = 200;
printf("子进程修改后: val=%d, &val=%p\n", val, &val);
} else {
sleep(1); // 让子进程先运行
printf("父进程: val=%d, &val=%p\n", val, &val);
}
return 0;
}
运行结果:
子进程修改前: val=100, &val=0x7ffdabcdef12
子进程修改后: val=200, &val=0x7ffdabcdef12
父进程: val=100, &val=0x7ffdabcdef12
现象说明:
- 父子进程的
val变量虚拟地址相同,但值不同,说明它们映射到了不同的物理内存。 - 子进程修改时触发了写时拷贝,分配了新的物理内存,父进程的
val不受影响。
五、进程的逻辑地址与物理地址
1. 概念区分
- 逻辑地址(虚拟地址):进程代码中使用的地址,比如
&val得到的地址,每个进程都有独立的 4GB(32 位)或 256TB(64 位)虚拟地址空间。 - 物理地址:内存芯片上的实际地址,由操作系统内核管理,进程无法直接访问。
2. 地址映射:页表
进程的虚拟地址通过页表映射到物理地址,页表由操作系统维护,CPU 的内存管理单元(MMU)负责地址转换。
六、为什么程序中不直接使用物理地址?
- 安全隔离:直接使用物理地址会导致进程可以访问任意内存,破坏系统和其他进程的数据,虚拟地址让进程互相隔离。
- 内存管理:虚拟地址让操作系统可以实现内存交换(Swap)、分页、写时拷贝等功能,提高内存利用率。
- 地址无关性:程序编译时使用虚拟地址,操作系统可以将程序加载到任意物理地址运行,实现地址重定位。
- 简化编程:进程以为自己独占全部内存,编程时无需关心物理内存的分配情况。
更多推荐




所有评论(0)