今天老師要給大家介紹一個比較特別的 RPC 伺服器模型,這個模型不同於 Nginx、不同於 Redis、不同於 Apache、不同於 Tornado、不同於 Netty,它的原型是 Node Cluster 的多程式併發模型。這種併發模型 Java 同學看完後是很憂傷的,因為他們永遠享受不了。
Nginx 併發模型
我們知道 Nginx 的併發模型是一個多程式併發模型,它的 Master 程式在繫結監聽地址埠後 fork 出了多個 Slave 程式共同競爭處理這個服務端套接字接收到的很多客戶端連線。
這多個 Slave 程式會共享同一個處於作業系統核心態的套接字佇列,作業系統的網路模組在處理完三次握手後就會將套接字塞進這個佇列。這是一個生產者消費者模型,生產者是作業系統的網路模組,消費者是多個 Slave 程式,佇列中的物件是客戶端套接字。
這種模型在負載均衡上有一個缺點,那就是套接字分配不均勻,形成了類似於貧富分化的局面,也就是「閒者愈閒,忙者愈忙」的狀態。這是因為當多個程式競爭同一個套接字佇列時,作業系統採用了 LIFO 的策略,最後一個來 accept 的程式最優先拿到 套接字。越是繁忙的程式越是有更多的機會呼叫 accept,它能拿到的套接字也就越多。
Node Cluster 併發模型
Node Cluster 為了解決負載均衡問題,它採用了不同的策略。它也是多程式併發模型,Master 程式會 fork 出多個子程式來處理客戶端套接字。但是不存在競爭問題,因為負責 accept 套接字的只能是 Master 程式,Slave 程式只負責處理客戶端套接字請求。那就存在一個問題,Master 程式拿到的客戶端套接字如何傳遞給 Slave 程式。
這時,神奇的 sendmsg 登場了。它是作業系統提供的系統呼叫,可以在不同的程式之間傳遞檔案描述符。sendmsg 會搭乘一個特殊的「管道」將 Master 程式的套接字描述符傳遞到 Slave 程式,Slave 程式通過 recvmsg 系統呼叫從這個「管道」中將描述符取出來。這個「管道」比較特殊,它是 Unix 域套接字。普通的套接字可以跨機器傳輸訊息,Unix 域套接字只能在同一個機器的不同程式之間傳遞訊息。同管道一樣,Unix 域套接字也分為有名套接字和無名套接字,有名套接字會在檔案系統指定一個路徑名,無關程式之間都可以通過這個路徑來訪問 Unix 域套接字。而無名套接字一般用於父子程式之間,父程式會通過 socketpair 呼叫來建立套接字,然後 fork 出來子程式,這樣子程式也會同時持有這個套接字的引用。後續父子程式就可以通過這個套接字互相通訊。
注意這裡的傳遞描述符,本質上不是傳遞,而是複製。父程式的描述符並不會在 sendmsg 自動關閉自動消失,子程式收到的描述符和父程式的描述符也不是同一個整數值。但是父子程式的描述符都會指向同一個核心套接字物件。
有了描述符的傳遞能力,父程式就可以將 accept 到的客戶端套接字輪流傳遞給多個 Slave 程式,負載均衡的目標就可以順利實現了。
接下來我們就是用 Python 程式碼來擼一遍 Node Cluster 的併發模型。因為 sendmsg 和 recvmsg 方法到了 Python3.5 才內建進來,所以下面的程式碼需要使用 Python3.5+才可以執行。
我們看 sendmsg 方法的定義
socket.sendmsg(buffers[, ancdata[, flags[, address]]])
複製程式碼
我們只需要關心第二個引數 ancdata,描述符是通過ancdata 引數傳遞的,它的意思是 「輔助資料」,而 buffers 表示需要傳遞的訊息內容,因為訊息內容這裡沒有意義,所以這個欄位可以任意填寫,但是必須要有內容,如果沒有內容,sendmsg 方法就是一個空呼叫。
import socket, struct
def send_fds(sock, fd):
return sock.sendmsg([b'x'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, struct.pack("i", fd))])
# ancdata 引數是一個三元組的列表,三元組的第一個參數列示網路協議棧級別 level,第二個參數列示輔助資料的型別 type,第三個引數才是攜帶的資料,level=SOL_SOCKET 表示傳遞的資料處於 TCP 協議層級,type=SCM_RIGHTS 就表示攜帶的資料是檔案描述符。我們傳遞的描述符 fd 是一個整數,需要使用 struct 包將它序列化成二進位制。
複製程式碼
再看 recvmsg 方法的定義
msg, ancdata, flags, addr = socket.recvmsg(bufsize[, ancbufsize[, flags]])
複製程式碼
同樣,我們只需要關心返回的 ancdata 資料,它裡面包含了我們需要的檔案描述符。但是需要提供訊息體的長度和輔助資料的長度引數。輔助資料的長度比較特殊,需要使用 CMSG_LEN 方法來計算,因為輔助資料裡面還有我們看不到的額外的頭部資訊。
bufsize = 1 # 訊息內容的長度
ancbufsize = socket.CMSG_LEN(struct.calcsize('i')) # 輔助資料的長度
msg, ancdata, flags, addr = socket.recvmsg(bufsize, ancbufsize) # 收取訊息
level, type, fd_bytes = ancdata[0] # 取第一個元祖,注意傳送訊息時我們傳遞的是一個三元組的列表
fd = struct.unpack('i', fd_bytes) # 反序列化
複製程式碼
程式碼實現
下面我來獻上完整的伺服器程式碼,為了簡單起見,我們在 Slave 程式中處理 RPC 請求使用同步模型。
# coding: utf
# sendmsg recvmsg python3.5+才可以支援
import os
import json
import struct
import socket
def handle_conn(conn, addr, handlers):
print(addr, "comes")
while True:
# 簡單起見,這裡就沒有使用迴圈讀取了
length_prefix = conn.recv(4)
if not length_prefix:
print(addr, "bye")
conn.close()
break # 關閉連線,繼續處理下一個連線
length, = struct.unpack("I", length_prefix)
body = conn.recv(length)
request = json.loads(body)
in_ = request['in']
params = request['params']
print(in_, params)
handler = handlers[in_]
handler(conn, params)
def loop_slave(pr, handlers):
while True:
bufsize = 1
ancsize = socket.CMSG_LEN(struct.calcsize('i'))
msg, ancdata, flags, addr = pr.recvmsg(bufsize, ancsize)
cmsg_level, cmsg_type, cmsg_data = ancdata[0]
fd = struct.unpack('i', cmsg_data)[0]
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, fileno=fd)
handle_conn(sock, sock.getpeername(), handlers)
def ping(conn, params):
send_result(conn, "pong", params)
def send_result(conn, out, result):
response = json.dumps({"out": out, "result": result}).encode('utf-8')
length_prefix = struct.pack("I", len(response))
conn.sendall(length_prefix)
conn.sendall(response)
def loop_master(serv_sock, pws):
idx = 0
while True:
sock, addr = serv_sock.accept()
pw = pws[idx % len(pws)]
# 訊息資料,whatever
msg = [b'x']
# 輔助資料,攜帶描述符
ancdata = [(
socket.SOL_SOCKET,
socket.SCM_RIGHTS,
struct.pack('i', sock.fileno()))]
pw.sendmsg(msg, ancdata)
sock.close() # 關閉引用
idx += 1
def prefork(serv_sock, n):
pws = []
for i in range(n):
# 開闢父子程式通訊「管道」
pr, pw = socket.socketpair()
pid = os.fork()
if pid < 0: # fork error
return pws
if pid > 0:
# 父程式
pr.close() # 父程式不用讀
pws.append(pw)
continue
if pid == 0:
# 子程式
serv_sock.close() # 關閉引用
pw.close() # 子程式不用寫
return pr
return pws
if __name__ == '__main__':
serv_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serv_sock.bind(("localhost", 8080))
serv_sock.listen(1)
pws_or_pr = prefork(serv_sock, 10)
if hasattr(pws_or_pr, '__len__'):
if pws_or_pr:
loop_master(serv_sock, pws_or_pr)
else:
# fork 全部失敗,沒有子程式,Game Over
serv_sock.close()
else:
handlers = {
"ping": ping
}
loop_slave(pws_or_pr, handlers)
複製程式碼
父程式使用 fork 呼叫建立了多個子程式,然後又使用 socketpair 呼叫為每一個子程式都建立一個無名套接字用來傳遞描述符。父程式使用 roundrobin 策略平均分配接收到的客戶端套接字。子程式接收到的是一個描述符整數,需要將描述符包裝成套接字物件後方可讀寫。列印對比傳送和接收到的描述符,你會發現它們倆的值並不相同,這是因為 sendmsg 將描述符傳送到核心後,核心給描述符指向的核心套接字又重新分配了一個新的描述符物件。
思考題
- sendmsg/recvmsg 除了可以傳送描述符外還可以用來幹什麼?
- sendmsg/recvmsg 傳送接收描述符在核心態具體是如何工作的?
本文節選之線上技術小冊《深入理解 RPC》,感興趣的讀者請繼續閱讀《深入理解 RPC》全書內容。
閱讀更多深度技術文章,微信掃一掃上面的二維碼或者搜尋關注公眾號「碼洞」或 「codehole」