概述

本文目标:完成从客户端建立连接到应用程序使用内核数据的完整流程原理剖析。
本文描述网络中TCP建立连接,以及数据传输到服务器内核的核心流程。

流程图

63fc50739be6c.png

TCP连接和Socket创建

客户端需要和服务器建立通信的时候,客户端会做两件事:
1、调用socket() 创建客户端的socket。
2、调用connect() 开始和服务器进行握手。
服务器会通过调用accept() 创建服务端的socket并和客户端通信。

三次握手的过程:

  • 第一次,握手的时候会发送SYN报文其中包含一个随机的初始序列号x。
  • 第二次,服务端收到报文后会同时发送SYN报文和ACK报文,其中SYN报文包含了服务端的初始序列号y,ACK是在客户端的序列号基础上+1,这样客户端就知道了服务端的序列号和窗口大小等信息。
  • 第三次,客户端发送ACK报文,报文中包含了客户端交互的序列号x+1的确认序号和服务端的序列号y+1,到这里服务端和客户端就协商好了数据发送的窗口大小。
SNY报文内容:
源端口号和目的端口号:分别指示源主机和目的主机的端口号。
序列号:表示 TCP 报文段所携带数据的第一个字节在数据流中的序列号,用于序列号的同步和无序分段的重组。
SYN 标志位:用于表示该报文是 SYN 报文。
窗口大小:用于告诉对方自己的接收窗口大小,控制发送方的发送速度。
其他选项字段:如 MSS(最大报文段长度)等。

ACK报文内容:
源端口号和目的端口号:分别指示源主机和目的主机的端口号。
序列号:表示 TCP 报文段所携带数据的第一个字节在数据流中的序列号。
确认号:表示对方已经收到的数据字节数,也就是期望收到的下一个字节的序号。
ACK 标志位:用于表示该报文是 ACK 报文。
窗口大小:用于告诉对方自己的接收窗口大小。
其他选项字段:如 MSS 等。

为什么每一次握手都必不可少?
根据上面的三次握手交换的数据,第一次是通知服务端,服务端只能知道客户端的初始序号信息,还不能保证连接可靠。第二次,客户端才知道服务端的基本信息,还处于服务器和客户端协商阶段,也不能保证连接可靠。第三次,客户端根据服务端的信息(窗口大小、序列号等)按照服务端的要求告诉服务端确认完成。只有这样才能保证消息不重复等问题。

三次握手建立后,服务端和客户端都有一个socket用于后续继续交换数据,socket本质上就是一个文件描述符,后续操作文件描述符就能读取和写入数据。

网络模型

服务端会根据不同的网络模型,在内核有不同的处理方式:BIO、NIO、多路复用等。

BIO(Blocking I/O)

BIO是同步阻塞模型。在进行网络通信的时候会阻塞直到数据传输完成或者出现错误。BIO 的实现方式是通过系统调用 read()write() 进行 I/O 操作。

当客户端发送数据时,服务器的 socket 会接收到数据并进行相应的处理,然后发送响应给客户端。当服务端调用read()的时候如果数据没有准备好,会一直阻塞直到客户端数据发送完成。

在阻塞网络模型下,如果要实现并发接收和处理IO就需要通过线程把IO处理放到其他线程中,一个客户端连接至少需要消耗一个服务器线程来处理,如果同时连接的客户端过多就会导致服务器的线程很多,这时候服务器的cpu切换、内存等等开销就是非常大的,性能很低。

NIO(Non-blocking I/O)

NIO是同步非阻塞IO,可以在一个线程中处理所有客户端连接,而不用像BIO一样一个客户端连接一个线程。

NIO有很多实现方式:异步IO(AIO,java中的NIO框架是支持AIO的)、多路复用(select、poll、epoll)。Linux 中的 AIO 通常是通过线程池等方式来模拟实现的,而且常常使用 libaio 库来进行操作。而 epoll 主要是基于多路复用的方式来提高 IO 操作的效率,可以实现非阻塞 IO。select、poll都是很老的多路复用实现,现在最好的方案是epoll,在redis、rocketmq、netty等网络IO场景下面都有体现。

