且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

Linux网络IO学习笔记

更新时间:2022-09-04 18:59:18

前言

本文主要讨论Linux环境下基于TCP的字节流网络IO。从socket说起,基于Linux内核源码,简析了阻塞式网络IO模型中,服务端内核协议栈数据接收过程及过程中的主要数据结构,然后通过IO复用模型中数据可读事件的处理过程,对Linux中的select系统调用与epoll进行了实现原理的介绍。 需要说明的是本文所涉及到的内核处理过程与内核函数等均基于Linux内核版本4.9.93。文中难免会有纰漏甚至错误的地方,还请各路大神批评指正。

socket

socket API 起源于1983年发行的4.2BSD操作系统,后来发展成为POSIX(可移植操作系统接口)的一部分。POSIX是由IEEE开发的一系列标椎,由类Unix内核的C语言接口发展而来。Linux作为类Unix操作系统,实现了POSIX的绝大部分API。

Linux网络IO学习笔记

图1.1、socket API

如图1.1所示,socket API 主要用作应用层与下层的交互,我们可以使用socket API 编写使用TCP或UDP的网络应用程序,并且 socket API 支持绕过传输层直接与网络层进行交互(raw socket)。

Linux socket API:

#include <sys/socket.h>
int socket(int family, int type, int protocol);

Linux网络IO学习笔记

图 1.2、socket库函数参数主要取值组合

图1.2给出了socket库函数参数主要取值组合,本文主要讨论 family = AF_INET, tye=SOCK_STREAM, protocol=TCP 的情况。

Linux中 socket 作为一种特殊文件而存在,在系统启动时将注册特殊文件系统SOCKFS以实现对socket的管理,在 struct socket 变量创建时将通过socket所持有的 struct sock 与 struct file_operations 等变量与系统的协议栈操作相关联起来。

阻塞式网络IO

一个简单的同步阻塞网络IO Java示例

在Java中,一个简单的同步阻塞网络IO示例(省略部分行)如下:

// 服务端
ServerSocket serverSocket = new ServerSocket(6666);
while (true) {
    Socket socket = serverSocket.accept();
    try {
        byte[] bytes = new byte[1024];
        InputStream inputStream = socket.getInputStream();
        while (true) {
            int read = inputStream.read(bytes);
            if (read != -1) {
                System.out.println("====> 来自客户端:" + new String(bytes, 0, read, "UTF-8"));
            } else {
                System.out.println("====> 客户端结束访问");
                break;
            }
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}
// 客户端
Socket socket = new Socket("127.0.0.1", 6666);
OutputStream outputStream = socket.getOutputStream();
outputStream.write("你好".getBytes("UTF-8"));
outputStream.close();
socket.close();

 

示例中只给出了客户端向服务端发送字符串“你好”的过程:(1)、服务端创建ServerSocket对象,等待客户端连接;(2.1)、客户端创建Socket对象通过服务端地址主动与服务端创建连接并发送数据;(2.2)、服务端收到客户端连接创建Socket对象接收客户端数据;(3)、客户端主动关闭链接。

客户端服务端交互过程浅析

Linux网络IO学习笔记图 2.1、客户端服务端交互过程

图2.1给出了Java socket 相关操作与 Linux socket API 相应操作的对应关系,与TCP连接的建立、数据交互、断开过程。服务端在创建ServerSocket对象并调用accept()方法之后将一直阻塞至客户端的连接请求到来,客户端服务端经过TCP三路握手之后建立TCP链接,双方利用各自的socket文件描述符进行读写操作,完成数据交互。图中只给出了客户端主动关闭链接的情况,服务端在收到来自客户端的FIN分节之后,确认客户端数据已经传输完毕,read()函数(Linux socket API)将返回0(Java中SocketInputStream::read将返回-1)表示数据读取完毕,随后服务端在确认本侧数据发送完毕之后发送FIN分节,关闭链接。

上图中服务端有可能在三处出现阻塞:(1)、调用accept()方法之后阻塞至客户端的连接请求到来;(2)、调用read()方法之后阻塞至客户端发送的数据到来;(3)、调用write方法之后,如果TCP发送缓冲区已满则阻塞至发送缓冲区可用。下文将主要讨论第二种:调用read()方法阻塞的情况。

Linux协议栈数据接收过程浅析

Linux网络IO学习笔记 

图 2.2、read()阻塞至数据接收返回的过程

图2.2以及本文接下来所提到的Linux内核处理过程及相应函数均基于Linux内核版本4.9.93。图中中间部分用不同颜色来标明了CPU的上线文切换情况,但本质上系统调用与返回将涉及到CPU上下文切换(Linux内核态与用户态的切换)但是考虑到此过程并没有发生线程上下文的切换所以在图中并没有通过不同颜色进行标记。在图2.2以及接下来的篇幅中我们将继续使用线程字样来描述,但是对于Linux内核来说并没有线程、进程之分,他们(线程和进程(只有一个线程的进程))都用唯一的struct task_struct 变量来表述,拥有唯一的pid(同一个进程的线程将拥有同一个tgid)。

图2.2中给出了服务端调用read()阻塞之后,等待客户端数据到来,Linux协议栈完成数据解析至read返回的整体过程,这里只讨论数据读取方先阻塞之后,数据发送方发送数据的情况。图2.2中:

  • (1.1)、用户线程调用 read()函数,指明所要读取的socket文件描述符,触发系统调用sys_read(),陷入内核。
  • (1.2)、内核依次调用socket.c:sock_recvmsg()、tcp.c:tcp_recvmsg() 、soct.c:sk_wait_data(),将当前用户线程加入当前socket的等待队列(sock.h:struct sock->sk_wq)当前线程让出CPU,进入睡眠状态。
  • (2.1)、网卡收到数据发送方所发送的数据,将数据封装为 skbuff.h: struct sk_buff,并触发DMA请求。
  • (2.2)、DMA控制器将数据拷贝至内存(内核空间),此时 sk_buff 的 head、data 指针指向数据链路层数据帧(图示为以太帧)的头部开始位置,tail、end 指针指向数据帧的尾部结束位置。
  • (2.3)、数据被拷贝至内存之后网卡向CPU发送中断请求(硬件中断)。
  • (3)、CPU处理硬件中断(中断上半部),此时CPU处在中断上线文中,中断处理程序(网卡驱动中实现)将当前网卡设备加入到CPU:netdevice.h:softnet_data->poll_list中,关闭网卡中断(在重新打开之前该网卡只能接收数据不再触发硬件中断),触发软中断(NET_RX_SOFTIRQ)。
  • (4)、CPU处理软中断(中断下半部),此时CPU处在中断上线文或内核线程ksoftirqd上下文中(待处理软中断较多时)故图中间部分用不同颜色进行了标记,软中断处理过程中调用dev.c:net_rx_action(softirq_action) 对 poll_list 中的设备进行轮训,调用网卡设备的 poll 函数(网卡驱动中实现),进而调用 dev.c:netif_receive_skb(sk_buff) 将数据推送至协议栈,打开网卡中断(后续再有新的数据到来可再触发硬件中断),将设备从poll_list中删除。第(3)、(4)步的处理逻辑基于支持NAPI的网卡。
  • (4.1)、dev.c:netif_receive_skb(sk_buff):根据数据帧中的协议类型定位至 ip_input.c:ip_rcv(sk_buff,…),如图2.2左侧所示,此时的sk_buff,head、end 指针指向不变,data指向IP数据报的开始位置、tail 指针指向IP数据报的结束位置。
  • (4.2)、ip_input.c:ip_rcv(sk_buff,…):根据IP数据报中的协议类型定位至 tcp_ipv4.c:tcp_v4_rcv(sk_buff),如图2.2左侧所示,此时的sk_buff,head、end 指针指向不变,data指向TCP分节的开始位置、tail 指针指向TCP分节的结束位置。
  • (4.3)、tcp_ipv4.c:tcp_v4_rcv(sk_buff),如图2.2左侧所示,此时的sk_buff,head、end 指针指向不变,data指向应用层数据的开始位置、tail 指针指向应用层数据的结束位置。
  • (4.3.1)、tcp_ipv4.c:__inet_lookup_skb(sk_buff,…):根据根据四元组(源IP、源端口、目标IP、目标端口)定位 sock.h:struct sock。
  • (4.3.2)、tcp_ipv4.c:tcp_prequeue(sk, skb):将数据加入队列 tcp.h:struct tcp_sock->ucopy.prequeue。TCP的数据接收会根据不同情况综合使用四个队列,此处只给出了基于图中事件发生顺序的一种情况。
  • (4.3.2.1)、wait.c:__wake_up_sync_key(sk_sleep(sk),…):唤醒等待队列 sock.h:struct sock->sk_wq 中的用户线程,至此软中断处理完毕。
  • (5)、用户线程继续执行tcp.c:tcp_recvmsg() 、 tcp.c:tcp_prequeue_proces()、 tcp_input.c:tcp_rcv_established(sock,sk_buff,…),将应用层数据copy至用户空间,系统调用返回(由内核态切换至用户态)。

图2.2中用户线程调用read()函数将一直阻塞至数据发送方数据到来以及自身协议栈处理完成之后才会返回。

多路复用网络IO

用户线程指示内核等待多个事件中的任何一个发生,并且只有一个或多个事件发生或经历一段指定的时间之后才需唤醒用户线程。

Java中的多路复用

Java中IO多路复用基于选择器Selector实现,同一Selector可以监听多个socket的不同事件,使用Selector的主要步骤如下(省略部分行):

// 初始化 Selector
Selector selector = Selector.open();
// 初始化ServerSocket,绑定监听端口
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(6666));
serverSocketChannel.configureBlocking(false);
// 向Selector注册ServerSocket,监听事件为客户端连接事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 循环调用Selector的select()方法,阻塞至所监听的事件发生或超时
selector.select(1000L);
// 获取就绪事件
Set<SelectionKey> selectionKeys = selector.selectedKeys();
// 如果是客户端连接事件则初始化Socket,向Selector注册Socket,监听事件为数据可读事件
if (selectionKey.isAcceptable()) {
    SocketChannel socketChannel = serverSocketChannel.accept();
    socketChannel.configureBlocking(false);
    socketChannel.register(selector, SelectionKey.OP_READ);
}
// 如果是数据可读事件则根据SelectionKey获取Socket,进行数据读取
if (selectionKey.isReadable()) {
    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
    ...
}
...

主从Reactor多线程模型

Linux网络IO学习笔记

图3.1、主从Reactor多线程模型

图3.1给出了经典的主从Reactor多线程模型,mianReactor负责监听客户端连接事件(OP_ACCEPT),并将建立的新连接(socket)交由subReactor处理,subReactor维护这自己的selector,监听socket的读写事件并进行分发处理,Reactor线程模型的出现让多路复用网络IO如虎添翼。

Java中在Linux环境(Linux 2.5.44 以后)下被实例化的Selector其实现类为sun.nio.ch.EPollSelectorImpl(MAC下为sun.nio.ch.KQueueSelectorImpl)。Linux中,epoll由系统调用select()、poll()发展而来,下面我们先从select系统调用说起。

select系统调用

Linux select API

#include <sys/select.h>
#include <sys/time.h>
// nfds:文件描述符最大值+1
// readfds:文件描述符集合(位图)所监听的事件为可读或新链接,值-结果参数
// writefds:文件描述符集合(位图)所监听的事件为可写,值-结果参数
// exceptfds:文件描述符集合(位图)所监听的事件为异常,值-结果参数
// timeout:等待时间
// 返回就绪文件数目
int select (int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)

Linux socket API 使用的主要步骤:(1)、创建socket;(2)设置所监听的相应事件的文件描述符集合(fd_set类型的参数);(3)、调用select()函数阻塞至超时或有所监听事件发生;(4)、若有所监听的事件发生,则遍历所监听的相应事件的文件描述符集合(fd_set类型的参数),依次检测所监听的socket是否有相应事件发生,并做相应处理。

select可读事件处理

Linux网络IO学习笔记图3.2、Linux select系统调用,监听socket数据可读事件

图3.2给出了Linux select系统调用阻塞后出现数据可读事件后系统调用返回的过程:

  • (1.1)、用户线程调用select()函数。
  • (1.2)、触发系统调用:syscall.h:sys_select() 进而调用 select.c:core_sys_select()。
  • (1.2.1)、将各事件对应的文件集合从用户空间拷贝到内核空间。
  • (1.2.2)、调用select.c:int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)。
  • (1.2.2.1)、select.c:poll_initwait() 初始化回调函数:poll_table pt -> _qproc = select.c:__pollwait(),关系如图中左侧所示。
  • (1.2.2.2)、依次调用每个socket的poll函数(poll_table pt ->_key=所关注的事件),如果有可读写或异常poll函数会返回相应事件(图中流程为当前无所监听事件发生的情况)。
  • (1.2.2.2.1)、调用socket.c:sock_poll() 进而调用 tcp.c:tcp_poll() 、poll.h:poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p),执行p->_qproc(filp, wait_address, p),即 select.c:__pollwait(),创建等待队列节点并将节点加入sock->sk_wq,关系如图中左侧所示。
  • (1.2.2.3)、若所有socket没有产生调用者所监听的事件,当前线程让出CPU,进入睡眠状态。
  • (2)、出现socket可读事件(详细流程可参照图2.2,此时CPU处在中断上线文或内核线程ksoftirqd上下文中),遍历等待队列中的 wait.h:struct wait_queue_t, 执行队列节点中的wait_queue_func_t 函数,即 select.c:pollwake 根据事件类型唤醒用户线程,关系如图中左侧所示。
  • (3)、用户线程继续执行select.c:core_sys_select() 将产生相应事件的文件集合由内核空间拷贝至用户空间,系统调用返回。

