Loading

从系统的角度分析影响程序执行性能的因素

1. 精简Linux系统概念模型

操作系统按照功能可以划分为进程管理、内存管理、文件管理以及设备管理,而Linux内核也对此进行了实现

1.1 进程管理

进程管理可以说是操作系统内核中最为核心的部分,其主要完成的一些功能可以概述为如下几点:

  1. 描述进程
  2. 进程的调度切换
  3. 进程间通信

1.1.1 进程的描述

在Linux内核中,采用task_struct数据结构(即进程控制块PCB)来描述一个进程,task_struct结构体所包含的内容异常复杂,包括了进程的状态state、用于进程管理的进程链表tasks、描述文件系统和进程打开文件的文件描述幅fsfiles以及相应的内存管理mm等成员变量。

由上所述可以看出,通过进程描述符可以将Linux下的进程调度、通信、内存管理、文件系统、以及驱动设备管理等模块进行良好的串联。

1.1.2 进程的调度切换

Linux系统属于一个多任务的操作系统,因此一般在同一时间段内不可能只运行一个进程,大多数情况下是存在多进程并发执行的。因此为了合理的安排各进程占用CPU的时间以及等待CPU的时间,便引出了进程调度切换的问题。

在Linux下,进程调度策略主要可分为先进先出策略(FIFO)、时间片轮转策略(RR)以及完全公平调度策略

对响应要求较高的实时系统一般采用先进先出策略或者时间片轮转策略,而对于普通进程来说则采用完全公平调度策略,完全公平调度算法会根据进程已占用CPU的时间来进行排序,并且进程获取CPU后能够执行的时间也是有其权重决定的,CPU会保证在某个时间周期内就绪队列中的所有进程都至少获取CPU执行一次,以此来达到完全公平调度的目的。

进程的调度切换所不得不涉及的两个概念就是系统调用和中断。

中断为计算机提供了一种运行正常程序以外代码的机制,如果没有中断机制就不存在多任务的操作系统。中断分为硬中断和软中断,硬中断是当CPU在执行每条指令后会检查中断信号,如果有中断请求便会中断当前程序的执行去处理中断,而系统调用是一种软中断,系统调用是程序从用户态自陷入内核态以完成一些用户态所不能完成的操作。

进程调度的大致过程可以描述为如下:

  1. 正在运行的用户态进程发生中断
  2. CPU完成中断上下文的切换(将IP、SP和flags寄存器中的值压入内核栈中),加载相应中断处理程序的IPSP值到指令指针寄存器和栈顶指针寄存器中
  3. 在中断处理程序的入口,对现场进行保护,此时进程由用户态转变为内核态完成中断上下文切换
  4. 执行中断处理程序,如果在此期间调用了进程调度函数schedule(),则会进行相应的进程上下文切换,将当前内核栈的相关信息保存,切换为下一个被调度进来的进程的内核栈
  5. 再由被调度进来的进程从调度函数返回到中断处理程序最终返回至被调度进来的进程的用户态继续执行剩余代码

1.1.3 进程间的通信

进程间有多种通信方式:

  1. 管道
  2. 套接字
  3. 共享内存
  4. 消息队列
  5. 信号量等

管道在操作系统中非常的常见,例如在Shell命令中就存在着管道的使用,如下命令就使用了管道

cat a.log | grep "hello world"

套接字在我们平常的网络通信中非常常见,TCP连接就是使用了TCP套接字来完成的

在Linux中存在许多的信号,比如在命令行下我们按下Ctrl + C,内核会向进程发送一个SIGINT信号以终止当前正在运行的进程

剩下的一些通信方式在此不过多描述

1.2 内存管理

Linux将虚拟内存划分成固定大小的页(Linux中的页大小是4KB),并且以页作为操作内存的最小单元,从磁盘中都是一次性读取一页,每一个进程都维护着一个页表,由于一个页表项(页号、物理块号、状态位、访问字段、修改位、外存地址等等)中可能存在大量的内容,因此对于一个页表进一步地进行划分以解决页表过大的问题。

Linux中采取的是多级页表方案,在页目录中,如果某个页表中的所有页表项均为无效的,那么在内存中就不会维护这个页表,即页目录中的记录页表的项在内存中不会分配页来记录它们,这样就可以达到减小页表大小的目的。

1.3 文件管理

在Linux系统中的任何一个概念几乎都可以看做一个文件。内核在非结构化的硬件上建立了一个结构化的虚拟文件系统,隐藏了各种硬件的具体细节,从而在整个系统的几乎所有机制中使用文件的抽象。Linux在不同物理介质或虚拟结构上支持数十种文件系统。