select 和 poll的实现方式大致一样:

1. 设置要监听的文件描述符集合,把需要监听的文件描述符添加到集合中。
2. 调用select或poll函数,等待有文件描述符状态发生变化。这里可以设置等待超时或者直接返回数据不全,等待下一次循环检查,这样可以不用一直阻塞。
3. 内核检查所有集合中的文件描述符状态,若有符合条件的文件描述符,则返回状态已经改变的文件描述符。
4. 处理返回的文件描述符,进行相应的读取、写入、错误处理等操作。
5. 再次添加需要监听的文件描述符到集合中,循环执行上述步骤。

select和poll的区别:

1. fds的传递方式不同:select将文件描述符集合以参数的形式传递给内核,每次调用都需要重新传递,而poll则将文件描述符集合存储在pollfd结构体数组中,每次调用时直接传递该数组的指针。
2. select对于每个文件描述符都需要线性扫描,每一次都需要把fds从用户空间拷贝到内核空间;而poll使用链表来存储文件描述符,每次调用时只需要在内核遍历链表即可。
3. select支持的文件描述符数量有限(通常是1024),而poll没有数量限制。
4. select有一个缺陷是当有新的文件描述符加入集合时,需要重新调用select函数更新文件描述符集合,而poll不需要。

简单的来说select在内核中没有保存fds,所以每一次要拷贝到内核空间,每次循环的时候事需要遍历全量的fds,fds的数量默认是1024,可以通过修改linux内核参数提高;poll使用的时候用户是直接传递指针所以没有从用户空间往内核空间拷贝的过程,由于poll在内核中是用pollfd的链表存储的,所以没有1024的限制。

epoll

epoll相对比较重要,就单独拿出来。epoll是多路复用的一个实现方式,并且也是同步非阻塞的。

epoll有三个系统函数:epoll_create、epoll_ctl、epoll_wait。
epoll_create:
应用程序调用epoll_create的时候会在内核创建两个数据结构保存文件描述符号(fds)和事件。其中链表用于保存fd,而红黑树用于保存fd对应的事件,key是fd,value是fd对应的事件。
epoll_ctl:
应用程序可以调用epoll_ctl来注册fd,当注册的时候就会向链表和红黑树中分别插入一条记录,用来表示fd已经注册到内核中。
epoll_wait:
应用程序调用epoll_wait来监听fd的事件,就再也不用每次遍历fd了,性能极大的提升。当内核把fd的数据准备好了后,会向用户提供的缓冲区文件事件列表中放入fd,通知应用程序。当有新事件的时候就会把fd标记成红色,如果一个文件描述符在前一次epoll_wait调用时已经就绪,那么它在红黑树中的节点将保持黑色,表示它是一个旧事件。
注意:

1. 边缘触发(ET)模式。当一个fd上的事件被触发后,epoll_wait只会返回一次该事件,直到下一次有新的事件发生。这要求应用程序必须及时处理事件,否则可能会错过事件。
2. 支持大量的并发连接。由于使用红黑树结构,epoll可以处理大量的并发连接,不会因为文件描述符数量的增加而导致性能下降。

在调用epoll_wait函数后,如果返回了需要处理的事件,那么应用程序需要遍历事件列表并对每个事件进行处理。对于每个事件,应用程序可以使用recv或send等系统调用从socket中读取或写入数据。

需要注意的是,在使用recv或send函数时,通常需要多次调用,直到处理完所有的数据。因为一次recv或send函数调用可能无法读取或写入所有的数据,此时需要再次调用函数继续处理剩余的数据。

如果使用了非阻塞I/O,那么在读写数据时需要注意使用非阻塞模式的recv或send函数,并在函数返回EAGAIN错误时等待下一个事件再继续读写。

异步IO(Asynchronous I/O)

