unix网络编程2.1——高并发服务器(一)基础——io与文件描述符、socket编程与单进程服务端客户端实现

前置文章

unix网络编程1.1——TCP协议详解(一):https://www.cnblogs.com/kongweisi/p/16882787.html

一些有用的指令

strace指令执行查看进程执行流程

  • linux中可执行文件编译后称为ELF文件,类似windows中的exe程序,通过./ELF文件可以执行这个进程
  • 而linux终端以shell作为媒介调用exec簇函数去执行这个ELF对应的进程,同时会跳转到代码段的main函数作为程序入口
strace ./ELF文件 追踪程序执行流程

image
参考:https://www.cnblogs.com/machangwei-8/p/10388883.html

netstat查看网络状态

netstat -apon | grep 端口号

nc模拟客户端

nc 127.0.0.1 端口号

linux中的io与文件描述符

参考:https://blog.csdn.net/weixin_51696091/article/details/121848245
image

image

  • 结合本文要说的内容,重点需要了解,文件描述符fd其本质:数组下标,体现为整型数据,当调用系统调用的接口(比如open)成功时,会返回一个fd,
    通过这个fd可以找到对应的文件(inode)与函数指针,进而可以调用函数,做一系列的io操作,也就是说fd与io是一种绑定关系;而本文下面要说的socket也是紧密与fd联系在一起, 通过生产的listen_fd与connect_fd等完全监听端口、建立连接,再通过read、write接口实现数据通信

  • 文件描述符的分配规则:在数组中,会优先找到当前未被使用的最小下标,将其作为新的文件描述符,每个这样的数组默认的0、1、2三个下标已经提前被占用,默认为标准输入、标准输出和标准错误

socket编程

socket套接字相关概念

  • Socket本身有“插座”的意思,在Linux环境下,用于表示进程间网络通信的特殊文件类型。本质为内核借助缓冲区形成的伪文件。

  • 既然是文件,那么理所当然的,我们可以使用文件描述符引用套接字。与管道类似的,Linux系统将其封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致。区别是管道主要应用于本地进程间通信,而套接字多应用于网络进程间数据的传递。

  • 在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。因此可以用Socket来描述网络连接的一对一关系。

  • 在网络通信中,套接字一定是成对出现的。一端的发送缓冲区对应对端的接收缓冲区。我们使用同一个文件描述符索发送缓冲区和接收缓冲区。
    image

  • socket在网络模型中的位置
    image

网络字节序和主机字节序

TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节,
主机字节序为小端,记忆方法,“小弟弟”,低地址低字节,因此网络字节序与主机字节序需要转换

字节序转换函数

  #include <arpa/inet.h>
  
  uint32_t htonl(uint32_t hostlong);
  uint16_t htons(uint16_t hostshort);
  uint32_t ntohl(uint32_t netlong);
  uint16_t ntohs(uint16_t netshort);
  • h表示host,n表示network,l表示32位长整数,s表示16位短整数。
  • 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回,如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。

阻塞与非阻塞

函数的阻塞是如何实现的?进程的几个状态转换

函数阻塞:https://blog.csdn.net/weixin_44367006/article/details/101637239
进程状态:https://blog.csdn.net/cafucwxy/article/details/78453430

accept阻塞在哪里?

  • accept是阻塞的,会一直等到有客户端发起三次握手连接,才会继续执行后续的代码

accept如何设置非阻塞

fcntl设置非阻塞

  • 通过fcntl可以将fd的属性设置为非阻塞,如果走到accept还没有客户端发起连接,accpet不会阻塞会执行后续的代码
int flag = fcntl(listen_fd, F_GETFL, 0);
flag |= O_NONBLOCK;
fcntl(listenfd, F_SETFL, flag);

select监听listen_fd的读事件

  • (后续文章介绍)

同步与异步

