MSG_ZEROCOPY

简介

MSG_ZEROCOPY 标志允许对套接字发送调用避免复制。 该功能当前已为 TCP、UDP 和 VSOCK(使用 virtio 传输)套接字实现。

机遇和注意事项

在用户进程和内核之间复制大型缓冲区可能代价高昂。 Linux 支持各种避免复制的接口,例如 sendfile 和 splice。 MSG_ZEROCOPY 标志将底层复制避免机制扩展到常见的套接字发送调用。

避免复制不是免费的午餐。 按照目前的实现,使用页面锁定,它将每个字节的复制成本替换为页面记账和完成通知开销。 因此,MSG_ZEROCOPY 通常仅在超过 10 KB 的写入时有效。

页面锁定还会更改系统调用语义。 它暂时在进程和网络堆栈之间共享缓冲区。 与复制不同,进程不能在系统调用返回后立即覆盖缓冲区,否则可能会修改传输中的数据。 内核完整性不受影响,但有缺陷的程序可能会损坏其自身的数据流。

内核会在可以安全地修改数据时返回通知。 然后,将现有应用程序转换为 MSG_ZEROCOPY 并不总是像传递标志那么简单。

更多信息

本文档的大部分内容来自 netdev 2.1 上发表的一篇较长的论文。 有关更深入的信息,请参阅该论文和演讲,LWN.net 上出色的报告或阅读原始代码。

接口

传递 MSG_ZEROCOPY 标志是启用复制避免的最明显的步骤,但不是唯一的步骤。

套接字设置

当应用程序将未定义的标志传递给发送系统调用时,内核是允许的。 默认情况下,它只是忽略这些。 为了避免为意外已经传递此标志的旧进程启用复制避免模式,进程必须首先通过设置套接字选项来发出意图信号

if (setsockopt(fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof(one)))
        error(1, errno, "setsockopt zerocopy");

传输

发送(或 sendto、sendmsg、sendmmsg)本身的更改是微不足道的。 传递新标志。

ret = send(fd, buf, sizeof(buf), MSG_ZEROCOPY);

如果套接字超出其 optmem 限制或用户超出其锁定页面的 ulimit,则 zerocopy 失败将返回 -1,错误代码为 ENOBUFS。

混合避免复制和复制

许多工作负载混合了大型和小型缓冲区。 因为对于小数据包,避免复制比复制更昂贵,所以该功能实现为一个标志。 可以安全地混合使用带有标志和不带标志的调用。

通知

内核必须在可以安全地重用先前传递的缓冲区时通知进程。 它在套接字错误队列上排队完成通知,类似于传输时间戳接口。

通知本身是一个简单的标量值。 每个套接字维护一个内部无符号 32 位计数器。 每次使用 MSG_ZEROCOPY 成功发送数据的发送调用都会增加计数器。 如果调用失败或长度为零,则计数器不会递增。 计数器计算系统调用调用,而不是字节。 它在 UINT_MAX 调用后回绕。

通知接收

下面的代码片段演示了 API。 在最简单的情况下,每个发送系统调用之后是轮询和接收错误队列上的 recvmsg。

从错误队列读取始终是非阻塞操作。 轮询调用用于阻塞,直到出现错误。 它将在其输出标志中设置 POLLERR。 该标志不必在 events 字段中设置。 错误是无条件地发出信号。

pfd.fd = fd;
pfd.events = 0;
if (poll(&pfd, 1, -1) != 1 || pfd.revents & POLLERR == 0)
        error(1, errno, "poll");

ret = recvmsg(fd, &msg, MSG_ERRQUEUE);
if (ret == -1)
        error(1, errno, "recvmsg");

read_notification(msg);

该示例仅用于演示目的。 实际上,不等待通知,而是每隔几次发送调用进行非阻塞读取更有效。

通知可以与其他套接字上的操作乱序处理。 具有排队错误的套接字通常会阻止其他操作,直到读取错误。 但是,Zerocopy 通知具有零错误代码,以不阻止发送和接收调用。

通知批处理

