C++ Learn Note

C++ 的筆記部分

基礎部分

C++ 可變範本參數

在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叫函數參數包。

省略號的作用有兩個:

  • 聲明一個參數包,這個參數包中可以包含0到任意個範本參數
  • 在範本定義的右邊,可以將參數包展開成一個一個獨立的參數
函數
遞歸展開

通過遞歸函數展開參數包,需要提供一個參數包展開的函數和一個遞歸終止函數。


#include <iostream>

// 遞歸終止函數
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 <iostream>
#include <stdexcept>

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 <iostream>

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 <iostream>
#include <typeinfo>
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 <iostream>

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的優點

  • 適配性更好,C語言和C++都適用
  • 效率更高,當傳遞的是"xxxx"這種串時,string方式會自動創建出個臨時對象,臨時對象的創建和銷毀也是比較耗費性能的
  • 還是效率,string佔用的空間比const char*更大

說了一大堆const char*的優點,那使用string究竟有沒有優點呢?

我總結了string相比於const char*的兩個優點

  • 方便,不需要關心是否釋放相關記憶體,不需要擔心記憶體洩漏問題
  • 有些string是const char不能替代的,比如一個string中間有’\0’,使用const char作為參數傳遞過去會被截斷的,也就不符合需求

數據結構部分

鏈表部分

一個十分高效的鏈表操作

#include <stdio.h>
#include <malloc.h>
#include <stdint.h>  // 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" );
  

代碼調試

GDB調試技巧

作為C/C++開發人員,保證程式正常運行是最基本也是最主要的目的。而為了保證程式正常運行,調試則是最基本的手段,熟悉這些調試方式,可以方便我們更快的定位程式問題所在,提高開發效率。

在開發過程,如果程式的運行結果不符合預期,第一時間就是打開GDB進行調試,在對應的地方設置中斷點,然後分析原因;當線上服務出了問題,第一時間查看進程在不在,如果不在的話,是否生成了coredump檔,如果有,則使用gdb調試coredump檔,否則通過dmesg來分析內核日誌來查找原因。

命令簡介

常用命令
Break-Point

可以根據行號、函數、條件生成中斷點,下麵是相關命令以及對應的作用說明:

命令作用
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行的中斷點
Watch-Point

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值時會中斷
Catch-Point

從字面意思理解,是捕獲中斷點,其主要監測信號的產生。例如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 onfork調用時只追蹤其中一個進程
set detach-on-fork offfork調用時會同時追蹤父子進程

在調試多進程程式時候,默認情況下,除了當前調試的進程,其他進程都處於掛起狀態,所以,如果需要在調試當前進程的時候,其他進程也能正常執行,那麼通過設置set schedule-multiple on即可。

多線程

多線程開發在日常開發工作中很常見,所以多線程的調試技巧非常有必要掌握。

默認調試多線程時,一旦程式中斷,所有線程都將暫停。如果此時再繼續執行當前線程,其他線程也會同時執行。

命令作用
info threads查看線程列表
print $_thread顯示當前正在調試的線程編號
set scheduler-locking on調試一個線程時,其他線程暫停執行
set scheduler-locking off調試一個線程時,其他線程同步執行
set scheduler-locking step僅用step調試線程時其他線程不執行,用其他命令如next調試時仍執行

如果只關心當前線程,建議臨時設置 scheduler-lockingon,避免其他線程同時運行,導致命中其他中斷點分散注意力。

列印輸出

通常情況下,在調試的過程中,我們需要查看某個變數的值,以分析其是否符合預期,這個時候就需要列印輸出變數值。

命令作用
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表示1byteh表示2bytehalf word),w表示4byteg表示8bytegiant 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同時比較函數源代碼和彙編代碼
調試和保存core檔
命令含義
file exec_file *# *加載可執行檔的符號表資訊
core core_file加載core-dump檔
gcore core_file生成core-dump檔,記錄當前進程的狀態

啟動方式

使用gdb調試,一般有以下幾種啟動方式:

  • gdb filename: 調試可執行程式
  • gdb attach pid: 通過”綁定“進程ID來調試正在運行的進程
  • gdb filename -c coredump_file: 調試可執行檔

在下面的幾節中,將分別對上述幾種調試方式進行講解,從例子的角度出發,使得大家能夠更好的掌握調試技巧。

示例代碼(單線程)
#include<stdio.h>

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)
backtrace
(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,這是因為_與棧的向下增長_規律一致,我們只需要記住_編號最小幀號就是最近一次調用的函數_。

frame

棧幀用來存儲函數的變數值等資訊,默認情況下,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 <chrono>
#include <iostream>
#include <string>
#include <thread>
#include <vector>

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;
}

上述代碼比較簡單:

  • 函數fun_int的功能是休眠10s,然後列印其參數
  • 函數fun_string功能是休眠10s,然後列印其參數
  • main函數中,創建兩個線程,分別執行上述兩個函數

下麵是一個完整的調試過程:

(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<long, std::ratio<1l, 1l> >, std::chrono::duration<long, std::ratio<1l, 1000000000l> >) () from /lib64/libstdc++.so.6
#3  0x00000000004018cc in std::this_thread::sleep_for<long, std::ratio<1l, 1l> > (__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<int (*(int))(int)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (this=0x609080)
    at /usr/include/c++/4.8.2/functional:1732
#6  0x000000000040443d in std::_Bind_simple<int (*(int))(int)>::operator()() (this=0x609080) at /usr/include/c++/4.8.2/functional:1720
#7  0x000000000040436e in std::thread::_Impl<std::_Bind_simple<int (*(int))(int)> >::_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

在上述調試過程中:

  1. b 27 在第27行加上中斷點
  2. b test.cc:32 在第32行加上中斷點(效果與b 32一致)
  3. info b 輸出所有的中斷點資訊
  4. r 程式開始運行,並在第一個中斷點處暫停
  5. c 執行c命令,在第二個中斷點處暫停,在第一個中斷點和第二個中斷點之間,創建了兩個線程t1和t2
  6. info threads 輸出所有的線程資訊,從輸出上可以看出,總共有3個線程,分別為main線程、t1和t2
  7. thread 2 切換至線程2
  8. bt 輸出線程2的堆疊資訊
  9. c 直至程式結束
  10. q 退出gdb
多進程

同上面一樣,我們仍然以一個例子進行模擬多進程調試,代碼如下:

#include <stdio.h>
#include <unistd.h>

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

首先,無論是父進程還是子進程,都可以通過attach命令啟動gdb進行調試。我們都知道,對於每個正在運行的程式,操作系統都會為其分配一個唯一ID號,也就是進程ID。如果我們知道了進程ID,就可以使用attach命令對其進行調試了。

在上面代碼中,fork()函數創建的子進程內部,首先會進入while迴圈sleep,然後在while迴圈之後調用printf函數。這樣做的目的有如下:

  • 幫助attach捕獲要調試的進程id
  • 在使用gdb進行調試的時候,真正的代碼(即print函數)沒有被執行,這樣就可以從頭開始對子進程進行調試

可能會有疑惑,上面代碼以及進入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有以下兩個選項:

  • parent:父進程,mode的默認選項
  • child:子進程,其目的是告訴 gdb 在目標應用調用fork之後接著調試子進程而不是父進程,因為在Linux系統中fork()系統調用成功會返回兩次,一次在父進程,一次在子進程
(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)

在上述命令中,我們做了如下操作:

  1. show follow-fork-mode:通過該命令來查看當前處於什麼模式下,通過輸出可以看出,處於parent即父進程模式
  2. set follow-fork-mode child:指定調試子進程模式
  3. r:運行程式,直接運行程式,此時會進入子進程,然後執行while迴圈
  4. ctrl + c:通過該命令,可以使得GDB收到SIGINT命令,從而暫停執行while迴圈
  5. n(next):繼續執行,進而進入到while迴圈的條件判斷處
  6. show follow-fork-mode:再次執行該命令,通過輸出可以看出,當前處於child模式下
detach-on-fork

如果一開始指定要調試子進程還是父進程,那麼使用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: 列印DGB控制下的所有被fork出來的進程列表。該列表包括fork id、進程id和當前進程的位置
  • fork fork-id: 參數fork-id是GDB分配的內部fork編號,該編號可用通過上面的命令info forks獲取
coredump

當我們開發或者使用一個程式時候,最怕的莫過於程式莫名其妙崩潰。為了分析崩潰產生的原因,操作系統的記憶體內容(包括程式崩潰時候的堆疊等資訊)會在程式崩潰的時候dump出來(默認情況下,這個檔案名為core.pid,其中pid為進程id),這個dump操作叫做coredump(核心轉儲),然後我們可以用調試器調試此檔,以還原程式崩潰時候的場景。

在我們分析如果用gdb調試coredump檔之前,先需要生成一個coredump,為了簡單起見,我們就用如下例子來生成:

#include <stdio.h>

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 <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data)
  • pid_t pid:指示 ptrace 要跟蹤的進程
  • void *addr:指示要監控的記憶體地址
  • enum __ptrace_request request:決定了系統調用的功能,幾個主要的選項:
    • PTRACE_TRACEME:表示此進程將被父進程跟蹤,任何信號(除了 SIGKILL)都會暫停子進程,接著阻塞於 wait() 等待的父進程被喚醒。子進程內部對 exec() 的調用將發出 SIGTRAP 信號,這可以讓父進程在子進程新程式開始運行之前就完全控制它
    • PTRACE_ATTACH:attach 到一個指定的進程,使其成為當前進程跟蹤的子進程,而子進程的行為等同於它進行了一次 PTRACE_TRACEME 操作。但需要注意的是,雖然當前進程成為被跟蹤進程的父進程,但是子進程使用 getppid() 的到的仍將是其原始父進程的pid
    • PTRACE_CONT:繼續運行之前停止的子進程。可同時向子進程交付指定的信號
