非阻塞 IO
當我們調用套接字的讀寫方法,默認它們是阻塞的,比如 read 方法要傳遞進去一個參數n,表示讀取這么多字節后再返回,如果沒有讀夠線程就會卡在那里,直到新的數據到來或者連接關閉了,read 方法才可以返回,線程才能繼續處理。而 write 方法一般來說不會阻塞,除非內核為套接字分配的寫緩沖區已經滿了,write 方法就會阻塞,直到緩存區中有空閑空間挪出來了。
非阻塞 IO 在套接字對象上提供了一個選項 Non_Blocking,當這個選項打開時,讀寫方法不會阻塞,而是能讀多少讀多少,能寫多少寫多少。能讀多少取決於內核為套接字分配的
讀緩沖區內部的數據字節數,能寫多少取決於內核為套接字分配的寫緩沖區的空閑空間字節數。讀方法和寫方法都會通過返回值來告知程序實際讀寫了多少字節。有了非阻塞 IO 意味着線程在讀寫 IO 時可以不必再阻塞了,讀寫可以瞬間完成然后線程可以繼續干別的事了。
事件輪詢 (多路復用)
非阻塞 IO 有個問題,那就是線程要讀數據,結果讀了一部分就返回了,線程如何知道何時才應該繼續讀。也就是當數據到來時,線程如何得到通知。寫也是一樣,如果緩沖區滿了,寫不完,剩下的數據何時才應該繼續寫,線程也應該得到通知。

事件輪詢 API 就是用來解決這個問題的,最簡單的事件輪詢 API 是 select 函數,它是操作系統提供給用戶程序的 API。輸入是讀寫描述符列表 read_fds & write_fds,輸出是與之對應的可讀可寫事件。同時還提供了一個 timeout 參數,如果沒有任何事件到來,那么就最多等待 timeout 時間,線程處於阻塞狀態。一旦期間有任何事件到來,就可以立即返回。時間過了之后還是沒有任何事件到來,也會立即返回。拿到事件后,線程就可以繼續挨個處理相應的事件。處理完了繼續過來輪詢。於是線程就進入了一個死循環,我們把這個死循環稱為事件循環,一個循環為一個周期。
每個客戶端套接字 socket 都有對應的讀寫文件描述符。
read_events, write_events = select(read_fds, write_fds, timeout)
for event in read_events:
handle_read(event.fd)
for event in write_events:
handle_write(event.fd)
handle_others() # 處理其它事情,如定時任務等
因為我們通過 select 系統調用同時處理多個通道描述符的讀寫事件,因此我們將這類系統調用稱為多路復用 API。現代操作系統的多路復用 API 已經不再使用 select 系統調用,而改用 epoll(linux)和 kqueue(freebsd & macosx),因為 select 系統調用的性能在描述符特別多時性能會非常差。它們使用起來可能在形式上略有差異,但是本質上都是差不多的,都可以使用上面的偽代碼邏輯進行理解。
服務器套接字 serversocket 對象的讀操作是指調用 accept 接受客戶端新連接。何時有新連
接到來,也是通過 select 系統調用的讀事件來得到通知的。事件輪詢 API 就是 Java 語言里面的 NIO 技術Java 的 NIO 並不是 Java 特有的技術,其它計算機語言都有這個技術,只不過換了一個詞匯,不叫 NIO 而已。
指令隊列
Redis 會將每個客戶端套接字都關聯一個指令隊列。客戶端的指令通過隊列來排隊進行順序處理,先到先服務。
響應隊列
Redis 同樣也會為每個客戶端套接字關聯一個響應隊列。Redis 服務器通過響應隊列來將指令的返回結果回復給客戶端。 如果隊列為空,那么意味着連接暫時處於空閑狀態,不需要去獲取寫事件,也就是可以將當前的客戶端描述符從 write_fds 里面移出來。等到隊列有數據了,再將描述符放進去。避免 select 系統調用立即返回寫事件,結果發現沒什么數據可以寫。出這種情況的線程會飆高 CPU。
定時任務
服務器處理要響應 IO 事件外,還要處理其它事情。比如定時任務就是非常重要的一件事。如果線程阻塞在 select 系統調用上,定時任務將無法得到准時調度。那 Redis 是如何解決這個問題的呢?
Redis 的定時任務會記錄在一個稱為最小堆的數據結構中。這個堆中,最快要執行的任務排在堆的最上方。在每個循環周期,Redis 都會將最小堆里面已經到點的任務立即進行處理。處理完畢后,將最快要執行的任務還需要的時間記錄下來,這個時間就是 select 系統調用的 timeout 參數。因為 Redis 知道未來 timeout 時間內,沒有其它定時任務需要處理,所以可以安心睡眠 timeout 的時間。Nginx 和 Node 的事件處理原理和 Redis 也是類似的