Linux编码的常见性能问题及优化

110 阅读17分钟

作为网络设备,操作系统的性能一直是大家关注的话题,直接影响着设备的关键指标:新建、吞吐、并发。对于性能优化的方向涉及到 CPU 调度、内存、IO等诸多层面,本文主要根据项目中的一些调优经验以及代码鉴定总结出的高性能编码经验,进行总结和归纳。除此之外,介绍一些性能分析工具的使用方法以及度量性能的指标。

1       编写高性能代码

首先需要明确的是,并不是所有的代码都需要保证高性能,对于明显的非热点代码,则应首先保证代码可读性、可维护性以及编码的效率。而对于热点代码或者潜在的热点代码,则需要及时辨别,养成编写高性能代码的习惯可以从源头推迟硬件性能瓶颈的到达。

1.1.   避免代码冗余   

1.1.1. 多余的初始化

变量定义时即初始化,是很多编程者养成的一种编码习惯,这种习惯有助于避免访问未初始化变量,提升编码质量。但是在热点代码中,对没有使用的变量进行多余的初始化,会带来性能上的浪费,尤其是占内存较大的缓冲区。对于热点代码,不妨将初始化习惯更改为:变量使用时初始化。

对于c风格字符串的初始化,可以不必初始化所有字节为0,只初始化首字节为‘\0’即可。

1.1.2. 频繁访问

在函数中直接使用全局变量或者直接用get接口的返回值进行计算,也是很多编程者的习惯,因为觉得用局部变量去接下来再做计算,似乎是多此一举。

而在热点代码中,这多此一举的习惯可能会带来不小的性能提升。

场景:在转发流程中频繁调用函数或访问全局变量以获取某一状态变量,一方面函数调用以及本身可能存在的性能问题,另一方面触发cache miss导致性能差。

优化:只在业务入口处获取一次,以bit位域存储于会话控制块,会话控制块作为转发流程使用最多的数据,访问时发生cache miss的概率大大降低。

1.1.3. Debug信息的影响

场景:转发流程往往由于过程比较复杂,编程人员往往习惯添加一些debug信息以供系统测试阶段的调试抑或正式发布版本的维护。有些debug接口没有设计好,导致存在多余的内存拷贝、字符串拼接、打印等耗费性能的操作;有些debug的位置添加在正确处理流程,即使添加开关也会影响性能。

优化:不添加非必要的debug信息;尽量只添加到出现异常的代码分支;尽量使用统计类信息代替打印;调试信息等次要代码封装子函数,并且集中打印信息和抽象,. 调试信息入口判断应该 使用 unlikely 协助编译器优化 。

1.1.4. 慢系统调用

在性能关键处尽量不去使用阻塞调用或者其他耗时操作,避免发生线程调度。应该尽量使用异步非阻塞或者同步非阻塞的接口去完成编码,保障性能。

1.1.5. 哈希不均

哈希桶由于其优秀的查找性能,广泛的使用于数据的存储。但是散列不均匀的哈希,会导致查找性能下降明显。

场景:

哈希大小#define APPGROUP_HASH_SIZE (ULONG)100000

哈希索引计算 ((ULONG)uiGrpID & (ULONG)(APPGROUP_HASH_SIZE -1));

100000 - 1 = 99999              //10 进制

             = 11000011010011111  // 2 进制

导致大量哈希桶未使用,散列不均匀,哈希冲突剧烈,性能差且浪费资源。

优化:#define APPGROUP_HASH_SIZE   0xffffffff

1.2.   编译优化

1.2.1. 全局变量访问的编译优化

场景:在同一函数中多次使用同一全局变量。

优化: 先获取到局部变量,再进行访问,可以引导编译器进行优化。编译器不会主动优化全局变量取值,每次访问都重新从内存取值。而局部变量的访问只从内存取一次,存储到寄存器以供后续使用。

1.2.2. 使用内联

热点函数的频繁调用,可以使用inline标记引导编译优化,进行代码段的替换,节省函数调用开销。但有几点需要注意:

1、内联节省的仅仅是函数调用开销,如果函数体实现复杂,这种优化可能不仅没有意义,还可能有副作用(代码段增大,指令Cache命中率降低,函数堆栈扩大);

2、非性能关键函数,规模超大,实现复杂 等函数,不应该设置成内联;

3、内联的编译优化有可能失败,复杂度的变化(增加或修改一行代码或一个调用),就可能导致函数规模超标或堆栈超大,从而导致函数内联失败;

4、对于性能关键必须内联成功的函数,建议使用宏定义或强制内联inline attribute((always_inline))。

1.2.3. 静态计算