調試原理
運行並調試新進程
  • 運行並調試新進程,步驟如下:
  • 運行gdb exe
  • 輸入run命令,gdb執行以下操作:
    • 通過fork()系統調用創建一個新進程
    • 在新創建的子進程中執行ptrace(PTRACE_TRACEME, 0, 0, 0)操作
    • 在子進程中通過execv()系統調用加載指定的可執行檔
attach運行的進程
  • 可以通過gdb attach pid來調試一個運行的進程,gdb將對指定進程執行ptrace(PTRACE_ATTACH, pid, 0, 0)操作。

  • 需要注意的是,當我們attach一個進程id時候,可能會報如下錯誤:

  • Attaching to process 28849
    ptrace: Operation not permitted.
    
  • 這是因為沒有許可權進行操作,可以根據啟動該進程用戶下或者root下進行操作。

中斷點原理
實現原理
  • 當我們通過b或者break設置中斷點時候,就是在指定位置插入中斷點指令,當被調試的程式運行到中斷點的時候,產生SIGTRAP信號。該信號被gdb捕獲並 進行中斷點命中判斷。
設置原理
  • 在程式中設置中斷點,就是先在該位置保存原指令,然後在該位置寫入int 3。當執行到int 3時,發生軟中斷,內核會向子進程發送SIGTRAP信號。當然,這個信號會轉發給父進程。然後用保存的指令替換int 3並等待操作恢復。
命中判斷
  • gdb將所有中斷點位置存儲在一個鏈表中。命中判定將被調試程式的當前停止位置與鏈表中的中斷點位置進行比較,以查看中斷點產生的信號。
條件判斷
  • 在中斷點處恢復指令後,增加了一個條件判斷。如果運算式為真,則觸發中斷點。由於需要判斷一次,添加條件中斷點後,是否觸發條件中斷點,都會影響性能。在 x86 平臺上,部分硬體支持硬體中斷點。不是在條件中斷點處插入 int 3,而是插入另一條指令。當程式到達這個地址時,不是發出int 3信號,而是進行比較。特定寄存器的內容和某個地址,然後決定是否發送int 3。因此,當你的中斷點位置被程式頻繁“通過”時,儘量使用硬體中斷點,這將有助於提高性能。
單步原理
  • 這個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
  • 此命令可顯示每個進程的棧跟蹤。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<long, std::ratio<1l, 1l> >, std::chrono::duration<long, std::ratio<1l, 1000000000l> >) () from /lib64/libstdc++.so.6
    #3  0x00000000004018cc in void std::this_thread::sleep_for<long, std::ratio<1l, 1l> >(std::chrono::duration<long, std::ratio<1l, 1l> > const&) ()
    #4  0x00000000004012de in fun_int(int) ()
    #5  0x0000000000404696 in int std::_Bind_simple<int (*(int))(int)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) ()
    #6  0x000000000040443d in std::_Bind_simple<int (*(int))(int)>::operator()() ()
    #7  0x000000000040436e in std::thread::_Impl<std::_Bind_simple<int (*(int))(int)> >::_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<long, std::ratio<1l, 1l> >, std::chrono::duration<long, std::ratio<1l, 1000000000l> >) () from /lib64/libstdc++.so.6
    #3  0x00000000004018cc in void std::this_thread::sleep_for<long, std::ratio<1l, 1l> >(std::chrono::duration<long, std::ratio<1l, 1l> > const&) ()
    #4  0x0000000000401340 in fun_string(std::string const&) ()
    #5  0x000000000040459f in int std::_Bind_simple<int (*(char const*))(std::string const&)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) ()
    #6  0x000000000040441f in std::_Bind_simple<int (*(char const*))(std::string const&)>::operator()() ()
    #7  0x0000000000404350 in std::thread::_Impl<std::_Bind_simple<int (*(char const*))(std::string const&)> >::_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 ()
    
  • 在上述輸出結果中,將進程內部的詳細資訊都輸出在終端,以方便分析問題。

ldd
  • 在我們編譯過程中通常會提示編譯失敗,通過輸出錯誤資訊發現是找不到函數定義,再或者編譯成功了,但是運行時候失敗(往往是因為依賴了非正常版本的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++filt

因為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<char, std::char_traits<char>, std::allocator<char> > const&)

通過上述輸出,我們可以將編譯器生成的函數名還原到我們代碼中的函數名即fun_string。

STL部分

Range

#include <iostream>
#include <ranges>

#ifdef __has_include (<format>)
#include <format>
#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;
}

Boost 部分

第1章 模板元編程

template <class T, T val> struct integral_constant {
    typedef integral_constant<T, val> type;
    typedef T value_type ;
    static const T value = val;
}

第8章 流處理

ios_base
basic_ios
basic_streambuf
basic_istream
basic_ostream
basic_iostream

第9章 序列化

basic_oarchive
- pimpl: basic_oarchive_impl
+vsave()
+get_library_version()
+get_flags()
interface_oarchive
+ is_loading
+ is_saving
+operator<<()
+operator&()
+register_type()
common_oarchive
basic_text_oarchive
basic_text_oprimitive
+save()
text_oarchive_impl
text_oarchive

GLibc

内存管理

由于内存管理不外乎三个层面,用户管理层,C 运行时库层,操作系统层,在操作系统层发现进程的内存暴增,同时又确认了用户管理层没有内存泄露,因此怀疑是 C 运行时库的问题,也就是Glibc 的内存管理方式导致了进程的内存暴增。

问题缩小到glibc的内存管理方面,把下面几个问题弄清楚,才能解决SeedService进程消失的问题:

  • glibc 在什么。情况下不会将内存归还给操作系统?
  • glibc 的内存管理方式有哪些约束?适合什么样的内存分配场景?
  • 我们的系统中的内存管理方式是与glibc 的内存管理的约束相悖的?
  • glibc 是如何管理内存的?

带着上面这些问题,大概用了将近一个月的时间分析了glibc运行时库的内存管理代码,今天将当时的笔记整理了出来,希望能够对大家有用。

基础部分

Linux 系统在装载 elf 格式的程序文件时,会调用 loader 把可执行文件中的各个段依次载入到从某一地址开始的空间中。

用户程序可以直接使用系统调用来管理 heap 和mmap 映射区域,但更多的时候程序都是使用 C 语言提供的 malloc()和 free()函数来动态的分配和释放内存。stack区域是唯一不需要映射,用户却可以访问的内存区域,这也是利用堆栈溢出进行攻击的基础。

进程内存布局

计算机系统分为32位和64位,而32位和64位的进程布局是不一样的,即使是同为32位系统,其布局依赖于内核版本,也是不同的。

在介绍详细的内存布局之前,我们先描述几个概念:

  • 栈区(Stack)— 存储程序执行期间的本地变量和函数的参数,从高地址向低地址生长
  • 堆区(Heap)动态内存分配区域,通过 malloc、new、free 和 delete 等函数管理
  • 未初始化变量区(BSS)— 存储未被初始化的全局变量和静态变量
  • 数据区(Data)— 存储在源代码中有预定义值的全局变量和静态变量
  • 代码区(Text)— 存储只读的程序执行代码,即机器指令
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位进程内存布局

如之前所述,64位进程内存布局方式由于其地址空间足够,且实现方便,所以采用的与32位经典内存布局的方式一致,如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9eGgCI6W-1654243096864)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603152902180.png)]

操作系统内存分配函数

在之前介绍内存布局的时候,有提到过,heap 和mmap 映射区域是可以提供给用户程序使用的虚拟内存空间。那么我们该如何获得该区域的内存呢?

操作系统提供了相关的系统调用来完成内存分配工作。

  • 对于heap的操作,操作系统提供了brk()函数,c运行时库提供了sbrk()函数。
  • 对于mmap映射区域的操作,操作系统提供了mmap()和munmap()函数。

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结构中:

  • [start_code,end_code)表示代码段的地址空间范围。
  • [start_data,end_start)表示数据段的地址空间范围。
  • [start_brk,brk)分别表示heap段的起始空间和当前的heap指针。
  • [start_stack,end_stack)表示stack段的地址空间范围。
  • mmap_base表示memory mapping段的起始地址。

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操作

在前面有提过,有两个函数可以直接从堆(Heap)申请内存,brk()函数为系统调用,sbrk()为c库函数。

系统调用通常提过一种最小的功能,而库函数相比系统调用,则提供了更复杂的功能。在glibc中,malloc就是调用sbrk()函数将数据段的下界移动以来代表内存的分配和释放。sbrk()函数在内核的管理下,将虚拟地址空间映射到内存,供malloc()函数使用。

下面为brk()函数和sbrk()函数的声明。

#include <unistd.h>
int brk(void *addr);

void *sbrk(intptr_t increment);

需要说明的是,当sbrk()的参数increment为0时候,sbrk()返回的是进程当前brk值。increment 为正数时扩展 brk 值,当 increment 为负值时收缩 brk 值。

MMap操作

