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

关于UNIX和Linux的宣传语中,一切皆文件应该是最广为人知的一句。

不管是普通文件,还是硬件设备、管道、网络套接字,在Linux甚至还有信号和定时器都共享一套相似的api,大家可以用类似的代码完成各种不同的任务,大大简化了代码复杂度和学习成本。

当然这只是理想中的情况,现实是普通文件和硬件设备是两种完全不同的东西,普通文件和网络套接字尤其是UDP协议的那种更是风马牛不相及,强行把这些行为属性完全不同的事物整合进同一套api,导致了read/write/send/recv这几个系统调用的行为极其复杂,bug丛生,更是给很多新手带来了无尽的困扰。

而且由于系统差异和资料分散,这类问题就连求助于AI都很难得到有效解决。这也是我写这篇文章的原因。

进入正题之前我们先限定一下讨论范围和实验环境,因为这个主题太复杂了包罗万象是不可能的。

讨论范围:行为会基于POSIX 2008这版标准进行讨论,但也会加入一下Linux和macOS上特有的行为,这些会特别标注。

实验环境:Linux环境内核版本高于4.0即可,macOS 15及以上。

基础回顾之部分读部分写

有一些重要的概念会贯穿整个我们对系统调用行为的讨论,这里必须先介绍一下。

我们先来看看接下来要说的系统调用长什么样:

c 复制代码
#include <sys/types.h>
#include <unistd.h>

// 从文件描述符里读数据
ssize_t read(int fd, void *buf, size_t nbyte);

// 向文件描述符里写数据
ssize_t write(int fd, const void *buf, size_t nbyte);

// 从套接字中读取数据,不可用于套接字以外
ssize_t recv(int sockfd, void *buf, size_t nbyte, int flags);
// 向套接字写入数据,不可用于套接字以外
ssize_t send(int sockfd, const void *buf, size_t nbyte, int flags);

他们长得很像,核心逻辑也差不多------围绕一块nbyte长度的缓冲区进行操作,把数据从缓冲区写入描述符,或者从描述符里读取数据填进缓冲区。这些系统调用是文件和网络io的核心。

通常读取类的系统调用会尽可能多地读入数据直到填满缓冲区,而写入类的系统调用则会尽可能把缓冲区里所有的数据写入描述符。

然而现实是POSIX除了少数操作之外并没有规定读写操作不能被打断,因此经常会出现读或者写了一半时操作被中断的情况:

  1. 进程收到了信号,导致系统调用中断,当然一部分系统会在中断后自动重启系统调用,但这个行为是可配置且有系统差异的,所以我们不能忽略这种中断场景
  2. 网络套接字的缓冲区中只有少量数据可读/少量空间可写,系统调用在一些情况下中止并返回
  3. 读写中遇到错误,比如网络中断、硬盘故障等

这些情况会导致缓冲区里的数据只有一部分被写入目标或者只从目标中读取了一部分数据没能填满缓冲区,简单的说就是调用返回的值比nbyte小且没有设置errno,我们把这些情况统一叫做部分读和部分写,英文叫short read/write或者partial read/write。

这不是bug,而是需要处理的正常的系统行为,尤其是在非阻塞io中。不同类型的对象在这方面有很大的行为差异,这也是本文下面要讨论的内容。

普通文件上的读写行为

普通文件是指在你硬盘里的那些文本文件、程序代码、音乐、图片、视频、PPT之类的东西。这些统称regular files。

普通文件上没有非阻塞io,且无法被poll、select监听。bsd系统上的kqueue对普通文件做了扩展,但这不属于POSIX规范且超出了讨论范围,我们就不提了。

虽然普通文件特性少,也因此read和write在它们上的行为更直观,也更符合预期。

read的行为:

  1. 几乎总是阻塞到填满缓冲区
  2. 文件可读取内容比缓冲区小的时候会把文件中剩下可读的数据全部读取,然后返回,这是返回值小于缓冲区大小
  3. 读取过程中可以被中断
  4. 如果读取出错了,则返回值是-1,errno会被设置,缓冲区里很可能会有垃圾数据
  5. 如果返回0(EOF,end-of-file),说明文件所有内容已经读取完毕,这也是正常情况,errno不会被设置

