成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

btrace 3.0 重磅新增 iOS 支持!免插樁原理大揭秘!

開發
btrace 是由字節跳動抖音基礎技術團隊自主研發的面向移動端的性能數據采集工具,它能夠高效的助力移動端應用采集性能 Trace 數據,深入剖析代碼的運行狀況,進而輔助優化提升移動端應用的性能體驗,提升業務價值

重磅更新

btrace 是由字節跳動抖音基礎技術團隊自主研發的面向移動端的性能數據采集工具,它能夠高效的助力移動端應用采集性能 Trace 數據,深入剖析代碼的運行狀況,進而輔助優化提升移動端應用的性能體驗,提升業務價值。此次更新,我們重磅推出 btrace 3.0 版本,提出了業界首創的同步抓棧的 Trace 采集方案,實現了高性能的 Trace 采集。此外,新版本在支持 Android 系統的基礎上,新增了對 iOS 系統的全面支持。

歡迎訪問 https://github.com/bytedance/btrace 進一步了解。 此外,后文我們還對 btrace 3.0 誕生的背景、建設思路和實現細節進行了深入介紹,歡迎進一步閱讀。

背景說明

自 btrace 2.0 正式發布以來,已近兩年時間。在此期間,我們收到了大量用戶反饋,經總結,主要問題如下:

1. 接入維護成本較高:接入、使用及維護的成本均偏高,對用戶的使用體驗產生了影響。接入的插件配置較為復雜,編譯期插樁致使構建耗時增加,接入后字節碼插樁異常會導致無法正常編譯,且問題原因難以排查。

2. 系統方法信息缺失:編譯期的字節碼插樁方案僅能對打包至 apk 內的方法生效,無法對 android framework 等系統方法生效,導致所采集的 Trace 信息不夠豐富,影響后續的性能分析。

除 android 端團隊針對 2.0 版本給出的反饋外,隨著行業內雙端合作愈發緊密,業界對于 iOS 相關能力的需求也十分迫切。然而,蘋果官方所提供的 Trace 方案 Time profiler 存在以下局限:

1. 使用成本高:Time profiler 的界面比較復雜,配套的說明文檔數量較少,使用 Time profiler 定位問題時需要消耗很多精力。

2. 應用靈活性低:Time profiler 工具是一個黑盒,出現問題時無法排查,且無法自定義數據維度和數據展示方式。

對此,為了持續提升用戶使用體驗、增強 Trace 信息豐富度、進一步降低性能損耗,以及對 iOS 端能力的支持,我們開啟了全新的 Trace 方案的探索。

思路介紹

實際上不難看出,btrace 目前存在的問題主要是由其使用的編譯期字節碼插樁方案引起的,因此為了解決目前存在的問題,我們重點探索了編譯期插樁之外的 Trace 采集方案。

目前業界主流的 Trace 采集方案分為兩種:代碼插樁方案和采樣抓棧方案。那么采樣抓棧方案是否就是更合適的方案呢,我們對兩種方案的優缺點做了簡單的總結:

從上述對比可以看出,兩種方案各有優劣。采樣抓棧方案可以追蹤到系統方法的執行、可以動態開啟或關閉、接入和維護成本也相對較低,但是它在 Trace 精度和性能上存在明顯的不足,核心原因在于采樣抓棧采用的是定期的異步抓棧流程。首先是每次抓棧都需要經歷掛起線程、回溯方法棧、恢復線程三個階段,這三個階段都有著明顯的性能損耗。其次后臺線程的定時任務由于線程調度的原因,無法做到精準的調度,以及線程掛起時機的不確定性,導致抓棧的間隔至少都設置在 10 毫秒以上,trace 的精度無法保證。

既然兩種方案各有優劣,我們能否取長補短,將兩種方案的優勢融合呢?答案是肯定的,將異步抓棧改成直接在目標線程進行同步抓棧來免去線程掛起和恢復帶來的性能損耗,再通過動態插樁提供的插樁點作為同步抓棧的驅動時機,最終就形成了 btrace 3.0 所采用的動態插樁和同步抓棧結合的 Trace 采集新方案。

新方案將如何保證 Trace 的精度呢?這對應著動態插樁的插樁點選取策略,也即尋找同步抓棧的最佳時機。實際上只要保證插樁點在線程運行時能夠被高頻地執行到,我們就可以通過高頻地同步抓棧來保證 Trace 的精度。另外還需要注意的是,插樁點的選取不僅要保證能夠被高頻地執行到,同時也要盡可能的分布在“葉子節點”所處的方法上。如下圖所示,如果“葉子節點”方法上沒有插樁點,那么最終生成的 Trace 就會存在信息丟失。如何實現快速同步抓棧、如何進行動態插樁以及具體選取哪些插樁點將在方案明細中介紹。

值得一提的是,同步抓棧方案除了免去線程掛起和恢復的性能損耗以外,還可以在抓棧時記錄當前線程的更多上下文信息,更進一步地結合了插樁與抓棧的雙重優勢,這方面也將在下文的方案中進行闡述。

然而,同步抓棧高度依賴樁點。若遇到極端情況,如方法邏輯本身不存在合適的樁點,或者線程被阻塞,便無法采集到相應的 Trace 信息。針對此問題,可通過異步抓棧進一步提高 Trace 的豐富度。特別是 iOS 系統,其自身具備極高的異步抓棧性能,適合在同步抓棧的基礎上疊加異步抓棧功能,以進一步提升 Trace 的豐富度。

方案明細

接下來,我們將對雙端的技術細節展開深入探討。鑒于系統差異,雙端的實現原理存在區別,以下將分別進行介紹。

Android

Android 端的 Trace 采集方案主要分為同步抓棧和動態插樁兩部分,其中同步抓棧部分由于已經免去了線程掛起和恢復流程,所以只需要聚焦于如何實現快速的方法抓棧即可。

快速抓棧

Android Framework 本身提供的抓棧方式 Thread.getStackTrace 會在抓棧的同時解析方法符號,解析符號是比較耗時的操作。針對 Trace 采集需要高頻率抓棧的場景,每次抓棧都解析方法符號顯然是比較浪費性能的選擇,尤其是多次抓棧里很可能會存在很多重復的方法符號解析。為了優化抓棧性能,我們選擇在抓棧時僅保存方法的指針信息,待抓棧完成后,進行 Trace 數據上報時對指針去重后進行批量符號化,這樣可以最大化節省解析符號的成本。

具體該如何實現快速抓棧且僅保存方法指針信息呢?在 Android 中 Java 方法的棧回溯主要依賴 ART 虛擬機內部的 StackVisitor 類來實現的,大部分的快速抓棧方案都是圍繞著創建 StackVisitor 的實現類對象并調用其 WalkStack() 方法進行回溯來實現的,我們也是使用這種方案。只有少數方案如 Facebook 的 profilo 是不依賴系統 StackVisitor 類自己實現的棧回溯,但是這種方案的兼容性較差且有較高的維護成本,目前隨著 Android 版本的不斷更新,profilo 官方已無力再對新版本進行更新適配了。

不過在使用系統的 StackVisitor 類進行棧回溯時我們也做了一些額外的版本適配方案的優化。具體來說是構造 StackVisitor 對象的方案優化,我們不假定 StackVisitor 類內成員變量的內存布局,而是定義了一個內存足夠的 mSpaceHolder 字段來容納 StackVisitor 對象的所有成員變量。

class StackVisitor {...    [[maybe_unused]] virtual bool VisitFrame();    // preserve for real StackVisitor's fields space    [[maybe_unused]] char mSpaceHolder[2048]; ...};
class StackVisitor {
...
    [[maybe_unused]] virtual bool VisitFrame();
    // preserve for real StackVisitor's fields space
    [[maybe_unused]] char mSpaceHolder[2048]; 
...
};

然后交由 ART 提供的構造函數來妥善初始化 StackVisitor 對象,自動將預留的 mSpaceHolder 初始化成預期的數據,省去了對每個版本的對象內存布局適配工作。同時再將 StackVisitor 對象的虛函數表替換,最后實現類似繼承自 StackVisitor 的效果。

bool StackVisitor::innerVisitOnce(JavaStack &stack, void *thread, uint64_t *outTime,                                  uint64_t *outCpuTime) {    StackVisitor visitor(stack);    void *vptr = *reinterpret_cast<void **>(&visitor);    // art::Context::Create()    auto *context = sCreateContextCall();    // art::StackVisitor::StackVisitor(art::Thread*, art::Context*, art::StackVisitor::StackWalkKind, bool)    sConstructCall(reinterpret_cast<void *>(&visitor), thread, context, StackWalkKind::kIncludeInlinedFrames, false);    *reinterpret_cast<void **>(&visitor) = vptr;    // void art::StackVisitor::WalkStack<(art::StackVisitor::CountTransitions)0>(bool)    visitor.walk();}
bool StackVisitor::innerVisitOnce(JavaStack &stack, void *thread, uint64_t *outTime,
                                  uint64_t *outCpuTime) {
    StackVisitor visitor(stack);
    void *vptr = *reinterpret_cast<void **>(&visitor);
    // art::Context::Create()
    auto *context = sCreateContextCall();
    // art::StackVisitor::StackVisitor(art::Thread*, art::Context*, art::StackVisitor::StackWalkKind, bool)
    sConstructCall(reinterpret_cast<void *>(&visitor), thread, context, StackWalkKind::
kIncludeInlinedFrames
, false);
    *reinterpret_cast<void **>(&visitor) = vptr;
    // void art::StackVisitor::WalkStack<(art::StackVisitor::CountTransitions)0>(bool)
    visitor.walk();
}

最后當調用 StackVisitor.walk 后,相關回調都將分發到我們自己的 VisitFrame,這樣只需要再調用相關函數進行堆棧數據讀取即可。

[[maybe_unused]] bool StackVisitor::VisitFrame() {    // art::StackVisitor::GetMethod() const    auto *method = sGetMethodCall(reinterpret_cast<void *>(this));    mStack.mStackMethods[mCurIndex] = uint64_t(method);    mCurIndex++;    return true;}
[[maybe_unused]] bool StackVisitor::VisitFrame() {
    // art::StackVisitor::GetMethod() const
    auto *method = sGetMethodCall(reinterpret_cast<void *>(this));
    mStack.mStackMethods[mCurIndex] = uint64_t(method);
    mCurIndex++;
    return true;
}

這種方案在性能和兼容性方面能同時得到保障,維護成本也低。

動態插樁

現在可以實現快速抓棧了,那么應該在什么時候抓棧呢?這就輪到動態插樁出場了,所謂的動態插樁是利用運行時的 Hook 工具對系統內部的方法進行 Hook 并插入同步抓棧的邏輯,目前主要使用到的 Hook 工具為 ShadowHook。

按照前文思路分析,對于動態插樁重點是高頻且盡可能分布在“葉子節點”方法上。而在 Android 應用中 Java 對象的創建是虛擬機運行過程中除方法調用外最高頻的操作,并且對象創建時的內存分配不會調用任何業務邏輯,是整個方法執行的末端。于是,第一個理想的抓棧時機即是 Java 對象創建時的內存分配。

Java 對象創建監控的核心是向虛擬機注冊對象創建的監聽器,這個能力虛擬機的 Heap 類已經提供有注冊接口,但是通過該接口注冊首先會暫停所有 Java 線程,這會存在很高的 ANR 風險,為此我們借鑒了公司內部開發的 Java 對象創建方案,實現了實時的 Java 對象創建監控能力,具體實現原理請移步查看倉庫源碼,這里不作詳細介紹。

注冊完 AllocationListener 后將在每次對象分配時收到回調:

class MyAllocationListener : AllocationListener {    ...    void ObjectAllocated(void *self, void **obj, size_t byte_count) override {        // TODO 這里抓棧    }};
class MyAllocationListener : AllocationListener {
    ...
    void ObjectAllocated(void *self, void **obj, size_t byte_count) override {
        // TODO 這里抓棧
    }
};

因為對象分配十分高頻,如果每次都進行抓棧會有很大的性能損耗。一方面大批量的抓棧開銷累計會有很大的性能成本,另一方面如此存儲大規模的抓棧數據也是棘手的問題。

為控制抓棧數量以減少性能損耗,首先可考慮的方法是控頻:通過對比連續兩次內存回調的間隔時間,僅當該時間間隔大于閾值時才再次進行抓棧操作。

thread_local uint64_t lastNano = 0;bool SamplingCollector::request(SamplingType type, void *self, bool force, bool captureAtEnd, uint64_t beginNano, uint64_t beginCpuNano, std::function<void(SamplingRecord&)> fn) {    auto currentNano = rheatrace::current_time_nanos();    if (force || currentNano - lastNano > threadCaptureInterval) {        lastNano = currentNano;        ...        if (StackVisitor::visitOnce(r.mStack, self)) {            collector->write(r);            return true;        }    }    return false;}

thread_local uint64_t lastNano = 0;
bool SamplingCollector::request(SamplingType type, void *self, bool force, bool captureAtEnd, uint64_t beginNano, uint64_t beginCpuNano, std::function<void(SamplingRecord&)> fn) {
    auto currentNano = rheatrace::current_time_nanos();
    if (force || currentNano - lastNano > threadCaptureInterval) {
        lastNano = currentNano;
        ...
        if (StackVisitor::visitOnce(r.mStack, self)) {
            collector->write(r);
            return true;
        }
    }
    return false;
}

除了內存分配以外,還可以通過豐富其他抓棧的時機來提升兩次抓棧的時間密度,提升數據的效果。比如 JNI 方法調用、獲取鎖、Object.wait、Unsafe.park 等節點。這些葉子節點可以主要分為兩大類:高頻執行阻塞執行

高頻執行是很好的進行主動抓棧的時間點,記錄下當前執行的堆棧即可,比如前面介紹的內存分配、JNI 調用等。

阻塞執行即可能處于阻塞的狀態等待滿足條件后繼續執行,對于此類節點,除了對應的執行堆棧外,還預期記錄當前方法阻塞的耗時。可以在方法即將執行時記錄開始執行時間,在方法結束時進行抓棧并記錄結束時間。

這里以獲取鎖的的場景為例,獲取鎖最終會走到 Native 層的 MonitorEnter,可以通過 shadowhook 來代理該函數的執行:

void Monitor_Lock(void* monitor, void* threadSelf) {SHADOWHOOK_STACK_SCOPE();    rheatrace::ScopeSampling a(rheatrace::stack::SamplingType::kMonitor, threadSelf);SHADOWHOOK_CALL_PREV(Monitor_Lock, monitor, threadSelf);}class ScopeSampling {private:    uint64_t beginNano_;    uint64_t beginCpuNano_;public:    ScopeSampling(SamplingType type, void *self = nullptr, bool force = false) : type_(type), self_(self), force_(force) {        beginNano_ = rheatrace::current_time_nanos();        beginCpuNano_ = rheatrace::thread_cpu_time_nanos();    }    ~ScopeSampling() {        SamplingCollector::request(type_, self_, force_, true, beginNano_, beginCpuNano_);    }};
void Monitor_Lock(void* monitor, void* threadSelf) {
SHADOWHOOK_STACK_SCOPE();
    rheatrace::ScopeSampling a(rheatrace::stack::SamplingType::
kMonitor, threadSelf);
SHADOWHOOK_CALL_PREV
(Monitor_Lock, monitor, threadSelf);
}
class ScopeSampling {
private:
    uint64_t beginNano_;
    uint64_t beginCpuNano_;
public:
    ScopeSampling(SamplingType type, void *self = nullptr, bool force = false) : type_(type), self_(self), force_(force) {
        beginNano_ = rheatrace::current_time_nanos();
        beginCpuNano_ = rheatrace::thread_cpu_time_nanos();
    }
    ~ScopeSampling() {
        SamplingCollector::request(type_, self_, force_, true, beginNano_, beginCpuNano_);
    }
};

通過封裝的 ScopeSampling 對象,可以在 Monitor_Lock 函數執行時記錄方法的開始時間,待方法結束時記錄結束時間的同時并進行抓棧。這樣整個鎖沖突的堆棧以及獲取鎖的耗時都會被完整的記錄下來。

除了鎖沖突以外,像 Object.wait、Unsafe.park、GC 等阻塞類型的耗時,都可以通過這樣的方法同時記錄執行堆棧與耗時信息。

至此 Android 端的核心原理基本完成介紹,歡迎移步 https://github.com/bytedance/btrace 進一步了解。

iOS

下面來看 iOS 的原理,正如前文所述,iOS 具備高性能的異步抓棧方案,因此 iOS 端采用同步與異步結合采樣的 Trace 采集方案:

  • 同步抓棧:選定一批方法進行 hook,當這些方法被執行時,同步采集當前線程的 Trace 數據。
  • 異步抓棧:當線程超過一定時間未被采集數據時,由獨立的采樣線程暫停線程,異步采集 Trace 數據然后恢復線程,以確保數據的時間連續性。

同步采集

同步采集模式在性能和數據豐富度方面都有一定優勢。在同步采集模式下,我們遇到并解決了一些有挑戰的問題:

  • 選擇同步采集點
  • 減少存儲占用
  • 多線程數據寫入性能
選擇同步采集點

可能的選取方法有:

  • 依靠 RD 同學的經驗,但是存在不可持續性。
  • 通過代碼插樁,但是需要重新編譯 app,其次無法獲取到系統庫方法,容易存在遺漏。

我們推薦檢查 iOS app 高頻系統調用,因為系統調用足夠底層且對于 app 來說必不可少。可以在模擬器上運行 app,然后使用如下命令查看 app 運行時的系統調用使用情況:

# 關閉sip# 終端登錄root賬號dtruss -c -p pid # -c表示統計系統調用的次數;
# 關閉sip
# 終端登錄root賬號
dtruss -c -p pid # -c表示統計系統調用的次數;

通過實際分析可知, iOS app 高頻系統調用可以大致分為這幾類:內存分配、I/O、鎖、時間戳讀取,如下所示:

名稱                          次數ulock_wake                   15346  # unfair lock, unlockulock_wait2                  15283  # unfair lock, lockpread                        10758  # iopsynch_cvwait                10360  # pthread條件變量,等待read                         9963   # iofcntl                        8403   # iomprotect                     8247   # memorymmap                         8225   # memorygettimeofday                 7531   # 時間戳psynch_cvsignal              6862   # pthread條件變量,發送信號writev                       6048   # ioread_nocancel                4892   # iofstat64                      4817   # iopwrite                       3646   # iowrite_nocancel               3446   # ioclose                        2850   # iogetsockopt                   2818   # 網絡stat64                       2811   # iopselect                      2457   # io多路復用psynch_mutexwait             1923   # mutex, lockpsynch_mutexdrop             1918   # mutex, unlockpsynch_cvbroad               1826   # pthread條件變量,發送廣播信號
名稱                          次數
ulock_wake                   15346  # unfair lock, unlock
ulock_wait2                  15283  # unfair lock, lock
pread                        10758  # io
psynch_cvwait                10360  # pthread條件變量,等待
read                         9963   # io
fcntl                        8403   # io
mprotect                     8247   # memory
mmap                         8225   # memory
gettimeofday                 7531   # 時間戳
psynch_cvsignal              6862   # pthread條件變量,發送信號
writev                       6048   # io
read_nocancel                4892   # io
fstat64                      4817   # io
pwrite                       3646   # io
write_nocancel               3446   # io
close                        2850   # io
getsockopt                   2818   # 網絡
stat64                       2811   # io
pselect                      2457   # io多路復用
psynch_mutexwait             1923   # mutex, lock
psynch_mutexdrop             1918   # mutex, unlock
psynch_cvbroad               1826   # pthread條件變量,發送廣播信號

減小存儲占用

所采集的 Trace 數據中占用存儲空間(內存和磁盤)最大的就是調用棧數據。調用棧具有時間和空間相似性,我們可以利用這些相似性來大幅壓縮調用棧所占用的空間。

空間相似性:可以觀察到,調用棧中越靠近上層的方法越容易是相同的,比如主線程的調用棧都以 main 方法開始。因此,我們可以將不同調用棧中相同層級的同名方法只存儲一份來節省存儲空間。我們設計了 CallstackTable 結構來存儲調用棧:

class CallstackTable{public:    struct Node    {        uint64_t parent;        uint64_t address;    };
        struct NodeHash {        size_t operator()(const Node* node) const {            size_t h = std::hash<uint64_t>{}(node->parent);            h ^= std::hash<uint64_t>{}(node->address);            return h;        }    };        struct NodeEqual    {        bool operator()(const Node* node1, const Node* node2) const noexcept        {            bool result = (node1->parent == node2->parent) && (node1->address == node2->address);            return result;        }    };    using CallStackSet = hash_set<Node *, NodeHash, NodeEqual>;private:    CallStackSet stack_set_;};

class CallstackTable
{
public:
    struct Node
    {
        uint64_t parent;
        uint64_t address;
    };


        struct NodeHash {
        size_t operator()(const Node* node) const {
            size_t h = std::hash<uint64_t>{}(node->parent);
            h ^= std::hash<uint64_t>{}(node->address);
            return h;
        }
    };
    
    struct NodeEqual
    {
        bool operator()(const Node* node1, const Node* node2) const noexcept
        {
            bool result = (node1->parent == node2->parent) && (node1->address == node2->address);
            return result;
        }
    };
    using CallStackSet = hash_set<Node *, NodeHash, NodeEqual>;
private:
    CallStackSet stack_set_;
};

以下圖為例介紹如何高效存儲調用棧。

  1. 樣本 1 的A方法沒有父方法,因此存儲為 Node(0, A),并記錄 Node 的地址為 NodeA
  2. 樣本 1 的B方法的父方法為A,因此存儲為 Node(NodeA, B),并記錄 Node 的地址為 NodeB
  3. 樣本 1 的C方法的父方法為B,因此存儲為 Node(NodeB, C),并記錄 Node 的地址為 NodeC
  4. 樣本 2 的A方法對應的 Node 為 Node(0, A),已經存儲過,不再重復存儲
  5. 樣本 2 的B方法和C方法同理不再重復存儲
  6. 樣本 3 的A方法不再重復存儲
  7. 樣本 3 的E方法存儲為 Node(NodeA, E)
  8. 樣本 3 的C方法存儲為 Node(NodeE, C)

時間相似性:App 中很多方法的執行時間都遠大于我們的采集間隔,因此連續一段時間內采集到的調用棧很有可能是相同的。我們可以把相鄰相同調用棧的記錄進行合并,只存儲開始和結束兩條記錄,就可以極大程度上減少存儲空間占用。

以上圖為例進行說明,如果以 1ms 的間隔進行數據采集,而 A->B->C->D 棧執行了 100ms,那么就會采集到 100 條記錄,由于這 100 條記錄對應的是相同的棧,我們可以只記錄開始和結束兩條記錄,從而大幅壓縮存儲占用。

多線程數據寫入

同步采集方案中,存在多線程同時寫入數據到 buffer 的情況,必須對多線程寫入進行處理,保證數據不異常。

一般通過加鎖來保證數據安全,但是性能比較差。而 CAS 操作可以顯著優于加鎖,但是 lock-free 不等于 wait-free,也會有偶發性性能問題。還可以使用 Thread Local Buffer 的方案,其最大的優勢是完全避免線程間沖突性能最優,但是容易造成內存浪費,需要仔細考慮內存復用機制。我們摒棄了以上傳統方案,而是將單個 buffer 拆分為多個 sub buffer,將線程的每次寫入操作根據線程 ID 和寫入大小分配到各個 sub buffer,類似哈希機制來提升寫入的并發度。

異步采集

異步抓棧方案相對成熟,Apple 官方的 Time Profiler 以及開源的 ETTrace 等項目均采用了該方案。抖音 iOS btrace 初版同樣運用了異步抓棧方案。異步抓棧方案不可避免地需要先暫停線程執行,接著采集數據,最后恢復線程執行。此過程易出現死鎖與性能問題,我們也對此進行了針對性處理。

防止死鎖

當訪問某些系統方法時,采集線程與被采集線程可能同時持有相同的鎖。例如,在同時調用 malloc 進行內存分配時,可能會觸發 malloc 內部的鎖。當代碼以如下方式被觸發時,將會導致死鎖:

  1. 被采樣線程調用 malloc 并首先獲取鎖;
  2. 被采樣線程被采樣線程暫停執行;
  3. 采樣線程調用 malloc 并嘗試獲取鎖,但是永遠等不到鎖釋放。

最終,采樣線程會陷入永久性的等待鎖狀態,被采樣線程則會發生永久性暫停。

針對此類問題也沒有特別好的處理辦法,目前是通過一些約定禁止在采樣線程在掛起被采樣線程期間調用危險 api,類似于信號安全函數,具體已知的函數如下:

  • ObjC 方法,因為 OC 方法動態派發時可能會持有內部鎖
  • 打印文本(printf 家族,NSlog 等)
  • 堆內存分配(malloc 等)
  • pthread 部分 api
性能優化

過濾非活躍線程:抖音這種大型 App 在運行過程中會創建數量非常多的線程,如果每次采樣都采集所有線程的數據,性能開銷是無法接受的。同時,也沒有必要每次都采集所有線程的數據,因為絕大多數線程在大部分時間里都在休眠,只有當事件發生時線程才會喚醒并處理執行任務,因此可以只采集活躍狀態的線程的數據,同時,對長時間未被采集數據的線程強制采集一次數據。

bool active(uint64_t thread_id) {    mach_msg_type_number_t thread_info_count = THREAD_BASIC_INFO_COUNT;    kern_return_t res = thread_info(thread_id, THREAD_BASIC_INFO, reinterpret_cast<thread_info_t>(info), &thread_info_count);    if (unlikely((res != KERN_SUCCESS)))    {        return false;    }    return (info.run_state == TH_STATE_RUNNING && (info.flags & TH_FLAGS_IDLE) == 0);}

bool active(uint64_t thread_id) 
{
    mach_msg_type_number_t thread_info_count = THREAD_BASIC_INFO_COUNT;
    kern_return_t res = thread_info(thread_id, THREAD_BASIC_INFO, reinterpret_cast<thread_info_t>(info), &thread_info_count);
    if (unlikely((res != KERN_SUCCESS)))
    {
        return false;
    }
    return (info.run_state == TH_STATE_RUNNING && (info.flags & TH_FLAGS_IDLE) == 0);
}

高效棧回溯:異步回溯線程的調用棧時,為了防止讀取到非法指針中的數據導致 app 崩潰,通常會使用系統庫提供的 api vm_read_overwrite來讀取數據,使用該 api 讀取到非法指針的數據時會得到一個錯誤標識而不會導致 app 崩潰。雖然vm_read_overwrite已經足夠高效(耗時在微秒級別),但其耗時相比于直接讀指針的耗時仍然高了數十倍。而且 app 的調用棧通常都有數十層,因此vm_read_overwrite的高耗時問題會被放大。我們在回溯調用棧之前已經將線程暫停了,理論上線程的調用棧不會發生變化,所有的指針都應該是合法的,然而經過實際驗證,直接讀指針確實會讀取到非法指針從而造成 app 崩潰。通過閱讀暫停線程的 api thread_suspend的說明文檔,以及分析崩潰日志,我們發現在一些情況下線程會繼續運行(如線程正在執行退出時的清理動作)。

The thread_suspend function increments the suspend count for target_thread and prevents the thread from executing any more user-level instructions.
In this context, a user-level instruction can be either a machine instruction executed in user mode or a system trap instruction, including a page fault. If a thread is currently executing within a system trap, the kernel code may continue to execute until it reaches the systemreturn code or it may suspend within the kernel code. In either case, the system trap returns when the thread resumes.

 
 
 The thread_suspend function increments the suspend count for target_thread and prevents the thread from executing any more user-level instructions.
In this context, a user-level instruction can be either a machine instruction executed in user mode or a system trap instruction, including a page fault. If a thread is currently executing within a system trap, the kernel code may continue to execute until it reaches the systemreturn code or it may suspend within the kernel code. In either case, the system trap returns when the thread resumes.
The thread_suspend function increments the suspend count for target_thread and prevents the thread from executing any more user-level instructions.


In this context, a user-level instruction can be either a machine instruction executed in user mode or a system trap instruction, including a page fault. If a thread is currently executing within a system trap, the kernel code may continue to execute until it reaches the systemreturn code or it may suspend within the kernel code. In either case, the system trap returns when the thread resumes.

最終,我們使用如下措施來盡可能使用直接讀指針的方式以提升棧回溯的性能:

  • 不采集正在退出的線程的數據
  • 暫停線程后,再次確認線程運行狀態,若線程已經暫停直接讀指針進行棧回溯
  • 否則兜底使用系統 api vm_read_overwrite 保證安全異步回溯調用棧

Trace 生成

在介紹完雙端的技術實現細節之后,接下來我們將關注 Trace 可視化部分。

在 Trace 可視化方面,雙端都依舊選擇了基于 perfetto 進行數據展示。具體邏輯與 Android 官方提供的 Debug.startMethodTracingSampling 的實現方案相似,此處以 Android 為例進行簡要介紹,iOS 的實現情況大體一致。

基本思路是對比連續抓取的棧信息之間的差異,找出從棧頂到棧底的第一個不同的函數。將前序堆棧中該不同的函數出棧,把后序堆棧中該不同的函數入棧,入棧和出棧的時間間隔即為該函數的執行耗時。以下是代碼示例:

// 生成一個虛擬的 Root 節點,待完成解析后,Root 的子樹便構成了 Trace 的森林CallNode root = CallNode.makeRoot();Stack<CallNode> stack = new Stack<>();stack.push(root);...for (int i = 0; i < stackList.size(); i++) {    StackItem curStackItem = stackList.get(i);    nanoTime = curStackItem.nanoTime;    // 第一個堆棧全部入棧    if (i == 0) {        for (String name : curStackItem.stackTrace) {            stack.push(new CallNode(curStackItem.tid, name, nanoTime, stack.peek()));        }    } else {        // 當前堆棧與前一個堆棧對比,自頂向下,找到第一個不同的函數        StackItem preStackItem = stackList.get(i - 1);        int preIndex = 0;        int curIndex = 0;        while (preIndex < preStackItem.size() && curIndex < curStackItem.size()) {            if (preStackItem.getPtr(preIndex) != curStackItem.getPtr(curIndex)) {                break;            }            preIndex++;            curIndex++;        }        // 前一個堆棧中不同的函數全部出棧        for (; preIndex < preStackItem.size(); preIndex++) {            stack.pop().end(nanoTime);        }        // 當前堆棧中不同的函數全部入棧        for (; curIndex < curStackItem.size(); curIndex++) {            String name = curStackItem.get(curIndex);            stack.push(new CallNode(curStackItem.tid, name, nanoTime, stack.peek()));        }    }}// 遺留在棧中的函數全部出棧while (!stack.isEmpty()) {    stack.pop().end(nanoTime);}

// 生成一個虛擬的 Root 節點,待完成解析后,Root 的子樹便構成了 Trace 的森林
CallNode root = CallNode.makeRoot();
Stack<CallNode> stack = new Stack<>();
stack.push(root);
...
for (int i = 0; i < stackList.size(); i++) {
    StackItem curStackItem = stackList.get(i);
    nanoTime = curStackItem.nanoTime;
    // 第一個堆棧全部入棧
    if (i == 0) {
        for (String name : curStackItem.stackTrace) {
            stack.push(new CallNode(curStackItem.tid, name, nanoTime, stack.peek()));
        }
    } else {
        // 當前堆棧與前一個堆棧對比,自頂向下,找到第一個不同的函數
        StackItem preStackItem = stackList.get(i - 1);
        int preIndex = 0;
        int curIndex = 0;
        while (preIndex < preStackItem.size() && curIndex < curStackItem.size()) {
            if (preStackItem.getPtr(preIndex) != curStackItem.getPtr(curIndex)) {
                break;
            }
            preIndex++;
            curIndex++;
        }
        // 前一個堆棧中不同的函數全部出棧
        for (; preIndex < preStackItem.size(); preIndex++) {
            stack.pop().end(nanoTime);
        }
        // 當前堆棧中不同的函數全部入棧
        for (; curIndex < curStackItem.size(); curIndex++) {
            String name = curStackItem.get(curIndex);
            stack.push(new CallNode(curStackItem.tid, name, nanoTime, stack.peek()));
        }
    }
}
// 遺留在棧中的函數全部出棧
while (!stack.isEmpty()) {
    stack.pop().end(nanoTime);
}

敏銳的讀者或許已經察覺到,由于采用的是采樣抓棧方式,因此可能出現多次抓棧時堆棧完全相同,但這些情況可能并非源自同一次代碼執行的情形。以下圖為例:

有 3 次不同的執行堆棧,但是由于采樣的原因,僅 1 和 3 被采樣抓棧。按照上面的規則生成 Trace,那么 B 和 C 的方法耗時都會被放大,包含了方法 D 的耗時。

很遺憾,對于此類情況無法徹底解決掉,但是可以針對特定的問題進行規避。比如針對消息執行的場景,可以設計一個消息 ID 用于標記消息,每次執行 nativePollOnce 后消息 ID 自增,在每次抓棧時同時記錄消息 ID。這樣就算多次抓棧結果一致,但是只要消息 ID 不一樣,依然可以識別出來從而在解析 Trace 時結束方法。

最后我們再看下數據效果,下面是 btrace demo 在啟動階段的 Trace 數據,可以看到豐富度和細節上相比于 2.0 有了明顯的提升。

Android 效果圖

iOS 效果圖

耗時歸因數據

除基本的方法執行 Trace 外,不僅要了解方法的耗時情況,還需明確方法產生耗時的原因。為此,我們需要進一步剖析方法的耗時原因,深入分析 WallTime 的具體去向,例如在 CPU 執行上花費了多少時間、有多少時間處于阻塞狀態等信息。得益于整體方案的設計,我們在每次進行棧抓取時采集包含 WallTime 在內的額外信息,從而能夠輕松實現函數級的數據統計。 這部分數據采集的原理在雙端是類似的,篇幅有限,這里僅以 Android 為例展開介紹。

CPUTime

CPUTime 是最基礎的耗時歸因數據,可以直觀體現當前函數是在執行 CPU 密集型任務,還是在等待資源。

實現方式很簡單,就是在每次抓棧時通過下面的方式獲取下當前線程的 CPUTime:

static uint64_t thread_cpu_time_nanos() {    struct timespec t;    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &t);    return t.tv_sec * 1000000000LL + t.tv_nsec;}
static uint64_t thread_cpu_time_nanos() {
    struct timespec t;
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &t);
    return t.tv_sec * 1000000000LL + t.tv_nsec;
}

Android 效果圖

iOS 效果圖

對象分配次數與大小

我們已借助對象分配的接口進行棧捕獲,但并非每次對象分配時都會進行棧捕獲,這會致使直接通過該接口采集的數據為抽樣結果,無法真實反映對象分配所需的內存大小。實際上,我們只需在對象分配接口中做好數據統計,隨后在每次進行棧捕獲時(無論由何種斷點觸發),記錄當前的線程級對象分配情況,與獲取線程的 CPU 時間的操作方式相同即可。

thread_local rheatrace::JavaObjectStat::ObjectStat stats;
void rheatrace::JavaObjectStat::onObjectAllocated(size_t b) {    stats.objects++;    stats.bytes += b;}
thread_local rheatrace::JavaObjectStat::ObjectStat stats;


void rheatrace::JavaObjectStat::onObjectAllocated(size_t b) {
    stats.objects++;
    stats.bytes += b;
}


Android 效果圖

iOS 效果圖

缺頁次數、上下文切換次數

和 CPUTime 類似,可以通過 getrusage 來讀取線程級別的缺頁次數、上下文切換次數的信息。

struct rusage ru;if (getrusage(RUSAGE_THREAD, &ru) == 0) {    r.mMajFlt = ru.ru_majflt;    r.mNvCsw = ru.ru_nvcsw;    r.mNivCsw = ru.ru_nivcsw;}
struct rusage ru;
if (getrusage(RUSAGE_THREAD, &ru) == 0) {
    r.mMajFlt = ru.ru_majflt;
    r.mNvCsw = ru.ru_nvcsw;
    r.mNivCsw = ru.ru_nivcsw;
}


線程阻塞歸因

以主線程為例,線程阻塞歸因是監控到主線程阻塞的時長,以及對應的喚醒線程。目前阻塞歸因已經包含 synchronized 鎖、Object.wait、Unsafe.park 3 中原因導致的線程阻塞。通過 hook 對應的函數(hook 方案可以參考過往文章:重要升級!btrace 2.0 技術原理大揭秘)來記錄主線程的阻塞時長,同時 hook 釋放鎖等操作,如果釋放的鎖是當前主線程正在等待的鎖,那么就在釋放鎖時強制抓棧,且記錄下當前釋放的目標線程的 ID 用來關聯阻塞與釋放的關系。基本原理可以參考下面:

static void *currentMainMonitor = nullptr;static uint64_t currentMainNano = 0;
void *Monitor_MonitorEnter(void *self, void *obj, bool trylock) {SHADOWHOOK_STACK_SCOPE();    if (rheatrace::isMainThread()) {        rheatrace::ScopeSampling a(rheatrace::SamplingType::kMonitor, self);        currentMainMonitor = obj; // 記錄當前阻塞的鎖        currentMainNano = a.beginNano_;        void *result = SHADOWHOOK_CALL_PREV(Monitor_MonitorEnter, self, obj, trylock);        currentMainMonitor = nullptr; // 鎖已經拿到,這里重置        return result;    }    ...}
bool Monitor_MonitorExit(void *self, void *obj) {SHADOWHOOK_STACK_SCOPE();    if (!rheatrace::isMainThread()) {        if (currentMainMonitor == obj) { // 當前釋放的鎖正式主線程等待的鎖           rheatrace::SamplingCollector::request(rheatrace::SamplingType::kUnlock, self, true, true, currentMainNano); // 強制抓棧,并通過 currentMainNano 和主線程建立聯系ALOGX("Monitor_MonitorExit wakeup main lock %ld", currentMainNano);        }    }    return SHADOWHOOK_CALL_PREV(Monitor_MonitorExit, self, obj);}

static void *currentMainMonitor = nullptr;
static uint64_t currentMainNano = 0;


void *Monitor_MonitorEnter(void *self, void *obj, bool trylock) {
SHADOWHOOK_STACK_SCOPE();
    if (rheatrace::isMainThread()) {
        rheatrace::ScopeSampling a(rheatrace::SamplingType::kMonitor, self);
        currentMainMonitor = obj; // 記錄當前阻塞的鎖
        currentMainNano = a.beginNano_;
        void *result = SHADOWHOOK_CALL_PREV(Monitor_MonitorEnter, self, obj, trylock);
        currentMainMonitor = nullptr; // 鎖已經拿到,這里重置
        return result;
    }
    ...
}


bool Monitor_MonitorExit(void *self, void *obj) {
SHADOWHOOK_STACK_SCOPE();
    if (!rheatrace::isMainThread()) {
        if (currentMainMonitor == obj) { // 當前釋放的鎖正式主線程等待的鎖
           rheatrace::SamplingCollector::request(rheatrace::SamplingType::kUnlock, self, true, true, currentMainNano); // 強制抓棧,并通過 currentMainNano 和主線程建立聯系
ALOGX("Monitor_MonitorExit wakeup main lock %ld", currentMainNano);
        }
    }
    return SHADOWHOOK_CALL_PREV(Monitor_MonitorExit, self, obj);
}

1.主線程等鎖,提示 wakeupby: 30657

2.輕松定位到 30657 線程相關代碼:

總結展望

以上主要闡述了 btrace 3.0 采用了將動態插樁與同步抓棧相結合的新型 Trace 方案。該新型方案在使用體驗和靈活性方面對 btrace 進行了一定程度的優化。后續,我們將持續對 Trace 能力、拓展 Trace 采集場景以及生態建設進行迭代與優化,具體內容如下:

  1. Trace 能力:在 Android 系統中支持 Native 層的 C/C++ Trace;在雙端均提供 GPU 等渲染層面的 Trace 信息。
  2. 使用場景:提供線上場景的 Trace 采集能力接入與使用方案,以助力發現并解決線上性能問題。
  3. 生態建設:圍繞 btrace 工具構建自動性能診斷能力。
  4. 提升性能和穩定性:工具的性能和穩定性優化工作永無止境,仍需進一步追求極致。
  5. 多端能力:在 Android、iOS 的基礎上,新增鴻蒙系統的支持,同時增加 Web 等跨平臺的 Trace 能力。

最后,歡迎大家移步 https://github.com/bytedance/btrace ,以進一步了解 btrace 3.0 所帶來的全新體驗。

責任編輯:龐桂玉 來源: 字節跳動技術團隊
相關推薦

2023-06-26 18:03:26

btrace 2.0開源

2011-08-10 13:29:53

虛擬化VMware Viewvmware

2021-07-16 09:55:37

iSQE峰會

2022-07-11 10:45:37

插樁性能優化打包

2009-03-19 12:48:31

2009-10-23 13:32:34

.NET CRL

2021-11-13 07:33:08

WPSXLOOKUP辦公軟件

2016-09-29 15:35:13

IOS 10蘋果

2009-09-16 09:57:35

谷歌Chrome 3.0瀏覽器

2021-01-18 18:15:00

GitHub 技術開發

2025-05-07 03:15:00

NacosAPIMCP

2018-09-18 15:57:44

機器學習ML神經網絡

2021-05-13 23:30:17

JavaScript 原理揭秘

2009-09-08 11:26:35

Spring 3.0

2016-02-29 16:54:10

OpenStack混合云應用軟件定義基礎設施

2017-07-05 16:43:52

VSAN加密虛擬化

2009-05-28 10:12:04

2017-07-06 08:21:27

VSAN加密虛擬機

2017-08-24 09:19:20

分解技術揭秘

2019-04-16 09:47:42

iOS應用系統
點贊
收藏

51CTO技術棧公眾號

主站蜘蛛池模板: 成人国产精品入口免费视频 | 午夜性视频 | 精品视频一区二区 | 欧美性视频在线播放 | 九九九久久国产免费 | 91精品国产欧美一区二区成人 | 久久精品一区二区三区四区 | 日本三级电影免费 | 日韩一区二区三区四区五区六区 | 极品粉嫩国产48尤物在线播放 | 99久久免费精品国产男女高不卡 | 三区四区在线观看 | 日韩欧美国产一区二区三区 | 精品久久久久久久久久久 | 免费黄色特级片 | 久久黄网 | 亚洲香蕉 | 亚洲免费精品 | 国产成人综合亚洲欧美94在线 | 日韩精品在线播放 | 日日爱av| 久久国产精品偷 | 亚洲高清视频一区二区 | 在线播放中文 | 在线观看成年视频 | 久久成人精品视频 | 久久久久久久一区二区三区 | av手机在线免费观看 | 精品美女在线观看视频在线观看 | 乳色吐息在线观看 | 婷婷桃色网 | 秋霞电影院午夜伦 | 91看片视频 | 一区二区免费在线观看 | 精品一区国产 | 亚洲午夜视频在线观看 | 黄色香蕉视频在线观看 | 国产成人精品一区二 | av网站免费观看 | 久久久久久亚洲 | 中文字幕日韩欧美 |