我眼中的 Nginx(一):Nginx 和位運算

又拍雲發表於2019-03-01

作者張超:又拍雲系統開發高階工程師,負責又拍雲 CDN 平臺相關元件的更新及維護。Github ID: tokers,活躍於 OpenResty 社群和 Nginx 郵件列表等開源社群,專注於服務端技術的研究;曾為 ngx_lua 貢獻原始碼,在 Nginx、ngx_lua、CDN 效能優化、日誌優化方面有較為深入的研究。

眾所周知 Nginx 以效能而出名,這和它優秀的程式碼實現有著密切的關係,而本文所要講述的——位運算,也是促成 Nginx 優秀效能的原因之一。

位運算在 Nginx 的原始碼是處處可見,從定義指令的型別(可以攜帶多少引數,可以出現在哪些配置塊下),到標記當前請求是否還有未傳送完的資料,再到 Nginx 事件模組裡用指標的最低位來標記一個事件是否過期,無不體現著位運算的神奇和魅力。

本文會介紹和分析 Nginx 原始碼裡的一些經典的位運算使用,並擴充套件介紹一些位其他的位運算技巧。

對齊

Nginx 內部在進行記憶體分配時,非常注意記憶體起始地址的對齊,即記憶體對齊(可以換來一些效能上的提升),這與處理器的定址特性有關,比如某些處理器會按 4 位元組寬度定址,在這樣的機器上,假設需要讀取從 0x46b1e7 開始的 4 個位元組,由於 0x46b1e7 並不處在 4 位元組邊界上(0x46b1e7 % 4 = 3),所以在進行讀的時候,會分兩次進行讀取,第一次讀取 0x46b1e4 開始的 4 個位元組,並取出低 3 位元組;再讀取 0x46b1e8 開始的 4 個位元組,取出最高的位元組。我們知道讀寫主存的速度並不能匹配 CPU,那麼兩次的讀取顯然帶來了更大的開銷,這會引起指令停滯,增大 CPI(每指令週期數),損害應用程式的效能。

因此 Nginx 封裝了一個巨集,專門用以進行對齊操作。

#define ngx_align(d, a)     (((d) + (a - 1)) & ~(a - 1))

複製程式碼

如上程式碼所示,該巨集使得 d 按 a 對齊,其中 a 必須是 2 的冪次。

比如 d 是 17,a 是 2 時,得到 18;d 是 15,a 是 4 時,得到 16;d 是 16,a 是 4 時,得到 16。

這個巨集其實就是在尋找大於等於 d 的,第一個 a 的倍數。由於 a 是 2 的冪次, 因此 a 的二進位制表示為 00...1...00 這樣的形式,即它只有一個 1,所以 a - 1 便是 00...01...1 這樣的格式,那麼 ~(a - 1) 就會把低 n 位全部置為 0,其中 n 是 a 低位連續 0 的個數。所以此時如果我們讓 d 和 ~(a - 1) 進行一次按位與操作,就能夠把 d 的低 n 位清零,由於我們需要尋找大於等於 d 的數,所以用 d + (a - 1) 即可。

點陣圖

點陣圖,通常用以標記事物的狀態,“位” 體現在每個事物只使用一個位元位進行標記,這即節約記憶體,又能提升效能。

Nginx 裡有多處使用點陣圖的例子,比如它的共享記憶體分配器(slab),再比如在對 uri(Uniform Resource Identifier)進行轉義時需要判斷一個字元是否是一個保留字元(或者不安全字元),這樣的字元需要被轉義成 %XX 。

static uint32_t   uri_component[] = {
        0xffffffff, /* 1111 1111 1111 1111  1111 1111 1111 1111 */

/* ?>=< ;:98 7654 3210  /.-, +*)( '&%$ #"!  */
        0xfc009fff, /* 1111 1100 0000 0000  1001 1111 1111 1111 */

/* _^]\ [ZYX WVUT SRQP  ONML KJIH GFED CBA@ */
        0x78000001, /* 0111 1000 0000 0000  0000 0000 0000 0001 */

/*  ~}| {zyx wvut srqp  onml kjih gfed cba` */
        0xb8000001, /* 1011 1000 0000 0000  0000 0000 0000 0001 */

        0xffffffff, /* 1111 1111 1111 1111  1111 1111 1111 1111 */
        0xffffffff, /* 1111 1111 1111 1111  1111 1111 1111 1111 */
        0xffffffff, /* 1111 1111 1111 1111  1111 1111 1111 1111 */
        0xffffffff  /* 1111 1111 1111 1111  1111 1111 1111 1111 */
    };

