☰
PHASE 1
2026/10/7 2:46:45 网站建设 项目流程

PHASE 1

一.Fork和exec和waitpid()

总代码

#include<cerrno>#include<cstdio>#include<sys/wait.h>#include<unistd.h>intmain(){// 先准备参数,避免 fork 后在 child 里做复杂 C++ 工作。charprogram[]="/bin/echo";charmessage[]="hello from child";char*argv[]={program,message,nullptr};constpid_t child=::fork();if(child<0){std::perror("fork");return1;}if(child==0){// exec 成功不会返回;只有失败才会执行下一行。::execv(program,argv);::_exit(127);}intstatus=0;pid_t waited;do{waited=::waitpid(child,&status,0);}while(waited<0&&errno==EINTR);if(waited<0){std::perror("waitpid");return1;}if(WIFEXITED(status)){std::printf("exit code = %d\n",WEXITSTATUS(status));returnWEXITSTATUS(status);}return1;}

1.Fork的使用

#include<iostream>#include<unistd.h>intmain(){std::cout<<"fork 之前\n";fork();std::cout<<"fork 之后\n";return0;}

运行后:
fork 之前
fork 之后
fork 之后

因为fork 前,一个进程执行代码,fork 后,两个进程继续执行后面的代码

constpid_t child=::fork();if(child<0){std::perror("fork");return1;}if(child==0){// exec 成功不会返回;只有失败才会执行下一行。::execv(program,argv);::_exit(127);}

pid_t 现在可以简单理解成:用来装 PID 这一类进程编号的整数类型。
而result父子进程里的值不一样。
子进程
子进程看到:
result == 0
所以:

if(result==0){// 我是 child}

父进程
父进程看到:

result>0

result > 0
而且这个正数就是:child 的 PID

2.父进程与子进程

#include<iostream>#include<unistd.h>intmain(){std::cout<<"fork 之前,我的 PID = "<<getpid()<<"\n";pid_t result=fork();if(result<0){std::cout<<"fork 失败\n";return1;}if(result==0){std::cout<<"我是 child,"<<"我的 PID = "<<getpid()<<"\n";}else{std::cout<<"我是 parent,"<<"我的 PID = "<<getpid()<<",我创建的 child PID = "<<result<<"\n";}return0;}

输出:
fork 之前,我的 PID = 5000
我是 parent,
我的 PID = 5000,
我创建的 child PID = 5001
我是 child,
我的 PID = 5001

3.exec的含义

把“当前这个进程正在运行的程序”替换成另一个程序。

charprogram[]="/bin/echo";char*argv[]={program,message,nullptr};//nullptr表示参数到这里结束了...execv(program,argv);

program是专门当路径用的字符串
execv 会把 argv 里的内容交给 echo 程序,echo 拿到之后,会把它当成自己的参数来用。
​
注意

std::cout<<"before exec\n";execv(program,argv);std::cout<<"after exec\n";

如果 execv() 成功before exec会执行
但是after exec不会执行
因为成功 exec 以后,原来的程序已经被替换了。原程序后面的std::cout << "after exec\n";已经不属于当前运行中的程序了
如果 execv() 成功,执行路径实际上是:
child
↓
execv()
↓
原来的程序映像被 /bin/echo 替换
↓
开始执行 /bin/echo

所以exec 成功不会返回。如果返回则失败,并且exec成功pid不变

4.waitpid

waitpid:等待指定的 child,并取得它的结束状态。
fork 不会自动让 parent 等 child。parent 和 child 是两个独立进程。
所以可能出现:
parent: TaskForge finished
child : hello

也可能:
child : hello
parent: TaskForge finished

谁先运行,由操作系统调度决定。

如果 parent 创建 child 后就不管了那 TaskForge 就没办法生成可靠的:
ProcessResult
所以 parent 必须有办法:
等待 child 的状态变化,并取得它的结束结果。
这个系统调用就是:waitpid()

intstatus=0;...waited=::waitpid(child,&status,0);

status 是 Linux 返回的一份:进程结束状态信息
里面可能包含:

  • 正常退出了吗?
  • 退出码是多少?
  • 还是被 signal 杀掉了?
  • 被哪个 signal 杀掉?

status 是编码后的状态,不能直接当退出码使用。
用WIFEXITED(status)判断是否正常退出
用WEXITSTATUS(status)得到真正退出码

do{waited=::waitpid(child,&status,0);}while(waited<0&&errno==EINTR);////EINTR突然 Linux 中间处理了某个 signal。于是这个 waitpid() 可能暂时返回:失败但这种失败不一定意味着:child 有问题。可能只是:“刚才等的时候被打断了一下。”这种情况:errno == EINTR

先等 child。
如果 waitpid 失败,
并且失败原因只是 EINTR:
那就再等一次。一直重试,直到成功,或者出现真正的其他错误。

二、Pipe

总代码

#include<cerrno>#include<cstdio>#include<unistd.h>intmain(){intends[2];if(::pipe(ends)<0){std::perror("pipe");return1;}constchartext[]="abc";std::size_t sent=0;boolfailed=false;while(sent<3){constssize_t n=::write(ends[1],text+sent,3-sent);if(n>0)sent+=static_cast<std::size_t>(n);elseif(n<0&&errno==EINTR)continue;else{failed=true;break;}}::close(ends[1]);// 没有写端持有者了;缓冲读完后才能读到 EOF。if(failed){::close(ends[0]);return1;}charbuffer[8];for(;;){constssize_t n=::read(ends[0],buffer,sizeof(buffer));if(n>0)std::printf("read %zd bytes: %.*s\n",n,static_cast<int>(n),buffer);elseif(n==0){std::puts("EOF");break;}elseif(errno==EINTR)continue;else{std::perror("read");::close(ends[0]);return1;}}::close(ends[0]);return0;}

1.pipe概念

输出要返回给调用方,不能只显示在终端。文档这里的出发点很简单:上一节 child 已经会通过 exec 去运行目标程序了,但它输出的内容目前只是显示在终端,TaskForge 自己还没有把这些内容收进 ProcessResult,所以需要一条 child → parent 的数据通道,这就是 pipe(管道)。(注意,是收集起来返回)

原来:/bin/echo │ │stdout,也就是 FD1▼ 终端 TaskForge 想要的:/bin/echo │ ▼ pipe │ ▼ TaskForge parent

2.第一段代码拆分

intends[2];if(::pipe(ends)<0){std::perror("pipe");return1;}

::pipe(ends); //Linux 成功创建 pipe 后,会把两个当前没被占用的文件描述符编号 填进这个数组
pipe() 自己的返回值主要表示:

  • 0 → 创建成功
  • < 0 → 创建失败

ends[0]固定为读端
ends[1]固定为写端

3.第二段代码拆分(write)

constssize_t n=::write(ends[1],text+sent,3-sent);

write(写到哪里, 从哪里拿数据, 要写多少字节);

ends[1]↓ 找到 pipe 的写端 ↓ 把"abc"写进 pipe

write这样写的原因:

第一次 write 想写 abc 实际写 ab 返回2sent=2第二次 write 从 c 开始 还剩1字节 实际写 c 返回1sent=3结束循环

read的问题

read() 返回 0

不是说:
“这一次恰好没读到东西。”

而是:
这个输入流已经结束了。

如果只是暂时没数据,但是写端还开着,普通阻塞式 read() 通常会等而不是返回EOF

情况1: pipe 里有数据 →read()读到数据 → 返回>0情况2: pipe 里没数据 但写端还开着 →read()等待 情况3: pipe 里没数据 而且所有写端都关了 →read()返回0→EOF

4.第三段代码拆分(read)

charbuffer[8];for(;;){constssize_t n=::read(ends[0],buffer,sizeof(buffer));if(n>0)std::printf("read %zd bytes: %.*s\n",n,static_cast<int>(n),buffer);elseif(n==0){std::puts("EOF");break;}elseif(errno==EINTR)continue;else{std::perror("read");::close(ends[0]);return1;}}

其中:

read(ends[0],buffer,sizeof(buffer));

表示从 pipe 的读端读取数据,放进 buffer,最多读取 sizeof(buffer) 个字节。

三.dup2() (复制文件描述符到指定编号)

概念:让 FD 1 不再指向终端,而改成指向 pipe 的写端。(流程图看pipe概念那一栏)

假设Linux 创建 pipe 后:
ends[0] = 3 // 读端
ends[1] = 4 // 写端
而child 里执行:

dup2(ends[1],STDOUT_FILENO);

而STDOUT_FILENO就是1
所以可以先理解成dup2(4,1)
意思是:让 FD 1 现在也指向 FD 4 所指向的那个资源。
于是:

原来: FD1─────→ 终端 FD4─────→ pipe 写端 执行:dup2(4,1);之后: FD1──┐ ├────→ pipe 写端 FD4──┘

为什么 parent 和 child 都要关闭不需要的 pipe 端点?

fork() 后,parent 和 child 都会继承 pipe 的读端和写端,所以刚fork完大致是:

parent:FD3→ pipe 读端 FD4→ pipe 写端 child:FD3→ pipe 读端 FD4→ pipe 写端

(pipe 创建的这两个 fd(3 和 4)在 fork 前就已经在父进程里了)
但实际需要的职责是:

parent:保留读端,关闭写端 child :关闭读端,保留写端

原因:

1. 职责明确:child 负责写输出,parent 负责读取输出。
2. 保证 EOF 正常出现:只有当 pipe 为空且所有写端都关闭时,read() 才返回 0(EOF)。如果 parent 忘记关闭自己的写端,即使 child 已退出,parent 也可能一直等不到 EOF。

CLOEXEC

为什么需要:exec() 会替换程序,但是不会默认关掉原有的fd,当一直拿着fd时可能就会认为还有一个人拿着写端没关 → 读端 read 就认为"还可能有人写"→ 永远不返回 EOF → 父进程卡死

CLOEXEC 用来防止 TaskForge 的内部 FD 泄漏到 exec 后的 workload;但重定向后的 stdout/stderr,也就是 FD 1、FD 2,必须继续保留。(FD 1 / FD 2是 workload 正常输出所依赖的,所以 exec 后必须保留。)

四.RAII

代码部分

classUniqueFd{//现在造一个盒子,把 fd 装进去,fd 归这个盒子管,盒子死的时候帮你关public:explicitUniqueFd(intfd=-1)noexcept:fd_(fd){}//它把传进来的 fd,存进盒子内部的 fd_。~UniqueFd(){reset();}};

情景

intfd=open(...);if(something_failed){return1;}close(fd);

如果中途步骤失败,直接return,那么close(fd)不会执行,导致资源泄漏
所以用UniqueFd来记录

函数正常结束: UniqueFd 被销毁 ↓ 自动close(5)中途return: UniqueFd 也会被销毁 ↓ 仍然自动close(5)

例子

执行 return 时,为什么 FD 5 仍然有机会被自动关闭?

voidtest(){UniqueFdfd(5);if(true){return;}}

答:return 让 test() 的局部作用域结束,局部对象 fd 随之析构,自动调用 ~UniqueFd(),而析构函数内部再负责关闭它拥有的 FD 5。

提醒

UniqueFd 禁止复制,是为了避免同一个 FD 被多个对象共同“拥有”,导致重复 close。
也就是要避免这种:

UniqueFda(5);UniqueFd b=a;

因为这种会有double close(重复关闭)的风险,这会让两个对象都认为自己拥有 FD 5。
但可以设计成:

UniqueFd b=std::move(a);//把 FD 5 的所有权从 a 转交给 b。

转移之后应该是:

a:不再拥有有效 FD b:拥有 FD5

五.waitpid()和读日志的顺序

不能先 waitpid() 再读日志
因为比如一个child无限多日志
所以可能出现:

child: 不停 write 日志 ↓ pipe 越来越满 ↓ pipe 满了 ↓ child 的 write 卡住,等 parent 来读

与此同时:

parent: 正在waitpid()↓ 等 child 退出

程序卡住

正确思路

child 运行期间,parent 就要持续把 pipe 里的数据读走。(drain)

child 继续写 ↓ pipe 有数据 ↓ parent 持续 read ↓ pipe 腾出空间 ↓ child 可以继续 write

六.阻塞式 read()的解决方案

POLL

poll (等待文件描述符事件)可以同时等待 stdout、stderr、startup 等多个 FD 的状态变化。

作用:负责真正把数据读出来。

Q:为什么 TaskForge 不能简单地依次对 stdout、stderr、startup 三个 pipe 做阻塞式 read()?
A:如果按 stdout → stderr → startup 的顺序做阻塞式 read(),而 stdout 此刻没数据,parent 可能先卡在 stdout 上。即使 stderr 已经有很多数据,也没机会去读;stderr 管道甚至可能继续被 child 写满。

但poll 不只是看一眼就立刻返回。它还有等待的语义。而"等多久",取决于你给它设的超时参数。

超时参数 行为 无限 没有任何 pipe 就绪就永远等,不返回0立刻返回,看一眼就走(不等) 某个正数(如20ms) 最多等这么久,要么有 pipe 就绪,要么时间到,就返回

nonblocking I/O

如果把 pipe 的读端设成:
O_NONBLOCK

那么当暂时没有数据时:
read(…)

不会一直卡住,而是马上返回失败,并通过:
errno == EAGAIN

或者:
errno == EWOULDBLOCK

告诉你:
“现在暂时没数据,不是 pipe 坏了,你过一会儿再来。”

child 不断输出 ↓stdout/stderrpipe ↓ parent 用 poll 看哪些 FD 有事件 ↓ 对有数据的 FD 做 nonblocking read ↓ 持续 drain,给 pipe 腾空间

非阻塞 read 遇到“暂时无数据”不会等待,而是返回 EAGAIN/EWOULDBLOCK;只有输入真正结束时才返回 0 表示 EOF。

区别

poll
→ 告诉你哪个 FD 值得处理

nonblocking read
→ 真正读取,但不会因为暂时没数据把整个 parent 卡死

七.启动失败必须有自己的通道

startup pipe(启动错误管道)专门传:child 在 exec 前阶段发生的启动错误
如果 child 在:设置 FD,exec
这些启动阶段出错,就往 startup pipe 里写错误信息。
例如:

阶段:exec 错误编号:ENOENT

parent 就知道:这不是 workload 正常退出,而是根本没启动成功。

如果 exec 成功startup pipe 的 child 写端设置:CLOEXEC
所以:

exec 成功 ↓ startup pipe 写端自动关闭

parent 就能观察到:

startup pipe 没收到错误消息 并且写端关闭

但不能仅凭 startup pipe 的“无消息 EOF”就断言 exec 一定成功。
因为还可能存在:

child 在来得及报告错误之前 ↓ 被 signal 杀掉 这种情况。

八.多线程环境里 fork() 之后的风险

fork后复制的是一整个进程(不是线程)——包括整份内存、所有 FD、各种状态。(fork() 后 child 得到 parent 当时进程内存状态的一份逻辑副本;但只保留调用 fork() 的那条线程。)
假设TaskForge 此时已经是一个多线程程序:

线程 A 线程 B 线程 C

假设fork 之前,线程 B 干了这件事:

线程 B:把 mutex 锁上了 ← 但还没解锁

现在线程 A 调用了:fork();
此时:

parent: 线程 A、线程 B、线程 C (全都在) child : 线程 A (只有 A)

fork 会复制 parent 的整个内存给 child。内存里那个 mutex 的状态是"已锁",于是 child 里:

child 的内存里: mutex 状态=已锁 (原样抄过来的) child 的线程里: 只有 A,没有 B (开锁的人没了)

最后导致程序卡死

九.为什么不用epoll?

poll → 少量 FD 时简单直接 epoll → 大量 FD、统一事件循环时更有优势

当前每个任务只需要监听少量 stdout、stderr、startup 等 FD,poll 的结构足够直接;epoll 更适合大量 FD 集中在单个事件循环中的场景,因此在这里换成 epoll 不一定带来实际收益。

Q

Q:为什么 execv() 失败以后,child 这里更倾向于用 _exit(),而不是普通的 exit()?
A:child 出错时使用 _exit(),是为了直接退出进程,不执行普通 exit() 会做的那些用户态清理、退出处理函数和 stdio 刷新
Q:CLOEXEC 和 close() 有什么区别?
A:close(fd)
→ 现在立即关闭
CLOEXEC
→ 现在继续保留
→ exec 成功时自动关闭

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询