admin 管理员组

文章数量: 1184232

转载来源:

进程启动速度

在实际开发过程中,经常会遇到这样的情况:由于对用户事件响应速度要求比较高,而当前的程序无法达到,程序员便不得不把它们改成守护进程,在一开机便将其启动,守候在系统中,来提高用户的响应速度,这样便导致了系统中守护进程的数量越来越多。这些守护进程不光会占用大量的内存,而且还容易造成内存泄漏,同时系统里存活的进程过多,也会导致系统的整体性能下降。

解决这个问题的关键是提高进程的启动速度,减少守护进程的数量。

进程的启动主要包括两个部分:

(1)进程启动,加载动态库,直到main函数之前。这时还没有运行到程序员编写的代码,其性能优化有其特殊的方法,这部分将是本章讲述的重点。

(2)main函数之后,直到对用户的操作有所响应。这时主要涉及自身编写代码的优化,笔者将在第7章和第8章详细叙述。

6.1  查看进程的启动过程

要想优化进程的启动速度,先要看看进程在启动时都做了什么事情。可以使用两个工具-strace和LD_DEBUG来查看进程的启动过程。例如:

  1. #include <stdlib.h>
  2. #include <stdio.h>
  3. int main() {  
  4.     printf("hello world\n");  
  5. return 0;  

编译程序:

  1. >gcc -o hello -O2 hello.c 
strace工具本身主要用来查看进程在运行过程中的系统调用。
  1. # strace -tt ./hello
  2. 23:31:02.618348 execve("./hello", ["./hello"],   
  3. [/* 9 vars */]) = 0  
  4. 23:31:02.624391 uname({sys="Linux", node="(none)", ...}) =  
  5.  0  
  6. 23:31:02.628785 brk(0)                  = 0x11000  
  7. 23:31:02.631655 access("/etc/ld.so.preload", R_OK) =  
  8.  -1 ENOENT (No such file or directory)  
  9. 23:31:02.634035 open("/etc/ld.so.cache", O_RDONLY) =   
  10. -1 ENOENT (No such file or directory)  
  11. 23:31:02.636079 open("/lib/tls/v6l/libc.so.6",   
  12. O_RDONLY) = -1 ENOENT (No such file or directory)  
  13. 23:31:02.638063 stat64("/lib/tls/v6l", 0xbefff1e8)   
  14. = -1 ENOENT (No such file or directory)  
  15. 23:31:02.639985 open("/lib/tls/libc.so.6", O_RDONLY)   
  16. = -1 ENOENT (No such file or directory)  
  17. 23:31:02.642000 stat64("/lib/tls", 0xbefff1e8)   
  18. = -1 ENOENT (No such file or directory)  
  19. 23:31:02.643892 open("/lib/v6l/libc.so.6",   
  20. O_RDONLY) = -1 ENOENT (No such file or directory)  
  21. 23:31:02.645784 stat64("/lib/v6l", 0xbefff1e8)   
  22. = -1 ENOENT (No such file or directory)  
  23. 23:31:02.647646 open("/lib/libc.so.6", O_RDONLY) = 3  
  24. 23:31:02.649538 read(3, "\177ELF\1\1\1\0\0\0\0\0  
  25. \0\0\0\0\3\0(\0\1\0\0\0  
  26. \264\271"..., 512) = 512  
  27. 23:31:02.651887 fstat64(3, {st_mode=S_IFREG  
  28. |0644, st_size=17592186044416, ...}) = 0  
  29. 23:31:02.654481 mmap2(0x411b8000, 1071140,   
  30. PROT_READ|PROT_EXEC, MAP_PRIVATE| MAP_DENYWRITE,   
  31. 3, 0) = 0x411b8000  
  32. 23:31:02.656343 mprotect(0x412b0000, 55332,   
  33. PROT_NONE) = 0  
  34. 23:31:02.658082 mmap2(0x412b8000, 16384,   
  35. PROT_READ|PROT_WRITE, MAP_PRIVATE| MAP_FIXED|  
  36. MAP_DENYWRITE, 3, 0xf8) = 0x412b8000  
  37. 23:31:02.662325 mmap2(0x412bc000, 6180,   
  38. PROT_READ|PROT_WRITE, MAP_PRIVATE| MAP_FIXED|  
  39. MAP_ANONYMOUS, -1, 0) = 0x412bc000  
  40. 23:31:02.664339 close(3)                = 0  
  41. 23:31:02.667817 mprotect(0x412b8000, 4096,   
  42. PROT_READ) = 0  
  43. 23:31:02.670321 fstat64(1, {st_mode=S_IFCHR|  
  44. 0600, st_rdev=makedev(136, 1), ...}) = 0  
  45. 23:31:02.672884 mmap2(NULL, 4096, PROT_READ|  
  46. PROT_WRITE, MAP_PRIVATE|MAP_ ANONYMOUS, -1, 0)   
  47. = 0x40000000 

通过显示系统调用,就能够得知进程在加载动态库时的大概过程。通过使用"-tt"选项,能够将进程运行过程中系统调用的时间戳打印出来,就可以知道进程在加载动态库过程中所用的时间,注意这个时间要比进程实际所用的时间要大。

如果想更加详细地了解strace工具,可以通过只输入strace来显示其帮助信息。

  1. # strace
  2. usage: strace [-dffhiqrtttTvVxxn] [-a column] [-e expr]   
  3. ... [-o file]  
  4.               [-p pid] ... [-s strsize] [-u username]   
  5.               [-E var=val] ...  
  6.               [command [arg ...]]  
  7.    or: strace -c [-e expr] ... [-O overhead]   
  8.    [-S sortby] [-E var=val] ...  
  9.               [command [arg ...]]  
  10. -c -- count time, calls, and errors for each   
  11. syscall and report summary  
  12. -f -- follow forks, -ff -- with output into   
  13. separate files  
  14. -F -- attempt to follow vforks, -h --   
  15. print help message  
  16. -i -- print instruction pointer at time of syscall  
  17. -q -- suppress messages about attaching, detaching,   
  18. etc.  
  19. -r -- print relative timestamp, -t --   
  20. absolute timestamp, -tt -- with usecs  
  21. -T -- print time spent in each syscall,   
  22. -V -- print version  
  23. -v -- verbose mode: print unabbreviated argv,   
  24. stat, termio[s], etc. args  
  25. -x -- print non-ascii strings in hex,   
  26. -xx -- print all strings in hex  
  27. -a column -- alignment COLUMN for printing   
  28. syscall results (default 40)  
  29. -e expr -- a qualifying expression: option=  
  30. [!]all or option=[!]val1[,val2]...  
  31.    options: trace, abbrev, verbose, raw, signal,   
  32.    read, or write  
  33. -o file -- send trace output to FILE instead of stderr  
  34. -O overhead -- set overhead for tracing   
  35. syscalls to OVERHEAD usecs  
  36. -p pid -- trace process with process id PID,   
  37. may be repeated  
  38. -s strsize -- limit length of print strings   
  39. to STRSIZE chars (default 32)  
  40. -S sortby -- sort syscall counts by: time, calls,   
  41. name, nothing (default time)  
  42. -u username -- run command as username   
  43. handling setuid and/or setgid  
  44. -E var=val -- put var=val in the environment for command  
  45. -E var -- remove var from the environment for command  
  46. -n -- print print the syscall number of each call.  

    下面,笔者再介绍一个更加专业的工具LD_DEBUG,它只是glibc中的loader为了方便自身的调试,而设置的一个环境变量。通过设置这个环境变量,可以打印出在进程加载过程中loader都做了哪些事情。

    1. # LD_DEBUG=libs ./hello
    2. 432:     find library=libc.so.6; searching  
    3. 432:      search cache=/etc/ld.so.cache  
    4. 432: search path=/lib/tls/v6l:/lib/tls:/lib/v6l:  
    5. /lib:/usr/lib/tls/v6l:/usr/lib/tls:/usr/lib/v6l: /usr/lib  
    6.             (system search path)  
    7. 432:       trying file=/lib/tls/v6l/libc.so.6  
    8. 432:       trying file=/lib/tls/libc.so.6  
    9. 432:       trying file=/lib/v6l/libc.so.6  
    10. 432:       trying file=/lib/libc.so.6  
    11. 432:  
    12. 432:  
    13. 432:     calling init: /lib/libc.so.6  
    14. 432:  
    15. 432:  
    16. 432:     initialize program: ./hello  
    17. 432:  
    18. 432:  
    19. 432:     transferring control: ./hello 

    如果查看loader都搜索哪些路径、加载哪些动态库,使用这个工具将更加直观、方便。另外,还可以通过下面的命令,来熟悉LD_DEBUG的相关选项:

    1. # LD_DEBUG=help ./hello
    2. Valid options for the LD_DEBUG environment variable are:  
    3. libs        display library search paths  
    4. reloc       display relocation processing  
    5. files       display progress for input file  
    6. symbols     display symbol table processing  
    7. bindings    display information about symbol binding  
    8. versions    display version dependencies  
    9. all         all previous options combined  
    10. statistics      display relocation statistics  
    11. unused      determined unused DSOs  
    12. help        display this help message and exit 

    strace和LD_DEBUG的比较如下:

    如果要查看一个进程启动过程中动态库的搜索和加载过程,那么无疑LD_DEBUG将更加直观。

    如果要查看一个进程加载动态库所花费的时间,LD_DEBUG并没有提供类似的功能,只能通过strace -tt来完成。

    从上面LD_DEBUG=lib ./hello的运行结果,可以清楚地看到,进程启动过程中系统都做了哪些事情:

    (1)搜索其所依赖的动态库。

    (2)加载动态库。

    (3)初始化动态库。

    (4)初始化进程。

    (5)将程序的控制权交给main函数。

    现在清楚了进程启动的过程,就可以想办法进行优化,缩短进程启动的时间。

    在做进程的代码优化时,程序员往往忽视进程加载动态库所需要的时间,往往想当然地认为这段时间与进程的启动过程相比不值一提。这在早期Linux下进程功能相对简单,只是加载少量的动态库时是正确的,可是随着程序复杂性的越来越大,一个进程往往依赖几十个,甚至接近100个动态库时,耗时就不可忽视了。笔者做过测试:以一个应用为例,其在启动过程中需要加载72个动态库,而加载这72个动态库,所用时间大约为0.8898s,将近1s,已经很长了,开始影响用户的操作体验。

    因此,优化加载动态库是提高进程启动速度很重要的一部.

    减少加载动态库的数量

    谈到优化,映射到我们头脑中的第一个想法就是:少做事!减少进程启动过程中的动态库数量,就成了当务之急。这里介绍几个方法:

    (1)将一些无用的动态库去掉。有些程序员为了自己编程方便,把一些动态库不管是否真的使用全都链接上,这导致进程启动过程中加载了一些无用的动态库,浪费了时间。这些无用的动态库应坚决去掉。

    (2)重新组织动态库的结构,力争将进程加载动态库的数量减到最少。对于使用标准C编写的动态库,可以考虑将几个小动态库合并为一个大的动态库,减少进程加载动态库的数量。

    对于使用C++编写的动态库,由于涉及全局对象初始化的问题,笔者建议将大的动态库拆分为若干个小的动态库,进程根据自己的需要灵活加载所需要动态库。对于那些经常一同出现的动态库,可以考虑将其进行合并。

    关于这点,可以参考2.1.5节,那里有更加详细的论述。

    (3)将一些动态库编译成静态库,与进程或其他动态库合并,从而减少加载动态库的数量。其优点是:

    减少了加载动态库的数量。

    在与其他动态库(或进程)合并之后,动态库内部之间的函数调用不必再进行符号查找、动态链接,从而提高速度。

    缺点是:

    该动态库如果被多个动态库或进程所依赖的话,那么该动态库将被复制多份合并到新的动态库中,导致整体的文件大小增加,占用更多的Flash。

    失去了动态库原有的代码段内存共享,因此可能会导致代码段内存使用上的增加。

    如果该动态库被多个守护进程所使用,那么其代码段很多代码已经被加载到物理内存,那么进程在运行该动态库的代码时产生的page fault就少;如果该动态库被编译成静态库与其他动态库合并,那么其代码段被其他多个守护进程运行到的机会就少,在进程启动过程中运行到新的动态库时所产生的page fault就多,从而有可能影响进程的加载速度。

    基于此,在考虑将动态库改为静态库时,有以下原则:

    对于那些只被很少进程加载的动态库,要将其编译成静态库,从而减少进程启动时加载动态库的数量;同时由于该动态库代码段很少被多个进程共享,所以不会增加内存方面的开销。

    对于那些守护使用的动态库,其代码段大多已经被加载到内存,运行时产生的page fault要少,故其为动态库反而有可能要比静态库速度更快。

    (4)使用dlopen动态加载动态库。进程所依赖的动态库,并不一定在进程启动时都要用到。不需要的动态库,要在进程启动时加载动态库的清单中去掉,从而加快进程的启动速度。在需要该动态库时,再使用dlopen来动态加载动态库。

    dlopen的优点是:可以精确控制动态库的生存周期,一方面可以减少动态库数据段的内存使用,另一方面可以减少进程启动时加载动态库的时间。

    其缺点是:程序员编写程序将变得很麻烦。

    动态库的高度

    根据动态库的依赖关系,可以画出动态库之间的依赖关系,这个依赖关系可能是一棵树,也可能是一个线性结构。当前动态库到最底层动态库之间的最长路径,称为该动态库的高度。

    有一种说法,改变动态库的依赖关系,降低动态库的高度,使动态库之间的依赖关系扁平的话,可以减少加载动态库的时间。有些人尝试做了这样的优化,也的确收到了效果,可为什么呢?

    笔者为此做了一个试验:

    测试1:创建一个可执行文件hello和82个动态库,这82个动态库之间不相互依赖。可以说从执行文件hello到最底层的libc.so的距离为2。

    使用strace -tt 来获取加载库的时间,为0.645112s。

    测试2:创建了一个可执行文件hello和82个动态库,这82个动态库相互依赖,lib1依赖于lib2,lib2依赖于lib3,….,lib81依赖lib82。可以说从hello到最底层的libc.so的距离为83。

    使用strace -tt来获取加载库的时间,为0.650055s。

    从结果可以看出,Test2的确比Test1的时间要少,但仅少0.004943s,几乎可以忽略不计。

    那为什么有些人降低了动态库的高度,的确缩短了加载动态库的时间呢?

    那是因为在降低动态库的高度时,经常采用的手段是合并动态库库、使用dlopen来动态加载库等方法,这样的做的效果会减少进程加载动态库的数量,因此真正缩短加载库时间的是减少动态库的数量,而不是动态库之间的依赖关系。

    动态库的初始化

    在loader完成了对动态库的内存映射之后,需要运行动态库的一些初始化函数,来完成设置动态库的一些基本环境。这些初始化函数主要包含两个部分:

    (1)动态库的构造和析构函数机制。

    (2)动态库的全局变量初始化工作。

    1.动态库的构造和析构函数机制

    在Linux中,提供了一个机制:在加载和卸载动态库时,可以编写一些函数,处理一些相应的事物,我们称这些函数为动态库的构造和析构函数,其代码格式如下:

    1. void __attribute__ ((constructor)) my_init(void);  
    2. void __attribute__ ((destructor)) my_fini(void); 

    在编译共享库时,不能使用"-nonstartfiles"或"-nostdlib"选项,否则,构建与析构函数将不能正常执行(除非你采取一定措施)。

    注意,构造函数的参数必须为空,返回值也必须为空。

    举个例子,动态库文件a、c的代码如下:

    1. void __attribute__ ((constructor)) my_init(void) {  
    2.     printf("init library\n");  

    编译成动态库:

    1. >gcc -fPIC -shared a.c -o liba.so 
    主程序hello.c如下:
    1. #include 
    2. #include 
    3. int main() {  
    4.     pause();  
    5. return 0;  

    编译:

    1. >gcc -L./ -la hello.c -o hello  
    2. 运行hello:# ./hello  
    3. init library 

    也就是说,在运行hello时,加载完liba.so后,自动运行liba.so的初始化函数。

    全局变量初始化

    在介绍全局变量初始化之前,先举个例子:

    1. b2.c  
    2. #include <stdlib.h>
    3. #include <stdio.h>
    4. int reti() {  
    5.     printf("reti\n");  
    6. return 10;  
    7. }  
    8. int g1=reti(); 

    g1是个全局变量。

    使用GCC对其进行编译:

    1. >gcc -o hello b2.c  
    2. b2.c:23: error: initializer element is not constant 
    使用G++对其进行编译:
    1. >g++ -o hello b2.c 

    编译成功!

    可见GCC和G++对于这种全局变量初始化的方法,支持力度是不一样的。

    运行hello:

    1. # ./hello
    2. reti 

    这说明,进程在加载liba.so后,为了初始化全局变量g1,其会运行reti来初始化g1。

    再来看一个C++的例子:

    1. b2.c  
    2. class Myclass {  
    3. public:  
    4.    Myclass();  
    5. int i;  
    6. };  
    7. Myclass::Myclass() {  
    8.    printf("construct Myclass\n");  
    9. };  
    10. Myclass g1; 
    编译动态库:
    1. >gcc -fPIC -shared b2.c -o liba.so 

    在动态库liba.so中,声明了一个类型为Myclass的全局变量g1。

    主程序

    1. hello.c  
    2. #include <stdlib.h>
    3. #include <stdio.h>
    4. int main() {  
    5.     pause();  
    6. return 0;  
    编译执行文件:
    1. >gcc -L./ -la hello.c -o hello  
    2. 运行hello:  
    3. # ./hello
    4. construct Myclass 

    这说明,进程在加载liba.so后,为了初始化全局变量g1,将会运行Myclass的构造函数。这些构造函数的运行,必然会导致加载动态库时间缓慢。如果构造函数修改了成员变量的话,其还会产生dirty page,有时候你会发现程序刚进入main函数,就已经产生了大量的dirty page。

    为什么非内置类型的全局变量,需要在main函数之前就构造出来呢?

    在C语言中,其全局变量保存在.data段。在启动过程中,loader只是简单地使用mmap将数据段映射到内存中,这些全局变量只有在第一次使用到的时候才会为其分配物理内存,其在启动过程中是不需要运行什么构造函数的。

    而在C++语言中,对于非内置类型的全局变量,一方面需要在main函数之前就准备好,需要的时候马上就可以使用;另一方面,全局对象内部的成员变量,不能像C语言那样一个简单的内存映射就可以了,故系统在bss节为全局对象分配了内存,运行构造函数,来初始化其值。这也就决定了,对于非内置类型的全局对象,系统要在main函数之前,运行其构造函数,完成全局变量的初始化。

    总的来讲,对于非内置类型的全局变量,无论在进程启动时是否会用到该全局变量,都需要运行其构造函数创建该全局变量,其对进程启动有如下影响:

    (1)由于运行了一些不必要的构造函数,减缓了进程的启动速度。

    (2)构造函数修改了类的成员变量,这时一方面会产生page fault,从而减缓进程的启动速度;另一方面,也会产生一些不必要的dirty page,造成内存上的浪费。

    在系统优化过程中,笔者曾经在一个进程的main函数中第一条指令前增加了一个pause语句,很惊奇地发现,在进程还没有做任何事情的时候,仅仅是加载动态库,进程和动态库的数据段就使用了800KB的物理内存,就是这个原因。

    从优化的角度来讲,要尽量减少全局对象的使用。

    对于一个给定的动态库(或进程),首要的问题是如何来查看其包含了哪些全局对象,以便程序员对其进行优化。可以通过查看动态库(或进程)的符号表,来查找全局对象。请看以下代码:

    1. #include <stdlib.h>
    2. #include <stdio.h>
    3. class Myclass {  
    4. public:  
    5. int n;  
    6. int m;  
    7.     Myclass() {  
    8.        m=10;  
    9.        n=20;  
    10.     }  
    11. };  
    12. Myclass obj;  
    13. Myclass *pobj;  
    14. int func() {  
    15. static Myclass sobj;  
    16. return sobj.n++;  
    17. }  
    18. int main() {  
    19.     Myclass mobj;  
    20.     mobj.n++;  
    21. return 0;  

    在上面的例子中,全局对象obj才会导致在main函数之前,运行其构造函数。

    pobj只是一个对象的指针,并不需要运行构造函数。

    在函数func内部的sobj,在第一次进入func时,才会运行构造函数。

    main函数中的mobj,将在程序运行到此时,才会运行构造函数。

    通过符号表来定位全局对象:

    1. >nm -f sysv hello | grep bss  
    2. _ZGVZ4funcvE4sobj   |000106ac|   b  |  
    3.         OBJECT  |00000004|     |.bss  
    4. _ZZ4funcvE4sobj         |000106b0|   b  |  
    5.          OBJECT |00000008|     |.bss  
    6. __bss_end__         |000106b8|   A  |  
    7.         NOTYPE  |        |     |*ABS*  
    8. __bss_start             |0001069c|   A  |   
    9.         NOTYPE  |        |     |*ABS*  
    10. __bss_start__       |0001069c|   A  |   
    11.         NOTYPE  |        |     |*ABS*  
    12. _bss_end__          |000106b8|   A  |   
    13.        NOTYPE   |        |     |*ABS*  
    14. completed.0         |0001069c|   b  |   
    15.         OBJECT  |00000001|     |.bss  
    16. obj                 |000106a4|   B  |   
    17.         OBJECT  |00000008|     |.bss  
    18. pobj                |000106a0|   B  |   
    19.         OBJECT  |00000004|     |.bss 

    mobj不是全局变量,也不是静态变量,故其不在符号表中。

    sobj在函数func中,故G++在编译过程中,会将其改名,在对象的名称前面加上函数名。并且可以看到有两个符号ZGVZ4funcvE4sobj、ZZ4funcvE4sobj,都包含sobj,其中第一个符号是用来标识sobj静态对象是否被创建;ZZ4funcvE4sobj则指向静态对象sobj。详情可以参见8.4.5节。

    obj 是全局对象,并且其大小为8。

    pobj是指向全局的对象指针,其大小为4。

    故要想查找全局对象,可以遵从下面的原则:

    在bss节,类型为OBJECT,不包含函数名,对象大小>4,基本可以认为是全局对象。

    在bss节,类型为OBJECT,不包含函数名,对象大小=4,有可能是全局对象,需要你到代码中去搜索、确认。

本文标签: 个动态库 加载动态 编程