MIT 6.824 lab2 啓動流程--多阻塞輸入驅動的狀態機模型設計--raft算法實現
標籤(空格分隔): 分佈式系統
運行
go test -run Election
首先go編譯器會執行
TestInitialElection(t *testing.T)
TestReElection(t *testing.T)
兩個函數
1.make_config
TestInitialElection函數會執行
cfg := make_config(t, servers, false)
初始化整個網絡
make_config函數會填充config結構體,其中
cfg.net = labrpc.MakeNetwork()
會調用labrpc.MakeNetwork()函數創建一個網絡,這個函數原型如下:
func MakeNetwork() *Network {
//fmt.Printf("MakeNetwork開始了\n")
rn := &Network{}
rn.reliable = true
rn.ends = map[interface{}]*ClientEnd{}
rn.enabled = map[interface{}]bool{}
rn.servers = map[interface{}]*Server{}
rn.connections = map[interface{}](interface{}){}
rn.endCh = make(chan reqMsg)
// single goroutine to handle all ClientEnd.Call()s
go func() {
for xreq := range rn.endCh {
go rn.ProcessReq(xreq)
}
}()
return rn
}
從上面代碼可以看出,這個函數只是初始化了Network結構體。關鍵在於這個結構體裏有一個channel,rn.endCh.注意到目前爲止的代碼運行在主線程裏,然後該函數創建了一個線程 thread1,監聽該管道
2. cfg.start1
主線程main繼續執行
for i := 0; i < cfg.n; i++ {
cfg.logs[i] = map[int]int{}
cfg.start1(i)
}
cfg.n指服務器的個數
這段代碼通過for循環初始化每一個服務器,也就是網絡中的每一個節點,這個函數會進一步完善cfg結構體的內容
cfg.start1函數原型如下:
func (cfg *config) start1(i int) {
cfg.crash1(i)
// a fresh set of outgoing ClientEnd names.
// so that old crashed instance's ClientEnds can't send.
cfg.endnames[i] = make([]string, cfg.n)
for j := 0; j < cfg.n; j++ {
cfg.endnames[i][j] = randstring(20)
}
// a fresh set of ClientEnds.
ends := make([]*labrpc.ClientEnd, cfg.n)
for j := 0; j < cfg.n; j++ {
ends[j] = cfg.net.MakeEnd(cfg.endnames[i][j])
cfg.net.Connect(cfg.endnames[i][j], j)
}
cfg.mu.Lock()
// a fresh persister, so old instance doesn't overwrite
// new instance's persisted state.
// but copy old persister's content so that we always
// pass Make() the last persisted state.
if cfg.saved[i] != nil {
cfg.saved[i] = cfg.saved[i].Copy()
} else {
cfg.saved[i] = MakePersister()
}
cfg.mu.Unlock()
// listen to messages from Raft indicating newly committed messages.
applyCh := make(chan ApplyMsg)
go func() {
for m := range applyCh {
err_msg := ""
if m.UseSnapshot {
// ignore the snapshot
} else if v, ok := (m.Command).(int); ok {
cfg.mu.Lock()
for j := 0; j < len(cfg.logs); j++ {
if old, oldok := cfg.logs[j][m.Index]; oldok && old != v {
// some server has already committed a different value for this entry!
err_msg = fmt.Sprintf("commit index=%v server=%v %v != server=%v %v",
m.Index, i, m.Command, j, old)
}
}
_, prevok := cfg.logs[i][m.Index-1]
cfg.logs[i][m.Index] = v
cfg.mu.Unlock()
if m.Index > 1 && prevok == false {
err_msg = fmt.Sprintf("server %v apply out of order %v", i, m.Index)
}
} else {
err_msg = fmt.Sprintf("committed command %v is not an int", m.Command)
}
if err_msg != "" {
log.Fatalf("apply error: %v\n", err_msg)
cfg.applyErr[i] = err_msg
// keep reading after error so that Raft doesn't block
// holding locks...
}
}
}()
rf := Make(ends, i, cfg.saved[i], applyCh)
cfg.mu.Lock()
cfg.rafts[i] = rf
cfg.mu.Unlock()
svc := labrpc.MakeService(rf)
srv := labrpc.MakeServer()
srv.AddService(svc)
cfg.net.AddServer(i, srv)
}
這段函數有幾個關鍵代碼:
1.
ends := make([]*labrpc.ClientEnd, cfg.n)
...中間省略若干代碼
rf := Make(ends, i, cfg.saved[i], applyCh)
cfg.mu.Lock()
cfg.rafts[i] = rf
cfg.mu.Unlock()
這一段是調用ClientEnd函數創建服務器節點,然後調用raft中的Make函數(lab2中需要完成的函數,經過上面的分析可以看出這個函數運行在main主線程裏)初始化該節點的rf結構體,然後把該節點的rf結構體加入到網絡配置信息中。cfg.rafts管理每一個節點的raft結構體信息
ClientEnd函數原型如下:
func (rn *Network) MakeEnd(endname interface{}) *ClientEnd {
rn.mu.Lock()
defer rn.mu.Unlock()
if _, ok := rn.ends[endname]; ok {
log.Fatalf("MakeEnd: %v already exists\n", endname)
}
e := &ClientEnd{}
e.endname = endname
e.ch = rn.endCh
rn.ends[endname] = e
rn.enabled[endname] = false
rn.connections[endname] = nil
return e
}
這段代碼中有個關鍵語句,
e.ch=rn.endch
這個語句說明,每個節點的管道實際上都指向了Network所創建的管道。而由之前的分析,這個管道實際上被線程thread1阻塞監聽處理
2.
applyCh := make(chan ApplyMsg)
go func()
這一段代碼所創建的管道的用處還不清楚,假設該處創建的線程爲thread2
3.
svc := labrpc.MakeService(rf)
srv := labrpc.MakeServer()
srv.AddService(svc)
cfg.net.AddServer(i, srv)
這段代碼尚未清楚
3. rpc調用的實現
rpc調用的實現在labrpc裏
函數爲:
func (e *ClientEnd) Call(svcMeth string, args interface{}, reply interface{}) bool {
req := reqMsg{}
req.endname = e.endname
req.svcMeth = svcMeth
req.argsType = reflect.TypeOf(args)
req.replyCh = make(chan replyMsg)
qb := new(bytes.Buffer)
qe := gob.NewEncoder(qb)
qe.Encode(args)
req.args = qb.Bytes()
e.ch <- req
rep := <-req.replyCh
if rep.ok {
rb := bytes.NewBuffer(rep.reply)
rd := gob.NewDecoder(rb)
if err := rd.Decode(reply); err != nil {
log.Fatalf("ClientEnd.Call(): decode reply: %v\n", err)
}
return true
} else {
return false
}
}
這個函數是ClientEnd結構裏的方法
svcMeth string是rpc調用的方法名
args和reply分別是輸入和結果參數
該函數首先封裝req結構體
然後用序列化框架序列化req.
然後通過channel將req傳入e.ch管道。這裏是最關鍵的。正如同之前討論的每個節點的管道實際上都指向了Network所創建的管道,所以e.ch實際上是Network結構體裏的rn.endch管道,這個管道由線程thead1監聽處理.這是在labrpc.MakeNetwork()函數中已經處理好的.
thead1線程代碼如下:
go func() {
for xreq := range rn.endCh {
go rn.ProcessReq(xreq)
}
}()
所以rpc的核心就是rn.ProcessReq函數
這個函數就不分析了
依我的理解,labrpc中並沒有爲每個節點創建線程,實現真正意義上線程之間的通信。而是將網絡拓撲組織成數據結構的形式存儲在主線程中(即config結構體),然後通過線程thread1以及channel來實現模擬意義上的rpc。
除此之外,框架代碼還實現如下功能:
對於raft.go中所創建的rpc調用函數Raft.AppendEntries,框架代碼實現了實驗實現者並不需要將func註冊到rpc框架裏,直接調用
ok:=rf.peers[server].Call("Raft.AppendEntries",args,reply)
就可以遠程調用該函數。
但問題在於,這個函數是實驗完成者在框架代碼裏添加的函數,並不是實驗框架設計者預先定義的函數。那麼該函數怎麼被實驗框架中的labrpc識別呢?
這是因爲go語言支持反射機制,go的反射機制庫爲reflect。反射機制允許程序根據傳入的字符串”Raft.AppendEntries”,動態調用Raft.AppendEntries方法。
多阻塞輸入驅動的狀態機模型設計
假設
1. 阻塞輸入爲:listen1,listen2,listen3..
2. 狀態state爲:s1,s2,s3,s4…
3. do(state)指只要狀態機處於state狀態就必須要週期執行的動作
4. do(listen,state)指狀態機處於state狀態,在接收到listen的輸入後所要執行的動作
5. switch語句指選擇語句
6. select語句指select系統調用語句(在c語言中),在go語言中指select關鍵字
7. thread指線程
8. change(state)語句指將state狀態變換到change(state)狀態
9. cancel(do(state))語句指取消state的動作do(state)所在的線程
10. go (do(change(state)))語句指用線程方式運行動作do(change(state))
則設計模式有以下幾種模式:
1. 集中外包式
for{
switch{
case state==s1:
select{
case listen1:
get(input)
do(listen1,state)
change(state)
case listen2:
get(input)
do(listen2,state)
change(state)
case listen3:
get(input)
do(listen3,state)
change(state)
default:
do(state)
}
case state==s2:
select{
case listen1:
get(input)
do(listen1,state)
change(state)
case listen2:
get(input)
do(listen2,state)
change(state)
case listen3:
get(input)
do(listen3,state)
change(state)
default:
do(state)
}
case state==s3:
select{
case listen1:
get(input)
do(listen1,state)
change(state)
case listen2:
get(input)
do(listen2,state)
change(state)
case listen3:
get(input)
do(listen3,state)
change(state)
default:
do(state)
}
}
}
- 集中內包式
for{
select{
case listen1:
get(input)
switch{
case state==s1:
do(listen1,state)
change(state)
cancel(do(state))
go do(change(state))
case state==s2:
do(listen1,state)
change(state)
cancel(do(state))
go do(change(state))
case state==s3:
do(listen1,state)
change(state)
cancel(do(state))
go do(change(state))
}
case listen2:
get(input)
switch{
case state==s1:
do(listen2,state)
change(state)
cancel(do(state))
go do(change(state))
case state==s2:
do(listen2,state)
change(state)
cancel(do(state))
go do(change(state))
case state==s3:
do(listen2,state)
change(state)
cancel(do(state))
go do(change(state))
}
case listen3:
get(input)
switch{
case state==s1:
do(listen3,state)
change(state)
cancel(do(state))
go do(change(state))
case state==s2:
do(listen3,state)
change(state)
cancel(do(state))
go do(change(state))
case state==s3:
do(listen3,state)
change(state)
cancel(do(state))
go do(change(state))
}
}
}
3.分佈內包式
thread1:
func listen1(){
get(input)
switch{
case state==s1:
do(listen1,state)
change(state)
cancel(do(state))
go do(change(state))
case state==s2:
do(listen1,state)
change(state)
cancel(do(state))
go do(change(state))
case state==s3:
do(listen1,state)
change(state)
cancel(do(state))
go do(change(state))
}
}
thread2:
func listen2(){
switch{
case state==s1:
do(listen2,state)
change(state)
cancel(do(state))
go do(change(state))
case state==s2:
do(listen2,state)
change(state)
cancel(do(state))
go do(change(state))
case state==s3:
do(listen2,state)
change(state)
cancel(do(state))
go do(change(state))
}
}
thread3:
func listen3(){
switch{
case state==s1:
do(listen3,state)
change(state)
cancel(do(state))
go do(change(state))
case state==s2:
do(listen3,state)
change(state)
cancel(do(state))
go do(change(state))
case state==s3:
do(listen3,state)
change(state)
cancel(do(state))
go do(change(state))
}
}
這三種設計各有優劣
一般來講的話,狀態機在state狀態下收到輸入listen後變換成狀態state2中,需要執行收到輸入listen後的動作do(listen,state2).除此之外,在狀態state2中有可能週期性執行某種動作do(state2).這種情況是很常見的.
舉例說:
集中外包式的方法有一種解決do(state2)的天然優勢,比較上面的代碼就可以清楚的看到.但是也可以看的出來
而以集中外包式和集中內包式的方法運行,會發現只要有一個線程就可以解決多阻塞監聽的問題。這是因爲運用了select套接字
而分佈內包式的設計,有多少個阻塞監聽就有多少個線程。從這個角度來看,確實比上面兩種設計方式複雜
除此之外
在解決某一狀態週期性動作時,集中外包式仍然只需要在一個線程裏就可以解決.而集中內包式以及分佈內包式需要反覆創建線程以及取消線程來實現
但是集中外包式也有自己的問題.
有些阻塞監聽是天然的多線程.
即每個阻塞監聽只能以單個線程的方式接收,不能使用select套接字.此時分佈內包式的設計更自然一些。
但是這種情況並非不能通過集中外包式設計.用main()線程實現集中外包式狀態機。listen1,listen2,listen3三個阻塞監聽線程通過三個管道chan1,chan2,chan3與main線程通信。而main線程監聽這三個管道chan1,chan2,chan3.如果listen1監聽到數據,則向管道chan1中寫數據。這樣通過增加一層管道通信就可以實現這種要求.
但是這種設計也有相關問題。比如,listen1監聽到數據value1,而狀態機的狀態改變需要用到value1,那麼value1如何從listen1線程傳入main線程需要額外的設計
下面給出設計代碼
thread1 listen1(chan1 chan int){
get(input)
chan1<-1
}
thread2 listen2(chan2 chan int){
get(input)
chan2<-1
}
thread3 listen3(chan3 chan int){
get(input)
chan2<-1
}
thread_main main(){
var chan1 chan int//管道1
var chan2 chan int//管道2
var chan3 chan int//管道3
go listen1(chan1)//創建listen1線程
go listen2(chan2)//創建listen2線程
go listen3(chan3)//創建listen3線程
//實現一個集中外包式的狀態機
for{
switch{
case state==s1:
select{
case listen1:
get(input)
do(listen1,state)
change(state)
case listen2:
get(input)
do(listen2,state)
change(state)
case listen3:
get(input)
do(listen3,state)
change(state)
default:
do(state)
}
case state==s2:
select{
case listen1:
get(input)
do(listen1,state)
change(state)
case listen2:
get(input)
do(listen2,state)
change(state)
case listen3:
get(input)
do(listen3,state)
change(state)
default:
do(state)
}
case state==s3:
select{
case listen1:
get(input)
do(listen1,state)
change(state)
case listen2:
get(input)
do(listen2,state)
change(state)
case listen3:
get(input)
do(listen3,state)
change(state)
default:
do(state)
}
}
}
raft算法描述
在任何時刻,每一個服務器節點都處於這三個狀態之一:領導人,跟隨者或者候選人
下面是關於raft算法中幾個最重要的地方,分別加以說明
原文:
任期在Raft算法中充當邏輯時鐘的作用,這會允許服務器節點查明一些過期的信息比如陳舊的領導者。當服務器之間通信的時候會交換當前任期號;如果一個服務器的當前任期號比其他人小,那麼他會更新自己的編號到較大的編號值。如果一個候選人或者領導者發現自己的任期號過期了,那麼他會立即恢復成跟隨者狀態。如果一個節點接收到一個包含過期的任期號的請求,那麼他會直接拒絕這個請求。
說明:
任期號的比較,在狀態機進行狀態轉換時非常重要。尤其是在解決陳舊的領導重新連上後的問題上。原文:
Raft算法中服務器節點之間通信使用遠程過程調用(RPCs),並且基本的一致性算法只需要兩種類型的RPCs。請求投票(RequestVote) RPCs由候選人在選舉期間發起,然後附加條目(AppendEntries)RPCs由領導人發起,用來複制日誌和提供一種心跳機制
說明:
實驗中只需要實現兩種RPC調用.所以實際上每個服務器只有四種驅動狀態機的輸入情況(還有一個選舉計時器,但是這是節點內部的輸入驅動)- 接收到其它服務器發出的RequestVote的輸入
- 自己發出RequestVote後接收到的返回結果
- 接收到其它服務器發出的AppendEntries的輸入
- 自己發出AppendEntries後接收到的返回結果
原文:
跟隨者:如果在超過選舉超時時間的情況之前都沒有收到領導人的心跳,或者是候選人請求投票的,就自己變成候選人.
說明:
有兩種輸入情況都會造成選舉計時器的重置- 收到心跳包
- 收到候選人請求投票
所以,如果一個follower收到了請求投票後,會重置選舉計時器。
原文:
每一個服務器最多會對一個任期號投出一張選票
說明:
一個任期內的所有的選票的數量是一定的-
原文:
一旦候選人贏得選舉,他就立即成爲領導人。然後他會向其他的服務器發送心跳消息來建立自己的權威並且阻止新的領導人的產生
說明:
闡釋了leader狀態應該做的動作 -
原文:
在轉變成候選人後就立即開始選舉過程
- 自增當前的任期號
- 給自己投票
- 重置選舉超時計時器
- 發送請求投票的RPC給其他所有服務器
如果接收到大多數服務器的選票,那麼就變成領導人
如果接收到來自新的領導人的附加日誌RPC,轉變成跟隨者
如果選舉過程超時,再次發起一輪選舉
說明:
在候選人狀態受到以上三種輸入的驅動,需要處理這三種情況
-
原文:
跟隨者:如果在超過選舉超時時間的情況之前都沒有收到領導人的心跳,或者是候選人請求投票的,就自己變成候選人.
說明:
給出了跟隨者變成候選人的條件
下面給出狀態機的僞代碼描述.狀態機作爲一個線程在Make函數裏創建
repeat:
如果當前狀態是follower狀態:
如果選舉計時器超時:
變成candidate
自增當前的任期號
給自己投票
重置選舉超時計時器
發送請求投票的RPC給其他所有服務器
如果當前狀態是candidate狀態:
select{//select關鍵字
case 接收到大多數服務器的選票:
變成leader
重置votedFor
重置選舉計時器
發送心跳包
case 接收到來自新的領導人的附加日誌RPC:
變成follower
重置votedFor
重置選舉計時器
case 選舉過程超時:
//再一次選舉
變成candidate
自增當前的任期號
給自己投票
重置選舉超時計時器
發送請求投票的RPC給其他所有服務器
}
如果當前狀態是leader狀態:
發送心跳包
睡眠50ms
下面給出四種輸入驅動的僞代碼描述:
- 接收到其它服務器發出的RequestVote的輸入
func RequestVote:
如果(發送方的term<自己的term):
則不投票給它
return
如果(發送方的term>自己的term):
重置votedFor
重置選舉計時器
如果(發送方的term>=自己的term 並且(自己的votedFor爲空 或者 votedFor==發送方的身份編號)):
就投票給他
否則:
不投票給他
- 自己發出RequestVote後接收到的返回結果
func send_RequestVote:
向其他服務器發送requestvote
收到其它服務器的返回值value
如果其它服務器給自己投票了:
則自己的票數+1
如果自己的票數大於一半:
則向狀態機通過管道發送消息說"接收到大多數服務器的選票"
- 接收到其它服務器發出的AppendEntries的輸入
func AppendEntries:
如果(發送方的term<自己的term):
說明這是舊領導發送給自己的心跳包
什麼也不處理,返回自己的term給舊領導,讓舊領導知道自己的term過期了
return
否則:
如果自己現在的身份是candidate:
說明新領導產生了,向狀態機通過管道發送消息"接收到來自新的領導人的附加日誌RPC"
如果自己是follower:
正常處理領導發來的日誌
重置選舉計時器
- 自己發出AppendEntries後接收到的返回結果
func send_AppendEntries:
發送日誌
收到其它服務器的返回值
如果對方返回的任期>自己的任期:
說明自己是舊leader
更新自己的任期
將自己變成follower
否則:
正常處理follower的返回值
多線程編程所產生的問題
1.多阻塞輸入驅動的狀態變化模型
2.多線程通信
一個管道,多線程寫,一線程讀
產生的
1. 寫阻塞問題
即另一個線程向管道里寫數據,讀線程沒有取出數據,所造成的另一個寫線程無法往管道里寫
2. 寫超時問題
如果寫超時了,則寫超時的線程如何停止寫.如果沒處理這種情況,1.線程會無限阻塞,2.有的寫線程有需求,即如果寫超時了,則該數據data要取消向管道里寫。如果不處理寫超時,在超時後,如果管道里的數據被取出了,則data等到了向管道里寫的機會。但是data是超時的數據,不應該往裏寫,但是被讀線程讀出來了.
一個管道,一線程寫,多線程讀
產生的讀競爭問題
一個管道,多線程寫,多線程讀