區塊鏈和AI無疑是近期業界當之無愧的兩大風口。AI就不說了,區塊鏈從17年各種數字貨幣被炒上了天,一下成為了人們街頭巷議的焦點,本文撇開數字貨幣的投資不說,僅僅從技術層面來剖析一下區塊鏈各個部分的原理。畢竟目前已經有包括BAT等巨頭在內的許多公司投入到了區塊鏈的研發,其相關的應用相信也會越來越多的落地,作為技術人員,學習一下區塊鏈的原始碼,抓住這個風口是非常值得的。
本系列將以比特幣區塊鏈作為學習的物件,這是所有區塊鏈專案的始祖,也是學習區塊鏈的最佳原材料。另外對於區塊鏈的學習,建議可以先看一下《精通比特幣》這本書,對比特幣整體的原理有一定了解後,再結合原始碼一塊塊的學習將會事半功倍。
因為區塊鏈的本質是一個建立在P2P網路上的分散式資料庫,所以P2P網路可以算得上是區塊鏈的一塊基石,我們就以P2P網路作為切入點來開始比特幣原始碼的學習。本文將結合比特幣原始碼,分析比特幣P2P網路中的一個節點是如何發現其他節點並與之建立資料通訊的通道。
由於作者本人也是區塊鏈新手,所以文中有錯誤的地方也歡迎大家指正。
1、原始碼獲取
比特幣是一個開源專案,其原始碼可以從以下github連結上獲取:
關於原始碼的編譯,在《精通比特幣》一書中有較為詳細的說明,有興趣的讀者可以參考此書中的說明嘗試去編譯一下。
2、比特幣P2P網路
區塊鏈從本質上講就是建立在P2P網路上的分散式資料庫,然後採用PoW或者PoS等共識演算法讓網路上的節點對某件事情(比如比特幣交易)達成共識。因此在瞭解比特幣其他模組之前,先了解比特幣的P2P網路是一個比較好的切入點。本文就從原始碼的角度來分析一下比特幣網路相關的知識點,比如一個新啟動的節點是如何發現並連線其他節點的,其他網路的節點又是如何連線到我們的節點上。
2.1、節點發現
當一個新的網路節點啟動後,為了能夠參與協同運作,它必須至少發現一個其他網路中的節點並與之建立連線。比特幣的網路拓撲結構不基於地理位置,因此可以隨機的選擇節點建立連線。
那麼一個新啟動的節點是如何發現其他網路的節點呢?比特幣網路採用了兩種方式:
(1) 利用種子節點
比特幣的客戶端會維護一個列表,列表中記錄了長期穩定執行的節點,這些節點也被稱之為種子節點。連線到種子節點的好處就是新節點可以快速的發現網路中的其他節點。在比特幣裡,可以通過“-dnsseed”選項來指定是否使用種子節點,該選項預設是開啟的。
(2) 節點引薦
除了使用種子節點外,還可以將當前啟動節點引薦給其他節點的方式。可以通過“-seednode”選項指定一個節點的ip,之後新節點將和該節點建立連線,將該節點作為DNS種子節點,在引薦資訊形成之後斷開與該節點的連線,並與新發現的節點連線。
2.2 握手協議
當節點與對等節點建立好連線後,首先要做的就是握手。其過程如下:
(1) 節點向peer傳送version訊息開始握手,此訊息中包含如下一些內容:
PROTOCOL_VERSION:當前節點的比特幣P2P協議的版本號;
nLocalServices:節點支援的本地服務列表,目前僅支援NODE_NETWORK;
nTime:當前時間;
addrYou:當前節點可見的遠端節點的IP地址;
addrMe:本節點發現的本地IP地址;
subver:當前節點執行的軟體型別的子版本號;
baseHeight:當前節點上的區塊鏈的高度。
對等節點收到version訊息後,會迴應verack進行確認並建立連線。有時候對等端可能需要互換連線並連回起始節點,此時對等端也會傳送該節點的version訊息。
參考下圖:
2.3、地址廣播及發現
完成握手協議後,新節點將會傳送一條包含自己IP地址的addr訊息給對等端,對等端收到以後又向與它連線的相鄰節點傳送addr訊息,這樣新節點的ip地址就會在P2P網路中廣播出去。此外新節點還可以傳送getaddr訊息,要求對等端把自己知道的節點的IP地址傳送過來。通過這種方式,新節點可以找到需要連線的對等節點。如下圖:
節點必須連線到若干不同的對等節點才能在比特幣網路中建立通向比特幣網路的種類各異的路徑(path)。由於節點可以隨時加入和離開,通訊路徑是不可靠的。因此,節點必須持續進行兩項工作:在失去已有連線時發現新節點,並在其他節點啟動時為其提供幫助。節點啟動時只需要一個連線,因為第一個節點可以將它引薦給它的對等節點,而這些節點又會進一步提供引薦。一個節點,如果連線到大量的其他對等節點,這既沒必要,也是對網路資源的浪費。在啟動完成後,節點會記住它最近成功連線的對等節點;因此,當重新啟動後它可以迅速與先前的對等節點網路重新建立連線。如果先前的網路的對等節點對連線請求無應答,該節點可以使用種子節點進行重啟動。
使用者可以通過提供-connect=<IP地址>
選項來指定一個或多個IP地址,從而達到複寫自動節點管理功能並指定IP地址列表的目的。如果採用此選項,節點只連線到這些選定的節點IP地址,而不會自動發現並維護對等節點之間的連線。
如果已建立的連線沒有資料通訊,所在的節點會定期傳送資訊以維持連線。如果節點持續某個連線長達90分鐘沒有任何通訊,它會被認為已經從網路中斷開,網路將開始查詢一個新的對等節點。因此,比特幣網路會隨時根據變化的節點及網路問題進行動態調整,不需經過中心化的控制即可進行規模增、減的有機調整。
3、原始碼分析
以上分析了比特幣P2P網路中的握手協議,以及地址的廣播擴散的原理。這一節開始分析原始碼,看看上面描述的這些互動過程在程式碼中是如何體現的。
3.1、初始化引數
比特幣核心的入口函式main函式位於檔案bitcoind.cpp中,入口函式非常簡短:
int main(int argc, char* argv[])
{
SetupEnvironment();
// Connect bitcoind signal handlers
noui_connect();
return (AppInit(argc, argv) ? EXIT_SUCCESS : EXIT_FAILURE);
}複製程式碼
第一個呼叫的就是SetupServerArgs,這裡會初始化整個服務端的相關引數,給一些預設值。首先來認識幾個類:
3.1.1 CBaseChainParams
這是比特幣客戶端(bitcoin_cli)和服務端共享的一個類,定義了基本的比特幣系統引數,主要是資料儲存目錄和相互通訊的rpc埠號。這個類比較簡單,可以一窺原始碼:
class CBaseChainParams
{
public:
/** BIP70 chain name strings (main, test or regtest) */
static const std::string MAIN;
static const std::string TESTNET;
static const std::string REGTEST;
const std::string& DataDir() const { return strDataDir; }
int RPCPort() const { return nRPCPort; }
CBaseChainParams() = delete;
CBaseChainParams(const std::string& data_dir, int rpc_port) : nRPCPort(rpc_port), strDataDir(data_dir) {}
private:
int nRPCPort;
std::string strDataDir;
};複製程式碼
裡面就兩個成員:nRPCPort定義了客戶端和服務端通訊的rpc埠,strDataDir定義了資料儲存的目錄。
3.1.2 CChainParams
這個類定義了比特幣系統的很多比較重要的引數。我們看看都有哪些:
Consensus::Params consensus;
CMessageHeader::MessageStartChars pchMessageStart;
int nDefaultPort;
uint64_t nPruneAfterHeight;
std::vector<std::string> vSeeds;
std::vector<unsigned char> base58Prefixes[MAX_BASE58_TYPES];
std::string bech32_hrp;
std::string strNetworkID;
CBlock genesis;
std::vector<SeedSpec6> vFixedSeeds;
bool fDefaultConsistencyChecks;
bool fRequireStandard;
bool fMineBlocksOnDemand;
CCheckpointData checkpointData;
ChainTxData chainTxData;
bool m_fallback_fee_enabled;複製程式碼
比較多,這裡我們就關注幾個和比特幣網路相關的引數:
nDefaultPort:比特幣P2P網路預設的監聽埠,預設是8333。
vSeeds:這個是比特幣程式碼中內建的一些DNS種子節點。在預設開啟-dnsseed選項並且不指定-connect的情況下,新節點啟動時將嘗試通過這些種子節點加入P2P網路中。
3.1.3 CMainParams
這個類繼承自上一節的CChainParams。這個類的建構函式裡初始化了比特幣系統的一些核心引數。另外比特幣創世區塊(區塊鏈上第一個區塊)也是在這裡生成的。這裡暫且先不關心其他的,先把關注點放在P2P網路相關的事情上來。
在這個類的建構函式中會發現內建的種子節點:
// Note that of those which support the service bits prefix, most only support a subset of
// possible options.
// This is fine at runtime as we'll fall back to using them as a oneshot if they don't support the
// service bits we want, but we should get them updated to support all service bits wanted by any
// release ASAP to avoid it where possible.
vSeeds.emplace_back("seed.bitcoin.sipa.be"); // Pieter Wuille, only supports x1, x5, x9, and xd
vSeeds.emplace_back("dnsseed.bluematt.me"); // Matt Corallo, only supports x9
vSeeds.emplace_back("dnsseed.bitcoin.dashjr.org"); // Luke Dashjr
vSeeds.emplace_back("seed.bitcoinstats.com"); // Christian Decker, supports x1 - xf
vSeeds.emplace_back("seed.bitcoin.jonasschnelli.ch"); // Jonas Schnelli, only supports x1, x5, x9, and xd
vSeeds.emplace_back("seed.btc.petertodd.org"); // Peter Todd, only supports x1, x5, x9, and xd
vSeeds.emplace_back("seed.bitcoin.sprovoost.nl"); // Sjors Provoost複製程式碼
感興趣的同學可以用DNS查詢工具來查詢這些種子。
認識了這三個記錄了比特幣系統執行所必須的引數的類以後,回到之前提到的 SetupServerArgs這個函式:
void SetupServerArgs()
{
const auto defaultBaseParams = CreateBaseChainParams(CBaseChainParams::MAIN);
const auto testnetBaseParams = CreateBaseChainParams(CBaseChainParams::TESTNET);
const auto defaultChainParams = CreateChainParams(CBaseChainParams::MAIN);
const auto testnetChainParams = CreateChainParams(CBaseChainParams::TESTNET);複製程式碼
可以看到這裡針對比特幣主網和測試用公網生成了不同的預設引數。
最終這些引數資訊將儲存在全域性變數中,方便其他模組引用:globalChainParams和globalChainBaseParams。
AppInit函式的最後,會呼叫AppInitMain,完成整個系統的初始化,AppInitMain比較重,比特幣系統核心的東西基本上都從這裡誕生。
3.2 比特幣P2P網路元件
先簡單瞭解比特幣系統中一些和網路相關的封裝類。逐一過一下。
3.2.1 CConnman
顧名思義,這個類是網路連線的管理類。負責節點的初始化及啟動,P2P訊息的推送及接收,接收其他節點的連線等等。這個類比較龐大。
3.2.2 PeerLogicValidation
這個類多重繼承了兩個介面類CValidationInterface和NetEventsInterface。其中CValidationInterface是和錢包相關的一個介面,暫且不提。NetEventsInterface是和網路相關的,看一下這個介面的定義:
class NetEventsInterface
{
public:
virtual bool ProcessMessages(CNode* pnode, std::atomic<bool>& interrupt) = 0;
virtual bool SendMessages(CNode* pnode, std::atomic<bool>& interrupt) = 0;
virtual void InitializeNode(CNode* pnode) = 0;
virtual void FinalizeNode(NodeId id, bool& update_connection_time) = 0;
protected:
/**
* Protected destructor so that instances can only be deleted by derived classes.
* If that restriction is no longer desired, this should be made public and virtual.
*/
~NetEventsInterface() = default;
};複製程式碼
NetEventsInterface::ProcessMessage:處理接收到的訊息;
NetEventsInterface::SendMessage:傳送訊息;
NetEventsInterface::InitializeNode:初始化節點;
3.2.3 CNode
維護節點資訊,包括通訊的套接字,傳送緩衝區,接收緩衝區等等。
3.2.4 CNetAddr和CService
對IP地址的封裝(IPV4和IPV6).
class CNetAddr
{
protected:
unsigned char ip[16]; // in network byte order
uint32_t scopeId; // for scoped/link-local ipv6 addresses複製程式碼
可以看到其主要屬性就是一個ip地址。另外這個類裡還包含了一些對IP地址型別進行各種判斷的工具函式。
CService繼承了CNetAddr,再其基礎上多了埠號的屬性。
class CService : public CNetAddr
{
protected:
uint16_t port; // host order複製程式碼
3.3 利用種子發現節點
本文之前已經提到過,新啟動的節點首先需要發現網路中的其他節點並與之建立連線。發現節點可以通過內建的種子節點,也可以在啟動bitcoind時通過-seednode選項指定一個種子節點。接下來就逐步分析程式碼裡是如何實現的。
繼續看AppInitMain這個函式,他會建立出上一節提到的連線管理器物件:
g_connman = std::unique_ptr<CConnman>(new CConnman(GetRand(std::numeric_limits<uint64_t>::max()), GetRand(std::numeric_limits<uint64_t>::max())));
CConnman& connman = *g_connman;
peerLogic.reset(new PeerLogicValidation(&connman, scheduler));複製程式碼
這兩個物件均為全域性變數。
接下來將呼叫Discover函式,找到所有的本地網路介面地址,並儲存起來。這些地址在隨後將傳送給連線到的對等節點(廣播本機地址)。
void Discover()
{
if (!fDiscover)
return;
#ifdef WIN32
// Get local host IP
char pszHostName[256] = "";
if (gethostname(pszHostName, sizeof(pszHostName)) != SOCKET_ERROR)
{
std::vector<CNetAddr> vaddr;
if (LookupHost(pszHostName, vaddr, 0, true))
{
for (const CNetAddr &addr : vaddr)
{
if (AddLocal(addr, LOCAL_IF))
LogPrintf("%s: %s - %s\n", __func__, pszHostName, addr.ToString());
}
}
}
#else
// Get local host ip
struct ifaddrs* myaddrs;
if (getifaddrs(&myaddrs) == 0)
{
for (struct ifaddrs* ifa = myaddrs; ifa != nullptr; ifa = ifa->ifa_next)
{
if (ifa->ifa_addr == nullptr) continue;
if ((ifa->ifa_flags & IFF_UP) == 0) continue;
if (strcmp(ifa->ifa_name, "lo") == 0) continue;
if (strcmp(ifa->ifa_name, "lo0") == 0) continue;
if (ifa->ifa_addr->sa_family == AF_INET)
{
struct sockaddr_in* s4 = (struct sockaddr_in*)(ifa->ifa_addr);
CNetAddr addr(s4->sin_addr);
if (AddLocal(addr, LOCAL_IF))
LogPrintf("%s: IPv4 %s: %s\n", __func__, ifa->ifa_name, addr.ToString());
}
else if (ifa->ifa_addr->sa_family == AF_INET6)
{
struct sockaddr_in6* s6 = (struct sockaddr_in6*)(ifa->ifa_addr);
CNetAddr addr(s6->sin6_addr);
if (AddLocal(addr, LOCAL_IF))
LogPrintf("%s: IPv6 %s: %s\n", __func__, ifa->ifa_name, addr.ToString());
}
}
freeifaddrs(myaddrs);
}
#endif
}複製程式碼
主要就是呼叫getifaddrs這個網路api獲取所有的本地地址,並呼叫AddLocal將這些地址新增到儲存本地地址的全域性變數中。這個後續在說明廣播本地地址給相鄰節點時還會提到,暫且略過。
接著初始化一個封裝網路連線各種引數的CConnman::Options物件:
CConnman::Options connOptions;
connOptions.nLocalServices = nLocalServices;
connOptions.nMaxConnections = nMaxConnections;
connOptions.nMaxOutbound = std::min(MAX_OUTBOUND_CONNECTIONS, connOptions.nMaxConnections);
connOptions.nMaxAddnode = MAX_ADDNODE_CONNECTIONS;
connOptions.nMaxFeeler = 1;
connOptions.nBestHeight = chain_active_height;
connOptions.uiInterface = &uiInterface;
connOptions.m_msgproc = peerLogic.get();
connOptions.nSendBufferMaxSize = 1000*gArgs.GetArg("-maxsendbuffer", DEFAULT_MAXSENDBUFFER);
connOptions.nReceiveFloodSize = 1000*gArgs.GetArg("-maxreceivebuffer", DEFAULT_MAXRECEIVEBUFFER);
connOptions.m_added_nodes = gArgs.GetArgs("-addnode");
connOptions.nMaxOutboundTimeframe = nMaxOutboundTimeframe;
connOptions.nMaxOutboundLimit = nMaxOutboundLimit;複製程式碼
這些引數包含了後續網路連線及通訊過程中的很多引數,比如一個節點允許的最大連線數,能夠連線的外部節點的最大數目,-seednode選項指定的種子節點等等。如果執行bitcoind時通過-seednode指定了種子節點,這些種子節點也會被儲存起來:
connOptions.vSeedNodes = gArgs.GetArgs("-seednode");複製程式碼
接下來,啟動節點、發現節點的過程就正式拉開序幕了:
if (!connman.Start(scheduler, connOptions)) {
return false;
}複製程式碼
呼叫CConnman::Start開始,一個嶄新的節點即將誕生了。
首先需要用前面生成的CConnman::Options對CConnman進行初始化,很簡單:
Init(connOptions);複製程式碼
其實就是將Options裡面的值複製一份給CConnman相應的欄位而已。
接下來就要載入已經儲存的節點的地址了。之前在說明節點發現的原理時提到過,這裡在溫習一下:
在啟動完成後,節點會記住它最近成功連線的對等節點;因此,當重新啟動後它可以迅速與先前的對等節點網路重新建立連線。如果先前的網路的對等節點對連線請求無應答,該節點可以使用種子節點進行重啟動。
載入先前連線過的對等節點的地址的程式碼:
// Load addresses from peers.dat
int64_t nStart = GetTimeMillis();
{
CAddrDB adb;
if (adb.Read(addrman))
LogPrintf("Loaded %i addresses from peers.dat %dms\n", addrman.size(), GetTimeMillis() - nStart);
else {
addrman.Clear(); // Addrman can be in an inconsistent state after failure, reset it
LogPrintf("Invalid or missing peers.dat; recreating\n");
DumpAddresses();
}
}
if (clientInterface)
clientInterface->InitMessage(_("Loading banlist..."));
// Load addresses from banlist.dat
nStart = GetTimeMillis();
CBanDB bandb;
banmap_t banmap;
if (bandb.Read(banmap)) {
SetBanned(banmap); // thread save setter
SetBannedSetDirty(false); // no need to write down, just read data
SweepBanned(); // sweep out unused entries
LogPrint(BCLog::NET, "Loaded %d banned node ips/subnets from banlist.dat %dms\n",
banmap.size(), GetTimeMillis() - nStart);
} else {
LogPrintf("Invalid or missing banlist.dat; recreating\n");
SetBannedSetDirty(true); // force write
DumpBanlist();
}複製程式碼
其中CAddrMan暫時不必細究,把它理解成一個小型DB即可。
最後CConnman將調兵遣將,把任務交給幾個執行緒去做:
(1) net執行緒
// Send and receive from sockets, accept connections
threadSocketHandler = std::thread(&TraceThread<std::function<void()> >, "net", std::function<void()>(std::bind(&CConnman::ThreadSocketHandler, this)));複製程式碼
從註釋上就知道,這個"net"執行緒的任務就是從套接字傳送和接收資料,同時還要監聽其他節點的連線請求。
(2) dnsseed執行緒
if (!gArgs.GetBoolArg("-dnsseed", true))
LogPrintf("DNS seeding disabled\n");
else
threadDNSAddressSeed = std::thread(&TraceThread<std::function<void()> >, "dnsseed", std::function<void()>(std::bind(&CConnman::ThreadDNSAddressSeed, this)));複製程式碼
這個執行緒名字就叫做"dnsseed",它的作用是通過dns查詢解析出種子節點的地址,之後新啟動的節點將要向這些種子節點發起連線。
(3)opencon執行緒
if (connOptions.m_use_addrman_outgoing || !connOptions.m_specified_outgoing.empty())
threadOpenConnections = std::thread(&TraceThread<std::function<void()> >, "opencon", std::function<void()>(std::bind(&CConnman::ThreadOpenConnections, this, connOptions.m_specified_outgoing)));複製程式碼
這個執行緒將負責向已發現的節點發起連線。
(4)msghand執行緒
// Process messages
threadMessageHandler = std::thread(&TraceThread<std::function<void()> >, "msghand", std::function<void()>(std::bind(&CConnman::ThreadMessageHandler, this)));複製程式碼
此執行緒將負責比特幣P2P協議的訊息處理。
接下來我們各個擊破,對這四個執行緒進行逐一分析。
3.3.1 解析種子節點
首先來看看種子節點的解析,之前提到過,利用種子發現節點有兩種方式,一種是開啟-dnsseed選項(預設開啟)連線內建的一些由專人維護的比較穩定的DNS種子,還由一種是通過-seednode選項指定種子節點。這裡dnsseed執行緒的作用是在開啟-dnsseed選項時,解析比特幣P2P網路內建的DNS種子:
void CConnman::ThreadDNSAddressSeed()
{
// goal: only query DNS seeds if address need is acute
// Avoiding DNS seeds when we don't need them improves user privacy by
// creating fewer identifying DNS requests, reduces trust by giving seeds
// less influence on the network topology, and reduces traffic to the seeds.
if ((addrman.size() > 0) &&
(!gArgs.GetBoolArg("-forcednsseed", DEFAULT_FORCEDNSSEED))) {
if (!interruptNet.sleep_for(std::chrono::seconds(11)))
return;
LOCK(cs_vNodes);
int nRelevant = 0;
for (auto pnode : vNodes) {
nRelevant += pnode->fSuccessfullyConnected && !pnode->fFeeler && !pnode->fOneShot && !pnode->m_manual_connection && !pnode->fInbound;
}
if (nRelevant >= 2) {
LogPrintf("P2P peers available. Skipped DNS seeding.\n");
return;
}
}
const std::vector<std::string> &vSeeds = Params().DNSSeeds();
int found = 0;
LogPrintf("Loading addresses from DNS seeds (could take a while)\n");
for (const std::string &seed : vSeeds) {
if (interruptNet) {
return;
}
if (HaveNameProxy()) {
AddOneShot(seed);
} else {
std::vector<CNetAddr> vIPs;
std::vector<CAddress> vAdd;
ServiceFlags requiredServiceBits = GetDesirableServiceFlags(NODE_NONE);
std::string host = strprintf("x%x.%s", requiredServiceBits, seed);
CNetAddr resolveSource;
if (!resolveSource.SetInternal(host)) {
continue;
}
unsigned int nMaxIPs = 256; // Limits number of IPs learned from a DNS seed
if (LookupHost(host.c_str(), vIPs, nMaxIPs, true))
{
for (const CNetAddr& ip : vIPs)
{
int nOneDay = 24*3600;
CAddress addr = CAddress(CService(ip, Params().GetDefaultPort()), requiredServiceBits);
addr.nTime = GetTime() - 3*nOneDay - GetRand(4*nOneDay); // use a random age between 3 and 7 days old
vAdd.push_back(addr);
found++;
}
addrman.Add(vAdd, resolveSource);
} else {
// We now avoid directly using results from DNS Seeds which do not support service bit filtering,
// instead using them as a oneshot to get nodes with our desired service bits.
AddOneShot(seed);
}
}
}
LogPrintf("%d addresses found from DNS seeds\n", found);
}複製程式碼
這個執行緒的工作其實比較簡單,程式碼也比較短。簡單來分析一下:
(1) 首先通過Params().DNSSeeds()拿到內建的DNS種子節點,這個在前文提到過的CMainParams的建構函式中已經說明,比特幣系統已經內建了一些DNS種子在裡面。
(2) 對於每個種子,通過LookupHost呼叫,進行DNS查詢,這個函式最終呼叫的是作業系統api:getaddrinfo,解析到的ip地址將存入CAddrMan中以備後用。
3.3.2 節點連線的建立
上一節已經解析出了比特幣系統內建的DNS種子節點,接下來就要連線這些節點。連線工作由上一節提到的opencon執行緒來處理。這個執行緒程式碼稍長,挑主要的來分析。
(1) 連線有-seednode指定的種子節點
如果使用者通過-seednode指定了種子節點,那麼將嘗試連線這些種子節點(這是節點發現的第二種方式)
while (!interruptNet)
{
ProcessOneShot();複製程式碼
ProcessOneshot函式的實現如下:
void CConnman::ProcessOneShot()
{
std::string strDest;
{
LOCK(cs_vOneShots);
if (vOneShots.empty())
return;
strDest = vOneShots.front();
vOneShots.pop_front();
}
CAddress addr;
CSemaphoreGrant grant(*semOutbound, true);
if (grant) {
OpenNetworkConnection(addr, false, &grant, strDest.c_str(), true);
}
}複製程式碼
最終呼叫了OpenNetworkConnection來連線這些種子節點。
(2) 如果CAddrMan中有記錄的地址資訊(上一次連線過的peer的地址或者是解析出來的內建dns種子的地址),同樣呼叫OpenNetworkConnection來發起連線。
繼續抽絲剝繭,揭開OpenNetworkConnection這個函式的神祕面紗,看看他是如何發起連線的。
void CConnman::OpenNetworkConnection(const CAddress& addrConnect, bool fCountFailure, CSemaphoreGrant *grantOutbound, const char *pszDest, bool fOneShot, bool fFeeler, bool manual_connection)
{
//
// Initiate outbound network connection
//
if (interruptNet) {
return;
}
if (!fNetworkActive) {
return;
}
if (!pszDest) {
if (IsLocal(addrConnect) ||
FindNode(static_cast<CNetAddr>(addrConnect)) || IsBanned(addrConnect) ||
FindNode(addrConnect.ToStringIPPort()))
return;
} else if (FindNode(std::string(pszDest)))
return;
CNode* pnode = ConnectNode(addrConnect, pszDest, fCountFailure, manual_connection);
if (!pnode)
return;
if (grantOutbound)
grantOutbound->MoveTo(pnode->grantOutbound);
if (fOneShot)
pnode->fOneShot = true;
if (fFeeler)
pnode->fFeeler = true;
if (manual_connection)
pnode->m_manual_connection = true;
m_msgproc->InitializeNode(pnode);
{
LOCK(cs_vNodes);
vNodes.push_back(pnode);
}
}複製程式碼
程式碼很短,除了一些錯誤檢查外,主要是兩步:ConnectNode建立節點,InitializeNode初始化節點。其中連線已發現節點在ConnectNode中完成,而前文提到的握手協議(傳送version握手訊息)則在InitializeNode中發起。節點之間的握手後文單獨分析。先看節點的連線。
ConnectNode函式先做一些檢查,以確保給定的節點地址還沒有連線。之後將建立套接字,並建立網路連線:
if (addrConnect.IsValid()) {
bool proxyConnectionFailed = false;
if (GetProxy(addrConnect.GetNetwork(), proxy)) {
hSocket = CreateSocket(proxy.proxy);
if (hSocket == INVALID_SOCKET) {
return nullptr;
}
connected = ConnectThroughProxy(proxy, addrConnect.ToStringIP(), addrConnect.GetPort(), hSocket, nConnectTimeout, &proxyConnectionFailed);
} else {
// no proxy needed (none set for target network)
hSocket = CreateSocket(addrConnect);
if (hSocket == INVALID_SOCKET) {
return nullptr;
}
connected = ConnectSocketDirectly(addrConnect, hSocket, nConnectTimeout, manual_connection);
}複製程式碼
這裡根據是否有設定代理分別進行處理,對於不配置代理的情況,將直接通過ConnectSocketDirectly來連線,其內部是呼叫socket api的connect函式。如果一切正常,那麼此時到所有發現的節點的網路連線就已經建立起來了,兩個節點之間就可以互通資料。ConnectNode函式的最後,會建立一個CNode物件,將連線好的套接字及其他必要的資訊封裝起來:
NodeId id = GetNewNodeId();
uint64_t nonce = GetDeterministicRandomizer(RANDOMIZER_ID_LOCALHOSTNONCE).Write(id).Finalize();
CAddress addr_bind = GetBindAddress(hSocket);
CNode* pnode = new CNode(id, nLocalServices, GetBestHeight(), hSocket, addrConnect, CalculateKeyedNetGroup(addrConnect), nonce, addr_bind, pszDest ? pszDest : "", false);
pnode->AddRef();複製程式碼
可以看到,CNode封裝了許多的東西,包括已連線的套接字,本節點支援的服務,本節點當前的區塊高度等等。生成的節點將會加入到集合中。
連線好以後,兩個節點就可以開始握手互動了。
3.3.3 通過套接字收發資料
上一節描述了節點是如何連線到發現相鄰節點的。這一節來看看是如何通過套接字收發資料的。收發資料是在net執行緒中完成的。執行緒函式體為CConnman::ThreadSocketHandler。相信有過網路程式設計基礎的同學對這一部分會倍感親切。我們擷取其主要程式碼一窺究竟。值得注意的是訊息的收發是有net執行緒和msghand執行緒協同處理的:msghand執行緒在條件變數上阻塞等待節點的新訊息的到來,net執行緒從套接字讀取資料,將資料拼接成訊息放到節點的訊息緩衝區中,並通知msghand執行緒有新訊息可以處理了。
ThreadSocketHandler將遍歷所有節點,將其套接字加入到接收描述符集合、傳送描述符集合中,然後通過select函式等待相應的描述符中的讀寫事件的到來:
LOCK(cs_vNodes);
for (CNode* pnode : vNodes)
{
// Implement the following logic:
// * If there is data to send, select() for sending data. As this only
// happens when optimistic write failed, we choose to first drain the
// write buffer in this case before receiving more. This avoids
// needlessly queueing received data, if the remote peer is not themselves
// receiving data. This means properly utilizing TCP flow control signalling.
// * Otherwise, if there is space left in the receive buffer, select() for
// receiving data.
// * Hand off all complete messages to the processor, to be handled without
// blocking here.
bool select_recv = !pnode->fPauseRecv;
bool select_send;
{
LOCK(pnode->cs_vSend);
select_send = !pnode->vSendMsg.empty();
}
LOCK(pnode->cs_hSocket);
if (pnode->hSocket == INVALID_SOCKET)
continue;
FD_SET(pnode->hSocket, &fdsetError);
hSocketMax = std::max(hSocketMax, pnode->hSocket);
have_fds = true;
if (select_send) {
FD_SET(pnode->hSocket, &fdsetSend);
continue;
}
if (select_recv) {
FD_SET(pnode->hSocket, &fdsetRecv);
}
}
}
int nSelect = select(have_fds ? hSocketMax + 1 : 0,
&fdsetRecv, &fdsetSend, &fdsetError, &timeout);複製程式碼
(1) 接收資料並處理
當某個節點的套接字可讀時,將從套接字讀取資料並把資料新增到節點的接收緩衝區(pNode->ReceiveMsgBytes):
for (CNode* pnode : vNodesCopy)
{
if (interruptNet)
return;
//
// Receive
//
bool recvSet = false;
bool sendSet = false;
bool errorSet = false;
{
LOCK(pnode->cs_hSocket);
if (pnode->hSocket == INVALID_SOCKET)
continue;
recvSet = FD_ISSET(pnode->hSocket, &fdsetRecv);
sendSet = FD_ISSET(pnode->hSocket, &fdsetSend);
errorSet = FD_ISSET(pnode->hSocket, &fdsetError);
}
if (recvSet || errorSet)
{
// typical socket buffer is 8K-64K
char pchBuf[0x10000];
int nBytes = 0;
{
LOCK(pnode->cs_hSocket);
if (pnode->hSocket == INVALID_SOCKET)
continue;
nBytes = recv(pnode->hSocket, pchBuf, sizeof(pchBuf), MSG_DONTWAIT);
}
if (nBytes > 0)
{
bool notify = false;
if (!pnode->ReceiveMsgBytes(pchBuf, nBytes, notify))
pnode->CloseSocketDisconnect();
RecordBytesRecv(nBytes);複製程式碼
最後,把接收緩衝區的資料拼接到待處理訊息緩衝區,然後通知訊息處理執行緒有新的訊息需要處理:
if (notify) {
size_t nSizeAdded = 0;
auto it(pnode->vRecvMsg.begin());
for (; it != pnode->vRecvMsg.end(); ++it) {
if (!it->complete())
break;
nSizeAdded += it->vRecv.size() + CMessageHeader::HEADER_SIZE;
}
{
LOCK(pnode->cs_vProcessMsg);
pnode->vProcessMsg.splice(pnode->vProcessMsg.end(), pnode->vRecvMsg, pnode->vRecvMsg.begin(), it);
pnode->nProcessQueueSize += nSizeAdded;
pnode->fPauseRecv = pnode->nProcessQueueSize > nReceiveFloodSize;
}
WakeMessageHandler(); //喚醒訊息處理執行緒複製程式碼
程式碼中WakeMessageHandler將喚醒msghand執行緒,告知其有新訊息可以處理了。
void CConnman::WakeMessageHandler()
{
{
std::lock_guard<std::mutex> lock(mutexMsgProc);
fMsgProcWake = true;
}
condMsgProc.notify_one();
}複製程式碼
msghand執行緒被喚醒後,將從節點的緩衝區中取出訊息並進行處理。處理完後又在條件變數上阻塞等待下一條訊息的到來。這屬於典型的執行緒間的同步模型,相信碼農們已經非常熟悉了。
(2) 傳送資料
當某個節點的套接字可寫時將資料通過套接字傳送出去:
if (sendSet)
{
LOCK(pnode->cs_vSend);
size_t nBytes = SocketSendData(pnode);
if (nBytes) {
RecordBytesSent(nBytes);
}
}複製程式碼
下一小節在分析一下msghand執行緒是如何處理訊息的,這也是兩個peer節點互動流程的最後一塊拼圖。
3.3.4 訊息處理執行緒
訊息處理執行緒的處理邏輯其實非常簡單:從節點的緩衝區中取出一個訊息,處理,然後阻塞等待下一條訊息。
for (CNode* pnode : vNodesCopy)
{
if (pnode->fDisconnect)
continue;
// Receive messages
bool fMoreNodeWork = m_msgproc->ProcessMessages(pnode, flagInterruptMsgProc);
fMoreWork |= (fMoreNodeWork && !pnode->fPauseSend);
if (flagInterruptMsgProc)
return;
// Send messages
{
LOCK(pnode->cs_sendProcessing);
m_msgproc->SendMessages(pnode, flagInterruptMsgProc);
}
if (flagInterruptMsgProc)
return;
}
{
LOCK(cs_vNodes);
for (CNode* pnode : vNodesCopy)
pnode->Release();
}
std::unique_lock<std::mutex> lock(mutexMsgProc);
if (!fMoreWork) {
condMsgProc.wait_until(lock, std::chrono::steady_clock::now() + std::chrono::milliseconds(100), [this] { return fMsgProcWake; });
}
fMsgProcWake = false;複製程式碼
註釋中其實已經比較清楚了:PeerLogicValidation::ProcessMessgae()從節點的接收緩衝區中取訊息並處理,PeerLogicValidation::SendMessage將節點傳送緩衝區中的訊息傳送出去,最後在條件變數上阻塞等待下一條訊息的到來。
ProcessMessage函式的程式碼如下:
bool PeerLogicValidation::ProcessMessages(CNode* pfrom, std::atomic<bool>& interruptMsgProc)
{
const CChainParams& chainparams = Params();
//
// Message format
// (4) message start
// (12) command
// (4) size
// (4) checksum
// (x) data
//
bool fMoreWork = false;
if (!pfrom->vRecvGetData.empty())
ProcessGetData(pfrom, chainparams.GetConsensus(), connman, interruptMsgProc);
if (pfrom->fDisconnect)
return false;
// this maintains the order of responses
if (!pfrom->vRecvGetData.empty()) return true;
// Don't bother if send buffer is too full to respond anyway
if (pfrom->fPauseSend)
return false;
std::list<CNetMessage> msgs;
{
LOCK(pfrom->cs_vProcessMsg);
if (pfrom->vProcessMsg.empty())
return false;
// Just take one message
msgs.splice(msgs.begin(), pfrom->vProcessMsg, pfrom->vProcessMsg.begin());
pfrom->nProcessQueueSize -= msgs.front().vRecv.size() + CMessageHeader::HEADER_SIZE;
pfrom->fPauseRecv = pfrom->nProcessQueueSize > connman->GetReceiveFloodSize();
fMoreWork = !pfrom->vProcessMsg.empty();
}
CNetMessage& msg(msgs.front());
msg.SetVersion(pfrom->GetRecvVersion());
// Scan for message start
if (memcmp(msg.hdr.pchMessageStart, chainparams.MessageStart(), CMessageHeader::MESSAGE_START_SIZE) != 0) {
LogPrint(BCLog::NET, "PROCESSMESSAGE: INVALID MESSAGESTART %s peer=%d\n", SanitizeString(msg.hdr.GetCommand()), pfrom->GetId());
pfrom->fDisconnect = true;
return false;
}
// Read header
CMessageHeader& hdr = msg.hdr;
if (!hdr.IsValid(chainparams.MessageStart()))
{
LogPrint(BCLog::NET, "PROCESSMESSAGE: ERRORS IN HEADER %s peer=%d\n", SanitizeString(hdr.GetCommand()), pfrom->GetId());
return fMoreWork;
}
std::string strCommand = hdr.GetCommand();
// Message size
unsigned int nMessageSize = hdr.nMessageSize;
// Checksum
CDataStream& vRecv = msg.vRecv;
const uint256& hash = msg.GetMessageHash();
if (memcmp(hash.begin(), hdr.pchChecksum, CMessageHeader::CHECKSUM_SIZE) != 0)
{
LogPrint(BCLog::NET, "%s(%s, %u bytes): CHECKSUM ERROR expected %s was %s\n", __func__,
SanitizeString(strCommand), nMessageSize,
HexStr(hash.begin(), hash.begin()+CMessageHeader::CHECKSUM_SIZE),
HexStr(hdr.pchChecksum, hdr.pchChecksum+CMessageHeader::CHECKSUM_SIZE));
return fMoreWork;
}
// Process message
bool fRet = false;
try
{
fRet = ProcessMessage(pfrom, strCommand, vRecv, msg.nTime, chainparams, connman, interruptMsgProc);
if (interruptMsgProc)
return false;
if (!pfrom->vRecvGetData.empty())
fMoreWork = true;
}
catch (const std::ios_base::failure& e)
{
connman->PushMessage(pfrom, CNetMsgMaker(INIT_PROTO_VERSION).Make(NetMsgType::REJECT, strCommand, REJECT_MALFORMED, std::string("error parsing message")));
if (strstr(e.what(), "end of data"))
{
// Allow exceptions from under-length message on vRecv
LogPrint(BCLog::NET, "%s(%s, %u bytes): Exception '%s' caught, normally caused by a message being shorter than its stated length\n", __func__, SanitizeString(strCommand), nMessageSize, e.what());
}
else if (strstr(e.what(), "size too large"))
{
// Allow exceptions from over-long size
LogPrint(BCLog::NET, "%s(%s, %u bytes): Exception '%s' caught\n", __func__, SanitizeString(strCommand), nMessageSize, e.what());
}
else if (strstr(e.what(), "non-canonical ReadCompactSize()"))
{
// Allow exceptions from non-canonical encoding
LogPrint(BCLog::NET, "%s(%s, %u bytes): Exception '%s' caught\n", __func__, SanitizeString(strCommand), nMessageSize, e.what());
}
else
{
PrintExceptionContinue(&e, "ProcessMessages()");
}
}
catch (const std::exception& e) {
PrintExceptionContinue(&e, "ProcessMessages()");
} catch (...) {
PrintExceptionContinue(nullptr, "ProcessMessages()");
}
if (!fRet) {
LogPrint(BCLog::NET, "%s(%s, %u bytes) FAILED peer=%d\n", __func__, SanitizeString(strCommand), nMessageSize, pfrom->GetId());
}
LOCK(cs_main);
SendRejectsAndCheckIfBanned(pfrom, connman);
return fMoreWork;
}複製程式碼
程式碼稍長,但邏輯實際上比較清晰:從節點訊息緩衝區(vProcessMsg)中取出訊息,然後讀取訊息頭,訊息校驗和,訊息長度等,對訊息進行檢查後,呼叫ProcessMessage訊息處理訊息:
ProcessMessage裡有許多的if-else分支,針對不同的訊息走不同的分支處理。這裡就不在展開了,等分析具體的P2P協議訊息時在回到這個函式裡來看。
3.3.5 節點之間握手
前文提到過,當兩個節點之間的網路連線建立起來以後,就需要按照比特幣的P2P網路協議來進行通訊。首先要做的就是兩個節點間的握手。兩個節點之間互發version訊息,並向對方回以verack進行確認。
握手是在一個節點初始化的時候出發的,還記得前文提到的OpenNetworkConnection函式麼,這個函式在生成一個新節點以後,還會對節點進行初始化:
m_msgproc->InitializeNode(pnode);複製程式碼
握手訊息version就是在這裡傳送出去的:
void PeerLogicValidation::InitializeNode(CNode *pnode) {
CAddress addr = pnode->addr;
std::string addrName = pnode->GetAddrName();
NodeId nodeid = pnode->GetId();
{
LOCK(cs_main);
mapNodeState.emplace_hint(mapNodeState.end(), std::piecewise_construct, std::forward_as_tuple(nodeid), std::forward_as_tuple(addr, std::move(addrName)));
}
if(!pnode->fInbound)
PushNodeVersion(pnode, connman, GetTime());
}複製程式碼
PushNodeVersion將向peer傳送version訊息:
static void PushNodeVersion(CNode *pnode, CConnman* connman, int64_t nTime)
{
ServiceFlags nLocalNodeServices = pnode->GetLocalServices();
uint64_t nonce = pnode->GetLocalNonce();
int nNodeStartingHeight = pnode->GetMyStartingHeight();
NodeId nodeid = pnode->GetId();
CAddress addr = pnode->addr;
CAddress addrYou = (addr.IsRoutable() && !IsProxy(addr) ? addr : CAddress(CService(), addr.nServices));
CAddress addrMe = CAddress(CService(), nLocalNodeServices);
connman->PushMessage(pnode, CNetMsgMaker(INIT_PROTO_VERSION).Make(NetMsgType::VERSION, PROTOCOL_VERSION, (uint64_t)nLocalNodeServices, nTime, addrYou, addrMe,
nonce, strSubVersion, nNodeStartingHeight, ::fRelayTxes));
if (fLogIPs) {
LogPrint(BCLog::NET, "send version message: version %d, blocks=%d, us=%s, them=%s, peer=%d\n", PROTOCOL_VERSION, nNodeStartingHeight, addrMe.ToString(), addrYou.ToString(), nodeid);
} else {
LogPrint(BCLog::NET, "send version message: version %d, blocks=%d, us=%s, peer=%d\n", PROTOCOL_VERSION, nNodeStartingHeight, addrMe.ToString(), nodeid);
}
}複製程式碼
結合前文描述的version訊息,上面這段程式碼就很容易理解了。version訊息將本節點的nLocalService,addrMe,addrYou,當前節點的區塊鏈高度等資訊傳送給peer。
之後就是等待peer回以verack的確認訊息的處理了。根據前文的分析,當節點收到peer的訊息後,select函式將返回,套接字在可讀描述符集合中將被置位,然後從套接字裡讀取資料,資料最終在PeerLogicValidation::ProcessMessage函式被消費掉,來看看一個節點收到version訊息時是如何處理的:
else if (strCommand == NetMsgType::VERSION)
{
// Each connection can only send one version message
if (pfrom->nVersion != 0)
{
connman->PushMessage(pfrom, CNetMsgMaker(INIT_PROTO_VERSION).Make(NetMsgType::REJECT, strCommand, REJECT_DUPLICATE, std::string("Duplicate version message")));
LOCK(cs_main);
Misbehaving(pfrom->GetId(), 1);
return false;
}
int64_t nTime;
CAddress addrMe;
CAddress addrFrom;
uint64_t nNonce = 1;
uint64_t nServiceInt;
ServiceFlags nServices;
int nVersion;
int nSendVersion;
std::string strSubVer;
std::string cleanSubVer;
int nStartingHeight = -1;
bool fRelay = true;
vRecv >> nVersion >> nServiceInt >> nTime >> addrMe;
nSendVersion = std::min(nVersion, PROTOCOL_VERSION);
nServices = ServiceFlags(nServiceInt);
if (!pfrom->fInbound)
{
connman->SetServices(pfrom->addr, nServices);
}
if (!pfrom->fInbound && !pfrom->fFeeler && !pfrom->m_manual_connection && !HasAllDesirableServiceFlags(nServices))
{
LogPrint(BCLog::NET, "peer=%d does not offer the expected services (%08x offered, %08x expected); disconnecting\n", pfrom->GetId(), nServices, GetDesirableServiceFlags(nServices));
connman->PushMessage(pfrom, CNetMsgMaker(INIT_PROTO_VERSION).Make(NetMsgType::REJECT, strCommand, REJECT_NONSTANDARD,
strprintf("Expected to offer services %08x", GetDesirableServiceFlags(nServices))));
pfrom->fDisconnect = true;
return false;
}
if (nServices & ((1 << 7) | (1 << 5))) {
if (GetTime() < 1533096000) {
// Immediately disconnect peers that use service bits 6 or 8 until August 1st, 2018
// These bits have been used as a flag to indicate that a node is running incompatible
// consensus rules instead of changing the network magic, so we're stuck disconnecting
// based on these service bits, at least for a while.
pfrom->fDisconnect = true;
return false;
}
}
if (nVersion < MIN_PEER_PROTO_VERSION)
{
// disconnect from peers older than this proto version
LogPrint(BCLog::NET, "peer=%d using obsolete version %i; disconnecting\n", pfrom->GetId(), nVersion);
connman->PushMessage(pfrom, CNetMsgMaker(INIT_PROTO_VERSION).Make(NetMsgType::REJECT, strCommand, REJECT_OBSOLETE,
strprintf("Version must be %d or greater", MIN_PEER_PROTO_VERSION)));
pfrom->fDisconnect = true;
return false;
}
if (nVersion == 10300)
nVersion = 300;
if (!vRecv.empty())
vRecv >> addrFrom >> nNonce;
if (!vRecv.empty()) {
vRecv >> LIMITED_STRING(strSubVer, MAX_SUBVERSION_LENGTH);
cleanSubVer = SanitizeString(strSubVer);
}
if (!vRecv.empty()) {
vRecv >> nStartingHeight;
}
if (!vRecv.empty())
vRecv >> fRelay;
// Disconnect if we connected to ourself
if (pfrom->fInbound && !connman->CheckIncomingNonce(nNonce))
{
LogPrintf("connected to self at %s, disconnecting\n", pfrom->addr.ToString());
pfrom->fDisconnect = true;
return true;
}
if (pfrom->fInbound && addrMe.IsRoutable())
{
SeenLocal(addrMe);
}
// Be shy and don't send version until we hear
if (pfrom->fInbound)
PushNodeVersion(pfrom, connman, GetAdjustedTime());
connman->PushMessage(pfrom, CNetMsgMaker(INIT_PROTO_VERSION).Make(NetMsgType::VERACK));複製程式碼
主要是對接收到的資料進行合法性檢查,沒問題了傳送verack進行確認。對於verack的處理此處就不在展開,可以自行在ProcessMessage函式中去檢視。
3.3.6 時序圖
簽名洋洋灑灑一大堆,很多讀者可能還是有點雲裡霧裡的感覺,這裡再用時序圖來對上面的過程做一個補充。
(1) 比特幣P2P網路的初始化
(2) 連線到peer
4 小結
本文分析了比特幣P2P網路的節點發現原理、節點之間連線的協議以及網路連線、節點間資料通訊的原始碼實現。區塊鏈的本質是建立在P2P網路上的一個分散式資料庫,網路上的節點通過Pow工作量證明或Pos權益證明等演算法達成共識。所以P2P網路以及其共識演算法可以看做是區塊鏈的基石,所以瞭解比特幣區塊鏈的P2P網路節點發現以及互聯互通的原理和實現對學習區塊鏈來說非常重要,同時對於像作者這樣的新手來說,也是進入區塊鏈學習的一個不錯的切入點。
本文只是分析了節點是如何連線到發現的相鄰節點上(對外),作為P2P網路,自己的節點當然也可以作為網路中的一個peer為其他節點服務。下一篇文章我們將分析一下比特幣區塊鏈是如何通過minipunp實現埠對映,讓網路中的其他節點連線到自己。
參考文章《精通比特幣》
---- 本文為原創文章,轉載請記得註明出處。