比特币源码分析--深入理解区块链 8.点对点网络:如何构建去中心化的网络系统

        在前面的文章中我们介绍了如何获取种子节点、节点连接数量的限制、如何使用Addrman维护一个免遭Erebus攻击的本地节点地址数据库。我们知道一个完整的Bitcoin节点既包含Outbound连接,也有Inbound类型的连接,其中Inbound连接要求Bitcoin客户端必须节开启侦听(listen)功能,以接收来自其他节点的连接。这里先明确两个概念:Bitcoin节点,通常是指部署在用户计算机上的具有完整功能的软件客户端程序,它是整个比特币网络中的一个物理节点。而我们在文章里面常说的节点,也称Peer Node,是指P2P网络中一个连接实例,一个Peer Node对应的是一个物理Bitcoin节点,当物理Bitcoin有多个IP时,也可是多个Peer Node对应一个物理节点。一个Peer Node具有唯一的IP地址。两种连接类型的区别在于:Outbound是我方主动发起的,Inbound是我方被动接受。它们功能和用作相似。

        前面的文章介绍的都是Outbound连接。在本篇文章中将重点介绍当Bitcoin节点作为服务器时,如何接受来自外部的连接 ,建立一个Inbound类型的Peer Node。

连接流程

Inbound连接流程:

获取本机的IP地址:

        获取本地IP地址,默认使用inaddr_any,表示绑定所有IP地址,只有当运行Bitcoin的节点拥有公网IP时,才可接收来自公网上其他节点的连接。当没有公网IP只有局域网IP时,默认只能接收本局域网其他节点的连接。当然可以通过网络端口映射来使外部的计算也能够连接到局域网上的节点,但效率会低一些。

网络处理线程:

        线程ThreadSocketHandler用来处理所有的网络端口的数据发送、接收、错误和接收新的连接事件,在该线程的循环体中:

DisconnectNodes:将有断开标记的peer断开连接并从列表中删除。

NotifyNumConnectionsChanged:用来通知UI界面当前已连接的peer数量。

SocketHandle:处理网络中的recv,sent和error事件。

网络事件处理:

SOCKET: 网络套接字,是网络通信中的端口或插座。

SocketEvents: 建立事件集合。首先它使用GenerateSelectSet将所有的网络套接字进行如下归类:

recv_set:该集合表示需要接收数据。

sent_set: 该集合表示需要发送数据。

error_set: 该集合表示需要处理异常错误。

这三个集合中, 任何一个网络套接词都需要处理error,因此所有的套接字存在于error_set集合中,同一个网络套接字可能既有数据接收,也有数据发送,但是发送和接收不允许在同一时刻出现,因此人为设定了处理的优先级,有当有数据发送时,优先处理发送,不处理接收。只有网络套接字的队列中没有数据发送时才处理数据接收。但不意味着接收的数据将会被丢弃。底层硬件和IP协议层会同时处理,只不过在应用层面分了先后。

代码如下:

bool CConnman::GenerateSelectSet(const std::vector<CNode*>& nodes,
                                 std::set<SOCKET>& recv_set,
                                 std::set<SOCKET>& send_set,
                                 std::set<SOCKET>& error_set)
{
    for (const ListenSocket& hListenSocket : vhListenSocket) {
        recv_set.insert(hListenSocket.sock->Get());
    }

    for (CNode* pnode : nodes) {
       
        bool select_recv = !pnode->fPauseRecv;
        bool select_send;
        {
            LOCK(pnode->cs_vSend);
            select_send = !pnode->vSendMsg.empty();
        }

        LOCK(pnode->m_sock_mutex);
        if (!pnode->m_sock) {
            continue;
        }

        error_set.insert(pnode->m_sock->Get());
        if (select_send) {
            send_set.insert(pnode->m_sock->Get());
            continue;
        }
        if (select_recv) {
            recv_set.insert(pnode->m_sock->Get());
        }
    }

    return !recv_set.empty() || !send_set.empty() || !error_set.empty();
}

        POLL用于监测一组文件描述符(File Descriptor, fd,也就是网络套接字)上的可读/可写和出错事件,通过调用poll函数获得返回的pollfd. Revents字段是否具有标记POLLIN(有数据读)、POLLOUT(有数据写)、POLLERR|POLLHUP(出错)来判断条件是否成立,并将对应的套接字归集到三个集合。

SocketHandlerConnected:  根据三个集合中的套接字检测对应的接口是否有数据发送、接收、和错误,并进行相应的数据处理。

SocketHandlerListening:接受新的连接(Inbound)。当绑定的IP地址的网络套接字检测到POLLIN事件时,表明有新的外部的连接进入。通过AcceptConnection创建一个新的Peer Node。并将其加入到本地节点列表。

数量限制

Inbound类型的连接数量
最大值 = nMaxConnections - m_max_outbound。

其中:

nMaxConnections=125

m_max_outbound=11

因此在一般情况下最多允许114个外部连接。当数量达到114时,系统尝试从本地驱逐一些Inbound类型节点,留出位置给新的节点。

