【Android Linux內存及性能優化】(四) 進程內存的優化 - 動態庫- 靜態庫


本文接着
【Android Linux內存及性能優化】(一) 進程內存的優化 - 堆段
【Android Linux內存及性能優化】(二) 進程內存的優化 - 棧段 - 環境變量 - ELF
【Android Linux內存及性能優化】(三) 進程內存的優化 - 數據段 - 代碼段


一、內存篇

1.1 系統當前可用內存

1.2 進程的內存使用

1.3 進程內存優化

1.3.1 ELF執行文件

1.3.2 動態庫

動態庫技術是當前程序中經常採用的技術,其目的是減小程序的大小,節省空間,提高效率,具有很高的靈活性。
與靜態庫不同,動態庫裏面的函數不是執行程序本身的一部分,
而是根據執行需要按需載入,其執行代碼可以同時在多個程序中共享。

動態庫加載方式有兩種:

  1. 靜態加載
    在程序編譯時,加上“ -l ”選項,指定其所依賴的動態庫,這個庫名字將記錄在 ELF 文件的 .dynamic 節。
    在程序運行時,loader 會預先將程序所依賴的所有動態庫都加載在進程空間中。
    優點: 動態庫的接口調用簡單,可以直接調用。
    缺點: 動態庫的生存週期等於進程的生存週期,其加載時機不靈活。

  2. 動態加載
    在程序中調用函數(dlopen、dlclose)來控制動態庫加載與卸載。
    優點:動態庫加載的時機非常靈活,可以非常細緻地定義動態庫搞錯生存週期。
    缺點:動態庫的接口調用起來比較麻煩,同時還要關注動態庫的生存週期。

前面介紹進程時,分別包括: 只讀的代碼段、可修改的數據段、堆段 和 棧段。
對於共享庫來說,分爲兩個段:只讀的代碼段 、 可修改的數據段。
如果你在共享庫的函數裏,動態分配一塊內存,這段內存將被算在調用該函數的進程的堆中。

對於共享庫的代碼段 和 數據段:

  • 代碼段由於其是隻讀的,內容 不會改變,對每個進程都是一樣的,所以它在系統中是唯一的,系統只爲其分配一塊內存,多個進程之間共享。
  • 數據段由於其內容在進程運行期間是變化的,每個進程都要對其進行改寫,所以在鏈接到進程空間後,系統會爲每個進程創建相應的數據段。也就是說如果一個共享庫,被 N 個進程鏈接,當這N 個進程同時運行時,同時共享一個代碼段,每個進程擁有一個數據段,系統中共有該動態鏈接庫的1 個代碼段 和 N 個數據段。

1.3.2.1 數據段

1.3.2.1.1 共享庫中的.bss 節

前面講過,在進程中的bss,如果數據段容不下,它將使用mmap 在堆段內存分配大內存。
那 共享庫中是怎麼分配的呢?

例:

a.c
#include <stdlib.h>
#include <stdio.h>

int bss[1024*128] = {0};
void a1(){
	printf("bss: %p\n",bss);
	return;
}
編譯成動態庫: gcc -shared -fPIC  a.c  -o  liba.so

hello.c
#include <stdlib.h>
#include <stdio.h>

extern int bss[1024*128];

int main(){
	a1();		// 調用a1()
	int pid = getpid();
	printf("pid: %d\n",pid);
	funca();
	pause();
}
編譯成可執行文件: gcc -L./  -Ia  hello.c -o hell

在這裏插入圖片描述

編譯執行後,使用 cat maps 和 cat memmap 可以看看出,
對於共享庫的 bss 節的數據,如果數據段不能容納的話,進程將會創建一內存段來容納bss 節的數據,
其中 bss 數組起始地址位於動態庫的數據段。
loader 在加載動態庫時會自動將數據段最後一個頁面剩餘的地址自動清零,留給 bss節的變量使用,故其數據段使用了一個物理頁而。

如果在進程中引用了共享庫的全局變量,進程將會擴展它的堆棧段,並將 bss 段的數據複製到堆棧段中來。

