操作系统内功 · 第 44 篇|tmpfs 与 /dev/shm:内存文件系统的用途与陷阱

三个看似无关的故障,根子是同一个东西。故障一:PostgreSQL 容器启动即崩,报错 could not map anonymous shared memory: cannot allocate memory——原因是 Docker 默认给容器的 /dev/shm 只有 64MB,而 PostgreSQL 的并行查询要把工作内存放在共享内存里。故障二:一台 64GB 的机器,df -h 里 /dev/shm 显示已用 30GB,工程师疑惑「谁把内存占了一半」——其实没占用那么多,df 的统计口径有玄机。故障三:有人把 Redis 数据目录放进了 tmpfs 想提速,结果机器内存用量直接翻倍,OOM。三个故障的主角都是 tmpfs——把文件内容存在内存里的文件系统。它是最古老、也最被低估的内核组件之一:/dev/shm 是它的标准挂载点,POSIX 共享内存靠它实现,Kubernetes 的 emptyDir 有它的身影,浏览器的缓存、编译系统的临时目录都爱用它的速度。这一篇把 tmpfs 的机制、配额、与 page cache 的关系讲透,再逐一拆解那三个故障的完整因果链。

一、tmpfs 的本质:既是文件系统,又是内存页

tmpfs 的实现(内核里的 shmem 机制)有一个精巧的双重身份:它提供完整的文件系统语义(目录、权限、符号链接、rename 都支持),但文件的每一个数据页就是一页匿名内存——写入 tmpfs 的数据不落到任何块设备,而是像进程的堆内存一样被内核当作匿名页管理。这带来两个直接推论。其一,惰性分配:mount -t tmpfs -o size=8G tmpfs /mnt 只是声明了一个 8G 的配额上限,实际内存占用从零开始,写多少文件占多少内存——mount 一万个 tmpfs 也不会立刻吃掉一字节(这也是它能在挂载表里大量存在的原因:/dev/shm、/run、/tmp 的 tmpfs 实例遍布系统)。其二,匿名页的身份意味着它可以被换出到 swap——很多人以为「tmpfs 在内存里所以绝不碰磁盘」,准确的说法是:tmpfs 的页优先驻留 RAM,但内存紧张时它们与其他匿名页一起进入 swap 换出队列。这个设计是刻意的:/dev/shm 里放的是 POSIX 共享内存(shm_open/sem_open 创建的对象),如果不允许换出,一段放在 /dev/shm 的大共享内存会变成「永不回收」的内存黑洞——swap 的存在让 tmpfs 有了回收退路。

df -h 看到的 tmpfs 统计揭示第三层身份:

$ df -h /dev/shm /run /tmp
Filesystem      Size  Used Avail Use% Mounted on
tmpfs            32G   28G   4.6G  86% /dev/shm
tmpfs           6.3G   12M  6.3G   1%  /run
tmpfs           6.3G  320K  6.3G   1%  /tmp

$ free -h | head -2
               total   used   free  shared  buff/cache available
Mem:            63Gi   21Gi  2.0Gi    28Gi      13Gi      41Gi

shared 列的 28Gi 正是 /dev/shm 里的内容——tmpfs 的页在 free 的统计里计入 shared(内核的 shmem 统计),不与进程 RSS 重复计算(进程 mmap 这段共享内存会同时计入该进程的 RSS/SHR,但内核内存账本上只有一份页)。Size 列显示的 32G 是默认配额:tmpfs 默认 size 是物理内存的一半(老内核用 totalram 的 50% 计算出 6.3G 那些,/dev/shm 单独默认一半内存)。注意 df 的 Used 统计包含所有页(含稀疏文件的洞?不——tmpfs 不支持稀疏洞的延迟语义,写过的字节都占页),但它与 free 的 used 有一个容易被误读的口径差:df 报的 28G 是「文件内容占了 28G 内存页」,若此时 swap 里还有被换出的 tmpfs 页,df 不显示它们(df 只看驻留内存中的部分),而 free 的 shared 通常统计含换出部分的总 shmem——所以「df 显示 28G、free 显示 shared 28G 但内存没爆」完全正常,一部分在 swap 里躺着。这个口径差是第二个故障(工程师误以为 /dev/shm 占了一半内存)的答案的一半,另一半在挂载参数。

二、tmpfs vs ramfs:size 限制为什么生死攸关

tmpfs 有一个更原始的表亲 ramfs——两者都是「数据只存内存」,分野只在配额机制。ramfs 没有任何限制:往里写多少就占多少内存,写满物理内存后继续向 page cache 借、向 swap 借,最后系统被拖入回收泥潭或 OOM。历史上它是内核为 initramfs(启动早期的根文件系统)设计的极简实现;tmpfs 在 ramfs 之上加了 size 上限与 inode 数上限(-o size=1G,nr_inodes=100000),写入触顶时返回 ENOSPC——像一块真的盘一样「满了就报错」,而不是把整个系统拖死。这个差别看似只是「有没有护栏」,实际是可用性的天壤:ramfs 写满 = 系统崩溃,tmpfs 写满 = 应用拿到 ENOSPC 自己处理。对照表如下:

