Linux BIO NIO原理

Java与Linux BIO NIO的原理

最近在研究netty底层,其中很多原理只能知其然不知其所以然,过段时间就忘了具体的实现细节。

一、我在学习中的困惑
1. 为什么代码要这么写?

下面分别是一段简化的Java BIO客户端和服务端示例,为什么客户端和服务端创建socket连接的过程不一样?

为什么BIO的代码要这么写?

package bio;
 
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintStream;
import java.net.Socket;
 
public class Client {
    public static void main(String[] args) throws Exception{
        //创建socket链接
        Socket socket = new Socket("127.0.0.1",8080);
        //从socket对象中获取一个输出流
        OutputStream out = socket.getOutputStream();
        // 发送数据...
    }
}
package bio;
 
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
 
public class Server {
    public static void main(String[] args) throws Exception{
        // 对服务端端口进行注册 9999
        ServerSocket serverSocket = new ServerSocket(8080);
        // 监听客户端socket请求
        Socket socket = serverSocket.accept();
        // 从socket管道中得到一个字节输入流对象
        InputStream is = socket.getInputStream();
        // 接收数据...
    }
}
2. NIO的N是什么意思?

在Linux系统下,Java的NIO是使用操作系统的epoll实现的,epoll是阻塞的。那么N是non-blocking的意思吗?如果是的话,到底谁是非阻塞的?

3. 客户端的数据是通过哪个端口发送的?

通常情况下,客户端在连接服务端时只声明要连接的服务端端口,那么客户端的数据是通过哪个端口发送的?可不可以人为指定呢?

二、图解数据接收过程

以C语言为例,通过read函数调用就能接收来自对端的数据

int main() {
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    connect(sock, ...);
    read(sock, buffer, sizeof(buffer)-1);
    ...
}

先用一张图了解一下操作系统的收包流程

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

对上图的步骤做一个简单解释:

  • 数据从网络到达主机的网卡后,网卡把数据DMA到内存中

    DMA:直接内存访问(DMA,Direct Memory Access)是一些计算机总线架构提供的功能,它能使数据从附加设备(如磁盘驱动器)直接发送到计算机主板的内存上。

  • 网卡发出一个硬中断通知CPU

    中断简单理解为,在硬件设备中有一个可编程中断控制器,它的物理引脚连接了不同的可以发出中断的硬件设备。可编程控制器提前被设置好了引脚与中断号的对应关系,就把引脚线上的信号转化成了中断号。然后给CPU发送一个信号,触发对应中断号的中断处理函数。

    硬中断一旦触发就会一直执行直到执行完毕

  • CPU响应硬中断,调用提前被注册好的网卡驱动函数将驱动传过来的poll_list添加到softnet_data中。poll_list是一个双向链表,其中设备带有输入帧等着被处理。简单理解就是驱动函数告诉了CPU有哪些数据要被处理。然后发出了一个软中断。

    为什么要有硬中断和软中断?首先如果CPU只响应一个中断,所有操作都在一个中断中完成,会导致中断处理函数过度占用CPU而无法响应其他设备,尤其是网络模块这种过程复杂耗时的操作。所以Linux中断处理函数是分为上半部和下半部的。

    硬中断和软中断的区别可以简单理解为,硬中断由是给物理引脚施加电压来触发对应的中断处理函数的,软中断虽然也是自身通过引脚的信号触发,但是具体触发哪个软中断处理函数是通过给内存中的一个变量赋予二进制值来判断的。

  • 接下来CPU响应软中断,由ksoftirqd内核线程处理软中断,调用网卡驱动注册的poll函数开始收包。

  • 数据帧被从内存中取出来保存为一个struck sk_buff对象,经过协议栈的处理(TPC/UDP),处理后的数据被放到了socket的接收队列中

  • 由内核唤醒在socket上等待的用户进程来处理接收队列中的数据

三、socket的创建与用户进程的协作
3.1 TCP交互流程简介

TCP的交互流程可以用以下一张图来解释

TCP流程简图

先尝试理解上边的TCP连接的建立和交互过程,我们先来看看关于socket的相关内容。

3.2 Socket的结构

在Java网络编程中,我们经常会接触socket,通常对socket的解释是套接字,百度的解释是

“所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。”

可以把socket理解为由代码实现的一层业务层,把底层的一些系统调用封装起来,实现了对端口的监听,连接的建立,网络数据的读写等一系列操作,将网络操作转化成了对socket的操作。