从POSIX标准和Linux的文档上来看,read是会有部分读存在的,然而标准是标准实现是实现,现实情况是不管是macOS的APFS上还是Linux上常见的文件系统,read一但准备工作完成就不可被信号中断,因此部分读无法发生。

以Linux为例,所有想利用page cache的文件系统在进行文件读写时都会调用filemap_read,这个函数会接着调用filemap_get_pagesfilemap_get_pages里有完成读取的主要逻辑,而在它的最开头处,初始化完所有资源就会调用fatal_signal_pendingfatal_signal_pending会让当前线程屏蔽包括SIGKILL在内的所有信号。

这意味着一但read开始,就会忽略所有信号,read也就不可能存在读取一部分数据后被中断的场景。这么做当然是为了数据一致性和安全考虑,虽然代价是和标准有了小小的冲突,但也无可厚非。

想要测试也很简单,准备一个1GB的文件,然后一个线程每次读写1MB,并且让另一个线程不停发信号,理论上下面这段代码不应该看到有"Short Read"的输出:

c++ 复制代码
#include <iostream>
#include <thread>
#include <atomic>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <chrono>

std::atomic<int> 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()<=1);
    while ((bytes_read = read(fd, buffer, buffer_size)) > 0) {
        if (bytes_read != 1024*1024) {
                std::cout << "Short Read: " << bytes_read << " bytes\n";
        }
    }

    if (bytes_read < 0) {
        perror("read");
    }

    close(fd);
    delete[] buffer;

    std::cout << "SIGINT received: " << sigint_count.load() << " times\n";

    return 0;
}

输出:

console 复制代码
$ g++-15 -Wall -Wextra -std=c++20 read.cpp
$ head -c 1073741824 /dev/random > test.data
$ ./a.out

SIGINT received: 1099 times

可以看到我们发送了1000多次信号,没有对read产生任何影响。

说完了read说说write。

write在普通文件上的行为:

  1. 正常情况下阻塞到buff全部写入文件
  2. 出错的时候直接返回-1,比如磁盘空间不够(一个字节都写不进去)、没有写入权限等,并设置对应的errno。
  3. 可以被信号中断,这时会发生部分写
  4. 如果信号在任何数据实际写入之前收到,write返回-1并且设置errno为EINTR。
  5. 如果磁盘的空间不够或者进程有写入配额限制,则发生部分写入,还有多少空间就写入多少,write在本次写入后正常返回

我们可以轻松得用ulimit来限制进程可写入的文件大小并模拟磁盘空间不够的情况:

c++ 复制代码
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>