因此,不要在進程中通過extern 的方式,引用共享庫中的變量,一旦引用,不論其是否使用,都會將佔用物理內存。
同時還會增加系統啓動時內存複製的代價,會導致性能下降


1.3.2.1.2 共享庫中的.data 節

對於未賦值或初值爲0 的全局變量在共享庫中的聲明,
在進程中使用,則該變量被複制到進程的數據段,同時修改使用該變量的共享庫的指向。

當主程序鏈接了一個共享庫的全局變量時,它會爲該變量定義一個地址,但它不會影響數據段的大小,將該值複製到這個地址上。如果地址段不夠用,它將佔用堆段,系統將調用brk 來擴展堆段。

總之,可執行文件(動態庫)儘量不要直接去操作位於其他動態庫的全局變量,跨動態庫的直接訪問全局變量的代價很高,可以在動態庫中編寫接口函數來操作全局變量,並將這些接口導出,供其他進程(或動態庫)使用。


1.3.2.2 代碼段

動態庫的代碼段會被多個不同的進程所引用,所以動態庫的代碼段與執行文件的代碼段有所不同。

1.3.2.2.1 符號解析

前面講解過的ELF文件的主體結構,ELF 其中有兩個section:.rel.dyn 和 .rel.plt 。
主要負責共享庫的重定向工作,現在就來看看它們的內容 。
在這裏插入圖片描述
可以看到變量 b 在 .rel.dyn 中,而 hello中用到的外部函數 funca 、printf 在 .rel.plt 中。

在加載 liba.so 後,以及查找全局變量b 時,會將liba.so 的b 複製到自已的數據段,並且修改 liba.so 中的 b 的指向。
主要是因爲進程不會爲這些共享庫的變量做重定向,它只是把該數據複製到自已的數據段,然後要求對應的共享庫修改其對應的指向。

  • 如果全局變量聲明在進程中,其共享庫中使用,則該變量位於進程的數據段。
  • 如果全局變量聲明在共享庫中,在進程中使用,則該變量被複制到進程的數據段。
  • 如果該變量在共享中聲明,在共享庫中使用,則該變量位於聲明它的共享庫的數據段中。

1.3.2.2.2 導出函數對代碼段的影響

在共享庫中所定義的函數,缺省都是導出函數。
通過 readelf -a liba.so ,可以看出各節的大小。

  1. 每增加一個函數
    .hash = .hash + 4
    .dynsym = .dynsym + 16
    .dynstr = .dynstr + 函數名長度 + 1
    .gnu.version = .gnu.version + 2
    .text 節按函數代碼長度有所增長

  2. 每增加一個全局變量
    .hash = .hash + 4
    .dynsym = .dynsym + 16
    .dynstr = .dynstr + 變量名長度 + 1
    .gnu.version = .gnu.version + 2
    .data 節按函數代碼長度有所增長

從這裏可以看出,每增加一個導出函數,除了自身代碼變化外,需要增加 24 + 函數名長度個字節。
而實際上,在共享庫代碼中有很多函數和變量,只是在共享庫內部使用,不需要導出,因此,只要可以精確定義出外部使用的函數,那麼就可以節省 .dynsym 和 .dynstr 等section 的大小,從而節省內存。

  1. 第一種 方法是:使用 static 修飾。
    使用static 修飾過的函數和全局變量,都將具有內部鏈接的屬性,只在其所在的編譯單元有效,編譯時,不會作爲導出符號。

  2. 第二種 方法是:使用 GCC 的 --version-script 選項。
    需要定義一個文件,標明所要導出符號,示例如下:

    編譯時,命令如下: gcc -o liba.so -shared -fPIC -WI,--version-script=a.map a.c

//   a.map
{
	global:
		functa;
	local: *;
}

1.3.2.2.3 查看依賴關係

在查看依賴關係時,我們經常使用工具 ldd,它能打印出該動態庫所依賴的所有動態庫,包括直接依賴和間接依賴。
如果想直接看直接依賴的動態庫,使用 readelf -d liba.so

示例如下,可以看到,該庫依賴了哪些庫。

