MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Linux C语言内存管理优化提升服务器性能

2022-11-057.8k 阅读

内存管理基础

在深入探讨优化之前,我们先来回顾一下 Linux C 语言内存管理的基础知识。在 C 语言中,内存分配主要通过几个标准库函数来实现,最常用的是 malloccallocrealloc

malloc 函数

malloc 函数用于在堆上分配指定字节数的内存空间,并返回一个指向分配内存起始地址的指针。其函数原型为:

void *malloc(size_t size);

例如,分配一个能存储 100 个字符的字符串空间:

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

int main() {
    char *str = (char *)malloc(100 * sizeof(char));
    if (str == NULL) {
        perror("malloc");
        return 1;
    }
    // 使用 str
    free(str);
    return 0;
}

这里,malloc 分配了 100 个字节的连续内存空间,并返回一个指向该空间的指针。注意在使用完后,必须调用 free 函数来释放内存,否则会导致内存泄漏。

calloc 函数

calloc 函数与 malloc 类似,但它会将分配的内存空间初始化为零。其函数原型为:

void *calloc(size_t nmemb, size_t size);

nmemb 是元素的个数,size 是每个元素的大小。例如,分配一个包含 10 个整数的数组,并初始化为零:

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

int main() {
    int *arr = (int *)calloc(10, sizeof(int));
    if (arr == NULL) {
        perror("calloc");
        return 1;
    }
    // 使用 arr
    free(arr);
    return 0;
}

calloc 先计算总共需要的内存大小(nmemb * size),然后分配内存并清零。

realloc 函数

realloc 函数用于重新分配已分配的内存块的大小。其函数原型为:

void *realloc(void *ptr, size_t size);

ptr 是指向先前由 malloccallocrealloc 分配的内存块的指针,size 是新的内存块大小。如果 ptrNULL,则 realloc 行为类似于 malloc。例如,动态调整一个已分配的数组大小:

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

int main() {
    int *arr = (int *)malloc(5 * sizeof(int));
    if (arr == NULL) {
        perror("malloc");
        return 1;
    }
    // 使用 arr
    int *new_arr = (int *)realloc(arr, 10 * sizeof(int));
    if (new_arr == NULL) {
        perror("realloc");
        free(arr);
        return 1;
    }
    arr = new_arr;
    // 使用新的 arr
    free(arr);
    return 0;
}

这里,先分配了一个能存储 5 个整数的数组,然后使用 realloc 将其大小调整为能存储 10 个整数。

内存碎片问题

在频繁进行内存分配和释放操作时,会出现内存碎片问题。内存碎片分为内部碎片和外部碎片。

内部碎片

内部碎片是指分配给一个进程的内存空间中,有一部分未被使用。例如,使用 malloc 分配 10 个字节的空间,但系统实际分配了 16 个字节(因为内存分配通常以一定的粒度进行),那么这多出来的 6 个字节就是内部碎片。

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

int main() {
    char *ptr = (char *)malloc(10);
    // 这里系统可能分配了大于 10 字节的空间,多出来的就是内部碎片
    free(ptr);
    return 0;
}

内部碎片主要由内存分配粒度引起,不同的内存分配器有不同的分配粒度策略。

外部碎片

外部碎片是指系统中存在许多分散的小空闲内存块,但这些小块无法满足一个较大的内存分配请求。例如,系统中有 10 个 10 字节的空闲块,但此时有一个需要 100 字节的分配请求,这些小块虽然总大小足够,但由于分散而无法满足请求,这就是外部碎片。

// 模拟外部碎片情况
#include <stdio.h>
#include <stdlib.h>

int main() {
    char *ptr1 = (char *)malloc(10);
    char *ptr2 = (char *)malloc(10);
    // 释放 ptr1 和 ptr2
    free(ptr1);
    free(ptr2);
    // 此时有两个 10 字节的空闲块
    char *big_ptr = (char *)malloc(25);
    // 可能由于外部碎片导致分配失败
    if (big_ptr == NULL) {
        perror("malloc");
    }
    return 0;
}

外部碎片通常在频繁的内存分配和释放过程中逐渐产生,严重时会导致系统内存利用率降低,影响服务器性能。

优化策略

减少内存分配和释放次数

频繁的内存分配和释放操作不仅会增加系统开销,还容易产生内存碎片。一种有效的方法是使用内存池技术。

内存池技术

内存池是在程序启动时预先分配一块较大的内存空间,然后在需要时从这个内存池中分配小块内存,使用完后再将小块内存归还到内存池中,而不是直接调用系统的内存分配和释放函数。

