讲明白socket和io多路复用

进程间的通信方式

讲socket之前先讲一下进程间的通信方式,我们都知道大概有以下几种

  • 管道
  • 消息队列
  • 共享内存
  • 信号量
  • 信号
  • socket

可以看到有socket的字眼,是的。socket的主要作用就是进程间通信

socket和其他方式不同的是:它不仅可用于本机间的进程通信,还可以跨主机进程通信,可以看出socket是非常重要的一种通信方式了。

Linux的理念就是一切皆文件,其实socket管道本质都是文件,进程之间信息的传递方式类似为读写文件时read()和write()。

socket

接下来我们正式介绍socket,好多解释是套接字,但是很抽象,根本不知道是什么意思。

Socket 的中文名叫作插口,咋一看还挺迷惑的。事实上,双方要进行网络通信前,各自得创建一个 Socket这相当于客户端和服务器都开了一个“口子”,双方读取和发送数据的时候,都通过这个“口子”。这样一看,是不是觉得很像弄了一根网线,一头插在客户端,一头插在服务端,然后进行通信。

要想客户端和服务器能在网络中通信,那必须得使用 Socket 编程,它是进程间通信里比较特别的方式,特别之处在于它是可以跨主机间通信,也可以同主机通信。

可能到现在还是不理解什么是socket,那么你肯定知道nginx、mysql、redis、kafka这些中间件服务,几乎所有的服务都是使用Socket来创建的。

是不是恍然大悟,原来我们平时一直在接触socket呀,这些服务启动时通常需要监听一个端口, 那么这个服务就是上面提到的Socket服务端了;而socket 客户端连接这些服务时通常需要连接服务的ip+port的方式来能够连接。socket进行通信需要创建俩个socket,一个是服务端的监听socket,另一个是客户端连接的socket

上面介绍的这种方式就是使用最多的TCP Socket方式了。

一句话:如果俩个不同的服务想要通信,就得使用socket编程来实现。

最基本的socket模型

创建socket

服务端和客户端第一步就是要先创建socket,上文介绍知道了scoket其实就是一个文件,所以创建socket返回的其实就是一个文件描述符文件描述符到底是个什么呢?下文会有介绍。

socket_create(int $domain, int $type, int $protocol): resource|false

domain 参数指定哪个协议用在当前套接字上。

Domain 描述
AF_INET IPv4 网络协议。TCP 和 UDP 都可使用此协议。
AF_INET6 IPv6 网络协议。TCP 和 UDP 都可使用此协议。
AF_UNIX 本地通讯协议。具有高性能和低成本的 IPC(进程间通讯)。

type 参数用于选择套接字使用的类型。

type 描述
SOCK_STREAM 提供一个顺序化的、可靠的、全双工的、基于连接的字节流。支持数据传送流量控制机制。TCP 协议即基于这种字流式套接字。
SOCK_DGRAM 提供数据报文的支持。(无连接,不可靠、固定最大长度).UDP协议即基于这种数据报文套接字。
SOCK_SEQPACKET 提供一个顺序化的、可靠的、全双工的、面向连接的、固定最大长度的数据通信;数据端通过接收每一个数据段来读取整个数据包。
SOCK_RAW 提供读取原始的网络协议。这种特殊的套接字可用于手工构建任意类型的协议。一般使用这个套接字来实现 ICMP 请求(例如 ping)。
SOCK_RDM 提供一个可靠的数据层,但不保证到达顺序。一般的操作系统都未实现此功能。

protocol 参数,是设置指定 domain 套接字下的具体协议。这个值可以使用 getprotobyname() 函数进行读取。如果所需的协议是 TCP 或 UDP,可以直接使用常量 SOL_TCPSOL_UDP

Domain 描述
SOL_TCP 表示TCP协议。TCP(Transmission Control Protocol)是一种面向连接的、可靠的传输协议,它确保数据在通信双方之间按序传递和可靠传输。
SOL_UDP 表示UDP协议。UDP(User Datagram Protocol)是一种无连接的传输协议,它提供了一种不保证数据传输顺序和可靠性的方式。
0 它会根据domain和type的取值来自动选择合适的协议。这种情况下,操作系统会根据套接字的类型和地址域来自动选择默认的协议。通过该参数写入0即可。

绑定socket

服务端创建完socket之后,需要将该socket bind。

socket_bind (Socket $socket, string $address, int $port = 0): bool

第一个参数即为socket_create()函数创建的文件描述符。

第二个参数表示Socket服务的监听地址,如果socket_create函数的domain参数值为AF_INET 或者AF_INET6,那么 address 必须是一个四点分法的 IP 地址(例如 127.0.0.1 )。如果套接字是AF_UNIX 族,那么 address 是 Unix 域套接字的路径(例如 /tmp/my.sock ):

  • 127.0.0.1:表示该服务只运行本地调用,表示该socket服务是一个tcp或者udp socket。
  • 192.168.x.x:表示该服务可在内网之间调用,表示该socket服务是一个tcp或者udp socket。
  • 0.0.0.0:表示该服务允许所有网络调用,表示该socket服务是一个tcp或者udp socket。
  • *.sock:是一个后缀为.sock文件,表示该socket服务是一个unix socket,用于本地服务之间调用。

参数 port 仅仅用于 AF_INET或者AF_INET6套接字连接的时候,并且指定连接中需要监听的端口号;为AF_UNIX时该参数省略。

监听socket

当服务端创建的socket被bind之后,还需要将该socket进行监听。

监听成功之后就代表这个socket服务已经启动成功了。

如果是tcp或者udp类型的socket,可使用netstat -anp| grep port命令查看socket是否启动成功。

如果是unix类型的socket,则查看查看*.sock文件是否存在如果存在则代表该服务启动成功;如果不存在则代表服务没有启动成功。

在Unix域套接字(Unix Domain Socket)的工作原理中,套接字文件通常用于建立服务器与客户端之间的通信。这些套接字文件实际上是文件系统中的特殊文件,用于表示套接字连接的端点。

当您的服务器程序运行时,它会创建一个套接字文件(使用socket_bind),这个文件在指定的路径上。如果在下次运行服务器程序之前没有将该文件删除,就会导致以下问题:

  • 套接字文件已存在: 如果套接字文件已经存在,那么 socket_bind 会失败,因为它要求指定的套接字文件不存在。这将阻止服务器重新绑定到相同的套接字文件路径上。

因此,为了避免这些问题,通常在每次运行服务器程序之前会检查套接字文件是否存在,如果存在则删除它。这样确保了服务器能够重新创建并绑定到套接字文件,而不会受到之前的文件状态的影响。

socket_listen (Socket $socket, int $backlog = 0): bool

第一个参数socket是指socket_create()函数创建的文件描述符。

第二个参数backlog是个很重要的参数,下面详细介绍下:

客户端在创建好 Socket 后,调用 connect() 函数(该函数下面会讲)发起对付服务端的连接,然后万众期待的 TCP 三次握手就开始了。

