本文将简要介绍Linux内核的内存管理方式,包括 node、zone 划分、空闲页面管理和 slab 分配器。此外,还将解析TCP连接相关的内核对象,涵盖 socket 函数创建和服务端 socket 创建的关键过程。
部分内容来源于 《深入理解Linux网络》、《Linux内核源码分析TCP实现》
Linux内核管理内存机制
SLAB/SLUB 内存管理机制通过 Node 和 Zone 划分、伙伴系统和缓存分配器来管理内存,将物理内存条划分为多个层次,确保内存分配的高效性和灵活性。这种机制特别适合处理内核频繁申请和释放的小块内存的场景,减少了碎片化。
node划分
在早期的计算机架构中,所有内存访问都需要经过北桥芯片,这种架构称为 UMA(一致性内存访问)。在 UMA 架构下,所有 CPU 通过统一的总线访问内存,访问延迟是均匀的。然而,随着计算机的发展,尤其是 CPU 核心数量的增加,这种架构的 前端总线成为了瓶颈,导致 CPU 频繁争用总线资源。
为了解决这一问题,NUMA(非一致性内存访问) 架构应运而生。在 NUMA 架构中,每个 CPU 都拥有自己的内存控制器,并直接连接到一部分内存(称为 本地内存)。不同的 CPU 之间通过 QPI(Quick Path Interconnect) 等高速互连技术连接,可以访问彼此的内存(称为 远程内存),但访问远程内存的延时比访问本地内存要高。
在 NUMA 架构下,Linux 内核对物理内存进行了抽象管理,采用 Node 划分 的方式来更好地支持多处理器系统的内存访问。每个 Node 是一个逻辑单元,它包含了某个 CPU 及其直接连接的本地内存。Node 的划分有助于减少跨节点的内存访问开销,提高系统性能。
Node 划分的目的是为了在多处理器系统中更好地利用本地内存,减少跨处理器访问内存的延迟。
zone划分
在 Linux 内核的内存管理体系中,物理内存不仅被划分为 Node(在 NUMA 架构下),每个 Node 内部的内存还进一步被划分为多个 Zone,每个 Zone 管理一部分特定类型的内存区域。
每个 Zone 是由 struct zone_struct 结构体描述的,内核使用它来表示内存中的特定范围。主要的内存区域(Zone)包括以下几种类型:
ZONE DMA: 地址段最低的一块内存区域,供1O设备DMA访问。
ZONE DMA32: 该zone用于支持32位地址总线的DMA设备,只在64位系统里才
有效。
ZONE NORMAL: 在X86-64架构下,DMA和DMA32之外的内存全部在NORMAL的zone里管理。
每个 Zone 下包含了许多 Page(页面),一个页面是 Linux 系统中的最小内存管理单元。在大多数 Linux 系统下,一个页面的大小为 4KB,这是由处理器架构和系统编译时决定的。
基于伙伴系统管理空闲页面
伙伴系统 是 Linux 内核中用于管理物理内存的分配和释放的一种高效算法。它的核心思想是将内存按大小分层管理,并以 “伙伴” 的形式进行分配和合并,确保当释放内存时,能够最大程度地还原为较大的连续内存块,以减少内存碎片问题。
在每个 Zone 里,有一个 free_area 数组,用于管理不同大小的空闲内存块。数组的每个元素代表一种大小的内存块(以页为单位),它们形成一个链表,记录可以使用的连续物理页面。
#define MAX_ORDER 11
struct zone {free_area free_area[MAX_ORDER];
}
- MAX_ORDER: 表示伙伴系统中管理的最大块大小,MAX_ORDER = 11 表示系统管理的最大连续内存块是 2^10 页(即 4MB 的内存)。
- free_area[0]: 表示 1 页(4KB)的空闲块链表。
- free_area[1]: 表示 2 页(8KB)的空闲块链表。
- free_area[2]: 表示 4 页(16KB)的空闲块链表。
以此类推,直到 free_area[10],表示包含 1024 页(4MB)的空闲块链表。
每个 free_area 元素实际上包含多个链表,用于管理不同类型的页面,这些页面根据可移动性或回收策略被划分为不同的类型。每种类型使用不同的链表来管理:
-
MIGRATE_UNMOVABLE:表示不可移动的页面,如内核数据结构所使用的页面,这类页面不能在内存管理过程中被移动。
-
MIGRATE_RECLAIMABLE:表示可回收的页面,例如系统缓存。当系统内存紧张时,可以回收这些页面以释放内存。
-
MIGRATE_MOVABLE:表示可移动的页面,例如用户进程使用的页面。这类页面可以在内存碎片整理或需要大块内存时进行移动,以形成更大的连续内存块。
-
MIGRATE_PCPTYPES:用于特殊用途的页面管理,通常用于管理临时备份或为某些特殊场景预留的页面。
-
MIGRATE_HIGHATOMIC:用于处理高优先级的内存分配请求。这类页面只有在内存极度紧张时才会被使用,确保关键任务的内存分配需求。
链表中的每一个元素都是一个空闲内存块。这些内存块在物理内存中是连续的,也就是说,它们包含的页框在物理内存中是紧邻的。这样,当内核需要分配一个连续的内存区域时,可以直接从这些链表中查找和分配。但要注意,虽然这些内存块在物理内存中是连续的,但在虚拟内存中可能并不连续。因为虚拟地址到物理地址的映射是通过页表完成的,不同的页框可以被映射到虚拟内存中的任意位置(不一定在相邻的页表项)。
伙伴系统的核心思想在于内存分配和回收时对内存块的 拆分与合并,确保系统能灵活使用内存资源。
内存分配: 当内核需要分配一块连续的内存时,伙伴系统首先从 free_area 数组中查找所需大小的内存块。如果找到合适的块,直接分配。如果没有找到合适的块,则查找更大的块,将其拆分为两个较小的块,分配其中一个,另一个块被放回相应的链表中。
内存释放: 系统会检查该内存块是否有相邻的 “伙伴”(大小相同且地址连续的块)。如果找到伙伴,则合并这两个块,恢复为更大的连续内存块。这个合并过程可以继续递归地向上进行,直到无法再合并为止。
slab分配器
为了高效管理内核中的小块内存分配,Linux 内核在 伙伴系统 之上引入了 SLAB 分配器,专门用于处理频繁的、较小的内存分配和释放操作。SLAB 分配器通过分配固定大小的内存块来减少内存碎片,并提高分配效率。这种机制特别适用于内核中频繁创建和销毁的小型对象,例如文件描述符、进程描述符等。
一个 slab 是由一组内存页(物理内存块)组成的内存区域,包含若干个相同大小的内存对象。每个 slab 负责管理一个特定大小的对象,当一个对象被释放时,另一个相同类型的对象可以复用该内存区域,从而减少内存碎片。
内核会为每个类型的对象创建一个 kmem_cache
,并根据该对象的大小初始化相应的 slab。kmem_cache
是管理特定类型对象的结构体。
struct kmem_cache {struct kmem_cache_node **node; // 用于支持 NUMA 系统的内存管理...
}
在 NUMA 系统中,不同处理器和内存节点的访问速度不同。为了解决这一问题,Linux 内核为每个 kmem_cache
在不同的 NUMA 节点上创建一个 kmem_cache_node
结构体,以确保尽量从当前 NUMA 节点分配内存,提升内存访问效率。
struct kmem_cache_node {struct list_head slabs_partial; // 部分已用 slab 链表struct list_head slabs_full; // 已满 slab 链表struct list_head slabs_free; // 空闲 slab 链表
}
当需要分配内存时,SLAB 分配器会从相应的 kmem_cache
中分配内存对象。流程如下:
内存分配: 当内核需要某种特定大小的内存对象时,SLAB 分配器会查找对应 kmem_cache
的 slab 链表。如果 slabs_partial
中有空闲对象,则直接分配该对象。如果没有,则从 slabs_free
中取出一个空闲 slab,或调用伙伴系统分配新的页创建 slab。
内存回收: 当对象被释放时,SLAB 分配器会将该对象归还给对应的 slab。如果 slab 中所有对象都被释放,则该 slab 会被放入 slabs_free
,可供重新使用。
SLAB 分配器避免了频繁的页级别分配。它通过缓存对象来复用内存区域,特别是对象大小确定时,避免每次分配都使用伙伴系统的内存页分配。每个 slab 内的内存块(对象)大小一致,复用时无需额外的分配逻辑,极大提高了小块内存的分配效率。
在 NUMA(非一致性内存访问)架构下,不同处理器访问不同内存区域的延迟和带宽不同。因此,为了在 NUMA 系统中提高性能,SLAB 分配器会优先从当前 CPU 对应的 NUMA 节点中分配内存。
每个 kmem_cache
在 NUMA 系统中都有一个或多个 kmem_cache_node
,每个 kmem_cache_node
代表某个 NUMA 节点的状态。在内存分配时,系统优先从当前 CPU 对应的 kmem_cache_node
获取对象,确保内存的本地访问性。
小结
Node 划分: 在支持 NUMA(非一致性内存访问)架构的系统中,物理内存和 CPU 会被划分为多个 Node,每个 Node 负责管理特定的 CPU 及其直接关联的内存。这样做是为了优化内存访问的性能,使得 CPU 优先访问它所在 Node 的本地内存,减少跨节点的内存访问延迟。
Zone 划分: 每个 Node 下的内存进一步被划分为多个 Zone,以适应不同设备和内存需求。常见的 Zone 包括 ZONE_DMA(用于 DMA 设备的内存区域)、ZONE_NORMAL(用于系统操作的常规内存区域)以及 ZONE_HIGHMEM(高端内存,仅用于 32 位系统)。这种划分方式帮助内核合理管理不同大小和用途的内存区域。
伙伴系统: 在每个 Zone 内,Linux 内核使用 伙伴系统 来管理空闲页面。伙伴系统通过按 2 的幂次分组内存块,提供高效的内存分配和回收机制。它可以快速找到合适大小的内存块,并通过拆分和合并内存块的方式,最大限度地减少内存碎片。伙伴系统是内核内存分配的基础模块,应用程序通过调页组件从中获取页面。
SLAB 分配器: 在伙伴系统之上,内核还引入了 SLAB(或 SLUB)分配器,专门用于分配和管理频繁使用的小型内存对象。SLAB 分配器通过预先分配内存块,并在同类对象之间复用内存,极大地提升了内核中小块内存分配的效率,减少了碎片化问题。SLAB 分配器为内核的自身需求服务,特别是内核中频繁使用的小型数据结构(如文件描述符、进程描述符等)。
前三步(Node 划分、Zone 划分、伙伴系统)主要是为整个系统的内存管理服务,确保内存能够高效地分配给应用程序和内核本身。这些基础模块使得内核在处理内存分配请求时,能够快速找到合适的内存区域,避免资源浪费。
而第四步的 SLAB 分配器 则是为内核的自身运作提供了一个更加精细化的内存管理工具。内核中有大量需要频繁创建、销毁的小型对象,如文件描述符、网络连接等。如果直接使用页级分配器来处理这些小块内存,将会导致严重的内存浪费和碎片化。SLAB 分配器通过缓存相同类型的对象来解决这个问题,在对象释放后,可以让另一个相同类型的对象复用这块内存,从而提高了内存分配效率并减少了碎片。
TCP连接相关内核对象
TCP连接建立的过程中,每申请一个内核对象也都需要到相应的缓存里申请一块内存。
socket函数直接创建
socket 函数的内核实现涉及一系列复杂的操作,包括 socket_alloc
、tcp_sock
、dentry
和 file
对象的创建,确保了内核能有效管理和维护每一个 TCP 连接。
sock_inode_cache 对象申请
socket 函数创建的第一个关键步骤是为 struct socket 对象分配内存。这个步骤通过调用 sock_alloc
来完成。
struct socket_alloc {struct socket socket;struct inode vfs_inode;
}
socket_alloc
内核对象将socket和inode信息关联了起来。在sock_alloc
的实现逻辑中,最后就调用了kmem_cache_alloc
从sock_inode_cache
中申请了一个struct socket_alloc
对象。
static struct inode *sock_alloc_inode(struct super_block *sb){struct socket_alloc *ei;struct socket_wq *wq;ei = kmem_cache_alloc(sock_inode_cachep, GFP_KERNEL);if(!ei)return NULL;wq = kmalloc(sizeof(*wq), GFP_KERNEL);
}
TCP 对象申请
在 __sock_create
函数中,当调用协议族的创建函数时,针对 IPv4 的 socket,会调用 inet_create
函数来进一步创建 TCP 的 sock 对象。
static int inet_create(struct net *net, struct socket *sock, int protocol, int kern) {...// answer_prot 是 tcp_protanswer_prot = answer->prot;// 分配 struct sock 对象sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot);
}
sk_alloc
函数负责分配 struct sock 对象。对于 TCP 连接,实际分配的是 struct tcp_sock
,这是 struct sock
的扩展版本。
struct sock *sk_alloc(...) {struct sock *sk;// 使用 sk_prot_alloc 从 slab 中分配内存sk = sk_prot_alloc(prot, priority | __GFP_ZERO, family);
}static struct sock *sk_prot_alloc(struct proto *prot, ...) {slab = prot->slab;if (slab != null) {// 从 slab 中分配内存sk = kmem_cache_alloc(slab, priority & ~__GFP_ZERO);}
}
在 sk_prot_alloc
中,内核从 TCP 专用的 slab 缓存中分配一个 struct tcp_sock
对象。该 slab 缓存是在协议栈初始化时通过 kmem_cache_create
初始化的:
struct proto tcp_prot = {.name = "TCP",.obj_size = sizeof(struct tcp_sock),
};
struct tcp_sock
是 struct sock
的扩展,支持更多 TCP 特性。由于 tcp_sock
和 sock 是逐层嵌套的,因此它可以被当作 sock 使用。
dentry 和 file 对象申请
在 socket 系统调用的入口处,除了 sock_create,还调用了 sock_map_fd
来为 socket 关联文件系统中的 dentry 和 file 对象。
SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol) {sock_create(family, type, protocol, &sock);sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}
sock_map_fd
函数完成了 struct dentry
和 struct file
对象的分配:
static int sock_map_fd(struct socket *sock, int flags) {struct file *newfile;int fd = get_unused_fd_flags(flags);...// 申请 dentry 和 file 内核对象newfile = sock_alloc_file(sock, flags, NULL);if (likely(!IS_ERR(newfile))) {// 关联到 socket 及进程fd_install(fd, newfile);return fd;}
}struct file *sock_alloc_file(struct socket *sock, int flags, const char *dname) {// 申请 dentrypath.dentry = d_alloc_pseudo(sock_mnt->mnt_sb, &name);// 申请 filefile = alloc_file(&path, FMOD_READ | FMODE_WRITE, &socket_file_ops);
}
d_alloc_pseudo
:为 dentry 对象分配内存,它使用了 slab 分配器从 dcache 缓存中分配。
alloc_file
:为 file 对象分配内存,使用 slab 分配器从 files 缓存中分配。
服务端socket创建
除了直接创建socket意外,服务端还可以通过accept函数在接受连接请求时完成相关内核对象的创建。
SYSCALL_DEFINE(accept4, int, fd, struct sockaddr __user *, upeerp_sockaddr, int __user *, upeer_addrlen, int, flags)
{struct socket *sock, *newsock;// 根据fd查找到监听的socketsock = sockfd_lookup_light(...);// 申请并初始化新的socketnewsock = sock_alloc();newsock->type = sock->type;newsock->ops = sock->ops;// 申请新的file对象,并设置到新的socket上newfile = sock_alloc_file(newsock, ...);// 接受连接err = sock->ops->accept(sock, newsock, sock->file->f_flags);// 将新文件添加到当前进程的打开文件列表fd_install(newfd, newfile);
}
可以看到socket_alloc
、file
、dentry
对象的分配都是相同的方式,唯一的区别是tcp_sock
对象是在第三次握手的时候创建的,所以这里在接收连接的时候直接从全连接队列拿出request_sock
的sock成员就可以了,无需再单独申请。
TCP内核对象开销测试
一条处于 ESTABLISH(已建立)状态的 TCP 连接,内存的消耗大约在 3KB 左右。这一估算已经包含了管理连接状态所需的各种内核对象和数据结构。例如,内核为每个 TCP 连接维护的 struct tcp_sock
和其他相关数据结构会占用一部分内存。具体包括以下几部分:
- TCP控制块:这是内核用来维护 TCP 连接的关键数据结构。它保存了连接状态、窗口大小、序列号等与该 TCP 连接相关的信息。
- 发送缓存区(Send Buffer):用于存储已经发送但尚未确认的数据包,直到接收到对方的 ACK。
- 接收缓存区(Receive Buffer):用于存储已经接收到但尚未被用户进程读取的数据。
TCP 连接并非总是处于 ESTABLISH 状态,在连接关闭时,连接会进入其他状态,比如 FIN_WAIT2 或 TIME_WAIT。这些状态下,内核会尽量释放与该连接相关的不必要内核对象,以减少内存消耗。
TIME_WAIT 状态:当 TCP 连接进入 TIME_WAIT 状态时,内核不再需要保持大量的内存来管理该连接,因为该状态下的连接仅仅是等待足够的时间以确保对方接收到最后的 ACK。这时,内核只需要保留少量的状态信息,因此每个 TIME_WAIT 状态的连接仅消耗 0.4KB 左右 的内存。
在高并发场景中,可能会有大量的 TCP 连接处于 TIME_WAIT 状态。如果服务器频繁创建和关闭 TCP 连接,TIME_WAIT 连接会堆积,占用内存资源。尽管每条 TIME_WAIT 连接消耗的内存较少,但如果累积数量巨大,也会对系统造成一定的内存压力。为了解决这一问题,Linux 内核提供了 tcp_tw_recycle
和 tcp_tw_reuse
参数,用于更快速地回收这些连接,减少 TIME_WAIT 连接的堆积。