Linux Kernel TCP/IP Stack — L7 Layer — Application Socket I/O 接口类型

Posted 云物互联

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux Kernel TCP/IP Stack — L7 Layer — Application Socket I/O 接口类型相关的知识,希望对你有一定的参考价值。


目录


文章目录


基本概念

同步与异步


  • 同步​:是指一个任务的完成需要依赖另外一个任务,只有等待被依赖的任务完成后,依赖的任务才能算完成。
  • 异步​:是指不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了,异步一般使用状态、通知和回调。

阻塞与非阻塞


  • 阻塞​:是指调用结果返回之前,当前线程会被挂起,一直处于等待消息通知,不能够执行其他业务。
  • 非阻塞​:是指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。

I/O 操作的执行流程

对于一次 IO 访问,数据会先被拷贝到 Kernel 的 Buffer(缓冲区)中,然后才会从 Kernel Buffer 拷贝到 Userspace Application 的地址空间。

简而言之,就是需要经历两个阶段:


  1. 等待数据​:等待批量数据。
  2. 将数据从内核中拷贝出来​:将数据从 Kernel Buffer 拷贝到 Userspace Application 的地址空间。

例如:对于一次 Socket I/O 的收包操作而言:


  1. 第一步,等待数据包到达 NIC,数据将会从 Kernel TCP/IP Stack 拷贝到 Kernel BSD Socket Buffer 中;
  2. 第二步,是从 Kernel Buffer 中把数据拷贝到 Userspace Application 的地址空间中。

Linux

Socket I/O 接口类型

Socket I/O 接口的本质是一个 Linux System Calls,所以 Socket I/O 接口也会随着 Linux Kernel 的发展而演进,大致可以分为如下几个阶段:


  1. 阻塞 I/O(BIO)
  2. 非阻塞 I/O(NIO)
  3. IO 多路复用
  4. 同步 I/O(信号驱动,SIGIO)
  5. 异步 I/O

每一个阶段的演进,都是为了解决当前实现所存在的一些缺陷。

阻塞 IO

默认情况下,所有的 Linux Kernel Socket I/O 都是阻塞式 I/O。

Linux

如上图所示,当一个 Application 调用了 System Call recvfrom(),Kernel 就进入 I/O 的第一阶段:准备数据,内核需要等待足够多的数据再进行拷贝。在等待 “数据就绪” 的过程中,Application 会被阻塞;数据就绪后,Kernel 进入了 I/O 的第二阶段:Kernel 将数据复制到 Userspace。完成后,Kernel 返回结果给 Application,Application 才会从阻塞态进入就绪态。

所以,我们称 Application 在调用 recvfrom() 开始,直到从 recvfrom() 返回的这段时间里,Application 都是被阻塞的。

Linux

缺点


  1. 当并发较大时,需要创建大量的线程来处理连接,占用了大量的系统资源。
  2. TCP connection 建立完成后,如果当前线程没有数据可读,将会阻塞在 read() 操作上,造成线程资源的浪费

非阻塞 IO

Linux 下可以将 Socket 设置为 non-blocking(非阻塞)模式。


  1. 当 Userspace Application 调用 System Call read() 时,如果 Kernel BSD Socket Buffer 中的数据还没有准备好(或者没有任务数据),那么 non-blocking Socket 并不会阻塞 Application,而是立刻返回一个 EWOULDBLOCK 的 Error。
  2. 当 Application 判断 Invoke Result 为一个 EWOULDBLOCK Error 时,那么它就知道了数据还没有准备好,于是 Application 可以等待一段时候之后,再次调用 read()。
  3. 直到数据就绪后,那么 Kernel 就会将数据拷贝到了 Userspace 地址空间,然后返回。

可见,非阻塞 IO 中,Application 需要不断地询问 Kernel 的数据准备好没,如果没有准备好,那么在某些场景中,Application 可以去做别的事情而不需要一直等待(阻塞)。

Linux

Linux

缺点


  1. Application 使用 non-blocking Socket 时,会不停的 Polling(轮询)Kernel,以此检查是否 I/O 操作已经就绪。这极大的浪费了 CPU 的资源。
  2. 另外,非阻塞 IO 需要 Application 多次发起 System Calls,频繁的系统调用也是比较消耗系统资源的。

所以这种模式并不常用。

阻塞 IO 与非阻塞 IO 的区别

Linux

IO 多路复用


  • 多路复用(Multiplexing)​:是一个通信专业术语,表示在一个信道上传输多路信号或数据流的过程和技术,在通信网络中,能够将多个低速信道集成到一个高速信道进行传输,从而充分地利用了高速信道的资源。
  • IO 多路复用​:即多个连接共享一个阻塞对象,Application 只会在一个阻塞对象上等待。当某个连接有新的数据处理,Kernel 直接通知 Application,线程从阻塞状态返回并开始业务处理。