可以使用 recvmmsg 调用一次读取多个未完成的数据包。 这通常不是必需的。 在每个消息中,内核返回的不是单个值,而是一个范围。 它在错误队列上合并连续的通知,同时为一个通知正在接收。

当要排队新的通知时,它会检查新值是否扩展了队列尾部通知的范围。 如果是这样,它会删除新的通知数据包,而是增加未完成通知的范围上限值。

对于按顺序确认数据的协议(如 TCP),每个通知都可以压缩到前一个通知中,因此在任何一个点都不会有多个通知未完成。

按顺序传递是常见情况,但不能保证。 通知可能会在重传和套接字拆卸时乱序到达。

通知解析

下面的代码片段演示了如何解析控制消息:上一个代码片段中的 read_notification() 调用。 通知以标准错误格式 sock_extended_err 编码。

控制数据中的 level 和 type 字段是协议族特定的,IP_RECVERR 或 IPV6_RECVERR(对于 TCP 或 UDP 套接字)。 对于 VSOCK 套接字,cmsg_level 将是 SOL_VSOCK,cmsg_type 将是 VSOCK_RECVERR。

错误来源是新类型 SO_EE_ORIGIN_ZEROCOPY。 ee_errno 为零,如前所述,以避免阻止套接字上的读取和写入系统调用。

32 位通知范围编码为 [ee_info, ee_data]。 此范围是包含的。 结构中的其他字段必须被视为未定义,ee_code 除外,如下所述。

struct sock_extended_err *serr;
struct cmsghdr *cm;

cm = CMSG_FIRSTHDR(msg);
if (cm->cmsg_level != SOL_IP &&
    cm->cmsg_type != IP_RECVERR)
        error(1, 0, "cmsg");

serr = (void *) CMSG_DATA(cm);
if (serr->ee_errno != 0 ||
    serr->ee_origin != SO_EE_ORIGIN_ZEROCOPY)
        error(1, 0, "serr");

printf("completed: %u..%u\n", serr->ee_info, serr->ee_data);

延迟复制

传递标志 MSG_ZEROCOPY 是内核应用复制避免的提示,以及内核将排队完成通知的合同。 它不能保证复制被省略。

避免复制并非总是可行的。 不支持分散-聚集 I/O 的设备无法发送由内核生成的协议头加上 zerocopy 用户数据组成的数据包。 可能需要将数据包转换为堆栈深处的私有数据副本,例如计算校验和。

在所有这些情况下,内核会在释放对共享页面的控制时返回完成通知。 该通知可能会在(复制的)数据完全传输之前到达。 因此,zerocopy 完成通知不是传输完成通知。

如果数据在缓存中不再是热数据,则延迟复制可能比系统调用中立即进行的复制更昂贵。 该过程还会产生通知处理成本,而没有任何好处。 因此,内核会通过在返回时在字段 ee_code 中设置标志 SO_EE_CODE_ZEROCOPY_COPIED 来指示数据是否已通过复制完成。 进程可以使用此信号来停止在同一套接字的后续请求上传递标志 MSG_ZEROCOPY。

实现

环回

对于 TCP 和 UDP:如果接收进程不读取其套接字,则发送到本地套接字的数据可以无限期地排队。 无界通知延迟是不可接受的。 因此,所有使用 MSG_ZEROCOPY 生成并循环到本地套接字的数据包都将产生延迟复制。 这包括循环到数据包套接字(例如,tcpdump)和 tun 设备。

对于 VSOCK:发送到本地套接字的数据路径与非本地套接字相同。

测试

更真实的示例代码可以在内核源代码的 tools/testing/selftests/net/msg_zerocopy.c 中找到。

请注意环回约束。 测试可以在一对主机之间运行。 但是,如果在本地一对进程之间运行,例如在使用 msg_zerocopy.sh 在命名空间之间的一对 veth 之间运行时,测试将不会显示任何改进。 对于测试,可以通过使 skb_orphan_frags_rx 与 skb_orphan_frags 相同来暂时放宽环回限制。

对于 VSOCK 类型的套接字,示例可以在 tools/testing/vsock/vsock_test_zerocopy.c 中找到。