在 TCP 连接的过程中,服务器的内核实际上为每个 Socket 维护了两个队列

一个是「还没完全建立」连接的队列,称为 TCP 半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于 syn_rcvd 的状态;
一个是「已经建立」连接的队列,称为TCP 全连接队列,这个队列都是完成了三次握手的连接,此时服务端处于 established 状态;

backlog参数值就是指TCP 全连接队列的长度,但是上限值是内核参数 somaxconn 的大小,也就说 TCP 全连接队列 = min(backlog, somaxconn)

当客户端socket连接成功服务端的socket之后,TCP全连接中的队列中就会增加一条连接,当TCP全连接中的队列长度超过限制之后,客户端再连接服务端时就会提示连接被拒绝的情况,这通常表现为"连接被拒绝"的错误,常见的错误码是 "ECONNREFUSED",所以backlog的值大小很重要,不能设置太小。

接收socket连接

服务端socket在listen之后就表示服务已经启动成功了,也就代表着可以接受客户端的请求了。

上面介绍了,客户端连接成功之后会将连接放置到一个TCP 全连接队列中,而服务端的socket_accept()函数正是从TCP 全连接队列获取一个链接。

如果socket没有设置异步非阻塞的情况下,socket_accept()函数是阻塞的,也就是说如果TCP 全连接队列中没有连接,服务端会一直阻塞在这里。

socket_accept (Socket $socket): Socket|false

socket参数是服务端socket_create()函数返回的文件描述符。

连接socket连接

当服务端的socket创建成功listen之后,其实客户端就可以通过以下函数连接服务端了。

这里需要说明一下,客户端通过socket_create()创建的socket和服务端通过socket_create()创建的socket类型需要保持一致,只有一致的情况下才能进行通信;也就是服务端如果创建的是TCP socket,那么客户端也需要创建的是TCP socket;如果服务端如果创建的是Unix socket,那么客户端也需要创建的是Unix socket

socket_connect (Socket $socket, string $address, ?int $port = 0): bool

第一个参数socket是客户端通过socket_create()函数返回的文件描述符

第二个参数表示Socket服务端的监听地址,如果服务端socket_create函数的domain参数值为AF_INET 或者AF_INET6,那么 address参数值就是服务端socket_bind()时的address地址,第三个参数也是socket_bind()时的port端口。
如果服务端socket_create函数的domain参数值为AF_UNIX 族,那么 address 是 Unix 域套接字的路径需要特别注意的是,这个.sock文件需要和服务端sock_bind()时的.sock文件保持一致,也就是相同的文件

如果socket_connect()返回true,就代表客户端连接成功,该文件描述符就会被放到TCP 全连接队列中,而服务端就可以通过socket_accept()函数获取该文件描述符,服务端和客户端就可以通过这个文件描述符进行通信了。

客户端可以对该文件描述符进行write()数据而服务端可以使用该文件描述符进行read()读取到客户端写入的数据;

或者是服务端对该文件描述符进行write()数据而客户端也同样可以read()读取到服务端写入的数据。

客户端和服务端通过使用相同的文件描述符进行read()write()操作就实现了信息的交换和通信,这就是socket客户端和服务端的通信机制了。

面试的时候经常会被问到服务端没有 accept之前能客户端能建立 TCP 连接吗?

通过上面的介绍,肯定是可以的啊,因为服务端的socket_accept只是从TCP全连接队列中获取一个链接,而客户端的socket_connection是往TCP全连接队列写入一个链接,俩者是串行的不发生冲突的。

读取socket消息和写入socket消息

当客户端和服务端链接成功之后,就可以使用相同的文件描述符进行数据写入和数据读取了。

socket_write (Socket $socket, string $data, ?int $length = 0): int|false 

socket_read (Socket $socket, int $length, int $mode = PHP_BINARY_READ): string|false

到底文件描述符是什么?

上面提到了,服务端和客户端如果想到通信双方都需要创建一个socket,而socket_create返回的是一个文件描述符,而看socket_create返回的是一个int类型的,也就是说文件描述符其实是一个int型数字。

每一个进程都有一个数据结构 task_struct,该结构体里有一个文件描述符数组数组。该数组里列出这个进程打开的所有文件的文件描述符。数组的下标是文件描述符,是一个整数,而数组的内容是一个指针,指向进程用户空间内的文件地址,该文件地址就是简称的虚拟空间中的匿名映射和文件映射端

在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目;

这下肯定理解了什么是文件描述符了吧,其实就是一个数组下标

socket的种类

socket_create函数创建时可以发现有TCPUDPUNIX3种类型。

TCP和UDP类型的socket已经很熟悉了,服务端socket bind时需要指定一个32位的ip地址和16位的port,同样客户端的socket在connect时也需要指定一个和服务端相同的ip和port。

TCP和UDP类型的socket不仅可以跨主机间进程的通信而且也可以同一主机上通信。

UNIX类型比较特别,UNIX类型的socket只适用用同一主机上的进程进行通信,服务端socket bind时是指定一个.socket文件,而客户端socket connect时也需要指定一个.sock文件,服务端和客户端指定的.socket文件必须是同一个文件

UNIX SOCKET每次重启的时需要确保.socket文件已经被删除,服务起来之后.socket文件被创建。

最基本的socket模型

上面介绍了一些基本的socket的函数,下面这张图是最基本的客户端服务端socket模型。


下面这张图表示的是服务端和客户端信息传递过程的整个过程。

该Socket 调用流程是最简单、最基本的,它基本只能一对一通信,因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 I/O 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。

可如果我们服务器只能服务一个客户,那这样就太浪费资源了,于是我们要改进这个网络 I/O 模型,以支持更多的客户端。

不知道读和写操作的具体流程可查看零拷贝文章,里面有详细介绍读和写的整个过程,以及哪个步骤会发送阻塞,这里不在赘述。

如何服务更多的用户

在改进网络 I/O 模型前,我先来提一个问题,你知道服务器单机理论最大能连接多少个客户端socket吗?

相信你知道 TCP 连接是由四元组唯一确认的,这个四元组就是:本机IP, 本机端口, 对端IP, 对端端口

服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的,所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数

对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数约为 2 的 48 次方

这个理论值相当“丰满”,但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制:

  • 文件描述符,Socket 实际上是一个文件,也就会对应一个文件描述符。在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目;
  • 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的;

那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗?

并发 1 万请求,也就是经典的 C10K 问题 ,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。

从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。

不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远。

多进程模型

基于最原始的阻塞网络 I/O, 如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型其实就是父子进程,也就是为每个客户端分配一个进程来处理请求。

服务器的主进程负责客户端连接的监听,一旦有客户端连接完成,accept()函数就会返回一个「已连接 Socket」,这时父进程就通过 fork()函数创建一个子进程子进程在初始阶段读数据的时候都是直接读取父进程虚拟空间内的所有段的,只有子进程需要对BSS端、堆栈以及匿名映射和文件映射区写数据时才会使用写时复制的机制将父进程内的段数据复制到子进程内。

通过系统函数fork返回的是一个整数,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程

