gperftools源碼分析和項目應用 - CPU Profiler

gperftools源碼分析和項目應用 - CPU Profiler

原文:https://blog.csdn.net/yubo112002/article/details/81076821 小程序

原文連接:http://www.tealcode.com/gperftool_source_analysis/數組

 

Google的gperftool是一款很是好用的服務器程序性能分析工具,能提供很是直觀和相對準確的性能數據,讓開發者能夠進行更有方向能的優化。關於工具的使用方法,用gperftool做關鍵字搜索,會有不少的結果,這裏就很少講了。本文的重點在於深刻到工具源碼的內部,瞭解一下這個工具的實現原理和數據格式,而後介紹一下我從事的一個商業項目集成使用這個工具的一點小技巧。服務器

 

工做原理
這一部分會重點解答這麼幾個問題:數據結構

一、這個工具是如何收集程序的性能數據的?多線程

二、這個工具使用的時候,不須要在產品代碼中插入任何的額外代碼,那麼它怎麼能知道哪一個函數執行了多長時間呢?app

三、工具的介紹上說,這個工具不工做的時候,對目標程序的執行性能幾乎沒有任何影響。可信嗎?幾乎沒影響究竟是多大的影響?產品能接受這樣的影響嗎?socket

若是上面三個問題你已經能很是清楚地解答了,那這篇文章你能夠直接跳到最後一部分:項目應用小技巧那裏了,看看這個技巧對你是否是有點用處。函數

 

廢話少說,直接到工具的源碼中去找答案吧。考慮到貼太多的代碼在這裏容易迷失在沒必要要的細節裏,我這裏就只放最核心的功能代碼了,爲了讓邏輯看上去更清晰,下面貼出的代碼都刪除了一些錯誤檢查類的容錯代碼。工具

extern 「C」 PERFTOOLS_DLL_DECL int ProfilerStart(const char* fname) {
    return CpuProfiler::instance_.Start(fname, NULL);
}

bool CpuProfiler::Start(const char* fname, const ProfilerOptions* options) {
    collector_.Start(fname, collector_options);
    // Setup handler for SIGPROF interrupts
    EnableHandler();
    return true;
}
CpuProfiler啓動的時候,核心功能就是啓動數據收集器(collector_),這個數據收集器的Start函數的功能就是初始化數據收集須要的數據結構,並建立數據收集文件:源碼分析

bool ProfileData::Start(const char* fname, const ProfileData::Options& options) {

    // Open output file and initialize various data structures
    int fd =open(fname, O_CREAT | O_WRONLY | O_TRUNC, 0666);
    start_time_ = time(NULL);
    fname_ = strdup(fname);

    // Reset counters
    num_evicted_ = 0;
    count_ = 0;
    evictions_ = 0;
    total_bytes_ = 0;

    hash_ = new Bucket[kBuckets];
    evict_ = new Slot[kBufferLength];
    memset(hash_, 0, sizeof(hash_[0]) * kBuckets);

    // Record special entries
    evict_[num_evicted_++] = 0; // count for header
    evict_[num_evicted_++] = 3; // depth for header
    evict_[num_evicted_++] = 0; // Version number
    CHECK_NE(0, options.frequency());
    int period =1000000/ options.frequency();
    evict_[num_evicted_++] = period; // Period (microseconds)
    evict_[num_evicted_++] = 0; // Padding
    out_ = fd;
    return true;
}
而後就是開啓了CpuProfiler的一個處理函數,而這個函數作的事情就是把prof_handler這個函數註冊到了某個地方。

void CpuProfiler::EnableHandler() {
    prof_handler_token_ = ProfileHandlerRegisterCallback(prof_handler, this);
}
註冊這個函數是幹什麼用的呢?

ProfileHandlerToken* ProfileHandlerRegisterCallback(
    ProfileHandlerCallback callback, void* callback_arg) {
    return ProfileHandler::Instance()->RegisterCallback(callback, callback_arg);
}
好吧,看來功能都在ProfileHandler裏面了。ProfileHandler又是一個單例類,來看它的構造函數:

ProfileHandler::ProfileHandler() {

    timer_type_ = (getenv(「CPUPROFILE_REALTIME」) ? ITIMER_REAL : ITIMER_PROF);
    signal_number_ = (timer_type_ == ITIMER_PROF ? SIGPROF : SIGALRM);

    // Get frequency of interrupts (if specified)
    char junk;
    constchar* fr =getenv(「CPUPROFILE_FREQUENCY」);

    if (fr != NULL && (sscanf(fr, "%u%c", &frequency_, &junk) == 1) && (frequency_ > 0)) {
        // Limit to kMaxFrequency
        frequency_ = (frequency_ > kMaxFrequency) ? kMaxFrequency : frequency_;
    } else {
        frequency_ = kDefaultFrequency;
    }

    // Install the signal handler.
    structsigaction sa;
    sa.sa_sigaction = SignalHandler;
    sa.sa_flags = SA_RESTART | SA_SIGINFO;
    sigemptyset(&sa.sa_mask);
    sigaction(signal_number_, &sa, NULL);
}
這個構造函數,根據環境變量CPUPROFILE_REALTIME的配置,來決定讓SIGPROF仍是SIGALRM信號來觸發SignalHandler信號處理函數,並根據環境變量CPUPROFILE_FREQUENCY的配置來設置本身的一個頻率變量 frequency_,若是沒有設置,就使用默認值,這個默認值是100,而最大值是4000.

