从系统的角度分析影响程序执行性能的因素——SA20225205 黄兴宇

2021-05-19

实验总结分析报告:从系统的角度分析影响程序执行性能的因素

1、请您根据本课程所学内容总结梳理出一个精简的Linux系统概念模型,最大程度统摄整顿本课程及相关的知识信息,模型应该是逻辑上可以运转的、自洽的,并举例某一两个具体例子(比如读写文件、分配内存、使用I/O驱动某个硬件等)纳入模型中验证模型。

2、然后将一个应用程序放入该系统模型中系统性的梳理影响应用程序性能表现的因素,并说明原因。

3、产出要求是发表一篇博客文章,长度不限,1要简略,2是重点,只谈自己的思考和梳理,严禁引用任何资料(包括本课程的资料)造成文章虚长。

SA20225205 黄兴宇

1 Linux系统模型简述

 Linux模型的主要模块分以下几个部分:存储管理、进程管理、文件系统、中断、系统调用等。Linux的各个模块之间相互依赖,共同完成操作系统的各项基本的功能和对系统的管理工作,对底层来说,与硬件交互管理所有的硬件资源;对上层来说,为用户程序(应用程序)提供一个良好的执行环境。Linux的整体架构如下图。内核向上为用户提供系统调用接口,向下调用硬件服务接口。其自身实现了上文提到的进程管理等功能,在内核外和提供了如shell、编译器、解释器、库函数等基础设施。

 

 

 

 

1.1 进程管理

1.1.1 进程的描述

 在Linux中每一个进程都由task_struct数据结构来定义,就是通常说的PCB,它是进程存在的标志。当我们调用fork()时,系统会为我们产生一个task_struct结构。然后从父进程那里继承一些数据,并把新的进程插入到进程树中,以待进行进程管理。以往各个进程的task_struct存放在它们内核栈的尾端,由于现在使用了slab分配器动态生成task_struct,所以只需要在栈底创建一个thread_info,该结构体中task指针指向的就是task_struct进程描述符。

 

 

 

实际的task_struct约有1.7KB大小,包含以下信息:

1、进程标识PID

2、进程的状态:有TASK_RUNNING(正在运行或正在等待)、TASK_INTERRUPTIBLE(被阻塞且可中断)、TASK_UNINTERRUPTIBLE(被阻塞且不可中断)、TASK_STOPPED(停止)、TASK_ZOMBIE(僵死,进程终止但资源未回收)、TASK_DEAD(父进程wait()系统调用回收子进程全部资源)、TASK_SWAPPING(换入换出)等。

 

3、调度信息和策略

3、进程的通信状况IPC

4、因为要插入进程树,要有指向父子兄弟的指针

5、时间信息:计算好执行的之间,以便CPU进程调度

6、占有的资源:如打开的文件

7、进程上下文和内核上下文

8、处理器上下文

9、内存信息

1.1.2 进程的创建

 init_task为第一个进程(0号进程)的进程描述符结构体变量,他的初始化是通过硬编码方式固定下来的。除此之外,所有其他的进程初始化都是通过fork()系统调复制父进程的方式初始化的(Linux使用clone()系统调用实现的fork())。这里新建一个子进程的主要工作就是新建一个PCB,当然需要修改必要的信息,还要有自己的数据空间和用户堆栈等。

 也就是说,在fork()函数之前需要确认内核中有足够的资源来完成。如果资源满足要求,则内核slab分配器在相应的缓冲区中构造子进程的进程控制块,并将父进程控制块中的全部成员都复制到子进程的控制块,然后再把子进程控制块必须的私有数据改成子进程的数据。当fork()返回到用户空间之前,向子进程的内核栈中压入返回值0,而向父进程内核堆栈压入子进程的pid。最后进行一次进程调度,决定是运行子进程还是父进程。

 

 

 

 为了能够使子进程和父进程执行不同的代码(子进程从当前父进程执行处开始执行),在fork()之后应该根据fork()返回值使用分支结构来组成程序代码。