可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。

下面这张图描述了从连接请求到连接建立,父进程创建生子进程为客户服务。

我们都知道进程是操作系统中的资源单位,每个进程都有自己独立的虚拟空间,页表以及PCB,而且进程的上下文切换是一个比较重的操作,不仅涉及到用户空间中各个段的切换,还涉及到内核空间中堆栈和寄存器的切换,所以多进程模式并不是一种好的模式,由于物理内存有限,导致能创建的子进程也一定是有限的。

这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。

多线程模型

既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求 —— 多线程模型

线程是基本的运行单位,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据如栈和寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。

主线程主要负责监听新建的客户端socket,当服务器与客户端 TCP 完成连接后,主线程通过 pthread_create() 函数创建线程,然后再线程内通过客户端的socket实现和客户端的通信。

如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。

那么,我们可以使用线程池的方式来避免线程的频繁创建销毁,所谓的线程池,就是提前创建若干个线程,这样当有新的客户端socket链接建立时,主线程就不需要使用 pthread_create() 函数来创建线程了,而是直接从线程池里取出一个线程即可;线程任务结束后,也不会立即销毁该线程而是重新放回到线程池中。

线程池的优点包括减少了线程创建和销毁的开销,提高了程序的响应速度和性能,以及更好地管理线程的数量。这在处理大量短暂任务的情况下尤为有用,比如处理多个客户端Socket连接请求,正如你所描述的情况。

那么有一个问题,如果线程池中的线程为空的情况下,有新的socket链接怎么办?客户端的表现是什么?

  • 排队等待:新的Socket连接请求被服务器接受,但服务器将客户端的连接请求放入队列中等待可用的线程。客户端会等待一段时间,直到有线程可用来处理它的连接。如果等待时间过长,客户端可能会超时并报告连接超时错误。
  • 拒绝连接:服务器可以选择拒绝新的连接请求,这通常会导致客户端收到连接被拒绝的错误。这种情况下,客户端需要根据错误码(通常是"连接被拒绝")来处理连接失败。
  • 动态增加线程:一些服务器可能会在连接压力大的情况下动态地增加线程,以应对连接请求的增加。这可以避免拒绝连接,但需要谨慎管理线程的生命周期。
  • 使用线程池饱和策略:线程池通常具有饱和策略,用于处理当线程池已满时的行为。这包括丢弃任务、阻塞等待、执行任务的主线程等选项,具体策略取决于线程池的实现。



说了这么多全是多线程的优点,那么是不是相对多进程来说就没有缺点呢?

肯定不是的,多线程对于多进程来说,会存在一个资源竞争的问题。所有线程共享进程内的大多数资源的,除了少数资源寄存器

那么进程内的BSS段、堆段和匿名文件和文件映射区的段数据所有线程都是可读可写的,线程中如果写入这些段数据的话,就需要加锁来实现资源的互斥了。

上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K(10k个client),意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的,所以需要一种更加优秀的模型来实现C10k

有没有想过为什么听到的都是线程池,而不是进程池呢?

线程池之所以比进程池更常见,主要有以下原因:

  • 资源消耗较小:线程是轻量级的执行单元,相对于进程,它们需要较少的系统资源,包括内存和处理器时间。创建和销毁线程的开销远低于进程。

  • 更高效的通信和协作:线程在同一进程内共享内存,这使得它们之间的通信和协作更加高效,因为它们可以直接访问共享数据。在进程之间共享数据要复杂得多,通常需要使用IPC(进程间通信)机制。

  • 更广泛的应用:多线程编程是更常见的编程模型,适用于许多应用,包括图形界面应用、服务器应用、并发任务处理等。线程的轻量级和灵活性使它们成为更通用的选择。

  • 操作系统支持:操作系统和编程语言通常提供了线程支持,因此线程更容易在应用程序中使用。而创建和管理进程通常需要更多的复杂性和系统开销。

其实最主要的原因是:每个进程的内存资源占用很高,线程的内存资源占用却很少,而内存又是及其宝贵的资源,所以创建进程池是不太现实的。进程和线程的创建和销毁都需要时间,那么创建一个线程池就是一个非常好的选择了,即占用不了太多内存,还减少了线程的创建个销毁时间。

io多路复用

既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?答案是有的,那就是I/O 多路复用技术

我们熟悉的 select/poll/epoll内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。

select/poll/epoll这是三个多路复用接口,都能实现 C10K 吗?接下来,我们分别说说它们。

select/poll

select实现多路复用的方式是:

  1. 将·已连接·的 Socket 都放到一个文件描述符集合
  2. 用户态调用 select 函数将文件描述符集合从用户空间拷贝到内核里。
  3. 内核接受到文件描述符集合时,通过遍历文件描述符集合的方式检查是否有可读可写的事件产生,如果有就会将此 Socket 标记为可读可写
  4. 接着再把整个文件描述符集合拷贝回用户态里。
  5. 用户态接受到文件描述符集合后,还需要再通过遍历的方法找到可读可写的 Socket,然后再对其处理。

可以发现select这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select 使用固定长度BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

poll不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select的文件描述符个数限制,当然还会受到系统文件描述符限制。poll实现的流程是和select 完全一致的,同样是需要2次拷贝2次遍历的。

但是 poll 和 select 并没有太大的本质区别,这种俩方式随着并发数上来,性能的损耗会呈指数级增长。

<?php

// 创建监听 socket
$serverSocket = stream_socket_server("tcp://0.0.0.0:8080", $errno, $errstr);
socket_bind($server_socket, '0.0.0.0' );
socket_listen($server_socket, 8080);

echo "服务器启动,监听 0.0.0.0:8080...\n";

// 存储所有活跃的客户端连接(包括 server socket 自身)
$readSockets = [$serverSocket]; // 可读 socket 列表

