Socket 网络编程:从基础到实践的全面解析(下)

2023年 9月 28日 67.7k 0

01-Java 套接字编程进阶

前面 《Socket 网络编程:从基础到实践的全面解析(下)》 介绍的基本套接字编程仅适用于小规模的系统。
如果有大量的 client 同时访问 server,即使 server 端使用多线程技术,每个线程服务一个 client,仍然无法扩展到同时支持大量用户。
虽然可以使用线程池进一步优化 server 端,受限于硬件资源,服务端程序仍会遇到所谓的“10k 瓶颈”。

“10k 瓶颈”指的是一种现象,即当并发连接数超过大约10000个时,系统的性能和扩展性可能会受到限制。
这种现象可能出现在各种不同的应用和系统中,例如分布式数据库、Web服务器、消息代理等等。

I/O 多路复用是解决提高服务端吞吐量的技术之一。
从 Java 8 开始,NIO 中的 Selector 接口提供了对 I/O 多路复用的支持,主要用于解决非阻塞 I/O 中进程空转问题。
更多关于 I/O 多路复用的介绍,参考后面的相关知识扩展章节。

除了 Selector 外,NIO 还提供了 Channel 接口,特别是与 Socket 相关的 SocketChannel 和 ServerSocketChannel 实现。
另外,Buffer 也是 Java 8 NIO 提供的另外一个常用接口。

01.1-使用 Selector 实现 TcpEchoServer

在《Socket 网络编程:从基础到实践的全面解析(下)》 我介绍了如何使用 ServerSocket 创建一个 TcpEchoServer。
接下来,我将介绍如何使用 Selector 提供的 I/O 多路复用技术,重新实现 TcpEchoServer。

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress(port));
System.out.println("Server listening on " + port);

Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

while (selector.select() > 0) {
    Iterator iterator = selector.selectedKeys().iterator();
    while (iterator.hasNext()) {
        SelectionKey key = iterator.next();
        iterator.remove();

        if (key.isAcceptable()) {
            // 处理客户端的连接请求
            ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
            SocketChannel accepted = ssc.accept();
            accepted.configureBlocking(false);

            accepted.register(key.selector(), SelectionKey.OP_READ);
        } else if (key.isReadable()) {
            SocketChannel sc = (SocketChannel) key.channel();
            // read client message
            ByteBuffer allocated = ByteBuffer.allocate(1024);
            int read = sc.read(allocated);
            if (read == -1) {
                // 说明 client socket 已关闭
                System.out.println("Client " + sc.getRemoteAddress() + " disconnect");
                sc.close();
                break;
            }

            System.out.println(sc.getLocalAddress() + " received message: " + new String(allocated.array(), StandardCharsets.UTF_8));

            // echo, write back
            allocated.flip();
            sc.write(allocated);
            allocated.clear();
        }
    }
}

02-从进程角度看,Socket 到底是个什么?

new Socket() 使用的底层系统调用是 int socket(int domain, int type, int protocol); 1。
当系统调用 socket 成功时,返回的是一个文件描述符(fd);失败时,返回 -1。
在操作系统中,内核通过进程描述符(有些地方也称作进程控制块,PCB)来管理进程。
进程描述符在内核中通过数据结构 task_struct 表示。
task_struct 结构中,files 字段指向 files_struct 结构(表示系统打开文件表)。
files_struct 结构中,fd 字段指向当前进程打开的文件描述符列表。其中 0 表示标准输入,1 表示标准输出,2 表示标准错误输出。
socket 系统调用成功时返回的整型值,是 fd 列表中对应的文件描述符(这就将套接字与文件系统 VFS 关联起来)。

img_vfs_socket.png

socketfs 是套接字的文件系统,是 VFS 中的一个特殊类型文件系统。

在 Linux 中,可以通过 ps -aux 查看进程的 pid。
获得 pid 后,可以查看 /proc/pid/fd来确定文件当前打开的文件描述符列表。或者,也可以通过lsof−p{pid}/fd 来确定文件当前打开的文件描述符列表。
或者,也可以通过 lsof -p pid/fd来确定文件当前打开的文件描述符列表。或者,也可以通过lsof−p{pid} 查看进程所有打开的文件信息。

