作者張超:又拍雲系統開發高階工程師,負責又拍雲 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 程式裡使用位運算。學會使用位運算對程式設計師來說也是一種進步,值得我們一直去研究。
推薦閱讀: