磁盤:最容易被忽略的性能窪地

引言:從整個軟件的性能來講,資源類性能就像是撐起冰山一角的下面的冰層。構成這部分的,是傳統部分的磁盤、CPU、內存和網絡以及由於移動網絡而顯得特別重要的電池(耗電)。本文咱們將向您着重介紹磁盤部分。
本文選自《Android移動性能實戰》。php

1 原理

  在沒有SSD硬盤以前,你們都會以爲咱們的HDD硬盤很好用,什麼5400轉、7200轉,廣告都是棒棒的。直到有一天,SSD出現了,發現啓動Windows的時候,竟然能夠秒開,這才幡然醒悟。所以,對於外行來講,磁盤I/O性能老是最容易被忽略的,精力會更集中在CPU上。可是對於內行人來講,你們都懂得,性能無非是CPU密集型和I/O密集型。磁盤I/O就是其中之一。那麼到了移動時代,咱們的存儲芯片性能究竟怎樣呢?在討論這個問題以前,咱們來看一個測試數據。
              圖片描述
  如上圖,咱們的順序讀/寫的性能進步得很是快,不少新的機型,順序讀/寫比起之前的性能,那是大幅度提高,跟SSD的差距已經縮小了不少。可是這裏有個壞消息,隨機讀/寫的性能依舊不好,見MOTO X、S七、iPhone 6S Plus。到這裏,必須給你們介紹第一個概念:隨機讀/寫。java

隨機讀/寫

  隨機寫無處不在,舉兩個簡單例子吧。第一個例子最簡單,數據庫的journal文件會致使隨機寫。當寫操做在數據庫的db文件和journal文件中來回發生時,則會引起隨機寫。以下表,將一條數據簡單地插入到test.db,監控pwrite64的接口,能夠看到表中有底紋的地方都是隨機寫。第二個例子,若是向設置了AUTOINCREMENT(自動建立主鍵字段的值)的數據庫表中插入多條數據,那麼每插入一條數據,都須要操做兩張數據庫表,這就意味着存在隨機寫。
           圖片描述
           圖片描述
  從上面的例子可知,隨機讀/ 寫是相對順序讀/ 寫而言的, 在讀取或者寫入的時候隨機地產生offset。但爲何隨機讀/ 寫會如此之慢呢? 
  1. 隨機讀會失去預讀(read-ahead)的優化效果。
  2. 隨機寫相對於順序寫除了產生大量的失效頁面以外,更重要的是增長了觸發「寫入放大」效應的機率。
  那麼「寫入放大」又是什麼呢?下面咱們來介紹第二個概念:「寫入放大」效應。android

「寫入放大」效應

  當數據第一次寫入時,因爲全部的顆粒都爲已擦除狀態,因此數據可以以頁爲最小單位直接寫入進去。當有新的數據寫入須要替換舊的數據時,主控制器將把新的數據寫入到另外的空白閃存空間上(已擦除狀態),而後更新邏輯LBA 地址來指向到新的物理FTL 地址。此時,舊的地址內容就變成了無效的數據,但主控制器並沒執行擦除操做而是會標記對應的「頁」爲無效。當磁盤須要在上述無效區域進行再次寫入的話,爲了獲得空閒空間,閃存必須先複製該「塊」中全部的有效「頁」到新的「塊」裏,並擦除舊「塊」後,才能寫入。(進一步學習,可參見:http://bbs.pceva.com.cn/forum.php?mod=viewthread&action=print able&tid=8277 。) 
  好比,如今寫入一個4KB 的數據,最壞的狀況就是,一個塊裏已經沒有乾淨空間了, 可是剛好有一個「頁」的無效數據能夠擦除,因此主控就把全部的數據讀出來,擦除塊, 再加上這個4KB 新數據寫回去。回顧整個過程,其實只想寫4KB 的數據,結果形成了整個塊(512KB)的寫入操做。同時帶來了本來只須要簡單地寫4KB 的操做變成了「閃存讀取 (512KB)-> 緩存改(4KB)-> 閃存擦除(512KB)-> 閃存寫入(512KB)」,這形成了延遲大大增長,速度慢是天然的。這就是所謂的「寫入放大」(Write Amplification) 問題。
          【圖4】
  下面咱們經過構造場景來驗證寫入放大效應的存在。
  場景 1:正常向 SD 卡寫入 1MB 文件,統計文件寫入的耗時。
  場景 2:先用 6KB 的小文件將 SD 卡寫滿,而後將寫入的文件刪除。這樣就能夠保證 SD 卡沒有乾淨的數據塊。這時再向 SD 卡寫入 1MB 的文件,統計文件寫入的耗時。
  下圖是分別在三星 9100、三星 9006 以及三星 9300 上進行的測試數據,從測試數據看, 在 SD 卡沒有乾淨數據塊的狀況下,文件的寫入耗時是正常寫入耗時的 1.9~6.5 倍,所以測 試結果能夠很好地說明「寫入放大」效應的存在。
          【圖5】sql

  那麼寫入放大效應最容易是在何時出現呢?外因:手機長期使用,磁盤空間不足。內因:應用觸發大量隨機寫。這時,磁盤I/O 的耗時會產生劇烈的波動,App 能作的只有一件事,即減小磁盤I/O 的操做量,特別是主線程的操做量。那麼如何發現、定位、解決這些磁盤I/O 的性能問題呢?固然就要利用咱們的工具了。數據庫

2 工具集

  工具集以下表。
   【圖6】
  STRICTMODE 應該是入門級必備工具了,能夠發現並定位磁盤I/O 問題中影響最大的主線程I/O。由下面代碼可見,啓用方法很是簡單。緩存

public void onCreate() { 
   if (DEVELOPER_MODE) { 
   StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder() 
   .detectDiskReads() 
   .detectDiskWrites() 
   .detectNetwork() 
   .penaltyLog() 
   .build()); 
   super.onCreate(); 
 } 
}

  原理也很是簡單,主要是文件操做(BlockGuardOs.java)、數據庫操做(SQLiteConnection. java)和SharePreferences 操做(SharedPreferencesImpl.java)的接口中插入檢查的代碼。咱們截取了一段Android 源碼中文件操做的監控實現代碼,以下,最後實際調用StrictMode 中的onWriteToDisk 方法,經過建立BlockGuardPolicyException 來打印I/O 調用的堆棧,幫助定位問題。
         【圖7】
詳細代碼: 
http://androidxref.com/4.4.4_r1/xref/libcore/luni/src/main/java/libcore/io/BlockGuardOs.java#91微信

Perfbox:I/OMonitor

  原理:I/OMonitor的功能能夠歸結爲經過Hook Java層系統I/O的方法,收集區分進程和場景的I/O信息。網絡

1. Hook java方法

  I/O Monitor Hook java方法借鑑了開源項目xposed,網上介紹xposed的文章不少,這裏就用流程圖來簡要說明獲取這次I/O操做信息的方法。
                      【圖8】app

2. 區分進程和場景的I/O 信息收集

  區分進程和場景的I/O 信息收集有如下4個步驟。ide

(1)app_process 替換

  app_process 是Android 中Java 程序的入口,經過替換app_process 就能夠控制入口, 在任何一個應用中運行咱們的代碼。替換後的app_process 工做流程以下。
                      【圖9】

(2) 將libfork.so添加到環境變量LD_PRELOAD中

  在UNIX中,LD_PRELOAD是一個能夠影響程序的運行時連接的環境變量,讓你能夠定義在程序運行前優先加載的動態連接庫。而這個功能就能夠用來有選擇性地載入不一樣動態連接庫中的相同函數。而在zygote進程啓動前設置LD_PRELOAD環境變量,這樣zygote的全部子進程都會繼承這個環境變量。libfork.so實現了一個fork函數,當app_process經過fork函數來啓動zygote進程時,會優先使用libfork.so中實現的fork函數,fork函數的流程以下。
                      【圖10】

(3) 將XPlatform.jar 添加到環境變量CLASSPATH 中

  將XPlatform.jar 加入到CLASSPATH 中,是爲了可讓像common.jar 這種插件型jar 使用XPlatform.jar 中的類。手機QQ 中也存在相似事情,開發的同事把整個工程編譯成了兩個dex 文件,在手機QQ 啓動後,把第二個dex 文件放入CLASSPATH 中(與XPlatform 實現方法不一樣,但效果相同),這樣主dex 能夠直接import 並使用第二個dex 中的類。若是不加入CLASSPATH,須要藉助DexClassLoader 類來使用另外一個jar 包中的類,這樣使用起來很麻煩,而且會有很大的限制。
在系統啓動過程當中,app_process 進程其實是zygote 進程的前身,因此XPlatform.jar 是在zygote 進程中運行的。
  在XPlatform 中主要Hook 了兩個java 方法,來監控system_server 進程和應用進程的啓·11· 動,並在這些進程中作一些初始化的操做。這裏面用了一個fork的特性,父進程使用fork建立子進程,子進程會繼承父進程的全部變量,因爲zygote使用fork建立子進程,因此在zygote進程中進行Hook,在它建立的任何一個應用進程和system_server進程也是生效的。
  XPlatform工做流程圖以下。
                      【圖11】
  這樣就實現了在應用進程啓動時,控制在指定進程中運行I/O Monitor的功能。

(4) 區分場景的I/O信息收集

  爲了實現分場景的I/O信息收集,咱們給I/O Monitor添加了一個開關,對應的就是Python控制腳本,這樣即可以實現指定場景的I/O信息收集,使測試結果作到更精準。
                      【圖12】
  這樣咱們就實現了區分進程和場景的I/O 信息收集。
  在介紹了咱們的工具原理以後,來看一下采集的I/O 日誌信息,包括文件路徑、進程、線程、讀/ 寫文件的次數、大小和耗時以及調用的堆棧。
     【圖13】
  XPlatform工做流程圖中的數聽說明:某個文件的一次對應CSV文件中的一行,每次調用系統的API(read或者write方法),讀/寫次數(readcount, writecount)就加1。讀/寫耗時(readtime, writetime)是計算open到close的時間。

SQLite性能分析/監控工具 SQL I/O Monitor

  咱們知道,數據庫操做最終操做的是磁盤上的DB文件,DB文件和普通的文件本質上並沒有差別,而I/O系統的性能一直是計算機的瓶頸,因此優化數據庫最終落腳點每每在如何減小磁盤I/O上。
  不管是優化表結構、使用索引、增長緩存、調整page size等,最終的目的都是減小磁盤I/O,而這些都是咱們常規的優化數據庫的手段。習慣從分析業務特性、嘗試優化策略到驗證測試結果的正向思惟,那麼咱們爲什麼不能逆向一次?既然數據庫優化的目的都是減小磁盤I/O,那咱們能不能直接從磁盤I/O數據出發,看會不會有意想不到的收穫。

1.採集數據庫I/O數據

  要想實現咱們的想法,第一步固然要採集數據庫操做過程當中對應的磁盤I/O數據。因爲以前經過Java Hook技術,獲取到了Java層的I/O操做數據,雖然SQLite的I/O操做在libsqlite.so進行,屬於Native層,但咱們會很天然地想到經過Native Hook採集SQLite的I/O數據。
Native Hook主要有如下實現方式。
  (1)修改環境變量LD_PRELOAD。
  (2)修改sys_call_table。
  (3)修改寄存器。
  (4)修改GOT表。
  (5)Inline Hook。
  下面主要介紹(1)、(4)、(5)三種實現方式。

(1)修改環境變量LD_PRELOAD

  這種方式實現最簡單,重寫系統函數open、read、write和close,將so庫放進環境變量LD_PRELOAD中,這樣程序在調用系統函數時,會先去環境變量裏面找,這樣就會調用重寫的系統函數。能夠參考看雪論壇的文章「Android使用LD_PRELOAD進行Hook」(http://bbs.pediy.com/showthread.php?t=185693)。
可是這種Hook針對整個系統生效,即系統全部I/O操做都被Hook,形成Hook的數據量巨大,系統動不動就卡死。

(4)修改GOT 表

  引用外部函數的時候,在編譯時會將外部函數的地址以Stub 的形式存放在.GOT 表中,加載時linker 再進行重定位,即將真實的外部函數寫到此stub 中。Hook 的思路就是替換.GOT 表中的外部函數地址。而libsqlite.so 中的I/O 操做是調用libc.so 中的系統函數進行,因此修改GOT 表的Hook 方案是可行的。
  然而現實總不是一路順風的,當咱們的方案實現後,發現只能記錄到libsqlite.so 中的open 和close 函數調用,而因爲sqlite 的內部機制而致使的read/write 調用咱們沒法記錄到。

(5)Inline Hook

  在前兩種方案無果後,只能嘗試Inline Hook。Inline Hook 能夠Hook so 庫的內部函數, 咱們首先想到的是Hook libsqlite.so 內部I/O 接口posixOpen、seekandread、seekandwrite 以及robust_close。可是在成功的路上老是充滿波折,sqlite 內部居然將大部分的關鍵函數定義爲static 函數,如posixOpen。在C 語言中,static 函數是不導出符號的,而Inline Hook 就是要在符號表中找到對應的函數位置。這樣一來,經過Hook sqlite 內部函數的路子又行不通了。

static int posixOpen(const char *zFile, int flags, int mode){ 
return open(zFile, flags, mode); 
}

  既然這樣不行,那咱們只能更暴力地Hook libc.so 中的open、read、write 和close 方法。由於無論sqlite 裏面怎麼改,最終仍是會調用系統函數,惟一很差的是這樣錄到了該進程全部的IO 數據。這種方法在本身編譯的libsqlite.so 裏面證明是可行的。
  正當我滿懷欣喜地去調用手機自帶的libsqlite.so 庫時,讀/ 寫數據再一次沒有被記錄到, 我當時的心裏幾乎是崩潰的。爲何我本身編譯的libsqlite.so 庫能夠,用手機上的就不行呢?沒辦法,只能再去看以下面的源碼,最後在seekAndRead 裏面發現,sqlite 定義了不少宏開關,能夠決定調用系統函數pread、pread64 以及read 來進行讀文件。莫非我本身編的so 和手機裏面的so 的編譯方式不同?

static int seekAndRead(unixFile *id, sqlite3_int64 offset, void *pBuf, int cnt){ 
int got; 
int prior = 0;#if (!defined(USE_PREAD) && !defined(USE_PREAD64)) i64 newOffset;#endif TIMER_START; 
do{#if defined(USE_PREAD) got = osPread(id->h, pBuf, cnt, offset); 
SimulateIOError( got = -1 );#elif defined(USE_PREAD64) got = osPread64(id->h, pBuf, cnt, offset); 
SimulateIOError( got = -1 );#else newOffset = lseek(id->h, offset, SEEK_SET); 
SimulateIOError( newOffset-- );

  筆者又Hook 了pread和pread64,這一次終於記錄到了完整的I/O數據,原來手機裏面的libsqlite.so調用系統的pread64和pwrite64函數來進行I/O操做,同時經過Inline Hook獲取到了數據庫讀/寫磁盤時page的類型,sqlite的page類型有表葉子頁、表內部頁、索引葉子頁、索引內部頁以及溢出頁,採集的數據庫日誌信息以下。
      【圖14】
  費盡了千辛萬苦,終於拿到了數據庫讀/寫磁盤的信息,可是這些信息有什麼用呢?咱們能想到能夠有如下用途。

  • 經過I/O數據的量直觀地驗證數據庫優化效果。

  • 經過偏移量找出隨機讀/寫進行優化。

可是咱們又面臨另一個問題,由於獲取的磁盤信息是基於DB 文件的,而應用層操做數據庫是基於表的,同時又缺少堆棧,很難定位問題。基於此,咱們又想到了另一個解決方法,就是Hook 應用代碼的數據庫操做,經過堆棧把二者對應起來,這樣就能夠把應用代碼聯繫起來,更方便分析問題。

2. Hook 應用層SQL 操做

  Hook 應用代碼其實就是Hook SQLiteDatabase 裏面的數據庫增刪改查操做,應用代碼SQL 語句以下,Java 層Hook 基於Xposed 的方案實現。
         【圖15】

  最終能夠經過堆棧和磁盤信息對應起來。

【圖16】

  獲取到了這麼多數據,咱們在以後的推送中將向你們介紹一些數據庫相關的案例,看其如何應用。

  本文選自《Android移動性能實戰》,點此連接可在博文視點官網查看此書。
                    圖片描述
  想及時得到更多精彩文章,可在微信中搜索「博文視點」或者掃描下方二維碼並關注。
                       圖片描述

相關文章
相關標籤/搜索