在LINUX中我们可以使用mmap用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UF6FvMsA-1654243096865)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603153147198.png)]

mmap()函数将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。

munmap 执行相反的操作,删除特定地址区域的对象映射。

函数的定义如下:

#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);
  • 映射关系分为以下两种:

    • 文件映射: 磁盘文件映射进程的虚拟地址空间,使用文件内容初始化物理内存。

    • 匿名映射: 初始化全为0的内存空间

映射关系是否共享,可以分为:

  • 私有映射(MAP_PRIVATE)

    • 多进程间数据共享,修改不反应到磁盘实际文件,是一个copy-on-write(写时复制)的映射方式。
  • 共享映射(MAP_SHARED)

    • 多进程间数据共享,修改反应到磁盘实际文件中。

因此,整个映射关系总结起来分为以下四种:

  • 私有文件映射多个进程使用同样的物理内存页进行初始化,但是各个进程对内存文件的修改不会共享,也不会反应到物理文件中

  • 私有匿名映射

    • mmap会创建一个新的映射,各个进程不共享,这种使用主要用于分配内存(malloc分配大内存会调用mmap)。例如开辟新进程时,会为每个进程分配虚拟的地址空间,这些虚拟地址映射的物理内存空间各个进程间读的时候共享,写的时候会copy-on-write。
  • 共享文件映射

    • 多个进程通过虚拟内存技术共享同样的物理内存空间,对内存文件的修改会反应到实际物理文件中,也是进程间通信(IPC)的一种机制。
  • 共享匿名映射

    • 这种机制在进行fork的时候不会采用写时复制,父子进程完全共享同样的物理内存页,这也就实现了父子进程通信(IPC)。

这里值得注意的是,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的数倍。

glib之内存管理(ptmalloc)

因为本次事故就是用的运行库函数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是指向分配区的指针,堆之间是以链表方式进行连接,后面我会详细讲述进程布局下,堆的结构表示图。

在开始这部分之前,我们先了解下一些概念。

分配区(arena)

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)]

需要注意几个点:

  • 主分配区通过brk进行分配,非主分配区通过mmap进行分配
  • 非主分配区虽然是mmap分配,但是和大于128K直接使用mmap分配没有任何联系。大于128K的内存使用mmap分配,使用完之后直接用ummap还给系统
  • 每个线程在malloc会先获取一个area,使用area内存池分配自己的内存,这里存在竞争问题
  • 为了避免竞争,我们可以使用线程局部存储,thread cache(tcmalloc中的tc正是此意),线程局部存储对area的改进原理如下:
  • 如果需要在一个线程内部的各个函数调用都能访问、但其它线程不能访问的变量(被称为static memory local to a thread 线程局部静态变量),就需要新的机制来实现。这就是TLS。
  • thread cache本质上是在static区为每一个thread开辟一个独有的空间,因为独有,不再有竞争
  • 每次malloc时,先去线程局部存储空间中找area,用thread cache中的area分配存在thread area中的chunk。当不够时才去找堆区的area。
Chunk

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; 
};  
  • prev_size: 如果前一个chunk是空闲的,则该域表示前一个chunk的大小,如果前一个chunk不空闲,该域无意义。

一段连续的内存被分成多个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开始的地址
  • mem指针指向用户内存块开始的地址。
  • p=0时,表示前一个chunk为空闲,prev_size才有效
  • p=1时,表示前一个chunk正在使用,prev_size无效 p主要用于内存块的合并操作;ptmalloc 分配的第一个块总是将p设为1, 以防止程序引用到不存在的区域
  • M=1 为mmap映射区域分配;M=0为heap区域分配
  • A=0 为主分配区分配;A=1 为非主分配区分配。

与非空闲chunk相比,空闲chunk在用户区域多了四个指针,分别为fd,bk,fd_nextsize,bk_nextsize,这几个指针的含义在上面已经有解释,在此不再赘述。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bFQMWdrl-1654243096868)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603154128930.png)]

空闲链表(bins)

用户调用free函数释放内存的时候,ptmalloc并不会立即将其归还操作系统,而是将其放入空闲链表(bins)中,这样下次再调用malloc函数申请内存的时候,就会从bins中取出一块返回,这样就避免了频繁调用系统调用函数,从而降低内存分配的开销。

在ptmalloc中,会将大小相似的chunk链接起来,叫做bin。总共有128个bin供ptmalloc使用。

根据chunk的大小,ptmalloc将bin分为以下几种:

  • fast bin
  • unsorted bin
  • small bin
  • large 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)]

fast bin

程序在运行时会经常需要申请和释放一些较小的内存空间。当分配器合并了相邻的几个小的 chunk 之后,也许马上就会有另一个小块内存的请求,这样分配器又需要从大的空闲内存中切分出一块,这样无疑是比较低效的,故而,malloc 中在分配过程中引入了 fast bins。

在前面malloc_state定义中

mfastbinptr fastbins[NFASTBINS]; // NFASTBINS  = 10
  1. fast bin的个数是10个

  2. 每个fast bin都是一个单链表(只使用fd指针)。这是因为fast bin无论是添加还是移除chunk都是在链表尾进行操作,也就是说,对fast bin中chunk的操作,采用的是LIFO(后入先出)算法:添加操作(free内存)就是将新的fast chunk加入链表尾,删除操作(malloc内存)就是将链表尾部的fast chunk删除。

  3. chunk size:10个fast bin中所包含的chunk size以8个字节逐渐递增,即第一个fast bin中chunk size均为16个字节,第二个fast bin的chunk size为24字节,以此类推,最后一个fast bin的chunk size为80字节。

  4. 不会对free chunk进行合并操作。这是因为fast bin设计的初衷就是小内存的快速分配和释放,因此系统将属于fast bin的chunk的P(未使用标志位)总是设置为1,这样即使当fast bin中有某个chunk同一个free chunk相邻的时候,系统也不会进行自动合并操作,而是保留两者。

  5. 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

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)]

small bin

大小小于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)]

large bin

大小大于等于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)]

特殊的Chunk

上节内容讲述了几种bin以及各种bin内存的分配和释放特点,但是,仅仅上面几种bin还不能够满足,比如假如上述bins不能满足分配条件的时候,glibc提出了另外几种特殊的chunk供分配和释放,分别为top chunk,mmaped chunk 和last remainder chunk。

top trunk

top chunk是堆最上面的一段空间,它不属于任何bin,当所有的bin都无法满足分配要求时,就要从这块区域里来分配,分配的空间返给用户,剩余部分形成新的top chunk,如果top chunk的空间也不满足用户的请求,就要使用brk或者mmap来向系统申请更多的堆空间(主分配区使用brk、sbrk,非主分配区使用mmap)。

在free chunk的时候,如果chunk size不属于fastbin的范围,就要考虑是不是和top chunk挨着,如果挨着,就要merge到top chunk中。

mmaped chunk

当分配的内存非常大(大于分配阀值,默认128K)的时候,需要被mmap映射,则会放到mmaped chunk上,当释放mmaped chunk上的内存的时候会直接交还给操作系统。(chunk中的M标志位置1)

last remainder chunk

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切分

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。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)]

在此,将上述流程图以文字形式表示出来,以方便大家理解:

  1. 获取分配区的锁,为了防止多个线程同时访问同一个分配区,在进行分配之前需要取得分配区域的锁。线程先查看线程私有实例中是否已经存在一个分配区,如果存在尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,否则,该线程搜索分配区循环链表试图获得一个空闲(没有加锁)的分配区。如果所有的分配区都已经加锁,那么 ptmalloc 会开辟一个新的分配区,把该分配区加入到全局分配区循环链表和线程的私有实例中并加锁,然后使用该分配区进行分配操作。开辟出来的新分配区一定为非主分配区,因为主分配区是从父进程那里继承来的。开辟非主分配区时会调用 mmap()创建一个 sub-heap,并设置好 top chunk。
  2. 将用户的请求大小转换为实际需要分配的 chunk 空间大小。
  3. 判断所需分配chunk 的大小是否满足chunk_size <= max_fast (max_fast 默认为 64B), 如果是的话,则转下一步,否则跳到第 5 步。
  4. 首先尝试在 fast bins 中取一个所需大小的 chunk 分配给用户。如果可以找到,则分配结束。否则转到下一步。
  5. 判断所需大小是否处在 small bins 中,即判断 chunk_size < 512B 是否成立。如果chunk 大小处在 small bins 中,则转下一步,否则转到第 6 步。
  6. 根据所需分配的 chunk 的大小,找到具体所在的某个 small bin,从该 bin 的尾部摘取一个恰好满足大小的 chunk。若成功,则分配结束,否则,转到下一步。
  7. 到了这一步,说明需要分配的是一块大的内存,或者 small bins 中找不到合适的chunk。于是,ptmalloc 首先会遍历 fast bins 中的 chunk,将相邻的 chunk 进行合并,并链接到 unsorted bin 中,然后遍历 unsorted bin 中的 chunk,如果 unsorted bin 只有一个 chunk,并且这个 chunk 在上次分配时被使用过,并且所需分配的 chunk 大小属于 small bins,并且 chunk 的大小大于等于需要分配的大小,这种情况下就直接将该 chunk 进行切割,分配结束,否则将根据 chunk 的空间大小将其放入 small bins 或是 large bins 中,遍历完成后,转入下一步。
  8. 到了这一步,说明需要分配的是一块大的内存,或者 small bins 和 unsorted bin 中都找不到合适的 chunk,并且 fast bins 和 unsorted bin 中所有的 chunk 都清除干净了。从 large bins 中按照“smallest-first,best-fit”原则,找一个合适的 chunk,从中划分一块所需大小的 chunk,并将剩下的部分链接回到 bins 中。若操作成功,则分配结束,否则转到下一步。
  9. 如果搜索 fast bins 和 bins 都没有找到合适的 chunk,那么就需要操作 top chunk 来进行分配了。判断 top chunk 大小是否满足所需 chunk 的大小,如果是,则从 top chunk 中分出一块来。否则转到下一步。
  10. 到了这一步,说明 top chunk 也不能满足分配要求,所以,于是就有了两个选择: 如果是主分配区,调用 sbrk(),增加 top chunk 大小;如果是非主分配区,调用 mmap 来分配一个新的 sub-heap,增加 top chunk 大小;或者使用 mmap()来直接分配。在这里,需要依靠 chunk 的大小来决定到底使用哪种方法。判断所需分配的 chunk 大小是否大于等于 mmap 分配阈值,如果是的话,则转下一步,调用 mmap 分配, 否则跳到第 12 步,增加 top chunk 的大小。
  11. 使用 mmap 系统调用为程序的内存空间映射一块 chunk_size align 4kB 大小的空间。然后将内存指针返回给用户。
  12. 判断是否为第一次调用 malloc,若是主分配区,则需要进行一次初始化工作,分配一块大小为(chunk_size + 128KB) align 4KB 大小的空间作为初始的 heap。若已经初始化过了,主分配区则调用 sbrk()增加 heap 空间,分主分配区则在 top chunk 中切割出一个 chunk,使之满足分配需求,并将内存指针返回给用户。