而後ProfileHandler的RegisterCallback函數的實現以下:

ProfileHandlerToken* ProfileHandler::RegisterCallback(ProfileHandlerCallback callback, void* callback_arg) {

    ProfileHandlerToken* token = new ProfileHandlerToken(callback, callback_arg);
    SpinLockHolder cl(&control_lock_);
    DisableHandler();
    {
        SpinLockHolder sl(&signal_lock_);
        callbacks_.push_back(token);
    }

    // Start the timer if timer is shared and this is a first callback.
    if ((callback_count_ == 0) && (timer_sharing_ == TIMERS_SHARED)) {
        StartTimer();
    }
    ++callback_count_;
    EnableHandler();
    return token;
}
這個函數就如其函數名字,把指定的回調函數添加到callbacks_裏面去,而後在加入第一個callback的時候調用StartTimer()函數來啓動定時器,而後調用EnableHander函數來開啓回調。StartTimer()的實現以下:

void ProfileHandler::StartTimer() {
    struct itimerval timer;
    timer.it_interval.tv_sec = 0;
    timer.it_interval.tv_usec = 1000000 / frequency_;
    timer.it_value = timer.it_interval;
    setitimer(timer_type_, &timer, 0);
}
而EnableHandler()的實現以下:

void ProfileHandler::EnableHandler() {
    struct sigaction sa;
    sa.sa_sigaction = SignalHandler;
    sa.sa_flags = SA_RESTART | SA_SIGINFO;
    sigemptyset(&sa.sa_mask);
    const int signal_number = (timer_type_ == ITIMER_PROF ? SIGPROF : SIGALRM);
    RAW_CHECK(sigaction(signal_number, &sa, NULL) == 0, "sigprof (enable)");
}
好了,到這裏,這個工具的基本工做原理已經能夠猜出個大概了。它用setitimer啓動一個系統定時器,這個定時器會每秒鐘執行觸發frequency次SIGPROF或者SIGALRM信號,從而去觸發上面註冊的信號處理函數。那麼猜測,信號處理函數裏面應該會用backtrace去檢查一下目標程序執行到什麼位置了。那麼繼續看信號處理函數裏面都作了些什麼事情吧。

void CpuProfiler::prof_handler(int sig, siginfo_t*, void* signal_ucontext, void* cpu_profiler) {
    CpuProfiler* instance = static_cast<CpuProfiler*>(cpu_profiler);
    if (instance->filter_==NULL||(*instance->filter_)(instance->filter_arg_)) {
        void* stack[ProfileData::kMaxStackDepth];
        stack[0] = GetPC(*reinterpret_cast<ucontext_t*>(signal_ucontext));
        int depth = GetStackTraceWithContext(stack +1, arraysize(stack) -1, 3, signal_ucontext);

        void**used_stack;
        if (depth >0&& stack[1] == stack[0]) {
            // in case of non-frame-pointer-based unwinding we will get
            // duplicate of PC in stack[1], which we don’t want
            used_stack = stack + 1;
        } else {
            used_stack = stack;
            depth++; // To account for pc value in stack[0];
        }
        instance->collector_.Add(depth, used_stack);
    }
}
果真是獲取backtrace,而後記錄到colloector_裏面去。另外這裏爲了讓代碼邏輯看起來更清晰,沒有貼出來源代碼中的大段註釋,那些註釋詳細解釋了對stack數組下標的那幾個加減值,感興趣的話能夠自行前往源代碼去進一步閱讀。

到此爲止,本文開頭的三個問題均可以有答案了。

一、這個工具是用系統定時器定時產生信號的方式,在信號處理函數裏面獲取當前的調用堆棧來肯定當前落在哪一個函數裏面的。獲取頻率默認是每10ms採樣一次,參數是可調的,可是最大頻率是4000,也就是支持的最小採樣間隔是250微秒;