下面是一个简单的内存池实现示例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MEMORY_POOL_SIZE 1024 * 1024 // 1MB 内存池
#define BLOCK_SIZE 16

typedef struct block {
    struct block *next;
} block_t;

typedef struct memory_pool {
    block_t *free_list;
    char memory[MEMORY_POOL_SIZE];
} memory_pool_t;

void init_memory_pool(memory_pool_t *pool) {
    int i;
    block_t *block, *prev = NULL;
    for (i = 0; i < MEMORY_POOL_SIZE / BLOCK_SIZE - 1; i++) {
        block = (block_t *)&pool->memory[i * BLOCK_SIZE];
        if (prev) {
            prev->next = block;
        } else {
            pool->free_list = block;
        }
        prev = block;
    }
    block = (block_t *)&pool->memory[(MEMORY_POOL_SIZE / BLOCK_SIZE - 1) * BLOCK_SIZE];
    prev->next = block;
    block->next = NULL;
}

void *allocate_from_pool(memory_pool_t *pool) {
    if (pool->free_list == NULL) {
        return NULL;
    }
    block_t *block = pool->free_list;
    pool->free_list = block->next;
    return block;
}

void free_to_pool(memory_pool_t *pool, void *ptr) {
    block_t *block = (block_t *)ptr;
    block->next = pool->free_list;
    pool->free_list = block;
}

int main() {
    memory_pool_t pool;
    init_memory_pool(&pool);
    void *ptr1 = allocate_from_pool(&pool);
    void *ptr2 = allocate_from_pool(&pool);
    // 使用 ptr1 和 ptr2
    free_to_pool(&pool, ptr1);
    free_to_pool(&pool, ptr2);
    return 0;
}

在这个示例中,我们创建了一个 1MB 的内存池,每个小块大小为 16 字节。init_memory_pool 函数初始化内存池,将所有小块链接成一个空闲链表。allocate_from_pool 函数从空闲链表中取出一个小块,free_to_pool 函数将使用完的小块归还到空闲链表。

通过使用内存池,减少了对系统内存分配和释放函数的调用次数,降低了内存碎片产生的概率,提高了内存分配效率。

优化内存分配粒度

合理选择内存分配粒度可以减少内部碎片。在某些情况下,可以根据应用程序的需求调整内存分配的粒度。例如,如果应用程序经常分配大小相近的内存块,可以选择一个合适的粒度,使得每个分配的内存块尽量接近实际需求。

假设应用程序经常分配大小为 20 - 30 字节的内存块,我们可以将内存池的块大小设置为 32 字节,这样既可以满足大部分需求,又不会产生过多的内部碎片。

// 调整块大小为 32 字节
#define BLOCK_SIZE 32
// 其他代码不变,仅修改块大小定义后重新编译运行

通过这种方式,可以在一定程度上优化内存利用率,提升服务器性能。

避免不必要的内存分配

在编写代码时,要仔细分析是否真的需要进行内存分配。例如,在函数内部,如果有一些局部变量的生命周期只在函数内部,并且大小固定,可以使用栈上分配,而不是堆上分配。

// 避免不必要的堆内存分配
#include <stdio.h>
#include <stdlib.h>

// 不推荐:每次调用都在堆上分配内存
char *get_string_heap() {
    char *str = (char *)malloc(10 * sizeof(char));
    if (str == NULL) {
        perror("malloc");
        return NULL;
    }
    strcpy(str, "hello");
    return str;
}

// 推荐:在栈上分配内存
void get_string_stack(char *str) {
    strcpy(str, "hello");
}

int main() {
    char stack_str[10];
    get_string_stack(stack_str);
    printf("%s\n", stack_str);

    char *heap_str = get_string_heap();
    if (heap_str) {
        printf("%s\n", heap_str);
        free(heap_str);
    }
    return 0;
}

get_string_heap 函数中,每次调用都在堆上分配内存,使用完后需要手动释放,容易出现内存泄漏。而 get_string_stack 函数通过在栈上分配内存,避免了堆内存分配和释放的开销,同时也减少了内存泄漏的风险。

内存对齐

内存对齐是指数据在内存中存储的起始地址是其自身大小的整数倍。在 Linux C 语言中,内存对齐对性能有重要影响。

为什么需要内存对齐

现代计算机的硬件通常对内存访问有一定的要求,按对齐方式访问内存可以提高访问效率。例如,一个 32 位系统,通常要求 4 字节对齐(即数据的起始地址是 4 的倍数),如果数据没有按对齐方式存储,CPU 可能需要进行多次访问才能获取完整的数据,这会降低性能。