flowchart TD
    A["写入内存文件系统"] --> B{tmpfs or ramfs?}
    B -- "tmpfs(有配额)" --> C{"超过 size 上限?"}
    C -- "是" --> D["write 返回 ENOSPC<br/>应用可控故障"]
    C -- "否" --> E["正常写入<br/>页可被 swap 换出(有退路)"]
    B -- "ramfs(无配额)" --> F["无限占用内存<br/>吃光 RAM + swap 后系统假死/OOM<br/>仅内核自用(initramfs)"]
    E --> G["POSIX shm_open / /dev/shm<br/>IPC 共享内存的实现载体"]

工程上真正要注意的是配额的默认值从哪来:非显式挂载的 tmpfs(/dev/shm、/run)默认 size 为 RAM 的一半,/tmp 在 systemd 系发行版由 tmp.mount 单元挂载(默认同样一半)。对「往 /tmp 写大临时文件」的工作负载(编译、视频处理、导出报表),一半内存的默认配额可能太小(ENOSPC)也可能太大(挤占服务内存)——都该显式指定:mount -o remount,size=8G /tmp。另外 tmpfs 的页虽然可换出,但换出只是应急通道:tmpfs 内容的整体生命周期与挂载点绑定,重启即清空(这正是它适合「临时文件」的语义),不能当作持久存储的任何幻觉都要掐灭。

三、正确用途:共享内存 IPC 与高频临时 IO

tmpfs 的两个黄金用途。第一个是 POSIX 共享内存:shm_open("/mybuf", O_CREAT|O_RDWR, 0600) 实际就是在 /dev/shm 下创建文件 /dev/shm/mybuf,随后 mmap 它,两个进程把同一段物理内存映射进各自地址空间——这是进程间共享内存的标准 API 路径(本系列 IPC 一篇讲过 SysV 的 shget/shmat,POSIX 版的实现载体就是 tmpfs)。这个用途里 /dev/shm 的大小直接决定共享内存的上限:PostgreSQL 的并行查询工作区、Redis 的某些共享结构、机器学习框架的进程间张量传输(PyTorch DataLoader 的 worker 共享张量就走 /dev/shm)都受它约束——Docker 默认 64MB 的 /dev/shm 是历史包袱(复用了 POSIX 规范建议的最小值),跑数据库或 DataLoader 的容器几乎必须 --shm-size=2g 或更高显式调大。Kubernetes 侧对应 emptyDir 的 Memory 模式:

volumes:
  - name: dshm
    emptyDir:
      medium: Memory          # 挂一个 tmpfs 到容器内
      sizeLimit: 2Gi          # 必须配 sizeLimit,否则共享节点默认配额
containers:
  - name: app
    volumeMounts:
      - mountPath: /dev/shm
        name: dshm

第二个用途是高频小 IO 的临时目录:编译中间产物、爬虫的原始页面暂存、日志处理管道的 staging 区——写在 tmpfs 上的随机小 IO 是纯内存拷贝(微秒级),没有块设备队列、没有 page cache 的二级缓存开销。给一个量级感受:在 NVMe 上做 100 万次 4KB 随机写约需 3~5 秒(含 fsync 则更长),在 tmpfs 上同样的操作约 1~2 秒内完成且不占任何磁盘寿命。但有个判断要先做:普通磁盘写的 4KB 数据本来就先经过 page cache(内存),异步刷盘——tmpfs 相比 page cache 的增益只在没有持久需求、且写入模式是「大量小文件、频繁创建删除」时显著(省掉了 inode/dentry 与回写路径);如果数据反正要持久化,tmpfs 只是把「写到 cache 再落盘」变成「写到 tmpfs 再拷贝落盘」,反而多一次拷贝。tmpfs 是「用完即弃」场景的优化,不是通用加速器。

四、三个陷阱:ENOSPC、双重内存与 swap 延迟

