閱讀 memcached 最好有 libevent 基礎, memcached 是基於 libevent 構建起來的. 通由 libevent 提供的事件驅動機制觸發 memcached 中的 IO 事件.
個人認為, 閱讀源碼的起初最忌鑽牛角尖, 如頭文件里天花亂墜的結構體到底有什么用. 源文件里稀里嘩啦的函數是做什么的. 剛開始並沒必要事無巨細弄清楚頭文件每個類型定義的具體用途; 很可能那些是不緊要的工具函數, 知道他的功能和用法就沒他事了.
來看 memcached 內部做了什么事情. memcached 是用 c 語言實現, 必須有一個入口函數main()
, memcached 的生命從這里開始.
初始化過程
建立並初始化 main_base, 即主線程的事件中心, 這是 libevent 里面的概念, 可以把它理解為事件分發中心.
建立並初始化 memcached 內部容器數據結構.
建立並初始化空閑連接結構體數組.
建立並初始化線程結構數組, 指定每個線程的入口函數是worker_libevent()
, 並創建工作線程. 從worder_libevent()
的實現來看, 工作線程都會調用event_base_loop()
進入自己的事件循環.
根據 memcached 配置, 開啟以下兩種服務模式中的一種:
- 以 UNIX 域套接字的方式接受客戶的請求
- 以 TCP/UDP 套接字的方式接受客戶的請求
memcached 有可配置的兩種模式: UNIX 域套接字和 TCP/UDP, 允許客戶端以兩種方式向 memcached 發起請求. 客戶端和服務器在同一個主機上的情況下可以用 UNIX 域套接字, 否則可以采用 TCP/UDP 的模式. 兩種模式是不兼容的. 特別的, 如果是 UNIX 域套接字或者 TCP 模式, 需要建立監聽套接字, 並在事件中心注冊了讀事件, 回調函數是event_handler()
, 我們會看到所有的連接都會被注冊回調函數是event_handler()
.
調用event_base_loop()
開啟 libevent 的事件循環. 到此, memcached 服務器的工作正式進入了工作. 如果遇到致命錯誤或者客戶明令結束 memcached, 那么才會進入接下來的清理工作.
UNIX 域套接字和 UDP/TCP 工作模式
在初始化過程中介紹了這兩種模式, memcached 這么做為的是讓其能更加可配置. TCP/UDP 自不用說, UNIX 域套接字有獨特的優勢:
- 在同一台主機上進行通信時,是不同主機間通信的兩倍
- UNIX 域套接口可以在同一台主機上,不同進程之間傳遞套接字描述符
- UNIX 域套接字可以向服務器提供客戶的憑證(用戶id或者用戶組id)
其他關於 UNIX 域套接字優缺點的請參看:https://pangea.stanford.edu/computing/UNIX/overview/advantages.php
工作線程管理和線程調配方式
在thread_init()
,setup_thread()
函數的實現中, memcached 的意圖是很清楚的. 每個線程都有自己獨有的連接隊列, 即 CQ, 注意這個連接隊列中的對象並不是一個或者多個 memcached 命令, 它對應一個客戶! 一旦一個客戶交給了一個線程, 它的余生就屬於這個線程了! 線程只要被喚醒就立即進入工作狀態, 將自己 CQ 隊列的任務所有完完成. 當然, 每一個工作線程都有自己的 libevent 事件中心.
很關鍵的線索是thread_init()
的實現中, 每個工作線程都創建了讀寫管道, 所能給我們的提示是: 只要利用 libevent 在工作線程的事件中心注冊讀管道的讀事件, 就可以按需喚醒線程, 完成工作, 很有意思, 而setup_thread()
的工作正是讀管道的讀事件被注冊到線程的事件中心, 回調函數是thread_libevent_process()
.thread_libevent_process()
的工作就是從工作線程自己的 CQ 隊列中取出任務執行, 而往工作線程工作隊列中添加任務的是dispatch_conn_new()
, 此函數一般由主線程調用. 下面是主線程和工作線程的工作流程:
前幾天在微博上, 看到 @高端小混混 的微博, 轉發了:
@高端小混混
多任務並行處理的兩種方式,一種是將所有的任務用隊列存儲起來,每個工作者依次去拿一個來處理,直到做完所有的>任務為止。另一種是將任務平均分給工作者,先做完任務的工作者就去別的工作者那里拿一些任務來做,同樣直到所有任務做完為止。兩種方式的結果如何?根據自己的場景寫碼驗證。
memcached 所采用的模式就是這里所說的第二種! memcached 的線程分配模式是:一個主線程和多個工作線程。主線程負責初始化和將接收的請求分派給工作線程,工作線程負責接收客戶的命令請求和回復客戶。
存儲容器
memcached 是做緩存用的, 內部肯定有一個容器. 回到main()
中, 調用assoc_init()
初始化了容器--hashtable, 采用頭插法插入新數據, 因為頭插法是最快的. memcached 只做了一級的索引, 即 hash; 接下來的就靠 memcmp() 在鏈表中找數據所在的位置. memcached 容器管理的接口主要在 item.h .c 中.
連接管理
每個連接都會建立一個連接結構體與之對應. main()
中會調用conn_init()
建立連接結構體數組. 連接結構體 struct conn 記錄了連接套接字, 讀取的數據, 將要寫入的數據, libevent event 結構體以及所屬的線程信息.
當有新的連接時, 主線程會被喚醒, 主線程選定一個工作線程 thread0, 在 thread0 的寫管道中寫入數據, 特別的如果是接受新的連接而不是接受新的數據, 寫入管道的數據是字符 'c'. 工作線程因管道中有數據可讀被喚醒,thread_libevent_process()
被調用, 新連接套接字被注冊了event_handler()
回調函數, 這些工作在conn_new()
中完成. 因此, 客戶端有命令請求的時候(譬如發起 get key 命令), 工作線程都會被觸發調用event_handler()
.
當出現致命錯誤或者客戶命令結束服務(quit 命令), 關於此連接的結構體內部的數據會被釋放(譬如曾經讀取的數據), 但結構體本身不釋放, 等待下一次使用. 如果有需要, 連接結構體數組會指數自增.
一個請求的工作流程
memcached 服務一個客戶的時候, 是怎么一個過程, 試着去調試模擬一下. 當一個客戶向 memcached 發起請求時, 主線程會被喚醒, 接受請求. 接下來的工作在連接管理中有說到.
客戶已經與 memcached 服務器建立了連接, 客戶在終端(黑框框)敲擊 get key + 回車鍵, 一個請求包就發出去了. 從連接管理中已經了解到所有連接套接字都會被注冊回調函數為event_handler()
, 因此event_handler()
會被觸發調用.
<code>void event_handler(const int fd, const short which, void *arg) { conn *c; c = (conn *)arg; assert(c != NULL); c->which = which; /* sanity */ if (fd != c->sfd) { if (settings.verbose > 0) fprintf(stderr, "Catastrophic: event fd doesn't match conn fd!\n"); conn_close(c); return; } drive_machine(c); /* wait for next event */ return; } </code>
event_handler()
調用了drive_machine()
.drive_machine()
是請求處理的開端, 特別的當有新的連接時, listen socket 也是有請求的, 所以建立新的連接也會調用drive_machine()
, 這在連接管理有提到過. 下面是drive_machine()
函數的骨架:
<code>// 請求的開端. 當有新的連接的時候 event_handler() 會調用此函數. static void drive_machine(conn *c) { bool stop = false; int sfd, flags = 1; socklen_t addrlen; struct sockaddr_storage addr; int nreqs = settings.reqs_per_event; int res; const char *str; assert(c != NULL); while (!stop) { // while 能保證一個命令被執行完成或者異常中斷(譬如 IO 操作次數超出了一定的限制) switch(c->state) { // 正在連接, 還沒有 accept case conn_listening: // 等待新的命令請求 case conn_waiting: // 讀取數據 case conn_read: // 嘗試解析命令 case conn_parse_cmd : // 新的命令請求, 只是負責轉變 conn 的狀態 case conn_new_cmd: // 真正執行命令的地方 case conn_nread: // 讀取所有的數據, 拋棄!!! 一般出錯的情況下會轉換到此狀態 case conn_swallow: // 數據回復 case conn_write: case conn_mwrite: // 連接結束. 一般出錯或者客戶顯示結束服務的情況下回轉換到此狀態 case conn_closing: } } return; } </code>
通過修改連接結構體狀態 struct conn.state 執行相應的操作, 從而完成一個請求, 完成后 stop 會被設置為 true, 一個命令只有執行結束(無論結果如何)才會跳出這個循環. 我們看到 struct conn 有好多種狀態, 一個正常執行的命令狀態的轉換是:
<code> conn_new_cmd->conn_waiting->conn_read->conn_parse_cmd->conn_nread->conn_mwrite->conn_close </code>
這個過程任何一個環節出了問題都會導致狀態轉變為 conn_close. 帶着剛開始的問題把從客戶連接到一個命令執行結束的過程是怎么樣的:
- 客戶
connect()
后, memcached 服務器主線程被喚醒, 接下來的調用鏈是event_handler()->drive_machine()
被調用,此時主線程對應 conn 狀態為 conn_listining,接受請求dispatch_conn_new(sfd, conn_new_cmd, EV_READ | EV_PERSIST,DATA_BUFFER_SIZE, tcp_transport);
dispatch_conn_new()
的工作是往工作線程工作隊列中添加任務(前面已經提到過), 所以其中一個沉睡的工作線程會被喚醒,thread_libevent_process()
會被工作線程調用, 注意這些機制都是由 libevent 提供的.thread_libevent_process()
調用conn_new()
新建 struct conn 結構體, 且狀態為 conn_new_cmd, 其對應的就是剛才accept()
的連接套接字.conn_new()
最關鍵的任務是將剛才接受的套接字在 libevent 中注冊一個事件, 回調函數是event_handler()
. 循環繼續, 狀態 conn_new_cmd 下的操作只是只是將 conn 的狀態轉換為 conn_waiting;- 循環繼續, conn_waiting 狀態下的操作只是將 conn 狀態轉換為 conn_read, 循環退出.
- 此后, 如果客戶端不請求服務, 那么主線程和工作線程都會沉睡, 注意這些機制都是由 libevent 提供的.
- 客戶敲擊命令「get key」后, 工作線程會被喚醒,
event_handler()
被調用了. 看! 又被調用了.event_handler()->drive_machine()
, 此時 conn 的狀態為 conn_read. conn_read 下的操作就是讀數據了, 如果讀取成功, conn 狀態被轉換為 conn_parse_cmd. - 循環繼續, conn_parse_cmd 狀態下的操作就是嘗試解析命令: 可能是較為簡單的命令, 就直接回復, 狀態轉換為 conn_close, 循環接下去就結束了; 涉及存取操作的請求會導致 conn_parse_cmd 狀態轉換為 conn_nread.
- 循環繼續, conn_nread 狀態下的操作是真正執行存取命令的地方. 里面的操作無非是在內存尋找數據項, 返回數據. 所以接下來的狀態 conn_mwrite, 它的操作是為客戶端回復數據.
- 狀態又回到了 conn_new_cmd 迎接新的請求, 直到客戶命令結束服務或者發生致命錯誤. 大概就是這么個過程.
memcached 的分布式
memcached 的服務器沒有向其他 memcached 服務器收發數據的功能, 意即就算部署多個 memcached 服務器, 他們之間也沒有任何的通信. memcached 所謂的分布式部署也是並非平時所說的分布式. 所說的「分布式」是通過創建多個 memcached 服務器節點, 在客戶端添加緩存請求分發器來實現的. memcached 的更多的時候限制是來自網絡 I/O, 所以應該盡量減少網絡 I/O.
我在 github 上分享了 memcached 的源碼剖析注釋: 這里
歡迎討論: @鄭思願daoluan