samson@localhost:~$ ll /proc/10/fd/
total 0
dr-x------ 2 samson samson 0 Sep 28 15:47 ./
dr-xr-xr-x 7 samson samson 0 Sep 28 15:47 ../
lrwx------ 1 samson samson 0 Sep 28 15:47 0 -> /dev/tty1
lrwx------ 1 samson samson 0 Sep 28 15:47 1 -> /dev/tty1
lrwx------ 1 samson samson 0 Sep 28 15:47 2 -> /dev/tty1
lrwx------ 1 samson samson 0 Sep 28 15:47 255 -> /dev/tty1

samson@localhost:~$ lsof -p 10
COMMAND PID   USER   FD   TYPE DEVICE    SIZE             NODE NAME
bash     10 samson  cwd    DIR    0,2    4096  844424930234815 /home/samson
bash     10 samson  rtd    DIR    0,2    4096  562949953522524 /
bash     10 samson  txt    REG    0,2 1113504  562949953522536 /bin/bash
bash     10 samson  mem    REG    0,0                   132539 /lib/x86_64-linux-gnu/libnss_files-2.27.so (path dev=0,2, inode=562949953553851)
bash     10 samson    2u   CHR    4,1         7036874418117270 /dev/tty1

strace -ff -o out java BIOServer # 查看进程的线程对内核进行了那些调用

02.1-通过写文件的方式进行网络 I/O

Bash 2.04+ 版本以后,默认支持 --enable-net-redirections,即允许 shell 通过重定向方式建立 socket 连接。
这就是 Bash 提供的 net-redirection 功能。