本质上来讲libevent应该是同步的,因为如果看到底层封装的select和epoll就会发现,里面仍然是个while循环,在不停的询问,是否准备就绪,
而异步同步IO的主要区别就是,应用发起一个 IO 操作以后,不等待内核 IO 操作的完成,等内核完成 IO 操作以后会通知应用程序,这其实就是同步和异步最关键的区别,同步必须等待或者主动的去询问 IO 是否完成,显然select和epoll是同步的。
IO同步、IO异步、阻塞、非阻塞
  POSIX(可移植操作系统接口)把同步IO操作定义为导致进程阻塞直到IO完成的操作,反之则是异步IO

 

  IO同步可分为(阻塞和非阻塞类型)

阻塞IO模型(进程在内核状态下等待)
使用recv的默认参数一直等数据直到拷贝到用户空间,这段时间内进程始终阻塞。A同学用杯子装水,打开水龙头装满水然后离开。这一过程就可以看成是使用了阻塞IO模型,因为如果水龙头没有水,他也要等到有水并装满杯子才能离开去做别的事情。很显然,这种IO模型是同步的。

  

非阻塞IO模型
改变flags,让recv不管有没有获取到数据都返回,如果没有数据那么一段时间后再调用recv看看,如此循环。B同学也用杯子装水,打开水龙头后发现没有水,它离开了,过一会他又拿着杯子来看看……在中间离开的这些时间里,B同学离开了装水现场(回到用户进程空间),可以做他自己的事情。这就是非阻塞IO模型。但是它只有是检查无数据的时候是非阻塞的,在数据到达的时候依然要等待复制数据到用户空间(等着水将水杯装满),因此它还是同步IO。

  注:红色字体是与异步IO的最大区别

 

IO复用模型
这里在调用recv前先调用select或者poll,这2个系统调用都可以在内核准备好数据(网络数据到达内核)时告知用户进程,这个时候再调用recv一定是有数据的。因此这一过程中它是阻塞于select或poll,而没有阻塞于recv,有人将非阻塞IO定义成在读写操作时没有阻塞于系统调用的IO操作(不包括数据从内核复制到用户空间时的阻塞,因为这相对于网络IO来说确实很短暂),如果按这样理解,这种IO模型也能称之为非阻塞IO模型,但是按POSIX来看,它也是同步IO,那么也和楼上一样称之为同步非阻塞IO吧。

这种IO模型比较特别,分个段。因为它能同时监听多个文件描述符(fd)。这个时候C同学来装水,发现有一排水龙头,舍管阿姨告诉他这些水龙头都还没有水,等有水了告诉他。于是等啊等(select调用中),过了一会阿姨告诉他有水了,但不知道是哪个水龙头有水,自己看吧。于是C同学一个个打开,往杯子里装水(recv)。这里再顺便说说鼎鼎大名的epoll(高性能的代名词啊),epoll也属于IO复用模型,主要区别在于舍管阿姨会告诉C同学哪几个水龙头有水了,不需要一个个打开看(当然还有其它区别)。

 

信号驱动IO模型
通过调用sigaction注册信号函数,等内核数据准备好的时候系统中断当前程序,执行信号函数(在这里面调用recv)。D同学让舍管阿姨等有水的时候通知他(注册信号函数),没多久D同学得知有水了,跑去装水。是不是很像异步IO?很遗憾,它还是同步IO(省不了装水的时间啊)。

 

异步IO模型
调用aio_read,让内核等数据准备好,并且复制到用户进程空间后执行事先指定好的函数。E同学让舍管阿姨将杯子装满水后通知他。整个过程E同学都可以做别的事情(没有recv),这才是真正的异步

复制代码
总结
IO分两阶段:

1.数据准备阶段
2.内核空间复制回用户进程缓冲区阶段
一般来讲:阻塞IO模型、非阻塞IO模型、IO复用模型(select/poll/epoll)、信号驱动IO模型都属于同步IO,因为阶段2是阻塞的(尽管时间很短)。只有异步IO模型是符合POSIX异步IO操作含义的,不管在阶段1还是阶段2都可以干别的事。
复制代码

socket模型创建流程图

image

