随笔篇-Socket IO 问题

0.中断

0.1 简介

中断是指计算机运行过程中,出现某些意外情况需主机干预时,机器能自动停止正在运行的程序并转入处理新情况的程序,处理完毕后又返回原被暂停的程序继续运行
-------- 引用百度百科

例如, 你正在家看电影,突然外卖送到了,此时你不得不暂停电影,停止播放,去拿外卖

这个过程就是中断,在计算中中断分为两种:

  • 硬中断
  • 软中断

0.2 硬中断

硬中断通常是由硬件发起的中断,电脑外设,网卡等发起的中断,例如最常见的键盘打字场景如下:

CPU正在运行用户程序,那么当用户在键盘输入字符时,那么键盘就会发起中断,CPU的中断引脚(INTR)就会接受到中断信号(唯一的数字标识),从而调用内核程序将输入的字符展示在对应的地方。

当然CPU不可能给每个硬件设备都弄一个中断引脚,因此中断信号(唯一的数字标识)时通过中断控制器去发送到CPU的中断引脚,如下图所示:

image-20210723114357715

8259A是一个中断控制器,每个接口都连接不同的硬件设备,当硬件设备超过8个时可以再连接一个中断控制器

中断控制器大概工作流程就是 中断请求寄存器保存多个硬件的中断请求信息,接下来通过优先级解析器对 中断请求进行排序,数字越小越先执行,最后将正在执行的中断请求存入

正在服务寄存器中,如下图:

image-20210723140324004

当然每个中断信号其实本质就是一个位置的数字,那么每个数字即要执行什么样的程序,这个由中断向量表,即 把系统中所有的中断类型码及其对应的中断向量按一定的规律存放在一个区域内,这个存储区域就叫中断向量表

在内核程序启动时就会去加载这个表,这样就知道了,不同的硬件设备中断就会执行不同的程序

0.3 软中断

众所周知,系统运行速度越快越好,最好是实时系统,因此,Liunx为了满足这点,当中断发生时,将耗时较短的操作的操作交给硬中断处理,而一些耗时较长的的工作交给软中断处理。

例如,网卡接收到数据时,就会发送一个硬中断请求,CPU接收到了这个中断,就会快速将网卡中的数据存放到内存中,然后发送一个软中断,当软中断信号被唤醒后,CPU就会处理内存中的数据,在处理期间还是能够响应其他的硬中断,如下图:

image-20210723142909629

1.FD

Linux中,一切皆文件,内核则是利用文件描述符来操作文件,当新建了一个文件或者打开现存文件都会返回一个文件描述符fd(整型数字),然后通过文件描述符来进行操作。

例如可以使用exec文件来给文件创建fd,例如:

touch test.txt
exec 5test.txt

数字5 就代表 test.txt这个文件的读操作,操作fd5,即操作这个文件都操作

数字6 就代表 test.txt这个文件的写操作,操作fd5,即操作这个文件都操作

#代表将hello写入到test.txt文件中
echo 'hello' >& 6

当然在linux中,任何程序都具备三个基础文件描述符0,1,2,含义如下:

  • 0代表标准输入
  • 1代表标准输出
  • 2代表错误输出

可以以下命令查询当前进程的文件描述符

# $$ 代表当前进程
ls /proc/$$/fd
image-20210801114938708

2. 状态

在操作系统中CPU有两种状态,分别是

  • 内核态

    运行内核程序的状态称为内核态

  • 用户态

    运行用户程序成为用户态

这种状态会时常进行切换,例如当发生中断时,CPU就会从用户态切换到内核态

3. Server

每个服务端程序一旦运行都会去创建socket,当然socket也是文件描述符进行表示的,

socket一旦创建,就会进入bind阶段,将地址绑定到socket上,绑定完成后就会开始进行监听,监听完成并开始进行调用accpet方法

以下对上述步骤进行详细演示

3.1 socket

这里使用nc命令进行创建服务端程序,如果没有可以安装

# 创建服务端程序 并且端口号为8989
nc -l 8989
image-20210801115547122

一旦执行此命令,CPU就会调用内核去执行内核的socket && bind && listen方法

当然也可以先找到nc的进程id,然后查看该进程的所有文件描述符

可以另起一个tab页,之前的程序不要关闭

ps -ef | grep nc
image-20210801120542819