场景:在涉及网络报文解析的编码时,经常需要对报文中某些字段做校验,校验的过程中就需要将这些字段从网络系转换为主机序。

优化:如果校验是比较相等,那可以直接使用网络序(将主机序的宏,转为网络序,在编译期即算出结果)判断,起到性能优化效果。

1.2.4. 比特位计算

场景:代码中为了构造一系列制定业务对应的bit位,传入了局部变量进行动态计算。但这里的局部变量实际是枚举变量,算出来的bit位实际上也是常量。

优化:静态计算,在编译期提前将这些bit位计算好,避免性能浪费。

1.2.5. 使用位操作

位操作通常比传统的算术运算更快速有效,因为编译期对位操作进行优化,可以在硬件级别上进行操作。所以一般的乘、除操作尽可能使用位操作代替。

1.3.   内存优化

1.3.1. 内存池

场景:业务处理过程中需要一个拼装信息的临时缓冲区,每次处理到都需要重新申请和释放这块缓冲区,影响处理效率。

优化:缓冲区只创建一次(区分每线程),不回收,业务可重复使用,避免了频繁的内存申请和释放。

1.3.2. 位域优化

网络设备处理会话的并发量基本上是百万级别的,基于每条会话的处理,会话控制块所占用的内存需要尽量精简。通过比特位域可以很大程度减少内存。

1.3.3. 避免内存浪费

尽量减少业务未开启状态下,对系统内存的占用。通过下挂子控制块指针的方法,保证业务模块扩展时带来的内存浪费,只有业务模块真正开启时才分配内存。

1.3.4. 避免内存膨胀

内核中通过slab池分配内存的,要避免slab对象大小超出边界导致的内存膨胀。例如申请的内存大小为8 * 1M + 24bytes,而kmalloc通用slab在8M以上直接上升到16M。虽然只超过8M边界24字节, 但是实际使用的是16M的大块内存,造成严重浪费。

在进行大块内存分配时,无论是使用专用slab还是通用slab,都需要关注一下系统实际分配的内存块大小,避免此类的内存膨胀。

1.4.   Io优化

1.4.1. 批量处理io

在io非常密集或者io性能本身很差的情况下,如果业务对时效性要求不高,都可以考虑设计成批量处理的逻辑,以减少io的次数。

1.4.2. 异步IO

同样的,在多核cpu上使用异步io结合io多路复用,对于io密集型业务也是很好的优化方法。即Epoll线程负责监控各io句柄,而将实际io处理分发给各个io线程,即reactor模式。

1.4.3. mmap优化文件io

涉及到操作大文件的场合,用mmap代替open/read在性能上有一定优势。

read 的通常使用方法是 read(fd, buffer, size),将要读取的数据读到buffer中。这就涉及到两个步骤,read是系统调用函数,每次使用read都要进入内核态,进行上下文切换。内核首先将文件数据从磁盘读入page cache缓存,再将数据从page cache拷贝到buffer中。上下文切换和拷贝要消耗一定性能。

而如果使用 mmap 命令,VFS(虚拟文件系统)会分配对应的虚拟内存空间,记录目标文件的 inode 和其他属性,将起始虚拟地址返回给进程。当进程想要访问某部分数据时,需要进行地址翻译,但此时没有更新页表,会触发缺页中断。linux根据VMA中记录的 inode 信息,调用对应的文件系统进行处理。文件系统读取该页,返回给VFS,VFS再更新页表,返回对应的物理页。

在 mmap 之后,后续的读写操作都是在内存中进行,不需要再读磁盘和进入内核态。

Mmap的使用也有一定限制:

mmap 每次以页为单位从文件中读取数据,因此映射的页面大小始终是整数。对于小文件可能会造成较多的内部碎片。同时,在读取数据时也需要显式修正数据在页面中的偏移量。

mmap 需要连续的虚拟内存空间用于储存文件,如果文件较大,对于32位地址空间的系统来说,可能找不到足够大的连续区域。

mmap 本身开销比 read 大,因为mmap涉及更多的系统调用,需要触发缺页中断,更改虚拟内存映射

1.5.   多线程与锁争用

1.5.1. 多线程模型

上文提到的异步io和多路复用,使用于io密集型业务,而对于io之后如果还有cpu密集型的业务,则又需另外的线程来异步处理cpu密集型任务,以避免各io线程阻塞。总而言之,在cpu资源充足的前提下,可以使用多线程异步处理来分担主线程的压力,以提升系统性能。

1.5.2. Cpu绑定