socket函数

  #include <sys/types.h> /* See NOTES */
  #include <sys/socket.h>
  int socket(int domain, int type, int protocol);
  domain:
  	AF_INET 这是大多数用来产生socket的协议,使用TCP或UDP来传输,用IPv4的地址
  	AF_INET6 与上面类似,不过是来用IPv6的地址
  	AF_UNIX 本地协议,使用在Unix和Linux系统上,一般都是当客户端和服务器在同一台及其上的时候使用
  type:
  	SOCK_STREAM 这个协议是按照顺序的、可靠的、数据完整的基于字节流的连接。这是一个使用最多的socket类型,这个socket是使用TCP来进行传输。
  	SOCK_DGRAM 这个协议是无连接的、固定长度的传输调用。该协议是不可靠的,使用UDP来进行它的连接。
  	SOCK_SEQPACKET该协议是双线路的、可靠的连接,发送固定长度的数据包进行传输。必须把这个包完整的接受才能进行读取。
  	SOCK_RAW socket类型提供单一的网络访问,这个socket类型使用ICMP公共协议。(ping、traceroute使用该协议)
  	SOCK_RDM 这个类型是很少使用的,在大部分的操作系统上没有实现,它是提供给数据链路层使用,不保证数据包的顺序
  protocol:
  	传0 表示使用默认协议。
  返回值:
  	成功:返回指向新创建的socket的文件描述符,失败:返回-1,设置errno
  • socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符,应用程序可以像读写文件一样用read/write在网络上收发数据,如果socket()调用出错则返回-1。对于IPv4,domain参数指定为AF_INET。对于TCP协议,type参数指定为SOCK_STREAM,表示面向流的传输协议。如果是UDP协议,则type参数指定为SOCK_DGRAM,表示面向数据报的传输协议。protocol参数的介绍从略,指定为0即可。

bind函数

  #include <sys/types.h> /* See NOTES */
  #include <sys/socket.h>
  int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  sockfd:
  	socket文件描述符
  addr:
  	构造出IP地址加端口号
  addrlen:
  	sizeof(addr)长度
  返回值:
  	成功返回0,失败返回-1, 设置errno
  • 服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接,因此服务器需要调用bind绑定一个固定的网络地址和端口号。

  • bind()的作用是将参数sockfd和addr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听addr所描述的地址和端口号。前面讲过,struct sockaddr *是一个通用指针类型,addr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度。如:

  struct sockaddr_in servaddr;
  bzero(&servaddr, sizeof(servaddr));
  servaddr.sin_family = AF_INET;
  servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
  servaddr.sin_port = htons(6666);
  • 首先将整个结构体清零,然后设置地址类型为AF_INET,网络地址为INADDR_ANY,这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个IP地址,端口号为6666。

listen函数

  #include <sys/types.h> /* See NOTES */
  #include <sys/socket.h>
  int listen(int sockfd, int backlog);
  sockfd:
  	socket文件描述符
  backlog:
  	排队建立3次握手队列和刚刚建立3次握手队列的链接数和

查看系统默认backlog

  cat /proc/sys/net/ipv4/tcp_max_syn_backlog
  • 典型的服务器程序可以同时服务于多个客户端,当有客户端发起连接时,服务器调用的accept()返回并接受这个连接,如果有大量的客户端发起连接而服务器来不及处理,尚未accept的客户端就处于连接等待状态,listen()声明sockfd处于监听状态,并且最多允许有backlog个客户端处于连接待状态,如果接收到更多的连接请求就忽略。listen()成功返回0,失败返回-1。

accept函数

  #include <sys/types.h> 		/* See NOTES */
  #include <sys/socket.h>
  int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  sockdf:
  	socket文件描述符
  addr:
  	传出参数,返回链接客户端地址信息,含IP地址和端口号
  addrlen:
  	传入传出参数(值-结果),传入sizeof(addr)大小,函数返回时返回真正接收到地址结构体的大小
  返回值:
  	成功返回一个新的socket文件描述符,用于和客户端通信,失败返回-1,设置errno
  • 三方握手完成后,服务器调用accept()接受连接,如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。addr是一个传出参数,accept()返回时传出客户端的地址和端口号。addrlen参数是一个传入传出参数(value-result argument),传入的是调用者提供的缓冲区addr的长度以避免缓冲区溢出问题,传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。如果给addr参数传NULL,表示不关心客户端的地址。