複製程式碼

如上所示,一個簡單的陣列組成了一個點陣圖,共包含 8 個數字,每個數字表示 32 個狀態,因此這個點陣圖把 256 個字元(包括了擴充套件 ASCII 碼)。為 0 的位表示一個通常的字元,即不需要轉義,為 1 的位代表的就需要進行轉義。

那麼這個點陣圖該如何使用?Nginx 在遍歷 uri 的時候,通過一條簡單的語句來進行判斷。

uri_component[ch >> 5] & (1U << (ch & 0x1f))

複製程式碼

如上所示,ch 表示當前字元,ch >> 5 是對 ch 右移 5 位,這起到一個除以 32 的效果,這一步操作確定了 ch 在 uri_component 的第幾個數字上;而右邊的,(ch & 0x1f) 則是取出了 ch 低 5 位的值,相當於取模 32,這個值即表示 ch 在對應數字的第幾個位(從低到高計算);因此左右兩邊的值進行一次按位與操作後,就把 ch 字元所在的點陣圖狀態取出來了。比如 ch 是 '0'(即數字 48),它存在於點陣圖的第 2 個數字上(48 >> 5 = 1),又在這個數字(0xfc009fff)的第 16 位上,所以它的狀態就是 0xfc009fff & 0x10000 = 0,所以 '0'是一個通用的字元,不用對它轉義。

從上面這個例子中我們還可以看到另外一個位運算的技巧,就是在對一個 2 的冪次的數進行取模或者除操作的時候,也可以通過位運算來實現,這比直接的除法和取模運算有著更好的效能,雖然在合適的優化級別下,編譯器也可能替我們完成這樣的優化。

尋找最低位 1 的位置

接著我們來介紹下一些其他的應用技巧。

找到一個數字二進位制裡最低位的 1 的位置,直覺上你也許會想到按位遍歷,這種演算法的時間複雜是 O(n),效能上不盡如人意。

如果你曾經接觸過樹狀陣列,你可能就會對此有不同的看法,樹狀陣列的一個核心概念是 計算 lowbit,即計算一個數字二進位制裡最低位 1 的冪次。它之所以有著不錯的時間複雜度(O(logN)),便是因為能夠在 O(1) 或者說常數的時間內得到答案。

int lowbit(int x)
{
    return x & ~(x - 1);
}

複製程式碼

這個技巧事實上和上述對齊的方式類似,比如 x 是 00...111000 這樣的數字,則 x - 1 就成了 00...110111,對之取反,則把原本 x 低位連續的 0 所在的位又重新置為了 0(而原本最低位 1 的位置還是為 1),我們會發現除了最低位 1 的那個位置,其他位置上的值和 x 都是相反的,因此兩者進行按位與操作後,結果裡只可能有一個 1,便是原本 x 最低位的 1。

尋找最高位 1 的位置

換一個問題,這次不是尋找最低位,而是尋找最高位的 1。

這個問題有著它實際的意義,比如在設計一個 best-fit 的記憶體池的時候,我們需要找到一個比使用者期望的 size 大的第一個 2 的冪次。

同樣地,你可能還是會先想到遍歷。

事實上 Intel CPU 指令集有這麼一條指令,就是用以計算一個數二進位制裡最高位 1 的位置。

size_t bsf(size_t input)
{
    size_t pos;

    __asm__("bsfq %1, %0" : "=r" (pos) : "rm" (input));

    return pos;
}

複製程式碼

這很好,但是這裡我們還是期望用位運算找到這個 1 的位置。

size_t bsf(size_t input)
{
    input |= input >> 1;
    input |= input >> 2;
    input |= input >> 4;
    input |= input >> 8;
    input |= input >> 16;
    input |= input >> 32;

    return input - (input >> 1);
}

複製程式碼

這便是我們所期望的計算方式了。我們來分析下這個計算的原理。

需要說明的是,如果你需要計算的值是 32 位的,則上面函式的最後一步 input |= input >> 32 是不需要的,具體執行多少次 input |= input >> m, 是由 input 的位長決定的,比如 8 位則進行 3 次,16 位進行 4 次,而 32 位進行 5 次。

為了更簡潔地進行描述,我們用 8 位的數字進行分析,設一個數 A,它的二進位制如下所示。

