当前位置: 首页 > news >正文

详解套接字 Socket 与网络 IO 模型演进

详解套接字 Socket 与网络 IO 模型演进

前言:我要吐了,学 redis 要看网络模型,学 nginx 要看网络模型,学 tomcat 要看网络模型,学 netty 要看网络模型,每次看到文档中的 select、epoll、poll 头都大了,最近找了多篇参考资料,总结 socket 和各种网络模型到底指的什么,演进流程是怎么样的,希望这篇文章可以总结清楚。

从网络通信说开去

我们知道,网络中的请求的发送,以 http 为例,需要逐层封装和解封,比如我发送了一个订单要买东西,从上到下依次要经过这些流程:

  • 我要买东西(正文)
  • Http头部:我要买东西
  • TCP头部:Http头部:我要买东西
  • IP头部:TCP头部:Http头部:我要买东西
  • MAC头部:IP头部:TCP头部:Http头部:我要买东西

最后经过网关、路由器等中间设备,一步步的到对方服务器进行处理

这是我们计算机网络学习的东西,那么引申出一个问题,socket 是什么?因为在上述的流程里,我们完全没有看到 socket 的影子

当请求到达对方服务器,进行一层一层的拆包,拆下 TCP 头部后,服务器发现这是给我 8080(假设是 tomcat 默认端口) 端口这个应用的,操作系统是不关心这到底是给哪个应用的,它只负责拆包,填充包,发送,解析等内容。有包来了,是需要应用程序来监听的,我们平时说 tomcat 监听的是 8080 端口就是这个意思。我们知道 linux 是区分用户态和内核态的,tomcat 作为上层应用当然是运行在用户态,但是 MAC 地址、IP 头部、TCP 头部都是在内核态进行处理的,那么当请求来了的时候,tomcat 需要接收,用户态该怎么进入内核态呢,没错,就是 socket 系统调用,所以在使用 socket 编程时,需要绑定端口号,因为要从内核获取发给指定端口号的包。

socket 不是网络分层体系中的概念,自然也没有说处于哪一层次这个说法,它是用户态和内核态进行通信的一个机制。

Socket 编程及演进

我们平时会提到 Socket 网络编程,原因是 tcp、udp 是内核态下的东西,不方便直接调用,Socket 是在其上面的一个抽象,提供了封装好的 API。Socket 可以作插口或者插槽讲,虽然我们是写软件程序,但是你可以想象为弄一根网线,一头插在客户端,一头插在服务端,然后进行通信。所以在通信之前,双方都要建立一个 Socket。

对于服务端:

  • 创建一个 Socket,绑定一个端口(bind)
  • 监听这个端口(listen)
  • 获取这个端口传来的数据(accept)
  • 读取数据(read)
  • 给予响应(write)

注意一个问题,第一步创建的 Socket 我们称为监听Socket,而第三步获取的称为已连接Socket。可以这样理解:监听 Socket 会去问内核要发往绑定端口的数据,如果要到了,带回来一个,也就是对应 accept 操作,没有要到,程序就会阻塞在这里,直到有数据到来。

对于客户端:

  • 连接远程主机的端口(connect)
  • 发送数据(write)
  • 读取响应(read)

可以用以下图示来进行说明:

image-20250321133845458

以下是 java 语言实现客户端和服务端在 9090 端口通信的一个案例程序:

服务端:

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