connect函数

  #include <sys/types.h> 					/* See NOTES */
  #include <sys/socket.h>
  int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  sockdf:
  	socket文件描述符
  addr:
  	传入参数,指定服务器端地址信息,含IP地址和端口号
  addrlen:
  	传入参数,传入sizeof(addr)大小
  返回值:
  	成功返回0,失败返回-1,设置errno
  • 客户端需要调用connect()连接服务器,connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址。connect()成功返回0,出错返回-1。

read与recv函数

参考:https://blog.csdn.net/superbfly/article/details/72782264
参考:https://www.cnblogs.com/kkshaq/p/4456179.html

read函数返回值讨论

  • read返回 > 0:说明读到了客户端发送来的数据
  • read返回 0:说明客户端主动关闭了连接,TCP中发送了FIN,服务端可以close conn_fd
  • read返回 < 0:说明发生了异常
    image

write与send函数

参考:https://www.zhihu.com/question/274995821

send返回一个大于0的数,不等于发送成功

参考:https://www.cnblogs.com/-citywall123/p/12572801.html

send 发送大小大于缓冲区大小会怎么样?

参考:https://blog.csdn.net/smart55427/article/details/9112941
当send的数据长度大于socket的缓冲区长度时,不管是windows还是linux,send都会分帧发送。

  • 如何判断数据是否发送成功?
    通过send()函数的返回值,应用层只知道数据是否“拷贝”成功,另一端是否接收数据成功仅靠send()函数的返回值是判断不了的,
    对方是否接收数据成功发送端是判断不了的,如果发送端需要知道接收端是否接收数据成功,需要接收端给发送端返回一个值来确定(应用层面确定数据发送成功)

    系统底层确定数据是否发送成功(协议层面确定数据发送成功

      TCP协议会确保数据完整的发送出去,同时保证数据到达接收端的SOCKET接收缓存区
      TCP数据报首部包含发送数据包的基本信息,比如数据长度
      TCP协议的ACK确认机制(怎么判断发的消息对端收到:对端回响应(TCP回的ACK怎么捕获))
    

    当SOCKET发送缓存区的大小为0的时候,也可以确定数据完全发送出去,buffer.size()==0

  • 补充:
    1、滑动窗口在SOCKET的缓存区里面,窗口的大小会根据接收端的处理情况动态变化(保证SOCKET的接收缓冲区不会溢出)
    2、TCP 的SOCKET有接收缓存区和发送缓存区,而UDP的socket只有一个接收缓冲区,没有发送缓冲区,从概念上来说就是只要有数据就发,不管对方是否可以正确接收,所以不缓冲,不需要发送缓冲区。

close与shutdown函数

参考:http://blog.chinaunix.net/uid-29075379-id-3896846.html

半关闭状态如何避免

客户端主动关闭时,发出FIN包,收到服务器的ACK,客户端停留在FIN_WAIT2状态。而服务端收到FIN,发出ACK后,停留在COLSE_WAIT状态。
    这个CLOSE_WAIT状态非常讨厌,它持续的时间非常长,服务器端如果积攒大量的COLSE_WAIT状态的socket,有可能将服务器资源耗尽,进而无法提供服务。
    那么,服务器上是怎么产生大量的失去控制的COLSE_WAIT状态的socket呢?我们来追踪一下。
    一个很浅显的原因是,服务器没有继续发FIN包给客户端。
    服务器为什么不发FIN,可能是业务实现上的需要,现在不是发送FIN的时机,因为服务器还有数据要发往客户端,发送完了自然就要通过系统调用发FIN了,这个场景并不是上面我们提到的持续的COLSE_WAIT状态,这个在受控范围之内。
    那么究竟是什么原因呢,咱们引入两个系统调用close(sockfd)和shutdown(sockfd,how)接着往下分析。
    在这儿,需要明确的一个概念---- 一个进程打开一个socket,然后此进程再派生子进程的时候,此socket的sockfd会被继承。socket是系统级的对象,现在的结果是,此socket被两个进程打开,此socket的引用计数会变成2。

    继续说上述两个系统调用对socket的关闭情况。
    调用close(sockfd)时,内核检查此fd对应的socket上的引用计数。如果引用计数大于1,那么将这个引用计数减1,然后返回。如果引用计数等于1,那么内核会真正通过发FIN来关闭TCP连接。
    调用shutdown(sockfd,SHUT_RDWR)时,内核不会检查此fd对应的socket上的引用计数,直接通过发FIN来关闭TCP连接。

     现在应该真相大白了,可能是服务器的实现有点问题,父进程打开了socket,然后用派生子进程来处理业务,父进程继续对网络请求进行监听,永远不会终止。客户端发FIN过来的时候,处理业务的子进程的read返回0,子进程发现对端已经关闭了,直接调用close()对本端进行关闭。实际上,仅仅使socket的引用计数减1,socket并没关闭。从而导致系统中又多了一个CLOSE_WAIT的socket。。。

如何避免这样的情况发生?
子进程的关闭处理应该是这样的:
shutdown(sockfd, SHUT_RDWR);
close(sockfd);
这样处理,服务器的FIN会被发出,socket进入LAST_ACK状态,等待最后的ACK到来,就能进入初始状态CLOSED。


补充一下shutdown()的函数说明
linux系统下使用shutdown系统调用来控制socket的关闭方式
int shutdown(int sockfd,int how);
参数 how允许为shutdown操作选择以下几种方式:

SHUT_RD:关闭连接的读端。也就是该套接字不再接受数据,任何当前在套接字接受缓冲区的数据将被丢弃。进程将不能对该套接字发出任何读操作。对TCP套接字该调用之后接受到的任何数据将被确认然后被丢弃。
SHUT_WR:关闭连接的写端。
SHUT_RDWR:相当于调用shutdown两次:首先是以SHUT_RD,然后以SHUT_WR

注意:
在多进程中如果一个进程中shutdown(sfd, SHUT_RDWR)后其它的进程将无法进行通信. 如果一个进程close(sfd)将不会影响到其它进程.

基于TCP协议的客户端/服务器程序的一般流程:

  • 在后续文章TCP协议详解会继续介绍
    image

  • 服务器调用socket()、bind()、listen()完成初始化后,调用accept()阻塞等待,处于监听端口的状态,客户端调用socket()初始化后,调用connect()发出SYN段并阻塞等待服务器应答,服务器应答一个SYN-ACK段,客户端收到后从connect()返回,同时应答一个ACK段,服务器收到后从accept()返回。

  • 数据传输的过程:
    建立连接后,TCP协议提供全双工的通信服务,但是一般的客户端/服务器程序的流程是由客户端主动发起请求,服务器被动处理请求,一问一答的方式。因此,服务器从accept()返回后立刻调用read(),读socket就像读管道一样,如果没有数据到达就阻塞等待,这时客户端调用write()发送请求给服务器,服务器收到后从read()返回,对客户端的请求进行处理,在此期间客户端调用read()阻塞等待服务器的应答,服务器调用write()将处理结果发回给客户端,再次调用read()阻塞等待下一条请求,客户端收到后从read()返回,发送下一条请求,如此循环下去。

    如果客户端没有更多的请求了,就调用close()关闭连接,就像写端关闭的管道一样,服务器的read()返回0,这样服务器就知道客户端关闭了连接,也调用close()关闭连接。注意,任何一方调用close()后,连接的两个传输方向都关闭,不能再发送数据了。如果一方调用shutdown()则连接处于半关闭状态,仍可接收对方发来的数据。

  • 在学习socket API时要注意应用程序和TCP协议层是如何交互的: 应用程序调用某个socket函数时TCP协议层完成什么动作,比如调用connect()会发出SYN段 应用程序如何知道TCP协议层的状态变化,比如从某个阻塞的socket函数返回就表明TCP协议收到了某些段,再比如read()返回0就表明收到了FIN段

server端实现

#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <ctype.h>
#include <unistd.h>
#include <stdio.h>
// struct sockaddr_in对应的头文件 <arpa/inet.h> 
#include <arpa/inet.h>  

#define SERV_PORT 6666

int main(void)
{
    struct sockaddr_in serverAddr, clientAddr;
    socklen_t clientLen;
    int listenFd, connectFd;
    char buf[BUFSIZ]; // 默认8k
  	char tmp[INET_ADDRSTRLEN];
    int i, n;

    listenFd = socket(AF_INET, SOCK_STREAM, 0);
    printf("服务端监听的 listenFd=%d\n", listenFd);

    bzero(&serverAddr, sizeof(serverAddr));
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);
    serverAddr.sin_port = htons(SERV_PORT);

    /**********端口复用代码************/
#if 0
    int opt = 1;
    setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
#endif

    bind(listenFd, (struct sockaddr *)&serverAddr, sizeof(serverAddr));
    listen(listenFd, 128); // 一次"三次握手"允许的上限客户端数(监听队列最大长度)

    printf("accepting 正在等待\n");
    // 阻塞等待
    clientLen = sizeof(clientLen);
    connectFd = accept(listenFd, (struct sockaddr *)&clientAddr, &clientLen);
    printf("服务端建立连接后返回的 connectFd=%d\n", connectFd);
    printf("accept done, client ip:%s, port:%d\n", 
            inet_ntop(AF_INET, &clientAddr.sin_addr, tmp, sizeof(tmp)),
            ntohs(clientAddr.sin_port));

    while (1) {
        // 阻塞
        n = read(connectFd, buf, sizeof(buf));
        for (i = 0; i < n; i++) {
          buf[i] = toupper(buf[i]);
        }
        write(connectFd, buf, n);    
    }

    close(listenFd);
    close(connectFd);
    return 0;
}

