很多第一次在C++里接触fork()函数的朋友,都有过类似的困惑:明明只调用了一次,代码却像被复制了一份,两段逻辑同时跑了。这种"调用一次返回两次"的现象,放在普通函数里几乎是不可想象的。这篇文章想把fork()的来龙去脉讲透——它的进程复制原理、在C++代码里的正确写法、典型的实战场景,以及我实际踩过的几个坑。无论你是刚学系统编程的学生,还是在写服务端程序的开发者,看完应该都能对多进程编程有更清晰的理解,至少要能避开那些入门必踩的雷。
1. 从"调用一次返回两次"说起:fork()的进程复制机制
1.1 fork()之前,先理解进程是什么
先建立一个基本概念:进程是操作系统分配资源的基本单位,它拥有独立的地址空间、文件描述符表、寄存器上下文、环境变量等。在Linux和macOS这类类Unix系统里,C++程序跑起来之后就是一个进程。理论上,进程之间互不干扰,一个进程崩溃了,不应该直接拖垮另一个进程——这正是"多进程"这个设计最大的价值所在。
fork()就是创建新进程的入口。调用它之后,内核会创建一个和当前进程几乎一模一样的子进程。这个复制过程不是"复制一份磁盘上的程序",而是复制当前进程在内存里的运行状态——包括代码段、数据段、堆、栈、打开的文件描述符、当前指令指针位置等。"fork"这个词直译过来就是"分叉",把一条执行流变成两条并行执行流,一个留在父进程里继续走,另一个从分叉点开始带着副本往下走。
注意,fork()不是C++标准库的一部分,而是POSIX规范定义的系统调用接口,在
<unistd.h>里声明。Windows原生运行时没有这套接口,不过在WSL(Windows Subsystem for Linux)或者MSYS2这类Unix兼容环境里可以正常使用。
1.2 子进程到底复制了什么
很多人以为fork复制的是"整个程序文件",这个理解不准确。fork复制的是当前进程运行时的内存快照。具体来说,子进程刚诞生时,它的内存内容和父进程完全一致,包括:
- 代码段和数据段的内容
- 堆上已经分配的变量,包括C++对象的当前状态
- 栈上的局部变量和返回地址
- 文件描述符表(不过父子进程共享的是同一个打开文件的描述,比如同一条管道的读写端,偏移量也是共享的)
也就是说,fork出来的子进程不是重新从main函数开头执行,而是从fork()返回这个点开始继续执行。这个"复制当前状态"的含义很关键:子进程看到的变量、对象、缓冲区,都是fork那一刻的副本,不会包含fork之后父进程或子进程各自产生的变化。
这段状态的复制规则里,确实有少数例外,下面单独开一节讲。
1.3 写时复制:fork为什么没把系统拖垮
如果fork每次都要完整复制一份进程的整个地址空间,那它的成本会高得离谱。一个占了几GB内存的进程,fork一次就要复制几GB数据,性能上是灾难。
现代内核早就解决了这个问题,方案叫写时复制(Copy-on-Write,COW)。它的思路可以打个比方:父子进程先共享同一份物理内存,不着急复制,内核把共享的物理页标记为只读。只要两方都在读,就相安无事。一旦某一方试图写入某个页,内核会触发一个缺页异常,在异常处理里把这一页内容复制一份,再让写入方操作副本,然后解除该页的共享状态。这样,只有实际被修改的内存页才会发生复制,大规模只读取数据时几乎零成本。
所以在常见的"fork之后立刻exec执行新程序"这种模式里,fork的开销非常小。因为子进程几乎没有改动父进程的内存,旧的内存页根本没有被复制,就被exec新程序全部覆盖重用了。COW也是Linux和macOS上进程创建高性能的核心机制。
1.4 返回值:父子进程之间的"分水岭"
fork函数定义是这样的原型:
#include <unistd.h> pid_t fork(void);它有一个特殊之处——调用一次,返回两次。更准确地说,是在父进程和子进程里各返回一次,而且返回值还不一样:
- 在父进程中,fork返回子进程的PID(进程ID),是一个大于0的整数
- 在子进程中,fork返回0
- 如果创建失败,fork在父进程里返回-1,子进程根本不会产生
为什么返回值要设计成"父进程拿到子进程PID,子进程拿到0"?因为父子关系的建立需要这个信息:父进程只有知道子进程的PID,将来才能用wait()或waitpid()回收它;子进程想找自己的"父母",调用getppid()就能拿到父进程PID,不需要额外返回值。而0在进程ID里永远不会出现,用来标识"我是不是子进程"既直观又安全。
返回值判断是fork编程的第一步,也是最重要的分支逻辑。所有基于fork的多进程代码,骨架几乎都是同一副样子:
pid_t pid = fork(); if (pid < 0) { // 出错了,处理异常 } else if (pid == 0) { // 在子进程里做子进程该做的事 } else { // 在父进程里做父进程该做的事 }这个结构我后面会反复强调——一旦漏写了某个分支,程序行为就会变得非常诡异。
1.5 不会被继承的东西:fork后的"断舍离"
fork复制了海量状态,但也不是什么都继承。有几样东西,子进程是拿不到的。这些细节平时不起眼,一旦遇到多进程协作项目,都是定位问题的关键线索:
- 子进程的PID一定和父进程不同,而且子进程的父进程PID会被设置为调用fork的那个进程的PID
- 文件锁不继承:如果父进程持有某个文件的锁,子进程原始状态下不持有
- 未处理的信号集会被清空:父进程收到但还没来得及处理的信号,不会传给子进程
- 定时器(如
setitimer设置的定时器)不会继承 - 各种性能计数(如CPU时间计数器)会从零开始
这类"不继承"规则对C++项目有个深远的含义:子进程不是父进程的"续集",它是一个全新的独立运行体,只是恰好继承了父进程当前的许多状态。写代码时必须默认"子进程需要自己重新初始化关键资源",而不是依赖从父进程带过来的东西。
2. 用C++写第一个fork()程序:环境准备、代码骨架与进程回收
2.1 头文件、编译选项与运行环境
在C++里直接使用fork,需要在文件开头包含这些头文件:
#include <unistd.h> // fork, getpid, getppid, pipe 等 #include <sys/types.h> // pid_t 类型定义 #include <sys/wait.h> // wait, waitpid #include <cstdio> // printf编译时不需要额外链接库,因为fork属于C库(libc)的一部分,不是第三方库。一个最简单的编译命令是:
g++ -std=c++17 fork_demo.cpp -o fork_demo环境方面,Linux、macOS都能直接跑。Windows如果坚持要用原生的Windows API,只能改用CreateProcess之类的接口,和fork的语义差异极大。但如果你在WSL里装一个Linux发行版,或者用MSYS2、Cygwin,就能正常体验fork。说实话,真正想学系统编程和并发编程,Linux环境是最省心的。
2.2 第一段可运行的fork示例
我平时给同事演示fork时,用的就是这版二十行不到的代码。建议你把它完整跑一遍,好好观察输出顺序:
#include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <cstdio> int main() { printf("调用fork之前:只有我一个进程,PID=%d\n", getpid()); pid_t pid = fork(); if (pid < 0) { perror("fork失败"); return 1; } if (pid == 0) { // 子进程执行的逻辑 printf("我是子进程,PID=%d,我的父进程PID=%d\n", getpid(), getppid()); } else { // 父进程执行的逻辑 printf("我是父进程,PID=%d,我的子进程PID=%d\n", getpid(), pid); } printf("这行两个进程都会执行,PID=%d\n", getpid()); return 0; }运行结果大概长这样:
调用fork之前:只有我一个进程,PID=1024 我是父进程,PID=1024,我的子进程PID=1025 我是子进程,PID=1025,我的父进程PID=1024 这行两个进程都会执行,PID=1025 这行两个进程都会执行,PID=1024注意观察:fork()之前的printf只打印一次,而fork()之后的printf打印了两次。而且第二次调用printf的那行代码,在逻辑上确实被两个进程各执行了一遍。两个进程里getpid()的值不同,PUZZLE就在这里——同样的代码,跑出了两个不同的"自己"。
输出的顺序可能不是严格固定先父后子,因为两个进程在之后是并发调度执行的。日志里哪个先刷出来,取决于操作系统的进程调度策略,这是多进程编程里第一个需要适应的"不确定性"。
2.3 父子进程的角色分工与退出策略
一个常见的初学者误区是:父子进程各自把整段业务逻辑都跑一遍,结果两边都干了一样的活。正确思路是:fork完成的一瞬间就应该"分道扬镳",用if (pid == 0)和else把逻辑彻底拆开。
父进程的角色通常是:继续服务主流程、监控子进程状态、接收子进程的返回结果。子进程的角色通常是:执行具体任务,任务结束就退出。
子进程退出时有一点要牢记:优先使用_exit()或_Exit(),而不是标准C的exit()。原因和C++的iostream缓冲区有关。exit()会刷新所有标准I/O流并调用全局对象的析构函数,而fork()已经把缓冲区和对象状态复制到了子进程,如果父进程的stdout缓冲里还残留着未刷出的数据,子进程一exit()就会把这份拷贝再刷一遍,导致日志重复输出。_exit()则直接进入内核退出流程,不经过C库的清理逻辑,干净利落。
2.4 用wait()回收子进程:防止僵尸进程
子进程退出之后,不会凭空消失。内核会保留一个叫做"进程表项"的记录,里面包含退出状态、CPU占用统计等信息,直到父进程调用wait()或waitpid()来读取它。如果父进程一直不读,这个子进程就成了僵尸进程,用ps看状态那一栏是Z。
僵尸进程不占CPU也不占内存,但它占着进程表项,而进程表项数量是有限的。积少成多,进程表满了,新fork就会失败。
父进程最基础的回收方式:
// 阻塞等待任意一个子进程结束 wait(nullptr); // 等待指定PID的子进程结束,同时回收退出状态 int status; waitpid(pid, &status, 0);waitpid的第三个参数支持传WNOHANG,实现非阻塞检查:子进程没退出时立刻返回0,父进程可以去干别的事,过一会儿再回来查看。这个特性在后面写并行任务调度时非常有用。
3. fork()的典型落地场景:数据并行、进程隔离与外部程序协作
3.1 fork与std::thread的选型对比
在C++里聊并发,大家第一反应往往是std::thread。但fork代表的"多进程方案"和"多线程方案"并不是同一回事。我在实际项目里的经验是,这两者的选择很大程度上取决于你需要什么程度的隔离性,而不只是性能。
| 维度 | fork创建的子进程 | std::thread创建的线程 |
|---|---|---|
| 地址空间 | 独立,互不干扰 | 共享,天然可用全局变量通信 |
| 一个成员崩溃 | 通常不影响其他进程 | 可能直接拖垮整个进程 |
| 通信方式 | 管道、共享内存、信号等 | 互斥锁、条件变量,更简洁直接 |
| 上下文切换开销 | 较大 | 较小 |
| 适用场景 | 隔离不可信模块、调用第三方库、进程级容错 | CPU密集型协作、高频小任务 |
我的选型原则是:如果任务是高频率精细化协作,比如计算同一个矩阵的不同分块然后合并结果,用线程更合适;如果任务是执行不太可信的第三方库、或者希望某个任务崩了也不影响主服务,比如Web服务器里处理单个请求的worker,fork就更有优势。Nginx的master-worker架构就是典型代表——master进程fork出一批worker进程,每个worker独立处理连接,某个worker出问题挂掉,master重新拉一个起来就行,整个服务不会因为一个请求导致的崩溃而全面瘫痪。
3.2 多进程并行:让N个子进程各干一段活
我用一个经典的场景来演示fork的并行能力:并行统计文件行数。假设目录里有三个文本文件,需要统计各自有多少行。最简单的做法是串行读,但如果我们想体会"并行完成任务,然后汇总结果",fork是个不错的切入点:
#include <unistd.h> #include <sys/wait.h> #include <fcntl.h> #include <cstdio> static int count_lines(const char* path) { FILE* fp = fopen(path, "r"); if (!fp) return -1; int lines = 0; int ch; while ((ch = fgetc(fp)) != EOF) { if (ch == '\n') ++lines; } fclose(fp); return lines; } int main() { const char* files[] = {"a.txt", "b.txt", "c.txt"}; const int n = 3; for (int i = 0; i < n; ++i) { pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } if (pid == 0) { // 子进程负责统计第i个文件 int result = count_lines(files[i]); if (result < 0) _exit(1); _exit(result & 0xff); // 直接通过退出码返回行数 } // 父进程继续循环fork下一个子进程 } // 父进程回收所有子进程,汇总结果 int total = 0; for (int i = 0; i < n; ++i) { int status = 0; wait(&status); if (WIFEXITED(status)) { total += WEXITSTATUS(status); } } printf("总行数=%d\n", total); return 0; }在这个例子里,每个子进程独立负责一个文件,_exit(result & 0xff)把行数编码在退出码里带回父进程。需要注意一个限制:退出码只有8位,最大只能表示255,所以这只适合行数少的演示场景。生产环境需要传大量数据时,建议用管道或共享内存。下面的管道部分会涉及。
3.3 管道通信:父子进程之间真正传输数据
退出码传不了多少数据,想要在父子进程之间传任意字节,最直接的手段是管道。管道是内核提供的一块缓冲区,一端写入一端读出,读写端分别是两个文件描述符。我用一个简单的"子进程向父进程发送字符串"例子来说明:
#include <unistd.h> #include <sys/wait.h> #include <cstdio> #include <cstring> int main() { int pipefd[2]; if (pipe(pipefd) == -1) { perror("pipe"); return 1; } pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } if (pid == 0) { // 子进程:关闭读端,只保留写端 close(pipefd[0]); const char* msg = "hello from child"; write(pipefd[1], msg, strlen(msg)); close(pipefd[1]); _exit(0); } else { // 父进程:关闭写端,只保留读端 close(pipefd[1]); char buf[128] = {0}; ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1); if (n > 0) { printf("父进程收到:%s\n", buf); } close(pipefd[0]); wait(nullptr); } return 0; }这段代码有几个细节值得注意:
- 必须关闭不需要的管道端。父进程要读,就得把写端关掉;子进程要写,就得把读端关掉。如果不关,管道两端的引用数都不为0,读端调用
read()时会因为写端仍然打开而永远阻塞,永远不会读到EOF——这是多进程通信里最常见的"程序卡住"原因之一。 - 管道是半双工的。一段管道数据只能单向流动,如果要双向通信,通常需要创建两根管道。
我第一次写管道程序时就因为忘记close(pipefd[1]),父进程的read()直接挂死,后来通过strace定位才发现是管道读写端引用数的问题。这个细节之后还会在调试章节提及。
3.4 调用外部程序:fork与exec系列组合
fork本身不会覆盖当前程序的映像。如果想在子进程里跑一个全新的外部程序,需要结合exec系列函数。exec的作用是:用新程序替换当前进程的代码段和数据段,但进程的PID保持不变。所以"fork+exec"组合才是"创建新进程执行新程序"的完整方案——fork先生成一个子进程,exec再把子进程变成要运行的l程序。
#include <unistd.h> #include <sys/wait.h> #include <cstdio> int main() { pid_t pid = fork(); if (pid == 0) { // 子进程:把自己替换成ls -l execlp("ls", "ls", "-l", nullptr); perror("execlp失败"); _exit(127); // 只有exec失败才会执行到这里 } else { wait(nullptr); } return 0; }execlp中的p表示会在PATH环境变量指定的目录里搜索可执行文件。成功执行后,execlp下面的代码永远不会运行,因为程序映像已经整个被替换了。失败时,它才会返回——所以紧随其后的perror异常处理非常重要,不写你就完全不知道exec为什么失败。
这套机制在C++项目里常用于:把一个子进程替换成Python脚本、Shell命令或者其他任何外部工具,实现某种程度的"进程内嵌外部程序"。
4. 最容易翻车的几个坑:缓冲区复制、多线程调用与C++对象状态
4.1 printf缓冲区被复制导致的双重输出问题
这是fork新手最经典的坑,同时也是体现fork"复制内存快照"语义最生动的例子。
printf输出到文件或管道时,数据会先进入进程的stdio缓冲区,只有缓冲区满、显式fflush或进程退出时才真正写出。fork复制进程内存时,缓冲区里的内容也被复制了。结果就是:如果你在fork之前printf了一段不带换行符的内容,而这段内容还滞留在缓冲区里,当父进程和子进程分别退出时,每个进程都会把缓冲区的内容刷一次——日志就出现了两份。
看个最小复现:
#include <unistd.h> #include <cstdio> int main() { printf("这句没有换行,且运行输出会被重定向"); fork(); return 0; }如果你直接运行程序到终端,printf遇到终端通常是行缓冲,可能看不到问题。但一旦把输出重定向到文件,比如./demo > out.txt,stdio会切换为全缓冲,打开out.txt你会发现那行文字出现了两遍。
修复方法很简单:在fork之前调用
fflush(nullptr),把所有缓冲区的数据强制刷出去。或者用write这类不经过stdio缓冲的系统调用直接写文件,避免中间层缓冲。
我的习惯是:fork之前,只要这个进程写过日志或C++的std::cout,一律先fflush(nullptr)再fork,宁可错杀也不留隐患。
4.2 多线程程序中调用fork的隐患
如果一个进程里已经跑着多个std::thread,然后在主线程里调fork(),这会引发一些非常隐蔽的问题。POSIX规范规定:fork之后,子进程里只保留当前调用fork的那个线程,其他线程全部消失。听起来很干脆,但问题来了——那些消失的线程可能正持有互斥锁、条件变量,甚至还在执行到一半的临界区操作。锁在子进程里保持着"已被某线程锁定"的状态,而那个线程已经不存在了。子进程下次尝试对这个锁加锁时,会直接死锁。
想象一下这个场景:线程A持有某个互斥锁在更新日志缓冲区,线程B调用了fork,子进程里线程A消失,但互斥锁状态还是"已锁定"。子进程里无论哪个线程尝试加锁,都永远等不到持有者来释放。
规避手段通常有三种:
- 在多线程环境里尽量避免fork,只在程序早期单线程阶段完成fork
- fork之后立刻exec外部程序,因为exec会重置掉进程的地址空间,锁状态被低温格式化
- 确实必须在多线程里fork,可以考虑用
pthread_atfork注册必要的清理函数,在fork即将发生时获取所有需要的锁,在子进程里重新初始化状态
这个坑属于"概率性踩坑"——有时候几个月跑不出问题,一旦出现就是死锁,而且多半在线上环境爆发。我个人的底线是:进程内只要有多个活跃线程,就默认不碰fork;必须要多进程隔离,就考虑在启动阶段一次性把子进程全部孵化好。
4.3 C++对象与RAII资源在fork后的状态
fork复制的是字节级别的内存快照。也就是说,子进程里所有的C++对象都是父进程当时的副本,构造函数不会重新执行,析构函数也不会因为"另一个进程里有同一份对象"而互相感知。
这带来一个有意思的行为:如果你用std::thread包装一些任务,在fork之后子进程里的thread对象只是一个"空壳",它对应的系统线程根本没有被创建。同样,基于RAII管理的堆内存、文件句柄、socket描述符,在子进程里全是"借来的身份",不会自动重新初始化。
所以维护C++多进程项目时,一个重要的心法是:fork之后,子进程代码应该像写一个全新的单线程小main程序一样,只信任最基础的、不依赖线程和复杂对象状态的逻辑。如果必须用某个类,最好显式re-init,或者干脆放在fork之后重新构造,而不是指望从父进程复制来的状态能正常工作。
4.4 进程数量失控:fork炸弹与资源耗尽
写得不够小心的循环fork会把系统资源耗光。网上流传的"fork炸弹"原理就是无限递归fork,一直到系统进程表满、负载飙升、系统假死。C++里另一个常见的变体是:父进程fork之后不wait,循环里不断创建新子进程,子进程再次fork……进程数量按指数增长,内存和PID资源迅速耗尽。
好习惯是:每次fork都明确谁负责做wait,以及通过waitpid的返回值判断子进程是否已经退出,避免重复fork。另外,开发环境里给每个终端设置用户进程数上限ulimit -u,能有效避免手滑导致的系统冻结。我在本机做实验时会特意先ulimit -u 256,宁可实验中途报错,也不想把自己机器搞到什么程序都启动不了。
5. 排查链路:僵尸进程、返回值遗漏与调试工具
5.1 返回值检查的错误示范与正确写法
很多初学示例为了短小精悍,会把返回值检查省略成只有一个"子进程和父进程都执行"的分支。这种写法在练习里能跑,但在生产项目里等于埋雷。
常见错误:
// 错误示范:不检查pid < 0 pid_t pid = fork(); if (pid == 0) { // 子进程逻辑 } else { // 父进程逻辑 }如果fork失败返回-1,这段代码会把失败误判为父进程分支继续往下跑。对于健壮的程序,fork失败是必须处理的情况——系统进程数打满、内存不足都可能导致失败。正确写法应该始终保留三分支结构:
pid_t pid = fork(); if (pid < 0) { perror("fork failed"); // 错误处理:记录日志、清理资源、恢复现场 } else if (pid == 0) { // 子进程专用逻辑,记得用_exit退出 } else { // 父进程专用逻辑,记得wait回收 }这个三分支结构是所有fork代码的基本盘。我在审查同事代码时,会直接检索所有fork调用,看到缺pid < 0分支的,一律打回重写。
5.2 僵尸进程与孤儿进程的排查过程
线上遇到进程数异常增多,ps aux里出现大量Z状态进程,基本就是某个父进程忘了回收子进程。排查思路一般是这几步:
- 用
ps -ef或ps aux找到Z状态的进程,记录它的PPID(父进程PID) - 找到对应的父进程,查看源码里是否有对应的
wait/waitpid调用 - 确认子进程是否因为某些逻辑迟迟不退出,导致
wait一直被阻塞 - 如果父进程长期存在且重循环fork,马上检查是否在循环每条路径上都做了回收
还有一类情况是僵尸进程出现时的"孤儿进程"问题:父进程先退出,子进程被内核移交给init进程(PID 1)收养,由init负责回收。这种通常不算泄漏,但如果子进程数量庞大且交给init后再也不回收,也会积累进程表项。
5.3 用gdb和strace调试多进程程序
调试fork程序时,一个常见的困惑是:gdb默认只跟踪父进程,fork出来的子进程不受控,断点打不上。解决思路是设置gdb跟随模式:
gdb ./demo # 在gdb里输入 set follow-fork-mode child # 再设置 set detach-on-fork on设置后,gdb在fork处会跳进子进程继续调试。如果父子进程都要调,可以打开set detach-on-fork off,让gdb同时接管父和子,用inferior命令切换。
strace是我更常用的诊断工具,因为不需要调试符号,直接跟踪系统调用:
strace -f -o trace.log ./demo-f表示跟随fork出来的子进程,-o把全部系统调用记录到文件。遇到"程序卡住"的谜题时,strace能直接告诉你进程阻塞在哪个系统调用上——比如之前提到的管道读写端没关,strace日志里就会看到父进程一直停在read(),永远等不到数据。
5.4 哪些情况下应该放弃fork改用线程
fork不是银弹。如果你的场景满足下面任意一条,我建议直接换std::thread或者线程池:
- 需要高频共享复杂数据结构,比如并发操作同一个
std::map或std::vector - 任务粒度小、切换频繁,进程切换开销会占掉收益
- 运行平台是Windows原生环境,不想引入WSL兼容层
- 进程内已经有大量活跃线程,而你又没法避免在线程存续期间创建进程
fork的优势集中在"隔离"和"稳定性"上,而线程的优势集中在"协作"和"共享"上。选型本质上是在这两者之间做权衡。
6. 以我的项目经验收尾:三个必须想清楚的问题
分享一点个人体会。我最早做多进程服务器时,第一次碰到子进程退出但父进程状态不对的问题,排查到半夜才发现是我忘了waitpid导致子进程积累成了僵尸,后来的日志错乱则是因为fork前没刷stdout缓冲区。这两个坑都不难修,但都够折腾人。
所以现在我每次写完fork相关代码,都会强制自己过三遍检查:
- 第一遍:fork前的缓冲区是否已经flush干净?是否有不该被子进程带走的引用计数或RAII资源?
- 第二遍:返回值的三分支是否完整?子进程是否用了
_exit而不是exit? - 第三遍:谁负责
wait回收子进程?如果子进程生命周期不稳定,是否用WNOHANG非阻塞轮询更安全?
这三个问题如果想清楚,多进程C++代码的基本盘就稳了。fork这个接口本身并不复杂,复杂的是它背后"复制进程状态"这个看似简单、实则暗藏无数细节的机制。吃透这一层,你再看Nginx的master-worker架构、各类多进程守护进程框架,就会有种豁然开朗的感觉。