在上一章节"增量传输算法"中,我们深入剖析了 rsync 的核心算法------它是如何通过巧妙的校验和比对,做到只传输文件的差异部分的。我们理解了"做什么"和"怎么做"的细节。然而,这个算法的执行并不是由一个单一的程序完成的,而是由一组分工明确的进程协同合作的结果。
本章,我们将把镜头从算法本身移开,聚焦于执行这些算法的rsync 的进程角色。你会发现,rsync 的架构并非简单的"客户端-服务器"两方通信,尤其是在文件的接收端,它采用了一个独特的三进程模型。理解这个模型是掌握 rsync 完整工作流程的关键。
一个建筑项目的比喻
想象一下,你要对一栋旧建筑进行现代化改造。这个项目不是简单地把新设计图交给施工队就完事了,而是需要一个更精密的流程:
-
架构师 (Generator - 生成器):首先,一位架构师会来到现场,仔细勘测现有的建筑结构(目标端的旧文件),并绘制出一份详细的"物料需求清单"(块校验和列表)。这份清单精确地描述了哪些旧结构可以保留,哪些需要替换。
-
物料供应商 (Sender - 发送者):架构师将这份清单发送给远方的物料供应商。供应商拥有所有新建筑的材料(源端的新文件)。他会对照清单,只挑选并运送那些现场没有的、或者需要更新的"新材料"(文件的差异数据)。他不会把整栋新建筑的材料都运过来,那样太浪费了。
-
施工队 (Receiver - 接收者):最后,现场的施工队拿到了供应商运来的新材料。他们根据架构师的原始蓝图(这里指重建指令),将新材料安装到正确的位置,并巧妙地利用了所有可复用的旧结构,最终精确地建好了改造后的新建筑(目标端的新文件)。
Rsync 的核心工作流程,就是这三个角色------生成器、发送者、接收者------的协同演出。其中,"生成器"和"接收者"都位于目标机器上,而"发送者"位于源机器上。
进程角色的分工
让我们来正式认识一下这三个角色:
-
发送者 (Sender)
- 位置: 源机器(存放新文件的机器)。
- 职责 :
- 接收来自"生成器"的校验和列表(物料需求清单)。
- 逐字节扫描自己的新文件,利用增量传输算法与校验和列表进行比对,找出差异。
- 将差异部分(新数据块)和匹配指令(重用旧数据块的命令)打包,发送给"接收者"。
- 对应代码 : 主要逻辑在
sender.c
文件中。
-
生成器 (Generator)
- 位置: 目标机器(存放旧文件的机器)。
- 职责 :
- 读取本地的旧文件(如果有的话)。
- 将旧文件分割成块,并为每个块计算出弱校验和与强校验和。
- 将这个校验和列表发送给"发送者"。
- 在整个同步流程中,它还扮演着"总指挥"的角色,决定哪些文件需要同步,并协调其他进程的工作。
- 对应代码 : 主要逻辑在
generator.c
文件中。
-
接收者 (Receiver)
- 位置: 目标机器。
- 职责 :
- 接收来自"发送者"的差异数据和匹配指令。
- 像一个"拼装工",根据指令,一部分数据直接使用新接收的,另一部分则从本地的旧文件中复制。
- 最终,在本地组装出完整的新文件。
- 对应代码 : 主要逻辑在
receiver.c
文件中。
协作流程图
这个"建筑项目"的流程可以用一个时序图清晰地展示出来。假设我们要将文件从"源端"同步到"目标端":
这个设计的精妙之处在于,计算密集型的"生成校验和"和 I/O 密集型的"重建文件"被分在了两个进程中,它们可以并行工作,极大地提高了效率。
深入代码:进程是如何诞生的?
你可能会问:这三个进程是在什么时候、又是如何被创建和分配角色的呢?
答案藏在 main.c
的 do_recv
函数中。当 rsync 确定自己是接收文件的一方时(即它不是 --sender
),它会执行这个函数。do_recv
的核心操作之一就是 fork()
,这是一个经典的 Unix 系统调用,用于创建一个新的子进程。
c
// 文件: main.c (简化逻辑)
static int do_recv(int f_in, int f_out, char *local_name)
{
int pid;
// ... 其他准备工作 ...
if ((pid = do_fork()) == -1) {
// fork 失败,报错退出
rsyserr(FERROR, errno, "fork failed in do_recv");
exit_cleanup(RERR_IPC);
}
if (pid == 0) {
// 这里是子进程的代码
am_receiver = 1; // 角色分配:我是"接收者" (施工队)
// ...
recv_files(f_in, f_out, local_name); // 开始接收并重建文件
// ...
}
// 这里是父进程的代码
am_generator = 1; // 角色分配:我是"生成器" (架构师)
// ...
generate_files(f_out, local_name); // 开始生成校验和并指挥整个流程
// ...
// 等待子进程结束
wait_process_with_flush(pid, &exit_code);
return exit_code;
}
这段代码清晰地展示了:
- 父进程调用
do_fork()
创建一个子进程。 - 在子进程(
pid == 0
的分支)中,通过设置全局变量am_receiver = 1
,将自己的角色定义为"接收者"。然后它会调用recv_files
(receiver.c
中的核心函数) 来执行重建任务。 - 在父进程中,它将自己的角色定义为"生成器"(
am_generator = 1
),并调用generate_files
(generator.c
中的核心函数) 来总揽全局。
而"发送者"(am_sender
)这个角色,则是在程序启动时根据命令行参数(如客户端指定了远程目标)或守护进程配置来确定的,它不需要 fork
,因为它自己就是独立的一方。
为了方便调试和日志记录,rsync 提供了一个内部函数 who_am_i()
,它会根据这些标志变量返回当前进程的角色名。
c
// 文件: rsync.c
const char *who_am_i(void)
{
// ...
return am_sender ? "sender"
: am_generator ? "generator"
: am_receiver ? "receiver"
: "Receiver"; /* pre-forked receiver */
}
这个函数在 rsync 的日志输出中被频繁使用,帮助我们理解在复杂的交互中,到底是哪个"角色"在说话。
各角色的核心动作
现在我们知道了角色的来源,再来看看它们各自执行的核心代码片段。
1. 生成器 (Generator) 的工作
在 generator.c
的 generate_and_send_sums
函数中,生成器读取本地文件,计算校验和,并发送出去。
c
// 文件: generator.c (简化版)
static int generate_and_send_sums(int fd, OFF_T len, int f_out, int f_copy)
{
// ...
// 1. 决定块大小和校验和长度
sum_sizes_sqroot(&sum, len);
// 2. 将这些头部信息发送给 Sender
write_sum_head(f_out, &sum);
// 3. 循环读取文件的每一个块
for (i = 0; i < sum.count; i++) {
// ... 读取一个数据块 ...
// 4. 计算两个校验和
sum1 = get_checksum1(map, n1);
get_checksum2(map, n1, sum2);
// 5. 将校验和发送给 Sender
write_int(f_out, sum1);
write_buf(f_out, sum2, sum.s2length);
}
// ...
return 0;
}
这个过程就是"绘制物料需求清单"并寄出的过程。
2. 发送者 (Sender) 的工作
在 sender.c
的 send_files
函数中,发送者接收校验和列表,然后开始匹配和发送差异。
c
// 文件: sender.c (简化版)
void send_files(int f_in, int f_out)
{
// ...
// 1. 接收来自 Generator 的校验和列表
s = receive_sums(f_in);
// 2. 打开本地的新文件
fd = do_open_checklinks(fname);
mbuf = map_file(fd, st.st_size, read_size, s->blength);
// ...
// 3. 核心步骤:匹配文件,并发送差异数据
// 这背后就是上一章讲的增量传输算法
match_sums(f_xfer, s, mbuf, st.st_size);
// ...
}
这就是"根据清单发货"的过程。
3. 接收者 (Receiver) 的工作
最后,在 receiver.c
的 receive_data
函数中,接收者根据收到的指令重建文件。
c
// 文件: receiver.c (简化版)
static int receive_data(...)
{
// ...
// 循环接收来自 Sender 的"令牌"(token)
while ((i = recv_token(f_in, &data)) != 0) {
if (i > 0) {
// 这是一个新数据块 (i 是数据长度)
// 直接将 data 写入新文件的当前位置
write_file(fd, 0, offset, data, i);
offset += i;
} else {
// 这是一个匹配指令 (i 是一个负数,代表块的索引)
i = -(i+1); // 解码出块的索引
offset2 = i * (OFF_T)sum.blength; // 计算旧文件中该块的位置
// 从旧文件中复制这个块到新文件的当前位置
map = map_ptr(mapbuf, offset2, len);
write_file(fd, 0, offset, map, len);
offset += len;
}
}
// ...
return 1;
}
这就是"施工队根据新材料和旧结构进行施工"的过程。
总结
在本章中,我们了解了 rsync 独特的三进程协作模型:
- 动机:为了将计算密集型任务和 I/O 密集型任务分离,实现更高的并行度和效率,rsync 在接收端采用了"生成器+接收者"的双进程模式。
- 三大角色 :
- 生成器 (Generator):目标端的"架构师",负责分析旧文件并创建校验和"蓝图"。
- 发送者 (Sender):源端的"物料供应商",根据蓝图仅发送必要的差异"材料"。
- 接收者 (Receiver):目标端的"施工队",利用新材料和旧结构重建文件。
- 实现 :这一模型通过在接收端执行一次
fork()
来实现,父进程成为"生成器",子进程成为"接收者"。它们通过am_generator
和am_receiver
等全局标志来识别自己的身份。
理解了 rsync 是如何组织其内部"团队"的,我们对它的工作原理有了更宏观的认识。我们已经知道了 rsync 如何解析选项,如何执行算法,以及是由哪些进程角色来执行的。
但是,在这一切发生之前,rsync 首先需要确定到底要同步哪些文件。这个决策过程本身也相当复杂,涉及到递归遍历、过滤规则等。在下一章,我们将探讨 rsync 工作流程的起点------文件列表 (File List),看看 rsync 是如何构建出需要处理的文件清单的。