Skip to content

流式与块式存取

Linux 把设备分为两大类:字符设备和块设备。这个划分不取决于设备的物理形态,而取决于访问语义——字符设备提供流式存取(按字节顺序读写),块设备提供块式存取(按固定大小块随机读写)。两种语义背后是两套不同的内核基础设施:字符设备走文件操作直接进驱动,块设备经过页缓存、IO 调度器和请求队列的完整链路。

流式存取:字符设备

字符设备的核心语义是"字节流":数据按顺序到达和处理,没有"位置"和"长度"的随机访问概念。终端(/dev/tty)、串口(/dev/ttyS0)、随机数发生器(/dev/urandom)、null 设备(/dev/null)都是字符设备——读操作消耗数据流,写操作追加数据流,不存在"改写中间某个字节"的操作。

字符设备的实现模型是 struct file_operations——驱动的每个系统调用对应一个函数指针:read/write/ioctl/mmap/poll。用户态的 read(fd, buf, n) 直接穿过 VFS 调用驱动的 read 函数,中间没有缓存层、没有调度器——数据从驱动到用户缓冲区的路径极短。这带来低延迟,也带来责任:驱动必须自己处理一切——缓冲区管理、并发访问、非阻塞语义(EAGAIN)、就绪通知(poll)。

流式存取最典型的工程场景是"无限数据流"——终端输入、网络包流、音频设备、传感器读数。这些数据的共同特征是没有固定的"总长度"和"寻址"概念,read 的语义是"给我接下来可用的数据"而非"给我偏移 X 处的数据"。非阻塞 IO(O_NONBLOCK)和多路复用(poll/select/epoll)天然匹配这种语义。

块式存取:块设备

块设备的核心语义是"定长块的随机访问":数据以固定大小的块(512B 或 4KB)组织,支持任意顺序的读写定位。硬盘、SSD、NVMe 都是块设备。pread(fd, buf, n, offset) 可以读取任意偏移处的数据——与之前的读写顺序无关。

块设备的实现模型完全不同:读写在到达驱动之前要经过页缓存(Page Cache)、IO 调度器和请求队列三层基础设施。写入先落在页缓存中标记为脏页,由内核在合适的时机(回写线程、脏页比例阈值)统一刷入设备;读取命中缓存则完全不走设备。IO 调度器将来自多个进程的请求合并(相邻请求合并为一个大请求)和排序(旋转盘按扇区位置排序减少寻道)后提交给驱动。

这条链路的收益是吞吐量和全局优化——多个进程的随机小 IO 在设备层被合并为大 IO;代价是延迟不可控——写入完成(write 返回)不等于数据落盘(只落在缓存),数据的持久性由回写策略保证而非系统调用语义保证。O_DIRECT 打开的文件绕过页缓存直接访问设备,代价是失去缓存收益,且要求对齐的缓冲区(通常是 512B 对齐)。

两种语义的工程选择

流式和块式的选择由数据的使用模式决定,而不是设备的物理属性——同一块磁盘上的文件,用 read 顺序读取就是流式使用,用 pread 随机定位就是块式使用。

文件服务(NFS、对象存储、静态资源服务器)是流式语义的典型用户——数据按顺序从头读到尾,页缓存的顺序预读(readahead)命中率极高。数据库是块式语义的典型用户——B+ 树页面分散在文件中,访问模式是"读第 1024 号页面,改其中 16 字节,写回"——页缓存在这里充当数据库自己的 buffer pool 的缓存层,两层的协作关系是数据库性能调优的经典话题(通常数据库选择 O_DIRECT 自管缓存以避免双重缓存)。

理解两种语义差异的关键收益在调优场景:一个"读文件很慢"的应用,先判断它是流式还是块式使用。流式慢通常是预读窗口太小(blockdev --setra 调大 readahead);块式慢通常是访问模式随机性太高导致缓存命中率低——此时调预读没有意义,需要的是更大的缓存或者重新设计数据布局(如列式存储、LSM 树把随机写变顺序写)。

与内核设施的关系

块设备的复杂链路(页缓存 → 调度器 → 请求队列)解释了为什么 io_uringO_DIRECT 在高性能场景有意义:它们绕过了部分中间层。也解释了 sendfile 为什么快:数据从页缓存直接到网络栈,不经过用户态缓冲区。字符设备的短链路解释了为什么终端和串口这类交互设备即使没有缓存也不影响体验——数据量小、延迟敏感、无复用需求。

两种存取模型的选择在系统设计中反复出现:日志是流式(append-only),数据库是块式(随机更新);管道是流式(顺序消耗),共享内存是块式(随机读写)。识别数据的存取模式,是选择内核设施和设计数据结构的第一步。