while (true) {
    // 每次 select 前复制一份可读列表(因为 stream_select 会修改数组)
    $read = $readSockets;
    $write = null;
    $except = null;

    // 阻塞等待有 socket 可读(超时设为 null 表示永久阻塞)
    // &$read类型:传入你希望监视“是否可读”的流(stream)列表。
    // &$write:传入你希望监视“是否可写”的流列表。
    // &$except: 传入你希望监视“是否发生异常条件”的流列表。
    // 第四个参数:设置 stream_select() 的 超时时间(秒)
           // null 或 负数:永久阻塞,直到至少有一个fd就绪时才返回。
          //0:非阻塞模式,立即返回(轮询)。
          //正整数(如 5):最多等待 5 秒,若无流就绪则超时返回。
    // 返回值:返回 就绪流的总数(即 $read + $write + $except 中所有就绪流的数量之和。
    //     注意是read、write、except都是&类型,返回的都是已经就绪的可读可写的fd。
  //       返回成功时:$numChanged就绪的fd的总和,返回失败时$numChanged为false
    $numChanged = stream_select($read, $write, $except, null);
    // 可以看到select会将所有的fd列表从用户态复制到内核态,内核态会遍历所有的fd,
   // 并对这些fd标记可读或者可写,然后将可读或者可写的fd在通过列表的形式从内核态复制到用户态。
  
   // stream_select的第四个参数使用的是null,代表的是永久阻塞,可能会有疑问,如果这里阻塞住了,此时有新连接的fd怎么办,也会被阻塞吗?
  // 其实不是的。发生阻塞的情况是此时所有的fd没有可读就绪,那么此时如果新来连接,那么此时这里会立即返回,且read中的fd是$serverSocket,那么$serverSocket就可以处理新来的连接了

    if ($numChanged === false) {
        // 错误处理
        break;
    }

    // 检查是否有新连接
    if (in_array($serverSocket, $read)) {
        // 接受新客户端连接
        $clientSocket = stream_socket_accept($serverSocket, -1);
        if ($clientSocket) {
            echo "新客户端连接\n";
            // 将新增的fd的新增到readSockets,并在stream_select函数中再次传入到内核态中,可以发现,
           // 如果后续的fd越来越多,那么readSockets列表也会越来愈大,那么在从用户态复制到内核态,在从内核态复制到用户态的代价将会越来越大。
            $readSockets[] = $clientSocket; // 加入监听列表
        }
    }

    // 处理已连接的客户端数据
    // 用户态仍然需要对可读的fd进行遍历处理
    foreach ($read as $client) {
       //过滤serverSocket,serverSocket只用于获取新的fd,而不用于正真的读取数据
       if ($socket === $serverSocket) {
            continue
        }
        // 读取数据(非阻塞方式读一点)
        $data = fread($client, 1024);

        if ($data === false || strlen($data) === 0) {
            // 客户端断开连接
            echo "客户端断开连接\n";
            fclose($client);
            // 从监听列表中移除
            $index = array_search($client, $readSockets);
            if ($index !== false) {
                unset($readSockets[$index]);
            }
        } else {
            // 回显收到的数据
            echo "收到数据: " . trim($data) . "\n";
            fwrite($client, "Echo: " . $data);
        }
    }
}

// 清理
fclose($serverSocket);

epoll

先复习下 epoll 的用法。如下的代码中,先用e poll_create 创建一个 epol l对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到epfd中,最后调用 epoll_wait 等待数据。

    //创建server端的socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    // Bind socket
    bind(server_fd, 127.0.0.1, 8888);

    // Listen socket
    listen(server_fd, 5) 

    // 创建一个 epoll,在内核中epoll创建一个红黑树
    epoll_fd = epoll_create(0);

    //添加服务端的socket 到 epoll,添加 server_fd到epoll中的红黑树上。
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd) 

    while (1) {
        //从epoll 拉取 所有可读可写可接受的socket,用events表示
        //epoll_wait 函数返回有事件发生的socket个数
        //events:指向用户空间缓冲区的指针,用于存储内核返回的就绪事件。
       //MAX_EVENTS:指定 events 缓冲区的大小(最大可返回的事件数),控制单次调用返回的事件数量上限。
       //最后一个参数表示等待超时时间:
              //- 1:永久阻塞,直到有事件发生。
              // 0:立即返回,不等待(非阻塞模式)。
              // >0:等待指定毫秒数,超时返回 0。
       //event_count: 实际返回的就绪事件数量,用于遍历 events就绪事件
        event_count = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        // 将fd从用户态复制到内核态,内核中epoll通过事件响应机制,获取可读的fd列表,通过将可读的fd列表从内核态复制到用户态。

        for (int i = 0; i < event_count; i++) {
            // 如果文件描述符等于server_fd,则表示该socket是服务端的socket而不是客户端的socket
            if (events[i].data.fd == server_fd) {
                // 服务端接受已经连接的客户端socket
                client_fd = accept(server_fd, 127.0.0.1, 8888);
              
                // 添加客户端的socket到epoll
                epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event)

            } else {
                // 表示为可读或者可写的客户端socket
                char buffer[1024];
                // 从events[i].data.fd文件描述符中读取数据并缓存到buffer变量中,
                //recv函数返回接收到的字节数,如果出现错误则返回 -1,recv函数
                //类似于read函数,但有一些差别
                int bytes_received = recv(events[i].data.fd, buffer, sizeof(buffer), 0);
                if (bytes_received <= 0) {
                    //表示读取客户端socket数据失败
                    // 将客户端的socket从epoll中删除
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
                   //关闭客户端的socket连接
                    close(events[i].data.fd);
                } else {
                    // 表示成功读取到了客户端socket的数据
                    TODO SOMETHING
                    //发生响应给客户端socket
                    send(events[i].data.fd, "Server says: Hello, Client!", 27, 0);
                    //关闭客户端的socket连接
                    close(events[i].data.fd);
                }
            }
        }
    }

    close(server_fd);
    return 0;

可以发现epoll的流程为

  1. 创建服务端 socket,bind 绑定端口、listen 监听端口
  2. 将服务端 socket 注册到 epoll
  3. epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket
  4. 将已连接的 socket 注册到 epoll
  5. epoll_wait 等待事件发生
  6. 对方连接关闭时,我方调用 close

Epoll 通过两个方面,很好解决了 select/poll的问题。

epoll优于select/poll体现在下面俩点:

  1. epoll 在内核里使用红黑树来跟踪进程所有待检测的fd,所以epoll只需要将单个的 fd socket通过epoll_ctl() 函数加入内核中的红黑树里即可,也就是只需要将单个的fd socket从用户态复制到内核态即可。
    而 select/poll每次操作时都传入整个 socket 集合从用户态复制到内核态,后续集合越来越大,select/poll的性能将会越来愈差。
    需要注意的一点是:在返回可读的fd socket时,epoll 和select/poll的方式都是一样的,同样的都是返回一个可读的list,需要从内核态复制到用户态。

  2. epoll 在内核中是使用事件驱动的机制,当某个 socket 有事件发生时,将红黑树中可读的 fd socket的写入到一个就绪链表中,epoll_wait返回的就是这个连接中的fd。
    而select/poll是在内核中那样轮询扫描整个socket 集合,然后标记哪些fd是可读可写的,然后从内核态返回给用户态。
    可以发现epoll的事件机制的效率明显高于select/poll。

上面俩点就是epoll完全优于select/poll的特点了。


epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll被称为解决 C10K问题的利器。

