监控和调整 Linux 网络堆栈:发送数据
总结
这篇博文解释了运行 Linux 内核的计算机如何发送数据包,以及如何在数据包从用户程序流向网络硬件时监控和调整网络堆栈的每个组件。
正如我们之前的文章中提到的,Linux 网络堆栈非常复杂,没有一种万能的解决方案可以用于监控或调优。如果你真的想调优网络堆栈,你别无选择,只能投入大量的时间、精力和金钱来了解网络系统各个部分是如何交互的。
本博文中提供的许多示例设置仅用于说明目的,并不建议或反对某种配置或默认设置。在调整任何设置之前,您应该围绕需要监控的内容制定一个参考框架,以注意到有意义的变化。
在通过网络连接到计算机时调整网络设置非常危险;您很容易将自己锁定或完全切断网络。请勿在生产机器上调整这些设置;相反,如果可能的话,请在新机器上进行调整并将其轮换到生产中。
概述
网络数据从用户程序到网络设备的高级路径如下:
- 数据是使用系统调用(如、等)写入的。 `
- 数据通过套接字子系统传递到套接字协议系列的系统(在我们的例子中为)。
- 协议族通过协议层传递数据,协议层(在许多情况下)将数据排列成数据包。
- 数据通过路由层,沿途填充目标和邻居缓存(如果它们是冷的)。如果需要查找以太网地址,这可能会生成 ARP 流量。
- 数据包穿过协议层后到达设备无关层。
- 使用 XPS(如果启用)或哈希函数来选择输出队列。
- 调用设备驱动程序的传输函数。
- 然后,数据被传递到连接到输出设备的队列规则 (qdisc)。
- 如果可以的话,qdisc 将直接传输数据,或者将其排队以便在软中断期间发送。
- 最终,数据从 qdisc 传递给驱动程序。
- 驱动程序创建所需的 DMA 映射,以便设备可以从 RAM 读取数据。
- 驱动程序向设备发出信号,表示数据已准备好传输。
- 该设备从 RAM 中获取数据并传输。
- 一旦传输完成,设备就会发出中断来表示传输完成。
- 驱动程序注册的用于传输完成的 IRQ 处理程序运行。对于许多设备,此处理程序只是触发 NAPI 轮询循环通过软中断开始运行。
- 轮询函数通过 softIRQ 运行,并调用驱动程序来取消映射 DMA 区域并释放数据包数据。
详细了解
让我们首先检查协议系列如何在内核中注册以及如何被套接字子系统使用,然后我们就可以继续接收数据。
协议家族注册
当您在用户程序中运行这样的一段代码来创建 UDP 套接字时会发生什么?
sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
简而言之,Linux 内核查找 UDP 协议栈导出的一组函数,这些函数处理许多事情,包括发送和接收网络数据。要准确了解其工作原理,我们必须研究地址系列代码。 AF_INET
Linux 内核在内核初始化期间尽早执行该函数。该函数注册协议系列、该系列中的各个协议栈(TCP、UDP、ICMP 和 RAW),并调用初始化例程以使协议栈准备好处理网络数据。您可以在./net/ipv4/af_inet.c中找到代码。 inet_init
AF_INET
inet_init
协议族导出一个具有函数的结构。当从用户程序创建套接字时,内核会调用此函数: AF_INET
create
static const struct net_proto_family inet_family_ops = {
.family = PF_INET,
.create = inet_create,
.owner = THIS_MODULE,
};
该函数获取传递给套接字系统调用的参数,并搜索已注册的协议以找到一组要链接到套接字的操作。看一下: inet_create
/* Look for the requested type/protocol pair. */
lookup_protocol:
err = -ESOCKTNOSUPPORT;
rcu_read_lock();
list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {
err = 0;
/* Check the non-wild match. */
if (protocol == answer->protocol) {
if (protocol != IPPROTO_IP)
break;
} else {
/* Check for the two wild cases. */
if (IPPROTO_IP == protocol) {
protocol = answer->protocol;
break;
}
if (IPPROTO_IP == answer->protocol)
break;
}
err = -EPROTONOSUPPORT;
}
稍后,它保存对特定协议栈的引用并将其字段复制到套接字结构中: answer
ops
sock->ops = answer->ops;
您可以在中找到所有协议栈的结构定义。让我们看一下
/* Upon startup we insert all the elements in inetsw_array[] into
* the linked list inetsw.
*/
static struct inet_protosw inetsw_array[] =
{
{
.type = SOCK_STREAM,
.protocol = IPPROTO_TCP,
.prot = &tcp_prot,
.ops = &inet_stream_ops,
.no_check = 0,
.flags = INET_PROTOSW_PERMANENT |
INET_PROTOSW_ICSK,
},
{
.type = SOCK_DGRAM,
.protocol = IPPROTO_UDP,
.prot = &udp_prot,
.ops = &inet_dgram_ops,
.no_check = UDP_CSUM_DEFAULT,
.flags = INET_PROTOSW_PERMANENT,
},
/* .... more protocols ... */
在的情况下,链接到一个结构
const struct proto_ops inet_dgram_ops = {
.family = PF_INET,
.owner = THIS_MODULE,
/* ... */
.sendmsg = inet_sendmsg,
.recvmsg = inet_recvmsg,
/* ... */
};
EXPORT_SYMBOL(inet_dgram_ops);
以及一个协议特定的结构,其中包含指向所有内部 UDP 协议栈函数的函数指针。对于 UDP 协议,此结构被调用并由导出:
struct proto udp_prot = {
.name = "UDP",
.owner = THIS_MODULE,
/* ... */
.sendmsg = udp_sendmsg,
.recvmsg = udp_recvmsg,
/* ... */
};
EXPORT_SYMBOL(udp_prot);
现在,我们来看一下发送UDP数据的用户程序,看看在内核中是如何调用的! udp_sendmsg
通过套接字发送网络数据
用户程序想要发送 UDP 网络数据,因此它使用系统调用,可能像这样: sendto
ret = sendto(socket, buffer, buflen, 0, &dest, sizeof(dest));
此系统调用穿过Linux系统调用层并进入以下[函数]
/*
* Send a datagram to a given address. We move the address into kernel
* space and check the user space data area is readable before invoking
* the protocol.
*/
SYSCALL_DEFINE6(sendto, int, fd, void __user *, buff, size_t, len,
unsigned int, flags, struct sockaddr __user *, addr,
int, addr_len)
{
/* ... code ... */
err = sock_sendmsg(sock, &msg, len);
/* ... code ... */
}
该宏展开为一堆宏,这些宏依次设置创建具有 6 个参数的系统调用所需的基础结构(因此)。这样做的结果之一是,在内核中,系统调用函数名称已添加到它们前面。
系统调用代码将数据排列成较低层能够处理的方式后进行调用。具体来说,它将传入的目标地址排列成一个结构体,我们来看看: sendto
sock_sendmsg
sendto
iov.iov_base = buff;
iov.iov_len = len;
msg.msg_name = NULL;
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = NULL;
msg.msg_controllen = 0;
msg.msg_namelen = 0;
if (addr) {
err = move_addr_to_kernel(addr, addr_len, &address);
if (err < 0)
goto out_put;
msg.msg_name = (struct sockaddr *)&address;
msg.msg_namelen = addr_len;
}
此代码将通过用户程序传入的 复制并放入内核数据结构中,然后将其嵌入到结构中。这类似于用户空间程序在调用而不是 时所做的操作。内核提供此变异是因为 和都调用。 addr
address
struct msghdr
msg_name
sendmsg
sendto
sendto
sendmsg
sock_sendmsg
sock_sendmsg
,, 和 __sock_sendmsg
__sock_sendmsg_nosec
sock_sendmsg
在调用之前执行一些错误检查在调用之前执行自己的错误检查。将数据传递到套接字子系统的更深处: __sock_sendmsg
__sock_sendmsg_nosec
__sock_sendmsg_nosec
static inline int __sock_sendmsg_nosec(struct kiocb *iocb, struct socket *sock,
struct msghdr *msg, size_t size)
{
struct sock_iocb *si = ....
/* other code ... */
return sock->ops->sendmsg(iocb, sock, msg, size);
}
正如上一节解释套接字创建时所见,注册到此套接字操作结构中的函数是。 sendmsg
inet_sendmsg
inet_sendmsg
您可能已经从名称中猜到了,这是协议系列提供的通用函数。此函数首先调用以记录处理流的最后一个 CPU;这由Receive Packet Steering使用。接下来,此函数在套接字的内部协议操作结构中查找函数并调用它: AF_INET
sock_rps_record_flow
sendmsg
int inet_sendmsg(struct kiocb *iocb, struct socket *sock, struct msghdr *msg,
size_t size)
{
struct sock *sk = sock->sk;
sock_rps_record_flow(sk);
/* We may need to bind the socket. */
if (!inet_sk(sk)->inet_num && !sk->sk_prot->no_autobind &&
inet_autobind(sk))
return -EAGAIN;
return sk->sk_prot->sendmsg(iocb, sk, msg, size);
}
EXPORT_SYMBOL(inet_sendmsg);
处理 UDP 时,上述内容由 UDP 协议层通过我们之前看到的结构导出。此函数调用从通用协议系列转换到 UDP 协议栈。 sk->sk_prot->sendmsg
udp_sendmsg
udp_prot
AF_INET
UDP 协议层
udp_sendmsg
该函数位于./net/ipv4/udp.c中。整个函数相当长,因此我们将在下面查看其中的部分内容。如果您想完整阅读,请点击上一个链接。 udp_sendmsg
UDP 堵塞
在变量声明和一些基本的错误检查之后,首先要做的一件事就是检查套接字是否“corked”。UDP corking 是一种功能,允许用户程序请求内核在发送之前将来自多个调用的数据累积到单个数据报中。有两种方法可以在用户程序中启用此选项: udp_sendmsg
send
- 使用系统调用并传递作为套接字选项。
setsockopt
UDP_CORK
- 在从程序中调用、或时,作为 之一传递。
MSG_MORE
flags
send
sendto
sendmsg
这些选项分别记录在UDP 手册页和send / sendto / sendmsg 手册页中。
代码检查以确定套接字当前是否已 corked,如果是,则直接继续附加数据。我们稍后会看到如何附加数据。 udp_sendmsg
up->pending
int udp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
size_t len)
{
/* variables and error checking ... */
fl4 = &inet->cork.fl.u.ip4;
if (up->pending) {
/*
* There are pending frames.
* The socket lock must be held while it's corked.
*/
lock_sock(sk);
if (likely(up->pending)) {
if (unlikely(up->pending != AF_INET)) {
release_sock(sk);
return -EINVAL;
}
goto do_append_data;
}
release_sock(sk);
}
获取UDP目标地址和端口
接下来,从两个可能的来源之一确定目标地址和端口:
- 套接字本身存储了目标地址,因为套接字在某个时刻已连接。
- 该地址通过辅助结构传递,就像我们在内核代码中看到的那样。
sendto
以下是内核处理这个问题的方式:
/*
* Get and verify the address.
*/
if (msg->msg_name) {
struct sockaddr_in *usin = (struct sockaddr_in *)msg->msg_name;
if (msg->msg_namelen < sizeof(*usin))
return -EINVAL;
if (usin->sin_family != AF_INET) {
if (usin->sin_family != AF_UNSPEC)
return -EAFNOSUPPORT;
}
daddr = usin->sin_addr.s_addr;
dport = usin->sin_port;
if (dport == 0)
return -EINVAL;
} else {
if (sk->sk_state != TCP_ESTABLISHED)
return -EDESTADDRREQ;
daddr = inet->inet_daddr;
dport = inet->inet_dport;
/* Open fast path for connected socket.
Route will not be used, if at least one option is set.
*/
connected = 1;
}
是的,那是在 UDP 协议层!套接字状态无论好坏都使用 TCP 状态描述。 TCP_ESTABLISHED
回想一下,我们之前看到过,当用户程序调用 时,内核如何为用户安排一个结构。上面的代码显示了内核如何解析该数据以设置和。 struct msghdr
sendto
daddr
dport
如果该函数由未安排结构的内核函数到达,则从套接字本身检索目标地址和端口,并将套接字标记为“已连接”。 udp_sendmsg
** struct msghdr
无论哪种情况,都将设置为目标地址和端口。 daddr
dport
套接字传输记录和时间戳
接下来,检索并存储源地址、设备索引以及在套接字上设置的任何时间戳选项(如、、) : SOCK_TIMESTAMPING_TX_HARDWARE
SOCK_TIMESTAMPING_TX_SOFTWARE
SOCK_WIFI_STATUS
ipc.addr = inet->inet_saddr;
ipc.oif = sk->sk_bound_dev_if;
sock_tx_timestamp(sk, &ipc.tx_flags);
辅助消息,通过 sendmsg
和系统调用允许用户在发送或接收数据包之外设置或请求辅助数据。用户程序可以通过编写嵌入请求的 来利用这些辅助数据。IP的手册页中记录了许多辅助数据类型。 sendmsg
recvmsg
struct msghdr
辅助数据的一个常见示例是。在这种情况下,这种数据类型允许程序设置要在发送数据时使用的。程序可以通过填写结构中的字段来指定要在数据包上使用的源地址。如果程序是侦听多个 IP 地址的服务器程序,这是一个有用的选项。在这种情况下,服务器程序可能希望使用客户端用于联系服务器的相同 IP 地址回复客户端。恰恰启用了这种用例。 IP_PKTINFO
sendmsg
struct in_pktinfo
struct in_pktinfo
IP_PKTINFO
类似地,和辅助消息允许用户在从用户程序传递数据时,逐个数据包设置 IP 数据包的TTL和TOS值。请注意,如果需要,可以使用 为所有传出数据包在套接字级别设置和,而不是逐个数据包设置。Linux 内核使用数组将指定的 TOS 值转换为优先级。优先级会影响数据包从排队规则传输的方式和时间。稍后我们将详细了解这意味着什么。 IP_TTL
IP_TOS
sendmsg
IP_TTL
IP_TOS
setsockopt
我们可以看到内核如何处理UDP 套接字上的辅助消息: sendmsg
if (msg->msg_controllen) {
err = ip_cmsg_send(sock_net(sk), msg, &ipc,
sk->sk_family == AF_INET6);
if (err)
return err;
if (ipc.opt)
free = 1;
connected = 0;
}
解析辅助消息的内部过程由./net/ipv4/ip_sockglue.c处理。请注意,仅提供任何辅助数据都会将此套接字标记为未连接。 ip_cmsg_send
设置自定义 IP 选项
接下来,将检查用户是否使用辅助消息指定了任何自定义 IP 选项。如果设置了选项,则将使用它们。如果没有,则将使用此套接字已使用的选项: sendmsg
if (!ipc.opt) {
struct ip_options_rcu *inet_opt;
rcu_read_lock();
inet_opt = rcu_dereference(inet->inet_opt);
if (inet_opt) {
memcpy(&opt_copy, inet_opt,
sizeof(*inet_opt) + inet_opt->opt.optlen);
ipc.opt = &opt_copy.opt;
}
rcu_read_unlock();
}
接下来,该函数检查是否设置了源记录路由 (SRR) IP 选项。源记录路由有两种类型:松散源记录路由和严格源记录路由。如果设置了此选项,则记录并存储第一跳地址,并将套接字标记为“未连接”。这将在后面使用: faddr
ipc.addr = faddr = daddr;
if (ipc.opt && ipc.opt->opt.srr) {
if (!daddr)
return -EINVAL;
faddr = ipc.opt->opt.faddr;
connected = 0;
}
处理完 SRR 选项后,将从用户通过辅助消息设置的值或套接字当前使用的值中检索 TOS IP 标志。然后检查以确定:
SO_DONTROUTE
已在套接字上设置(使用),或setsockopt
MSG_DONTROUTE
在调用或时被指定为标志,或sendto
sendmsg
is_strictroute
已设置,表明需要严格的源记录路由
然后,将()添加到其位设置中,并且套接字被视为未“连接”: tos
0x1
RTO_ONLINK
tos = get_rttos(&ipc, inet);
if (sock_flag(sk, SOCK_LOCALROUTE) ||
(msg->msg_flags & MSG_DONTROUTE) ||
(ipc.opt && ipc.opt->opt.is_strictroute)) {
tos |= RTO_ONLINK;
connected = 0;
}
多播还是单播?
接下来,代码尝试处理多播。这有点棘手,因为用户可以通过发送辅助消息来指定从哪里发送数据包的备用源地址或设备索引,如前所述。 IP_PKTINFO
如果目标地址是多播地址:
- 写入数据包的设备索引将被设置为多播设备索引,并且
- 数据包上的源地址将被设置为多播源地址。
除非用户没有通过发送辅助消息来覆盖设备索引。我们来看一下: IP_PKTINFO
if (ipv4_is_multicast(daddr)) {
if (!ipc.oif)
ipc.oif = inet->mc_index;
if (!saddr)
saddr = inet->mc_addr;
connected = 0;
} else if (!ipc.oif)
ipc.oif = inet->uc_index;
如果目标地址不是多播地址,则设置设备索引,除非用户使用 覆盖它。 IP_PKTINFO
路由
现在到了路由的时间了!
UDP 层中处理路由的代码从快速路径开始。如果套接字已连接,则尝试获取路由结构:
if (connected)
rt = (struct rtable *)sk_dst_check(sk, 0);
如果套接字未连接,或者已连接但路由助手认为路由已过时,则代码将进入慢速路径以生成路由结构。首先调用构造一个描述此 UDP 流的结构: sk_dst_check
flowi4_init_output
if (rt == NULL) {
struct net *net = sock_net(sk);
fl4 = &fl4_stack;
flowi4_init_output(fl4, ipc.oif, sk->sk_mark, tos,
RT_SCOPE_UNIVERSE, sk->sk_protocol,
inet_sk_flowi_flags(sk)|FLOWI_FLAG_CAN_SLEEP,
faddr, saddr, dport, inet->inet_sport);
一旦构建了此流结构,套接字及其流结构就会传递给安全子系统,以便SELinux或SMACK等系统可以在流结构上设置安全 ID 值。接下来,将调用 IP 路由代码来为此流生成路由结构: ip_route_output_flow
security_sk_classify_flow(sk, flowi4_to_flowi(fl4));
rt = ip_route_output_flow(net, fl4, sk);
如果无法生成路由结构并且错误,则统计计数器将递增。 ENETUNREACH
OUTNOROUTES
if (IS_ERR(rt)) {
err = PTR_ERR(rt);
rt = NULL;
if (err == -ENETUNREACH)
IP_INC_STATS(net, IPSTATS_MIB_OUTNOROUTES);
goto out;
}
保存这些统计计数器的文件的位置以及其他可用的计数器及其含义将在下面的 UDP 监控部分中讨论。
接下来,如果路由用于广播,但套接字上未设置套接字选项,则代码终止。如果套接字被视为“已连接”(如该函数中所述),则路由结构将缓存在套接字上: SOCK_BROADCAST
err = -EACCES;
if ((rt->rt_flags & RTCF_BROADCAST) &&
!sock_flag(sk, SOCK_BROADCAST))
goto out;
if (connected)
sk_dst_set(sk, dst_clone(&rt->dst));
防止 ARP 缓存过期 MSG_CONFIRM
如果用户在调用、或时指定了标志,则UDP 协议层现在将处理: MSG_CONFIRM
send
sendto
sendmsg
if (msg->msg_flags&MSG_CONFIRM)
goto do_confirm;
back_from_confirm:
此标志指示系统确认 ARP 缓存条目仍然有效并防止其被垃圾收集。该函数只是在目标缓存条目上设置一个标志,当查询邻居缓存并找到条目时,该标志将在稍后检查。我们稍后会再次看到这一点。此功能通常用于 UDP 网络应用程序以减少不必要的 ARP 流量。标签位于此函数末尾附近,但它很简单: dst_confirm
do_confirm
do_confirm:
dst_confirm(&rt->dst);
if (!(msg->msg_flags&MSG_PROBE) || len)
goto back_from_confirm;
err = 0;
goto out;
如果这不是一个探测,此代码将确认缓存条目并跳转回。 back_from_confirm
一旦代码跳回(或者根本没有发生跳转),代码将接下来尝试处理 UDP cork 和 uncorked 的情况。 do_confirm
back_from_confirm
do_confirm
未加塞的 UDP 套接字的快速路径:准备传输数据
如果没有请求 UDP corking,数据可以打包到 中,然后传递到 中,以便向下移动到更靠近 IP 协议层的位置。这可以通过调用 来完成。请注意,之前通过调用生成的路由结构也会被传递进去。它将被附加到 skb 中,稍后在 IP 协议层中使用。 struct sk_buff
udp_send_skb
ip_make_skb
ip_route_output_flow
/* Lockless fast path for the non-corking case. */
if (!corkreq) {
skb = ip_make_skb(sk, fl4, getfrag, msg->msg_iov, ulen,
sizeof(struct udphdr), &ipc, &rt,
msg->msg_flags);
err = PTR_ERR(skb);
if (!IS_ERR_OR_NULL(skb))
err = udp_send_skb(skb, fl4);
goto out;
}
该函数将尝试构建一个 skb,同时考虑多种因素,例如: ip_make_skb
大多数网络设备驱动程序不支持 UFO,因为网络硬件本身不支持此功能。让我们看一下这段代码,记住 corking 是禁用的。接下来我们将查看启用 corking 的路径。
ip_make_skb
该函数可以在./net/ipv4/ip_output.c中找到。这个函数有点棘手。构建 skb 所需的底层代码需要一个 corking 结构和队列,skb 将排队等待传入。在套接字未 corking 的情况下,将传入一个伪 corking 结构和空队列作为虚拟对象。 ip_make_skb
ip_make_skb
让我们看一下仿制的 corking 结构和队列是如何设置的:
struct sk_buff *ip_make_skb(struct sock *sk, /* more args */)
{
struct inet_cork cork;
struct sk_buff_head queue;
int err;
if (flags & MSG_PROBE)
return NULL;
__skb_queue_head_init(&queue);
cork.flags = 0;
cork.addr = 0;
cork.opt = NULL;
err = ip_setup_cork(sk, &cork, /* more args */);
if (err)
return ERR_PTR(err);
如上所示,corking 结构 ( cork
) 和队列 ( ) 都是堆栈分配的;在完成queue
时都不需要。通过调用 来设置仿 corking 结构,该调用会分配内存并初始化结构。接下来,调用 并传入队列和 corking 结构: ip_make_skb
ip_setup_cork
__ip_append_data
err = __ip_append_data(sk, fl4, &queue, &cork,
¤t->task_frag, getfrag,
from, length, transhdrlen, flags);
稍后我们将看到此函数的工作原理,因为无论套接字是否已 corked,它都会在两种情况下使用。现在,我们需要知道的是,它将创建一个 skb,将数据附加到其中,并将该 skb 添加到传入的队列中。如果附加数据失败,则调用将数据丢弃到地板上,并将错误代码向上传递回去: __ip_append_data
__ip_flush_pending_frame
if (err) {
__ip_flush_pending_frames(sk, &queue, &cork);
return ERR_PTR(err);
}
最后,如果没有发生错误,将出列排队的 skb,添加 IP 选项,并返回准备传递给下层发送的 skb: __ip_make_skb
return __ip_make_skb(sk, fl4, &queue, &cork);
傳輸數據!
如果没有发生错误,则将 skb 交给网络堆栈的下一层,即 IP 协议堆栈: udp_send_skb
err = PTR_ERR(skb);
if (!IS_ERR_OR_NULL(skb))
err = udp_send_skb(skb, fl4);
goto out;
如果出现错误,稍后会进行记录。有关详细信息,请参阅 UDP corking 案例下方的“错误记录”部分。
对于没有预先存在的 Corked 数据的 Corked UDP 套接字,路径较慢
如果正在使用 UDP corking,但没有预先存在的数据被 corking,则慢速路径开始:
- 锁住插座。
- 检查应用程序错误:正在被“重新塞住”的塞住的插座。
- 此 UDP 流的流结构已为 corking 做好准备。
- 要发送的数据将附加到现有数据中。
您可以在下一段代码中看到这一点,继续往下看: udp_sendmsg
lock_sock(sk);
if (unlikely(up->pending)) {
/* The socket is already corked while preparing it. */
/* ... which is an evident application bug. --ANK */
release_sock(sk);
LIMIT_NETDEBUG(KERN_DEBUG pr_fmt("cork app bug 2\n"));
err = -EINVAL;
goto out;
}
/*
* Now cork the socket to pend data.
*/
fl4 = &inet->cork.fl.u.ip4;
fl4->daddr = daddr;
fl4->saddr = saddr;
fl4->fl4_dport = dport;
fl4->fl4_sport = inet->inet_sport;
up->pending = AF_INET;
do_append_data:
up->len += ulen;
err = ip_append_data(sk, fl4, getfrag, msg->msg_iov, ulen,
sizeof(struct udphdr), &ipc, &rt,
corkreq ? msg->msg_flags|MSG_MORE : msg->msg_flags);
ip_append_data
这是一个小的包装函数,在调用之前它主要做两件事: ip_append_data
__ip__append_data
- 检查标志是否由用户传入。此标志表示用户实际上并不想发送数据。应探测路径(例如确定 PMTU )。
MSG_PROBE
- 检查套接字的发送队列是否为空。如果是,则表示没有待处理的 corked 数据,因此将调用此函数来设置 corking。
ip_setup_cork
一旦处理了上述条件,就会调用包含将数据处理成数据包的大部分逻辑的函数。 __ip_append_data
__ip_append_data
如果套接字已 corked,则从 调用此函数;如果套接字未 corked,则从 调用此函数。无论哪种情况,此函数都会分配一个新缓冲区来存储传入的数据,或者将数据附加到现有数据中。 ip_append_data
ip_make_skb
这项工作以套接字的发送队列为中心。等待发送的现有数据(例如,如果套接字已 corked)将在队列中有一个条目,可在该条目中附加其他数据。
这个函数很复杂;它执行几轮计算来确定如何构造将传递给较低级别网络层的 skb,并且详细检查缓冲区分配过程对于理解网络数据如何传输并不是绝对必要的。
此功能的重要亮点包括:
- 处理 UDP 碎片卸载 (UFO)(如果硬件支持)。绝大多数网络硬件不支持 UFO。如果您的网卡驱动程序支持它,它将设置功能标志。
NETIF_F_UFO
- 处理支持分散/聚集 IO的网卡。许多卡都支持此功能,并通过功能标志进行宣传。此功能的可用性表明网卡可以处理已在一组缓冲区之间拆分的数据包传输;内核无需花时间将多个缓冲区合并为一个缓冲区。避免这种额外的复制是理想的,大多数网卡都支持此功能。
NETIF_F_SG
- 通过调用 来跟踪发送队列的大小。分配新的 skb 时,skb 的大小将计入拥有它的套接字,并且套接字发送队列的分配字节数将增加。如果发送队列中没有足够的空间,则不会分配 skb,并返回并跟踪错误。我们将在下面的调整部分中了解如何设置套接字发送队列大小。
sock_wmalloc
- 增加错误统计。此函数中的任何错误都会增加“discard”。我们将在下面的监控部分中了解如何读取此值。
该函数成功完成后,将返回,并将要传输的数据组装成适合网络设备的 skb,并在发送队列中等待。 0
在未加塞的情况下,保存 skb 的队列将被传递到上面描述的位置,在那里它将出队并准备通过 发送到较低的层。 __ip_make_skb
udp_send_skb
在 corked 情况下, 的返回值向上传递。数据位于发送队列中,直到确定是时候调用,这将完成 skb 并调用。 __ip_append_data
udp_sendmsg
udp_push_pending_frames
udp_send_skb
冲洗软木插座
现在,将继续检查返回值(如下): udp_sendmsg
err
__ip_append_skb
if (err)
udp_flush_pending_frames(sk);
else if (!corkreq)
err = udp_push_pending_frames(sk);
else if (unlikely(skb_queue_empty(&sk->sk_write_queue)))
up->pending = 0;
release_sock(sk);
让我们来看看每个案例:
- 如果出现错误(
err
非零),则调用,取消 corking 并删除套接字发送队列中的所有数据。udp_flush_pending_frames
- 如果发送的数据没有经过指定,则会调用该方法尝试将数据传送到较低的网络层。
MSG_MORE
udp_push_pending_frames
- 如果发送队列为空,则将套接字标记为不再 Corking。
如果附加操作成功完成,并且有更多的数据要写入 cork,则代码继续清理并返回附加数据的长度:
ip_rt_put(rt);
if (free)
kfree(ipc.opt);
if (!err)
return len;
这就是内核处理 corked UDP 套接字的方式。
错误会计
如果:
- 非 corking 快速路径无法创建 skb 或报告错误,或者
udp_send_skb
ip_append_data
无法将数据附加到 corked UDP 套接字,或者udp_push_pending_frames
返回尝试传输 corked skb 时收到的错误udp_send_skb
仅当收到错误(没有可用的内核内存)或套接字已设置(发送队列已满)时,统计信息才会增加: SNDBUFERRORS
ENOBUFS
SOCK_NOSPACE
/*
* ENOBUFS = no kernel mem, SOCK_NOSPACE = no sndbuf space. Reporting
* ENOBUFS might not be good (it's not tunable per se), but otherwise
* we don't have a good statistic (IpOutDiscards but it can be too many
* things). We could add another new stat but at least for now that
* seems like overkill.
*/
if (err == -ENOBUFS || test_bit(SOCK_NOSPACE, &sk->sk_socket->flags)) {
UDP_INC_STATS_USER(sock_net(sk),
UDP_MIB_SNDBUFERRORS, is_udplite);
}
return err;
我们将在下面的监控部分看到如何读取这些计数器。
udp_send_skb
该函数最终将 skb 推送到网络堆栈的下一层,在本例中为 IP 协议层。此函数执行一些重要操作: udp_send_skb
udp_sendmsg
- 向 skb 添加 UDP 标头。
- 处理校验和:软件校验和、硬件校验和或无校验和(如果禁用)。
- 尝试通过调用将 skb 发送到 IP 协议层。
ip_send_skb
- 增加传输成功或失败的统计计数器。
让我们看一下。首先,创建一个 UDP 标头:
static int udp_send_skb(struct sk_buff *skb, struct flowi4 *fl4)
{
/* useful variables ... */
/*
* Create a UDP header
*/
uh = udp_hdr(skb);
uh->source = inet->inet_sport;
uh->dest = fl4->fl4_dport;
uh->len = htons(len);
uh->check = 0;
接下来,处理校验和。有几种情况:
- 首先处理UDP-Lite校验和。
- 接下来,如果套接字设置为根本不生成校验和(通过),它将被标记为这样。
setsockopt
SO_NO_CHECK
- 接下来,如果硬件支持 UDP 校验和,将调用 进行设置。请注意,如果数据包被分割,内核将在软件中生成校验和。您可以在的源代码中看到这一点。
udp4_hwcsum
udp4_hwcsum
- 最后,通过调用来生成软件校验和。
udp_csum
if (is_udplite) /* UDP-Lite */
csum = udplite_csum(skb);
else if (sk->sk_no_check == UDP_CSUM_NOXMIT) { /* UDP csum disabled */
skb->ip_summed = CHECKSUM_NONE;
goto send;
} else if (skb->ip_summed == CHECKSUM_PARTIAL) { /* UDP hardware csum */
udp4_hwcsum(skb, fl4->saddr, fl4->daddr);
goto send;
} else
csum = udp_csum(skb);
接下来,添加伪标头:
uh->check = csum_tcpudp_magic(fl4->saddr, fl4->daddr, len,
sk->sk_protocol, csum);
if (uh->check == 0)
uh->check = CSUM_MANGLED_0;
如果校验和为 0,则按照RFC 768将其补码设置为校验和。最后,skb 被传递给 IP 协议栈,并且统计信息增加:
send:
err = ip_send_skb(sock_net(sk), skb);
if (err) {
if (err == -ENOBUFS && !inet->recverr) {
UDP_INC_STATS_USER(sock_net(sk),
UDP_MIB_SNDBUFERRORS, is_udplite);
err = 0;
}
} else
UDP_INC_STATS_USER(sock_net(sk),
UDP_MIB_OUTDATAGRAMS, is_udplite);
return err;
如果成功完成,则统计信息将递增。如果 IP 协议层报告错误,则统计信息将递增,但前提是错误为(内核内存不足)并且未启用错误队列。 ip_send_skb
OUTDATAGRAMS
SNDBUFERRORS
ENOBUFS
在进入 IP 协议层之前,让我们先看看如何在 Linux 内核中监视和调整 UDP 协议层。
监控:UDP协议层统计
获取 UDP 协议统计信息的两个非常有用的文件是:
/proc/net/snmp
/proc/net/udp
/proc/net/snmp
通过阅读来监控详细的 UDP 协议统计数据。 /proc/net/snmp
$ cat /proc/net/snmp | grep Udp:
Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors
Udp: 16314 0 0 17161 0 0
为了准确了解这些统计数据的增加位置,您需要仔细阅读内核源代码。在某些情况下,一些错误会被计入多个统计数据中。
InDatagrams
:当用户空间程序使用它来读取数据报时,该值会增加。当 UDP 数据包被封装并发送回去进行处理时,该值也会增加。recvmsg
NoPorts
:当 UDP 数据包到达没有程序监听的端口时,该值会增加。InErrors
:在几种情况下会增加:接收队列中没有内存、出现错误校验和以及添加数据报失败。sk_add_backlog
OutDatagrams
:当 UDP 数据包毫无错误地传递到 IP 协议层进行发送时,该值会增加。RcvbufErrors
:当报告没有可用内存时增加;如果大于或等于就会发生这种情况。sock_queue_rcv_skb
sk->sk_rmem_alloc
sk->sk_rcvbuf
SndbufErrors
:如果尝试发送数据包时 IP 协议层报告错误且未设置错误队列,则增加。如果没有可用的发送队列空间或内核内存,也会增加。InCsumErrors
:检测到 UDP 校验和失败时增加。请注意,在我能找到的所有情况下,都会与同时增加。因此,-应该会得出接收端内存相关错误的数量。InCsumErrors
InErrors
InErrors
InCsumErros
请注意,UDP 协议层发现的一些错误会在其他协议层的统计文件中报告。例如:路由错误。UDP 协议层发现的路由错误会导致 IP 协议层的统计信息增加。 udp_sendmsg
OutNoRoutes
/proc/net/udp
通过读取来监控 UDP 套接字统计信息 /proc/net/udp
$ cat /proc/net/udp
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ref pointer drops
515: 00000000:B346 00000000:0000 07 00000000:00000000 00:00000000 00000000 104 0 7518 2 0000000000000000 0
558: 00000000:0371 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7408 2 0000000000000000 0
588: 0100007F:038F 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7511 2 0000000000000000 0
769: 00000000:0044 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7673 2 0000000000000000 0
812: 00000000:006F 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7407 2 0000000000000000 0
第一行描述了下面各行中的每个字段:
sl
:套接字的内核哈希槽local_address
:十六进制的套接字本地地址及端口号,以 分隔。:
rem_address
:十六进制的套接字远程地址和端口号,以 分隔。:
st
:套接字的状态。奇怪的是,UDP 协议层似乎使用了一些 TCP 套接字状态。在上面的示例中,是。7
TCP_CLOSE
tx_queue
:内核中为传出 UDP 数据报分配的内存量。rx_queue
:内核中为传入的 UDP 数据报分配的内存量。tr
,,:这些字段不被UDP协议层使用。tm->when
retrnsmt
uid
:创建此套接字的用户的有效用户ID。timeout
:UDP协议层未使用。inode
:此套接字对应的 inode 编号。您可以使用它来帮助您确定哪个用户进程打开了此套接字。检查,它将包含指向 的符号链接。/proc/[pid]/fd
socket[:inode]
ref
:套接字的当前引用计数。pointer
:内核中的内存地址。struct sock
drops
:与此套接字相关的数据报丢弃数。请注意,这不包括与发送数据报(在 corked UDP 套接字或其他方式上)相关的任何丢弃;自本博客文章所检查的内核版本起,此值仅在接收路径中递增。
输出此内容的代码可以在 中找到。 net/ipv4/udp.c
调优:套接字发送队列内存
可以通过设置 sysctl 来调整发送队列(也称为写入队列)的最大大小。 net.core.wmem_max
通过设置来增加最大发送缓冲区大小。 sysctl
$ sudo sysctl -w net.core.wmem_max=8388608
sk->sk_write_queue
从该值开始,也可以通过设置 sysctl 进行调整,如下所示: net.core.wmem_default
通过设置来调整默认的初始发送缓冲区大小。 sysctl
$ sudo sysctl -w net.core.wmem_default=8388608
您还可以通过从应用程序调用并传递来设置大小。您可以设置的最大值是。 sk->sk_write_queue
setsockopt
SO_SNDBUF
setsockopt
net.core.wmem_max
但是,您可以通过调用和传递来覆盖该限制,但运行该应用程序的用户需要该功能。 net.core.wmem_max
setsockopt
SO_SNDBUFFORCE
CAP_NET_ADMIN
每次通过调用分配 skb 时, 都会递增。正如我们所见,UDP 数据报传输速度很快,通常不会在发送队列中花费太多时间。 sk->sk_wmem_alloc
__ip_append_data
IP协议层
UDP 协议层通过简单的调用将 skbs 交给 IP 协议,所以让我们从那里开始并绘制出 IP 协议层! ip_send_skb
ip_send_skb
该函数位于./net/ipv4/ip_output.c中,非常简短。它只是调用并增加错误统计信息(如果返回某种错误)。让我们看一下: ip_send_skb
ip_local_out
ip_local_out
int ip_send_skb(struct net *net, struct sk_buff *skb)
{
int err;
err = ip_local_out(skb);
if (err) {
if (err > 0)
err = net_xmit_errno(err);
if (err)
IP_INC_STATS(net, IPSTATS_MIB_OUTDISCARDS);
}
return err;
}
如上所示,调用 ,然后处理返回值。调用有助于将来自较低级别的任何错误“翻译”为 IP 和 UDP 协议层可以理解的错误。如果发生任何错误,IP 协议统计信息“OutDiscards”将递增。我们稍后会看到要读取哪些文件才能获得此统计信息。现在,让我们继续深入研究,看看会将我们带到哪里。 ip_local_out
net_xmit_errno
ip_local_out
ip_local_out
和 __ip_local_out
幸运的是,和都很简单。只需调用并根据返回值,就会调用路由层来输出数据包: ip_local_out
__ip_local_out
ip_local_out
__ip_local_out
int ip_local_out(struct sk_buff *skb)
{
int err;
err = __ip_local_out(skb);
if (likely(err == 1))
err = dst_output(skb);
return err;
}
从源代码我们可以看出,该函数首先做了两件重要的事情: __ip_local_out
- 设置 IP 数据包的长度
- 调用来计算要写入 IP 数据包头的校验和。该函数将调用名为的函数来计算校验和。在 x86 和 x86_64 架构上,此函数以汇编语言实现。您可以在此处阅读 64 位实现,在此处阅读 32 位实现。
ip_send_check
ip_send_check
ip_fast_csum
接下来,IP 协议层将通过调用 向下调用 netfilter 。该函数的返回值将传递回。如果返回,则表明允许数据包通过,并且调用者应将其传递给自身。正如我们上面所看到的,这正是发生的事情:检查 的返回值并通过调用自身传递数据包。让我们看一下 的代码: nf_hook
nf_hook
ip_local_out
nf_hook
1
ip_local_out
1
dst_output
__ip_local_out
int __ip_local_out(struct sk_buff *skb)
{
struct iphdr *iph = ip_hdr(skb);
iph->tot_len = htons(skb->len);
ip_send_check(iph);
return nf_hook(NFPROTO_IPV4, NF_INET_LOCAL_OUT, skb, NULL,
skb_dst(skb)->dev, dst_output);
}
netfilter 和 nf_hook
为了简洁起见(以及我的 RSI),我决定跳过对 netfilter、iptables 和 conntrack 的深入研究。您可以从这里和这里开始深入了解 netfilter 的源代码。
简而言之,这是一个包装器,它首先调用它检查是否为指定的协议系列和钩子类型(在本例中分别是)安装了任何过滤器,并尝试将执行返回到 IP 协议层,以避免更深入地进入 netfilter 和任何在下面钩住的东西,如 iptables 和 conntrack。 nf_hook
nf_hook_thresh
NFPROTO_IPV4
NF_INET_LOCAL_OUT
请记住:如果您有大量或非常复杂的 netfilter 或 iptables 规则,这些规则将在发起原始调用的用户进程的 CPU 上下文中执行。如果您设置了 CPU 固定以将此进程的执行限制到特定 CPU(或一组 CPU),请注意 CPU 将花费系统时间来处理出站 iptables 规则。根据系统的工作负载,如果您在此处测量性能回归,您可能需要小心地将进程固定到 CPU 或降低规则集的复杂性。 sendmsg
为了讨论的目的,我们假设返回表明调用者(在本例中为 IP 协议层)应该自行传递数据包。 nf_hook
1
目标缓存
该代码在 Linux 内核中实现了独立于协议的目标缓存。要了解如何设置条目以继续发送 UDP 数据报,我们需要简要检查条目和路由的生成方式。目标缓存、路由和邻居子系统都可以单独进行极其详细的检查。为了我们的目的,我们可以快速查看一下这一切是如何组合在一起的。 dst
dst
dst
上面我们看到的代码调用了。这个函数只是查找附加到的条目并调用输出函数。我们来看看: dst_output(skb)
dst
skb
/* Output packet to network from transport. */
static inline int dst_output(struct sk_buff *skb)
{
return skb_dst(skb)->output(skb);
}
看起来很简单,但首先该输出函数是如何附加到条目的呢? dst
重要的是要了解目标缓存条目以多种不同的方式添加。到目前为止,我们在所遵循的代码路径中看到的一种方法是调用from 。该函数调用which ,后者又调用。该函数创建路由和目标缓存条目。当它这样做时,它会确定哪个输出函数适合此目标。大多数情况下,这个函数是。 ip_route_output_flow
udp_sendmsg
ip_route_output_flow
__ip_route_output_key
__mkroute_output
__mkroute_output
ip_output
ip_output
因此,执行该函数,在 UDP IPv4 情况下为。该函数很简单: dst_output
output
ip_output
ip_output
int ip_output(struct sk_buff *skb)
{
struct net_device *dev = skb_dst(skb)->dev;
IP_UPD_PO_STATS(dev_net(dev), IPSTATS_MIB_OUT, skb->len);
skb->dev = dev;
skb->protocol = htons(ETH_P_IP);
return NF_HOOK_COND(NFPROTO_IPV4, NF_INET_POST_ROUTING, skb, NULL, dev,
ip_finish_output,
!(IPCB(skb)->flags & IPSKB_REROUTED));
}
首先,更新统计计数器。宏将增加字节数和数据包数。我们将在后面的部分中了解如何获取 IP 协议层统计信息以及它们各自的含义。接下来,设置要传输此数据的设备以及协议。 IPSTATS_MIB_OUT
IP_UPD_PO_STATS
skb
最后,通过调用 将控制权传递给 netfilter 。查看 的函数原型将有助于更清楚地解释其工作原理。来自./include/linux/netfilter.h: NF_HOOK_COND
NF_HOOK_COND
static inline int
NF_HOOK_COND(uint8_t pf, unsigned int hook, struct sk_buff *skb,
struct net_device *in, struct net_device *out,
int (*okfn)(struct sk_buff *), bool cond)
NF_HOOK_COND
通过检查传入的条件来工作。在本例中,该条件为。如果此条件为真,则将传递给 netfilter。如果 netfilter 允许数据包通过,则调用。在本例中,为。 !(IPCB(skb)->flags & IPSKB_REROUTED
skb
okfn
okfn
ip_finish_output
ip_finish_output
该函数也很简短、清晰。我们来看一下: ip_finish_output
static int ip_finish_output(struct sk_buff *skb)
{
#if defined(CONFIG_NETFILTER) && defined(CONFIG_XFRM)
/* Policy lookup after SNAT yielded a new policy */
if (skb_dst(skb)->xfrm != NULL) {
IPCB(skb)->flags |= IPSKB_REROUTED;
return dst_output(skb);
}
#endif
if (skb->len > ip_skb_dst_mtu(skb) && !skb_is_gso(skb))
return ip_fragment(skb, ip_finish_output2);
else
return ip_finish_output2(skb);
}
如果此内核中启用了 netfilter 和数据包转换,则的标志会更新并通过 发送回去。两种更常见的情况是: skb
dst_output
- 如果数据包的长度大于 MTU,并且数据包的分段不会卸载到设备上,则会调用此函数来帮助在传输之前对数据包进行分段。
ip_fragment
- 否则,数据包将直接传递至。
ip_finish_output2
在继续探讨内核之前,我们先简单讨论一下路径 MTU 发现。
路径 MTU 发现
Linux 提供了一项我至今都避免提及的功能:路径 MTU 发现。此功能允许内核自动确定特定路由的最大MTU。确定此值并发送小于或等于路由 MTU 的数据包意味着可以避免 IP 碎片。这是首选设置,因为碎片化数据包会消耗系统资源,而且似乎很容易避免:只需发送足够小的数据包,就不需要碎片化。
您可以通过使用级别和optname 调用应用程序来调整每个套接字的路径 MTU 发现设置。optval 可以是IP 协议手册页中描述的几个值之一。您可能想要设置的值是:这意味着“始终执行路径 MTU 发现”。更高级的网络应用程序或诊断工具可能会选择自己实现RFC 4821,以确定应用程序启动时特定路由的 PMTU。在这种情况下,您可以使用该选项告诉内核设置“不分段”位,但允许您发送大于 PMTU 的数据。 setsockopt
SOL_IP
IP_MTU_DISCOVER
IP_PMTUDISC_DO
IP_PMTUDISC_PROBE
您的应用程序可以通过调用,使用和optname来检索 PMTU 。您可以使用它来帮助指导您的应用程序在尝试传输之前将构建的 UDP 数据报的大小。 getsockopt
SOL_IP
IP_MTU
如果您已启用 PTMU 发现,则任何尝试发送大于 PMTU 的 UDP 数据都将导致应用程序收到错误代码。然后应用程序可以重试,但数据会减少。 EMSGSIZE
强烈建议启用 PTMU 发现,因此我将避免详细描述 IP 碎片代码路径。当我们查看 IP 协议层统计数据时,我将解释所有统计数据,包括碎片相关统计数据。其中许多统计数据以 递增。在碎片或非碎片情况下都会调用 ,所以让我们继续。 ip_fragment
ip_finish_output2
ip_finish_output2
在 IP 分段之后调用,也直接从调用。此函数处理在将数据包传递给邻居缓存之前对各种统计计数器进行碰撞。让我们看看它是如何工作的: ip_finish_output2
ip_finish_output
static inline int ip_finish_output2(struct sk_buff *skb)
{
/* variable declarations */
if (rt->rt_type == RTN_MULTICAST) {
IP_UPD_PO_STATS(dev_net(dev), IPSTATS_MIB_OUTMCAST, skb->len);
} else if (rt->rt_type == RTN_BROADCAST)
IP_UPD_PO_STATS(dev_net(dev), IPSTATS_MIB_OUTBCAST, skb->len);
/* Be paranoid, rather than too clever. */
if (unlikely(skb_headroom(skb) < hh_len && dev->header_ops)) {
struct sk_buff *skb2;
skb2 = skb_realloc_headroom(skb, LL_RESERVED_SPACE(dev));
if (skb2 == NULL) {
kfree_skb(skb);
return -ENOMEM;
}
if (skb->sk)
skb_set_owner_w(skb2, skb->sk);
consume_skb(skb);
skb = skb2;
}
如果与此数据包关联的路由结构为多播类型,则使用宏将和计数器都增加。否则,如果路由类型为广播,则和计数器将增加。 OutMcastPkts
OutMcastOctets
IP_UPD_PO_STATS
OutBcastPkts
OutBcastOctets
接下来,检查 skb 结构是否有足够的空间容纳需要添加的任何链路层标头。如果没有,则通过调用分配额外的空间,并将新 skb 的费用计入相关套接字。 skb_realloc_headroom
rcu_read_lock_bh();
nexthop = (__force u32) rt_nexthop(rt, ip_hdr(skb)->daddr);
neigh = __ipv4_neigh_lookup_noref(dev, nexthop);
if (unlikely(!neigh))
neigh = __neigh_create(&arp_tbl, &nexthop, dev, false);
继续往下看,我们可以看到下一跳是通过查询路由层然后查找邻居缓存来计算的。如果找不到邻居,则通过调用创建一个邻居。例如,第一次将数据发送到另一台主机时就可能出现这种情况。请注意,此函数使用 调用(在./net/ipv4/arp.c中定义)以在 ARP 表中创建邻居条目。其他系统(如 IPv6 或DECnet)维护自己的 ARP 表,并将不同的结构传递给。这篇文章的目的不是详细介绍邻居缓存,但值得注意的是,如果必须创建邻居,则此创建可能会导致缓存增长。这篇文章将在以下部分中介绍有关邻居缓存的更多详细信息。无论如何,邻居缓存都会导出自己的一组统计数据,以便可以测量这种增长。有关更多信息,请参阅下面的监控部分。 __neigh_create
arp_tbl
__neigh_create
if (!IS_ERR(neigh)) {
int res = dst_neigh_output(dst, neigh, skb);
rcu_read_unlock_bh();
return res;
}
rcu_read_unlock_bh();
net_dbg_ratelimited("%s: No header cache and no neighbour!\n",
__func__);
kfree_skb(skb);
return -EINVAL;
}
最后,如果没有返回错误,则调用 来传递 skb 以进行输出。否则,将释放 skb 并返回 EINVAL。此处的错误将波及回来并导致增加。让我们继续前进,继续接近 Linux 内核的网络设备子系统。 dst_neigh_output
OutDiscards
ip_send_skb
dst_neigh_output
dst_neigh_output
该函数为我们做了两件重要的事情。首先,回想一下,在这篇博文的前面部分,我们看到如果用户通过辅助消息指定该函数,则会翻转一个标志,以指示远程主机的目标缓存条目仍然有效,不应被垃圾收集。该检查发生在这里,邻居的字段设置为当前的 jiffies 计数。 dst_neigh_output
MSG_CONFIRM
sendmsg
confirmed
static inline int dst_neigh_output(struct dst_entry *dst, struct neighbour *n,
struct sk_buff *skb)
{
const struct hh_cache *hh;
if (dst->pending_confirm) {
unsigned long now = jiffies;
dst->pending_confirm = 0;
/* avoid dirtying neighbour */
if (n->confirmed != now)
n->confirmed = now;
}
其次,检查邻居的状态并调用适当的输出函数。让我们看一下条件并尝试了解发生了什么:
hh = &n->hh;
if ((n->nud_state & NUD_CONNECTED) && hh->hh_len)
return neigh_hh_output(hh, skb);
else
return n->output(n, skb);
}
如果考虑邻居,则意味着它是以下一个或多个: NUD_CONNECTED
NUD_PERMANENT
:静态路由。NUD_NOARP
:不需要 ARP 请求(例如,目标是多播或广播地址,或者是环回设备)。NUD_REACHABLE
:邻居“可达”。只要目标 ARP 请求成功处理,该目标就被标记为可达。
并且 “硬件头”( hh
) 被缓存(因为我们之前已经发送过数据并且之前已经生成过数据),则调用。否则,调用函数。两个代码路径都以 结尾,将 skb 传递到 Linux 网络设备子系统,在那里它将在到达设备驱动程序层之前进行更多处理。让我们遵循和代码路径,直到到达。 neigh_hh_output
output
dev_queue_xmit
neigh_hh_output
n->output
dev_queue_xmit
neigh_hh_output
如果目标是,并且硬件头已经被缓存,那么将被调用,它会在将 skb 移交给 之前进行一些处理。让我们从./include/net/neighbour.h中看一下: NUD_CONNECTED
neigh_hh_output
dev_queue_xmit
static inline int neigh_hh_output(const struct hh_cache *hh, struct sk_buff *skb)
{
unsigned int seq;
int hh_len;
do {
seq = read_seqbegin(&hh->hh_lock);
hh_len = hh->hh_len;
if (likely(hh_len <= HH_DATA_MOD)) {
/* this is inlined by gcc */
memcpy(skb->data - HH_DATA_MOD, hh->hh_data, HH_DATA_MOD);
} else {
int hh_alen = HH_DATA_ALIGN(hh_len);
memcpy(skb->data - hh_alen, hh->hh_data, hh_alen);
}
} while (read_seqretry(&hh->hh_lock, seq));
skb_push(skb, hh_len);
return dev_queue_xmit(skb);
}
此函数有点难以理解,部分原因是用于同步缓存硬件头上的读取/写入的锁定原语。此代码使用了一种称为seqlock的东西。您可以将上面的循环想象成一个简单的重试机制,它将尝试执行循环中的操作,直到可以成功执行为止。 do { } while()
循环本身会尝试确定在复制之前是否需要对齐硬件标头的长度。这是必需的,因为某些硬件标头(如IEEE 802.11标头)大于(16 字节)。 HH_DATA_MOD
一旦数据被复制到 skb 并且跟踪数据的 skb 内部指针用 更新,skb 就会被传递到 Linux 网络设备子系统。 skb_push
dev_queue_xmit
n->output
如果目标不是或硬件头没有被缓存,代码将继续沿着路径前进。邻居结构上的函数指针附加了什么?嗯,这取决于情况。要理解这是如何设置的,我们需要更多地了解邻居缓存的工作原理。 NUD_CONNECTED
n->output
output
A包含几个重要字段。如上所示,字段、函数和结构。回想一下,我们之前看到,如果在缓存中找不到现有条目,则会从中调用。当调用时,邻居被分配,其函数最初设置为。随着代码的进展,它将根据邻居的状态调整值以指向适当的函数。 struct neighbour
nud_state
output
ops
__neigh_create
ip_finish_output2
__neigh_creaet
output
neigh_blackhole
__neigh_create
output
output
例如,将用于将指针设置为当代码确定邻居已连接时。或者,将用于将指针设置为当代码怀疑邻居可能已关闭时(例如,如果自发送探测以来已超过秒)。 neigh_connect
output
neigh->ops->connected_output
neigh_suspect
output
neigh->ops->output
/proc/sys/net/ipv4/neigh/default/delay_first_probe_time
换句话说:设置为另一个指针,或者取决于它的状态。它来自哪里? neigh->output
neigh->ops_connected_output
neigh->ops->output
neigh->ops
邻居分配后,(来自./net/ipv4/arp.c)被调用来设置 的一些字段。具体来说,此函数检查与此邻居关联的设备,如果设备公开包含函数的结构(以太网设备执行),则设置为./net/ipv4/arp.c中定义的以下结构: arp_constructor
struct neighbour
header_ops
cache
neigh->ops
static const struct neigh_ops arp_hh_ops = {
.family = AF_INET,
.solicit = arp_solicit,
.error_report = arp_error_report,
.output = neigh_resolve_output,
.connected_output = neigh_resolve_output,
};
因此,无论邻居缓存代码是否将邻居视为“连接”或“可疑”,该函数都将被附加到上面调用时将被调用。 neigh_resolve_output
neigh->output
n->output
neigh_resolve_output
此函数的目的是尝试解析未连接的邻居或已连接但没有缓存硬件标头的邻居。让我们看看这个函数是如何工作的:
/* Slow and careful. */
int neigh_resolve_output(struct neighbour *neigh, struct sk_buff *skb)
{
struct dst_entry *dst = skb_dst(skb);
int rc = 0;
if (!dst)
goto discard;
if (!neigh_event_send(neigh, skb)) {
int err;
struct net_device *dev = neigh->dev;
unsigned int seq;
代码首先进行一些基本检查,然后继续调用。该函数是一个简短的包装器,它将完成解决邻居的繁重工作。您可以在./net/core/neighbour.c中阅读源代码,但从代码中可以得出的结论是,用户最感兴趣的有三种情况: neigh_event_send
neigh_event_send
__neigh_event_send
__neigh_event_send
- 处于 状态(分配时的默认状态)的邻居将立即发送 ARP 请求,假设 中设置的值允许发送探测(如果不是,则状态标记为)。邻居状态将更新并设置为。
NUD_NONE
/proc/sys/net/ipv4/neigh/default/app_solicit
/proc/sys/net/ipv4/neigh/default/mcast_solicit
NUD_FAILED
NUD_INCOMPLETE
- 状态中的邻居将被更新,并且将设置一个计时器以便稍后探测它们(稍后是现在的时间 +秒数)。
NUD_STALE
NUD_DELAYED
/proc/sys/net/ipv4/neigh/default/delay_first_probe_time
- 将检查中的任何邻居(包括上述情况 1 中的邻居),以确保未解析邻居的排队数据包数量小于或等于。如果数量更多,则数据包将出队并丢弃,直到长度小于或等于 proc 中的值。邻居缓存统计信息中的统计计数器会针对所有此类情况进行调整。
NUD_INCOMPLETE
/proc/sys/net/ipv4/neigh/default/unres_qlen
如果需要立即进行 ARP 探测,则会发送该探测。将返回表明邻居被视为“已连接”或“已延迟”或其他情况。返回值允许继续: __neigh_event_send
0
1
0
neigh_resolve_output
if (dev->header_ops->cache && !neigh->hh.hh_len)
neigh_hh_init(neigh, dst);
如果与邻居关联的设备的协议实现(在我们的例子中是以太网)支持缓存硬件头并且当前尚未缓存,则调用将缓存它。 neigh_hh_init
do {
__skb_pull(skb, skb_network_offset(skb));
seq = read_seqbegin(&neigh->ha_lock);
err = dev_hard_header(skb, dev, ntohs(skb->protocol),
neigh->ha, NULL, skb->len);
} while (read_seqretry(&neigh->ha_lock, seq));
接下来,使用seqlock来同步对邻居结构的硬件地址的访问,该地址将在尝试为 skb 创建以太网头时读取。一旦 seqlock 允许执行继续,就会进行错误检查: dev_hard_header
if (err >= 0)
rc = dev_queue_xmit(skb);
else
goto out_kfree_skb;
}
如果以太网头写入时没有返回错误,则将 skb 传递给 Linux 网络设备子系统进行传输。如果出现错误,则将丢弃 skb,设置返回代码并返回错误: dev_queue_xmit
goto
out:
return rc;
discard:
neigh_dbg(1, "%s: dst=%p neigh=%p\n", __func__, dst, neigh);
out_kfree_skb:
rc = -EINVAL;
kfree_skb(skb);
goto out;
}
EXPORT_SYMBOL(neigh_resolve_output);
在进入Linux网络设备子系统之前,让我们先看一些用于监视和转换IP协议层的文件。
监控:IP协议层
/proc/net/snmp
通过阅读来监控详细的 IP 协议统计数据。 /proc/net/snmp
$ cat /proc/net/snmp
Ip: Forwarding DefaultTTL InReceives InHdrErrors InAddrErrors ForwDatagrams InUnknownProtos InDiscards InDelivers OutRequests OutDiscards OutNoRoutes ReasmTimeout ReasmReqds ReasmOKs ReasmFails FragOKs FragFails FragCreates
Ip: 1 64 25922988125 0 0 15771700 0 0 25898327616 22789396404 12987882 51 1 10129840 2196520 1 0 0 0
...
该文件包含多个协议层的统计信息。IP 协议层首先出现。第一行包含下一行中每个对应值的空格分隔名称。
在 IP 协议层,您会发现统计计数器被提升。这些计数器由 C 枚举引用。所有有效的枚举值及其对应的字段名称都可以在include/uapi/linux/snmp.h中找到: /proc/net/snmp
enum
{
IPSTATS_MIB_NUM = 0,
/* frequently written fields in fast path, kept in same cache line */
IPSTATS_MIB_INPKTS, /* InReceives */
IPSTATS_MIB_INOCTETS, /* InOctets */
IPSTATS_MIB_INDELIVERS, /* InDelivers */
IPSTATS_MIB_OUTFORWDATAGRAMS, /* OutForwDatagrams */
IPSTATS_MIB_OUTPKTS, /* OutRequests */
IPSTATS_MIB_OUTOCTETS, /* OutOctets */
/* ... */
一些有趣的统计数据:
OutRequests
:每次尝试发送 IP 数据包时都会增加。似乎每次发送(无论成功与否)都会增加。OutDiscards
:每次丢弃 IP 数据包时都会增加。如果将数据附加到 skb(用于 corked 套接字)失败,或者 IP 下的各层返回错误,则会发生这种情况。OutNoRoute
:在多个位置增加,例如在 UDP 协议层 (udp_sendmsg
) 中,如果无法为给定目标生成路由。当应用程序在 UDP 套接字上调用“连接”但找不到路由时也会增加。FragOKs
:每个被分割的数据包增加一次。例如,一个被分割成 3 个片段的数据包将导致此计数器增加一次。FragCreates
:每创建一次片段,计数器增加一次。例如,一个数据包被分成 3 个片段,将导致该计数器增加三次。FragFails
:如果尝试进行碎片整理但未获准(因为已设置“不进行碎片整理”位),则递增。如果输出碎片整理失败,也递增。
其他统计数据记录在接收方博客文章中。
/proc/net/netstat
通过阅读来监控扩展 IP 协议统计信息。 /proc/net/netstat
$ cat /proc/net/netstat | grep IpExt
IpExt: InNoRoutes InTruncatedPkts InMcastPkts OutMcastPkts InBcastPkts OutBcastPkts InOctets OutOctets InMcastOctets OutMcastOctets InBcastOctets OutBcastOctets InCsumErrors InNoECTPkts InECT0Pktsu InCEPkts
IpExt: 0 0 0 0 277959 0 14568040307695 32991309088496 0 0 58649349 0 0 0 0 0
格式类似于,只是行以 为前缀。 /proc/net/snmp
IpExt
一些有趣的统计数据:
OutMcastPkts
:每次发送发往多播地址的数据包时都会增加。OutBcastPkts
:每次发送发往广播地址的数据包时都会增加。OutOctects
:输出的数据包字节数。OutMcastOctets
:输出的多播数据包字节数。OutBcastOctets
:输出的广播包字节数。
其他统计数据记录在接收方博客文章中。
请注意,这些指标中的每一个指标都在 IP 层中非常具体的位置递增。代码会不时地移动,重复计算错误或其他会计错误可能会潜入其中。如果这些统计数据对您很重要,强烈建议您阅读对您来说很重要的指标的 IP 协议层源代码,以便您了解它们何时递增(以及何时不递增)。
Linux 网络设备子系统
在我们了解数据包传输路径之前,让我们花点时间讨论一下接下来章节中将出现的一些重要概念。 dev_queue_xmit
Linux 流量控制
Linux 支持一项称为流量控制的功能。此功能允许系统管理员控制数据包从机器传输的方式。这篇博文不会深入介绍 Linux 流量控制的各个方面。本文档对系统、其控制及其功能进行了深入的检查。有几个概念值得一提,以使接下来的代码更容易理解。
流量控制系统包含几组不同的排队系统,它们提供不同的流量控制功能。单个排队系统通常称为排队规则。您可以将 qdisc 视为调度程序;qdisc 决定何时以及如何传输数据包。 qdisc
在 Linux 上,每个接口都有一个与之关联的默认 qdisc。对于仅支持单个传输队列的网络硬件,将使用默认 qdisc。支持多个传输队列的网络硬件使用默认 qdisc 。您可以通过运行来检查您的系统。 pfifo_fast
mq
tc qdisc
还需要注意的是,一些设备支持硬件中的流量控制,这可以让管理员将流量控制卸载到网络硬件并节省系统的 CPU 资源。
现在已经介绍了这些想法,让我们从./net/core/dev.c继续下去。 dev_queue_xmit
dev_queue_xmit
和 __dev_queue_xmit
dev_queue_xmit
是一个简单的包装: __dev_queue_xmit
int dev_queue_xmit(struct sk_buff *skb)
{
return __dev_queue_xmit(skb, NULL);
}
EXPORT_SYMBOL(dev_queue_xmit);
接下来是繁重的工作。让我们逐个查看并逐步执行此代码。请继续: __dev_queue_xmit
static int __dev_queue_xmit(struct sk_buff *skb, void *accel_priv)
{
struct net_device *dev = skb->dev;
struct netdev_queue *txq;
struct Qdisc *q;
int rc = -ENOMEM;
skb_reset_mac_header(skb);
/* Disable soft irqs for various locks below. Also
* stops preemption for RCU.
*/
rcu_read_lock_bh();
skb_update_prio(skb);
上面的代码开始于:
- 声明变量。
- 通过调用 来准备要处理的 skb 。这将重置 skb 的内部指针,以便可以访问以太网头。
skb_reset_mac_header
rcu_read_lock_bh
在下面的代码中被调用来准备读取 RCU 保护的数据结构。阅读有关安全使用 RCU 的更多信息。skb_update_prio
如果正在使用网络优先级 cgroup,则调用它来设置 skb 的优先级。
现在,我们将讨论数据传输的更复杂的部分;)
txq = netdev_pick_tx(dev, skb, accel_priv);
此处代码尝试确定要使用哪个传输队列。正如您将在本文后面看到的,一些网络设备公开多个传输队列用于传输数据。让我们详细了解一下它的工作原理。
netdev_pick_tx
代码位于./net/core/flow_dissector.c中。我们来看一下: netdev_pick_tx
struct netdev_queue *netdev_pick_tx(struct net_device *dev,
struct sk_buff *skb,
void *accel_priv)
{
int queue_index = 0;
if (dev->real_num_tx_queues != 1) {
const struct net_device_ops *ops = dev->netdev_ops;
if (ops->ndo_select_queue)
queue_index = ops->ndo_select_queue(dev, skb,
accel_priv);
else
queue_index = __netdev_pick_tx(dev, skb);
if (!accel_priv)
queue_index = dev_cap_txqueue(dev, queue_index);
}
skb_set_queue_mapping(skb, queue_index);
return netdev_get_tx_queue(dev, queue_index);
}
如上所示,如果网络设备仅支持单个 TX 队列,则会跳过更复杂的代码并返回单个 TX 队列。高端服务器上使用的大多数设备将具有多个 TX 队列。具有多个 TX 队列的设备有两种情况:
- 驱动程序实现了,可用于以硬件或功能特定的方式更智能地选择 TX 队列,或者
ndo_select_queue
- 驱动程序没有实现“ndo_select_queue”,所以内核应该自己选择设备。
从 3.13 内核开始,实现 的驱动程序并不多。bnx2x 和 ixgbe 驱动程序实现了此功能,但它仅用于以太网光纤通道 (FCoE)。鉴于此,我们假设网络设备未实现和/或未使用 FCoE。在这种情况下,内核将选择具有 的 tx 队列。 ndo_select_queue
ndo_select_queue
__netdev_pick_tx
一旦确定了队列的索引,就会缓存该值(稍后将在流量控制代码中使用),然后查找并返回指向该队列的指针。在回到之前,让我们先看看它的工作原理。 __netdev_pick_tx
skb_set_queue_mapping
netdev_get_tx_queue
__netdev_pick_tx
__dev_queue_xmit
__netdev_pick_tx
让我们看一下内核如何选择用于传输数据的 TX 队列。来自./net/core/flow_dissector.c:
u16 __netdev_pick_tx(struct net_device *dev, struct sk_buff *skb)
{
struct sock *sk = skb->sk;
int queue_index = sk_tx_queue_get(sk);
if (queue_index < 0 || skb->ooo_okay ||
queue_index >= dev->real_num_tx_queues) {
int new_index = get_xps_queue(dev, skb);
if (new_index < 0)
new_index = skb_tx_hash(dev, skb);
if (queue_index != new_index && sk &&
rcu_access_pointer(sk->sk_dst_cache))
sk_tx_queue_set(sk, new_index);
queue_index = new_index;
}
return queue_index;
}
代码首先通过调用来检查传输队列是否已经缓存在套接字上,如果尚未缓存,则返回。 sk_tx_queue_get
-1
下一个 if 语句检查下列任一情况是否为真:
- queue_index < 0。如果尚未设置队列,就会发生这种情况。
- 如果设置了该标志。如果设置了该标志,则意味着现在允许无序数据包。协议层必须适当地设置该标志。当流的所有未完成数据包都已确认时,TCP 协议层会设置该标志。发生这种情况时,内核可以为该数据包选择不同的 TX 队列。UDP 协议层不会设置该标志 - 因此 UDP 数据包永远不会设置为非零值。
ooo_okay
ooo_okay
- 如果队列索引大于队列数量。如果用户最近通过 更改了设备上的队列计数,则会发生这种情况。稍后将对此进行详细介绍。
ethtool
在任何一种情况下,代码都会进入慢速路径以获取传输队列。这首先会尝试使用用户配置的映射将传输队列链接到 CPU。这称为“传输数据包控制”。我们将在稍后更详细地介绍传输数据包控制 (XPS) 是什么以及它的工作原理。 get_xps_queue
如果由于该内核不支持 XPS 或者 XPS 未由系统管理员配置或者配置的映射指向无效队列而返回,则代码将继续调用。 get_xps_queue
-1
skb_tx_hash
一旦队列被 XPS 或内核自动选中(使用 ),队列就会被缓存在套接字对象上并返回。在继续之前,让我们先看看 XPS 和 是如何工作的。 skb_tx_hash
sk_tx_queue_set
skb_tx_hash
dev_queue_xmit
传输数据包控制 (XPS)
传输数据包控制 (XPS) 是一项功能,允许系统管理员确定哪些 CPU 可以处理设备支持的每个可用传输队列的传输操作。此功能的目的主要是避免在处理传输请求时发生锁争用。使用 XPS 还会带来其他好处,例如减少缓存驱逐和避免NUMA 计算机上的远程内存访问。
您可以通过查看 XPS 的内核文档来了解有关 XPS 工作原理的更多信息。我们将在下面介绍如何为您的系统调整 XPS,但现在,您只需要知道,要配置 XPS,系统管理员可以定义一个将传输队列映射到 CPU 的位图。
上述代码中的函数调用将参考此用户指定的映射来确定应使用哪个传输队列。如果返回,则将使用。 get_xps_queue
get_xps_queue
-1
skb_tx_hash
skb_tx_hash
如果内核中未包含 XPS,或者未配置 XPS,或者建议使用不可用的队列(因为用户可能调整了队列计数),则接管以确定应将数据发送到哪个队列。准确了解其工作原理非常重要,具体取决于您的传输工作负载。请注意,此代码已随时间进行了调整,因此如果您使用的内核版本与本文档不同,则应直接查阅内核源代码。 skb_tx_hash
skb_tx_hash
让我们从./include/linux/netdevice.h看一下它是如何工作的:
/*
* Returns a Tx hash for the given packet when dev->real_num_tx_queues is used
* as a distribution range limit for the returned value.
*/
static inline u16 skb_tx_hash(const struct net_device *dev,
const struct sk_buff *skb)
{
return __skb_tx_hash(dev, skb, dev->real_num_tx_queues);
}
代码只是从./net/core/flow_dissector.c向下调用。此函数中有一些有趣的代码,让我们看一下: __skb_tx_hash
/*
* Returns a Tx hash based on the given packet descriptor a Tx queues' number
* to be used as a distribution range.
*/
u16 __skb_tx_hash(const struct net_device *dev, const struct sk_buff *skb,
unsigned int num_tx_queues)
{
u32 hash;
u16 qoffset = 0;
u16 qcount = num_tx_queues;
if (skb_rx_queue_recorded(skb)) {
hash = skb_get_rx_queue(skb);
while (unlikely(hash >= num_tx_queues))
hash -= num_tx_queues;
return hash;
}
此函数中的第一个 if 节是一个有趣的短路。函数名称有点误导。skb 有一个用于 rx 和 tx 的字段。无论如何,如果您的系统正在接收数据包并将其转发到其他地方,则此 if 语句可能为真。如果不是这种情况,代码将继续。 skb_rx_queue_recorded
queue_mapping
if (dev->num_tc) {
u8 tc = netdev_get_prio_tc_map(dev, skb->priority);
qoffset = dev->tc_to_txq[tc].offset;
qcount = dev->tc_to_txq[tc].count;
}
要理解这段代码,必须提到程序可以设置套接字上发送的数据的优先级。这可以通过分别使用和level以及 optname 来实现。有关 的更多信息,请参阅socket(7) 手册页。 setsockopt
SOL_SOCKET
SO_PRIORITY
SO_PRIORITY
请注意,如果您在应用程序中使用了选项来设置通过特定套接字发送的 IP 数据包的 TOS 标志(或者,如果作为辅助消息传递给 ,则在每个数据包的基础上设置),则内核会将您设置的 TOS 选项转换为最终的优先级。 setsockopt
IP_TOS
sendmsg
skb->priority
前面提到过,有些网络设备支持基于硬件的流量控制系统。如果不为零,则表示该设备支持基于硬件的流量控制。 num_tc
如果该数字非零,则表示该设备支持基于硬件的流量控制。将参考将数据包优先级映射到基于硬件的流量控制的优先级映射。将根据此映射选择适合数据优先级的流量类别。
接下来,将生成适合该流量类别的传输队列范围。它们将用于确定传输队列。
如果为零(因为网络设备不支持基于硬件的流量控制),则和变量分别设置为传输队列的数量和。 num_tc
qcount
qoffset
0
使用和,将计算传输队列的索引: qcount
qoffset
if (skb->sk && skb->sk->sk_hash)
hash = skb->sk->sk_hash;
else
hash = (__force u16) skb->protocol;
hash = __flow_hash_1word(hash);
return (u16) (((u64) hash * qcount) >> 32) + qoffset;
}
EXPORT_SYMBOL(__skb_tx_hash);
最后,返回适当的队列索引。 __netdev_pick_tx
恢复 __dev_queue_xmit
此时已经选择了合适的传输队列。可以继续: __dev_queue_xmit
q = rcu_dereference_bh(txq->qdisc);
#ifdef CONFIG_NET_CLS_ACT
skb->tc_verd = SET_TC_AT(skb->tc_verd, AT_EGRESS);
#endif
trace_net_dev_queue(skb);
if (q->enqueue) {
rc = __dev_xmit_skb(skb, q, dev, txq);
goto out;
}
它首先获取与此队列关联的排队规则的引用。回想一下,我们之前看到,单传输队列设备的默认设置是 qdisc ,而多队列设备的默认设置是qdisc。 pfifo_fast
mq
接下来,如果内核中启用了数据包分类 API,代码会为传出数据分配流量分类“判定”。接下来,检查队列规则以查看是否有方法对数据进行排队。某些排队规则(如 qdisc)没有队列。如果有队列,代码会向下调用以继续处理要传输的数据。之后,执行跳转到此函数的末尾。我们稍后会看一下。现在,让我们看看如果没有队列会发生什么,从一个非常有用的注释开始: noqueue
__dev_xmit_skb
__dev_xmit_skb
/* The device has no queue. Common case for software devices:
loopback, all the sorts of tunnels...
Really, it is unlikely that netif_tx_lock protection is necessary
here. (f.e. loopback and IP tunnels are clean ignoring statistics
counters.)
However, it is possible, that they rely on protection
made by us here.
Check this and shot the lock. It is not prone from deadlocks.
Either shot noqueue qdisc, it is even simpler 8)
*/
if (dev->flags & IFF_UP) {
int cpu = smp_processor_id(); /* ok because BHs are off */
正如注释所示,唯一可能具有没有队列的 qdisc 的设备是环回设备和隧道设备。如果设备当前处于启动状态,则保存当前 CPU。它用于下一个检查,这有点棘手,让我们看一下:
if (txq->xmit_lock_owner != cpu) {
if (__this_cpu_read(xmit_recursion) > RECURSION_LIMIT)
goto recursion_alert;
有两种情况:此设备队列上的传输锁是否由该 CPU 拥有。如果是,则检查每个 CPU 分配的计数器变量,以确定计数是否超过。一个程序可能会尝试发送数据并在代码中的此处被抢占。调度程序可以选择另一个程序来运行。如果第二个程序也尝试发送数据并到达此处。因此,计数器用于防止多个程序在这里争相传输数据。让我们继续: xmit_recursion
RECURSION_LIMIT
xmit_recursion
RECURSION_LIMIT
HARD_TX_LOCK(dev, txq, cpu);
if (!netif_xmit_stopped(txq)) {
__this_cpu_inc(xmit_recursion);
rc = dev_hard_start_xmit(skb, dev, txq);
__this_cpu_dec(xmit_recursion);
if (dev_xmit_complete(rc)) {
HARD_TX_UNLOCK(dev, txq);
goto out;
}
}
HARD_TX_UNLOCK(dev, txq);
net_crit_ratelimited("Virtual device %s asks to queue packet!\n",
dev->name);
} else {
/* Recursion is detected! It is possible,
* unfortunately
*/
recursion_alert:
net_crit_ratelimited("Dead loop on virtual device %s, fix it urgently!\n",
dev->name);
}
}
代码的其余部分首先尝试获取传输锁。检查要使用的设备传输队列,以查看传输是否已停止。如果没有,则增加变量并将数据传递到更靠近要传输的设备的位置。我们稍后会详细介绍。一旦完成,将释放锁并打印警告。 xmit_recursion
dev_hard_start_xmit
或者,如果当前 CPU 是传输锁所有者,或者如果命中,则不进行传输,但会打印警告。函数中的其余代码设置错误代码并返回。 RECURSION_LIMIT
因为我们对真正的以太网设备感兴趣,所以我们继续沿着之前通过的代码路径前进。 __dev_xmit_skb
__dev_xmit_skb
现在我们从./net/core/dev.c开始,了解排队规则、网络设备和传输队列引用: __dev_xmit_skb
static inline int __dev_xmit_skb(struct sk_buff *skb, struct Qdisc *q,
struct net_device *dev,
struct netdev_queue *txq)
{
spinlock_t *root_lock = qdisc_lock(q);
bool contended;
int rc;
qdisc_pkt_len_init(skb);
qdisc_calculate_pkt_len(skb, q);
/*
* Heuristic to force contended enqueues to serialize on a
* separate lock before trying to get qdisc main lock.
* This permits __QDISC_STATE_RUNNING owner to get the lock more often
* and dequeue packets faster.
*/
contended = qdisc_is_running(q);
if (unlikely(contended))
spin_lock(&q->busylock);
此代码首先使用和计算 qdisc 稍后将使用的数据的准确长度。这对于将通过基于硬件的发送卸载(例如我们之前看到的 UDP 碎片卸载)的 skb 是必要的,因为需要考虑发生碎片时将添加的额外标头。 qdisc_pkt_len_init
qdisc_calculate_pkt_len
接下来,使用锁来帮助减少对 qdisc 主锁的争用(我们稍后会看到第二个锁)。如果 qdisc 当前正在运行,则尝试传输的其他程序将争用 qdisc 的。这允许正在运行的 qdisc 处理数据包并与较少数量的程序争用第二个主锁。随着竞争者数量的减少,此技巧可提高吞吐量。您可以在此处阅读描述此操作的原始提交消息。接下来获取主锁: busylock
spin_lock(root_lock);
现在,我们来看一下 if 语句,它处理 3 种可能的情况:
- qdisc 已停用。
- qdisc 允许数据包绕过排队系统,没有其他数据包要发送,并且 qdisc 当前未运行。qdisc 允许数据包绕过“工作节约型”qdisc - 换句话说,qdisc 不会为了流量整形而延迟数据包传输。
- 所有其他情况。
让我们按照停用的 qdisc 的顺序看一下每种情况下发生的情况:
if (unlikely(test_bit(__QDISC_STATE_DEACTIVATED, &q->state))) {
kfree_skb(skb);
rc = NET_XMIT_DROP;
这很简单。如果 qdisc 已停用,则释放数据并将返回代码设置为。接下来,允许数据包旁路的 qdisc,没有其他未完成的数据包,当前未运行: NET_XMIT_DROP
} else if ((q->flags & TCQ_F_CAN_BYPASS) && !qdisc_qlen(q) &&
qdisc_run_begin(q)) {
/*
* This is a work-conserving queue; there are no old skbs
* waiting to be sent out; and the qdisc is not running -
* xmit the skb directly.
*/
if (!(dev->priv_flags & IFF_XMIT_DST_RELEASE))
skb_dst_force(skb);
qdisc_bstats_update(q, skb);
if (sch_direct_xmit(skb, q, dev, txq, root_lock)) {
if (unlikely(contended)) {
spin_unlock(&q->busylock);
contended = false;
}
__qdisc_run(q);
} else
qdisc_run_end(q);
rc = NET_XMIT_SUCCESS;
这个 if 语句有点棘手。整个语句的计算结果都假设以下所有条件都为真: true
q->flags & TCQ_F_CAN_BYPASS
:qdisc 允许数据包绕过排队系统。这对于“工作节约型”qdisc 来说是正确的;也就是说,不会为了流量整形而延迟数据包传输的 qdisc 被视为“工作节约型”并允许数据包绕过。qdisc允许数据包绕过排队系统。pfifo_fast
!qdisc_qlen(q)
:qdisc 的队列中没有等待传输的数据。qdisc_run_begin(p)
:此函数调用将把 qdisc 的状态设置为“正在运行”并返回 true,如果 qdisc 已在运行则返回 false。
如果以上所有条件都成立,则:
- 检查该标志。如果启用,则该标志表示允许内核释放 skb 的目标缓存结构。此函数中的代码检查该标志是否被禁用,并强制对该结构进行引用计数。
IFF_XMIT_DST_RELEASE
qdisc_bstats_update
用于增加 qdisc 发送的字节数和数据包的数量。sch_direct_xmit
用于尝试传输数据包。我们稍后会深入探讨,因为它也用于较慢的代码路径。sch_direct_xmit
检查两种情况的返回值: sch_direct_xmit
- 队列不为空(
>0
返回)。在这种情况下,将释放防止其他程序争用的锁,并调用它来重新启动 qdisc 处理。__qdisc_run
- 队列为空(
0
返回)。在这种情况下用于关闭 qdisc 处理。qdisc_run_end
无论哪种情况,返回值都会设置为返回代码。这还不错。让我们检查最后一种情况,即 catch all: NET_XMIT_SUCCESS
} else {
skb_dst_force(skb);
rc = q->enqueue(skb, q) & NET_XMIT_MASK;
if (qdisc_run_begin(q)) {
if (unlikely(contended)) {
spin_unlock(&q->busylock);
contended = false;
}
__qdisc_run(q);
}
}
在所有其他情况下:
- 调用以强制在 skb 的目标缓存引用上增加引用计数。
skb_dst_force
- 调用queue disc函数将数据排队到qdisc中。存储返回码。
enqueue
- 调用此函数将 qdisc 标记为正在运行。如果尚未运行,则释放 并调用此函数启动 qdisc 处理。
qdisc_run_begin(p)
busylock
__qdisc_run(p)
然后,该函数通过释放一些锁并返回返回代码来完成:
spin_unlock(root_lock);
if (unlikely(contended))
spin_unlock(&q->busylock);
return rc;
调整:传输数据包控制 (XPS)
为了使 XPS 正常工作,必须在内核配置中启用它(在 Ubuntu 内核 3.13.0 中启用它),并使用位掩码描述哪些 CPU 应该处理给定接口和 TX 队列的数据包。
这些位掩码与RPS 位掩码类似,您可以在内核文档中找到有关这些位掩码的一些文档。
简而言之,需要修改的位掩码位于:
/sys/class/net/DEVICE_NAME/queues/QUEUE/xps_cpus
因此,对于 eth0 和传输队列 0,您需要修改文件:使用十六进制数指示哪些 CPU 应该处理来自的传输队列 0 的传输完成。正如文档指出的那样,在某些配置中 XPS 可能是不必要的。 /sys/class/net/eth0/queues/tx-0/xps_cpus
eth0
排队纪律!
要跟踪网络数据的路径,我们需要稍微了解一下 qdisc 代码。这篇文章不打算介绍每个不同传输队列选项的具体细节。如果您对此感兴趣,请查看这份出色的指南。
出于本篇博文的目的,我们将通过研究通用数据包调度程序代码的工作原理来继续代码路径。特别是,我们将探索、、和如何将网络数据移近驱动程序进行传输。 qdisc_run_begin
qdisc_run_end
__qdisc_run
sch_direct_xmit
让我们先来研究一下它的工作原理,然后再继续进行。 qdisc_run_begin
qdisc_run_begin
和 qdisc_run_end
该函数可以在./include/net/sch_generic.h中找到: qdisc_run_begin
static inline bool qdisc_run_begin(struct Qdisc *qdisc)
{
if (qdisc_is_running(qdisc))
return false;
qdisc->__state |= __QDISC___STATE_RUNNING;
return true;
}
此函数很简单:检查qdisc标志。如果它已在运行,则返回。否则,更新以启用该位。 __state
false
__state
__QDISC___STATE_RUNNING
同样,也是虎头蛇尾的: qdisc_run_end
static inline void qdisc_run_end(struct Qdisc *qdisc)
{
qdisc->__state &= ~__QDISC___STATE_RUNNING;
}
它只是禁用qdisc字段中的位。需要注意的是,这两个函数只是翻转位;实际上它们本身都不会启动或停止处理。另一方面,函数实际上会启动处理。 __QDISC___STATE_RUNNING
__state
__qdisc_run
__qdisc_run
代码看似简单: __qdisc_run
void __qdisc_run(struct Qdisc *q)
{
int quota = weight_p;
while (qdisc_restart(q)) {
/*
* Ordered by possible occurrence: Postpone processing if
* 1. we've exceeded packet quota
* 2. another process needs the CPU;
*/
if (--quota <= 0 || need_resched()) {
__netif_schedule(q);
break;
}
}
qdisc_run_end(q);
}
此函数首先获取值。这通常通过 sysctl 设置,也用于接收路径。我们稍后会看到如何调整此值。此循环执行两件事: weight_p
- 它会在一个繁忙的循环中调用,直到返回 false(或者触发下面的中断)。
qdisc_restart
- 确定配额是否降至零以下或返回 true。如果为,则调用 并跳出循环。
need_resched()
true
__netif_schedule
请记住:到目前为止,内核仍在执行用户程序最初调用的函数;用户程序目前正在累积系统时间。如果用户程序已用尽其在内核中的时间配额,则将返回 true。如果仍有可用配额,并且用户程序尚未用完其时间片,则将再次调用。 sendmsg
need_resched
qdisc_restart
让我们先看看它是如何工作的,然后我们再深入研究。 qdisc_restart(q)
__netif_schedule(q)
qdisc_restart
让我们看看代码: qdisc_restart
/*
* NOTE: Called under qdisc_lock(q) with locally disabled BH.
*
* __QDISC_STATE_RUNNING guarantees only one CPU can process
* this qdisc at a time. qdisc_lock(q) serializes queue accesses for
* this queue.
*
* netif_tx_lock serializes accesses to device driver.
*
* qdisc_lock(q) and netif_tx_lock are mutually exclusive,
* if one is grabbed, another must be free.
*
* Note, that this procedure can be called by a watchdog timer
*
* Returns to the caller:
* 0 - queue is empty or throttled.
* >0 - queue is not empty.
*
*/
static inline int qdisc_restart(struct Qdisc *q)
{
struct netdev_queue *txq;
struct net_device *dev;
spinlock_t *root_lock;
struct sk_buff *skb;
/* Dequeue packet */
skb = dequeue_skb(q);
if (unlikely(!skb))
return 0;
WARN_ON_ONCE(skb_dst_is_noref(skb));
root_lock = qdisc_lock(q);
dev = qdisc_dev(q);
txq = netdev_get_tx_queue(dev, skb_get_queue_mapping(skb));
return sch_direct_xmit(skb, q, dev, txq, root_lock);
}
该函数以一个有用的注释开头,描述了调用此函数的一些锁定约束。此函数执行的第一个操作是尝试从 qdisc 中出队 skb。 qdisc_restart
该函数将尝试获取下一个要传输的数据包。如果队列为空,则返回 false(导致上面的循环退出)。 dequeue_skb
qdisc_restart
__qdisc_run
假设有数据需要传输,代码将继续获取对 qdisc 队列锁、qdisc 关联设备和传输队列的引用。
所有这些都传递给了。我们先看一下,然后再回来。 sch_direct_xmit
dequeue_skb
sch_direct_xmit
dequeue_skb
我们来看看./net/sched/sch_generic.c。此函数处理两种主要情况: dequeue_skb
- 由于之前无法发送而重新排队的数据出队,或者
- 从 qdisc 中出队新数据以供处理。
我们先来看第一个案例:
static inline struct sk_buff *dequeue_skb(struct Qdisc *q)
{
struct sk_buff *skb = q->gso_skb;
const struct netdev_queue *txq = q->dev_queue;
if (unlikely(skb)) {
/* check the reason of requeuing without tx lock first */
txq = netdev_get_tx_queue(txq->dev, skb_get_queue_mapping(skb));
if (!netif_xmit_frozen_or_stopped(txq)) {
q->gso_skb = NULL;
q->q.qlen--;
} else
skb = NULL;
请注意,代码首先引用qdisc 的 字段。此字段保存对重新排队的数据的引用。如果没有数据重新排队,则此字段将为。如果该字段不是,则代码继续获取数据的传输队列并检查队列是否已停止。如果队列未停止,则清除该字段并减少队列长度计数器。如果队列已停止,数据仍附加到,但将从此函数返回。 gso_skb
NULL
NULL
gso_skb
gso_skb
NULL
让我们检查下一种情况,其中没有重新排队的数据:
} else {
if (!(q->flags & TCQ_F_ONETXQUEUE) || !netif_xmit_frozen_or_stopped(txq))
skb = q->dequeue(q);
}
return skb;
}
在没有数据重新排队的情况下,另一个棘手的复合 if 语句被评估。如果:
- qdisc 没有单个传输队列,或者
- 传输队列未停止
然后,将调用 qdisc 的函数来获取新数据。 的内部实现将根据 qdisc 的实现和功能而有所不同。 dequeue
dequeue
该函数最后返回需要处理的数据。
sch_direct_xmit
现在我们来看看(在./net/sched/sch_generic.c中),它是将数据向下移动到网络设备的重要参与者。让我们逐一介绍一下它: sch_direct_xmit
/*
* Transmit one skb, and handle the return status as required. Holding the
* __QDISC_STATE_RUNNING bit guarantees that only one CPU can execute this
* function.
*
* Returns to the caller:
* 0 - queue is empty or throttled.
* >0 - queue is not empty.
*/
int sch_direct_xmit(struct sk_buff *skb, struct Qdisc *q,
struct net_device *dev, struct netdev_queue *txq,
spinlock_t *root_lock)
{
int ret = NETDEV_TX_BUSY;
/* And release qdisc */
spin_unlock(root_lock);
HARD_TX_LOCK(dev, txq, smp_processor_id());
if (!netif_xmit_frozen_or_stopped(txq))
ret = dev_hard_start_xmit(skb, dev, txq);
HARD_TX_UNLOCK(dev, txq);
代码首先解锁 qdisc 锁,然后锁定传输锁。请注意,这是一个宏: HARD_TX_LOCK
#define HARD_TX_LOCK(dev, txq, cpu) { \
if ((dev->features & NETIF_F_LLTX) == 0) { \
__netif_tx_lock(txq, cpu); \
} \
}
此宏检查设备的功能标志中是否设置了该标志。此标志已弃用,不应由新设备驱动程序使用。此内核版本中的大多数驱动程序不使用此标志,因此此检查将评估为真,并将获得此数据的传输队列的锁定。 NETIF_F_LLTX
接下来,检查传输队列以确保它没有停止,然后调用它。正如我们稍后会看到的,它处理将网络数据从 Linux 内核的网络设备子系统转换到设备驱动程序本身以进行传输。此函数的返回代码被存储,接下来将检查它以确定传输是否成功。 dev_hard_start_xmit
dev_hard_start_xmit
一旦运行完毕(或由于队列停止而被跳过),队列的传输锁就会被释放。让我们继续:
spin_lock(root_lock);
if (dev_xmit_complete(ret)) {
/* Driver sent out skb successfully or skb was consumed */
ret = qdisc_qlen(q);
} else if (ret == NETDEV_TX_LOCKED) {
/* Driver try lock failed */
ret = handle_dev_cpu_collision(skb, txq, q);
接下来,再次获取此 qdisc 的锁,然后检查的返回值。通过调用来检查第一种情况,它只是检查返回值以确定数据是否已成功发送。如果是,则将 qdisc 队列长度设置为返回值。 dev_hard_start_xmit
dev_xmit_complete
如果返回 false,则将检查返回值以查看是否从设备驱动程序返回。带有已弃用功能标志的设备可以在驱动程序尝试自行锁定传输队列并失败时返回。在这种情况下,调用来处理锁定争用。我们稍后会仔细研究一下,但现在,让我们继续往下看,看看万能情况: dev_xmit_complete
dev_hard_start_xmit
NETDEV_TX_LOCKED
NETIF_F_LLTX
NETDEV_TX_LOCKED
handle_dev_cpu_collision
handle_dev_cpu_collision
sch_direct_xmit
} else {
/* Driver returned NETDEV_TX_BUSY - requeue skb */
if (unlikely(ret != NETDEV_TX_BUSY))
net_warn_ratelimited("BUG %s code %d qlen %d\n",
dev->name, ret, q->q.qlen);
ret = dev_requeue_skb(skb, q);
}
因此,如果驱动程序未传输数据,并且不是由于传输锁被持有,则可能是由于(如果不是,则会打印警告)。驱动程序可以返回,以指示设备或驱动程序“繁忙”,并且无法立即传输数据。在这种情况下,用于对要重试的数据进行排队。 NETDEV_TX_BUSY
NETDEV_TX_BUSY
dev_requeue_skb
该函数通过(可能)调整返回值来结束:
if (ret && netif_xmit_frozen_or_stopped(txq))
ret = 0;
return ret;
让我们深入了解一下。 handle_dev_cpu_collision
dev_requeue_skb
handle_dev_cpu_collision
来自./net/sched/sch_generic.c的代码处理两种情况: handle_dev_cpu_collision
- 传输锁由当前 CPU 持有。
- 传输锁由其他 CPU 持有。
在第一种情况下,这被视为配置问题,因此会打印警告。在第二种情况下,统计计数器会增加,数据会发送出去,以便稍后重新排队传输。回想一下,我们之前看到了专门处理重新排队的 skbs 的代码。 cpu_collision
dev_requeue_skb
dequeue_skb
代码很短,值得快速阅读: handle_dev_cpu_collision
static inline int handle_dev_cpu_collision(struct sk_buff *skb,
struct netdev_queue *dev_queue,
struct Qdisc *q)
{
int ret;
if (unlikely(dev_queue->xmit_lock_owner == smp_processor_id())) {
/*
* Same CPU holding the lock. It may be a transient
* configuration error, when hard_start_xmit() recurses. We
* detect it by checking xmit owner and drop the packet when
* deadloop is detected. Return OK to try the next skb.
*/
kfree_skb(skb);
net_warn_ratelimited("Dead loop on netdevice %s, fix it urgently!\n",
dev_queue->dev->name);
ret = qdisc_qlen(q);
} else {
/*
* Another cpu is holding lock, requeue & delay xmits for
* some time.
*/
__this_cpu_inc(softnet_data.cpu_collision);
ret = dev_requeue_skb(skb, q);
}
return ret;
}
让我们看一看它的作用是什么,因为我们将看到这个函数被调用。 dev_requeue_skb
sch_direct_xmit
dev_requeue_skb
值得庆幸的是,源代码很简短,直奔主题,来自./net/sched/sch_generic.c: dev_requeue_skb
/* Modifications to data participating in scheduling must be protected with
* qdisc_lock(qdisc) spinlock.
*
* The idea is the following:
* - enqueue, dequeue are serialized via qdisc root lock
* - ingress filtering is also serialized via qdisc root lock
* - updates to tree and tree walking are only done under the rtnl mutex.
*/
static inline int dev_requeue_skb(struct sk_buff *skb, struct Qdisc *q)
{
skb_dst_force(skb);
q->gso_skb = skb;
q->qstats.requeues++;
q->q.qlen++; /* it's still part of the queue */
__netif_schedule(q);
return 0;
}
此函数做了几件事:
- 它强制对 skb 进行引用计数。
- 它将 skb 附加到 qdisc 的字段。回想一下,我们之前看到过,在数据从 qdisc 的队列中拉出之前会检查此字段。
gso_skb
dequeue_skb
- 统计计数器被撞。
- 队列的大小增加了。
__netif_schedule
叫做。
简单明了。让我们回顾一下我们是如何到达这里的,然后检查一下。 __netif_schedule
提醒一下,while 循环 __qdisc_run
回想一下,我们是通过检查包含以下代码的函数到达这里的: __qdisc_run
void __qdisc_run(struct Qdisc *q)
{
int quota = weight_p;
while (qdisc_restart(q)) {
/*
* Ordered by possible occurrence: Postpone processing if
* 1. we've exceeded packet quota
* 2. another process needs the CPU;
*/
if (--quota <= 0 || need_resched()) {
__netif_schedule(q);
break;
}
}
qdisc_run_end(q);
}
此代码通过反复调用循环来工作,在内部,循环将 skbs 移出队列,然后尝试通过调用 来传输它们,然后调用来让驱动程序执行实际传输。任何无法传输的内容都会重新排队,以便在软中断中传输。 qdisc_restart
sch_direct_xmit
dev_hard_start_xmit
NET_TX
传输过程的下一步是检查如何调用驱动程序来发送数据。在此之前,我们应该检查并充分了解和的工作原理。 dev_hard_start_xmit
__netif_schedule
__qdisc_run
dev_requeue_skb
__netif_schedule
让我们从./net/core/dev.c开始: __netif_schedule
void __netif_schedule(struct Qdisc *q)
{
if (!test_and_set_bit(__QDISC_STATE_SCHED, &q->state))
__netif_reschedule(q);
}
EXPORT_SYMBOL(__netif_schedule);
此代码检查并设置qdisc 状态中的位。如果位被翻转(意味着它之前不处于该状态),代码将调用,这不会太长,但有非常有趣的副作用。让我们来看看: __QDISC_STATE_SCHED
__QDISC_STATE_SCHED
__netif_reschedule
static inline void __netif_reschedule(struct Qdisc *q)
{
struct softnet_data *sd;
unsigned long flags;
local_irq_save(flags);
sd = &__get_cpu_var(softnet_data);
q->next_sched = NULL;
*sd->output_queue_tailp = q;
sd->output_queue_tailp = &q->next_sched;
raise_softirq_irqoff(NET_TX_SOFTIRQ);
local_irq_restore(flags);
}
这个函数做了几件事:
- 通过调用 保存当前本地 IRQ 状态并禁用 IRQ 。
local_irq_save
- 获取当前 CPU结构。
softnet_data
- 将 qdisc 添加到的输出队列。
softnet_data
- 提高软中断。
NET_TX_SOFTIRQ
- 恢复 IRQ 状态并重新允许中断。
您可以通过阅读我们之前有关网络堆栈接收端的帖子来了解有关数据结构初始化的更多信息。 softnet_data
上述函数中最重要的代码是:触发软中断。软中断及其注册在我们之前的文章中也有介绍。简而言之,你可以认为软中断是内核线程,它们以极高的优先级执行并代表内核处理数据。它们用于处理传入的网络数据,也用于处理传出的数据。 raise_softirq_irqoff
NET_TX_SOFTIRQ
正如您在上一篇文章中看到的,softirq已注册了该函数。这意味着有一个内核线程正在执行。该线程偶尔会暂停并恢复。让我们看看它做了什么,以便我们了解内核如何处理传输请求。 NET_TX_SOFTIRQ
net_tx_action
net_tx_action
raise_softirq_irqoff
net_tx_action
net_tx_action
./net/core/dev.c中的函数在运行时处理两件主要的事情: net_tx_action
- 执行 CPU 的结构的完成队列。
softnet_data
- 执行 CPU 的结构的输出队列。
softnet_data
实际上,该函数的代码是两个大的 if 块。让我们一次看一个,同时记住此代码在 softirq 上下文中作为独立内核线程执行。的目的是执行无法在网络堆栈传输端的热路径中执行的代码;工作被推迟并稍后由执行的线程处理。 net_tx_action
net_tx_action
net_tx_action
完成队列
的完成队列只是一个等待释放的 skb 队列。该函数可用于将 skb 添加到队列中,以便稍后释放。设备驱动程序通常使用此方法来推迟释放已使用的 skb。驱动程序之所以希望推迟释放 skb 而不是直接释放 skb,是因为释放内存需要时间,并且在某些情况下(例如 hardirq 处理程序),代码需要尽快执行并返回。 softnet_data
dev_kfree_skb_irq
看一下处理释放完成队列中的 skbs 的代码: net_tx_action
if (sd->completion_queue) {
struct sk_buff *clist;
local_irq_disable();
clist = sd->completion_queue;
sd->completion_queue = NULL;
local_irq_enable();
while (clist) {
struct sk_buff *skb = clist;
clist = clist->next;
WARN_ON(atomic_read(&skb->users));
trace_kfree_skb(skb, net_tx_action);
__kfree_skb(skb);
}
}
如果完成队列有条目,循环将遍历 skb 的链接列表并调用每个 skb 来释放内存。请记住,此代码在名为 softirq 的单独“线程”中运行 - 它不代表任何特定的用户程序运行。 while
__kfree_skb
net_tx_action
输出队列
输出队列的用途完全不同。如前所述,通过调用(通常是从 调用)将数据添加到输出队列。到目前为止,我们已经看到过两个实例调用该函数: __netif_reschedule
__netif_schedule
__netif_schedule
dev_requeue_skb
:正如我们所见,如果驱动程序报告错误代码或者发生 CPU 冲突,则可以调用此函数。NETDEV_TX_BUSY
__qdisc_run
:我们之前也见过这个函数。当配额超出或进程需要重新调度时,它也会调用。__netif_schedule
无论是哪种情况,都会调用该函数,将 qdisc 添加到的输出队列进行处理。我将输出队列处理代码分成三个块。我们来看看第一个: __netif_schedule
softnet_data
if (sd->output_queue) {
struct Qdisc *head;
local_irq_disable();
head = sd->output_queue;
sd->output_queue = NULL;
sd->output_queue_tailp = &sd->output_queue;
local_irq_enable();
这个块只是确保输出队列上有 qdisc,如果是这样,它就设置为第一个条目并移动队列的尾指针。 head
接下来,开始遍历 qdsics 列表的循环: while
while (head) {
struct Qdisc *q = head;
spinlock_t *root_lock;
head = head->next_sched;
root_lock = qdisc_lock(q);
if (spin_trylock(root_lock)) {
smp_mb__before_clear_bit();
clear_bit(__QDISC_STATE_SCHED,
&q->state);
qdisc_run(q);
spin_unlock(root_lock);
上面这段代码将头指针向前移动,并获取对 qdisc 锁的引用。用于检查是否可以获取锁;请注意,此调用是专门用来获取锁的,因为它不会阻塞。 如果锁已被持有,将立即返回,而不是等待获取锁。 spin_trylock
spin_trylock
如果成功获得锁,它将返回一个非零值。在这种情况下,qdisc 的状态字段的位被翻转,并且被调用,这将翻转位并开始执行。 spin_trylock
__QDISC_STATE_SCHED
qdisc_run
__QDISC___STATE_RUNNING
__qdisc_run
这很重要。这里发生的事情是,我们之前检查过的代表用户发出的系统调用运行的处理循环现在再次运行,但在软中断上下文中,因为此 qdisc 的 skb 传输无法传输。这种区别很重要,因为它会影响您如何监控发送大量数据的应用程序的 CPU 使用率。让我换一种说法:
- 程序的系统时间将包括调用驱动程序尝试发送数据所花费的时间,无论发送是否完成或驱动程序是否返回错误。
- 如果该发送在驱动程序层失败(例如,因为设备正忙于发送其他内容),则 qdisc 将被添加到输出队列,稍后由软中断线程处理。在这种情况下,软中断 (si) 时间将用于尝试传输数据。
因此,发送数据所花费的总时间是发送相关系统调用的系统时间和软中断的软中断时间的组合。 NET_TX
无论如何,上面的代码通过释放 qdisc 锁来完成。如果上面的调用失败以获取锁,则执行以下代码: spin_trylock
} else {
if (!test_bit(__QDISC_STATE_DEACTIVATED,
&q->state)) {
__netif_reschedule(q);
} else {
smp_mb__before_clear_bit();
clear_bit(__QDISC_STATE_SCHED,
&q->state);
}
}
}
}
此代码仅在无法获取 qdisc 锁时执行,处理两种情况。要么:
- qdisc 未停用,但无法获取执行 的锁。因此,调用。此处调用会将 qdisc 放回到此函数当前正在出队的队列中。这样,当锁可能已被放弃时,稍后可以再次检查 qdisc。
qdisc_run
__netif_reschedule
__netif_reschedule
- qdisc 被标记为已停用,请确保状态标志也被清除。
__QDISC_STATE_SCHED
终于可以见见我们的朋友了 dev_hard_start_xmit
因此,我们已经遍历了整个网络堆栈。您可能直接从系统调用到达这里,或者通过处理 qdisc 上的网络数据的软中断线程到达这里。将调用设备驱动程序来实际执行传输操作。 dev_hard_start_xmit
sendmsg
dev_hard_start_xmit
该函数处理两种主要情况: dev_hard_start_xmit
- 已准备好发送的网络数据,或
- 需要处理分段卸载的网络数据。
我们将看看这两种情况的处理方式,首先是准备发送的网络数据的情况。让我们来看一下(请继续:./net /code/dev.c :
int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev,
struct netdev_queue *txq)
{
const struct net_device_ops *ops = dev->netdev_ops;
int rc = NETDEV_TX_OK;
unsigned int skb_len;
if (likely(!skb->next)) {
netdev_features_t features;
/*
* If device doesn't need skb->dst, release it right now while
* its hot in this cpu cache
*/
if (dev->priv_flags & IFF_XMIT_DST_RELEASE)
skb_dst_drop(skb);
features = netif_skb_features(skb);
此代码首先使用 获取对设备驱动程序公开操作的引用。稍后在让驱动程序执行一些工作以传输数据时将使用它。代码检查以确保此数据不是分段准备就绪的数据链的一部分,然后继续执行以下两项操作: ops
skb->next
- 首先,它检查设备上是否设置了该标志。此内核中的任何“真实”以太网设备都不使用此标志。但它由环回设备和一些其他软件设备使用。如果启用此标志,则可以减少目标缓存条目上的引用计数,因为驱动程序不需要它。
IFF_XMIT_DST_RELEASE
- 接下来,用于从设备获取功能标志,并根据数据目的地协议对其进行一些修改()。例如,如果协议是设备可以进行校验和的协议,则 skb 将标记为此类协议。VLAN 标记(如果已设置)也会导致其他功能标志被翻转。
netif_skb_features
dev->protocol
接下来,将检查 VLAN 标记,如果设备无法卸载 VLAN 标记,则将使用软件执行此操作: __vlan_put_tag
if (vlan_tx_tag_present(skb) &&
!vlan_hw_offload_capable(features, skb->vlan_proto)) {
skb = __vlan_put_tag(skb, skb->vlan_proto,
vlan_tx_tag_get(skb));
if (unlikely(!skb))
goto out;
skb->vlan_tci = 0;
}
随后,将检查数据是否为封装卸载请求(例如,针对GRE)。在这种情况下,功能标记将更新,以包含任何可用的设备特定硬件封装功能:
/* If encapsulation offload request, verify we are testing
* hardware encapsulation features instead of standard
* features for the netdev
*/
if (skb->encapsulation)
features &= dev->hw_enc_features;
接下来,用于确定 skb 本身是否需要分段。如果 skb 需要分段,但设备不支持,则将返回,表明分段应在软件中进行。在这种情况下,调用 进行分段,代码将跳转到传输数据包。我们稍后会看到 GSO 路径。 netif_needs_gso
netif_needs_gso
true
dev_gso_segment
gso
if (netif_needs_gso(skb, features)) {
if (unlikely(dev_gso_segment(skb, features)))
goto out_kfree_skb;
if (skb->next)
goto gso;
}
如果数据不需要分段,则需要处理其他几种情况。首先:数据是否需要线性化?也就是说,如果数据分散在多个缓冲区中,设备是否支持发送网络数据,还是需要先将它们全部合并到单个线性缓冲区中?绝大多数网卡不需要在传输前对数据进行线性化,因此在几乎所有情况下,这都会被评估为 false 并被跳过。
else {
if (skb_needs_linearize(skb, features) &&
__skb_linearize(skb))
goto out_kfree_skb;
接下来提供了一个有用的注释,解释了下一种情况。将检查数据包以确定它是否仍需要校验和。如果设备不支持校验和,则现在将在软件中生成校验和:
/* If packet is not checksummed and device does not
* support checksumming for this protocol, complete
* checksumming here.
*/
if (skb->ip_summed == CHECKSUM_PARTIAL) {
if (skb->encapsulation)
skb_set_inner_transport_header(skb,
skb_checksum_start_offset(skb));
else
skb_set_transport_header(skb,
skb_checksum_start_offset(skb));
if (!(features & NETIF_F_ALL_CSUM) &&
skb_checksum_help(skb))
goto out_kfree_skb;
}
}
现在我们来谈谈数据包分接头!回想一下接收端博客文章,我们了解了数据包是如何传递到数据包分接头(如PCAP)的。此函数中的下一段代码将即将传输的数据包传递给数据包分接头(如果有的话)。
if (!list_empty(&ptype_all))
dev_queue_xmit_nit(skb, dev);
最后,驱动程序通过调用以下命令将数据传递给设备: ops
ndo_start_xmit
skb_len = skb->len;
rc = ops->ndo_start_xmit(skb, dev);
trace_net_dev_xmit(skb, rc, dev, skb_len);
if (rc == NETDEV_TX_OK)
txq_trans_update(txq);
return rc;
}
返回值指示数据包是否已传输。我们看到了这个返回值将如何影响上层:数据可能会被此函数上方的 qdisc 重新排队,以便稍后再次传输。 ndo_start_xmit
我们来看看 GSO 的情况。如果 skb 已经由于此函数中发生的分段而被分成一串数据包,或者之前已分段但发送失败并排队等待再次发送的数据包,则此代码将运行。
gso:
do {
struct sk_buff *nskb = skb->next;
skb->next = nskb->next;
nskb->next = NULL;
if (!list_empty(&ptype_all))
dev_queue_xmit_nit(nskb, dev);
skb_len = nskb->len;
rc = ops->ndo_start_xmit(nskb, dev);
trace_net_dev_xmit(nskb, rc, dev, skb_len);
if (unlikely(rc != NETDEV_TX_OK)) {
if (rc & ~NETDEV_TX_MASK)
goto out_kfree_gso_skb;
nskb->next = skb->next;
skb->next = nskb;
return rc;
}
txq_trans_update(txq);
if (unlikely(netif_xmit_stopped(txq) && skb->next))
return NETDEV_TX_BUSY;
} while (skb->next);
您可能已经猜到了,此代码是一个 while 循环,它遍历数据分段时生成的 skb 列表。
每个数据包为:
- 通过数据包分接头(如果有的话)。
- 通过驱动器进行传输。
ndo_start_xmit
传输数据包时出现的任何错误都可以通过调整需要发送的 skbs 列表来处理。错误将返回到堆栈上层,未发送的 skbs 可能会重新排队以便稍后再次发送。
此函数的最后一部分负责在发生上述任何错误时清理并释放数据:
out_kfree_gso_skb:
if (likely(skb->next == NULL)) {
skb->destructor = DEV_GSO_CB(skb)->destructor;
consume_skb(skb);
return rc;
}
out_kfree_skb:
kfree_skb(skb);
out:
return rc;
}
EXPORT_SYMBOL_GPL(dev_hard_start_xmit);
在继续讨论设备驱动程序之前,让我们先看一下可以对刚刚介绍过的代码进行的一些监控和调整。
监控队列规定
使用命令行工具 tc
使用以下方式监控 qdisc 统计数据 tc
$ tc -s qdisc show dev eth1
qdisc mq 0: root
Sent 31973946891907 bytes 2298757402 pkt (dropped 0, overlimits 0 requeues 1776429)
backlog 0b 0p requeues 1776429
为了监控系统的数据包传输健康状况,检查连接到网络设备的队列规则的统计信息至关重要。您可以通过运行命令行工具来检查状态。上面的示例显示了如何检查接口的统计信息。 tc
eth1
bytes
:推送到驱动程序进行传输的字节数。pkt
:推送到驱动程序进行传输的数据包数量。dropped
:qdisc 丢弃的数据包数量。如果传输队列长度不足以容纳排队的数据,就会发生这种情况。overlimits
:取决于排队规则,但可以是由于达到限制而无法入队的数据包数量,和/或出队时触发限制事件的数据包数量。requeues
:已调用重新排队 skb 的次数。请注意,多次重新排队的 skb 每次重新排队时都会增加此计数器。dev_requeue_skb
backlog
:qdisc 队列中当前的字节数。每次将数据包放入队列时,此数字通常都会增加。
一些 qdsics 可能会导出额外的统计数据。每个 qdisc 都不同,并且可能会在不同时间触发这些计数器。您可能需要研究您正在使用的 qdisc 的源代码,以准确了解这些值在您的系统上何时可以增加,以帮助了解对您造成的后果。
调整队列规定
增加处理重量 __qdisc_run
您可以调整前面看到的循环权重(上面看到的变量),这将导致执行更多调用。结果将是当前 qdisc多次添加到当前 CPU 的列表中,这应该会导致对传输数据包进行额外的处理。 __qdisc_run
quota
__netif_schedule
output_queue
例如:使用“sysctl”增加所有 qdisc 的“__qdisc_run”配额。
$ sudo sysctl -w net.core.dev_weight=600
增加传输队列长度
每个网络设备都有一个可以修改的调节旋钮。大多数 qdisc 会在将最终应由 qdisc 传输的数据放入队列时检查设备是否有足够的字节。您可以调整其参数以增加 qdisc 可以放入队列的字节数。 txqueuelen
txqueuelen
例如:将eth0
的txqueuelen
增加到10000
。
$ sudo ifconfig eth0 txqueuelen 10000
以太网设备的默认值为。您可以通过读取 的输出来检查网络设备的。 1000
txqueuelen
ifconfig