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

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

 

衆所周知 Nginx 以性能而出名,這和它優秀的代碼實現有着密切的關係,而本文所要講述的——位運算,也是促成 Nginx 優秀性能的緣由之一。html

位運算在 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%

相關文章
相關標籤/搜索