poll()系统调用相对于select()系统调用来说不再有所监听的最大文件数目限制(select()系统调用默认最大可监听1024个文件,poll()所监听的最大文件数目依然受进程所打开的最大文件数目限制),其实现机制与select()系统调用类似,本文不再详述。

epoll

Linux epoll API

epoll相较于select、poll有较大的改进,Linux epoll相关API如下:

// 创建epoll返回文件描述符,size参数已废弃
int epoll_create(int size);

// 向epfd上添加/修改/删除所监听的文件描述符及相应事件
// epfd: epoll文件描述符
// op: 操作类型(添加/修改/删除)
// fd: 所要监听的文件描述符
// event: 所要监听的事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

// 等待至获取所监听的就绪事件集合或超时返回
// epfd: epoll文件描述符
// events: 就绪事件集合,结果参数
// maxevents: events的最大数目
// timeout: 等待超时时间
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

Linux epoll 的主要使用步骤即以上三个函数的调用:创建epoll、设置所要监听的文件及事件然后调用epoll_wait等待事件就绪后处理事件。相较于select系统调用,epoll引入特殊文件eventpoll作为中间层,在内核空间维护了所监听的文件事件集合(红黑树)与就绪文件事件链表,实现了更高效的IO多路复用。

epoll可读事件处理