public class Server {
    public static void main(String[] args) {
        int port = 9090;
        try {
            // 创建 ServerSocket 监听指定端口
            ServerSocket serverSocket = new ServerSocket(port);
            System.out.println("服务器启动,监听端口: " + port);
            while (true) {
                // 等待客户端连接
                Socket clientSocket = serverSocket.accept();
                System.out.println("客户端连接成功,来自: " + clientSocket.getInetAddress());
                // 获取客户端的输入流
                BufferedReader input = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
                // 获取客户端的输出流
                PrintWriter output = new PrintWriter(clientSocket.getOutputStream(), true);
                String message;
                // 循环读取客户端发送的消息
                while ((message = input.readLine()) != null) {
                    System.out.println("客户端发送的消息: " + message);
                    // 回应客户端
                    output.println("服务器收到消息: " + message);
                }
                // 关闭连接
                input.close();
                output.close();
                clientSocket.close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
  • 第 9 行,只标识了端口,这个构造器方法会自动绑定本地的对应端口并进行监听(bind、listen)
  • 第 13 行,调用 accept 获取一个客户端发送来的 socket

客户端:

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

public class Client {
    public static void main(String[] args) {
        String serverAddress = "127.0.0.1"; // 本机地址
        int port = 9090;
        try {
            // 连接到服务器
            Socket socket = new Socket(serverAddress, port);
            System.out.println("连接到服务器: " + serverAddress + ":" + port);
            // 获取输出流并发送消息给服务器
            PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
            BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            // 创建输入流来读取用户输入
            BufferedReader userInput = new BufferedReader(new InputStreamReader(System.in));
            String message;
            // 循环发送消息给服务器
            while (true) {
                System.out.print("请输入消息发送给服务器 (退出请输入 'exit'): ");
                message = userInput.readLine();
                // 退出条件
                if ("exit".equalsIgnoreCase(message)) {
                    break;
                }
                // 发送消息给服务器
                out.println(message);
                // 读取并输出服务器的响应
                String response = in.readLine();
                System.out.println("服务器回应: " + response);
            }
            // 关闭连接
            userInput.close();
            out.close();
            in.close();
            socket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
  • 第 10 行,连接远程地址的对应端口,返回一个 socket,注意这里并没有指定本机端口,内核会给客户端分配一个临时的端口。一旦握手成功,服务端的 accept 就会返回这个 socket

其实这个很好理解,服务端如果只有一个监听 socket 用来接收数据,那么无法标识同时到来的多个请求,不知道哪个请求该回复给谁,所以针对每一个连接,都有一个已连接 socket。

但是你如果仔细看服务端的代码,会发现一个问题

            while (true) {
                // 等待客户端连接
                Socket clientSocket = serverSocket.accept();
                .......
                // 关闭连接
                input.close();
                output.close();
                clientSocket.close();
            }

服务端接收一个客户端 socket 后,会一直为其服务,直到这个连接关闭后才继续 while 循环,处理其他的连接请求,那这样的程序并发一定不高,基本退化成了一对一沟通,这样肯定不行,我们要对其进行优化。

在优化前,我们先计算一下服务端最多能接收多少个请求,一个 socket 可以用 [本机ip、本机端口、客户端ip、客户端端口]这样的四元组表示,那么对于一个服务程序来说,【本机ip、本机端口】是固定的,【客户端ip、客户端端口】的最大数量约为 232 * 216 这样,当然这只是逻辑上,实际数量远远远远少于这个值,因为具有以下限制:

  • linux 中一切皆文件,Socket 也是文件,所以首先要通过 ulimit 配置文件描述符的数目
  • 每一个连接都消耗内存,操作系统的资源是有限的

注意:在内核中,Socket 是一个文件,那对应就有文件描述符。每一个进程都有一个数据结构 task_struct,里面指向一个文件描述符数组,来列出这个进程打开的所有文件的文件描述符。文件描述符是一个整数,是这个数组的下标。这个数组中的内容是一个指针,指向内核中所有打开的文件的列表。既然是一个文件,就会有一个 inode,只不过 Socket 对应的 inode 不像真正的文件系统一样,保存在硬盘上的,而是在内存中的。在这个 inode 中,指向了 Socket 在内核中的 Socket 结构。

img

【优化1】每获得一个新的已连接 socket,都丢给一个新进程去做

一旦建立了一个连接,就会有一个已连接 Socket,这时候你可以创建一个子进程,然后将基于已连接 Socket 的交互交给这个新的子进程来做。如果你对 redis 有一定了解,可能会想到 redis 开启后台进程去做一些事情:比如持久化,调用的是 linux 的 fork 函数,fork 的消耗是很大的,会复制主进程的所有内容,页表项、内存空间等,当然也能获得主进程刚获得的已连接 Socket,就可以去进行自己的处理了

【优化2】每获得一个新的已连接 socket,都丢给一个新线程去做。每次 fork 新的进程消耗还是太大了,而创建一个线程不用复制那么多东西,直接引用父进程的页表项等就行。但是有个C10K 问题,它的意思是一台机器要维护 1 万个连接,就要创建 1 万个进程或者线程,那么操作系统是无法承受的

【优化3】一个线程维护多个 socket,有事来轮询处理(select)

由于 Socket 是文件描述符,因而某个线程盯的所有的 Socket,都放在一个文件描述符集合 fd_set 中,然后调用 select 函数来监听文件描述符集合是否有变化。一旦有变化,就会依次查看每个文件描述符。那些发生变化的文件描述符在 fd_set 对应的位都设为 1,表示 Socket 可读或者可写,从而可以进行读写操作,然后再调用 select,接着盯着下一轮的变化

【优化4】一个线程维护多个 socket,但是由 socket 通知线程处理(epoll)

上面 select 函数还是有问题的,因为每次 Socket 所在的文件描述符集合中有 Socket 发生变化的时候,都需要通过轮询的方式,也就是需要将全部项目都过一遍的方式来查看进度,因而使用 select,能够同时盯的 socket 数量由 FD_SETSIZE(通常为1024) 限制。

如果改成事件通知的方式,情况就会好很多,项目组不需要通过轮询挨个盯着这些项目,而是当项目进度发生变化的时候,主动通知项目组,然后项目组再根据项目进展情况做相应的操作。能完成这件事情的函数叫 epoll,它在内核中的实现不是通过轮询的方式,而是通过注册 callback 函数的方式,当某个文件描述符发送变化的时候,就会主动通知。这种通知方式使得监听的 Socket 数据增加的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了。上限就为系统定义的、进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器

image-20250321151051419

【1】上述流程大多基于 TCP 连接分析,UDP 由于不需要握手什么的,流程会简单一点

【2】图示需要对 网络协议、linux 数据结构有一定了解,参考极客时间刘超老师专栏:趣谈 Linux 操作系统

相关文章:

  • Android使用RxHttp进行国密4加密解密
  • 如何在IPhone 16Pro上运行python文件?
  • SQL Server 2014 (x64) 中文版安装与使用指南(附安装包)
  • 深入解析 Java Stream API:筛选子节点的优雅实现!!!
  • 金桔网桥路由版3
  • Excel(进阶篇):powerquery详解、PowerQuery的各种用法,逆透视表格、双行表头如何制作透视表、不规则数据如何制作数据透视表
  • 网络华为HCIA+HCIP 网络编程自动化
  • 《Python实战进阶》No26: CI/CD 流水线:GitHub Actions 与 Jenkins 集成
  • Neo4j GDS(Graph Data Science)库安装(Mac版)
  • 网络知识编-数据链路层(以太网 局域网通信 ARP协议 ARP 欺骗 DDos 攻击)
  • Elasticsearch零基础入门:从安装到入门
  • 紫光展锐社招
  • 详解MySQL的事务实现机制
  • 网页性能优化中 有一条叫做“避免使用未合成的动画”,请问该如何理解?
  • 【技术选型】三大 Python Web 框架全面对比
  • SQL Server数据库表删除分区
  • self Attention为何除以根号dk?(全新角度)
  • 【入门初级篇】布局类组件的使用(2)
  • IRF拆除
  • android facebook 分享到 messenger
  • 上海团队在医学顶刊连发两文,率先提出“证据污染”循证概念
  • 牧原股份子公司与养殖户种猪买卖纠纷案一审胜诉
  • 马上评丨摆摊要交芙蓉王?对吃拿卡要必须零容忍
  • A股三大股指集体高开
  • 傅利叶提出下个十年战略,CEO顾捷:机器人要有温度,要用实际价值来定义形态
  • 印度最新发声:对所有敌对行动均予以反击和回应,不会升级冲突