在异步I/O模型中,当进行I/O操作时,线程会直接返回,并注册一个回调函数等待数据准备好后自动被调用。这种方式与多路复用的主要区别在于它是基于事件驱动的,通过回调函数来处理数据,可以更加灵活高效地处理大量的I/O操作。

在 Linux 中,异步 I/O 是通过 aio 系列函数实现的,这些函数被称为异步 I/O 操作函数,如 aio_read,aio_write 等。异步 I/O 的实现原理基于操作系统提供的异步 I/O 机制,Linux 中采用了 POSIX AIO 标准,它通过线程池和信号驱动两种方式来实现异步 I/O。当调用 aio 系列函数时,内核会将请求加入到内核的 I/O 请求队列中,并注册一个回调函数。然后,内核会在数据准备好时,通过信号或者线程池调用注册的回调函数来通知应用程序数据已经准备好了。在回调函数中,应用程序可以从内核的缓冲区中读取或者写入数据。异步 I/O 在网络编程中应用广泛,可以通过异步 I/O 的方式,将 I/O 的等待时间从线程中抽离出来,使得线程可以去做其他的任务,从而提高了系统的并发性能和可靠性。同时,异步 I/O 在文件 I/O 中也有着很好的表现,可以用于处理大量的小文件读写场景,可以减少 I/O 等待的时间,提高磁盘的利用率和系统的吞吐量。

在Windows系统中,异步I/O通常使用Overlapped I/O和I/O Completion Routine(IOCP)实现。这些机制可以使用Windows提供的API来调用,如ReadFileEx、WriteFileEx、WSARecv、WSASend等。在Windows系统中,还有一种高性能的I/O机制叫做Completion Port,可以在处理大量I/O操作时提供更好的性能。

信号驱动IO(Signal-driven I/O)

信号驱动 I/O(Signal-driven I/O)是一种基于信号机制的 I/O 模型,它使用信号来通知应用程序有 I/O 事件已经发生,从而可以在不阻塞进程的情况下处理 I/O。

在信号驱动 I/O 模型中,应用程序首先调用 fcntl() 函数来将文件描述符设置为非阻塞模式,然后调用 sigaction() 函数注册一个信号处理函数,来处理特定的信号(如 SIGIO)。当系统检测到文件描述符上的 I/O 事件时,它会向进程发送一个信号,告诉它有 I/O 事件已经发生。接着,进程的信号处理函数被调用,函数中可以调用 read() 或 write() 等函数进行实际的 I/O 操作。

由于信号驱动 I/O 使用了信号机制,所以在高负载情况下会有一定的开销。此外,由于一个进程只能同时使用一个信号,因此在同时处理多个文件描述符的情况下,必须为每个文件描述符分配一个单独的进程,这也会增加系统开销。因此,在大多数情况下,多路复用和异步 I/O 是更为常见和高效的 I/O 模型。

概述

通过docker的方式快速的使用内网穿透工具frp。客户端和服务端的镜像都已经封装好了可以直接使用。当前版本是官网的0.44.0版本。
如果不需要镜像制作,可以直接通过快速开始直接使用。最后也会补上如何制作镜像的dockerfile。

快速开始

服务端部署

服务端需要部署到一台公网服务器上。

1)准备配置文件docker-compose.yml
docker-compose.yml

version: '3'
services:
  frp:
    image: ${image}
    container_name: frp
    restart: always
    # network_mode: host
    ports:
      - 7000:7000
      - 7500:7500
      - 4000-5000:4000:5000
    volumes:
      - ${docker_volume_directory:-.}/frp/server/frps.ini:/app/frp/frps.ini
    command: ./frps -c /app/frp/frps.ini
  • 7000:客户端连接服务器的端口
  • 7500:服务器的dashboard端口
  • 4000-5000:可以使用的内网穿透端口。这里最好使用 network_mode: host的网络模式,原因是大批量端口在docker中会消耗过多的资源如果使用主机映射的网络模式则会降低内存使用,特别是服务器内存不够的情况下。