Linux网络IO学习笔记图3.3、Linux epoll 监听socket数据可读事件

图3.3给出了利用epoll 监听socket数据可读事件直至获取就绪事件的过程:

  • (1.1)、库函数调用:int epoll_create(int size):初始化epoll。
  • (1.2)、触发系统调用:sys_epoll_create(),初始化epoll,返回文件描述符epfd。
  • (2.1)、库函数调用:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event):向epoll添加所需监听的socket,等待事件为:有数据可读(EPOLLIN | EPOLLET)。
  • (2.2)、系统调用:sys_epoll_ctl()。
  • (2.2.1)、调用eventpoll.c:ep_insert():初始化 struct epitem 变量,设置回调函数:poll_table ->_qproc =  eventpoll.c:ep_ptable_queue_proc(),如图中左侧所示。
  • (2.2.1.1)、调用eventpoll.c:ep_item_poll():设置 poll_table->_key 为要监听的事件,调用所监听文件的poll函数(如果当前存在被监听的事件会返回相应事件)。
  • (2.2.1.1.1)、调用socket.c:sock_poll()、tcp.c:tcp_poll()、poll.h:poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p),执行p->_qproc(filp, wait_address, p),即 eventpoll.c:ep_ptable_queue_proc()(如图中左侧所示)。
  • (2.2.1.1.1.1)、eventpoll.c:ep_ptable_queue_proc():根据poll_table获取epitem(container_of宏),初始化 struct epoll_entry 变量,设置 eppoll_entry->wait->private = NULL,eppoll_entry->wait->func=eventpoll.c:ep_poll_callback,eppoll_entry->base=epitem,将epoll_entry->wait加入sock->sk_wq。关联关系如图中左侧所示。
  • (3.1)、库函数调用:int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout):等待事件就绪。
  • (3.2)、系统调用:sys_epoll_wait()。
  • (3.2.1)、eventpoll.c:ep_poll():就绪队列为空,将当前线程加入等待队列(struct eventpoll->wq),当前线程让出CPU,进入睡眠状态。
  • (4)、出现socket可读事件,系统遍历socket等待队列(sock->sk_wq)中的 wait.h:struct wait_queue_t, 执行队列节点中的wait_queue_func_t 函数,即 eventpoll.c:ep_poll_callback(),根据当前wait_queue_t变量获取(container_of())epitem与eventpoll,将epitem加入eventpoll的事件就绪队列rdllink,唤醒等待队列(struct eventpoll->wq)中的等待线程。
  • (5)、继续eventpoll.c:ep_poll() ,调用eventpoll.c:ep_send_events(),重新调用就绪队列中socket的poll函数获取事件,将事件拷贝至用户空间,系统调用返回。

