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;
};
  1. 本文中,函数指针均绘制为黄色,一般的结构体为灰色。大家将函数指针当做java的成员方法即可。
  2. 为什么代码中用的都是函数指针?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),大致过程是:

  1. 分配一个socket内核对象
  2. 根据参数,获得协议族的操作表pf,调用协议族的创建函数.比如传入协议族的参数为AF_NET,则最终执行的方法是inet_create,这是通过函数指针实现的
  3. 在pf的create函数中,动态设置sock的ops属性
  4. 其他类似的函数指针绑定

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继续执行,返回结果

总结

epoll流程图

阻塞到底是什么一回事?

BIO中,socket的接收队列为空时导致阻塞。在epoll中,socket不会导致阻塞,而当epoll的就绪队列为空时,会导致阻塞。然而,socket足够多的时候,epoll一直有活干,就几乎不阻塞了,也线程上下文切换的开销大大降低。

posted on 2024-10-10 10:07  Nammonco  阅读(20)  评论(0编辑  收藏  举报

导航