二、這個工具獲取到的性能數據是基於統計數據的,也就是他並不真正跟蹤函數的每一次調用過程,而是均勻地採樣並記錄採樣點所落在的函數調用位置,用這些統計數據來計算每一個函數的執行時間佔比。這個數據並非準確的數據,可是隻要運行時間相對比較長,統計數據仍是能比較準確地說明問題的。而這也是爲何說這個工具是比較好的服務器程序性能分析工具,而對一些客戶端程序,好比遊戲客戶端並非很是合適。由於遊戲客戶端上,相比長時間的統計數據,它們一般更加關心的是某些幀內的具體負載狀況。

三、這個工具不工做的時候,就會把系統定時器取消掉,不會定時產生中斷信號,不會觸發中斷處理程序,因此對運行程序的影響真的是很小,運行效率上能夠說徹底沒有影響。而對產品的影響只是多佔用一些連接profiler庫的內存而已。

收集器中的數據格式
先來看ProfileData類中相關的結構定義:

static const int kAssociativity =4; // For hashtable
static const int kBuckets =1<<10; // For hashtable
static const int kBufferLength =1<<18; // For eviction buffer

// Type of slots: each slot can be either a count, or a PC value
typedef uintptr_t Slot;

// Hash-table/eviction-buffer entry (a.k.a. a sample)
struct Entry {
  Slot count; // Number of hits
  Slot depth; // Stack depth
  Slot stack[kMaxStackDepth]; // Stack contents
};

// Hash table bucket
struct Bucket {
  Entry entry[kAssociativity];
};
使用這些結構的成員以下:

Bucket* hash_; // hash table
Slot* evict_; // evicted entries
int num_evicted_; // how many evicted entries?
建立代碼:

hash_ = new Bucket[kBuckets];             //長度1024的hash表
evict_ = new Slot[kBufferLength];         //256K的移除buffer
memset(hash_, 0, sizeof(hash_[0]) * kBuckets);

// Record special entries
evict_[num_evicted_++] = 0; // count for header
evict_[num_evicted_++] = 3; // depth for header
evict_[num_evicted_++] = 0; // Version number

CHECK_NE(0, options.frequency());
int period =1000000/ options.frequency();
evict_[num_evicted_++] = period; // Period (microseconds)
evict_[num_evicted_++] = 0; // Padding
收集數據的邏輯:

//1. Make hash-value
Slot h = 0;
for (int i =0; i < depth; i++) {
    Slot slot = reinterpret_cast<Slot>(stack[i]);
    h = (h << 8) | (h >> (8*(sizeof(h)-1)));
    h += (slot * 31) + (slot * 7) + (slot * 3);
}
count_++;

//2. See if table already has an entry for this trace
bool done =false;
Bucket* bucket = &hash_[h % kBuckets];

for (int a =0; a < kAssociativity; a++) {
    Entry* e = &bucket->entry[a];
    if (e->depth== depth) {
        bool match =true;
        for (int i =0; i < depth; i++) {
            if (e->stack[i] !=reinterpret_cast<Slot>(stack[i])) {
                match = false;
                break;
            }
        }

        if (match) {
            e->count++;
            done = true;
            break;
        }
    }
}

// 3.          
if (!done) {
    // Evict entry with smallest count
    Entry* e = &bucket->entry[0];
    for (int a =1; a < kAssociativity; a++) {
        if (bucket->entry[a].count< e->count) {
            e = &bucket->entry[a];
        }
    }

    if (e->count>0) {
        evictions_++;
        Evict(*e);
    }

    // Use the newly evicted entry
    e->depth = depth;
    e->count = 1;
    for (int i =0; i < depth; i++) {
        e->stack[i] = reinterpret_cast<Slot>(stack[i]);
    }
}
能夠看到,它使用了長度爲1024的Bucket數組來存放性能收集的記錄,每一個Bucke能最多存放四條hash衝突的記錄。

拿到性能記錄以後,第一步先對記錄中的backtrace計算hash值,hash值模餘1024肯定存儲該條記錄使用的Bucket,而後在Bucket的四個位置中查看能不能找到一個徹底同樣的backtrace,若是能找到,就直接在這個位置上累加計數;若是找不到,說明遇到了一個全新的backtrace,那麼就在四個位置中找一個當前計數最少的位置來存儲當前的記錄。若是目標位置原來沒有計數,那就直接當作一條新的記錄添加進去,而若是目標位置處已經有計數了,說明當前的Bucket已經滿了,那麼就把當前位置處的記錄驅逐到evict_數組中,而把新的記錄保存到當前的位置上。

驅逐邏輯的代碼是這樣的:

