磁盤:最容易被忽略的性能窪地
引言:從整個軟件的性能來說,資源類性能就像是撐起冰山一角的下麵的冰層。構成這部分的,是傳統部分的磁盤、CPU、內存和網絡以及因為移動網絡而顯得特別重要的電池(耗電)。本文我們將向您著重介紹磁盤部分。
本文選自《Android移動性能實戰》。
1 原理
在沒有SSD硬盤之前,大家都會覺得我們的HDD硬盤很好用,什麼5400轉、7200轉,廣告都是棒棒的。直到有一天,SSD出現了,發現啟動Windows的時候,居然可以秒開,這才幡然醒悟。因此,對於外行來說,磁盤I/O性能總是最容易被忽略的,精力會更集中在CPU上。但是對於內行人來說,大家都懂得,性能無非是CPU密集型和I/O密集型。磁盤I/O就是其中之一。那麼到了移動時代,我們的存儲芯片性能究竟怎樣呢?在討論這個問題之前,我們來看一個測試數據。
如上圖,我們的順序讀/寫的性能進步得非常快,很多新的機型,順序讀/寫比起以前的性能,那是大幅度提升,跟SSD的差距已經縮小了很多。但是這裏有個壞消息,隨機讀/寫的性能依舊很差,見MOTO X、S7、iPhone 6S Plus。到這裏,必須給大家介紹第一個概念:隨機讀/寫。
隨機讀/寫
隨機寫無處不在,舉兩個簡單例子吧。第一個例子最簡單,數據庫的journal文件會導致隨機寫。當寫操作在數據庫的db文件和journal文件中來回發生時,則會引發隨機寫。如下表,將一條數據簡單地插入到test.db,監控pwrite64的接口,可以看到表中有底紋的地方都是隨機寫。第二個例子,如果向設置了AUTOINCREMENT(自動創建主鍵字段的值)的數據庫表中插入多條數據,那麼每插入一條數據,都需要操作兩張數據庫表,這就意味著存在隨機寫。
從上麵的例子可知,隨機讀/ 寫是相對順序讀/ 寫而言的, 在讀取或者寫入的時候隨機地產生offset。但為什麼隨機讀/ 寫會如此之慢呢?
1. 隨機讀會失去預讀(read-ahead)的優化效果。
2. 隨機寫相對於順序寫除了產生大量的失效頁麵之外,更重要的是增加了觸發“寫入放大”效應的概率。
那麼“寫入放大”又是什麼呢?下麵我們來介紹第二個概念:“寫入放大”效應。
“寫入放大”效應
當數據第一次寫入時,由於所有的顆粒都為已擦除狀態,所以數據能夠以頁為最小單位直接寫入進去。當有新的數據寫入需要替換舊的數據時,主控製器將把新的數據寫入到另外的空白閃存空間上(已擦除狀態),然後更新邏輯LBA 地址來指向到新的物理FTL 地址。此時,舊的地址內容就變成了無效的數據,但主控製器並沒執行擦除操作而是會標記對應的“頁”為無效。當磁盤需要在上述無效區域進行再次寫入的話,為了得到空閑空間,閃存必須先複製該“塊”中所有的有效“頁”到新的“塊”裏,並擦除舊“塊”後,才能寫入。(進一步學習,可參見:https://bbs.pceva.com.cn/forum.php?mod=viewthread&action=print able&tid=8277 。)
比如,現在寫入一個4KB 的數據,最壞的情況就是,一個塊裏已經沒有幹淨空間了, 但是恰好有一個“頁”的無效數據可以擦除,所以主控就把所有的數據讀出來,擦除塊, 再加上這個4KB 新數據寫回去。回顧整個過程,其實隻想寫4KB 的數據,結果造成了整個塊(512KB)的寫入操作。同時帶來了原本隻需要簡單地寫4KB 的操作變成了“閃存讀取 (512KB)-> 緩存改(4KB)-> 閃存擦除(512KB)-> 閃存寫入(512KB)”,這造成了延遲大大增加,速度慢是自然的。這就是所謂的“寫入放大”(Write Amplification) 問題。
下麵我們通過構造場景來驗證寫入放大效應的存在。
場景 1:正常向 SD 卡寫入 1MB 文件,統計文件寫入的耗時。
場景 2:先用 6KB 的小文件將 SD 卡寫滿,然後將寫入的文件刪除。這樣就可以保證 SD 卡沒有幹淨的數據塊。這時再向 SD 卡寫入 1MB 的文件,統計文件寫入的耗時。
下圖是分別在三星 9100、三星 9006 以及三星 9300 上進行的測試數據,從測試數據看, 在 SD 卡沒有幹淨數據塊的情況下,文件的寫入耗時是正常寫入耗時的 1.9~6.5 倍,因此測 試結果可以很好地說明“寫入放大”效應的存在。
那麼寫入放大效應最容易是在什麼時候出現呢?外因:手機長期使用,磁盤空間不足。內因:應用觸發大量隨機寫。這時,磁盤I/O 的耗時會產生劇烈的波動,App 能做的隻有一件事,即減少磁盤I/O 的操作量,特別是主線程的操作量。那麼如何發現、定位、解決這些磁盤I/O 的性能問題呢?當然就要利用我們的工具了。
2 工具集
工具集如下表。
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 調用的堆棧,幫助定位問題。
詳細代碼:
https://androidxref.com/4.4.4_r1/xref/libcore/luni/src/main/java/libcore/io/BlockGuardOs.java
Perfbox:I/OMonitor
原理:I/OMonitor的功能可以歸結為通過Hook Java層係統I/O的方法,收集區分進程和場景的I/O信息。
1. Hook java方法
I/O Monitor Hook java方法借鑒了開源項目xposed,網上介紹xposed的文章很多,這裏就用流程圖來簡要說明獲取此次I/O操作信息的方法。
2. 區分進程和場景的I/O 信息收集
區分進程和場景的I/O 信息收集有以下4個步驟。
(1)app_process 替換
app_process 是Android 中Java 程序的入口,通過替換app_process 就可以控製入口, 在任何一個應用中運行我們的代碼。替換後的app_process 工作流程如下。
(2) 將libfork.so添加到環境變量LD_PRELOAD中
在UNIX中,LD_PRELOAD是一個可以影響程序的運行時鏈接的環境變量,讓你可以定義在程序運行前優先加載的動態鏈接庫。而這個功能就可以用來有選擇性地載入不同動態鏈接庫中的相同函數。而在zygote進程啟動前設置LD_PRELOAD環境變量,這樣zygote的所有子進程都會繼承這個環境變量。libfork.so實現了一個fork函數,當app_process通過fork函數來啟動zygote進程時,會優先使用libfork.so中實現的fork函數,fork函數的流程如下。
(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工作流程圖如下。
這樣就實現了在應用進程啟動時,控製在指定進程中運行I/O Monitor的功能。
(4) 區分場景的I/O信息收集
為了實現分場景的I/O信息收集,我們給I/O Monitor添加了一個開關,對應的就是Python控製腳本,這樣便可以實現指定場景的I/O信息收集,使測試結果做到更精準。
這樣我們就實現了區分進程和場景的I/O 信息收集。
在介紹了我們的工具原理之後,來看一下采集的I/O 日誌信息,包括文件路徑、進程、線程、讀/ 寫文件的次數、大小和耗時以及調用的堆棧。
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”(https://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類型有表葉子頁、表內部頁、索引葉子頁、索引內部頁以及溢出頁,采集的數據庫日誌信息如下。
費盡了千辛萬苦,終於拿到了數據庫讀/寫磁盤的信息,但是這些信息有什麼用呢?我們能想到可以有以下用途。
- 通過I/O數據的量直觀地驗證數據庫優化效果。
- 通過偏移量找出隨機讀/寫進行優化。
但是我們又麵臨另外一個問題,因為獲取的磁盤信息是基於DB 文件的,而應用層操作數據庫是基於表的,同時又缺乏堆棧,很難定位問題。基於此,我們又想到了另外一個解決方法,就是Hook 應用代碼的數據庫操作,通過堆棧把兩者對應起來,這樣就可以把應用代碼聯係起來,更方便分析問題。
2. Hook 應用層SQL 操作
Hook 應用代碼其實就是Hook SQLiteDatabase 裏麵的數據庫增刪改查操作,應用代碼SQL 語句如下,Java 層Hook 基於Xposed 的方案實現。
最終可以通過堆棧和磁盤信息對應起來。
獲取到了這麼多數據,我們在之後的推送中將向大家介紹一些數據庫相關的案例,看其如何應用。
本文選自《Android移動性能實戰》,點此鏈接可在博文視點官網查看此書。
想及時獲得更多精彩文章,可在微信中搜索“博文視點”或者掃描下方二維碼並關注。
最後更新:2017-04-25 15:00:58