将上面流程串起来就是:

根据用户请求分配的内存的大小,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)

内存释放(free)

malloc进行内存分配,那么与malloc相对的就是free,进行内存释放,下面是free函数的基本流程图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WCxlRARx-1654243096872)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\image-20220603155445684.png)]

对上述流程图进行描述,如下:

  1. 获取分配区的锁,保证线程安全。
  2. 如果free的是空指针,则返回,什么都不做。
  3. 判断当前chunk是否是mmap映射区域映射的内存,如果是,则直接munmap()释放这块内存。前面的已使用chunk的数据结构中,我们可以看到有M来标识是否是mmap映射的内存。
  4. 判断chunk是否与top chunk相邻,如果相邻,则直接和top chunk合并(和top chunk相邻相当于和分配区中的空闲内存块相邻)。否则,转到步骤8
  5. 如果chunk的大小大于max_fast(64b),则放入unsorted bin,并且检查是否有合并,有合并情况并且和top chunk相邻,则转到步骤8;没有合并情况则free。
  6. 如果chunk的大小小于 max_fast(64b),则直接放入fast bin,fast bin并没有改变chunk的状态。没有合并情况,则free;有合并情况,转到步骤7
  7. 在fast bin,如果当前chunk的下一个chunk也是空闲的,则将这两个chunk合并,放入unsorted bin上面。合并后的大小如果大于64B,会触发进行fast bins的合并操作,fast bins中的chunk将被遍历,并与相邻的空闲chunk进行合并,合并后的chunk会被放到unsorted bin中,fast bin会变为空。合并后的chunk和topchunk相邻,则会合并到topchunk中。转到步骤8
  8. 判断top chunk的大小是否大于mmap收缩阈值(默认为128KB),如果是的话,对于主分配区,则会试图归还top chunk中的一部分给操作系统。free结束。

如果将free函数内部各种条件加入进去,那么free调用的详细流程图如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZyBtoRE9-1654243096872)(D:\Files_Tree\DOCS\Markdown - DOCS\學習筆記\C++ Learn Note.assets\640.jpg)]

问题分析以及解决

通过前面对glibc运行时库的分析,基本就能定位出原因,是因为我们调用了free进行释放,但仅仅是将内存返还给了glibc库,而glibc库却没有将内存归还操作系统,最终导致系统内存耗尽,程序因为 OOM 被系统杀掉。

有以下两种方案:

  • 禁用 ptmalloc 的 mmap 分配 阈 值 动 态 调 整 机 制 。通 过 mallopt() 设置M_TRIM_THRESHOLD,M_MMAP_THRESHOLD,M_TOP_PAD 和 M_MMAP_MAX 中的任意一个,关闭 mmap 分配阈值动态调整机制,同时需要将 mmap 分配阈值设置为 64K,大于 64K 的内存分配都使用mmap 向系统分配,释放大于 64K 的内存将调用 munmap 释放回系统。但是,这种方案的 缺点是每次内存分配和申请,都是直接向操作系统申请,效率低
  • 预 估 程 序 可 以 使 用 的 最 大 物 理 内 存 大 小 , 配 置 系 统 的/proc/sys/vm/overcommit_memory,/proc/sys/vm/overcommit_ratio,以及使用 ulimt –v限制程序能使用虚拟内存空间大小,防止程序因 OOM 被杀掉。这种方案的 缺点是如果预估的内存小于进程实际占用,那么仍然会出现OOM,导致进程被杀掉
  • tcmalloc

最终采用tcmalloc来解决了问题。

结语

业界语句说法,是否了解内存管理机制,是辨别C/C++程序员和其他的高级语言程序员的重要区别。作为C/C++中的最重要的特性,指针及动态内存管理在给编程带来极大的灵活性的同时,也给开发人员带来了许多困扰。

了解底层内存实现,有时候会有意想不到的效果哦。

Design Pattern – 設計模式

可復用面嚮對象軟件的基礎

前言

這是課程筆記

關於變化

  • 理解隔離變化
    • 從宏觀層面來看,面向對象的構建方式更能適應軟體的變化,能將變化所帶來的影響減為最小
  • 各司其職
    • 從微觀層面來看,面向對象的方式更強調各個類的“責任”
    • 由於需求變化導致的新增類型不應該影響原來類型的實現—是所謂各負其責
  • 對象是什麼?
    • 從語言實現層面來看,對象封裝了代碼和數據。
    • 從規格層面講,對象是一系列可被使用的公共介面。
    • 從概念層面講,對象是某種擁有責任的抽象。

