剝開比原看程式碼06:比原是如何把請求區塊資料的資訊發出去的
作者:freewind
比原專案倉庫:
Github地址:https://github.com/Bytom/bytom
Gitee地址:https://gitee.com/BytomBlockchain/bytom
在前一篇中,我們說到,當比原向其它節點請求區塊資料時,BlockKeeper
會傳送一個BlockRequestMessage
把需要的區塊height
告訴對方,並把該資訊對應的二進位制資料放入ProtocolReactor
對應的sendQueue
通道中,等待傳送。而具體的傳送細節,由於邏輯比較複雜,所以在前一篇中並未詳解,放到本篇中。
由於sendQueue
是一個通道,資料放進去後,到底是由誰在什麼情況下取走併傳送,BlockKeeper
這邊是不知道的。經過我們在程式碼中搜尋,發現只有一個型別會直接監視sendQueue
中的資料,它就是前文出現的MConnection
。MConnection
的物件在它的OnStart
方法中,會監視sendQueue
中的資料,然後,等發現資料時,會將之取走並放入一個叫sending
的通道里。
事情變得有點複雜了:
- 由前篇我們知道,一個
MConnection
對應了一個與peer的連線,而比原節點之間建立連線的情況又有多種:比如主動連線別的節點,或者別的節點主動連上我 - 放入通道
sending
之後,我們還需要知道又是誰在什麼情況下會監視sending
,取走它裡面的資料 sending
中的資料被取走後,又是如何被髮送到其它節點的呢?
還是像以前一樣,遇到複雜的問題,我們先通過“相互獨立,完全窮盡”的原則,把它分解成一個個小問題,然後依次解決。
那麼首先我們需要弄清楚的是:
比原在什麼情況下,會建立MConnection
的物件並呼叫其OnStart
方法?
(從而我們知道sendQueue
中的資料是如何被監視的)
經過分析,我們發現MConnection
的啟動,只出現在一個地方,即Peer
的OnStart
方法中。那麼就這個問題就變成了:比原在什麼情況下,會建立Peer
的物件並呼叫其OnStart
方法?
再經過一番折騰,終於確定,在比原中,在下列4種情況Peer.OnStart
方法最終會被呼叫:
- 比原節點啟動後,主動去連線配置檔案指定的種子節點、以及本地資料目錄中
addrbook.json
中儲存的節點的時候 - 比原監聽本地p2p埠後,有別的節點連上來的時候
- 啟動
PEXReactor
,並使用它自己的協議與當前連線上的節點進行通訊的時候 - 在一個沒有用上的
Switch.Connect2Switches
方法中(可忽略)
第4種情況我們完全忽略。第3種情況中,由於PEXReactor
會使用類似於BitTorrent的檔案分享協議與其它節點分享資料,邏輯比較獨立,算是一種輔助作用,我們也暫不考慮。這樣我們就只需要分析前兩種情況了。
比原節點啟動時,是如何主動連線其它節點,並最終呼叫了MConnection.OnStart
方法的?
首先我們快速走到SyncManager.Start
方法:
func main() {
cmd := cli.PrepareBaseCmd(commands.RootCmd, "TM", os.ExpandEnv(config.DefaultDataDir()))
cmd.Execute()
}
cmd/bytomd/commands/run_node.go#L41
func runNode(cmd *cobra.Command, args []string) error {
n := node.NewNode(config)
if _, err := n.Start(); err != nil {
// ...
}
func (n *Node) OnStart() error {
// ...
n.syncManager.Start()
// ...
}
func (sm *SyncManager) Start() {
go sm.netStart()
// ...
}
然後我們將進入netStart()
方法。在這個方法中,比原將主動連線其它節點:
func (sm *SyncManager) netStart() error {
// ...
if sm.config.P2P.Seeds != "" {
// dial out
seeds := strings.Split(sm.config.P2P.Seeds, ",")
if err := sm.DialSeeds(seeds); err != nil {
return err
}
}
return nil
}
這裡出現的sm.config.P2P.Seeds
,對應的就是本地資料目錄中config.toml
中的p2p.seeds
中的種子結點。
接著通過sm.DialSeeds
去主動連線每個種子:
func (sm *SyncManager) DialSeeds(seeds []string) error {
return sm.sw.DialSeeds(sm.addrBook, seeds)
}
[p2p/switch.go#L311-L340](https://github.com/freewind/bytom-v1.0.1/blob/master/p2p/switch.go#L311-L340)
func (sw *Switch) DialSeeds(addrBook *AddrBook, seeds []string) error {
// ...
for i := 0; i < len(perm)/2; i++ {
j := perm[i]
sw.dialSeed(netAddrs[j])
}
// ...
}
func (sw *Switch) dialSeed(addr *NetAddress) {
peer, err := sw.DialPeerWithAddress(addr, false)
// ...
}
func (sw *Switch) DialPeerWithAddress(addr *NetAddress, persistent bool) (*Peer, error) {
// ...
peer, err := newOutboundPeerWithConfig(addr, sw.reactorsByCh, sw.chDescs, sw.StopPeerForError, sw.nodePrivKey, sw.peerConfig)
// ...
err = sw.AddPeer(peer)
// ...
}
先是通過newOutboundPeerWithConfig
建立了peer
,然後把它加入到sw
(即Switch
物件)中。
func (sw *Switch) AddPeer(peer *Peer) error {
// ...
// Start peer
if sw.IsRunning() {
if err := sw.startInitPeer(peer); err != nil {
return err
}
}
// ...
}
在sw.startInitPeer
中,將會呼叫peer.Start
:
func (sw *Switch) startInitPeer(peer *Peer) error {
peer.Start()
// ...
}
而peer.Start
對應了Peer.OnStart
,最後就是:
func (p *Peer) OnStart() error {
p.BaseService.OnStart()
_, err := p.mconn.Start()
return err
}
可以看到,在這裡呼叫了mconn.Start
,終於找到了。總結一下就是:
Node.Start
->SyncManager.Start
->SyncManager.netStart
->Switch.DialSeeds
->Switch.AddPeer
->Switch.startInitPeer
->Peer.OnStart
->MConnection.OnStart
那麼,第一種主動連線別的節點的情況就到這裡分析完了。下面是第二種情況:
當別的節點連線到本節點時,比原是如何走到MConnection.OnStart
方法這一步的?
比原節點啟動後,會監聽本地的p2p埠,等待別的節點連線上來。那麼這個流程又是什麼樣的呢?
由於比原節點的啟動流程在目前的文章中已經多次出現,這裡就不貼了,我們直接從Switch.OnStart
開始(它是在SyncManager
啟動的時候啟動的):
```go func (sw *Switch) OnStart() error { // ... for _, peer := range sw.peers.List() { sw.startInitPeer(peer) }
// Start listeners
for _, listener := range sw.listeners {
go sw.listenerRoutine(listener)
}
// ...
} ```
這個方法經過省略以後,還剩兩塊程式碼,一塊是startInitPeer(...)
,一塊是sw.listenerRoutine(listener)
。
如果你剛才在讀前一節時留意了,就會發現,startInitPeer(...)
方法馬上就會呼叫Peer.Start
。然而在這裡需要說明的是,經過我的分析,發現這塊程式碼實際上沒有起到任何作用,因為在當前這個時刻,sw.peers
總是空的,它裡面還沒有來得及被其它的程式碼新增進peer。所以我覺得它可以刪掉,以免誤導讀者。(提了一個issue,參見#902)
第二塊程式碼,listenerRoutine
,如果你還有印象的話,它就是用來監聽本地p2p埠的,在前面“比原是如何監聽p2p埠的”一文中有詳細的講解。
我們今天還是需要再挖掘一下它,看看它到底是怎麼走到MConnection.OnStart
的:
go
func (sw *Switch) listenerRoutine(l Listener) {
for {
inConn, ok := <-l.Connections()
// ...
err := sw.addPeerWithConnectionAndConfig(inConn, sw.peerConfig)
// ...
}
}
這裡的l
就是監聽本地p2p埠的Listener。通過一個for
迴圈,拿到連線到該埠的節點的連線,生成新peer。
go
func (sw *Switch) addPeerWithConnectionAndConfig(conn net.Conn, config *PeerConfig) error {
// ...
peer, err := newInboundPeerWithConfig(conn, sw.reactorsByCh, sw.chDescs, sw.StopPeerForError, sw.nodePrivKey, config)
// ...
if err = sw.AddPeer(peer); err != nil {
// ...
}
// ...
}
生成新的peer之後,呼叫了Switch
的AddPeer
方法。到了這裡,就跟前一節一樣了,在AddPeer
中將呼叫sw.startInitPeer(peer)
,然後呼叫peer.Start()
,最後呼叫了MConnection.OnStart()
。由於程式碼一模一樣,就不貼出來了。
總結一下,就是:
Node.Start
->SyncManager.Start
->SyncManager.netStart
->Switch.OnStart
->Switch.listenerRoutine
->Switch.addPeerWithConnectionAndConfig
->Switch.AddPeer
->Switch.startInitPeer
->Peer.OnStart
->MConnection.OnStart
那麼,第二種情況我們也分析完了。
不過到目前為止,我們只解決了這次問題中的第一個小問題,即:我們終於知道了比原始碼會在什麼情況來啟動一個MConnection
,從而監視sendQueue
通道,把要傳送的資訊資料,轉到了sending
通道中。
那麼,我們進入下一個小問題:
資料放入通道sending
之後,誰又會來取走它們呢?
經過分析之後,發現通道sendQueue
和sending
都屬於型別Channel
,只不過兩者作用不同。sendQueue
是用來存放待傳送的完整的資訊資料,而sending
更底層一些,它持有的資料可能會被分成多個塊傳送。如果只有sendQueue
一個通道,那麼很難實現分塊的操作的。
而Channel
的傳送是由MConnection
來呼叫的,幸運的是,當我們一直往回追溯下去,發現竟走到了MConnection.OnStart
這裡。也就是說,我們在這個小問題中,研究的正好是前面兩個鏈條後面的部分:
Node.Start
->SyncManager.Start
->SyncManager.netStart
->Switch.DialSeeds
->Switch.AddPeer
->Switch.startInitPeer
->Peer.OnStart
->MConnection.OnStart
->???
Node.Start
->SyncManager.Start
->SyncManager.netStart
->Switch.OnStart
->Switch.listenerRoutine
->Switch.addPeerWithConnectionAndConfig
->Switch.AddPeer
->Switch.startInitPeer
->Peer.OnStart
->MConnection.OnStart
->???
也就是上面的???
部分。
那麼我們就直接從MConnection.OnStart
開始:
func (c *MConnection) OnStart() error {
// ...
go c.sendRoutine()
// ...
}
c.sendRoutine()
方法就是我們需要的。當MConnection
啟動以後,就會開始進行傳送操作(等待資料到來)。它的程式碼如下:
func (c *MConnection) sendRoutine() {
// ...
case <-c.send:
// Send some msgPackets
eof := c.sendSomeMsgPackets()
if !eof {
// Keep sendRoutine awake.
select {
case c.send <- struct{}{}:
default:
}
}
}
// ...
}
這個方法本來很長,只是我們省略掉了很多無關的程式碼。裡面的c.sendSomeMsgPackets()
就是我們要找的,但是,我們突然發現,怎麼又出來了一個c.send
通道?它又有什麼用?而且看起來好像只有當這個通道里有東西的時候,我們才會去呼叫c.sendSomeMsgPackets()
,似乎像是一個鈴鐺一樣用來提醒我們。
那麼c.send
什麼時候會有東西呢?檢查了程式碼之後,發現在以下3個地方:
func (c *MConnection) Send(chID byte, msg interface{}) bool {
// ...
success := channel.sendBytes(wire.BinaryBytes(msg))
if success {
// Wake up sendRoutine if necessary
select {
case c.send <- struct{}{}:
// ..
}
func (c *MConnection) TrySend(chID byte, msg interface{}) bool {
// ...
ok = channel.trySendBytes(wire.BinaryBytes(msg))
if ok {
// Wake up sendRoutine if necessary
select {
case c.send <- struct{}{}:
// ...
}
func (c *MConnection) sendRoutine() {
// ....
case <-c.send:
// Send some msgPackets
eof := c.sendSomeMsgPackets()
if !eof {
// Keep sendRoutine awake.
select {
case c.send <- struct{}{}:
// ...
}
如果我們對前一篇文章還有印象,就會記得channel.trySendBytes
是在我們想給對方節點發資訊時呼叫的,呼叫完以後,它會把資訊對應的二進位制資料放入到channel.sendQueue
通道(所以才有了本文)。channel.sendBytes
我們目前雖然還沒用到,但是它也應該是類似的。在它們兩個呼叫完之後,它們都會向c.send
通道里放入一個資料,用來通知Channel
有資料可以傳送了。
而第三個sendRoutine()
就是我們剛剛走到的地方。當我們呼叫c.sendSomeMsgPackets()
傳送了sending
中的一部分之後,如果還有剩餘的,則繼續向c.send
放個資料,提醒可以繼續傳送。
那到目前為止,傳送資料涉及到的Channel就有三個了,分別是sendQueue
、sending
和send
。之所以這麼複雜,根本原因就是想把資料分塊傳送。
為什麼要分塊傳送呢?這是因為比原希望能控制傳送速率,讓節點之間的網速能保持在一個合理的水平。如果不限制的話,一下子發出大量的資料,一是可能會讓接收者來不及處理,二是有可能會被惡意節點利用,請求大量區塊資料把頻寬佔滿。
擔心sendQueue
、sending
和send
這三個通道不太好理解,我想到了一個“燒鴨店”的比喻,來理解它們:
sendQueue
就像是用來掛烤好的燒鴨的勾子,可以有多個(但對於比原來說,預設只有一個,因為sendQueue
的容量預設為1
),當有燒鴨烤好以後,就掛在勾子上;sending
是砧板,可以把燒鴨從sendQueue
勾子上取下來一隻,放在上面切成塊,等待裝盤,一隻燒鴨可能可以裝成好幾盤;- 而
send
是鈴鐺,當有人點單後,服務員就會按一下鈴鐺,廚師就從sending
砧板上拿幾塊燒鴨放在小盤中放在出餐口。由於廚師非常忙,每次切出一盤後都可能會去做別的事情,而忘了sending
砧板上還有燒鴨沒裝盤,所以為了防止自己忘記,他每切出一盤之後,都會看一眼sending
砧板,如果還有肉,就會按一下鈴鐺提醒自己繼續裝盤。
好了,理解了send
後,我們就可以回到主線,繼續看c.sendSomeMsgPackets()
的程式碼了:
func (c *MConnection) sendSomeMsgPackets() bool {
// Block until .sendMonitor says we can write.
// Once we're ready we send more than we asked for,
// but amortized it should even out.
c.sendMonitor.Limit(maxMsgPacketTotalSize, atomic.LoadInt64(&c.config.SendRate), true)
// Now send some msgPackets.
for i := 0; i < numBatchMsgPackets; i++ {
if c.sendMsgPacket() {
return true
}
}
return false
}
c.sendMonitor.Limit
的作用是限制傳送速率,其中maxMsgPacketTotalSize
即每個packet的最大長度為常量10240
,第二個引數是預先指定的傳送速率,預設值為500KB/s
,第三個引數是說,當實際速度過大時,是否暫停傳送,直到變得正常。
經過限速的調整後,後面一段就可以正常傳送資料了,其中的c.sendMsgPacket
是我們繼續要看的方法:
func (c *MConnection) sendMsgPacket() bool {
// ...
n, err := leastChannel.writeMsgPacketTo(c.bufWriter)
// ..
c.sendMonitor.Update(int(n))
// ...
return false
}
這個方法最前面我省略了一大段程式碼,其作用是檢查多個channel,結合它們的優先順序和已經發的資料量,找到當前最需要傳送資料的那個channel,記為leastChannel
。
然後就是呼叫leastChannel.writeMsgPacketTo(c.bufWriter)
,把當前要傳送的一塊資料,寫到bufWriter
中。這個bufWriter
就是真正與連線物件繫結的一個快取區,寫入到它裡面的資料,會被Go傳送出去。它的定義是在建立MConnection
的地方:
func NewMConnectionWithConfig(conn net.Conn, chDescs []*ChannelDescriptor, onReceive receiveCbFunc, onError errorCbFunc, config *MConnConfig) *MConnection {
mconn := &MConnection{
conn: conn,
bufReader: bufio.NewReaderSize(conn, minReadBufferSize),
bufWriter: bufio.NewWriterSize(conn, minWriteBufferSize),
其中minReadBufferSize
為1024
,minWriteBufferSize
為65536
。
資料寫到bufWriter
以後,我們就不需要關心了,交給Go來操作了。
在leastChannel.writeMsgPacketTo(c.bufWriter)
呼叫完以後,後面會更新c.sendMonitor
,這樣它才能繼續正確的限速。
這時我們已經知道資料是怎麼發出去的了,但是我們還沒有找到是誰在監視sending
裡的資料,那讓我們繼續看leastChannel.writeMsgPacketTo
:
func (ch *Channel) writeMsgPacketTo(w io.Writer) (n int, err error) {
packet := ch.nextMsgPacket()
wire.WriteByte(packetTypeMsg, w, &n, &err)
wire.WriteBinary(packet, w, &n, &err)
if err == nil {
ch.recentlySent += int64(n)
}
return
}
其中的ch.nextMsgPacket()
是取出下一個要傳送的資料塊,那麼是從哪裡取出呢?是從sending
嗎?
其後的程式碼是把資料塊物件變成二進位制,放入到前面的bufWriter
中傳送。
繼續ch.nextMsgPacket()
:
func (ch *Channel) nextMsgPacket() msgPacket {
packet := msgPacket{}
packet.ChannelID = byte(ch.id)
packet.Bytes = ch.sending[:cmn.MinInt(maxMsgPacketPayloadSize, len(ch.sending))]
if len(ch.sending) <= maxMsgPacketPayloadSize {
packet.EOF = byte(0x01)
ch.sending = nil
atomic.AddInt32(&ch.sendQueueSize, -1) // decrement sendQueueSize
} else {
packet.EOF = byte(0x00)
ch.sending = ch.sending[cmn.MinInt(maxMsgPacketPayloadSize, len(ch.sending)):]
}
return packet
}
終於看到sending
了。從這裡可以看出,sending
的確是放著很多塊鴨肉的砧板,而packet
就是一個小盤,所以需要從先sending
中拿出不超過指定長度的資料放到packet
中,然後判斷sending
裡還有沒有剩下的。如果有,則packet
的EOF
值為0x00
,否則為0x01
,這樣呼叫者就知道資料有沒有發完,還需不需要去按那個叫send
的鈴。
那麼到這裡為止,我們就知道原來還是Channel自己在關注sending
,並且為了限制傳送速度,需要把它切成一個個小塊。
最後就我們的第三個小問題了,其實我們剛才在第二問裡已經弄清楚了。
sending
中的資料被取走後,又是如何被髮送到其它節點的呢?
答案就是,sending
中的資料被分成一塊塊取出來後,會放入到bufWriter
中,就直接被Go的net.Conn
物件傳送出去了。到這一層面,就不需要我們再繼續深入了。
總結
由於本篇中涉及的方法呼叫比較多,可能看完都亂了,所以在最後,我們前面呼叫鏈補充完整,放在最後:
Node.Start
->SyncManager.Start
->SyncManager.netStart
->Switch.DialSeeds
->Switch.AddPeer
->Switch.startInitPeer
->Peer.OnStart
->MConnection.OnStart
->...
Node.Start
->SyncManager.Start
->SyncManager.netStart
->Switch.OnStart
->Switch.listenerRoutine
->Switch.addPeerWithConnectionAndConfig
->Switch.AddPeer
->Switch.startInitPeer
->Peer.OnStart
->MConnection.OnStart
->...
然後是:
MConnection.sendRoutine
->MConnection.send
->MConnection.sendSomeMsgPackets
->MConnection.sendMsgPacket
->MConnection.writeMsgPacketTo
->MConnection.nextMsgPacket
->MConnection.sending
到了最後,我的感覺就是,一個複雜問題最開始看起來很可怕,但是一旦把它分解成小問題之後,每次只關注一個,各個擊破,好像就沒那麼複雜了。
相關文章
- 剝開比原看程式碼07:比原節點收到“請求區塊資料”的資訊後如何應答?
- 剝開比原看程式碼05:如何從比原節點拿到區塊資料?
- 剝開比原看程式碼03:比原是如何監聽p2p埠的
- 剝開比原看程式碼11:比原是如何通過介面/create-account建立帳戶的
- 剝開比原看程式碼04:如何連上一個比原節點
- 剝開比原看程式碼08:比原的Dashboard是怎麼做出來的?
- 剝開比原看程式碼14:比原的挖礦流程是什麼樣的?
- 剝開比原看程式碼02:比原啟動後去哪裡連線別的節點
- 剝開比原看程式碼01:初始化時生成的配置檔案在哪兒
- 區塊鏈與分散式資料庫的比較區塊鏈分散式資料庫
- 一比一還原axios原始碼(一)—— 發起第一個請求iOS原始碼
- iOS開發比較有用的程式碼段iOS
- 比原鏈CTO James | Go語言成為區塊鏈主流開發語言的四點理由Go區塊鏈
- node 使用get和post向後臺請求資料的使用方式對比
- 網路請求框架對比框架
- 一比一還原axios原始碼(二)—— 請求響應處理iOS原始碼
- 外包區塊鏈開發比建立內部團隊更好區塊鏈
- 如何避免舊請求的資料覆蓋掉最新請求
- 請求重定向和請求轉發的區別
- 相親原始碼開發,從程式碼級別減少資料請求次數的實現原始碼
- 非同步請求xhr、ajax、axios與fetch的區別比較非同步iOS
- 前端開發薪資之各地區對比前端
- 為什麼說無程式碼開發比低程式碼開發更好?
- 2.2 基於python開發的資料比對工具--SYDCTOOL程式碼設計解讀Python
- 低程式碼開發平臺有哪些比較好用的?
- 2019年全球按地區劃分的專利申請佔比(附原資料表)
- 原聲ajax與jquery ajax請求的區別jQuery
- 如何使用事務碼SMICM分析ABAP程式碼發起的HTTP請求的錯誤HTTP
- 區塊鏈與DAG的比較 -ICO.li區塊鏈
- 各區塊鏈架構的橫向比較區塊鏈架構
- jquery實現的ajax請求獲取json資料程式碼jQueryJSON
- 網路 保證在關閉連線前, 把資料發出去
- ClownFish:比寫程式碼還快的通用資料訪問層
- Flutter資料持久化入門以及與Web開發的對比Flutter持久化Web
- 五種VC++資料庫開發技術的比較C++資料庫
- 3分鐘短文:Laravel把資料驗證的手伸向“請求體”Laravel
- 重定向和請求轉發的區別
- 前端開發薪資之各地區對比(圖文分析)前端