ZeroTier 源码解析 (8) 物理路径 (Path)

127 阅读7分钟

在上一章 拓扑 (Topology) 中,我们学习了一个节点 (Node)是如何维护一张关于整个 ZeroTier 网络的“地图”的。通过这张地图,节点可以找到网络中的其他对等节点 (Peer),并与它们建立虚拟的连接关系。

我们现在知道,Peer 对象代表了“与谁通信”以及“如何安全通信”。但是,所有这些虚拟的连接,最终都必须通过真实、物理的互联网线路来传输数据。

这里就引出了我们旅程的最后一站,也是最接近物理现实的一个问题:当你的笔记本电脑决定要给服务器发送一个数据包 (Packet)时,它究竟应该通过哪个具体的网络接口(Wi-Fi还是有线网?)、哪个IP地址(IPv4还是IPv6?)来发送呢?

为了管理这些具体的、物理的连接路由,ZeroTier 引入了 物理路径 (Path) 的概念。

什么是物理路径 (Path)?

Path 对象代表了一条可以通过真实互联网连接到另一个对等节点 (Peer)的具体物理路由。你可以把它想象成你的通讯录里,一个联系人名下的某一个具体的电话号码

让我们用一个简单的比喻来理解:

  • 对等节点 (Peer):是你通讯录里的一个联系人,比如“公司服务器”。
  • 物理路径 (Path):是这个联系人名下的一个具体联系方式。例如:
    • Path 1: 办公室的有线网络IP地址 (198.51.100.5:9993)
    • Path 2: 办公室的Wi-Fi IPv4地址 (198.51.100.6:9993)
    • Path 3: 办公室的Wi-Fi IPv6地址 ([2001:db8::1]:9993)

一个节点 (Node)可能同时拥有多种网络连接方式,因此一个Peer可能会对应多个可用的Path。ZeroTier 的智能之处就在于,它会像一个聪明的拨号助理,持续地监控所有这些“电话号码”的通话质量(如延迟、丢包率),并总是选择最快、最可靠的一条来发送数据。

graph TD
    Peer["Peer 对象 (公司服务器)"] --> Path1["Path 1<br>(有线 IPv4: 198.51.100.5)"];
    Peer --> Path2["Path 2<br>(Wi-Fi IPv4: 198.51.100.6)"];
    Peer --> Path3["Path 3<br>(Wi-Fi IPv6: [2001:db8::1])"];

Path 对象的核心职责

Path 对象本质上是一个轻量级的数据结构,它的核心职责是记录和评估一条物理路由的状态

  1. 身份标识: 每个 Path 对象都由一个本地套接字(我们从哪个网卡发出)和一个远端 InetAddress(IP地址和端口)唯一确定。
  2. 状态追踪: 它记录了关于这条路径的关键性能指标:
    • _lastIn / _lastOut: 最后一次在该路径上接收或发送数据包的时间戳。这用于判断路径是否还“活着”。
    • _latency: 这条路径的估计往返延迟(单位是毫秒)。
  3. 发送执行者: 当对等节点 (Peer)对象最终选定了一个 Path 来发送数据时,Path 对象的 send() 方法就是最终的执行者。它负责将数据包交给底层的网络接口,从指定的本地套接字发送到目标IP地址。

代码中的 Path

Path 类的定义非常简洁,可以在 node/Path.hpp 文件中找到。它主要包含了一些状态变量和几个核心方法。

// 文件: node/Path.hpp (简化版)

class Path
{
private:
    volatile int64_t _lastOut;   // 最后发送时间
    volatile int64_t _lastIn;    // 最后接收时间
    
    int64_t _localSocket;        // 使用的本地套接字
    InetAddress _addr;           // 远端的 IP 地址和端口
    
    volatile unsigned int _latency; // 估计的延迟 (ms)

public:
    // 构造函数
    Path(const int64_t localSocket, const InetAddress &addr);

    // 通过此路径发送数据
    bool send(const RuntimeEnvironment *RR, void *tPtr, const void *data, unsigned int len, int64_t now);

    // 检查路径是否还存活
    inline bool alive(const int64_t now) const;

    // 更新延迟测量值
    inline void updateLatency(const unsigned int l, int64_t now);

    // ... 其他辅助方法
};
  • _lastOut_lastIn: 这两个时间戳是判断路径活跃度的关键。如果一条路径很久没有收到任何数据包,它就会被认为是“死的”。
  • _localSocket_addr: 这两个变量唯一地定义了一条路径。
  • _latency: 这个值由对等节点 (Peer)通过发送 ECHO 包并测量响应时间来不断更新。
  • send(): 这是 Path 最直接的功能。它封装了向物理网络发送数据的最终操作。

Path 的生命周期:发现、评估与淘汰

Path 不是静态配置的,而是由 ZeroTier 动态发现和管理的。这个过程展示了 ZeroTier P2P 网络强大的自适应能力。

场景:两条路径的发现之旅

假设你的笔记本电脑(A)和一台服务器(B)都加入了同一个 ZeroTier 网络。A 有 Wi-Fi,B 有有线网络。它们之间是如何发现并建立直接路径的呢?