void ProfileData::Evict(const Entry& entry) {
  const int d = entry.depth;
  const int nslots = d +2; // Number of slots needed in eviction buffer
  if (num_evicted_ + nslots > kBufferLength) {
    FlushEvicted();
    assert(num_evicted_ ==0);
    assert(nslots <= kBufferLength);
  }

  evict_[num_evicted_++] = entry.count;
  evict_[num_evicted_++] = d;
  memcpy(&evict_[num_evicted_], entry.stack, d *sizeof(Slot));
  num_evicted_ += d;
}
若是當前evict_數組已經放不下當前的記錄了,那就先用FlushEvicted方法把當前的內容都寫入到文件中去,而後清空當前的evict_數組,從頭開始放這些被驅逐出來的記錄。結合初始化的時候註釋爲「Record special entries」的代碼塊,能夠看到,寫入到文件中的結構是開頭的固定的五個slot的文件頭,slot的大小取決於目標程序是32位的仍是64位的,而後後面會跟着多塊採樣數據,每塊數據都是固定的兩個slot分別存放採樣點命中的次數和backtrace的深度,而後後面跟着可變長度的N個PC值,N由backtrace的深度值來決定,每一個Bucket中的Entry的結構與此也是同樣的,而Bucket中的Entry,是在性能數據收集完成以後,統一Flush到文件中。在全部採樣點數據dump完成以後,會用三個slot來做爲數據結束的標記,分別設置爲0,1,0,最後還會把當前進程的maps信息輸出到最終的文件中。輸出maps信息的做用,是幫助後期定位到某個PC值來源於哪一個動態連接庫,並能夠根據偏移量來取得它對應的函數名。

void ProfileData::Stop() {

    if (!enabled()) {
        return;
    }

// Move data from hash table to eviction buffer
for (int b =0; b < kBuckets; b++) {
    Bucket* bucket = &hash_[b];
    for (int a =0; a < kAssociativity; a++) {
        if (bucket->entry[a].count>0) {
            Evict(bucket->entry[a]);
        }
    }
}

if (num_evicted_ +3> kBufferLength) {
    // Ensure there is enough room for end of data marker
    FlushEvicted();
}

// Write end of data marker
evict_[num_evicted_++] = 0; // count
evict_[num_evicted_++] = 1; // depth
evict_[num_evicted_++] = 0; // end of data marker
FlushEvicted();

// Dump 「/proc/self/maps」 so we get list of mapped shared libraries
DumpProcSelfMaps(out_);
Reset();
fprintf(stderr, 「PROFILE: interrupts/evictions/bytes = %d/%d/%」 PRIuS 「\n」,
    count_, evictions_, total_bytes_);
}
下面一張圖是dump了一個真實的性能數據,能夠來對比驗證一下:

 

這是一個在64位機器上運行的Linux程序,因此每一個slot是8個字節,開始時5個Slot的文件頭,其中第四個Slot指示採樣的間隔是10000(0x2710)微秒,也就是默認的每秒採樣100次。而後後面能夠找到兩塊採樣點數據,第一個塊命中了三次,backtrace深度是10;第二塊命中了一次,backtrace深度是7。而後是值分別爲0,1,0的採樣數據結束標誌。在後面就是ascii字符形式保存的maps文本。結合pprof的文本方式的分析結果,也能夠驗證咱們上面的觀察:

 

這下文件結構應該很清楚了,甚至pprof分析工具應該如何處理的邏輯也能想出個大概來了。

項目應用的小技巧
使用gperftools收集運行數據的時候,須要在須要開始收集的位置調用ProfilerStart(),並在結束收集的時候調用profilerStop(),收集到的數據纔會被寫入到文件裏面去。可是有時咱們但願能動態地控制性能數據收集的開始和結束時間,而不想頻繁地修改代碼中ProfilerStart() 和 ProfilerStop()的插入位置。

有兩種方法:

一、在產品中添加自定義信號處理函數,好比能夠分別在SIGUSR1和 SIGUSR2信號的處理函數中執行ProfilerStart()和ProfilerStop(),使用的時候用kill程序發送指定的信號來開啓和結束數據收集就能夠了;

二、產品中啓動一個專門監聽外部命令的線程,接收到指定命令時開啓和結束性能收集。好比監聽一個本地Socket,在這個socket上接收到命令時就執行,並把輸出也都反饋到這個本地socket中去。這樣只要再寫另一個簡單的讀寫這個socket的小程序,就能夠很方便地實現動態控制服務器進程的效果。

易用性上的考慮,推薦使用第二種方法。這樣能夠根據本身的須要靈活擴展這個監聽線程的功能,控制客戶端工具也能作到很是人性化的交互接口。監聽線程還能夠擴展不少其餘的調試或監控功能能,而這個線程在沒有命令需求的時候,只是阻塞在一個Socket監聽事件上,對產品的運行沒有任何其餘影響。

對CPUProfiler的分析就到這裏了,後面還會整理一個隊TCMalloc的源碼級分析,看看google是如何加速多線程應用的內存分配性能的,敬請期待。

        任何問題,歡迎在評論區留言討論。

====================== End

相關文章
相關標籤/搜索