Linux网络(二)——socket、BIO、epoll原理
二、内核如何与用户进程协作
//创建Socket的c语言程序
...
int main(){
int sk = socket(PF_INET, SOCK_STREAM, 0);
//忽略bind和accept
...
}
2.1 读取视角:Linux socket 结构
2.1.1 socket源码
//代码:/include/linux/net.h
struct socket {
socket_state state;
short type;
unsigned long flags;
struct file *file;
struct sock *sk;
const struct proto_ops *ops; /* Might change with IPV6_ADDRFORM or MPTCP. */
struct socket_wq wq;
};
//代码:/include/linux/net.h
struct proto_ops {
//其他的的属性忽略
int family;
int (*release) (struct socket *sock);
int (*bind) (struct socket *sock, struct sockaddr *myaddr, int sockaddr_len);
int (*connect) (struct socket *sock, struct sockaddr *vaddr, int sockaddr_len, int flags);
int (*accept) (struct socket *sock, struct socket *newsock, struct proto_accept_arg *arg);
int (*listen) (struct socket *sock, int len);
};
//sock的属性太多了,只简单展示重要的
// 代码:/include / net / sock.h
struct sock {
struct sk_buff_head sk_receive_queue;
union {
struct socket_wq __rcu *sk_wq;
...
};
#define sk_prot __sk_common.skc_prot
void (*sk_data_ready)(struct sock *sk);
}
// 代码: / include / linux / wait.h
struct wait_queue_entry {
unsigned int flags;
//void指针什么类型都可
void *private;
wait_queue_func_t func;
struct list_head entry;
};
- 本文中,函数指针均绘制为黄色,一般的结构体为灰色。大家将函数指针当做java的成员方法即可。
- 为什么代码中用的都是函数指针?Linux内核使用C语言编写,C语言本身对OOP的支持度不高,结构体类型只封装属性,却不封装方法,因此通过函数指针的方式,实现成员方法的效果。内核在初始化很多结构体时,都是通过给函数指针赋值的方式,实现多态。
2.1.2 Socket的创建
C语言中(Linux环境下),通过glibc库函数:int socket (int __domain, int __type, int __protocol) 发起系统调用,创建一个socket,内核源码的调用链比较长(socket--sock_create--__sock_create),大致过程是:
- 分配一个socket内核对象
- 根据参数,获得协议族的操作表pf,调用协议族的创建函数.比如传入协议族的参数为AF_NET,则最终执行的方法是inet_create,这是通过函数指针实现的
- 在pf的create函数中,动态设置sock的ops属性
- 其他类似的函数指针绑定
2.2 读取视角:socket等待接受消息
在BIO中,大家都知道socket接收数据时,若数据没有到,进程会阻塞。那么,socket是如何导致进程阻塞的呢?
2.2.1 概述
程序通过glibc库函数recv发起系统调用,会进入recvfrom方法。通过socket属性ops绑定的函数进入sock对象所绑定的接收函数。前面已经提到,sock对象内部有接收队列,recvmsg函数会访问接收队列,如果队列里的数据为空、或者收到的不够多,则调用sk_wait_data阻塞掉当前进程。
2.2.2 tcp的接收函数
对于tcp协议的socket,recvfrom系统调用会执行到sock对象注册的tcp_recvmsg方法,内部又调用了tcp_recvmsg_locked方法,在该方法中具体执行数据包接受并且阻塞线程的操作。
// 代码:/ net / ipv4 / tcp.c
int tcp_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, int flags,
int *addr_len){
...
lock_sock(sk);
//接收数据
ret = tcp_recvmsg_locked(sk, msg, len, flags, &tss, &cmsg_flags);
release_sock(sk);
...
}
static int tcp_recvmsg_locked(...){
//代码非常长,只看关键
do{
if (copied >= target) {
/* Do not sleep, just process backlog. */
__sk_flush_backlog(sk);
} else {
tcp_cleanup_rbuf(sk, copied);
//这里阻塞线程
err = sk_wait_data(sk, &timeo, last);
if (err < 0) {
err = copied ? : err;
goto out;
}
}
}while(len>0);
}
从以上代码可以看出,读取socket数据也是加锁的。至于Linux锁原理,我以后再仔细研究。tcp_recvmsg_locked里面有do while循环,如果接受数据不够,则阻塞线程,如果线程唤醒,满足循环条件则继续循环,读取数据。
2.2.3 sk_wait_data是怎么阻塞掉进程的?
先上源码。进程是如何阻塞/睡眠的?这个是操作系统的知识了。后续我另外一篇文章会比较详细介绍进程调度的。
int sk_wait_data(struct sock *sk, long *timeo, const struct sk_buff *skb)
{
DEFINE_WAIT_FUNC(wait, woken_wake_function);
int rc;
//将当前进程加到sock对象的等待队列中
add_wait_queue(sk_sleep(sk), &wait);
sk_set_bit(SOCKWQ_ASYNC_WAITDATA, sk);
//让出CPU,进行睡眠
rc = sk_wait_event(sk, timeo, skb_peek_tail(&sk->sk_receive_queue) != skb, &wait);
sk_clear_bit(SOCKWQ_ASYNC_WAITDATA, sk);
remove_wait_queue(sk_sleep(sk), &wait);
return rc;
}
后续内核接收完数据产生就绪事件时,就查找socket上的等待队列上的等待项,进而找到回调函数以及睡眠的进程。为了避免惊群现象,系统只会唤醒一个进程。
2.3 接收视角:软中断如何处理数据包
前面讲述了程序如何通过socket读取数据,本节介绍网卡接受到数据后,交给软中断,最后送到socket接受队列的过程。
2.3.1 概括
数据包抵达网卡,网卡DMA数据到内存,发起硬中断,这个硬中断简单处理后触发软中断(网络类型的软中断),由ksoftirqd内核线程进行处理,ksoftirqd会调用到该类型中断注册的中断处理函数,交给协议栈逐层解析,最终数据包放到sock对象接收队列中,并唤醒等待队列上的线程。
2.3.2 同步阻塞方式总结
- 用户进程,调用库函数recv进入系统调用函数recvfrom,在这里执行socket中注册的接收函数。如果sock的接收队列为空,则将进程状态改为阻塞(TASK_INTERRUPTIBLE),让出CPU。
- 数据到达网卡,网卡找到可用的RingBuffer,DMA到这块内存中。硬中断触发软中断中,通过网卡注册的poll函数,将数据包交给协议栈。数据处理完,在tcp_v4_rcv函数中,根据包头的ip、port执行找到socket,数据会被放到socket的接收队列中,从等待队列中找到被阻塞的进程,把它唤醒。
2.4 大名鼎鼎的epoll
- 前面介绍的recvfrom系统调用为阻塞式IO,一个进程负责监听一个socket。效率太低。频繁的上下文切换开销很大。据统计,一次进程上下文切换的时间开销为3~5微秒。
- 网上介绍epoll时都说epoll是基于事件和回调函数的,那到底是怎么回调的呢?在那里回调?
int main(){
...
//监听
listen(lfd,...);
//接收连接
cfd1=accept(...);
cfd1=accept(...);
//创建epoll
efd=epoll_create(...);
//连接交给epoll管理
epoll_ctr(efd,EPOLL_CTL_ADD,cfd1);
epoll_ctr(efd,EPOLL_CTL_ADD,cfd2);
epoll_wait(efd,...);
...
}
2.4.1 epoll的数据结构
// fs/evenpoll.c
struct eventpoll {
//sys_epoll_wait用的等待队列,放阻塞的用户进程
wait_queue_head_t wq;
//红黑树根节点
struct rb_root_cached rbr;
//接收就绪的描述符列表
struct list_head rdllist;
struct file *file;
...
};
struct epitem {
...
struct rb_node rbn;
struct list_head rdllink;
struct epoll_filefd ffd;
struct eppoll_entry *pwqlist;
struct eventpoll *ep;
...
};
eventpoll的成员含义:
- wq:等待队列列表,软中断就绪时通过wq找被阻塞的用户进程
- rdllist:连接就绪时,将就绪的连接放入链表,这样用户进程就不用遍历整棵树了
- rbr:红黑树,存储海量连接,高效查找、插入、删除
epitem含义
- rbn 红黑树节点
- ffd socket文件描述符
- ep 所属的eventpoll对象
- pwqlist 等待队列
epoll使用红黑树存储连接集合,使用链表存储就绪的连接,如果既要又要,不如综合使用多种数据结构
2.4.2 epoll是如何与socket关联的
1.为epoll添加socket
通过函数epoll_ctr(efd,EPOLL_CTL_ADD,cfd1)为epoll添加socket,我们看一下源代码:
SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd,
struct epoll_event __user *, event)
{
struct epoll_event epds;
if (ep_op_has_event(op) &&
copy_from_user(&epds, event, sizeof(struct epoll_event)))
return -EFAULT;
//我们进入到这里
return do_epoll_ctl(epfd, op, fd, &epds, false);
}
// 为epoll添加socket的函数
int do_epoll_ctl(int epfd, int op, int fd, struct epoll_event *epds,
bool nonblock)
{
//根据epoll句柄找到epoll对象
f = fdget(epfd);
ep = f.file->private_data;
switch (op) {
case EPOLL_CTL_ADD:
if (!epi) {
epds->events |= EPOLLERR | EPOLLHUP;
//进入到这里
error = ep_insert(ep, epds, tf.file, fd, full_check);
} else
error = -EEXIST;
break;
...
}
//具体插入的函数
static int ep_insert(struct eventpoll *ep, const struct epoll_event *event,
struct file *tfile, int fd, int full_check)
{
//省略申请epitem内核对象、epitem的ep指针、fd的设置
...
// 设置等待队列的回调函数!
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
revents = ep_item_poll(epi, &epq.pt, 1);
...
}
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,
poll_table *pt)
{
...
//这里设置了等待队列的回调函数为ep_poll_callback!注意,函数中的whead是sock对象的等待队列!
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
pwq->whead = whead;
pwq->base = epi;
if (epi->event.events & EPOLLEXCLUSIVE)
add_wait_queue_exclusive(whead, &pwq->wait);
else
add_wait_queue(whead, &pwq->wait);
}
//介绍sock等待队列已经介绍了,等待队列项private是对象,func是回调函数
//阻塞式IO中,recvfrom函数无法从接收队列得到数据时,会向sock等待队列中增加等待项,此时的private为用户进程描述符,func设置成了autoremove_wake_function。
static inline void
init_waitqueue_func_entry(struct wait_queue_entry *wq_entry, wait_queue_func_t func)
{
wq_entry->flags = 0;
wq_entry->private = NULL;
wq_entry->func = func;
}
经过初始化,epoll结构示意图
file 数组是task_struck记录的打开文件列表,内核打开文件(比如socket),就创建一个fd(句柄),fd就是一个下标,file[fd]获取file对象。而file类存放数据的字段为void private(这个名字真迷惑),private属性放的就是具体的类型了,比如socket
总结一下,往epoll对象添加socket时,会创建一个epitem封装之,sock等待队列等待项的func被设置为ep_poll_callback,供数据达到时调用,base被设置为epitem。epitem记录了socket句柄和所属的epoll对象。epoll使用红黑树插入、查询、删除epitem,队列存储就绪的socket。
2.4.3 epoll_wait等待接收
epoll_wait函数就是查看一下event_poll对象的等待队列有没有就绪的socket,如果有,则返回之,否则,创建等待队列项,将当前进程关联到等待项中,注册唤醒的回调函数,以便事件就绪时唤醒epoll进程。将等待项插入到epoll的等待队列中,再主动让出cpu,进入睡眠。
网上常有人说epoll非阻塞,实际上,epoll本身也会阻塞进程,当没有就绪的socket,epoll进程就阻塞一下,占着cpu也没有意义。实际上,epoll的非阻塞,具体是socket非阻塞。在BIO中,sock接受队列没有数据时,就将当前进程阻塞。而在epoll中,sock没有数据并不会阻塞进程,反而是epoll就绪队列空时,会造成进程阻塞。
BIO的缺陷在于频繁的阻塞,对于epoll,只要有事件,就会一直干活,socket一多,阻塞的概率就很低。频繁的阻塞以及上下文切换才是造成性能开销大的根本原因。
2.4.4 数据到来
介绍recvfrom时,我们知道,数据从网卡-->硬中断-->软中断-->协议栈,进入到tcp_v4_rcv内,将数据放到sock接受队列上,然后调用等待项注册的回调函数func(BIO中,为唤醒进程),我们已经知道func在关联socket时,被设置为ep_poll_callback函数了。这个函数主要将当前epitem放到eventpoll的就绪队列中,如果eventpoll上有等待,则唤醒之。epoll_wait继续执行,返回结果
总结
阻塞到底是什么一回事?
BIO中,socket的接收队列为空时导致阻塞。在epoll中,socket不会导致阻塞,而当epoll的就绪队列为空时,会导致阻塞。然而,socket足够多的时候,epoll一直有活干,就几乎不阻塞了,也线程上下文切换的开销大大降低。