同)
很多人在聊 Redis 高性能時總是把功勞歸結為“內存操作快”。內存快只是其中一半的答案——另一半在于 Redis 在事件驅動這塊如何調度“什么時候讀、什么時候寫、什么時候去做后臺任務”。這套調度機制就是由 ae 層實現(xiàn)的核心框架。這篇主要講一件事Redis 事件驅動框架到底是怎么把網絡事件和定時任務統(tǒng)一在一個主循環(huán)里轉起來的。我會從阻塞 IO 的困境出發(fā)順著源碼把 aeEventLoop、文件事件、時間事件、beforesleep 這些關鍵設計一個個拆開最后再聊聊這個框架對日常工程設計的啟發(fā)。適合讀過一些 Redis 源碼但沒理清主循環(huán)脈絡的讀者也適合想在自己的服務里借鑒事件驅動思路的開發(fā)者。1. 單線程高并發(fā)的秘密為什么Redis需要事件驅動1.1 阻塞IO的困境一個連接拖垮一切先回到最原始的網絡服務模型。假設我們用傳統(tǒng)阻塞式 socket 寫一個服務器accept()等待客戶端連接連接建立后read()讀取請求。問題就出在這個read()上——如果客戶端連上后遲遲不發(fā)數(shù)據(jù)那么read()會一直卡在那里后續(xù)所有連接都要等著。這就像只有一個窗口的銀行柜臺第一位客戶站在窗口前不辦業(yè)務也不走后面排隊的只能干瞪眼。很多人第一反應是那用多線程啊一個連接一個線程誰也不影響誰。這當然能解決問題但代價不小。線程多了以后操作系統(tǒng)要頻繁進行上下文切換線程之間共享數(shù)據(jù)還要加鎖鎖競爭一上來性能反而可能比單線程更差。而且 Redis 的命令執(zhí)行大多都在內存里完成耗時通常只有幾十微秒到幾百微秒用多線程來并發(fā)的收益遠遠抵不上鎖和切換帶來的開銷。Redis 選了另一條路讓任何一個連接都不能把主線程卡住。這也就是事件驅動模型的核心思路——把“等待”集中起來把“處理”分發(fā)出去。1.2 非阻塞IO與多路復用讓內核替我們等要讓主線程“不被任何連接卡住”前提是 socket 必須設置為非阻塞模式。非阻塞 socket 下read()沒有數(shù)據(jù)時不會傻等而是立即返回一個錯誤碼。但問題也隨之而來如果每個連接都去輪詢一遍有沒有數(shù)據(jù)十萬個連接就是十萬次系統(tǒng)調用照樣浪費。所以事件驅動還需要第二塊拼圖IO 多路復用。把一堆文件描述符統(tǒng)一交給內核內核幫忙盯著一旦某個 fd 可讀或者可寫就立刻通知程序。程序只需要在事件循環(huán)里問一次內核“哪些 fd 有動靜”然后處理返回的那一小批就緒事件。用生活化的話說這相當于把“挨個詢問每位客戶是否要辦業(yè)務”改成了“前臺在叫號叫到誰誰就上來”。銀行不需要盯著每一位客戶只需要坐等號牌系統(tǒng)廣播。Redis 也只需要等著內核告訴它連接 A 來了新數(shù)據(jù)連接 B 的發(fā)送緩沖區(qū)空了可以繼續(xù)寫。1.3 事件驅動框架的構成文件事件 時間事件Redis 的事件驅動框架在代碼上就是ae.c、ae.h以及各平臺的多路復用實現(xiàn)文件。整個 ae 層抽象出兩類事件文件事件對應網絡連接的讀、寫狀態(tài)變化比如“某個客戶端連接可讀了”“某個 fd 可寫了”。時間事件對應定時或周期性任務比如serverCron這個固定 100ms 跑一次的周期性調度函數(shù)。這兩類事件統(tǒng)一掛在一個aeEventLoop結構上由aeMain()這個主循環(huán)不斷驅動。網絡請求來了主循環(huán)在 epoll 上醒來去處理網絡空閑了主循環(huán)靠著設定的超時時間也能按節(jié)奏醒來執(zhí)行周期任務。Redis 能在一個單線程里同時兼顧高并發(fā)網絡請求和復雜后臺任務靠的就是這個機制。2. 拆開Redis的兩個“事件心臟”文件事件與時間事件2.1 aeEventLoop驅動一切的中樞結構ae.h里定義了整個框架的核心結構體aeEventLoop。第一次讀源碼時我建議先把里面的關鍵字段標記出來typedef struct aeEventLoop { int maxfd; // 當前已注冊的最大文件描述符 int setsize; // 最多可監(jiān)聽的文件事件數(shù) long long timeEventNextId; // 下一個時間事件的ID aeFileEvent *events; // 文件事件表以fd為下標 aeFiredEvent *fired; // 已就緒的文件事件數(shù)組 aeTimeEvent *timeEventHead; // 時間事件鏈表頭 int stop; // 停止循環(huán)的標志 void *apidata; // 底層多路復用專用的數(shù)據(jù)(如epoll實例) aeBeforeSleepProc *beforesleep; // 進入等待前執(zhí)行的回調 aeBeforeSleepProc *aftersleep; // 等待返回后執(zhí)行的回調 } aeEventLoop;可以看到文件事件用數(shù)組存直接用 fd 當下標時間事件用鏈表存因為 Redis 里定時任務數(shù)量很少鏈表足夠。這兩者的數(shù)據(jù)結構選擇直接對應了它們各自的使用頻率和查找方式。2.2 文件事件以fd為下標的回調表aeFileEvent結構體長這樣typedef struct aeFileEvent { int mask; // AE_READABLE 或 AE_WRITABLE或兩者都有 aeFileProc *rfileProc; // 讀事件處理器 aeFileProc *wfileProc; // 寫事件處理器 void *clientData; // 透傳給回調函數(shù)的業(yè)務數(shù)據(jù) } aeFileEvent;mask 是個位標記。比如一個客戶端連接通常同時關心“可讀”和“可寫”mask 就是AE_READABLE | AE_WRITABLE。注冊事件時Redis 把events[fd].rfileProc設置為對應的讀處理器wfileProc設置為寫處理器。以 fd 當數(shù)組下標的好處太明顯了多路復用層返回“第幾個 fd 就緒”之后事件循環(huán)可以直接定位到這個 fd 對應的回調處理器不需要遍歷查找。Redis 之所以在大量連接下依然能保持低延遲這種 O(1) 的定位能力是很關鍵的一塊地基。業(yè)務層代碼里比如networking.c的acceptTcpHandler、readQueryFromClient都是通過注冊文件事件的方式掛到這套框架上的。2.3 時間事件按時間排隊的任務鏈表再看aeTimeEventtypedef struct aeTimeEvent { long long id; // 唯一標識 long long when; // 下一次執(zhí)行的時間(毫秒級絕對時間) long long period; // 執(zhí)行間隔-1表示只執(zhí)行一次 aeTimeProc *proc; // 事件處理器 void *clientData; struct aeTimeEvent *next; // 鏈表指針 } aeTimeEvent;when是基于mstime()計算出的絕對時間戳單位是毫秒。如果period是 -1那么這個時間事件執(zhí)行一次后就會被刪除如果period是正數(shù)那么每次執(zhí)行完框架會把when重新設置為當前時間加上period讓它繼續(xù)排在鏈表尾部等下一輪。Redis 的很多“后臺任務”都以時間事件的形式存在最典型的是serverCron默認每 100 毫秒執(zhí)行一次。另外還有一些臨時任務也會注冊進來比如 RDB 保存完成后的回調、某些延遲執(zhí)行的清理動作??傮w上時間事件的數(shù)量級只有個位數(shù)所以用鏈表完全夠用沒必要引入定時器堆之類的結構。3. 事件循環(huán)怎么跑從注冊事件到epoll_wait3.1 多路復用層的抽離epoll、kqueue與selectRedis 在 ae 層抽象出了統(tǒng)一接口然后在編譯時根據(jù)平臺挑選具體的多路復用實現(xiàn)。文件列表里有ae_epoll.c、ae_kqueue.c、ae_evport.c和ae_select.c。對應關系可以看這張表實現(xiàn)文件底層系統(tǒng)調用適用平臺特點ae_epoll.cepoll_create / epoll_ctl / epoll_waitLinux高并發(fā)下性能最好是生產環(huán)境最常用的實現(xiàn)ae_kqueue.ckqueue / keventmacOS、BSD 系蘋果系統(tǒng)和 BSD 上表現(xiàn)優(yōu)秀ae_evport.cevent portsSolaris 系老牌 Solaris 自帶的事件端口機制ae_select.cselect一切平臺兼容性最強但支持的文件描述符數(shù)量受限所以同樣一個aeMain()主循環(huán)在 Linux 上跑的是 epoll在 macOS 上跑的是 kqueue。上層業(yè)務代碼不關心底層是誰這種替換能力也是事件驅動框架值得借鑒的一個工程點。我們在自己寫服務時也應該把“等待事件”這種變化點隔離起來方便在不同系統(tǒng)上切換實現(xiàn)。3.2 創(chuàng)建事件循環(huán)與注冊文件事件aeCreateEventLoop()負責初始化。它先分配aeEventLoop結構體然后調用aeApiCreate()——在 epoll 實現(xiàn)里這一步就是創(chuàng)建epoll_create()的實例并把 fd 存進apidata指向的aeApiState結構。同時它還會分配events和fired兩個數(shù)組setsize決定了最多能監(jiān)聽多少個文件事件。注冊一個文件事件時Redis 調用aeCreateFileEvent()int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask, aeFileProc *proc, void *clientData)核心邏輯有兩步第一步更新eventLoop-events[fd]上的 mask并把相應的rfileProc或wfileProc設置成傳入的proc第二步調用aeApiAddEvent()在 epoll 里就是執(zhí)行epoll_ctl()把 fd 和對應的事件掩碼注冊進 epoll 實例。注意如果 fd 之前已經注冊過epoll_ctl的操作會從 ADD 變成 MOD這塊在ae_epoll.c里通過op變量做了區(qū)分。這里有個容易忽略的細節(jié)Redis 在ae_epoll.c里調用epoll_ctl時并沒有給event.events加上EPOLLET標志。也就是說Redis 的 epoll 工作在**水平觸發(fā)LT**模式不是很多人以為的邊緣觸發(fā)ET。水平觸發(fā)模式下只要 fd 上還有未讀數(shù)據(jù)epoll_wait 就會反復返回這個 fd。Redis 的做法是每次讀事件觸發(fā)后盡量把數(shù)據(jù)讀到不能再讀返回EAGAIN配合 LT 模式反而讓代碼更安全——如果這次沒讀完下一輪 epoll_wait 還會繼續(xù)通知。3.3 aeProcessEvents的核心調度流程aeMain()是個很薄的主循環(huán)void aeMain(aeEventLoop *eventLoop) { eventLoop-stop 0; while (!eventLoop-stop) { if (eventLoop-beforesleep ! NULL) eventLoop-beforesleep(eventLoop); aeProcessEvents(eventLoop, AE_ALL_EVENTS | AE_CALL_BEFORE_SLEEP); } }每一輪循環(huán)先執(zhí)行beforesleep回調再進入aeProcessEvents()。真正復雜的調度邏輯全在aeProcessEvents()里大致流程如下如果傳入的標志允許處理時間事件就先調用aeSearchNearestTimer()遍歷時間事件鏈表找出“距離下一次執(zhí)行時間最近”的那個計算出還需要等多少毫秒存進tvp。計算完超時時間后調用aeApiPoll(eventLoop, tvp)。在 epoll 實現(xiàn)里就是調用epoll_wait()把就緒的文件描述符和事件掩碼填入fired數(shù)組返回本次就緒的事件數(shù)量。遍歷fired數(shù)組對每個就緒事件取出對應的events[fd]。代碼里處理的順序是如果就緒掩碼包含讀事件先執(zhí)行rfileProc如果包含寫事件再執(zhí)行wfileProc。也就是說同一輪循環(huán)里可讀可寫的 fd讀事件優(yōu)先于寫事件。如果本輪沒有處理任何文件事件或者文件事件還沒耗盡時間事件的槽位就繼續(xù)處理時間事件調用processTimeEvents()。返回本輪處理的文件事件數(shù)量。一個很關鍵的設計在于第 1 步和第 2 步的配合tvp直接決定了epoll_wait的超時時間。假設當前不存在任何網絡事件但serverCron距離下次執(zhí)行還差 40ms那么epoll_wait最多睡 40ms 就會醒來執(zhí)行時間事件。這就保證了在空閑狀態(tài)下周期任務依然能按時跑事件循環(huán)不會被“無事可睡”困死。4. 時間事件執(zhí)行的微妙之處定時任務如何“遲到”4.1 processTimeEvents的處理邏輯processTimeEvents()負責掃描時間事件鏈表把到期的任務拿出來執(zhí)行。它的處理思路比較樸素先記錄一個maxId表示本輪開始前已經存在的時間事件里最大的 id。遍歷鏈表時只處理id maxId的事件。也就是說本輪執(zhí)行過程中新注冊的時間事件不會在同一次processTimeEvents()里立刻被觸發(fā)要等下一輪循環(huán)。判斷當前時間now是否已經晚于te-when如果晚于就執(zhí)行te-proc()。proc的返回值如果是AE_NOMORE-1說明這個任務是一次性的把它從鏈表中摘掉并釋放如果返回的是正整數(shù)就把te-when更新為now te-period讓它繼續(xù)排隊。所以一個周期性時間事件實際執(zhí)行間隔并不是嚴格的period而是period加上循環(huán)處理耗時。如果事件處理器自己跑得慢后續(xù)周期就會順延。這是所有事件循環(huán)模型的共同特性算不上缺陷但寫代碼時要清楚這一點。4.2 serverCron占用Redis最多的時間事件serverCron是 Redis 最重要的周期事件注冊周期默認是1000ms / hzhz可以從配置文件調整默認 10也就是每 100ms 執(zhí)行一次。如果你把hz調到 100那么serverCron的執(zhí)行間隔會縮短到 10ms過期鍵清理、客戶端超時檢查等任務的響應速度會更快但代價就是主線程被占用的 CPU 會增多。serverCron干的事情很多更新服務器統(tǒng)計信息、通過activeExpireCycle()進行過期鍵主動刪除、檢查 RDB/AOF 持久化任務的執(zhí)行狀態(tài)、關閉超時空閑客戶端、更新 LRU 時鐘、處理收到的信號等等??梢哉f Redis 里所有不依賴網絡事件的后臺“心跳”幾乎都集中在這一個時間事件里。4.3 計時精度的邊界新增時間事件不喚醒主循環(huán)時間事件這里藏著一個非常容易踩的坑如果主線程當前正阻塞在epoll_wait里此時新注冊一個時間事件是不會打斷這次阻塞的。舉個例子假設epoll_wait的超時時間被設定為 80ms而你在第 10ms 時注冊了一個 5ms 后要執(zhí)行的一次性時間事件。那么對不起這個時間事件雖然在第 15ms 就該執(zhí)行但主線程要到第 80ms 從epoll_wait返回后才會在processTimeEvents()里發(fā)現(xiàn)它到期了。實際執(zhí)行時間被拖長了 65ms。這個問題在 Redis 里不算嚴重因為大部分需要精確觸發(fā)的事情并不依賴 ae 的時間事件機制而是通過文件事件的內核通知來驅動。但在自己動手寫事件驅動服務時如果業(yè)務里有“延遲任務必須準點執(zhí)行”的強需求就值得注意了要么直接把等待超時設得很短要么單獨開一個定時器線程用線程安全的方式喚醒主循環(huán)。這不算框架缺陷而是任何“一個循環(huán)里做等待”的方案都必須接受的取舍。5. beforeSleep事件循環(huán)“空檔期”里的批量收尾5.1 為什么需要beforeSleep這個鉤子事件循環(huán)每一輪都在做“等待 - 處理 - 再等待”的循環(huán)。但 Redis 有些工作不適合在事件回調里立刻做而更適合在“這一輪忙完、下一輪還沒開始等”的空檔統(tǒng)一處理。為了干這件事aeEventLoop留了一個beforesleep函數(shù)指針。名字叫 beforeSleep意思是“在進入下一次睡眠之前”。aeMain()的主循環(huán)里每次迭代先調用beforesleep(eventLoop)然后再進入aeProcessEvents()。注意這里它并不在aeProcessEvents()里面而是主循環(huán)自己調用的外部鉤子。Redis 啟動時在server.c里設置aeSetBeforeSleepProc(server.el, beforeSleep);這個 hook 機制本身很值得借鑒它讓主循環(huán)保持純粹又給了業(yè)務層一個可以批量處理“收尾邏輯”的時機。如果你寫自己的事件驅動服務也可以預留類似的回調點比把所有邏輯都塞進事件處理函數(shù)里要干凈得多。5.2 Redis在beforeSleep中做了什么Redis 的beforeSleep()干了不少正事調用handleClientsWithPendingWrites()把那些命令執(zhí)行后已經寫入到輸出緩沖、但還沒真正發(fā)送給客戶端的響應嘗試寫出去。如果一次寫不完再給對應 fd 注冊寫事件等下次可寫時繼續(xù)寫。這種“延遲批量發(fā)送”有效減少了系統(tǒng)調用次數(shù)提升了吞吐。刷 AOF如果 AOF 開啟了但策略不是每條命令都立刻刷盤那么累積在 AOF 緩沖里的數(shù)據(jù)會在beforeSleep里調用flushAppendFile()刷到磁盤。處理過期鍵和各類定時任務比如activeExpireCycle()會在這里嘗試清理一批過期鍵而不是全等serverCron。更新各種時間緩存、處理模塊事件包括集群、哨兵等特殊場景下的周期性動作。所以你會發(fā)現(xiàn)Redis 其實把“周期性的、不緊急的、適合集中處理的事情”都塞進了這個空檔而不是在每個事件回調里零散地做。這個設計讓命令處理的路徑變得非常短——客戶端請求來了執(zhí)行命令寫入回復緩沖馬上就能繼續(xù)處理下一個請求剩下發(fā)送時機由空檔期去調度。說到這也可以順帶解釋一個經典問題為什么刪除一個超大 key 會卡頓因為DEL命令直接跑在主線程上幾百萬元素的集合要逐個釋放內存這段耗時沒有任何事件回調能繞過。命令執(zhí)行完beforeSleep才運行然后才回到epoll_wait。這期間所有新請求都在內核等待隊列里候著表現(xiàn)出來就是整個實例的響應變慢。解決辦法也簡單用UNLINK異步刪除或者把大 key 拆小。6. 內核之外事件驅動模型給工程實踐的三個啟發(fā)6.1 不要在事件循環(huán)里做重活讀完 ae 框架最直接的一個體會就是事件循環(huán)主線程里的一切操作都必須短平快。Redis 的命令設計本身也在向這個原則靠攏比如O(N)命令要被謹慎使用、KEYS這種全庫掃描命令被警告替代為SCAN。原因不是這些命令不能實現(xiàn)而是它們會阻塞事件循環(huán)的正常流轉。我們自己寫服務時也是一樣。如果事件回調里出現(xiàn)了耗時的計算、同步的磁盤寫、加鎖等待整個服務的延遲就會呈現(xiàn)“周期性心跳變慢”的規(guī)律。遇到這種需求正確的姿勢是把重任務丟到獨立線程完成后通過管道或事件通知來喚醒主線程而不是在主線程里硬扛。6.2 阻塞命令并沒有“阻塞”主線程很有意思的是Redis 里像BLPOP這種看起來是“阻塞命令”的操作實際上并沒有把主線程阻塞住。它的實現(xiàn)方式是客戶端執(zhí)行BLPOP時如果沒有數(shù)據(jù)可取Redis 會把這個客戶端標記為blocked狀態(tài)然后主線程繼續(xù)事件循環(huán)處理其他請求。直到數(shù)據(jù)準備就緒或者客戶端超時Redis 再把這個客戶端從阻塞隊列里喚醒把命令重新執(zhí)行一遍或返回超時結果。這本質上是事件驅動框架的優(yōu)勢用狀態(tài)機代替線程阻塞。一個連接長時間沒有數(shù)據(jù)不代表整個進程需要停下來等它。受這個啟發(fā)我在自己的網絡服務里也盡量避免用阻塞式等待來實現(xiàn)交互協(xié)議而是讓每個連接變成一個帶有狀態(tài)的回調誰到了時間或者來了數(shù)據(jù)誰就繼續(xù)推進。6.3 從ae框架到自研事件驅動骨架如果你也想寫一個輕量的事件驅動服務完全可以照抄 ae 的分層思路底層抽象一個poller封裝epoll_wait或kqueue對外只暴露“注冊事件”和“等待事件”兩個接口。中間維護一張“fd - 回調”的表事件就緒時直接查表調用。上層再加一個定時器鏈表每次等待事件前計算出最近到期時間作為 poll 的超時參數(shù)。這套骨架一旦跑起來你會發(fā)現(xiàn)非常多中間件和服務都可以往里面塞TCP 服務器、定時任務調度器、RPC 客戶端的心跳管理器。這也是為什么讀懂了 Redis 的 ae 層再去看其他框架會覺得似曾相識——事件循環(huán)的本質都是相似的差異只在各個業(yè)務回調里填充了什么邏輯。如果打算跟著源碼走一遍我建議的順序是ae.h看結構體定義ae.c看主循環(huán)和事件處理ae_epoll.c看多路復用封裝最后回到networking.c看acceptTcpHandler和readQueryFromClient是怎么把網絡請求掛到這個框架上的。看完這幾個文件Redis 的高性能“發(fā)動機艙”基本就在你腦子里了。