read

read是一种阻塞I/O,调用时,用户进程会在内核态查看socket的接收缓冲区是否有数据

  • 有数据,则拷贝回用户空间,然后返回
  • 无数据,进程让出cpu变成阻塞,当数据到达缓冲区唤醒进程,变成就绪状态等待调度

这里值得关注的是用户进程是如何被唤醒的。

  1. 当调用read时,用户进程从用户态变为内核态
  2. struct task_struct结构找到fd_array,然后根据Socket文件描述符fd找到对应的struct file,调用对应的file_operation,即socket_read_iter
  3. 找到对应的struct socket,然后调用socket->ops->recvmsg,这里调用的是inet_stream_ops集合中的inet_recvmsg
  4. inet_recvmsg会找到struct sock,调用sock->skprot->recvmsg,这里调用的是tcp_prot集合中定义的tcp_recvmsg

阻塞IO中用户进程阻塞以及唤醒原理

接下来重点是tcp_recvmsg是如何阻塞用户进程的

  1. 调用sk_sleep(sk)获取struct sock队列中的等待队列头指针wait_queue_head_t
  2. 调用prepare_to_wait将新创建的等待项wait_queue_t插入等待队列,并将进程设置为INTERRUPTIBLE
  3. 调用sk_wait_event让出CPU,进程进入睡眠状态

这样进程进入了睡眠状态,并且放在等待队列上

接下来是进程如何被唤醒

  1. 当数据到达网卡的时候,网卡通过DMA的方法将数据放到RingBuffer

  2. cpu发起硬中断,在硬中断过程中创建sk_buffer,将数据拷贝到sk_buffer

  3. 然后发起软中断,内核线程ksoftirqd响应软中断,调用poll函数将sk_buffer送往内核协议栈进行处理

  4. 在传输层tcp_rcv函数中,去掉TCP头,根据四元组(源IP,源端口,目的IP,目的端口)查找对应的socket

  5. sk_buffer放入socket的接收队列里

  6. 放到接收队列上后,会调用sk_data_ready,这个函数指针指向了sock_def_readable

  7. sock_def_readable会去等待队列,通过wake_up_common函数从等待队列里拿出一个wait_queue_t,调用注册的回调函数autoremove_wake_function,根据wait_queue_t上关联的fd,唤醒阻塞在该fd对应的socket上的进程

    1. 注意为了避免惊群效应,这里只会唤醒一个socket

epoll

epoll主要有3api

  1. epoll_create:创建一个epoll实例,返回一个文件描述符fd
  2. epoll_ctl:向创建的epoll实例里添加一个fd,监听
  3. epoll_wait:阻塞等待,如果有新的事件发生则返回新的事件

epoll_create

epoll_create是操作系统提供的一个系统调用。当调用epoll_create时,系统会创建一个struct eventpoll对象,并且有相关的struct filefilefd也会被加入当前文件列表,且file_operations的指针指向的是eventpoll_fops操作函数集合。

eventepoll

struct eventepoll {
	// 等待队列,阻塞在epoll上的进程会放在这里
	wait_queue_head_t wq;
 
	// 就绪队列,I/O就绪的socket连接会放在这里
	struct list head rdlist;
	
	// 红黑树管理所有socket连接
	struct rb_root rbr;
}
  • wait_queue_head_t:存放的是epoll上所有阻塞的,等待就绪I/O的用户socket,当有I/O就绪的时候,可以通过这个队列寻找阻塞的进程并唤醒,然后获取就绪I/O,到对应socket上去读写数据
  • struct list head rdlist:存放的是就绪的socket I/O,唤醒的用户进程直接来这里获得对应的socket,无需遍历整个集合
  • struct rb_root rbr:红黑树,高效插入删除所有socket连接,相比selectpoll使用链表或数组性能更好

epoll_ctl

用于向epoll添加监听的socket,这里在epoll内核创建一个表示Socket连接的数据结构struct epitem,为了性能考虑,这里使用红黑树维护海量的socket连接。

先在socket等待队列中创建好等待项wait_queue_t,设置好回调函数ep_poll_callback,然后通过epoll_entry关联epitem,最后将epitem插入红黑树。

这里一个优化点是,添加socket连接的时候不用像selectpoll一样每次全量传入内核所有socket连接,而只用通过红黑树维护增量即可。

epoll_wait

用于阻塞等待事件发生,一次获取多个epoll_event

  1. 首先查看eventpoll->rdlist是否有I/O就绪的eptiem,有的话,通过eptiem找到对应的socket,返回。
  2. 如果eventpoll->rdlist里没有I/O就绪的eptiem,就创建一个等待项wait_queue_t,然后将用户进程fd关联到wait_queue_t->private上,并在wait_queue_t->func上注册回调函数default_wake_function,最后添加到epoll的等待队列上,阻塞让出CPU

唤醒流程

  1. 首先是socket传来了数据,网络数据包在经过软中断、内核协议栈处理后到达socket的接收缓冲区,接着调用socket数据就绪回调指针sk_data_ready,回调函数为sock_def_readable。在socket等待队列中找到等待项,其中等待项的回调函数为ep_poll_callback
  2. 在回调函数ep_poll_callback中,根据struct epoll_entry中的struct wait_queue_t wait通过contain_of宏找到epoll_entry对象,并通过base指针找到对应的红黑树节点eptiem,将这个eptiem加入到rdlist
  3. 然后查看epoll中是否有等待进程,如果没有则软中断完成,否则调用回调函数default_wake_function,在回调函数中唤醒阻塞的进程,并将rdlist里的eptiem封装成epoll_event返回
  4. 以上获取了I/O就绪的socket,然后读取里面的数据

水平/边缘触发

水平和边缘触发的区别在于,当一次epoll_wait获取了返回的epoll_envet后,是否清空rdlist

  • 水平触发:当一次epoll_wait获取到I/O就绪的socket后,对socket进行系统调用读取数据。假设单次读取没有全部读取完socket缓冲区的数据,epoll_wait会检查socket,如果没有读完,会将socket放回rdlist,这样下次读的时候不需要阻塞可以直接读取
  • 边缘触发:直接清空rdlist,不管socket中是否还有数据。这样如果想再次读取这个socket,只能等socket再次获取网络数据包,注册rdlist