例如我这里是6040,那么我就需要去6040下去找对应的文件描述符,如下:

ll /proc/6040/fd
image-20210801121039226

可以发现多了两个文件描述符3,4,而且分别指向了socket

为了更加了解nc -l 8989运行以后,内核程序是如何执行的,可以通过strace命令查看到应用程序与内核的交互过程,如下:

先停掉之前的程序

# strace 追踪与内核交互程序
# -ff 如果一个程序启动有多个线程,那么就会按照线程号记录到每个文件中
# -o kk 将内容输出到kk文件中
strace -ff -o kk nc -l 8989
image-20210801121924541

另起一个tab页,就可以看到输出的kk文件,如下图所示:

image-20210801122112802

打开文件,查看器文件内容,可知,当执行完指令后创建了两个socket,如下图:

image-20210801122230399
image-20210801122418490

之所以有两个socket,是因为一个是IPV4,一个是IPV6

同时当socket方法执行完成以后,返回了文件描述符3,4


为了更加了解socket方法可以通过man指令查看socket的描述,如下:

# 2 代表查看2类命令
man 2 socket
image-20210801123318871

从描述可以出,socket方法调用代表创建一个通信端点,并且返回一个fd

调用时需给这个方法传入一个 域名参数,类型参数,和协议参数

因此只要是服务端程序一旦启动,就一定会调用socket

调用内核程序,也叫系统调用

3.2 bind

继续查看kk文件内容可知,当socket创建完成以后,紧接着会调用bind内核程序,如下图所示:

image-20210801123941438

从图中代码可以知道,是将端口和地址绑定到了fd4这个socket上,为了更加了解清楚

可以继续查看手册,了解其细节 ,如下:

image-20210801124201289
image-20210801124219000

从上图中可知,当socket创建成功后,并没有分配给scoket地址,因此借助bind需要给socket分配地址。

当分配成功后就会返回一个0,注意这个0并不是fd

3.3 listen

继续查看kk文件,发现当bind过后紧接着调用了listen程序,如下图:

image-20210801124701832

同理查看手册去了解该方法的作用

image-20210801124746644

从上图可知,该方法是用来监听是否有客户端来连接这个socket,如果一旦连接就会回调accept方法

至此可以发现,任何一个服务端程序启动都会经过socket bind listen三个步骤

4.client

4.1 accpet

当使用nc程序连接服务端时,从上面描述可知会先调用accpet方法,与服务端建立连接

nc localhost 8989
image-20210801125156809

继续查看kk文件,可以发现调用了accpet方法建立了连接,如下:

image-20210801125251823

注意: 此时并没有给服务端发消息,只是建立三次握手

同理查看手册去了解该方法的说明

image-20210801125425867

从图中可知,该它提取挂起连接队列上的第一个连接请求侦听套接字的连接sockfd创建一个新的已连接套接字,并返回引用该套接字的新文件描述符

意思就是创建一个客户端连接的socket,然后返回给socket的文件描述符,从之前的图可知,目前的socket描述为7

注意:这里说的socket指的是客户端连接的socket

4.2 recvfrom

当客户端给服务端发送消息,服务端就会调用recvfrom程序接受网卡中的消息,并且通过调用write方法去写入到用户程序上,如下:

image-20210801130415636

因此整个流程客户端与服务端通信过程是:

  1. 服务端启动后先系统调用socket,bind,listen

    多路复用器select后续再说

  2. 客户端与服务端建立三次握手时,先将数据包发送到网卡,网卡发起硬中断,然后由

    用户态切换到内核态,调用accpet方法建立连接创建客户端socket

  3. 客户端发送消息时,还是会发送到网卡,然后发起中断,状态切换,最后接收消息,然后通过write调用将消息写回用户程序

5.BIO

BIO(阻塞式IO),即socket返回的文件描述符都是阻塞的,如果说连接的数据没有到达那么就会一直阻塞在那,等到数据到达。这种效率可想而知是非常低的

5.1 案例

该案例简单模拟一下BIO

在系统中安装java环境,并且准备一个java程序,内容如下:

import java.io.*;
import java.net.*;

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocket ss = new ServerSocket(8888);
        System.out.println("create server socket");
        while(true) {
            Socket socket = ss.accept();
            System.out.println("client port:" + socket.getPort());
            BufferedReader  br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            while(true) {
                System.out.println(br.readLine());
                if(br.readLine().equals("exit")) break;
            }
        }
    }
}

