网卡接收数据

通过硬件传输,网卡接收的数据存放到内存中。当网卡把数据写入到内存后(DMA 引擎传输数据到内存不需要cpu),网卡向cpu发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据。

执行中断程序

一般而言,由硬件产生的信号需要cpu立马做出回应(不然数据可能就丢失),所以它的优先级很高。cpu理应中断掉正在执行的程序,去做出响应;当cpu完成对硬件的响应后,再重新执行用户程序。以键盘为例,当用户按下键盘某个按键时,键盘会给cpu的中断引脚发出一个高电平(中断信号)。cpu能够捕获这个信号,然后执行键盘中断程序

阻塞IO (blocking IO)

image.png
核心:内核sock等待队列

操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态,会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。运行状态是进程获得cpu使用权,正在执行代码的状态;等待状态是阻塞状态,比如下述程序运行到recv时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态

  1. //创建socket
  2. int s = socket(AF_INET, SOCK_STREAM, 0);
  3. //绑定
  4. bind(s, ...)
  5. //监听
  6. listen(s, ...)
  7. //接受客户端连接
  8. int c = accept(s, ...)
  9. //接收客户端数据
  10. recv(c, ...);
  11. //将数据打印出来
  12. printf(...)

这是一段最基础的网络编程代码,先新建socket对象,依次调用bind、listen、accept,最后调用recv接收数据。recv是个阻塞方法,当程序运行到recv时,它会一直等待,直到接收到数据才往下执行

下图中的计算机中运行着A、B、C三个进程,其中进程A执行着上述基础网络程序,一开始,这3个进程都被操作系统的工作队列所引用,处于运行状态,会分时执行

等待队列的过程:
worker.jpg111.jpg

当进程A执行到创建socket的语句时,操作系统会创建一个由文件系统管理的sock对象(如上图)。这个socket对象包含了发送缓冲区、接收缓冲区、等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该socket事件的进程,当其程序执行到recv时,操作系统会将进程A从工作队列移动到该socket的等待队列中(只是把A进程的引用添加到等待队列,A进程在工作队列的位置仍然保留不变)(如下图)。由于工作队列只剩下了进程B和C,依据进程调度,cpu会轮流(分时)执行这两个进程的程序,不会执行进程A的程序。所以进程A被阻塞,不会往下执行代码,也不会占用cpu资源
222.jpg

唤醒的过程:

网络编程 - 图7网络编程 - 图8

socket对应着一个端口号,而网络数据包中包含了ip和端口的信息,内核可以通过维护的端口号到socket的索引结构快速找到对应的socket
把socket接收的数据写入sock输出缓冲区,唤醒进程A,从sock的等待队列中移除,A处于运行状态,和 B、C 一起分时执行

非阻塞 I/O(nonblocking IO)

image.png
核心:应用进程轮询
**
设置 setblocking(False) ,进程A程序执行到recv后内核会立即响应一个errno,不会阻塞进程A

  1. //创建socket
  2. int s = socket(AF_INET, SOCK_STREAM, 0);
  3. //绑定
  4. bind(s, ...)
  5. s.setblocking(False)
  6. //监听
  7. listen(s, ...)
  8. //接受客户端连接
  9. int c = accept(s, ...)
  10. //接收客户端数据
  11. while(true){
  12. recv(c, ...);
  13. //将数据打印出来
  14. printf(...)
  15. }

IO 多路复用 (IO multiplexing 原理)

核心:内核底层 select、epoll 等函数

image.png

Reactor设计模式:Reactor线程负责调用内核的select函数检查socket状态

image.png

select

服务端建立每个链接,相当于打开文件,获得对应的文件描述符fd,相同的源IP/源端口/目标IP/目标端口 对应同一个fd

与poll比较:
select数据结构是数组,有连接数限制1024
poll是数据结构是链表,无连接限制

  1. int s = socket(AF_INET, SOCK_STREAM, 0);
  2. bind(s, ...)
  3. listen(s, ...)
  4. int fds[] = 存放需要监听的socket
  5. while(true){
  6. int n = select(..., fds, ...)
  7. for(int i=0; i < fds.count; i++){
  8. if(FD_ISSET(fds[i], ...)){
  9. //fds[i]的数据处理
  10. }
  11. }
  12. }

程序同时监视如下图的sock1、sock2和sock3三个socket,那么在调用select之后,操作系统把进程A分别加入这三个socket的等待队列中:

网络编程 - 图12

当任何一个socket收到数据后,中断程序将唤起进程:
网络编程 - 图13