回到开头三个故障的完整复盘。陷阱一:/dev/shm 满(ENOSPC)。PostgreSQL 容器故障的机制:PG 启动时按 shared_memory 需求在 /dev/shm 建共享段,Docker 默认 64MB 配额直接不够 → mmap 或 ftruncate 失败 → 启动崩溃。解法是显式 --shm-size(docker)或 emptyDir Memory + sizeLimit(K8s),并配上监控——监控口径用 df /dev/shm 的 Use%(注意它只反映驻留部分)加上 free -h 的 shared 列对账。同类事故高发区还有 Chrome 无头浏览器(headless chrome 默认假设 /dev/shm 足够大,Docker 里跑会崩溃,--shm-size=1g 是标准解法)与 PyTorch 多 worker DataLoader(RuntimeError: DataLoader worker (pid ...) is killed by signal: Bus error——经典签名就是 /dev/shm 满)。陷阱二:Redis 进 tmpfs 的双重内存。把 Redis 的 RDB/AOF 或数据目录放进 tmpfs:Redis 自己的键值数据占一份内存(工作集),tmpfs 里的持久化文件再占一份——同一份数据在 RAM 里存在两份副本,内存用量接近翻倍;若还开了 AOF 每秒刷盘,tmpfs 的页频繁变脏再换出,IO 反而更重。tmpfs 对 Redis 的正确用法只有一种:放没有持久化语义的临时产物(如临时脚本缓存),且 size 显式收小;数据持久化文件该去的地方是磁盘(fsync 语义在 tmpfs 上是空操作——数据本来就在内存,「fsync 了」不代表任何持久保证,这是 tmpfs 用于持久化语义文件的另一个隐蔽错误:程序以为 fsync 落盘了,实际只是把内存里的页数了个数)。陷阱三:swap 换出后的延迟尖刺。/dev/shm 的大对象在内存压力下被换出到磁盘 swap,进程随后访问共享内存时逐页换回——每次缺页 60μs~10ms(随介质),一次扫过 100MB 共享内存的机器学习训练循环能产生秒级停顿。缓解思路按序:给 tmpfs 配额留足余量(size 不超过 RAM 的可让出部分)、对热数据用 mlock 钉住(需 CAP_IPC_LOCK 与 ulimit 的 memlock 配额——注意 mlock 又与 OOM 风险互搏,钉多少要算)、或者干脆承认「这段数据就是工作集」,按工作集大小规划内存而不是依赖换出。

五、观测与治理:三个口径对齐

tmpfs 的观测要把三个口径对齐才不出错:df 的 Used(tmpfs 内文件占用,只算驻留内存页)、free 的 shared(shmem 总量,含换出部分)、cgroup 的 memory.stat 里的 shmem(容器视角的 tmpfs 占用,计入容器内存 limit——这是容器场景最重要的一点:tmpfs 写入会消耗容器的 cgroup 内存配额,emptyDir Memory 写爆了不是报 ENOSPC 就是触发 cgroup OOM kill,kubectl describe pod 里看到的 "OOMKilled" 一部分根因在这里)。治理动作三条:一是配额显式化——所有 tmpfs 挂载点(/tmp、/dev/shm、应用自挂)都写明 size,不给默认值留机会;二是清理策略——tmpfs 文件不会自动消失,长驻进程的临时目录要配 systemd 的 tmpfiles.d 规则(R /tmp/myapp - - - - 按期清理)或应用内 TTL;三是容量规划——按「工作集 + 共享内存需求 + 预留」计算 size,把它当成内存预算的一部分写进容量文档,而不是当成「免费的快盘」。tmpfs 的正确心智模型一句话:它是把匿名页包装成文件系统语义的适配层——文件系统的一切直觉(持久性、fsync、空间回收)都不能照搬进来,内存管理的一切直觉(配额、换出、cgroup 计费)全部照搬进来。

小结

tmpfs 的双重身份决定了它的全部特性与全部陷阱:它提供完整文件系统语义,但每个数据页都是匿名内存——惰性分配(mount 不占内存,写多少占多少)、可换出到 swap(回收有退路)、计入 cgroup 内存配额(容器里写 tmpfs 就是写内存预算)、重启即清空(临时语义)。它与 ramfs 的分野在配额护栏:tmpfs 超限返回 ENOSPC(应用可控),ramfs 无限占用(系统崩溃),后者只该存在于 initramfs。/dev/shm 是 POSIX 共享内存的实现载体——PostgreSQL、Chrome、PyTorch DataLoader 的容器故障都是 64MB 默认配额的历史包袱,解法是 --shm-size 与 emptyDir Memory + sizeLimit。三个陷阱的机制分别是:ENOSPC(配额)与 OOMKilled(cgroup 计费)、双重内存与 fsync 空操作(同一数据两份副本、持久化语义失效)、swap 换出延迟(大共享内存被逐页换回)。观测要 df/shared/cgroup 三个口径对齐,治理三板斧是配额显式化、清理策略、容量规划。判断一个场景该不该用 tmpfs 的标准也在这双重身份里:数据「用完即弃」且高频小 IO,tmpfs 赚;数据有持久语义,tmpfs 不是加速器而是内存黑洞——把「文件系统直觉」挡在门外,才能安全地用它。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?