IM伺服器要實作的最基本功能就是消息的轉發。——好像是一句廢話!
這就意味着IM伺服器要為每個登入使用者建立一個與該使用者資訊相關的記憶體上下文,為友善描述我們在這裡稱之為:user_context。user_context中一般包含這些基本資訊:使用者id、昵稱、peer端的ip和端口,以及最重要的用于通信的socket。
使用者連接配接上線時,需要malloc一個user_context塊,用于存儲上述資訊,使用者斷開連接配接時,需要free這個user_context塊。
IM伺服器要随時維護這張user_context清單,這張表我們在這裡稱之為:list_user_context。這張表非常重要,im伺服器要根據這張表進行消息的轉發。如果100個使用者登入,list_user_context表中就有100個元素,10萬個使用者就有10萬個元素,使用者間聊天時,IM伺服器就需要反複查詢list_user_context,進而确定轉發的消息要發送到哪個使用者的機器上。
舉個例子:使用者A發消息給使用者B,基本流程如下:
1、A将消息發送給IM伺服器;
2、IM伺服器解析消息,擷取該消息的接收人為B;
3、IM伺服器查詢list_user_context表,找到B的user_context(裡面有B的連接配接通道socket);
4、IM伺服器将A的消息轉發給B;
正常流程都沒有問題,我們說下特殊的情況(注意不是異常情況):
【特殊情況一】
A在發送消息給B時,B突然退出用戶端程式。
此時IM伺服器接收到2個來自IO層的事件:
事件1:A發給B的聊天資料
事件2:B的掉線通知
這兩個事件會觸發IM伺服器進行如下兩個操作,
操作1:查詢list_user_context表,找到B的user_context(一個指向該記憶體的指針),并準備轉發A的消息。
操作2:查詢list_user_context表,找到B的user_context,從表中移除并準備釋放指向該記憶體的上下文。
這兩個操作可能是在不同的線程中執行,實際上在IOCP這種完全異步的模型下,這種可能的幾率非常大。這時候B的user_context所在的記憶體區就是“臨界區”,操作不當就會導緻通路“野指針”,進而導緻整個IM伺服器挂掉。當然你可以給list_user_context表加把鎖,加鎖可以減少通路野指針的幾率但還是無法完全避免這種情況的發生。
如果IM伺服器先執行釋放操作,也就是“操作2”,則是安全的,當“操作1”執行時,由于查找不到B的user_context,就會認為B已離線,并放棄發送操作。但如果“操作1”先執行,IM伺服器首先獲得了指向B的user_context指針,剛準備發送資料時,CPU的時間片切換到了“操作2”上,并把B的user_context釋放,之後,CPU時間片又切換到“操作1”上,此時im server會通路之前 查到的B的user_context記憶體區,這時通路異常,伺服器程式崩潰。這種幾率看似很小,但在高并發且聊天繁忙時,還是會發生。注意這種情況不是異常情況,而是在真實的業務場景中會實實在在并且經常發生的情況。
當然,你可以将鎖的範圍擴大,也就是從“臨界區”資料通路擴大到操作層面上,也就是将整個發送操作和釋放操作進行加鎖,進而確定CPU在時間片切換時仍能保證讀、寫、删除等操作的原子性。這種方式雖然安全了,但顯然會讓你的伺服器從底層IOCP的完全異步,退化為一個業務層面上的完全同步。
如果1萬人同時聊天的話,其結果将是災難性的。如果是群聊的話,就會更加複雜,如果A所在的群有100人,這就意味着IM伺服器要将消息轉發給群中的其他99人。這99人可能會在此時發生各種情況,比如某些人突然退出或者突然退群。
【特殊情況二】
先說一下IM伺服器和WEB伺服器在設計上的最大不同。了解這一點,就能體會到IM伺服器設計上的複雜性。 WEB伺服器,也就是基于HTTP協定的伺服器,其業務可以抽象為:請求應答式服務, 即客戶發送請求,伺服器響應請求,一問一答。即使是用POST指令上傳檔案也是基于請求應答式,隻不過發送請求的資料特别長而已。伺服器在沒有收到請求時,不會主動發送資料給用戶端,這點非常重要,也就是說同一時間要麼隻有一個讀操作,要麼隻有一個寫操作。
“請求應答式”業務,如果放在IO層看就是讀寫同步,伺服器從IO中讀完請求後開始向IO中寫響應。實際上大部分應用協定都是基于請求應答式,比如:Telnet、FTP、POP3、SMTP。。。,這種方式在業務層面處理起來比較簡單。
另一種業務模式,就是:“非請求應答式”,比如IM,讀寫之間沒有聯系,讀寫操作可能同時存在。
A在給B發送消息的時候,可能會同時收到B發來的消息,甚至還有其它人的消息,這時A要時刻保持“讀”監聽狀态,同時也會進行“寫”操作。對于IM伺服器來說,既要保持對A的“讀”監聽(用于接收A發來的消息),也可能要對A進行“寫”操作 (轉發其他人發給A的消息)。
假設A和100人同時進行聊天,就意味着IM伺服器可能要不停的對A的IO進行“寫”操作。即使A不發送消息,IM伺服器也要保持對A的“讀”監聽。如果此時,A退出聊天用戶端程式,而此時尚有98條消息正在準備發送A。那些存在于記憶體中的98條消息,該如何釋放?
伺服器捕獲到A離線,開始準備釋放A的user_context,此時伺服器正在向A轉發群聊中來自不同使用者的消息給A(上述98條消息),這時一旦處理不當就會導緻記憶體通路異常,進而造成伺服器崩潰。
當然這種情況下,你也可以通過加鎖來解決,但遇到的問題和上述 【特殊情況一】 一樣,你可能要鎖的不是一個資料臨界區,而是一個完整的操作,進而確定操作的原子性,避免記憶體通路異常。但過多的加鎖會導緻IM伺服器性能大打折扣。
如果是IM伺服器叢集則會更加複雜,不同的使用者會登入在不同的IM伺服器上,A可能在伺服器1上,B可能在伺服器2上。A給B轉發消息時,可能B已從伺服器2上離線。如果支援群聊的話,就更加複雜了。