从上图程序可知,当接收到一个socket后,会开启一个死循环读取这个socket发送过来的消息

如果消息没有发送过来就会一直等待,这样即使有第二个socket发送消息过来也只能是等待

输入以下指令执行程序

strace -ff -o bbb java Server

发现程序一直阻塞着等待客户端的连接,如下图:

image-20210723161307075

此时可以再起一个tab页,查看用户内核交互记录

image-20210723161331038

发现产生了很多文件,这是因为jvm启动时会创建一些线程去处理一些事情,例如垃圾回收等等之类


输入jps指令,可以发现Server程序运行在2039进程id上,如下图所示:

image-20210723162434020

通过以下命令,进入进程对应的目录中

cd /proc/2039/fd

查看内容详情,如下图:

image-20210723162914851
  • 3 是因为程序启动时会加载rt.jar,所以会有一个文件描述符 3
  • 4,5 代表创建的socket,之所以有两个是因为一个是IPV4 Socket,一个是IPV6 Socket

nc 命令可以与任何服务端程序建立连接

通过以下命令与服务端程序(Server)建立通信,如下:

# 注意:在两个tab页同时输入该命令
nc localhost 8888
image-20210801132156203

可以从输出的消息可以看出,只输出了一个客户端的消息,而第二个客户端的消息并没有输出,如下:

image-20210801132300141

这种通信模型即BIO模型,也就是说其socket是阻塞的,一直等待客户端消息发送过来

如果不发送一直阻塞,其他客户端也无法连接过来

5.2 thread

从上图也可知,当在高并发的情况下并不是适合,不可能10w个客户端一直要等服务端挨个处理完成

为了提高程序的并发能力,采用一些人采取了多线程的方式,如下:

import java.io.*;
import java.net.*;

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocket ss = new ServerSocket(8888);
        System.out.println("create server socket");
        while(true) {
            Socket socket = ss.accept();
            new Thread(() -> {
                try {
                         System.out.println("client port:" + 
                                            socket.getPort());
                        BufferedReader  br = 
                            new BufferedReader(
                            new InputStreamReader(
                                socket.getInputStream()));
                         while(true) {
                            System.out.println(br.readLine());
                        }
                } catch(Exception e) {
                  e.printStackTrace();
                }

           }).start();
        }

    }
}

输入以下指令运行程序

# 最好是将之前的bbb文件删除吗,防止引起混乱
strace -ff -o mm  java Server

运行成功以后再通过nc程序去连接服务端,这里还是启动两个客户端去连接服务端程序

nc localhost 8888

从服务端输出的结果可以看出完美解决了上述问题,如下图

image-20210801133549543

5.3 缺点

上述多线程的方式,解决了阻塞问题,但是从本质来讲socket还是阻塞的,只不过是换成了多线程的方式

查看与内核交互的日志,去了解当创建线程时,内核干了什么事情

image-20210801134627178

从上图可知,文件有多个,我们并不知道主线程对应的是哪个文件,因此可以筛选以下,因为主线程启动时会输出create server socket,这样可以通过以下命令进行筛选

 grep 'create server socket' ./mm.* 
image-20210801134223266

从图中可以看出,主线程相关内容是在6341文件上,因此查看该文件内容

vim mm.6341

从文件内容上可以看出,创建线程其本质是调用内核的clone方法,如下图所示:

image-20210801134528961

且与线程的文件一一对应,如下图:

image-20210801134725283

在此我们得出结论,每创建一个线程就会发生一个系统调用,假设10W个客户端连接进来,那么就意味着要创建10W个线程,发生10W次系统调用

且不说能不能能不能支持这么多线程数,即使能支持,CPU的线程调度,系统调用也会大大消耗资源

因此如果这种方式并不适合在并发的情况下使用

3.NIO

基于上述原因,因在在Linux中提供了另外一个socket,即非阻塞式socket,主要是为了解决BIO 问题,如下:

image-20210730170634325

有了非阻塞Socket,那么就意味着不用向之前那样进行等待阻塞又或者是创建多个线程去处理客户端,即一个线程也可以去处理多个客户端程序,而不阻塞

此时工作方式就变成了以下方式:

socket(...) = 4
bind(4,...) = 0
listen(4)
accpet(4,...) = -1 # 返回的socket不再阻塞等待客户端连接,没有数据到达返回 -1

# 当有下一个客户端连接过来会继续 accpet
accpet(4,...) = 5
listen(5)
recvfrom(5) = -1 # 没有数据到达返回 -1

因此修改之前的程序代码,如下:

public static void main(String[] args) throws IOException {
    List socketChannels = new ArrayList<>(10);

    ServerSocketChannel ss = ServerSocketChannel.open();
    ss.bind(new InetSocketAddress(8888));

    while (true) {
        // 不会阻塞 如果客户端没有发送数据那么channel为空
        SocketChannel channel = ss.accept();
        if (channel == null) {
            System.out.println("发数据为空");
        } else {
            ss.configureBlocking(false);
            socketChannels.add(channel);
        }

        socketChannels.forEach(t -> {
            // 读取客户端消息
        });
    }
}

从上述伪代码可知,这种工作模型一个线程可以接收多个客户端请求,然后在程序中即用户态中判断客户端发送的消息是否到达。到达则处理数据,不到搭达则进行下一次循环

每次查询是否到达都需要调用recv...方法,也就是系统调用,而用户态与内核态的切换时比较小号资源的

当有10W个客户端,意味着每次循环都要经历10W次,那么这个效率肯定时非常地下的

4. 多路复用

4.1 select

为了解决上述在用户态循环多次系统调用问题,内核增加了一个系统调用select,通过命令man 2 select 查看其介绍可知:

image-20210801141843761
image-20210801141923089
image-20210801141934771

从上图中可以看出,select函数,可以一次监听多个文件描述符,一旦调用就会进入阻塞状态,当客户端的消息到达时,就会返回对应客户端的fd

也就是说以前需要在程序中循环判断消息是否到达,而现在只需要将多个socketfd传给select,在内核内部就会判断,如果到达就会返回fd,用户态就可以再次操作,如下:

image-20210730174707240

这种相当于以前是在用户态即用户程序员那边去判断,现在只需要调用一次select即可判断,这样系统调用次数也就降低了

这样由n次系统调用降低为了1,调用一次select就知道哪些文件描述符时可用的,之前的poll函数就是类似功能

同时从参数select方法参数可知,如下图

image-20210801142946239
  • nfds待监听的最大fd指+1,最大值为1024
  • *readfds 待监听的可读fd集合
  • *writefds 待监听可写fd集合
  • exceptfds 带监听异常fd集合
  • timeout 超时时间

select虽然降低了NIO或者BIO过程中的多次系统调用,但是有以下缺点无法解决:

  • select是直接在readfdswritefds操作,导致这两个数组不可重用,每次都需要重新赋值
  • 每次select都要便利全量的fds

5.EPOLL

epoll 是Linux所特有的

epoll本质是一个组合系统掉i用,由三部分组成

  • epoll_create
  • epoll_ctl
  • epoll_waite

当然也可以从其手册上看出,如下图所示:

image-20210801105335237

5.1 epoll_create

当服务端程序,调用epoll_create时,就会创建一个epoll实例,在内核中开辟一块空间,并且返回一个文件描述符,这个文件描述符就是用来描述这块开辟的空间,当然也可以从其手册中可以看出来,如下:

image-20210801105648914

从图中的描述可以看出,当调用epoll_create方法时,需要传入一个size,但是这个size2.6.8版本后已经忽略掉了

开辟的内核空间,其本质就是一个结构体,这个结构体主要是是由红黑树 + 就绪链表组成

5.2 epoll_ctl

从手册中可以看出,当调用该方法需要传入之前epoll_create返回的文件描述符,如下图:

image-20210801110806729

关于参数解释如下:

  • epfd epoll实例的fd

  • op 对空间红黑数进行操作,操作参数如下图

    image-20210801110859014
    • epoll_ctl_add表示将fd添加到之前在内核开辟的内存空间,也就是将fd添加到红黑树上
    • 同时moddel,则表示修改和删除
  • fd socketfd

5.3 epoll_wait

当调用epoll_wait方法时,就会直接获取到达消息文件描述符,应用程序进行读写操作

整个工作流程如下:

image-20210801151058612

你可能感兴趣的:(随笔篇-Socket IO 问题)