sequenceDiagram
    participant Node_A as 节点 A (你的电脑)
    participant Peer_B as B 的 Peer 对象 (在 A 中)
    participant Planet as 行星服务器 (中继)
    participant Node_B as 节点 B (服务器)

    Node_A->>Planet: 我想和 B 通信 (通过中继)
    Planet->>Node_B: A 给你发消息了 (源 IP 是 A 的 Wi-Fi 公网 IP)

    %% 路径发现的第一步
    Node_B->>Node_B: 注意到数据包来自 A 的公网 IP:Port
    Node_B->>Node_A: 回复 HELLO (直接发往 A 的公网 IP:Port)
    
    %% 路径在 A 侧被创建
    Node_A->>Peer_B: 我收到一个来自 B 的直接 HELLO!
    Peer_B->>Peer_B: 创建一个新的 Path 对象 (B -> A)

    %% A 也开始尝试联系 B
    Node_A->>Node_B: 我也给你发个 HELLO 吧 (直接发往 B 的公网 IP)
    Node_B->>Peer_B: 我也收到一个来自 A 的直接 HELLO!
    Peer_B->>Peer_B: (在 B 中) 创建一个新的 Path 对象 (A -> B)
    
    %% 路径评估
    Peer_B->>Peer_B: 定期通过新 Path 发送 ECHO 包
    Peer_B->>Peer_B: 根据 ECHO 回复更新 Path 的延迟
  1. 初次接触: A 和 B 通过行星服务器 (Planet)进行中继通信。当 B 收到 Planet 转发来的 A 的数据包时,它可以看到数据包的“真实”来源 IP 地址(即 A 的 Wi-Fi 出口的公网 IP 地址)。
  2. 路径学习: B 会认为这个地址是一个潜在的、可以直接联系到 A 的Path。于是,B 会尝试直接向这个地址发送一个 HELLO 包。
  3. 路径创建: 当 A 收到这个直接从 B 发来的 HELLO 包时,A 的对等节点 (Peer)对象(为 B 创建的那个)就会意识到:“哇,我发现了一条直达 B 的新路!” 它会立即创建一个新的 Path 对象来代表这条新发现的物理路由。
  4. 双向探测: 同样的过程也会在 A 尝试联系 B 时发生。
  5. 评估与选择: 一旦 Path 被创建,Peer 对象就会开始定期通过它发送 ECHO 探测包,以测量其延迟和稳定性。当需要发送真正的业务数据时,PeergetAppropriatePath() 方法就会在所有已知的、活跃的 Path 中,选择延迟最低、质量最好的那一个。
  6. 淘汰: 如果一条 Path 长时间没有任何数据往来(alive() 返回 false),Peer 对象最终会将其从自己的路径列表中移除,释放资源。

深入代码:Peer 如何管理 Path

一个 Peer 对象内部维护着一个 Path 列表。让我们看看 Peer.hpp 中这个列表是如何定义的:

// 文件: node/Peer.hpp

class Peer
{
    // ...
private:
    // 一个 PeerPath 结构体数组,用来存储所有已知的路径
    struct _PeerPath {
        int64_t lr; // 最后收到数据包的时间
        SharedPtr<Path> p; // 指向 Path 对象的智能指针
        // ...
    } _paths[ZT_MAX_PEER_NETWORK_PATHS];
    Mutex _paths_m; // 保护路径列表的锁
    // ...
};

这个 _paths 数组就是我们比喻中的“联系人名下的电话号码列表”。Peer 对象的所有路径管理和选择逻辑,都是围绕着操作这个数组展开的。

Peer::getAppropriatePath() 被调用时,它会执行类似下面的伪代码逻辑:

// Peer::getAppropriatePath() 的概念性伪代码
function getAppropriatePath(现在的时间):
    bestPath = null
    bestQuality = 无穷差

    // 遍历所有已知的路径
    for each path_entry in this._paths:
        path = path_entry.p
        if (path is not null) and (path.alive(现在的时间)):
            // 计算当前路径的质量(延迟越低,质量越好)
            currentQuality = path.quality(现在的时间)
            
            if (currentQuality < bestQuality):
                bestQuality = currentQuality
                bestPath = path
    
    return bestPath // 返回质量最好的那个路径

深入代码:Path::send() 的最终使命

当最佳路径被选定后,数据包最终会通过 Path::send() 方法被发送出去。这个方法的实现非常直接,位于 Path.cpp 中。

// 文件: node/Path.cpp

bool Path::send(const RuntimeEnvironment *RR, void *tPtr, const void *data, unsigned int len, int64_t now)
{
    // 调用 Node 提供的底层发包函数
    if (RR->node->putPacket(tPtr, _localSocket, _addr, data, len)) {
        // 如果发送成功,更新本路径的最后发送时间
        _lastOut = now;
        return true;
    }
    return false;
}

send() 方法的工作就是调用节点 (Node)提供的 putPacket 回调函数。这个回调函数是 ZeroTier 核心与操作系统网络功能之间的桥梁。Path::send 告诉它:“请从 _localSocket 这个出口,把 data 发送到 _addr 这个目的地。”

至此,一个虚拟网络中的数据包,经过层层封装、路由和决策,终于踏上了它在物理互联网上的真实旅程。

总结与回顾

在本章中,我们抵达了 ZeroTier 核心概念之旅的终点站:物理路径 (Path)

  • Path 代表了连接两个节点的具体物理路由,即一个 IP 地址和端口的组合。它就像一个联系人的特定电话号码
  • 一个对等节点 (Peer)可以拥有多个 Path,ZeroTier 会动态地发现、评估和选择其中最优的一条进行通信。
  • 这种对物理路径的智能管理,是 ZeroTier 实现高性能、低延迟和强大网络适应性的关键。