内存对齐示例

#include <stdio.h>
#include <stdint.h>

// 结构体未对齐
typedef struct {
    char a;
    int b;
    char c;
} unaligned_struct;

// 结构体对齐
typedef struct {
    char a;
    char padding1[3];
    int b;
    char c;
    char padding2[3];
} aligned_struct;

int main() {
    printf("unaligned_struct size: %zu\n", sizeof(unaligned_struct));
    printf("aligned_struct size: %zu\n", sizeof(aligned_struct));
    return 0;
}

unaligned_struct 中,a 占 1 字节,b 占 4 字节,c 占 1 字节,理论上大小应该是 6 字节,但由于 b 没有 4 字节对齐,实际大小为 8 字节。而在 aligned_struct 中,通过添加填充字节(padding1padding2),使得 b 按 4 字节对齐,虽然结构体大小变为 12 字节,但在内存访问时效率更高。

在实际编程中,可以使用 #pragma pack 指令来指定结构体的对齐方式,不过要注意,不同编译器对该指令的支持可能略有不同。

// 使用 #pragma pack 指令指定对齐方式
#pragma pack(push, 1)
typedef struct {
    char a;
    int b;
    char c;
} packed_struct;
#pragma pack(pop)

int main() {
    printf("packed_struct size: %zu\n", sizeof(packed_struct));
    return 0;
}

这里使用 #pragma pack(push, 1) 将对齐方式设置为 1 字节对齐,packed_struct 的大小变为 6 字节,但可能会降低内存访问效率,所以要根据实际需求谨慎使用。

动态内存管理与缓存

在服务器应用中,缓存是提高性能的重要手段。对于动态分配的内存,也可以利用缓存机制来优化性能。

缓存频繁使用的内存块

如果应用程序中某些内存块会被频繁使用,可以将这些内存块缓存起来,避免重复的内存分配和释放。例如,在一个网络服务器中,可能会频繁创建和销毁用于接收和发送数据的缓冲区。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define BUFFER_SIZE 1024
#define CACHE_SIZE 10

typedef struct buffer_cache {
    char *buffers[CACHE_SIZE];
    int used[CACHE_SIZE];
} buffer_cache_t;

void init_buffer_cache(buffer_cache_t *cache) {
    int i;
    for (i = 0; i < CACHE_SIZE; i++) {
        cache->buffers[i] = (char *)malloc(BUFFER_SIZE);
        cache->used[i] = 0;
    }
}

char *get_buffer_from_cache(buffer_cache_t *cache) {
    int i;
    for (i = 0; i < CACHE_SIZE; i++) {
        if (!cache->used[i]) {
            cache->used[i] = 1;
            return cache->buffers[i];
        }
    }
    return (char *)malloc(BUFFER_SIZE);
}

void free_buffer_to_cache(buffer_cache_t *cache, char *buffer) {
    int i;
    for (i = 0; i < CACHE_SIZE; i++) {
        if (cache->buffers[i] == buffer) {
            cache->used[i] = 0;
            return;
        }
    }
    free(buffer);
}

int main() {
    buffer_cache_t cache;
    init_buffer_cache(&cache);
    char *buf1 = get_buffer_from_cache(&cache);
    char *buf2 = get_buffer_from_cache(&cache);
    // 使用 buf1 和 buf2
    free_buffer_to_cache(&cache, buf1);
    free_buffer_to_cache(&cache, buf2);
    return 0;
}

在这个示例中,我们创建了一个缓存,缓存中最多可以存放 10 个大小为 1024 字节的缓冲区。init_buffer_cache 函数初始化缓存,get_buffer_from_cache 函数从缓存中获取缓冲区,如果缓存已满则新分配内存,free_buffer_to_cache 函数将缓冲区归还到缓存中,如果缓冲区不在缓存中则释放内存。

通过缓存频繁使用的内存块,可以减少内存分配和释放的开销,提高服务器性能。

与操作系统缓存的协同

Linux 操作系统本身有内存缓存机制,如文件系统缓存。在编写服务器应用时,要合理利用操作系统的缓存,避免不必要的重复缓存。例如,如果应用程序需要频繁读取文件数据,可以利用操作系统的文件系统缓存,而不是自己再创建一个相同数据的缓存。

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>

#define BUFFER_SIZE 1024