A[7] A[6] A[5] A[4] A[3] A[2] A[1] A[0]

複製程式碼

上面的計算過程如下。

A[7] A[6] A[5] A[4] A[3] A[2] A[1] A[0]
0    A[7] A[6] A[5] A[4] A[3] A[2] A[1]
---------------------------------------
A[7] A[7]|A[6] A[6]|A[5] A[5]|A[4] A[4]|A[3] A[3]|A[2] A[2]|A[1] A[1]|A[0]
0    0         A[7]      A[7]|A[6] A[6]|A[5] A[5]|A[4] A[4]|A[3] A[3]|A[2]
--------------------------------------------------------------------------
A[7] A[7]|A[6] A[7]|A[6]|A[5] A[7]|A[6]|A[5]|A[4] A[6]|A[5]|A[4]|A[3] A[5]|A[4]|A[3]|A[2] A[4]|A[3]|A[2]|A[1] A[3]|A[2]|A[1]|A[0]
0    0         0              0                   A[7]                A[7]|A[6]           A[7]|A[6]|A[5]      A[7]|A[6]|A[5]|A[4]
---------------------------------------------------------------------------------------------------------------------------------
A[7] A[7]|A[6] A[7]|A[6]|A[5]  A[7]|A[6]|A[5]|A[4] A[7]|A[6]|A[5]|A[4]|A[3] A[7]|A[6]|A[5]|A[4]|A[3]|A[2] A[7]|A[6]|A[5]|A[4]|A[3]|A[2]|A[1] A[7]|A[6]|A[5]|A[4]|A[3]|A[2]|A[1]|A[0]

複製程式碼

我們可以看到,最終 A 的最高位是 A[7],次高位是 A[7]|A[6],第三位是 A[7]|A[6]|A[5],最低位 A[7]|A[6]|A[5]|A[4]|A[3]|A[2]|A[1]|A[0]

假設最高位的 1 是在第 m 位(從右向左算,最低位稱為第 0 位),那麼此時的低 m 位都是 1,其他的高位都是 0。也就是說,A 將會是 2 的某冪再減一,於是最後一步(input - (input >> 1))的用意也就非常明顯了,即將除最高位以外的 1 全部置為 0,最後返回的便是原來的 input 裡最高位 1 的對應冪了。

計算 1 的個數

如何計算一個數字二進位制表示裡有多少個 1 呢?

直覺上可能還是會想到遍歷(遍歷真是個好東西),讓我們計算下複雜度,一個位元組就是 O(8),4 個位元組就是 O(32),而 8 位元組就是 O(64)了。

如果這個計算會頻繁地出現在你的程式裡,當你在用 perf 這樣的效能分析工具觀察你的應用程式時,它或許就會得到你的關注,而你不得不去想辦法進行優化。

事實上《深入理解計算機系統》這本書裡就有一個這個問題,它要求計算一個無符號長整型數字二進位制裡 1 的個數,而且希望你使用最優的演算法,最終這個演算法的複雜度是 O(8)。

long fun_c(unsigned long x)
{
    long val = 0;
    int i;
    for (i = 0; i < 8; i++) {
        val += x & 0x0101010101010101L;
        x >>= 1;
    }

    val += val >> 32;
    val += val >> 16;
    val += val >> 8;

    return val & 0xFF;
}

複製程式碼

這個演算法在我的另外一篇文章裡曾有過分析。

觀察 0x0101010101010101 這個數,每 8 位只有最後一位是 1。那麼 x 與之做按位與,會得到下面的結果:

設 A[i] 表示 x 二進位制表示裡第 i 位的值(0 或 1)。
第一次:
A[0] + (A[8] << 8) + (A[16] << 16) + (A[24] << 24) + (A[32] << 32) + (A[40] << 40) + (A[48] << 48) + (A[56] << 56)
第二次:
A[1] + (A[9] << 8) + (A[17] << 16) + (A[25] << 24) + (A[33] << 32) + (A[41] << 40) + (A[49] << 48) + (A[57] << 56)
......
第八次:
A[7] + (A[15] << 8) + (A[23] << 16) + (A[31] << 24) + (A[39] << 32) + (A[47] << 40) + (A[55] << 48) + (A[63] << 56)
相加後得到的值為:
(A[63] + A[62] + A[61] + A[60] + A[59] + A[58] + A[57] + A[56]) << 56 +
(A[55] + A[54] + A[53] + A[52] + A[51] + A[50] + A[49] + A[48]) << 48 +
(A[47] + A[46] + A[45] + A[44] + A[43] + A[42] + A[41] + A[40]) << 40 +
(A[39] + A[38] + A[37] + A[36] + A[35] + A[34] + A[33] + A[32]) << 32 +
(A[31] + A[30] + A[29] + A[28] + A[27] + A[26] + A[25] + A[24]) << 24 +
(A[23] + A[22] + A[21] + A[20] + A[19] + A[18] + A[17] + A[16]) << 16 +
(A[15] + A[14] + A[13] + A[12] + A[11] + A[10] + A[9]  + A[8])  << 8  +
(A[7]  + A[6]  + A[5]  + A[4]  + A[3]  + A[2]  + A[1]  + A[0])

