MSG_ZEROCOPY

简介

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

机会和注意事项

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

避免复制并非免费的午餐。如当前实现方式,使用页面锁定,它将每字节复制的成本替换为页面记账和完成通知的开销。因此,MSG_ZEROCOPY 通常仅在大于 10 KB 的写入操作时有效。

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

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

更多信息

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

论文、幻灯片、视频

https://netdevconf.org/2.1/session.html?debruijn

LWN 文章

https://lwn.net/Articles/726917/

补丁集

[PATCH net-next v4 0/9] socket sendmsg MSG_ZEROCOPY https://lore.kernel.org/netdev/[email protected]

接口

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

套接字设置

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

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

传输

对 send(或 sendto、sendmsg、sendmmsg)本身的更改是微不足道的。传递新标志即可。

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

如果套接字超出其 optmem 限制或用户超出其锁定页面的 ulimit,零复制失败将返回 -1 并设置 errno 为 ENOBUFS。

混合使用避免复制和复制

许多工作负载都有大小缓冲区的混合。由于对于小数据包来说,避免复制比复制更昂贵,因此该功能以标志的形式实现。可以将带有标志的调用与不带标志的调用混合使用,这是安全的。

通知

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

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

通知接收

以下代码片段演示了 API。在最简单的情况下,每个 send 系统调用后都会在错误队列上进行轮询和 recvmsg。

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

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);

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

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

通知批处理

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

当即将排队新的通知时,它会检查新值是否扩展了队列尾部通知的范围。如果是,则会删除新的通知数据包,而是增加未处理通知的范围上限值。

对于按顺序确认数据的协议(如 TCP),可以将每个通知压缩到前一个通知中,从而在任何时候最多只有一个通知未处理。

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

通知解析

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

控制数据中的级别和类型字段是特定于协议族的,例如 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 的设备无法发送由内核生成的协议头和零复制用户数据组成的数据包。可能需要将数据包转换为堆栈深处的私有数据副本,例如计算校验和。

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

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

实现

环回

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

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

测试

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

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

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