详情请进入 湖南阳光电子学校 已关注:人 咨询电话:13807313137 微信号:yp941688, yp94168
因为要对百万、千万、甚至是过亿的用户提供各种网络服务,所以在一线互联网企业里面试和晋升后端开发同学的其中一个重点要求就是要能支撑高并发,要理解性能开销,会进行性能优化。而很多时候,如果你对Linux底层的理解不深的话,遇到很多线上性能瓶颈你会觉得狗拿刺猬,无从下手。
printf("Receive from client:%s\n", buff);
}
上面代码是一段udp server接收收据的逻辑。当在开发视角看的时候,只要客户端有对应的数据发送过来,服务器端执行recv_from后就能收到它,并把它打印出来。我们现在想知道的是,当网络达到网卡,直到我们的recvfrom收到数据,这中间,究竟都发生过什么? 通过本文,你将深入理解Linux网络系统内部是如何实现的,以及各个部分之间如何交互。相信这对你的工作将会有非常大的帮助。本文基于Linux 3.10,源代码参见https://mirrors.edge.kernel.org/pub/linux/kernel/v3.x/,网卡驱动采用Intel的igb网卡举例。 一 Linux网络收总览 在TCP/IP网络分层模型里,整个协议栈被分成了物理层、链路层、网络层,传输层和应用层。物理层对应的是网卡和网线,应用层对应的是我们常见的Nginx,FTP等等各种应用。Linux实现的是链路层、网络层和传输层这三层。//file: kernel/softirq.c
static struct smp_hotplug_thread softirq_threads = {
.store = &ksoftirqd,return 0;
}
early_initcall(spawn_ksoftirqd);
当ksoftirqd被创建出来以后,它就会进入自己的线程循环函数ksoftirqd_should_run和run_ksoftirqd了。不停地判断有没有软中断需要被处理。这里需要注意的一点是,软中断不仅仅只有网络软中断,还有其它类型。 //file: include/linux/interrupt.h enum{};
2.2 网络子系统初始化 图4 网络子系统初始化 linux内核通过调用subsys_initcall来初始化各个子系统,在源代码目录里你可以grep出许多对这个函数的调用。这里我们要说的是网络子系统的初始化,会执行到net_dev_init函数。//file: net/core/dev.c
static int __init net_dev_init(void){
......open_softirq(NET_RX_SOFTIRQ, net_rx_action);
}
subsys_initcall(net_dev_init);
在这个函数里,会为每个CPU都申请一个softnet_data数据结构,在这个数据结构里的poll_list是等待驱动程序将其poll函数注册进来,稍后网卡驱动初始化的时候我们可以看到这一过程。 另外open_softirq注册了每一种软中断都注册一个处理函数。NET_TX_SOFTIRQ的处理函数为net_tx_action,NET_RX_SOFTIRQ的为net_rx_action。继续跟踪open_softirq后发现这个注册的方式是记录在softirq_vec变量里的。后面ksoftirqd线程收到软中断的时候,也会使用这个变量来找到每一种软中断对应的处理函数。//file: kernel/softirq.c
void open_softirq(int nr, void (*action)(struct softirq_action *)){
softirq_vec[nr].action = action;
}
2.3 协议栈注册 内核实现了网络层的ip协议,也实现了传输层的tcp协议和udp协议。这些协议对应的实现函数分别是ip_rcv(),tcp_v4_rcv()和udp_rcv()。和我们平时写代码的方式不一样的是,内核是通过注册的方式来实现的。 Linux内核中的fs_initcall和subsys_initcall类似,也是初始化模块的入口。fs_initcall调用inet_init后开始网络协议栈注册。通过inet_init,将这些函数注册到了inet_protos和ptype_base数据结构中了。如下图: 图5 AF_INET协议栈注册 相关代码如下://file: net/ipv4/af_inet.c
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),.netns_ok = 1,
};
staticint __init inet_init(void){
......}
这里我们需要记住inet_protos记录着udp,tcp的处理函数地址,ptype_base存储着ip_rcv()函数的处理地址。后面我们会看到软中断中会通过ptype_base找到ip_rcv函数地址,进而将ip正确地送到ip_rcv()中执行。在ip_rcv中将会通过inet_protos找到tcp或者udp的处理函数,再而把转发给udp_rcv()或tcp_v4_rcv()函数。 扩展一下,如果看一下ip_rcv和udp_rcv等函数的代码能看到很多协议的处理过程。例如,ip_rcv中会处理netfilter和iptable过滤,如果你有很多或者很复杂的 netfilter 或 iptables 规则,这些规则都是在软中断的上下文中执行的,会加大网络延迟。再例如,udp_rcv中会判断socket接收队列是否满了。对应的相关内核参数是net.core.rmem_max和net.core.rmem_default。如果有兴趣,建议大家好好读一下inet_init这个函数的代码。 2.4 网卡驱动初始化 每一个驱动程序(不仅仅只是网卡驱动)会使用 module_init 向内核注册一个初始化函数,当驱动被加载时,内核会调用这个函数。比如igb网卡驱动的代码位于drivers/net/ethernet/intel/igb/igb_main.c//file: drivers/net/ethernet/intel/igb/igb_main.c
static struct pci_driver igb_driver = {
.name = igb_driver_name,......
};
staticint __init igb_init_module(void){
......return ret;
}
驱动的pci_register_driver调用完成后,Linux内核就知道了该驱动的相关信息,比如igb网卡驱动的igb_driver_name和igb_probe函数地址等等。当网卡设备被识别以后,内核会调用其驱动的probe方法(igb_driver的probe方法是igb_probe)。 驱动probe方法执行的目的就是让设备ready,对于igb网卡,其igb_probe位于drivers/net/ethernet/intel/igb/igb_main.c下。主要执行的操作如下: 图6 网卡驱动初始化 第5步中我们看到,网卡驱动实现了ethtool所需要的接口,也在这里注册完成函数地址的注册。当 ethtool 发起一个系统调用之后,内核会找到对应操作的回调函数。对于igb网卡来说,其实现函数都在drivers/net/ethernet/intel/igb/igb_ethtool.c下。 相信你这次能彻底理解ethtool的工作原理了吧?这个命令之所以能查看网卡收发统计、能修改网卡自适应模式、能调整RX 队列的数量和大小,是因为ethtool命令 终调用到了网卡驱动的相应方法,而不是ethtool本身有这个超能力。 第6步注册的igb_netdev_ops中含的是igb_open等函数,该函数在网卡被启动的时候会被调用。//file: drivers/net/ethernet/intel/igb/igb_main.c
staticconststruct net_device_ops igb_netdev_ops = {
.ndo_open = igb_open,.ndo_do_ioctl = igb_ioctl,
......
第7步中,在igb_probe初始化过程中,还调用到了igb_alloc_q_vector。他注册了一个NAPI机制所必须的poll函数,对于igb网卡驱动来说,这个函数就是igb_poll,如下代码所示。 static int igb_alloc_q_vector(struct igb_adapter *adapter,igb_poll, 64);
}
2.5 启动网卡 当上面的初始化都完成以后,就可以启动网卡了。回忆前面网卡驱动初始化时,我们提到了驱动向内核注册了 structure net_device_ops 变量,它含着网卡启用、发、设置mac 地址等回调函数(函数指针)。当启用一个网卡时(例如,通过 ifconfig eth0 up),net_device_ops 中的 igb_open方法会被调用。它通常会做以下事情: 图7 启动网卡//file: drivers/net/ethernet/intel/igb/igb_main.c
static int __igb_open(struct net_device *netdev, bool resuming){
......
}
在上面__igb_open函数调用了igb_setup_all_tx_resources,和igb_setup_all_rx_resources。在igb_setup_all_rx_resources这一步操作中,分配了RingBuffer,并建立内存和Rx队列的映射关系。(Rx Tx 队列的数量和大小可以通过 ethtool 进行配置)。我们再接着看中断函数注册igb_request_irq: static int igb_request_irq(struct igb_adapter *adapter){}
}
staticintigb_request_msix(struct igb_adapter *adapter){
......//file: drivers/net/ethernet/intel/igb/igb_main.c
static irqreturn_t igb_msix_ring(int irq, void *data){
struct igb_q_vector *q_vector = data;return IRQ_HANDLED;
}
igb_write_itr只是记录一下硬件中断频率(据说目的是在减少对CPU的中断频率时用到)。顺着napi_schedule调用一路跟踪下去,__napi_schedule=>____napi_schedule/* Called with irq disabled */
static inline void ____napi_schedule(struct softnet_data *sd,
struct napi_struct *napi){__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}
这里我们看到,list_add_tail修改了CPU变量softnet_data里的poll_list,将驱动napi_struct传过来的poll_list添加了进来。其中softnet_data中的poll_list是一个双向列表,其中的设备都带有输入帧等着被处理。紧接着__raise_softirq_irqoff触发了一个软中断NET_RX_SOFTIRQ, 这个所谓的触发过程只是对一个变量进行了一次或运算而已。 void __raise_softirq_irqoff(unsigned int nr){or_softirq_pending(1UL >= 1;
} while (pending);
}
在网络子系统初始化小节, 我们看到我们为NET_RX_SOFTIRQ注册了处理函数net_rx_action。所以net_rx_action函数就会被执行到了。 这里需要注意一个细节,硬中断中设置软中断标记,和ksoftirq的判断是否有软中断到达,都是基于smp_processor_id()的。这意味着只要硬中断在哪个CPU上被响应,那么软中断也是在这个CPU上处理的。 所以说,如果你发现你的Linux软中断CPU消耗都集中在一个核上的话,做法是要把调整硬中断的CPU亲和性,来将硬中断打散到不同的CPU核上去。 我们再来把精力集中到这个核心函数net_rx_action上来。 static void net_rx_action(struct softirq_action *h){}
}
函数开头的time_limit和budget是用来控制net_rx_action函数主动退出的,目的是保证网络的接收不霸占CPU不放。 等下次网卡再有硬中断过来的时候再处理剩下的接收数据。其中budget可以通过内核参数调整。 这个函数中剩下的核心逻辑是获取到当前CPU变量softnet_data,对其poll_list进行遍历, 然后执行到网卡驱动注册到的poll函数。对于igb网卡来说,就是igb驱动力的igb_poll函数了。 staticintigb_poll(struct napi_struct *napi, int budget){...
}
在读取操作中,igb_poll的重点工作是对igb_clean_rx_irq的调用。 static bool igb_clean_rx_irq(struct igb_q_vector *q_vector, const int budget){//file: net/core/dev.c
gro_result_t napi_gro_receive(struct napi_struct *napi, struct sk_buff *skb){
skb_gro_reset_offset(skb);return napi_skb_finish(dev_gro_receive(napi, skb), skb);
}
dev_gro_receive这个函数代表的是网卡GRO特性,可以简单理解成把相关的小合并成一个大就行,目的是减少传送给网络栈的数,这有助于减少 CPU 的使用量。我们暂且忽略,直接看napi_skb_finish, 这个函数主要就是调用了netif_receive_skb。//file: net/core/dev.c
static gro_result_t napi_skb_finish(gro_result_t ret, struct sk_buff *skb){
switch (ret) {......
}
在netif_receive_skb中,数据将被送到协议栈中。声明,以下的3.3, 3.4, 3.5也都属于软中断的处理过程,只不过由于篇幅太长,单独拿出来成小节。 3.3 网络协议栈处理 netif_receive_skb函数会根据的协议,假如是udp,会将依次送到ip_rcv(),udp_rcv()协议处理函数中进行处理。 图10 网络协议栈处理//file: net/core/dev.c
int netif_receive_skb(struct sk_buff *skb){
//RPS处理逻辑,先忽略 ......return __netif_receive_skb(skb);
}
static int __netif_receive_skb(struct sk_buff *skb){
......}
}
在__netif_receive_skb_core中,我看着原来经常使用的tcpdump的抓点,很是激动,看来读一遍源代码时间真的没白浪费。 接着__netif_receive_skb_core取出protocol,它会从数据中取出协议信息,然后遍历注册在这个协议上的回调函数列表。ptype_base是一个 hash table,在协议注册小节我们提到过。ip_rcv 函数地址就是存在这个 hash table中的。//file: net/core/dev.c
static inline int deliver_skb(struct sk_buff *skb,
struct packet_type *pt_prev,return pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
}
pt_prev->func这一行就调用到了协议层注册的处理函数了。对于ip来讲,就会进入到ip_rcv(如果是arp的话,会进入到arp_rcv)。 3.4 IP协议层处理 我们再来大致看一下linux在ip协议层都做了什么,又是怎么样进一步被送到udp或tcp协议处理函数中的。//file: net/ipv4/ip_input.c
int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt, struct net_device *orig_dev){
......ip_rcv_finish);
}
这里NF_HOOK是一个钩子函数,当执行完注册的钩子后就会执行到 后一个参数指向的函数ip_rcv_finish。 static int ip_rcv_finish(struct sk_buff *skb){return dst_input(skb);
}
跟踪ip_route_input_noref后看到它又调用了ip_route_input_mc。在ip_route_input_mc中,函数ip_local_deliver被赋值给了dst.input, 如下://file: net/ipv4/route.c
static int ip_route_input_mc(struct sk_buff *skb, __be32 daddr, __be32 saddr,u8 tos, struct net_device *dev, int our){
if (our) {}
}
所以回到ip_rcv_finish中的return dst_input(skb);。/* Input packet from network to transport. */
static inline int dst_input(struct sk_buff *skb){
return skb_dst(skb)->input(skb);
}
skb_dst(skb)->input调用的input方法就是路由子系统赋的ip_local_deliver。//file: net/ipv4/ip_input.c
int ip_local_deliver(struct sk_buff *skb){
/* * Reassemble IP fragments. */ip_local_deliver_finish);
}
staticintip_local_deliver_finish(struct sk_buff *skb){
......}
}
如协议注册小节看到inet_protos中保存着tcp_rcv()和udp_rcv()的函数地址。这里将会根据中的协议类型选择进行分发,在这里skb将会进一步被派送到更上层的协议中,udp和tcp。 3.5 UDP协议层处理 在协议注册小节的时候我们说过,udp协议的处理函数是udp_rcv。//file: net/ipv4/udp.c
int udp_rcv(struct sk_buff *skb){
return __udp4_lib_rcv(skb, &udp_table, IPPROTO_UDP);
}
int__udp4_lib_rcv(struct sk_buff *skb, struct udp_table *udptable,
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);
}
__udp4_lib_lookup_skb是根据skb来寻找对应的socket,当找到以后将数据放到socket的缓存队列里。如果没有找到,则发送一个目标不可达的icmp。//file: net/ipv4/udp.c
int udp_queue_rcv_skb(struct sock *sk, struct sk_buff *skb){
......return rc;
}
sock_owned_by_user判断的是用户是不是正在这个socker上进行系统调用(socket被占用),如果没有,那就可以直接放到socket的接收队列中。如果有,那就通过sk_add_backlog把数据添加到backlog队列。当用户释放的socket的时候,内核会检查backlog队列,如果有数据再移动到接收队列中。 sk_rcvqueues_full接收队列如果满了的话,将直接把丢弃。接收队列大小受内核参数net.core.rmem_max和net.core.rmem_default影响。 四 recvfrom系统调用 花开两朵,各表一枝。上面我们说完了整个Linux内核对数据的接收和处理过程, 后把数据放到socket的接收队列中了。那么我们再回头看用户进程调用recvfrom后是发生了什么。 我们在代码里调用的recvfrom是一个glibc的库函数,该函数在执行后会将用户进行陷入到内核态,进入到Linux实现的系统调用sys_recvfrom。 在理解Linux对sys_revvfrom之前,我们先来简单看一下socket这个核心数据结构。这个数据结构太大了,我们只把对和我们今天主题相关的内容画出来,如下://file: net/ipv4/af_inet.c
const struct proto_ops inet_stream_ops = {
............
}
const struct proto_ops inet_dgram_ops = {
............
}
socket数据结构中的另一个数据结构struct sock *sk是一个非常大,非常重要的子结构体。其中的sk_prot又定义了二级处理函数。对于UDP协议来说,会被设置成UDP协议实现的方法集udp_prot。//file: net/ipv4/udp.c
struct proto udp_prot = {
.name = "UDP",......
}
看完了socket变量之后,我们再来看sys_revvfrom的实现过程。 图12 recvfrom函数内部实现过程 在inet_recvmsg调用了sk->sk_prot->recvmsg。//file: net/ipv4/af_inet.c
int inet_recvmsg(struct kiocb *iocb, struct socket *sock, struct msghdr *msg,size_t size, int flags){
......return err;
}
上面我们说过这个对于udp协议的socket来说,这个sk_prot就是net/ipv4/udp.c下的struct proto udp_prot。由此我们找到了udp_recvmsg方法。//file:net/core/datagram.c:EXPORT_SYMBOL(__skb_recv_datagram);
struct sk_buff *__skb_recv_datagram(struct sock *sk, unsigned int flags,int*peeked, int*off, int*err){
......} while (!wait_for_more_packets(sk, err, &timeo, last));
}
终于我们找到了我们想要看的重点,在上面我们看到了所谓的读取过程,就是访问sk->sk_receive_queue。如果没有数据,且用户也允许等待,则将调用wait_for_more_packets()执行等待操作,它加入会让用户进程进入睡眠状态。 五 总结 网络模块是Linux内核中 复杂的模块了,看起来一个简简单单的收过程就涉及到许多内核组件之间的交互,如网卡驱动、协议栈,内核ksoftirqd线程等。看起来很复杂,本文想通过图示的方式,尽量以容易理解的方式来将内核收过程讲清楚。现在让我们再串一串整个收过程。 当用户执行完recvfrom调用后,用户进程就通过系统调用进行到内核态工作了。如果接收队列没有数据,进程就进入睡眠状态被操作系统挂起。这块相对比较简单,剩下大部分的戏份都是由Linux内核其它模块来表演了。 首先在开始收之前,Linux要做许多的准备工作:1. 创建ksoftirqd线程,为它设置好它自己的线程函数,后面指望着它来处理软中断呢
2. 协议栈注册,linux要实现许多协议,比如arp,icmp,ip,udp,tcp,每一个协议都会将自己的处理函数注册一下,方便来了迅速找到对应的处理函数
3. 网卡驱动初始化,每个驱动都有一个初始化函数,内核会让驱动也初始化一下。在这个初始化过程中,把自己的DMA准备好,把NAPI的poll函数地址告诉内核
4. 启动网卡,分配RX,TX队列,注册中断对应的处理函数
以上是内核准备收之前的重要工作,当上面都ready之后,就可以打开硬中断,等待数据的到来了。 当数据到来了以后,第一个迎接它的是网卡(我去,这不是废话么):1. 网卡将数据帧DMA到内存的RingBuffer中,然后向CPU发起中断通知
2. CPU响应中断请求,调用网卡启动时注册的中断处理函数
3. 中断处理函数几乎没干啥,就发起了软中断请求
4. 内核线程ksoftirqd线程发现有软中断请求到来,先关闭硬中断
5. ksoftirqd线程开始调用驱动的poll函数收
6. poll函数将收到的送到协议栈注册的ip_rcv函数中
7. ip_rcv函数再讲送到udp_rcv函数中(对于tcp就送到tcp_rcv)
现在我们可以回到开篇的问题了,我们在用户层看到的简单一行recvfrom,Linux内核要替我们做如此之多的工作,才能让我们顺利收到数据。这还是简简单单的UDP,如果是TCP,内核要做的工作更多,不由得感叹内核的开发者们真的是用心良苦。 理解了整个收过程以后,我们就能明确知道Linux收一个的CPU开销了。首先第一块是用户进程调用系统调用陷入内核态的开销。第二块是CPU响应的硬中断的CPU开销。第三块是ksoftirqd内核线程的软中断上下文花费的。后面我们再专门发一篇文章实际观察一下这些开销。 另外网络收发中有很多末支细节咱们并没有展开了说,比如说no NAPI, GRO,RPS等。因为我觉得说的太对了反而会影响大家对整个流程的把握,所以尽量只保留主框架了,少即是多!.(编辑:碑林电工培训学校)