文章目录
最近毕业了,所以整理下网络相关的八股文,本文全程都是基于linux的3.10版本,网卡都是采用intel的igb网卡驱动
一、内核如何接受网络包
- 以udp的代码举例
int main(){
int serverSocketFd = socket(AF_INET, SOCK_DGRAM, 0);
bind(serverSocketFd, ...);
char buff[BUFFSIZE];
int readCount = recvfrom(serverSocketFd, buff, BUFFSIZE, 0, ...);
buff[readCount] = '\0';
printf("Receive from client:%s\n", buff);
}
1)linux网络层收包总览(按TCP/IP分层)
-
背景知识了解(内核和网络设备驱动是通过中断的方式来处理的)
1)当设备上有数据到达的时候,会给CPU的相关引脚上触发一个电压变化,以通知CPU来处理数据
2)上半部是硬中断
只进行最简单的工作,快速处理然后释放CPU,接着CPU就可以允许其它中断进来。剩下将绝大部分的工作都放到下半部中,可以慢慢从容处理。(硬中断是通过给CPU物理引脚施加电压变化)
3)2.4以后的内核版本采用的下半部实现方式是软中断,由ksoftirqd内核线程在软中断中全权处理。(软中断
是通过给内存中的一个变量的二进制值以通知软中断处理程序。) -
问题:为什么不在中断函数中完成所有的处理?
对于网络模块来说,由于处理过程比较复杂和耗时
,如果在中断函数中完成所有的处理,将会导致中断处理函数(优先级过高)将过度占据CPU
,将导致CPU无法响应其它设备,例如鼠标和键盘的消息。因此Linux中断处理函数是分上半部和下半部的。 -
总体收包流程预览(后面的章节会更详细介绍)
1)网络驱动会以DMA的方式把网卡上收到的帧写到内存里。再向CPU发起一个中断,以通知CPU有数据到达
2)当CPU收到中断请求后,会去调用网络驱动注册的中断处理函数。网卡的中断处理函数并不做过多工作,发出软中断请求,然后尽快释放CPU。
3)ksoftirqd检测到有软中断请求到达,调用poll开始轮询收包,收到后交由各级协议栈处理
4)帧被从ringbuffer上保存成一个skb存储数据的结构体。对于UDP包来说,会被放到用户socket的接收队列中。
- 源码位置(网络模块)
在Linux的源代码中,网络设备驱动对应的逻辑位于driver/net/ethernet, 其中intel系列网卡的驱动在driver/net/ethernet/intel目录下。协议栈模块代码位于kernel和net目录。
2)linux启动预备流程(准备工作,初始化流程)
(1)创建ksoftirqd内核线程
Linux的软中断都是在专门的内核线程(ksoftirqd)中进行的,该进程数量不是1个,而是N个,其中N等于你的机器的核数。(博主服务器有四核)
系统初始化的时候在kernel/smpboot.c中调用了smpboot_register_percpu_thread, 该函数进一步会执行到spawn_ksoftirqd(位于kernel/softirq.c)来创建出softirqd进程。
- 相关代码如下:
//file: kernel/softirq.c
static struct smp_hotplug_thread softirq_threads = {
.store = &ksoftirqd,
.thread_should_run = ksoftirqd_should_run,
.thread_fn = run_ksoftirqd,
.thread_comm = "ksoftirqd/%u",};
static __init int spawn_ksoftirqd(void){
register_cpu_notifier(&cpu_nfb);
BUG_ON(smpboot_register_percpu_thread(&softirq_threads));
return 0;
}
early_initcall(spawn_ksoftirqd);
(2)网络子系统初始化(例如给ksoftirqd线程的变量绑定处理函数)
linux内核通过调用subsys_initcall来初始化各个子系统,在源代码目录里你可以grep出许多对这个函数的调用。这里我们要说的是网络子系统的初始化,会执行到net_dev_init函数。
//下面的init_module是个函数指针,是可以注册的
/* Each module must use one module_init(). */
#define module_init(initfn) \
static inline initcall_t __maybe_unused __inittest(void) \
{ return initfn; } \
int init_module(void) __attribute__((alias(#initfn)));
//--------------
//file: net/core/dev.c
static int __init net_dev_init(void){
......
for_each_possible_cpu(i) {
struct softnet_data *sd = &per_cpu(softnet_data, i);
memset(sd, 0, sizeof(*sd));
skb_queue_head_init(&sd->input_pkt_queue);
skb_queue_head_init(&sd->process_queue);
sd->completion_queue = NULL;
INIT_LIST_HEAD(&sd->poll_list);
......
}
......
open_softirq(NET_TX_SOFTIRQ, net_tx_action);
open_softirq(NET_RX_SOFTIRQ, net_rx_action);
}
subsys_initcall(net_dev_init);
在这个net_dev_init函数里,会为每个CPU都申请一个softnet_data数据结构,在这个数据结构里的poll_list是等待驱动程序将其poll函数注册进来,稍后网卡驱动初始化的时候我们可以看到这一过程。
- 软中断后,ksoftirqd线程根据
变量
找到对应的处理函数并执行
另外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;
}
变量枚举
(每个变量绑定对应的处理函数)
enum
{
HI_SOFTIRQ=0,
TIMER_SOFTIRQ,
NET_TX_SOFTIRQ, //绑定处理函数net_tx_action
NET_RX_SOFTIRQ, //绑定处理函数net_rx_action
BLOCK_SOFTIRQ,
IRQ_POLL_SOFTIRQ,
TASKLET_SOFTIRQ,
SCHED_SOFTIRQ,
HRTIMER_SOFTIRQ, /* Unused, but kept as tools rely on the
numbering. Sigh! */
RCU_SOFTIRQ, /* Preferable RCU should always be the last softirq */
NR_SOFTIRQS
};
(3)协议栈注册(对传输层的tcp、udp协议注册具体的实现函数)
内核实现了网络层的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数据结构中了。如下图:
- 相关代码如下
//file: net/ipv4/af_inet.c
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv,};static const struct net_protocol udp_protocol = {
.handler = udp_rcv,
.err_handler = udp_err,
.no_policy = 1,
.netns_ok = 1,};static const struct net_protocol tcp_protocol = {
.early_demux = tcp_v4_early_demux,
.handler = tcp_v4_rcv,
.err_handler = tcp_v4_err,
.no_policy = 1,
.netns_ok = 1,
};
static int __init inet_init(void){
......
if (inet_add_protocol(&icmp_protocol, IPPROTO_ICMP) < 0)
pr_crit("%s: Cannot add ICMP protocol\n", __func__);
if (inet_add_protocol(&udp_protocol, IPPROTO_UDP) < 0)
pr_crit("%s: Cannot add UDP protocol\n", __func__);
if (inet_add_protocol(&tcp_protocol, IPPROTO_TCP) < 0)
pr_crit("%s: Cannot add TCP protocol\n", __func__);
......
dev_add_pack(&ip_packet_type);
}
上面的代码中我们可以看到,udp_protocol结构体中的handler是udp_rcv,tcp_protocol结构体中的handler是tcp_v4_rcv,通过inet_add_protocol被初始化了进来。
int inet_add_protocol(const struct net_protocol *prot, unsigned char protocol){
if (!prot->netns_ok) {
pr_err("Protocol %u is not namespace aware, cannot register.\n",
protocol);
return -EINVAL;
}
return !cmpxchg((const struct net_protocol **)&inet_protos[protocol],
NULL, prot) ? 0 : -1;
}
- 具体注册函数的实现(
把udp、tcp的处理函数放到inet_protos数组,ptype_base哈希表中存放ip_rcv()函数的地址
)
inet_add_protocol函数将tcp和udp对应的处理函数都注册到了inet_protos数组中了。再看dev_add_pack(&ip_packet_type);这一行,ip_packet_type结构体中的type是协议名,func是ip_rcv函数,在dev_add_pack中会被注册到ptype_base哈希表中。
//file: net/core/dev.c
void dev_add_pack(struct packet_type *pt){
struct list_head *head = ptype_head(pt);
......
}
static inline struct list_head *ptype_head(const struct packet_type *pt){
if (pt->type == htons(ETH_P_ALL))
return &ptype_all;
else
return &ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];
}
这里我们需要记住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()函数。
流程:
1)ip_rcv中将会通过inet_protos找到tcp或者udp的处理函数
2)再而把包转发给udp_rcv()或tcp_v4_rcv()函数
- iptables的过滤和netfilter的设置参数也在这里(
inet_init函数
)
扩展一下,如果看一下ip_rcv和udp_rcv等函数的代码能看到很多协议的处理过程。例如,ip_rcv中会处理netfilter和iptable过滤,如果你有很多或者很复杂的 netfilter 或 iptables 规则,这些规则都是在软中断的上下文中执行的,会加大网络延迟。再例如,udp_rcv中会判断socket接收队列是否满了。对应的相关内核参数是net.core.rmem_max和net.core.rmem_default。如果有兴趣,建议大家好好读一下inet_init这个函数的代码。
(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,
.id_table = igb_pci_tbl,
.probe = igb_probe,
.remove = igb_remove,
......
};
static int __init igb_init_module(void){
......
ret = pci_register_driver(&igb_driver);
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下。主要执行的操作如下:
- 流程
1)启动
2)调用probe函数
3)获取电脑物理地址,例如mac地址
4)DMA初始化
5)注册ethtool实现函数
6)注册net_device_ops\netdev等变量
7)NAPI初始化,注册poll函数
- 备注
1)第5步
中我们看到,网卡驱动实现了ethtool所需要的接口,也在这里注册完成函数地址的注册。当 ethtool 发起一个系统调用之后,内核会找到对应操作的回调函数。对于igb网卡来说,其实现函数都在drivers/net/ethernet/intel/igb/igb_ethtool.c下。相信你这次能彻底理解ethtool的工作原理了吧?这个命令之所以能查看网卡收发包统计、能修改网卡自适应模式、能调整RX 队列的数量和大小,是因为ethtool命令最终调用到了网卡驱动的相应方法,而不是ethtool本身有这个超能力。
2)第6步
注册的igb_netdev_ops中包含的是igb_open等函数,该函数在网卡被启动的时候会被调用。(以下是对应的注册函数)
//file: drivers/net/ethernet/intel/igb/igb_main.c
static const struct net_device_ops igb_netdev_ops = {
.ndo_open = igb_open,
.ndo_stop = igb_close,
.ndo_start_xmit = igb_xmit_frame,
.ndo_get_stats64 = igb_get_stats64,
.ndo_set_rx_mode = igb_set_rx_mode,
.ndo_set_mac_address = igb_set_mac,
.ndo_change_mtu = igb_change_mtu,
.ndo_do_ioctl = igb_ioctl,
......
3)第7步
在igb_probe初始化过程中,还调用到了igb_alloc_q_vector。他注册了一个NAPI机制所必须的poll函数,对于igb网卡驱动来说,这个函数就是igb_poll,如下代码所示。
static int igb_alloc_q_vector(struct igb_adapter *adapter,
int v_count, int v_idx,
int txr_count, int txr_idx,
int rxr_count, int rxr_idx){
......
/* initialize NAPI */
netif_napi_add(adapter->netdev, &q_vector->napi,
igb_poll, 64);
}
(5)启动网卡(按照前面的初始化函数,注册一堆启动回调函数和参数变量)
当上面的初始化都完成以后,就可以启动网卡了。回忆前面网卡驱动初始化时,我们提到了驱动向内核注册了 structure net_device_ops 变量,它包含着网卡启用、发包、设置mac 地址等回调函数(函数指针)。当启用一个网卡时(例如,通过 ifconfig eth0 up),net_device_ops 中的 igb_open方法会被调用。它通常会做以下事情:
- 流程解析
1)启动网卡
2)调用net_device_ops中注册的open函数和__igb_open函数
3)分配RX、TX队列内存
4)注册中断处理函数
5)打开硬中断,等待包进来
- __igb_open源码解析
//file: drivers/net/ethernet/intel/igb/igb_main.c
static int __igb_open(struct net_device *netdev, bool resuming){
/* allocate transmit descriptors */
err = igb_setup_all_tx_resources(adapter);
/* allocate receive descriptors */
err = igb_setup_all_rx_resources(adapter);
/* 注册中断处理函数 */
err = igb_request_irq(adapter);
if (err)
goto err_req_irq;
/* 启用NAPI */
for (i = 0; i < adapter->num_q_vectors; i++)
napi_enable(&(adapter->q_vector[i]->napi));
......
}
在上面__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:
- __igb_open函数后面的流程( __igb_open => igb_request_irq => igb_request_msix)
static int igb_request_irq(struct igb_adapter *adapter){
if (adapter->msix_entries) {
err = igb_request_msix(adapter);
if (!err)
goto request_done;
......
}
}
static int igb_request_msix(struct igb_adapter *adapter){
......
for (i = 0; i < adapter->num_q_vectors; i++) {
...
err = request_irq(adapter->msix_entries[vector].vector,
igb_msix_ring, 0, q_vector->name,
}
在上面的代码中跟踪函数调用, __igb_open => igb_request_irq => igb_request_msix, 在igb_request_msix中我们看到了,对于多队列的网卡,为每一个队列都注册了中断,其对应的中断处理函数是igb_msix_ring(该函数也在drivers/net/ethernet/intel/igb/igb_main.c下)。我们也可以看到,msix方式下,每个 RX 队列有独立的MSI-X 中断,从网卡硬件中断的层面就可以设置让收到的包被不同的 CPU处理。(可以通过 irqbalance ,或者修改 /proc/irq/IRQ_NUMBER/smp_affinity能够修改和CPU的绑定行为)。
当做好以上准备工作以后,就可以开门迎客(数据包)了!
3)内核具体收包流程(之前的准备工作做好了)
(1)数据从网卡送到ringbuffer,DMA把数据送到内存,通知CPU开启硬中断处理
首先当数据帧从网线到达网卡上的时候,第一站是网卡的接收队列。网卡在分配给自己的RingBuffer中寻找可用的内存位置,找到后DMA引擎会把数据DMA到网卡之前关联的内存里,这个时候CPU都是无感的。当DMA操作完成以后,网卡会像CPU发起一个硬中断,通知CPU有数据到达。
- 流程
1)数据帧从外部网络到达网卡
2)网卡把帧DMA到内存
3)对CPU发出IRQ硬中断
4)CPU调用驱动注册的硬中断处理函数
5)对应的处理函数启动NAPI,发出软中断
-
注意
当RingBuffer满的时候,新来的数据包将给丢弃。ifconfig查看网卡的时候,可以里面有个overruns,表示因为环形队列满被丢弃的包。如果发现有丢包,可能需要通过ethtool命令来加大环形队列的长度。 -
硬中断处理函数igb_msix_ring(网卡的硬中断注册的处理函数)
//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;
/* Write the ITR value calculated from the previous interrupt. */
igb_write_itr(q_vector);
napi_schedule(&q_vector->napi);
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){
list_add_tail(&napi->poll_list, &sd->poll_list);
__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){
trace_softirq_raise(nr);
or_softirq_pending(1UL << nr);
}
//file: include/linux/irq_cpustat.h
#define or_softirq_pending(x) (local_softirq_pending() |= (x))
- 硬中断做的事(记录一个寄存器,修改了CPU变量softnet_data里的poll_list,然后发出软中断)
void __raise_softirq_irqoff(unsigned int nr){
trace_softirq_raise(nr);
or_softirq_pending(1UL << nr);
}
//file: include/linux/irq_cpustat.h
#define or_softirq_pending(x) (local_softirq_pending() |= (x))
(2)ksoftirqd内核线程处理软中断
- 整体流程
1)启动ksoftirqd线程,这个内核线程无线loop循环
2)线程判断softirq_pending标志,执行__do_softirq
3)调用驱动注册的poll函数
4)从ringbuffer取下数据包
5)调用igb_clean_rx_irq把包送到协议栈
- 备注
1)第二步
线程无线循环调用ksoftirqd_should_run函数,这函数再调用local_softirq_pending()函数,读取硬中断设置的NET_RX_SOFTIRQ,然后进去线程函数中run_ksoftirqd处理,在__do_softirq中,判断根据当前CPU的软中断类型,调用其注册的action方法。
static void run_ksoftirqd(unsigned int cpu){
local_irq_disable();
if (local_softirq_pending()) {
__do_softirq();
rcu_note_context_switch(cpu);
local_irq_enable();
cond_resched();
return;
}
local_irq_enable();
}
//-----------------
asmlinkage void __do_softirq(void){
do {
if (pending & 1) {
unsigned int vec_nr = h - softirq_vec;
int prev_count = preempt_count();
...
trace_softirq_entry(vec_nr);
h->action(h);
trace_softirq_exit(vec_nr);
...
}
h++;
pending >>= 1;
} while (pending);
}
在网络子系统初始化小节, 我们看到我们为NET_RX_SOFTIRQ注册了处理函数net_rx_action。所以net_rx_action函数就会被执行到了。
-
打散硬中断到不同CPU中去
这里需要注意一个细节,硬中断中设置软中断标记,和ksoftirq的判断是否有软中断到达,都是基于smp_processor_id()
的。这意味着只要硬中断在哪个CPU上被响应,那么软中断也是在这个CPU上处理的。所以说,如果你发现你的Linux软中断CPU消耗都集中在一个核上的话,做法是要把调整硬中断的CPU亲和性,来将硬中断打散到不同的CPU核上去
。 -
NET_RX_SOFTIRQ对应处理函数net_rx_action
static void net_rx_action(struct softirq_action *h){
struct softnet_data *sd = &__get_cpu_var(softnet_data);
unsigned long time_limit = jiffies + 2;
int budget = netdev_budget;
void *have;
local_irq_disable();
while (!list_empty(&sd->poll_list)) {
......
n = list_first_entry(&sd->poll_list, struct napi_struct, poll_list);
work = 0;
if (test_bit(NAPI_STATE_SCHED, &n->state)) {
work = n->poll(n, weight);
trace_napi_poll(n);
}
budget -= work;
}
}
函数开头的time_limit和budget是用来控制net_rx_action函数主动退出的,目的是保证网络包的接收不霸占CPU不放。等下次网卡再有硬中断过来的时候再处理剩下的接收数据包。其中budget可以通过内核参数调整。这个函数中剩下的核心逻辑是获取到当前CPU变量softnet_data,对其poll_list进行遍历, 然后执行到网卡驱动注册到的poll函数。对于igb网卡来说,就是igb驱动力的igb_poll函数了。
static int igb_poll(struct napi_struct *napi, int budget){
...
if (q_vector->tx.ring)
clean_complete = igb_clean_tx_irq(q_vector);
if (q_vector->rx.ring)
clean_complete &= igb_clean_rx_irq(q_vector, budget);
...
}
//在读取操作中,igb_poll的重点工作是对igb_clean_rx_irq的调用。
static bool igb_clean_rx_irq(struct igb_q_vector *q_vector, const int budget){
...
do {
/* retrieve a buffer from the ring */
skb = igb_fetch_rx_buffer(rx_ring, rx_desc, skb);
/* fetch next buffer in frame if non-eop */
if (igb_is_non_eop(rx_ring, rx_desc))
continue;
}
/* verify the packet layout is correct */
if (igb_cleanup_headers(rx_ring, rx_desc, skb)) {
skb = NULL;
continue;
}
/* populate checksum, timestamp, VLAN, and protocol */
igb_process_skb_fields(rx_ring, rx_desc, skb);
napi_gro_receive(&q_vector->napi, skb);
}
igb_fetch_rx_buffer和igb_is_non_eop的作用就是把数据帧从RingBuffer上取下来。为什么需要两个函数呢?因为有可能帧要占多多个RingBuffer,所以是在一个循环中获取的,直到帧尾部。获取下来的一个数据帧用一个sk_buff来表示。收取完数据以后,对其进行一些校验,然后开始设置sbk变量的timestamp, VLAN id, protocol等字段。接下来进入到napi_gro_receive中:
//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) {
case GRO_NORMAL:
if (netif_receive_skb(skb))
ret = GRO_DROP;
break;
......
}
在netif_receive_skb中,数据包将被送到协议栈中。声明,以下的3.3, 3.4, 3.5也都属于软中断的处理过程,只不过由于篇幅太长,单独拿出来成小节。
(3)网络协议栈处理
- 根据包的协议调用对应处理函数
netif_receive_skb函数会根据包的协议,假如是udp包,会将包依次送到ip_rcv(),udp_rcv()协议处理函数中进行处理。
//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){
......
ret = __netif_receive_skb_core(skb, false);}static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc){
......
//pcap逻辑,这里会将数据送入抓包点。tcpdump就是从这个入口获取包的 list_for_each_entry_rcu(ptype, &ptype_all, list) {
if (!ptype->dev || ptype->dev == skb->dev) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}
......
list_for_each_entry_rcu(ptype,
&ptype_base[ntohs(type) & PTYPE_HASH_MASK], list) {
if (ptype->type == type &&
(ptype->dev == null_or_dev || ptype->dev == skb->dev ||
ptype->dev == orig_dev)) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}
}
-
关于
tcpdump
在__netif_receive_skb_core中,我看着原来经常使用的tcpdump的抓包点,很是激动,看来读一遍源代码时间真的没白浪费。接着__netif_receive_skb_core取出protocol,它会从数据包中取出协议信息,然后遍历注册在这个协议上的回调函数列表。ptype_base 是一个 hash table,在协议注册小节我们提到过。ip_rcv 函数地址就是存在这个 hash table中的。 -
进入协议层注册的处理函数(ip就调用ip_rcv,arp就调用arp_rcv)
//file: net/core/dev.c
static inline int deliver_skb(struct sk_buff *skb,
struct packet_type *pt_prev,
struct net_device *orig_dev){
......
return pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
}
pt_prev->func这一行就调用到了协议层注册的处理函数了。对于ip包来讲,就会进入到ip_rcv(如果是arp包的话,会进入到arp_rcv)。
(4)IP协议层处理(ip包就调用到ip_rcv函数,inet_protos中保存着tcp_rcv()和udp_rcv()的函数地址)
- 源码接口
//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){
......
//这里NF_HOOK是一个钩子函数,
//当执行完注册的钩子后就会执行到最后一个参数指向的函数
//ip_rcv_finish。
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL,
ip_rcv_finish);
}
//---------------
static int ip_rcv_finish(struct sk_buff *skb){
......
if (!skb_dst(skb)) {
int err = ip_route_input_noref(skb, iph->daddr, iph->saddr,
iph->tos, skb->dev);
...
}
......
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) {
rth->dst.input= ip_local_deliver;
rth->rt_flags |= RTCF_LOCAL;
}
}
所以回到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. */
if (ip_is_fragment(ip_hdr(skb))) {
if (ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER))
return 0;
}
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL,
ip_local_deliver_finish);
}
static int ip_local_deliver_finish(struct sk_buff *skb){
......
int protocol = ip_hdr(skb)->protocol;
const struct net_protocol *ipprot;
ipprot = rcu_dereference(inet_protos[protocol]);
if (ipprot != NULL) {
//调用对应的处理函数,传入数据包
ret = ipprot->handler(skb);
}
}
如协议注册小节看到inet_protos中保存着tcp_rcv()和udp_rcv()的函数地址。这里将会根据包中的协议类型选择进行分发,在这里skb包将会进一步被派送到更上层的协议中,udp和tcp。
/* This is used to register protocols. */
struct net_protocol {
//early_demux提前查找skb数据包的监听sock和输入路由dst,提前分
int (*early_demux)(struct sk_buff *skb);
int (*early_demux_handler)(struct sk_buff *skb);
//对应的数据处理函数
int (*handler)(struct sk_buff *skb);
void (*err_handler)(struct sk_buff *skb, u32 info);
unsigned int no_policy:1,
netns_ok:1,
/* does the protocol do more stringent
* icmp tag validation than simple
* socket lookup?
*/
icmp_strict_tag_validation:1;
};
(5)UDP协议层处理
- 前情提要
在协议注册小节的时候我们说过,udp协议的处理函数是udp_rcv。 - 源码(也就是上一节里面handler函数指针存的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,
int proto){
sk = __udp4_lib_lookup_skb(skb, uh->source, uh->dest, udptable);
if (sk != NULL) {
int ret = udp_queue_rcv_skb(sk, skb
}
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);
}
__udp4_lib_lookup_skb是根据skb来寻找对应的socket
,当找到以后将数据包放到socket的缓存队列里。
- 套接字源码信息
struct sock {
struct options *opt;/*IP选项缓存于此处*/
volatile unsigned long wmem_alloc;/*当前写缓冲区大小,该值不可大于系统规定的最大值*/
volatile unsigned long rmem_alloc;/*当前读缓冲区大小,该值不可大于系统规定最大值*/
unsigned long write_seq;/* write_seq 表示应用程序下一次写数据时所对应的第一个字节的序列号*/
unsigned long sent_seq;/* sent_seq 表示本地将要发送的下一个数据包中第一个字节对应的序列号*/
unsigned long acked_seq;/* acked_seq 表示本地希望从远端接收的下一个数据的序列号*/
unsigned long copied_seq; /* 应用程序有待读取(但尚未读取)数据的第一个序列号。*/
unsigned long rcv_ack_seq; /* 表示目前本地接收到的对本地发送数据的应答序列号。*/
unsigned long window_seq;/* 窗口大小,是一个绝对值,表示本地将要发送数据包中所包含最后一个数据的序列号,不可大于 window_seq.*/
unsigned long fin_seq; /* 该字段在对方发送 FIN数据包时使用,在接收到远端发送的 FIN数据包后,fin_seq 被初始化为对方的 FIN 数据包最后一个字节的序列号加 1,表示本地对此 FIN 数据包进行应答的序列号*/
unsigned long urg_seq;
unsigned long urg_data;
/* 以上两个字段用于紧急数据处理,urg_seq 表示紧急数据最大序列号。urg_data 是一个标志位,当设置为 1 时,表示接收到紧急数据。*/
volatile char inuse,/*inuse=1 表示其它进程正在使用该 sock 结构,本进程需等待*/
dead,/* dead=1 表示该 sock 结构已处于释放状态*/
urginline,/* urginline=1 表示紧急数据将被当作普通数据处理。*/
intr,
blog,/* blog=1 表示对应套接字处于节制状态,此时接收的数据包均被丢弃*/
done,
reuse,
keepopen,/* keepopen=1 表示使用保活定时器 */
linger,/* linger=1 表示在关闭套接字时需要等待一段时间以确认其已关闭。*/
delay_acks,/* delay_acks=1表示延迟应答,可一次对多个数据包进行应答 */
destroy,/* destroy=1 表示该 sock 结构等待销毁*/
ack_timed,
no_check,
zapped, /* In ax25 & ipx means not linked */
broadcast,
nonagle;/* noagle=1 表示不使用 NAGLE 算法*/
unsigned long lingertime;/*表示等待关闭操作的时间,只有当 linger 标志位为 1 时,该字段才有意义。*/
int proc;/* 该 sock 结构(即该套接字)所属的进程的进程号。*/
struct sock *next;
struct sock *prev;
struct sock *pair;
/* 以上三个字段用于 sock 的连接*/
struct sk_buff * volatile send_head;
struct sk_buff * volatile send_tail;
/* send_head, send_tail 用于 TCP协议重发队列。*/
struct sk_buff_head back_log;/* back_log为接收的数据包缓存队列。用于计算目前累计的应发送而未发送的应答数据包的个数*/
struct sk_buff *partial;/*创建最大长度的待发送数据包。*/
struct timer_list partial_timer;/*按时发送 partial 指针指向的数据包,以免缓存(等待)时间过长。*/
long retransmits;/* 重发次数*/
/*
write_queue 指向待发送数据包,其与 send_head,send_tail 队列的不同之处在于send_head,send_tail 队列中数据包均已经发送出去,但尚未接收到应答。而 write_queue 中数据包尚未发送。 receive-queue为读队列,其不同于 back_log 队列之处在于 back_log 队列缓存从网络层传 上来的数据包,在用户进行读取操作时,不可操作 back_log 队列,而是从 receive_queue 队列中去数据包读取其中的数据,即数据包首先缓存在 back_log 队列中,然后从 back_log 队列中移动到 receive_queue队列中方可被应用程序读取。而并非所有back_log 队列中缓 存的数据包都可以成功的被移动到 receive_queue队列中,如果此刻读缓存区太小,则当 前从back_log 队列中被取下的被处理的数据包将被直接丢弃,而不会被缓存到receive_queue 队列中。如果从应答的角度看,在back_log队列中的数据包由于有可能被 丢弃,故尚未应答,而将一个数据包从 back_log 移动到 receive_queue时,表示该数据包 已被正式接收,即会发送对该数据包的应答给远端表示本地已经成功接收该数据包。 */
struct sk_buff_head write_queue,
receive_queue;
struct proto *prot;/*指向传输层处理函数集*/
struct wait_queue **sleep;/*进程等待sock的地位*/
unsigned long daddr;/*套接字的远端地址*/
unsigned long saddr;/*套接字的本地地址*/
unsigned short max_unacked;/* 最大未处理请求连接数(应答数) */
unsigned short window;/* 远端窗口大小 */
unsigned short bytes_rcv;/* 已接收字节总数*/
/* mss is min(mtu, max_window) */
unsigned short mtu; /*最大传输单元*/
volatile unsigned short mss; /*最大报文长度:MSS=MTU-IP 首部长度-TCP首部长度 */
volatile unsigned short user_mss; /*用户指定的 MSS值*/
volatile unsigned short max_window;
unsigned long window_clamp;/*最大窗口大小和窗口大小钳制值 */
unsigned short num;/* 本地端口号*/
/*
以下三个字段用于拥塞算法
*/
volatile unsigned short cong_window;
volatile unsigned short cong_count;
volatile unsigned short ssthresh;
volatile unsigned short packets_out;/* 本地已发送出去但尚未得到应答的数据包数目*/
volatile unsigned short shutdown;/* 本地关闭标志位,用于半关闭操作*/
volatile unsigned long rtt;/* 往返时间估计值*/
volatile unsigned long mdev;/* mean deviation, 即RTTD, 绝对偏差*/
volatile unsigned long rto;/* RTO是用 RTT 和 mdev 用算法计算出的延迟时间值*/
volatile unsigned short backoff;/* 退避算法度量值 */
volatile short err;/* 错误标志值*/
unsigned char protocol;/* 传输层协议值*/
volatile unsigned char state;/* 套接字状态值,如 TCP_ESTABLISHED */
volatile unsigned char ack_backlog;/* 缓存的未应答数据包个数*/
unsigned char max_ack_backlog;/* 最大缓存的未应答数据包个数*/
unsigned char priority;/* 该套接字优先级,在硬件缓存发送数据包时使用 */
unsigned char debug;
unsigned short rcvbuf;/* 最大接收缓冲区大小*/
unsigned short sndbuf;/* 最大发送缓冲区大小*/
unsigned short type;/* 类型值如 SOCK_STREAM */
unsigned char localroute; /* localroute=1 表示只使用本地路由,一般目的端在相同子网时使用。*/
#ifdef CONFIG_IPX
ipx_address ipx_dest_addr;
ipx_interface *ipx_intrfc;
unsigned short ipx_port;
unsigned short ipx_type;
#endif
#ifdef CONFIG_AX25
ax25_address ax25_source_addr,ax25_dest_addr;
struct sk_buff *volatile ax25_retxq[8];
char ax25_state,ax25_vs,ax25_vr,ax25_lastrxnr,ax25_lasttxnr;
char ax25_condition;
char ax25_retxcnt;
char ax25_xx;
char ax25_retxqi;
char ax25_rrtimer;
char ax25_timer;
unsigned char ax25_n2;
unsigned short ax25_t1,ax25_t2,ax25_t3;
ax25_digi *ax25_digipeat;
#endif
#ifdef CONFIG_ATALK
struct atalk_sock at;
#endif
/* IP 'private area' or will be eventually */
int ip_ttl; /* IP首部 TTL 字段值,实际上表示路由器跳数*/
int ip_tos; /* IP首部 TOS字段值,服务类型值*/
struct tcphdr dummy_th;/* 缓存的 TCP首部,在 TCP协议中创建一个发送数据包时可以利用此字段快速创建 TCP 首部。*/
struct timer_list keepalive_timer; /*保活定时器,用于探测对方窗口大小,防止对方通报窗口大小的数据包丢弃,从而造成 本地发送通道被阻塞。*/
struct timer_list retransmit_timer; /*重发定时器,用于数据包超时重发*/
struct timer_list ack_timer; /*延迟应答定时器,延迟应答可以减少应答数据包的个数,但不可无限延迟以免造成远端 重发,所以设置定时器定期发送应答数据包。 */
int ip_xmit_timeout; /*该字段为标志位组合字段,用于表示下文中 timer定时器超时的原因*/
#ifdef CONFIG_IP_MULTICAST
int ip_mc_ttl;
int ip_mc_loop;
char ip_mc_name[MAX_ADDR_LEN];
struct ip_mc_socklist *ip_mc_list;
#endif
/*以上4 个字段用于 IP多播*/
int timeout;
struct timer_list timer;
/* 以上两个字段用于通用定时,timeout 表示定时时间值,ip_xmit_timeout表示此次定时的 原因,timer为定时器。 */
struct timeval stamp;/* 时间戳*/
struct socket *socket;/*对应的socket结构体*/
void (*state_change)(struct sock *sk);
void (*data_ready)(struct sock *sk,int bytes);
void (*write_space)(struct sock *sk);
void (*error_report)(struct sock *sk);
/* 以上四个函数指针字段指向回调函数。这些字段的设置为自定义回调函数提供的很大的
灵活性,内核在发生某些时间时,会调用这些函数,如此可以实现自定义响应。目前这
种自定义响应还是完全有内核控制。 */
};
- 如果没有找到,则发送一个目标不可达的icmp包。
//file: net/ipv4/udp.c
int udp_queue_rcv_skb(struct sock *sk, struct sk_buff *skb){
......
if (sk_rcvqueues_full(sk, skb, sk->sk_rcvbuf))
goto drop;
rc = 0;
ipv4_pktinfo_prepare(skb);
bh_lock_sock(sk);
if (!sock_owned_by_user(sk))
rc = __udp_queue_rcv_skb(sk, skb);
else if (sk_add_backlog(sk, skb, sk->sk_rcvbuf)) {
bh_unlock_sock(sk);
goto drop;
}
bh_unlock_sock(sk);
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影响。
3)内核收包结束,用户使用recvfrom系统调用接受数据(UDP)
花开两朵,各表一枝。上面我们说完了整个Linux内核对数据包的接收和处理过程,最后把数据包放到socket的接收队列中了。那么我们再回头看用户进程调用recvfrom后是发生了什么。我们在代码里调用的recvfrom是一个glibc的库函数,该函数在执行后会将用户进行陷入到内核态,进入到Linux实现的系统调用sys_recvfrom。在理解Linux对sys_revvfrom之前,我们先来简单看一下socket这个核心数据结构。这个数据结构太大了,我们只把对和我们今天主题相关的内容画出来,如下:
socket数据结构中的const struct proto_ops对应的是协议的方法集合。每个协议都会实现不同的方法集,对于IPv4 Internet协议族来说,每种协议都有对应的处理方法,如下。对于udp来说,是通过inet_dgram_ops来定义的,其中注册了inet_recvmsg方法。
//file: net/ipv4/af_inet.c
const struct proto_ops inet_stream_ops = {
......
.recvmsg = inet_recvmsg,
.mmap = sock_no_mmap,
......
}
const struct proto_ops inet_dgram_ops = {
......
.sendmsg = inet_sendmsg,
.recvmsg = inet_recvmsg,
......
}
socket数据结构中的另一个数据结构struct sock *sk是一个非常大,非常重要的子结构体。其中的sk_prot又定义了二级处理函数。对于UDP协议来说,会被设置成UDP协议实现的方法集udp_prot。
//file: net/ipv4/udp.c
struct proto udp_prot = {
.name = "UDP",
.owner = THIS_MODULE,
.close = udp_lib_close,
.connect = ip4_datagram_connect,
......
.sendmsg = udp_sendmsg,
.recvmsg = udp_recvmsg,
.sendpage = udp_sendpage,
......
}
- 看完了socket变量之后,我们再来看sys_revvfrom的实现过程。
1)库函数调用recv_from
2)Glibc库调用系统调用sys_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){
......
err = sk->sk_prot->recvmsg(iocb, sk, msg, size, flags & MSG_DONTWAIT,
flags & ~MSG_DONTWAIT, &addr_len);
if (err >= 0)
msg->msg_namelen = addr_len;
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){
......
do {
struct sk_buff_head *queue = &sk->sk_receive_queue;
skb_queue_walk(queue, skb) {
......
}
/* User doesn't want to wait */
error = -EAGAIN;
if (!timeo)
goto no_packet;
} while (!wait_for_more_packets(sk, err, &timeo, last));
}
终于我们找到了我们想要看的重点,在上面我们看到了所谓的读取过程,就是访问sk->sk_receive_queue。如果没有数据,且用户也允许等待,则将调用wait_for_more_packets()执行等待操作,它加入会让用户进程进入睡眠状态。
4)总结
1)收包前准备工作
1. 创建ksoftirqd线程,为它设置好它自己的线程函数,后面指望着它来处理软中断呢
2. 协议栈注册,linux要实现许多协议,比如arp,icmp,ip,udp,tcp,每一个协议都会将自己的处理函数
注册一下,方便包来了迅速找到对应的处理函数
3. 网卡驱动初始化,每个驱动都有一个初始化函数,内核会让驱动也初始化一下。在这个初始化过程中,把
自己的DMA准备好,把NAPI的poll函数地址告诉内核
4. 启动网卡,分配RX,TX队列,注册中断对应的处理函数
2)内核收包流程
1. 网卡将数据帧DMA到内存的RingBuffer中,然后向CPU发起中断通知
2. CPU响应中断请求,调用网卡启动时注册的中断处理函数
3. 中断处理函数几乎没干啥,就发起了软中断请求
4. 内核线程ksoftirqd线程发现有软中断请求到来,先关闭硬中断
5. ksoftirqd线程开始调用驱动的poll函数收包
6. poll函数将收到的包送到协议栈注册的ip_rcv函数中
7. ip_rcv函数再讲包送到udp_rcv函数中(对于tcp包就送到tcp_rcv)
3)相关实际问题
1)内核ringbuffer到底是什么?为什么会丢包?丢包怎么办?
2)网络相关的软硬中断是什么?
3)ksoftirqd内核线程是干嘛的?
4)为什么网卡开多队列能提升网络性能?
5)tcpdump是如何工作的?
6)iptable/netfilter是在那一层实现的?
7)tcpdump能否抓到被iptable封禁的包?
8)网路接受过程中的CPU开销怎么看?
1)内核ringbuffer到底是什么?为什么会丢包?丢包怎么办?
①ringbuffer内部是有两个环形队列数组:
1)igb_rx_buffer:内核使用的,通过vzalloc申请的
2)e1000_adv_rx_desc数组:网卡硬件使用的,通过dma_alloc_coherent分配
②内核ringbuffer使用流程
1、网卡收到数据,以DMA方式将包写到ringbuffer中。
2、软中断收包把skb取走,并申请新的skb重新挂上去
③这两个ringbuffer的指针数组数是预先分配好的,而skb则会随着收包过程动态申请
④这个ringbuffer是有大小和长度限制的,长度可以通过ethtool工具查看
Pre-set maximums 指的是ringbuffer的最大值
Current hardware settings: 当前的设置
⑤如果内核数据得不到及时处理,满了,后面的数据就会丢弃,通过ethtool或ifconfig工具可以查看是否有ringbuffer溢出发生
[root@VM-4-14-centos 4.18.0-305.3.1.el8.x86_64]# ethtool -S eth0
NIC statistics:
rx_queue_0_drops: 0 //0代表的是第0个RingBuffer的丢包数
rx_queue_1_drops: 0 //1代表的是第1个RingBuffer的丢包数
tx_queue_0_xdp_tx_drops: 0 //
tx_queue_1_xdp_tx_drops: 0 //
若有丢弃:
1、增加ringbuffer的大小(但是排队包过多会增加网络包的延时)
ethtool -G eth1 rx 4096 tx 4096
2、硬中断打散到其他CPU
3、多增加网卡队列数
2)网络相关的软硬中断是什么?
1、硬中断
网卡将数据放到ringbuffer,发起硬中断(将传过来的poll_list添加到CPU变量的softnet_data的poll_list里)通知CPU处理
2、软中断
CPU修改寄存器的值,触发软中断。(对softnet_data里面的poll_list遍历,执行网卡驱动提供的poll来接受网络包,处理后送到协议栈的ip_rcv、udp_rcv等函数中)
3)ksoftirqd内核线程是干嘛的?
根据软中断的枚举类型,执行对应的中断处理函数。
- 软中断信息可以从/proc/softirqs读取
[root@VM-4-14-centos 4.18.0-305.3.1.el8.x86_64]# cat /proc/softirqs
CPU0 CPU1 CPU2 CPU3
HI: 0 0 0 1
TIMER: 81618865 172954307 134791511 118752523
NET_TX: 4 3 1 5
NET_RX: 84212506 68906362 15767705 12088615
BLOCK: 0 0 0 26111592
IRQ_POLL: 0 0 0 0
TASKLET: 43809 33931 218 213
SCHED: 138842691 223803035 182385156 165390901
HRTIMER: 47006 31494 51602 38942
RCU: 230638347 256538894 248099181 237309069
CPU执行了84212506次NET_RX和4次NET_TX
4)为什么网卡开多队列能提升网络性能?
先查看网卡信息
[root@VM-4-14-centos 4.18.0-305.3.1.el8.x86_64]# ethtool -l eth0
Channel parameters for eth0:
Pre-set maximums:
RX: n/a
TX: n/a
Other: n/a
Combined: 2
Current hardware settings:
RX: n/a
TX: n/a
Other: n/a
Combined: 2
当晚网卡支持的最大队列是2,当前开启了2条队列
- 增大队列数
ethtool -L eth0 combined 32
- 查看各个队列对应的硬件中断号
[root@VM-4-14-centos 4.18.0-305.3.1.el8.x86_64]# cat /proc/interrupts
CPU0 CPU1 CPU2 CPU3
1: 0 0 0 9 IO-APIC 1-edge i8042
4: 0 478 8 0 IO-APIC 4-edge ttyS0
8: 0 0 0 0 IO-APIC 8-edge rtc0
9: 0 0 0 0 IO-APIC 9-fasteoi acpi
11: 0 7 0 0 IO-APIC 11-fasteoi virtio3, uhci_hcd:usb1, virtio2
12: 0 0 15 0 IO-APIC 12-edge i8042
14: 0 0 0 10603856 IO-APIC 14-edge ata_piix
15: 0 0 0 0 IO-APIC 15-edge ata_piix
24: 0 0 0 0 PCI-MSI 98304-edge virtio1-config
25: 0 0 0 20852942 PCI-MSI 98305-edge virtio1-req.0
26: 0 0 0 0 PCI-MSI 81920-edge virtio0-config
27: 26934181 0 1 0 PCI-MSI 81921-edge virtio0-input.0
28: 24303428 0 0 1 PCI-MSI 81922-edge virtio0-output.0
29: 1 17653502 0 0 PCI-MSI 81923-edge virtio0-input.1
30: 0 24229158 0 0 PCI-MSI 81924-edge virtio0-output.1
这里网卡输入队列i8042的中断号是1,tty50对应的中断号是4,共开了15个接受队列
- 查看中断号对应的smp_affinity,可以看到亲和的CPU核是哪一个
[root@VM-4-14-centos 4.18.0-305.3.1.el8.x86_64]# cat /proc/irq/29/smp_affinity
2
[root@VM-4-14-centos 4.18.0-305.3.1.el8.x86_64]# cat /proc/irq/27/smp_affinity
1
这就意味着哪个核相应的硬中断,那么该核发起的软中断任务就必须由这个核来处理
- 理解
如果网络包的接受频率高,导致个别核si偏高,可以通过
1、加大网卡队列数
2、硬中断打散到其他核心,这样软中断CPU开销也将由多个核分担 - top命令看si
top - 10:59:13 up 421 days, 22:38, 1 user, load average: 0.95, 0.62, 0.65
Tasks: 207 total, 1 running, 206 sleeping, 0 stopped, 0 zombie
%Cpu0 : 15.6 us, 3.1 sy, 0.0 ni, 80.9 id, 0.0 wa, 0.0 hi, 0.3 si, 0.0 st
%Cpu1 : 15.2 us, 3.1 sy, 0.0 ni, 81.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu2 : 14.9 us, 3.5 sy, 0.0 ni, 81.3 id, 0.0 wa, 0.0 hi, 0.3 si, 0.0 st
%Cpu3 : 14.6 us, 3.5 sy, 0.0 ni, 81.6 id, 0.0 wa, 0.0 hi, 0.3 si, 0.0 st
%Cpu4 : 27.4 us, 3.4 sy, 0.0 ni, 68.9 id, 0.0 wa, 0.0 hi, 0.3 si, 0.0 st
%Cpu5 : 25.8 us, 3.4 sy, 0.0 ni, 69.8 id, 0.0 wa, 0.0 hi, 1.0 si, 0.0 st
%Cpu6 : 27.2 us, 3.7 sy, 0.0 ni, 68.4 id, 0.0 wa, 0.0 hi, 0.7 si, 0.0 st
%Cpu7 : 25.0 us, 3.7 sy, 0.0 ni, 69.9 id, 0.0 wa, 0.0 hi, 1.4 si, 0.0 st
KiB Mem : 8010720 total, 138484 free, 1621728 used, 6250508 buff/cache
KiB Swap: 16777212 total, 15481716 free, 1295496 used. 6024296 avail Mem
5)tcpdump是如何工作的?
当内核收包的时候,会调用igb_poll函数,最终调用到__netif_receive_skb_core,这个函数会在将包送到协议栈(ip_rcv、arp_rcv等)之前,将包先送到ptype_all抓包
6)iptable/netfilter是在那一层实现的?
在IP/ARP层实现的,可以通过对NF_HOOK函数的引用来深入理解netfilter的实现。如果配置太多,会消耗太多CPU,加大网络延迟
7)tcpdump能否抓到被iptable封禁的包?
-
举例接受
硬件:硬中断
内核态:软中断、驱动系统、网络设备层(tcpdump)、协议栈(传输层、网络层,有netfilter)、用户进程
收包抓不到,发包tcpdump抓到了netfilter过滤的包
8)网络接受过程中的CPU开销怎么看 -
top命令看(si:CPU处理软中断,hi:CPU处理硬中断)
top - 10:59:13 up 421 days, 22:38, 1 user, load average: 0.95, 0.62, 0.65
Tasks: 207 total, 1 running, 206 sleeping, 0 stopped, 0 zombie
%Cpu0 : 15.6 us, 3.1 sy, 0.0 ni, 80.9 id, 0.0 wa, 0.0 hi, 0.3 si, 0.0 st
%Cpu1 : 15.2 us, 3.1 sy, 0.0 ni, 81.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu2 : 14.9 us, 3.5 sy, 0.0 ni, 81.3 id, 0.0 wa, 0.0 hi, 0.3 si, 0.0 st
%Cpu3 : 14.6 us, 3.5 sy, 0.0 ni, 81.6 id, 0.0 wa, 0.0 hi, 0.3 si, 0.0 st
%Cpu4 : 27.4 us, 3.4 sy, 0.0 ni, 68.9 id, 0.0 wa, 0.0 hi, 0.3 si, 0.0 st
%Cpu5 : 25.8 us, 3.4 sy, 0.0 ni, 69.8 id, 0.0 wa, 0.0 hi, 1.0 si, 0.0 st
%Cpu6 : 27.2 us, 3.7 sy, 0.0 ni, 68.4 id, 0.0 wa, 0.0 hi, 0.7 si, 0.0 st
%Cpu7 : 25.0 us, 3.7 sy, 0.0 ni, 69.9 id, 0.0 wa, 0.0 hi, 1.4 si, 0.0 st
KiB Mem : 8010720 total, 138484 free, 1621728 used, 6250508 buff/cache
KiB Swap: 16777212 total, 15481716 free, 1295496 used. 6024296 avail Mem
I try to explain these:
us: is meaning of "user CPU time"
sy: is meaning of "system CPU time"
ni: is meaning of" nice CPU time"
id: is meaning of "idle"
wa: is meaning of "iowait"
hi:is meaning of "hardware irq"
si : is meaning of "software irq"
st : is meaning of "steal time"
中文翻译为:
us 用户空间占用CPU百分比
sy 内核空间占用CPU百分比
ni 用户进程空间内改变过优先级的进程占用CPU百分比
id 空闲CPU百分比
wa 等待输入输出的CPU时间百分比
hi 硬件中断
si 软件中断
st: 实时
二、内核如何与用户进程协作
1)前倾提要
前面说了网络包如何被网卡送到协议栈,在协议栈接受完处理输入包后,如何通知用户进程收到并出数据?这里只介绍两个比较典型的:同步阻塞
和多路I/O复用
2)实际问题(带着问题去看书)
1)阻塞是怎么回事
2)同步阻塞IO组要哪些开销
3)多路复用epoll为什么能提高网络性能
4)epoll也是阻塞的吗?
5)为什么redis的网络性能突出?
3)总体流程
int main()
{
int sk = socket(AF_INET, SOCK_STREAM, 0);
connect(sk, ...)
recv(sk, ...)
}
在上面的 demo 中虽然只是简单的两三行代码,但实际上用户进程和内核配合做了非常多的工作。先是用户进程发起创建 socket 的指令,然后切换到内核态完成了内核对象的初始化。接下来 Linux 在数据包的接收上,是硬中断和 ksoftirqd 进程在进行处理。当 ksoftirqd 进程处理完以后,再通知到相关的用户进程。
从用户进程创建 socket,到一个网络包抵达网卡到被用户进程接收到,总体上的流程图如下:
(1)socket的直接创建
开篇源码中的 socket 函数调用执行完以后,内核在内部创建了一系列的 socket 相关的内核对象(是的,不是只有一个)。它们互相之间的关系如图。
- 图片讲解
一个socekt对应主要成员是三个
struct file* file
struct sock* sk 储存对应网络模块的信息,例如五元组等
struct proto_ops* ops 对应的操作函数
.accept 绑定对应处理函数inet_accept
.sendmsg 绑定对应处理函数inet_sendmsg
.recvmsg 绑定对应处理函数inet_recvmsg
- 函数执行顺序(linux 3.10版本)
SYSCALL_DEFINE3 -> sock_create->__sock_create(主要)->inet_create
->sock_init_data->sock_def_readable()
下面开始讲解
//file:net/socket.c
SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
{
......
retval = sock_create(family, type, protocol, &sock);
}
sock_create 是创建 socket 的主要位置。其中 sock_create 又调用了 __sock_create。(1、分配 socket 对象,获得每个协议族的操作表,2、调用每个协议族的创建函数, 3、对于 AF_INET 对应的是inet_create)
//file:net/socket.c
int __sock_create(struct net *net, int family, int type, int protocol,
struct socket **res, int kern)
{
struct socket *sock;
const struct net_proto_family *pf;
......
//1、分配 socket 对象
sock = sock_alloc();
//2、获得每个协议族的操作表
pf = rcu_dereference(net_families[family]);
//3、调用每个协议族的创建函数, 对于 AF_INET 对应的是
err = pf->create(net, sock, protocol, kern);
}
在 inet_create 中,根据类型 SOCK_STREAM 查找到对于 tcp 定义的操作方法实现集合 inet_stream_ops 和 tcp_prot。并把它们分别设置到 socket->ops 和 sock->sk_prot 上。
//file:net/ipv4/af_inet.c
tatic int inet_create(struct net *net, struct socket *sock, int protocol,
int kern)
{
struct sock *sk;
//查找对应的协议,对于TCP SOCK_STREAM 就是获取到了
//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,
// },
//}
list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {
//1、将 inet_stream_ops 赋到 socket->ops 上
sock->ops = answer->ops;
//2、获得 tcp_prot
answer_prot = answer->prot;
//3、分配 sock 对象, 并把 tcp_prot 赋到 sock->sk_prot 上
sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot);
//4、对 sock 对象进行初始化
sock_init_data(sock, sk);
}
图片讲解:
在inet_create中,根据类型SOCK_STREAM设置对应的处理函数在sock的sk_prot里面
我们再往下看到了 sock_init_data。在这个方法中将 sock 中的 sk_data_ready 函数指针进行了初始化,设置为默认 sock_def_readable()。
//file: net/core/sock.c
void sock_init_data(struct socket *sock, struct sock *sk)
{
sk->sk_data_ready = sock_def_readable;
sk->sk_write_space = sock_def_write_space;
sk->sk_error_report = sock_def_error_report;
}
当软中断上收到数据包时会通过调用 sk_data_ready 函数指针(实际被设置成了 sock_def_readable()
) 来唤醒在 sock 上等待的进程。
void sock_def_readable(struct sock *sk);
-
重点:
这里是看到当软中断包处理好放用void sock_def_readable(struct sock *sk);通知sock上等待的线程 -
总结
至此,一个 tcp对象,确切地说是 AF_INET 协议族下 SOCK_STREAM对象就算是创建完成了。这里花费了一次 socket 系统调用的开销
(2)以阻塞方式协同内核和用户进程(同步方式)
(1)recv等待接受消息(主动调用recv但是没有消息到时)
接着我们来看 recv 函数依赖的底层实现。首先通过 strace 命令跟踪,可以看到 clib 库函数 recv 会执行到 recvfrom 系统调用。
进入系统调用后,用户进程就进入到了内核态,通过执行一系列的内核协议层函数,然后到 socket 对象的接收队列中查看是否有数据,没有的话就把自己添加到 socket 对应的等待队列里。最后让出CPU,操作系统会选择下一个就绪状态的进程来执行。整个流程图如下:
- 流程分解
1)系统调用recvfrom
2)调用inet_stream_ops函数指针绑定的函数inet_recvmsg
3)tcp_prot
4)若接受队列数据为空
5)那么就调用函数sk_wait_data()修改当前进程状态从TASK_RUNNING改变到TASK_INTERRUPTIPLE,调用prepare_to_wait()把当前进程添加到socket的等待队列中
6)调用sk_wait_event主动让出CPU,linux将调度下一个进程
看完了整个流程图,接下来让我们根据源码来看更详细的细节。其中我们今天要关注的重点是 recvfrom 最后是怎么把自己的进程给阻塞掉的(假如我们没有使用 O_NONBLOCK 标记)。
- 整体函数调用流程
SYSCALL_DEFINE6->sock_recvmsg ==> __sock_recvmsg => __sock_recvmsg_nosec->sock->ops->recvmsg->inet_recvmsg->tcp_recvmsg - 流程讲解
//file: net/socket.c
SYSCALL_DEFINE6(recvfrom, int, fd, void __user *, ubuf, size_t, size,
unsigned int, flags, struct sockaddr __user *, addr,
int __user *, addr_len)
{
struct socket *sock;
//根据用户传入的 fd 找到 socket 对象
sock = sockfd_lookup_light(fd, &err, &fput_needed);
......
err = sock_recvmsg(sock, &msg, size, flags);
......
}
static inline int __sock_recvmsg_nosec(struct kiocb *iocb, struct socket *sock,
struct msghdr *msg, size_t size, int flags)
{
......
return sock->ops->recvmsg(iocb, sock, msg, size, flags);
}
调用 socket 对象 ops 里的 recvmsg, 回忆我们上面的 socket 对象图,从图中可以看到 recvmsg 指向的是 inet_recvmsg 方法。
//file: net/ipv4/af_inet.c
int inet_recvmsg(struct kiocb *iocb, struct socket *sock, struct msghdr *msg,
size_t size, int flags)
{
...
err = sk->sk_prot->recvmsg(iocb, sk, msg, size, flags & MSG_DONTWAIT,
flags & ~MSG_DONTWAIT, &addr_len);
这里又遇到一个函数指针,这次调用的是 socket 对象里的 sk_prot 下面的 recvmsg方法。同上,得出这个 recvmsg 方法对应的是 tcp_recvmsg 方法。
//file: net/ipv4/tcp.c
int tcp_recvmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
size_t len, int nonblock, int flags, int *addr_len)
{
int copied = 0;
...
do {
//遍历接收队列接收数据
skb_queue_walk(&sk->sk_receive_queue, skb) {
...
}
...
}
if (copied >= target) {
release_sock(sk);
lock_sock(sk);
} else //没有收到足够数据,启用 sk_wait_data 阻塞当前进程
sk_wait_data(sk, &timeo);
}
终于看到了我们想要看的东西,skb_queue_walk 是在访问 sock 对象下面的接收队列了。
如果没有收到数据,或者收到不足够多,则调用 sk_wait_data 把当前进程阻塞掉。
//file: net/core/sock.c
int sk_wait_data(struct sock *sk, long *timeo)
{
//当前进程(current)关联到所定义的等待队列项上
DEFINE_WAIT(wait);
// 调用 sk_sleep 获取 sock 对象下的 wait
// 并准备挂起,将进程状态设置为可打断 INTERRUPTIBLE
prepare_to_wait(sk_sleep(sk), &wait, TASK_INTERRUPTIBLE);
set_bit(SOCK_ASYNC_WAITDATA, &sk->sk_socket->flags);
// 通过调用schedule_timeout让出CPU,然后进行睡眠
rc = sk_wait_event(sk, timeo, !skb_queue_empty(&sk->sk_receive_queue));
...
我们再来详细看下 sk_wait_data 是怎么把当前进程给阻塞掉的。
首先在 DEFINE_WAIT 宏下,定义了一个等待队列项 wait。在这个新的等待队列项上,注册了回调函数 autoremove_wake_function,并把当前进程描述符 current 关联到其 .private成员上。
//file: include/linux/wait.h
#define DEFINE_WAIT(name) DEFINE_WAIT_FUNC(name, autoremove_wake_function)
#define DEFINE_WAIT_FUNC(name, function) \
wait_queue_t name = { \
.private = current, \
.func = function, \
.task_list = LIST_HEAD_INIT((name).task_list), \
}
紧接着在 sk_wait_data 中 调用 sk_sleep 获取 sock 对象下的等待队列列表头 wait_queue_head_t。sk_sleep 源代码如下:
//file: include/net/sock.h
static inline wait_queue_head_t *sk_sleep(struct sock *sk)
{
BUILD_BUG_ON(offsetof(struct socket_wq, wait) != 0);
return &rcu_dereference_raw(sk->sk_wq)->wait;
}
接着调用 prepare_to_wait 来把新定义的等待队列项 wait 插入到 sock 对象的等待队列下。
void
prepare_to_wait(wait_queue_head_t *q, wait_queue_t *wait, int state)
{
unsigned long flags;
wait->flags &= ~WQ_FLAG_EXCLUSIVE;
spin_lock_irqsave(&q->lock, flags);
if (list_empty(&wait->task_list))
__add_wait_queue(q, wait);
set_current_state(state);
spin_unlock_irqrestore(&q->lock, flags);
}
这样后面当内核收完数据产生就绪时间的时候,就可以查找 socket 等待队列上的等待项,进而就可以找到回调函数和在等待该 socket 就绪事件的进程了。
- 最后
最后再调用sk_wait_event
让出 CPU,进程将进入睡眠状态,这会导致一次进程上下文的开销。(这里因为没有数据读取,所以进程切换
了)
(2)软中断模块处理(当有消息过来时)
关于网络包到网卡后是怎么被网卡接收,最后在交由软中断处理的,这里就不多赘述了。直接从 tcp 协议的接收函数 tcp_v4_rcv 看起。
- 数据到达流程
1)数据包到达网卡,硬软中断。。。。。
2)tcp_queue_rcv函数将数据保存到socket的接受队列
3)在 tcp_rcv_established 中通过调用sock_def_readable函数唤醒等待队列上的进程
(执行的是函数指针sk_data_ready,但是这个函数指针在创建 socket 流程里执行到的
sock_init_data 函数已经设置成sock_def_readable了)
(唤醒备注:__wake_up_common 实现唤醒,传入参数为1,这里指的是即使是有多个进程
都阻塞在同一个 socket 上,也只唤醒 1 个进程。其作用是为了避免惊群)
软中断(也就是 Linux 里的 ksoftirqd 进程)里收到数据包以后,发现是 tcp 的包的话就会执行到 tcp_v4_rcv 函数。接着走,如果是 ESTABLISH 状态下的数据包,则最终会把数据拆出来放到对应 socket 的接收队列中。然后调用 sk_data_ready 来唤醒用户进程。
-
函数执行流程
tcp_v4_rcv->tcp_v4_do_rcv->tcp_rcv_established->tcp_queue_rcv->sk_data_ready(来唤醒在socket上等待的用户进程。) -
详细流程
// file: net/ipv4/tcp_ipv4.c
int tcp_v4_rcv(struct sk_buff *skb)
{
......
th = tcp_hdr(skb); //获取tcp header
iph = ip_hdr(skb); //获取ip header
//根据数据包 header 中的 ip、端口信息查找到对应的socket
sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);
......
//socket 未被用户锁定
if (!sock_owned_by_user(sk)) {
{
if (!tcp_prequeue(sk, skb))
ret = tcp_v4_do_rcv(sk, skb);
}
}
}
在 tcp_v4_rcv 中首先根据收到的网络包的 header 里的 source 和 dest 信息来在本机上查询对应的 socket。找到以后,我们直接进入接收的主体函数 tcp_v4_do_rcv 来看。
//file: net/ipv4/tcp_ipv4.c
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
if (sk->sk_state == TCP_ESTABLISHED) {
//执行连接状态下的数据处理
if (tcp_rcv_established(sk, skb, tcp_hdr(skb), skb->len)) {
rsk = sk;
goto reset;
}
return 0;
}
//其它非 ESTABLISH 状态的数据包处理
......
}
我们假设处理的是 ESTABLISH 状态下的包,这样就又进入 tcp_rcv_established 函数中进行处理。
//file: net/ipv4/tcp_input.c
int tcp_rcv_established(struct sock *sk, struct sk_buff *skb,
const struct tcphdr *th, unsigned int len)
{
......
//接收数据到队列中
eaten = tcp_queue_rcv(sk, skb, tcp_header_len,
&fragstolen);
//数据 ready,唤醒 socket 上阻塞掉的进程
sk->sk_data_ready(sk, 0);
在 tcp_rcv_established 中通过调用 tcp_queue_rcv 函数中完成了将接收数据放到 socket 的接收队列上。
源码展示
//file: net/ipv4/tcp_input.c
static int __must_check tcp_queue_rcv(struct sock *sk, struct sk_buff *skb, int hdrlen,
bool *fragstolen)
{
//把接收到的数据放到 socket 的接收队列的尾部
if (!eaten) {
__skb_queue_tail(&sk->sk_receive_queue, skb);
skb_set_owner_r(skb, sk);
}
return eaten;
}
调用 tcp_queue_rcv 接收完成之后,接着再调用 sk_data_ready 来唤醒在socket上等待的用户进程。 这又是一个函数指针。回想上面我们在 创建 socket 流程里执行到的 sock_init_data 函数,在这个函数里已经把 sk_data_ready 设置成 sock_def_readable 函数了(可以ctrl + f 搜索前文)。它是默认的数据就绪处理函数。
//file: net/core/sock.c
static void sock_def_readable(struct sock *sk, int len)
{
struct socket_wq *wq;
rcu_read_lock();
wq = rcu_dereference(sk->sk_wq);
//有进程在此 socket 的等待队列
if (wq_has_sleeper(wq))
//唤醒等待队列上的进程
wake_up_interruptible_sync_poll(&wq->wait, POLLIN | POLLPRI |
POLLRDNORM | POLLRDBAND);
sk_wake_async(sk, SOCK_WAKE_WAITD, POLL_IN);
rcu_read_unlock();
}
在 sock_def_readable 中再一次访问到了 sock->sk_wq 下的wait。回忆下我们前面调用 recvfrom 执行的最后,通过 DEFINE_WAIT(wait) 将当前进程关联的等待队列添加到 sock->sk_wq 下的 wait 里了。
那接下来就是调用 wake_up_interruptible_sync_poll 来唤醒在 socket 上因为等待数据而被阻塞掉的进程了。
//file: include/linux/wait.h
#define wake_up_interruptible_sync_poll(x, m) \
__wake_up_sync_key((x), TASK_INTERRUPTIBLE, 1, (void *) (m))
//file: kernel/sched/core.c
void __wake_up_sync_key(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, void *key)
{
unsigned long flags;
int wake_flags = WF_SYNC;
if (unlikely(!q))
return;
if (unlikely(!nr_exclusive))
wake_flags = 0;
spin_lock_irqsave(&q->lock, flags);
__wake_up_common(q, mode, nr_exclusive, wake_flags, key);
spin_unlock_irqrestore(&q->lock, flags);
}
__wake_up_common 实现唤醒。这里注意下, 该函数调用是参数 nr_exclusive 传入的是 1,这里指的是即使是有多个进程都阻塞在同一个 socket 上,也只唤醒 1 个进程。其作用是为了避免惊群。
//file: kernel/sched/core.c
static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, int wake_flags, void *key)
{
wait_queue_t *curr, *next;
list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
unsigned flags = curr->flags;
if (curr->func(curr, mode, wake_flags, key) &&
(flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
}
}
在 __wake_up_common 中找出一个等待队列项 curr,然后调用其 curr->func。回忆我们前面在 recv 函数执行的时候,使用 DEFINE_WAIT() 定义等待队列项的细节,内核把 curr->func 设置成了 autoremove_wake_function。
//file: include/linux/wait.h
#define DEFINE_WAIT(name) DEFINE_WAIT_FUNC(name, autoremove_wake_function)
#define DEFINE_WAIT_FUNC(name, function) \
wait_queue_t name = { \
.private = current, \
.func = function, \
.task_list = LIST_HEAD_INIT((name).task_list), \
}
在 autoremove_wake_function 中,调用了 default_wake_function。
//file: kernel/sched/core.c
int default_wake_function(wait_queue_t *curr, unsigned mode, int wake_flags,
void *key)
{
return try_to_wake_up(curr->private, mode, wake_flags);
}
调用 try_to_wake_up 时传入的 task_struct 是 curr->private。这个就是当时因为等待而被阻塞的进程项。当这个函数执行完的时候,在 socket 上等待而被阻塞的进程就被推入到可运行队列里了,这又将是一次进程上下文切换的开销
。
(3)阻塞方式流程总结
内核在通知网络包的运行环境分两部分:
- 第一部分是我们自己代码所在的进程,我们调用的 socket() 函数会进入内核态创建必要内核对象。recv() 函数在进入内核态以后负责查看接收队列,以及在没有数据可处理的时候把当前进程阻塞掉,让出 CPU。
- 第二部分是硬中断、软中断上下文(系统进程 ksoftirqd)。在这些组件中,将包处理完后会放到 socket 的接收队列中。然后再根据 socket 内核对象找到其等待队列中正在因为等待而被阻塞掉的进程,然后把它唤醒。
(3)以epoll协同内核和用户进程
- epoll简单例子开局
int main(){
listen(lfd, ...);
cfd1 = accept(...);
cfd2 = accept(...);
efd = epoll_create(...);
epoll_ctl(efd, EPOLL_CTL_ADD, cfd1, ...);
epoll_ctl(efd, EPOLL_CTL_ADD, cfd2, ...);
epoll_wait(efd, ...)
}
epoll相关函数是下面三个
epoll_create:创建一个 epoll 对象
epoll_ctl:向 epoll 对象中添加要管理的连接
epoll_wait:等待其管理的连接上的 IO 事件
一、accept 创建新 socket(初始化struct socket对象、为新 socket 对象申请 file,file是内核对象指针,指存放的内存、接收连接、添加新文件到当前进程的打开文件列表中)
我们直接从服务器端的 accept 讲起。当 accept 之后,进程会创建一个新的 socket 出来,专门用于和对应的客户端通信,然后把它放到当前进程的打开文件列表中。
其中一条连接的 socket 内核对象更为具体一点的结构图如下。
接下来我们来看一下接收连接时 socket 内核对象的创建源码。accept 的系统调用代码位于源文件 net/socket.c 下。
//file: net/socket.c
SYSCALL_DEFINE4(accept4, int, fd, struct sockaddr __user *, upeer_sockaddr,
int __user *, upeer_addrlen, int, flags)
{
struct socket *sock, *newsock;
//根据 fd 查找到监听的 socket
sock = sockfd_lookup_light(fd, &err, &fput_needed);
//1.1 申请并初始化新的 socket
newsock = sock_alloc();
newsock->type = sock->type;
newsock->ops = sock->ops;
//1.2 申请新的 file 对象,并设置到新 socket 上
newfile = sock_alloc_file(newsock, flags, sock->sk->sk_prot_creator->name);
......
//1.3 接收连接
err = sock->ops->accept(sock, newsock, sock->file->f_flags);
//1.4 添加新文件到当前进程的打开文件列表
fd_install(newfd, newfile);
- accpet做的事
//根据 fd 查找到监听的 socket
//1.1 申请并初始化新的 socket
//1.2 申请新的 file 对象,并设置到新 socket 上
//1.3 接收连接
//1.4 添加新文件到当前进程的打开文件列表
- 1.1 初始化 struct socket 对象
在上述的源码中,首先是调用 sock_alloc 申请一个 struct socket 对象出来。然后接着把 listen 状态的 socket 对象上的协议操作函数集合 ops 赋值给新的 socket。(对于所有的 AF_INET 协议族下的 socket 来说,它们的 ops 方法都是一样的,所以这里可以直接复制过来)
其中 inet_stream_ops 的定义如下
//file: net/ipv4/af_inet.c
const struct proto_ops inet_stream_ops = {
...
.accept = inet_accept,
.listen = inet_listen,
.sendmsg = inet_sendmsg,
.recvmsg = inet_recvmsg,
...
}
- 1.2 为新 socket 对象申请 file(指向存放的内存地址)
struct socket 对象中有一个重要的成员 – file 内核对象指针。这个指针初始化的时候是空的。在 accept 方法里会调用 sock_alloc_file 来申请内存并初始化。然后将新 file 对象设置到 sock->file 上。
来看 sock_alloc_file 的实现过程:
struct file *sock_alloc_file(struct socket *sock, int flags,
const char *dname)
{
struct file *file;
file = alloc_file(&path, FMODE_READ | FMODE_WRITE,
&socket_file_ops);
......
sock->file = file;
}
sock_alloc_file 又会接着调用到 alloc_file。注意在 alloc_file 方法中,把 socket_file_ops 函数集合一并赋到了新 file->f_op 里了。
//file: fs/file_table.c
struct file *alloc_file(struct path *path, fmode_t mode,
const struct file_operations *fop)
{
struct file *file;
file->f_op = fop;
......
}
socket_file_ops 的具体定义如下:
//file: net/socket.c
static const struct file_operations socket_file_ops = {
...
.aio_read = sock_aio_read,
.aio_write = sock_aio_write,
.poll = sock_poll,
.release = sock_close,
...
};
这里看到,在accept里创建的新 socket 里的 file->f_op->poll 函数指向的是 sock_poll。接下来我们会调用到它,后面我们再说。
其实 file 对象内部也有一个 socket 指针,指向 socket 对象。
- 1.3 接收连接
在 socket 内核对象中除了 file 对象指针以外,有一个核心成员 sock。
//file: include/linux/net.h
struct socket {
struct file *file;
struct sock *sk;
}
这个 struct sock 数据结构非常大,是 socket 的核心内核对象。发送队列、接收队列、等待队列等核心数据结构都位于此。其定义位置文件 include/net/sock.h,由于太长就不展示了。
在 accept 的源码中:
//file: net/socket.c
SYSCALL_DEFINE4(accept4, ...)
...
//1.3 接收连接
err = sock->ops->accept(sock, newsock, sock->file->f_flags);
}
sock->ops->accept 对应的方法是 inet_accept。它执行的时候会从握手队列里直接获取创建好的 sock。sock 对象的完整创建过程涉及到三次握手,比较复杂,不展开了说了。咱们只看 struct sock 初始化过程中用到的一个函数:
void sock_init_data(struct socket *sock, struct sock *sk)
{
sk->sk_wq = NULL;
sk->sk_data_ready = sock_def_readable;
}
在这里把 sock 对象的 sk_data_ready 函数指针设置为 sock_def_readable
。这个这里先记住就行了,后面会用到。
- 1.4 添加新文件到当前进程的打开文件列表中
当 file、socket、sock 等关键内核对象创建完毕以后,剩下要做的一件事情就是把它挂到当前进程的打开文件列表中就行了。
//file: fs/file.c
void fd_install(unsigned int fd, struct file *file)
{
__fd_install(current->files, fd, file);
}
void __fd_install(struct files_struct *files, unsigned int fd,
struct file *file)
{
...
fdt = files_fdtable(files);
BUG_ON(fdt->fd[fd] != NULL);
rcu_assign_pointer(fdt->fd[fd], file);
}
二、epoll_create 实现(有啥成员以及做了啥)
在用户进程调用 epoll_create 时,内核会创建一个 struct eventpoll 的内核对象。并同样把它关联到当前进程的已打开文件列表中。
对于 struct eventpoll 对象,更详细的结构如下(同样只列出和今天主题相关的成员)。
epoll_create 的源代码相对比较简单。在 fs/eventpoll.c 下
// file:fs/eventpoll.c
SYSCALL_DEFINE1(epoll_create1, int, flags)
{
struct eventpoll *ep = NULL;
//创建一个 eventpoll 对象
error = ep_alloc(&ep);
}
// file:fs/eventpoll.c
struct eventpoll {
//sys_epoll_wait用到的等待队列
wait_queue_head_t wq;
//接收就绪的描述符都会放到这里
struct list_head rdllist;
//每个epoll对象中都有一颗红黑树
struct rb_root rbr;
......
}
- eventpoll 这个结构体中的几个成员的含义如下:
1)wq: 等待队列链表。软中断数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程。
2)rbr: 一棵红黑树。为了支持对海量连接的高效查找、插入和删除,eventpoll 内部使用了一棵红黑树。通过这棵树来管理用户进程下添加进来的所有 socket 连接。
3)rdllist: 就绪的描述符的链表。当有的连接就绪的时候,内核会把就绪的连接放到 rdllist 链表里。这样应用进程只需要判断链表就能找出就绪进程,而不用去遍历整棵树。
当然这个结构被申请完之后,需要做一点点的初始化工作,这都在 ep_alloc 中完成。(初始化操作)
//file: fs/eventpoll.c
static int ep_alloc(struct eventpoll **pep)
{
struct eventpoll *ep;
//申请 epollevent 内存
ep = kzalloc(sizeof(*ep), GFP_KERNEL);
//初始化等待队列头
init_waitqueue_head(&ep->wq);
//初始化就绪列表
INIT_LIST_HEAD(&ep->rdllist);
//初始化红黑树指针
ep->rbr = RB_ROOT;
......
}
三、epoll_ctl 添加 socket(创建红黑树节点、加入等待队列、将节点加上事件插入到epoll的红黑树)
假设我们现在和客户端们的多个连接的 socket 都创建好了,也创建好了 epoll 内核对象。在使用 epoll_ctl 注册每一个 socket 的时候,内核会做如下三件事情
1.分配一个红黑树节点对象 epitem,
2.添加等待事件到 socket 的等待队列中,其回调函数是 ep_poll_callback
3.将 epitem 插入到 epoll 对象的红黑树里
通过 epoll_ctl 添加两个 socket 以后,这些内核数据结构最终在进程中的关系图大致如下:
我们来详细看看 socket 是如何添加到 epoll 对象里的,找到 epoll_ctl 的源码。
// file:fs/eventpoll.c
SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd,
struct epoll_event __user *, event)
{
struct eventpoll *ep;
struct file *file, *tfile;
//根据 epfd 找到 eventpoll 内核对象
file = fget(epfd);
ep = file->private_data;
//根据 socket 句柄号, 找到其 file 内核对象
tfile = fget(fd);
switch (op) {
case EPOLL_CTL_ADD:
if (!epi) {
epds.events |= POLLERR | POLLHUP;
error = ep_insert(ep, &epds, tfile, fd);
} else
error = -EEXIST;
clear_tfile_check_list();
break;
}
在 epoll_ctl 中首先根据传入 fd 找到 eventpoll、socket相关的内核对象 。对于 EPOLL_CTL_ADD 操作来说,会然后执行到 ep_insert 函数。所有的注册都是在这个函数中完成的。
//file: fs/eventpoll.c
static int ep_insert(struct eventpoll *ep,
struct epoll_event *event,
struct file *tfile, int fd)
{
//3.1 分配并初始化 epitem
//分配一个epi对象
struct epitem *epi;
if (!(epi = kmem_cache_alloc(epi_cache, GFP_KERNEL)))
return -ENOMEM;
//对分配的epi进行初始化
//epi->ffd中存了句柄号和struct file对象地址
INIT_LIST_HEAD(&epi->pwqlist);
epi->ep = ep;
ep_set_ffd(&epi->ffd, tfile, fd);
//3.2 设置 socket 等待队列
//定义并初始化 ep_pqueue 对象
struct ep_pqueue epq;
epq.epi = epi;
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
//调用 ep_ptable_queue_proc 注册回调函数
//实际注入的函数为 ep_poll_callback
revents = ep_item_poll(epi, &epq.pt);
......
//3.3 将epi插入到 eventpoll 对象中的红黑树中
ep_rbtree_insert(ep, epi);
......
}
四、epoll_wait 等待接收(观察 eventpoll->rdllist 链表里有没有数据即可)
epoll_wait 做的事情不复杂,当它被调用时它观察 eventpoll->rdllist 链表里有没有数据即可。有数据就返回,没有数据就创建一个等待队列项,将其添加到 eventpoll 的等待队列上,然后把自己阻塞掉就完事。
注意:epoll_ctl 添加 socket 时也创建了等待队列项。不同的是这里的等待队列项是挂在 epoll 对象上的,而前者是挂在 socket 对象上的。其源代码如下:
//file: fs/eventpoll.c
SYSCALL_DEFINE4(epoll_wait, int, epfd, struct epoll_event __user *, events,
int, maxevents, int, timeout)
{
...
error = ep_poll(ep, events, maxevents, timeout);
}
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)
{
wait_queue_t wait;
......
fetch_events:
//4.1 判断就绪队列上有没有事件就绪
if (!ep_events_available(ep)) {
//4.2 定义等待事件并关联当前进程
init_waitqueue_entry(&wait, current);
//4.3 把新 waitqueue 添加到 epoll->wq 链表里
__add_wait_queue_exclusive(&ep->wq, &wait);
for (;;) {
...
//4.4 让出CPU 主动进入睡眠状态
if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
timed_out = 1;
...
}
五、当数据来时(执行不同回调函数)
在前面 epoll_ctl 执行的时候,内核为每一个 socket 上都添加了一个等待队列项。在 epoll_wait 运行完的时候,又在 event poll 对象上添加了等待队列元素。在讨论数据开始接收之前,我们把这些队列项的内容再稍微总结一下。
- 流程
1)socket->sock->sk_data_ready 设置的就绪处理函数是 sock_def_readable
2)在 socket 的等待队列项中,其回调函数是 ep_poll_callback。另外其 private 没有用了,指向的是空指针 null。
3)在 eventpoll 的等待队列项中,回调函数是 default_wake_function。其 private 指向的是等待该事件的用户进程。
六、epoll使用总结
其中软中断回调的时候回调函数也整理一下:
sock_def_readable:sock 对象初始化时设置的 => ep_poll_callback : epoll_ctl 时添加到 socket 上的 => default_wake_function: epoll_wait 是设置到 epoll上的
总结下,epoll 相关的函数里内核运行环境分两部分:
- 用户进程内核态。进行调用 epoll_wait 等函数时会将进程陷入内核态来执行。这部分代码负责查看接收队列,以及负责把当前进程阻塞掉,让出 CPU。
- 硬软中断上下文。在这些组件中,将包从网卡接收过来进行处理,然后放到 socket 的接收队列。对于 epoll 来说,再找到 socket 关联的 epitem,并把它添加到 epoll 对象的就绪链表中。这个时候再捎带检查一下 epoll 上是否有被阻塞的进程,如果有唤醒之。
备注
只要活儿足够的多,epoll_wait 根本都不会让进程阻塞
4)本章总结(回答之前的问题)
- 1)阻塞是怎么回事(关键看是否放弃CPU,放弃就是阻塞)
阻塞定义:进程因为等待某个事件而主动让出CPU挂起的操作
原因:
在网络IO中,当进程在等待socket上的数据时,如果数据还没到来,那就把当前进程状态从TASK_RUNNING修改为TASK_INTERRUPTIPLE,然后让出CPU。由调度器来调度下一个就绪状态的进程来执行
- 2)同步阻塞IO组要哪些开销
1)进程调用recv系统调用接受一个socket上的数据,如果数据没有到达,就要让出CPU切换到其他进程。这就导致一
次进程上下文的切换开销
2)当连接上的数据就绪时,睡眠的进程又会被唤醒,又是一次进程切换的开销
3)一个进程同时只能等待一条连接,如果高并发则需要很多进程,每个进程都将占用几MB的内存。百万并发,
这内存就不够用了
备注:
从CPU开销来看,一次同步阻塞网络IO将导致两次进程上下文切换开销,这涉及3~5微秒。但这个对于开发者来看,没有任何意义,网络IO密集起来,CPU不断做进程切换,忙还不讨好
- 3)多路复用epoll为什么能提高网络性能
高性能原因:极大程度减少了无用的进程上下文切换,让进程更专注地处理网络请求
过程:
(1)在内核的软、硬中断上下文中,包从网卡接受过来处理,放到socket的接受队列。再找到socket关联的epitem,并把它添加到epoll对象的就绪链表中
(2)用户进程中,通过调用epoll_wait
来查看就绪链表中是否有事件到达,如果有,直接取走并进行处理。处理完毕再次调用epoll_wait。在高并发实践中,只要活足够多,epoll_wait不会让用户进程阻塞,直到没活可干才会主动让出CPU
- 4)epoll也是阻塞的吗?
epoll是阻塞的,进程因为等到某个事件而主动让出CPU挂起的操作
。但是实际过程中,只要活够多,epoll_wait根本不会让进程阻塞,用户进程会一直干活
备注:
但是阻塞并不会直接导致低性能,过多频道的阻塞才会导致低性能
.epoll的高性能和它的阻塞并不冲突
- 5)为什么redis的网络性能突出?
(1)事件循环很简单
void aeMain(aeEvetnLoop* eventLoop)
{
job = epoll_wait();
do_job();
}
(2)主要业务逻辑在内存上的数据结构的读写
(3)主服务端程序是单进程的,省区了多进程之间协作的负担,减少了进程切换