client端实现

  #include <stdio.h>
  #include <stdlib.h>
  #include <string.h>
  #include <unistd.h>
  #include <sys/socket.h>
  #include <netinet/in.h>
  #include <arpa/inet.h>
  
  #define SERV_IP "127.0.0.1"
  #define SERV_PORT 6666
  
  int main(int argc, char *argv[])
  {
  	struct sockaddr_in servaddr;
  	char buf[BUFSIZ];
  	int sockFd, n;
  
  	sockFd = socket(AF_INET, SOCK_STREAM, 0);
   	printf("客户端的 fd=%d\n", sockFd);
  
  	bzero(&servaddr, sizeof(servaddr));
  	servaddr.sin_family = AF_INET;
  	inet_pton(AF_INET, SERV_IP, &servaddr.sin_addr);
  	servaddr.sin_port = htons(SERV_PORT);

    // 与服务器建立连接
  	connect(sockFd, (struct sockaddr *)&servaddr, sizeof(servaddr));
  
    /* fgets: 读用户输入,每次读一行,然后在最后补'\0'
    *  比如: hello  ---fgets--->  hello\n\0 
    */
    while (fgets(buf, sizeof(buf), stdin)) {
        write(sockFd, buf, strlen(buf)); // strlen利用了buf都要\0结尾
        // 阻塞
        n = read(sockFd, buf, sizeof(buf));
        // printf("Response from server:%s\n", buf); 
        write(STDOUT_FILENO, buf, n);
    }
  	
  	close(sockFd);  
  	return 0;
  }

异常处理:中断产生EINTR错误...

  • 待补充

IO多路复用(后续文章介绍)参考链接

https://cloud.tencent.com/developer/article/1805838

posted @ 2022-11-15 00:02  胖白白  阅读(312)  评论(0编辑  收藏  举报