从CPU的缓存架构图可以看出,多核的CPU的L1,L2缓存是每颗核心独享的,如果启动某个线程,根据调度时间片,可能线程在某个时刻运行的核心1上,下一个调度时间片可能就在核心2上,这样L1,L2缓存存在不命中的问题,但是如果我们能让线程或者进程独立的跑在一个核心上,这样就不需要将缓存换入缓出,理论上就可以提升性能。

通过perf``可以查看cpu-migrations的CPU迁移次数,合理的对线程和cpu或cpu组进行绑定,有助于减少迁移次数,提升系统性能。

1.5.3. 线程池

线程池作为一种并发编程的解决方案,已经得到广泛的运用,不论是否是高性能的接口,已经很少遇到频繁创建、销毁线程的代码了。线程池不仅可以免除频繁创建、销毁的性能开销,任务响应及时,而且方便管理。

1.5.4. 无锁化访问

无锁化访问作为并发编程中解决临界区冲突的手段,可以有效避免因激烈的锁争用而导致的性能下降。常见的方法包括:原子变量、每线程变量、rcu读锁等。

1.5.5. 锁的使用

u  加锁粒度

加锁粒度过粗或者过细都会导致性能的下降:过粗会导致锁争用激烈;过细会导致锁资源的浪费,影响内存。需要正确评估并发场景,设计合适的加锁粒度

u  锁的选择

要正确评估当前多线程环境下临界区的范围、公共资源的访问特点以及锁争用的激烈程度,来选择合适的锁(互斥、读写、自旋等),以达到预期的最佳性能。本章内容具体可以参考另一篇多线程编程中的介绍。

1.5.6. 定时器精度

场景:业务需求的定时间隔(秒级)远大于定时器的激励源(10ms),过高的定时器精度导致系统频繁无效地进行上下文切换、调度与处理,从而降低系统的整体性能。

优化:设置合适的定时器激励源精度。

1.6.   缓存优化

1.6.1. 局部性原理

利用时间局部性和空间局部性原理,尽量保证程序访问的数据和指令都是临近的,这样可以提高缓存的命中率。

内存和cache交换的最小单位是cacheline。比如cacheline 64字节,每一次缓存数据的单位都是以一个 CacheLine 64 字节为单位进行存储的。假如说要查询的数据在 L1 中不存在,那么 CPU 的做法是一次性从 L2 中把要访问的数据及其后面的 64 个字节全部缓存进来。假如下一次再执行的时候要访问的指令在上一次已经在 L1 中存在了,那么就直接访问 L1,就不必再从 L2 来读取了,通过一个代码示例来分析:

 

#include<stdio.h>                                                                                                                                          

#include<stdlib.h>

#include<string.h>

 

int a[10000][10000];

 

void bad_cache() {

    int i = 0,j =0;

    for(i = 0; i < 10000; i++) {

        for(j = 0; j< 10000; j++) {

            a[j][i] = 1;

        }

    }

}

 

void good_cache() {

    int i = 0,j =0;

    for(i = 0; i < 10000; i++) {

        for(j = 0; j< 10000; j++) {

            a[i][j] = 1;

        }

    }

}

调用good_cache的性能数据:

 

#simpleperf  stat -e cache-references,cache-misses cache

 

Performance counter statistics:

 

  1,788,304,622  cache-references   # 665.594 M/sec        (100%)

      7,106,477  cache-misses       # 0.397386% miss rate  (100%)

 

Total test time: 2.686779 seconds.

可以看到共花费2.68s,cache-misses比例很低,而bad_cache的性能数据:

 

#simpleperf  stat -e cache-references,cache-misses cache

 

 

Performance counter statistics:

 

  1,611,937,854  cache-references   # 126.103 M/sec         (100%)

    171,774,619  cache-misses       # 10.656405% miss rate  (100%)

 

Total test time: 12.782758 seconds.

由于cache-misses率过高导致时间花费了12.78s时间。

1.6.2. 避免Cacheline伪共享

多处理器架构下,多线程并行写入同一内存位置,由于缓存一致性问题会导致性能问题,这种现象称为cache伪共享。

l  把结构体对齐到cache line

内核代码中经常看到某个结构体对齐到cache line size。如果有很多结构体的数组,结构体内存对齐将有助于性能提升。

示例代码:

#define ____cacheline_aligned attribute((aligned(64)))

#define LOOP 10000 * 10000

struct data {

    int32_t x;

}/____cacheline_aligned/;

 

typedef struct data Data;

 

Data dArray[2];

 

void f1() {

    int64_t i = 0;

    for(i = 0; i < LOOP; i++) {

        dArray[0].x = 2;

    }

    printf("f1 complete\n");

}

 

void f2() {

    int64_t i = 0;

    for(i= 0; i < LOOP; i++) {

        dArray[1].x = 1;

    }

    printf("f2 complete\n");

}

 