从开发者的角度来看,可以通过socket()函数来创建一个socket对象,在用户层面看得到的是一个句柄(也可以理解成一个内存对象的地址),但其实内核在内部创建了一系列相关的内核对象。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从Linux内核角度看,一切皆文件,Socket也不例外,当内核创建出socket之后,会将这个socket放到当前进程所打开的文件列表中管理起来,用户空间操作这个socket时只能操作这个socket所对应的文件描述符fd,因此对Socket对象的操作就是对文件的操作。

在创建socket的时候struct file指向一个file对象,file对象中的struct file_operations指向的socket_file_ops定义了该文件的操作函数。

在Linux中针对不同文件的操作是不同的,针对Socket文件的操作定义在struct file_operations中

static const struct file_operations socket_file_ops = {
.owner =  THIS_MODULE,
.llseek =  no_llseek,
.read_iter =  sock_read_iter,
.write_iter =  sock_write_iter,
.poll =    sock_poll,
.unlocked_ioctl = sock_ioctl,
.mmap =    sock_mmap,
.release =  sock_close,
.fasync =  sock_fasync,
.sendpage =  sock_sendpage,
.splice_write = generic_splice_sendpage,
.splice_read =  sock_splice_read,
};

在用户空间发起的读写等系统调用,进入内核首先会调用Socket对应的struct file中指向的socket_file_ops,最终会调用到struct sock中sk_port中的协议处理函数

read调用链

在创建socket时,会根据Socket的类型(TCP/UDP)查找到对应的操作方法集合。针对TCP找到的是inet_stream_ops和tcp_port,并把他们分别设置到socket->ops和sock->sk_port上。

socket对象创建好后对其进行初始化,将sock中的sk_data_ready函数指针(这个函数指针很重要,是实现epoll的关键也是BIO和NIO的关键区别)进行初始化,设为默认值sock_def_readable。当软中断上收到数据包时会通过sk_data_ready函数指针,也就是sock_def_readable()函数来唤醒在sock上等待的进程。

3.3 用户线程的阻塞与唤醒

前面已经讲了Socket的内核结构,在讲TCP的详细交互流程之前,我们还需要了解一个重要的原理就是用户线程的阻塞与唤醒,这与epoll的实现细节息息相关。

首先,我们在创建了socket对象之后,通过read函数来接收数据,实际调用链路是read -> recv -> recvfrom。

执行recvfrom系统调用,用户进程就进入了内核态,执行一系列的内核协议层函数,然后到socket对象的接收队列中查看是否有数据,没有的话就把自己添加到socket对应的等待队列中。最后让出CPU,操作系统会选择下一个就绪状态的进程来执行。下面我们来详细拆解这些步骤

  • 根据我们我们上面讲的socket的结构,我们最终会调用到tcp_recvmsg(recvfrom -> inet_recvmsg -> tcp_recvmsg)。

协议栈

  • tcp_recvmsg会遍历socket的接收队列,如果没有接收到数据或者接收到的数据不够多,就会调用sk_wait_data把当前进程阻塞掉。

  • sk_wait_data首先执行了DEFINE_WAIT宏,定义了一个等待队列wait。在这个新的等待队列项上,注册了回调函数autoremove_wake_function,并把当前进程描述符current关联到其.private成员上。

  • 接着改变当前进程状态,让出CPU,至此当前进程就阻塞在了recvmsg系统调用上。

    用户进程阻塞

接下来我们来看进程的唤醒流程,结合上面讲的数据接收流程,我们来看看触发软中断后做了什么

  • 软中断里收到数据,如果是TCP包会执行tcp_v4_rcv函数

  • 如果是ESTABLISH状态下的数据包,会把数据拆出来放到对应socket的接收队列中

  • 调用sk_data_ready来唤醒用户进程,上一小节讲socket结构的时候,创建完socket对象进程初始化的时候,将sk_data_ready指针设置成了sock_def_readable函数,所以这里调用的是sock_def_readable

  • sock_def_readable函数会访问struck sock中的sk_wq等待队列,等待队列中保存了被阻塞进程的fd,最终调用autoremove_wake_function唤醒fd对应的进程

    即使有多个进程都阻塞在同一个socket上,也就是等待队列上有多个等待项,最终也只会唤醒一个进程。其作用是为了避免”惊群“

