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

Socket 网络编程:从基础到实践的全面解析(下)基本套接字编程仅适用于小规模的系统,如果有大量的 client 同时访问 server,即使 server 端使用多线程技术,每个线程服务一个 client,仍然无法扩展到同时支持大量用户。

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<SelectionKey> 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/
p i d / f d 来确定文件当前打开的文件描述符列表。或者,也可以通过 l s o f 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.0\n" >&3
samson@localhost:~$ cat <&3
HTTP/1.0 200 OK
Accept-Ranges: bytes
Cache-Control: no-cache
Content-Length: 9508
Content-Type: text/html
Date: Thu, 28 Sep 2023 07:52:07 GMT
P3p: CP=" OTI DSP COR IVA OUR IND COM "
P3p: CP=" OTI DSP COR IVA OUR IND COM "
Pragma: no-cache
Server: BWS/1.1

在 Bash 的实现中,/dev/tcp/xxxx/xx 是一个具有特殊含义的字符串,它会创建一个到指定 ip(域名或 ip 地址)+ port 的 socket 连接。 exec 3<> 表示将文件描述符(fd) 3作为创建的 socket fd,语义类似 socket() 系统调用返回值为3。 建立 TCP 连接后,可以向对端发送 HTTP 协议(以写文件的方式),然后获取对端的响应(以读文件的方式)。

1. Linux内核 | socket底层的来龙去脉 2. 走进Linux内核网络 套接字的秘密—socket与sock 3. 网络数据包收发流程(三):e1000网卡和DMA 4. 轻松看懂网络栈主要结构介绍(socket、sock、sk_buff,etc)

03-相关知识扩展

03.1-操作系统 I/O

计算机操作系统中的 I/O,指的是对 I/O 设备的读写。 I/O 设备一般分为两类:块设备和字符设备。 其中,磁盘是最典型的块设备,鼠标、键盘等是典型的字符设备,网络接口也被视为是字符设备。 操作系统中的文件系统,仅控制抽象的 I/O 设备,而把与设备相关的部分留给底层软件(即设备驱动程序)去处理。 与 I/O 设备相关的还有一个称为设备控制器的电子设备,负责将 I/O 设备的比特流转换为字节块(读取)或将字节块转换为比特流(写入)。 设备控制器控制着一组与 CPU 通讯的寄存器,有些设备还包括一个供操作系统读写的数据缓冲区。 操作系统或 CPU 可以通过这些寄存器了解 I/O 设备的工作状态、是否准备好接受新的命令等,以及通过缓冲区进行数据的读写。

通过前面的描述,应用读取 I/O 设备中的数据分为三个阶段:

  1. I/O 设备中的数据在设备控制器的控制下,先复制到数据缓冲区。
  2. 操作系统将数据缓冲区的内容拷贝到内核空间中。
  3. 操作系统将内核空间中的数据拷贝到应用空间中。

在第二步时,CPU 需要请求设备控制器,读取数据到内核空间中。 这是十分浪费 CPU 时间,而且效率比较低。 为了解决这个问题,出现了所谓的直接内存访问(DMA)技术。

03.2-直接内存存取(DMA)

在没有 DMA 之前是如何读取数据?

  1. 设备中的数据 -> 设备控制器,然后进行校验确保未发生错误。
  2. 设备控制器产生中断。
  3. 操作系统从设备控制器的寄存器中,读取数据到内存。

DMA(直接存储器访问,Direct Memory Access)如何读取数据?

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

DMA与零拷贝技术[1]

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

03.3-5种 I/O 模型

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

  1. 第一种,阻塞式 I/O 模型。 在该模型下,应用进行 I/O 的第二、第三步骤,应用进程是阻塞的。
  2. 第二种,非阻塞式 I/O 模型。 在该模型下,应用进行 I/O 的第二步骤时,应用进程并不是阻塞的,而是轮询的方式来判断应用数据是否已拷贝到内核空间。 第三步骤时,应用进程仍然是阻塞的。
  3. 第三中,I/O 复用模型。(下面章节中详细介绍) 在此模型下,应用通过 select / poll 同时监控多个 I/O 设备的读写状态。 应用并不会阻塞在 I/O 的第二步骤上,但却会阻塞在 select / poll 的调用上,直到应用感兴趣的 I/O 设备准备就绪(即数据已拷贝到内核空间)。 第三步骤时,应用进程仍然是阻塞的。 和第一种模型相比,I/O 复用并不显得有什么优势,甚至稍有劣势。不过,I/O 复用的优势在于可以同时监控多个 I/O 设备,从而减轻操作系统的负担。
  4. 第四种,信号驱动式 I/O 模型。 在此模型下,I/O 的第二步中也不会阻塞应用进程。 应用进程通过 sigaction 系统调用,向操作系统安装一个信号及信号处理函数。 当数据拷贝到内核空间后,内核会为应用产生一个信号。 第三步骤时,应用进程仍然是阻塞的。
  5. 第五种,异步 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 就绪或被中断或超时。

今天的文章Socket 网络编程:从基础到实践的全面解析(下)分享到此就结束了,感谢您的阅读。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/59614.html

(0)
编程小号编程小号

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注