找回密码
 立即注册
首页 业界区 业界 POSIX兼容系统上read和write系统调用的行为总结 ...

POSIX兼容系统上read和write系统调用的行为总结

墨淳雅 2025-11-10 06:50:02
关于UNIX和Linux的宣传语中,一切皆文件应该是最广为人知的一句。
不管是普通文件,还是硬件设备、管道、网络套接字,在Linux甚至还有信号和定时器都共享一套相似的api,大家可以用类似的代码完成各种不同的任务,大大简化了代码复杂度和学习成本。
当然这只是理想中的情况,现实是普通文件和硬件设备是两种完全不同的东西,普通文件和网络套接字尤其是UDP协议的那种更是风马牛不相及,强行把这些行为属性完全不同的事物整合进同一套api,导致了read/write/send/recv这几个系统调用的行为极其复杂,bug丛生,更是给很多新手带来了无尽的困扰。
而且由于系统差异和资料分散,这类问题就连求助于AI都很难得到有效解决。这也是我写这篇文章的原因。
进入正题之前我们先限定一下讨论范围和实验环境,因为这个主题太复杂了包罗万象是不可能的。
讨论范围:行为会基于POSIX 2008这版标准进行讨论,但也会加入一下Linux和macOS上特有的行为,这些会特别标注。
实验环境:Linux环境内核版本高于4.0即可,macOS 15及以上。
基础回顾之部分读部分写

有一些重要的概念会贯穿整个我们对系统调用行为的讨论,这里必须先介绍一下。
我们先来看看接下来要说的系统调用长什么样:
  1. #include <sys/types.h>
  2. #include <unistd.h>
  3. // 从文件描述符里读数据
  4. ssize_t read(int fd, void *buf, size_t nbyte);
  5. // 向文件描述符里写数据
  6. ssize_t write(int fd, const void *buf, size_t nbyte);
  7. // 从套接字中读取数据,不可用于套接字以外
  8. ssize_t recv(int sockfd, void *buf, size_t nbyte, int flags);
  9. // 向套接字写入数据,不可用于套接字以外
  10. ssize_t send(int sockfd, const void *buf, size_t nbyte, int flags);
复制代码
他们长得很像,核心逻辑也差不多——围绕一块nbyte长度的缓冲区进行操作,把数据从缓冲区写入描述符,或者从描述符里读取数据填进缓冲区。这些系统调用是文件和网络io的核心。
通常读取类的系统调用会尽可能多地读入数据直到填满缓冲区,而写入类的系统调用则会尽可能把缓冲区里所有的数据写入描述符。
然而现实是POSIX除了少数操作之外并没有规定读写操作不能被打断,因此经常会出现读或者写了一半时操作被中断的情况:

  • 进程收到了信号,导致系统调用中断,当然一部分系统会在中断后自动重启系统调用,但这个行为是可配置且有系统差异的,所以我们不能忽略这种中断场景
  • 网络套接字的缓冲区中只有少量数据可读/少量空间可写,系统调用在一些情况下中止并返回
  • 读写中遇到错误,比如网络中断、硬盘故障等
这些情况会导致缓冲区里的数据只有一部分被写入目标或者只从目标中读取了一部分数据没能填满缓冲区,简单的说就是调用返回的值比nbyte小且没有设置errno,我们把这些情况统一叫做部分读和部分写,英文叫short read/write或者partial read/write。
这不是bug,而是需要处理的正常的系统行为,尤其是在非阻塞io中。不同类型的对象在这方面有很大的行为差异,这也是本文下面要讨论的内容。
普通文件上的读写行为

普通文件是指在你硬盘里的那些文本文件、程序代码、音乐、图片、视频、PPT之类的东西。这些统称regular files。
普通文件上没有非阻塞io,且无法被poll、select监听。bsd系统上的kqueue对普通文件做了扩展,但这不属于POSIX规范且超出了讨论范围,我们就不提了。
虽然普通文件特性少,也因此read和write在它们上的行为更直观,也更符合预期。
read的行为:

  • 几乎总是阻塞到填满缓冲区
  • 文件可读取内容比缓冲区小的时候会把文件中剩下可读的数据全部读取,然后返回,这是返回值小于缓冲区大小
  • 读取过程中可以被中断
  • 如果读取出错了,则返回值是-1,errno会被设置,缓冲区里很可能会有垃圾数据
  • 如果返回0(EOF,end-of-file),说明文件所有内容已经读取完毕,这也是正常情况,errno不会被设置
从POSIX标准和Linux的文档上来看,read是会有部分读存在的,然而标准是标准实现是实现,现实情况是不管是macOS的APFS上还是Linux上常见的文件系统,read一但准备工作完成就不可被信号中断,因此部分读无法发生。
以Linux为例,所有想利用page cache的文件系统在进行文件读写时都会调用filemap_read,这个函数会接着调用filemap_get_pages,filemap_get_pages里有完成读取的主要逻辑,而在它的最开头处,初始化完所有资源就会调用fatal_signal_pending,fatal_signal_pending会让当前线程屏蔽包括SIGKILL在内的所有信号。
这意味着一但read开始,就会忽略所有信号,read也就不可能存在读取一部分数据后被中断的场景。这么做当然是为了数据一致性和安全考虑,虽然代价是和标准有了小小的冲突,但也无可厚非。
想要测试也很简单,准备一个1GB的文件,然后一个线程每次读写1MB,并且让另一个线程不停发信号,理论上下面这段代码不应该看到有“Short Read”的输出:
[code]#include #include #include #include #include #include #include #include std::atomic sigint_count{0};void handle_sigint(int signo) {    if (signo == SIGINT) {        sigint_count++;    }}int main() {    struct sigaction sa;    memset(&sa, 0, sizeof(sa));    sa.sa_handler = handle_sigint;    sigemptyset(&sa.sa_mask);    sa.sa_flags = 0; // 不设置SA_RESTART,这会禁止系统调用自动重启    if (sigaction(SIGINT, &sa, nullptr) == -1) {        perror("sigaction");        return 1;    }    int fd = open("test.data", O_RDONLY); // 1GB    if (fd < 0) {        perror("open");        return 1;    }    pid_t pid = getpid();    // 每隔100ns就发一次信号    std::thread([pid]() {        while (true) {            kill(pid, SIGINT);            std::this_thread::sleep_for(std::chrono::nanoseconds(100));        }    }).detach();    const size_t buffer_size = 1024 * 1024; // 1MB    char* buffer = new char[buffer_size];    ssize_t bytes_read;    while (sigint_count.load() 0) {        if (bytes_read != 1024*1024) {                std::cout

相关推荐

您需要登录后才可以回帖 登录 | 立即注册