int main() {

    printf("sizeof(Data):%d\n", sizeof(Data));

    std::thread t1(f1);

    std::thread t2(f2);

    t1.join();

    t2.join();

    printf("complete\n");

    return 0;

}

如果Data结构体不对齐到cache line,那么dArray[0]和dArray[1]会在同一个cacheline上面,两个线程同时修改结构体成员变量,由于缓存一致性机制,会导致缓存失效。

 

上面代码的cache-miss比例 6.2%

 

Performance counter statistics:

  1,217,787,043  cache-references   # 1.374 G/sec          (100%)

     75,934,256  cache-misses       # 6.235430% miss rate  (100%)

将____cacheline_aligned打开以后,结构体对齐到64字节,这样dArray[0]和dArray[1]将分别占用不同的cache line,写cache失效后两者不会互相影响,cache-miss比例0.017%:

 

  1,216,454,682  cache-references   # 1.413 G/sec          (100%)

        205,538  cache-misses       # 0.016896% miss rate  (100%)

 

l  把结构体成员对齐到cache line

数据结构中频繁访问的成员可以单独占用一个cache line或者相关的成员在不同的cache line中错开,以提高访问效率。比如linux内核struct zone数据结构中zone->lock和zone->lru_lock两个频繁访问的锁,可以让他们在不同的cache line中,以提高获取锁的效率。

 

    ZONE_PADDING(pad1)                                                                                                                                  

    /* free areas of different sizes */

    struct free_area    free_area[MAX_ORDER];

 

    /* zone flags, see below */

    unsigned long       flags;

 

    /* Write-intensive fields used from the page allocator */

    spinlock_t      lock;

 

    ZONE_PADDING(pad2)

 

    /* Write-intensive fields used by page reclaim */

 

    /* Fields commonly accessed by the page reclaim scanner */

spinlock_t      lru_lock;

 

l  把数组对齐到cacheline

atomic_long_t vm_zone_stat[NR_VM_ZONE_STAT_ITEMS] __cacheline_aligned_in_smp;                                                        

atomic_long_t vm_numa_stat[NR_VM_NUMA_STAT_ITEMS] __cacheline_aligned_in_smp;

atomic_long_t vm_node_stat[NR_VM_NODE_STAT_ITEMS] __cacheline_aligned_in_smp;

内核代码将vm_zone_stat等几个内存系统频繁访问的数组对齐到cacheline,确保每个数组成员在一个cacheline中

1.6.3. 避免非对齐访问

字节对齐是指在计算机系统中,数据结构中的成员按照某种特定规则放置在地址上的过程,通常是为了充分利用计算机的硬件特性和提高数据访问的效率。

当出现非对齐的访问时,将会导致处理器性能下降或者访问异常。非对齐的访问经常出现在以下场景:1、结构体嵌套,父结构体和子结构体对齐方式不同,导致访问子结构体时出现非对齐访问;2、两个结构体强转或者是字节流向结构体强转时,二者的对齐方式不同,导致强转之后的访问出现非对齐访问。

一、在C语言中,可以使用指定对齐方式的方式来实现结构体对齐到缓存行(cacheline)的方法。

  1. 使用__attribute__((aligned(n)))方式:

   可以使用__attribute__((aligned(n)))关键字将结构体按照指定的字节数(n)进行对齐。其中,n应为缓存行大小,可以通过系统相关的头文件或查询来获取。

   示例代码:

   ```c

   #include <stdio.h>

   #define CACHELINE_SIZE 64

   struct MyStruct {

       int a;

       char b;

       double c;

   } attribute((aligned(CACHELINE_SIZE)));

 

   int main() {

       printf("Size of MyStruct: %lu\n", sizeof(struct MyStruct));

       return 0;

   }

   ```

  1. 使用#pragma pack(n)方式:

   另一种方法是使用#pragma pack(n)预处理指令,将结构体的对齐方式设置为n字节。

 

   示例代码:

   ```c

   #include <stdio.h>

   #define CACHELINE_SIZE 64

 

   #pragma pack(CACHELINE_SIZE)

   struct MyStruct {

       int a;

       char b;

       double c;

   };

 

   int main() {

       printf("Size of MyStruct: %lu\n", sizeof(struct MyStruct));

       return 0;

   }

 

 

 

1.7.   算法设计

n  速率展示

n  自动机前缀树

1.8.   第三方库、硬件

n  Hyperscan

除了以上一般性的建议之外,具体的优化方案要做针对性的优化性能分析工具:使用性能分析工具如perf、gprof等进行性能测试和分析,找到瓶颈,并做有针对性的优化。