epoll触发模式

epoll支持两种触发模式:水平触发(Level-Triggered)与边缘触发(Edge-Triggered)。水平触发:只要满足条件,就触发一个事件;边缘触发:每当状态变化时,才触发一个事件。依然拿监听事件为socket可读事件举例,水平触发模式下,只要所监听的socket有数据可读则当做就绪事件返回,即便此就绪事件在上一次调用epoll_wait时返回过,并在上次返回该事件之后该socket没有再接收到新的数据;而边缘触发模式下只有在被监听的socket有新的数据到来时才会被当做就绪事件返回。epoll默认为水平触发方式,边缘触发EPOLLET为struct epoll_event->events位掩码取值之一,可通过epoll_ctl函数设置所监听文件的触发模式。

Linux网络IO学习笔记图3.4、epoll的水平触发(LT)与边缘触发(ET)

图3.4中给出了,图3.3中第(5)步 eventpoll.c:ep_send_events()函数的执行过程。eventpoll.c:ep_send_events_proc()函数中遍历当前epoll的就绪事件链表,首先将当前节点从就绪事件列表中删除,然后调用eventpoll.c:ep_item_poll(),其中参数poll_table->_qproc = NULL,所以在调用至sock.h:sock_poll_wait()函数时并不会引发图3.3中第(2.2.1.1.1)步回调函数eventpoll.c:ep_ptable_queue_proc()的调用。tcp.c:tcp_poll()检测到数据可读事件之后返回该事件,eventpoll.c:ep_send_events_proc()函数中将事件copy至用户空间后会根据当前socket的触发模式来判断是否将当前节点重新加入到epoll就绪事件链表。若为水平触发(LT)模式,则将当前节点重新加入到epoll就绪事件链表,故在下次调用epoll_wait函数时会重新检测是否有数据可读,若有则继续返回该数据可读事件。若为边缘触发(ET)模式,只有当图3.3中第(4)步中回调函数eventpoll.c:ep_poll_callback()被重新调用时,当前socket才会有新的可读事件返回。

epoll与select的对比

Linux网络IO学习笔记图3.4、epoll与select的对比说明

epoll在Nettty中的应用

Netty由Jboss提供,是目前最流行的Java NIO通信框架之一。Netty针对Java NIO类库做了封装并提供了丰富的编解码功能,支持多种应用层主流协议。图4.1给出了基于epoll的Netty工作架构,Netty提供了灵活的Reactor线程模型的实现,通过不同的构造方法参数和线程组实例化个数,能灵活的实现Reactor单线程模型、Reactor多线程模型和主从Reactor多线程模型。Netty自己实现了对Linux epoll API 的调用,相对于JDK的实现,Netty提供了epoll 边缘触发模式的支持与更多的socket配置参数设置如:TCP_CORK等。

Linux网络IO学习笔记图4.1、基于epoll的Netty工作架构

后记

《UNIX网络编程》一书中给出了类Unix系统下五种可用的IO模型(阻塞式IO、非阻塞式IO、IO复用、信号驱动式IO、异步IO),本文只涉及到了阻塞式/非阻塞式IO与IO复用。关于异步IO(AIO),当前 Linux 对 POSIX AIO API的实现是由glibc在用户空间中提供的,有较多限制,基于内核的AIO实现方案目前并不成熟。Java 从1.7开始提供了对AIO的支持,但是在Linux环境下并没有比基于epoll的IO复用性能更好所以并没有得到广泛的应用。

主要参考文献

《UNIX网络编程卷1:套接字联网API》
《Linux内核设计与实现》
《深入理解Linux网络技术内幕》
Epoll的本质(内部实现原理)
Linux内核:sk_buff解析
TCP消息的接收
NAPI 技术在 Linux 网络驱动上的应用和完善