ciellee@sh:~$ readelf  -d libnative-platform.so 

Dynamic section at offset 0x4b70 contains 25 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libstdc++.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [libm.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [libgcc_s.so.1]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000e (SONAME)             Library soname: [libnative-platform.so]
 0x000000000000000c (INIT)               0x1fc0
 0x000000000000000d (FINI)               0x3f88


1.3.2.3 動態庫的優化

由於一個動態庫的數據段所佔內存會隨着依賴它的進程的數量線性增長,而其代碼段則是系統共享,所以應該將優化的重點放在動態庫的數據段。

1.3.2.3.1 減少 .bss 節的數據

在做動態庫數據段優化的時候,有這麼一個概念:
在加載動態庫的時候,.bss 節的數據將被單獨放在一個數據段中,試想,1kb 的data 數據 和 1kb 的bss 數據, 要分別佔用2 個 4kb 的物理頁面,浪引費了 6kb。 如果將bss 的數據全部賦上初值,轉化爲 data 段,這樣就可以轉化爲 1 個2kb 的數據段,佔用一個物理頁面,從而節省出一個物理頁面。

這種優化思路:
關鍵在於加載動態庫的時候 bss 數據單獨分出一坐個內存段來。可實際上,在是最後一個頁面,哪果還有剩餘的話,會使用 bss 去補齊, 1KB 的 data 數據 和 1 KB的 bss 數據, 最的合在一起只佔用1個頁面,總共4 kb,並不省出一個物理頁面。

loader 在補齊的時候,採用了將這這些bss 變量所 對應的肉存直接清0 的方式,這會造成動態庫數據段最後一個頁面的寫操作,從而分配物理內存。因此,在大多數情況下,你會必以現數據段的最後一個頁面被分配了物理頁面。

因此,通過將 bss 變量賦初值,將 bss 段與數據段合併的方式,並不能優化內存。


1.3.2.3.2 無用的動態庫

loader 在加載動態庫時 ,需要做很多事情,
對於代碼段來講其要做一些重定位的工作,至佔用一個物理頁面;
對於數據段來講,首先要做一些重定位的工作,在映射數據段時,還要在最後一個頁面,將.data 節未填充完的剩餘字節填充爲0,其最少要佔用一個物理頁面。
也就是說,鏈接一個無用的共享庫,至少損失了8kb 的物理內存。
對於每個無論是直接依賴還是間接依賴該動態庫的進程,都將損失 8 KB 的物理內存。

  1. 使用readelf 讀取動態庫A 的dynamic 節,獲取其所有的直接依賴的動態庫。
  2. 讀取動態庫A 中所有weak 和 undefine 的符號。
  3. 讀取動態庫 A 所直接依賴動態庫所有已經定義的符號。
  4. 將上面的結果取個交集,剔除一些系統的符號,如果交集不爲空,就認爲兩個動態庫有實際的依賴關係,否則就是鏈接了無用的動態庫。

1.3.2.3.3 動態庫的合併

試想一個場景,動態庫A 的數據段爲 1kb,動態庫B的數據段爲 1kb ,進程加載兩個動態庫,就需要2個頁面 8kb 的內存。如果將這兩個動態庫合併到一起,那麼合併動態庫的數據段所佔內存爲 4kb ,節省出一個物理頁面。

問題的關鏈是:
將很多小動態庫合併成一個大動態庫還是將一個大動態庫拆分成 爲若干個小動態庫。

  1. 將很多小動態庫合併成一個大動態庫
    優點: 數據段都合併到一起,節約內存。
    缺點:進程可能會因此引入很多無用的內容,
    對於C 語言的動態庫來講,在加載動態庫時,只是爲數據段分配了一起虛內存,沒有用到的全局變量並不會佔用更多的內存。
    而對於 C++ 來講,對於非內置類型的全局變量,在加載完後需要調用其構造函數,會產生 dirty page ,反而有可能增加內存使用。

  2. 將一個大動態庫拆分成若干個小動態庫
    優點:進程可以只加載那些它所需要的內容,對於C++ 的庫來講可能會節省內存。
    缺點:需要加載更多的動態庫,而產生很的內存段,造成每個動態庫數據段最後一個頁面部分空間浪費。

因此,對於 C語言寫的庫,動態庫的合併會通過合併數據段來節省內存;
對於 C++ 編寫的動態庫,有可能會由於無用的非內置類型的全局變量的增加而增加內存消耗。


如果幾個動態庫同時出現的話,那麼動態庫的合併從內存角度來講,對進程有益無害。

關於動態庫的合併,可以總結如下幾點:
(1) 對於C語言編寫的動態庫,合併動態庫沒有什麼害處。
(2) 對於C++ 編寫的動態庫,可以考慮將一些不常用的功能分拆,使用 dlopen 的方式加載,來節省內存。
(3) 防止由於動態庫之間的合併,導致進程加載很多無關的內容。
(4) 對於經常一同出現的動態庫,可以合併。


1.3.2.3.4 僅被依賴一次的動態庫

下面再來考慮一種特殊情形:
一個動態庫A 只被某一個動態庫(或執行文件)B 所依賴,我們可以說這個動態庫A 和 動態庫B 是100% 同時出現的,
那可以考慮將動態庫A 做成一個靜態庫,然後將其與動態庫 B 進行合併,從內存的角度來講這是有益無害的。

查找僅使用一次的動態庫方法爲:
(1)使用 readelf 讀取動態庫的 dynamic 節,打到其所有直接依賴的動態庫 。
(2)爲每一個動態庫建立一個依賴於它的動態庫和執行文件表。表長度爲 1 的動態庫,即是僅被依賴一次的動態庫。


1.3.2.3.5 使用 dlopen 來控制動態庫的生存週期

在啓動進程時,進程會加載了很多的動態庫 ,有些動態庫是很少用到的,有些可能只用一次,而進程一啓動就全部加載進來,每個動態庫最少使用8KB(4KB代碼段,4KB數據段)內存,實在是非常浪費。
更嚴重的是,動態庫的數據段一旦使用使用後,便無法釋放,將會導致動態庫所佔用的內存越來越多。

使用dlopen 加載一個共享庫時:
(1)進程會加載該動態庫的txt 段和 數據段,同時爲這個代享庫講數 +1 .
(2)進程查找該共享庫的dynamic 節,查看其所依賴的共享庫。
(3) 首先檢查所依賴庫是否已經被加載,如果已被加載,則爲這個 這個共享庫計數 +1.
如果未被加載,則加載其 txt段 和 data段,然後爲這個共享庫計數+1.
(4)再查找這些庫所依賴的庫,重複上面的佔驟。
最終進程會爲每個加載的共享庫維護一個依賴的計數。

使用dlclose 卸載共享庫時:
(1)首先將該共享庫的計數減1,如是該共享庫依賴計數爲0,則卸載該共享庫。
(2)在dynamic 節中,查找其所依賴的共享庫。
(3)爲每個共享庫的計數減1,如果該共享庫依賴計數爲0,則卸載該共享庫。
(4)重複上面的步驟。

dlopen的優點在於:
(1)可以在程序啓動的時候,減少加載庫的數量,這樣可以加快進程的啓動速度和減少加載庫的內存使用。
(2)爲進程提供了卸載共享庫的機會,達到回收共享庫的代碼段和數據段所佔用內存的目的。

缺點在於:
對於程序員編碼來講,很不方便。


現在使用dlopen 來提高程序性能和節約內存的程序員越來越多。下面舉例一些採用 dlopen 優化時,經常遇到的問題。

  1. 由於dlopen 的嵌套,導致一些動態庫沒有卸載
    比如:
    進程 p:
    共享庫 liba.so : 依賴於 libb.so
    共享庫 libc.so : 依賴於 libd.so
    進程P 的執行過程爲:
    (1)進程P,通過 dlopen 調用liba.so ,進程將liba.so 和 libb.so 加載到進程中。
    (2)進程P,通過liba.so 的庫函數,dlopen 加載libc.so ,進程將libc.so 和libd.so 加載到進程中。
    (3)這時,進程P ,調用dlclose 來卸載 liba.so,dlclose 會查找其所依賴的庫,那麼它只能查到libb.so, 所以它將卸載 libb.so 及 liba.so.
    這樣雖然進程P關閉了 liba.so ,但是進程的所加載的共享庫卻增加了 libc.so 和 libd.so ,導致共享庫並沒有完全卸載。
    這個問題的根源在於,進程P 在卸載liba.so 之前,應該先調用liba.so 裏面的函數卸載 libc.so。

    這個問題的難點在於,需要一個時機,在dlclose 一個動態庫時,將這個動態庫所有 dlopen 的動態庫關閉。
    如果每次 dlclose 的進候,去檢查這個庫是否又打開了其他的庫,那麼就需要上層的函數了解該動態庫詳細的實現細節,這不符合軟件的封裝的概念。

    好在glibc 提供了一個這樣的時機: 程序員可以將自已定義的函數聲明爲動態庫的構造函和析構函數:
    void __attribute__ ((constructor))my_init(void);
    void __attribute__ ((destructor)) my_fini(void);

    在編譯共享庫時,不能使用 “-nonstartfiles” 或 “-nostdlib” 選項,否則構造與析構函數將不能正常執行。
    可以在libs.so 的析構函數中,檢測自已都dlopen 了哪些動態庫,並將其全部dlclose。


  1. dlopen 有可能導致內存泄漏
    比如: 在動態庫中要使用進程中唯一的對象 mInstance ,標準寫法是:
// liba.so

static Myclass * mInstance; // 聲明一個靜態Myclass 的對象指針
Myclass getInstance(){
	if(NULL == mInstance) mInstance = new Myclass;
	return mInstance;
}

上面的代碼,引入dlopen 後,有可能會導致內存泄漏

dlopen(liba.so);			// 進程加載動態庫liba.so 時,同時初始化了其數據段,這時mInstance 應該爲空
pInstance = getInstance();	
// getInstannce 時,進程在堆中分配了一塊內存,生成一個Myclase  實例,同時爲數據段的 mInstance 賦值 
......
dlclose(liba.so)
//卸載 liba.so 後,這時mInstance是不存在的,也就是丟失了在堆中生成的Myclass 的對象實例
......
dlopen(liba.so);	//進程進程加載動態庫liba.so 時,同時初始化了其數據段,這時mInstance 還是爲空
pInstance = getInstance()
// getInstannce 時,進程在堆中分配了一塊內存,生成一個Myclase  實例,同時爲數據段的 mInstance 賦值 
......
dlclose(liba.so)
......
結查,如果重複循環的話,堆中會有越來越多的 Myclass 的對象實例,導致 Myclass 對象內存卸漏。

這個 問題的實質是:、
在程序員的心目中,一個static 對象的生存週期是貫穿進程始終的。
實際上並非如此,在動態庫中的static 對象,其生命週期等於該動態庫的生命週期。
如果採用靜態鏈接的方式,動態庫的生命週期等於進程的聲明週期;
而採用動態加載的方式,則是不同的,其生命週期等於該動態庫的生命週期。
爲了避免上面的問題出現,需要在動態卸載的時候,釋放該塊內存。


有兩個方法如下:
(1)寫一個釋放內存的函數,在調用dlclose 函數之前,調用該函數。
(2)在動態庫的析構函數中,釋放這塊內存。 void __attribute__ ((destructor)) my_fini(void);

1.3.3 靜態庫

相信大家都比較清楚動態庫和靜態庫的不同:

  • 靜態庫在程序編譯時會被鏈接到目標代碼中,程序運行時將不再需要加載該靜態庫。
  • 動態庫在程序編譯時並不會被鏈接到目標代碼中,而是在程序運行時才被載入,因此程序運行時還需要動態庫存在。

在編譯程序、鏈接靜態庫時,GCC編譯器並不會選擇性地只複製在目標代碼中用的全局變量和函數,
而是把靜態庫的所有全局變量和函數 全部複製到目標代碼中。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章