panjf2000

panjf2000 查看完整檔案

海外編輯石家莊新東方烹飪學校  |  消滅辣椒專業 編輯gnet  |  coder 編輯 andypan.me 編輯
編輯

程序猿。

個人動態

panjf2000 發布了文章 · 2月18日

Redis 多線程網絡模型全面揭秘

博客原文

Redis 多線程網絡模型全面揭秘

導言

在目前的技術選型中,Redis 儼然已經成為了系統高性能緩存方案的事實標準,因此現在 Redis 也成為了后端開發的基本技能樹之一,Redis 的底層原理也順理成章地成為了必須學習的知識。

Redis 從本質上來講是一個網絡服務器,而對于一個網絡服務器來說,網絡模型是它的精華,搞懂了一個網絡服務器的網絡模型,你也就搞懂了它的本質。

本文通過層層遞進的方式,介紹了 Redis 網絡模型的版本變更歷程,剖析了其從單線程進化到多線程的工作原理,此外,還一并分析并解答了 Redis 的網絡模型的很多抉擇背后的思考,幫助讀者能更深刻地理解 Redis 網絡模型的設計。

Redis 有多快?

根據官方的 benchmark,通常來說,在一臺普通硬件配置的 Linux 機器上跑單個 Redis 實例,處理簡單命令(時間復雜度 O(N) 或者 O(log(N))),QPS 可以達到 8w+,而如果使用 pipeline 批處理功能,則 QPS 至高能達到 100w。

僅從性能層面進行評判,Redis 完全可以被稱之為高性能緩存方案。

Redis 為什么快?

Redis 的高性能得益于以下幾個基礎:

  • C 語言實現,雖然 C 對 Redis 的性能有助力,但語言并不是最核心因素。
  • 純內存 I/O,相較于其他基于磁盤的 DB,Redis 的純內存操作有著天然的性能優勢。
  • I/O 多路復用,基于 epoll/select/kqueue 等 I/O 多路復用技術,實現高吞吐的網絡 I/O。
  • 單線程模型,單線程無法利用多核,但是從另一個層面來說則避免了多線程頻繁上下文切換,以及同步機制如鎖帶來的開銷。

Redis 為何選擇單線程?

Redis 的核心網絡模型選擇用單線程來實現,這在一開始就引起了很多人的不解,Redis 官方的對于此的回答是:

It's not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.

核心意思就是,對于一個 DB 來說,CPU 通常不會是瓶頸,因為大多數請求不會是 CPU 密集型的,而是 I/O 密集型。具體到 Redis 的話,如果不考慮 RDB/AOF 等持久化方案,Redis 是完全的純內存操作,執行速度是非??斓?,因此這部分操作通常不會是性能瓶頸,Redis 真正的性能瓶頸在于網絡 I/O,也就是客戶端和服務端之間的網絡傳輸延遲,因此 Redis 選擇了單線程的 I/O 多路復用來實現它的核心網絡模型。

上面是比較籠統的官方答案,實際上更加具體的選擇單線程的原因可以歸納如下:

避免過多的上下文切換開銷

多線程調度過程中必然需要在 CPU 之間切換線程上下文 context,而上下文的切換又涉及程序計數器、堆棧指針和程序狀態字等一系列的寄存器置換、程序堆棧重置甚至是 CPU 高速緩存、TLB 快表的汰換,如果是進程內的多線程切換還好一些,因為單一進程內多線程共享進程地址空間,因此線程上下文比之進程上下文要小得多,如果是跨進程調度,則需要切換掉整個進程地址空間。

如果是單線程則可以規避進程內頻繁的線程切換開銷,因為程序始終運行在進程中單個線程內,沒有多線程切換的場景。

避免同步機制的開銷

如果 Redis 選擇多線程模型,又因為 Redis 是一個數據庫,那么勢必涉及到底層數據同步的問題,則必然會引入某些同步機制,比如鎖,而我們知道 Redis 不僅僅提供了簡單的 key-value 數據結構,還有 list、set 和 hash 等等其他豐富的數據結構,而不同的數據結構對同步訪問的加鎖粒度又不盡相同,可能會導致在操作數據過程中帶來很多加鎖解鎖的開銷,增加程序復雜度的同時還會降低性能。

簡單可維護

Redis 的作者 Salvatore Sanfilippo (別稱 antirez) 對 Redis 的設計和代碼有著近乎偏執的簡潔性理念,你可以在閱讀 Redis 的源碼或者給 Redis 提交 PR 的之時感受到這份偏執。因此代碼的簡單可維護性必然是 Redis 早期的核心準則之一,而引入多線程必然會導致代碼的復雜度上升和可維護性下降。

事實上,多線程編程也不是那么盡善盡美,首先多線程的引入會使得程序不再保持代碼邏輯上的串行性,代碼執行的順序將變成不可預測的,稍不注意就會導致程序出現各種并發編程的問題;其次,多線程模式也使得程序調試更加復雜和麻煩。網絡上有一幅很有意思的圖片,生動形象地描述了并發編程面臨的窘境。

你期望的多線程編程 VS 實際上的多線程編程:

你期望的多線程VS實際上的多線程

前面我們提到引入多線程必須的同步機制,如果 Redis 使用多線程模式,那么所有的底層數據結構都必須實現成線程安全的,這無疑又使得 Redis 的實現變得更加復雜。

總而言之,Redis 選擇單線程可以說是多方博弈之后的一種權衡:在保證足夠的性能表現之下,使用單線程保持代碼的簡單和可維護性。

Redis 真的是單線程?

在討論這個問題之前,我們要先明確『單線程』這個概念的邊界:它的覆蓋范圍是核心網絡模型,抑或是整個 Redis?如果是前者,那么答案是肯定的,在 Redis 的 v6.0 版本正式引入多線程之前,其網絡模型一直是單線程模式的;如果是后者,那么答案則是否定的,Redis 早在 v4.0 就已經引入了多線程。

因此,當我們討論 Redis 的多線程之時,有必要對 Redis 的版本劃出兩個重要的節點:

  1. Redis v4.0(引入多線程處理異步任務)
  2. Redis v6.0(正式在網絡模型中實現 I/O 多線程)

單線程事件循環

我們首先來剖析一下 Redis 的核心網絡模型,從 Redis 的 v1.0 到 v6.0 版本之前,Redis 的核心網絡模型一直是一個典型的單 Reactor 模型:利用 epoll/select/kqueue 等多路復用技術,在單線程的事件循環中不斷去處理事件(客戶端請求),最后回寫響應數據到客戶端:

這里有幾個核心的概念需要學習:

  • client:客戶端對象,Redis 是典型的 CS 架構(Client <---> Server),客戶端通過 socket 與服務端建立網絡通道然后發送請求命令,服務端執行請求的命令并回復。Redis 使用結構體 client 存儲客戶端的所有相關信息,包括但不限于封裝的套接字連接 -- *conn,當前選擇的數據庫指針 -- *db,讀入緩沖區 -- querybuf,寫出緩沖區 -- buf,寫出數據鏈表 -- reply等。
  • aeApiPoll:I/O 多路復用 API,是基于 epoll_wait/select/kevent 等系統調用的封裝,監聽等待讀寫事件觸發,然后處理,它是事件循環(Event Loop)中的核心函數,是事件驅動得以運行的基礎。
  • acceptTcpHandler:連接應答處理器,底層使用系統調用 accept 接受來自客戶端的新連接,并為新連接注冊綁定命令讀取處理器,以備后續處理新的客戶端 TCP 連接;除了這個處理器,還有對應的 acceptUnixHandler 負責處理 Unix Domain Socket 以及 acceptTLSHandler 負責處理 TLS 加密連接。
  • readQueryFromClient:命令讀取處理器,解析并執行客戶端的請求命令。
  • beforeSleep:事件循環中進入 aeApiPoll 等待事件到來之前會執行的函數,其中包含一些日常的任務,比如把 client->buf 或者 client->reply (后面會解釋為什么這里需要兩個緩沖區)中的響應寫回到客戶端,持久化 AOF 緩沖區的數據到磁盤等,相對應的還有一個 afterSleep 函數,在 aeApiPoll 之后執行。
  • sendReplyToClient:命令回復處理器,當一次事件循環之后寫出緩沖區中還有數據殘留,則這個處理器會被注冊綁定到相應的連接上,等連接觸發寫就緒事件時,它會將寫出緩沖區剩余的數據回寫到客戶端。

Redis 內部實現了一個高性能的事件庫 --- AE,基于 epoll/select/kqueue/evport 四種事件驅動技術,實現 Linux/MacOS/FreeBSD/Solaris 多平臺的高性能事件循環模型。Redis 的核心網絡模型正式構筑在 AE 之上,包括 I/O 多路復用、各類處理器的注冊綁定,都是基于此才得以運行。

至此,我們可以描繪出客戶端向 Redis 發起請求命令的工作原理:

  1. Redis 服務器啟動,開啟主線程事件循環(Event Loop),注冊 acceptTcpHandler 連接應答處理器到用戶配置的監聽端口對應的文件描述符,等待新連接到來;
  2. 客戶端和服務端建立網絡連接;
  3. acceptTcpHandler 被調用,主線程使用 AE 的 API 將 readQueryFromClient 命令讀取處理器綁定到新連接對應的文件描述符上,并初始化一個 client 綁定這個客戶端連接;
  4. 客戶端發送請求命令,觸發讀就緒事件,主線程調用 readQueryFromClient 通過 socket 讀取客戶端發送過來的命令存入 client->querybuf 讀入緩沖區;
  5. 接著調用 processInputBuffer,在其中使用 processInlineBuffer 或者 processMultibulkBuffer 根據 Redis 協議解析命令,最后調用 processCommand 執行命令;
  6. 根據請求命令的類型(SET, GET, DEL, EXEC 等),分配相應的命令執行器去執行,最后調用 addReply 函數族的一系列函數將響應數據寫入到對應 client 的寫出緩沖區:client->buf 或者 client->reply ,client->buf 是首選的寫出緩沖區,固定大小 16KB,一般來說可以緩沖足夠多的響應數據,但是如果客戶端在時間窗口內需要響應的數據非常大,那么則會自動切換到 client->reply 鏈表上去,使用鏈表理論上能夠保存無限大的數據(受限于機器的物理內存),最后把 client 添加進一個 LIFO 隊列 clients_pending_write;
  7. 在事件循環(Event Loop)中,主線程執行 beforeSleep --> handleClientsWithPendingWrites,遍歷 clients_pending_write 隊列,調用 writeToClientclient 的寫出緩沖區里的數據回寫到客戶端,如果寫出緩沖區還有數據遺留,則注冊 sendReplyToClient 命令回復處理器到該連接的寫就緒事件,等待客戶端可寫時在事件循環中再繼續回寫殘余的響應數據。

對于那些想利用多核優勢提升性能的用戶來說,Redis 官方給出的解決方案也非常簡單粗暴:在同一個機器上多跑幾個 Redis 實例。事實上,為了保證高可用,線上業務一般不太可能會是單機模式,更加常見的是利用 Redis 分布式集群多節點和數據分片負載均衡來提升性能和保證高可用。

多線程異步任務

以上便是 Redis 的核心網絡模型,這個單線程網絡模型一直到 Redis v6.0 才改造成多線程模式,但這并不意味著整個 Redis 一直都只是單線程。

Redis 在 v4.0 版本的時候就已經引入了的多線程來做一些異步操作,此舉主要針對的是那些非常耗時的命令,通過將這些命令的執行進行異步化,避免阻塞單線程的事件循環。

我們知道 Redis 的 DEL 命令是用來刪除掉一個或多個 key 儲存的值,它是一個阻塞的命令,大多數情況下你要刪除的 key 里存的值不會特別多,最多也就幾十上百個對象,所以可以很快執行完,但是如果你要刪的是一個超大的鍵值對,里面有幾百萬個對象,那么這條命令可能會阻塞至少好幾秒,又因為事件循環是單線程的,所以會阻塞后面的其他事件,導致吞吐量下降。

Redis 的作者 antirez 為了解決這個問題進行了很多思考,一開始他想的辦法是一種漸進式的方案:利用定時器和數據游標,每次只刪除一小部分的數據,比如 1000 個對象,最終清除掉所有的數據,但是這種方案有個致命的缺陷,如果同時還有其他客戶端往某個正在被漸進式刪除的 key 里繼續寫入數據,而且刪除的速度跟不上寫入的數據,那么將會無止境地消耗內存,雖然后來通過一個巧妙的辦法解決了,但是這種實現使 Redis 變得更加復雜,而多線程看起來似乎是一個水到渠成的解決方案:簡單、易理解。于是,最終 antirez 選擇引入多線程來實現這一類非阻塞的命令。更多 antirez 在這方面的思考可以閱讀一下他發表的博客:Lazy Redis is better Redis。

于是,在 Redis v4.0 之后增加了一些的非阻塞命令如 UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC。

UNLINK 命令其實就是 DEL 的異步版本,它不會同步刪除數據,而只是把 key 從 keyspace 中暫時移除掉,然后將任務添加到一個異步隊列,最后由后臺線程去刪除,不過這里需要考慮一種情況是如果用 UNLINK 去刪除一個很小的 key,用異步的方式去做反而開銷更大,所以它會先計算一個開銷的閥值,只有當這個值大于 64 才會使用異步的方式去刪除 key,對于基本的數據類型如 List、Set、Hash 這些,閥值就是其中存儲的對象數量。

Redis 多線程網絡模型

前面提到 Redis 最初選擇單線程網絡模型的理由是:CPU 通常不會成為性能瓶頸,瓶頸往往是內存網絡,因此單線程足夠了。那么為什么現在 Redis 又要引入多線程呢?很簡單,就是 Redis 的網絡 I/O 瓶頸已經越來越明顯了。

隨著互聯網的飛速發展,互聯網業務系統所要處理的線上流量越來越大,Redis 的單線程模式會導致系統消耗很多 CPU 時間在網絡 I/O 上從而降低吞吐量,要提升 Redis 的性能有兩個方向:

  • 優化網絡 I/O 模塊
  • 提高機器內存讀寫的速度

后者依賴于硬件的發展,暫時無解。所以只能從前者下手,網絡 I/O 的優化又可以分為兩個方向:

  • 零拷貝技術或者 DPDK 技術
  • 利用多核優勢

零拷貝技術有其局限性,無法完全適配 Redis 這一類復雜的網絡 I/O 場景,更多網絡 I/O 對 CPU 時間的消耗和 Linux 零拷貝技術,可以閱讀我的另一篇文章:Linux I/O 原理和 Zero-copy 技術全面揭秘。而 DPDK 技術通過旁路網卡 I/O 繞過內核協議棧的方式又太過于復雜以及需要內核甚至是硬件的支持。

因此,利用多核優勢成為了優化網絡 I/O 性價比最高的方案。

6.0 版本之后,Redis 正式在核心網絡模型中引入了多線程,也就是所謂的 I/O threading,至此 Redis 真正擁有了多線程模型。前一小節,我們了解了 Redis 在 6.0 版本之前的單線程事件循環模型,實際上就是一個非常經典的 Reactor 模型:

目前 Linux 平臺上主流的高性能網絡庫/框架中,大都采用 Reactor 模式,比如 netty、libevent、libuv、POE(Perl)、Twisted(Python)等。

Reactor 模式本質上指的是使用 I/O 多路復用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O) 的模式。

更多關于 Reactor 模式的細節可以參考我之前的文章:Go netpoller 原生網絡模型之源碼全面揭秘,Reactor 網絡模型那一小節,這里不再贅述。

Redis 的核心網絡模型在 6.0 版本之前,一直是單 Reactor 模式:所有事件的處理都在單個線程內完成,雖然在 4.0 版本中引入了多線程,但是那個更像是針對特定場景(刪除超大 key 值等)而打的補丁,并不能被視作核心網絡模型的多線程。

通常來說,單 Reactor 模式,引入多線程之后會進化為 Multi-Reactors 模式,基本工作模式如下:

區別于單 Reactor 模式,這種模式不再是單線程的事件循環,而是有多個線程(Sub Reactors)各自維護一個獨立的事件循環,由 Main Reactor 負責接收新連接并分發給 Sub Reactors 去獨立處理,最后 Sub Reactors 回寫響應給客戶端。

Multiple Reactors 模式通常也可以等同于 Master-Workers 模式,比如 Nginx 和 Memcached 等就是采用這種多線程模型,雖然不同的項目實現細節略有區別,但總體來說模式是一致的。

設計思路

Redis 雖然也實現了多線程,但是卻不是標準的 Multi-Reactors/Master-Workers 模式,這其中的緣由我們后面會分析,現在我們先看一下 Redis 多線程網絡模型的總體設計:

  1. Redis 服務器啟動,開啟主線程事件循環(Event Loop),注冊 acceptTcpHandler 連接應答處理器到用戶配置的監聽端口對應的文件描述符,等待新連接到來;
  2. 客戶端和服務端建立網絡連接;
  3. acceptTcpHandler 被調用,主線程使用 AE 的 API 將 readQueryFromClient 命令讀取處理器綁定到新連接對應的文件描述符上,并初始化一個 client 綁定這個客戶端連接;
  4. 客戶端發送請求命令,觸發讀就緒事件,服務端主線程不會通過 socket 去讀取客戶端的請求命令,而是先將 client 放入一個 LIFO 隊列 clients_pending_read;
  5. 在事件循環(Event Loop)中,主線程執行 beforeSleep -->handleClientsWithPendingReadsUsingThreads,利用 Round-Robin 輪詢負載均衡策略,把 clients_pending_read隊列中的連接均勻地分配給 I/O 線程各自的本地 FIFO 任務隊列 io_threads_list[id] 和主線程自己,I/O 線程通過 socket 讀取客戶端的請求命令,存入 client->querybuf 并解析第一個命令,但不執行命令,主線程忙輪詢,等待所有 I/O 線程完成讀取任務;
  6. 主線程和所有 I/O 線程都完成了讀取任務,主線程結束忙輪詢,遍歷 clients_pending_read 隊列,執行所有客戶端連接的請求命令,先調用 processCommandAndResetClient 執行第一條已經解析好的命令,然后調用 processInputBuffer 解析并執行客戶端連接的所有命令,在其中使用 processInlineBuffer 或者 processMultibulkBuffer 根據 Redis 協議解析命令,最后調用 processCommand 執行命令;
  7. 根據請求命令的類型(SET, GET, DEL, EXEC 等),分配相應的命令執行器去執行,最后調用 addReply 函數族的一系列函數將響應數據寫入到對應 client 的寫出緩沖區:client->buf 或者 client->reply ,client->buf 是首選的寫出緩沖區,固定大小 16KB,一般來說可以緩沖足夠多的響應數據,但是如果客戶端在時間窗口內需要響應的數據非常大,那么則會自動切換到 client->reply 鏈表上去,使用鏈表理論上能夠保存無限大的數據(受限于機器的物理內存),最后把 client 添加進一個 LIFO 隊列 clients_pending_write;
  8. 在事件循環(Event Loop)中,主線程執行 beforeSleep --> handleClientsWithPendingWritesUsingThreads,利用 Round-Robin 輪詢負載均衡策略,把 clients_pending_write 隊列中的連接均勻地分配給 I/O 線程各自的本地 FIFO 任務隊列 io_threads_list[id] 和主線程自己,I/O 線程通過調用 writeToClientclient 的寫出緩沖區里的數據回寫到客戶端,主線程忙輪詢,等待所有 I/O 線程完成寫出任務;
  9. 主線程和所有 I/O 線程都完成了寫出任務, 主線程結束忙輪詢,遍歷 clients_pending_write 隊列,如果 client 的寫出緩沖區還有數據遺留,則注冊 sendReplyToClient 到該連接的寫就緒事件,等待客戶端可寫時在事件循環中再繼續回寫殘余的響應數據。

這里大部分邏輯和之前的單線程模型是一致的,變動的地方僅僅是把讀取客戶端請求命令和回寫響應數據的邏輯異步化了,交給 I/O 線程去完成,這里需要特別注意的一點是:I/O 線程僅僅是讀取和解析客戶端命令而不會真正去執行命令,客戶端命令的執行最終還是要在主線程上完成。

源碼剖析

以下所有代碼基于目前最新的 Redis v6.0.10 版本。

多線程初始化

void initThreadedIO(void) {
    server.io_threads_active = 0; /* We start with threads not active. */

    // 如果用戶只配置了一個 I/O 線程,則不會創建新線程(效率低),直接在主線程里處理 I/O。
    if (server.io_threads_num == 1) return;

    if (server.io_threads_num > IO_THREADS_MAX_NUM) {
        serverLog(LL_WARNING,"Fatal: too many I/O threads configured. "
                             "The maximum number is %d.", IO_THREADS_MAX_NUM);
        exit(1);
    }

    // 根據用戶配置的 I/O 線程數,啟動線程。
    for (int i = 0; i < server.io_threads_num; i++) {
        // 初始化 I/O 線程的本地任務隊列。
        io_threads_list[i] = listCreate();
        if (i == 0) continue; // 線程 0 是主線程。

        // 初始化 I/O 線程并啟動。
        pthread_t tid;
        // 每個 I/O 線程會分配一個本地鎖,用來休眠和喚醒線程。
        pthread_mutex_init(&io_threads_mutex[i],NULL);
        // 每個 I/O 線程分配一個原子計數器,用來記錄當前遺留的任務數量。
        io_threads_pending[i] = 0;
        // 主線程在啟動 I/O 線程的時候會默認先鎖住它,直到有 I/O 任務才喚醒它。
        pthread_mutex_lock(&io_threads_mutex[i]);
        // 啟動線程,進入 I/O 線程的主邏輯函數 IOThreadMain。
        if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) {
            serverLog(LL_WARNING,"Fatal: Can't initialize IO thread.");
            exit(1);
        }
        io_threads[i] = tid;
    }
}

initThreadedIO 會在 Redis 服務器啟動時的初始化工作的末尾被調用,初始化 I/O 多線程并啟動。

Redis 的多線程模式默認是關閉的,需要用戶在 redis.conf 配置文件中開啟:

io-threads 4
io-threads-do-reads yes

讀取請求

當客戶端發送請求命令之后,會觸發 Redis 主線程的事件循環,命令處理器 readQueryFromClient 被回調,在以前的單線程模型下,這個方法會直接讀取解析客戶端命令并執行,但是多線程模式下,則會把 client 加入到 clients_pending_read 任務隊列中去,后面主線程再分配到 I/O 線程去讀取客戶端請求命令:

void readQueryFromClient(connection *conn) {
    client *c = connGetPrivateData(conn);
    int nread, readlen;
    size_t qblen;

    // 檢查是否開啟了多線程,如果是則把 client 加入異步隊列之后返回。
    if (postponeClientRead(c)) return;
    
    // 省略代碼,下面的代碼邏輯和單線程版本幾乎是一樣的。
    ... 
}

int postponeClientRead(client *c) {
    // 當多線程 I/O 模式開啟、主線程沒有在處理阻塞任務時,將 client 加入異步隊列。
    if (server.io_threads_active &&
        server.io_threads_do_reads &&
        !ProcessingEventsWhileBlocked &&
        !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))
    {
        // 給 client 打上 CLIENT_PENDING_READ 標識,表示該 client 需要被多線程處理,
        // 后續在 I/O 線程中會在讀取和解析完客戶端命令之后判斷該標識并放棄執行命令,讓主線程去執行。
        c->flags |= CLIENT_PENDING_READ;
        listAddNodeHead(server.clients_pending_read,c);
        return 1;
    } else {
        return 0;
    }
}

接著主線程會在事件循環的 beforeSleep() 方法中,調用 handleClientsWithPendingReadsUsingThreads

int handleClientsWithPendingReadsUsingThreads(void) {
    if (!server.io_threads_active || !server.io_threads_do_reads) return 0;
    int processed = listLength(server.clients_pending_read);
    if (processed == 0) return 0;

    if (tio_debug) printf("%d TOTAL READ pending clients\n", processed);

    // 遍歷待讀取的 client 隊列 clients_pending_read,
    // 通過 RR 輪詢均勻地分配給 I/O 線程和主線程自己(編號 0)。
    listIter li;
    listNode *ln;
    listRewind(server.clients_pending_read,&li);
    int item_id = 0;
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        int target_id = item_id % server.io_threads_num;
        listAddNodeTail(io_threads_list[target_id],c);
        item_id++;
    }

    // 設置當前 I/O 操作為讀取操作,給每個 I/O 線程的計數器設置分配的任務數量,
    // 讓 I/O 線程可以開始工作:只讀取和解析命令,不執行。
    io_threads_op = IO_THREADS_OP_READ;
    for (int j = 1; j < server.io_threads_num; j++) {
        int count = listLength(io_threads_list[j]);
        io_threads_pending[j] = count;
    }

    // 主線程自己也會去執行讀取客戶端請求命令的任務,以達到最大限度利用 CPU。
    listRewind(io_threads_list[0],&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        readQueryFromClient(c->conn);
    }
    listEmpty(io_threads_list[0]);

    // 忙輪詢,累加所有 I/O 線程的原子任務計數器,直到所有計數器的遺留任務數量都是 0,
    // 表示所有任務都已經執行完成,結束輪詢。
    while(1) {
        unsigned long pending = 0;
        for (int j = 1; j < server.io_threads_num; j++)
            pending += io_threads_pending[j];
        if (pending == 0) break;
    }
    if (tio_debug) printf("I/O READ All threads finshed\n");

    // 遍歷待讀取的 client 隊列,清除 CLIENT_PENDING_READ 和 CLIENT_PENDING_COMMAND 標記,
    // 然后解析并執行所有 client 的命令。
    while(listLength(server.clients_pending_read)) {
        ln = listFirst(server.clients_pending_read);
        client *c = listNodeValue(ln);
        c->flags &= ~CLIENT_PENDING_READ;
        listDelNode(server.clients_pending_read,ln);

        if (c->flags & CLIENT_PENDING_COMMAND) {
            c->flags &= ~CLIENT_PENDING_COMMAND;
            // client 的第一條命令已經被解析好了,直接嘗試執行。
            if (processCommandAndResetClient(c) == C_ERR) {
                /* If the client is no longer valid, we avoid
                 * processing the client later. So we just go
                 * to the next. */
                continue;
            }
        }
        processInputBuffer(c); // 繼續解析并執行 client 命令。

        // 命令執行完成之后,如果 client 中有響應數據需要回寫到客戶端,則將 client 加入到待寫出隊列 clients_pending_write
        if (!(c->flags & CLIENT_PENDING_WRITE) && clientHasPendingReplies(c))
            clientInstallWriteHandler(c);
    }

    /* Update processed count on server */
    server.stat_io_reads_processed += processed;

    return processed;
}

這里的核心工作是:

  • 遍歷待讀取的 client 隊列 clients_pending_read,通過 RR 策略把所有任務分配給 I/O 線程和主線程去讀取和解析客戶端命令。
  • 忙輪詢等待所有 I/O 線程完成任務。
  • 最后再遍歷 clients_pending_read,執行所有 client 的命令。

寫回響應

完成命令的讀取、解析以及執行之后,客戶端命令的響應數據已經存入 client->buf 或者 client->reply 中了,接下來就需要把響應數據回寫到客戶端了,還是在 beforeSleep 中, 主線程調用 handleClientsWithPendingWritesUsingThreads

int handleClientsWithPendingWritesUsingThreads(void) {
    int processed = listLength(server.clients_pending_write);
    if (processed == 0) return 0; /* Return ASAP if there are no clients. */

    // 如果用戶設置的 I/O 線程數等于 1 或者當前 clients_pending_write 隊列中待寫出的 client
    // 數量不足 I/O 線程數的兩倍,則不用多線程的邏輯,讓所有 I/O 線程進入休眠,
    // 直接在主線程把所有 client 的相應數據回寫到客戶端。
    if (server.io_threads_num == 1 || stopThreadedIOIfNeeded()) {
        return handleClientsWithPendingWrites();
    }

    // 喚醒正在休眠的 I/O 線程(如果有的話)。
    if (!server.io_threads_active) startThreadedIO();

    if (tio_debug) printf("%d TOTAL WRITE pending clients\n", processed);

    // 遍歷待寫出的 client 隊列 clients_pending_write,
    // 通過 RR 輪詢均勻地分配給 I/O 線程和主線程自己(編號 0)。
    listIter li;
    listNode *ln;
    listRewind(server.clients_pending_write,&li);
    int item_id = 0;
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        c->flags &= ~CLIENT_PENDING_WRITE;

        /* Remove clients from the list of pending writes since
         * they are going to be closed ASAP. */
        if (c->flags & CLIENT_CLOSE_ASAP) {
            listDelNode(server.clients_pending_write, ln);
            continue;
        }

        int target_id = item_id % server.io_threads_num;
        listAddNodeTail(io_threads_list[target_id],c);
        item_id++;
    }

    // 設置當前 I/O 操作為寫出操作,給每個 I/O 線程的計數器設置分配的任務數量,
    // 讓 I/O 線程可以開始工作,把寫出緩沖區(client->buf 或 c->reply)中的響應數據回寫到客戶端。
    io_threads_op = IO_THREADS_OP_WRITE;
    for (int j = 1; j < server.io_threads_num; j++) {
        int count = listLength(io_threads_list[j]);
        io_threads_pending[j] = count;
    }

    // 主線程自己也會去執行讀取客戶端請求命令的任務,以達到最大限度利用 CPU。
    listRewind(io_threads_list[0],&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        writeToClient(c,0);
    }
    listEmpty(io_threads_list[0]);

    // 忙輪詢,累加所有 I/O 線程的原子任務計數器,直到所有計數器的遺留任務數量都是 0。
    // 表示所有任務都已經執行完成,結束輪詢。
    while(1) {
        unsigned long pending = 0;
        for (int j = 1; j < server.io_threads_num; j++)
            pending += io_threads_pending[j];
        if (pending == 0) break;
    }
    if (tio_debug) printf("I/O WRITE All threads finshed\n");

    // 最后再遍歷一次 clients_pending_write 隊列,檢查是否還有 client 的寫出緩沖區中有殘留數據,
    // 如果有,那就為 client 注冊一個命令回復器 sendReplyToClient,等待客戶端寫就緒再繼續把數據回寫。
    listRewind(server.clients_pending_write,&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);

        // 檢查 client 的寫出緩沖區是否還有遺留數據。
        if (clientHasPendingReplies(c) &&
                connSetWriteHandler(c->conn, sendReplyToClient) == AE_ERR)
        {
            freeClientAsync(c);
        }
    }
    listEmpty(server.clients_pending_write);

    /* Update processed count on server */
    server.stat_io_writes_processed += processed;

    return processed;
}

這里的核心工作是:

  • 檢查當前任務負載,如果當前的任務數量不足以用多線程模式處理的話,則休眠 I/O 線程并且直接同步將響應數據回寫到客戶端。
  • 喚醒正在休眠的 I/O 線程(如果有的話)。
  • 遍歷待寫出的 client 隊列 clients_pending_write,通過 RR 策略把所有任務分配給 I/O 線程和主線程去將響應數據寫回到客戶端。
  • 忙輪詢等待所有 I/O 線程完成任務。
  • 最后再遍歷 clients_pending_write,為那些還殘留有響應數據的 client 注冊命令回復處理器 sendReplyToClient,等待客戶端可寫之后在事件循環中繼續回寫殘余的響應數據。

I/O 線程主邏輯

void *IOThreadMain(void *myid) {
    /* The ID is the thread number (from 0 to server.iothreads_num-1), and is
     * used by the thread to just manipulate a single sub-array of clients. */
    long id = (unsigned long)myid;
    char thdname[16];

    snprintf(thdname, sizeof(thdname), "io_thd_%ld", id);
    redis_set_thread_title(thdname);
    // 設置 I/O 線程的 CPU 親和性,盡可能將 I/O 線程(以及主線程,不在這里設置)綁定到用戶配置的
    // CPU 列表上。
    redisSetCpuAffinity(server.server_cpulist);
    makeThreadKillable();

    while(1) {
        // 忙輪詢,100w 次循環,等待主線程分配 I/O 任務。
        for (int j = 0; j < 1000000; j++) {
            if (io_threads_pending[id] != 0) break;
        }

        // 如果 100w 次忙輪詢之后如果還是沒有任務分配給它,則通過嘗試加鎖進入休眠,
        // 等待主線程分配任務之后調用 startThreadedIO 解鎖,喚醒 I/O 線程去執行。
        if (io_threads_pending[id] == 0) {
            pthread_mutex_lock(&io_threads_mutex[id]);
            pthread_mutex_unlock(&io_threads_mutex[id]);
            continue;
        }

        serverAssert(io_threads_pending[id] != 0);

        if (tio_debug) printf("[%ld] %d to handle\n", id, (int)listLength(io_threads_list[id]));


        // 注意:主線程分配任務給 I/O 線程之時,
        // 會把任務加入每個線程的本地任務隊列 io_threads_list[id],
        // 但是當 I/O 線程開始執行任務之后,主線程就不會再去訪問這些任務隊列,避免數據競爭。
        listIter li;
        listNode *ln;
        listRewind(io_threads_list[id],&li);
        while((ln = listNext(&li))) {
            client *c = listNodeValue(ln);
            // 如果當前是寫出操作,則把 client 的寫出緩沖區中的數據回寫到客戶端。
            if (io_threads_op == IO_THREADS_OP_WRITE) {
                writeToClient(c,0);
              // 如果當前是讀取操作,則socket 讀取客戶端的請求命令并解析第一條命令。
            } else if (io_threads_op == IO_THREADS_OP_READ) {
                readQueryFromClient(c->conn);
            } else {
                serverPanic("io_threads_op value is unknown");
            }
        }
        listEmpty(io_threads_list[id]);
        // 所有任務執行完之后把自己的計數器置 0,主線程通過累加所有 I/O 線程的計數器
        // 判斷是否所有 I/O 線程都已經完成工作。
        io_threads_pending[id] = 0;

        if (tio_debug) printf("[%ld] Done\n", id);
    }
}

I/O 線程啟動之后,會先進入忙輪詢,判斷原子計數器中的任務數量,如果是非 0 則表示主線程已經給它分配了任務,開始執行任務,否則就一直忙輪詢一百萬次等待,忙輪詢結束之后再查看計數器,如果還是 0,則嘗試加本地鎖,因為主線程在啟動 I/O 線程之時就已經提前鎖住了所有 I/O 線程的本地鎖,因此 I/O 線程會進行休眠,等待主線程喚醒。

主線程會在每次事件循環中嘗試調用 startThreadedIO 喚醒 I/O 線程去執行任務,如果接收到客戶端請求命令,則 I/O 線程會被喚醒開始工作,根據主線程設置的 io_threads_op 標識去執行命令讀取和解析或者回寫響應數據的任務,I/O 線程在收到主線程通知之后,會遍歷自己的本地任務隊列 io_threads_list[id],取出一個個 client 執行任務:

  • 如果當前是寫出操作,則調用 writeToClient,通過 socket 把 client->buf 或者 client->reply 里的響應數據回寫到客戶端。
  • 如果當前是讀取操作,則調用 readQueryFromClient,通過 socket 讀取客戶端命令,存入 client->querybuf,然后調用 processInputBuffer 去解析命令,這里最終只會解析到第一條命令,然后就結束,不會去執行命令。
  • 在全部任務執行完之后把自己的原子計數器置 0,以告知主線程自己已經完成了工作。
void processInputBuffer(client *c) {
// 省略代碼
...

    while(c->qb_pos < sdslen(c->querybuf)) {
        /* Return if clients are paused. */
        if (!(c->flags & CLIENT_SLAVE) && clientsArePaused()) break;

        /* Immediately abort if the client is in the middle of something. */
        if (c->flags & CLIENT_BLOCKED) break;

        /* Don't process more buffers from clients that have already pending
         * commands to execute in c->argv. */
        if (c->flags & CLIENT_PENDING_COMMAND) break;
        /* Multibulk processing could see a <= 0 length. */
        if (c->argc == 0) {
            resetClient(c);
        } else {
            // 判斷 client 是否具有 CLIENT_PENDING_READ 標識,如果是處于多線程 I/O 的模式下,
            // 那么此前已經在 readQueryFromClient -> postponeClientRead 中為 client 打上該標識,
            // 則立刻跳出循環結束,此時第一條命令已經解析完成,但是不執行命令。
            if (c->flags & CLIENT_PENDING_READ) {
                c->flags |= CLIENT_PENDING_COMMAND;
                break;
            }

            // 執行客戶端命令
            if (processCommandAndResetClient(c) == C_ERR) {
                /* If the client is no longer valid, we avoid exiting this
                 * loop and trimming the client buffer later. So we return
                 * ASAP in that case. */
                return;
            }
        }
    }

...
}

這里需要額外關注 I/O 線程初次啟動時會設置當前線程的 CPU 親和性,也就是綁定當前線程到用戶配置的 CPU 上,在啟動 Redis 服務器主線程的時候同樣會設置 CPU 親和性,Redis 的核心網絡模型引入多線程之后,加上之前的多線程異步任務、多進程(BGSAVE、AOF、BIO、Sentinel 腳本任務等),Redis 現如今的系統并發度已經很大了,而 Redis 本身又是一個對吞吐量和延遲極度敏感的系統,所以用戶需要 Redis 對 CPU 資源有更細粒度的控制,這里主要考慮的是兩方面:CPU 高速緩存和 NUMA 架構。

首先是 CPU 高速緩存(這里討論的是 L1 Cache 和 L2 Cache 都集成在 CPU 中的硬件架構),這里想象一種場景:Redis 主進程正在 CPU-1 上運行,給客戶端提供數據服務,此時 Redis 啟動了子進程進行數據持久化(BGSAVE 或者 AOF),系統調度之后子進程搶占了主進程的 CPU-1,主進程被調度到 CPU-2 上去運行,導致之前 CPU-1 的高速緩存里的相關指令和數據被汰換掉,CPU-2 需要重新加載指令和數據到自己的本地高速緩存里,浪費 CPU 資源,降低性能。

因此,Redis 通過設置 CPU 親和性,可以將主進程/線程和子進程/線程綁定到不同的核隔離開來,使之互不干擾,能有效地提升系統性能。

其次是基于 NUMA 架構的考慮,在 NUMA 體系下,內存控制器芯片被集成到處理器內部,形成 CPU 本地內存,訪問本地內存只需通過內存通道而無需經過系統總線,訪問時延大大降低,而多個處理器之間通過 QPI 數據鏈路互聯,跨 NUMA 節點的內存訪問開銷遠大于本地內存的訪問:

因此,Redis 通過設置 CPU 親和性,讓主進程/線程盡可能在固定的 NUMA 節點上的 CPU 上運行,更多地使用本地內存而不需要跨節點訪問數據,同樣也能大大地提升性能。

關于 NUMA 相關知識請讀者自行查閱,篇幅所限這里就不再展開,以后有時間我再單獨寫一篇文章介紹。

最后還有一點,閱讀過源碼的讀者可能會有疑問,Redis 的多線程模式下,似乎并沒有對數據進行鎖保護,事實上 Redis 的多線程模型是全程無鎖(Lock-free)的,這是通過原子操作+交錯訪問來實現的,主線程和 I/O 線程之間共享的變量有三個:io_threads_pending 計數器、io_threads_op I/O 標識符和 io_threads_list 線程本地任務隊列。

io_threads_pending 是原子變量,不需要加鎖保護,io_threads_opio_threads_list 這兩個變量則是通過控制主線程和 I/O 線程交錯訪問來規避共享數據競爭問題:I/O 線程啟動之后會通過忙輪詢和鎖休眠等待主線程的信號,在這之前它不會去訪問自己的本地任務隊列 io_threads_list[id],而主線程會在分配完所有任務到各個 I/O 線程的本地隊列之后才去喚醒 I/O 線程開始工作,并且主線程之后在 I/O 線程運行期間只會訪問自己的本地任務隊列 io_threads_list[0] 而不會再去訪問 I/O 線程的本地隊列,這也就保證了主線程永遠會在 I/O 線程之前訪問 io_threads_list 并且之后不再訪問,保證了交錯訪問。io_threads_op 同理,主線程會在喚醒 I/O 線程之前先設置好 io_threads_op 的值,并且在 I/O 線程運行期間不會再去訪問這個變量。

性能提升

Redis 將核心網絡模型改造成多線程模式追求的當然是最終性能上的提升,所以最終還是要以 benchmark 數據見真章:

測試數據表明,Redis 在使用多線程模式之后性能大幅提升,達到了一倍。更詳細的性能壓測數據可以參閱這篇文章:Benchmarking the experimental Redis Multi-Threaded I/O。

以下是美圖技術團隊實測的新舊 Redis 版本性能對比圖,僅供參考:

模型缺陷

首先第一個就是我前面提到過的,Redis 的多線程網絡模型實際上并不是一個標準的 Multi-Reactors/Master-Workers 模型,和其他主流的開源網絡服務器的模式有所區別,最大的不同就是在標準的 Multi-Reactors/Master-Workers 模式下,Sub Reactors/Workers 會完成 網絡讀 -> 數據解析 -> 命令執行 -> 網絡寫 整套流程,Main Reactor/Master 只負責分派任務,而在 Redis 的多線程方案中,I/O 線程任務僅僅是通過 socket 讀取客戶端請求命令并解析,卻沒有真正去執行命令,所有客戶端命令最后還需要回到主線程去執行,因此對多核的利用率并不算高,而且每次主線程都必須在分配完任務之后忙輪詢等待所有 I/O 線程完成任務之后才能繼續執行其他邏輯。

Redis 之所以如此設計它的多線程網絡模型,我認為主要的原因是為了保持兼容性,因為以前 Redis 是單線程的,所有的客戶端命令都是在單線程的事件循環里執行的,也因此 Redis 里所有的數據結構都是非線程安全的,現在引入多線程,如果按照標準的 Multi-Reactors/Master-Workers 模式來實現,則所有內置的數據結構都必須重構成線程安全的,這個工作量無疑是巨大且麻煩的。

所以,在我看來,Redis 目前的多線程方案更像是一個折中的選擇:既保持了原系統的兼容性,又能利用多核提升 I/O 性能。

其次,目前 Redis 的多線程模型中,主線程和 I/O 線程的通信過于簡單粗暴:忙輪詢和鎖,因為通過自旋忙輪詢進行等待,導致 Redis 在啟動的時候以及運行期間偶爾會有短暫的 CPU 空轉引起的高占用率,而且這個通信機制的最終實現看起來非常不直觀和不簡潔,希望后面 Redis 能對目前的方案加以改進。

總結

Redis 作為緩存系統的事實標準,它的底層原理值得開發者去深入學習,Redis 自 2009 年發布第一版之后,其單線程網絡模型的選擇在社區中從未停止過討論,多年來一直有呼聲希望 Redis 能引入多線程從而利用多核優勢,但是作者 antirez 是一個追求大道至簡的開發者,對 Redis 加入任何新功能都異常謹慎,所以在 Redis 初版發布的十年后才最終將 Redis 的核心網絡模型改造成多線程模式,這期間甚至誕生了一些 Redis 多線程的替代項目。雖然 antirez 一直在推遲多線程的方案,但卻從未停止思考多線程的可行性,Redis 多線程網絡模型的改造不是一朝一夕的事情,這其中牽扯到項目的方方面面,所以我們可以看到 Redis 的最終方案也并不完美,沒有采用主流的多線程模式設計。

讓我們來回顧一下 Redis 多線程網絡模型的設計方案:

  • 使用 I/O 線程實現網絡 I/O 多線程化,I/O 線程只負責網絡 I/O 和命令解析,不執行客戶端命令。
  • 利用原子操作+交錯訪問實現無鎖的多線程模型。
  • 通過設置 CPU 親和性,隔離主進程和其他子進程,讓多線程網絡模型能發揮最大的性能。

通讀本文之后,相信讀者們應該能夠了解到一個優秀的網絡系統的實現所涉及到的計算機領域的各種技術:設計模式、網絡 I/O、并發編程、操作系統底層,甚至是計算機硬件。另外還需要對項目迭代和重構的謹慎,對技術方案的深入思考,絕不僅僅是寫好代碼這一個難點。

參考&延伸閱讀

查看原文

贊 23 收藏 22 評論 1

panjf2000 發布了文章 · 1月3日

分布式事務/系統之底層原理揭秘

博客原文

分布式事務/系統之底層原理揭秘

導言

分布式事務是分布式系統必不可少的組成部分,基本上只要實現一個分布式系統就逃不開對分布式事務的支持。本文從分布式事務這個概念切入,嘗試對分布式事務最核心的底層原理逐一進行剖析,內容包括但不限于 BASE 原則、兩階段原子提交協議、三階段原子提交協議、Paxos/Multi-Paxos 分布式共識算法的原理與證明、Raft 分布式共識算法分布式事務的并發控制等內容。

事務

事務是訪問并可能更新各種數據項的一個程序執行單元(unit)。事務由一個或多個步驟組成,一般使用形如 begin transactionend transaction 語句或者函數調用作為事務界限,事務內的所有步驟必須作為一個單一的、不可分割的單元去執行,因此事務的結果只有兩種:1. 全部步驟都執行完成,2. 任一步驟執行失敗則整個事務回滾。

事務最早由數據庫管理系統(database management system,DBMS)引入并實現,數據庫事務是數據庫管理系統執行過程中的一個邏輯單位,由一個有限的數據庫操作序列構成。數據庫事務嚴格遵循 ACID 原則,屬于剛性事務,一開始數據庫事務僅限于對單一數據庫資源對象的訪問控制,這一類事務稱之為本地事務 (Local Transaction),后來隨著分布式系統的出現,數據的存儲也不可避免地走向了分布式,分布式事務(Distributed Transaction)便應運而生。

剛性事務

剛性事務(如單一數據庫事務)完全遵循 ACID 規范,即數據庫事務的四大基本特性:

  • Atomicity(原子性):一個事務(transaction)中的所有操作,或者全部完成,或者全部不完成,不會結束在中間某個環節。事務在執行過程中發生錯誤,會被回滾(Rollback)到事務開始前的狀態,就像這個事務從來沒有執行過一樣。即,事務不可分割、不可約簡。
  • Consistency(一致性):在事務開始之前和事務結束以后,數據庫的完整性沒有被破壞。這表示寫入的資料必須完全符合所有的預設約束、觸發器、級聯回滾等。
  • Isolation(隔離性):數據庫允許多個并發事務同時對其數據進行讀寫和修改的能力,隔離性可以防止多個事務并發執行時由于交叉執行而導致數據的不一致。事務隔離分為不同級別,包括未提交讀(Read uncommitted)、提交讀(read committed)、可重復讀(repeatable read)和串行化(Serializable)。
  • Durability(持久性):事務處理結束后,對數據的修改就是永久的,即便系統故障也不會丟失。

剛性事務也能夠以分布式 CAP 理論中的 CP 事務來作為定義。

柔性事務

在電商領域等互聯網場景下,傳統的事務在數據庫性能和處理能力上都遇到了瓶頸。因此,柔性事務被提了出來,柔性事務基于分布式 CAP 理論以及延伸出來的 BASE 理論,相較于數據庫事務這一類完全遵循 ACID 的剛性事務來說,柔性事務保證的是 “基本可用,最終一致”,CAP 原理相信大家都很熟悉了,這里我們講一下 BASE 原則:

  • 基本可用(Basically Available):系統能夠基本運行、一直提供服務。
  • 軟狀態(Soft-state):系統不要求一直保持強一致狀態。
  • 最終一致性(Eventual consistency):系統需要在某一時刻后達到一致性要求。

柔性事務(如分布式事務)為了滿足可用性、性能與降級服務的需要,降低一致性(Consistency)與隔離性(Isolation)的要求,遵循 BASE 理論,傳統的 ACID 事務對隔離性的要求非常高,在事務執行過程中,必須將所有的資源對象鎖定,因此對并發事務的執行極度不友好,柔性事務(比如分布式事務)的理念則是將鎖資源對象操作從本地資源對象層面上移至業務邏輯層面,再通過放寬對強一致性要求,以換取系統吞吐量的提升。

此外,雖然柔性事務遵循的是 BASE 理論,但是還需要遵循部分 ACID 規范:

  • 原子性:嚴格遵循。
  • 一致性:事務完成后的一致性嚴格遵循;事務中的一致性可適當放寬。
  • 隔離性:并行事務間不可影響;事務中間結果可見性允許安全放寬。
  • 持久性:嚴格遵循。

本地事務

本地事務(Local Transaction)指的是僅僅對單一節點/數據庫資源對象進行訪問/更新的事務,在這種事務模式下,BASE 理論派不上用場,事務完全遵循 ACID 規范,確保事務為剛性事務。

分布式事務

在分布式架構成為主流的當下,系統對資源對象的訪問不能還局限于單節點,多服務器、多節點的資源對象訪問成為剛需,因此,本地事務無法滿足分布式架構的系統的要求,分布式事務應運而生。

訪問/更新由多個服務器管理的資源對象的平面事務或者嵌套事務稱之為分布式事務(Distributed Transaction),分布式事務是相對于本地事務來說的。

平面事務:單一事務,訪問多個服務器節點的資源對象,一個平面事務完成一次請求之后才能發起下一個請求。

嵌套事務:多事務組成,頂層事務可以不斷創建子事務,子事務又可以進一步地以任意深度嵌套子事務。

對于分布式事務來說,有兩個最核心的問題:

  1. 如何管理分布式事務的提交/放棄決定?如果事務中的一個節點在執行自己的本地事務過程中遇到錯誤,希望放棄整個分布式事務,與此同時其他節點則在事務執行過程中一切順利,希望提交這個分布式事務,此時我們應該如何做決策?
  2. 如何保證并發事務在涉及多個節點上資源對象訪問的可串行性(規避分布式死鎖)?如果事務 T 對某一個服務器節點上的資源對象 S 的并發訪問在事務 U 之前,那么我們需要保證在所有服務器節點上對 S 和其他資源對象的沖突訪問,T 始終在 U 之前。

問題 1 的解決需要引入一類分布式原子提交協議的算法如兩階段提交協議等,來對分布式事務過程中的提交或放棄決策進行管理,并確保分布式提交的原子性。而問題 2 則由分布式事務的并發控制機制來處理。

原子提交協議

原子性是分布式事務的前置性約束,沒有原子性則分布式事務毫無意義。

原子性約束要求在分布式事務結束之時,它的所有操作要么全部執行,要么全部不執行。以分布式事務的原子性來分析,客戶端請求訪問/更新多個服務器節點上的資源對象,在客戶端提交或放棄該事務從而結束事務之后,多個服務器節點的最終狀態要么是該事務里的所有步驟都執行成功之后的狀態,要么恢復到事務開始前的狀態,不存在中間狀態。滿足這種約束的分布式事務協議則稱之為原子提交協議。

當一個分布式事務結束時,事務的原子特性要求所有參與該事務的服務器節點必須全部提交或者全部放棄該事務,為了實現這一點,必須引入一個協調者(Coordinator)的角色,從參與事務的所有服務器節點中挑選一個作為協調者,由它來保證在所有服務器節點上最終獲得同樣的結果。協調者的工作原理取決于分布式事務選用的協議。

一般來說,分布式事務中包含的兩個最基礎的角色就是:

  • Coordinator -- 協調者
  • Participants -- 參與者

單階段原子提交協議

單階段原子提交協議(one-phase atomic commit protocol, 1APC)是最簡單的一種原子提交協議,它通過設置一個協調者并讓它不斷地向所有參與者發送提交(commit)或放棄(abort)事務的請求,直到所有參與者確認已執行完相應的操作。

1APC 協議的優點是簡單易用,對一些事務不復雜的場景比較合適,但在復雜事務場景則顯得捉襟見肘,因為該協議不允許任何服務器節點單方面放棄事務,事務的放棄必須由協調者來發起,這個設計會導致很多問題:首先因為只有一次通信,協調者并不會收集所有參與者的本地事務執行的情況,所以協調者決定提交還是放棄事務只基于自己的判斷,在參與者執行事務期間可能會遇到錯誤從而導致最終事務未能真正提交,錯誤一般與事務的并發控制有關,比如事務執行期間對資源對象加鎖,遇到死鎖,需要放棄事務從而解開死鎖,而協調者并不知道,因此在發起下一個請求之前,客戶端完全不知道事務已被放棄。另一種情況就是利用樂觀并發控制機制訪問資源對象,某一個服務器節點的驗證失敗將導致事務被放棄,而協調者完全不知情。

兩階段提交協議

定義

兩階段提交協議(two-phase commit protocol, 2PC)的設計初衷是為了解決 1APC 不允許任意一個服務器節點自行放棄它自己的那部分本地事務的痛點,2PC 允許任何一個參與者自行決定要不要放棄它的本地事務,而由于原子提交協議的約束,任意一個本地事務被放棄將導致整個分布式事務也必須放棄掉。

兩階段提交協議基于以下幾個假設:

  • 存在一個節點作為協調者(Coordinator),分布式事務通常由協調者發起(當然也可以由參與者發起),其余節點作為參與者(Participants),且節點之間可以自由地進行網絡通信,協調者負責啟動兩階段提交流程以及決定事務最終是被提交還是放棄。
  • 每個節點會記錄該節點上的本地操作日志(op logs),日志必須持久化在可靠的存儲設備上(比如磁盤),以便在節點重啟之后需要恢復操作日志。另外,不記錄全局操作日志。
  • 所有節點不能發生永久性損壞,也就是說節點就算是損壞了也必須能通過可靠性存儲恢復如初,不允許出現數據永久丟失的情況。
  • 參與者對協調者的回復必須要去除掉那些受損和重復的消息。
  • 整個集群不會出現拜占庭故障(Byzantine Fault)-- 服務器要么崩潰,要么服從其發送的消息。

原理

兩階段提交協議,顧名思義整個過程需要分為兩個階段:

  1. 準備階段(Prepare Phase)
  2. 提交階段(Commit Phase)

在進行兩階段提交的過程中,協調者會在以下四種狀態間流轉:

  1. init
  2. preparing
  3. committed
  4. aborted

而參與者則會在以下三種狀態間流轉:

  1. working
  2. prepared
  3. committed

階段 I(投票表決階段)

  1. 任意一個參與者發起分布式事務 T 并執行本地事務成功,接著將一條 <ready T> 記錄追加到本地日志 buffer 中并 flush 到可靠性存儲設備如磁盤上,從 working 狀態進入 prepared 狀態,然后向協調者發送 prepare T 消息;
  2. 收到參與者發來的 prepare T 消息后,協調者將一條 <prepare T> 記錄追加到日志中,然后從 init 狀態進入 preparing 狀態,緊接著向分布式事務的其他參與者發出 canCommit? 消息,發起事務表決過程;
  3. 當參與者收到 canCommit? 請求后,除了發起事務的那一個之外,其他還在 working 狀態的參與者會先嘗試執行本地事務,如果本地事務執行成功,則會往本地日志 buffer 寫入一條 <ready T> 記錄并 flush 到可靠性存儲中,但不提交事務,進入 prepared 狀態,然后回復一條 ready T 消息對此事務投 YES 票;如果本地事務執行失敗,則參與者會往本地日志 buffer 寫入一條 <don't commit T> 記錄并 flush 到可靠性存儲中,然后回復一條 don't commit T 消息投 NO 票。

階段 II(收集投票結果完成事務)

  1. 協調者收集所有的投票(包括它自己的投票);

    (a) 如果所有的投票都是 ready T,則表示沒有故障發生,那么協調者決定提交該事務,首先它會在其本地日志中追加一條 <commit T> 記錄,從 preparing 狀態進入 committed 狀態,然后向所有的參與者發送 doCommit 請求消息,要求參與者提交它們的本地事務;

    (b) 如果有任一個投票是 No,則協調者決定放棄掉該事務,首先它會往本地日志中追加一條 <abort T> 記錄,從 preparing 狀態進入 aborted 狀態,然后發送 doAbort 請求消息給所有的參與者,通知它們回滾各自的本地事務。

  2. 投了 YES 票的參與者阻塞等待協調者給它發來 doCommitdoAbort 消息,如果接收到的是 doCommit 消息則提交本地事務并在此過程中記錄日志 <commit T>,然后進入 committed 狀態,最后回復一個 haveCommitted 的消息通知協調者本地事務已經成功提交;反之,如果收到的是 doAbort 消息則回滾本地事務并寫入日志 <abort T>,然后進入 aborted狀態。

上面的過程是一種更通用的流程,即由任意的參與者發起一個分布式事務,而在實踐中一般把分布式事務的發起交給協調者來做,減少事務發起者確認該事務已被提交所需等待的網絡消息延遲:

性能

網絡 I/O 開銷

假設兩階段提交過程一切運行正常,即協調者和參與者都不出現崩潰和重啟,網絡通信也都正常。那么假設有一個協調者和 N 個參與者,兩階段提交過程中將會發送如下的消息:

  • 任意一個參與者從 working 狀態進入 prepared 狀態并發送 Prepared 消息給協調者,1 條消息。
  • 協調者收到消息后,向其他參與者發送 canCommit? 請求消息,N - 1 條消息。
  • 收到 canCommit? 消息的參與者各自回復協調者投票消息,N - 1 條消息。
  • 協調者統計投票情況之后,發送 doCommit 消息給其他參與者,N 條消息。

所以,事務發起者在經過 4 條網絡消息延遲之后確認該分布式事務已被提交,而整個過程共計發送 3N - 1 條網絡消息(因為 haveCommitted 在 2PC 僅僅是用于最后通知協調者而已,屬于可有可無的一次網絡消息,2PC 在該消息缺省的情況下也能正常運行,因此 haveCommitted 一般不計入網絡延遲成本中)。

前面我們提到,在實踐中一般是由協調者來發起事務,如果考慮這種情況的話,事務發起者 -- 協調者在經過 3 條網絡消息延遲之后確認該分布式事務已經被提交,而整個過程實際發送的網絡消息則變成 3N 條。

總而言之,兩階段提交協議的網絡通信開銷和集群節點的數量成 3 倍正比。

本地存儲設備 I/O 開銷

基于前文中敘述的兩階段提交協議的基本假設之一:每個節點會通過日志來記錄在本地執行的操作,以便在節點發生故障并重啟節點之后能利用日志恢復到故障前的狀態,因此兩階段提交過程中除了網絡 I/O 的開銷之外,還有本地存儲設備 I/O 的開銷:

  • 發起事務的參與者執行本地事務,1 次寫操作。
  • 其余參與者執行各自的本地事務,N - 1 次寫操作。
  • 協調者統計投票結果并決定提交事務,1 次寫操作。

所以事務發起者在經過 3 次本地存儲設備 I/O 延遲之后確認該事務已被提交,整個過程總計有 N + 1 次本地存儲設備 I/O,而如果由協調者來發起事務的話,則還是需要 N + 1 次本地存儲設備 I/O,但是只需要經過 2 次本地存儲設備 I/O 延遲即可確認事務已被提交。

恢復

在分布式事務中,所有的參與者節點都可能發生故障,所以我們需要保證在該故障節點恢復時發生的一切都和分布式事務 T 的全局決策保持一致。節點在恢復的時候會讀取 T 的最后一個本地日志記錄并作出相應的操作:

  1. 如果 T 的最后一條日志記錄是 <commit T>,那么說明協調者在節點發生故障時的全局決策是提交 T,根據本地事務所使用的日志方式,在該節點上可能需要執行 redo T。
  2. 如果 T 的最后一條日志記錄是 <abort T>,那么說明協調者在節點發生故障時的全局決策是中止 T,根據本地事務所使用的日志方式,在該節點上可能需要執行 undo T。
  3. 如果 T 的最后一條日志記錄是 <don't commit T>,則和第 2 中情況類似,執行 undo T。
  4. 如果 T 的最后一條日志記錄是 <ready T>,這種情況比較麻煩,因為恢復節點無法確認在它故障之后協調者發出的最終全局決策是什么,因此它必須要和集群中其余至少一個節點取得聯系,詢問 T 的最終結果是什么:恢復節點先嘗試詢問協調者,如果此時協調者正在工作,則告知恢復節點 T 的最終結果,如果是提交就執行 redo T,中止就執行 undo T;如果協調者因故不在工作,則恢復節點可以要求其他某一個參與者節點去查看本地日志以找出 T 的最終結果并告知恢復節點。在最壞的情況下,恢復節點無法和集群中其他所有節點取得聯系,這時恢復節點只能阻塞等待,直至得知 T 的最終結果是提交還是中止。
  5. 如果本地日志中沒有記錄任何關于 T 在兩階段提交過程中的操作,那么根據前面的兩階段提交流程可知恢復節點還沒來得及回復協調者的 canCommit? 請求消息就發生了故障,因此根據兩階段算法,恢復節點只能執行 undo T。

缺陷

  1. 同步阻塞:兩階段提交協議是一個阻塞的協議,在第二階段期間,參與者在事務未提交之前會一直鎖定其占有的本地資源對象,直到接收到來自協調者的 doCommitdoAbort 消息。
  2. 單點故障:兩階段提交協議中只有一個協調者,而由于在第二階段中參與者在收到協調者的進一步指示之前會一直鎖住本地資源對象,如果唯一的協調者此時出現故障而崩潰掉之后,那么所有參與者都將無限期地阻塞下去,也就是一直鎖住本地資源對象而導致其他進程無法使用。
  3. 數據不一致:如果在兩階段提交協議的第二階段中,協調者向所有參與者發送 doCommit 消息之后,發生了局部網絡抖動或者異常,抑或是協調者在只發送了部分消息之后就崩潰了,那么就只會有部分參與者接收到了 doCommit 消息并提交了本地事務;其他未收到 doCommit 消息的參與者則不會提交本地事務,因而導致了數據不一致問題。

XA 標準接口

2PC 兩階段提交協議本身只是一個通用協議,不提供具體的工程實現的規范和標準,在工程實踐中為了統一標準,減少行業內不必要的對接成本,需要制定標準化的處理模型及接口標準,國際開放標準組織 Open Group 定義了分布式事務處理模型 DTP(Distributed Transaction Processing)Model,現在 XA 已經成為 2PC 分布式事務提交的事實標準,很多主流數據庫如 Oracle、MySQL 等都已經實現 XA。

兩階段事務提交采用的是 X/OPEN 組織所定義的 DTP Model 所抽象的 AP(應用程序), TM(事務管理器)和 RM(資源管理器) 概念來保證分布式事務的強一致性。 其中 TM 與 RM 間采用 XA 的協議進行雙向通信。 與傳統的本地事務相比,XA 事務增加了準備階段,數據庫除了被動接受提交指令外,還可以反向通知調用方事務是否可以被提交。 TM 可以收集所有分支事務的準備結果,并于最后進行原子提交,以保證事務的強一致性。

Java 通過定義 JTA 接口實現了 XA 模型,JTA 接口中的 ResourceManager 需要數據庫廠商提供 XA 驅動實現, TransactionManager 則需要事務管理器的廠商實現,傳統的事務管理器需要同應用服務器綁定,因此使用的成本很高。 而嵌入式的事務管器可以以 jar 包的形式提供服務,同 Apache ShardingSphere 集成后,可保證分片后跨庫事務強一致性。

通常,只有使用了事務管理器廠商所提供的 XA 事務連接池,才能支持 XA 的事務。Apache ShardingSphere 在整合 XA 事務時,采用分離 XA 事務管理和連接池管理的方式,做到對應用程序的零侵入。

三階段提交協議

由于前文提到的兩階段提交協議的種種弊端,研究者們后來又提出了一種新的分布式原子提交協議:三階段提交協議(three-phase commit protocol, 3PC)。

三階段提交協議是對兩階段提交協議的擴展,它在特定假設下避免了同步阻塞的問題。該協議基于以下兩個假設:

  1. 集群不發生網絡分區;
  2. 故障節點數不超過 K 個(K 是預先設定的一個數值)。

基于這兩個假設,三階段提交協議通過引入超時機制和一個額外的階段來解決阻塞問題,三階段提交協議把兩階段提交協議的第一個階段拆分成了兩步:1) 評估,2) 資源對象加鎖,最后才真正提交:

  1. CanCommit 階段:協調者發送 CanCommit 請求消息,詢問各個參與者節點,參與者節點各自評估本地事務是否可以執行并回復消息(可以執行則回復 YES,否則回復 NO),此階段不執行事務,只做判斷;
  2. PreCommit 階段:協調者根據上一階段收集的反饋決定通知各個參與者節點執行(但不提交)或中止本地事務;有兩種可能:1) 所有回復都是 YES,則發送 PreCommit 請求消息,要求所有參與者執行事務并追加記錄到 undo 和 redo 日志,如果事務執行成功則參與者回復 ACK 響應消息,并等待下一階段的指令;2) 反饋消息中只要有一個 NO,或者等待超時之后協調者都沒有收到參與者的回復,那么協調者會中止事務,發送 Abort 請求消息給所有參與者,參與者收到該請求后中止本地事務,或者參與者超時等待仍未收到協調者的消息,同樣也中止當前本地事務。
  3. DoCommit 階段:協調者根據上一階段收集到的反饋決定通知各個參與者節點提交或回滾本地事務,分三種情況:1) 協調者收到全部參與者回復的 ACK,則向所有參與者節點廣播 DoCommit 請求消息,各個參與者節點收到協調者的消息之后決定提交事務,然后釋放資源對象上的鎖,成功之后向協調者回復 ACK,協調者接收到所有參與者的 ACK 之后,將該分布式事務標記為 committed;2) 協調者沒有收到全部參與者回復的 ACK(可能參與者回復的不是 ACK,也可能是消息丟失導致超時),那么協調者就會中止事務,首先向所有參與者節點廣播 Abort 請求消息,各個參與者收到該消息后利用上一階段的 undo 日志進行事務的回滾,釋放占用的資源對象,然后回復協調者 ACK 消息,協調者收到參與者的 ACK 消息后將該分布式事務標記為 aborted;3) 參與者一直沒有收到協調者的消息,等待超時之后會直接提交事務。

事實上,在最后階段,協調者不是通過追加本地日志的方式記錄提交決定的,而是首先保證讓至少 K 個參與者節點知道它決定提交該分布式事務。如果協調者發生故障了,那么剩下的參與者節點會重新選舉一個新的協調者,這個新的協調者就可以在集群中不超過 K 個參與者節點故障的情況下學習到舊協調者之前是否已經決定要提交分布式事務,若是,則重新開始協議的第三階段,否則就中止該事務,重新發起分布式事務。

在最后的 DoCommit 階段,如果參與者一直沒有收到協調者的 DoCommit 或者 Abort 請求消息時,會在等待超時之后,直接提交事務。這個決策機制是基于概率學的:當已經進入第三階段之后,說明參與者在第二階段已經收到了 PreCommit 請求消息,而協調者發出 PreCommit 請求的前提條件是它在第二階段開頭收集到的第一階段向所有參與者發出的 CanCommit 請求消息的反饋消息都是 YES。所以參與者可以根據自己收到了 PreCommit 請求消息這一既定事實得出這樣的一個結論:其他所有參與者都同意了進行這次的事務執行,因此當前的參與者節點有理由相信,進入第三階段后,其他參與者節點的本地事務最后成功提交的概率很大,而自己遲遲沒有收到 DoCommitAbort 消息可能僅僅是因為網絡抖動或異常,因此直接提交自己的本地事務是一個比較合理的選擇。

三階段提交協議主要著重于解決兩階段提交協議中因為協調者單點故障而引發的同步阻塞問題,雖然相較于兩階段提交協議有所優化,但還是沒解決可能發生的數據不一致問題,比如由于網絡異常導致部分參與者節點沒有收到協調者的 Abort 請求消息,超時之后這部分參與者會直接提交事務,從而導致集群中的數據不一致,另外三階段提交協議也無法解決腦裂問題,同時也因為這個協議的網絡開銷問題,導致它并沒有被廣泛地使用,有關該協議的具體細節可以參閱本文最后的延伸閱讀一節中的文獻進一步了解,這里不再深入。

共識算法

共識(Consensus),很多時候會見到與一致性(Consistency)術語放在一起討論。嚴謹地講,兩者的含義并不完全相同。

一致性的含義比共識寬泛,在不同場景(基于事務的數據庫、分布式系統等)下意義不同。具體到分布式系統場景下,一致性指的是多個副本對外呈現的狀態。如前面提到的順序一致性、線性一致性,描述了多節點對數據狀態的共同維護能力。而共識,則特指在分布式系統中多個節點之間對某個事情(例如多個事務請求,先執行誰?)達成一致意見的過程。因此,達成某種共識并不意味著就保障了一致性。

實踐中,要保證系統滿足不同程度的一致性,往往需要通過共識算法來達成。

共識算法解決的是分布式系統對某個提案(Proposal),大部分節點達成一致意見的過程。提案的含義在分布式系統中十分寬泛,如多個事件發生的順序、某個鍵對應的值、誰是主節點……等等??梢哉J為任何可以達成一致的信息都是一個提案。

對于分布式系統來講,各個節點通常都是相同的確定性狀態機模型(又稱為狀態機復制問題,State-Machine Replication),從相同初始狀態開始接收相同順序的指令,則可以保證相同的結果狀態。因此,系統中多個節點最關鍵的是對多個事件的順序進行共識,即排序。

算法共識/一致性算法有兩個最核心的約束:1) 安全性(Safety),2) 存活性(Liveness):

  • Safety:保證決議(Value)結果是對的,無歧義的,不會出現錯誤情況。

    • 只有是被提案者提出的提案才可能被最終批準;
    • 在一次執行中,只批準(chosen)一個最終決議。被多數接受(accept)的結果成為決議;
  • Liveness:保證決議過程能在有限時間內完成。

    • 決議總會產生,并且學習者最終能獲得被批準的決議。

Paxos

Google Chubby 的作者 Mike Burrows 說過, there is only one consensus protocol, and that’s Paxos” – all other approaches are just broken versions of Paxos.

意即世上只有一種共識算法,那就是 Paxos,其他所有的共識算法都只是 Paxos 算法的殘缺版本。雖然有點武斷,但是自從 Paxos 問世以來,它便幾乎成為了分布式共識算法的代名詞,后來的許多應用廣泛的分布式共識算法如 Raft、Zab 等的原理和思想都可以溯源至 Paxos 算法。

Paxos 是由 Leslie Lamport (LaTeX 發明者,圖靈獎得主,分布式領域的世界級大師) 在 1990 年的論文《The PartTime Parliament》里提出的,Lamport 在論文中以一個古希臘的 Paxos 小島上的議會制訂法律的故事切入,引出了 Paxos 分布式共識算法。

Basic Paxos

業界一般將 Lamport 論文里最初提出分布式算法稱之為 Basic Paxos,這是 Paxos 最基礎的算法思想。

Basic Paxos 算法的最終目標是通過嚴謹和可靠的流程來使得集群基于某個提案(Proposal)達到最終的共識。

基礎概念

  • Value:提案值,是一個抽象的概念,在工程實踐中可以是任何操作,如『更新數據庫某一行的某一列』、『選擇 xxx 服務器節點作為集群中的主節點』。
  • Number:提案編號,全局唯一,單調遞增。
  • Proposal:集群需要達成共識的提案,提案 = 編號 + 值。

Proposal 中的 Value 就是在 Paxos 算法完成之后需要達成共識的值。

Paxos 算法中有三個核心角色:

  • Proposer:生成提案編號 n 和值 v,然后向 Acceptors 廣播該提案,接收 Acceptors 的回復,如果有超過半數的 Acceptors 同意該提案,則選定該提案,否則放棄此次提案并生成更新的提案重新發起流程,提案被選定之后則通知所有 Learners 學習該最終選定的提案值(也可以由 Acceptor 來通知,看具體實現)。Basic Paxos 中允許有多個 Proposers。
  • Acceptor:接收 Proposer 的提案并參與提案決策過程,把各自的決定回復給 Proposer 進行統計。Acceptor 可以接受來自多個 proposers 的多個提案。
  • Learner:不參與決策過程,只學習最終選定的提案值。

在具體的工程實踐中,一個節點往往會充當多種角色,比如一個節點可以既是 Proposer 又是 Acceptor,甚至還是 Learner。

算法流程

相較于直接給出 Paxos 算法的流程,我想沿襲 Lamport 大師的經典 Paxos 論文《Paxos Made Simple》中的思路:通過循序漸進的方式推導出 Paxos 算法。

首先需要了解 Paxos 算法中的兩個重要的約束:

C1. 一個 Acceptor 必須接受它收到的第一個提案。

C2. 只有當超過半數的 Acceptors 接受某一個提案,才能最終選定該提案。

C2 其實有一個隱含的推論:一個 Acceptor 可以接受多個提案,這也是為什么我們需要給每一個提案生成一個編號的原因,用來給提案排序。

我們前面提到過 Paxos 的最終目標是通過嚴謹和可靠的流程來使得集群基于某個提案(Proposal)達到最終的共識,也就是說基于某一個提案發起的一次 Paxos 流程,最終目的是希望集群對該提案達成一致的意見,而為了實現并維持集群中的這種一致性,前提是 Paxos 算法必須具有冪等性:一旦提案(Proposal)中的值(Value)被選定(Chosen),那么只要還在此次 Paxos 流程中,就算不斷按照 Paxos 的規則重復步驟,未來被 Chosen 的 Value 都會是同一個。如果不滿足這種冪等性,將可能導致不一致的問題。

因此,我們可以把 Paxos 的基本命題提煉出來:

P1. 在一次 Paxos 流程中,如果一個值(Value)為 v 的提案(Proposal)被選定(Chosen)了,那么后續任何被最終選定的帶有更大編號(Number)的提案中的 Value 也必須是 v。

提案在被最終選定之前必須先被 Acceptor 接受,于是我們可以再進一步總結一個具有更強約束的命題:

P2. 在一次 Paxos 流程中,如果一個值(Value)為 v 的提案(Proposal)被選定(Chosen)了,那么后續任何被 Acceptor 接受的帶有更大編號(Number)的提案中的 Value 也必須是 v。

這還不是具備最強約束的命題,因為提案在被 Acceptor 接受之前必須先由 Proposer 提出,因此還可以繼續強化命題:

P3. 在一次 Paxos 流程中,如果一個值(Value)為 v 的提案(Proposal)被選定(Chosen)了,那么后續任何 Proposer 提議的帶有更大編號(Number)的提案中的 Value 也必須是 v。

從上述的三個命題,我們可以很容易地看出來,P3 可以推導出 P2,進而推導出 P1,也就是說這是一個歸約的過程,因此只要 P3 成立則 P1 成立,也就是 Paxos 算法的正確性得到保證。

那么要如何實現呢 P3 呢?只需滿足如下約束:

C3. 對于一個被 Proposer 提議的提案中任意的 vn,存在一個數量超過半數 Acceptors 的集合 S,滿足以下兩個條件中的任意一個:

  • S 中的任何一個 Acceptor 都沒有接受過編號小于 n 的提案。
  • S 中所有的 Acceptors 接受過的最大編號的提案的 Value 為 v。

為了滿足 C3 從而實現 P3,需要引入一條約束:Proposer 每次生成自己的 n 之后,發起提案之前,必須要先去『學習』那個已經被選定或者將要被選定的小于 n 的提案,如果有這個提案的話則把那個提案的 v 作為自己的此次提案的 Value,沒有的話才可以自己指定一個 Value,這樣的話 Proposer 側就可以保證更高編號的提案的值只會是已選定的 v 了,但是 Acceptor 側還無法保證,因為 Acceptor 有可能還會接受其他的 Proposers 的提案值,于是我們需要對 Acceptor 也加一條約束,讓它承諾在收到編號為 nv 之后,不會再接受新的編號小于 n 的提案值。

所以我們可以得到一個 Paxos 在 Proposer 側的算法流程:

  1. Proposer 生成一個新的提案編號 n 然后發送一個 prepare 請求給超過半數的 Acceptors 集合,要求集合中的每一個 Acceptor 做出如下響應:

    (a) 向 Proposer 承諾在收到該消息之后就不再接受編號小于 n 的提案。

    (b) 如果 Acceptor 在收到該消息之前已經接受過其他提案,則把當前接受的編號最大的提案回復給 Proposer。

  2. 如果 Proposer 收到了超過半數的 Acceptors 的回復,那么就可以生成 (n, v) 的提案,這里 v 是所有 Acceptors 回復中編號最大的那個提案里的值,如果所有 Acceptors 回復中都沒有附帶上提案的話,則可以由 Proposer 自己選擇一個 v。
  3. Proposer 將上面生成的提案通過一個 accept 請求發送給一個超過半數的 Acceptors 集合。(需要注意的是這個集合不一定和第二步中的那個集合是同一個。)

Paxos 在 Proposer 側的算法流程已經確定了,接下來我們需要從 Acceptor 的視角來完成剩下的算法推導。前面我們提到過,Acceptor 是可以接受多個 Proposers 的多個提案的,但是在收到一個 Proposer 的 prepare 消息后會承諾不再接受編號小于 n 的新提案,也就是說 Acceptor 也是可以忽略掉其他 Proposers 消息(包括 prepareaccept)而不會破壞算法的安全性,當然了,在工程實踐中也可以直接回復一個錯誤,讓 Proposer 更早知道提案被拒絕然后生成提案重新開始流程。這里我們應該重點思考的場景是一個 Acceptor 接受一個提案請求的時候,根據前面 Proposer 要求 Acceptor 的承諾,我們可以給 Acceptor 設置一個這樣的約束:

C4. 如果一個 Proposer 發出了帶 nprepare 請求,只要 Acceptor 還沒有回復過任何其他編號大于 nprepare 請求,則該 Acceptor 可以接受這個提案。

因為 Acceptor 需要對 Proposer 做出不接受編號小于 n 的提案的承諾,因此它需要做持久化記錄,那么它就必須是有狀態的,也因此每個 Acceptor 都需要利用可靠性存儲(日志)來保存兩個對象:

  1. Acceptor 接受過的編號最大的提案;
  2. Acceptor 回復過的最大的 prepare 請求提案編號。

以上這就是 Acceptor 側的約束。接下來我們就可以得到 Paxos 的整個算法流程了。

Paxos 算法可以歸納為兩大基本過程:

  1. 選擇過程;
  2. 學習過程。
選擇過程

選擇過程分為兩個階段:

  • 階段一(Phase 1):

    (a) Proposer 生成一個全局唯一且單調遞增的提案編號 n,然后發送編號為 nprepare 請求(P1a msg)給超過半數的 Acceptors 集合。

    (b) 當一個 Acceptor 收到一個編號為 nprepare 請求,如果 n 比它此前接受過其他的提案編號(如果有)都要大的話,那么將這個提案編號 n 寫入本地日志,這里記為 max_n,然后作出『兩個承諾,一個回復』:

    • 兩個承諾:

      • 不再接受編號小于等于 nprepare 請求
      • 不再接受編號小于等于 naccept 請求
    • 一個回復:

      • 在不違背以前作出的承諾下,回復消息(P1b msg),附帶上自己已經接受過的提案中編號最大的那個提案的 vn,沒有則返回空值。

    否則就忽略該 prepare 消息或者回復一個錯誤。

  • 階段二(Phase 2):

    (a) 當 Proposer 收到超過半數的 Acceptors 回復它的編號為 nprepare 請求的響應,此時有兩種可能:

    • Free:沒有任何一個 Acceptor 的回復消息中附帶已被接受的提案,意味著當前流程中還沒有提案值被最終接受,此時 Proposer 可以自由地選擇提案值 Value,最后發送一個包含 (n, v) 提案的 accept 請求消息(P2a msg)給 Acceptors 集合。
    • Forced:某些 Acceptors 的回復消息中附帶已被接受的提案,那么 Proposer 必須強制使用這些回復消息中編號最大的提案 Value 作為自己的提案值,最后發送一個包含 (n, v) 提案的 accept 請求消息(P2a msg)給 Acceptors 集合。

    (b) 當 Acceptor 收到一個編號為 n 的提案的 accept 請求消息,需要分兩種情況處理:

    • 如果 n >= max_n(通常情況下這兩個值是相等的),則接受該提案并回復消息(P2b msg)。
    • 如果 n < max_n,則忽略該 accept 消息或者回復一個錯誤(P2b error)。
學習過程

選擇過程結束之后,我們得到了一個提案值,接下來就是要讓集群中的所有 Learner 『學習』到這個值了,以求達到集群的共識。

Learner 學習提案值的方式可以分成三種:

  1. 任意一個 Acceptor 接受了一個提案后就立刻將該提案發送給所有 Learner。優點:Learner 能實時學習到被 Paxos 流程選定的 Value;缺點:網絡通信次數太多,如果有 N 個 Acceptors 和 M 個 Learner,則需要的網絡通信是 N*M 次。
  2. 設置一個主 Learner,Acceptor 接受了一個提案后只將該提案發送給主 Learner,主 Learner 再轉發給剩下的 Learners。優點:網絡通信次數只需 N+M-1 次;缺點:主 Learner 有單點故障的風險。
  3. Acceptor 接受了一個提案后將該提案發送給一個 Learner 集合,由這個集合去通知剩下的 Learners。優點:用集合替代單點,可靠性更高;缺點:增加系統復雜度,需要維護一個 Learner 小集群。

至此,我們就推導出了整個 Paxos 算法的流程:

算法證明

這一節我們來證明 Paxos 算法的正確性。

上一節我們已經提煉出來了 Paxos 的基本命題 P1,并通過歸約 P1 得到了約束性更強的另外兩個命題 P2 和 P3,根據歸約的原理,我們知道 P3 可以最終推導出 P1,也就是說如果要證明 Paxos 的基本命題 P1,只需要證明 P3 即可。為什么之前我們要不斷強化 Paxos 的命題呢?因為從數學的層面來講,一個具有更強約束(更多假設)的命題一般會更容易證明。

現在我們把 P1, P2 和 P3 用更嚴格的數學語言來描述:

P1. 在一次 Paxos 流程中,如果一個包含 (n, v) 的提案被選定(Chosen),那么存在未來被選定的提案 (k, v1),必然滿足 k > n,v1 = v。

P2. 在一次 Paxos 流程中,如果一個包含 (n, v) 的提案被選定(Chosen),那么存在未來被超過半數的 Acceptors 接受的提案 (k, v1),必然滿足 k > n,v1 = v。

P3. 在一次 Paxos 流程中,如果一個包含 (n, v) 的提案被選定(Chosen),那么存在未來由 Proposer 提議的提案 (k, v1),必然滿足 k > n,v1 = v。

現在我們利用數學歸納法來證明 P3:

假設 k = m 時 P3 成立,由于 (n, v) 已經是被選定的提案,因此 Proposer 發起的從 n 到 k 的提案中的 Value 都會是 v,其中 m >= n,那么根據歸約的原理可證 k = m 時 P1 也成立。

現在令 k = m+1,Proposer 發送帶編號 k 的 prepare 請求消息到 Acceptors 集合。

由于此前已經有了選定的提案,那么根據 Paxos 的約束 C2 可知參與這一個提案投票的 Acceptors 集合必定和上一個集合有重合。

根據 Acceptors 集合重疊和 Paxos 的 P1b 階段可知,回復的消息中必定附帶有已被大多數 Acceptors 接受的提案 (i, v0)。

然后根據 P2a 階段,Proposer 提案 (k, v1),其中 v1 = v0。

還是根據 P1b,可知 i 是所有回復消息里編號最大的,可得 i >= m,又根據 P1a 可知 i < k,因此可以得出提案 (i, v0) 中有 v0 = v。

可知當 k = m+1 時,提案 (k, v1) 中的 v1 = v。

根據數學歸納法的原理,我們還需要找到一個特例來使得命題成立,然后由特例推廣到普遍,我們這里選擇 k = 1 作為特例,證明 k = 1 時 P3 成立:根據 Paxos 的約束 C1 易知在 n = 0,k = 1 的場景下,P3 成立。

因此可根據數學歸納法基于 k = 1 進行推廣至 k = m(m 代表任意自然數),最后 P3 命題得證。

再由歸約的原理可知,P3 可推導出 P2,最后 P2 推導出 P1。至此, Paxos 算法原理正確性的證明完成。

上述的證明只是一種比較簡單且粗淺的證明方法,但是對于工程師理解 Paxos 原理來說已經足夠了,如果希望進一步學習 Paxos 原理的嚴格數學證明,可以參閱 Leslie Lamport 的原始論文《The PartTime Parliament》,里面給出了 Paxos 算法的嚴格數學證明。

Multi-Paxos

自 Lamport 于 1990 年在論文《The PartTime Parliament》中提出 Paxos 算法之后,這個算法一直被評價為難以理解和實現,這篇論文中運用了大量的數學對 Paxos 的原理進行證明,而又由于 Lamport 在論文里用講故事的形式解釋 Paxos,進一步增大了人們徹底理解 Paxos 的難度,事實上 Lamport 的這篇論文也因此在發表過程中一波三折,這里不展開,有興趣的讀者可以自行去了解這段這段背景故事。

因為業界在理解 Paxos 算法上持續的怨聲載道,Lamport 在 2001 年發表了論文《Paxos Made Simple》,對原論文進行精簡,以更通俗易懂的語言和形式闡述 Paxos 算法,并在其中提出了更加具備工程實踐性的 Multi-Paxos 的思想。

關于 Paxos 難以理解的問題上,我個人的一點愚見是:Paxos 算法的思想其實并不難理解,真正難的地方是:

  1. Paxos 背后那一套完整的數學原理和證明
  2. 在復雜分布式環境將 Paxos 進行工程落地

我個人建議的 Paxos 學習資料是:《Paxos Made Simple》,《Paxos Made Live - An Engineering Perspective》以及 Paxos lecture (Raft user study)。第一篇論文可以說是 Lamport 1990 年那篇最初的論文的精簡版,可讀性提高了很多,論文里也沒有使用任何數學公式,只需一點英文基礎就可以通讀,第二篇論文講的則是 Google 內部基于 Multi-Paxos 實現的分布式鎖機制和小文件存儲系統,這是業界較早的實現了 Multi-Paxos 的大規模線上系統,十分具有參考性,最后的 Youtube 視頻則是 Raft 的作者 Diego Ongaro 為了對比 Raft 和 Multi-Paxos 的學習的難易程度而做的,非常適合作為學習 Paxos 和 Raft 的入門資料。

從上一節可知 Basic Paxos 算法有幾個天然缺陷:

  • 只能就單個值(Value)達成共識,不支持多值共識。在實際的工程實踐中往往是需要對一系列的操作達成共識,比如分布式事務,由很多執行命令組成。
  • 至少需要 2 輪往返 4 次 prepareaccept 網絡通信才能基于一項提案達成共識。對于一個分布式系統來說,網絡通信是最影響性能的因素之一,過多的網絡通信往往會導致系統的性能瓶頸。
  • 不限制 Proposer 數量導致非常容易發生提案沖突。極端情況下,多 Proposer 會導致系統出現『活鎖』,破壞分布式共識算法的兩大約束之一的活性(liveness)。

關于第三點,前文提到分布式共識算法必須滿足兩個最核心的約束:安全性(safety)和活性(liveness),從上一節我們可以看出 Basic Paxos 主要著重于 safety,而對 liveness 并沒有進行強約束,讓我們設想一種場景:兩個 Proposers (記為 P1 和 P2) 輪替著發起提案,導致兩個 Paxos 流程重疊了:

  1. 首先,P1 發送編號 N1 的 prepare 請求到 Acceptors 集合,收到了過半的回復,完成階段一。
  2. 緊接著 P2 也進入階段一,發送編號 N2 的 prepare 請求到過半的 Acceptors 集合,也收到了過半的回復,Acceptors 集合承諾不再接受編號小于 N2 的提案。
  3. 然后 P1 進入階段二,發送編號 N1 的 accept 請求被 Acceptors 忽略,于是 P1 重新進入階段一發送編號 N3 的 prepare 請求到 Acceptors 集合,Acceptors 又承諾不再接受編號小于 N3 的提案。
  4. 緊接著 P2 進入階段二,發送編號 N2 的 accept 請求,又被 Acceptors 忽略。
  5. 不斷重復上面的過程......

在極端情況下,這個過程會永遠持續,導致所謂的『活鎖』,永遠無法選定一個提案,也就是 liveness 約束無法滿足。

為了解決這些問題,Lamport 在《Paxos Made Simple》論文中提出了一種基于 Basic Paxos 的 Multi-Paxos 算法思想,并基于該算法引出了一個分布式銀行系統狀態機的實現方案,感興趣的讀者不妨看一下。

Multi-Paxos 算法在 Basic Paxos 的基礎上做了兩點改進:

  1. 多 Paxos 實例:針對每一個需要達成共識的單值都運行一次 Basic Paxos 算法的實例,并使用 Instance ID 做標識,最后匯總完成多值共識。
  2. 選舉單一的 Leader Proposer:選舉出一個 Leader Proposer,所有提案只能由 Leader Proposer 來發起并決策,Leader Proposer 作為 Paxos 算法流程中唯一的提案發起者,『活鎖』將不復存在。此外,由于單一 Proposer 不存在提案競爭的問題,Paxos 算法流程中的階段一中的 prepare 步驟也可以省略掉,從而將兩階段流程變成一階段,大大減少網絡通信次數。

關于多值共識的優化,如果每一個 Basic Paxos 算法實例都設置一個 Leader Proposer 來工作,還是會產生大量的網絡通信開銷,因此,多個 Paxos 實例可以共享同一個 Leader Proposer,這要求該 Leader Proposer 必須是穩定的,也即 Leader 不應該在 Paxos 流程中崩潰或改變。

由于 Lamport 在論文中提出的 Multi-Paxos 只是一種思想而非一個具體算法,因此關于 Multi-Paxos 的很多細節他并沒有給出具體的實現方案,有些即便給出了方案也描述得不是很清楚,比如他在論文中最后一節提出的基于銀行系統的狀態機中的多 Paxos 實例處理,雖然給了具體的論述,但是在很多關鍵地方還是沒有指明,這也導致了后續業界里的 Multi-Paxos 實現各不相同。

我們這里用 Google Chubby 的 Multi-Paxos 實現來分析這個算法。

首先,Chubby 通過引入 Master 節點,實現了 Lamport 在論文中提到的 single distinguished proposer,也就是 Leader Proposer,Leader Proposer 作為 Paxos 算法流程中唯一的提案發起者,規避了多 Proposers 同時發起提案的場景,也就不存在提案沖突的情況了,從而解決了『活鎖』的問題,保證了算法的活性(liveness)。

Lamport 在論文中指出,選擇 Leader Proposer 的過程必須是可靠的,那么具體如何選擇一個 Leader Proposer 呢?在 Chubby 中,集群利用 Basic Paxos 算法的共識功能來完成對 Leader Proposer 的選舉,這個實現是具有天然合理性的,因為 Basic Paxos 本身就是一個非??煽慷医涍^嚴格數學證明的共識算法,用來作為選舉算法再合適不過了,在 Multi-Paxos 流程期間,Master 會通過不斷續租的方式來延長租期(Lease)。比如在實際場景中,一般在長達幾天的時期內都是同一個服務器節點作為 Master。萬一 Master 故障了,那么剩下的 Slaves 節點會重新發起 Paxos 流程票選出新的 Master,也就是說主節點是一直存在的,而且是唯一的。

此外,Lamport 在論文中提到的過一種優化網絡通信的方法:“當 Leader Proposer 處于穩定狀態時,可以跳過階段一,直接進入階段二”,在 Chubby 中也實現了這個優化機制,Leader Proposer 在為多個 Paxos 算法實例服務的時候直接跳過階段一進入階段二,只發送 accept 請求消息給 Acceptors 集合,將算法從兩階段優化成了一階段,大大節省網絡帶寬和提升系統性能。

最后,Multi-Paxos 是一個"腦裂"容錯的算法思想,就是說當 Multi-Paxos 流程中因為網絡問題而出現多 Leaders 的情況下,該算法的安全性(safety )約束依然能得到保證,因為在這種情況下,Multi-Paxos 實際上是退化成了 Basic Paxos,而 Basic Paxos 天然就支持多 Proposers。

在分布式事務中,Paxos 算法能夠提供比兩階段提交協議更加可靠的一致性提交:通過將提交/放棄事務的決定從原來兩階段協議中單一的協調者轉移到一個由 Proposer + Acceptors 組成的集群中。Lamport 曾經發表過一篇《Consensus on Transaction Commit》的論文,通過將兩階段提交協議和基于 Paxos 實現的分布式提交協議做對比,對基于 Paxos 實現的提交協議有非常精彩的論述,感興趣的讀者不妨一讀。

Raft

Raft 算法實際上是 Multi-Paxos 的一個變種,通過新增兩個約束:

  1. 追加日志約束:Raft 中追加節點的日志必須是串行連續的,而 Multi-Paxos 中則可以并發追加日志(實際上 Multi-Paxos 的并發也只是針對日志追加,最后應用到內部 State Machine 的時候還是必須保證順序)。
  2. 選主限制:Raft 中只有那些擁有最新、最全日志的節點才能當選 Leader 節點,而 Multi-Paxos 由于允許并發寫日志,因此無法確定一個擁有最新、最全日志的節點,因此可以選擇任意一個節點作為 Leader,但是選主之后必須要把 Leader 節點的日志補全。

基于這兩個限制,Raft 算法的實現比 Multi-Paxos 更加簡單易懂,不過由于 Multi-Paxos 的并發度更高,因此從理論上來說 Multi-Paxos 的性能會更好一些,但是到現在為止業界也沒有一份權威的測試報告來支撐這一觀點。

對比一下 Multi-Paxos 和 Raft 下集群中可能存在的日志順序:

可以看出,Raft 中永遠滿足這樣一個約束:follower log 一定會是 leader log 的子集并且順序一定是連續的,而 Multi-Paxos 則不一定滿足這個約束,日志記錄通常是亂序的。

由于 Raft 的核心思想源自 Multi-Paxos,在實現過程中做了很多改進優化,然而萬變不離其宗,我相信理解了 Multi-Paxos 之后再去學習 Raft 會事半功倍(Raft 在誕生之初也是打著"容易理解"的旗號來對標 Paxos 的),由于前面已經深度剖析過 Paxos 算法的流程和原理了,礙于本文的篇幅所限,這里就不再對 Raft 算法的細節進行深入探討了,如果有意深入學習 Raft,可以從 The Raft Consensus Algorithm 處找到相關的論文、源碼等資料進行全面的學習。

最后有一些概念要澄清一下,Basic Paxos 是一個經過了嚴格數學證明的分布式共識算法,但是由于前文提到的 Basic Paxos 算法應用在實際工程落地中的種種問題,現實中幾乎沒有直接基于 Basic Paxos 算法實現的分布式系統,絕大多數都是基于 Multi-Paxos,然而 Multi-Basic 僅僅是一種對 Basic Paxos 的延伸思想而非一個具體算法,問題在于目前業界并沒有一個統一的 Multi-Paxos 實現標準,因此 Multi-Paxos 的工程實現是建立在一個未經嚴格證明的前提之上的,工程實現最終的正確性只能靠實現方自己去驗證,而 Raft 則是一個具有統一標準實現的、正確性已經過嚴格證明的具體算法,因此在分布式系統的工程實踐中大多數人往往還是會選擇 Raft 作為底層的共識算法。

算法類型

需要特別指出的一點是,根據解決的場景是否允許拜占庭(Byzantine)錯誤,共識算法可以分為 Crash Fault Tolerance (CFT) 和 Byzantine Fault Tolerance(BFT)兩類。

對于非拜占庭錯誤的情況,已經存在不少經典的算法,包括 Paxos(1990 年)、Raft(2014 年)及其變種等。這類容錯算法往往性能比較好,處理較快,容忍不超過一半的故障節點。

對于要能容忍拜占庭錯誤的情況,包括 PBFT(Practical Byzantine Fault Tolerance,1999 年)為代表的確定性系列算法、PoW(1997 年)為代表的概率算法等。確定性算法一旦達成共識就不可逆轉,即共識是最終結果;而概率類算法的共識結果則是臨時的,隨著時間推移或某種強化,共識結果被推翻的概率越來越小,最終成為事實上結果。拜占庭類容錯算法往往性能較差,容忍不超過 1/3 的故障節點。

本文主要討論的分布式共識算法是 CFT 類算法,畢竟對于大多數分布式系統來說,集群節點和網絡消息一般都是可控的,系統只會出現節點故障而不會出現像拜占庭錯誤那樣偽造的、欺騙性的網絡消息,在這種場景下,CFT 類算法更具有現實意義;BFT/PBFT 類算法更多是用在系統被惡意入侵,故意偽造網絡消息的場景里。

并發控制

在分布式事務中,集群中的每個服務器節點要管理很多資源對象,每個節點必須保證在并發事務訪問這些資源對象時,它們能夠始終保持一致性。因此,每個服務器節點需要對自己的管理的資源對象應用一定的并發控制機制。分布式事務中需要所有服務器節點共同保證事務以串行等價的的方式執行。

也就是說,如果事務 T 對某一個服務器節點上的資源對象 S 的并發訪問在事務 U 之前,那么我們需要保證在所有服務器節點上對 S 和其他資源對象的沖突訪問,T 始終在 U 之前。

鎖并發控制

在分布式事務中,某個對象的鎖總是本地持有的(在同一個服務器節點上)。是否加鎖是由本地鎖管理器(Local Lock Manager,LLM)決定的。LLM 決定是滿足客戶端持鎖的請求,還是阻塞客戶端發起的分布式事務。但是,事務在所有服務器節點上被提交或者放棄之前,LLM 不能釋放任何鎖。在使用加鎖機制的并發控制中,原子提交協議在進行的過程中資源對象始終被鎖住,并且是排他鎖,其他事務無法染指這些資源對象。但如果事務在兩階段提交協議的階段一就被放棄,則互斥鎖可以提前釋放。

由于不同服務器節點上的 LLM 獨立設置資源對象鎖,因此,對于不同的事務,它們加鎖的順序也可能出現不一致??紤]一個場景:事務 T 和 U在服務器 X 和 Y 之間的交錯執行:

  1. 事務 T 鎖住了服務器節點 X 上的資源對象 A,做寫入操作;
  2. 事務 U 鎖住了服務器節點 Y 上的資源對象 B,做寫入操作;
  3. 事務 T 試圖讀取服務器節點 Y 上的資源對象 B,此時 B 被事務 U 鎖住,因此 T 等待鎖釋放;
  4. 事務 U 試圖讀取服務器節點 X 上的資源對象 A,此時 A 被事務 T 鎖住,因此 U 等待鎖釋放。

在服務器節點 X 上,事務 T 在事務 U 之前;而在服務器節點 Y 上,事務 U 在事務 T 之前。這種不一致的事務次序導致了事務之間的循環依賴,從而引起分布式死鎖。分布式死鎖需要通過特定的方法/算法來檢測并解除,一旦檢測到死鎖,則必須放棄其中的某個事務來解除死鎖,然后通知事務協調者,它將會放棄該事務所涉及的所有參與者上的事務。

時間戳并發控制

對于單一服務器節點的事務來說,協調者在每個事務啟動時會為其分配一個全局唯一的時間戳。通過按照訪問資源對象的事務時間戳順序提交資源對象的版本來強制保證以事務執行的串行等價性。在分布式事務中,協調者必須保證每個事務都會附帶全局唯一的時間戳。全局唯一的時間戳由事務訪問的第一個協調者發給客戶端。如果任意一個服務器節點上的資源對象執行了事務中的一個操作,那么事務時間戳會被發送給該服務器節點上的協調者。

分布式事務中的所有服務器節點共同保證事務以串行等價的方式執行。例如,如果在某服務器節點上,由事務 U 訪問的資源對象版本在事務 T 訪問之后提交;而在另一個服務器節點上,事務 T 和事務 U 又訪問了同一個資源對象,那么它們也必須按照相同的次序提交資源對象。為了保證所有服務器節點上的事務執行的相同順序,協調者必須就時間戳排序達成一致。時間戳是一個二元組 < 本地時間戳,服務器 ID > 對。在時間戳的比較排序過程中,首先比較本地時間戳,然后再比較服務器 ID。

一個可靠的時間戳并發控制應該保證即使各個服務器節點之間的本地時間不同步,也能保證事務之間的相同順序。但是考慮到效率,各個協調者之間的時間戳還是最好還是要求大致同步。這樣的話,事務之間的順序通常與它們實際開始的時間順序相一致??梢岳靡恍┍镜匚锢頃r鐘同步方法來保證時間戳的大致同步。

如果決定利用時間戳機制進行分布式事務的并發控制,那么還需要通過某些方法來解決事務沖突問題。如果為了解決沖突需要放棄某個事務時,相應的協調者會收到通知,并且它將在所有的參與者上放棄該事務。這樣,如果事務能夠堅持到客戶端發起提交請求命令的那個時候,那么這個事務就總能被提交。因此在兩階段提交協議中,正常情況下參與者都會同意提交,唯一一種不同意提交的情況是參與者在事務執行過程中曾經崩潰過。

樂觀并發控制

加鎖機制這一類悲觀并發控制有許多明顯的缺陷:

  • 鎖的維護帶來了很多新的開銷。這些開銷在不支持對共享數據并發訪問的系統中是不存在的。即使是只讀事務(如查詢),就算這一類事務不會改變數據的完整性,卻仍然需要利用鎖來保證數據在讀取過程中不會被其他事務修改,然而鎖卻只在最極端的情況下才會發揮作用。
  • 鎖機制非常容易引發死鎖。預防死鎖會嚴重降低并發度,因此必須利用超時或者死鎖檢測來解除死鎖,但這些死鎖解除方案對于交互式的程序來說并不是很理想。
  • 鎖周期過長。為了避免事務的連鎖(雪崩)放棄,鎖必須保留到事務結束之時才能釋放,這再一次嚴重降低了系統的并發度。

由于鎖這一類的悲觀并發控制有上述的種種弊端,因此研究者們提出了另一種樂觀并發控制的機制,以求規避鎖機制的天然缺陷,研究者們發現這樣的一個現象:在大多數應用中兩個客戶端事務訪問同一個資源對象的可能性其實很低,事務總是能夠成功執行,就好像事務之間不存在沖突一樣。

所以事務的樂觀并發控制的基本思路就是:各個并發事務只有在執行完成之后并且發出 closeTransaction 請求時,再去檢測是否有沖突,如果確實存在沖突,那么就放棄一些事務,然后讓客戶端重新啟動這些事務進行重試。

在樂觀并發控制中,每個事務在提交之前都必須進行驗證。事務在驗證開始時首先要附加一個事務號,事務的串行化就是根據這些事務號的順序實現的。分布式事務的驗證由一組獨立的服務器節點共同完成,每個服務器節點驗證訪問自己資源對象的事務。這些驗證在兩階段提交協議的第一個階段進行。

關于分布式事務的并發控制就暫時介紹到這里,如果想要繼續深入學習更多并發控制的細節,可以深入閱讀《分布式系統:概念與設計》、《數據庫系統實現》和《數據庫系統概念》等書籍或者其他資料。

總結

本文通過講解 BASE 原則、兩階段原子提交協議、三階段原子提交協議、Paxos/Multi-Paxos 分布式共識算法的原理與證明、Raft 分布式共識算法分布式事務的并發控制等內容,為讀者全面而又深入地講解分析了分布式事務的底層核心原理,特別是通過對原子提交協議中的 2PC/3PC 的闡述和分析,以及對分布式共識算法 Paxos 的原理剖析和正確性的證明,最后還有對分布式事務中幾種并發控制的介紹,相信能夠讓讀者對分布式事務底層的一致性和并發控制原理有一個深刻的認知,對以后學習和理解分布式系統大有裨益。

本文不僅僅是簡單地介紹分布式事務的底層原理,更是在介紹原理的同時,通過層層遞進的方式引導讀者去真正地理解分布式系統的底層原理和設計思路,而非讓讀者死記硬背一些概念,所以希望通過這篇拋磚引玉的文章,能夠對本文讀者在以后學習、操作甚至是設計分布式系統以及分布式事務時的思路有所開拓。

參考&延伸

查看原文

贊 0 收藏 0 評論 0

panjf2000 發布了文章 · 2020-12-28

Linux I/O 原理和 Zero-copy 技術全面揭秘

博客原文

Linux I/O 原理和 Zero-copy 技術全面揭秘

導言

如今的網絡應用早已從 CPU 密集型轉向了 I/O 密集型,網絡服務器大多是基于 C-S 模型,也即 客戶端 - 服務端 模型,客戶端需要和服務端進行大量的網絡通信,這也決定了現代網絡應用的性能瓶頸:I/O。

傳統的 Linux 操作系統的標準 I/O 接口是基于數據拷貝操作的,即 I/O 操作會導致數據在操作系統內核地址空間的緩沖區和用戶進程地址空間定義的緩沖區之間進行傳輸。設置緩沖區最大的好處是可以減少磁盤 I/O 的操作,如果所請求的數據已經存放在操作系統的高速緩沖存儲器中,那么就不需要再進行實際的物理磁盤 I/O 操作;然而傳統的 Linux I/O 在數據傳輸過程中的數據拷貝操作深度依賴 CPU,也就是說 I/O 過程需要 CPU 去執行數據拷貝的操作,因此導致了極大的系統開銷,限制了操作系統有效進行數據傳輸操作的能力。

I/O 是決定網絡服務器性能瓶頸的關鍵,而傳統的 Linux I/O 機制又會導致大量的數據拷貝操作,損耗性能,所以我們亟需一種新的技術來解決數據大量拷貝的問題,這個答案就是零拷貝(Zero-copy)。

計算機存儲器

既然要分析 Linux I/O,就不能不了解計算機的各類存儲器。

存儲器是計算機的核心部件之一,在完全理想的狀態下,存儲器應該要同時具備以下三種特性:

  1. 速度足夠快:存儲器的存取速度應當快于 CPU 執行一條指令,這樣 CPU 的效率才不會受限于存儲器
  2. 容量足夠大:容量能夠存儲計算機所需的全部數據
  3. 價格足夠便宜:價格低廉,所有類型的計算機都能配備

但是現實往往是殘酷的,我們目前的計算機技術無法同時滿足上述的三個條件,于是現代計算機的存儲器設計采用了一種分層次的結構:

從頂至底,現代計算機里的存儲器類型分別有:寄存器、高速緩存、主存和磁盤,這些存儲器的速度逐級遞減而容量逐級遞增。存取速度最快的是寄存器,因為寄存器的制作材料和 CPU 是相同的,所以速度和 CPU 一樣快,CPU 訪問寄存器是沒有時延的,然而因為價格昂貴,因此容量也極小,一般 32 位的 CPU 配備的寄存器容量是 32??32 Bit,64 位的 CPU 則是 64??64 Bit,不管是 32 位還是 64 位,寄存器容量都小于 1 KB,且寄存器也必須通過軟件自行管理。

第二層是高速緩存,也即我們平時了解的 CPU 高速緩存 L1、L2、L3,一般 L1 是每個 CPU 獨享,L3 是全部 CPU 共享,而 L2 則根據不同的架構設計會被設計成獨享或者共享兩種模式之一,比如 Intel 的多核芯片采用的是共享 L2 模式而 AMD 的多核芯片則采用的是獨享 L2 模式。

第三層則是主存,也即主內存,通常稱作隨機訪問存儲器(Random Access Memory, RAM)。是與 CPU 直接交換數據的內部存儲器。它可以隨時讀寫(刷新時除外),而且速度很快,通常作為操作系統或其他正在運行中的程序的臨時資料存儲介質。

最后則是磁盤,磁盤和主存相比,每個二進制位的成本低了兩個數量級,因此容量比之會大得多,動輒上 GB、TB,而問題是訪問速度則比主存慢了大概三個數量級。機械硬盤速度慢主要是因為機械臂需要不斷在金屬盤片之間移動,等待磁盤扇區旋轉至磁頭之下,然后才能進行讀寫操作,因此效率很低。

主內存是操作系統進行 I/O 操作的重中之重,絕大部分的工作都是在用戶進程和內核的內存緩沖區里完成的,因此我們接下來需要提前學習一些主存的相關原理。

物理內存

我們平時一直提及的物理內存就是上文中對應的第三種計算機存儲器,RAM 主存,它在計算機中以內存條的形式存在,嵌在主板的內存槽上,用來加載各式各樣的程序與數據以供 CPU 直接運行和使用。

虛擬內存

在計算機領域有一句如同摩西十誡般神圣的哲言:"計算機科學領域的任何問題都可以通過增加一個間接的中間層來解決",從內存管理、網絡模型、并發調度甚至是硬件架構,都能看到這句哲言在閃爍著光芒,而虛擬內存則是這一哲言的完美實踐之一。

虛擬內存是現代計算機中的一個非常重要的存儲器抽象,主要是用來解決應用程序日益增長的內存使用需求:現代物理內存的容量增長已經非??焖倭?,然而還是跟不上應用程序對主存需求的增長速度,對于應用程序來說內存還是不夠用,因此便需要一種方法來解決這兩者之間的容量差矛盾。

計算機對多程序內存訪問的管理經歷了 靜態重定位 --> 動態重定位 --> 交換(swapping)技術 --> 虛擬內存,最原始的多程序內存訪問是直接訪問絕對內存地址,這種方式幾乎是完全不可用的方案,因為如果每一個程序都直接訪問物理內存地址的話,比如兩個程序并發執行以下指令的時候:

mov cx, 2
mov bx, 1000H
mov ds, bx
mov [0], cx

...

mov ax, [0]
add ax, ax

這一段匯編表示在地址 1000:0 處存入數值 2,然后在后面的邏輯中把該地址的值取出來乘以 2,最終存入 ax 寄存器的值就是 4,如果第二個程序存入 cx 寄存器里的值是 3,那么并發執行的時候,第一個程序最終從 ax 寄存器里得到的值就可能是 6,這就完全錯誤了,得到臟數據還頂多算程序結果錯誤,要是其他程序往特定的地址里寫入一些危險的指令而被另一個程序取出來執行,還可能會導致整個系統的崩潰。所以,為了確保進程間互不干擾,每一個用戶進程都需要實時知曉當前其他進程在使用哪些內存地址,這對于寫程序的人來說無疑是一場噩夢。

因此,操作絕對內存地址是完全不可行的方案,那就只能用操作相對內存地址,我們知道每個進程都會有自己的進程地址,從 0 開始,可以通過相對地址來訪問內存,但是這同樣有問題,還是前面類似的問題,比如有兩個大小為 16KB 的程序 A 和 B,現在它們都被加載進了內存,內存地址段分別是 0 ~ 16384,16384 ~ 32768。A 的第一條指令是 jmp 1024,而在地址 1024 處是一條 mov 指令,下一條指令是 add,基于前面的 mov 指令做加法運算,與此同時,B 的第一條指令是 jmp 1028,本來在 B 的相對地址 1028 處應該也是一條 mov 去操作自己的內存地址上的值,但是由于這兩個程序共享了段寄存器,因此雖然他們使用了各自的相對地址,但是依然操作的還是絕對內存地址,于是 B 就會跳去執行 add 指令,這時候就會因為非法的內存操作而 crash。

有一種靜態重定位的技術可以解決這個問題,它的工作原理非常簡單粗暴:當 B 程序被加載到地址 16384 處之后,把 B 的所有相對內存地址都加上 16384,這樣的話當 B 執行 jmp 1028 之時,其實執行的是 jmp 1028+16384,就可以跳轉到正確的內存地址處去執行正確的指令了,但是這種技術并不通用,而且還會對程序裝載進內存的性能有影響。

再往后,就發展出來了存儲器抽象:地址空間,就好像進程是 CPU 的抽象,地址空間則是存儲器的抽象,每個進程都會分配獨享的地址空間,但是獨享的地址空間又帶來了新的問題:如何實現不同進程的相同相對地址指向不同的物理地址?最開始是使用動態重定位技術來實現,這是用一種相對簡單的地址空間到物理內存的映射方法?;驹砭褪菫槊恳粋€ CPU 配備兩個特殊的硬件寄存器:基址寄存器和界限寄存器,用來動態保存每一個程序的起始物理內存地址和長度,比如前文中的 A,B 兩個程序,當 A 運行時基址寄存器和界限寄存器就會分別存入 0 和 16384,而當 B 運行時則兩個寄存器又會分別存入 16384 和 32768。然后每次訪問指定的內存地址時,CPU 會在把地址發往內存總線之前自動把基址寄存器里的值加到該內存地址上,得到一個真正的物理內存地址,同時還會根據界限寄存器里的值檢查該地址是否溢出,若是,則產生錯誤中止程序,動態重定位技術解決了靜態重定位技術造成的程序裝載速度慢的問題,但是也有新問題:每次訪問內存都需要進行加法和比較運算,比較運算本身可以很快,但是加法運算由于進位傳遞時間的問題,除非使用特殊的電路,否則會比較慢。

然后就是 交換(swapping)技術,這種技術簡單來說就是動態地把程序在內存和磁盤之間進行交換保存,要運行一個進程的時候就把程序的代碼段和數據段調入內存,然后再把程序封存,存入磁盤,如此反復。為什么要這么麻煩?因為前面那兩種重定位技術的前提條件是計算機內存足夠大,能夠把所有要運行的進程地址空間都加載進主存,才能夠并發運行這些進程,但是現實往往不是如此,內存的大小總是有限的,所有就需要另一類方法來處理內存超載的情況,第一種便是簡單的交換技術:

先把進程 A 換入內存,然后啟動進程 B 和 C,也換入內存,接著 A 被從內存交換到磁盤,然后又有新的進程 D 調入內存,用了 A 退出之后空出來的內存空間,最后 A 又被重新換入內存,由于內存布局已經發生了變化,所以 A 在換入內存之時會通過軟件或者在運行期間通過硬件(基址寄存器和界限寄存器)對其內存地址進行重定位,多數情況下都是通過硬件。

另一種處理內存超載的技術就是虛擬內存技術了,它比交換(swapping)技術更復雜而又更高效,是目前最新應用最廣泛的存儲器抽象技術:

虛擬內存的核心原理是:為每個程序設置一段"連續"的虛擬地址空間,把這個地址空間分割成多個具有連續地址范圍的頁 (page),并把這些頁和物理內存做映射,在程序運行期間動態映射到物理內存。當程序引用到一段在物理內存的地址空間時,由硬件立刻執行必要的映射;而當程序引用到一段不在物理內存中的地址空間時,由操作系統負責將缺失的部分裝入物理內存并重新執行失敗的指令:

虛擬地址空間按照固定大小劃分成被稱為頁(page)的若干單元,物理內存中對應的則是頁框(page frame)。這兩者一般來說是一樣的大小,如上圖中的是 4KB,不過實際上計算機系統中一般是 512 字節到 1 GB,這就是虛擬內存的分頁技術。因為是虛擬內存空間,每個進程分配的大小是 4GB (32 位架構),而實際上當然不可能給所有在運行中的進程都分配 4GB 的物理內存,所以虛擬內存技術還需要利用到前面介紹的交換(swapping)技術,在進程運行期間只分配映射當前使用到的內存,暫時不使用的數據則寫回磁盤作為副本保存,需要用的時候再讀入內存,動態地在磁盤和內存之間交換數據。

其實虛擬內存技術從某種角度來看的話,很像是糅合了基址寄存器和界限寄存器之后的新技術。它使得整個進程的地址空間可以通過較小的單元映射到物理內存,而不需要為程序的代碼和數據地址進行重定位。

進程在運行期間產生的內存地址都是虛擬地址,如果計算機沒有引入虛擬內存這種存儲器抽象技術的話,則 CPU 會把這些地址直接發送到內存地址總線上,直接訪問和虛擬地址相同值的物理地址;如果使用虛擬內存技術的話,CPU 則是把這些虛擬地址通過地址總線送到內存管理單元(Memory Management Unit,MMU),MMU 將虛擬地址映射為物理地址之后再通過內存總線去訪問物理內存:

虛擬地址(比如 16 位地址 8196=0010 000000000100)分為兩部分:虛擬頁號(高位部分)和偏移量(低位部分),虛擬地址轉換成物理地址是通過頁表(page table)來實現的,頁表由頁表項構成,頁表項中保存了頁框號、修改位、訪問位、保護位和 "在/不在" 位等信息,從數學角度來說頁表就是一個函數,入參是虛擬頁號,輸出是物理頁框號,得到物理頁框號之后復制到寄存器的高三位中,最后直接把 12 位的偏移量復制到寄存器的末 12 位構成 15 位的物理地址,即可以把該寄存器的存儲的物理內存地址發送到內存總線:

在 MMU 進行地址轉換時,如果頁表項的 "在/不在" 位是 0,則表示該頁面并沒有映射到真實的物理頁框,則會引發一個缺頁中斷,CPU 陷入操作系統內核,接著操作系統就會通過頁面置換算法選擇一個頁面將其換出 (swap),以便為即將調入的新頁面騰出位置,如果要換出的頁面的頁表項里的修改位已經被設置過,也就是被更新過,則這是一個臟頁 (dirty page),需要寫回磁盤更新改頁面在磁盤上的副本,如果該頁面是"干凈"的,也就是沒有被修改過,則直接用調入的新頁面覆蓋掉被換出的舊頁面即可。

最后,還需要了解的一個概念是轉換檢測緩沖器(Translation Lookaside Buffer,TLB),也叫快表,是用來加速虛擬地址映射的,因為虛擬內存的分頁機制,頁表一般是保存內存中的一塊固定的存儲區,導致進程通過 MMU 訪問內存比直接訪問內存多了一次內存訪問,性能至少下降一半,因此需要引入加速機制,即 TLB 快表,TLB 可以簡單地理解成頁表的高速緩存,保存了最高頻被訪問的頁表項,由于一般是硬件實現的,因此速度極快,MMU收到虛擬地址時一般會先通過硬件 TLB 查詢對應的頁表號,若命中且該頁表項的訪問操作合法,則直接從 TLB 取出對應的物理頁框號返回,若不命中則穿透到內存頁表里查詢,并且會用這個從內存頁表里查詢到最新頁表項替換到現有 TLB 里的其中一個,以備下次緩存命中。

至此,我們介紹完了包含虛擬內存在內的多項計算機存儲器抽象技術,虛擬內存的其他內容比如針對大內存的多級頁表、倒排頁表,以及處理缺頁中斷的頁面置換算法等等,以后有機會再單獨寫一篇文章介紹,或者各位讀者也可以先行去查閱相關資料了解,這里就不再深入了。

用戶態和內核態

一般來說,我們在編寫程序操作 Linux I/O 之時十有八九是在用戶空間和內核空間之間傳輸數據,因此有必要先了解一下 Linux 的用戶態和內核態的概念。

首先是用戶態和內核態:

從宏觀上來看,Linux 操作系統的體系架構分為用戶態和內核態(或者用戶空間和內核)。內核從本質上看是一種軟件 —— 控制計算機的硬件資源,并提供上層應用程序 (進程) 運行的環境。用戶態即上層應用程序 (進程) 的運行空間,應用程序 (進程) 的執行必須依托于內核提供的資源,這其中包括但不限于 CPU 資源、存儲資源、I/O 資源等等。

現代操作系統都是采用虛擬存儲器,那么對 32 位操作系統而言,它的尋址空間(虛擬存儲空間)為 2^32 B = 4G。操作系統的核心是內核,獨立于普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的所有權限。為了保證用戶進程不能直接操作內核(kernel),保證內核的安全,操心系統將虛擬空間劃分為兩部分,一部分為內核空間,一部分為用戶空間。針對 Linux 操作系統而言,將最高的 1G 字節(從虛擬地址 0xC0000000 到 0xFFFFFFFF),供內核使用,稱為內核空間,而將較低的 3G 字節(從虛擬地址 0x00000000 到 0xBFFFFFFF),供各個進程使用,稱為用戶空間。

因為操作系統的資源是有限的,如果訪問資源的操作過多,必然會消耗過多的系統資源,而且如果不對這些操作加以區分,很可能造成資源訪問的沖突。所以,為了減少有限資源的訪問和使用沖突,Unix/Linux 的設計哲學之一就是:對不同的操作賦予不同的執行等級,就是所謂特權的概念。簡單說就是有多大能力做多大的事,與系統相關的一些特別關鍵的操作必須由最高特權的程序來完成。Intel 的 x86 架構的 CPU 提供了 0 到 3 四個特權級,數字越小,特權越高,Linux 操作系統中主要采用了 0 和 3 兩個特權級,分別對應的就是內核態和用戶態。運行于用戶態的進程可以執行的操作和訪問的資源都會受到極大的限制,而運行在內核態的進程則可以執行任何操作并且在資源的使用上沒有限制。很多程序開始時運行于用戶態,但在執行的過程中,一些操作需要在內核權限下才能執行,這就涉及到一個從用戶態切換到內核態的過程。比如 C 函數庫中的內存分配函數 malloc(),它具體是使用 sbrk() 系統調用來分配內存,當 malloc 調用 sbrk() 的時候就涉及一次從用戶態到內核態的切換,類似的函數還有 printf(),調用的是 wirte() 系統調用來輸出字符串,等等。

用戶進程在系統中運行時,大部分時間是處在用戶態空間里的,在其需要操作系統幫助完成一些用戶態沒有特權和能力完成的操作時就需要切換到內核態。那么用戶進程如何切換到內核態去使用那些內核資源呢?答案是:1) 系統調用(trap),2) 異常(exception)和 3) 中斷(interrupt)。

  • 系統調用:用戶進程主動發起的操作。用戶態進程發起系統調用主動要求切換到內核態,陷入內核之后,由操作系統來操作系統資源,完成之后再返回到進程。
  • 異常:被動的操作,且用戶進程無法預測其發生的時機。當用戶進程在運行期間發生了異常(比如某條指令出了問題),這時會觸發由當前運行進程切換到處理此異常的內核相關進程中,也即是切換到了內核態。異常包括程序運算引起的各種錯誤如除 0、緩沖區溢出、缺頁等。
  • 中斷:當外圍設備完成用戶請求的操作后,會向 CPU 發出相應的中斷信號,這時 CPU 會暫停執行下一條即將要執行的指令而轉到與中斷信號對應的處理程序去執行,如果前面執行的指令是用戶態下的程序,那么轉換的過程自然就會是從用戶態到內核態的切換。中斷包括 I/O 中斷、外部信號中斷、各種定時器引起的時鐘中斷等。中斷和異常類似,都是通過中斷向量表來找到相應的處理程序進行處理。區別在于,中斷來自處理器外部,不是由任何一條專門的指令造成,而異常是執行當前指令的結果。

通過上面的分析,我們可以得出 Linux 的內部層級可分為三大部分:

  1. 用戶空間;
  2. 內核空間;
  3. 硬件。

Linux I/O

I/O 緩沖區

在 Linux 中,當程序調用各類文件操作函數后,用戶數據(User Data)到達磁盤(Disk)的流程如上圖所示。

圖中描述了 Linux 中文件操作函數的層級關系和內存緩存層的存在位置,中間的黑色實線是用戶態和內核態的分界線。

read(2)/write(2) 是 Linux 系統中最基本的 I/O 讀寫系統調用,我們開發操作 I/O 的程序時必定會接觸到它們,而在這兩個系統調用和真實的磁盤讀寫之間存在一層稱為 Kernel buffer cache 的緩沖區緩存。在 Linux 中 I/O 緩存其實可以細分為兩個:Page CacheBuffer Cache,這兩個其實是一體兩面,共同組成了 Linux 的內核緩沖區(Kernel Buffer Cache):

  • 讀磁盤:內核會先檢查 Page Cache 里是不是已經緩存了這個數據,若是,直接從這個內存緩沖區里讀取返回,若否,則穿透到磁盤去讀取,然后再緩存在 Page Cache 里,以備下次緩存命中;
  • 寫磁盤:內核直接把數據寫入 Page Cache,并把對應的頁標記為 dirty,添加到 dirty list 里,然后就直接返回,內核會定期把 dirty list 的頁緩存 flush 到磁盤,保證頁緩存和磁盤的最終一致性。

Page Cache 會通過頁面置換算法如 LRU 定期淘汰舊的頁面,加載新的頁面??梢钥闯?,所謂 I/O 緩沖區緩存就是在內核和磁盤、網卡等外設之間的一層緩沖區,用來提升讀寫性能的。

在 Linux 還不支持虛擬內存技術之前,還沒有頁的概念,因此 Buffer Cache 是基于操作系統讀寫磁盤的最小單位 -- 塊(block)來進行的,所有的磁盤塊操作都是通過 Buffer Cache 來加速,Linux 引入虛擬內存的機制來管理內存后,頁成為虛擬內存管理的最小單位,因此也引入了 Page Cache 來緩存 Linux 文件內容,主要用來作為文件系統上的文件數據的緩存,提升讀寫性能,常見的是針對文件的 read()/write() 操作,另外也包括了通過 mmap() 映射之后的塊設備,也就是說,事實上 Page Cache 負責了大部分的塊設備文件的緩存工作。而 Buffer Cache 用來在系統對塊設備進行讀寫的時候,對塊進行數據緩存的系統來使用,實際上負責所有對磁盤的 I/O 訪問:

因為 Buffer Cache 是對粒度更細的設備塊的緩存,而 Page Cache 是基于虛擬內存的頁單元緩存,因此還是會基于 Buffer Cache,也就是說如果是緩存文件內容數據就會在內存里緩存兩份相同的數據,這就會導致同一份文件保存了兩份,冗余且低效。另外一個問題是,調用 write 后,有效數據是在 Buffer Cache 中,而非 Page Cache 中。這就導致 mmap 訪問的文件數據可能存在不一致問題。為了規避這個問題,所有基于磁盤文件系統的 write,都需要調用 update_vm_cache() 函數,該操作會把調用 write 之后的 Buffer Cache 更新到 Page Cache 去。由于有這些設計上的弊端,因此在 Linux 2.4 版本之后,kernel 就將兩者進行了統一,Buffer Cache 不再以獨立的形式存在,而是以融合的方式存在于 Page Cache 中:

融合之后就可以統一操作 Page CacheBuffer Cache:處理文件 I/O 緩存交給 Page Cache,而當底層 RAW device 刷新數據時以 Buffer Cache 的塊單位來實際處理。

I/O 模式

在 Linux 或者其他 Unix-like 操作系統里,I/O 模式一般有三種:

  1. 程序控制 I/O
  2. 中斷驅動 I/O
  3. DMA I/O

下面我分別詳細地講解一下這三種 I/O 模式。

程序控制 I/O

這是最簡單的一種 I/O 模式,也叫忙等待或者輪詢:用戶通過發起一個系統調用,陷入內核態,內核將系統調用翻譯成一個對應設備驅動程序的過程調用,接著設備驅動程序會啟動 I/O 不斷循環去檢查該設備,看看是否已經就緒,一般通過返回碼來表示,I/O 結束之后,設備驅動程序會把數據送到指定的地方并返回,切回用戶態。

比如發起系統調用 read()

中斷驅動 I/O

第二種 I/O 模式是利用中斷來實現的:

流程如下:

  1. 用戶進程發起一個 read() 系統調用讀取磁盤文件,陷入內核態并由其所在的 CPU 通過設備驅動程序向設備寄存器寫入一個通知信號,告知設備控制器 (我們這里是磁盤控制器)要讀取數據;
  2. 磁盤控制器啟動磁盤讀取的過程,把數據從磁盤拷貝到磁盤控制器緩沖區里;
  3. 完成拷貝之后磁盤控制器會通過總線發送一個中斷信號到中斷控制器,如果此時中斷控制器手頭還有正在處理的中斷或者有一個和該中斷信號同時到達的更高優先級的中斷,則這個中斷信號將被忽略,而磁盤控制器會在后面持續發送中斷信號直至中斷控制器受理;
  4. 中斷控制器收到磁盤控制器的中斷信號之后會通過地址總線存入一個磁盤設備的編號,表示這次中斷需要關注的設備是磁盤;
  5. 中斷控制器向 CPU 置起一個磁盤中斷信號;
  6. CPU 收到中斷信號之后停止當前的工作,把當前的 PC/PSW 等寄存器壓入堆棧保存現場,然后從地址總線取出設備編號,通過編號找到中斷向量所包含的中斷服務的入口地址,壓入 PC 寄存器,開始運行磁盤中斷服務,把數據從磁盤控制器的緩沖區拷貝到主存里的內核緩沖區;
  7. 最后 CPU 再把數據從內核緩沖區拷貝到用戶緩沖區,完成讀取操作,read() 返回,切換回用戶態。

DMA I/O

并發系統的性能高低究其根本,是取決于如何對 CPU 資源的高效調度和使用,而回頭看前面的中斷驅動 I/O 模式的流程,可以發現第 6、7 步的數據拷貝工作都是由 CPU 親自完成的,也就是在這兩次數據拷貝階段中 CPU 是完全被占用而不能處理其他工作的,那么這里明顯是有優化空間的;第 7 步的數據拷貝是從內核緩沖區到用戶緩沖區,都是在主存里,所以這一步只能由 CPU 親自完成,但是第 6 步的數據拷貝,是從磁盤控制器的緩沖區到主存,是兩個設備之間的數據傳輸,這一步并非一定要 CPU 來完成,可以借助 DMA 來完成,減輕 CPU 的負擔。

DMA 全稱是 Direct Memory Access,也即直接存儲器存取,是一種用來提供在外設和存儲器之間或者存儲器和存儲器之間的高速數據傳輸。整個過程無須 CPU 參與,數據直接通過 DMA 控制器進行快速地移動拷貝,節省 CPU 的資源去做其他工作。

目前,大部分的計算機都配備了 DMA 控制器,而 DMA 技術也支持大部分的外設和存儲器。借助于 DMA 機制,計算機的 I/O 過程就能更加高效:

DMA 控制器內部包含若干個可以被 CPU 讀寫的寄存器:一個主存地址寄存器 MAR(存放要交換數據的主存地址)、一個外設地址寄存器 ADR(存放 I/O 設備的設備碼,或者是設備信息存儲區的尋址信息)、一個字節數寄存器 WC(對傳送數據的總字數進行統計)、和一個或多個控制寄存器。

  1. 用戶進程發起一個 read() 系統調用讀取磁盤文件,陷入內核態并由其所在的 CPU 通過設置 DMA 控制器的寄存器對它進行編程:把內核緩沖區和磁盤文件的地址分別寫入 MAR 和 ADR 寄存器,然后把期望讀取的字節數寫入 WC 寄存器,啟動 DMA 控制器;
  2. DMA 控制器根據 ADR 寄存器里的信息知道這次 I/O 需要讀取的外設是磁盤的某個地址,便向磁盤控制器發出一個命令,通知它從磁盤讀取數據到其內部的緩沖區里;
  3. 磁盤控制器啟動磁盤讀取的過程,把數據從磁盤拷貝到磁盤控制器緩沖區里,并對緩沖區內數據的校驗和進行檢驗,如果數據是有效的,那么 DMA 就可以開始了;
  4. DMA 控制器通過總線向磁盤控制器發出一個讀請求信號從而發起 DMA 傳輸,這個信號和前面的中斷驅動 I/O 小節里 CPU 發給磁盤控制器的讀請求是一樣的,它并不知道或者并不關心這個讀請求是來自 CPU 還是 DMA 控制器;
  5. 緊接著 DMA 控制器將引導磁盤控制器將數據傳輸到 MAR 寄存器里的地址,也就是內核緩沖區;
  6. 數據傳輸完成之后,返回一個 ack 給 DMA 控制器,WC 寄存器里的值會減去相應的數據長度,如果 WC 還不為 0,則重復第 4 步到第 6 步,一直到 WC 里的字節數等于 0;
  7. 收到 ack 信號的 DMA 控制器會通過總線發送一個中斷信號到中斷控制器,如果此時中斷控制器手頭還有正在處理的中斷或者有一個和該中斷信號同時到達的更高優先級的中斷,則這個中斷信號將被忽略,而 DMA 控制器會在后面持續發送中斷信號直至中斷控制器受理;
  8. 中斷控制器收到磁盤控制器的中斷信號之后會通過地址總線存入一個主存設備的編號,表示這次中斷需要關注的設備是主存;
  9. 中斷控制器向 CPU 置起一個 DMA 中斷的信號;
  10. CPU 收到中斷信號之后停止當前的工作,把當前的 PC/PSW 等寄存器壓入堆棧保存現場,然后從地址總線取出設備編號,通過編號找到中斷向量所包含的中斷服務的入口地址,壓入 PC 寄存器,開始運行 DMA 中斷服務,把數據從內核緩沖區拷貝到用戶緩沖區,完成讀取操作,read() 返回,切換回用戶態。

傳統 I/O 讀寫模式

Linux 中傳統的 I/O 讀寫是通過 read()/write() 系統調用完成的,read() 把數據從存儲器 (磁盤、網卡等) 讀取到用戶緩沖區,write() 則是把數據從用戶緩沖區寫出到存儲器:

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

一次完整的讀磁盤文件然后寫出到網卡的底層傳輸過程如下:

可以清楚看到這里一共觸發了 4 次用戶態和內核態的上下文切換,分別是 read()/write() 調用和返回時的切換,2 次 DMA 拷貝,2 次 CPU 拷貝,加起來一共 4 次拷貝操作。

通過引入 DMA,我們已經把 Linux 的 I/O 過程中的 CPU 拷貝次數從 4 次減少到了 2 次,但是 CPU 拷貝依然是代價很大的操作,對系統性能的影響還是很大,特別是那些頻繁 I/O 的場景,更是會因為 CPU 拷貝而損失掉很多性能,我們需要進一步優化,降低、甚至是完全避免 CPU 拷貝。

零拷貝 (Zero-copy)

Zero-copy 是什么?

Wikipedia 的解釋如下:

"Zero-copy" describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.

零拷貝技術是指計算機執行操作時,CPU不需要先將數據從某處內存復制到另一個特定區域。這種技術通常用于通過網絡傳輸文件時節省CPU周期和內存帶寬。

Zero-copy 能做什么?

  • 減少甚至完全避免操作系統內核和用戶應用程序地址空間這兩者之間進行數據拷貝操作,從而減少用戶態 -- 內核態上下文切換帶來的系統開銷。
  • 減少甚至完全避免操作系統內核緩沖區之間進行數據拷貝操作。
  • 幫助用戶進程繞開操作系統內核空間直接訪問硬件存儲接口操作數據。
  • 利用 DMA 而非 CPU 來完成硬件接口和內核緩沖區之間的數據拷貝,從而解放 CPU,使之能去執行其他的任務,提升系統性能。

Zero-copy 的實現方式有哪些?

從 zero-copy 這個概念被提出以來,相關的實現技術便猶如雨后春筍,層出不窮。但是截至目前為止,并沒有任何一種 zero-copy 技術能滿足所有的場景需求,還是計算機領域那句無比經典的名言:"There is no silver bullet"!

而在 Linux 平臺上,同樣也有很多的 zero-copy 技術,新舊各不同,可能存在于不同的內核版本里,很多技術可能有了很大的改進或者被更新的實現方式所替代,這些不同的實現技術按照其核心思想可以歸納成大致的以下三類:

  • 減少甚至避免用戶空間和內核空間之間的數據拷貝:在一些場景下,用戶進程在數據傳輸過程中并不需要對數據進行訪問和處理,那么數據在 Linux 的 Page Cache 和用戶進程的緩沖區之間的傳輸就完全可以避免,讓數據拷貝完全在內核里進行,甚至可以通過更巧妙的方式避免在內核里的數據拷貝。這一類實現一般是通過增加新的系統調用來完成的,比如 Linux 中的 mmap(),sendfile() 以及 splice() 等。
  • 繞過內核的直接 I/O:允許在用戶態進程繞過內核直接和硬件進行數據傳輸,內核在傳輸過程中只負責一些管理和輔助的工作。這種方式其實和第一種有點類似,也是試圖避免用戶空間和內核空間之間的數據傳輸,只是第一種方式是把數據傳輸過程放在內核態完成,而這種方式則是直接繞過內核和硬件通信,效果類似但原理完全不同。
  • 內核緩沖區和用戶緩沖區之間的傳輸優化:這種方式側重于在用戶進程的緩沖區和操作系統的頁緩存之間的 CPU 拷貝的優化。這種方法延續了以往那種傳統的通信方式,但更靈活。

減少甚至避免用戶空間和內核空間之間的數據拷貝

mmap()
#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

一種簡單的實現方案是在一次讀寫過程中用 Linux 的另一個系統調用 mmap() 替換原先的 read(),mmap() 也即是內存映射(memory map):把用戶進程空間的一段內存緩沖區(user buffer)映射到文件所在的內核緩沖區(kernel buffer)上。

利用 mmap() 替換 read(),配合 write() 調用的整個流程如下:

  1. 用戶進程調用 mmap(),從用戶態陷入內核態,將內核緩沖區映射到用戶緩存區;
  2. DMA 控制器將數據從硬盤拷貝到內核緩沖區;
  3. mmap() 返回,上下文從內核態切換回用戶態;
  4. 用戶進程調用 write(),嘗試把文件數據寫到內核里的套接字緩沖區,再次陷入內核態;
  5. CPU 將內核緩沖區中的數據拷貝到的套接字緩沖區;
  6. DMA 控制器將數據從套接字緩沖區拷貝到網卡完成數據傳輸;
  7. write() 返回,上下文從內核態切換回用戶態。

通過這種方式,有兩個優點:一是節省內存空間,因為用戶進程上的這一段內存是虛擬的,并不真正占據物理內存,只是映射到文件所在的內核緩沖區上,因此可以節省一半的內存占用;二是省去了一次 CPU 拷貝,對比傳統的 Linux I/O 讀寫,數據不需要再經過用戶進程進行轉發了,而是直接在內核里就完成了拷貝。所以使用 mmap() 之后的拷貝次數是 2 次 DMA 拷貝,1 次 CPU 拷貝,加起來一共 3 次拷貝操作,比傳統的 I/O 方式節省了一次 CPU 拷貝以及一半的內存,不過因為 mmap() 也是一個系統調用,因此用戶態和內核態的切換還是 4 次。

mmap() 因為既節省 CPU 拷貝次數又節省內存,所以比較適合大文件傳輸的場景。雖然 mmap() 完全是符合 POSIX 標準的,但是它也不是完美的,因為它并不總是能達到理想的數據傳輸性能。首先是因為數據數據傳輸過程中依然需要一次 CPU 拷貝,其次是內存映射技術是一個開銷很大的虛擬存儲操作:這種操作需要修改頁表以及用內核緩沖區里的文件數據汰換掉當前 TLB 里的緩存以維持虛擬內存映射的一致性。但是,因為內存映射通常針對的是相對較大的數據區域,所以對于相同大小的數據來說,內存映射所帶來的開銷遠遠低于 CPU 拷貝所帶來的開銷。此外,使用 mmap() 還可能會遇到一些需要值得關注的特殊情況,例如,在 mmap() --> write() 這兩個系統調用的整個傳輸過程中,如果有其他的進程突然截斷了這個文件,那么這時用戶進程就會因為訪問非法地址而被一個從總線傳來的 SIGBUS 中斷信號殺死并且產生一個 core dump。有兩種解決辦法:

  1. 設置一個信號處理器,專門用來處理 SIGBUS 信號,這個處理器直接返回, write() 就可以正常返回已寫入的字節數而不會被 SIGBUS 中斷,errno 錯誤碼也會被設置成 success。然而這實際上是一個掩耳盜鈴的解決方案,因為 BIGBUS 信號的帶來的信息是系統發生了一些很嚴重的錯誤,而我們卻選擇忽略掉它,一般不建議采用這種方式。
  2. 通過內核的文件租借鎖(這是 Linux 的叫法,Windows 上稱之為機會鎖)來解決這個問題,這種方法相對來說更好一些。我們可以通過內核對文件描述符上讀/寫的租借鎖,當另外一個進程嘗試對當前用戶進程正在進行傳輸的文件進行截斷的時候,內核會發送給用戶一個實時信號:RT_SIGNAL_LEASE 信號,這個信號會告訴用戶內核正在破壞你加在那個文件上的讀/寫租借鎖,這時 write() 系統調用會被中斷,并且當前用戶進程會被 SIGBUS 信號殺死,返回值則是中斷前寫的字節數,errno 同樣會被設置為 success。文件租借鎖需要在對文件進行內存映射之前設置,最后在用戶進程結束之前釋放掉。
sendfile()

在 Linux 內核 2.1 版本中,引入了一個新的系統調用 sendfile()

#include <sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

從功能上來看,這個系統調用將 mmap() + write() 這兩個系統調用合二為一,實現了一樣效果的同時還簡化了用戶接口,其他的一些 Unix-like 的系統像 BSD、Solaris 和 AIX 等也有類似的實現,甚至 Windows 上也有一個功能類似的 API 函數 TransmitFile。

out_fd 和 in_fd 分別代表了寫入和讀出的文件描述符,in_fd 必須是一個指向文件的文件描述符,且要能支持類 mmap() 內存映射,不能是 Socket 類型,而 out_fd 在 Linux 內核 2.6.33 版本之前只能是一個指向 Socket 的文件描述符,從 2.6.33 之后則可以是任意類型的文件描述符。off_t 是一個代表了 in_fd 偏移量的指針,指示 sendfile() 該從 in_fd 的哪個位置開始讀取,函數返回后,這個指針會被更新成 sendfile() 最后讀取的字節位置處,表明此次調用共讀取了多少文件數據,最后的 count 參數則是此次調用需要傳輸的字節總數。

使用 sendfile() 完成一次數據讀寫的流程如下:

  1. 用戶進程調用 sendfile() 從用戶態陷入內核態;
  2. DMA 控制器將數據從硬盤拷貝到內核緩沖區;
  3. CPU 將內核緩沖區中的數據拷貝到套接字緩沖區;
  4. DMA 控制器將數據從套接字緩沖區拷貝到網卡完成數據傳輸;
  5. sendfile() 返回,上下文從內核態切換回用戶態。

基于 sendfile(), 整個數據傳輸過程中共發生 2 次 DMA 拷貝和 1 次 CPU 拷貝,這個和 mmap() + write() 相同,但是因為 sendfile() 只是一次系統調用,因此比前者少了一次用戶態和內核態的上下文切換開銷。讀到這里,聰明的讀者應該會開始提問了:"sendfile() 會不會遇到和 mmap() + write() 相似的文件截斷問題呢?",很不幸,答案是肯定的。sendfile() 一樣會有文件截斷的問題,但欣慰的是,sendfile() 不僅比 mmap() + write() 在接口使用上更加簡潔,而且處理文件截斷時也更加優雅:如果 sendfile() 過程中遭遇文件截斷,則 sendfile() 系統調用會被中斷殺死之前返回給用戶進程其中斷前所傳輸的字節數,errno 會被設置為 success,無需用戶提前設置信號處理器,當然你要設置一個進行個性化處理也可以,也不需要像之前那樣提前給文件描述符設置一個租借鎖,因為最終結果還是一樣的。

sendfile() 相較于 mmap() 的另一個優勢在于數據在傳輸過程中始終沒有越過用戶態和內核態的邊界,因此極大地減少了存儲管理的開銷。即便如此,sendfile() 依然是一個適用性很窄的技術,最適合的場景基本也就是一個靜態文件服務器了。而且根據 Linus 在 2001 年和其他內核維護者的郵件列表內容,其實當初之所以決定在 Linux 上實現 sendfile() 僅僅是因為在其他操作系統平臺上已經率先實現了,而且有大名鼎鼎的 Apache Web 服務器已經在使用了,為了兼容 Apache Web 服務器才決定在 Linux 上也實現這個技術,而且 sendfile() 實現上的簡潔性也和 Linux 內核的其他部分集成得很好,所以 Linus 也就同意了這個提案。

然而 sendfile() 本身是有很大問題的,從不同的角度來看的話主要是:

  1. 首先一個是這個接口并沒有進行標準化,導致 sendfile() 在 Linux 上的接口實現和其他類 Unix 系統的實現并不相同;
  2. 其次由于網絡傳輸的異步性,很難在接收端實現和 sendfile() 對接的技術,因此接收端一直沒有實現對應的這種技術;
  3. 最后從性能方面考量,因為 sendfile() 在把磁盤文件從內核緩沖區(page cache)傳輸到到套接字緩沖區的過程中依然需要 CPU 參與,這就很難避免 CPU 的高速緩存被傳輸的數據所污染。

此外,需要說明下,sendfile() 的最初設計并不是用來處理大文件的,因此如果需要處理很大的文件的話,可以使用另一個系統調用 sendfile64(),它支持對更大的文件內容進行尋址和偏移。

send?le() with DMA Scatter/Gather Copy

上一小節介紹的 sendfile() 技術已經把一次數據讀寫過程中的 CPU 拷貝的降低至只有 1 次了,但是人永遠是貪心和不知足的,現在如果想要把這僅有的一次 CPU 拷貝也去除掉,有沒有辦法呢?

當然有!通過引入一個新硬件上的支持,我們可以把這個僅剩的一次 CPU 拷貝也給抹掉:Linux 在內核 2.4 版本里引入了 DMA 的 scatter/gather -- 分散/收集功能,并修改了 sendfile() 的代碼使之和 DMA 適配。scatter 使得 DMA 拷貝可以不再需要把數據存儲在一片連續的內存空間上,而是允許離散存儲,gather 則能夠讓 DMA 控制器根據少量的元信息:一個包含了內存地址和數據大小的緩沖區描述符,收集存儲在各處的數據,最終還原成一個完整的網絡包,直接拷貝到網卡而非套接字緩沖區,避免了最后一次的 CPU 拷貝:

sendfile() + DMA gather 的數據傳輸過程如下:

  1. 用戶進程調用 sendfile(),從用戶態陷入內核態;
  2. DMA 控制器使用 scatter 功能把數據從硬盤拷貝到內核緩沖區進行離散存儲;
  3. CPU 把包含內存地址和數據長度的緩沖區描述符拷貝到套接字緩沖區,DMA 控制器能夠根據這些信息生成網絡包數據分組的報頭和報尾
  4. DMA 控制器根據緩沖區描述符里的內存地址和數據大小,使用 scatter-gather 功能開始從內核緩沖區收集離散的數據并組包,最后直接把網絡包數據拷貝到網卡完成數據傳輸;
  5. sendfile() 返回,上下文從內核態切換回用戶態。

基于這種方案,我們就可以把這僅剩的唯一一次 CPU 拷貝也給去除了(嚴格來說還是會有一次,但是因為這次 CPU 拷貝的只是那些微乎其微的元信息,開銷幾乎可以忽略不計),理論上,數據傳輸過程就再也沒有 CPU 的參與了,也因此 CPU 的高速緩存再不會被污染了,也不再需要 CPU 來計算數據校驗和了,CPU 可以去執行其他的業務計算任務,同時和 DMA 的 I/O 任務并行,此舉能極大地提升系統性能。

splice()

sendfile() + DMA Scatter/Gather 的零拷貝方案雖然高效,但是也有兩個缺點:

  1. 這種方案需要引入新的硬件支持;
  2. 雖然 sendfile() 的輸出文件描述符在 Linux kernel 2.6.33 版本之后已經可以支持任意類型的文件描述符,但是輸入文件描述符依然只能指向文件。

這兩個缺點限制了 sendfile() + DMA Scatter/Gather 方案的適用場景。為此,Linux 在 2.6.17 版本引入了一個新的系統調用 splice(),它在功能上和 sendfile() 非常相似,但是能夠實現在任意類型的兩個文件描述符時之間傳輸數據;而在底層實現上,splice()又比 sendfile() 少了一次 CPU 拷貝,也就是等同于 sendfile() + DMA Scatter/Gather,完全去除了數據傳輸過程中的 CPU 拷貝。

splice() 系統調用函數定義如下:

#include <fcntl.h>
#include <unistd.h>

int pipe(int pipefd[2]);
int pipe2(int pipefd[2], int flags);

ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

fd_in 和 fd_out 也是分別代表了輸入端和輸出端的文件描述符,這兩個文件描述符必須有一個是指向管道設備的,這也是一個不太友好的限制,雖然 Linux 內核開發的官方從這個系統調用推出之時就承諾未來可能會重構去掉這個限制,然而他們許下這個承諾之后就如同石沉大海,如今 14 年過去了,依舊杳無音訊...

off_in 和 off_out 則分別是 fd_in 和 fd_out 的偏移量指針,指示內核從哪里讀取和寫入數據,len 則指示了此次調用希望傳輸的字節數,最后的 flags 是系統調用的標記選項位掩碼,用來設置系統調用的行為屬性的,由以下 0 個或者多個值通過『或』操作組合而成:

  • SPLICE_F_MOVE:指示 splice() 嘗試僅僅是移動內存頁面而不是復制,設置了這個值不代表就一定不會復制內存頁面,復制還是移動取決于內核能否從管道中移動內存頁面,或者管道中的內存頁面是否是完整的;這個標記的初始實現有很多 bug,所以從 Linux 2.6.21 版本開始就已經無效了,但還是保留了下來,因為在未來的版本里可能會重新被實現。
  • SPLICE_F_NONBLOCK:指示 splice() 不要阻塞 I/O,也就是使得 splice() 調用成為一個非阻塞調用,可以用來實現異步數據傳輸,不過需要注意的是,數據傳輸的兩個文件描述符也最好是預先通過 O_NONBLOCK 標記成非阻塞 I/O,不然 splice() 調用還是有可能被阻塞。
  • SPLICE_F_MORE:通知內核下一個 splice() 系統調用將會有更多的數據傳輸過來,這個標記對于輸出端是 socket 的場景非常有用。

splice() 是基于 Linux 的管道緩沖區 (pipe buffer) 機制實現的,所以 splice() 的兩個入參文件描述符才要求必須有一個是管道設備,一個典型的 splice() 用法是:

int pfd[2];

pipe(pfd);

ssize_t bytes = splice(file_fd, NULL, pfd[1], NULL, 4096, SPLICE_F_MOVE);
assert(bytes != -1);

bytes = splice(pfd[0], NULL, socket_fd, NULL, bytes, SPLICE_F_MOVE | SPLICE_F_MORE);
assert(bytes != -1);

數據傳輸過程圖:

使用 splice() 完成一次磁盤文件到網卡的讀寫過程如下:

  1. 用戶進程調用 pipe(),從用戶態陷入內核態,創建匿名單向管道,pipe() 返回,上下文從內核態切換回用戶態;
  2. 用戶進程調用 splice(),從用戶態陷入內核態;
  3. DMA 控制器將數據從硬盤拷貝到內核緩沖區,從管道的寫入端"拷貝"進管道,splice() 返回,上下文從內核態回到用戶態;
  4. 用戶進程再次調用 splice(),從用戶態陷入內核態;
  5. 內核把數據從管道的讀取端"拷貝"到套接字緩沖區,DMA 控制器將數據從套接字緩沖區拷貝到網卡;
  6. splice() 返回,上下文從內核態切換回用戶態。

相信看完上面的讀寫流程之后,讀者肯定會非常困惑:說好的 splice()sendfile() 的改進版呢?sendfile() 好歹只需要一次系統調用,splice() 居然需要三次,這也就罷了,居然中間還搞出來一個管道,而且還要在內核空間拷貝兩次,這算個毛的改進???

我最開始了解 splice() 的時候,也是這個反應,但是深入學習它之后,才漸漸知曉個中奧妙,且聽我細細道來:

先來了解一下 pipe buffer 管道,管道是 Linux 上用來供進程之間通信的信道,管道有兩個端:寫入端和讀出端,從進程的視角來看,管道表現為一個 FIFO 字節流環形隊列:

管道本質上是一個內存中的文件,也就是本質上還是基于 Linux 的 VFS,用戶進程可以通過 pipe() 系統調用創建一個匿名管道,創建完成之后會有兩個 VFS 的 file 結構體的 inode 分別指向其寫入端和讀出端,并返回對應的兩個文件描述符,用戶進程通過這兩個文件描述符讀寫管道;管道的容量單位是一個虛擬內存的頁,也就是 4KB,總大小一般是 16 個頁,基于其環形結構,管道的頁可以循環使用,提高內存利用率。 Linux 中以 pipe_buffer 結構體封裝管道頁,file 結構體里的 inode 字段里會保存一個 pipe_inode_info 結構體指代管道,其中會保存很多讀寫管道時所需的元信息,環形隊列的頭部指針頁,讀寫時的同步機制如互斥鎖、等待隊列等:

struct pipe_buffer {
    struct page *page; // 內存頁結構
    unsigned int offset, len; // 偏移量,長度
    const struct pipe_buf_operations *ops;
    unsigned int flags;
    unsigned long private;
};

struct pipe_inode_info {
    struct mutex mutex;
    wait_queue_head_t wait;
    unsigned int nrbufs, curbuf, buffers;
    unsigned int readers;
    unsigned int writers;
    unsigned int files;
    unsigned int waiting_writers;
    unsigned int r_counter;
    unsigned int w_counter;
    struct page *tmp_page;
    struct fasync_struct *fasync_readers;
    struct fasync_struct *fasync_writers;
    struct pipe_buffer *bufs;
    struct user_struct *user;
};

pipe_buffer 中保存了數據在內存中的頁、偏移量和長度,以這三個值來定位數據,注意這里的頁不是虛擬內存的頁,而用的是物理內存的頁框,因為管道時跨進程的信道,因此不能使用虛擬內存來表示,只能使用物理內存的頁框定位數據;管道的正常讀寫操作是通過 pipe_write()/pipe_read() 來完成的,通過把數據讀取/寫入環形隊列的 pipe_buffer 來完成數據傳輸。

splice() 是基于 pipe buffer 實現的,但是它在通過管道傳輸數據的時候卻是零拷貝,因為它在寫入讀出時并沒有使用 pipe_write()/pipe_read() 真正地在管道緩沖區寫入讀出數據,而是通過把數據在內存緩沖區中的物理內存頁框指針、偏移量和長度賦值給前文提及的 pipe_buffer 中對應的三個字段來完成數據的"拷貝",也就是其實只拷貝了數據的內存地址等元信息。

splice() 在 Linux 內核源碼中的內部實現是 do_splice() 函數,而寫入讀出管道則分別是通過 do_splice_to()do_splice_from(),這里我們重點來解析下寫入管道的源碼,也就是 do_splice_to(),我現在手頭的 Linux 內核版本是 v4.8.17,我們就基于這個版本來分析,至于讀出的源碼函數 do_splice_from(),原理是相通的,大家舉一反三即可。

splice() 寫入數據到管道的調用鏈式:do_splice() --> do_splice_to() --> splice_read()

static long do_splice(struct file *in, loff_t __user *off_in,
              struct file *out, loff_t __user *off_out,
              size_t len, unsigned int flags)
{
...

    // 判斷是寫出 fd 是一個管道設備,則進入數據寫入的邏輯
    if (opipe) {
        if (off_out)
            return -ESPIPE;
        if (off_in) {
            if (!(in->f_mode & FMODE_PREAD))
                return -EINVAL;
            if (copy_from_user(&offset, off_in, sizeof(loff_t)))
                return -EFAULT;
        } else {
            offset = in->f_pos;
        }

        // 調用 do_splice_to 把文件內容寫入管道
        ret = do_splice_to(in, &offset, opipe, len, flags);

        if (!off_in)
            in->f_pos = offset;
        else if (copy_to_user(off_in, &offset, sizeof(loff_t)))
            ret = -EFAULT;

        return ret;
    }

    return -EINVAL;
}

進入 do_splice_to() 之后,再調用 splice_read()

static long do_splice_to(struct file *in, loff_t *ppos,
             struct pipe_inode_info *pipe, size_t len,
             unsigned int flags)
{
    ssize_t (*splice_read)(struct file *, loff_t *,
                   struct pipe_inode_info *, size_t, unsigned int);
    int ret;

    if (unlikely(!(in->f_mode & FMODE_READ)))
        return -EBADF;

    ret = rw_verify_area(READ, in, ppos, len);
    if (unlikely(ret < 0))
        return ret;

    if (unlikely(len > MAX_RW_COUNT))
        len = MAX_RW_COUNT;

    // 判斷文件的文件的 file 結構體的 f_op 中有沒有可供使用的、支持 splice 的 splice_read 函數指針
    // 因為是 splice() 調用,因此內核會提前給這個函數指針指派一個可用的函數
    if (in->f_op->splice_read)
        splice_read = in->f_op->splice_read;
    else
        splice_read = default_file_splice_read;

    return splice_read(in, ppos, pipe, len, flags);
}

in->f_op->splice_read 這個函數指針根據文件描述符的類型不同有不同的實現,比如這里的 in 是一個文件,因此是 generic_file_splice_read(),如果是 socket 的話,則是 sock_splice_read(),其他的類型也會有對應的實現,總之我們這里將使用的是 generic_file_splice_read() 函數,這個函數會繼續調用內部函數 __generic_file_splice_read 完成以下工作:

  1. 在 page cache 頁緩存里進行搜尋,看看我們要讀取這個文件內容是否已經在緩存里了,如果是則直接用,否則如果不存在或者只有部分數據在緩存中,則分配一些新的內存頁并進行讀入數據操作,同時會增加頁框的引用計數;
  2. 基于這些內存頁,初始化 splice_pipe_desc 結構,這個結構保存會保存文件數據的地址元信息,包含有物理內存頁框地址,偏移、數據長度,也就是 pipe_buffer 所需的三個定位數據的值;
  3. 最后,調用 splice_to_pipe(),splice_pipe_desc 結構體實例是函數入參。
ssize_t splice_to_pipe(struct pipe_inode_info *pipe, struct splice_pipe_desc *spd)
{
...

    for (;;) {
        if (!pipe->readers) {
            send_sig(SIGPIPE, current, 0);
            if (!ret)
                ret = -EPIPE;
            break;
        }

        if (pipe->nrbufs < pipe->buffers) {
            int newbuf = (pipe->curbuf + pipe->nrbufs) & (pipe->buffers - 1);
            struct pipe_buffer *buf = pipe->bufs + newbuf;

            // 寫入數據到管道,沒有真正拷貝數據,而是內存地址指針的移動,
            // 把物理頁框、偏移量和數據長度賦值給 pipe_buffer 完成數據入隊操作
            buf->page = spd->pages[page_nr];
            buf->offset = spd->partial[page_nr].offset;
            buf->len = spd->partial[page_nr].len;
            buf->private = spd->partial[page_nr].private;
            buf->ops = spd->ops;
            if (spd->flags & SPLICE_F_GIFT)
                buf->flags |= PIPE_BUF_FLAG_GIFT;

            pipe->nrbufs++;
            page_nr++;
            ret += buf->len;

            if (pipe->files)
                do_wakeup = 1;

            if (!--spd->nr_pages)
                break;
            if (pipe->nrbufs < pipe->buffers)
                continue;

            break;
        }

    ...
}

這里可以清楚地看到 splice() 所謂的寫入數據到管道其實并沒有真正地拷貝數據,而是玩了個 tricky 的操作:只進行內存地址指針的拷貝而不真正去拷貝數據。所以,數據 splice() 在內核中并沒有進行真正的數據拷貝,因此 splice() 系統調用也是零拷貝。

還有一點需要注意,前面說過管道的容量是 16 個內存頁,也就是 16 * 4KB = 64 KB,也就是說一次往管道里寫數據的時候最好不要超過 64 KB,否則的話會 splice() 會阻塞住,除非在創建管道的時候使用的是 pipe2() 并通過傳入 O_NONBLOCK 屬性將管道設置為非阻塞。

即使 splice() 通過內存地址指針避免了真正的拷貝開銷,但是算起來它還要使用額外的管道來完成數據傳輸,也就是比 sendfile() 多了兩次系統調用,這不是又增加了上下文切換的開銷嗎?為什么不直接在內核創建管道并調用那兩次 splice(),然后只暴露給用戶一次系統調用呢?實際上因為 splice() 利用管道而非硬件來完成零拷貝的實現比 sendfile() + DMA Scatter/Gather 的門檻更低,因此后來的 sendfile() 的底層實現就已經替換成 splice() 了。

至于說 splice() 本身的 API 為什么還是這種使用模式,那是因為 Linux 內核開發團隊一直想把基于管道的這個限制去掉,但不知道因為什么一直擱置,所以這個 API 也就一直沒變化,只能等內核團隊哪天想起來了這一茬,然后重構一下使之不再依賴管道,在那之前,使用 splice() 依然還是需要額外創建管道來作為中間緩沖,如果你的業務場景很適合使用 splice(),但又是性能敏感的,不想頻繁地創建銷毀 pipe buffer 管道緩沖區,那么可以參考一下 HAProxy 使用 splice() 時采用的優化方案:預先分配一個 pipe buffer pool 緩存管道,每次調用 spclie() 的時候去緩存池里取一個管道,用完就放回去,循環利用,提升性能。

send() with MSG_ZEROCOPY

Linux 內核在 2017 年的 v4.14 版本接受了來自 Google 工程師 Willem de Bruijn 在 TCP 網絡報文的通用發送接口 send() 中實現的 zero-copy 功能 (MSG_ZEROCOPY) 的 patch,通過這個新功能,用戶進程就能夠把用戶緩沖區的數據通過零拷貝的方式經過內核空間發送到網絡套接字中去,這個新技術和前文介紹的幾種零拷貝方式相比更加先進,因為前面幾種零拷貝技術都是要求用戶進程不能處理加工數據而是直接轉發到目標文件描述符中去的。Willem de Bruijn 在他的論文里給出的壓測數據是:采用 netperf 大包發送測試,性能提升 39%,而線上環境的數據發送性能則提升了 5%~8%,官方文檔陳述說這個特性通常只在發送 10KB 左右大包的場景下才會有顯著的性能提升。一開始這個特性只支持 TCP,到內核 v5.0 版本之后才支持 UDP。

這個功能的使用模式如下:

if (setsockopt(socket_fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof(one)))
        error(1, errno, "setsockopt zerocopy");

ret = send(socket_fd, buffer, sizeof(buffer), MSG_ZEROCOPY);

首先第一步,先給要發送數據的 socket 設置一個 SOCK_ZEROCOPY option,然后在調用 send() 發送數據時再設置一個 MSG_ZEROCOPY option,其實理論上來說只需要調用 setsockopt() 或者 send() 時傳遞這個 zero-copy 的 option 即可,兩者選其一,但是這里卻要設置同一個 option 兩次,官方的說法是為了兼容 send() API 以前的設計上的一個錯誤:send() 以前的實現會忽略掉未知的 option,為了兼容那些可能已經不小心設置了 MSG_ZEROCOPY option 的程序,故而設計成了兩步設置。不過我猜還有一種可能:就是給使用者提供更靈活的使用模式,因為這個新功能只在大包場景下才可能會有顯著的性能提升,但是現實場景是很復雜的,不僅僅是全部大包或者全部小包的場景,有可能是大包小包混合的場景,因此使用者可以先調用 setsockopt() 設置 SOCK_ZEROCOPY option,然后再根據實際業務場景中的網絡包尺寸選擇是否要在調用 send() 時使用 MSG_ZEROCOPY 進行 zero-copy 傳輸。

因為 send() 可能是異步發送數據,因此使用 MSG_ZEROCOPY 有一個需要特別注意的點是:調用 send() 之后不能立刻重用或釋放 buffer,因為 buffer 中的數據不一定已經被內核讀走了,所以還需要從 socket 關聯的錯誤隊列里讀取一下通知消息,看看 buffer 中的數據是否已經被內核讀走了:

pfd.fd = fd;
pfd.events = 0;
if (poll(&pfd, 1, -1) != 1 || pfd.revents & POLLERR == 0)
        error(1, errno, "poll");

ret = recvmsg(fd, &msg, MSG_ERRQUEUE);
if (ret == -1)
        error(1, errno, "recvmsg");

read_notification(msg);


uint32_t read_notification(struct msghdr *msg)
{
    struct sock_extended_err *serr;
    struct cmsghdr *cm;
    
    cm = CMSG_FIRSTHDR(msg);
    if (cm->cmsg_level != SOL_IP &&
        cm->cmsg_type != IP_RECVERR)
            error(1, 0, "cmsg");
    
    serr = (void *) CMSG_DATA(cm);
    if (serr->ee_errno != 0 ||
        serr->ee_origin != SO_EE_ORIGIN_ZEROCOPY)
            error(1, 0, "serr");
    
    return serr->ee _ data;
}

這個技術是基于 redhat 紅帽在 2010 年給 Linux 內核提交的 virtio-net zero-copy 技術之上實現的,至于底層原理,簡單來說就是通過 send() 把數據在用戶緩沖區中的分段指針發送到 socket 中去,利用 page pinning 頁鎖定機制鎖住用戶緩沖區的內存頁,然后利用 DMA 直接在用戶緩沖區通過內存地址指針進行數據讀取,實現零拷貝;具體的細節可以通過閱讀 Willem de Bruijn 的論文 (PDF) 深入了解。

目前來說,這種技術的主要缺陷有:

  1. 只適用于大文件 (10KB 左右) 的場景,小文件場景因為 page pinning 頁鎖定和等待緩沖區釋放的通知消息這些機制,甚至可能比直接 CPU 拷貝更耗時;
  2. 因為可能異步發送數據,需要額外調用 poll()recvmsg() 系統調用等待 buffer 被釋放的通知消息,增加代碼復雜度,以及會導致多次用戶態和內核態的上下文切換;
  3. MSG_ZEROCOPY 目前只支持發送端,接收端暫不支持。

繞過內核的直接 I/O

可以看出,前面種種的 zero-copy 的方法,都是在想方設法地優化減少或者去掉用戶態和內核態之間以及內核態和內核態之間的數據拷貝,為了實現避免這些拷貝可謂是八仙過海,各顯神通,采用了各種各樣的手段,那么如果我們換個思路:其實這么費勁地去消除這些拷貝不就是因為有內核在摻和嗎?如果我們繞過內核直接進行 I/O 不就沒有這些煩人的拷貝問題了嗎?這就是繞過內核直接 I/O 技術:

這種方案有兩種實現方式:

  1. 用戶直接訪問硬件
  2. 內核控制訪問硬件
用戶直接訪問硬件

這種技術賦予用戶進程直接訪問硬件設備的權限,這讓用戶進程能有直接讀寫硬件設備,在數據傳輸過程中只需要內核做一些虛擬內存配置相關的工作。這種無需數據拷貝和內核干預的直接 I/O,理論上是最高效的數據傳輸技術,但是正如前面所說的那樣,并不存在能解決一切問題的銀彈,這種直接 I/O 技術雖然有可能非常高效,但是它的適用性也非常窄,目前只適用于諸如 MPI 高性能通信、叢集計算系統中的遠程共享內存等有限的場景。

這種技術實際上破壞了現代計算機操作系統最重要的概念之一 —— 硬件抽象,我們之前提過,抽象是計算機領域最最核心的設計思路,正式由于有了抽象和分層,各個層級才能不必去關心很多底層細節從而專注于真正的工作,才使得系統的運作更加高效和快速。此外,網卡通常使用功能較弱的 CPU,例如只包含簡單指令集的 MIPS 架構處理器(沒有不必要的功能,如浮點數計算等),也沒有太多的內存來容納復雜的軟件。因此,通常只有那些基于以太網之上的專用協議會使用這種技術,這些專用協議的設計要比遠比 TCP/IP 簡單得多,而且多用于局域網環境中,在這種環境中,數據包丟失和損壞很少發生,因此沒有必要進行復雜的數據包確認和流量控制機制。而且這種技術還需要定制的網卡,所以它是高度依賴硬件的。

與傳統的通信設計相比,直接硬件訪問技術給程序設計帶來了各種限制:由于設備之間的數據傳輸是通過 DMA 完成的,因此用戶空間的數據緩沖區內存頁必須進行 page pinning(頁鎖定),這是為了防止其物理頁框地址被交換到磁盤或者被移動到新的地址而導致 DMA 去拷貝數據的時候在指定的地址找不到內存頁從而引發缺頁錯誤,而頁鎖定的開銷并不比 CPU 拷貝小,所以為了避免頻繁的頁鎖定系統調用,應用程序必須分配和注冊一個持久的內存池,用于數據緩沖。

用戶直接訪問硬件的技術可以得到極高的 I/O 性能,但是其應用領域和適用場景也極其的有限,如集群或網絡存儲系統中的節點通信。它需要定制的硬件和專門設計的應用程序,但相應地對操作系統內核的改動比較小,可以很容易地以內核模塊或設備驅動程序的形式實現出來。直接訪問硬件還可能會帶來嚴重的安全問題,因為用戶進程擁有直接訪問硬件的極高權限,所以如果你的程序設計沒有做好的話,可能會消耗本來就有限的硬件資源或者進行非法地址訪問,可能也會因此間接地影響其他正在使用同一設備的應用程序,而因為繞開了內核,所以也無法讓內核替你去控制和管理。

內核控制訪問硬件

相較于用戶直接訪問硬件技術,通過內核控制的直接訪問硬件技術更加的安全,它比前者在數據傳輸過程中會多干預一點,但也僅僅是作為一個代理人這樣的角色,不會參與到實際的數據傳輸過程,內核會控制 DMA 引擎去替用戶進程做緩沖區的數據傳輸工作。同樣的,這種方式也是高度依賴硬件的,比如一些集成了專有網絡棧協議的網卡。這種技術的一個優勢就是用戶集成去 I/O 時的接口不會改變,就和普通的 read()/write() 系統調用那樣使用即可,所有的臟活累活都在內核里完成,用戶接口友好度很高,不過需要注意的是,使用這種技術的過程中如果發生了什么不可預知的意外從而導致無法使用這種技術進行數據傳輸的話,則內核會自動切換為最傳統 I/O 模式,也就是性能最差的那種模式。

這種技術也有著和用戶直接訪問硬件技術一樣的問題:DMA 傳輸數據的過程中,用戶進程的緩沖區內存頁必須進行 page pinning 頁鎖定,數據傳輸完成后才能解鎖。CPU 高速緩存內保存的多個內存地址也會被沖刷掉以保證 DMA 傳輸前后的數據一致性。這些機制有可能會導致數據傳輸的性能變得更差,因為 read()/write() 系統調用的語義并不能提前通知 CPU 用戶緩沖區要參與 DMA 數據傳輸傳輸,因此也就無法像內核緩沖區那樣可依提前加載進高速緩存,提高性能。由于用戶緩沖區的內存頁可能分布在物理內存中的任意位置,因此一些實現不好的 DMA 控制器引擎可能會有尋址限制從而導致無法訪問這些內存區域。一些技術比如 AMD64 架構中的 IOMMU,允許通過將 DMA 地址重新映射到內存中的物理地址來解決這些限制,但反過來又可能會導致可移植性問題,因為其他的處理器架構,甚至是 Intel 64 位 x86 架構的變種 EM64T 都不具備這樣的特性單元。此外,還可能存在其他限制,比如 DMA 傳輸的數據對齊問題,又會導致無法訪問用戶進程指定的任意緩沖區內存地址。

內核緩沖區和用戶緩沖區之間的傳輸優化

到目前為止,我們討論的 zero-copy 技術都是基于減少甚至是避免用戶空間和內核空間之間的 CPU 數據拷貝的,雖然有一些技術非常高效,但是大多都有適用性很窄的問題,比如 sendfile()、splice() 這些,效率很高,但是都只適用于那些用戶進程不需要直接處理數據的場景,比如靜態文件服務器或者是直接轉發數據的代理服務器。

現在我們已經知道,硬件設備之間的數據可以通過 DMA 進行傳輸,然而卻并沒有這樣的傳輸機制可以應用于用戶緩沖區和內核緩沖區之間的數據傳輸。不過另一方面,廣泛應用在現代的 CPU 架構和操作系統上的虛擬內存機制表明,通過在不同的虛擬地址上重新映射頁面可以實現在用戶進程和內核之間虛擬復制和共享內存,盡管一次傳輸的內存顆粒度相對較大:4KB 或 8KB。

因此如果要在實現在用戶進程內處理數據(這種場景比直接轉發數據更加常見)之后再發送出去的話,用戶空間和內核空間的數據傳輸就是不可避免的,既然避無可避,那就只能選擇優化了,因此本章節我們要介紹兩種優化用戶空間和內核空間數據傳輸的技術:

  1. 動態重映射與寫時拷貝 (Copy-on-Write)
  2. 緩沖區共享 (Buffer Sharing)
動態重映射與寫時拷貝 (Copy-on-Write)

前面我們介紹過利用內存映射技術來減少數據在用戶空間和內核空間之間的復制,通常簡單模式下,用戶進程是對共享的緩沖區進行同步阻塞讀寫的,這樣不會有 data race 問題,但是這種模式下效率并不高,而提升效率的一種方法就是異步地對共享緩沖區進行讀寫,而這樣的話就必須引入保護機制來避免數據沖突問題,寫時復制 (Copy on Write) 就是這樣的一種技術。

寫入時復制Copy-on-write,COW)是一種計算機程序設計領域的優化策略。其核心思想是,如果有多個調用者(callers)同時請求相同資源(如內存或磁盤上的數據存儲),他們會共同獲取相同的指針指向相同的資源,直到某個調用者試圖修改資源的內容時,系統才會真正復制一份專用副本(private copy)給該調用者,而其他調用者所見到的最初的資源仍然保持不變。這過程對其他的調用者都是透明的。此作法主要的優點是如果調用者沒有修改該資源,就不會有副本(private copy)被創建,因此多個調用者只是讀取操作時可以共享同一份資源。

舉一個例子,引入了 COW 技術之后,用戶進程讀取磁盤文件進行數據處理最后寫到網卡,首先使用內存映射技術讓用戶緩沖區和內核緩沖區共享了一段內存地址并標記為只讀 (read-only),避免數據拷貝,而當要把數據寫到網卡的時候,用戶進程選擇了異步寫的方式,系統調用會直接返回,數據傳輸就會在內核里異步進行,而用戶進程就可以繼續其他的工作,并且共享緩沖區的內容可以隨時再進行讀取,效率很高,但是如果該進程又嘗試往共享緩沖區寫入數據,則會產生一個 COW 事件,讓試圖寫入數據的進程把數據復制到自己的緩沖區去修改,這里只需要復制要修改的內存頁即可,無需所有數據都復制過去,而如果其他訪問該共享內存的進程不需要修改數據則可以永遠不需要進行數據拷貝。

COW 是一種建構在虛擬內存沖映射技術之上的技術,因此它需要 MMU 的硬件支持,MMU 會記錄當前哪些內存頁被標記成只讀,當有進程嘗試往這些內存頁中寫數據的時候,MMU 就會拋一個異常給操作系統內核,內核處理該異常時為該進程分配一份物理內存并復制數據到此內存地址,重新向 MMU 發出執行該進程的寫操作。

COW 最大的優勢是節省內存和減少數據拷貝,不過卻是通過增加操作系統內核 I/O 過程復雜性作為代價的。當確定采用 COW 來復制頁面時,重要的是注意空閑頁面的分配位置。許多操作系統為這類請求提供了一個空閑的頁面池。當進程的堆?;蚨岩獢U展時或有寫時復制頁面需要管理時,通常分配這些空閑頁面。操作系統分配這些頁面通常采用稱為按需填零的技術。按需填零頁面在需要分配之前先填零,因此會清除里面舊的內容。

局限性

COW 這種零拷貝技術比較適用于那種多讀少寫從而使得 COW 事件發生較少的場景,因為 COW 事件所帶來的系統開銷要遠遠高于一次 CPU 拷貝所產生的。此外,在實際應用的過程中,為了避免頻繁的內存映射,可以重復使用同一段內存緩沖區,因此,你不需要在只用過一次共享緩沖區之后就解除掉內存頁的映射關系,而是重復循環使用,從而提升性能,不過這種內存頁映射的持久化并不會減少由于頁表往返移動和 TLB 沖刷所帶來的系統開銷,因為每次接收到 COW 事件之后對內存頁而進行加鎖或者解鎖的時候,頁面的只讀標志 (read-ony) 都要被更改為 (write-only)。

緩沖區共享 (Buffer Sharing)

從前面的介紹可以看出,傳統的 Linux I/O接口,都是基于復制/拷貝的:數據需要在操作系統內核空間和用戶空間的緩沖區之間進行拷貝。在進行 I/O 操作之前,用戶進程需要預先分配好一個內存緩沖區,使用 read() 系統調用時,內核會將從存儲器或者網卡等設備讀入的數據拷貝到這個用戶緩沖區里;而使用 write() 系統調用時,則是把用戶內存緩沖區的數據拷貝至內核緩沖區。

為了實現這種傳統的 I/O 模式,Linux 必須要在每一個 I/O 操作時都進行內存虛擬映射和解除。這種內存頁重映射的機制的效率嚴重受限于緩存體系結構、MMU 地址轉換速度和 TLB 命中率。如果能夠避免處理 I/O 請求的虛擬地址轉換和 TLB 刷新所帶來的開銷,則有可能極大地提升 I/O 性能。而緩沖區共享就是用來解決上述問題的一種技術。

最早支持 Buffer Sharing 的操作系統是 Solaris。后來,Linux 也逐步支持了這種 Buffer Sharing 的技術,但時至今日依然不夠完整和成熟。

操作系統內核開發者們實現了一種叫 fbufs 的緩沖區共享的框架,也即快速緩沖區( Fast Buffers ),使用一個 fbuf 緩沖區作為數據傳輸的最小單位,使用這種技術需要調用新的操作系統 API,用戶區和內核區、內核區之間的數據都必須嚴格地在 fbufs 這個體系下進行通信。fbufs 為每一個用戶進程分配一個 buffer pool,里面會儲存預分配 (也可以使用的時候再分配) 好的 buffers,這些 buffers 會被同時映射到用戶內存空間和內核內存空間。fbufs 只需通過一次虛擬內存映射操作即可創建緩沖區,有效地消除那些由存儲一致性維護所引發的大多數性能損耗。

傳統的 Linux I/O 接口是通過把數據在用戶緩沖區和內核緩沖區之間進行拷貝傳輸來完成的,這種數據傳輸過程中需要進行大量的數據拷貝,同時由于虛擬內存技術的存在,I/O 過程中還需要頻繁地通過 MMU 進行虛擬內存地址到物理內存地址的轉換,高速緩存的汰換以及 TLB 的刷新,這些操作均會導致性能的損耗。而如果利用 fbufs 框架來實現數據傳輸的話,首先可以把 buffers 都緩存到 pool 里循環利用,而不需要每次都去重新分配,而且緩存下來的不止有 buffers 本身,而且還會把虛擬內存地址到物理內存地址的映射關系也緩存下來,也就可以避免每次都進行地址轉換,從發送接收數據的層面來說,用戶進程和 I/O 子系統比如設備驅動程序、網卡等可以直接傳輸整個緩沖區本身而不是其中的數據內容,也可以理解成是傳輸內存地址指針,這樣就就避免了大量的數據內容拷貝:用戶進程/ IO 子系統通過發送一個個的 fbuf 寫出數據到內核而非直接傳遞數據內容,相對應的,用戶進程/ IO 子系統通過接收一個個的 fbuf 而從內核讀入數據,這樣就能減少傳統的 read()/write() 系統調用帶來的數據拷貝開銷:

  1. 發送方用戶進程調用 uf_allocate 從自己的 buffer pool 獲取一個 fbuf 緩沖區,往其中填充內容之后調用 uf_write 向內核區發送指向 fbuf 的文件描述符;
  2. I/O 子系統接收到 fbuf 之后,調用 uf_allocb 從接收方用戶進程的 buffer pool 獲取一個 fubf 并用接收到的數據進行填充,然后向用戶區發送指向 fbuf 的文件描述符;
  3. 接收方用戶進程調用 uf_get 接收到 fbuf,讀取數據進行處理,完成之后調用 uf_deallocate 把 fbuf 放回自己的 buffer pool。

fbufs 的缺陷

共享緩沖區技術的實現需要依賴于用戶進程、操作系統內核、以及 I/O 子系統 (設備驅動程序,文件系統等)之間協同工作。比如,設計得不好的用戶進程容易就會修改已經發送出去的 fbuf 從而污染數據,更要命的是這種問題很難 debug。雖然這個技術的設計方案非常精彩,但是它的門檻和限制卻不比前面介紹的其他技術少:首先會對操作系統 API 造成變動,需要使用新的一些 API 調用,其次還需要設備驅動程序配合改動,還有由于是內存共享,內核需要很小心謹慎地實現對這部分共享的內存進行數據保護和同步的機制,而這種并發的同步機制是非常容易出 bug 的從而又增加了內核的代碼復雜度,等等。因此這一類的技術還遠遠沒有到發展成熟和廣泛應用的階段,目前大多數的實現都還處于實驗階段。

總結

本文中我主要講解了 Linux I/O 底層原理,然后介紹并解析了 Linux 中的 Zero-copy 技術,并給出了 Linux 對 I/O 模塊的優化和改進思路。

Linux 的 Zero-copy 技術可以歸納成以下三大類:

  • 減少甚至避免用戶空間和內核空間之間的數據拷貝:在一些場景下,用戶進程在數據傳輸過程中并不需要對數據進行訪問和處理,那么數據在 Linux 的 Page Cache 和用戶進程的緩沖區之間的傳輸就完全可以避免,讓數據拷貝完全在內核里進行,甚至可以通過更巧妙的方式避免在內核里的數據拷貝。這一類實現一般是是通過增加新的系統調用來完成的,比如 Linux 中的 mmap(),sendfile() 以及 splice() 等。
  • 繞過內核的直接 I/O:允許在用戶態進程繞過內核直接和硬件進行數據傳輸,內核在傳輸過程中只負責一些管理和輔助的工作。這種方式其實和第一種有點類似,也是試圖避免用戶空間和內核空間之間的數據傳輸,只是第一種方式是把數據傳輸過程放在內核態完成,而這種方式則是直接繞過內核和硬件通信,效果類似但原理完全不同。
  • 內核緩沖區和用戶緩沖區之間的傳輸優化:這種方式側重于在用戶進程的緩沖區和操作系統的頁緩存之間的 CPU 拷貝的優化。這種方法延續了以往那種傳統的通信方式,但更靈活。

本文從虛擬內存、I/O 緩沖區,用戶態&內核態以及 I/O 模式等等知識點全面而又詳盡地剖析了 Linux 系統的 I/O 底層原理,分析了 Linux 傳統的 I/O 模式的弊端,進而引入 Linux Zero-copy 零拷貝技術的介紹和原理解析,通過將零拷貝技術和傳統的 I/O 模式進行區分和對比,帶領讀者經歷了 Linux I/O 的演化歷史,通過幫助讀者理解 Linux 內核對 I/O 模塊的優化改進思路,相信不僅僅是讓讀者了解 Linux 底層系統的設計原理,更能對讀者們在以后優化改進自己的程序設計過程中能夠有所啟發。

參考&延伸閱讀

查看原文

贊 22 收藏 11 評論 0

panjf2000 發布了文章 · 2020-12-28

Go netpoller 原生網絡模型之源碼全面揭秘

博客原文

Go netpoller 原生網絡模型之源碼全面揭秘

導言

Go 基于 I/O multiplexing 和 goroutine scheduler 構建了一個簡潔而高性能的原生網絡模型(基于 Go 的 I/O 多路復用 netpoller ),提供了 goroutine-per-connection 這樣簡單的網絡編程模式。在這種模式下,開發者使用的是同步的模式去編寫異步的邏輯,極大地降低了開發者編寫網絡應用時的心智負擔,且借助于 Go runtime scheduler 對 goroutines 的高效調度,這個原生網絡模型不論從適用性還是性能上都足以滿足絕大部分的應用場景。

然而,在工程性上能做到如此高的普適性和兼容性,最終暴露給開發者提供接口/模式如此簡潔,其底層必然是基于非常復雜的封裝,做了很多取舍,也有可能放棄了一些追求極致性能的設計和理念。事實上 Go netpoller 底層就是基于 epoll/kqueue/iocp 這些 I/O 多路復用技術來做封裝的,最終暴露出 goroutine-per-connection 這樣的極簡的開發模式給使用者。

Go netpoller 在不同的操作系統,其底層使用的 I/O 多路復用技術也不一樣,可以從 Go 源碼目錄結構和對應代碼文件了解 Go 在不同平臺下的網絡 I/O 模式的實現。比如,在 Linux 系統下基于 epoll,freeBSD 系統下基于 kqueue,以及 Windows 系統下基于 iocp。

本文將基于 Linux 平臺來解析 Go netpoller 之 I/O 多路復用的底層是如何基于 epoll 封裝實現的,從源碼層層推進,全面而深度地解析 Go netpoller 的設計理念和實現原理,以及 Go 是如何利用 netpoller 來構建它的原生網絡模型的。主要涉及到的一些概念:I/O 模型、用戶/內核空間、epoll、Linux 源碼、goroutine scheduler 等等,我會盡量簡單地講解,如果有對相關概念不熟悉的同學,還是希望能提前熟悉一下。

用戶空間與內核空間

現代操作系統都是采用虛擬存儲器,那么對 32 位操作系統而言,它的尋址空間(虛擬存儲空間)為 4G(2 的 32 次方)。操作系統的核心是內核,獨立于普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的所有權限。為了保證用戶進程不能直接操作內核(kernel),保證內核的安全,操心系統將虛擬空間劃分為兩部分,一部分為內核空間,一部分為用戶空間。針對 Linux 操作系統而言,將最高的 1G 字節(從虛擬地址 0xC0000000 到 0xFFFFFFFF),供內核使用,稱為內核空間,而將較低的 3G 字節(從虛擬地址 0x00000000 到 0xBFFFFFFF),供各個進程使用,稱為用戶空間。

現代的網絡服務的主流已經完成從 CPU 密集型到 IO 密集型的轉變,所以服務端程序對 I/O 的處理必不可少,而一旦操作 I/O 則必定要在用戶態和內核態之間來回切換。

I/O 模型

在神作《UNIX 網絡編程》里,總結歸納了 5 種 I/O 模型,包括同步和異步 I/O:

  • 阻塞 I/O (Blocking I/O)
  • 非阻塞 I/O (Nonblocking I/O)
  • I/O 多路復用 (I/O multiplexing)
  • 信號驅動 I/O (Signal driven I/O)
  • 異步 I/O (Asynchronous I/O)

操作系統上的 I/O 是用戶空間和內核空間的數據交互,因此 I/O 操作通常包含以下兩個步驟:

  1. 等待網絡數據到達網卡(讀就緒)/等待網卡可寫(寫就緒) –> 讀取/寫入到內核緩沖區
  2. 從內核緩沖區復制數據 –> 用戶空間(讀)/從用戶空間復制數據 -> 內核緩沖區(寫)

而判定一個 I/O 模型是同步還是異步,主要看第二步:數據在用戶和內核空間之間復制的時候是不是會阻塞當前進程,如果會,則是同步 I/O,否則,就是異步 I/O?;谶@個原則,這 5 種 I/O 模型中只有一種異步 I/O 模型:Asynchronous I/O,其余都是同步 I/O 模型。

這 5 種 I/O 模型的對比如下:

Non-blocking I/O

什么叫非阻塞 I/O,顧名思義就是:所有 I/O 操作都是立刻返回而不會阻塞當前用戶進程。I/O 多路復用通常情況下需要和非阻塞 I/O 搭配使用,否則可能會產生意想不到的問題。比如,epoll 的 ET(邊緣觸發) 模式下,如果不使用非阻塞 I/O,有極大的概率會導致阻塞 event-loop 線程,從而降低吞吐量,甚至導致 bug。

Linux 下,我們可以通過 fcntl 系統調用來設置 O_NONBLOCK 標志位,從而把 socket 設置成 Non-blocking。當對一個 Non-blocking socket 執行讀操作時,流程是這個樣子:

當用戶進程發出 read 操作時,如果 kernel 中的數據還沒有準備好,那么它并不會 block 用戶進程,而是立刻返回一個 EAGAIN error。從用戶進程角度講 ,它發起一個 read 操作后,并不需要等待,而是馬上就得到了一個結果。用戶進程判斷結果是一個 error 時,它就知道數據還沒有準備好,于是它可以再次發送 read 操作。一旦 kernel 中的數據準備好了,并且又再次收到了用戶進程的 system call,那么它馬上就將數據拷貝到了用戶內存,然后返回。

所以,Non-blocking I/O 的特點是用戶進程需要不斷的主動詢問 kernel 數據好了沒有。下一節我們要講的 I/O 多路復用需要和 Non-blocking I/O 配合才能發揮出最大的威力!

I/O 多路復用

所謂 I/O 多路復用指的就是 select/poll/epoll 這一系列的多路選擇器:支持單一線程同時監聽多個文件描述符(I/O 事件),阻塞等待,并在其中某個文件描述符可讀寫時收到通知。 I/O 復用其實復用的不是 I/O 連接,而是復用線程,讓一個 thread of control 能夠處理多個連接(I/O 事件)。

select & poll

#include <sys/select.h>

/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

// 和 select 緊密結合的四個宏:
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);

select 是 epoll 之前 Linux 使用的 I/O 事件驅動技術。

理解 select 的關鍵在于理解 fd_set,為說明方便,取 fd_set 長度為 1 字節,fd_set 中的每一 bit 可以對應一個文件描述符 fd,則 1 字節長的 fd_set 最大可以對應 8 個 fd。select 的調用過程如下:

  1. 執行 FD_ZERO(&set), 則 set 用位表示是 0000,0000
  2. 若 fd=5, 執行 FD_SET(fd, &set); 后 set 變為 0001,0000(第 5 位置為 1)
  3. 再加入 fd=2, fd=1,則 set 變為 0001,0011
  4. 執行 select(6, &set, 0, 0, 0) 阻塞等待
  5. 若 fd=1, fd=2 上都發生可讀事件,則 select 返回,此時 set 變為 0000,0011 (注意:沒有事件發生的 fd=5 被清空)

基于上面的調用過程,可以得出 select 的特點:

  • 可監控的文件描述符個數取決于 sizeof(fd_set) 的值。假設服務器上 sizeof(fd_set)=512,每 bit 表示一個文件描述符,則服務器上支持的最大文件描述符是 512*8=4096。fd_set 的大小調整可參考 【原創】技術系列之 網絡模型(二) 中的模型 2,可以有效突破 select 可監控的文件描述符上限
  • 將 fd 加入 select 監控集的同時,還要再使用一個數據結構 array 保存放到 select 監控集中的 fd,一是用于在 select 返回后,array 作為源數據和 fd_set 進行 FD_ISSET 判斷。二是 select 返回后會把以前加入的但并無事件發生的 fd 清空,則每次開始 select 前都要重新從 array 取得 fd 逐一加入(FD_ZERO 最先),掃描 array 的同時取得 fd 最大值 maxfd,用于 select 的第一個參數
  • 可見 select 模型必須在 select 前循環 array(加 fd,取 maxfd),select 返回后循環 array(FD_ISSET 判斷是否有事件發生)

所以,select 有如下的缺點:

  1. 最大并發數限制:使用 32 個整數的 32 位,即 32*32=1024 來標識 fd,雖然可修改,但是有以下第 2, 3 點的瓶頸
  2. 每次調用 select,都需要把 fd 集合從用戶態拷貝到內核態,這個開銷在 fd 很多時會很大
  3. 性能衰減嚴重:每次 kernel 都需要線性掃描整個 fd_set,所以隨著監控的描述符 fd 數量增長,其 I/O 性能會線性下降

poll 的實現和 select 非常相似,只是描述 fd 集合的方式不同,poll 使用 pollfd 結構而不是 select 的 fd_set 結構,poll 解決了最大文件描述符數量限制的問題,但是同樣需要從用戶態拷貝所有的 fd 到內核態,也需要線性遍歷所有的 fd 集合,所以它和 select 只是實現細節上的區分,并沒有本質上的區別。

epoll

epoll 是 Linux kernel 2.6 之后引入的新 I/O 事件驅動技術,I/O 多路復用的核心設計是 1 個線程處理所有連接的 等待消息準備好 I/O 事件,這一點上 epoll 和 select&poll 是大同小異的。但 select&poll 錯誤預估了一件事,當數十萬并發連接存在時,可能每一毫秒只有數百個活躍的連接,同時其余數十萬連接在這一毫秒是非活躍的。select&poll 的使用方法是這樣的: 返回的活躍連接 == select(全部待監控的連接) 。

什么時候會調用 select&poll 呢?在你認為需要找出有報文到達的活躍連接時,就應該調用。所以,select&poll 在高并發時是會被頻繁調用的。這樣,這個頻繁調用的方法就很有必要看看它是否有效率,因為,它的輕微效率損失都會被 高頻 二字所放大。它有效率損失嗎?顯而易見,全部待監控連接是數以十萬計的,返回的只是數百個活躍連接,這本身就是無效率的表現。被放大后就會發現,處理并發上萬個連接時,select&poll 就完全力不從心了。這個時候就該 epoll 上場了,epoll 通過一些新的設計和優化,基本上解決了 select&poll 的問題。

epoll 的 API 非常簡潔,涉及到的只有 3 個系統調用:

#include <sys/epoll.h>  
int epoll_create(int size); // int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

其中,epoll_create 創建一個 epoll 實例并返回 epollfd;epoll_ctl 注冊 file descriptor 等待的 I/O 事件(比如 EPOLLIN、EPOLLOUT 等) 到 epoll 實例上;epoll_wait 則是阻塞監聽 epoll 實例上所有的 file descriptor 的 I/O 事件,它接收一個用戶空間上的一塊內存地址 (events 數組),kernel 會在有 I/O 事件發生的時候把文件描述符列表復制到這塊內存地址上,然后 epoll_wait 解除阻塞并返回,最后用戶空間上的程序就可以對相應的 fd 進行讀寫了:

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

epoll 的工作原理如下:

與 select&poll 相比,epoll 分清了高頻調用和低頻調用。例如,epoll_ctl 相對來說就是非頻繁調用的,而 epoll_wait 則是會被高頻調用的。所以 epoll 利用 epoll_ctl 來插入或者刪除一個 fd,實現用戶態到內核態的數據拷貝,這確保了每一個 fd 在其生命周期只需要被拷貝一次,而不是每次調用 epoll_wait 的時候都拷貝一次。 epoll_wait 則被設計成幾乎沒有入參的調用,相比 select&poll 需要把全部監聽的 fd 集合從用戶態拷貝至內核態的做法,epoll 的效率就高出了一大截。

在實現上 epoll 采用紅黑樹來存儲所有監聽的 fd,而紅黑樹本身插入和刪除性能比較穩定,時間復雜度 O(logN)。通過 epoll_ctl 函數添加進來的 fd 都會被放在紅黑樹的某個節點內,所以,重復添加是沒有用的。當把 fd 添加進來的時候時候會完成關鍵的一步:該 fd 會與相應的設備(網卡)驅動程序建立回調關系,也就是在內核中斷處理程序為它注冊一個回調函數,在 fd 相應的事件觸發(中斷)之后(設備就緒了),內核就會調用這個回調函數,該回調函數在內核中被稱為: ep_poll_callback ,這個回調函數其實就是把這個 fd 添加到 rdllist 這個雙向鏈表(就緒鏈表)中。epoll_wait 實際上就是去檢查 rdllist 雙向鏈表中是否有就緒的 fd,當 rdllist 為空(無就緒 fd)時掛起當前進程,直到 rdllist 非空時進程才被喚醒并返回。

相比于 select&poll 調用時會將全部監聽的 fd 從用戶態空間拷貝至內核態空間并線性掃描一遍找出就緒的 fd 再返回到用戶態,epoll_wait 則是直接返回已就緒 fd,因此 epoll 的 I/O 性能不會像 select&poll 那樣隨著監聽的 fd 數量增加而出現線性衰減,是一個非常高效的 I/O 事件驅動技術。

由于使用 epoll 的 I/O 多路復用需要用戶進程自己負責 I/O 讀寫,從用戶進程的角度看,讀寫過程是阻塞的,所以 select&poll&epoll 本質上都是同步 I/O 模型,而像 Windows 的 IOCP 這一類的異步 I/O,只需要在調用 WSARecv 或 WSASend 方法讀寫數據的時候把用戶空間的內存 buffer 提交給 kernel,kernel 負責數據在用戶空間和內核空間拷貝,完成之后就會通知用戶進程,整個過程不需要用戶進程參與,所以是真正的異步 I/O。

延伸

另外,我看到有些文章說 epoll 之所以性能高是因為利用了 Linux 的 mmap 內存映射讓內核和用戶進程共享了一片物理內存,用來存放就緒 fd 列表和它們的數據 buffer,所以用戶進程在 epoll_wait 返回之后用戶進程就可以直接從共享內存那里讀取/寫入數據了,這讓我很疑惑,因為首先看 epoll_wait 的函數聲明:

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

第二個參數:就緒事件列表,是需要在用戶空間分配內存然后再傳給 epoll_wait 的,如果內核會用 mmap 設置共享內存,直接傳遞一個指針進去就行了,根本不需要在用戶態分配內存,多此一舉。其次,內核和用戶進程通過 mmap 共享內存是一件極度危險的事情,內核無法確定這塊共享內存什么時候會被回收,而且這樣也會賦予用戶進程直接操作內核數據的權限和入口,非常容易出現大的系統漏洞,因此一般極少會這么做。所以我很懷疑 epoll 是不是真的在 Linux kernel 里用了 mmap,我就去看了下最新版本(5.3.9)的 Linux kernel 源碼:

/*
 * Implement the event wait interface for the eventpoll file. It is the kernel
 * part of the user space epoll_wait(2).
 */
static int do_epoll_wait(int epfd, struct epoll_event __user *events,
             int maxevents, int timeout)
{
    ...
  
    /* Time to fish for events ... */
    error = ep_poll(ep, events, maxevents, timeout);
}

// 如果 epoll_wait 入參時設定 timeout == 0, 那么直接通過 ep_events_available 判斷當前是否有用戶感興趣的事件發生,如果有則通過 ep_send_events 進行處理
// 如果設置 timeout > 0,并且當前沒有用戶關注的事件發生,則進行休眠,并添加到 ep->wq 等待隊列的頭部;對等待事件描述符設置 WQ_FLAG_EXCLUSIVE 標志
// ep_poll 被事件喚醒后會重新檢查是否有關注事件,如果對應的事件已經被搶走,那么 ep_poll 會繼續休眠等待
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events, int maxevents, long timeout)
{
    ...
  
    send_events:
    /*
     * Try to transfer events to user space. In case we get 0 events and
     * there's still timeout left over, we go trying again in search of
     * more luck.
     */
  
    // 如果一切正常, 有 event 發生, 就開始準備數據 copy 給用戶空間了
    // 如果有就緒的事件發生,那么就調用 ep_send_events 將就緒的事件 copy 到用戶態內存中,
    // 然后返回到用戶態,否則判斷是否超時,如果沒有超時就繼續等待就緒事件發生,如果超時就返回用戶態。
    // 從 ep_poll 函數的實現可以看到,如果有就緒事件發生,則調用 ep_send_events 函數做進一步處理
    if (!res && eavail &&
            !(res = ep_send_events(ep, events, maxevents)) && !timed_out)
        goto fetch_events;
  
    ...
}

// ep_send_events 函數是用來向用戶空間拷貝就緒 fd 列表的,它將用戶傳入的就緒 fd 列表內存簡單封裝到
// ep_send_events_data 結構中,然后調用 ep_scan_ready_list 將就緒隊列中的事件寫入用戶空間的內存;
// 用戶進程就可以訪問到這些數據進行處理
static int ep_send_events(struct eventpoll *ep,
                struct epoll_event __user *events, int maxevents)
{
    struct ep_send_events_data esed;

    esed.maxevents = maxevents;
    esed.events = events;
    // 調用 ep_scan_ready_list 函數檢查 epoll 實例 eventpoll 中的 rdllist 就緒鏈表,
    // 并注冊一個回調函數 ep_send_events_proc,如果有就緒 fd,則調用 ep_send_events_proc 進行處理
    ep_scan_ready_list(ep, ep_send_events_proc, &esed, 0, false);
    return esed.res;
}

// 調用 ep_scan_ready_list 的時候會傳遞指向 ep_send_events_proc 函數的函數指針作為回調函數,
// 一旦有就緒 fd,就會調用 ep_send_events_proc 函數
static __poll_t ep_send_events_proc(struct eventpoll *ep, struct list_head *head, void *priv)
{
    ...
  
    /*
     * If the event mask intersect the caller-requested one,
     * deliver the event to userspace. Again, ep_scan_ready_list()
     * is holding ep->mtx, so no operations coming from userspace
     * can change the item.
     */
    revents = ep_item_poll(epi, &pt, 1);
    // 如果 revents 為 0,說明沒有就緒的事件,跳過,否則就將就緒事件拷貝到用戶態內存中
    if (!revents)
        continue;
    // 將當前就緒的事件和用戶進程傳入的數據都通過 __put_user 拷貝回用戶空間,
    // 也就是調用 epoll_wait 之時用戶進程傳入的 fd 列表的內存
    if (__put_user(revents, &uevent->events) || __put_user(epi->event.data, &uevent->data)) {
        list_add(&epi->rdllink, head);
        ep_pm_stay_awake(epi);
        if (!esed->res)
            esed->res = -EFAULT;
        return 0;
    }
  
    ...
}

do_epoll_wait 開始層層跳轉,我們可以很清楚地看到最后內核是通過 __put_user 函數把就緒 fd 列表和事件返回到用戶空間,而 __put_user 正是內核用來拷貝數據到用戶空間的標準函數。此外,我并沒有在 Linux kernel 的源碼中和 epoll 相關的代碼里找到 mmap 系統調用做內存映射的邏輯,所以基本可以得出結論:epoll 在 Linux kernel 里并沒有使用 mmap 來做用戶空間和內核空間的內存共享,所以那些說 epoll 使用了 mmap 的文章都是誤解。

Go netpoller 核心

Go netpoller 基本原理

Go netpoller 通過在底層對 epoll/kqueue/iocp 的封裝,從而實現了使用同步編程模式達到異步執行的效果??偨Y來說,所有的網絡操作都以網絡描述符 netFD 為中心實現。netFD 與底層 PollDesc 結構綁定,當在一個 netFD 上讀寫遇到 EAGAIN 錯誤時,就將當前 goroutine 存儲到這個 netFD 對應的 PollDesc 中,同時調用 gopark 把當前 goroutine 給 park 住,直到這個 netFD 上再次發生讀寫事件,才將此 goroutine 給 ready 激活重新運行。顯然,在底層通知 goroutine 再次發生讀寫等事件的方式就是 epoll/kqueue/iocp 等事件驅動機制。

總所周知,Go 是一門跨平臺的編程語言,而不同平臺針對特定的功能有不用的實現,這當然也包括了 I/O 多路復用技術,比如 Linux 里的 I/O 多路復用有 select、pollepoll,而 freeBSD 或者 MacOS 里則是 kqueue,而 Windows 里則是基于異步 I/O 實現的 iocp,等等;因此,Go 為了實現底層 I/O 多路復用的跨平臺,分別基于上述的這些不同平臺的系統調用實現了多版本的 netpollers,具體的源碼路徑如下:

本文的解析基于 epoll 版本,如果讀者對其他平臺的 netpoller 底層實現感興趣,可以在閱讀完本文后自行翻閱其他 netpoller 源碼,所有實現版本的機制和原理基本類似,所以了解了 epoll 版本的實現后再去學習其他版本實現應該沒什么障礙。

接下來讓我們通過分析最新的 Go 源碼(v1.15.3),全面剖析一下整個 Go netpoller 的運行機制和流程。

數據結構

netFD

net.Listen("tcp", ":8888") 方法返回了一個 TCPListener,它是一個實現了 net.Listener 接口的 struct,而通過 listener.Accept() 接收的新連接 TCPConn 則是一個實現了 net.Conn 接口的 struct,它內嵌了 net.conn struct。仔細閱讀上面的源碼可以發現,不管是 Listener 的 Accept 還是 Conn 的 Read/Write 方法,都是基于一個 netFD 的數據結構的操作, netFD 是一個網絡描述符,類似于 Linux 的文件描述符的概念,netFD 中包含一個 poll.FD 數據結構,而 poll.FD 中包含兩個重要的數據結構 Sysfd 和 pollDesc,前者是真正的系統文件描述符,后者對是底層事件驅動的封裝,所有的讀寫超時等操作都是通過調用后者的對應方法實現的。

netFDpoll.FD 的源碼:

// Network file descriptor.
type netFD struct {
    pfd poll.FD

    // immutable until Close
    family      int
    sotype      int
    isConnected bool // handshake completed or use of association with peer
    net         string
    laddr       Addr
    raddr       Addr
}

// FD is a file descriptor. The net and os packages use this type as a
// field of a larger type representing a network connection or OS file.
type FD struct {
    // Lock sysfd and serialize access to Read and Write methods.
    fdmu fdMutex

    // System file descriptor. Immutable until Close.
    Sysfd int

    // I/O poller.
    pd pollDesc

    // Writev cache.
    iovecs *[]syscall.Iovec

    // Semaphore signaled when file is closed.
    csema uint32

    // Non-zero if this file has been set to blocking mode.
    isBlocking uint32

    // Whether this is a streaming descriptor, as opposed to a
    // packet-based descriptor like a UDP socket. Immutable.
    IsStream bool

    // Whether a zero byte read indicates EOF. This is false for a
    // message based socket connection.
    ZeroReadIsEOF bool

    // Whether this is a file rather than a network socket.
    isFile bool
}

pollDesc

前面提到了 pollDesc 是底層事件驅動的封裝,netFD 通過它來完成各種 I/O 相關的操作,它的定義如下:

type pollDesc struct {
    runtimeCtx uintptr
}

這里的 struct 只包含了一個指針,而通過 pollDesc 的 init 方法,我們可以找到它具體的定義是在 runtime.pollDesc 這里:

func (pd *pollDesc) init(fd *FD) error {
    serverInit.Do(runtime_pollServerInit)
    ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
    if errno != 0 {
        if ctx != 0 {
            runtime_pollUnblock(ctx)
            runtime_pollClose(ctx)
        }
        return syscall.Errno(errno)
    }
    pd.runtimeCtx = ctx
    return nil
}

// Network poller descriptor.
//
// No heap pointers.
//
//go:notinheap
type pollDesc struct {
    link *pollDesc // in pollcache, protected by pollcache.lock

    // The lock protects pollOpen, pollSetDeadline, pollUnblock and deadlineimpl operations.
    // This fully covers seq, rt and wt variables. fd is constant throughout the PollDesc lifetime.
    // pollReset, pollWait, pollWaitCanceled and runtime·netpollready (IO readiness notification)
    // proceed w/o taking the lock. So closing, everr, rg, rd, wg and wd are manipulated
    // in a lock-free way by all operations.
    // NOTE(dvyukov): the following code uses uintptr to store *g (rg/wg),
    // that will blow up when GC starts moving objects.
    lock    mutex // protects the following fields
    fd      uintptr
    closing bool
    everr   bool    // marks event scanning error happened
    user    uint32  // user settable cookie
    rseq    uintptr // protects from stale read timers
    rg      uintptr // pdReady, pdWait, G waiting for read or nil
    rt      timer   // read deadline timer (set if rt.f != nil)
    rd      int64   // read deadline
    wseq    uintptr // protects from stale write timers
    wg      uintptr // pdReady, pdWait, G waiting for write or nil
    wt      timer   // write deadline timer
    wd      int64   // write deadline
}

這里重點關注里面的 rgwg,這里兩個 uintptr "萬能指針"類型,取值分別可能是 pdReady、pdWait、等待 file descriptor 就緒的 goroutine 也就是 g 數據結構以及 nil,它們是實現喚醒 goroutine 的關鍵。

runtime.pollDesc 包含自身類型的一個指針,用來保存下一個 runtime.pollDesc 的地址,以此來實現鏈表,可以減少數據結構的大小,所有的 runtime.pollDesc 保存在 runtime.pollCache 結構中,定義如下:

type pollCache struct {
   lock  mutex
   first *pollDesc
   // PollDesc objects must be type-stable,
   // because we can get ready notification from epoll/kqueue
   // after the descriptor is closed/reused.
   // Stale notifications are detected using seq variable,
   // seq is incremented when deadlines are changed or descriptor is reused.
}

因為 runtime.pollCache 是一個在 runtime 包里的全局變量,因此需要用一個互斥鎖來避免 data race 問題,從它的名字也能看出這是一個用于緩存的數據結構,也就是用來提高性能的,具體如何實現呢?

const pollBlockSize = 4 * 1024

func (c *pollCache) alloc() *pollDesc {
    lock(&c.lock)
    if c.first == nil {
        const pdSize = unsafe.Sizeof(pollDesc{})
        n := pollBlockSize / pdSize
        if n == 0 {
            n = 1
        }
        // Must be in non-GC memory because can be referenced
        // only from epoll/kqueue internals.
        mem := persistentalloc(n*pdSize, 0, &memstats.other_sys)
        for i := uintptr(0); i < n; i++ {
            pd := (*pollDesc)(add(mem, i*pdSize))
            pd.link = c.first
            c.first = pd
        }
    }
    pd := c.first
    c.first = pd.link
    lockInit(&pd.lock, lockRankPollDesc)
    unlock(&c.lock)
    return pd
}

Go runtime 會在調用 poll_runtime_pollOpen 往 epoll 實例注冊 fd 之時首次調用 runtime.pollCache.alloc方法時批量初始化大小 4KB 的 runtime.pollDesc 結構體的鏈表,初始化過程中會調用 runtime.persistentalloc 來為這些數據結構分配不會被 GC 回收的內存,確保這些數據結構只能被 epollkqueue 在內核空間去引用。

再往后每次調用這個方法則會先判斷鏈表頭是否已經分配過值了,若是,則直接返回表頭這個 pollDesc,這種批量初始化數據進行緩存而后每次都直接從緩存取數據的方式是一種很常見的性能優化手段,在這里這種方式可以有效地提升 netpoller 的吞吐量。

Go runtime 會在關閉 pollDesc 之時調用 runtime.pollCache.free 釋放內存:

func (c *pollCache) free(pd *pollDesc) {
    lock(&c.lock)
    pd.link = c.first
    c.first = pd
    unlock(&c.lock)
}

實現原理

使用 Go 編寫一個典型的 TCP echo server:

package main

import (
    "log"
    "net"
)

func main() {
    listen, err := net.Listen("tcp", ":8888")
    if err != nil {
        log.Println("listen error: ", err)
        return
    }

    for {
        conn, err := listen.Accept()
        if err != nil {
            log.Println("accept error: ", err)
            break
        }

        // start a new goroutine to handle the new connection.
        go HandleConn(conn)
    }
}

func HandleConn(conn net.Conn) {
    defer conn.Close()
    packet := make([]byte, 1024)
    for {
        // block here if socket is not available for reading data.
        n, err := conn.Read(packet)
        if err != nil {
            log.Println("read socket error: ", err)
            return
        }

        // same as above, block here if socket is not available for writing.
        _, _ = conn.Write(packet[:n])
    }
}

上面是一個基于 Go 原生網絡模型(基于 netpoller)編寫的一個 TCP server,模式是 goroutine-per-connection ,在這種模式下,開發者使用的是同步的模式去編寫異步的邏輯而且對于開發者來說 I/O 是否阻塞是無感知的,也就是說開發者無需考慮 goroutines 甚至更底層的線程、進程的調度和上下文切換。而 Go netpoller 最底層的事件驅動技術肯定是基于 epoll/kqueue/iocp 這一類的 I/O 事件驅動技術,只不過是把這些調度和上下文切換的工作轉移到了 runtime 的 Go scheduler,讓它來負責調度 goroutines,從而極大地降低了程序員的心智負擔!

Go 的這種同步模式的網絡服務器的基本架構通常如下:

上面的示例代碼中相關的在源碼里的幾個數據結構和方法:

// TCPListener is a TCP network listener. Clients should typically
// use variables of type Listener instead of assuming TCP.
type TCPListener struct {
    fd *netFD
    lc ListenConfig
}

// Accept implements the Accept method in the Listener interface; it
// waits for the next call and returns a generic Conn.
func (l *TCPListener) Accept() (Conn, error) {
    if !l.ok() {
        return nil, syscall.EINVAL
    }
    c, err := l.accept()
    if err != nil {
        return nil, &OpError{Op: "accept", Net: l.fd.net, Source: nil, Addr: l.fd.laddr, Err: err}
    }
    return c, nil
}

func (ln *TCPListener) accept() (*TCPConn, error) {
    fd, err := ln.fd.accept()
    if err != nil {
        return nil, err
    }
    tc := newTCPConn(fd)
    if ln.lc.KeepAlive >= 0 {
        setKeepAlive(fd, true)
        ka := ln.lc.KeepAlive
        if ln.lc.KeepAlive == 0 {
            ka = defaultTCPKeepAlive
        }
        setKeepAlivePeriod(fd, ka)
    }
    return tc, nil
}

// TCPConn is an implementation of the Conn interface for TCP network
// connections.
type TCPConn struct {
    conn
}

// Conn
type conn struct {
    fd *netFD
}

type conn struct {
    fd *netFD
}

func (c *conn) ok() bool { return c != nil && c.fd != nil }

// Implementation of the Conn interface.

// Read implements the Conn Read method.
func (c *conn) Read(b []byte) (int, error) {
    if !c.ok() {
        return 0, syscall.EINVAL
    }
    n, err := c.fd.Read(b)
    if err != nil && err != io.EOF {
        err = &OpError{Op: "read", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err}
    }
    return n, err
}

// Write implements the Conn Write method.
func (c *conn) Write(b []byte) (int, error) {
    if !c.ok() {
        return 0, syscall.EINVAL
    }
    n, err := c.fd.Write(b)
    if err != nil {
        err = &OpError{Op: "write", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err}
    }
    return n, err
}

net.Listen

調用 net.Listen 之后,底層會通過 Linux 的系統調用 socket 方法創建一個 fd 分配給 listener,并用以來初始化 listener 的 netFD ,接著調用 netFD 的 listenStream 方法完成對 socket 的 bind&listen 操作以及對 netFD 的初始化(主要是對 netFD 里的 pollDesc 的初始化),調用鏈是 runtime.runtime_pollServerInit --> runtime.poll_runtime_pollServerInit --> runtime.netpollGenericInit,主要做的事情是:

  1. 調用 epollcreate1 創建一個 epoll 實例 epfd,作為整個 runtime 的唯一 event-loop 使用;
  2. 調用 runtime.nonblockingPipe 創建一個用于和 epoll 實例通信的管道,這里為什么不用更新且更輕量的 eventfd 呢?我個人猜測是為了兼容更多以及更老的系統版本;
  3. netpollBreakRd 通知信號量封裝成 epollevent 事件結構體注冊進 epoll 實例。

相關源碼如下:

// 調用 linux 系統調用 socket 創建 listener fd 并設置為為阻塞 I/O
s, err := socketFunc(family, sotype|syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC, proto)
// On Linux the SOCK_NONBLOCK and SOCK_CLOEXEC flags were
// introduced in 2.6.27 kernel and on FreeBSD both flags were
// introduced in 10 kernel. If we get an EINVAL error on Linux
// or EPROTONOSUPPORT error on FreeBSD, fall back to using
// socket without them.

socketFunc        func(int, int, int) (int, error)  = syscall.Socket

// 用上面創建的 listener fd 初始化 listener netFD
if fd, err = newFD(s, family, sotype, net); err != nil {
    poll.CloseFunc(s)
    return nil, err
}

// 對 listener fd 進行 bind&listen 操作,并且調用 init 方法完成初始化
func (fd *netFD) listenStream(laddr sockaddr, backlog int, ctrlFn func(string, string, syscall.RawConn) error) error {
    ...
  
    // 完成綁定操作
    if err = syscall.Bind(fd.pfd.Sysfd, lsa); err != nil {
        return os.NewSyscallError("bind", err)
    }
  
    // 完成監聽操作
    if err = listenFunc(fd.pfd.Sysfd, backlog); err != nil {
        return os.NewSyscallError("listen", err)
    }
  
    // 調用 init,內部會調用 poll.FD.Init,最后調用 pollDesc.init
    if err = fd.init(); err != nil {
        return err
    }
    lsa, _ = syscall.Getsockname(fd.pfd.Sysfd)
    fd.setAddr(fd.addrFunc()(lsa), nil)
    return nil
}

// 使用 sync.Once 來確保一個 listener 只持有一個 epoll 實例
var serverInit sync.Once

// netFD.init 會調用 poll.FD.Init 并最終調用到 pollDesc.init,
// 它會創建 epoll 實例并把 listener fd 加入監聽隊列
func (pd *pollDesc) init(fd *FD) error {
    // runtime_pollServerInit 通過 `go:linkname` 鏈接到具體的實現函數 poll_runtime_pollServerInit,
    // 接著再調用 netpollGenericInit,然后會根據不同的系統平臺去調用特定的 netpollinit 來創建 epoll 實例
    serverInit.Do(runtime_pollServerInit)
  
    // runtime_pollOpen 內部調用了 netpollopen 來將 listener fd 注冊到 
    // epoll 實例中,另外,它會初始化一個 pollDesc 并返回
    ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
    if errno != 0 {
        if ctx != 0 {
            runtime_pollUnblock(ctx)
            runtime_pollClose(ctx)
        }
        return syscall.Errno(errno)
    }
    // 把真正初始化完成的 pollDesc 實例賦值給當前的 pollDesc 代表自身的指針,
    // 后續使用直接通過該指針操作
    pd.runtimeCtx = ctx
    return nil
}

var (
    // 全局唯一的 epoll fd,只在 listener fd 初始化之時被指定一次
    epfd int32 = -1 // epoll descriptor
)

// netpollinit 會創建一個 epoll 實例,然后把 epoll fd 賦值給 epfd,
// 后續 listener 以及它 accept 的所有 sockets 有關 epoll 的操作都是基于這個全局的 epfd
func netpollinit() {
    epfd = epollcreate1(_EPOLL_CLOEXEC)
    if epfd < 0 {
        epfd = epollcreate(1024)
        if epfd < 0 {
            println("runtime: epollcreate failed with", -epfd)
            throw("runtime: netpollinit failed")
        }
        closeonexec(epfd)
    }
    r, w, errno := nonblockingPipe()
    if errno != 0 {
        println("runtime: pipe failed with", -errno)
        throw("runtime: pipe failed")
    }
    ev := epollevent{
        events: _EPOLLIN,
    }
    *(**uintptr)(unsafe.Pointer(&ev.data)) = &netpollBreakRd
    errno = epollctl(epfd, _EPOLL_CTL_ADD, r, &ev)
    if errno != 0 {
        println("runtime: epollctl failed with", -errno)
        throw("runtime: epollctl failed")
    }
    netpollBreakRd = uintptr(r)
    netpollBreakWr = uintptr(w)
}

// netpollopen 會被 runtime_pollOpen 調用,注冊 fd 到 epoll 實例,
// 注意這里使用的是 epoll 的 ET 模式,同時會利用萬能指針把 pollDesc 保存到 epollevent 的一個 8 位的字節數組 data 里
func netpollopen(fd uintptr, pd *pollDesc) int32 {
    var ev epollevent
    ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET
    *(**pollDesc)(unsafe.Pointer(&ev.data)) = pd
    return -epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev)
}

我們前面提到的 epoll 的三個基本調用,Go 在源碼里實現了對那三個調用的封裝:

#include <sys/epoll.h>  
int epoll_create(int size);  
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

// Go 對上面三個調用的封裝
func netpollinit()
func netpollopen(fd uintptr, pd *pollDesc) int32
func netpoll(block bool) gList

netFD 就是通過這三個封裝來對 epoll 進行創建實例、注冊 fd 和等待事件操作的。

Listener.Accept()

netpoll accept socket 的工作流程如下:

  1. 服務端的 netFD 在 listen 時會創建 epoll 的實例,并將 listenerFD 加入 epoll 的事件隊列
  2. netFD 在 accept 時將返回的 connFD 也加入 epoll 的事件隊列
  3. netFD 在讀寫時出現 syscall.EAGAIN 錯誤,通過 pollDesc 的 waitRead 方法將當前的 goroutine park 住,直到 ready,從 pollDesc 的 waitRead 中返回

Listener.Accept() 接收來自客戶端的新連接,具體還是調用 netFD.accept 方法來完成這個功能:

// Accept implements the Accept method in the Listener interface; it
// waits for the next call and returns a generic Conn.
func (l *TCPListener) Accept() (Conn, error) {
    if !l.ok() {
        return nil, syscall.EINVAL
    }
    c, err := l.accept()
    if err != nil {
        return nil, &OpError{Op: "accept", Net: l.fd.net, Source: nil, Addr: l.fd.laddr, Err: err}
    }
    return c, nil
}

func (ln *TCPListener) accept() (*TCPConn, error) {
    fd, err := ln.fd.accept()
    if err != nil {
        return nil, err
    }
    tc := newTCPConn(fd)
    if ln.lc.KeepAlive >= 0 {
        setKeepAlive(fd, true)
        ka := ln.lc.KeepAlive
        if ln.lc.KeepAlive == 0 {
            ka = defaultTCPKeepAlive
        }
        setKeepAlivePeriod(fd, ka)
    }
    return tc, nil
}

func (fd *netFD) accept() (netfd *netFD, err error) {
    // 調用 poll.FD 的 Accept 方法接受新的 socket 連接,返回 socket 的 fd
    d, rsa, errcall, err := fd.pfd.Accept()
    if err != nil {
        if errcall != "" {
            err = wrapSyscallError(errcall, err)
        }
        return nil, err
    }
    // 以 socket fd 構造一個新的 netFD,代表這個新的 socket
    if netfd, err = newFD(d, fd.family, fd.sotype, fd.net); err != nil {
        poll.CloseFunc(d)
        return nil, err
    }
    // 調用 netFD 的 init 方法完成初始化
    if err = netfd.init(); err != nil {
        fd.Close()
        return nil, err
    }
    lsa, _ := syscall.Getsockname(netfd.pfd.Sysfd)
    netfd.setAddr(netfd.addrFunc()(lsa), netfd.addrFunc()(rsa))
    return netfd, nil
}

netFD.accept 方法里會再調用 poll.FD.Accept ,最后會使用 Linux 的系統調用 accept 來完成新連接的接收,并且會把 accept 的 socket 設置成非阻塞 I/O 模式:

// Accept wraps the accept network call.
func (fd *FD) Accept() (int, syscall.Sockaddr, string, error) {
    if err := fd.readLock(); err != nil {
        return -1, nil, "", err
    }
    defer fd.readUnlock()

    if err := fd.pd.prepareRead(fd.isFile); err != nil {
        return -1, nil, "", err
    }
    for {
        // 使用 linux 系統調用 accept 接收新連接,創建對應的 socket
        s, rsa, errcall, err := accept(fd.Sysfd)
        // 因為 listener fd 在創建的時候已經設置成非阻塞的了,
        // 所以 accept 方法會直接返回,不管有沒有新連接到來;如果 err == nil 則表示正常建立新連接,直接返回
        if err == nil {
            return s, rsa, "", err
        }
        // 如果 err != nil,則判斷 err == syscall.EAGAIN,符合條件則進入 pollDesc.waitRead 方法
        switch err {
        case syscall.EAGAIN:
            if fd.pd.pollable() {
                // 如果當前沒有發生期待的 I/O 事件,那么 waitRead 會通過 park goroutine 讓邏輯 block 在這里
                if err = fd.pd.waitRead(fd.isFile); err == nil {
                    continue
                }
            }
        case syscall.ECONNABORTED:
            // This means that a socket on the listen
            // queue was closed before we Accept()ed it;
            // it's a silly error, so try again.
            continue
        }
        return -1, nil, errcall, err
    }
}

// 使用 linux 的 accept 系統調用接收新連接并把這個 socket fd 設置成非阻塞 I/O
ns, sa, err := Accept4Func(s, syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC)
// On Linux the accept4 system call was introduced in 2.6.28
// kernel and on FreeBSD it was introduced in 10 kernel. If we
// get an ENOSYS error on both Linux and FreeBSD, or EINVAL
// error on Linux, fall back to using accept.

// Accept4Func is used to hook the accept4 call.
var Accept4Func func(int, int) (int, syscall.Sockaddr, error) = syscall.Accept4

pollDesc.waitRead 方法主要負責檢測當前這個 pollDesc 的上層 netFD 對應的 fd 是否有『期待的』I/O 事件發生,如果有就直接返回,否則就 park 住當前的 goroutine 并持續等待直至對應的 fd 上發生可讀/可寫或者其他『期待的』I/O 事件為止,然后它就會返回到外層的 for 循環,讓 goroutine 繼續執行邏輯。

poll.FD.Accept() 返回之后,會構造一個對應這個新 socket 的 netFD,然后調用 init() 方法完成初始化,這個 init 過程和前面 net.Listen() 是一樣的,調用鏈:netFD.init() --> poll.FD.Init() --> poll.pollDesc.init(),最終又會走到這里:

var serverInit sync.Once

func (pd *pollDesc) init(fd *FD) error {
    serverInit.Do(runtime_pollServerInit)
    ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
    if errno != 0 {
        if ctx != 0 {
            runtime_pollUnblock(ctx)
            runtime_pollClose(ctx)
        }
        return syscall.Errno(errno)
    }
    pd.runtimeCtx = ctx
    return nil
}

然后把這個 socket fd 注冊到 listener 的 epoll 實例的事件隊列中去,等待 I/O 事件。

Conn.Read/Conn.Write

我們先來看看 Conn.Read 方法是如何實現的,原理其實和 Listener.Accept 是一樣的,具體調用鏈還是首先調用 conn 的 netFD.Read ,然后內部再調用 poll.FD.Read ,最后使用 Linux 的系統調用 read: syscall.Read 完成數據讀?。?/p>

// Implementation of the Conn interface.

// Read implements the Conn Read method.
func (c *conn) Read(b []byte) (int, error) {
    if !c.ok() {
        return 0, syscall.EINVAL
    }
    n, err := c.fd.Read(b)
    if err != nil && err != io.EOF {
        err = &OpError{Op: "read", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err}
    }
    return n, err
}

func (fd *netFD) Read(p []byte) (n int, err error) {
    n, err = fd.pfd.Read(p)
    runtime.KeepAlive(fd)
    return n, wrapSyscallError("read", err)
}

// Read implements io.Reader.
func (fd *FD) Read(p []byte) (int, error) {
    if err := fd.readLock(); err != nil {
        return 0, err
    }
    defer fd.readUnlock()
    if len(p) == 0 {
        // If the caller wanted a zero byte read, return immediately
        // without trying (but after acquiring the readLock).
        // Otherwise syscall.Read returns 0, nil which looks like
        // io.EOF.
        // TODO(bradfitz): make it wait for readability? (Issue 15735)
        return 0, nil
    }
    if err := fd.pd.prepareRead(fd.isFile); err != nil {
        return 0, err
    }
    if fd.IsStream && len(p) > maxRW {
        p = p[:maxRW]
    }
    for {
        // 嘗試從該 socket 讀取數據,因為 socket 在被 listener accept 的時候設置成
        // 了非阻塞 I/O,所以這里同樣也是直接返回,不管有沒有可讀的數據
        n, err := syscall.Read(fd.Sysfd, p)
        if err != nil {
            n = 0
            // err == syscall.EAGAIN 表示當前沒有期待的 I/O 事件發生,也就是 socket 不可讀
            if err == syscall.EAGAIN && fd.pd.pollable() {
                // 如果當前沒有發生期待的 I/O 事件,那么 waitRead 
                // 會通過 park goroutine 讓邏輯 block 在這里
                if err = fd.pd.waitRead(fd.isFile); err == nil {
                    continue
                }
            }

            // On MacOS we can see EINTR here if the user
            // pressed ^Z.  See issue #22838.
            if runtime.GOOS == "darwin" && err == syscall.EINTR {
                continue
            }
        }
        err = fd.eofError(n, err)
        return n, err
    }
}

conn.Writeconn.Read 的原理是一致的,它也是通過類似 pollDesc.waitReadpollDesc.waitWrite 來 park 住 goroutine 直至期待的 I/O 事件發生才返回恢復執行。

pollDesc.waitRead/pollDesc.waitWrite

pollDesc.waitRead 內部調用了 poll.runtime_pollWait --> runtime.poll_runtime_pollWait 來達成無 I/O 事件時 park 住 goroutine 的目的:

//go:linkname poll_runtime_pollWait internal/poll.runtime_pollWait
func poll_runtime_pollWait(pd *pollDesc, mode int) int {
    err := netpollcheckerr(pd, int32(mode))
    if err != pollNoError {
        return err
    }
    // As for now only Solaris, illumos, and AIX use level-triggered IO.
    if GOOS == "solaris" || GOOS == "illumos" || GOOS == "aix" {
        netpollarm(pd, mode)
    }
    // 進入 netpollblock 并且判斷是否有期待的 I/O 事件發生,
    // 這里的 for 循環是為了一直等到 io ready
    for !netpollblock(pd, int32(mode), false) {
        err = netpollcheckerr(pd, int32(mode))
        if err != 0 {
            return err
        }
        // Can happen if timeout has fired and unblocked us,
        // but before we had a chance to run, timeout has been reset.
        // Pretend it has not happened and retry.
    }
    return 0
}

// returns true if IO is ready, or false if timedout or closed
// waitio - wait only for completed IO, ignore errors
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
    // gpp 保存的是 goroutine 的數據結構 g,這里會根據 mode 的值決定是 rg 還是 wg,
  // 前面提到過,rg 和 wg 是用來保存等待 I/O 就緒的 gorouine 的,后面調用 gopark 之后,
  // 會把當前的 goroutine 的抽象數據結構 g 存入 gpp 這個指針,也就是 rg 或者 wg
    gpp := &pd.rg
    if mode == 'w' {
        gpp = &pd.wg
    }

    // set the gpp semaphore to WAIT
    // 這個 for 循環是為了等待 io ready 或者 io wait
    for {
        old := *gpp
        // gpp == pdReady 表示此時已有期待的 I/O 事件發生,
        // 可以直接返回 unblock 當前 goroutine 并執行響應的 I/O 操作
        if old == pdReady {
            *gpp = 0
            return true
        }
        if old != 0 {
            throw("runtime: double wait")
        }
        // 如果沒有期待的 I/O 事件發生,則通過原子操作把 gpp 的值置為 pdWait 并退出 for 循環
        if atomic.Casuintptr(gpp, 0, pdWait) {
            break
        }
    }

    // need to recheck error states after setting gpp to WAIT
    // this is necessary because runtime_pollUnblock/runtime_pollSetDeadline/deadlineimpl
    // do the opposite: store to closing/rd/wd, membarrier, load of rg/wg
  
    // waitio 此時是 false,netpollcheckerr 方法會檢查當前 pollDesc 對應的 fd 是否是正常的,
    // 通常來說  netpollcheckerr(pd, mode) == 0 是成立的,所以這里會執行 gopark 
    // 把當前 goroutine 給 park 住,直至對應的 fd 上發生可讀/可寫或者其他『期待的』I/O 事件為止,
    // 然后 unpark 返回,在 gopark 內部會把當前 goroutine 的抽象數據結構 g 存入
    // gpp(pollDesc.rg/pollDesc.wg) 指針里,以便在后面的 netpoll 函數取出 pollDesc 之后,
    // 把 g 添加到鏈表里返回,接著重新調度 goroutine
    if waitio || netpollcheckerr(pd, mode) == 0 {
        // 注冊 netpollblockcommit 回調給 gopark,在 gopark 內部會執行它,保存當前 goroutine 到 gpp
        gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceEvGoBlockNet, 5)
    }
    // be careful to not lose concurrent READY notification
    old := atomic.Xchguintptr(gpp, 0)
    if old > pdWait {
        throw("runtime: corrupted polldesc")
    }
    return old == pdReady
}

// gopark 會停住當前的 goroutine 并且調用傳遞進來的回調函數 unlockf,從上面的源碼我們可以知道這個函數是
// netpollblockcommit
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
    if reason != waitReasonSleep {
        checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy
    }
    mp := acquirem()
    gp := mp.curg
    status := readgstatus(gp)
    if status != _Grunning && status != _Gscanrunning {
        throw("gopark: bad g status")
    }
    mp.waitlock = lock
    mp.waitunlockf = unlockf
    gp.waitreason = reason
    mp.waittraceev = traceEv
    mp.waittraceskip = traceskip
    releasem(mp)
    // can't do anything that might move the G between Ms here.
  // gopark 最終會調用 park_m,在這個函數內部會調用 unlockf,也就是 netpollblockcommit,
    // 然后會把當前的 goroutine,也就是 g 數據結構保存到 pollDesc 的 rg 或者 wg 指針里
    mcall(park_m)
}

// park continuation on g0.
func park_m(gp *g) {
    _g_ := getg()

    if trace.enabled {
        traceGoPark(_g_.m.waittraceev, _g_.m.waittraceskip)
    }

    casgstatus(gp, _Grunning, _Gwaiting)
    dropg()

    if fn := _g_.m.waitunlockf; fn != nil {
        // 調用 netpollblockcommit,把當前的 goroutine,
        // 也就是 g 數據結構保存到 pollDesc 的 rg 或者 wg 指針里
        ok := fn(gp, _g_.m.waitlock)
        _g_.m.waitunlockf = nil
        _g_.m.waitlock = nil
        if !ok {
            if trace.enabled {
                traceGoUnpark(gp, 2)
            }
            casgstatus(gp, _Gwaiting, _Grunnable)
            execute(gp, true) // Schedule it back, never returns.
        }
    }
    schedule()
}

// netpollblockcommit 在 gopark 函數里被調用
func netpollblockcommit(gp *g, gpp unsafe.Pointer) bool {
    // 通過原子操作把當前 goroutine 抽象的數據結構 g,也就是這里的參數 gp 存入 gpp 指針,
    // 此時 gpp 的值是 pollDesc 的 rg 或者 wg 指針
    r := atomic.Casuintptr((*uintptr)(gpp), pdWait, uintptr(unsafe.Pointer(gp)))
    if r {
        // Bump the count of goroutines waiting for the poller.
        // The scheduler uses this to decide whether to block
        // waiting for the poller if there is nothing else to do.
        atomic.Xadd(&netpollWaiters, 1)
    }
    return r
}

pollDesc.waitWrite 的內部實現原理和 pollDesc.waitRead 是一樣的,都是基于 poll.runtime_pollWait --> runtime.poll_runtime_pollWait,這里就不再贅述。

netpoll

前面已經從源碼的層面分析完了 netpoll 是如何通過 park goroutine 從而達到阻塞 Accept/Read/Write 的效果,而通過調用 gopark,goroutine 會被放置在某個等待隊列中,這里是放到了 epoll 的 "interest list" 里,底層數據結構是由紅黑樹實現的 eventpoll.rbr,此時 G 的狀態由 _Grunning_Gwaitting ,因此 G 必須被手動喚醒(通過 goready ),否則會丟失任務,應用層阻塞通常使用這種方式。

所以我們現在可以來從整體的層面來概括 Go 的網絡業務 goroutine 是如何被規劃調度的了:

首先,client 連接 server 的時候,listener 通過 accept 調用接收新 connection,每一個新 connection 都啟動一個 goroutine 處理,accept 調用會把該 connection 的 fd 連帶所在的 goroutine 上下文信息封裝注冊到 epoll 的監聽列表里去,當 goroutine 調用 conn.Read 或者 conn.Write 等需要阻塞等待的函數時,會被 gopark 給封存起來并使之休眠,讓 P 去執行本地調度隊列里的下一個可執行的 goroutine,往后 Go scheduler 會在循環調度的 runtime.schedule() 函數以及 sysmon 監控線程中調用 runtime.nepoll 以獲取可運行的 goroutine 列表并通過調用 injectglist 把剩下的 g 放入全局調度隊列或者當前 P 本地調度隊列去重新執行。

那么當 I/O 事件發生之后,netpoller 是通過什么方式喚醒那些在 I/O wait 的 goroutine 的?答案是通過 runtime.netpoll。

runtime.netpoll 的核心邏輯是:

  1. 根據調用方的入參 delay,設置對應的調用 epollwait 的 timeout 值;
  2. 調用 epollwait 等待發生了可讀/可寫事件的 fd;
  3. 循環 epollwait 返回的事件列表,處理對應的事件類型, 組裝可運行的 goroutine 鏈表并返回。
// netpoll checks for ready network connections.
// Returns list of goroutines that become runnable.
// delay < 0: blocks indefinitely
// delay == 0: does not block, just polls
// delay > 0: block for up to that many nanoseconds
func netpoll(delay int64) gList {
    if epfd == -1 {
        return gList{}
    }

    // 根據特定的規則把 delay 值轉換為 epollwait 的 timeout 值
    var waitms int32
    if delay < 0 {
        waitms = -1
    } else if delay == 0 {
        waitms = 0
    } else if delay < 1e6 {
        waitms = 1
    } else if delay < 1e15 {
        waitms = int32(delay / 1e6)
    } else {
        // An arbitrary cap on how long to wait for a timer.
        // 1e9 ms == ~11.5 days.
        waitms = 1e9
    }
    var events [128]epollevent
retry:
    // 超時等待就緒的 fd 讀寫事件
    n := epollwait(epfd, &events[0], int32(len(events)), waitms)
    if n < 0 {
        if n != -_EINTR {
            println("runtime: epollwait on fd", epfd, "failed with", -n)
            throw("runtime: netpoll failed")
        }
        // If a timed sleep was interrupted, just return to
        // recalculate how long we should sleep now.
        if waitms > 0 {
            return gList{}
        }
        goto retry
    }

    // toRun 是一個 g 的鏈表,存儲要恢復的 goroutines,最后返回給調用方
    var toRun gList
    for i := int32(0); i < n; i++ {
        ev := &events[i]
        if ev.events == 0 {
            continue
        }

        // Go scheduler 在調用 findrunnable() 尋找 goroutine 去執行的時候,
        // 在調用 netpoll 之時會檢查當前是否有其他線程同步阻塞在 netpoll,
        // 若是,則調用 netpollBreak 來喚醒那個線程,避免它長時間阻塞
        if *(**uintptr)(unsafe.Pointer(&ev.data)) == &netpollBreakRd {
            if ev.events != _EPOLLIN {
                println("runtime: netpoll: break fd ready for", ev.events)
                throw("runtime: netpoll: break fd ready for something unexpected")
            }
            if delay != 0 {
                // netpollBreak could be picked up by a
                // nonblocking poll. Only read the byte
                // if blocking.
                var tmp [16]byte
                read(int32(netpollBreakRd), noescape(unsafe.Pointer(&tmp[0])), int32(len(tmp)))
                atomic.Store(&netpollWakeSig, 0)
            }
            continue
        }

        // 判斷發生的事件類型,讀類型或者寫類型等,然后給 mode 復制相應的值,
    // mode 用來決定從 pollDesc 里的 rg 還是 wg 里取出 goroutine
        var mode int32
        if ev.events&(_EPOLLIN|_EPOLLRDHUP|_EPOLLHUP|_EPOLLERR) != 0 {
            mode += 'r'
        }
        if ev.events&(_EPOLLOUT|_EPOLLHUP|_EPOLLERR) != 0 {
            mode += 'w'
        }
        if mode != 0 {
            // 取出保存在 epollevent 里的 pollDesc
            pd := *(**pollDesc)(unsafe.Pointer(&ev.data))
            pd.everr = false
            if ev.events == _EPOLLERR {
                pd.everr = true
            }
            // 調用 netpollready,傳入就緒 fd 的 pollDesc,
            // 把 fd 對應的 goroutine 添加到鏈表 toRun 中
            netpollready(&toRun, pd, mode)
        }
    }
    return toRun
}

// netpollready 調用 netpollunblock 返回就緒 fd 對應的 goroutine 的抽象數據結構 g
func netpollready(toRun *gList, pd *pollDesc, mode int32) {
    var rg, wg *g
    if mode == 'r' || mode == 'r'+'w' {
        rg = netpollunblock(pd, 'r', true)
    }
    if mode == 'w' || mode == 'r'+'w' {
        wg = netpollunblock(pd, 'w', true)
    }
    if rg != nil {
        toRun.push(rg)
    }
    if wg != nil {
        toRun.push(wg)
    }
}

// netpollunblock 會依據傳入的 mode 決定從 pollDesc 的 rg 或者 wg 取出當時 gopark 之時存入的
// goroutine 抽象數據結構 g 并返回
func netpollunblock(pd *pollDesc, mode int32, ioready bool) *g {
    // mode == 'r' 代表當時 gopark 是為了等待讀事件,而 mode == 'w' 則代表是等待寫事件
    gpp := &pd.rg
    if mode == 'w' {
        gpp = &pd.wg
    }

    for {
        // 取出 gpp 存儲的 g
        old := *gpp
        if old == pdReady {
            return nil
        }
        if old == 0 && !ioready {
            // Only set READY for ioready. runtime_pollWait
            // will check for timeout/cancel before waiting.
            return nil
        }
        var new uintptr
        if ioready {
            new = pdReady
        }
        // 重置 pollDesc 的 rg 或者 wg
        if atomic.Casuintptr(gpp, old, new) {
      // 如果該 goroutine 還是必須等待,則返回 nil
            if old == pdWait {
                old = 0
            }
            // 通過萬能指針還原成 g 并返回
            return (*g)(unsafe.Pointer(old))
        }
    }
}

// netpollBreak 往通信管道里寫入信號去喚醒 epollwait
func netpollBreak() {
    // 通過 CAS 避免重復的喚醒信號被寫入管道,
    // 從而減少系統調用并節省一些系統資源
    if atomic.Cas(&netpollWakeSig, 0, 1) {
        for {
            var b byte
            n := write(netpollBreakWr, unsafe.Pointer(&b), 1)
            if n == 1 {
                break
            }
            if n == -_EINTR {
                continue
            }
            if n == -_EAGAIN {
                return
            }
            println("runtime: netpollBreak write failed with", -n)
            throw("runtime: netpollBreak write failed")
        }
    }
}

Go 在多種場景下都可能會調用 netpoll 檢查文件描述符狀態,netpoll 里會調用 epoll_wait 從 epoll 的 eventpoll.rdllist 就緒雙向鏈表返回,從而得到 I/O 就緒的 socket fd 列表,并根據取出最初調用 epoll_ctl 時保存的上下文信息,恢復 g。所以執行完netpoll 之后,會返回一個就緒 fd 列表對應的 goroutine 鏈表,接下來將就緒的 goroutine 通過調用 injectglist 加入到全局調度隊列或者 P 的本地調度隊列中,啟動 M 綁定 P 去執行。

具體調用 netpoll 的地方,首先在 Go runtime scheduler 循環調度 goroutines 之時就有可能會調用 netpoll 獲取到已就緒的 fd 對應的 goroutine 來調度執行。

首先 Go scheduler 的核心方法 runtime.schedule() 里會調用一個叫 runtime.findrunable() 的方法獲取可運行的 goroutine 來執行,而在 runtime.findrunable() 方法里就調用了 runtime.netpoll 獲取已就緒的 fd 列表對應的 goroutine 列表:

// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
    ...
  
  if gp == nil {
        gp, inheritTime = findrunnable() // blocks until work is available
    }
  
    ...
}

// Finds a runnable goroutine to execute.
// Tries to steal from other P's, get g from global queue, poll network.
func findrunnable() (gp *g, inheritTime bool) {
  ...
  
  // Poll network.
    if netpollinited() && (atomic.Load(&netpollWaiters) > 0 || pollUntil != 0) && atomic.Xchg64(&sched.lastpoll, 0) != 0 {
        atomic.Store64(&sched.pollUntil, uint64(pollUntil))
        if _g_.m.p != 0 {
            throw("findrunnable: netpoll with p")
        }
        if _g_.m.spinning {
            throw("findrunnable: netpoll with spinning")
        }
        if faketime != 0 {
            // When using fake time, just poll.
            delta = 0
        }
        list := netpoll(delta) // 同步阻塞調用 netpoll,直至有可用的 goroutine
        atomic.Store64(&sched.pollUntil, 0)
        atomic.Store64(&sched.lastpoll, uint64(nanotime()))
        if faketime != 0 && list.empty() {
            // Using fake time and nothing is ready; stop M.
            // When all M's stop, checkdead will call timejump.
            stopm()
            goto top
        }
        lock(&sched.lock)
        _p_ = pidleget() // 查找是否有空閑的 P 可以來就緒的 goroutine
        unlock(&sched.lock)
        if _p_ == nil {
            injectglist(&list) // 如果當前沒有空閑的 P,則把就緒的 goroutine 放入全局調度隊列等待被執行
        } else {
            // 如果當前有空閑的 P,則 pop 出一個 g,返回給調度器去執行,
            // 并通過調用 injectglist 把剩下的 g 放入全局調度隊列或者當前 P 本地調度隊列
            acquirep(_p_)
            if !list.empty() {
                gp := list.pop()
                injectglist(&list)
                casgstatus(gp, _Gwaiting, _Grunnable)
                if trace.enabled {
                    traceGoUnpark(gp, 0)
                }
                return gp, false
            }
            if wasSpinning {
                _g_.m.spinning = true
                atomic.Xadd(&sched.nmspinning, 1)
            }
            goto top
        }
    } else if pollUntil != 0 && netpollinited() {
        pollerPollUntil := int64(atomic.Load64(&sched.pollUntil))
        if pollerPollUntil == 0 || pollerPollUntil > pollUntil {
            netpollBreak()
        }
    }
    stopm()
    goto top
}

另外, sysmon 監控線程會在循環過程中檢查距離上一次 runtime.netpoll 被調用是否超過了 10ms,若是則會去調用它拿到可運行的 goroutine 列表并通過調用 injectglist 把 g 列表放入全局調度隊列或者當前 P 本地調度隊列等待被執行:

// Always runs without a P, so write barriers are not allowed.
//
//go:nowritebarrierrec
func sysmon() {
        ...
  
        // poll network if not polled for more than 10ms
        lastpoll := int64(atomic.Load64(&sched.lastpoll))
        if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
            atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
            list := netpoll(0) // non-blocking - returns list of goroutines
            if !list.empty() {
                // Need to decrement number of idle locked M's
                // (pretending that one more is running) before injectglist.
                // Otherwise it can lead to the following situation:
                // injectglist grabs all P's but before it starts M's to run the P's,
                // another M returns from syscall, finishes running its G,
                // observes that there is no work to do and no other running M's
                // and reports deadlock.
                incidlelocked(-1)
                injectglist(&list)
                incidlelocked(1)
            }
        }
  
  ...
}

Go runtime 在程序啟動的時候會創建一個獨立的 M 作為監控線程,叫 sysmon ,這個線程為系統級的 daemon 線程,無需 P 即可運行, sysmon 每 20us~10ms 運行一次。 sysmon 中以輪詢的方式執行以下操作(如上面的代碼所示):

  1. 以非阻塞的方式調用 runtime.netpoll ,從中找出能從網絡 I/O 中喚醒的 g 列表,并通過調用 injectglist 把 g 列表放入全局調度隊列或者當前 P 本地調度隊列等待被執行,調度觸發時,有可能從這個全局 runnable 調度隊列獲取 g。然后再循環調用 startm ,直到所有 P 都不處于 _Pidle 狀態。
  2. 調用 retake ,搶占長時間處于 _Psyscall 狀態的 P。

綜上,Go 借助于 epoll/kqueue/iocp 和 runtime scheduler 等的幫助,設計出了自己的 I/O 多路復用 netpoller,成功地讓 Listener.Accept / conn.Read / conn.Write 等方法從開發者的角度看來是同步模式。

Go netpoller 的價值

通過前面對源碼的分析,我們現在知道 Go netpoller 依托于 runtime scheduler,為開發者提供了一種強大的同步網絡編程模式;然而,Go netpoller 存在的意義卻遠不止于此,Go netpoller I/O 多路復用搭配 Non-blocking I/O 而打造出來的這個原生網絡模型,它最大的價值是把網絡 I/O 的控制權牢牢掌握在 Go 自己的 runtime 里,關于這一點我們需要從 Go 的 runtime scheduler 說起,Go 的 G-P-M 調度模型如下:

G 在運行過程中如果被阻塞在某個 system call 操作上,那么不光 G 會阻塞,執行該 G 的 M 也會解綁 P(實質是被 sysmon 搶走了),與 G 一起進入 sleep 狀態。如果此時有 idle 的 M,則 P 與其綁定繼續執行其他 G;如果沒有 idle M,但仍然有其他 G 要去執行,那么就會創建一個新的 M。當阻塞在 system call 上的 G 完成 syscall 調用后,G 會去嘗試獲取一個可用的 P,如果沒有可用的 P,那么 G 會被標記為 _Grunnable 并把它放入全局的 runqueue 中等待調度,之前的那個 sleep 的 M 將再次進入 sleep。

現在清楚為什么 netpoll 為什么一定要使用非阻塞 I/O 了吧?就是為了避免讓操作網絡 I/O 的 goroutine 陷入到系統調用從而進入內核態,因為一旦進入內核態,整個程序的控制權就會發生轉移(到內核),不再屬于用戶進程了,那么也就無法借助于 Go 強大的 runtime scheduler 來調度業務程序的并發了;而有了 netpoll 之后,借助于非阻塞 I/O ,G 就再也不會因為系統調用的讀寫而 (長時間) 陷入內核態,當 G 被阻塞在某個 network I/O 操作上時,實際上它不是因為陷入內核態被阻塞住了,而是被 Go runtime 調用 gopark 給 park 住了,此時 G 會被放置到某個 wait queue 中,而 M 會嘗試運行下一個 _Grunnable 的 G,如果此時沒有 _Grunnable 的 G 供 M 運行,那么 M 將解綁 P,并進入 sleep 狀態。當 I/O available,在 epoll 的 eventpoll.rdr 中等待的 G 會被放到 eventpoll.rdllist 鏈表里并通過 netpoll 中的 epoll_wait 系統調用返回放置到全局調度隊列或者 P 的本地調度隊列,標記為 _Grunnable ,等待 P 綁定 M 恢復執行。

Goroutine 的調度

這一小節主要是講處理網絡 I/O 的 goroutines 阻塞之后,Go scheduler 具體是如何像前面幾個章節所說的那樣,避免讓操作網絡 I/O 的 goroutine 陷入到系統調用從而進入內核態的,而是封存 goroutine 然后讓出 CPU 的使用權從而令 P 可以去調度本地調度隊列里的下一個 goroutine 的。

溫馨提示:這一小節屬于延伸閱讀,涉及到的知識點更偏系統底層,需要有一定的匯編語言基礎才能通讀,另外,這一節對 Go scheduler 的講解僅僅涉及核心的一部分,不會把整個調度器都講一遍(事實上如果真要解析 Go scheduler 的話恐怕重開一篇幾萬字的文章才能基本講清楚。。。),所以也要求讀者對 Go 的并發調度器有足夠的了解,因此這一節可能會稍顯深奧。當然這一節也可選擇不讀,因為通過前面的整個解析,我相信讀者應該已經能夠基本掌握 Go netpoller 處理網絡 I/O 的核心細節了,以及能從宏觀層面了解 netpoller 對業務 goroutines 的基本調度了。而這一節主要是通過對 goroutines 調度細節的剖析,能夠加深讀者對整個 Go netpoller 的徹底理解,接上前面幾個章節,形成一個完整的閉環。如果對調度的底層細節沒興趣的話這也可以直接跳過這一節,對理解 Go netpoller 的基本原理影響不大,不過還是建議有條件的讀者可以看看。

從源碼可知,Go scheduler 的調度 goroutine 過程中所調用的核心函數鏈如下:

runtime.schedule --> runtime.execute --> runtime.gogo --> goroutine code --> runtime.goexit --> runtime.goexit1 --> runtime.mcall --> runtime.goexit0 --> runtime.schedule
Go scheduler 會不斷循環調用 runtime.schedule() 去調度 goroutines,而每個 goroutine 執行完成并退出之后,會再次調用 runtime.schedule(),使得調度器回到調度循環去執行其他的 goroutine,不斷循環,永不停歇。

當我們使用 go 關鍵字啟動一個新 goroutine 時,最終會調用 runtime.newproc --> runtime.newproc1,來得到 g,runtime.newproc1 會先從 P 的 gfree 緩存鏈表中查找可用的 g,若緩存未生效,則會新創建 g 給當前的業務函數,最后這個 g 會被傳給 runtime.gogo 去真正執行。

這里首先需要了解一個 gobuf 的結構體,它用來保存 goroutine 的調度信息,是 runtime.gogo 的入參:

// gobuf 存儲 goroutine 調度上下文信息的結構體
type gobuf struct {
    // The offsets of sp, pc, and g are known to (hard-coded in) libmach.
    //
    // ctxt is unusual with respect to GC: it may be a
    // heap-allocated funcval, so GC needs to track it, but it
    // needs to be set and cleared from assembly, where it's
    // difficult to have write barriers. However, ctxt is really a
    // saved, live register, and we only ever exchange it between
    // the real register and the gobuf. Hence, we treat it as a
    // root during stack scanning, which means assembly that saves
    // and restores it doesn't need write barriers. It's still
    // typed as a pointer so that any other writes from Go get
    // write barriers.
    sp   uintptr // Stack Pointer 棧指針
    pc   uintptr // Program Counter 程序計數器
    g    guintptr // 持有當前 gobuf 的 goroutine
    ctxt unsafe.Pointer
    ret  sys.Uintreg
    lr   uintptr
    bp   uintptr // for GOEXPERIMENT=framepointer
}

執行 runtime.execute(),進而調用 runtime.gogo

func execute(gp *g, inheritTime bool) {
    _g_ := getg()

    // Assign gp.m before entering _Grunning so running Gs have an
    // M.
    _g_.m.curg = gp
    gp.m = _g_.m
    casgstatus(gp, _Grunnable, _Grunning)
    gp.waitsince = 0
    gp.preempt = false
    gp.stackguard0 = gp.stack.lo + _StackGuard
    if !inheritTime {
        _g_.m.p.ptr().schedtick++
    }

    // Check whether the profiler needs to be turned on or off.
    hz := sched.profilehz
    if _g_.m.profilehz != hz {
        setThreadCPUProfiler(hz)
    }

    if trace.enabled {
        // GoSysExit has to happen when we have a P, but before GoStart.
        // So we emit it here.
        if gp.syscallsp != 0 && gp.sysblocktraced {
            traceGoSysExit(gp.sysexitticks)
        }
        traceGoStart()
    }
    // gp.sched 就是 gobuf
    gogo(&gp.sched)
}

這里還需要了解一個概念:g0,Go G-P-M 調度模型中,g 代表 goroutine,而實際上一共有三種 g:

  1. 執行用戶代碼的 g;
  2. 執行調度器代碼的 g,也即是 g0;
  3. 執行 runtime.main 初始化工作的 main goroutine;

第一種 g 就是使用 go 關鍵字啟動的 goroutine,也是我們接觸最多的一類 g;第三種 g 是調度器啟動之后用來執行的一系列初始化工作的,包括但不限于啟動 sysmon 監控線程、內存初始化和啟動 GC 等等工作;第二種 g 叫 g0,用來執行調度器代碼,g0 在底層和其他 g 是一樣的數據結構,但是性質上有很大的區別,首先 g0 的棧大小是固定的,比如在 Linux 或者其他 Unix-like 的系統上一般是固定 8MB,不能動態伸縮,而普通的 g 初始棧大小是 2KB,可按需擴展,g0 其實就是線程棧,我們知道每個線程被創建出來之時都需要操作系統為之分配一個初始固定的線程棧,就是前面說的 8MB 大小的棧,g0 棧就代表了這個線程棧,因此每一個 m 都需要綁定一個 g0 來執行調度器代碼,然后跳轉到執行用戶代碼的地方。

runtime.gogo 是真正去執行 goroutine 代碼的函數,這個函數由匯編實現,為什么需要用匯編?因為 gogo 的工作是完成線程 M 上的堆棧切換:從系統堆棧 g0 切換成 goroutine gp,也就是 CPU 使用權和堆棧的切換,這種切換本質上是對 CPU 的 PC、SP 等寄存器和堆棧指針的更新,而這一類精度的底層操作別說是 Go,就算是最貼近底層的 C 也無法做到,這種程度的操作已超出所有高級語言的范疇,因此只能借助于匯編來實現。

runtime.gogo 在不同的 CPU 架構平臺上的實現各不相同,但是核心原理殊途同歸,我們這里選用 amd64 架構的匯編實現來分析,我會在關鍵的地方加上解釋:

// func gogo(buf *gobuf)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $16-8
    // 將第一個 FP 偽寄存器所指向的 gobuf 的第一個參數存入 BX 寄存器, 
    // gobuf 的一個參數即是 SP 指針
    MOVQ    buf+0(FP), BX
    MOVQ    gobuf_g(BX), DX  // 將 gp.sched.g 保存到 DX 寄存器
    MOVQ    0(DX), CX        // make sure g != nil

    // 將 tls (thread local storage) 保存到 CX 寄存器,然后把 gp.sched.g 放到 tls[0],
    // 這樣以后調用 getg() 之時就可以通過 TLS 直接獲取到當前 goroutine 的 g 結構體實例,
    // 進而可以得到 g 所在的 m 和 p,TLS 里一開始存儲的是系統堆棧 g0 的地址
    get_tls(CX)
    MOVQ    DX, g(CX)

    // 下面的指令則是對函數棧的 BP/SP 寄存器(指針)的存取,
    // 最后進入到指定的代碼區域,執行函數棧幀
    MOVQ    gobuf_sp(BX), SP    // restore SP
    MOVQ    gobuf_ret(BX), AX
    MOVQ    gobuf_ctxt(BX), DX
    MOVQ    gobuf_bp(BX), BP

    // 這里是在清空 gp.sched,因為前面已經把 gobuf 里的字段值都存入了寄存器,
    // 所以 gp.sched 就可以提前清空了,不需要等到后面 GC 來回收,減輕 GC 的負擔
    MOVQ    $0, gobuf_sp(BX)    // clear to help garbage collector
    MOVQ    $0, gobuf_ret(BX)
    MOVQ    $0, gobuf_ctxt(BX)
    MOVQ    $0, gobuf_bp(BX)

    // 把 gp.sched.pc 值放入 BX 寄存器
    // PC 指針指向 gogo 退出時需要執行的函數地址
    MOVQ    gobuf_pc(BX), BX
    // 用 BX 寄存器里的值去修改 CPU 的 IP 寄存器,
    // 這樣就可以根據 CS:IP 寄存器的段地址+偏移量跳轉到 BX 寄存器里的地址,也就是 gp.sched.pc
    JMP    BX

runtime.gogo 函數接收 gp.sched 這個 gobuf 結構體實例,其中保存了函數棧寄存器 SP/PC/BP,如果熟悉操作系統原理的話可以知道這些寄存器是 CPU 進行函數調用和返回時切換對應的函數棧幀所需的寄存器,而 goroutine 的執行和函數調用的原理是一致的,也是 CPU 寄存器的切換過程,所以這里的幾個寄存器當前存的就是 G 的函數執行棧,當 goroutine 在處理網絡 I/O 之時,如果恰好處于 I/O 就緒的狀態的話,則正常完成 runtime.gogo,并在最后跳轉到特定的地址,那么這個地址是哪里呢?

我們知道 CPU 執行函數的時候需要知道函數在內存里的代碼段地址和偏移量,然后才能去取來函數棧執行,而典型的提供代碼段地址和偏移量的寄存器就是 CS 和 IP 寄存器,而 JMP BX 指令則是用 BX 寄存器去更新 IP 寄存器,而 BX 寄存器里的值是 gp.sched.pc,那么這個 PC 指針究竟是指向哪里呢?讓我們來看另一處源碼。

眾所周知,啟動一個新的 goroutine 是通過 go 關鍵字來完成的,而 go compiler 會在編譯期間利用 cmd/compile/internal/gc.state.stmtcmd/compile/internal/gc.state.call 這兩個函數將 go 關鍵字翻譯成 runtime.newproc 函數調用,而 runtime.newproc 接收了函數指針和其大小之后,會獲取 goroutine 和調用處的程序計數器,接著再調用 runtime.newproc1

// Create a new g in state _Grunnable, starting at fn, with narg bytes
// of arguments starting at argp. callerpc is the address of the go
// statement that created this. The caller is responsible for adding
// the new g to the scheduler.
//
// This must run on the system stack because it's the continuation of
// newproc, which cannot split the stack.
//
//go:systemstack
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
  ...
  
  memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
    newg.sched.sp = sp
    newg.stktopsp = sp
    // 把 goexit 函數地址存入 gobuf 的 PC 指針里
    newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
    newg.sched.g = guintptr(unsafe.Pointer(newg))
    gostartcallfn(&newg.sched, fn)
    newg.gopc = callerpc
    newg.ancestors = saveAncestors(callergp)
    newg.startpc = fn.fn
    if _g_.m.curg != nil {
        newg.labels = _g_.m.curg.labels
    }
    if isSystemGoroutine(newg, false) {
        atomic.Xadd(&sched.ngsys, +1)
    }
    casgstatus(newg, _Gdead, _Grunnable)
  
  ...
}

這里可以看到,newg.sched.pc 被設置了 runtime.goexit 的函數地址,newg 就是后面 runtime.gogo 執行的 goroutine,因此 runtime.gogo 最后的匯編指令 JMP BX是跳轉到了 runtime.goexit,讓我們來繼續看看這個函數做了什么:

// The top-most function running on a goroutine
// returns to goexit+PCQuantum. Defined as ABIInternal
// so as to make it identifiable to traceback (this
// function it used as a sentinel; traceback wants to
// see the func PC, not a wrapper PC).
TEXT runtime·goexit<ABIInternal>(SB),NOSPLIT,$0-0
    BYTE    $0x90    // NOP
    CALL    runtime·goexit1(SB)    // does not return
    // traceback from goexit1 must hit code range of goexit
    BYTE    $0x90    // NOP

這個函數也是匯編實現的,但是非常簡單,就是直接調用 runtime·goexit1

// Finishes execution of the current goroutine.
func goexit1() {
    if raceenabled {
        racegoend()
    }
    if trace.enabled {
        traceGoEnd()
    }
    mcall(goexit0)
}

調用 runtime.mcall函數:

// func mcall(fn func(*g))
// Switch to m->g0's stack, call fn(g).
// Fn must never return. It should gogo(&g->sched)
// to keep running g.

// 切換回 g0 的系統堆棧,執行 fn(g)
TEXT runtime·mcall(SB), NOSPLIT, $0-8
    // 取入參 funcval 對象的指針存入 DI 寄存器,此時 fn.fn 是 goexit0 的地址
    MOVQ    fn+0(FP), DI

    get_tls(CX)
    MOVQ    g(CX), AX    // save state in g->sched
    MOVQ    0(SP), BX    // caller's PC
    MOVQ    BX, (g_sched+gobuf_pc)(AX)
    LEAQ    fn+0(FP), BX    // caller's SP
    MOVQ    BX, (g_sched+gobuf_sp)(AX)
    MOVQ    AX, (g_sched+gobuf_g)(AX)
    MOVQ    BP, (g_sched+gobuf_bp)(AX)

    // switch to m->g0 & its stack, call fn
    MOVQ    g(CX), BX
    MOVQ    g_m(BX), BX

    // 把 g0 的棧指針存入 SI 寄存器,后面需要用到
    MOVQ    m_g0(BX), SI
    CMPQ    SI, AX    // if g == m->g0 call badmcall
    JNE    3(PC)
    MOVQ    $runtime·badmcall(SB), AX
    JMP    AX

    // 這兩個指令是把 g0 地址存入到 TLS 里,
    // 然后從 SI 寄存器取出 g0 的棧指針,
    // 替換掉 SP 寄存器里存的當前 g 的棧指針
    MOVQ    SI, g(CX)    // g = m->g0
    MOVQ    (g_sched+gobuf_sp)(SI), SP    // sp = m->g0->sched.sp

    PUSHQ    AX
    MOVQ    DI, DX

    // 入口處的第一個指令已經把 funcval 實例對象的指針存入了 DI 寄存器,
    // 0(DI) 表示取出 DI 的第一個成員,即 goexit0 函數地址,再存入 DI
    MOVQ    0(DI), DI
    CALL    DI // 調用 DI 寄存器里的地址,即 goexit0
    POPQ    AX
    MOVQ    $runtime·badmcall2(SB), AX
    JMP    AX
    RET

可以看到 runtime.mcall 函數的主要邏輯是從當前 goroutine 切換回 g0 的系統堆棧,然后調用 fn(g),此處的 g 即是當前運行的 goroutine,這個方法會保存當前運行的 G 的 PC/SP 到 g->sched 里,以便該 G 可以在以后被重新恢復執行,因為也涉及到寄存器和堆棧指針的操作,所以也需要使用匯編實現,該函數最后會在 g0 系統堆棧下執行 runtime.goexit0:

func goexit0(gp *g) {
    _g_ := getg()

    casgstatus(gp, _Grunning, _Gdead)
    if isSystemGoroutine(gp, false) {
        atomic.Xadd(&sched.ngsys, -1)
    }
    gp.m = nil
    locked := gp.lockedm != 0
    gp.lockedm = 0
    _g_.m.lockedg = 0
    gp.preemptStop = false
    gp.paniconfault = false
    gp._defer = nil // should be true already but just in case.
    gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data.
    gp.writebuf = nil
    gp.waitreason = 0
    gp.param = nil
    gp.labels = nil
    gp.timer = nil

    if gcBlackenEnabled != 0 && gp.gcAssistBytes > 0 {
        // Flush assist credit to the global pool. This gives
        // better information to pacing if the application is
        // rapidly creating an exiting goroutines.
        scanCredit := int64(gcController.assistWorkPerByte * float64(gp.gcAssistBytes))
        atomic.Xaddint64(&gcController.bgScanCredit, scanCredit)
        gp.gcAssistBytes = 0
    }

    dropg()

    if GOARCH == "wasm" { // no threads yet on wasm
        gfput(_g_.m.p.ptr(), gp)
        schedule() // never returns
    }

    if _g_.m.lockedInt != 0 {
        print("invalid m->lockedInt = ", _g_.m.lockedInt, "\n")
        throw("internal lockOSThread error")
    }
    gfput(_g_.m.p.ptr(), gp)
    if locked {
        // The goroutine may have locked this thread because
        // it put it in an unusual kernel state. Kill it
        // rather than returning it to the thread pool.

        // Return to mstart, which will release the P and exit
        // the thread.
        if GOOS != "plan9" { // See golang.org/issue/22227.
            gogo(&_g_.m.g0.sched)
        } else {
            // Clear lockedExt on plan9 since we may end up re-using
            // this thread.
            _g_.m.lockedExt = 0
        }
    }
    schedule()
}

runtime.goexit0 的主要工作是就是

  1. 利用 CAS 操作把 g 的狀態從 _Grunning 更新為 _Gdead;
  2. 對 g 做一些清理操作,把一些字段值置空;
  3. 調用 runtime.dropg 解綁 g 和 m;
  4. 把 g 放入 p 存儲 g 的 gfree 鏈表作為緩存,后續如果需要啟動新的 goroutine 則可以直接從鏈表里取而不用重新初始化分配內存。
  5. 最后,調用 runtime.schedule() 再次進入調度循環去調度新的 goroutines,永不停歇。

另一方面,如果 goroutine 處于 I/O 不可用狀態,我們前面已經分析過 netpoller 利用非阻塞 I/O + I/O 多路復用避免了陷入系統調用,所以此時會調用 runtime.gopark 并把 goroutine 暫時封存在用戶態空間,并休眠當前的 goroutine,因此不會阻塞 runtime.gogo 的匯編執行,而是通過 runtime.mcall 調用 runtime.park_m

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
    if reason != waitReasonSleep {
        checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy
    }
    mp := acquirem()
    gp := mp.curg
    status := readgstatus(gp)
    if status != _Grunning && status != _Gscanrunning {
        throw("gopark: bad g status")
    }
    mp.waitlock = lock
    mp.waitunlockf = unlockf
    gp.waitreason = reason
    mp.waittraceev = traceEv
    mp.waittraceskip = traceskip
    releasem(mp)
    // can't do anything that might move the G between Ms here.
    mcall(park_m)
}

func park_m(gp *g) {
    _g_ := getg()

    if trace.enabled {
        traceGoPark(_g_.m.waittraceev, _g_.m.waittraceskip)
    }

    casgstatus(gp, _Grunning, _Gwaiting)
    dropg()

    if fn := _g_.m.waitunlockf; fn != nil {
        ok := fn(gp, _g_.m.waitlock)
        _g_.m.waitunlockf = nil
        _g_.m.waitlock = nil
        if !ok {
            if trace.enabled {
                traceGoUnpark(gp, 2)
            }
            casgstatus(gp, _Gwaiting, _Grunnable)
            execute(gp, true) // Schedule it back, never returns.
        }
    }
    schedule()
}

runtime.mcall 方法我們在前面已經介紹過,它主要的工作就是是從當前 goroutine 切換回 g0 的系統堆棧,然后調用 fn(g),而此時 runtime.mcall 調用執行的是 runtime.park_m,這個方法里會利用 CAS 把當前運行的 goroutine -- gp 的狀態 從 _Grunning 切換到 _Gwaiting,表明該 goroutine 已進入到等待喚醒狀態,此時封存和休眠 G 的操作就完成了,只需等待就緒之后被重新喚醒執行即可。最后調用 runtime.schedule() 再次進入調度循環,去執行下一個 goroutine,充分利用 CPU。

至此,我們完成了對 Go netpoller 原理剖析的整個閉環。

Go netpoller 的問題

Go netpoller 的設計不可謂不精巧、性能也不可謂不高,配合 goroutine 開發網絡應用的時候就一個字:爽。因此 Go 的網絡編程模式是及其簡潔高效的,然而,沒有任何一種設計和架構是完美的, goroutine-per-connection 這種模式雖然簡單高效,但是在某些極端的場景下也會暴露出問題:goroutine 雖然非常輕量,它的自定義棧內存初始值僅為 2KB,后面按需擴容;海量連接的業務場景下, goroutine-per-connection ,此時 goroutine 數量以及消耗的資源就會呈線性趨勢暴漲,雖然 Go scheduler 內部做了 g 的緩存鏈表,可以一定程度上緩解高頻創建銷毀 goroutine 的壓力,但是對于瞬時性暴漲的長連接場景就無能為力了,大量的 goroutines 會被不斷創建出來,從而對 Go runtime scheduler 造成極大的調度壓力和侵占系統資源,然后資源被侵占又反過來影響 Go scheduler 的調度,進而導致性能下降。

Reactor 網絡模型

目前 Linux 平臺上主流的高性能網絡庫/框架中,大都采用 Reactor 模式,比如 netty、libevent、libev、ACE,POE(Perl)、Twisted(Python)等。

Reactor 模式本質上指的是使用 I/O 多路復用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O) 的模式。

通常設置一個主線程負責做 event-loop 事件循環和 I/O 讀寫,通過 select/poll/epoll_wait 等系統調用監聽 I/O 事件,業務邏輯提交給其他工作線程去做。而所謂『非阻塞 I/O』的核心思想是指避免阻塞在 read() 或者 write() 或者其他的 I/O 系統調用上,這樣可以最大限度的復用 event-loop 線程,讓一個線程能服務于多個 sockets。在 Reactor 模式中,I/O 線程只能阻塞在 I/O multiplexing 函數上(select/poll/epoll_wait)。

Reactor 模式的基本工作流程如下:

  • Server 端完成在 bind&listen 之后,將 listenfd 注冊到 epollfd 中,最后進入 event-loop 事件循環。循環過程中會調用 select/poll/epoll_wait 阻塞等待,若有在 listenfd 上的新連接事件則解除阻塞返回,并調用 socket.accept 接收新連接 connfd,并將 connfd 加入到 epollfd 的 I/O 復用(監聽)隊列。
  • 當 connfd 上發生可讀/可寫事件也會解除 select/poll/epoll_wait 的阻塞等待,然后進行 I/O 讀寫操作,這里讀寫 I/O 都是非阻塞 I/O,這樣才不會阻塞 event-loop 的下一個循環。然而,這樣容易割裂業務邏輯,不易理解和維護。
  • 調用 read 讀取數據之后進行解碼并放入隊列中,等待工作線程處理。
  • 工作線程處理完數據之后,返回到 event-loop 線程,由這個線程負責調用 write 把數據寫回 client。

accept 連接以及 conn 上的讀寫操作若是在主線程完成,則要求是非阻塞 I/O,因為 Reactor 模式一條最重要的原則就是:I/O 操作不能阻塞 event-loop 事件循環。實際上 event loop 可能也可以是多線程的,只是一個線程里只有一個 select/poll/epoll_wait。

上面提到了 Go netpoller 在某些場景下可能因為創建太多的 goroutine 而過多地消耗系統資源,而在現實世界的網絡業務中,服務器持有的海量連接中在極短的時間窗口內只有極少數是 active 而大多數則是 idle,就像這樣(非真實數據,僅僅是為了比喻):

那么為每一個連接指派一個 goroutine 就顯得太過奢侈了,而 Reactor 模式這種利用 I/O 多路復用進而只需要使用少量線程即可管理海量連接的設計就可以在這樣網絡業務中大顯身手了:

MultiReactors.png

在絕大部分應用場景下,我推薦大家還是遵循 Go 的 best practices,使用原生的 Go 網絡庫來構建自己的網絡應用。然而,在某些極度追求性能、壓榨系統資源以及技術棧必須是原生 Go (不考慮 C/C++ 寫中間層而 Go 寫業務層)的業務場景下,我們可以考慮自己構建 Reactor 網絡模型。

gnet

gnet 是一個基于事件驅動的高性能和輕量級網絡框架。它直接使用 epollkqueue 系統調用而非標準 Go 網絡包:net 來構建網絡應用,它的工作原理類似兩個開源的網絡庫:nettylibuv,這也使得gnet 達到了一個遠超 Go net 的性能表現。

gnet 設計開發的初衷不是為了取代 Go 的標準網絡庫:net,而是為了創造出一個類似于 Redis、Haproxy 能高效處理網絡包的 Go 語言網絡服務器框架。

gnet 的賣點在于它是一個高性能、輕量級、非阻塞的純 Go 實現的傳輸層(TCP/UDP/Unix Domain Socket)網絡框架,開發者可以使用 gnet 來實現自己的應用層網絡協議(HTTP、RPC、Redis、WebSocket 等等),從而構建出自己的應用層網絡應用:比如在 gnet 上實現 HTTP 協議就可以創建出一個 HTTP 服務器 或者 Web 開發框架,實現 Redis 協議就可以創建出自己的 Redis 服務器等等。

gnet,在某些極端的網絡業務場景,比如海量連接、高頻短連接、網絡小包等等場景,gnet 在性能和資源占用上都遠超 Go 原生的 net 包(基于 netpoller)。

gnet 已經實現了 Multi-ReactorsMulti-Reactors + Goroutine Pool 兩種網絡模型,也得益于這些網絡模型,使得 gnet 成為一個高性能和低損耗的 Go 網絡框架:

MultiReactors.png

multireactorsthreadpool.png

?? 功能

  • [x] 高性能 的基于多線程/Go程網絡模型的 event-loop 事件驅動
  • [x] 內置 goroutine 池,由開源庫 ants 提供支持
  • [x] 內置 bytes 內存池,由開源庫 bytebufferpool 提供支持
  • [x] 整個生命周期是無鎖的
  • [x] 簡單易用的 APIs
  • [x] 基于 Ring-Buffer 的高效且可重用的內存 buffer
  • [x] 支持多種網絡協議/IPC 機制:TCP、UDPUnix Domain Socket
  • [x] 支持多種負載均衡算法:Round-Robin(輪詢)、Source-Addr-Hash(源地址哈希)Least-Connections(最少連接數)
  • [x] 支持兩種事件驅動機制:Linux 里的 epoll 以及 FreeBSD/DragonFly/Darwin 里的 kqueue
  • [x] 支持異步寫操作
  • [x] 靈活的事件定時器
  • [x] SO_REUSEPORT 端口重用
  • [x] 內置多種編解碼器,支持對 TCP 數據流分包:LineBasedFrameCodec, DelimiterBasedFrameCodec, FixedLengthFrameCodec 和 LengthFieldBasedFrameCodec,參考自 netty codec,而且支持自定制編解碼器
  • [x] 支持 Windows 平臺,基于 IOCP 事件驅動機制 Go 標準網絡庫
  • [ ] 實現 gnet 客戶端

參考&延伸閱讀

查看原文

贊 2 收藏 0 評論 0

panjf2000 發布了文章 · 2020-06-09

【譯】為什么 Kafka 這么快?

博客原文

https://taohuawu.club/why-kaf...

為什么 Kafka 如此地快

探究是哪些精妙的設計決策使得 Kafka 成為了現如今的性能強者。

軟件體系結構在過去的幾年間發生了巨大的變化。單體應用程序或甚至幾個粗粒度的服務共享一個公共數據存儲的理念,在全世界的軟件從業者的頭腦中早已不復存在了。自主微服務、事件驅動架構和職責分離 (CQRS) 模式是構建以業務為中心的現代應用程序的主要工具。除此之外,設備連接物聯網、移動和可穿戴設備的普及,正在對系統在接近實時的情況下必須處理的事件數量造成越來越大的壓力。

我們首先要接受一個共識:術語『快』是一個多義的、復雜的甚至是模糊不清的詞。延遲、吞吐量和抖動,這些指標會影響人們對這個術語的理解。它還具有內在的上下文關系:行業和應用領域本身就設置了關于性能的規范和期望。某個東西是否『快』很大程度上取決于一個人的參照系。

Apache Kafka 以延遲和抖動為代價對吞吐量進行了優化,同時保留了其他必須的功能特性,比如持久化、嚴格的日志記錄順序和至少交付一次的語義。當有人說 "Kafka 很快",并且假定他們至少是有資格說這話的,那么我們可以認為他們指的是 Kafka 在短時間內安全地積累和分發大量日志記錄的能力。

從歷史上看,Kafka 誕生于 LinkedIn 的業務需求:高效地移動大量的消息,每小時的數據量達數 TB 。因為時間的可變性,單個消息的傳播延遲被認為是次要的。畢竟,LinkedIn 不是從事高頻交易的金融機構,也不是需要在確定的時限內完成指定操作的工業控制系統。Kafka 可用于實現近實時(或稱為軟實時)的系統。

注意:對于不熟悉這個術語的人,這里必須說明一下,實時并不等同于快速,它僅僅意味著 "可預測"。具體點說,實時意味著完成一個指定操作所需的硬性時間上限,或稱為截止時間。如果系統作為一個整體不能每次都滿足這個時限(內完成操作),它就不能被歸類為實時。能夠在具有小概率超時容錯性的時限范圍內完成操作的系統被稱為近實時系統。就吞吐量而言,實時系統通常比近實時或非實時的系統要慢。

Kafka 的高性能主要得益于兩個要素,這兩個要素需要分開來討論。第一個與客戶端 (Client) 和 代理 (Broker) 實現上的底層效率有關。第二個則來自于流數據處理的機會性并行。

Broker 性能

日志結構的持久性

Kafka 利用了一種分段式的、只追加 (Append-Only) 的日志,基本上把自身的讀寫操作限制為順序 I/O,也就使得它在各種存儲介質上能有很快的速度。一直以來,有一種廣泛的誤解認為磁盤很慢。實際上,存儲介質 (特別是旋轉式的機械硬盤) 的性能很大程度依賴于訪問模式。在一個 7200 轉/分鐘的 SATA 機械硬盤上,隨機 I/O 的性能比順序 I/O 低了大概 3 到 4 個數量級。此外,一般來說現代的操作系統都會提供預讀和延遲寫技術:以大數據塊的倍數預先載入數據,以及合并多個小的邏輯寫操作成一個大的物理寫操作。正因為如此,順序 I/O 和隨機 I/O 之間的性能差距在 flash 和其他固態非易失性存儲介質中仍然很明顯,盡管它遠沒有旋轉式的存儲介質那么明顯。

日志記錄批處理

順序 I/O 在大多數的存儲介質上都非???,幾乎可以和網絡 I/O 的峰值性能相媲美。在實踐中,這意味著一個設計良好的日志結構的持久層將可以緊隨網絡流量的速度。事實上,Kafka 的瓶頸通常是網絡而非磁盤。因此,除了由操作系統提供的底層批處理能力之外,Kafka 的 Clients 和 Brokers 會把多條讀寫的日志記錄合并成一個批次,然后才通過網絡發送出去。日志記錄的批處理通過使用更大的包以及提高帶寬效率來攤薄網絡往返的開銷。

批量壓縮

當啟用壓縮功能時,批處理的影響尤為明顯,因為壓縮效率通常會隨著數據量大小的增加而變得更高。特別是當使用 JSON 等基于文本的數據格式時,壓縮效果會非常顯著,壓縮比通常能達到 5 到 7 倍。此外,日志記錄批處理在很大程度上是作為 Client 側的操作完成的,此舉把負載轉移到 Client 上,不僅對網絡帶寬效率、而且對 Brokers 的磁盤 I/O 利用率也有很大的提升。

廉價的 Consumers

與傳統 MQ 風格的 Brokers 在消費點刪除消息 (導致隨機 I/O 損耗) 不同,Kafka 不會在消息被消費后刪除消息 —— 相反,它獨立地跟蹤每個 Consumer Group 級別的偏移量。偏移量本身的進度被發布到一個名為 __consumer_offsets 的內部 Kafka Topic 上了。同樣的,因為是只追加 (Append-Only) 的操作,所以這個過程非???。這個 Topic 的內容會在后臺被進一步縮減 (利用了 Kafka 的壓縮特性) ,只為任意給定的 Consumer Group 保留最后的已知偏移量。

將此模型與更傳統的消息 Brokers 進行比較,后者通常提供幾種不同的消息分布拓撲。一方面,是一個消息隊列 —— 一種提供點對點消息傳遞而不具備點對多點功能的持久化傳輸機制。另一方面,一個發布-訂閱主題允許點對多點消息傳輸,但是這樣帶來的代價是犧牲持久性。在傳統 MQ 中實現持久的點對多點消息傳遞模型需要為每個有狀態的 Consumer 維護一個專有的消息隊列。這將同時產生讀和寫操作的擴增。一方面,發布者被迫往多個隊列寫數據。另一種情況是,扇出中繼可能從一個隊列里消費日志記錄并將其寫入其他幾個隊列,但這只會延遲讀寫擴增到來的時間,治標不治本。另一方面,一些 Consumers 在 Broker 上產生負載 —— 混合了讀和寫 I/O,既有順序的,也有隨機的。

Kafka 里的 Consumers 是 "廉價的",只要他們不修改日志文件 (只有 Producer 或者是 Kafka 的內部進程有權限修改)。這意味著大量的 Consumers 可以并發地讀取同一個 Topic,而不會壓垮集群。不過,新增一個 Consumer 仍然需要一些成本,但是它主要是順序讀操作,順序寫操作占的比率很低。因此,一個單一的 Topic 在不同的 Consumer 生態系統中被共享是相當正常的。

非強制刷新緩沖寫操作

另一個助力 Kafka 高性能、同時也是一個值得更進一步去探究的底層原因:Kafka 在確認寫成功之前的磁盤寫操作不會真正調用 fsync 命令;通常只需要確保日志記錄被寫入到 I/O Buffer 里就可以給 Client 回復 ACK 信號。這是一個鮮為人知卻至關重要的事實:事實上,這正是讓 Kafka 能表現得如同一個內存型消息隊列的原因 —— 因為 Kafka 是一個基于磁盤的內存型消息隊列 (受緩沖區/頁面緩存大小的限制)。

另一方面,這種形式的寫入是不安全的,因為副本的寫失敗可能會導致數據丟失,即使日志記錄似乎已經被確認成功。換句話說,與關系型數據庫不同,確認一個寫操作成功并不等同于持久化成功。真正使得 Kafka 具備持久化能力的是運行多個同步的副本的設計;即便有一個副本寫失敗了,其他的副本 (假設有多個) 仍然可以保持可用狀態,前提是寫失敗是不相關的 (例如,多個副本由于一個共同的上游故障而同時寫失敗)。因此,不使用 fsync 的 I/O 非阻塞方法和冗余同步副本的結合,使得 Kafka 同時具備了高吞吐量、持久性和可用性。

Client 側的優化

大多數數據庫、隊列和其他形式的持久化中間件都是圍繞重量級 Server (或 Server 集群) 和輕量級 (極度簡單的) Client —— 通過知名的有線協議與 Server(s) 通信這一組合模式來設計的。 Client 的實現通常被認為要比服務端簡單得多。在這種模式下,服務端將承擔大部分的負載,而 Client 僅僅是充當應用程序代碼和 Server 之間的接口。

Kafka 對 Client 采取了一種獨具一格的設計理念。在將日志記錄發送到服務端之前, Client 需要先完成大量的工作。這其中包括日志記錄在累加器中的分段,散列日志記錄的鍵值以到達正確的分區 (Partition) 索引,對日志記錄進行校驗和計算以及壓縮日志記錄批次。 Client 能夠感知集群元數據,并定期刷新該元數據,以同步 Broker 拓撲結構的任何變更。這也讓 Client 可以做一些低層次的轉發決策;生產者 Clients 不會盲目地向集群發送一條日志記錄并依靠集群將其轉發到適當的 Broker 節點,而是直接將寫操作轉發到分區主節點。類似地, 消費者 Clients 在搜尋日志記錄時能夠做出明智的決策, 它們可能會去訪問 (在地理意義上) 距離發出『讀取查詢』更近的副本數據。(該特性是 Kafka 最近添加的,從 2.4.0 版本開始提供。)

零拷貝

導致應用程序效率低下的一個典型根源是緩沖區之間的字節數據拷貝。Kafka 使用由 Producer、Broker 和 Consumer 多方共享的二進制消息格式,因此數據塊即便是處于壓縮狀態也可以在不被修改的情況下在端到端之間流動。雖然消除通信各方之間的結構化差異是非常重要的一步,但它本身并不能避免數據的拷貝。

Kafka 通過利用 Java 的 NIO 框架,尤其是 java.nio.channels.FileChannel 里的 transferTo 這個方法,解決了前面提到的在 Linux 等類 UNIX 系統上的數據拷貝問題。此方法能夠在不借助作為傳輸中介的應用程序的情況下,將字節數據從源通道直接傳輸到接收通道。要了解 NIO 的帶來的改進,請考慮傳統方式下作為兩個單獨的操作:源通道中的數據被讀入字節緩沖區,接著寫入接收通道:

File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);

通過圖表來說明,這個過程可以被描述如下:

img

盡管上面的過程看起來已經足夠簡單,但是在內部仍需要 4 次用戶態和內核態的上下文切換來完成拷貝操作,而且需要拷貝 4 次數據才能完成這個操作。下面的示意圖概述了每一個步驟中的上下文切換。

img

讓我們來更詳細地看一下細節:

  1. 初始的 read() 調用導致了一次用戶態到內核態的上下文切換。DMA (Direct Memory Access 直接內存訪問) 引擎讀取文件,并將其內容復制到內核地址空間中的緩沖區中。這個緩沖區和上面的代碼片段里使用的并非同一個。
  2. 在從 read() 返回之前,內核緩沖區的數據會被拷貝到用戶態的緩沖區。此時,我們的程序可以讀取文件的內容。
  3. 接下來的 send() 方法會切換回內核態,拷貝用戶態的緩沖區數據到內核地址空間 —— 這一次是拷貝到一個關聯著目標套接字的不同緩沖區。在后臺,DMA 引擎會接手這一操作,異步地把數據從內核緩沖區拷貝到協議堆棧。send() 方法在返回之前不等待此操作。
  4. send() 調用返回,切換回用戶態。

盡管模式切換的效率很低,而且需要進行額外的拷貝,但在許多情況下,中間內核緩沖區的性能實際上可以進一步提高。比如它可以作為一個預讀緩存,異步預載入數據塊,從而可以在應用程序前端運行請求。但是,當請求的數據量極大地超過內核緩沖區大小時,內核緩沖區就會成為性能瓶頸。它不會直接拷貝數據,而是迫使系統在用戶態和內核態之間搖擺,直到所有數據都被傳輸完成。

相比之下,零拷貝方式能在單個操作中處理完成。前面示例中的代碼片段現在能重寫為一行程序:

fileDesc.transferTo(offset, len, socket);

零拷貝方式可以用下圖來說明:

img

在這種模式下,上下文的切換次數被縮減至一次。具體來說,transferTo() 方法指示數據塊設備通過 DMA 引擎將數據讀入讀緩沖區,然后這個緩沖區的數據拷貝到另一個內核緩沖區中,分階段寫入套接字。最后,DMA 將套接字緩沖區的數據拷貝到 NIC 緩沖區中。

img

最終結果,我們已經把拷貝的次數從 4 降到了 3,而且其中只有一次拷貝占用了 CPU 資源。我們也已經把上下文切換的次數從 4 降到了 2。

這是一個巨大的提升,不過還沒有實現完全 "零拷貝"。不過我們可以通過利用 Linux 內核 2.4 或更高版本以及支持 gather 操作的網卡來做進一步的優化從而實現真正的 "零拷貝"。下面的示意圖可以說明:

img

調用 transferTo() 方法會致使設備通過 DMA 引擎將數據讀入內核讀緩沖區,就像前面的例子那樣。然而,通過 gather 操作,讀緩沖區和套接字緩沖區之間的數據拷貝將不復存在。相反地,NIC 被賦予一個指向讀緩沖區的指針,連同偏移量和長度,所有數據都將通過 DMA 抽取干凈并拷貝到 NIC 緩沖區。在這個過程中,在緩沖區間拷貝數據將無需占用任何 CPU 資源。

傳統的方式和零拷貝方式在 MB 字節到 GB 字節的文件大小范圍內的性能對比顯示,零拷貝方式相較于傳統方式的性能提升幅度在 2 到 3 倍。但更令人印象深刻的是,Kafka 僅僅是在一個純 JVM 虛擬機下、沒有使用本地庫或 JNI 代碼,就實現了這一點。

規避 GC

對通道 (Channel)、本地緩沖區 (Native Buffer) 和頁面緩存 (Page Cache) 的大量使用還有一個額外的好處 —— 即減少垃圾收集器 (GC) 的負載。舉個例子,在一臺 32 GB 內存的機器上運行 Kafka 會產生 28-30 GB 的可用頁面緩存,這完全超出了 GC 的作用范圍。其實最終吞吐量的差異很小 —— 只有幾個百分點 —— 因為經過正確的參數調優之后的 GC 的吞吐量可能相當高,特別是在處理壽命較短的對象時。真正的收益是抖動的減少;通過規避 GC,Brokers 不太可能出現那種導致日志記錄端到端傳播延遲增大、從而影響 Client 的暫停。

有一說一,與當時構想出 Kafka 的難度相比,現在規避 GC 已經不是什么問題了。像 Shenandoah 和 ZGC 這樣的現代垃圾回收器可以擴展到巨大的、TB 級的堆,并且有可調的最壞情況下的暫停時間,可以把該時間優化到個位數毫秒級別。目前,基于 JVM 的應用程序在使用基于堆的大型緩存之后的性能優于堆外設計的情況并不少見。

流數據并行

日志結構 I/O 的效率是影響性能的一個關鍵因素,主要影響寫操作;Kafka 在對 Topic 結構和 Consumer 生態系統的并行處理是其讀性能的基礎。這種組合產生了非常高的端到端消息傳遞總體吞吐量。并發性根深蒂固地存在于 Kafka 的分區方案和 Consumer Groups 的操作中,這是 Kafka 中一種有效的負載均衡機制 —— 把數據分區 (Partition) 近似均勻地分配給組內的各個 Consumer 實例。將此與更傳統的 MQ 進行比較:在 RabbitMQ 的等效設置中,多個并發的 Consumers 可能以輪詢的方式從隊列讀取數據,然而這樣做,就會失去消息消費的順序性。

分區機制也使得 Kafka Brokers 可以水平擴展。每個分區都有一個專門的 Leader;因此,任何重要的主題 Topic (具有多個分區) 都可以利用整個 Broker 集群進行寫操作,這是 Kafka 和消息隊列之間的另一個區別;后者利用集群來獲得可用性,而 Kafka 將真正地在 Brokers 之間負載均衡,以獲得可用性、持久性和吞吐量。

生產者在發布日志記錄之時指定分區,假設你正在發布消息到一個有多個分區的 Topic 上。(也可能有單一分區的 Topic, 這種情況下將不成問題。) 這可以通過直接指定分區索引來完成,或者間接通過日志記錄的鍵值來完成,該鍵值能被確定性地哈希到一個一致的 (即每次都相同) 分區索引。擁有相同哈希值的日志記錄將會被存儲到同一個分區中。假設一個 Topic 有多個分區,那些不同哈希值的日志記錄將很可能最后被存儲到不同的分區里。但是,由于哈希碰撞的緣故,不同哈希值的日志記錄也可能最后被存儲到相同的分區里。這是哈希的本質,如果你理解哈希表的原理,那應該是顯而易見的。

日志記錄的實際處理是由一個在 (可選的) Consumer Group 中的 Consumer 操作完成。Kafka 確保一個分區最多只能分配給它的 Consumer Group 中的一個 Consumer 。(我們說 "最多" 是因為考慮到一種全部 Consumer 都離線的情況。) 當第一個 Consumer Group 里的 Consumer 訂閱了 Topic,它將消費這個 Topic 下的所有分區的數據。當第二個 Consumer 緊隨其后加入訂閱時,它將大致獲得這個 Topic 的一半分區,減輕第一個 Consumer 先前負荷的一半。這使得你能夠并行處理事件流,并根據需要增加 Consumer (理想情況下,使用自動伸縮機制),前提是你已經對事件流進行了合理的分區。

日志記錄吞吐量的控制一般通過以下兩種方式來達成:

  1. Topic 的分區方案。應該對 Topics 進行分區,以最大限度地增加獨立子事件流的數量。換句話說,日志記錄的順序應該只保留在絕對必要的地方。如果任意兩個日志記錄在某種意義上沒有合理的關聯,那它們就不應該被綁定到同一個分區。這暗示你要使用不同的鍵值,因為 Kafka 將使用日志記錄的鍵值作為一個散列源來派生其一致的分區映射。
  2. 一個組里的 Consumers 數量。你可以增加 Consumer Group 里的 Consumer 數量來均衡入站的日志記錄的負載,這個數量的上限是 Topic 的分區數量。(如果你愿意的話,你當然可以增加更多的 Consumers ,不過分區計數將會設置一個上限來確保每一個活躍的 Consumer 至少被指派到一個分區,多出來的 Consumers 將會一直保持在一個空閑的狀態。) 請注意, Consumer 可以是進程或線程。依據 Consumer 執行的工作負載類型,你可以在線程池中使用多個獨立的 Consumer 線程或進程記錄。

如果你之前一直想知道 Kafka 是否很快、它是如何擁有其現如今公認的高性能標簽,或者它是否可以滿足你的使用場景,那么相信你現在應該有了所需的答案。

為了讓事情足夠清楚,必須說明 Kafka 并不是最快的 (也就是說,具有最大吞吐量能力的) 消息傳遞中間件,還有其他具有更大吞吐量的平臺 —— 有些是基于軟件的 —— 有些是在硬件中實現的。Apache Pulsar 是一項極具前景的技術,它具備可擴展性,在提供相同的消息順序性和持久性保證的同時,還能實現更好的吞吐量-延遲效果。使用 Kafka 的根本原因是,它作為一個完整的生態系統仍然是無與倫比的。它展示了卓越的性能,同時提供了一個豐富和成熟而且還在不斷進化的環境,盡管 Kafka 的規模已經相當龐大了,但仍以一種令人羨慕的速度在成長。

Kafka 的設計者和維護者們在創造一個以性能導向為核心的解決方案這方面做得非常出色。它的大多數設計/理念元素都是早期就構思完成、幾乎沒有什么是事后才想到的,也沒有什么是附加的。從把工作負載分攤到 Client 到 Broker 上的日志結構持久性,批處理、壓縮、零拷貝 I/O 和流數據級并行 —— Kafka 向幾乎所有其他面向消息的中間件 (商業的或開源的) 發起了挑戰。而且最令人嘆為觀止的是,它做到這些事情的同時竟然沒有犧牲掉持久性、日志記錄順序性和至少交付一次的語義等特性。

Kafka 不是最簡單的消息傳輸平臺,所以有很多東西可以學習。一個人必須先掌握整體/部分順序性、主題 (Topic)、分區 (Partition)、 消費者 (Consumer) 和 消費者組 (Consumer Group) 等基本概念之后,才有可能輕松地設計和構建高性能的事件驅動系統。雖然學習曲線是陡峭的,但最終的結果肯定是值得的。如果你熱衷于服用《黑客帝國》中的 "紅色藥丸",可以閱讀 Introduction to Event Streaming with Kafka and Kafdrop。

這篇文章是否對你有所裨益?我很想聽到你的反饋,所以別藏著掖著了。如果你對 Kafka、Kubernetes、微服務或者事件流處理,甚至于說僅僅只是想提問題,歡迎在 Twitter 上關注我。我同時還是開源項目 Kafdrop 的維護者以及 Effective Kafka 一書的作者。

查看原文

贊 1 收藏 0 評論 0

panjf2000 發布了文章 · 2020-05-29

【譯】CPU 高速緩存原理和應用

博客原文: 【譯】CPU 高速緩存原理和應用

曾三次獲得 F1 世界冠軍的杰基?斯圖爾特 (Jackie Stewart) 表示,了解汽車的工作原理讓他成為了一名更好的駕駛員。

"你并不需要先成為一個工程師才能去做一個賽車手,但是你得有一種機械同感 (Mechanical Sympathy)"

Martin Thompson (高性能消息庫 LMAX Disruptor 的設計者) 就一直都把機械同感的理念應用到編程中。簡而言之,了解計算機底層硬件能讓我們作為一個更優秀的開發者去設計算法、數據結構等等。

在這篇文章中,我們會深入鉆研計算機處理器然后看看了解它的一些概念是如何幫助我們去優化程序的。

基本原理

現代計算機處理器是基于一種叫對稱多處理 (symmetric multiprocessing, SMP) 的概念。在一個 SMP 系統里,處理器的設計使兩個或多個核心連接到一片共享內存 (也叫做主存,RAM)。另外,為了加速內存訪問,處理器有著不同級別的緩存,分別是 L1、L2 和 L3。確切的體系結構可能因供應商、處理器模型等等而異。然而,目前最流行的模型是把 L1 和 L2 緩存內嵌在 CPU 核心本地,而把 L3 緩存設計成跨核心共享:

越靠近 CPU 核心的緩存,容量就越小,同時訪問延遲就越低 (越快):

CacheLatencyCPU cyclesSize
L1 access~1.2 ns~4Between 32 KB and 512 KB
L2 access~3 ns~10Between 128 KB and 24 MB
L3 access~12 ns~40Between 2 MB and 32 MB

同樣的,這些具體的數字因不同的處理器模型而異。不過,我們可以做一個粗略的估算:假設 CPU 訪問主存需要耗費 60 ns,那么訪問 L1 緩存會快上 50 倍。

在處理器的世界里,有一個很重要的概念叫訪問局部性 (locality of reference),當處理器訪問某個特定的內存地址時,有很大的概率會發生下面的情況:

  • CPU 在不久的將來會去訪問相同的地址:這叫時間局部性 (temporal locality)原則。
  • CPU 會訪問特定地址附近的內存地址:這叫空間局部性 (spatial locality)原則。

之所以會有 CPU 緩存,時間局部性是其中一個重要的原因。不過,我們到底應該怎么利用處理器的空間局部性呢?比起拷貝一個單獨的內存地址到 CPU 緩存里,拷貝一個緩存行 (Cache Line) 是更好的實現。一個緩存行是一個連續的內存段。

緩存行的大小取決于緩存的級別 (同樣的,具體還是取決于處理器模型)。舉個例子,這是我的電腦的 L1 緩存行的大?。?/p>

$ sysctl -a | grep cacheline
hw.cachelinesize: 64

處理器會拷貝一段連續的 64 字節的內存段到 L1 緩存里,而不是僅僅拷貝一個單獨的變量。舉個例子,當處理器要拷貝一個由 int64 類型組成 Go 的切片到 CPU 緩存里的時候,它會一起拷貝 8 個元素,而不是單單拷貝 1 個。

一個具體的應用緩存行的 Go 程序

讓我們來看一個具體的例子,這個例子將會給我們展示利用 CPU 緩存帶來的好處。下面的代碼完成的功能是合并兩個由 int64 類型組成的方形矩陣:

func BenchmarkMatrixCombination(b *testing.B) {
    matrixA := createMatrix(matrixLength)
    matrixB := createMatrix(matrixLength)

    for n := 0; n < b.N; n++ {
        for i := 0; i < matrixLength; i++ {
            for j := 0; j < matrixLength; j++ {
                matrixA[i][j] = matrixA[i][j] + matrixB[i][j]
            }
        }
    }
}

給定的 matrixLength 變量值設為 64k,壓測結果如下:

BenchmarkMatrixSimpleCombination-64000                     8  130724158 ns/op

現在,我們把加 matrixB[i][j] 的操作換成 matrixB[j][i]

func BenchmarkMatrixReversedCombination(b *testing.B) {
    matrixA := createMatrix(matrixLength)
    matrixB := createMatrix(matrixLength)

    for n := 0; n < b.N; n++ {
        for i := 0; i < matrixLength; i++ {
            for j := 0; j < matrixLength; j++ {
                matrixA[i][j] = matrixA[i][j] + matrixB[j][i]
            }
        }
    }
}

改動之后對壓測結果的影響有多大呢?

BenchmarkMatrixCombination-64000                           8  130724158 ns/op
BenchmarkMatrixReversedCombination-64000                   2  573121540 ns/op

性能大幅下降!那該怎么解釋這個結果呢?

讓我們畫幾幅圖來更直觀地描述一下中間到底發生了什么,藍色圓圈代表第一個矩陣的當前指針而粉紅色圓圈代表了第二個矩陣的指針。由于程序的操作是 matrixA[i][j] = matrixA[i][j] + matrixB[j][i] ,所以當藍色指針處于坐標 (4,0) 之時,粉紅色指針對應的坐標就是 (0,4):

在上面的圖解中,我們用橫坐標縱坐標來表示矩陣,(0,0) 代表頂上最左的方塊。從計算機原理的角度,一個矩陣所有的行將會被分配到一片連續的內存上,不過為了更直觀地表示,我們還是按照數學的表示方法。

此外,接下來的例子里,矩陣的大小是緩存行大小的倍數。因此,一個緩存行不會在下一個矩陣行溢出。

程序會怎么遍歷矩陣?藍色指針會一直向右移動直到最后一列,然后移到下一行,到達坐標 (5,0),以此類推。相反地,粉紅色指針會一直往下移動直到最后一行,然后移到下一列。

當粉紅色指針在坐標 (0,4) 之時,處理器會緩存指針所在那一行 (在這個示意圖里,我們假設緩存行的大小是 4 個元素):

因此,當粉紅色指針到達坐標 (0,5) 之時,我們可能會假定這個變量已經在 L1 緩存里了對不對?實際上這取決于矩陣的大小

  • 如果矩陣足夠小從而所有的緩存行都能被容納在 L1 里,那答案就是肯定的。
  • 否則的話,該緩存行就會在指針達到 (0,5) 之前就被清出 L1。因此,將會產生一個緩存缺失,然后處理器就不得不通過別的方式訪問該變量 (比如從 L2 里去取)。此時,程序的狀態將會是這樣的:

那么矩陣的容量應該達到多小才能從 L1 緩存中獲益呢?讓我們做個簡單的計算:首先,我們需要知道 L1 緩存的容量有多大:

$ sysctl hw.l1icachesize
hw.l1icachesize: 32768

在我的機器上,L1 緩存的大小是 32768 字節而緩存行的大小是 64 字節。因此,我最多能存 512 個緩存行到 L1 里。那么如果我們把上面的程序里的矩陣的大小改成 512 之后再跑一下壓測,結果會怎樣?

BenchmarkMatrixCombination-512                1404     718594 ns/op
BenchmarkMatrixReversedCombination-512        1363     850141 ns/opp

盡管我們已經把兩個測試用例的性能差距縮小了很多 (用 64k 大小的矩陣測的時候,第二個要慢了大約 300%),我們還是可以看到會有細微的差距。到底是哪里出了問題?在壓測過程中,我們使用了兩個矩陣,因此 CPU 需要儲存這兩個矩陣的所有緩存行。在一個完全理想的狀態下 (比如壓測過程中沒有其他程序在運行,而這幾乎是不可能的),L1 緩存會用 50% 的容量來存第一個矩陣而用另外的 50% 的容量來存第二個矩陣。那我們就再進一步縮小兩個矩陣的大小,縮減到 256 個元素:

BenchmarkMatrixCombination-256                5712     176415 ns/op
BenchmarkMatrixReversedCombination-256        6470     164720 ns/op

現在我們終于得到了一個近乎相等的壓測結果了。

關于為什么第二個測試用例還要略微地比第一個快,這點差別看起來不是很容易察覺而且應該和 Go 編譯器生成的匯編代碼有關。在第二個測試用例里,第二個矩陣上的指針區別于第一個矩陣指針的管理方式,使用的是 LEA (Load Effective Address) 匯編指令。因為操作系統的虛擬內存機制,當一個處理器訪問一個內存地址時,需要做一個虛擬內存到物理真實內存的轉換。使用 LEA 指令允許你不經過虛擬內存的轉換直接得到內存地址。舉個例子,如果我們維護一個由 int64 類型元素組成的切片,我們已經知道了切片里第一個元素的地址,我們就能使用 LEA 指令簡單地往后移動 8 個字節得到第二個元素的地址。在我們的例子里,這可能就是為什么第二個測試更快的原因。不過,因為我不是匯編方面的專家,所以如果覺得我的分析有問題的話歡迎提出異議。我已經把第一個函數第二個函數 (反向相加)的匯編代碼上傳到 GitHub 了,有興趣的話可以看看。

好了,那我們現在怎么才能在處理一個大容量矩陣時減少處理器緩存缺失帶來的影響呢?這里介紹一種叫嵌套循環最優化 (Loop Nest Optimization) 的技巧:我們遍歷矩陣的時候,每次都以一個指定大小的矩陣塊為單位來遍歷,以此來最大化利用 CPU 緩存。

在上面的例子里定義一個包含 4 * 4 大小的矩陣塊。在第一個矩陣里,我們從 (4,0) 到 (4,3) 遍歷一次,然后切換到下一行。相應的,我們在第二個矩陣里就是從 (0,4) 到 (3,4) 遍歷一次,然后切換到下一列。

當粉紅色指針遍歷完第一列之后,處理器就會把相應的的所有緩存行都儲存到 L1 里了,因此,遍歷剩下的那些元素的時候就都是從 L1 里訪問了,這樣就能加快速度了:

讓我們把上述的思路用 Go 實現出來,不過我們得謹慎地選擇矩陣塊的大??;在之前的例子里,矩陣塊的邊長等于緩存行的大小,這個值不能設置得再小了,否則的話,緩存行里就會有空余,浪費空間。在我們的 Go 壓測程序里,矩陣的元素是 int64 類型 (8 個字節),而緩存行是 64 字節,可以儲存 8 個元素,那么矩陣塊的邊長就至少要是 8:

func BenchmarkMatrixReversedCombinationPerBlock(b *testing.B) {
    matrixA := createMatrix(matrixLength)
    matrixB := createMatrix(matrixLength)
    blockSize := 8

    for n := 0; n < b.N; n++ {
        for i := 0; i < matrixLength; i += blockSize {
            for j := 0; j < matrixLength; j += blockSize {
                for ii := i; ii < i+blockSize; ii++ {
                    for jj := j; jj < j+blockSize; jj++ {
                        matrixA[ii][jj] = matrixA[ii][jj] + matrixB[jj][ii]
                    }
                }
            }
        }
    }
}

現在用這個最新的代碼實現去跑壓測,結果要比直接遍歷整個矩陣的實現快 67%:

BenchmarkMatrixReversedCombination-64000          2  573121540 ns/op
BenchmarkMatrixReversedCombinationPerBlock-64000  6  185375690 ns/op

這就是用來展示對 CPU 緩存的了解可以如何潛在地幫助我們設計更高效算法的第一個例子。

偽共享 (False Sharing)

經過上面的分析,我們現在應該對處理器如何管理內部緩存有一個比較清晰的理解了;再來快速回顧一下:

  • 因為空間局部性原則,處理器會儲存緩存行而不是一個單獨內存地址。
  • L1 緩存是內嵌在指定的 CPU 核心本地的。

現在,讓我們通過一個例子來討論一下 L1 緩存一致性和偽共享的問題。假設現在有兩個變量: var1var2 被儲存在主存里,一個在 core1 里的線程訪問 var1 ,而另一個 core2 里的線程訪問 var2 。假設這兩個變量在內存中的位置是相鄰的 (或者是非??拷?,那么最后就會導致 var2 存在于兩個核心的同一個 L1 緩存行里:

如果第一個線程更新了它所在 CPU 的緩存行會發生什么?這更新操作可能會更新任何包含 var2 的緩存行。接著,當第二個線程嘗試去讀 var2 的時候,它的值可能已經和之前不一致了。

處理器是如何保持緩存的一致性的?如果兩個緩存行共享了一些內存地址,處理器將會把他們標記成 Shared 狀態。如果一個線程修改了其中一個 Shared 狀態的緩存行,那么兩個緩存行都會被標記成 Modified。為了保證緩存一致性,需要引入在多核之間引入一種協調機制,而這種機制可能會導致應用程序的性能大幅度下降。這個問題就被成為偽共享 (Fasle Sharing)。

我們來看一個具體的 Go 程序。在這個例子里,我們相繼地實例化了兩個結構體,一個緊挨著另一個;因此,這兩個結構體應該會被分配在一片連續的內存上;然后,我們再創建兩個 goroutines,分別去訪問對應的結構體 (變量 M 的值等于 100 萬):

type SimpleStruct struct {
    n int
}

func BenchmarkStructureFalseSharing(b *testing.B) {
    structA := SimpleStruct{}
    structB := SimpleStruct{}
    wg := sync.WaitGroup{}

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        wg.Add(2)
        go func() {
            for j := 0; j < M; j++ {
                structA.n += j
            }
            wg.Done()
        }()
        go func() {
            for j := 0; j < M; j++ {
                structB.n += j
            }
            wg.Done()
        }()
        wg.Wait()
    }
}

在這個例子里,第二個結構體里的變量 n 只會被第二個 goroutine 訪問,然而,因為兩個結構體在內存上的地址是連續的, n 將會存在于兩個 CPU 緩存行中 (這里假設兩個 goroutine 會被分配到不同核心上調度,當然,這通常不是必須的),這是壓測結果:

BenchmarkStructureFalseSharing         514    2641990 ns/op

那么我們如何才能規避這種偽共享呢?有一個解決辦法是使用內存填充 (Memory Padding)。這種方案的原理是在兩個變量之間填充足夠多的空間,以確保它們會儲存在不同的 CPU 緩存行里。

首先,讓我們創建一個替代之前那個結構體的新結構體,在變量聲明之后填充足夠的內存:

type PaddedStruct struct {
    n int
    _ CacheLinePad
}

type CacheLinePad struct {
    _ [CacheLinePadSize]byte
}

const CacheLinePadSize = 64

接著,我們再初始化這兩個結構體而且和之前一樣通過單獨的 goroutine 分別去訪問這兩個變量:

func BenchmarkStructurePadding(b *testing.B) {
    structA := PaddedStruct{}
    structB := SimpleStruct{}
    wg := sync.WaitGroup{}

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        wg.Add(2)
        go func() {
            for j := 0; j < M; j++ {
                structA.n += j
            }
            wg.Done()
        }()
        go func() {
            for j := 0; j < M; j++ {
                structB.n += j
            }
            wg.Done()
        }()
        wg.Wait()
    }
}

內存智能化,這個例子里的內存分布應該看起來像下圖這樣,兩個變量之間留有足夠多的內存填充,從而導致它們最后只會分別存在于不同核心的緩存行上:

讓我們來看下最新的壓測結果:

BenchmarkStructureFalseSharing         514    2641990 ns/op
BenchmarkStructurePadding              735    1622886 ns/op

使用了內存填充之后的第二個例子要比最初的那個快了差不多 40% ??,雖然不是沒有代價的。內存填充的確能加快執行時間,不過代價是會導致更多的內存分配和浪費。

機械同感 (Mechanical Sympathy) 在程序優化方面是一個重要的概念。在這篇文章中,我們已經通過相關的例子展示了對 CPU 處理器的了解是如何幫助我們優化/降低程序執行時間的。

這里我要感謝 Inanc GumusVal Deleplace,正是因為和他們二位在 Twitter 上進行了一番有趣的探討之后,才讓我萌生了寫這篇博客的想法。你們也應該去看看他們寫的博客,因為他們輸出了很多優質的內容。

延伸閱讀

go-cpu-caches

Numbers Every Programmer Should Know By Year

False Sharing

Loop Optimizations Where Blocks are Required

從Java視角理解偽共享(False Sharing)

關于 Fasle Sharing 的補充

由于本文關于 False Sharing 那一章節對于該知識點的闡述過于簡略以及分析不夠準確,所以在這里譯者補充一下我個人對 False Sharing 的分析。

要真正理解偽共享,首先要了解 MESI 協議及 RFO 請求

從前面的內容我們可以知道,每個核心都有自己私有的 L1、L2 緩存。那么多線程編程時, 另外一個核的線程想要訪問當前核內 L1、L2 緩存行的數據, 該怎么做呢?

有人說可以通過第 2 個核直接訪問第 1 個核的緩存行,這是當然是可行的,但這種方法不夠快??绾嗽L問需要通過 Memory Controller (內存控制器,是計算機系統內部控制內存并且通過內存控制器使內存與 CPU 之間交換數據的重要組成部分),典型的情況是第 2 個核經常訪問第 1 個核的這條數據,那么每次都有跨核的消耗。更糟的情況是,有可能第 2 個核與第 1 個核不在一個插槽內,況且 Memory Controller 的總線帶寬是有限的,扛不住這么多數據傳輸。所以,CPU 設計者們更偏向于另一種辦法:如果第 2 個核需要這份數據,由第 1 個核直接把數據內容發過去,數據只需要傳一次。

那么什么時候會發生緩存行的傳輸呢?答案很簡單:當一個核需要讀取另外一個核的臟緩存行時發生。但是前者怎么判斷后者的緩存行已經被弄臟(寫)了呢?

下面將詳細地解答以上問題。 首先我們需要談到一個協議—— MESI 協議?,F在主流的處理器都是用它來保證緩存的相干性和內存的相干性。M、E、S 和 I 代表使用 MESI 協議時緩存行所處的四個狀態:

M(修改,Modified):本地處理器已經修改緩存行,即是臟行,它的內容與內存中的內容不一樣,并且此 cache 只有本地一個拷貝(專有);
E(專有,Exclusive):緩存行內容和內存中的一樣,而且其它處理器都沒有這行數據;
S(共享,Shared):緩存行內容和內存中的一樣, 有可能其它處理器也存在此緩存行的拷貝;
I(無效,Invalid):緩存行失效, 不能使用。

下面說明這四個狀態是如何轉換的:

初始:一開始時,緩存行沒有加載任何數據,所以它處于 I 狀態。

本地寫(Local Write):如果本地處理器寫數據至處于 I 狀態的緩存行,則緩存行的狀態變成 M。

本地讀(Local Read):如果本地處理器讀取處于 I 狀態的緩存行,很明顯此緩存沒有數據給它。此時分兩種情況:(1)其它處理器的緩存里也沒有此行數據,則從內存加載數據到此緩存行后,再將它設成 E 狀態,表示只有我一家有這條數據,其它處理器都沒有;(2)其它處理器的緩存有此行數據,則將此緩存行的狀態設為 S 狀態。(備注:如果處于M狀態的緩存行,再由本地處理器寫入/讀出,狀態是不會改變的)

遠程讀(Remote Read):假設我們有兩個處理器 c1 和 c2,如果 c2 需要讀另外一個處理器 c1 的緩存行內容,c1 需要把它緩存行的內容通過內存控制器 (Memory Controller) 發送給 c2,c2 接到后將相應的緩存行狀態設為 S。在設置之前,內存也得從總線上得到這份數據并保存。

遠程寫(Remote Write):其實確切地說不是遠程寫,而是 c2 得到 c1 的數據后,不是為了讀,而是為了寫。也算是本地寫,只是 c1 也擁有這份數據的拷貝,這該怎么辦呢?c2 將發出一個 RFO (Request For Owner) 請求,它需要擁有這行數據的權限,其它處理器的相應緩存行設為 I,除了它自已,誰不能動這行數據。這保證了數據的安全,同時處理 RFO 請求以及設置I的過程將給寫操作帶來很大的性能消耗。

下面添加一個簡單的 MESI 狀態轉換圖:

現在,讓我們通過一個例子來討論一下 L1 緩存一致性和偽共享的問題。假設現在有兩個變量: var1var2 被儲存在主存里,一個在 core1 里的線程訪問 var1 ,而另一個 core2 里的線程訪問 var2 。假設這兩個變量在內存中的位置是相鄰的 (或者是非??拷?,那么最后就會導致 var2 存在于兩個核心的同一個 L1 緩存行里:

上圖中 thread1 位于 core1 ,而 thread2 位于 core2 ,二者均想更新彼此獨立的兩個變量,但是由于兩個變量位于不同核心中的同一個 L1 緩存行中,此時可知的是兩個緩存行的狀態應該都是 Shared ,而對于同一個緩存行的操作,不同的 core 間必須通過發送 RFO 消息來爭奪所有權 (ownership) ,如果 core1 搶到了, thread1 因此去更新該緩存行,把狀態變成 Modified ,那就會導致 core2 中對應的緩存行失效變成 Invalid ,當 thread2 取得所有權之后再去更新該緩存行時必須先讓 core1 把對應的緩存行刷回 L3 緩存/主存,然后它再從 L3 緩存/主存中加載該緩存行進 L1 之后才能進行修改。然而,這個過程又會導致 core1 對應的緩存行失效變成 Invalid ,這個過程將會一直循環發生,從而導致 L1 高速緩存并未起到應有的作用,反而會降低性能;輪番奪取擁有權不但帶來大量的 RFO 消息,而且如果某個線程需要讀此行數據時,L1 和 L2 緩存上都是失效數據,只有 L3 緩存上是同步好的數據,而從前面的內容可以知道,L3 的讀取速度相比 L1/L2 要慢了數十倍,性能下降很大;更壞的情況是跨槽讀取,L3 都不能命中,只能從主存上加載,那就更慢了。

請記住,CPU 緩存的最小的處理單位永遠是緩存行 (Cache Line),所以當某個核心發送 RFO 消息請求把其他核心對應的緩存行設置成 Invalid 從而使得 var1 緩存失效的同時,也會導致同在一個緩存行里的 var2 失效,反之亦然。

Medium 英文原文

Go and CPU Caches

查看原文

贊 2 收藏 1 評論 0

panjf2000 發布了文章 · 2020-04-07

【譯】Go 語言項目源碼貢獻官方指導文檔

Golang.png

以前給 Go 語言項目源碼提交過一些 commits,期間閱讀他們的官方指導文檔的時候覺得這篇指導文檔可以作為絕佳的關于大型軟件項目的規范管理的參考,因為最近又提交了幾個 commits,就又把這篇文檔再看了一遍,有感于 Go 團隊在項目管理和工程實踐上的一些寶貴經驗,就把文檔翻譯成了中文;一來為了更加深入地理解 Go 語言團隊的項目工程最佳實踐,二來則是為了給其他有意給 Go 語言源碼提交貢獻的開發者提供一點參考。

導言

Go 語言項目歡迎所有的代碼貢獻者。

這是一份指導你完成向 Go 語言項目貢獻代碼整個流程的文檔,會略微跟其他開源項目所使用的指導文檔有所不同。我們假設閱讀者已經對 Git 和 Go 有基本的理解以及具備相關的基礎知識。

除了這里所介紹的信息,Go 語言社區也維護了一份關于代碼評審的 wiki 頁面。在你學習評審流程期間,歡迎隨時給這份 wiki 貢獻、補充新內容。

請注意, gccgo 前端的文檔在另一處;看這里:Contributing to gccgo。

成為一個代碼貢獻者

概述

第一步需要注冊成為一個 Go contributor 以及配置你的環境。這里有一份包含了所需步驟的清單:

  • 步驟 0: 準備好一個你將用來給 Go 語言貢獻代碼的 Google 賬號。在后面所有的步驟中都要使用這個賬號,還有確保你的 git 已經正確配置了這個賬號的郵箱地址,以便后續提交 commits。
  • 步驟 1: 簽署以及提交一個 CLA(貢獻者證書協議)。
  • 步驟 2: 給 Go Git 倉庫配置好權限憑證。訪問 go.googlesource.com,點擊右上角的齒輪圖標,接著點擊 "Obtain password",然后跟著指引操作即可。
  • 步驟 3: 在這個頁面注冊一個 Gerrit 賬號,它是 Go 語言團隊使用的代碼評審工具。CLA 的申請和 Gerrit 的注冊只需要在你的賬號上做一次就可以了
  • 步驟 4: 運行 go get -u golang.org/x/review/git-codereview 命令安裝 git-codereview 工具。

如果你圖省事的話,可以直接用自動化工具幫你完成上面的全部步驟,只需運行:

$ go get -u golang.org/x/tools/cmd/go-contrib-init
$ cd /code/to/edit
$ go-contrib-init

這個章節的后面部分將會更加詳盡地闡述上面的每一個步驟。如果你已經完成上面的所有步驟(不管是手動還是通過自動化工具),可以直接跳到貢獻代碼之前部分。

步驟 0: 選擇一個 Google 賬號

每一個提交到 Go 語言的代碼貢獻都是通過一個綁定了特定郵箱地址的 Google 賬號來完成的。請確保你在整個流程中自始至終使用的都是同一個賬號,當然,后續你提交的所有代碼貢獻也是如此。你可能需要想好使用哪一種郵箱,個人的還是企業的。郵箱類型的選擇將決定誰擁有你編寫和提交的代碼的版權。在決定使用哪個賬戶之前,你大概要和你的雇主商議一下。

Google 賬號可以是 Gmail 郵箱賬號、G Suite 組織賬號,或者是那些綁定了外部郵箱的賬號。例如,如果你想要使用一個已存在且并不屬于 G Suite 的企業郵箱,你可以創建一個綁定了外部郵箱的 Google 賬號。

你還需要確保你的 Git 工具已經正確配置好你之前選定的郵箱地址,用來提交代碼。你可以通過 Git 命令來進行全局配置(所有項目都將默認使用這個配置)或者只進行本地配置(只指定某個特定的項目使用)??梢酝ㄟ^以下的命令來檢查當前的配置情況:

$ git config --global user.email  # check current global config
$ git config user.email           # check current local config

修改配置好的郵箱地址:

$ git config --global user.email name@example.com   # change global config
$ git config user.email name@example.com            # change local config

步驟 1: 貢獻者證書協議

在你發送第一個代碼變更到 Go 語言項目(進行評審)之前,你必須先簽署下面兩種證書協議的其中之一。最后的代碼版權歸屬于誰,將決定你應該簽署哪一種協議。

你可以在 Google Developers Contributor License Agreements 網站上檢查當前已簽署的協議以及再簽署新的協議。如果你代碼的版權持有方之前已經在其他的 Google 開源項目上簽署過這些協議了,那么就不需要再重復簽署了。

如果你代碼的版權持有方更改了--例如,如果你開始代表新的公司來貢獻代碼--請發送郵件到 golang-dev 郵件組。這樣我們可以知悉情況,接著準備一份新的協議文件以及更新 作者 文件。

步驟 2: 配置 Git 認證信息

Go 語言的主倉庫位于 go.googlesource.com,這是一個 Google 自建的 Git 服務器。Web 服務器上的認證信息是通過你的 Google 帳戶生成的,不過你還是需要在你的個人電腦上安裝配置 git 來訪問它。按照以下的步驟進行:

  1. 訪問 go.googlesource.com 然后點擊頁面右上角菜單條上的 "Generate Password" 按鈕。接著你會被重定向到 accounts.google.com 去登陸。
  2. 登陸之后,你會被引導到一個標題為 "Configure Git" 的網頁。這個網頁包含了一段個性化的腳本代碼,運行這個腳本之后會自動生成身份認證的密鑰并配置到 Git 里面去。這個密鑰是和另一個在遠端 Server 生成并存儲的密鑰成對的,類似于 SSH 密鑰對的工作原理。
  3. 復制這段腳本并在你的個人電腦上的終端運行一下,你的密鑰認證 token 就會被保存到一個 .gitcookies 的文件里。如果你使用的是 Windows 電腦,那你應該復制并運行黃色方格里的腳本,而不是下面那個通用的腳本。

步驟 3: 創建一個 Gerrit 賬號

Gerrit 是 Go 語言團隊所使用的一個開源工具,用來進行討論和代碼評審。

要注冊一個你自己的 Gerrit 賬號,訪問 go-review.googlesource.com/login/ 然后使用你上面的 Google 賬號登陸一次,然后就自動注冊成功了。

步驟 4: 安裝 git-codereview 命令行工具

無論是誰,提交到 Go 語言源碼的代碼變更在被接受合并之前,必須要經過代碼評審。Go 官方提供了一個叫 git-codereview 的定制化 git 命令行工具,它可以簡化與 Gerrit 的交互流程。

運行下面的命令安裝 git-codereview 命令行工具:

$ go get -u golang.org/x/review/git-codereview

確保 git-codereview 被正確安裝到你的終端路徑里,這樣 git 命令才可以找到它,檢查一下:

git codereview help

正確打印出幫助信息,而且沒有任何錯誤。如果發現有錯誤,確保環境變量 $PATH 里有 $GOPATH/bin 這個值。

在 Windows 系統上,當使用 git-bash 的時候你必須確保 git-codereview.exe 已經存在于你的 git exec-path 上了??梢赃\行 git --exec-path 來找到正確的位置然后創建一個軟鏈接指向它或者直接從 $GOPATH/bin 目錄下拷貝這個可執行文件到 exec-path。

貢獻代碼之前

Go 語言項目歡迎提交代碼補丁,但是為了確保很好地進行協調,你應該在開始提交重大代碼變更之前進行必要的討論。我們建議你把自己的意圖或問題要不先提交到一個新的 GitHub issue,要不找到一個和你的問題相同或類似的 issue 跟進查看。

檢查 issue 列表

不管你是已經明確了要提交什么代碼,還是你正在搜尋一個想法,你都應該先到 issue 列表 看一下。所有 Issues 已經被分門別類以及被用來管理 Go 開發的工作流。

大多數 issues 會被標記上以下眾多的工作流標簽中的其中一個:

  • NeedsInvestigation: 該 issue 并不能被完全清晰地解讀,需要更多的分析去找到問題的根源
  • NeedsDecision: 該 issue 已經在相當程度上被解讀,但是 Go 團隊還沒有得出一個最好的方法去解決它。最好等 Go 團隊得出了最終的結論之后才開始寫代碼修復它。如果你對解決這個 issue 感興趣,而且這個 issue 已經過了很久都沒得出最終結論,隨時可以在該 issue 下面發表評論去"催促"維護者。
  • NeedsFix: 該 issue 可以被完全清晰地解讀而且可以開始寫代碼修復它。

你可以使用 GitHub 的搜索功能去搜尋一個 issue 然后搭把手幫忙解決它。例子:

新開一個關于任何新問題的 issue

除了一些很瑣碎的變更之外,所有的代碼貢獻都應該關聯到一個已有的 issue。你隨時可以新開一個 issue 來討論你的相關計劃。這個流程可以讓所有人都能夠參與驗證代碼的設計,同時幫忙減少一些重復的工作,以及確保這個想法是符合這門語言和相關工具的目標和理念的。還有就是能在真正開始寫代碼之前就檢查這個代碼設計是否合理;代碼評審工具不是用來討論高層次問題的。

在規劃你的代碼變更工作的時候,請知悉 Go 語言項目遵循的是 6 個月開發周期。在每一個 6 個月周期的后半部分是長達 3 個月的新功能特性凍結期:這期間我們只接受 bug 修復和文檔更新相關的變更。在凍結期內還是可以提交新的變更的,但是這些變更的代碼在凍結期結束之前不會被合并入主分支。

那些針對語言、標準庫或者工具的重大變更必須經過變更提議流程才能被接受。

敏感性的安全相關的 issues 只能上報到 security@golang.org 郵箱!

通過 GitHub 提交一個變更

我們鼓勵那些初次提交代碼并且已經相當熟悉 GitHub 工作流的貢獻者通過標準的 GitHub 工作流給 Go 提交代碼。盡管 Go 的維護者們是使用 Gerrit 來進行代碼評審,但是不用擔心,會有一個叫 Gopherbot 的機器人專門把 GitHub PR 同步到 Gerrit 上。

就像你以往那樣新建一個 pull request,Gopherbot 會創建一個對應的 Gerrit 變更頁面然后把指向該 Gerrit 變更頁面的鏈接發布在 GitHub PR 里面;所有 GitHub PR 的更新都會被同步更新到 Gerrit 里。當有人在 Gerrit 的代碼變更頁面里發表評論的時候,這些評論也會被同步更新回 GitHub PR 里,因此 PR owner 將會收到一個通知。

需要謹記于心的東西:

  • 如果要在 GitHub PR 里進行代碼更新的話,只需要把你最新的代碼推送到對應的分支;你可以添加更多的 commits、或者做 rebase 和 force-push 操作(無論哪種方式都是可以接受的)。
  • 一旦 GitHub PR 被接受,所有的 commits 將會被合并成一條,而且最終的 commit 信息將由 PR 的標題和描述聯結而成。那些單獨的 commit 描述將會被丟棄掉。查看寫好 Commits 信息獲取更多的建議。
  • Gopherbot 無法逐字逐句地把代碼評審的信息同步回 Github: 僅僅是(未經格式化的)全部評論的內容會被同步過去。請記住,你總是可以訪問 Gerrit 去查看更細粒度和格式化的內容。

通過 Gerrit 提交一個變更

一般來說,我們基本不可能在 Gerrit 和 GitHub 之間完整地同步所有信息,至少在現階段來說是這樣,所以我們推薦你去學習一下 Gerrit。它是不同于 GitHub 卻同樣強大的工具,而且熟悉它能幫助你更好地理解我們的工作流。

概述

這是一個關于整個流程的概述:

  • 步驟 1: 從 go.googlesource.com 克隆 Go 的源碼下來,然后通過編譯和測試一次確保這份源碼是完整和穩定的:

    $ git clone https://go.googlesource.com/go
    $ cd go/src
    $ ./all.bash                                # compile and test
  • 步驟 2: 從 master 分支上拉出一條新分支并在這個分支上準備好你的代碼變更。使用 git codereview change 來提交代碼變更;這將會在這個分支上新建或者 amend 一條單獨的 commit。

    $ git checkout -b mybranch
    $ [edit files...]
    $ git add [files...]
    $ git codereview change   # create commit in the branch
    $ [edit again...]
    $ git add [files...]
    $ git codereview change   # amend the existing commit with new changes
    $ [etc.]
  • 步驟 3: 重跑 all.bash 腳本,測試你的代碼變更。

    $ ./all.bash    # recompile and test
  • 步驟 4: 使用 git codereview mail 命令發送你的代碼變更到 Gerrit 進行代碼評審(這個過程并不使用 e-mail,請忽略這個奇葩名字)。

    $ git codereview mail     # send changes to Gerrit
  • 步驟 5: 經過一輪代碼評審之后,把你新的代碼變更依附在同一個單獨 commit 上然后再次使用 mail 命令發送到 Gerrit:

    $ [edit files...]
    $ git add [files...]
    $ git codereview change   # update same commit
    $ git codereview mail     # send to Gerrit again

這個章節剩下的內容將會把上面的步驟進行詳細的講解。

步驟 1: 克隆 Go 語言的源碼

除了你近期安裝的 Go 版本,你還需要有一份從正確的遠程倉庫克隆下來的本地拷貝。你可以克隆 Go 語言源碼到你的本地文件系統上的任意路徑下,除了你的 GOPATH 環境變量對應的目錄。從 go.googlesource.com 克隆下來 (不是從 Github):

$ git clone https://go.googlesource.com/go
$ cd go

步驟 2: 在新分支上準備好代碼變更

每一次代碼變更都必須在一條從 master 拉出來的獨立分支上開發。你可以使用正常的 git 命令來新建一條分支然后把代碼變更添加到暫存區:

$ git checkout -b mybranch
$ [edit files...]
$ git add [files...]

使用 git codereview change 而不是 git commit 命令來提交變更。

$ git codereview change
(open $EDITOR)

你可以像往常一樣在你最喜歡的編輯器里編輯 commit 的描述信息。 git codereview change 命令會自動在靠近底部的地方添加一個唯一的 Change-Id 行。那一行是被 Gerrit 用來匹配歸屬于同一個變更的多次連續的上傳。不要編輯或者是刪除這一行。一個典型的 Change-Id 一般長的像下面這樣:

Change-Id: I2fbdbffb3aab626c4b6f56348861b7909e3e8990

這個工具還會檢查你是否有使用 go fmt 命令對代碼進行格式化,以及你的 commit 信息是否遵循建議的格式。

如果你需要再次編輯這些文件,你可以把新的代碼變更暫存到暫存區然后重跑 git codereview change : 后續每一次運行都會 amend 到現存的上一條 commit 上,同時保留同一個 Change-Id。

確保在每一條分支上都只存在一個單獨的 commit,如果你不小心添加了多條 commits,你可以使用 git rebase把它們合并成一條。

步驟 3: 測試你的代碼變更

此時,你已經寫好并測試好你的代碼了,但是在提交你的代碼去進行代碼評審之前,你還需要對整個目錄樹運行所有的測試來確保你的代碼變更沒有對其他的包或者程序造成影響/破壞:

$ cd go/src
$ ./all.bash

(如果是在 Windows 下構建,使用 all.bat ;還需要在保存 Go 語言源碼樹的目錄下為引導編譯器設置環境變量 GOROOT_BOOTSTRAP。)

在運行和打印測試輸出一段時間后,這個命令在結束前打印的最后一行應該是:

ALL TESTS PASSED

你可以使用 make.bash 而不是 all.bash 來構建編譯器以及標準庫而不用運行整個測試套件。一旦 go 工具構建完成,一個 bin/go 可執行程序會被安裝在你前面克隆下來的 Go 語言源碼的根目錄下,然后你可以在那個目錄下直接運行那個程序??梢圆榭?a href="#%E5%BF%AB%E9%80%9F%E6%B5%8B%E8%AF%95%E4%BD%A0%E7%9A%84%E4%BB%A3%E7%A0%81%E5%8F%98%E6%9B%B4">快速測試你的代碼變更這個章節。

步驟 4: 提交代碼變更進行代碼評審

一旦代碼變更準備好了而且通過完整的測試了,就可以發送代碼變更去進行代碼評審了。這個步驟可以通過 mail 子命令完成,當然它并沒有發送任何郵件;只是把代碼變更發送到 Gerrit 上面去了:

git codereview mail

Gerrit 會給你的變更分配一個數字和 URL,通過 git codereview mail 打印出來,類似于下面的:

remote: New Changes:
remote:   https://go-review.googlesource.com/99999 math: improved Sin, Cos and Tan precision for very large arguments

如果有錯誤,查看 mail 命令錯誤大全和故障排除。

如果你的代碼變更關聯到一個現存的 GitHub issue 而且你也已經遵循了建議的 commit 信息格式,機器人將會在幾分鐘更新那個 issue:在評論區添加 Gerrit 變更頁面的鏈接。

步驟 5: 代碼評審之后修正變更

Go 語言的維護者們會在 Gerrit 上對你的代碼進行 review,然后你會收到一堆郵件通知。你可以在 Gerrit 上查看詳情以及發表評論,如果你更傾向于直接使用郵件回復,也沒問題。

如果你需要在一輪代碼評審之后更新代碼,直接在你之前創建的同一條分支上編輯代碼文件,接著添加這些文件進 Git 暫存區,最后通過 git codereview change amend 到上一條 commit:

$ git codereview change     # amend current commit
(open $EDITOR)
$ git codereview mail       # send new changes to Gerrit

要是你不需要更改 commit 描述信息,可以直接在編輯器保存然后退出。記得不要去碰那一行特殊的 Change-Id。

再次確保你在每一條分支上只保留了一個單獨的 commit,如果你不小心添加了多條 commits,你可以使用 git rebase把它們合并成一條。

良好的 commit 信息

Go 語言的 commit 信息遵循一系列特定的慣例,我們將在這一章節討論。

這是一個良好的 commit 信息的例子:

math: improve Sin, Cos and Tan precision for very large arguments

The existing implementation has poor numerical properties for
large arguments, so use the McGillicutty algorithm to improve
accuracy above 1e10.

The algorithm is described at https://wikipedia.org/wiki/McGillicutty_Algorithm

Fixes #159

首行

變更信息的第一行照慣例一般是一短行關于代碼變更的概述,前綴是此次代碼變更影響的主要的包名。

作為經驗之談,這一行是作為 "此次變更對 Go 的 _ 部分進行了改動" 這一個句子的補全信息,也就是說這一行并不是一個完整的句子,因此并不需要首字母大寫,僅僅只是對于代碼變更的歸納總結。

緊隨第一行之后的是一個空行。

主干內容

描述信息中剩下的內容會進行詳盡地闡述以及會提供關于此次變更的上下文信息,而且還要解釋這個變更具體做了什么。請用完整的句子以及正確的標點符號來表達,就像你在 Go 代碼里的注釋那樣。不要使用 HTML、Markdown 或者任何其他的標記語言。

添加相關的信息,比如,如果是性能相關的改動就需要添加對應的壓測數據。照慣例會使用 benchstat 工具來對壓測數據進行格式化處理,以便寫入變更信息里。

引用 issues

接下來那個特殊的表示法 "Fixes #12345" 把代碼變更關聯到了 Go issue tracker 列表里的 issue 12345。當這個代碼變更最終實施之后 (也就是合入主干),issue tracker 將會自動標記那個 issue 為"已解決"并關閉它。

如果這個代碼變更只是部分解決了這個 issue 的話,請使用 "Updates #12345",這樣的話就會在那個 issue 的評論區里留下一個評論把它鏈接回 Gerrit 上的變更頁面,但是在該代碼變更被實施之后并不會關閉掉 issue。

如果你是針對一個子倉庫發送的代碼變更,你必須使用 GitHub 支持的完全形式的語法來確保這個代碼變更是鏈接到主倉庫的 issue 上去的,而非子倉庫。主倉庫的 issue tracker 會追蹤所有的 issues,正確的格式是 "Fixes golang/go#159"。

代碼評審流程

這個章節是對代碼評審流程的詳細介紹以及如何在一個變更被發送之后處理反饋。

常見的新手錯誤

當一個變更被發送到 Gerrit 之后,通常來說它會在幾天內被分門別類。接著一個維護者將會查看并提供一些初始的評審,對于初次提交代碼貢獻者來說,這些評審通常集中在基本的修飾和常見的錯誤上。

內容包括諸如:

  • Commit 信息沒有遵循建議的格式
  • 沒有鏈接到對應的 GitHub issue。大部分代碼變更需要鏈接到對應的 GitHub issue,說明這次變更修復的 bug 或者實現的功能特性,而且在開始這個變更之前,issue 里應該已經達成了一致的意見。Gerrit 評審不會討論代碼變更的價值,僅僅是討論它的具體實現。
  • 變更如果是在開發周期的凍結階段被發送到 Gerrit 上的,也就是說彼時 Go 代碼樹是不接受一般的變更的,這種情況下,一個維護者可能會在評審代碼時留下一行這樣的評論:R=go.1.12,意思是這個代碼變更將會在下一個開發窗口期打開 Go 代碼樹的時候再進行評審。如果你知道那不是這個代碼變更應該被評審的正確的時間范圍,你可以自己加上這樣的評論:R=go1.XX 來更正。

Trybots

在第一次審查完你的代碼變更之后,維護者會啟動一些 trybots,這是一個會在不同 CPU 架構的機器上運行完整測試套件的服務器集群。大部分 trybots 會在幾分鐘內執行完成,之后會有一個可以查看具體結果的鏈接出現在 Gerrit 變更頁面上。

如果 trybot 最后執行失敗了,點擊鏈接然后查看完整的日志,看看是在哪個平臺上測試失敗了。盡量嘗試去弄明白失敗的原因,然后更新你的代碼去修復它,最后重新上傳你的新代碼。維護者會重新啟動一個新的 trybot 再跑一遍,看看問題是不是已經解決了。

有時候,Go 代碼樹會在某些平臺上有長達數小時的執行失??;如果 trybot 上報的失敗的問題看起來和你的這次代碼變更無關的話,到構建面板上去查看近期內的其他 commits 在相同的平臺上是不是有出現過這種一樣的失敗。如果有的話,你就在 Gerrit 變更頁面的評論區里說明一下這個失敗和你的代碼變更無關,以此讓維護者知悉這種情況。

評審

Go 語言社區非常重視全面的評審。你要把每一條評審的評論的當成一張罰單:你必須通過某種方式把它"關掉",或者是你把評論里評審人建議/要求的改動實現一下,或者是你說服維護者那部分不需要修改。

在你更新了你的代碼之后,過一遍評審頁面的所有評論,確保你已經全部回復了。你可以點擊 "Done" 按鈕回復,這表示你已經實現了評審人建議的修改,否則的話,點擊 "Reply" 按鈕然后解釋一下你為什么還沒修改、或者是你已經做了其他地方的修改并覆蓋了這一部分。

一般來說,代碼評審里會經歷多輪的評審,期間會有一個或者多個評審人不斷地發表新的代碼審查評論然后等待提交者修改更新代碼之后繼續評審,這是很正常的。甚至一些經驗老到的代碼貢獻者也會經歷這種循環,所以不要因此而被打擊到。

投票規則

在評審人們差不多要得出結論之時,他們會對你的此次代碼變更進行"投票"。Gerrit 的投票系統包含了一個在[-2, 2]區間的整數:

  • +2: 同意此次代碼變更被合入到主分支。只有 Go 語言的維護者們才有權限投 +2 的票。
  • +1: 這個代碼變更看起來沒什么問題,不過要么是因為評審人還要求對代碼做一些小的改動、要么是因為該評審人不是一個維護者而無法直接批準這個變更,但是該評審人支持批準這個變更。
  • -1: 這個代碼變更并不是很合理但可能有機會做進一步的修改。如果你得到了一個 -1 票,那一定會有一個明確的解釋告訴你為什么。
  • -2: 一個維護者否決了這個代碼變更并且不同意合入主干。同樣的,會有一個明確的解釋來說明原因。

提交一個核準的變更

在一個代碼變更被投了一個 +2 票之后,投下這票的核準人將會使用 Gerrit 的用戶界面來將代碼合并入主干,這個操作被稱為"提交變更"。

之所以把核準和提交拆分成兩步,是因為有些時候維護者們可能并不想把剛剛批準的代碼變更立刻合入主干,比如,彼時可能正處于 Go 代碼樹的暫時凍結期。

提交一個變更將會把代碼合入主倉庫,代碼變更的描述信息里會包含一個指向對應代碼評審頁面的鏈接,而具體代碼評審頁面處也會更新一個鏈接指向倉庫里的此次代碼變更 commit。把代碼變更合入主干時使用的是 Git 的 "Cherry Pick" 命令,因此在主倉庫里的關于此次代碼變更的 commit 哈希 ID 會被這個提交操作更改。

如果你的變更已經被批準了好幾天了,但是一直沒有被提交到主倉庫,你可以在 Gerrit 寫個評論要求合入。

更多信息

除了這里的信息,Go 語言社區還維護了一個代碼評審的 wiki 頁面。隨時歡迎你在學習相關的評審流程之時為這個頁面貢獻、補充新內容。

其他主題

這個章節收集了一些除了 issue/edit/code review/submit 流程之外的注解信息。

版權標頭

Go 語言倉庫里的文件不會保存一份作者列表,既是為了避免雜亂也是為了避免需要實時更新這份列表。相反的,你的名字將會出現在變更日志貢獻者文件里,也可能會出現在作者文件里。這些文件是定期從 commit 日志上自動生成的。作者文件定義了哪些人是 “Go 語言作者” - 版權持有者。

如果你在提交變更的時候有新添加的文件,那么應該使用標準的版權頭:

// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

(如果你此刻是在 2021 年或者往后的時間閱讀這份文檔,請使用你當前的年份。)倉庫里的文件版權生效于被添加進去的當年,不要在你變更的文件里更改版權信息里的年份。

mail 命令錯誤大全和故障排除

git codereview mail 命令失敗的最常見原因是因為你的郵件地址和你在注冊流程中使用的郵件地址不匹配。

如果你看到這樣的輸出信息:

remote: Processing changes: refs: 1, done
remote:
remote: ERROR:  In commit ab13517fa29487dcf8b0d48916c51639426c5ee9
remote: ERROR:  author email address XXXXXXXXXXXXXXXXXXX
remote: ERROR:  does not match your user account.

你需要在這個倉庫下把 Git 用戶郵箱配置為你一開始注冊好的那個郵箱。更正郵箱地址以確保不會再發生這個錯誤:

$ git config user.email email@address.com

然后通過以下命令修改你的 commit 信息,更正里面的用戶名和郵箱:

$ git commit --amend --author="Author Name <email@address.com>"

最后運行一下的命令再重試一次:

$ git codereview mail

快速測試你的代碼變更

如果每一次單獨的代碼變更都對整個代碼樹運行 all.bash 腳本的話太費勁了,盡管我們極力建議你在發送代碼變更之前跑一下這個腳本,然而在開發的期間你可能只想要編譯和測試那些你涉及到的包。

  • 通常來說,你可以運行 make.bash 而不是 all.bash 來只構建 Go 工具鏈,而不需要運行整個測試套件?;蛘吣憧梢赃\行 run.bash 來運行整個測試套件而不構建 Go 工具鏈。你可以把 all.bash 看成是依次執行 make.bashrun.bash 。
  • 在這個章節,我們會把你存放 Go 語言倉庫的目錄稱為 $GODIR 。 make.bash 腳本構建的 go 工具會被安裝到 $GODIR/bin/go 然后你就可以調用它來測試你的代碼了。例如,如果你修改了編譯器而且你想要測試看看會對你自己項目里的測試套件造成怎樣的影響,直接用它運行 go test

    $ cd <MYPROJECTDIR>
    $ $GODIR/bin/go test
  • 如果你正在修改標準庫,你可能不需要重新構建編譯器:你可以直接在你正在修改的包里跑一下測試代碼就可以了。你可以使用平時用的 Go 版本或者從克隆下來的源碼構建而成的編譯器(有時候這個是必須的因為你正在修改的標準庫代碼可能會需要一個比你已經安裝的穩定版更新版本的編譯器)來做這件事。

    $ cd $GODIR/src/hash/sha1
    $ [make changes...]
    $ $GODIR/bin/go test .
  • 如果你正在修改編譯器本身,你可以直接重新編譯 編譯 工具(這是一個使用 go build 命令編譯每一個單獨的包之時會調用到的一個內部的二進制文件)。完成之后,你會想要編譯或者運行一些代碼來測試一下:

    $ cd $GODIR/src
    $ [make changes...]
    $ $GODIR/bin/go install cmd/compile
    $ $GODIR/bin/go build [something...]   # test the new compiler
    $ $GODIR/bin/go run [something...]     # test the new compiler
    $ $GODIR/bin/go test [something...]    # test the new compiler

    同樣的操作可以應用到 Go 工具鏈里的其他內部工具,像是 asm , cover , link 等等。直接重新編譯然后使用 go install cmd/<TOOL> 命令安裝,最后使用構建出來的 Go 二進制文件測試一下。

  • 除了標準的逐包測試,在 $GODIR/test 目錄下有一個頂級的測試套件,里面包含了多種黑盒和回歸測試。這個測試套件是包含在 all.bash 腳本里運行的,不過你也可以手動運行它:

    $ cd $GODIR/test
    $ $GODIR/bin/go run run.go

向子倉庫提交貢獻 (golang.org/x/...)

如果你正在向一個子倉庫提交貢獻,你需要使用 go get 來獲取對應的 Go 包。例如,如果要向 golang.org/x/oauth2 包貢獻代碼,你可以通過運行以下的命令來獲取代碼:

$ go get -d golang.org/x/oauth2/...

緊接著,進入到包的源目錄($GOPATH/src/golang.org/x/oauth2),然后按照正常的代碼貢獻流程走就行了。

指定一個評審人/抄送其他人

除非有明確的說明,比如在你提交代碼變更之前的討論中,否則的話最好不要自己指定評審人。所有的代碼變更都會自動抄送給 golang-codereviews@googlegroups.com 郵件組。如果這是你的第一次提交代碼變更,在它出現在郵件列表之前可能會有一個審核延遲,主要是為了過濾垃圾郵件。

你可以指定一個評審人或者使用 -r/-cc 選項抄送有關各方。這兩種方式都接受逗號分隔的郵件地址列表:

$ git codereview mail -r joe@golang.org -cc mabel@example.com,math-nuts@swtch.com

同步你的客戶端

在你做代碼變更期間,可能有其他人的變更已經先你一步被提交到主倉庫里,那么為了保持你的本地分支更新,運行:

git codereview sync

(這個命令背后運行的是 git pull -r .)

其他人評審代碼

評審人作為評審流程的一部分可以直接提交代碼到你的變更里(就像是在 GitHub 工作流里有其他人把 commits 依附到你的 PR 上了)。你可以導入這些他人提交的變更到你的本地 Git 分支上。在 Gerrit 的評審頁面,點擊右上角的 "Download ▼" 鏈接,復制 "Checkout" 命令然后在你的本地 Git 倉庫下運行它。這個命令類似如下的格式:

$ git fetch https://go.googlesource.com/review refs/changes/21/13245/1 && git checkout FETCH_HEAD

如果要撤銷,切換回你之前在開發的那個分支即可。

設置 Git 別名

git codereview 相關的命令可以直接在終端鍵入對應的選項運行,例如:

$ git codereview sync

不過給 git codereview 子命令命令設置別名會更方便使用,上面的命令可以替換成:

$ git sync

git codereview 的子命令的名字是排除了 Git 本身的命令關鍵字而挑選出來的,所以不用擔心設置了這些別名會和 Git 本身的命令沖突。要設置這些別名,復制下面的文本到你的 Git 配置文件里(通常是在 home 路徑下的 .gitconfig 文件):

[alias]
    change = codereview change
    gofmt = codereview gofmt
    mail = codereview mail
    pending = codereview pending
    submit = codereview submit
    sync = codereview sync

發送多個依賴的變更

老司機用戶可能會想要把相關的 commits 疊加到一個單獨的分支上。Gerrit 允許多個代碼變更之間相互依賴,形成這樣的依賴鏈。每一個變更需要被單獨地核準和提交,但是依賴對于評審人來說是可見的。

要發送一組依賴的代碼更改,請將每個變更作為不同的 commit 保存在同一分支下,然后運行:

$ git codereview mail HEAD

要確保顯示地指定 HEAD ,不過這在單個變更的場景里通常是不需要指定的。

英文原文地址

https://golang.org/doc/contribute.html

查看原文

贊 1 收藏 1 評論 0

panjf2000 發布了文章 · 2020-04-07

最快的 Go 網絡框架 gnet 來啦!

gnet 是什么?

gnet 是一個基于事件驅動的高性能且輕量級的網絡框架。它直接使用 epollkqueue 系統調用而非標準 Golang 網絡包:net 來構建網絡應用,它的工作原理類似兩個開源的網絡庫:nettylibuv。

gnet 設計開發的初衷不是為了取代 Go 的標準網絡庫:net,而是為了創造出一個類似于 Redis、Haproxy 能高效處理網絡包的 Go 語言網絡服務器框架。

gnet 的亮點在于它是一個高性能、輕量級、非阻塞的純 Go 實現的傳輸層(TCP/UDP/Unix Domain Socket)網絡框架,開發者可以使用 gnet 來實現自己的應用層網絡協議(HTTP、RPC、Redis、WebSocket 等等),從而構建出自己的應用層網絡應用:比如在 gnet 上實現 HTTP 協議就可以創建出一個 HTTP 服務器 或者 Web 開發框架,實現 Redis 協議就可以創建出自己的 Redis 服務器等等。

開源地址:https://github.com/panjf2000/gnet

v1.0.0 正式版本

從 2019 年 9 月份開放源碼到 GitHub,經過半年多的新功能開發、bug 修復、架構設計重構以及性能優化,Go 語言網絡框架 gnet 現在終于發布了第一個正式的 v1 穩定版本!具體的 release 列表可以到 https://github.com/panjf2000/gnet/releases 查看。往后還會持續不斷地進行開發、修復、優化甚至重構,如果 gnet 的用戶在使用的過程中發現 bug,隨時到 gnetGithub Issue 頁 給我提 issue。

目前,gnet 具備了如下的功能特性:

  • [X] 高性能 的基于多線程/Go 程網絡模型的 event-loop 事件驅動
  • [X] 內置 goroutine 池,由開源庫 ants 提供支持
  • [X] 內置 bytes 內存池,由開源庫 bytebufferpool 提供支持
  • [X] 簡潔的 APIs
  • [X] 基于 Ring-Buffer 的高效內存利用
  • [X] 支持多種網絡協議/IPC 機制:TCP、UDP 和 Unix Domain Socket
  • [X] 支持多種負載均衡算法:Round-Robin(輪詢)、Source Addr Hash(源地址哈希)和 Least-Connections(最少連接數)
  • [X] 支持兩種事件驅動機制:Linux 里的 epoll 以及 FreeBSD 里的 kqueue
  • [X] 支持異步寫操作
  • [X] 靈活的事件定時器
  • [X] SO_REUSEPORT 端口重用
  • [X] 內置多種編解碼器,支持對 TCP 數據流分包:LineBasedFrameCodec, DelimiterBasedFrameCodec, FixedLengthFrameCodec 和 LengthFieldBasedFrameCodec,參考自 netty codec,而且支持自定制編解碼器
  • [X] 支持 Windows 平臺,基于 IOCP 事件驅動機制 Go 標準網絡庫
  • [ ] 實現 gnet 客戶端

上面列表中除了一些最基本的功能特性,后來的新功能都是由 gnet 的用戶提出、我開發實現的,在此感謝這些同學的貢獻!列表中還有幾個計劃中的新功能特性在考察和開發階段,我會對用戶提出的新功能需求進行合理性和必要性的評估,然后進行適當的取舍,因此計劃中的功能特性列表可能會隨時發生變化。另外,也歡迎對 gnet 源碼感興趣且想為 gnet 增添新功能或者修復 bug 的同學給我提 PR 貢獻代碼,謝謝!

gnet 的自我定位是高性能且輕量級的 Go 語言網絡框架,暴露極簡的接口的同時又能提供豐富的功能,性能遠超 Go 語言原生網絡庫,如果你的追求極致的性能,那 gnet 絕對是你的絕佳選擇。

性能測試

上面提到 gnet 作為一個 Go 語言網絡框架主打的是高性能,當然,不能只憑我一張嘴說說就證明了 gnet 的高性能,畢竟空口無憑嘛!所以,在這里讓我引用改編一下程序員撕逼界著名的一句話:Talk is cheap, show me your benchmark!

提到框架性能測試,熟悉這方面的同學不會沒聽過 TechEmpower,這是全球 Web 框架權威性能測試:

This is a performance comparison of many Web application frameworks executing fundamental tasks such as JSON serialization, database access, and server-side template composition. Each framework is operating in a realistic production configuration. Results are captured on cloud instances and on physical hardware. The test implementations are largely community-contributed and all source is available at the GitHub repository.

TechEmpower 測試有源代碼,硬件配置全部公開,而且很多框架是作者自己或資深愛好者提交的,他們各自肯定知道該如何極致地優化基于這些框架的 Server,而且這些結果都是可重現的,誰覺得不服可以自己跑跑看,源代碼和需求頁面提供了每種測試的執行細節以及其它相關信息,各種 Web 框架性能對比頁面提供了更多有關如何進行測試的細節與測試基準的概況。TechEmpower 測試的主要目的是將目前流行的 Web 開發框架從多個維度來進行測試,這些測試的場景主要是針對這些 Web 框架執行的基本任務,比如數據庫訪問、JSON 序列化和服務端模板的組合等等場景,整體得分非常具有借鑒價值。

目前已提交 TechEmpower 測試的框架有將近 700 個,其中包括 Netty、Vert.x、Spring、Actix、FastHTTP、Swoole、Nginx 等業界知名的框架/平臺,囊括了 C/C++、Java、C#、Rust、Go、PHP、Ruby、Python 等一眾主流編程語言,是目前業界最權威的 Web 框架性能測試。

目前,TechEmpower 提供了 2 種硬件環境:云主機 Microsoft Azure D3v2 instances; switched gigabit Ethernet 和物理機 Dell R440 servers each equipped with an Intel Xeon Gold 5120 CPU, 32 GB of memory, and an enterprise SSD. Dedicated Cisco 10-gigabit Ethernet switch。

測試內容包括 Plaintext、Single Database Query、Multiple Database Queries、Fortunes、JSON Serialization 等等(全部的測試 cases 可以查看 Project Information Framework Tests Overview),得出了一系列的 Web 框架的性能基準,對于程序員來說,這是一份極具參考價值的 Web 框架評估選型的 benchmark 數據。

gnet 的性能數據將借助于 TechEmpower 展現,由于 TechEmpower 的測試是基于 HTTP 協議的,因此需要基于 gnet 實現一個簡單的 HTTP Server 并提交 TechEmpower 測試,目前 gnet 參與測試的只有 Plaintext 這一項,這也是最能直接體現出框架網絡處理性能的一項測試。

下面是最新一輪的 TechEmpower Benchmark 性能測試結果:

# Hardware
CPU: 28 HT Cores Intel(R) Xeon(R) Gold 5120 CPU @ 2.20GHz
Mem: 32GB RAM
OS : Ubuntu 18.04.3 4.15.0-88-generic #88-Ubuntu
Net: Switched 10-gigabit ethernet
Go : go1.14.x linux/amd64

所有語言框架

這是包含全部編程語言框架的性能排名 top 50 的結果,總榜單包含了全世界共計 382 個框架(Plaintext 測試), gnet 位列第 5, gnet 也是唯一進入前十的 Go 語言框架。其中,一些業界比較知名的框架/平臺的排名:Netty 排名 36、Nginx 排名 66、Vert.x 排名 40、Spring 排名 238,等等。

Go 語言框架

這是 Go 語言分類下的性能排名, gnet 位列第 1。

完整的排行可以通過 view all benchmark results 查看。

基于上面的 TechEmpower 性能測試結果, gnet 在全世界的框架/平臺的競爭中名列第 5,中二點說法就是天下第五,它的高性能定位應該可以說是毋庸置疑了。

P.S. 需要說明的是,因為 gnet 并不是一個 Web/HTTP 框架而是一個更加底層的網絡框架,所以我給 gnet 裸寫了一個簡單的 HTTP Parser,其對于 HTTP 協議的解析是不完備的,跳過了一些(對于這個測試不需要的)繁雜解析步驟,可以說是針對性的優化。相較于其他真正的 Web/HTTP 框架, gnet 在這方面占了一點便宜,不過,Plaintext Benchmarks 主要測試的是框架最基礎/核心request-routing 性能,所以最終的測試結果對于評估一個框架的網絡處理性能還是極具參考價值和現實意義的。

總結

gnet 作為一個高性能且輕量級的網絡框架,適用于追求極致性能的網絡場景,性能表現遠超 Go 語言原生網絡庫,就算是在全球權威性能測試 TechEmpower 排行榜上的表現也很耀眼:全部編程語言總排行第 5,Go 語言分類排行第 1。如果你正在用 Go 開發網絡應用且追求極致的性能, gnet 將會是你的絕佳選擇,歡迎試用!

開源地址:https://github.com/panjf2000/gnet

查看原文

贊 3 收藏 1 評論 0

panjf2000 分享了頭條 · 2019-12-12

gnet 是一個高性能、輕量級、非阻塞的基于事件驅動的 Go 網絡框架,支持 Linux、macOS 以及 Windows 全平臺,性能遠超 Go 原生網絡庫。

贊 0 收藏 0 評論 0

panjf2000 發布了文章 · 2019-10-15

【發布】高性能 Go 網絡庫 gnet 發布 v1 版

Github 主頁

https://github.com/panjf2000/...

歡迎大家圍觀~~,目前還在持續更新,感興趣的話可以 star 一下暗中觀察哦。

簡介

gnet 是一個基于事件驅動的高性能和輕量級網絡框架。它直接使用 epollkqueue 系統調用而非標準 Golang 網絡包:net 來構建網絡應用,它的工作原理類似兩個開源的網絡庫:nettylibuv。

這個項目存在的價值是提供一個在網絡包處理方面能和 Redis、Haproxy 這兩個項目具有相近性能的 Go 語言網絡服務器框架。

gnet 的亮點在于它是一個高性能、輕量級、非阻塞的純 Go 實現的傳輸層(TCP/UDP/Unix-Socket)網絡框架,開發者可以使用 gnet 來實現自己的應用層網絡協議,從而構建出自己的應用層網絡應用:比如在 gnet 上實現 HTTP 協議就可以創建出一個 HTTP 服務器 或者 Web 開發框架,實現 Redis 協議就可以創建出自己的 Redis 服務器等等。

gnet 衍生自另一個項目:evio,但性能遠勝之。

功能

  • 高性能 的基于多線程/Go程模型的 event-loop 事件驅動
  • 內置 Round-Robin 輪詢負載均衡算法
  • 內置 goroutine 池,由開源庫 ants 提供支持
  • 內置 bytes 內存池,由開源庫 pool 提供支持
  • 簡潔的 APIs
  • 基于 Ring-Buffer 的高效內存利用
  • 支持多種網絡協議:TCP、UDP、Unix Sockets
  • 支持兩種事件驅動機制:Linux 里的 epoll 以及 FreeBSD 里的 kqueue
  • 支持異步寫操作
  • 靈活的事件定時器
  • SO_REUSEPORT 端口重用

核心設計

多線程/Go程模型

主從多 Reactors 模型

gnet 重新設計開發了一個新內置的多線程/Go程模型:『主從多 Reactors』,這也是 netty 默認的線程模型,下面是這個模型的原理圖:

它的運行流程如下面的時序圖:

主從多 Reactors + 線程/Go程池

你可能會問一個問題:如果我的業務邏輯是阻塞的,那么在 EventHandler.React 注冊方法里的邏輯也會阻塞,從而導致阻塞 event-loop 線程,這時候怎么辦?

正如你所知,基于 gnet 編寫你的網絡服務器有一條最重要的原則:永遠不能讓你業務邏輯(一般寫在 EventHandler.React 里)阻塞 event-loop 線程,否則的話將會極大地降低服務器的吞吐量,這也是 netty 的一條最重要的原則。

我的回答是,基于gnet 的另一種多線程/Go程模型:『帶線程/Go程池的主從多 Reactors』可以解決阻塞問題,這個新網絡模型通過引入一個 worker pool 來解決業務邏輯阻塞的問題:它會在啟動的時候初始化一個 worker pool,然后在把 EventHandler.React里面的阻塞代碼放到 worker pool 里執行,從而避免阻塞 event-loop 線程,

模型的架構圖如下所示:

它的運行流程如下面的時序圖:

gnet 通過利用 ants goroutine 池(一個基于 Go 開發的高性能的 goroutine 池 ,實現了對大規模 goroutines 的調度管理、goroutines 復用)來實現『主從多 Reactors + 線程/Go程池』網絡模型。關于 ants 的全部功能和使用,可以在 ants 文檔 里找到。

gnet 內部集成了 ants 以及提供了 pool.NewWorkerPool 方法來初始化一個 ants goroutine 池,然后你可以把 EventHandler.React 中阻塞的業務邏輯提交到 goroutine 池里執行,最后在 goroutine 池里的代碼調用 gnet.Conn.AsyncWrite 方法把處理完阻塞邏輯之后得到的輸出數據異步寫回客戶端,這樣就可以避免阻塞 event-loop 線程。

有關在 gnet 里使用 ants goroutine 池的細節可以到這里進一步了解。

自動擴容的 Ring-Buffer

gnet 利用 Ring-Buffer 來緩沖網絡數據以及管理內存。

開始使用

前提

gnet 需要 Go 版本 >= 1.9。

安裝

go get -u github.com/panjf2000/gnet

gnet 支持作為一個 Go module 被導入,基于 Go 1.11 Modules (Go 1.11+),只需要在你的項目里直接 import "github.com/panjf2000/gnet",然后運行 go [build|run|test] 自動下載和構建需要的依賴包。

使用示例

詳細的文檔在這里: gnet 接口文檔,不過下面我們先來了解下使用 gnet 的簡略方法。

gnet 來構建網絡服務器是非常簡單的,只需要實現 gnet.EventHandler接口然后把你關心的事件函數注冊到里面,最后把它連同監聽地址一起傳遞給 gnet.Serve 函數就完成了。在服務器開始工作之后,每一條到來的網絡連接會在各個事件之間傳遞,如果你想在某個事件中關閉某條連接或者關掉整個服務器的話,直接把 gnet.Action 設置成 Cosed 或者 Shutdown就行了。

Echo 服務器是一種最簡單網絡服務器,把它作為 gnet 的入門例子在再合適不過了,下面是一個最簡單的 echo server,它監聽了 9000 端口:

不帶阻塞邏輯的 echo 服務器

package main

import (
    "log"

    "github.com/panjf2000/gnet"
)

type echoServer struct {
    *gnet.EventServer
}

func (es *echoServer) React(c gnet.Conn) (out []byte, action gnet.Action) {
    out = c.Read()
    c.ResetBuffer()
    return
}

func main() {
    echo := new(echoServer)
    log.Fatal(gnet.Serve(echo, "tcp://:9000", gnet.WithMulticore(true)))
}

正如你所見,上面的例子里 gnet 實例只注冊了一個 React 事件。一般來說,主要的業務邏輯代碼會寫在這個事件方法里,這個方法會在服務器接收到客戶端寫過來的數據之時被調用,然后處理輸入數據(這里只是把數據 echo 回去)并且在處理完之后把需要輸出的數據賦值給 out 變量然后返回,之后你就不用管了,gnet 會幫你把數據寫回客戶端的。

帶阻塞邏輯的 echo 服務器

package main

import (
    "log"
    "time"

    "github.com/panjf2000/gnet"
    "github.com/panjf2000/gnet/pool"
)

type echoServer struct {
    *gnet.EventServer
    pool *pool.WorkerPool
}

func (es *echoServer) React(c gnet.Conn) (out []byte, action gnet.Action) {
    data := append([]byte{}, c.Read()...)
    c.ResetBuffer()

    // Use ants pool to unblock the event-loop.
    _ = es.pool.Submit(func() {
        time.Sleep(1 * time.Second)
        c.AsyncWrite(data)
    })

    return
}

func main() {
    p := pool.NewWorkerPool()
    defer p.Release()
    
    echo := &echoServer{pool: p}
    log.Fatal(gnet.Serve(echo, "tcp://:9000", gnet.WithMulticore(true)))
}

正如我在『主從多 Reactors + 線程/Go程池』那一節所說的那樣,如果你的業務邏輯里包含阻塞代碼,那么你應該把這些阻塞代碼變成非阻塞的,比如通過把這部分代碼通過 goroutine 去運行,但是要注意一點,如果你的服務器處理的流量足夠的大,那么這種做法將會導致創建大量的 goroutines 極大地消耗系統資源,所以我一般建議你用 goroutine pool 來做 goroutines 的復用和管理,以及節省系統資源。

I/O 事件

gnet 目前支持的 I/O 事件如下:

  • EventHandler.OnInitComplete 當 server 初始化完成之后調用。
  • EventHandler.OnOpened 當連接被打開的時候調用。
  • EventHandler.OnClosed 當連接被關閉的時候調用。
  • EventHandler.React 當 server 端接收到從 client 端發送來的數據的時候調用。(你的核心業務代碼一般是寫在這個方法里)
  • EventHandler.Tick 服務器啟動的時候會調用一次,之后就以給定的時間間隔定時調用一次,是一個定時器方法。
  • EventHandler.PreWrite 預先寫數據方法,在 server 端寫數據回 client 端之前調用。

定時器

Tick 會每隔一段時間觸發一次,間隔時間你可以自己控制,設定返回的 delay 變量就行。

定時器的第一次觸發是在 gnet.Serving 事件之后。

events.Tick = func() (delay time.Duration, action Action){
    log.Printf("tick")
    delay = time.Second
    return
}

UDP 支持

gnet 支持 UDP 協議,在 gnet.Serve 里綁定 UDP 地址即可,gnet 的 UDP 支持有如下的特性:

  • 數據進入服務器之后立刻寫回客戶端,不做緩存。
  • OnOpenedOnClosed 這兩個事件在 UDP 下不可用,唯一可用的事件是 React。

使用多核

gnet.WithMulticore(true) 參數指定了 gnet 是否會使用多核來進行服務,如果是 true 的話就會使用多核,否則就是單核運行,利用的核心數一般是機器的 CPU 數量。

負載均衡

gnet 目前內置的負載均衡算法是輪詢調度 Round-Robin,暫時不支持自定制。

SO_REUSEPORT 端口復用

服務器支持 SO_REUSEPORT 端口復用特性,允許多個 sockets 監聽同一個端口,然后內核會幫你做好負載均衡,每次只喚醒一個 socket 來處理 accept 請求,避免驚群效應。

開啟這個功能也很簡單,使用 functional options 設置一下即可:

gnet.Serve(events, "tcp://:9000", gnet.WithMulticore(true), gnet.WithReusePort(true)))

性能測試

Linux (epoll)

系統參數

# Machine information
        OS : Ubuntu 18.04/x86_64
       CPU : 8 Virtual CPUs
    Memory : 16.0 GiB

# Go version and configurations
Go Version : go1.12.9 linux/amd64
GOMAXPROCS=8

同類型的網絡庫性能對比:

Echo Server

echolinux.png

HTTP Server

httplinux.png

FreeBSD (kqueue)

系統參數

# Machine information
        OS : macOS Mojave 10.14.6/x86_64
       CPU : 4 CPUs
    Memory : 8.0 GiB

# Go version and configurations
Go Version : go version go1.12.9 darwin/amd64
GOMAXPROCS=4

Echo Server

echomac.png

HTTP Server

httpmac.png

證書

gnet 的源碼允許用戶在遵循 MIT 開源證書 規則的前提下使用。

致謝

相關文章

查看原文

贊 0 收藏 0 評論 1

認證與成就

  • 獲得 150 次點贊
  • 獲得 0 枚徽章 獲得 0 枚金徽章, 獲得 0 枚銀徽章, 獲得 0 枚銅徽章

擅長技能
編輯

開源項目 & 著作
編輯

  • gnet

    一個輕量級且高性能的網絡庫,基于 event-loop 和 multi-reactors 網絡模型。

  • ants

    ants是一個高性能的協程池,實現了對大規模goroutine的調度管理、goroutine復用,允許使用者在開發并發程序的時候限制協程數量,復用資源,達到更高效執行任務的效果。

  • goproxy

    goproxy是使用golang實現的一個基本的負載均衡服務器,支持緩存(使用redis);反向代理,目前支持隨機分發和IP HASH兩種模式,另外,對轉發的請求有較大的控制度,可以控制代理特定的請求,屏蔽特定的請求,甚至可以重寫特定的請求。 另外,有時候項目需要用到第三方的服務并對返回的數據進行自定義修改,調用第三方的API,利用proxy server可以很容易的控制第三方API返回的數據并進行自定義修改。

注冊于 2015-11-09
個人主頁被 3.1k 人瀏覽

一本到在线是免费观看_亚洲2020天天堂在线观看_国产欧美亚洲精品第一页_最好看的2018中文字幕 <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <文本链> <文本链> <文本链> <文本链> <文本链> <文本链>