前兩天在小破站看到zinx框架的教程,於是跟着學了學,實現完了換了個名叫 Kinx hhhhh~。有意願的可以star一波~ https://github.com/k-si/Kinx
附一張整體架構圖:
設計思想
tcp通信在代碼層面上是非常簡單的,因為幾乎所有的語言都提供了套接字,套接字就是對底層操作系統通信細節的封裝,只需要調用封裝好的api,就可以完成復雜的通信操作。tcp通信典型的有server-client模式,對於TCP服務器來說扮演的肯定是server的角色。它通過監聽端口,循環阻塞獲取連接句柄,然后通過句柄進行發收消息。客戶端只需要拿到服務端的ip和端口,使用tcp協議連接上服務器,開始發送接受消息即可。
但是對於服務器來說,是需要提供一定的並發量的,不能一個server只給一個client使用吧。所以肯定是多線程的給每個client提供服務。ok,到這我們就能確定,每一個client的連接,都需要一個goroutine/thread進行處理。連接成功之后,開始處理業務,一般業務划分為讀/寫兩種,那么對於每一個連接的處理,讀和寫的處理必須保證是同時的,不能寫完一個再讀一個,應該是讀和寫互不影響的。由此我們可以確定,對於每一個連接,我們需要分別再開兩個線程來處理讀寫業務。
控制並發量
我們再考慮一個問題,服務端提供的服務一定是有限的,不可能有一億個客戶端來連接,服務端就必須開辟一億個goroutine來進行處理吧,服務的機器性能不允許,即使能開一億個線程,線程之間的切換會消耗大量的線程,反而得不償失。
有什么解決辦法呢?你可能會說,我們可以限制client連接數量,一旦達到某個值,就拒絕連接。這當然可以,但是就是有點浪費資源。為什么?每個連接都要開一個goroutine,有的連接可能業務非常簡單,這樣它的3個goroutine空閑的時間將非常多,我們希望的是充分利用每一個goroutine,讓他們不能停歇(心疼goroutine一秒)。為實現這個目的,我們使用線程池+任務隊列來限制過多的goroutine。
假設有100個client連接,那么會啟動100個goroutine處理連接,然后每個連接啟動2個goroutine處理讀寫,這一共就是300個goroutine。這其中最耗時的操作是什么呢?應該是處理的讀業務,一般寫業務是比較簡單的,麻煩的是讀取一個數據之后進行的邏輯業務,寫業務只是把結果寫回就可以了。所以關鍵就在這100個讀業務goroutine上。
我們可以開辟一個線程池,在啟動server的時候就初始化線程池,存放10個線程,在處理讀業務的時候,將任務push給一個線程,並通過某種均衡算法使得這10個線程處理的任務量是均衡的。那么我們就必須為每一個線程綁定一個任務隊列,任務隊列在golang中直接使用channel實現就好,非常方便~
那么現在再有100個client連接后,每個連接的業務任務都會分配到這10個線程中,每個線程都是100%進行工作的,當然也可能不是100%,這住要取決於均衡算法的實現。但是整體來說資源的利用率肯定是大大提升了,整體的處理速度也不一定會差很多。這種實現方式,很像GMP模型中的調度器,每一個p就是一個任務,由調度器決定p分給哪個內核線程。可見處理問題的思路是相同的~
todo:消息均衡算法的優化
TCP粘包
涉及tcp通信不可避免的就是tcp黏包問題,在代碼中讀取數據實際上是需要一個byte數組一個byte數組這樣讀的,並不能是像水流一樣源源不斷的讀取,肯定是有截斷的。那么萬一傳輸的數據比較長,中間被截斷了,怎么給聯系起來呢?這里就需要我們自定義一種協議,有一種簡單的TLV協議,即每次發送的消息應該包含消息類型、消息內容、消息長度這三個變量。並且server和client都要遵守這個協議。這樣在讀取的時候,就可以判斷數據的長度,然后再從連接中讀取正確長度的數據,並且數據類型還能方面的指示數據的業務類型,方便消息的分類。
心跳檢測
心跳檢測是tcp服務必備的手段,用來檢測客戶端是否假死,及時清理無用連接。tcp雖然是可靠的,但是也只是相對可靠,仍然有很多不可抗力因素導致網絡斷開。如果網絡斷開,客戶端服務端就都不知道對方是否活着。那么就有必要一直通過收發消息進行確認存活。
一般客戶端是心跳包的發起者,服務端持續進行心跳檢測,檢查是否客戶端發來了心跳包,如果長時間沒有發來,就認為該客戶端死亡,需要將它的句柄fd關閉。這里實現的較為簡單,服務端起一個goroutine,每5s循環檢測內存中維護的連接表,並且每次都給連接的fresh字段+1。同時服務端的讀業務可以識別心跳包,檢測到該包是心跳包,將該連接的fresh置為0。當一個連接的fresh達到5就清除該連接。客戶端發送只有header的包表示是心跳包。
todo:當存在大量連接,循環遍歷時間可能過長,需要優化算法減少檢測時間。
讀寫業務交互和業務函數注冊
每個連接的讀寫業務是需要溝通的,讀業務讀取數據,並處理,最終結果需要輸出給寫業務,這就用到了channel,這非常的合乎時宜,channel就是用來做不同goroutine之間通信的。處理完的數據直接塞到channel里,然后寫業務輪詢讀取就好了。
對於框架來說,是給人使用的,它本身並不能實現具體的業務邏輯。框架一般都會提供一個接口,開發者使用時可以專注於業務的處理,而不是鑽這些並發、消息處理上的牛角尖。所以Kinx是需要提供這樣一個函數或者接口,開發者在其中實現具體業務邏輯,然后服務運行時就會將函數嵌入到框架中,自然就處理了業務。這個概念對應框架中的router模塊,該模塊提供了prehandle,handle,posthandle三個函數來對應處理業務前、處理業務時、處理業務后三個階段。模塊的具體實現也挺有意思的,router結構體帶有這三個函數,框架獲取連接后調用router的三個函數.......這里不太好用文字描述,直接去看代碼吧~
另外就是一些模塊的抽象,怎么去抽象、然后將抽象實現。這就對應面向對象設計中的結構體/類,和對應的方法了。
開發過程中的坑
1、使用defer conn.close() 導致used closed connection
2、connectionManager 的每個操作都加鎖導致在 清除connection時死鎖
解決方法:(todo)