int main(void)
{
    pid_t pid;
    
    pid = fork();
    if(pid < 0){
        ...                //打印fork()失败信息
    }else if(pid == 0){
        ...                //子进程代码
    }else{
        ...                //父进程代码
    }
    
    return 0;
}

 另一种使子进程执行不同代码的方法是execv()系统调用,子进程调用execv()后,系统会立即为子进程分配私有程序内存空间,并把函数参数path所指定的可执行文件加载该空间中,从此子进程也成为一个真正的进程。

 与一般函数不同,exec族函数执行成功后一般不会返回调用点,因为它运行了一个新的程序,进程的代码段、数据段、和堆栈都被新的数据取代。只有调用失败,才会返回一个-1,从原程序的调用点接着执行。

 Linux采用了写时拷贝,即fork()不复制父进程的代码区,而是使用指针共享一份拷贝。这样做的好处就是,如果子进程调用execv()拷贝了其它代码段,就能避免父进程代码段无效的复制;如果依然使用父进程代码,也可以在父或子进程任一方写入时,再为子进程拷贝。

int execv(const char* path, char* const argv[]);

int main(void)
{
    pid_t pid;
 
    if(!(pid=fork())){
        execv("./hello.o",NULL);//可执行文件./hello可以编写一个.c程序再进行编译获得
    }else {
        printf("my pif is %d\n", getpid());
    }
 
    return 0;
}

 

 1号和2号进程的创建是start_kernel初始化到最后由test_init通过kernel_thread创建了两个内核线程:一个是kernel_init,把用户态的进程init启动起来,是所有用户进程的祖先;另一个是kthreadd内核线程,kthreadd是所有内核线程的祖先,负责管理所有内核线程。

1.1.3 进程的调度

Linux的进程调度使用了剥夺方式,剥夺原则有优先权原则、短进程、优先原则、时间片原则。进程的调度算法有FIFO(非剥夺)、最短CPU运行期优先、优先权调度、时间片轮转。

调度时机

1、进程状态发生变化时

2、当前进程时间片用完时

3、进程从系统返回到用户态时

4、中断处理后,进程返回到用户态时

进程切换

1、切换页全局目录以安装一个新的地址空间

2、切换内核态堆栈和硬件上下文

1.1.4 进程的终止

 

 

 

僵尸进程

当子进程终止,OS释放掉其资源。但是由于父进程未调用wait(),所以它位于今称表中的条目还存在,这样的进程被称为僵尸进程。

孤儿进程

如果父进程没有调用wait()就终止,以至于子进程称为孤儿进程处于僵尸态。Linux对此情况的处理是,任选一个进程作为其父进程,如init进程。init进程定期调用wait(),以便定期释放挂在其下的僵尸进程。

1.2 内存管理

 内存管理用于确保所有进程能够安全地共享机器主内存区,同时,内存管理模块还支持虚拟内存管理方式,使得 Linux 支持进程使用比实际内存空间更多的内存容量。并可以利用文件系统把暂时不用的内存数据块会被交换到外部存储设备上去,当需要时再交换回来。Linux采用虚拟地址,在 32 位 Linux 系统上每个进程有4GB 的进程地址空间,在用户态下,只能访问 0x00000000~0xbfffffff 的地址空间,而内核态下可以访问全部空间。linux的地址分为逻辑地址、线性地址和物理地址。逻辑地址和线性地址在 32 位和 64 位上目前都是虚拟地址,需要依次经过分段映射和分页映射最后才转换成物理地址。这个映射计算地址的过程一般由 CPU 内部的 MMU(内存管理单元)负责把虚拟地址转换为物理地址。

 

 

 

1.3 设备管理

 设备驱动程序是一个软件层,该软件层使硬件响应预定义好的编程接口,我们已经熟悉了这种接口,它由一组控制设备的VFS函数(open,read,lseek,ioctl等)组成,这些函数实际实现由设备驱动程序全权负责。

1.4 文件系统

文件系统模块用于支持对外部设备的驱动和存储。虚拟文件系统模块通过向所有的外部存储设备提供一个通用的文件接口,隐藏了各种硬件设备的不同细节,从而提供并支持与其他操作系统兼容的多种文件系统格式。VFS所提供的这些统一的API,再经过系统调用包装一下,用户空间就可以经过SCI的系统调用来操作不同的文件系统。

 

 

 

1.5 中断和系统调用

中断是计算机的三大法宝之一,分外部中断(硬件中断)和内部中断(软件中断),其中软件系统调用作为一种特殊的中断,就是利用陷阱(trap)这种软件中断式主动从用户态进入内核态的。

中断执行过程:

确定中断向量,利用idtr找到中断入口地址,确定特权级是否匹配,是否需要变更堆栈段,然后在栈中保存eflags、cs和eip的内容(保存在被中断进程的内核栈中),如果异常产生一个硬件出错码,则将它保存在栈中,然后装载cs和eip,执行中断处理程序。

 

 

中断返回:

用保存在栈中的值装载cs、eip和eflags寄存器。如果一个硬件出错码曾被压入栈中,那么弹出这个硬件出错码,检查中断前是否在内核态,如果不是,从栈中装载ss和esp回退到用户态。

其中系统调用机制如下图:

 

 

 

Linux系统调用总体上可以划分为内核态和用户态,也可以说是内核和应用程序,系统调用就是提供给用户程序的一组可以访问内核的接口。普通的函数调用是通过将参数压栈的方式传递的。系统调用从用户态切换到内核态,在用户态只能访问到用户堆栈,在内核态可以访问内核和用户堆栈。

系统调用的过程:

1、用户执行 int $0X80 或者 syscall 触发系统调用

2、利用寄存器保存现场,其中eax中存放系统调用号

3、CPU切换到内核态执行system_call()汇编代码,此时需要从eax寄存器中获取系统调用号,内核才知道执行哪个系统调用

4、执行完后回复现场

5、系统调用返回

2 具体实例

这里描述读写文件的过程。在读写文件之前,必须使用oepn打开一个文件,打开文件首先open会执行到C库,C库里有INT $0x80指令,然后在中断向量表中找到128项,中断向量表里有中断描述符,可以找到中断处理程序入口,第128项是系统调用处理函数,进入系统调用处理函数,保存现场,系统调用号存储eax中,根据系统调用号执行系统调用表中对那一项的函数。系统调用表是相应的函数指针,这里会执行sys_open。sys_open进行命令查找,找到文件控制块,根据不同文件类型,调用文件打开函数,文件打开函数会创建一个系统文件打开表file,file的很多内容来自文件控制块,填完之后,进程也有一个进程文件打开表,这个结构里面有fd数组,fd数组是指针,找个空闲的,把它指向之前已经创建的file结构,最后返回那一项的索引号,即fd。

当进程使用read系统调用读这个文件,就会根据fd数组的下标 找到fd数组的对应项,然后找到指向之前创建的file结构的指针,再找到这个file结构,最后找到file结构里的file_operations里的具体的read函数来读取文件。write系统调用原理类似。

3 分析Linux应用程序性能影响因素

在分析linux程序性能影响因素时需要先确定程序的类型。主要有以下两种:

cpu密集型:例如web服务器像nginx node.js需要CPU进行批处理和数学计算都属于此类型

io密集型:例如数据库常见的mysql,大量消耗内存和存储系统,对CPU和网络要求不高,这种应用使用CPU来发起IO请求,然后进入sleep状态。

确定了应用类型就开始分析有哪些情况能影响性能:

大量的网页请求会填满运行队列、大量的上下文切换,中断。

大量的磁盘请求。

网卡大量的吞吐。

以及内存耗尽等。

归结起来就是4个方面cpu memory i/o和 network。

 

这里我选择了nginx来分析性能影响因素,通过在容器内搭建nginx,然后用另一个终端进行大量请求测试发现CPU使用率很高,但是看下面的进程的CPU使用率好像很正常。

这里使用top和pidstat命令来定位问题,发现task任务数量不正常,想到可能是短时间的应用导致的问题,如下面的两个:

(1)应用里直接调用了其他二进制程序,这些程序通常运行时间比较短,通过top等工具发现不了。

(2)应用本身在不停地崩溃重启,而启动过程的资源初始化,很可能会占用很多CPU资源。

另外通过对nginx大量请求测试可能会出现丢包问题。可以总结为以下几个方面:

在两台 VM 连接之间,可能会发生传输失败的错误,比如网络拥塞、线路错误等;

在网卡收包后,环形缓冲区可能会因为溢出而丢包;

在链路层,可能会因为网络帧校验失败、QoS 等而丢包;

在 IP 层,可能会因为路由失败、组包大小超过 MTU 等而丢包;

在传输层,可能会因为端口未监听、资源占用超过内核限制等而丢包;

在套接字层,可能会因为套接字缓冲区溢出而丢包;

在应用层,可能会因为应用程序异常而丢包;

总之,对于具体的应用程序,我们可以从CPU ,内存, I/O以及网络等几个方面去分析影响性能的因素,利用性能测试工具去定位和解决问题。