2)准备环境变量文件.env
.env

image=registry.cn-hangzhou.aliyuncs.com/yangqiang-pub/frp-server:0.44.0
docker_volume_directory=/data/docker-data
  • image:已经封装好的镜像。
  • docker_volume_directory:数据文件目录,包括配置。

3) 准备配置文件frp/server/frps.ini
frp/server/frps.ini

在.env的统计目录下面新建文件frp/server/frps.ini

[common]
bind_addr = 0.0.0.0
bind_port = 7000

dashboard_addr = 0.0.0.0
dashboard_port = 7500
dashboard_user = admin
dashboard_pwd = dashboard的密码

#log_file = /data/docker-data/frp/log/frps.log
#log_level = debug
#log_max_days = 3

authentication_method = token
token = 客户端连接服务端的密码

注意上面两个密码需要填写自己的密码

4)启动服务端

docker compose up -d
  • 可以通过docker logs -f --tail 100 frp查看日志

客户端连接

在你需要内网穿透的电脑上,一般是自己本地的电脑或者本地服务器部署客户端。

1) 准备docker-compose.yml
docker-compose.yml

version: '3'
services:
  frp-client:
    image: ${image}
    container_name: frp-client
    restart: always
    ports:
      - 7400:7400
    volumes:
      - ${docker_volume_directory:-.}/frp/client/frpc.ini:/app/frp/frpc.ini
    command: ./frpc -c /app/frp/frpc.ini
    healthcheck:
      test: ./frpc status || exit 1 
      interval: 5s
      timeout: 3s
      retries: 3
      start_period: 1s

2) 准备环境变量文件.env
.env

image=registry.cn-hangzhou.aliyuncs.com/yangqiang-pub/frp-client:0.44.0
docker_volume_directory=/root/docker-data

3) 准备配置文件
frp/client/frpc.ini

[common]
token = 服务端的token密码
server_addr = 服务端ip
server_port = 7000

admin_addr = 0.0.0.0
admin_port = 7400
admin_user = admin
admin_pwd = 客户端管理密码,连接上管理端后可以新增穿透配置

# 配置客户端管理端穿透出去访问服务端ip:4000就可以访问
[dashboard]
type = tcp
local_ip = 本地ip
local_port = 7400
remote_port = 4000

# 配置一个ssh的内网穿透通过服务端ip:4022可以访问
[ssh]
type = tcp
local_ip = 本地或者局域网ip
local_port = 22
remote_port = 4022

4) 启动客户端
docker compose up -d

镜像制作

服务端

1) 准备dockerfile

Dockerfile

FROM alpine:latest

WORKDIR /tmp/

RUN cd /tmp/ && \
    wget https://github.com/fatedier/frp/releases/download/v0.44.0/frp_0.44.0_linux_amd64.tar.gz && \
    tar -xvf /tmp/frp_0.44.0_linux_amd64.tar.gz && \
    mkdir -p /app && \
    mv /tmp/frp_0.44.0_linux_amd64 /app/frp && \
    rm -rf /tmp/frp_0.44.0_linux_amd64.tar.gz

WORKDIR /app/frp

CMD ["./frps","-c","/app/frp/frps.ini"]

2)构建
docker build -t registry.cn-hangzhou.aliyuncs.com/yangqiang-pub/frp-server:0.44.0 .

客户端

dockerfile

FROM alpine:latest

WORKDIR /tmp/

RUN cd /tmp/ && \
    wget https://github.com/fatedier/frp/releases/download/v0.44.0/frp_0.44.0_linux_amd64.tar.gz && \
    tar -xvf /tmp/frp_0.44.0_linux_amd64.tar.gz && \
    mkdir -p /app && \
    mv /tmp/frp_0.44.0_linux_amd64 /app/frp && \
    rm -rf /tmp/frp_0.44.0_linux_amd64.tar.gz

WORKDIR /app/frp

CMD ["./frpc","-c","/app/frp/frpc.ini"]