int main() {
    int fd = open("test.txt", O_RDONLY);
    if (fd == -1) {
        perror("open");
        return 1;
    }
    char *buffer = (char *)malloc(BUFFER_SIZE);
    ssize_t read_bytes = read(fd, buffer, BUFFER_SIZE);
    if (read_bytes == -1) {
        perror("read");
        free(buffer);
        close(fd);
        return 1;
    }
    // 使用 buffer 处理读取的数据,这里利用了操作系统的文件系统缓存
    free(buffer);
    close(fd);
    return 0;
}

在这个示例中,通过 read 函数读取文件数据,操作系统会自动将文件数据缓存到内存中。应用程序在后续再次读取相同文件数据时,如果数据还在操作系统缓存中,就可以直接从缓存中获取,而不需要再次从磁盘读取,提高了数据读取效率。

内存管理工具

在优化内存管理的过程中,有一些工具可以帮助我们检测内存问题,分析内存使用情况,从而更好地进行优化。

Valgrind

Valgrind 是一款功能强大的内存调试和性能分析工具。它可以检测内存泄漏、非法内存访问等问题。

使用 Valgrind 检测内存泄漏的示例:

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

int main() {
    int *ptr = (int *)malloc(sizeof(int));
    // 忘记释放 ptr
    return 0;
}

编译上述代码后,使用 Valgrind 运行:

valgrind --leak-check=full./a.out

Valgrind 会输出详细的内存泄漏信息,包括泄漏的内存块大小、分配位置等,帮助我们定位和修复内存泄漏问题。

GDB

GDB 是一款常用的调试工具,虽然它不是专门的内存管理工具,但在调试内存相关问题时非常有用。例如,当程序因非法内存访问而崩溃时,可以使用 GDB 来确定程序崩溃的位置。

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

int main() {
    int *ptr = (int *)malloc(sizeof(int));
    *ptr = 10;
    free(ptr);
    // 非法访问已释放的内存
    printf("%d\n", *ptr);
    return 0;
}

编译代码后,使用 GDB 调试:

gdb./a.out
run

当程序崩溃时,GDB 会显示程序崩溃的位置,帮助我们找出非法内存访问的代码行,从而进行修复。

PAPI(Performance Application Programming Interface)

PAPI 是一个用于访问硬件性能计数器的库。通过 PAPI,我们可以获取与内存访问相关的性能指标,如内存带宽利用率、缓存命中率等。

#include <stdio.h>
#include <papi.h>

#define NUM_EVENTS 2
int main() {
    int EventSet = PAPI_NULL;
    long long values[NUM_EVENTS];
    int retval;

    // 初始化 PAPI
    if ((retval = PAPI_library_init(PAPI_VER_CURRENT))!= PAPI_VER_CURRENT) {
        printf("PAPI library init error!\n");
        return 1;
    }

    // 创建事件集
    if ((retval = PAPI_create_eventset(&EventSet))!= PAPI_OK) {
        printf("PAPI create eventset error!\n");
        return 1;
    }

    // 添加事件
    if ((retval = PAPI_add_event(EventSet, PAPI_L1_DCM))!= PAPI_OK) {
        printf("PAPI add event error!\n");
        return 1;
    }
    if ((retval = PAPI_add_event(EventSet, PAPI_TOT_CYC))!= PAPI_OK) {
        printf("PAPI add event error!\n");
        return 1;
    }

    // 启动事件集
    if ((retval = PAPI_start(EventSet))!= PAPI_OK) {
        printf("PAPI start error!\n");
        return 1;
    }

    // 执行内存相关操作
    int *ptr = (int *)malloc(1000 * sizeof(int));
    // 操作 ptr
    free(ptr);

    // 停止事件集并获取数据
    if ((retval = PAPI_stop(EventSet, values))!= PAPI_OK) {
        printf("PAPI stop error!\n");
        return 1;
    }

    printf("L1 data cache misses: %lld\n", values[0]);
    printf("Total cycles: %lld\n", values[1]);

    // 销毁事件集
    if ((retval = PAPI_destroy_eventset(&EventSet))!= PAPI_OK) {
        printf("PAPI destroy eventset error!\n");
        return 1;
    }

    return 0;
}

通过 PAPI 获取的性能指标,可以帮助我们分析内存管理优化措施对硬件性能的影响,进一步调整优化策略,提升服务器性能。

通过深入理解 Linux C 语言内存管理的原理,运用各种优化策略,结合内存管理工具进行分析和调试,我们可以有效地优化服务器的内存使用,提升服务器性能,使其能够更好地应对高并发、大数据量等复杂的应用场景。