VFS 是底层文件系统的主要接口。这个组件导出一组接口,然后将它们抽象到各个文件系统,各个文件系统的行为可能差异很大。有两个针对文件系统对象的缓存(inodedentry)。它们缓存最近使用过的文件系统对象。在这里缓冲区缓存和设备驱动的交互、以及VFS提供的系统接口暂不讨论,主要看看实现这个VFS子系统的主要结构

1.4 驱动设备程序

设备驱动程序包含与设备进行通信时所需的所有特定于设备的代码。此代码包括一组用于系统其余部分的标准接口。就像系统调用接口可使应用程序不受平台特定信息影响一样,此接口可保护内核不受设备特定信息的影响。应用程序和内核其余部分需要非常少的特定于设备的代码(如果有)对此设备进行寻址。这样,设备驱动程序使得系统的可移植性更强,并更易于维护。
设备驱动程序按照处理 I/O 的方式可以分为以下三大类别:

  1. 块设备驱动程序-适用于可将 I/O 数据作为异步块进行处理的情况。通常,块驱动程序用于管理可物理寻址的存储介质的设备,如磁盘。
  2. 字符设备驱动程序-适用于针对连续的字节流执行 I/O 操作的设备。
  3. STREAMS 设备驱动程序-字符驱动程序的子集,将 streamio(7I) 例程集用于内核中的字符 I/O

1.5 具体例子

读文件流程

  1. 进程调用read()库函数
  2. 库函数会将对应的参数封装好,预处理后调用read()库函数对应的系统调用
  3. 内核通过检查进程的文件描述符定位到虚拟文件系统的已打开文件列表表项
  4. 通过文件表项链接到目录项模块,根据传入的文件路径,在目录项模块中检索,找到该文件的inode
  5. inode中,通过文件内容偏移量计算出要读取的页
  6. 通过inode找到文件对应的address_space
  7. address_space中访问该文件的页缓存树,查找对应的页缓存结点:
    1. 如果页缓存命中,那么直接返回文件内容;
    2. 如果页缓存缺失,那么产生一个页缺失异常,创建一个页缓存页,同时通过inode找到文件该页的磁盘地址,读取相应的页填充该缓存页;重新进行第6步查找页缓存

2. 影响应用程序性能表现的因素

  1. 硬件的速度:程序的运行需要将磁盘中静态的程序加载到CPU中,而硬盘的速度相对CPU是程序运行的下限。同样CPU性能至关重要。
  2. 缓存的命中:现代计算机的构造中,有多级缓存,在缓存都命中的情况下,程序的运行速度是更快的。
  3. 缺页异常:在页命中时,计算机无需进行磁盘IO操作,程序的运行速度是比频繁发生缺页异常的程序快的

上述的虚拟页内存管理以及缓冲Cache其实都是利用的局部性原理,即CPU访存时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。

根据局部性原理而引入了Cache,在C语言中数据是以行优先的形式连续存放的,因此在编写程序时,我们应该尽量以行优先的形式来遍历数组,以增加Cache的命中次数,如果以列优先的方式来访问数组的话则会发生更多次的缓存不命中和缺页的情况,我们可以通过以下C程序,来对上述结论进行验证

#include <stdio.h>
#include <time.h>
#define N 1000

// 行优先方式访问double二维数组
void rowFirst(double nums[N][N])
{
    for (int i = 0; i < N; i++)
    {
        for (int j = 0; j < N; j++)
        {
            nums[i][j] /= 2;
        }
    }
}
// 列优先方式访问double二维数组
void colFirst(double nums[N][N])
{
    for (int j = 0; j < N; j++)
    {
        for (int i = 0; i < N; i++)
        {
            nums[i][j] /= 2;
        }
    }
}

int main(void)
{
    clock_t begin, end, duration;
    double nums[N][N];
    for (int i = 0; i < N; i++)
    {
        for (int j = 0; j < N; j++)
        {
            nums[i][j] = i * j;
        }
    }

    // 计算行优先的运行时间
    begin = clock();
    for (int i = 0; i < N; i++) {
        rowFirst(nums);
    }
    end = clock();
    duration = end - begin;
    printf("行优先访问二维数组1000次时间: %lfs\n", (double)(duration) / CLOCKS_PER_SEC);

    // 计算列优先的运行时间
    begin = clock();
    for (int i = 0; i < N; i++) {
        colFirst(nums);
    }
    end = clock();
    duration = end - begin;
    printf("列优先访问二维数组1000次时间: %\n", (double)(duration) / CLOCKS_PER_SEC);

    return 0;
}

根据运行结果可以看出按行访问数组的方式比按列访问数组的方式速度快了接近3倍,上述的结论是成立的。因此,编写程序时应该尽量地去遵循局部性原理,增加Cache命中次数来保证程序的运行速度

posted @ 2021-05-18 13:16  DreamD  阅读(185)  评论(0编辑  收藏  举报