samson@localhost:~$ exec 3 /dev/tcp/www.baidu.com/80
samson@localhost:~$ echo -e "GET / HTTP/1.0n" >&3
samson@localhost:~$ cat  设备控制器,然后进行校验确保未发生错误。
  • 设备控制器产生中断。
  • 操作系统从设备控制器的寄存器中,读取数据到内存。
  • DMA(直接存储器访问,Direct Memory Access)如何读取数据?

  • CPU 对 DMA 控制器编程,因此 DMA 控制器知道将数据传输到什么地方。
  • DMA 对设备控制器(例如磁盘控制器)发出命令,将设备中的数据 -> 设备控制器中,并校验以确保无错误。
  • DMA 在总线上发送读命令,将设备控制器中的数据传输到内存中。(这里设备控制器不关心读信号来自 CPU 还是 DMA,对它来说都一样)
  • 所有内容写到内存后,设备控制器通过总线应答 DMA,通知其数据传输完成。DMA 增加要使用的内存地址,较少字节数,如果此时字节数仍大于0,则重复2-3中的步骤。
  • 当字节数为0时,DMA 产生中断。
  • DMA与零拷贝技术[1]

    DMA 的优势在于,在设备控制器将数据传输到内核空间(内存)这段过程中,CPU 可以不用参与。 但是,I/O 的第三阶段,从内核空间到用户空间的复制,仍需要 CPU 参与。 有了这两节的背景知识后,我们来看下 Unix 中的5种 I/O 模型。

    03.3-5种 I/O 模型

    Unix 操作系统下支持5种不同的 I/O 模型:

  • 第一种,阻塞式 I/O 模型。 在该模型下,应用进行 I/O 的第二、第三步骤,应用进程是阻塞的。
  • 第二种,非阻塞式 I/O 模型。 在该模型下,应用进行 I/O 的第二步骤时,应用进程并不是阻塞的,而是轮询的方式来判断应用数据是否已拷贝到内核空间。 第三步骤时,应用进程仍然是阻塞的。
  • 第三中,I/O 复用模型。(下面章节中详细介绍) 在此模型下,应用通过 select / poll 同时监控多个 I/O 设备的读写状态。 应用并不会阻塞在 I/O 的第二步骤上,但却会阻塞在 select / poll 的调用上,直到应用感兴趣的 I/O 设备准备就绪(即数据已拷贝到内核空间)。 第三步骤时,应用进程仍然是阻塞的。 和第一种模型相比,I/O 复用并不显得有什么优势,甚至稍有劣势。不过,I/O 复用的优势在于可以同时监控多个 I/O 设备,从而减轻操作系统的负担。
  • 第四种,信号驱动式 I/O 模型。 在此模型下,I/O 的第二步中也不会阻塞应用进程。 应用进程通过 sigaction 系统调用,向操作系统安装一个信号及信号处理函数。 当数据拷贝到内核空间后,内核会为应用产生一个信号。 第三步骤时,应用进程仍然是阻塞的。
  • 第五种,异步 I/O 模型。 此模型是真正意义上的异步,I/O 过程中的第二、第三步骤都不会阻塞应用。 只不过,目前 linux 对异步 I/O 的支持并不完美。
  • 上述五种 I/O 模型的对比如下图所示:

    io_models.png

    03.4-I/O 复用(Multiplexing)

    在阻塞式 I/O 模型中,操作系统将数据从设备控制器读取到内核空间,再从内核空间复制到用户空间。 发起 I/O 请求的应用进程被阻塞,直至数据被完全复制到用户空间。 在高并发场景时,会有大量的线程因等待 I/O 完成而被阻塞,浪费 CPU 资源。 而且,大量线程会导致频繁地上下文切换,造成额外的性能开销。

    在非阻塞式 I/O 模型中,操作系统将数据从设备控制器读取到内核空间的过程中,发起 I/O 请求的进程是不会阻塞的,而是会通过轮询(poll)方式判断是否完成。 在高并发场景时,大量的线程不断轮询,会造成 CPU 空转,从而浪费硬件资源。

    为解决上述两种模型的不足,I/O 多路复用模型被设计出来。 在 I/O 多路复用模型中,应用进程通过 selcet 或 poll 这两个函数调用来同时监控多个文件的状态,一旦其状态变得可读则立即通知对应的应用进程。 从而减少不必要的轮询、上下文切换等开销。在高并发场景,I/O 多路复用是提高服务端吞吐量的重要手段之一。

    select() 的接口定义如下[1]:

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

    其中:

    • readfds,是一个文件描述符集合(fd),select 负责监控这些 fd 是否可读。
    • writefds,是一个文件描述符集合(fd),select 负责监控这些 fd 是否可写。
    • exceptfds,是一个文件描述符集合(fd),select 负责监控这些 fd 是否发生异常状况。
    • nfds,是一个整型值,应当设置为 readfds/writefds/exceptfds 集合中的最大 fd + 1。
      select() 实际上会检查在 [0, nfds) 范围中的 fd 是否可读、可写、有异常。
    • timeout,是一个 timeval 结构体,表示 select() 由于等待 fd 就绪而阻塞的最长时间。

    select() 方式有个明显的缺点,就是可以同时监控的 fd 的熟练受限,最大是 1024。针对并发特别高的场景,仍不适用。

    poll() 的接口定义如下[2]:

    int poll(struct pollfd fds[], nfds_t nfds, int timeout);
    // pollfd
    struct pollfd {
        int fd;
        short events;
        short revents;
    };
    

    其中:

    • fds,是一个 pollfd 结构体数组,其中的 fd 是关心的文件描述符,events 是感兴趣的事件,例如可读、可写等,revents 是发生在 fd 上的事件。
    • nfds,是数组的长度,
    • timeout,单位是毫秒,与 select 中的 timeout 作用类似。

    epoll api[3] 与 poll 类似,可以同时监控一组文件描述符,并在文件描述符可用是通知应用进程。
    只不过 epoll 并不是一个系统调用,而是一组系统调用的组合。
    epoll 的核心是 epoll 实例,是内核空间的一个数据结构。
    不过,从用户空间的角度看,这个数据接口可以看作是一个容器,包含两个列表:

    • 关注列表,是一组 fd,由应用程序注册在 epoll 实例上,epoll 负责监控它们的状态。
    • 就绪列表,是一组 fd,表示可以进行 I/O 的文件,是关注列表的子集。

    epoll api 由下述系统调用组成:

    • epoll_create,int epoll_create(int size);,负责在内核空间创建一个 epoll 实例。size 只是一个提示信息,从 linux 2.6.8 开始,这个参数就被忽略了。
    • epoll_ctl,int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event),负责向关注列表中添加、修改、删除 fd;
      epfd 是 epoll 实例,就是 epoll_crate 返回的值;
      op 是 EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DEL 中的一个,表示添加、修改、删除;
      fd 是要添加到关注列表的 fd;
      event 包含 events 和 data,其中的 data 在 fd 就绪时返回。
    • epoll_wait,它的功能与 select() 或 poll() 类似,会将当前应用阻塞在 epoll_wait() 上,直到 I/O 就绪或被中断或超时。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论