换言之,IO 多路复用可以让一个 Application(进程)监视多个文件描述符(套接字描述符),一旦某个文件描述符就绪(一般是读就绪,或者写就绪),就能够通知程序进行相应的读写操作了。这样就不需要每个 Application 不断的询问内核数据准备好了没有,也不需要内核给 Application 发送信号了。

Linux

I/O 多路复用的主要函数有 select()、poll()、epoll(),在调用他们时,Application 会阻塞,而不是在调用 recvfrom() 的时候阻塞。当 select()、poll()、epoll() 有返回的时候,就是数据就绪的时候,这时 Application 就可以调用 recvfrom() 函数来将数据拷贝到缓存区中。

应用场景


  • 当一个客户端需要同时处理多个文件描述符的输入输出操作时。
  • 当程序需要同时进行多个套接字的操作时。
  • 如果一个 TCP 服务器程序同时处理正在侦听网络连接的套接字和已经连接好的套接字。
  • 如果一个服务器程序同时使用 TCP 和 UDP 协议。
  • 如果一个服务器同时使用多种服务并且每种服务使用协议不同。

select

Linux

Kernel 会监视所有 select() 负责的若干个 Socket,当任意 Socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read(),将数据从 Kernel 拷贝到用户进程。

select() 监视的文件描述符分 3 类:


  1. writefds
  2. readfds
  3. exceptfds

函数声明​:

int select(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

形参列表​:


  • 读、写、异常、集合中的文件描述符的最大值 +1。
  • 读集合。
  • 写集合。
  • 异常集合。
  • 超时结构体。

返回值​:为检测到的事件个数,并且返回哪些 I/O 发生了事件,遍历这些事件,进而处理事件。

Linux

缺点

select() 的一个缺点在于单个进程能够监视的文件描述符数量存在限制,在 Linux 上一般为 1024 个,可以通过调整内核的参数进行修改。另外,select() 中的 fd_set 集合容量同样具有限制(FD_SETSIZE=1024)这需要重新编译内核。

poll

由于 select() 所支持的描述符有限,随后提出了 poll() 来解决这个问题。

poll() 使用了一个 pollfd 的指针实现。

函数声明​:

int poll(struct pollfd *fds, unsigned int nfds, int timeout);

  • 第一参数是指向结构体数组,每个数组元素都是一个 pollfd 结构。
  • 结构体类型参数 pollfd 包含了要监视的 Event 和发生的 Event。

struct pollfd 
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
;

和 select() 一样,poll() 返回后,内核要遍历所有文件描述符,直到找到所有发生事件的 pollfd 文件描述符来获取其中就绪的描述符。区别在于 poll 没有监听的最大数量限制。

缺点

select 和 poll 在管理海量的连接时,会频繁的从用户态拷贝到内核态,比较消耗资源。

epoll

Linux

epoll 使用一个文件描述符来管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,采用监听回调的机制,这样在用户空间和内核空间的数据拷贝只需要进行一次,避免再次遍历就绪的文件描述符列表,从而提升了性能。

相比于 select() 与 poll(),epoll() 最大的好处在于它不会随着监听 fd 数目的增长而降低效率,内核中 select() 与 poll() 的实现时采用轮询来处理的,轮询的 fd 数目越多,自然耗时越多。而 epoll() 实现是基于回调的,如果 fd 有期望的事件发生就通过回调函数将其加入 epoll 就绪队列中,也就说说它只关心 “活跃” 的 fd,与 fd 的数目无关。

另外,内核空间和用户空间拷贝问题,在这个问题上 select/poll 采取的是内存拷贝的方式,而 epoll 采用的共享内存(缓冲区共享)的方式,避免了一次拷贝。

epoll 不仅会告诉应用程序有 I/O 事件到来,还会告诉应用程序相关的信息,这些信息是应用填充的,因此根据这些消息应用程序就能直接定位到事件,而不必遍历整个 fd 集合。

epoll 的 2 种工作模式​:


  1. LT(Level Trigger,条件/水平触发)模式​:当 epoll_wait 检测到描述符就绪,将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用 epoll_wait 时,会再次响应应用程序并通知此事件。LT 模式是默认的工作模式,同时支持阻塞和非阻塞 Socket。
  2. ET(Edge Trigger,边缘触发)模式​:当 epoll_wait 检测到描述符就绪,将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次响应应用程序并通知此事件。ET 是高速工作方式,只支持非阻塞 Socket。ET 模式减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。

epoll 的编程需要 3 个接口​:

  1. 创建一个 epoll 的句柄,形参 size 用来告诉内核这个监听的数目一共有多大。
int epoll_create(int size);
  1. 对指定描述符 fd 执行 op 操作。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

  • epfd:是 epoll_create() 的返回值。
  • op:表示操作,用三个宏来表示:EPOLL_CTL_ADD,EPOLL_CTL_DEL,EPOLL_CTL_MOD。分别添加、删除和修改对 fd 的监听事件。
  • fd:是需要监听的 fd(文件描述符)。
  • epoll_event:是告诉内核需要监听什么事件,struct epoll_event 结构如下:

struct epoll_event 
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
;

events 可以是以下几个宏的集合:


  • EPOLLIN :表示对应的文件描述符可以读(包括对端 Socket 正常关闭);
  • EPOLLOUT:表示对应的文件描述符可以写;
  • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
  • EPOLLERR:表示对应的文件描述符发生错误;
  • EPOLLHUP:表示对应的文件描述符被挂断;
  • EPOLLET: 将 epoll 设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的;
  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 Socket 的话,需要再次把这个 Socket 加入到 epoll 队列里。

  1. 等待 epfd 上的 IO 事件,最多返回 maxevents 个事件。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

  • events:用来从内核得到事件的集合
  • maxevents:告之内核这个 events 有多大,这个 maxevents 的值不能大于创建 epoll_create() 时指定的 size
  • timeout:超时时间,单位毫秒,0 表示立即返回,-1 表示不确定,也有说法说是永久阻塞

该函数返回需要处理的事件数目,如返回 0 表示已超时。

Linux

三者的比较

Linux

用通俗的话来讲,假如有三个老师分别是 select,poll,epoll,每次当老师要去收全班同学的作业时,也就是当老师被调用时:select 和 poll 老师就会一个一个的检查在这个班的所有的同学的作业并拿走作业,而 epoll 老师设置了一个讲台,说谁写完了就放在讲台上,那当 epoll 老师工作的时候,只需要在讲台上拿走完成的作业,而不用全部遍历。

select、poll 的缺点​:虽然 select、poll 解决了频繁的系统调用次数,但同时引入了新的问题:用户需要每次将海量的 socket fds 集合从用户态传递到内核态,让内核态去检测哪些网络连接数据就绪了。

epoll 的优点​:一开始就在内核态分配了一段空间,来存放管理的 fd。所以在每次连接建立后,交给 epoll 管理时,需要将其添加到原先分配的空间中,后面再管理时就不需要频繁的从用户态拷贝管理的 fd 集合。通通过这种方式大大的提升了性能。

同步 IO(信号驱动)

当我们将一个套接字设置为信号驱动 I/O 模式,让内核文件描述符就绪后,通过 Signal(信号)通知用户进程,用户进程再通过系统调用读取数据,我们将这种模式称之为信号驱动 I/O 模式。

对于信号驱动 I/O 模式,好处在于等待数据的时候不会阻塞,程序可以做自己的事情。当有数据到达的时候,系统内核会向应用程序主动发送一个 SIGIO 信号进行通知,所以应用程序就可以获得更大的灵活性,而不必为阻塞等待数据进行额外的编码。

此方式的本质属于同步 IO,因为实际读取数据到用户进程缓存的工作仍然是由用户进程自己负责的。

Linux

为了在一个套接字上使用信号驱动 I/O 操作,必须有下面三个步骤:


  1. 必须设定一个处理 SIGIO 信号的函数。
  2. 必须设定套接字的拥有者,一般使用 fcntl 函数的 F_SETOWN 参数来设定拥有着。
  3. 套接字必须被允许使用异步 I/O(接受 SIGIO)。一般使用 fcntl 函数的 F_SETFL 命令,O_ASYNC 为参数来实现。

异步 IO

用户进程发起 read() 调用之后,立刻就可以开始去做其它的事。内核收到一个异步 IO read 之后,会立刻返回,不会阻塞用户进程。内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,内核会给用户进程发送一个 Signal(信号),告诉它 read() 完成了。用户进程再从用户内存读取数据。

异步 I/O 与信号驱 动I/O 区别:


  • 信号驱动 I/O 模式下,内核在操作可以操作的时候通知给程序发送 SIGIO 消息。
  • 异步 I/O 模式下,内核在所有操作都被内核操作结束后才会通知给程序。

当进程进行 I/O 操作时,进程传递给内核它的文件描述符、缓存区指针、缓存区的大小以及一个偏移量 offset,以及在内核结束所有操作后和给进程发送通知,这种调用也是立即返回的,程序不需要阻塞来等待程序的就绪。

Linux

几种 I/O 接口比较

  • 阻塞程度​:阻塞 IO > 非阻塞 IO > 多路复用 IO > 信号驱动 IO > 异步 IO,效率是由低到高的。
    Linux

参考文档

《​​你管这破玩意叫 IO 多路复用?​​》



以上是关于Linux Kernel TCP/IP Stack — L7 Layer — Application Socket I/O 接口类型的主要内容,如果未能解决你的问题,请参考以下文章

Linux Kernel TCP/IP Stack — L4 Layer

Linux Kernel TCP/IP Stack — 协议栈发包处理流程

Linux Kernel TCP/IP Stack — Overview

Linux Kernel TCP/IP Stack — Overview

Linux Kernel TCP/IP Stack — L1 Layer

Linux Kernel TCP/IP Stack — L1 Layer