int main() {
    int fd = open("test.data", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    const size_t chunk_size = 12345;
    const size_t total_writes = 10000;
    char buffer[chunk_size];
    memset(buffer, 'c', chunk_size);

    for (size_t i = 0; i < total_writes; ++i) {
        ssize_t written = write(fd, buffer, chunk_size);
        if (written < 0) {
            perror("write");
            break;
        }
        if (written != chunk_size) {
            std::cout << "Short Write: " << written << " bytes\n";
            std::cout << "Count of write: " << i << "\n";
        }
    }

    close(fd);

    return 0;
}

运行:

console 复制代码
$ ulimit -f 102400
$ ./a.out
Short Write: 11515 bytes
Count of write: 8493
core dump‰./a.out

可以看到最后一次写入已经没有足够的空间写入12345字节了,所以只写入了11515字节。真实的磁盘耗尽会在部分写入发生后下一次write理论上应该返回-1并设置errno,但ulimit模拟的会直接杀死进程,因为POSIX要求在这种情况下发送信号SIGXFSZ给进程,这个信号的默认处理行为的进程崩溃。

和read一样,对于普通文件的write在准备工作完成后也会屏蔽掉所有信号,这使得在macOS和Linux普通文件的写也不会发生部分写入。

对于普通文件,write还有一个特殊行为:如果第三个参数是0,则不写入任何数据,但会探测写入操作是否会出错,比如硬盘挂掉了或者权限不够。

总结:对于普通文件,macOS和Linux上的read/write总是会读取/写入和buf长度相等的数据。

不对普通文件处理部分读和部分写正在成为越来越多人的共识,毕竟代码写起来简单。但标准留了口子,为了可移植性开发者最好还是不要对此做出过多假设为好。

管道上的读写行为

管道大家应该不陌生,POSIX规定了两种类型的管道pipe和FIFO。

pipe是匿名管道,FIFO是有名字的且需要在支持管道文件的文件系统上生成一个对应的实体。除此之外两者行为上没有差别。所以这一节两者合并在一起讨论。

管道的读写行为和普通文件不同,也和下一节要说的套接字有些出入,所以需要单独拿出来作为一节内容。

管道有一个固定的总容量,超过此容量的数据无法继续写入。管道也支持非阻塞io。管道上的读写行为还受到读写端是否开启的影响,所以整体上管道的复杂度比普通文件高了至少一个数量级。

先总结一下读的行为:

条件 read行为 read返回值 errno 是否是部分读
写端的管道被关闭 遇到EOF 0 不设置
同步io,管道里没有数据 阻塞到有数据能读为止,但有多少读多少,不要求填满buf,可被中断 <= buf长度 不设置
非阻塞io,管道中没有数据 直接出错 -1 EAGAIN
同步io,管道里有数据 不阻塞,有多少读多少,不要求填满buf,可被中断 <= buf长度 不设置
非阻塞io,管道中有数据 不阻塞,有多少读多少,不要求填满buf,可被中断 <= buf长度 不设置
任何模式下,读取开始前被信号中断 直接出错 -1 EINTR
任何模式下,读取了部分数据,被信号中断 正常返回 <= buf长度 不设置

最后一种情况其实涵盖在第四和第五中了,但我还是单独列出方便大家理解。

简单地说,管道的读大部分都是部分读,管道里有多少数据就读多少,唯一会发生阻塞的场景是管道里一点数据都没有的时候。

看个例子就知道了,管道里只有11个字节数据,我们的读取buf有1024长,但和读普通文件不一样,read读完11字节就正常返回:

c 复制代码
#define _GNU_SOURCE // for pipe2
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main()
{
    int pair[2] = {-1, -1};
    if (pipe2(pair, 0) < 0) { // 没有设置任何标志,默认是同步io
        perror("pipe2");
        return 1;
    }

    if (write(pair[1], "Hello World", 11) < 0) {
        perror("write");
        return 1;
    }

    char buf[1024] = {0};
    int n = read(pair[0], buf, 1024);
    if (n < 0) {
        perror("read");
        return 1;
    }
    printf("read %d bytes\n", n); // output: read 11 bytes

    // read(pair[0], buf, 1024);
    // 已经没数据了,同步io下程序会阻塞在这,非阻塞io下返回-1,errno被设置为EAGAIN
}

写入的行为则比读取要复杂的多,会同时受到管道容量、原子写、信号的影响。

原子写是管道特有的概念:任何大小小于等于PIPE_BUF大小的写操作都是原子的,要么全部写入要么彻底失败,且不可被中断。

PIPE_BUF的值在不同系统上也是不同的,在macOS上是512字节,而在Linux上是4096。管道的总容量也是一样的,在Linux是16个page size大小,而且容量可以手动修改,在macOS管道默认大小16kb,但可以扩展到64kb。

写入行为总结:

条件 write行为 write返回值 errno 是否是部分写
读取端关闭 直接出错 -1 EPIPE
同步io,原子写,管道中容量足够容纳所有内容 不阻塞,原子地写入所有数据,不可中断 len(buf) 不设置
同步io,原子写,管道没有容量或者容量不足以容纳所有内容 阻塞到所有内容可被写入为止,不可中断 len(buf) 不设置
非阻塞io,原子写,管道中容量足够容纳所有内容 不阻塞,原子地写入所有数据,不可中断 len(buf) 不设置
非阻塞io,原子写,管道没有容量或者容量不足以容纳所有内容 不阻塞,直接出错 -1 EAGAIN
同步io,普通写,管道中容量足够容纳所有内容 不阻塞,写入所有数据,可中断 <= len(buf) 不设置
同步io,普通写,管道没有容量或者容量不足以容纳所有内容 阻塞到所有内容可都写入为止,可中断 <= len(buf) 不设置
非阻塞io,普通写,管道中容量足够容纳所有内容 不阻塞,写入所有数据,可中断 <= len(buf) 不设置
非阻塞io,普通写,管道没有任何容量 不阻塞,直接出错 -1 EAGAIN
非阻塞io,普通写,管道容量不足以写入所有数据 不阻塞,有多少写入多少,可中断 <= len(buf) 不设置
任何模式,写入没开始前被信号中断 直接出错 -1 EINTR

可以看到部分写主要发生在非原子写的情况下。看一个非阻塞io时容量不够导致部分写的例子:

c 复制代码
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

#define PIPE_MAX (4096*16)
#ifdef __linux__
// #include <linux/limits.h>
#define PIPE_BUF 4096
#else
#define PIPE_BUF 512
#endif


int main()
{
    int pair[2] = {-1, -1};
    if (pipe(pair) < 0) {
        perror("pipe2");
        return 1;
    }
    // 设置为非阻塞io,macOS不支持pipe2,为了跨平台只能用这种原始办法
    if (fcntl(pair[0], F_SETFL, fcntl(pair[0], F_GETFL) | O_NONBLOCK) < 0) {
        perror("fcntl pair[0]");
        return 1;
    }
    if (fcntl(pair[1], F_SETFL, fcntl(pair[1], F_GETFL) | O_NONBLOCK) < 0) {
        perror("fcntl pair[1]");
        return 1;
    }

    // 非原子写入,因此发生部分写
    char buf[PIPE_MAX-1] = {0};
    memset(buf, 'c', sizeof(buf));
    if (write(pair[1], buf, PIPE_MAX-1) != PIPE_MAX-1) {
        printf("this can not be a short write\n");
        return 1;
    }
    char new_buf[PIPE_BUF+1] = {0};
    int n = write(pair[1], new_buf, PIPE_BUF+1);
    if (n < 0) {
        perror("write");
        return 1;
    }
    printf("short %d bytes\n", n);

    if (read(pair[0], read_buf, PIPE_BUF-100) != PIPE_BUF-100) {
        printf("this can not be a short read\n");
        return 1;
    }

    // 原子写入会立即失败
    char atomic_buf[PIPE_BUF];
    memset(atomic_buf, 'c', PIPE_BUF);
    n = write(pair[1], atomic_buf, PIPE_BUF);
    if (n < 0) {
        perror("atomic write failed");
        return 0;
    } else {
        printf("no way!\n");
        return 1;
    }
}

程序首先写入数据,只留一字节给管道,然后非阻塞写入一个比原子写限制大一字节的数据,这时候程序就会发生部分写,只写入一字节。如果这里是同步io的话程序则会阻塞住直到剩下的所有数据都能写入。接着我们把读取PIPE_BUF-100的数据,现在管道的容量只有PIPE_BUF-100字节,然后又再往管道里原子写入PIPE_BUF长度的数据,这一步应该直接失败。

运行结果:

console 复制代码
$ ./a.out

short 1 bytes
atomic write failed: Resource temporarily unavailable

输出中的Resource temporarily unavailable就是EAGAIN的文字描述。可见即使还有空间,只要不能容纳下原子写入要求的全部数据,就会立即失败。

总结:尽量每次读写管道都使用PIPE_BUF大小的buf可以免去很多麻烦,但我还是建议每次读写之后检查返回值和errno,以免发生问题,毕竟读写加起来差不多有20种情况存在了。这也是APUE这本书推荐的做法。

有一点需要注意,POSIX规定了所有errno被设置成EPIPE的场景,进程都会收到SIGPIPE,这个信号默认行为会导致进程崩溃。但这个信号并不意味着程序发生了无法挽回的错误,所以常见的做法是彻底屏蔽它然后检查write调用的返回值和errno。

UDP协议套接字上的读写行为

终于来到最复杂的套接字了,这里说的套接字包含网络类型为INETUDS这两种,尽管他们的实现完全不同处理数据的方式也大相径庭,但在readwritesendrecv这些系统调用上的行为是一样的。

POSIX规定readwrite如果操作对象是socket,那么效果等同于调用recvsend。所以在socket的两节里我们只讨论recvsend

对UDP套接字的操作是比较简单的,每次recv和send都会读取/发送一个UDP数据报,而且这个操作是原子的不可中断。

这意味send会把buf中所有东西全部写入后才会成功返回,而且写入一但开始就不可被中断。所以不存在部分写。

而recv则会尽量把下一个待读取的数据报全部读入缓冲区,如果数据报的大小超过缓冲区大小,则会截断,截断之后数据报剩余的数据会被全部丢弃,recv在截断时也会正常返回。recv同样一但开始读取就不可中断,所以不存在部分读。

UDP有读写缓冲区的概念,这会影响它在不同io模式下的行为:

  1. 如果读缓冲区是空的,同步io时recv会一直阻塞到有数据进来才返回;非阻塞io下则直接报错并设置EAGAIN
  2. 如果读缓冲区有数据,不管什么模式下都会立即读取一个数据报并返回
  3. 如果发送缓冲区是满的,同步io时send会一直阻塞到所有数据都能写入为止;而非阻塞下会直接报错并设置EAGAIN
  4. 如果发送缓冲区有空间但不足以写入所有内容,同步io的send会阻塞到缓冲区有足够空间,然后一次性写入所有内容;非阻塞io时则直接报错并设置EAGAIN
  5. 如果发送缓冲区有空间写入所有数据,则任意模式都不会阻塞,会立即把所有数据写入并返回
  6. 向没有服务监听的地址端口写数据并不会发生错误,这是udp协议的特性,除非你把套接字的对端地址进行了绑定

总体UDP很简单没有部分读写问题,只有数据截断需要特别注意。这在后文会讲。

TCP协议套接字上的读写行为

TCP是这些总结里面最复杂的,因为它受io模式和信号影响,同时也有读写缓冲区的概念,并且TCP是面向连接的协议,连接状态还会额外影响读写的行为。

场景实在太多,用文字描述会非常费篇幅,因此我们直接上表格:

io模式 读缓冲区状态 连接状态 recv行为 是否能被中断 recv返回值 errno 是否是部分读
同步 缓冲区空 正常连接 阻塞到有数据为止,然后尽可能多读取信息,直到缓冲区里没数据或者buf填满 可中断 <= len(buf) 不设置
同步 缓冲区有数据或者满 正常连接 不阻塞,尽可能多读取信息,直到缓冲区里没数据或者buf填满 可中断 <= len(buf) 不设置
同步 缓冲区空 连接已经关闭 不阻塞,直接返回 可中断 0 不设置
同步 缓冲区有数据或者满 连接已经关闭 不阻塞,尽可能多读取信息,直到缓冲区里没数据或者buf填满 可中断 <= len(buf) 不设置
非阻塞 缓冲区空 正常连接 直接出错 可中断 -1 EAGAIN
非阻塞 缓冲区有数据或者满 正常连接 不阻塞,尽可能多读取信息,直到缓冲区里没数据或者buf填满 可中断 <= len(buf) 不设置
非阻塞 缓冲区空 连接已经关闭 不阻塞,直接返回 可中断 0 不设置
非阻塞 缓冲区有数据或者满 连接已经关闭 不阻塞,尽可能多读取信息,直到缓冲区里没数据或者buf填满 可中断 <= len(buf) 不设置
任意 缓冲区空 连接异常终止收到RST 直接出错 可中断 -1 ECONNRESET
任意 缓冲区有数据或者满 连接异常终止收到RST 不阻塞,尽可能多读取信息,直到缓冲区里没数据或者buf填满 可中断 <= len(buf) 不设置
任意 任意 本地close了socket,然后继续调用recv 直接出错 可中断 -1 EBADF

recv返回0(EOF)说明所有的数据都已经被读取,连接的生命周期也应该正常结束了。

由此可见,除了部分异常情况,TCP下几乎所有的读都是部分读而且可被信号中断,因此必须去检查recv的返回值并做处理。

写入时的情况类似:

io模式 写缓冲区状态 连接状态 send行为 是否能被中断 send返回值 errno 是否是部分写
同步 缓冲区有足够空间写入全部数据 正常连接 不阻塞,写入全部数据 可中断 <= len(buf) 不设置
同步 缓冲区有空间但不能写入全部数据或者满 正常连接 先写入数据,然后阻塞到缓冲区有空间,接着写入,循环往复直到全部写入 可中断 <= len(buf) 不设置
非阻塞 缓冲区有足够空间写入全部数据 正常连接 不阻塞,写入全部数据 可中断 <= len(buf) 不设置
非阻塞 缓冲区有空间但不能写入全部数据 正常连接 不阻塞,尽可能多写入然后返回 可中断 < len(buf) 不设置
非阻塞 缓冲区满 正常连接 直接出错 可中断 -1 EAGAIN
任意 任意 连接已经关闭 直接出错 可中断 -1 EPIPE
任意 任意 连接异常终止收到RST 直接出错 可中断 -1 ECONNRESET
任意 任意 本地close了socket,然后继续调用send 直接出错 可中断 -1 EBADF

send要简单一些,因为它对连接状态的要求更为严格。同步io下send会尽量发生全部数据,但会被信号中断;非阻塞io下则是能写多少是多少,几乎都是部分写。

所以针对tcp必须检查所有读写操作的返回值和errno,这也是为什么UNP这本网络编程的名著会在头两章就给出下面这样的帮助函数:

c 复制代码
/* Like write(), but retries in case of partial write */
ssize_t writen(int fd, const void *buf, size_t count)
{
	size_t n = 0;
	while (count > 0) {
		int r = write(fd, buf, count);
		if (r < 0) {
			if (errno == EINTR)
				continue;
			return r;
		}
		if (r == 0)
			return n;
		buf = (const char *)buf + r;
		count -= r;
		n += r;
	}
	return n;
}

这样的检查和处理逻辑每次都写一遍代码很快就能进化成屎山了,所以作者给出了这个函数,而且这种帮助函数在c/c++项目中很常见。

不同读写行为导致的问题

不同的读写行为经常会带来心智负担,最后在代码里留下问题。

比如前文中提到的UDP数据截断问题。我刚入行的时候就被坑过,当时我写的代码在解析一些特定种类的数据报信息时算错了数据长度,导致读取用的缓冲区设置小了,这些种类的数据报都被截断了。然而recvrecvFrom都不会报告截断问题还会正常返回,这导致调试过程异常艰难,最后还是有经验的前辈和我一起抓包对比接收到的数据才发现发送的数据比接收的大,这才想到了是recv截断数据的问题。

当然recvmsgrecvmmsg这两个系统调用会在返回的结构体的flags字段里设置MSG_TRUNC标志来表示数据报被截断,但相对来说recv和read因为接口更简单所以大多数人优先选择使用它们,我也不例外。

除了上面的UDP数据截断问题,部分写入也会出问题,比如不检查返回值导致需要的数据没有全部写入,这种问题在新接触TCP编程的人的代码里很常见。

不过物极必反,有时候太谨慎也不好,比如最近我在审查golang的代码时发现有人把上一节的writen函数搬到go里了,最后搞出了下面这样的代码:

golang 复制代码
func (c *Client) sendData(data []byte) error {
    header := 从data里生成header
    if err := util.WriteAll(c.tcpConn, header); err != nil {
        return err
    }
    if err := util.WriteAll(c.tcpConn, data); err != nil {
        return err
    }
    return nil
}

代码看起来很清晰,开发者还想到了TCP的部分写入问题,简直无可挑剔啊。

然而这是go语言,go对io做了很多封装,把异步非阻塞io操作封装成了同步操作,因此部分写问题被已经考虑到并且强制要求所有实现io.Writer接口的类型保证不出现部分写的:

console 复制代码
$ go doc io.Writer

package io // import "io"

type Writer interface {
        Write(p []byte) (n int, err error)
}
    Writer is the interface that wraps the basic Write method.

    Write writes len(p) bytes from p to the underlying data stream. It returns
    the number of bytes written from p (0 <= n <= len(p)) and any error
    encountered that caused the write to stop early. Write must return a non-nil
    error if it returns n < len(p). Write must not modify the slice data,
    even temporarily.

    Implementations must not retain p.

其中Write must return a non-nil error if it returns n < len(p).就说明了,如果写操作没有出错,则数据必须全部写入,因此没有部分写问题。

实际上net.TCPConn也是这么做的:

golang 复制代码
func (fd *FD) Write(p []byte) (int, error) {
	...
	var nn int
	for {
		max := len(p)
		if fd.IsStream && max-nn > maxRW {
			max = nn + maxRW
		}
		n, err := ignoringEINTRIO(syscall.Write, fd.Sysfd, p[nn:max])
		if n > 0 {
			if n > max-nn {
				// This can reportedly happen when using
				// some VPN software. Issue #61060.
				// If we don't check this we will panic
				// with slice bounds out of range.
				// Use a more informative panic.
				panic("invalid return from write: got " + itoa.Itoa(n) + " from a write of " + itoa.Itoa(max-nn))
			}
			nn += n
		}
		if nn == len(p) {
			return nn, err
		}
		if err == syscall.EAGAIN && fd.pd.pollable() {
			if err = fd.pd.waitWrite(fd.isFile); err == nil {
				continue
			}
		}
		if err != nil {
			return nn, err
		}
		if n == 0 {
			return nn, io.ErrUnexpectedEOF
		}
	}
}

这是一个非阻塞版本的writen,在收到EAGAIN的时候会调用poll之类的系统调用来等待文件描述符可写。所以util.WriteAll是完全多余的并会成为性能杀手。

golang这么做很正常,因为在同步io模式下,除了被信号中断,几乎所有的写入操作都是保证buf里的数据全部写入才返回给调用者的,模拟同步io的go没有不这样做的理由。另一个原因是这样可以减轻开发者的心智负担。当然如果一个第三方库没有按要求处理部分写,那就会引发新的问题了,但这属于是第三方库的责任。

总结

这篇文章总结了常见对象上read/write系统调用的行为,对于日常的Linux/Unix程序开发来说足够了。

然而还有很多东西没被覆盖,比如Linux上的signalfd和timerfd,比如虚拟文件系统和设备文件,在这些资源上io相关的系统调用又是另一种情况了。尤其是虚拟文件系统,稍有不慎就会出错,因此我准备另写一篇文章。对于这些POSIX中未明确定义的或者操作系统特有的对象,开发者只能自己去找相关的文档看了。

虽然我总结的只有readwrite,但整个函数族的行为是一致的,只有很微小的区别,比如read的行为对于preadreadv也都适用,write的总结也对writevpwrite等适用。

这就是一切皆文件的真相,把不相干的东西杂糅在一起除了会给使用者带来麻烦之外并不能带来多少收益,这也时刻提醒着所有开发者不要为了抽象而抽象,抽象和设计始终是为使用服务的。

参考

https://pubs.opengroup.org/onlinepubs/9699919799/functions/

相关推荐
暴风鱼划水2 小时前
算法题(Python)数组篇 | 6.区间和
python·算法·数组·区间和
No0d1es2 小时前
2025年第十六届蓝桥杯青少组省赛 C++编程 中级组真题
c++·青少年编程·蓝桥杯·省赛
童话ing2 小时前
【Golang】常见数据结构原理剖析
数据结构·golang
Derrick__12 小时前
Web Js逆向——加密参数定位方法(Hook)
python·js
千禧皓月2 小时前
【C++】基于C++的RPC分布式网络通信框架(二)
c++·分布式·rpc
森语林溪2 小时前
大数据环境搭建从零开始(十四)CentOS 7 系统更新源更换详解:阿里云镜像源配置完整指南
大数据·linux·运维·阿里云·centos
是苏浙2 小时前
零基础入门C语言之C语言实现数据结构之顺序表应用
c语言·数据结构·算法
南汐汐月2 小时前
重生归来,我要成功 Python 高手--day33 决策树
开发语言·python·决策树
AA陈超3 小时前
虚幻引擎5 GAS开发俯视角RPG游戏 P07-08 点击移动
c++·游戏·ue5·游戏引擎·虚幻