唤起进程:当进程A被唤醒后,它知道至少有一个socket接收了数据。程序只需遍历一遍socket列表,就可以得到就绪的socket

  1. ![](https://cdn.nlark.com/yuque/0/2020/jpeg/1746233/1594966413848-1e56e6d3-2f6a-4b5c-b326-d43f180abe29.jpeg#align=left&display=inline&height=351&margin=%5Bobject%20Object%5D&originHeight=351&originWidth=483&size=0&status=done&style=none&width=483)

分别从用户态和内核态分析它们的详情运行机制:
用户态层面:在用户进程里,
(1)创建socket,记录连接套字节socketSocketArray集合里
(2)构建3个fd数组,分别是关注 读(ReadSet)、写(WriteSet)、异常(ErrorSet)事件,遍历SocketArray集合,添加套子节到3个fd数组里,然后调用系统的select()方法

  1. int select(int maxfdp, fd_set * readfds, fd_set * writefds, fd_set * errorfds, struct timeval * timeout);
  2. # 结构体 fd_set可以理解为一个集合,这个集合中存放的是文件描述符
  3. # timeout 若将NULL以形参传入,即不传入时间结构,就是将select置于阻塞状态

(3)调用select时,需要将fd数组传到内核态,等待部分fd就绪后,将传入的fd数组返回到用户态
(4)用户进程对fd数组进行遍历,处理就绪的fd(用户线程请求read把socket接收到的数据 从内核态copy到用户态,进行编解码等业务处理)
(5)循环,重新构建fd数组,调用select

内存态层面:调用select时进入内核态,
(1)遍历fd数组,对每个fd,调用其驱动对应的poll方法。将fd所在线程加入设备的等待队列,及时检查设备就绪状态,并记录下来

调用select后内核会使用copy_from_user将三个位映射数组拷贝到内核中,构造辅助数据结构poll_wqueues,调用每一个fd所对应的驱动中的poll函数,在poll函数中最终会调用到poll_wait(注册的回调函数),这个函数将当前进程加入到设备的等待队列中,加入的同时会及时检查设备的状态,并进行记录,

(2)如果不存在感兴趣的就绪状态,进入休眠,等待有fd就绪后,会唤醒设备等待队列中的线程(当前线程)

当for循环执行完,包含当前进程的等待队列元素会加入到所有待监视的fd的驱动程序的等待队列中,这样的话,不管哪一个fd准备就绪了,都会唤醒当前进程。当前进程被唤醒后,会再执行一次驱动中的poll,检查是否有其他设备准备就绪,返回准备就绪fd的数量,将修改后的fd_set写回用户空间(select源码 doSelect调用之后的代码逻辑 set_fs_set(……))

可优化之处:
(1)每次都要传入fd数组,返回整个fd数组,导致可大量用户空间很内核空间的互相拷贝
(2)用户态需要遍历fd数组才能找到就绪的fd
(3)内核态同样需要遍历检查fd

epoll

select低效的原因
1:将“维护等待队列”和“阻塞进程”两个步骤合二为一,导致每次select都需要添加等待队列和阻塞进程2个操作。大多数场景需要监视的socket相对固定,并不需要每次都修改,epoll将这两个操作分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程
1:程序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历

网络编程 - 图14

关键函数
  • epoll_create: 创建一个epoll实例,文件描述符


  1. int epoll_create(int size);
  2. # size:用来告诉内核这个监听的数目一共有多大
  3. # 返回值:epoll句柄的文件描述符 下面用epfd表示
  4. # 当创建好epoll句柄后,它就是会占用一个fd值,在linux下/proc/进程id/fd/ 查看
  5. /*
  6. * This structure is stored inside the "private_data" member of the file
  7. * structure and represents the main data structure for the eventpoll
  8. * interface.
  9. */
  10. struct eventpoll {
  11. /* Protect the access to this structure */
  12. spinlock_t lock;
  13. /*
  14. * This mutex is used to ensure that files are not removed
  15. * while epoll is using them. This is held during the event
  16. * collection loop, the file cleanup path, the epoll file exit
  17. * code and the ctl operations.
  18. */
  19. struct mutex mtx;
  20. /* Wait queue used by sys_epoll_wait() */
  21. wait_queue_head_t wq;
  22. /* Wait queue used by file->poll() */
  23. wait_queue_head_t poll_wait;
  24. /* List of ready file descriptors */
  25. struct list_head rdlist;
  26. /* RB tree root used to store monitored fd structs */
  27. struct rb_root rbr;
  28. /*
  29. * This is a single linked list that chains all the "struct epitem" that
  30. * happened while transferring ready events to userspace w/out
  31. * holding ->lock.
  32. */
  33. struct epitem *ovflist;
  34. /* wakeup_source used when ep_scan_ready_list is running */
  35. struct wakeup_source *ws;
  36. /* The user that created the eventpoll descriptor */
  37. struct user_struct *user;
  38. struct file *file;
  39. /* used to optimize loop detection check */
  40. int visited;
  41. struct list_head visited_list_link;
  42. };
  • epoll_ctl: 将监听的文件描述符添加到epoll实例中
  1. # epoll_ctl只支持:管道pipe,FIFO,套接字socket,POSIX消息队列,终端,设备等,但是就是不支持普通文件或目录的fd
  2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  3. # epfd:epoll句柄的文件描述符
  4. # op:要进行的操作例如注册事件
  5. ## EPOLL_CTL_ADD:注册新的fd到epfd中;
  6. ## EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
  7. ## EPOLL_CTL_DEL:从epfd中删除一个fd;
  8. #fd:要监听的fd
  9. # event:是告诉内核需要监听什么事件
  10. ## EPOLLIN : 表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
  11. ## EPOLLOUT: 表示对应的文件描述符可以写
  12. ## EPOLLPRI: 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
  13. ## EPOLLERR: 表示对应的文件描述符发生错误
  14. ## EPOLLHUP: 表示对应的文件描述符被挂断
  15. ## EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式(默认为水平触发),这是相对于水平触发(Level Triggered)来说的
  16. ## EPOLLONESHOT: 只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
  • epoll_wait: 等待epoll事件从epoll实例中发生, 并返回事件以及对应文件描述符
  1. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
  2. # epfd:epoll句柄的文件描述符
  3. # events: 用于回传代处理事件的epoll_event结构体数组,epoll将会把发生的事件复制到 events数组中
  4. # maxevents: 表示本次可以返回的最大事件数目,通常 maxevents参数与预分配的events数组的大小是相等的
  5. # timeout:表示在没有检测到事件发生时最多等待的时间(单位为毫秒),-1相当于阻塞,0相当于非阻塞,如果 timeout为0,则表示 epoll_wait在 rdllist链表中为空,立刻返回,不会等待
  6. # 返回值:返回发生事件数

整体流程
  1. int s = socket(AF_INET, SOCK_STREAM, 0);
  2. bind(s, ...)
  3. listen(s, ...)
  4. int epfd = epoll_create(...); // 创建一个epoll实例
  5. epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
  6. while(1){
  7. int n = epoll_wait(...) //
  8. for(接收到数据的socket){
  9. //处理
  10. }
  11. }

调用epoll_create时,内核创建一个epoll实例 eventpoll结构体,其中 rdr是个红黑树 用于存储以后epoll_ctl传来的socket句柄fd,rdlist是个链表,用于存储准备就绪的事件.
网络编程 - 图15

调用epoll_ctl时,对socket封装为epitem到 eventpoll红黑树集合中

当socket收到数据后,当socket收到数据后,中断程序会操作eventpoll对象,添加sock引用(epitem)到eventpoll结构的redlist 集合中
这里是与select区别,select会直接操作进程(唤醒),epoll操作的是eventpoll
网络编程 - 图16

当程序执行到epoll_wait时,如果rdlist为空,阻塞进程,添加进程到eventpoll结构中的
等待队列 wq中
w.jpg
当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态

网络编程 - 图18

LT和ET

主线程正在epoll_wait等待事件时,

  • LT模式状态下,请求到了,epoll_wait返回后没有去处理请求(recv),那么下次epoll_wait时此请求还是会返回(立刻返回了);
  • ET模式状态下,请求到了,epoll_wait返回后没有去处理请求(recv),下次epoll_wait时将不返回(所以我们应该每次一定要处理),可见很大程度降低了epoll的触发次数

缺点:epoll 目前只支持 pipe, 网络等操作产生的 fd,暂不支持文件系统产生的 fd

epoll与select

不同点:
  • select每次都需要把fd集合从用户态拷贝到内核态。epoll是建立socket时把当前socket fd拷贝到内核
  • 执行select时,内核每次要遍历传入的fd集合,epoll只需检查就绪队列是否为零,
  • 有fd就绪后,select把就绪数目和fd集合拷贝到用户态,用户态需要再次遍历fd集合才能得到具体的就绪fd,epoll把就绪数目和具体的就绪fd拷贝到用户态

    相同点
  • 用户进程都需要循环遍历才能拿到就绪事件

  • 拿到就绪事件后都需要再次根据事件类型与内核交互,例如拿到recv的就绪事件,需要去调用read请求,把响应数据从内核态拷贝到用户态

零拷贝技术:

传统的IO

读写操作,总共进行了4次上下文切换(read请求/响应 write请求/响应 ),2次DMA Copy,2次cpu copy网络编程 - 图19

虚拟内存技术(mmap

网络编程 - 图20

mmap+write方式
网络编程 - 图21

4次上下文切换,2次copy,一次cpu copy 对应java的 MappedByteBuffer 与 DirectByteBuffer

sendfile方式

linux内核2.1支持 在 mmap基础上扩展。= mmap() + write(),减少了一次write操作,对应java的transferTo方法
网络编程 - 图22

  1. sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
  2. # in_fd:输入文件的描述符,向的文件必须是可以mmap的
  3. # out_fd: 输出文件的描述符,指向一个套接字
  4. # endfile只能将数据从文件传递到套接字上

2次切换,2次DMAcopy,1次cpu copy

直接传递文件描述符 linux内核2.4支持
网络编程 - 图23
2次切换,2次DMAcopy