用户进程唤醒

3.4 详解TCP交互流程

我们在编写服务端程序时,以Java为例,我们会先创建一个ServerSocket对象用于接收客户端的连接。客户端连接后会产生一个Socket对象用于接收和发送数据。我们先将ServerSocket成为监听Socket,我们来看看服务端程序的相关步骤都做了哪些事。

1、首先,服务端创建一个socket对象,执行bind系统调用,绑定一个指定端口

2、接着将socket对象传入listen()函数,执行listen系统调用,listen主要做了两件事

  • 将原来struct socket对象中的sturct sock对象强制转换成了inet_connection_sock,名叫icsk,这也是监听Socket和普通Socket在结构上的区别

  • 创建和初始化接收队列

    半连接队列的长度是min(backlog, somaxconn, tcp_max_syn_backlog) + 1再向上取整到2的N次幂,但最小不能小于16。backlog是调用listen时用户传入的参数,somaxconn和tcp_max_syn_backlog分别是net.core.somaxconn、net.ipv4.tcp_max_syn_backong内核参数。如果出现半连接队列溢出问题,需要同时修改以上三个参数

    全连接队列长度是min(backlog, somaxconn)

监听socket

上面这张图展示了监听Socket和普通Socket在结构上的区别。

3、服务端执行accept系统调用,如果此时没有客户端经过三次握手连接上来,那么用户进程将阻塞在accept系统调用上,如果有客户端连接上来会进行以下操作

  • 内核会基于监听socket创建出来一个新的socket用于与客户端同通信
  • 内核会为已连接的socket创建struct file并初始化并把Socket文件操作函数集合(socket_file_ops)赋值给struct file中的f_ops指针,然后将struct socket中的file指针指向这个新分配的struct file结构体
  • 然后调用监听socket的accpet,实际上是调用了inet_accept,该函数从全连接队列中取头部创建好的stuct sock赋值给新创建的socket对象中的sock指针,然后将head指针指向下一个元素

4、客户端创建一个socket对象,用于和服务端通信

5、客户端执行connect系统调用,把socket状态设置成TCP_SYN_SENT,选择一个可用端口,发出SYN握手请求并启动重传定时器

  • 如果创建客户端socket时没有指定端口,connect系统调用将选择一个可用端口进行数据收发。首先会根据要连接的目标ip和port信息生成一个随机数,然后读取net.ipv4.ip_local_port_range(管理员配置的可用端口范围)这个内核参数,在可用端口范围内的某个随机数开始遍历,跳过已经被占用的和net.ipv4.ip_local_reserved_ports中声明保留的端口,找到第一个可用的端口。

  • 构建一个SYN请求的报文,发出SYN数据

  • 设置重传定时器

6、服务端收到客户端发来的SYN请求,处理并发送SYN_ACK数据

结合前面讲的网络报接收过程,经过网卡,中断等一系列处理后最终会进入协议层处理。在收到数据后,协议层将tcp_v4_rcv函数。tcp_v4_rcv根据TCP头信息中的ip信息找到了socket(先找已经建立连接的如果没有再找监听的),因为还没有建立连接所以此时找到的是监听socket,然后把socket和数据作为入参调用tcp_v4_do_rcv,该函数处理分为两种情况

  • 如果入参的socket是TCP_LISTEN状态(第一次握手和第三次握手),调用tcp_v4_hnd_req函数处理
  • 如果入参的socket不是TCP_LISTEN状态,调用tcp_rcv_state_process函数处理

此时,由于是处理SYN请求,因此调用tcp_v4_hnd_req函数,构建一个request_sock内核对象放入半连接队列,并响应SYN_ACK

7、客户端收到服务端响应的SYN_ACK后也是一样的调用链,tcp_v4_rcv -> tcp_v4_do_rcv -> tcp_rcv_state_process(因为之前客户端的socket被设置成了TCP_SYN_SENT,所以会进入这个函数)。tcp_rcv_state_process识别到是服务端发来的SYN_ACK后,清楚了connect时设置的重传定时器,把当前socket状态设置为ESTABLISHED,开启保活计时器后发出第三次握手的ack确认

8、服务端收到第三次握手请求,最终和第一次握手的处理流程一样调用tcp_v4_hnd_req函数处理,不过处理过程有一点区别。服务端响应第三次握手ACK会把当前半连接对象删除,创建新的sock后加入全连接队列,最后将新连接的状态设置为ESTABLISHED

