使用Socket API可以很方便的完成网络编程。但是从性能角度考虑,不论是阻塞IO或是非阻塞IO,Sockets都不能回避最基本的问题——Sockets与网络中断的异步处理机制。
我们考虑一个多线程程序,它从多个sockets读取数据。一般来说它会这么做:
int n = epoll_wait(e->fd, e->event, e->max_events, 1000);
for (int i = 0; i < n; ++i) {
unsigned char buf[4096];
read(e->event[i].data.fd, buf, 4096);
}
执行poll操作的socket既可以是阻塞的也可以是非阻塞的。让我们暂且忘记缓冲区复制,将注意力集中在接收到的报文上。
网络处理架构这张图显示了两个进程(在我们此次的讨论中,进程和线程并没有区别)从3个sockets读取数据。不同的进程工作在不同的CPU上。这里有可能启用了Receive Flow Steering (RFS),使得softirq在处理报文时,特定发给第一个进程的报文限定被交给第一个CPU,发给第二个进程的报文发给第二个CPU。各个socket都有一个接收队列,接收到的报文在被处理之前先进入该队列。
认真的看代码样例,我们会发现两个系统调用,这是相对较慢的操作。而且在两次系统调用之间,进程可能被重新调度并且/或者被占先。所以当进程在epoll_wait()调用中被socket事件(当socket得到一个报文)唤醒时,进程读取socket数据时会有一些延迟(delay)。从第二个socket队列到第二个进程之间有一条加粗的箭头线,它表示的是从socket读取一段数据。这里有两个并发情况:
- 在epoll_wait()唤醒进程和进程从socket读取数据这段时间之间,进程会被softirq占先(peempted)(当然,可以通过将进程和NIC中断绑定到不同的CPU cores上避免这种情况)。、
- 在高负载情况下,Linux切换到polling模式,非常非常快速地获取大量的报文,所i在这段延迟期间,sftirq可以处理大量其他报文。
问题是,当进程开始处理这个报文时,softirq可以读取其他报文(事实上应该是大量报文,如图中softirq与第一个Socket队列自建的加粗箭头线)。对于一般以太网链路,报文大小为64到1500字节,那么显然那个当前被进程处理的报文不能留存在CPU Cache中了,它会被CPU Cache中的其他报文挤出去。这样,即使使用了零拷贝,用户空间的网络程序也不能得到好的性能。
事实上Linux防火墙工作在softirq上下文中。这意味着当接收到报文后,是以同步synchronously模型立即进行处理的。进而,同步报文处理在网络层面(防火墙在这个层面工作)是没有限制的。幸运的是,Linux将TCP流放在同样是softirq上下文中处理,Linux内核同时在struct结构体中提供了几个回调(参考include/net/sock.h):
void (*sk_state_change)(struct sock *sk); void (*sk_data_ready)(struct sock *sk, int bytes); void (*sk_write_space)(struct sock *sk); void (*sk_error_report)(struct sock *sk);
int (*sk_backlog_rcv)(struct sock *sk, struct sk_buff *skb);
例如,当新数据在socket上被接收时回调sk_data_ready()函数。所以,在延迟的中断上下文中可以简单的同步读取TCP数据。而往socket写数据要难一些,但仍然是可能的。当然,此时你的程序必须在内核模式运行了。
让我们看看再TCP数据读取时是哦那个钩子的例子。首先我们需要一个监听socket(这是内核sockets,所以socket API是不一样的)。
struct socket *l_sock;
sock_create_kern(AF_INET, SOCK_STREAM, IPPROTO_TCP, &l_sock);
inet_sk(s->sk)->freebind = 1;
/* addr is some address packed into struct sockaddr_in */ l_sock->ops->bind(l_sock, (struct sockaddr *)addr, sizeof(addr));
l_sock->sk->sk_state_change = th_tcp_state_change; l_sock->ops->listen(l_sock, 100);
当Socket状态变化时,sk_state_chage()被Linux TCP 代码调用。 我们需要建立一个新的socket连接,所以我们需要处理TCP_ESTABLISHED状态变化. child socket也当然会设置TCP_ESTABLISHED状态, 不过我们再监听socket上设置这个回调,因为child socket 从其parent继承了回调指针,th_tcp_state_change()可以被定义为:
void th_tcp_state_change(struct sock *sk) {
if (sk->sk_state == TCP_ESTABLISHED)
sk->sk_data_ready = th_tcp_data_ready;
}
然后我们会设置其他的回调,child socket也已经被设置。 th_tcp_data_ready() 会在接收队列(sk_receive_queue)中有新的数据时被调用。因此再这个函数中,我们需要做标准Linux tcp_recvmsg()会做的事情——遍历队列,从中挑选序列号正确的报文:
void
th_tcp_data_ready(struct sock *sk, int bytes)
{
unsigned int processed = 0, off;
struct sk_buff *skb, *tmp;
struct tcp_sock *tp = tcp_sk(sk);
skb_queue_walk_safe(&sk->sk_receive_queue, skb, tmp) {
off = tp->copied_seq - TCP_SKB_CB(skb)->seq;
if (tcp_hdr(skb)->syn)
off--;
if (off < skb->len) {
int n = skb_headlen(skb);
printk(KERN_INFO "Received: %.*s\n",
n - off, skb->data + off);
tp->copied_seq += n - off;
processed += n - off;
}
}
/*
* Send ACK to the client and recalculate
* the appropriate TCP receive buffer space.
*/
tcp_cleanup_rbuf(sk, processed);
tcp_rcv_space_adjust(sk);
/* Release skb - it's no longer needed. */
sk_eat_skb(sk, skb, 0);
}
这个函数应该更加复杂,以正确的处理skb的分页数据和碎片,释放skb,更准确的处理TCP序列号,等等。但是基本的思想如上所属。
原文来源:[What's Wrong With Sockets Performance And How to Fix It](http://natsys-lab.blogspot.com/2013/03/whats-wrong-with-sockets-performance.html)
网友评论