插个题外话,网上文章不少说,epoll_wait返回时就绪的队列中的socket时,epoll 使用的是共享内存的方式,即用户态内核态都指向了就绪链表,所以就避免了`内存拷贝消耗。

这是错的!看过 epoll 内核源码的都知道,压根就没有使用共享内存这个玩意。你可以从下面这份代码看到, epoll_wait 实现的内核代码中调用了 __put_user 函数,这个函数就是将数据从内核拷贝到用户空间。

模型间的组合使用

通过上面的介绍,多进程模型或者多线程模型可以利用多核实现多个客户端连接真正的并行,而io多路复用是实现了一个进程内的多个连接实现并发,所以一些追求高并发,高响应的中间件都是采用的多进程+io多路复用或者多线程+io多路复用的模型,基本不会单独的使用多进程、多线程或者io多路复用。

多进程模型:在多进程模型中,每个客户端连接通常由一个独立的进程来处理。这意味着每个连接都有自己的进程,可以并行运行,互不干扰。这提供了真正的并行性,但也会导致更多的系统资源开销。

IO多路复用模型:IO多路复用是一种机制,通常由单个进程来管理多个连接。这个进程使用IO多路复用系统调用(如select、poll、epoll等)来等待多个连接上的IO事件,例如读取或写入数据。当有IO事件发生时,进程可以快速响应。这种模型允许一个进程并发地处理多个连接,而不需要为每个连接创建一个单独的进程或线程,从而降低了资源开销。

大多数追求高并发的中间件确实采用多进程+IO多路复用的模型,因为这种模型在面对大量并发连接时表现出色,同时可以有效利用多核处理器。典型的例子包括NginxApache HTTP Server(在一定程度上,可以使用多进程模型)。

有一些中间件也采用多线程+IO多路复用的模型,这是一种在多核系统上利用多线程的方式。这种模型通过使用线程来处理连接,每个线程可以并发处理多个连接。这种模型在某些情况下可以提供更好的性能,因为线程切换通常比进程切换开销更小。典型的例子包括一些Web服务器、应用服务器和消息队列中间件,如Jetty、Tomcat等。

可能你会有疑问,既然多线程切换比多进程切换开销小很多,为什么大量追求高并发的中间件使用的是多进程+IO多路复用而不是多线程+IO多路复用的呢?

  1. 可伸缩性: 在多进程模型中,每个进程都是相对独立的,它们之间不共享内存空间,这种分离性使得在多核系统上更容易实现负载均衡

  2. 稳定性和安全性: 多进程模型更稳定因为一个进程的崩溃不会影响其他进程;而多线程之间由于共享进程的资源(栈和寄存器除外),所以某个线程奔溃的话也将会导致进程奔溃(C、C++会奔溃,在JAVA中并不会奔溃)。多进程相对于多线程来说有助于提高稳定性和减少潜在的安全漏洞。

  3. 特定性能需求: 多线程相比多进程更加具有复杂性,主要原因是线程共享进程的内存资源,也就是线程之间具有资源竞争问题,换言之线程之间需要对共享资源使用加锁的方式来避免资源冲突加锁对于高并发和高性能的服务是非常影响的,所以大多数追求高并发的中间件使用的是多进程+IO多路复用的模式。

实现一个简单的socket服务

用php语言实现一个最基本的tcp socketunix socket模型。

tcp socket

服务端代码如下

<?php
$server = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

socket_bind($server,"10.189.72.105",8777);

socket_listen($server,100);

while (1){
    echo "开始接受socket\n";
    $connection = socket_accept($server);

    sleep(20);
    echo "接收到新的socket文件描述符了".$connection."\n";
    $data = socket_read($connection,1024);

    echo "接受到该socket的具体内容为".$data."\n";

    socket_write($connection,'已经接收到了数据');

    echo "服务端响应客户端成功\n";
    socket_close($connection);
    echo $connection."socket关闭\n";
}

客户端代码如下

<?php
$clinet = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

echo "客户端开始连接\n";
socket_connect($clinet,"10.189.72.105",8777);

echo "客户端连接成功\n";
sleep(10);

echo "客户端开始写数据\n";

socket_write($clinet,"我开始写数据啦啦啦啦");

echo "客户端写数据成功\n";

$data = socket_read($clinet,1024);

echo "客户端读取成功服务端的响应数据".$data."\n";

echo "客户端关闭连接\n";


unix socket

服务端代码

<?php
$sock_file = "/home/first.sock";

if (file_exists($sock_file)) {
    unlink($sock_file);
}
$server = socket_create(STREAM_PF_UNIX,SOCK_STREAM,0);

socket_bind($server,$sock_file);

socket_listen($server,100);

while (1){
    echo "开始接受unix socket\n";
    $connection = socket_accept($server);

    echo "接收到新的unix socket文件描述符了".$connection."\n";
    $data = socket_read($connection,1024);

    echo "接受到该unix socket的具体内容为".$data."\n";

    socket_write($connection,'已经接收到了数据');

    echo "服务端响应客户端成功\n";
    socket_close($connection);
    echo $connection."unix socket关闭\n";
}

客户端代码

<?php
$sock_file = "/home/first.sock";
$clinet = socket_create(STREAM_PF_UNIX,STREAM_SOCK_STREAM,0);

echo "客户端开始连接\n";
socket_connect($clinet,$sock_file);

echo "客户端连接成功\n";
sleep(10);

echo "客户端开始写数据\n";

socket_write($clinet,"我开始写数据啦啦啦啦");

echo "客户端写数据成功\n";

$data = socket_read($clinet,1024);

echo "客户端读取成功服务端的响应数据".$data."\n";

echo "客户端关闭连接\n";


启动socket服务

上面介绍了socket的基本使用和socket模型的迭代以及socket的代码编写。

其实上面已经完成了90%,还差最后一步就可以启动服务了,就是代码的编译,既编译成一个二进制文件

其实我们平时用的中间件大多数都是将服务端的socket编译成一个二进制文件,并放到bin目录下或者sbin目录下,例如nginx服务启动时

./bin/nginx -c ./nginx.conf

例如mysql服务启动时

./bin/mysql -u root -P 8888 -p

这些二进制文件编译之前的语言都是c、c++、java或者go,可以发现这些语言都是编译语言。

在许多情况下,Socket服务通常使用C和C++等编程语言来编写。这是因为C和C++提供了对底层操作系统API的直接访问,使得编写高性能高度可控的网络应用程序变得更加容易

可能你会好奇,很多bin命令后需要增加一些参数,例如-C表示配置文件地址,-P表示端口,这些参数是怎么映射到服务端的socket的呢?其实很简单,只是在上面的代码中使用硬编码的方式直接指定了一些参数,例如ip、port、sock文件地址之类的。而我们只需要将socket代码改成参数型即可,如下的c语言代码。

int main(int argc, char **argv)
{
    int default_port = 8000;
    int optch = 0;
    while ((optch = getopt(argc, argv, "s:p:")) != -1)
    {
        switch (optch)
        {
        case 'p':
            default_port = atoi(optarg);
            printf("port: %s\n", optarg);
            break;
        case '?':
            printf("Unknown option: %c\n", (char)optopt);
            break;
        default:
            break;
        }
    }
}

启动时指定参数p即可指定该服务的端口号,如下

./server.php -p 8080

在php代码中也很好实现,如下代码

<?php
<?php
$argc = count($argv);
$parameters = [];
//默认端口号8080
$port =8080;
$host = "10.189.72.105";
for ($i = 1; $i < $argc; $i++) {
    if ($argv[$i] === '-p' && $i + 1 < $argc) {
        // 如果参数是'-p',则获取下一个参数作为值
        $port = $argv[$i + 1];
    }
    if ($argv[$i] === '-h' && $i + 1 < $argc) {
        // 如果参数是'-p',则获取下一个参数作为值
        $host = $argv[$i + 1];
    }
}
$server = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

socket_bind($server,$host,$port);

socket_listen($server,100);

使用如下命令,即可实现一个自定义的ip 和port。

php ./server.php -p 8081 -h 127.0.0.1

说了这么多,我们知道了启动socket服务的方式有俩种

  • 将socket服务端编译成二进制文件启动,例如c、c++、go、java编写的socket
  • 将socket服务使用命令行启动,例如php编写的socket

二进制文件和命令行的区别

既然有着俩种方式可以启动socket服务,那么这俩种方式有什么区别呢?为什么大多数服务时使用编写成二进制文件的方式启动呢?

通过命令行运行脚本(如php service.php和java ServiceClass) 来启动服务是一种常见的开发和测试方式,特别是在开发阶段。,这种方式非常方便,可以迅速启动服务并进行调试。

然而,在生产环境中,将Socket服务编译成二进制文件或打包成独立的可执行文件会有以下优势:

  • 性能: 编译成二进制文件可以提供更好的性能。二进制文件中的机器码是操作系统直接可以读取的,而如果使用命令行的方式的话,例如php server.php,首先需要php进行代码的解释,相比二进制文件的性能会更好。

  • 可分发性: 编译成二进制文件后,您不再需要在目标服务器上安装解释器。这使得部署更加简单无需担心服务器上是否有正确版本的解释器。而如果使用php命令的方式的话,首先需要按照php。

  • 资源占用: 编译成二进制文件通常占用更少的系统资源,因为不需要运行解释器

  • 隐藏源代码: 将程序编译成二进制文件可以隐藏源代码,从而保护您的知识产权

  • 包装和发布: 二进制文件可以更容易地进行包装和发布,因为您可以将依赖库和其他资源一起打包,从而减少了部署的复杂性。

总之,将Socket服务编译成二进制文件或打包成独立的可执行文件可以提供更好的性能、更简单的部署以及更好的资源管理。

需要注意的是:解释性语言只能使用命令行的方式启动,而编译型语言通常在测试环境使用命令行方式调试,在线上环境需要编译成二进制文件。

socket代码编译

上面也介绍了一些代码编译的知识了,那么具体怎么将socket代码编译成二进制文件呢?

其实每个编译型语言编译二进制文件时的难易程度都不一致。

在Go语言中,将Socket服务编译成二进制文件相对较简单,直接go build即可生成一个名为与代码文件相同的可执行文件。例如,如果代码文件名是 server.go,则生成的可执行文件名将是 server。

go build

例如java、c和c++编译的难度相对较大,这里就不展示了,自行百度吧。

查看sock连接

服务端socket listen之后,客户端就可以connect了,那么怎么查看当前监听的服务端有哪些客户端连接呢?通过以下命令行即可。

netstat -anp | grep 服务端socket监听的端口号

以下的所有模拟实验的服务端tcp sockek的ip地址为10.189.72.105,监听端口为7888。

server = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

socket_bind($server,"10.189.72.105",8777);

监听socket

当服务端socket listen之后,使用上述命令查看如下。可以看到始终有一个处于listen的socket,这个socket叫做监听 socket

sftcwl@gz-cvm-ebuild-xux-dev001 ~]$ netstat -anp | grep 8777
tcp        0      0 10.189.72.105:8777      0.0.0.0:*               LISTEN      31283/php

从图中的信息可以看出服务端的监听socket属于tcp socket类型且监听的端口号为8777,该进程的PID为31283

已完成连接的socket

当服务端socket listen之后,客户端socket就可以connect了。当一个客户端连接后,再次查看,如下图:

[sftcwl@gz-cvm-ebuild-xuxiaoyu-dev001 ~]$ netstat -anp | grep 8777
tcp        1      0 10.189.72.105:8777      0.0.0.0:*               LISTEN      31283/php
tcp       30      0 10.189.72.105:8777      10.189.72.105:56376     ESTABLISHED -
tcp        0      0 10.189.72.105:56376     10.189.72.105:8777      ESTABLISHED 27244/php

可能之前没有实践过的同学,会有疑问:当客户端创建一个新的socket且连接成功服务端的socket后,客户端和服务端将使用客户端新建的socket连接进行后续的通信,所以明明只创建了一个客户端的连接,为什么netstat返回的却是"俩个"连接呢?

你的理解是正确的,通常在创建服务端Socket并接受客户端连接后,应该存在一个连接,而不是两个。在netstat的输出中,第二行和第三行分别表示同一连接不同方向这是因为TCP连接是双向的,数据可以在两个方向上传输,所以你会看到本地IP地址和端口号在这两行中颠倒

从上图得知,服务端的socket地址10.189.72.105:8777,新建的客户端socket地址为10.189.72.105:56376。

第二行则代表的传输方向是服务端----->客户端的方向,该行的 ESTABLISHED表示的就是TCP 3次握手时的服务端的状态。

第三行则代表的传输方向是客户端----->服务端的方向,该行的ESTABLISHED表示的就是TCP 3次握手的客户端的状态。

所以,实际上只有一个连接,但netstat的输出方式导致看起来像是两个连接。

上面描述了俩个socket概念,一个是监听socket,另一个是已完成连接的socket,这是俩个不同的socket,真正用来客户端和服务端信息传递的是已完成连接的socket

以下是一个非常简单的服务端socket的例子,第2行所创建的socket就叫监听socket,第9行从accept队列中获取的socket叫做已完成连接的socket,这下肯定都明白了吧。

  1 <?php
  2 $server = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);
  3
  4 socket_bind($server,"10.189.72.105",8777);
  5
  6 socket_listen($server,100);
  7
  8 echo "开始接受socket\n";
  9 $connection = socket_accept($server);
 10
 11 $data = socket_read($connection,1024);
 12
 13 //socket_write($connection,'已经接收到了数据');
 14
 15 echo "服务端响应客户端成功\n";
 16 socket_close($connection);

长链接和短链接

短链接是一种即时性的连接方式,正常的连接过程如下:

  1. 客户端socket连接成功服务端的监听socket后,tcp 3次握手成功,此时客户端和服务端都处于ESTABLISHED状态。
  2. 握手成功之后客户端和服务端则开始处理对应的代码逻辑,此时客户端和服务端仍然处于ESTABLISHED状态。
  3. 客户端和服务端都是可以发生fin报文来主动断开连接的,优先发送fin报文的一端则是tcp 4次挥手中的主动断开连接方那么在短连接中什么时机下客户端或者服务端才会发生fin报文呢?
    • 执行完代码逻辑之后,尽管没有显示的调用close函数,也会发生fin报文;
    • 执行代码过程显示调用close函数,会立即发生fin报文;
  4. 4次挥手之后,客户端和服务端都处于close状态;当新的客户端socket连接过来之后,重复创建(tcp 3次握手)和销毁(4次挥手),这意味着每次通信都需要经历连接的建立、数据传输和连接的断开过程,这会导致一定的额外开销。

长连接是一种保持连接状态的通信方式,客户端与服务器之间的连接不会在每次请求/响应之后立刻关闭,而是会被保持打开一段时间,以便后续的通信,相对于短链接来说减少了每次通信的连接建立和断开开销,明显可以提高性能,在HTTP 1.1 中默认使用长连接,可以在response header中看到Connection:keep-alive。

长连接和短连接的唯一区别是:短链接在执行完代码逻辑之后,尽管没有显示的调用close函数,也会发生fin报文;而长连接在执行完代码逻辑之后,并不会发送fin报文,客户端和服务端则是一直在处于ESTABLISHED状态。

那么长连接就永远不会断开了吗?当然不是,满足下面任意条件则会发送fin报文:

  • 调用close函数:客户端或者服务端任意一方显示调用close函数,则开始发送fin报文,称为主动断开连接方,这点和短连接断开的方式是完全相同的。
  • 长连接超时:并不是长连接如果不调用close函数则永远不会断开,设想一种场景如果长连接永远不会断开且该j连接后续只通信了一次,那么长时间不断开且不很浪费系统资源?所以长连接通常会有超时时间的设置,比如 nginx 提供的keepalive_timeout,参数,长连接建立时间如果超过设置超时时间,该连接也会调用close函数进行4次挥手。
  • 长连接的请求数量达到上限:长连接通常会定义长连接上最大能处理的请求数量,当超过最大限制时,就会主动调用close函数来关闭连接,比如 nginx 的 keepalive_requests

4次挥手的更正

在很多文章中4次挥手是这样描述的,客户端发起fin报文,然后服务端接收到fin报文后返回·客户端·给ack消息。。。。。。


但是在我们刚才的描述中发现,客户端和服务端都是可以发生fin报文的,所以我们将发送fin报文的一方成为主动断开连接方,另一方成为被动断开连接方,所以正确的4次挥手图如下:

验证

下面的实验主要验证4次挥手的过程,且验证方式使用的都是短连接的方式。

客户端socket优先发送fin报文(执行完成代码逻辑)

服务端代码如下

<?php
$server = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

socket_bind($server,"10.189.72.105",8777);

socket_listen($server,100);

$connection = socket_accept($server);

echo "接收到新的socket文件描述符了".$connection."\n";
$data = socket_read($connection,1024);

echo "接受到该socket的具体内容为:".$data."\n";

//TODO SOMETHING

sleep(30);

echo "服务端逻辑完成";

客户端代码如下

<?php
$clinet = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

socket_connect($clinet,"10.189.72.105",8777);

echo "客户端连接成功\n";

socket_write($clinet,"我开始写数据啦啦啦啦");

echo "客户端写数据成功\n";

//TODO SOMETHING

sleep(10);

echo "客户端逻辑完成\n";

先分析一下上面的代码:客户端和服务端都没有显示调用close函数来关闭连接;客户端代码sleep 10s,而服务端代码sleep 30s,也就是说客户端会优先执行完代码并发送fin报文,接下来验证是否正常。

  1. 启动服务端socket,然后查看,发现服务端的监听socket已经被监听成功。
[sftcwl@gz-cvm-ebuild-xuxiaoyu-dev001 ~]$ netstat -anp | grep 8777
tcp        0      0 10.189.72.105:8777      0.0.0.0:*               LISTEN      793/php
  1. 启动客户端socket,立即查看,可以看到第三行客户端和第二行服务端都处于ESTABLISHED了。
[sftcwl@gz-cvm-ebuild-xuxiaoyu-dev001 ~]$ netstat -anp | grep 8777
tcp        0      0 10.189.72.105:8777      0.0.0.0:*               LISTEN      793/php
tcp        0      0 10.189.72.105:8777      10.189.72.105:40786     ESTABLISHED 793/php
tcp        0      0 10.189.72.105:40786     10.189.72.105:8777      ESTABLISHED 5555/php
  1. 在大约10s后,继续查看。
[sftcwl@gz-cvm-ebuild-xuxiaoyu-dev001 ~]$ netstat -anp | grep 8777
tcp        0      0 10.189.72.105:8777      0.0.0.0:*               LISTEN      793/php
tcp        1      0 10.189.72.105:8777      10.189.72.105:40786     CLOSE_WAIT  793/php
tcp        0      0 10.189.72.105:40786     10.189.72.105:8777      FIN_WAIT2   -

可以看出俩个重要的点

  • 第三行客户端的状态为FIN_WAIT2,该状态属于4次挥手中主动断开连接发送fin报文的一端,也就是客户端socket发送的fin报文,符合我们的预期。
  • 客户端代码sleep 10s,而服务端代码sleep 30。在10s后查看也就意味着客户端socket已经执行完代码,所以客户端优先发送了fin报文,客户端成为主动断开连接方,服务端成为被动断开连接方;被动断开连接方(服务端)收到了主动断开连接方(客户端)的fin报文并返回ack报文给动断开连接方,所以动断开连接方(客户端)处于了FIN_WAIT2状态,但是此时被动断开连接方(服务端)代码仍在sleep中并没有执行完,所以处在了CLOSE_WAIT状态。
  1. 大约在30s后,继续查看。
[sftcwl@gz-cvm-ebuild-xuxiaoyu-dev001 ~]$ netstat -anp | grep 8777
tcp        0      0 10.189.72.105:41610     10.189.72.105:8777      TIME_WAIT   -

在30s后,被动断开连接方(服务端)的代码全部执行完,此时被动断开连接方(服务端)也将会发生fin报文给主动断开连接方(客户端),被动断开连接方(服务端)的状态由CLOSE_WAIT转变为LAST_ACK,主动断开连接方(客户端)收到被动断开连接方(服务端)的fin报文之后会立即状态更改为TIME_WAIT,紧接着主动断开连接方(客户端)在发生ack报文给被动断开连接方(服务端),被动断开连接方(服务端)收到ack后状态就由LAST_ACK转变为CLOSE了。

整个状态的变化是非常快的,所以只看到了主动断开连接方(客户端)的状态此时为TIME_WAIT状态,此状态将持续大约2MSL时候后,主动断开连接方(客户端)也将处于close状态了。

此时应该会注意到监听的socket也消失了,这个是因为服务端代码执行完成之后,该进程也就释放了。

  1. 在2MSL时间后查看。
[sftcwl@gz-cvm-ebuild-xuxiaoyu-dev001 ~]$ netstat -anp | grep 8777

可以发现,监听socket以及其他socket连接都已经没了。

服务端socket优先发送fin报文(执行完成代码逻辑)

服务端代码如下

<?php
$server = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

socket_bind($server,"10.189.72.105",8777);

socket_listen($server,100);

$connection = socket_accept($server);

echo "接收到新的socket文件描述符了".$connection."\n";
$data = socket_read($connection,1024);

echo "接受到该socket的具体内容为:".$data."\n";

//TODO SOMETHING

sleep(10);

echo "服务端逻辑完成";

客户端代码如下

<?php
$clinet = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

socket_connect($clinet,"10.189.72.105",8777);

echo "客户端连接成功\n";

socket_write($clinet,"我开始写数据啦啦啦啦");

echo "客户端写数据成功\n";

//TODO SOMETHING

sleep(30);

echo "客户端逻辑完成\n";

和上一节代码的区别是:服务端sleep 由30s更新到了10s,而客户端sleep由10s更新到了30s。

因为之前说过客户端服务端都是可以主动断开连接发送fin报文的,取决谁先优先执行完代码,所以预测上面代码中是服务端主动断开连接优先发送fin报文的。

将服务端socket监听,且客户端socket连接成功后10s查看,如下

[sftcwl@gz-cvm-ebuild-xuxiaoyu-dev001 ~]$ netstat -anp | grep 8777
tcp        1      0 10.189.72.105:44844     10.189.72.105:8777      CLOSE_WAIT  26337/php
tcp        0      0 10.189.72.105:8777      10.189.72.105:44844     FIN_WAIT2   -

发生和上一节有俩个不同

  • 监听的scoket消失:很明显10s服务端socket的代码执行完成且进程释放,所以监听的scoket也就消失了。
  • 第三行数据中FIN_WAIT2属于4次挥手中的主动断开连接的一方,所以推断是服务端主动断开连接且发生fin报文的,符合我们开始的猜想。

其他环节的状态和第一节一样,这里就不展示了。

客户端socket优先发送fin报文(显示调用close函数)

我们之前说过,不管在短连接还是长链接中,客户端或者服务端显示的调用了close函数,那么就会发送fin报文,做为主动断开连接的一方。

此小结我们就验证一下这个结论是否正确。

服务端代码

<?php
$server = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

socket_bind($server,"10.189.72.105",8777);

socket_listen($server,100);

$connection = socket_accept($server);

echo "接收到新的socket文件描述符了".$connection."\n";
$data = socket_read($connection,1024);

echo "接受到该socket的具体内容为:".$data."\n";

//TODO SOMETHING

sleep(10);

echo "服务端逻辑完成";

客户端代码

<?php
$clinet = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

socket_connect($clinet,"10.189.72.105",8777);

echo "客户端连接成功\n";

socket_write($clinet,"我开始写数据啦啦啦啦");

echo "客户端写数据成功\n";

socket_close($clinet);

//TODO SOMETHING

sleep(30);

echo "客户端逻辑完成\n";

继续分析下代码:服务端socket是sleep 10s,客户端显示调用了close函数,close函数下面sleep 30s。如果显示的调用了close函数的一方则主动断开连接,那么客户端很快则会主动断开连接;如果不是,那么在10s后服务端会主动断开连接。

将服务端socket启动成功且客户端socket连接成功后,快速的查看(没有达到10s),显示如下:

[sftcwl@gz-cvm-ebuild-xuxiaoyu-dev001 ~]$ netstat -anp | grep 8777
tcp        0      0 10.189.72.105:8777      0.0.0.0:*               LISTEN      2398/php
tcp        0      0 10.189.72.105:8777      10.189.72.105:50052     CLOSE_WAIT  2398/php
tcp        0      0 10.189.72.105:50052     10.189.72.105:8777      FIN_WAIT2   -

可以发现是客户端主动断开的连接,那么我们的设想是正确的:客户端或者服务端显示的调用close函数之后就会主动断开连接并发送fin报文了。

模拟服务端socket一直处于close_wait

我们知道close_wait属于被动断开连接一端的状态,此时被动断开连接一端将会执行剩余代码逻辑,在执行完成之后会发生fin报文给主动断开连接一端,并将状态更改为LAST_ACK状态。

此次试验是模拟服务端代码一直循环,被动断开连接一端是否一直处于close_wait,主动断开连接一方是否一直处于FIN_WAIT2状态?

服务端代码如下

<?php
$server = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

socket_bind($server,"10.189.72.105",8777);

socket_listen($server,100);

while (1){
    $connection = socket_accept($server);

    echo "接收到新的socket文件描述符了".$connection."\n";
    $data = socket_read($connection,1024);

    echo "接受到该socket的具体内容为:".$data."\n";

    //TODO

    echo "服务端逻辑完成";
}

客户端代码如下

<?php
$clinet = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

socket_connect($clinet,"10.189.72.105",8777);

echo "客户端连接成功\n";

socket_write($clinet,"我开始写数据啦啦啦啦");

echo "客户端写数据成功\n";

//TODO SOMETHING

sleep(10);

echo "客户端逻辑完成\n";

可以看到服务端代码没有显示的调用close函数且一直在循环中,而客户端代码只sleep 10s。

那么在10s后客户端则执行完所有代码逻辑,10s后查看如下:

[sftcwl@gz-cvm-ebuild-xuxiaoyu-dev001 ~]$ netstat -anp | grep 8777
tcp        0      0 10.189.72.105:8777      0.0.0.0:*               LISTEN      8208/php
tcp        1      0 10.189.72.105:8777      10.189.72.105:53900     CLOSE_WAIT  8208/php
tcp        0      0 10.189.72.105:53900     10.189.72.105:8777      FIN_WAIT2   -

由于客户端会主动断开连接并发生fin报文,主动断开连接一方状态为FIN_WAIT2,而被动断开连接的一方由于死循环中表示代码一直未完成,所以也一直处于了CLOSE_WAIT状态。

在过了一段时间后仍然是上面的状态。

又过了一段时间查看,结果如下:发生主动断开连接的一方消失,后经查阅资料,如果被动断开连接一方长时间处于CLOSE_WAIT时,主动断开连接一方将直接从FIN_WAIT2转化为CLOSE状态。

变相的说明如果被动断开连接一方长时间处于CLOSE_WAIT时,可能的原因就是被动断开连接一方的代码出现问题,类似死循环之类,应该首先检查代码是否正确。

[sftcwl@gz-cvm-ebuild-xuxiaoyu-dev001 ~]$ netstat -anp | grep 8777
tcp        0      0 10.189.72.105:8777      0.0.0.0:*               LISTEN      8208/php
tcp        1      0 10.189.72.105:8777      10.189.72.105:53900     CLOSE_WAIT  8208/php

正确的服务端socket代码

在上面的实验中,有的实验服务端socket读取完消息之后直接退出了,有点实验直接陷入死循环了,这样都是不行的。

所以大概非常粗糙一份服务端监听socket的代码如下:在处理完业务之后显示的调用close函数正确的释放连接,避免一直处于TIME_WAIT状态。

<?php
$server = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

socket_bind($server,"10.189.72.105",8777);

socket_listen($server,100);

while (1){
    $connection = socket_accept($server);

    echo "接收到新的socket文件描述符了".$connection."\n";
    $data = socket_read($connection,1024);

    echo "接受到该socket的具体内容为:".$data."\n";

    //TODO

    echo "服务端逻辑完成";
    socket_close($connection);
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容