驱逐算法

  1. 先将所有inbound类型连接存入临时列表。
  2. SelectNodeToEvict(选择驱逐节点)。
  3. 保留至少4个不同网络组的节点。
  4. 保留ping时间最小的8个节点。
  5. 保留最新交易的4个节点。
  6. 保留 8 个向我们发送新块的非交易事务转发的节点。
  7. 保留最近向我们发送新块的 4 个节点。
  8. 通过合意的比率保护一些剩余的驱逐候选者。

去掉上述受保护的项目,从剩下的列表中选择第一个节点将其驱逐。

 通过节点共享建立去中心化的网络

        当你在一台机器上部署了Bitcoin客户端。除了初始化时通过系统内置的种子节点建立的Outbound连接外。这个时候其实外面的世界并不知道你这个节点的存在。因此你作为服务器接收来自外部的(Inbound)连接也是零。如何让外面的世界知道你,以便他人也能连接你进行区块转发,通过在不同节点之间相互共享地址和建立连接,最终形成一个去中心化的网络。让所有的客户端都能提供资源,包括带宽,存储空间和计算能力。建立这样的网络依赖于Bitcoin自我广告(Self Advertisement)方式与其他节点交换IP地址,从而将你的身份广播到全网。而其他节点上拥有的可连接的IP地址也通过该方式共享给你。下图说明了这个流程:

 

  以下通过“我”和“对方”来说明整个过程,这里“我”是指当前的Bitcoin节点。“对方”是指一个对等节点。

  1. 我通过Outbound连接到种子节点时,通过PushNoeVersion将我的版本信息发送给对方。
  2. 对方响应NetMsgType::VERSION版本信息,通过协商后,我将本机的可与对方通信的IP地址通过PushAdress存入m_addrs_to_send。同时发送消息NetMsgType::GETADDR给对方,通知对方将保留在本地的节点地址发给我,发送的数量占总量的23%,最大不超过1000个。
  3. 对方通过NetMsgType::ADDRV2和NetMsgType::ADDV消息响应我的请求并将对方拥有的节点的IP地址发给我,我收到节点地址后将其存入本地的节点地址管理器addrman中。
  4. 通过ThreadMessageHandler线程中的SendMessages,我将存入m_addrs_to_send中的节点IP地址发给对方。

        上述的协议,可将本机的IP地址在不同节点之间不断广播,也可从对方获得更多节点的IP地址,因此,当有节点加入且对系统请求增多,整个系统的容量也增大。在该网络中,节点不需要依靠一个中心索引服务器来发现数据,系统也不会出现单点崩溃,从而形成一个去中心化P2P网络。

断开不活跃的节点 

        不管是Inbound还是Outbound类型的连接,当认为节点不活跃时就会断开并删除它,这样可以节省网络资源,为新的连接预留位置。那么Bitcoin如何检测节点是否活跃呢,请看下面的代码:

bool CConnman::ShouldRunInactivityChecks(const CNode& node, std::chrono::seconds now) const
{
    return node.m_connected + m_peer_connect_timeout < now;
}

bool CConnman::InactivityCheck(const CNode& node) const
{
    // Tests that see disconnects after using mocktime can start nodes with a
    // large timeout. For example, -peertimeout=999999999.
    const auto now{GetTime<std::chrono::seconds>()};
    const auto last_send{node.m_last_send.load()};
    const auto last_recv{node.m_last_recv.load()};

    if (!ShouldRunInactivityChecks(node, now)) return false;

    if (last_recv.count() == 0 || last_send.count() == 0) {
        LogPrint(BCLog::NET, "socket no message in first %i seconds, %d %d peer=%d\n", count_seconds(m_peer_connect_timeout), last_recv.count() != 0, last_send.count() != 0, node.GetId());
        return true;
    }

    if (now > last_send + TIMEOUT_INTERVAL) {
        LogPrint(BCLog::NET, "socket sending timeout: %is peer=%d\n", count_seconds(now - last_send), node.GetId());
        return true;
    }

    if (now > last_recv + TIMEOUT_INTERVAL) {
        LogPrint(BCLog::NET, "socket receive timeout: %is peer=%d\n", count_seconds(now - last_recv), node.GetId());
        return true;
    }

    if (!node.fSuccessfullyConnected) {
        LogPrint(BCLog::NET, "version handshake timeout peer=%d\n", node.GetId());
        return true;
    }

    return false;
}

其中:

ShouldRunInactivityChecks: 凡是自连接建立开始算起,距离现在时间超过60秒(默认为60秒,可通过参数- peertimeout来设置)的连接都应该检测是否活跃。

InactivityCheck:满足以下都属于不活跃:

  1. 距离最近发送的时间超过 20秒。
  2. 距离最近接收的时间超过20秒。
  3. 未收到来自对方版本协商成功的消息(VERACK消息)。

满足任何上述任何一个条件即认为该节点不活跃,系统将断开连接并从列表中删除。

本篇文章主要相关代码:

https://github.com/bitcoin/bitcoin/blob/master/src/net_processing.cpp

猜你喜欢

转载自blog.csdn.net/dragon_trooquant/article/details/123530448