相關的原則(8大)

  • 依賴倒置原則(DIP
    • 高層模組(穩定)不應該依賴於低層模組(變化),二者都應該依賴於抽象(穩定)。
    • 抽象(穩定)不應該依賴於實現細節(變化),實現細節應該依賴於抽象(穩定)。

  • 開放封閉原則(OCP
    • 對擴展開放,對更改封閉。
    • 類模組應該是可擴展的,但是不可修改。

  • 單一職責原則(SRP)
    • 一個類應該僅有一個引起它變化的原因。
    • 變化的方向隱含著類的責任。

  • Liskov替換原則(LSP)
    • 子類必須能夠替換它們的基類(IS-A)。
    • 繼承表達類型抽象。

  • 介面隔離原則(ISP
    • 不應該強迫客戶程式依賴它們不用的方法。
    • 介面應該小而完備。

  • 優先使用對象組合,而不是類繼承
    • 類繼承通常為“白箱複用”,對象組合通常為“黑箱復用”
    • 繼承在某種程度上破壞了封裝性,子類父類耦合度高
    • 而對象組合則只要求被組合的對象具有良好定義的介面,耦合度低。

  • 封裝變化點
    • ·使用封裝來創建對象之間的分界層,讓設計者可以一側進行修改,而不會對另一側產生不良的影響,從而實現層次間的松耦合。

  • 針對介面編程,而不是針對實現編程
    • 不將變數類型聲明為某個特定的具體類,而是聲明為某個介面。
    • 客戶程式無需獲知對象的具體類型,只需要知道對象所具有的介面。
    • 減少系統中各部分的依賴關係,從而實現“高內聚、松耦合”的類型設計方案。

分類方式

  • 從目的來看:
    • 創建型(Creational)模式:對象創建
    • 結構型(Structural)模式:對象的組合和創建
    • 行為型(Behavioral)模式:對象之間的相關職責

  • 從範圍來看:
    • 類模式處理類與子類的靜態關係。
    • 對象模式處理對象間的動態關係。

  • 從封裝變化角度對模式分類
    • 組件協作:
      • Template Method
      • Stategy
      • Observer/Event
    • 單一職責:
      • Decorator
      • Bridge
    • 對象創建:
      • Factory Method
      • Abstract Factory
      • Prototype
      • Builder
    • 對象性能:
      • Singleton
      • Flyweight ·
    • 介面隔離:
      • Façade
      • Proxy
      • Mediator
      • Adapter
    • 狀態變化:
      • Memento
      • State
    • 數據結構
      • Composite
      • Iterator
      • Chain of Resposibility
    • 行為變化
      • Command
      • Visitor
    • 領域問題
      • Interpreter

組件協作

現代軟體專業分工之後的第一個結果是“框架與應用程式的劃分”,“組件寫作”模式通過晚期綁定,來實現框架與應用程式之間的松耦合,是二者之間協作時常用的模式

Template Method

動機(Motivation)
  • 在軟體構建過程中,對於某一項任務,它常常有穩定的整體操作結構,但各個子步驟卻有很多改變的需求,或者由於固有的原因(比如框架與應用之間的關係)而無法和任務的整體結構同時實現。
  • 如何在確穩定操作結構的前提下,來靈活應對各個子步驟的變化或者晚期實現需求?
模式定義

定義一個操作中的演算法的骨架(穩定),而將一些步驟延遲(變化) 倒子類中。Template Method使得子類可以不改變(複用) 一個演算法的結構即可重定義(override重寫) 該演算法的某些特定步驟。

結構

Template Method

AbstractClass
+TemplateMethod() : void
+PrimitiveOperation1() : void
+PrimitiveOperation2() : void
ConcreteClass
+PrimitiveOperation1() : void
+PrimitiveOperation2() : void

注意

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;
}

Stategy

動機(Motivation)
  • 在軟體構建過程中,某些對象使用的演算法可能多種多樣,經常改變,如果將這些演算法都編碼到對象中,將會使對象變得異常複雜;
    而且有時候支持不使用的演算法也是一個性能負擔。
  • 如何在運行時根據需要透明地更改對象的演算法?將演算法與對象本身解耦,從而避免上述問題?
模式定義

定義一系列演算法,把它們一個個封裝起來,並且使它們可互相替換((變化)。該模式使得演算法可獨立於使用它的客戶程式(穩定)而變化(擴展,子類化)。

結構

Strategy Method


Observer/Event

動機(Motivation)
  • 在軟體構建過程中,我們需要為某些對象建立一種“通知依賴關係”一個對象(目標對象)的狀態發生改變,所有的依賴對象(觀察者對象)都將得到通知。如果這樣的依賴關係過於緊密將使軟體不能很好地抵禦變化。
  • 使用面向對象技術,可以將這種依賴關係弱化,並形成一種穩定的依賴關係。從而實現軟體體系結構的松耦合。
模式定義

定義對象間的一種一對多(變化)的依賴關係,以便當一個對象(Subject)的狀態發生改變時,所有依賴於它的對象都得到通知並自動更新。

結構

Observer Method

單一職責:

在軟體組件的設計中,如果責任劃分的不清晰,使用繼承得到的結果往往是隨著需求的變化,子類急劇膨脹,同時充斥著重複代碼,這時候的關鍵是劃清責任。

Decorator

動機
  • 在某些情況下我們可能會“過度地使用繼承來擴展對象的功能”,由於繼承為類型引入的靜態特質,使得這種擴展方式缺乏靈活性;並且隨著子類的增多(擴展功能的增多),各種子類的組合(擴展功能的組合)會導致更多子類的膨脹。
  • 如何使“對象功能的擴展”能夠根據需要來動態地實現?同時避免“擴展功能的增多”帶來的子類膨脹問題?從而使得任何“功能擴展變化”所導致的影響將為最低?
模式定義

動態(組合)地給一個對象增加一些額外的職責。就增加功能而言,Decorator模式比生成子類(繼承)更為靈活(消除重複代碼&減少子類個數)。

結構

在這裏插入圖片描述

Bridge

動機
  • 由於某些類型的固有的實現邏輯,使得它們具有兩個變化的維度,乃至多個緯度的變化。
  • 如何應對這種“多維度的變化”?如何利用面向對象技術來使得類型可以輕鬆地沿著兩個乃至多個方向變化,而不引入額外的複雜度?
模式定義

將抽象部分(業務功能)與實現部分(平臺實現)分離,使它們都可以獨立地變化。

結構

在這裏插入圖片描述

對象創建

通過“對象創建”模式繞開new, 來避免對象創建(new) 過程中所導致的緊耦合(依賴具體類),從而支持對象創建的穩定。它是介面抽象之後的第一步工作。

Factory Method

動機
  • 在軟體系統中,經常面臨著創建對象的工作;由於需求的變化,需要創建的對象的具體類型經常變化。
  • 如何應對這種變化?如何繞過常規的對象創建方法(new),提供一種“封裝機制”來避免客戶程式和這種“具體對象創建工作”的緊藕合?
模式定義

定義一個用於創建對象的介面,讓子類決定實例化哪一個類。Factory Method使得一個類的實例化延遲(目的:解耦,手段:虛函數)到子類。

結構

Factory Method

Abstract Factory

動機
  • 在軟體系統中,經常面臨著“一系列相互依賴的對象”的創建工作;同時,由於需求的變化,往往存在更多系列對象的創建工作。
  • 如何應對這種變化?如何繞過常規的對象創建方法(new),提供一種“封裝機制”來避免客戶程式和這種“多系列具體對象創建工作”的緊耦合?
模式定義

提供一個介面,讓該介面負責創建一系列“相關或者相互依賴的對象”,無需指定它們具體的類。

結構

Abstract Factory Method

Prototype

動機
  • 在軟體系統中,經常面臨著“某些結構複雜的對象”的創建工作;由於需求的變化,這些對象經常面臨著劇烈的變化,但是它們卻擁有比較穩定一致的介面。
  • 如何應對這種變化?如何向“客戶程式(使用這些對象的程式)"隔離出“這些易變對象”,從而使得“依賴這些易變對象的客戶程式”不隨著需求改變而改變?
模式定義

使用原型實例指定創建對象的種類,然後通過拷貝這些原型來創建新的對象。

結構

Prototype

Builder

動機
  • 在軟體系統中,有時候面臨著“一個複雜對象”的創建工作,其通常由各個部分的子對象用一定的演算法構成;由於需求的變化,這個複雜對象的各個部分經常面臨著劇烈的變化,但是將它們組合在一起的演算法卻相對穩定。
  • 如何應對這種變化?如何提供一種“封裝機制”來隔離出“複雜對象的各個部分”的變化,從而保持系統中的“穩定構建演算法”不隨著需求改變而改變?
模式定義

將一個複雜對象的構建與其表示相分離,使得同樣的構建過程(穩定)可以創建不同的表示(變化)。

結構

Builder

對象性能

面向對象很好地解決了“抽象”的問題,但是必不可免地要付出一定的代價。對於通常情況來講,面向對象的成本大都可以忽略不計。但是某些情況,面向對象所帶來的成本必須謹慎處理。

Singleton

動機
  • 在軟體系統中,經常有這樣一些特殊的類,必須保證它們在系統中只存在一個實例,才能確保它們的邏輯正確性、以及良好的效率。
  • 如何繞過常規的構造器,提供一種機制來保證一個類只有一個實例?
  • 這應該是類設計者的責任,而不是使用者的責任。
模式定義

保證一個類僅有一個實例,並提供一個該實例的全局訪問點。

結構

Singleton

SingleTon
- uniqueInstance <Object>
- SingletonData <Object>
+Instance() : <Object>
+SingletonOperation() : void
+GetSingletonData() : <Object>

注意 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;
}

Flyweight

動機
  • 在軟體系統採用純粹對象方案的問題在於大量細粒度的對象會很快充斥在系統中,從而帶來很高的運行時代價——主要指記憶體需求方面的代價。
  • 如何在避免大量細粒度對象問題的同時,讓外部客戶程式仍然能夠透明地使用面向對象的方式來進行操作?
模式定義

運用共用技術有效地支持大量細粒度的對象

結構

Flyweight

介面隔離

在組件構建過程中,某些介面之間直接的依賴常常會帶來很多問題、甚至根本無法實現。採用添加一層間接(穩定)介面,來隔離本來互相緊密關聯的介面是一種常見的解決方案。

Façade

動機
  • 上述A方案的問題在於組件的客戶和組件中各種複雜的子系統有了過多的耦合,隨著外部客戶程式和各子系統的演化,這種過多的耦合面臨很多變化的挑戰。
  • 如何簡化外部客戶程式和系統間的交互介面?如何將外部客戶程式的演化和內部子系統的變化之間的依賴相互解耦?
模式定義

為子系統中的一組介面提供一個一致(穩定)的介面,Façade模式定義了一個高層介面,這個介面使得這一子系統更加容易使用(複用)。

結構

Façade

Proxy

動機
  • 在面向對象系統中,有些對象由於某種原因(比如對象創建的開銷很大,或者某些操作需要安全控制,或者需要進程外的訪問等),直接訪問會給使用者、或者系統結構帶來很多麻煩。
  • 如何在不失去透明操作對象的同時來管理/控制這些對象特有的複雜性?增加一層間接層是軟體開發中常見的解決方式。
模式定義

為其他對象提供一種代理以控制(隔離,使用介面))對這個對象的訪問。

結構

Proxy

Mediator

動機
  • 在軟體構建過程中,經常會出現多個對象互相關聯交互的情況,對象之間常常會維持一種複雜的引用關係,如果遇到一些需求的更改,這種直接的引用關係將面臨不斷的變化。
  • 在這種情況下,我們可使用一個“仲介對象”來管理對象間的關聯關係,避免相互交互的對象之間的緊藕合引用關係,從而更好地抵禦變化。
模式定義

用一個仲介對象來封裝(封裝變化)一系列的對象交互。仲介者使各對象不需要顯式的相互引用(編譯時依賴→運行時依賴),從而使其藕合鬆散(管理變化),而且可以獨立地改變它們之間的交互。

結構

Mediator

Adapter

動機
  • 在軟體系統中,由於應用環境的變化,常常需要將“一些現存的對象”放在新的環境中應用,但是新環境要求的介面是這些現存對象所不滿足的。
  • 如何應對這種“遷移的變化”?如何既能利用現有對象的良好實現,同時又能滿足新的應用環境所要求的介面?
模式定義

將一個類的介面轉換成客戶希望的另一個介面。Adapter模式使得原本由於介面不相容而不能一起工作的那些類可以一起工作。

結構

Adapter

狀態變化:

在組件構建過程中,某些對象的狀態經常面臨變化,如何對這些變化進行有效的管理?同時又維持高層模組的穩定?“狀態變化”模式為這一問題提供了一種解決方案。

Memento

動機
  • 在軟體構建過程中,某些對象的狀態在轉換過程中,可能由於某種需要,要求程式能夠回溯到對象之前處於某個點時的狀態。如果使用一些公有介面來讓其他對象得到對象的狀態,便會暴露對象的細節實現。
  • 如何實現對象狀態的良好保存與恢復?但同時又不會因此而破壞對象本身的封裝性。
模式定義

在不破壞封裝性的前提下,捕獲一個對象的內部狀態,並在該對象之外保存這個狀態。這樣以後就可以將該對象恢復到原先保存的狀態。

結構

Memento

State

動機
  • 在軟體構建過程中,某些對象的狀態如果改變,其行為也會隨之而發生變化,比如文檔處於只讀狀態,其支持的行為和讀寫狀態支持的行為就可能完全不同。
  • 如何在運行時根據對象的狀態來透明地更改對象的行為?而不會為對象操作和狀態轉化之間引入緊藕合?
模式定義

允許一個對象在其內部狀態改變時改變它的行為。從而使對象看起來似乎修改了其行為。

結構

State

數據結構

常常有一些組件在內部具有特定的數據結構,如果讓客戶程式依賴這些特定的數據結構,將極大地破壞組件的複用。這時候,將這些特定數據結構封裝在內部,在外部提供統一的介面,來實現與特定數據結構無關的訪問,是一種行之有效的解決方案。

Composite

動機
  • 在軟體在某些情況下,客戶代碼過多地依賴於對象容器複雜的內部實現結構,對象容器內部實現結構(而非抽象介面)的變化將引起客戶代碼的頻繁變化,帶來了代碼的維護性、擴展性等弊端。
  • 如何將“客戶代碼與複雜的對象容器結構”解耦?讓對象容器自己來實現自身的複雜結構,從而使得客戶代碼就像處理簡單對象一樣來處理複雜的對象容器?
模式定義

將對象組合成樹形結構以表示“部分-整體”的層次結構。
Composite使得用戶對單個對象和組合對象的使用具有一致性(穩定)

結構

Composite

Iterator

動機
  • 在軟體構建過程中,集合對象內部結構常常變化各異。但對於這些集合對象,我們希望在不暴露其內部結構的同時,可以讓外部客戶代碼透明地訪問其中包含的元素;同時這種“透明遍曆”也為“同一種演算法在多種集合對象上進行操作”提供了可能。
  • 使用面向對象技術將這種遍曆機制抽象為“迭代器對象”為“應對變化中的集合對象”提供了一種優雅的方式。
模式定義

提供一種方法順序訪問一個聚合對象中的各個元素,而又不暴露(穩定)該對象的內部表示。

結構

Iterator

Chain of Resposibility

動機
  • 在軟體構建過程中,一個請求可能被多個對象處理,但是每個請求在運行時只能有一個接受者,如果顯式指定,將必不可少地帶來請求發送者與接受者的緊耦合。
  • 如何使請求的發送者不需要指定具體的接受者?讓請求的接受者自己在運行時決定來處理請求,從而使兩者解耦。
模式定義

使多個對象都有機會處理請求,從而避免請求的發送者和接收者之間的耦合關係。將這些對象連成一條鏈,並沿著這條鏈傳遞請求,直到有一個對象處理它為止。

結構

Handler

行為變化

在組件的構建過程中,組件行為的變化經常導致組件本身劇烈的變化。“行為變化”模式將組件的行為和組件本身進行解耦,從而支持組件行為的變化,實現兩者之間的松耦合。

Command

動機
  • 在軟體構建過程中,“行為請求者”與“行為實現者”通常呈現一種“緊耦合”。但在某些場合一比如需要對行為進行“記錄、撤銷/重(undo/redo)、事務”等處理,這種無法抵禦變化的緊耦合是不合適的。
  • 在這種情況下,如何將“行為請求者”與“行為實現者”解耦?將一組行為抽象為對象,可以實現二者之間的松耦合。
模式定義

將一個請求(行為)封裝為一個對象,從而使你可用不同的請求對客戶進行參數化;對請求排隊或記錄請求日誌,以及支持可撤銷的操作。

結構

Command

Visitor

動機
  • 在軟體構建過程中,由於需求的改變,某些類層次結構中常常需要增加新的行為(方法),如果直接在基類中做這樣的更改,將會給子類帶來很繁重的變更負擔,甚至破壞原有設計。
  • 如何在不更改類層次結構的前提下,在運行時根據需要透明地為類層次結構上的各個類動態添加新的操作,從而避免上述問題?
模式定義 (Double Dispatch)

表示一個作用於某對象結構中的各元素的操作。使得可以在不改變(穩定)各元素的類的前提下定義(擴展)作用於這些元素的新操作(變化)。

結構

Visitor

領域問題

在特定領域中,某些變化雖然頻繁,但可以抽象為某種規則。這時候,結合特定領域,將問題抽象為語法規則,從而給出在該領域下的一般性解決方案。

Interpreter

動機
  • 在軟體構建過程中,如果某一特定領域的問題比較複雜,類似的結構不斷重複出現,如果使用普通的編程方式來實現將面臨非常頻繁的變化。
  • 在這種情況下,將特定領域的問題表達為某種語法規則下的句子,然後構建一個解釋器來解釋這樣的句子,從而達到解決問題的目的。
模式定義

給定一個語言,定義它的文法的一種表示,並定義一種解釋器,這個解釋器使用該表示來解釋語言中的句子。

結構

Interpreter

編譯原理(C++)

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;
n
e
w
.
S0
S1
S2
S3

詞法分析器

對於需要實現轉移圖的代碼來說,轉移圖充當了這些代碼的抽象。轉移圖還可以看做是形式化的數學對象,稱為有限自動機,它定義了識別器的規格。形式上,有限自動機(FA)是一個五元組( S \mathrm{S} S Σ \mathrm{\Sigma} Σ δ \mathrm{\delta} δ s 0 \mathrm{s_{0}} s0 S a \mathrm{S_{a}} Sa),其中各分量的含義如下所示。

  • S S S是識別器中的有限狀態集,以及一個錯誤狀態 s e s_e se
  • Σ \Sigma Σ是識別器使用的有限字母表。通常, Σ \Sigma Σ是轉移圖中邊的標籤的合集。
  • δ ( s , c ) \delta(s, c) δ(s,c)是識別器的轉移函數。它將每個狀態 s ∈ S s \in S sS和每個字元 c ∈ Σ c \in \Sigma cΣ的組合 ( s , c ) (s, c) (s,c)映射到下一個狀態。在狀態 s i s_i si遇到輸入字元 c c cFA將採用轉移 s i → c δ ( s i , c ) s_i \stackrel{c}{\rightarrow} \delta(s_i, c) sicδ(si,c)
  • s 0 ∈ S s_0 \in S s0S S S S是指定的起始狀態。
  • S 4 S_4 S4是接受狀態的集合, S A ⊆ S S_A \subseteq S SAS S A 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=0Ri

求補運算符 符號表示法 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,並組成用於更複雜REFA

  • 假設,我們有用於 m m m n n n兩個REFA,分別表示為 F A m FA_m FAm F A n FA_n FAn
  • F A m FA_m FAm的接受狀態添加一個針對輸入 ϵ \epsilon ϵ的轉移,轉移到 F A n FA_n FAn的初始狀態,把各個狀態重新編號,然後使用 F A n FA_n FAn的接受狀態作爲新 F A FA FA的接受狀態,這樣將構建用於處理 m n mn mnFA.
m
n
m
epsilon
n
.
s0
s1
.
s0
s1
.
s0
s1
s2
s3

ε轉移 針對空串輸入ε進行的轉移,不會改變輸入流中的讀寫位置

如果一個FA包含了 s 0 s_0 s0這樣的狀態,即對單個輸入字符有多種可能的轉移,則稱爲非確定性有限自動機(Nondeterministic Finite Automation, NFA)

非確定性FA 允許在空串輸入 ϵ \epsilon ϵ上進行轉移的FA,其狀態對同一字符輸入可能有多種轉移。

確定性FA 轉移函數為單值得FA成爲DFADFA不允許 ϵ \epsilon ϵ轉移。

兩種不同的NFA模型

  • 每次NFA必須進行非確定性選擇時,如果有使得輸入字串轉向接受狀態的轉移存在,則採用這樣的轉移。使用“全知”NFA的這種模型頗有吸引力,因為它(表面上)維護了DFA那種定義明確的接受機制。本質上,NFA在每個狀態都需要猜測正確的轉移。
  • 每次NFA必須進行非確定性選擇時,NFA都克隆自身,以追蹤每個可能的轉移。因而,對於一個給定的輸入字元,NFA實際上是處於一個特定的狀態集合,其中每個狀態都由NFA的某個克隆來處理。在這種模型中,NFA併發地追蹤所有轉移路徑。在任一時刻,存在NFA克隆副本活動狀態的那些集合稱為NFA的配置。當NFA到達一個配置,此時NFA已經耗盡輸入字串,且配置中的一個或多個克隆副本處於某個接受狀態,則NFA接受該輸入字串。
從正則表達式到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 演算法

alpha
beta
delta
omega

原理

灰狼優化演算法(GWO),靈感來自於灰狼。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=CXpX(t)(GWO:1)

注意

  • 式子(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)AD(GWO:2)

注意

  • 式子(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=2ar1a(GWO:3)

C = 2 ⋅ r 2 (GWO:4) \mathrm{C} = 2 \cdot r_{2} \tag{GWO:4} C=2r2(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)

  • 式子(6)分別定義了狼群中 ω 個體朝向 α , β 和 δ 前進的步長和方向。
  • 式子(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 $時,灰狼與獵物分離,希望找到更合適的獵物(全局最優).

img

GWO演算法還有另一個組件 C C C來幫助發現新的解決方案.由式(4)可知, C C C [ 0 , 2 ] [0,2] [0,2]之間的隨機值. C C C表示狼所在的位置對獵物影響的隨機權重, C > 1 C>1 C1表示影響權重大,反之,表示影響權重小.這有助於GWO演算法更隨機地表現並支持探索,同時可在優化過程中避免陷入局部最優.另外,與 A A A不同, C C C是非線性減小的.這樣,從最初的迭代到最終的迭代中,它都提供了決策空間中的全局搜索.在演算法陷入了局部最優並且不易跳出時, C C C的隨機性在避免局部最優方面發揮了非常重要的作用,尤其是在最後需要獲得全局最優解的迭代中。

演算法過程

YES
NO
開始
初始化灰狼種群
計算灰狼個體的適度, 保持適應度最好的前3匹狼
更新當前灰狼的位置
更新a, A和C
計算全部灰狼的適應度
更新alpha beta和delta的適應度和位置
達到最大迭代代數
結束

Raft 演算法

说明

分佈式存儲系統通常通過維護多個副本來進行容錯,提高系統的可用性。要實現此目標,就必須要解決分佈式存儲系統的最核心問題:維護多個副本的一致性。

首先需要解釋一下什麼是一致性(consensus),它是構建具有容錯性(fault-tolerant)的分佈式系統的基礎。 在一個具有一致性的性質的集群裏面,同一時刻所有的結點對存儲在其中的某個值都有相同的結果,即對其共用的存儲保持一致。集群具有自動恢復的性質,當少數結點失效的時候不影響集群的正常工作,當大多數集群中的結點失效的時候,集群則會停止服務(不會返回一個錯誤的結果)。

一致性協議就是用來幹這事的,用來保證即使在部分(確切地說是小部分)副本宕機的情況下,系統仍然能正常對外提供服務。一致性協議通常基於replicated state machines,即所有結點都從同一個state出發,都經過同樣的一些操作序列(log),最後到達同樣的state

架構

img

系統中每個結點有三個組件:

  • 狀態機: 當我們說一致性的時候,實際就是在說要保證這個狀態機的一致性。狀態機會從log裏面取出所有的命令,然後執行一遍,得到的結果就是我們對外提供的保證了一致性的數據
  • Log: 保存了所有修改記錄
  • 一致性模組: 一致性模組演算法就是用來保證寫入的log的命令的一致性,這也是raft演算法核心內容

協議內容

Raft協議將一致性協議的核心內容分拆成為幾個關鍵階段,以簡化流程,提高協議的可理解性。

Leader election

Raft協議的每個副本都會處於三種狀態之一:LeaderFollowerCandidate

Leader:所有請求的處理者,Leader副本接受client的更新請求,本地處理後再同步至多個其他副本;
Follower:請求的被動更新者,從Leader接受更新請求,然後寫入本地日誌檔
Candidate:如果Follower副本在一段時間內沒有收到Leader副本的心跳,則判斷Leader可能已經故障,此時啟動選主過程,此時副本會變成Candidate狀態,直到選主結束。

時間被分為很多連續的隨機長度的termterm有唯一的id。每個term一開始就進行選主:

  1. Follower將自己維護的current_term_id1
  2. 然後將自己的狀態轉成Candidate
  3. 發送RequestVoteRPC消息(帶上current_term_id) 給 其他所有server

這個過程會有三種結果:

  • 自己被選成了主。當收到了majority的投票後,狀態切成Leader,並且定期給其他的所有server發心跳消息(不帶logAppendEntriesRPC)以告訴對方自己是current_term_id所標識的termleader。每個term最多只有一個leaderterm id作為logical clock,在每個RPC消息中都會帶上,用於檢測過期的消息。當一個server收到的RPC消息中的rpc_term_id比本地的current_term_id更大時,就更新current_term_idrpc_term_id,並且如果當前stateleader或者candidate時,將自己的狀態切成follower。如果rpc_term_id比本地的current_term_id更小,則拒絕這個RPC消息。
  • 別人成為了主。如1所述,當Candidator在等待投票的過程中,收到了大於或者等於本地的current_term_id的聲明對方是leaderAppendEntriesRPC時,則將自己的state切成follower,並且更新本地的current_term_id
  • 沒有選出主。當投票被瓜分,沒有任何一個candidate收到了majorityvote時,沒有leader被選出。這種情況下,每個candidate等待的投票的過程就超時了,接著candidates都會將本地的current_term_id再加1,發起RequestVoteRPC進行新一輪的leader election
投票策略
  • 每個節點只會給每個term投一票,具體的是否同意和後續的Safety有關。
  • 當投票被瓜分後,所有的candidate同時超時,然後有可能進入新一輪的票數被瓜分,為了避免這個問題,Raft採用一種很簡單的方法:每個Candidateelection timeout150ms-300ms之間隨機取,那麼第一個超時的Candidate就可以發起新一輪的leader election,帶著最大的term_id給其他所有server發送RequestVoteRPC消息,從而自己成為leader,然後給他們發送心跳消息以告訴他們自己是主。

Log Replication

Leader被選出來後,就可以接受客戶端發來的請求了,每個請求包含一條需要被replicated state machines執行的命令。leader會把它作為一個log entry append到日誌中,然後給其他的serverAppendEntriesRPC請求。當Leader確定一個log entrysafely replicated了(大多數副本已經將該命令寫入日誌當中),就apply這條log entry到狀態機中然後返回結果給客戶端。如果某個Follower宕機了或者運行的很慢,或者網路丟包了,則會一直給這個FollowerAppendEntriesRPC直到日誌一致。

當一條日誌是commited時,Leader才可以將它應用到狀態機中。Raft保證一條commitedlog entry已經持久化了並且會被所有的節點執行。

當一個新的Leader被選出來時,它的日誌和其他的Follower的日誌可能不一樣,這個時候,就需要一個機制來保證日誌的一致性。一個新leader產生時,集群狀態可能如下:

img

最上面這個是新Leadera~fFollower,每個格子代表一條log entry,格子內的數字代表這個log entry是在哪個term上產生的。

Leader產生後,就以Leader上的log為准。其他的follower要麼少了數據比如b,要麼多了數據,比如d,要麼既少了又多了數據,比如f

因此,需要有一種機制來讓leaderfollowerlog達成一致,leader會為每個follower維護一個nextIndex,表示leader給各個follower發送的下一條log entrylog中的index,初始化為leader的最後一條log entry的下一個位置。leaderfollower發送AppendEntriesRPC消息,帶著(term_id, (nextIndex-1))term_id(nextIndex-1)這個槽位的log entryterm_idfollower接收到AppendEntriesRPC後,會從自己的log中找是不是存在這樣的log entry,如果不存在,就給leader回復拒絕消息,然後leader則將nextIndex1,再重複,知道AppendEntriesRPC消息被接收。

leaderb為例:

初始化,nextIndex11leaderb發送AppendEntriesRPC(6,10)b在自己log10號槽位中沒有找到term_id6log entry。則給leader回應一個拒絕消息。接著,leadernextIndex減一,變成10,然後給b發送AppendEntriesRPC(6, 9)b在自己log9號槽位中同樣沒有找到term_id6log entry。迴圈下去,直到leader發送了AppendEntriesRPC(4,4)b在自己log的槽位4中找到了term_id4log entry。接收了消息。隨後,leader就可以從槽位5開始給b推送日誌了。

Safety
  • 哪些follower有資格成為leader?

Raft保證被選為新leader的節點擁有所有已提交的log entry,這與ViewStamped Replication不同,後者不需要這個保證,而是通過其他機制從follower拉取自己沒有的提交的日誌記錄

這個保證是在RequestVoteRPC階段做的,candidate在發送RequestVoteRPC時,會帶上自己的最後一條日誌記錄的term_idindex,其他節點收到消息時,如果發現自己的日誌比RPC請求中攜帶的更新,拒絕投票。日誌比較的原則是,如果本地的最後一條log entryterm id更大,則更新,如果term id一樣大,則日誌更多的更大(index更大)。

  • 哪些日誌記錄被認為是commited?
  1. leader正在replicate當前term(即term 2)的日誌記錄給其他Follower,一旦leader確認了這條log entrymajority寫盤了,這條log entry就被認為是committed。如圖a,S1作為當前termterm2leaderlog index2的日誌被majority寫盤了,這條log entry被認為是commited
  2. leader正在replicate更早的termlog entry給其他follower。圖b的狀態是這麼出來的。
對協議的一點修正

在實際的協議中,需要進行一些微調,這是因為可能會出現下麵這種情況:

img

  1. 在階段a,term2,S1是Leader,且S1寫入日誌(term, index)(2, 2),並且日誌被同步寫入了S2;
  2. 在階段b,S1離線,觸發一次新的選主,此時S5被選為新的Leader,此時系統term為3,且寫入了日誌(term, index)(3, 2);
  3. S5尚未將日誌推送到Followers變離線了,進而觸發了一次新的選主,而之前離線的S1經過重新上線後被選中變成Leader,此時系統term為4,此時S1會將自己的日誌同步到Followers,按照上圖就是將日誌(2, 2)同步到了S3,而此時由於該日誌已經被同步到了多數節點(S1, S2, S3),因此,此時日誌(2,2)可以被commit了(即更新到狀態機);
  4. 在階段d,S1又很不幸地下線了,系統觸發一次選主,而S5有可能被選為新的Leader
    1. 這是因為S5可以滿足作為主的一切條件:
      1. term = 3 > 2,
      2. 最新的日誌index為2,比大多數節點(如S2/S3/S4的日誌都新),然後S5會將自己的日誌更新到Followers,於是S2、S3中已經被提交的日誌(2,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)

Log Compaction

在實際的系統中,不能讓日誌無限增長,否則系統重啟時需要花很長的時間進行回放,從而影響availabilityRaft採用對整個系統進行snapshot來處理,snapshot之前的日誌都可以丟棄。Snapshot技術在ChubbyZooKeeper系統中都有採用。

Raft使用的方案是:每個副本獨立的對自己的系統狀態進行Snapshot,並且只能對已經提交的日誌記錄(已經應用到狀態機)進行snapshot

Snapshot中包含以下內容:

  • 日誌元數據,最後一條commited log entry(log index, last_included_term)。這兩個值在Snapshot之後的第一條log entryAppendEntriesRPCconsistency check的時候會被用上,之前講過。一旦這個server做完了snapshot,就可以把這條記錄的最後一條log index及其之前的所有的log entry都刪掉。
  • 系統狀態機:存儲系統當前狀態(這是怎麼生成的呢?)

snapshot的缺點就是不是增量的,即使記憶體中某個值沒有變,下次做snapshot的時候同樣會被dump到磁片。當leader需要發給某個followerlog entry被丟棄了(因為leader做了snapshot),leader會將snapshot發給落後太多的follower。或者當新加進一臺機器時,也會發送snapshot給它。發送snapshot使用新的RPCInstalledSnapshot

snapshot有一些需要注意的性能點,

  1. 不要做太頻繁,否則消耗磁片帶寬。
  2. 不要做的太不頻繁,否則一旦節點重啟需要回放大量日誌,影響可用性。系統推薦當日志達到某個固定的大小做一次snapshot
  3. 做一次snapshot可能耗時過長,會影響正常log entryreplicate。這個可以通過使用copy-on-write的技術來避免snapshot過程影響正常log entryreplicate
集群拓撲變化

集群拓撲變化的意思是在運行過程中多副本集群的結構性變化,如增加/減少副本數、節點替換等。

Raft協議定義時也考慮了這種情況,從而避免由於下線老集群上線新集群而引起的系統不可用。Raft也是利用上面的Log Entry和一致性協議來實現該功能。

假設在Raft中,老集群配置用Cold表示,新集群配置用Cnew表示,整個集群拓撲變化的流程如下:

  1. 當集群成員配置改變時,leader收到人工發出的重配置命令從Cold切成Cnew
  2. Leader副本在本地生成一個新的log entry,其內容是Cold∪Cnew,代表當前時刻新舊拓撲配置共存,寫入本地日誌,同時將該log entry推送至其他Follower節點
  3. Follower副本收到log entry後更新本地日誌,並且此時就以該配置作為自己瞭解的全局拓撲結構,
  4. 如果多數Follower確認了Cold ∪ Cnew這條日誌的時候,LeaderCommit這條log entry
  5. 接下來Leader生成一條新的log entry,其內容是全新的配置Cnew,同樣將該log entry寫入本地日誌,同時推送到Follower上;
  6. Follower收到新的配置日誌Cnew後,將其寫入日誌,並且從此刻起,就以該新的配置作為系統拓撲,並且如果發現自己不在Cnew這個配置中會自動退出
  7. Leader收到多數Follower的確認消息以後,給客戶端發起命令執行成功的消息
異常分析
  • 如果LeaderCold ∪ Cnew尚未推送到FollowerLeader就掛了,此時選出的新的Leader並不包含這條日誌,此時新的Leader依然使用Cold作為全局拓撲配置
  • 如果LeaderCold ∪ 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,如下圖:

img

假如此時變更了系統配置,將集群範圍擴大為5個,新增了S4和S5兩個服務節點,這個消息被分別推送至S2和S3,但是假如只有S3收到了消息並處理,S2尚未得到該消息

img

這時在S2的眼裏,拓撲依然是<S1, S2, S3>,而在S3的眼裏拓撲則變成了<S1, S2, S3, S4, S5>。假如此時由於某種原因觸發了一次新的選主,S2和S3分別發起選主的請求:

img

最終,候選者S2獲得了S1和S2自己的贊成票,那麼在它眼裏,它就變成了Leader,而S3獲得了S4、S5和S3自己的贊成票,在它眼裏S3也變成了Leader,那麼多Leader的問題就產生了。而產生該問題的最根本原因是S2和S3的系統視圖不一致。

C++ 網絡編程部分

UDP and TCP

TCP

  • Why my TCP is not reliable?
    • Sent all then close,but last few bytes are lost.
  • Wrong:send()+close()
    • When there is data in input buffer,close() will cause RST,terminate connection prematurely
    • With or without SO_LINGER? TL;DR:don’t use LINGER
  • Correct sender:send()+shutdown(WR) + read()→ 0 + close()
  • Correct receiver:read()→ 0 + if nothing more to send + `close()``
  • Or,better,design you app protocol to allow safe disconnecting
SIGPIPE
  • SIGPIPE is sent to writer when the other end of "pipe"is closed
    • Default action is terminating the process,good for command line piping
      • gunzip-c huge.log.gz | grep ERROR | head
  • What if client closes a socket when server is still writing to it?
    • Client could be offensive or misbehaving,server should be defensive
  • The default action will kill the server and affect all clients
    • Where is my server process after Ctrl-C a client?
  • So ignore SIGPIPE in network programs
    • If a program writes to stdout,check return value of printf() then exit()
Nagle’s algorithm, TCP_NODELAY
  • Affect latency of request-response protocol
    • write() will not send data if there is any unacked TCP segment
  • For write-write-read, the second write will be delayed by one RTT
    • Solution: buffering, make it write-read
    • However, it still affect request pipelining
  • I recommend to disable Nagle’s algorithm by default
    • Go does this for every TCP connection
Trilogy of TCP client/server
  • SO_REUSEADDR
    • So that a TCP server can restart immediately after crash/kill
    • Also needed for fork-per-connection model
  • lgnore SIGPIPE
  • TCP_NODELAY

Natcat

Operation modes

ServerClient
bind+listen+acceptresolve address + connect
  • Once connection has been established, c/s behave the same
  • Two parallel loops serve two directions:
    • Read from stdin, write to TCP socket
    • Read from TCP socket, write to stdout

Thread-per-connection with blocking IO

  • Those two parallel loops map to two thread natively
    • Thread-per-half-connection,to be exact
    • Go uses this model universally
  • Two directions are not affecting each other
    • How to tell the other thread to exit,when it’s blocking on read or write?
  • Blocking IO throttles traffic by default

IO-multiplexing

  • IO-multiplexing is also called event-driven, event-based, reactor
    • It is not asynchronous!
  • Use only one thread
    • Stock netcats are implemented in this way
  • With blocking IO: one direction could block the other
  • Demo
    • Server: chargen.py, a white hole
    • Client: nc, nc </dev/zero

Non-blocking IO to rescue

  • Code is much more complicated, short read and short write
    • Caution: in this example we only show how to deal with short write
  • Application level buffers are needed
  • Usually non-blocking IO is delegated to a good network library,like libevent, netty, muduo, etc.
  • ACE is a bad example, its Reactor only dispatches events, you have to write your own code for non-blocking read/write in every program.

Why non-blocking IO is a must

  • Example from Unix Network Programming
    • Calling accept(2) after a listening socket is "ready for reading"could block,because client could have disconnected in between.
  • http://man7.org/linux/man-pages/man2/select.2.html
    • Under Linux,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.

When short write happens in non-blocking IO

  • Save remaining data in some buffer
    • Never call write() when buffer is not empty, it reorders data
    • Alternatively,always send from buffer
  • Start watching POLLOUT event
    • Meanwhile, any write() should append the buffer instead
  • When POLLOUT is ready, write from buffer
    • Consume buffer
  • If buffer becomes empty, stop watching POLLOUT event
    • Otherwise, it end up with a busy loop

What if sink is slow?

  • The common pitfall in non-blocking IO
    • Avoid memory exhaustion or dropping messages
  • Stop reading and stop watching readiness
    • Otherwise end up with a busy loop in level-triggered 10 multiplexer
  • Sender will be throttled because TCP advertised window drops to zero
  • Amortized throughput will be the same
    • Due to buffering, instant throughput will be different

Level-trigger and edge trigger?

  • 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 this
  • epoll(5) stands for edge-poll, works in both LT and ET mode
  • No up-to-date benchmarks show which is faster
  • Edge trigger works best for writing and accepting
  • Level trigger works best for reading data Current Linux kernel doesn’t support mix LT/ET for on socket
    • Muduo uses level trigger

Procmon

DumpyLoad

Code

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
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值