在C++11之前,類範本和函數範本只能含有固定數量的範本參數。C++11增強了範本功能,允許範本定義中包含0到任意個範本參數,這就是可變參數範本
// T叫範本參數包,args叫函數參數包
template<class ... T>
void func(T ... args)
{ //可變參數範本函數
}
func(); // OK:args不含有任何實參
func(1); // OK:args含有一個實參:int
func(2, 1.0); // OK:args含有兩個實參int和double
T
叫範本參數包,args
叫函數參數包。
省略號…
的作用有兩個:
通過遞歸函數展開參數包,需要提供一個參數包展開的函數和一個遞歸終止函數。
#include
// 遞歸終止函數
void debug()
{
std::cout << "empty\n";
}
//展開函數
template <class T, class ... Args>
void debug(T first, Args ... last)
{
std::cout << "parameter " << first << std::endl;
debug(last...);
}
int main()
{
debug(1, 2, 3, 4);
return 0;
}
通過可變參數範本實現列印函數
#include
#include
void Debug(const char* s)
{
while (*s)
{
if (*s == '%' && *++s != '%')
{
throw std::runtime_error("invalid format string: missing arguments");
}
std::cout << *s++;
}
}
template<typename T, typename... Args>
void Debug(const char* s, T value, Args... args)
{
while (*s)
{
if (*s == '%' && *++s != '%')
{
std::cout << value;
return Debug(++s, args...);
}
std::cout << *s++;
}
throw std::runtime_error("extra arguments provided to Debug");
}
int main(int argc, const char **argv)
{
Debug("a = %d, b = %c, c = %s\n", 250, 'm', "mike");
return 0;
}
#include
template <class T>
void print(T arg)
{
std::cout << arg << std::endl;
}
template <class ... Args>
void expand(Args ... args)
{
int a[] = { (print(args), 0)... };
}
int main()
{
expand(1, 2, 3, 4);
return 0;
}
expand
函數的逗號運算式:(print(args), 0)
, 也是按照這個執行順序,先執行print(args
),再得到逗號運算式的結果0
。
同時,通過初始化列表來初始化一個變長數組,{ (print(args), 0)...}
將會展開成( (print(args1), 0)
, (print(args2), 0)
, (print(args3), 0)
, etc)
, 最終會創建一個元素只都為0
的數組int a[sizeof…(args)]
。
#include
#include
using namespace std;
template<typename... A> class BMW{}; // 變長範本的聲明
template<typename Head, typename... Tail> // 遞歸的偏特化定義
class BMW<Head, Tail...> : public BMW<Tail...>
{//當實例化對象時,則會引起基類的遞歸構造
public:
BMW()
{
printf("type: %s\n", typeid(Head).name());
}
Head head;
};
template<> class BMW<>{}; // 邊界條件
int main()
{
BMW<int, char, float> car;
return 0;
}
#include
template <long... nums> struct Multiply;// 變長範本的聲明
template <long first, long... last>
struct Multiply<first, last...> // 變長範本類
{
static const long val = first * Multiply<last...>::val;
};
template<>
struct Multiply<> // 邊界條件
{
static const long val = 1;
};
int main()
{
std::cout << Multiply<2, 3, 4, 5>::val << std::endl; // 120
return 0;
}
const char*
和string
const char*相比於string的優點:
說了一大堆const char*的優點,那使用string究竟有沒有優點呢?
我總結了string相比於const char*的兩個優點:
一個十分高效的鏈表操作
#include
#include
#include // gcc needs this for intptr_t.
typedef struct xorll {
int value;
struct xorll *np;
} xorll;
// traverse the list given either the head or the tail
void traverse( xorll *start ) // point to head or tail
{
xorll *prev, *cur;
cur = prev = start;
while ( cur )
{
printf( "value = %d\n", cur->value );
if ( cur->np == cur )
// done
break;
if ( cur == prev )
cur = cur->np; // start of list
else {
xorll *save = cur;
cur = (xorll*)((uintptr_t)prev ^ (uintptr_t)cur->np);
prev = save;
}
}
}
// create a new node adding it to the given end and return it
xorll* newnode( xorll *prev, xorll *cur, int value )
{
xorll *next;
next = (xorll*)malloc( sizeof( xorll ));
next->value = value;
next->np = cur; // end node points to previous one
if ( cur == NULL )
; // very first node - we'll just return it
else if ( prev == NULL ) {
// this is the second node (they point at each other)
cur->np = next;
next->np = cur;
}
else {
// do the xor magic
cur->np = (xorll*)((uintptr_t)prev ^ (uintptr_t)next);
}
return next;
}
int main( int argc, char* argv[] )
{
xorll *head, *tail;
int value = 1;
// the first two nodes point at each other. Weird param calls to
// get the list started
head = tail = newnode( NULL, NULL, value++ );
tail = newnode( NULL, tail, value++ );
// now add a couple to the end
tail = newnode( tail->np, tail, value++ );
tail = newnode( tail->np, tail, value++ );
// this is cool - add a new head node
head = newnode( head->np, head, 999 );
printf( "Forwards:\n" );
作為C/C++開發人員,保證程式正常運行是最基本也是最主要的目的。而為了保證程式正常運行,調試則是最基本的手段,熟悉這些調試方式,可以方便我們更快的定位程式問題所在,提高開發效率。
在開發過程,如果程式的運行結果不符合預期,第一時間就是打開GDB進行調試,在對應的地方
設置中斷點
,然後分析原因;當線上服務出了問題,第一時間查看進程在不在,如果不在的話,是否生成了coredump檔
,如果有,則使用gdb
調試coredump
檔,否則通過dmesg
來分析內核日誌來查找原因。
可以根據行號、函數、條件生成中斷點,下麵是相關命令以及對應的作用說明:
命令 | 作用 |
---|---|
break [file]:function | 在檔file的function函數入口設置中斷點 |
break [file]:line | 在檔file的第line行設置中斷點 |
info breakpoints | 查看中斷點列表 |
break [±]offset | 在當前位置偏移量為[±]offset處設置中斷點 |
break *addr | 在地址addr處設置中斷點 |
break … if expr | 設置條件中斷點,僅僅在條件滿足時 |
ignore n count | 接下來對於編號為n的中斷點忽略count次 |
clear | 刪除所有中斷點 |
clear function | 刪除所有位於function內的中斷點 |
delete n | 刪除指定編號的中斷點 |
enable n | 啟用指定編號的中斷點 |
disable n | 禁用指定編號的中斷點 |
save breakpoints file | 保存中斷點資訊到指定檔 |
source file | 導入檔中保存的中斷點資訊 |
break | 在下一個指令處設置中斷點 |
clear [file:]line | 刪除第line行的中斷點 |
watchpoint是一種特殊類型的中斷點,類似於正常中斷點,是要求GDB暫停程式執行的命令。區別在於watchpoint沒有駐留
某一行源代碼中,而是指示GDB每當某個運算式改變了值就暫停執行
的命令。
watchpoint分為硬體實現和軟體實現
兩種。前者需要硬體系統的支持;後者的原理就是每步執行後都檢查變數的值是否改變。GDB在新建數據中斷點時會優先嘗試硬體方式,如果失敗再嘗試軟體實現。
命令 | 作用 |
---|---|
watch variable | 設置變數數據中斷點 |
watch var1 + var2 | 設置運算式數據中斷點 |
rwatch variable | 設置讀中斷點,僅支持硬體實現 |
awatch variable | 設置讀寫中斷點,僅支持硬體實現 |
info watchpoints | 查看數據中斷點列表 |
set can-use-hw-watchpoints 0 | 強制基於軟體方式實現 |
使用數據中斷點時,需要注意:
p
,則watch *p
監控的是p
所指記憶體數據的變化情況,而watch p
監控的是p
指針本身有沒有改變指向最常見的數據中斷點應用場景:「定位堆上的結構體內部成員何時被修改」。由於指針一般為局部變數,為了解決中斷點失效,一般有兩種方法。
命令 | 作用 |
---|---|
print &variable | 查看變數的記憶體地址 |
watch *(type *)address | 通過記憶體地址間接設置中斷點 |
watch -l variable | 指定location參數 |
watch variable thread 1 | 僅編號為1的線程修改變數var值時會中斷 |
從字面意思理解,是捕獲中斷點,其主要監測信號的產生。例如c++的throw,或者加載庫的時候,產生中斷點行為。
命令 | 含義 |
---|---|
catch fork | 程式調用fork時中斷 |
tcatch fork | 設置的中斷點只觸發一次,之後被自動刪除 |
catch syscall ptrace | 為ptrace系統調用設置中斷點 |
在
command
命令後加中斷點編號,可以定義中斷點觸發後想要執行的操作。在一些高級的自動化調試場景中可能會用到。
命令 | 作用 |
---|---|
run arglist | 以arglist為參數列表運行程式 |
set args arglist | 指定啟動命令行參數 |
set args | 指定空的參數列表 |
show args | 列印命令行列表 |
命令 | 作用 |
---|---|
backtrace [n] | 列印棧幀 |
frame [n] | 選擇第n個棧幀,如果不存在,則列印當前棧幀 |
up n | 選擇當前棧幀編號+n的棧幀 |
down n | 選擇當前棧幀編號-n的棧幀 |
info frame [addr] | 描述當前選擇的棧幀 |
info args | 當前棧幀的參數列表 |
info locals | 當前棧幀的局部變數 |
GDB在調試多進程程式(程式含fork
調用)時,默認只追蹤父進程。可以通過命令設置,實現只追蹤父進程或子進程,或者同時調試父進程和子進程。
命令 | 作用 |
---|---|
info inferiors | 查看進程列表 |
attach pid | 綁定進程id |
inferior num | 切換到指定進程上進行調試 |
print $_exitcode | 顯示程式退出時的返回值 |
set follow-fork-mode child | 追蹤子進程 |
set follow-fork-mode parent | 追蹤父進程 |
set detach-on-fork on | fork調用時只追蹤其中一個進程 |
set detach-on-fork off | fork調用時會同時追蹤父子進程 |
在調試多進程程式時候,默認情況下,除了當前調試的進程,其他進程都處於掛起狀態,所以,如果需要在調試當前進程的時候,其他進程也能正常執行,那麼通過設置set schedule-multiple on
即可。
多線程開發在日常開發工作中很常見,所以多線程的調試技巧非常有必要掌握。
默認調試多線程時,一旦程式中斷,所有線程都將暫停。如果此時再繼續執行當前線程,其他線程也會同時執行。
命令 | 作用 |
---|---|
info threads | 查看線程列表 |
print $_thread | 顯示當前正在調試的線程編號 |
set scheduler-locking on | 調試一個線程時,其他線程暫停執行 |
set scheduler-locking off | 調試一個線程時,其他線程同步執行 |
set scheduler-locking step | 僅用step調試線程時其他線程不執行,用其他命令如next調試時仍執行 |
如果只關心當前線程,建議臨時設置 scheduler-locking
為 on
,避免其他線程同時運行,導致命中其他中斷點分散注意力。
通常情況下,在調試的過程中,我們需要查看某個變數的值,以分析其是否符合預期,這個時候就需要列印輸出變數值。
命令 | 作用 |
---|---|
whatis variable | 查看變數的類型 |
ptype variable | 查看變數詳細的類型資訊 |
info variables var | 查看定義該變數的檔,不支持局部變數 |
使用x/s
命令列印ASCII
字串,如果是寬字元字串,需要先看寬字元的長度 print sizeof(str)
。
如果長度為2
,則使用x/hs
列印;如果長度為4
,則使用x/ws
列印。
命令 | 作用 |
---|---|
x/s str | 列印字串 |
set print elements 0 | 列印不限制字串長度/或不限制數組長度 |
call printf(“%s\n”,xxx) | 這時列印出的字串不會含有多餘的轉義符 |
printf “%s\n”,xxx | 同上 |
命令 | 作用 |
---|---|
print *array@10 | 列印從數組開頭連續10個元素的值 |
print array[60]@10 | 列印array數組下標從60開始的10個元素,即第60~69個元素 |
set print array-indexes on | 列印數組元素時,同時列印數組的下標 |
命令 | 作用 |
---|---|
print ptr | 查看該指針指向的類型及指針地址 |
print *(struct xxx *)ptr | 查看指向的結構體的內容 |
使用x
命令來列印記憶體的值,格式為x/nfu addr
,以f
格式列印從addr
開始的n
個長度單元為u
的記憶體值。
n
:輸出單元的個數f
:輸出格式,如x
表示以16
進制輸出,o
表示以8
進制輸出,默認為x
u
:一個單元的長度,b
表示1
個byte
,h
表示2
個byte
(half word
),w
表示4
個byte
,g
表示8
個byte
(giant word
)命令 | 作用 |
---|---|
x/8xb array | 以16進制列印數組array的前8個byte的值 |
x/8xw array | 以16進制列印數組array的前16個word的值 |
命令 | 作用 |
---|---|
info locals | 列印當前函數局部變數的值 |
backtrace full | 列印當前棧幀各個函數的局部變數值,命令可縮寫為bt |
bt full n | 從內到外顯示n個棧幀及其局部變數 |
bt full -n | 從外向內顯示n個棧幀及其局部變數 |
命令 | 作用 |
---|---|
set print pretty on | 每行只顯示結構體的一名成員 |
set print null-stop | 不顯示’\000’這種 |
命令 | 作用 |
---|---|
set step-mode on | 不跳過不含調試資訊的函數,可以顯示和調試彙編代碼 |
finish | 執行完當前函數並列印返回值,然後觸發中斷 |
return 0 | 不再執行後面的指令,直接返回,可以指定返回值 |
call printf(“%s\n”, str) | 調用printf函數,列印字串(可以使用call或者print調用函數) |
print func() | 調用func函數(可以使用call或者print調用函數) |
set var variable=xxx | 設置變數variable的值為xxx |
set {type}address = xxx | 給存儲地址為address,類型為type的變數賦值 |
info frame | 顯示函數堆疊的資訊(堆疊幀地址、指令寄存器的值等) |
tui為terminal user interface
的縮寫,在啟動時候指定-tui
參數,或者調試時使用ctrl+x+a
組合鍵,可進入或退出圖形化介面。
命令 | 含義 |
---|---|
layout src | 顯示源碼窗口 |
layout asm | 顯示彙編窗口 |
layout split | 顯示源碼 + 彙編窗口 |
layout regs | 顯示寄存器 + 源碼或彙編窗口 |
winheight src +5 | 源碼窗口高度增加5行 |
winheight asm -5 | 彙編窗口高度減小5行 |
winheight cmd +5 | 控制臺窗口高度增加5行 |
winheight regs -5 | 寄存器窗口高度減小5行 |
命令 | 含義 |
---|---|
disassemble function | 查看函數的彙編代碼 |
disassemble /mr function | 同時比較函數源代碼和彙編代碼 |
命令 | 含義 |
---|---|
file exec_file *# * | 加載可執行檔的符號表資訊 |
core core_file | 加載core-dump檔 |
gcore core_file | 生成core-dump檔,記錄當前進程的狀態 |
使用gdb調試,一般有以下幾種啟動方式:
在下面的幾節中,將分別對上述幾種調試方式進行講解,從例子的角度出發,使得大家能夠更好的掌握調試技巧。
#include
void print(int xx, int *xxptr) {
printf("In print():\n");
printf(" xx is %d and is stored at %p.\n", xx, &xx);
printf(" ptr points to %p which holds %d.\n", xxptr, *xxptr);
}
int main(void) {
int x = 10;
int *ptr = &x;
printf("In main():\n");
printf(" x is %d and is stored at %p.\n", x, &x);
printf(" ptr points to %p which holds %d.\n", ptr, *ptr);
print(x, ptr);
return 0;
}
gdb ./test_main
GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-114.el7
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /root/test_main...done.
(gdb) r
Starting program: /root/./test_main
In main():
x is 10 and is stored at 0x7fffffffe424.
ptr points to 0x7fffffffe424 which holds 10.
In print():
xx is 10 and is stored at 0x7fffffffe40c.
xxptr points to 0x7fffffffe424 which holds 10.
[Inferior 1 (process 31518) exited normally]
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64
在上述命令中,我們通過gdb test
命令啟動調試,然後通過執行r(run命令的縮寫)執行程式,直至退出,換句話說,上述命令是一個完整的使用gdb運行可執行程式的完整過程(只使用了r命令),接下來,我們將以此為例子,介紹幾種比較常見的命令。
(gdb) b 15
Breakpoint 1 at 0x400601: file test_main.cc, line 15.
(gdb) info b
Num Type Disp Enb Address What
1 breakpoint keep y 0x0000000000400601 in main() at test_main.cc:15
(gdb) r
Starting program: /root/./test_main
In main():
x is 10 and is stored at 0x7fffffffe424.
ptr points to 0x7fffffffe424 which holds 10.
Breakpoint 1, main () at test_main.cc:15
15 print(xx, xxptr);
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64
(gdb)
(gdb) backtrace
#0 main () at test_main.cc:15
(gdb)
backtrace命令是列出當前堆疊中的所有幀。在上面的例子中,棧上只有一幀,編號為0,屬於main函數。
(gdb) step
print (xx=10, xxptr=0x7fffffffe424) at test_main.cc:4
4 printf("In print():\n");
(gdb)
接著,我們執行了step命令,即進入函數內。下麵我們繼續通過backtrace命令來查看棧幀資訊。
(gdb) backtrace
#0 print (xx=10, xxptr=0x7fffffffe424) at test_main.cc:4
#1 0x0000000000400612 in main () at test_main.cc:15
(gdb)
從上面輸出結果,我們能夠看出,有兩個棧幀,第1幀屬於main函數,第0幀屬於print函數。
每個棧幀都列出了該函數的參數列表。從上面我們可以看出,main函數沒有參數,而print函數有參數,並且顯示了其參數的值。
有一點我們可能比較迷惑,在第一次執行backtrace
的時候,main函數所在的棧幀編號為0,而第二次執行的時候,main函數的棧幀為1,而print函數的棧幀為0,這是因為_與棧的向下增長_規律一致,我們只需要記住_編號最小幀號就是最近一次調用的函數_。
棧幀用來存儲函數的變數值等資訊,默認情況下,GDB總是位於當前正在執行函數對應棧幀的上下文中。
在前面的例子中,由於當前正在print()函數中執行,GDB位於第0幀的上下文中。可以通過frame命令來獲取當前正在執行的上下文所在的幀。
(gdb) frame
#0 print (xx=10, xxptr=0x7fffffffe424) at test_main.cc:4
4 printf("In print():\n");
(gdb)
下麵,我們嘗試使用print命令列印下當前棧幀的值,如下:
(gdb) print xx
$1 = 10
(gdb) print xxptr
$2 = (int *) 0x7fffffffe424
(gdb)
如果我們想看其他棧幀的內容呢?比如main函數中x和ptr的資訊呢?假如直接列印這倆值的話,那麼就會得到如下:
(gdb) print x
No symbol "x" in current context.
(gdb) print xxptr
No symbol "ptr" in current context.
(gdb)
在此,我們可以通過_frame num_
來切換棧幀,如下:
(gdb) frame 1
#1 0x0000000000400612 in main () at test_main.cc:15
15 print(x, ptr);
(gdb) print x
$3 = 10
(gdb) print ptr
$4 = (int *) 0x7fffffffe424
(gdb)
為了方便進行演示,我們創建一個簡單的例子,代碼如下:
#include
#include
#include
#include
#include
int fun_int(int n) {
std::this_thread::sleep_for(std::chrono::seconds(10));
std::cout << "in fun_int n = " << n << std::endl;
return 0;
}
int fun_string(const std::string &s) {
std::this_thread::sleep_for(std::chrono::seconds(10));
std::cout << "in fun_string s = " << s << std::endl;
return 0;
}
int main() {
std::vector<int> v;
v.emplace_back(1);
v.emplace_back(2);
v.emplace_back(3);
std::cout << v.size() << std::endl;
std::thread t1(fun_int, 1);
std::thread t2(fun_string, "test");
std::cout << "after thread create" << std::endl;
t1.join();
t2.join();
return 0;
}
上述代碼比較簡單:
下麵是一個完整的調試過程:
(gdb) b 27
Breakpoint 1 at 0x4013d5: file test.cc, line 27.
(gdb) b test.cc:32
Breakpoint 2 at 0x40142d: file test.cc, line 32.
(gdb) info b
Num Type Disp Enb Address What
1 breakpoint keep y 0x00000000004013d5 in main() at test.cc:27
2 breakpoint keep y 0x000000000040142d in main() at test.cc:32
(gdb) r
Starting program: /root/test
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".
Breakpoint 1, main () at test.cc:27
(gdb) c
Continuing.
3
[New Thread 0x7ffff6fd2700 (LWP 44996)]
in fun_int n = 1
[New Thread 0x7ffff67d1700 (LWP 44997)]
Breakpoint 2, main () at test.cc:32
32 std::cout << "after thread create" << std::endl;
(gdb) info threads
Id Target Id Frame
3 Thread 0x7ffff67d1700 (LWP 44997) "test" 0x00007ffff7051fc3 in new_heap () from /lib64/libc.so.6
2 Thread 0x7ffff6fd2700 (LWP 44996) "test" 0x00007ffff7097e2d in nanosleep () from /lib64/libc.so.6
* 1 Thread 0x7ffff7fe7740 (LWP 44987) "test" main () at test.cc:32
(gdb) thread 2
[Switching to thread 2 (Thread 0x7ffff6fd2700 (LWP 44996))]
#0 0x00007ffff7097e2d in nanosleep () from /lib64/libc.so.6
(gdb) bt
#0 0x00007ffff7097e2d in nanosleep () from /lib64/libc.so.6
#1 0x00007ffff7097cc4 in sleep () from /lib64/libc.so.6
#2 0x00007ffff796ceb9 in std::this_thread::__sleep_for(std::chrono::duration >, std::chrono::duration >) () from /lib64/libstdc++.so.6
#3 0x00000000004018cc in std::this_thread::sleep_for > (__rtime=...) at /usr/include/c++/4.8.2/thread:281
#4 0x0000000000401307 in fun_int (n=1) at test.cc:9
#5 0x0000000000404696 in std::_Bind_simple::_M_invoke<0ul>(std::_Index_tuple<0ul>) (this=0x609080)
at /usr/include/c++/4.8.2/functional:1732
#6 0x000000000040443d in std::_Bind_simple::operator()() (this=0x609080) at /usr/include/c++/4.8.2/functional:1720
#7 0x000000000040436e in std::thread::_Impl >::_M_run() (this=0x609068) at /usr/include/c++/4.8.2/thread:115
#8 0x00007ffff796d070 in ?? () from /lib64/libstdc++.so.6
#9 0x00007ffff7bc6dd5 in start_thread () from /lib64/libpthread.so.0
#10 0x00007ffff70d0ead in clone () from /lib64/libc.so.6
(gdb) c
Continuing.
after thread create
in fun_int n = 1
[Thread 0x7ffff6fd2700 (LWP 45234) exited]
in fun_string s = test
[Thread 0x7ffff67d1700 (LWP 45235) exited]
[Inferior 1 (process 45230) exited normally]
(gdb) q
在上述調試過程中:
gdb
同上面一樣,我們仍然以一個例子進行模擬多進程調試,代碼如下:
#include
#include
int main()
{
pid_t pid = fork();
if (pid == -1) {
perror("fork error\n");
return -1;
}
if(pid == 0) { // 子進程
int num = 1;
while(num == 1){
sleep(10);
}
printf("this is child,pid = %d\n", getpid());
} else { // 父進程
printf("this is parent,pid = %d\n", getpid());
wait(NULL); // 等待子進程退出
}
return 0;
}
在上面代碼中,包含兩個進程,一個是父進程(也就是main進程),另外一個是由fork()函數創建的子進程。
在默認情況下,在多進程程式中,GDB只調試main進程,也就是說無論程式調用了多少次fork()函數創建了多少個子進程,GDB在默認情況下,只調試父進程。為了支持多進程調試,從GDB版本7.0開始支持單獨調試(調試父進程或者子進程)和同時調試多個進程。
那麼,我們該如何調試子進程呢?我們可以使用如下幾種方式進行子進程調試。
首先,無論是父進程還是子進程,都可以通過attach命令啟動gdb進行調試。我們都知道,對於每個正在運行的程式,操作系統都會為其分配一個唯一ID號,也就是進程ID。如果我們知道了進程ID,就可以使用attach命令對其進行調試了。
在上面代碼中,fork()函數創建的子進程內部,首先會進入while迴圈sleep,然後在while迴圈之後調用printf函數。這樣做的目的有如下:
❝
可能會有疑惑,上面代碼以及進入while迴圈,無論如何是不會執行到下麵printf函數。其實,這就是gdb的厲害之處,可以通過gdb命令修改num的值,以便其跳出while迴圈
❞
使用如下命令編譯生成可執行檔test_process
g++ -g test_process.cc -o test_process
現在,我們開始嘗試啟動調試。
gdb -q ./test_process
Reading symbols from /root/test_process...done.
(gdb)
這裏需要說明下,之所以加-q選項,是想去掉其他不必要的輸出,q為quite的縮寫。
(gdb) r
Starting program: /root/./test_process
Detaching after fork from child process 37482.
this is parent,pid = 37478
[Inferior 1 (process 37478) exited normally]
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64 libgcc-4.8.5-36.el7.x86_64 libstdc++-4.8.5-36.el7.x86_64
(gdb) attach 37482
//符號類輸出,此處略去
(gdb) n
Single stepping until exit from function __nanosleep_nocancel,
which has no line number information.
0x00007ffff72b3cc4 in sleep () from /lib64/libc.so.6
(gdb)
Single stepping until exit from function sleep,
which has no line number information.
main () at test_process.cc:8
8 while(num==10){
(gdb)
在上述命令中,我們執行了n(next的縮寫),使其重新對while迴圈的判斷體進行判斷。
(gdb) set num = 1
(gdb) n
12 printf("this is child,pid = %d\n",getpid());
(gdb) c
Continuing.
this is child,pid = 37482
[Inferior 1 (process 37482) exited normally]
(gdb)
為了退出while迴圈,我們使用set命令設置了num的值為1,這樣條件就會失效退出while迴圈,進而執行下麵的printf()函數;在最後我們執行了c(continue的縮寫)命令,支持程式退出。
❝
如果程式正在正常運行,出現了死鎖等現象,則可以通過ps獲取進程ID,然後根據gdb attach pid進行綁定,進而查看堆疊資訊
❞
默認情況下,GDB調試多進程程式時候,只調試父進程。GDB提供了兩個命令,可以通過follow-fork-mode和detach-on-fork來指定調試父進程還是子進程。
follow-fork-mode
該命令的使用方式為:
(gdb) set follow-fork-mode mode
其中,mode有以下兩個選項:
(gdb) show follow-fork-mode
Debugger response to a program call of fork or vfork is "parent".
(gdb) set follow-fork-mode child
(gdb) r
Starting program: /root/./test_process
[New process 37830]
this is parent,pid = 37826
^C
Program received signal SIGINT, Interrupt.
[Switching to process 37830]
0x00007ffff72b3e10 in __nanosleep_nocancel () from /lib64/libc.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64 libgcc-4.8.5-36.el7.x86_64 libstdc++-4.8.5-36.el7.x86_64
(gdb) n
Single stepping until exit from function __nanosleep_nocancel,
which has no line number information.
0x00007ffff72b3cc4 in sleep () from /lib64/libc.so.6
(gdb) n
Single stepping until exit from function sleep,
which has no line number information.
main () at test_process.cc:8
8 while(num==10){
(gdb) show follow-fork-mode
Debugger response to a program call of fork or vfork is "child".
(gdb)
在上述命令中,我們做了如下操作:
如果一開始指定要調試子進程還是父進程,那麼使用follow-fork-mode命令完全可以滿足需求;但是如果想在調試過程中,想根據實際情況在父進程和子進程之間來回切換調試呢?
GDB提供了另外一個命令:
(gdb) set detach-on-fork mode
其中mode有如下兩個值:
on:默認值,即表明只調試一個進程,可以是子進程,也可以是父進程
off:程式中的每個進程都會被記錄,進而我們可以對所有的進程進行調試
如果選擇關閉detach-on-fork
模式(mode為off),那麼GDB將保留對所有被fork出來的進程控制,即可用調試所有被fork出來的進程。可用 使用info forks
命令列出所有的可被GDB調試的fork進程,並可用使用fork命令從一個fork進程切換到另一個fork進程。
info forks
獲取當我們開發或者使用一個程式時候,最怕的莫過於程式莫名其妙崩潰。為了分析崩潰產生的原因,操作系統的記憶體內容(包括程式崩潰時候的堆疊等資訊)會在程式崩潰的時候dump出來(默認情況下,這個檔案名為core.pid,其中pid為進程id),這個dump操作叫做coredump(核心轉儲),然後我們可以用調試器調試此檔,以還原程式崩潰時候的場景。
在我們分析如果用gdb調試coredump檔之前,先需要生成一個coredump,為了簡單起見,我們就用如下例子來生成:
#include
void print(int *v, int size) {
for (int i = 0; i < size; ++i) {
printf("elem[%d] = %d\n", i, v[i]);
}
}
int main() {
int v[] = {0, 1, 2, 3, 4};
print(v, 1000);
return 0;
}
編譯並運行該程式:
g++ -g test_core.cc -o test_core
./test_core
輸出如下:
elem[775] = 1702113070
elem[776] = 1667200115
elem[777] = 6648431
elem[778] = 0
elem[779] = 0
段錯誤(吐核)
如我們預期,程式產生了異常,但是卻沒有生成coredump檔,這是因為在系統默認情況下,coredump生成是關閉的,所以需要設置對應的選項以打開coredump生成。
針對多線程程式產生的coredump,有時候其堆疊資訊並不能完整的去分析原因,這就使得我們得有其他方式。
18年有一次線上故障,在測試環境一切正常,但是線上上的時候,就會coredump,根據gdb調試coredump,只能定位到了libcurl裏面,但卻定位不出原因,用了大概兩天的時間,發現只有在超時的時候,才會coredump,而測試環境因為配置比較差超時設置的是20ms,而線上是5ms,知道coredump原因後,採用逐步定位縮小範圍法
,逐步縮小代碼範圍,最終定位到是libcurl一個bug導致。所以,很多時候,定位線上問題需要結合實際情況,採取合適的方法來定位問題。
配置coredump生成,有臨時配置(退出終端後,配置失效)和永久配置兩種。
通過ulimit -a
可以判斷當前有沒有配置coredump生成:
ulimit -a
core file size (blocks, -c) 0
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
從上面輸出可以看出core file size後面的數為0,即不生成coredump檔,我們可以通過如下命令進行設置
ulimit -c size
其中size為允許生成的coredump大小,這個一般儘量設置大點,以防止生成的coredump資訊不全,筆者一般設置為不限。
ulimit -c unlimited
需要說明的是,臨時配置的coredump選項,其默認生成路徑為執行該命令時候的路徑,可以通過修改配置來進行路徑修改。
上面的設置只是使能了core dump功能,缺省情況下,內核在coredump時所產生的core檔放在與該程式相同的目錄中,並且檔案名固定為core。很顯然,如果有多個程式產生core檔,或者同一個程式多次崩潰,就會重複覆蓋同一個core檔。
過修改kernel的參數,可以指定內核所生成的coredump檔的檔案名。使用下麵命令,可以實現coredump永久配置、存放路徑以及生成coredump名稱等。
mkdir -p /www/coredump/
chmod 777 /www/coredump/
/etc/profile
ulimit -c unlimited
/etc/security/limits.conf
* soft core unlimited
echo "/www/coredump/core-%e-%p-%h-%t" > /proc/sys/kernel/core_pattern
現在,我們重新執行如下命令,按照預期產生coredump檔:
./test_coredump
elem[955] = 1702113070
elem[956] = 1667200115
elem[957] = 6648431
elem[958] = 0
elem[959] = 0
段錯誤(吐核)
然後使用下麵的命令進行coredump調試:
gdb ./test_core -c /www/coredump/core_test_core_1640765384_38924 -q
輸出如下:
#0 0x0000000000400569 in print (v=0x7fff3293c100, size=1000) at test_core.cc:5
5 printf("elem[%d] = %d\n", i, v[i]);
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7.x86_64 libgcc-4.8.5-36.el7.x86_64 libstdc++-4.8.5-36.el7.x86_64
(gdb)
可以看出,程式core在了第5行,此時,我們可以通過where
命令來查看堆疊回溯資訊。
在gdb中輸入where命令,可以獲取堆疊調用資訊。當進行coredump調試時候,這個是最基本且最有用處的命令。where命令輸出的結果包含程式中 的函數名稱和相關參數值。
通過where命令,我們能夠發現程式core在了第5行,那麼根據分析源碼基本就能定位原因。
需要注意的是,在多線程運行的時候,core不一定在當前線程,這就需要我們對代碼有一定的瞭解,能夠保證哪塊代碼是安全的,然後通過thread num切換線程,然後再通過bt或者where命令查看堆疊資訊,進而定位coredump原因。
在前面幾節,我們講了gdb的命令,以及這些命令在調試時候的作用,並以例子進行了演示。作為C/C++ coder,要知其然,更要知其所以然。所以,借助本節,我們大概講下GDB調試的原理。
gdb 通過系統調用 ptrace
來接管一個進程的執行。ptrace 系統調用提供了一種方法使得父進程可以觀察和控制其他進程的執行,檢查和改變其核心映像以及寄存器。它主要用來實現中斷點調試和系統調用跟蹤。
ptrace系統調用定義如下:
#include
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data)
SIGKILL
)都會暫停子進程,接著阻塞於 wait()
等待的父進程被喚醒。子進程內部對 exec()
的調用將發出 SIGTRAP
信號,這可以讓父進程在子進程新程式開始運行之前就完全控制它getppid()
的到的仍將是其原始父進程的pid可以通過gdb attach pid來調試一個運行的進程,gdb將對指定進程執行ptrace(PTRACE_ATTACH, pid, 0, 0)操作。
需要注意的是,當我們attach一個進程id時候,可能會報如下錯誤:
Attaching to process 28849
ptrace: Operation not permitted.
這是因為沒有許可權進行操作,可以根據啟動該進程用戶下或者root下進行操作。
鏈表
中。命中判定將被調試程式的當前停止位置與鏈表中的中斷點位置進行比較,以查看中斷點產生的信號。這個ptrace函數本身就支持,可以通過ptrace(PTRACE_SINGLESTEP, pid,…)調用來實現單步。
printf("attaching to PID %d\n", pid);
if (ptrace(PTRACE_ATTACH, pid, 0, 0) != 0)
{
perror("attach failed");
}
int waitStat = 0;
int waitRes = waitpid(pid, &waitStat, WUNTRACED);
if (waitRes != pid || !WIFSTOPPED(waitStat))
{
printf("unexpected waitpid result!\n");
exit(1);
}
int64_t numSteps = 0;
while (true) {
auto res = ptrace(PTRACE_SINGLESTEP, pid, 0, 0);
}
上述代碼,首先接收一個pid,然後對其進行attach,最後調用ptrace進行單步調試。
此命令可顯示每個進程的棧跟蹤。pstack 命令必須由相應進程的屬主或 root 運行。可以使用 pstack 來確定進程掛起的位置。此命令允許使用的唯一選項是要檢查的進程的 PID。
這個命令在排查進程問題時非常有用,比如我們發現一個服務一直處於work狀態(如假死狀態,好似死迴圈),使用這個命令就能輕鬆定位問題所在;可以在一段時間內,多執行幾次pstack,若發現代碼棧總是停在同一個位置,那個位置就需要重點關注,很可能就是出問題的地方;
以前面的多線程代碼為例,其進程ID是4507(在筆者本地),那麼通過
pstack 4507輸出結果如下:
Thread 3 (Thread 0x7f07aaa69700 (LWP 45708)):
#0 0x00007f07aab2ee2d in nanosleep () from /lib64/libc.so.6
#1 0x00007f07aab2ecc4 in sleep () from /lib64/libc.so.6
#2 0x00007f07ab403eb9 in std::this_thread::__sleep_for(std::chrono::duration >, std::chrono::duration >) () from /lib64/libstdc++.so.6
#3 0x00000000004018cc in void std::this_thread::sleep_for >(std::chrono::duration > const&) ()
#4 0x00000000004012de in fun_int(int) ()
#5 0x0000000000404696 in int std::_Bind_simple::_M_invoke<0ul>(std::_Index_tuple<0ul>) ()
#6 0x000000000040443d in std::_Bind_simple::operator()() ()
#7 0x000000000040436e in std::thread::_Impl >::_M_run() ()
#8 0x00007f07ab404070 in ?? () from /lib64/libstdc++.so.6
#9 0x00007f07ab65ddd5 in start_thread () from /lib64/libpthread.so.0
#10 0x00007f07aab67ead in clone () from /lib64/libc.so.6
Thread 2 (Thread 0x7f07aa268700 (LWP 45709)):
#0 0x00007f07aab2ee2d in nanosleep () from /lib64/libc.so.6
#1 0x00007f07aab2ecc4 in sleep () from /lib64/libc.so.6
#2 0x00007f07ab403eb9 in std::this_thread::__sleep_for(std::chrono::duration >, std::chrono::duration >) () from /lib64/libstdc++.so.6
#3 0x00000000004018cc in void std::this_thread::sleep_for >(std::chrono::duration > const&) ()
#4 0x0000000000401340 in fun_string(std::string const&) ()
#5 0x000000000040459f in int std::_Bind_simple::_M_invoke<0ul>(std::_Index_tuple<0ul>) ()
#6 0x000000000040441f in std::_Bind_simple::operator()() ()
#7 0x0000000000404350 in std::thread::_Impl >::_M_run() ()
#8 0x00007f07ab404070 in ?? () from /lib64/libstdc++.so.6
#9 0x00007f07ab65ddd5 in start_thread () from /lib64/libpthread.so.0
#10 0x00007f07aab67ead in clone () from /lib64/libc.so.6
Thread 1 (Thread 0x7f07aba80740 (LWP 45707)):
#0 0x00007f07ab65ef47 in pthread_join () from /lib64/libpthread.so.0
#1 0x00007f07ab403e37 in std::thread::join() () from /lib64/libstdc++.so.6
#2 0x0000000000401455 in main ()
在上述輸出結果中,將進程內部的詳細資訊都輸出在終端,以方便分析問題。
在我們編譯過程中通常會提示編譯失敗,通過輸出錯誤資訊發現是找不到函數定義,再或者編譯成功了,但是運行時候失敗(往往是因為依賴了非正常版本的lib庫導致),這個時候,我們就可以通過ldd來分析該可執行檔依賴了哪些庫以及這些庫所在的路徑。
用來查看程式運行所需的共用庫,常用來解決程式因缺少某個庫檔而不能運行的一些問題。
仍然查看可執行程式test_thread的依賴庫,輸出如下:
ldd -r ./test_thread
linux-vdso.so.1 => (0x00007ffde43bc000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f8c5e310000)
libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007f8c5e009000)
libm.so.6 => /lib64/libm.so.6 (0x00007f8c5dd07000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f8c5daf1000)
libc.so.6 => /lib64/libc.so.6 (0x00007f8c5d724000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8c5e52c000)
在上述輸出中:
第一列:程式需要依賴什麼庫
第二列:系統提供的與程式需要的庫所對應的庫
第三列:庫加載的開始地址
在有時候,我們通過ldd查看依賴庫的時候,會提示找不到庫,如下:
ldd -r test_process
linux-vdso.so.1 => (0x00007ffc71b80000)
libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007fe4badd5000)
libm.so.6 => /lib64/libm.so.6 (0x00007fe4baad3000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007fe4ba8bd000)
libc.so.6 => /lib64/libc.so.6 (0x00007fe4ba4f0000)
/lib64/ld-linux-x86-64.so.2 (0x00007fe4bb0dc000)
liba.so => not found
比如上面最後一句提示,liba.so找不到,這個時候,需要我們知道liba.so的路徑,比如在/path/to/liba.so,那麼可以有下麵兩種方式:
LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/path/to/
這樣在通過ldd查看,就能找到對應的lib庫,但是這個缺點是臨時的,即退出終端後,再執行ldd,仍然會提示找不到該庫,所以就有了另外一種方式,即通過修改/etc/ld.so.conf,在該檔的後面加上需要的路徑,即
include ld.so.conf.d/*.conf
/path/to/
然後通過如下命令,即可永久生效
/sbin/ldconfig
因為c++支持重載,也就引出了編譯器的name mangling
機制,對函數進行重命名。
我們通過strings命令查看test_thread中的函數資訊(僅輸出fun等相關)
strings test_thread | grep fun_
in fun_int n =
in fun_string s =
_GLOBAL__sub_I__Z7fun_inti
_Z10fun_stringRKSs
可以看到_Z10fun_stringRKSs這個函數,如果想知道這個函數定義的話,可以使用c++filt命令,如下:
c++filt _Z10fun_stringRKSs
fun_string(std::basic_string, std::allocator > const&)
通過上述輸出,我們可以將編譯器生成的函數名還原到我們代碼中的函數名即fun_string。
Range
庫#include
#include
#ifdef __has_include (<format>)
#include
#endif
int main (int argc, const char **argv) {
for (auto i: std::ranges::views::iota(0, 11) |
std::ranges:;views::filter([](int i) { return not(i & 1); }) |
std::ranges::views::transform([](int i) { return i * i; }
)) {
#ifdef __cpp_lib_format
std::cout << std::format("# - i, {}", i);
#else
std::cout << i << " ";
#endif
std::cout << std::endl;
return 0;
}
template <class T, T val> struct integral_constant {
typedef integral_constant<T, val> type;
typedef T value_type ;
static const T value = val;
}
由于内存管理不外乎三个层面,用户管理层,C 运行时库层,操作系统层,在操作系统层发现进程的内存暴增,同时又确认了用户管理层没有内存泄露,因此怀疑是 C 运行时库的问题,也就是Glibc 的内存管理方式导致了进程的内存暴增。
问题缩小到glibc的内存管理方面,把下面几个问题弄清楚,才能解决SeedService进程消失的问题:
带着上面这些问题,大概用了将近一个月的时间分析了glibc运行时库的内存管理代码,今天将当时的笔记整理了出来,希望能够对大家有用。
Linux 系统在装载 elf 格式的程序文件时,会调用 loader 把可执行文件中的各个段依次载入到从某一地址开始的空间中。
用户程序可以直接使用系统调用来管理 heap 和mmap 映射区域,但更多的时候程序都是使用 C 语言提供的 malloc()和 free()函数来动态的分配和释放内存。stack区域是唯一不需要映射,用户却可以访问的内存区域,这也是利用堆栈溢出进行攻击的基础。
计算机系统分为32位和64位,而32位和64位的进程布局是不一样的,即使是同为32位系统,其布局依赖于内核版本,也是不同的。
在介绍详细的内存布局之前,我们先描述几个概念:
在Linux内核2.6.7以前,进程的布局如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SPKJVZsP-1654243096863)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603152726040.png)]
在该内存布局示例图中,mmap 区域与栈区域相对增长,这意味着堆只有 1GB 的虚拟地址空间可以使用,继续增长就会进入 mmap 映射区域, 这显然不是我们想要的。这是由于 32 模式地址空间限制造成的,所以内核引入了另一种虚拟地址空间的布局形式。但对于 64 位系统,因为提供了巨大的虚拟地址空间,所以64位系统就采用的这种布局方式。
默认布局
如上所示,由于经典内存布局具有空间局限性,因此在内核2.6.7以后,就引入了下图这种默认进程布局方式。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aNu1lMA2-1654243096863)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603152810931.png)]
从上图可以看到,栈至顶向下扩展,并且栈是有界的。堆至底向上扩展,mmap 映射区域至顶向下扩展,mmap 映射区域和堆相对扩展,直至耗尽虚拟地址空间中的剩余区域,这种结构便于C运行时库使用 mmap 映射区域和堆进行内存分配。
如之前所述,64位进程内存布局方式由于其地址空间足够,且实现方便,所以采用的与32位经典内存布局的方式一致,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9eGgCI6W-1654243096864)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603152902180.png)]
在之前介绍内存布局的时候,有提到过,heap 和mmap 映射区域是可以提供给用户程序使用的虚拟内存空间。那么我们该如何获得该区域的内存呢?
操作系统提供了相关的系统调用来完成内存分配工作。
sbrk(),brk() 或者 mmap() 都可以用来向我们的进程添加额外的虚拟内存。而glibc就是使用这些函数来向操作系统申请虚拟内存,以完成内存分配的。
这里要提到一个很重要的概念,内存的延迟分配,只有在真正访问一个地址的时候才建立这个地址的物理映射,这是 Linux 内存管理的基本思想之一。Linux 内核在用户申请内存的时候,只是给它分配了一个线性区(也就是虚拟内存),并没有分配实际物理内存;只有当用户使用这块内存的时候,内核才会分配具体的物理页面给用户,这时候才占用宝贵的物理内存。内核释放物理页面是通过释放线性区,找到其所对应的物理页面,将其全部释放的过程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UARPrRrk-1654243096864)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603152959380.png)]
进程的内存结构,在内核中,是用mm_struct来表示的,其定义如下:
struct mm_struct {
...
unsigned long (*get_unmapped_area) (struct file *filp,
unsigned long addr, unsigned long len,
unsigned long pgoff, unsigned long flags);
...
unsigned long mmap_base; /* base of mmap area */
unsigned long task_size; /* size of task vm space */
...
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
...
}
在上述mm_struct结构中:
C语言的动态内存分配基本函数是 malloc(),在 Linux 上的实现是通过内核的 brk 系统调用。brk()是一个非常简单的系统调用, 只是简单地改变mm_struct结构的成员变量 brk 的值。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sFgomP59-1654243096865)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603153044085.png)]
在前面有提过,有两个函数可以直接从堆(Heap)申请内存,brk()函数为系统调用,sbrk()为c库函数。
系统调用通常提过一种最小的功能,而库函数相比系统调用,则提供了更复杂的功能。在glibc中,malloc就是调用sbrk()函数将数据段的下界移动以来代表内存的分配和释放。sbrk()函数在内核的管理下,将虚拟地址空间映射到内存,供malloc()函数使用。
下面为brk()函数和sbrk()函数的声明。
#include
int brk(void *addr);
void *sbrk(intptr_t increment);
需要说明的是,当sbrk()的参数increment为0时候,sbrk()返回的是进程当前brk值。increment 为正数时扩展 brk 值,当 increment 为负值时收缩 brk 值。
在LINUX中我们可以使用mmap用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UF6FvMsA-1654243096865)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603153147198.png)]
mmap()函数将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。
munmap 执行相反的操作,删除特定地址区域的对象映射。
函数的定义如下:
#include
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
映射关系分为以下两种:
文件映射: 磁盘文件映射进程的虚拟地址空间,使用文件内容初始化物理内存。
匿名映射: 初始化全为0的内存空间
映射关系是否共享,可以分为:
私有映射(MAP_PRIVATE)
共享映射(MAP_SHARED)
因此,整个映射关系总结起来分为以下四种:
私有文件映射多个进程使用同样的物理内存页进行初始化,但是各个进程对内存文件的修改不会共享,也不会反应到物理文件中
私有匿名映射
共享文件映射
共享匿名映射
这里值得注意的是,mmap只是在虚拟内存分配了地址空间,只有在第一次访问虚拟内存的时候才分配物理内存。
在mmap之后,并没有在将文件内容加载到物理页上,只有在虚拟内存中分配了地址空间。当进程在访问这段地址时,通过查找页表,发现虚拟内存对应的页没有在物理内存中缓存,则产生"缺页",由内核的缺页异常处理程序处理,将文件对应内容,以页为单位(4096)加载到物理内存,注意是只加载缺页,但也会受操作系统一些调度策略影响,加载的比所需的多。
下面的内容将是本文的重点中的重点,对于了解内存布局以及后面glibc的内存分配原理至关重要,必要的话,可以多阅读几次。
在前面,我们有提到在堆上分配内存有两个函数,分别为brk()系统调用和sbrk()c运行时库函数,在内存映射区分配内存有mmap函数。
现在我们假设一种情况,如果每次分配,都直接使用brk(),sbrk()或者mmap()函数进行多次内存分配。如果程序频繁的进行内存分配和释放,都是和操作系统直接打交道,那么性能可想而知。
这就引入了一个概念,「内存管理」。
本节大纲如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YNRFezKr-1654243096866)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603153435321.png)]
内存管理是指软件运行时对计算机内存资源的分配和使用的技术。其最主要的目的是如何高效,快速的分配,并且在适当的时候释放和回收内存资源。
一个好的内存管理器,需要具有以下特点:1、跨平台、可移植通常情况下,内存管理器向操作系统申请内存,然后进行再次分配。所以,针对不同的操作系统,内存管理器就需要支持操作系统兼容,让使用者在跨平台的操作上没有区别。
2、浪费空间小内存管理器管理内存,如果内存浪费比较大,那么显然这就不是一个优秀的内存管理器。通常说的内存碎片,就是浪费空间的罪魁祸首,若内存管理器中有大量的内存碎片,它们是一些不连续的小块内存,它们总量可能很大,但无法使用,这显然也不是一个优秀的内存管理器。
3、速度快之所以使用内存管理器,根本原因就是为了分配/释放快。
4、调试功能作为一个 C/C++程序员,内存错误可以说是我们的噩梦,上一次的内存错误一定还让你记忆犹新。内存管理器提供的调试功能,强大易用,特别对于嵌入式环境来说,内存错误检测工具缺乏,内存管理器提供的调试功能就更是不可或缺了。
内存管理的管理方式,分为 手动管理 和 自动管理 两种。
所谓的手动管理,就是使用者在申请内存的时候使用malloc等函数进行申请,在需要释放的时候,需要调用free函数进行释放。一旦用过的内存没有释放,就会造成内存泄漏,占用更多的系统内存;如果在使用结束前释放,会导致危险的悬挂指针,其他对象指向的内存已经被系统回收或者重新使用。
自动管理内存由编程语言的内存管理系统自动管理,在大多数情况下不需要使用者的参与,能够自动释放不再使用的内存。
手动管理内存是一种比较传统的内存管理方式,C/C++ 这类系统级的编程语言不包含狭义上的自动内存管理机制,使用者需要主动申请或者释放内存。经验丰富的工程师能够精准的确定内存的分配和释放时机,人肉的内存管理策略只要做到足够精准,使用手动管理内存的方式可以提高程序的运行性能,也不会造成内存安全问题。
但是,毕竟这种经验丰富且能精准确定内存和分配释放实际的使用者还是比较少的,只要是人工处理,总会带来一些错误,内存泄漏和悬挂指针基本是 C/C++ 这类语言中最常出现的错误,手动的内存管理也会占用工程师的大量精力,很多时候都需要思考对象应该分配到栈上还是堆上以及堆上的内存应该何时释放,维护成本相对来说还是比较高的,这也是必然要做的权衡。
自动管理内存基本是现代编程语言的标配,因为内存管理模块的功能非常确定,所以我们可以在编程语言的编译期或者运行时中引入自动的内存管理方式,最常见的自动内存管理机制就是垃圾回收,不过除了垃圾回收之外,一些编程语言也会使用自动引用计数辅助内存的管理。
自动的内存管理机制可以帮助工程师节省大量的与内存打交道的时间,让使用者将全部的精力都放在核心的业务逻辑上,提高开发的效率;在一般情况下,这种自动的内存管理机制都可以很好地解决内存泄漏和悬挂指针的问题,但是这也会带来额外开销并影响语言的运行时性能。
1 ptmalloc:ptmalloc是隶属于glibc(GNU Libc)的一款内存分配器,现在在Linux环境上,我们使用的运行库的内存分配(malloc/new)和释放(free/delete)就是由其提供。
2 BSD Malloc:BSD Malloc 是随 4.2 BSD 发行的实现,包含在 FreeBSD 之中,这个分配程序可以从预先确实大小的对象构成的池中分配对象。它有一些用于对象大小的size 类,这些对象的大小为 2 的若干次幂减去某一常数。所以,如果您请求给定大小的一个对象,它就简单地分配一个与之匹配的 size 类。这样就提供了一个快速的实现,但是可能会浪费内存。
3 Hoard:编写 Hoard 的目标是使内存分配在多线程环境中进行得非常快。因此,它的构造以锁的使用为中心,从而使所有进程不必等待分配内存。它可以显著地加快那些进行很多分配和回收的多线程进程的速度。
4 TCMalloc:Google 开发的内存分配器,在不少项目中都有使用,例如在 Golang 中就使用了类似的算法进行内存分配。它具有现代化内存分配器的基本特征:对抗内存碎片、在多核处理器能够 scale。据称,它的内存分配速度是 glibc2.3 中实现的 malloc的数倍。
因为本次事故就是用的运行库函数new/delete进行的内存分配和释放,所以本文将着重分析glibc下的内存分配库ptmalloc。
本节大纲如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dRPEhj0s-1654243096866)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603153716681.png)]
在c/c++中,我们分配内存是在堆上进行分配,那么这个堆,在glibc中是怎么表示的呢?
我们先看下堆的结构声明:
typedef struct _heap_info
{
mstate ar_ptr; /* Arena for this heap. */
struct _heap_info *prev; /* Previous heap. */
size_t size; /* Current size in bytes. */
size_t mprotect_size; /* Size in bytes that has been mprotected
PROT_READ|PROT_WRITE. */
/* Make sure the following data is properly aligned, particularly
that sizeof (heap_info) + 2 * SIZE_SZ is a multiple of
MALLOC_ALIGNMENT. */
char pad[-6 * SIZE_SZ & MALLOC_ALIGN_MASK];
在堆的上述定义中,ar_ptr是指向分配区的指针,堆之间是以链表方式进行连接,后面我会详细讲述进程布局下,堆的结构表示图。
在开始这部分之前,我们先了解下一些概念。
ptmalloc对进程内存是通过一个个Arena来进行管理的。
在ptmalloc中,分配区分为主分配区(arena)和非主分配区(narena),分配区用struct malloc_state来表示。主分配区和非主分配区的区别是 主分配区可以使用sbrk和mmap向os申请内存,而非分配区只能通过mmap向os申请内存。
当一个线程调用malloc申请内存时,该线程先查看线程私有变量中是否已经存在一个分配区。如果存在,则对该分配区加锁,加锁成功的话就用该分配区进行内存分配;失败的话则搜索环形链表找一个未加锁的分配区。如果所有分配区都已经加锁,那么malloc会开辟一个新的分配区加入环形链表并加锁,用它来分配内存。释放操作同样需要获得锁才能进行。
需要注意的是,非主分配区是通过mmap向os申请内存,一次申请64MB,一旦申请了,该分配区就不会被释放,为了避免资源浪费,ptmalloc对分配区是有个数限制的。
对于32位系统,分配区最大个数 = 2 * CPU核数 + 1
对于64位系统,分配区最大个数 = 8 * CPU核数 + 1
堆管理结构:
struct malloc_state {
mutex_t mutex; /* Serialize access. */
int flags; /* Flags (formerly in max_fast). */
#if THREAD_STATS
/* Statistics for locking. Only used if THREAD_STATS is defined. */
long stat_lock_direct, stat_lock_loop, stat_lock_wait;
#endif
mfastbinptr fastbins[NFASTBINS]; /* Fastbins */
mchunkptr top;
mchunkptr last_remainder;
mchunkptr bins[NBINS * 2];
unsigned int binmap[BINMAPSIZE]; /* Bitmap of bins */
struct malloc_state *next; /* Linked list */
INTERNAL_SIZE_T system_mem;
INTERNAL_SIZE_T max_system_mem;
};
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T3rwZ9is-1654243096866)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603153907598.png)]
每一个进程只有一个主分配区和若干个非主分配区。主分配区由main线程或者第一个线程来创建持有。主分配区和非主分配区用环形链表连接起来。分配区内有一个变量mutex以支持多线程访问。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t45vdofw-1654243096867)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603153927292.png)]
在前面有提到,在每个分配区中都有一个变量mutex来支持多线程访问。每个线程一定对应一个分配区,但是一个分配区可以给多个线程使用,同时一个分配区可以由一个或者多个的堆组成,同一个分配区下的堆以链表方式进行连接,它们之间的关系如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-47HoCZ40-1654243096867)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603153944965.png)]
一个进程的动态内存,由分配区管理,一个进程内有多个分配区,一个分配区有多个堆,这就组成了复杂的进程内存管理结构。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2yH7rYxE-1654243096868)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603154003486-16542420056841.png)]
需要注意几个点:
ptmalloc通过malloc_chunk来管理内存,给User data前存储了一些信息,使用边界标记区分各个chunk。
chunk定义如下:
struct malloc_chunk {
INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;
/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};
一段连续的内存被分成多个chunk,prev_size记录的就是相邻的前一个chunk的size,知道当前chunk的地址,减去prev_size便是前一个chunk的地址。prev_size主要用于相邻空闲chunk的合并。
size :当前 chunk 的大小,并且记录了当前 chunk 和前一个 chunk 的一些属性,包括前一个 chunk 是否在使用中,当前 chunk 是否是通过 mmap 获得的内存,当前 chunk 是否属于非主分配区。
fd 和 bk :指针 fd 和 bk 只有当该 chunk 块空闲时才存在,其作用是用于将对应的空闲 chunk 块加入到空闲chunk 块链表中统一管理,如果该 chunk 块被分配给应用程序使用,那么这两个指针也就没有用(该 chunk 块已经从空闲链中拆出)了,所以也当作应用程序的使用空间,而不至于浪费。
fd_nextsize 和 bk_nextsize: 当前的 chunk 存在于 large bins 中时, large bins 中的空闲 chunk 是按照大小排序的,但同一个大小的 chunk 可能有多个,增加了这两个字段可以加快遍历空闲 chunk ,并查找满足需要的空闲 chunk , fd_nextsize 指向下一个比当前 chunk 大小大的第一个空闲 chunk , bk_nextszie 指向前一个比当前 chunk 大小小的第一个空闲 chunk 。(同一大小的chunk可能有多块,在总体大小有序的情况下,要想找到下一个比自己大或小的chunk,需要遍历所有相同的chunk,所以才有fd_nextsize和bk_nextsize这种设计) 如果该 chunk 块被分配给应用程序使用,那么这两个指针也就没有用(该chunk 块已经从 size 链中拆出)了,所以也当作应用程序的使用空间,而不至于浪费。
正如上面所描述,在ptmalloc中,为了尽可能的节省内存,使用中的chunk和未使用的chunk在结构上是不一样的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CmherN2t-1654243096868)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603154114215.png)]
在上图中:
与非空闲chunk相比,空闲chunk在用户区域多了四个指针,分别为fd,bk,fd_nextsize,bk_nextsize,这几个指针的含义在上面已经有解释,在此不再赘述。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bFQMWdrl-1654243096868)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603154128930.png)]
用户调用free函数释放内存的时候,ptmalloc并不会立即将其归还操作系统,而是将其放入空闲链表(bins)中,这样下次再调用malloc函数申请内存的时候,就会从bins中取出一块返回,这样就避免了频繁调用系统调用函数,从而降低内存分配的开销。
在ptmalloc中,会将大小相似的chunk链接起来,叫做bin。总共有128个bin供ptmalloc使用。
根据chunk的大小,ptmalloc将bin分为以下几种:
从前面malloc_state结构定义,对bin进行分类,可以分为fast bin和bins,其中unsorted bin、small bin 以及 large bin属于bins。
在glibc中,上述4中bin的个数都不等,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jsFkiVxT-1654243096869)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603154205115.png)]
程序在运行时会经常需要申请和释放一些较小的内存空间。当分配器合并了相邻的几个小的 chunk 之后,也许马上就会有另一个小块内存的请求,这样分配器又需要从大的空闲内存中切分出一块,这样无疑是比较低效的,故而,malloc 中在分配过程中引入了 fast bins。
在前面malloc_state定义中
mfastbinptr fastbins[NFASTBINS]; // NFASTBINS = 10
fast bin的个数是10个
每个fast bin都是一个单链表(只使用fd指针)。这是因为fast bin无论是添加还是移除chunk都是在链表尾进行操作,也就是说,对fast bin中chunk的操作,采用的是LIFO(后入先出)算法:添加操作(free内存)就是将新的fast chunk加入链表尾,删除操作(malloc内存)就是将链表尾部的fast chunk删除。
chunk size:10个fast bin中所包含的chunk size以8个字节逐渐递增,即第一个fast bin中chunk size均为16个字节,第二个fast bin的chunk size为24字节,以此类推,最后一个fast bin的chunk size为80字节。
不会对free chunk进行合并操作。这是因为fast bin设计的初衷就是小内存的快速分配和释放,因此系统将属于fast bin的chunk的P(未使用标志位)总是设置为1,这样即使当fast bin中有某个chunk同一个free chunk相邻的时候,系统也不会进行自动合并操作,而是保留两者。
malloc操作:在malloc的时候,如果申请的内存大小范围在fast bin的范围内,则先在fast bin中查找,如果找到了,则返回。否则则从small bin、unsorted bin以及large bin中查找。
free操作:先通过chunksize函数根据传入的地址指针获取该指针对应的chunk的大小;然后根据这个chunk大小获取该chunk所属的fast bin,然后再将此chunk添加到该fast bin的链尾即可。
下面是fastbin结构图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tJcExF4y-1654243096869)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603154334708.png)]
unsorted bin 的队列使用 bins 数组的第一个,是bins的一个缓冲区,加快分配的速度。当用户释放的内存大于max_fast或者fast bins合并后的chunk都会首先进入unsorted bin上。
在unsorted bin中,chunk的size 没有限制,也就是说任何大小chunk都可以放进unsorted bin中。这主要是为了让“glibc malloc机制”能够有第二次机会重新利用最近释放的chunk(第一次机会就是fast bin机制)。利用unsorted bin,可以加快内存的分配和释放操作,因为整个操作都不再需要花费额外的时间去查找合适的bin了。
用户malloc时,如果在 fast bins 中没有找到合适的 chunk,则malloc 会先在 unsorted bin 中查找合适的空闲 chunk,如果没有合适的bin,ptmalloc会将unsorted bin上的chunk放入bins上,然后到bins上查找合适的空闲chunk。
与fast bin所不同的是,unsortedbin采用的遍历顺序是FIFO。
unsorted bin结构图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nJf9XkFV-1654243096869)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603154402172.png)]
大小小于512字节的chunk被称为small chunk,而保存small chunks的bin被称为small bin。数组从2开始编号,前62个bin为small bins,small bin每个bin之间相差8个字节,同一个small bin中的chunk具有相同大小。
每个small bin都包括一个空闲区块的双向循环链表(也称binlist)。free掉的chunk添加在链表的前端,而所需chunk则从链表后端摘除。
两个毗连的空闲chunk会被合并成一个空闲chunk。合并消除了碎片化的影响但是减慢了free的速度。分配时,当samll bin非空后,相应的bin会摘除binlist中最后一个chunk并返回给用户。
在free一个chunk的时候,检查其前或其后的chunk是否空闲,若是则合并,也即把它们从所属的链表中摘除并合并成一个新的chunk,新chunk会添加在unsorted bin链表的前端。
small bin也采用的是FIFO算法,即内存释放操作就将新释放的chunk添加到链表的front end(前端),分配操作就从链表的rear end(尾端)中获取chunk。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HY7ulUtZ-1654243096870)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603154426368.png)]
大小大于等于512字节的chunk被称为large chunk,而保存large chunks的bin被称为large bin,位于small bins后面。large bins中的每一个bin分别包含了一个给定范围内的chunk,其中的chunk按大小递减排序,大小相同则按照最近使用时间排列。
两个毗连的空闲chunk会被合并成一个空闲chunk。
small bins 的策略非常适合小分配,但我们不能为每个可能的块大小都有一个 bin。对于超过 512 字节(64 位为 1024 字节)的块,堆管理器改为使用“large bin”。
63 large bin中的每一个都与small bin的操作方式大致相同,但不是存储固定大小的块,而是存储大小范围内的块。每个large bin 的大小范围都设计为不与small bin 的块大小或其他large bin 的范围重叠。换句话说,给定一个块的大小,这个大小对应的正好是一个small bin或large bin。
在这63个largebins中:第一组的32个largebin链依次以64字节步长为间隔,即第一个largebin链中chunksize为1024-1087字节,第二个large bin中chunk size为1088~1151字节。第二组的16个largebin链依次以512字节步长为间隔;第三组的8个largebin链以步长4096为间隔;第四组的4个largebin链以32768字节为间隔;第五组的2个largebin链以262144字节为间隔;最后一组的largebin链中的chunk大小无限制。
在进行malloc操作的时候,首先确定用户请求的大小属于哪一个large bin,然后判断该large bin中最大的chunk的size是否大于用户请求的size。如果大于,就从尾开始遍历该large bin,找到第一个size相等或接近的chunk,分配给用户。如果该chunk大于用户请求的size的话,就将该chunk拆分为两个chunk:前者返回给用户,且size等同于用户请求的size;剩余的部分做为一个新的chunk添加到unsorted bin中。
如果该large bin中最大的chunk的size小于用户请求的size的话,那么就依次查看后续的large bin中是否有满足需求的chunk,不过需要注意的是鉴于bin的个数较多(不同bin中的chunk极有可能在不同的内存页中),如果按照上一段中介绍的方法进行遍历的话(即遍历每个bin中的chunk),就可能会发生多次内存页中断操作,进而严重影响检索速度,所以glibc malloc设计了Binmap结构体来帮助提高bin-by-bin检索的速度。
Binmap记录了各个bin中是否为空,通过bitmap可以避免检索一些空的bin。如果通过binmap找到了下一个非空的large bin的话,就按照上一段中的方法分配chunk,否则就使用top chunk(在后面有讲)来分配合适的内存。
large bin的free 操作与small bin一致,此处不再赘述。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IBdug8AZ-1654243096870)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603154504631.png)]
上述几种bin,组成了进程中最核心的分配部分:bins,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0hpml2je-1654243096870)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603154527406.png)]
上节内容讲述了几种bin以及各种bin内存的分配和释放特点,但是,仅仅上面几种bin还不能够满足,比如假如上述bins不能满足分配条件的时候,glibc提出了另外几种特殊的chunk供分配和释放,分别为top chunk,mmaped chunk 和last remainder chunk。
top chunk是堆最上面的一段空间,它不属于任何bin,当所有的bin都无法满足分配要求时,就要从这块区域里来分配,分配的空间返给用户,剩余部分形成新的top chunk,如果top chunk的空间也不满足用户的请求,就要使用brk或者mmap来向系统申请更多的堆空间(主分配区使用brk、sbrk,非主分配区使用mmap)。
在free chunk的时候,如果chunk size不属于fastbin的范围,就要考虑是不是和top chunk挨着,如果挨着,就要merge到top chunk中。
当分配的内存非常大(大于分配阀值,默认128K)的时候,需要被mmap映射,则会放到mmaped chunk上,当释放mmaped chunk上的内存的时候会直接交还给操作系统。(chunk中的M标志位置1)
Last remainder chunk是另外一种特殊的chunk,这个特殊chunk是被维护在unsorted bin中的。
如果用户申请的size属于small bin的,但是又不能精确匹配的情况下,这时候采用最佳匹配(比如申请128字节,但是对应的bin是空,只有256字节的bin非空,这时候就要从256字节的bin上分配),这样会split chunk成两部分,一部分返给用户,另一部分形成last remainder chunk,插入到unsorted bin中。
当需要分配一个small chunk,但在small bins中找不到合适的chunk,如果last remainder chunk的大小大于所需要的small chunk大小,last remainder chunk被分裂成两个chunk,其中一个chunk返回给用户,另一个chunk变成新的last remainder chunk。
last remainder chunk主要通过提高内存分配的局部性来提高连续malloc(产生大量 small chunk)的效率。
chunk释放时,其长度不属于fastbins的范围,则合并前后相邻的chunk。首次分配的长度在large bin的范围,并且fast bins中有空闲chunk,则将fastbins中的chunk与相邻空闲的chunk进行合并,然后将合并后的chunk放到unsorted bin中,如果fastbin中的chunk相邻的chunk并非空闲无法合并,仍旧将该chunk放到unsorted bin中,即能合并的话就进行合并,但最终都会放到unsorted bin中。
fastbins,small bin中都没有合适的chunk,top chunk的长度也不能满足需要,则对fast bin中的chunk进行合并。
前面讲了相邻的chunk可以合并成一个大的chunk,反过来,一个大的chunk也可以分裂成两个小的chunk。chunk的分裂与从top chunk中分配新的chunk是一样的。需要注意的一点是:分裂后的两个chunk其长度必须均大于chunk的最小长度(对于64位系统是32字节),即保证分裂后的两个chunk仍旧是可以被分配使用的,否则则不进行分裂,而是将整个chunk返回给用户。
glibc运行时库分配动态内存,底层用的是malloc来实现(new 最终也是调用malloc),下面是malloc函数调用流程图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AkdDQdj1-1654243096871)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603155246367.png)]
在此,将上述流程图以文字形式表示出来,以方便大家理解:
将上面流程串起来就是:
根据用户请求分配的内存的大小,ptmalloc有可能会在两个地方为用户分配内存空间。在第一次分配内存时,一般情况下只存在一个主分配区,但也有可能从父进程那里继承来了多个非主分配区,在这里主要讨论主分配区的情况,brk值等于start_brk,所以实际上heap大小为0,top chunk 大小也是0。这时,如果不增加heap大小,就不能满足任何分配要求。所以,若用户的请求的内存大小小于mmap分配阈值, 则ptmalloc会初始heap。
然后在heap中分配空间给用户,以后的分配就基于这个heap进行。若第一次用户的请求就大于mmap分配阈值,则ptmalloc直接使用mmap()分配一块内存给用户,而heap也就没有被初始化,直到用户第一次请求小于mmap分配阈值的内存分配。第一次以后的分配就比较复杂了,简单说来,ptmalloc首先会查找fast bins,如果不能找到匹配的chunk,则查找small bins。
若仍然不满足要求,则合并fast bins,把chunk加入unsorted bin,在unsorted bin中查找,若仍然不满足要求,把unsorted bin 中的chunk全加入large bins 中,并查找large bins。在fast bins 和small bins中的查找都需要精确匹配, 而在large bins中查找时,则遵循“smallest-first,best-fit”的原则,不需要精确匹配。
若以上方法都失败了,则ptmalloc会考虑使用top chunk。若top chunk也不能满足分配要求。而且所需chunk大小大于mmap分配阈值,则使用mmap进行分配。否则增加heap,增大top chunk。以满足分配要求。
当然了,glibc中malloc的分配远比上面的要复杂的多,要考虑到各种情况,比如指针异常ΩΩ越界等,将这些判断条件也加入到流程图中,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7nm96nJ3-1654243096871)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\640 (1)].jpg)
malloc进行内存分配,那么与malloc相对的就是free,进行内存释放,下面是free函数的基本流程图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WCxlRARx-1654243096872)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603155445684.png)]
对上述流程图进行描述,如下:
如果将free函数内部各种条件加入进去,那么free调用的详细流程图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZyBtoRE9-1654243096872)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\640.jpg)]
通过前面对glibc运行时库的分析,基本就能定位出原因,是因为我们调用了free进行释放,但仅仅是将内存返还给了glibc库,而glibc库却没有将内存归还操作系统,最终导致系统内存耗尽,程序因为 OOM 被系统杀掉。
有以下两种方案:
最终采用tcmalloc来解决了问题。
业界语句说法,是否了解内存管理机制,是辨别C/C++程序员和其他的高级语言程序员的重要区别。作为C/C++中的最重要的特性,指针及动态内存管理在给编程带来极大的灵活性的同时,也给开发人员带来了许多困扰。
了解底层内存实现,有时候会有意想不到的效果哦。
可復用面嚮對象軟件的基礎
這是課程筆記
DIP
)
OCP
)
SRP
)
Liskov
替換原則(LSP
)
ISP
)
現代軟體專業分工之後的第一個結果是“框架與應用程式的劃分”,“組件寫作”模式通過晚期綁定,來實現框架與應用程式之間的松耦合,是二者之間協作時常用的模式
定義一個操作中的演算法的骨架(穩定),而將一些步驟延遲(變化) 倒子類中。Template Method使得子類可以不改變(複用) 一個演算法的結構即可重定義(override重寫) 該演算法的某些特定步驟。
注意
在TemplateMethod
中存在 虛函數的調用
void TemplateMethod () {
/*...*/
PrimitiveOperation1();
/*...*/
PrimitiveOperation2();
/*...*/
}
struct AbstractClass {
void TemplateMethod() = 0;
virtual void PrimitiveOperation1() = 0;
virtual void PrimitiveOperation2() = 0;
}
struct ConcreteClass: AbstractClass {
void PrimitiveOperation1() override final = 0;
void PrimitiveOperation2() override final = 0;
}
定義一系列演算法,把它們一個個封裝起來,並且使它們可互相替換((變化)。該模式使得演算法可獨立於使用它的客戶程式(穩定)而變化(擴展,子類化)。
定義對象間的一種一對多(變化)的依賴關係,以便當一個對象(Subject)的狀態發生改變時,所有依賴於它的對象都得到通知並自動更新。
在軟體組件的設計中,如果責任劃分的不清晰,使用繼承得到的結果往往是隨著需求的變化,子類急劇膨脹,同時充斥著重複代碼,這時候的關鍵是劃清責任。
動態(組合)地給一個對象增加一些額外的職責。就增加功能而言,Decorator模式比生成子類(繼承)更為靈活(消除重複代碼&減少子類個數)。
將抽象部分(業務功能)與實現部分(平臺實現)分離,使它們都可以獨立地變化。
通過“對象創建”模式繞開new, 來避免對象創建(new) 過程中所導致的緊耦合(依賴具體類),從而支持對象創建的穩定。它是介面抽象之後的第一步工作。
定義一個用於創建對象的介面,讓子類決定實例化哪一個類。Factory Method使得一個類的實例化延遲(目的:解耦,手段:虛函數)到子類。
提供一個介面,讓該介面負責創建一系列“相關或者相互依賴的對象”,無需指定它們具體的類。
使用原型實例指定創建對象的種類,然後通過拷貝這些原型來創建新的對象。
將一個複雜對象的構建與其表示相分離,使得同樣的構建過程(穩定)可以創建不同的表示(變化)。
面向對象很好地解決了“抽象”的問題,但是必不可免地要付出一定的代價。對於通常情況來講,面向對象的成本大都可以忽略不計。但是某些情況,面向對象所帶來的成本必須謹慎處理。
保證一個類僅有一個實例,並提供一個該實例的全局訪問點。
注意 Instance()
返回一個獨一無二的對象
線程非安全版本
Singleton* Singleton::getInstance() {
if (nullptr == m_instance) m_instance = new Singleton();
return m_instance;
}
線程安全版本,但鎖的代價過高
Singletone* Singleton::getInstance() {
Lock lock;
if (nullptr == m_instance) m_instance = new Singleton();
return m_instance;
}
雙檢查鎖,但由於記憶體讀寫reorder
不安全
Singleton* Singleton::getInstance() {
if (nullptr == m_instance) {
Lock lock;
if (nullptr == m_instance) m_instance = new Singleton();
}
return m_instance ;
}
C++ 11版本之後的跨平臺實現(volatile
)
std:atomic<Singleton*>Singleton:m_instance;
std:mutex Singleton:m_mutex;
Singleton*Singleton:getInstance() {
Singleton*tmp m_instance.Load(std:memory_order_relaxed);
std::atomic_thread._fence(std::memo ry._order_acquire);//獲取記憶體fence
if (nullptr == tmp) {
std:lock_guard<std:mutex>lock(m_mutex);
tmp = m_instance.load(std:memory_order_relaxed);
if (nullptr == tmp) {
tmp new Singleton();
std::atomic._thread_fence(std::memo ry_order_release);//釋放記憶體fence
m_instance.store(tmp,std:memory_order_relaxed);
}
}
return tmp;
}
運用共用技術有效地支持大量細粒度的對象
在組件構建過程中,某些介面之間直接的依賴常常會帶來很多問題、甚至根本無法實現。採用添加一層間接(穩定)介面,來隔離本來互相緊密關聯的介面是一種常見的解決方案。
為子系統中的一組介面提供一個一致(穩定)的介面,Façade模式定義了一個高層介面,這個介面使得這一子系統更加容易使用(複用)。
為其他對象提供一種代理以控制(隔離,使用介面))對這個對象的訪問。
用一個仲介對象來封裝(封裝變化)一系列的對象交互。仲介者使各對象不需要顯式的相互引用(編譯時依賴→運行時依賴),從而使其藕合鬆散(管理變化),而且可以獨立地改變它們之間的交互。
將一個類的介面轉換成客戶希望的另一個介面。Adapter模式使得原本由於介面不相容而不能一起工作的那些類可以一起工作。
在組件構建過程中,某些對象的狀態經常面臨變化,如何對這些變化進行有效的管理?同時又維持高層模組的穩定?“狀態變化”模式為這一問題提供了一種解決方案。
在不破壞封裝性的前提下,捕獲一個對象的內部狀態,並在該對象之外保存這個狀態。這樣以後就可以將該對象恢復到原先保存的狀態。
允許一個對象在其內部狀態改變時改變它的行為。從而使對象看起來似乎修改了其行為。
常常有一些組件在內部具有特定的數據結構,如果讓客戶程式依賴這些特定的數據結構,將極大地破壞組件的複用。這時候,將這些特定數據結構封裝在內部,在外部提供統一的介面,來實現與特定數據結構無關的訪問,是一種行之有效的解決方案。
Composite
將對象組合成樹形結構以表示“部分-整體”的層次結構。
Composite使得用戶對單個對象和組合對象的使用具有一致性(穩定)
提供一種方法順序訪問一個聚合對象中的各個元素,而又不暴露(穩定)該對象的內部表示。
使多個對象都有機會處理請求,從而避免請求的發送者和接收者之間的耦合關係。將這些對象連成一條鏈,並沿著這條鏈傳遞請求,直到有一個對象處理它為止。
在組件的構建過程中,組件行為的變化經常導致組件本身劇烈的變化。“行為變化”模式將組件的行為和組件本身進行解耦,從而支持組件行為的變化,實現兩者之間的松耦合。
將一個請求(行為)封裝為一個對象,從而使你可用不同的請求對客戶進行參數化;對請求排隊或記錄請求日誌,以及支持可撤銷的操作。
表示一個作用於某對象結構中的各元素的操作。使得可以在不改變(穩定)各元素的類的前提下定義(擴展)作用於這些元素的新操作(變化)。
在特定領域中,某些變化雖然頻繁,但可以抽象為某種規則。這時候,結合特定領域,將問題抽象為語法規則,從而給出在該領域下的一般性解決方案。
給定一個語言,定義它的文法的一種表示,並定義一種解釋器,這個解釋器使用該表示來解釋語言中的句子。
C <- NextChar();
if (c = 'n')
then begin:
c <- NextChar();
if (c = 'e')
then begin:
c <- NextChar();
if (c = 'w')
then report success;
else try something else;
end;
else try something else;
end;
else try something else;
對於需要實現轉移圖的代碼來說,轉移圖充當了這些代碼的抽象。轉移圖還可以看做是形式化的數學對象,稱為有限自動機,它定義了識別器的規格。形式上,有限自動機(FA)是一個五元組( S \mathrm{S} S, Σ \mathrm{\Sigma} Σ, δ \mathrm{\delta} δ, s 0 \mathrm{s_{0}} s0, S a \mathrm{S_{a}} Sa),其中各分量的含義如下所示。
有限自動機(Finite Automaton)
識別器的一種形式化方法,包含一個有限狀態集、一個字母表、一個轉移函數、一個起始狀態和一個或多個接受狀態。
有限閉包 對任一整數 i i i , RE R i R^{i} Ri 指定了 R R R出現一次到 i i i次的情形。
正閉包 RE R i R^{i} Ri表示出現一次或多次,通常寫作 ∪ i = 0 ∞ R i \cup^{\infty}_{i = 0} R^{i} ∪i=0∞Ri。
求補運算符 符號表示法 c ^c c表示集合 { Σ − c } \{\Sigma - c\} {Σ−c},即 c c c相對於 Σ \Sigma Σ的補集。求補運算符的優先級高於 ∗ * ∗, ∣ | ∣, + + +。
轉義序列 會被詞法分析器轉換為另一個字符的兩個或更多字符。轉義序列用於表示沒有字形的字符,如換行符或制表符,以及用於表示語法結構的字符,如引號。
對於有限自動機來說,我們的目標是,使得從一組RE導出可執行詞法分析器的過程自動化。本節將開發一些構造法,以便將RE轉換為適合於直接實現的FA,還將設計一種演算法,從FA接受的語言推導出對應的RE。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LMAC5M6B-1650629991023)(C:\Users\HR_in\Downloads\LearnNote\C++ Learn Note.assets\image-20220419085134497.png)]
回憶RE的定義,當時我夢將空船 ϵ \epsilon ϵ規定為RE。我們手工構建的FA都不包含 ϵ \epsilon ϵ,但一些RE確實用到了 ϵ \epsilon ϵ。在FA使用針對 ϵ \epsilon ϵ輸入的轉移條件來合并FA,並組成用於更複雜RE的FA
ε轉移 針對空串輸入ε進行的轉移,不會改變輸入流中的讀寫位置
如果一個FA包含了 s 0 s_0 s0這樣的狀態,即對單個輸入字符有多種可能的轉移,則稱爲非確定性有限自動機(Nondeterministic Finite Automation, NFA)
非確定性FA 允許在空串輸入 ϵ \epsilon ϵ上進行轉移的FA,其狀態對同一字符輸入可能有多種轉移。
確定性FA 轉移函數為單值得FA成爲DFA, DFA不允許 ϵ \epsilon ϵ轉移。
兩種不同的NFA模型
Thompson
構造法[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MSsFRE6F-1650629991023)(C:\Users\HR_in\Downloads\LearnNote\C++ Learn Note.assets\image-20220419093214952.png)]
這個構造法從為輸入RE中每個字符構建簡單的NFA開始。接下來,它按照優先級和括號規定的順序,對簡單NFA的集合應用選擇、鏈接和閉包等轉換。對於a(b|c)*
,該構造法首先分別構建對應於a、b和c的NFA。因爲括號的優先級最高,接下來為括號中的表達式b|c
構建NFA。閉包的優先級比鏈接高,因此接下來為閉包(b|c)*
構建NFA。最後對應於a
和(b|c)*
的NFA鏈接起來。
NFA的配置 NFA上并發活動狀態的集合。
灰狼優化演算法(GWO),靈感來自於灰狼。GWO演算法模擬了自然界灰狼的領導層和狩獵機制。四種類型的灰狼,α ,β , δ ,ω 被用來模擬領導階層。此外,還實現了狩獵的三個主要步驟: 尋找獵物、包圍獵物和攻擊獵物。
為了在設計GWO演算法時對灰狼的社會等級進行數學建模,我們將最適解作為 α 。因此,第二和第三個最佳解決方案分別被命名為 β 和 δ 。剩下的候選解被假定為 ω 。 在GWO演算法中,狩獵過程由 α ,β 和 δ 引導。 ω 狼跟隨這三只狼。
D = ∣ C ⋅ X p − X ( t ) ∣ (GWO:1) \mathrm{D} = \vert \mathrm{C} \cdot X_{p} - X(t) \vert \tag{GWO:1} D=∣C⋅Xp−X(t)∣(GWO:1)
注意
X ( t + 1 ) = X p ( t ) − A ⋅ D (GWO:2) X(t + 1) = X_{p}(t) - \mathrm{A} \cdot \mathrm{D} \tag{GWO:2} X(t+1)=Xp(t)−A⋅D(GWO:2)
注意
t t t 是目前的迭代代數, A A A和 C C C 是係數向量, X p X_{p} Xp 和 X X X分別是獵物的位置向量和灰狼的位置向量.
A A A 和 C C C 的計算公式
A = 2 a ⋅ r 1 − a (GWO:3) \mathrm{A} = 2a \cdot r_{1} - a \tag{GWO:3} A=2a⋅r1−a(GWO:3)
C = 2 ⋅ r 2 (GWO:4) \mathrm{C} = 2 \cdot r_{2} \tag{GWO:4} C=2⋅r2(GWO:4)
其中, a a a是收斂因數,隨著迭代次數從2線性減小道0, r 1 r_{1} r1和 r 2 r_2 r2的模取 [ 0 , 1 ] [0, 1] [0,1]之間的亂數。
灰狼能夠識別獵物的位置並包圍它們.當灰狼識別出獵物的位置後, β 和 δ 在 α 的帶領導下指導狼群包圍獵物。灰狼個體跟蹤獵物位置的數學模型描述如下:
$$
\begin{matrix}
\mathrm{D}{\alpha} = \vert \mathrm{C}{1} \cdot X_{\alpha} - X \vert \ \
\mathrm{D}{\beta} = \vert \mathrm{C}{2} \cdot X_{\beta} - X \vert \ \
\mathrm{D}{\delta} = \vert \mathrm{C}{3} \cdot X_{\delta} - X \vert \ \
\end{matrix} \tag{GWO:5}
$$
其中, D α D_{\alpha} Dα, D β D_{\beta} Dβ 和 D δ D_{\delta} Dδ分別表示α, β 和 δ 與其他個體間的距離; X α X_{\alpha} Xα, X β X_{\beta} Xβ 和 X δ X_{\delta} Xδ 分別表示α, β 和 & 當前位置, C 1 C_{1} C1, C 2 C_{2} C2, C 3 C_{3} C3是隨機向量, X是當前灰狼的位置。
$$
\begin{matrix}
X_{1} = X_{\alpha} - \mathrm{A}{1} \cdot (\mathrm{D}{\alpha}) \ \
X_{2} = X_{\beta} - \mathrm{A}{2} \cdot (\mathrm{D}{\beta}) \ \
X_{3} = X_{\delta} - \mathrm{A}{3} \cdot (\mathrm{D}{\delta})
\end{matrix} \tag{GWO:6}
$$
X ( t + 1 ) = X 1 + X 2 + X 3 3 (GWO:7) X(t + 1) = \frac{X_{1} + X_{2} + X_{3}}{3} \tag{GWO:7} X(t+1)=3X1+X2+X3(GWO:7)
當獵物停止移動時,灰狼通過攻擊來完成狩獵過程為了模擬逼近獵物, a a a的值被逐漸減小,因此 A A A 的波動範圍也隨之減小.換句話說,在選代過程中,當 a a a的值從2線性下降到0時,其對應的 A A A的值也在區間 [ − a , a ] [-a,a] [−a,a]內變化.如下圖所示,當A的值位於區間內時,灰狼的下一位置可以位於其當前位置和獵物位置之間的任意位置.當 ∣ A < 1 ∣ \vert A<1 \vert ∣A<1∣時,狼群向獵物發起攻擊(陷入局部最優).當$\vert A>1 \vert $時,灰狼與獵物分離,希望找到更合適的獵物(全局最優).
GWO演算法還有另一個組件 C C C來幫助發現新的解決方案.由式(4)可知, C C C是 [ 0 , 2 ] [0,2] [0,2]之間的隨機值. C C C表示狼所在的位置對獵物影響的隨機權重, C > 1 C>1 C>1表示影響權重大,反之,表示影響權重小.這有助於GWO演算法更隨機地表現並支持探索,同時可在優化過程中避免陷入局部最優.另外,與 A A A不同, C C C是非線性減小的.這樣,從最初的迭代到最終的迭代中,它都提供了決策空間中的全局搜索.在演算法陷入了局部最優並且不易跳出時, C C C的隨機性在避免局部最優方面發揮了非常重要的作用,尤其是在最後需要獲得全局最優解的迭代中。
分佈式存儲系統通常通過維護多個副本來進行容錯,提高系統的可用性。要實現此目標,就必須要解決分佈式存儲系統的最核心問題:維護多個副本的一致性。
首先需要解釋一下什麼是一致性(consensus),它是構建具有容錯性(fault-tolerant)的分佈式系統的基礎。 在一個具有一致性的性質的集群裏面,同一時刻所有的結點對存儲在其中的某個值都有相同的結果,即對其共用的存儲保持一致。集群具有自動恢復的性質,當少數結點失效的時候不影響集群的正常工作,當大多數集群中的結點失效的時候,集群則會停止服務(不會返回一個錯誤的結果)。
一致性協議就是用來幹這事的,用來保證即使在部分(確切地說是小部分)副本宕機的情況下,系統仍然能正常對外提供服務。一致性協議通常基於replicated state machines,即所有結點都從同一個state出發,都經過同樣的一些操作序列(log),最後到達同樣的state。
系統中每個結點有三個組件:
- 狀態機: 當我們說一致性的時候,實際就是在說要保證這個狀態機的一致性。狀態機會從log裏面取出所有的命令,然後執行一遍,得到的結果就是我們對外提供的保證了一致性的數據
- Log: 保存了所有修改記錄
- 一致性模組: 一致性模組演算法就是用來保證寫入的log的命令的一致性,這也是raft演算法核心內容
協議內容
Raft協議將一致性協議的核心內容分拆成為幾個關鍵階段,以簡化流程,提高協議的可理解性。
Raft協議的每個副本都會處於三種狀態之一:Leader、Follower、Candidate。
Leader:所有請求的處理者,
Leader
副本接受client
的更新請求,本地處理後再同步至多個其他副本;
Follower:請求的被動更新者,從Leader
接受更新請求,然後寫入本地日誌檔
Candidate:如果Follower
副本在一段時間內沒有收到Leader
副本的心跳,則判斷Leader
可能已經故障,此時啟動選主過程,此時副本會變成Candidate
狀態,直到選主結束。
時間被分為很多連續的隨機長度的term
,term
有唯一的id
。每個term
一開始就進行選主:
Follower
將自己維護的current_term_id
加1
。Candidate
RequestVoteRPC
消息(帶上current_term_id
) 給 其他所有server
這個過程會有三種結果:
majority
的投票後,狀態切成Leader
,並且定期給其他的所有server
發心跳消息(不帶log
的AppendEntriesRPC
)以告訴對方自己是current_term_id
所標識的term
的leader
。每個term
最多只有一個leader
,term id
作為logical clock
,在每個RPC
消息中都會帶上,用於檢測過期的消息。當一個server
收到的RPC
消息中的rpc_term_id
比本地的current_term_id
更大時,就更新current_term_id
為rpc_term_id
,並且如果當前state
為leader
或者candidate
時,將自己的狀態切成follower
。如果rpc_term_id
比本地的current_term_id
更小,則拒絕這個RPC
消息。Candidator
在等待投票的過程中,收到了大於或者等於本地的current_term_id
的聲明對方是leader
的AppendEntriesRPC
時,則將自己的state
切成follower
,並且更新本地的current_term_id
。candidate
收到了majority
的vote
時,沒有leader
被選出。這種情況下,每個candidate
等待的投票的過程就超時了,接著candidates
都會將本地的current_term_id
再加1
,發起RequestVoteRPC
進行新一輪的leader election
。term
投一票,具體的是否同意和後續的Safety有關。candidate
同時超時,然後有可能進入新一輪的票數被瓜分,為了避免這個問題,Raft採用一種很簡單的方法:每個Candidate
的election timeout
從150ms-300ms
之間隨機取,那麼第一個超時的Candidate
就可以發起新一輪的leader election
,帶著最大的term_id
給其他所有server
發送RequestVoteRPC
消息,從而自己成為leader
,然後給他們發送心跳消息以告訴他們自己是主。當Leader
被選出來後,就可以接受客戶端發來的請求了,每個請求包含一條需要被replicated state machines
執行的命令。leader
會把它作為一個log entry append
到日誌中,然後給其他的server
發AppendEntriesRPC
請求。當Leader
確定一個log entry
被safely replicated
了(大多數副本已經將該命令寫入日誌當中),就apply
這條log entry
到狀態機中然後返回結果給客戶端。如果某個Follower
宕機了或者運行的很慢,或者網路丟包了,則會一直給這個Follower
發AppendEntriesRPC
直到日誌一致。
當一條日誌是commited
時,Leader
才可以將它應用到狀態機中。Raft保證一條commited
的log entry
已經持久化了並且會被所有的節點執行。
當一個新的Leader
被選出來時,它的日誌和其他的Follower
的日誌可能不一樣,這個時候,就需要一個機制來保證日誌的一致性。一個新leader
產生時,集群狀態可能如下:
最上面這個是新Leader
,a~f
是Follower
,每個格子代表一條log entry
,格子內的數字代表這個log entry
是在哪個term
上產生的。
新Leader
產生後,就以Leader
上的log
為准。其他的follower
要麼少了數據比如b
,要麼多了數據,比如d
,要麼既少了又多了數據,比如f
。
因此,需要有一種機制來讓leader
和follower
對log
達成一致,leader
會為每個follower
維護一個nextIndex
,表示leader
給各個follower
發送的下一條log entry
在log
中的index
,初始化為leader
的最後一條log entry
的下一個位置。leader
給follower
發送AppendEntriesRPC
消息,帶著(term_id, (nextIndex-1))
,term_id
即(nextIndex-1)
這個槽位的log entry
的term_id
,follower
接收到AppendEntriesRPC
後,會從自己的log
中找是不是存在這樣的log entry
,如果不存在,就給leader
回復拒絕消息,然後leader
則將nextIndex
減1
,再重複,知道AppendEntriesRPC
消息被接收。
以leader
和b
為例:
初始化,nextIndex
為11
,leader
給b
發送AppendEntriesRPC(6,10)
,b
在自己log
的10
號槽位中沒有找到term_id
為6
的log entry
。則給leader
回應一個拒絕消息。接著,leader
將nextIndex
減一,變成10
,然後給b
發送AppendEntriesRPC(6, 9)
,b
在自己log
的9
號槽位中同樣沒有找到term_id
為6
的log entry
。迴圈下去,直到leader
發送了AppendEntriesRPC(4,4)
,b
在自己log
的槽位4
中找到了term_id
為4
的log entry
。接收了消息。隨後,leader
就可以從槽位5
開始給b
推送日誌了。
follower
有資格成為leader
?Raft保證被選為新
leader
的節點擁有所有已提交的log entry
,這與ViewStamped Replication
不同,後者不需要這個保證,而是通過其他機制從follower
拉取自己沒有的提交的日誌記錄
這個保證是在RequestVoteRPC
階段做的,candidate
在發送RequestVoteRPC
時,會帶上自己的最後一條日誌記錄的term_id
和index
,其他節點收到消息時,如果發現自己的日誌比RPC
請求中攜帶的更新,拒絕投票。日誌比較的原則是,如果本地的最後一條log entry
的term id
更大,則更新,如果term id
一樣大,則日誌更多的更大(index
更大)。
commited
?leader
正在replicate
當前term
(即term 2
)的日誌記錄給其他Follower
,一旦leader
確認了這條log entry
被majority
寫盤了,這條log entry
就被認為是committed
。如圖a,S1作為當前term
即term2
的leader
,log index
為2
的日誌被majority
寫盤了,這條log entry
被認為是commited
leader
正在replicate
更早的term
的log entry
給其他follower
。圖b的狀態是這麼出來的。在實際的協議中,需要進行一些微調,這是因為可能會出現下麵這種情況:
term
為2
,S1是Leader
,且S1寫入日誌(term, index)
為(2, 2)
,並且日誌被同步寫入了S2;Leader
,此時系統term
為3,且寫入了日誌(term, index)
為(3, 2)
;Followers
變離線了,進而觸發了一次新的選主,而之前離線的S1經過重新上線後被選中變成Leader
,此時系統term
為4,此時S1會將自己的日誌同步到Followers
,按照上圖就是將日誌(2, 2)
同步到了S3,而此時由於該日誌已經被同步到了多數節點(S1, S2, S3)
,因此,此時日誌(2,2)
可以被commit
了(即更新到狀態機);Leader
term = 3 > 2
,為了避免這種致命錯誤,需要對協議進行一個微調:
只允許主節點提交包含當前
term
的日誌
針對上述情況就是:即使日誌(2,2)
已經被大多數節點(S1、S2、S3)
確認了,但是它不能被commit
,因為它是來自之前term(2)
的日誌,直到S1在當前term(4)
產生的日誌(4, 3)
被大多數Follower
確認,S1方可Commit(4,3)
這條日誌,當然,根據Raft定義,(4,3)
之前的所有日誌也會被Commit
。此時即使S1再下線,重新選主時S5不可能成為Leader
,因為它沒有包含大多數節點已經擁有的日誌(4,3)
。
在實際的系統中,不能讓日誌無限增長,否則系統重啟時需要花很長的時間進行回放,從而影響availability。Raft採用對整個系統進行snapshot來處理,snapshot之前的日誌都可以丟棄。Snapshot技術在Chubby和ZooKeeper系統中都有採用。
Raft使用的方案是:每個副本獨立的對自己的系統狀態進行Snapshot,並且只能對已經提交的日誌記錄(已經應用到狀態機)進行snapshot。
Snapshot中包含以下內容:
commited log entry
的 (log index, last_included_term)
。這兩個值在Snapshot之後的第一條log entry
的AppendEntriesRPC
的consistency check
的時候會被用上,之前講過。一旦這個server
做完了snapshot,就可以把這條記錄的最後一條log index
及其之前的所有的log entry
都刪掉。snapshot的缺點就是不是增量的,即使記憶體中某個值沒有變,下次做snapshot的時候同樣會被dump
到磁片。當leader
需要發給某個follower
的log entry
被丟棄了(因為leader
做了snapshot),leader
會將snapshot發給落後太多的follower
。或者當新加進一臺機器時,也會發送snapshot給它。發送snapshot使用新的RPC
,InstalledSnapshot
。
做snapshot有一些需要注意的性能點,
log entry
的replicate
。這個可以通過使用copy-on-write
的技術來避免snapshot過程影響正常log entry
的replicate
。集群拓撲變化的意思是在運行過程中多副本集群的結構性變化,如增加/減少副本數、節點替換等。
Raft協議定義時也考慮了這種情況,從而避免由於下線老集群上線新集群而引起的系統不可用。Raft也是利用上面的Log Entry
和一致性協議來實現該功能。
假設在Raft中,老集群配置用Cold表示,新集群配置用Cnew表示,整個集群拓撲變化的流程如下:
leader
收到人工發出的重配置命令從Cold切成Cnew;Leader
副本在本地生成一個新的log entry
,其內容是Cold∪Cnew,代表當前時刻新舊拓撲配置共存,寫入本地日誌,同時將該log entry
推送至其他Follower
節點Follower
副本收到log entry
後更新本地日誌,並且此時就以該配置作為自己瞭解的全局拓撲結構,Follower
確認了Cold ∪ Cnew這條日誌的時候,Leader
就Commit
這條log entry
;Leader
生成一條新的log entry
,其內容是全新的配置Cnew,同樣將該log entry
寫入本地日誌,同時推送到Follower
上;Follower
收到新的配置日誌Cnew後,將其寫入日誌,並且從此刻起,就以該新的配置作為系統拓撲,並且如果發現自己不在Cnew這個配置中會自動退出Leader
收到多數Follower
的確認消息以後,給客戶端發起命令執行成功的消息Leader
的Cold ∪ Cnew尚未推送到Follower
,Leader
就掛了,此時選出的新的Leader
並不包含這條日誌,此時新的Leader
依然使用Cold作為全局拓撲配置Leader
的Cold ∪ Cnew推送到大部分的Follower
後就掛了,此時選出的新的Leader
可能是Cold
也可能是Cnew中的某個Follower
;Leader
在推送Cnew配置的過程中掛了,那麼和2一樣,新選出來的Leader
可能是Cold也可能是Cnew中的某一個,那麼此時客戶端繼續執行一次改變配置的命令即可Follower
確認了Cnew這個消息後,那麼接下來即使Leader
掛了,新選出來的Leader
也肯定是位於Cnew這個配置中的,因為有Raft的協議保證。為什麼需要弄這樣一個兩階段協議,而不能直接從Cold切換至Cnew?
這是因為,如果直接這麼簡單粗暴的來做的話,可能會產生多主。簡單說明下:
假設Cold為拓撲為(S1, S2, S3)
,且S1為當前的Leader
,如下圖:
假如此時變更了系統配置,將集群範圍擴大為5個,新增了S4和S5兩個服務節點,這個消息被分別推送至S2和S3,但是假如只有S3收到了消息並處理,S2尚未得到該消息
這時在S2的眼裏,拓撲依然是
,而在S3的眼裏拓撲則變成了
。假如此時由於某種原因觸發了一次新的選主,S2和S3分別發起選主的請求:
最終,候選者S2獲得了S1和S2自己的贊成票,那麼在它眼裏,它就變成了Leader
,而S3獲得了S4、S5和S3自己的贊成票,在它眼裏S3也變成了Leader
,那麼多Leader
的問題就產生了。而產生該問題的最根本原因是S2和S3的系統視圖不一致。
send()
+close()
close()
will cause RST,terminate connection prematurelySO_LINGER
? TL
;DR
:don’t use LINGER
send()
+shutdown(WR)
+ read()
→ 0 + close()
read()
→ 0 + if nothing more to send + `close()``gunzip-c huge.log.gz | grep ERROR | head
printf()
then exit()
TCP_NODELAY
write()
will not send data if there is any unacked TCP segmentSIGPIPE
TCP_NODELAY
Server | Client |
---|---|
bind+listen+accept | resolve address + connect |
nc
accept(2)
after a listening socket is "ready for reading"could block,because client could have disconnected in between.select()
may report a socket file descriptor as"ready for reading"while nevertheless a subsequent read blocks.This could for example happen when data has arrived but upon examination has wrong checksum and is discarded.There may be other circumstances in which a file descriptor is spuriously reported as ready.Thus it may be safer to use O_NONBLOCK
on sockets that should not block.write()
when buffer is not empty, it reorders dataPOLLOUT
event
write()
should append the buffer insteadPOLLOUT
is ready, write from buffer
POLLOUT
event
select(2)
and poll(2)
are level-trigger,community has 30+years of experience on how to write correct code,many 3rd party libraries rely on thisepoll(5)
stands for edge-poll
, works in both LT and ET modeCode
void cosine() {
whlie(true) for(int i = 0; i < 200; ++i) {
int percent =
static_cast<int>(
(1.0 + cos(i * 3.14159 / 100)) / 2 * g_percent + 0.5
);
load(percent)
}
}
load function
void load(int percent) {
percent = std::max(0, percent);
percent = std::min(100, percent);
// Bresenham's line algorithm
int err = 2 * percent - 100;
int count = 0;
for (int i = 0; i < 100; ++i) {
bool busy = false;
if (err > 0) {
busy = true;
err += 2 * (percent - 100);
++count;
} else {
err += 2 * percent;
}
{
MutexLockGuard guard(g_mutex);
g_busy = busy;
g_cond.notifyAll();
}
CurrentThread::sleepUsec(10 * 1000); // 10 ms
}
}