9、之后客户端和服务端会进行正常的数据收发操作,按照前面的流程,因为连接已经建立,所以当调用tcp_v4_rcv函数处理数据时,根据ip会查找到状态为ESTABLISHED的普通socket进行数据处理

用一张图详细解读TCP客户端与服务端的交互流程如下

tcp流程

四、epoll

在上一节中,我们讲了TCP的交互流程,可以看到read和accept对应的底层系统调用都是阻塞的。如何高效地对海量用户提供服务,也就是经典地c10k问题。

一种方法是每一个进程处理一个连接,但是进程在Linux上是一个不小的开销,当连接数不多(100以内)可以采用这种方式。

另外一种方法是可以采用循环遍历的方式来发现IO事件,以非阻塞的方式遍历所有的socket。我们希望有一种更高效的机制,在很多连接中快速找出有IO事件发生的连接,这就是IO多路复用。这里的复用指的就是对进程的复用。

在Linux上的多路复用方案有select、poll、epoll,其中epoll性能最优,能支撑的并发量最大。接下来我们看看epoll是如何工作的。

先看一个epoll的简单示例

int main() {
    listen(lfd, ...);
    cfd1 = accept(...);
    cfd2 = accept(...);
    efd = epoll_create(...);
    
    epoll_ctl(efd, EPOLL_CTL_ADD, cfd1, ...);
    epoll_ctl(efd, EPOLL_CTL_ADD, cfd2, ...);
    epoll_wait(efd, ...);
}
  • epoll_create:创建一个epoll对象
  • epoll_ctl:向epoll对象添加要管理的连接
  • epoll_wait:等待其管理的连接上的IO事件
4.1 epoll的结构

在调用epoll_create时,内核会创建一个struct eventpoll的内核对象,其对象结构如下图所示

  • wait_queue_head_t wq:epoll_wait使用的等待队列。软中断数据就绪时会通过wq来找到阻塞在epoll对象上的用户进程。
  • struct list_head rdllist:就绪描述符队列(链表)。有连接就绪的时候,内核会把就绪的连接放到链表里,用户进程只需要判断链表里的数据就能找出就绪连接,而不需要遍历红黑树。
  • struct rb_root rbr:红黑树。管理在epoll上注册的连接。

epoll结构

4.2 epoll注册socket

结合前面的示例代码,我们来看下epoll的工作流程

1、创建监听socket和epoll对象,并将这两个对象关联到进程已打开的文件列表中

epoll注册socket1

2、调用epoll_ctl函数,传入socket和epoll对象的文件描述符fd,内核根据fd找到其内核对象,执行ep_insert函数。所有的注册逻辑都是在ep_insert这个函数中执行的,主要分为以下几步

2.1 分配并初始化epitem对象

对于每一个socket,调用epoll_ctl的时候,都会为之分配一个epitem。将该对象的ep指针指向关联的eventpoll对象。另外用要添加的socket的file、fd来填充ffd。其关联关系如下图

epitem

2.2 设置socket等待队列

在内核中创建完表示Socket连接的数据结构struct epitem后,我们就需要在Socket中的等待队列上创建等待项wait_queue_t并且注册epoll的回调函数ep_poll_callback。这里我们再与用户进程的阻塞与唤醒小节讲到的等待项做个对比,当时讲到等待项wait_queue_t中private属性保存了当前进程的fd,func指向的是autoremove_wake_function。

2.3 将epitem插入eventpoll对象的红黑树中

4.3 epoll_wait等待接收

1、用户程序调用epoll_wait后,内核首先会查找epoll中的就绪队列eventpoll->rdllist是否有IO就绪epitemepitem里封装了socket的信息。如果就绪队列中有就绪的epitem,就将就绪的socket信息封装到epoll_event返回。

2、如果eventpoll->rdllist就绪队列中没有IO就绪epitem,则会创建等待项wait_queue_t,将用户进程的fd关联到wait_queue_t->private上,并在等待项wait_queue_t->func上注册回调函数default_wake_function。最后将等待项添加到epoll中的等待队列中。用户进程让出CPU,进入阻塞状态

现在调用的是epoll_wait,用户进程也是阻塞在epoll_wait函数上的,因此此时构建的等待队列是挂在epoll对象上的,这里要和socket的等待队列区分开

  • 18
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值