複製程式碼

之後的三個操作:

val += val >> 32;
val += val >> 16;
val += val >> 8;

複製程式碼

每次將 val 折半然後相加。

第一次折半(val += val >> 32)後,得到的 val 的低 32 位:

(A[31] + A[30] + A[29] + A[28] + A[27] + A[26] + A[25] + A[24] + A[63] + A[62] + A[61] + A[60] + A[59] + A[58] + A[57] + A[56]) << 24 +
(A[23] + A[22] + A[21] + A[20] + A[19] + A[18] + A[17] + A[16] + A[55] + A[54] + A[53] + A[52] + A[51] + A[50] + A[49] + A[48]) << 16 +
(A[15] + A[14] + A[13] + A[12] + A[11] + A[10] + A[9]  + A[8] + A[47] + A[46] + A[45] + A[44] + A[43] + A[42] + A[41] + A[40])  << 8  +
(A[7]  + A[6]  + A[5]  + A[4]  + A[3]  + A[2]  + A[1]  + A[0] + A[39] + A[38] + A[37] + A[36] + A[35] + A[34] + A[33] + A[32])

複製程式碼

第二次折半(val += val >> 16)後,得到的 val 的低 16 位:

15] + A[14] + A[13] + A[12] + A[11] + A[10] + A[9]  + A[8] + A[47] + A[46] + A[45] + A[44] + A[43] + A[42] + A[41] + A[40] + A[31] + A[30] + A[29] + A[28] + A[27] + A[26] + A[25] + A[24] + A[63] + A[62] + A[61] + A[60] + A[59] + A[58] + A[57] + A[56])  << 8  +
(A[7]  + A[6]  + A[5]  + A[4]  + A[3]  + A[2]  + A[1]  + A[0] + A[39] + A[38] + A[37] + A[36] + A[35] + A[34] + A[33] + A[32] + A[23] + A[22] + A[21] + A[20] + A[19] + A[18] + A[17] + A[16] + A[55] + A[54] + A[53] + A[52] + A[51] + A[50] + A[49] + A[48])

複製程式碼

第三次折半(val += val >> 8)後,得到的 val 的低 8 位:

(A[7]  + A[6]  + A[5]  + A[4]  + A[3]  + A[2]  + A[1]  + A[0] + A[39] + A[38] + A[37] + A[36] + A[35] + A[34] + A[33] + A[32] + A[23] + A[22] + A[21] + A[20] + A[19] + A[18] + A[17] + A[16] + A[55] + A[54] + A[53] + A[52] + A[51] + A[50] + A[49] + A[48] + A[15] + A[14] + A[13] + A[12] + A[11] + A[10] + A[9]  + A[8] + A[47] + A[46] + A[45] + A[44] + A[43] + A[42] + A[41] + A[40] + A[31] + A[30] + A[29] + A[28] + A[27] + A[26] + A[25] + A[24] + A[63] + A[62] + A[61] + A[60] + A[59] + A[58] + A[57] + A[56])

複製程式碼

可以看到,經過三次折半,64 個位的值全部累加到低 8 位,最後取出低 8 位的值,就是 x 這個數字二進位制裡 1 的數目了,這個問題在數學上稱為“計算漢明重量”。

位運算以它獨特的優點(簡潔、效能棒)吸引著程式設計師,比如 LuaJIT 內建了 bit 這個模組,允許程式設計師在 Lua 程式裡使用位運算。學會使用位運算對程式設計師來說也是一種進步,值得我們一直去研究。

推薦閱讀:

又拍雲 OpenResty / Nginx 服務優化實踐

又拍雲丁雪峰:自研快取元件 BearCache,CDN 磁碟響應提速38%

相關文章