网络编程套接字

1.预备知识

1.1 认识端口号

在网络通信中,用户通过启动应用软件(即进程)完成数据的发送与接收,因此其本质是进程间通信。这种通信的实现,是通过网络协议栈利用网络资源,让两个不同的进程能"看到"同一份资源------一个从网络中写入数据,一个从网络中读取数据。其中,网络协议的下三层主要负责确保数据安全可靠地传输到远端机器,而真正直接参与通信的则是应用层

1.1.1 端口号的基本概念

  • 端口号(port)是传输层协议的内容,是一个 2 字节 16 位的整数。
  • 作用:用来标识一个进程,告诉操作系统当前的数据要交给哪一个进程来处理。
  • 组合标识:IP 地址 + 端口号能够标识网络上的某一台主机的某一个进程。
  • 占用规则:一个端口号只能被一个进程占用,但是一个进程可以绑定多个端口号。

1.1.2 端口号的核心作用

传输层的报文需要明确交给应用层的哪个应用软件,端口号的引入就是为了实现这一目标。通过端口号可以标识和定位具体的应用软件,确保数据能准确传递给上层对应的应用,在通信过程中始终起到标识应用、精准路由数据的关键作用。

1.1.3 基于端口号的通信流程(以微信客户端 - 微信服务端为例)

客户端发消息流程

  1. 服务器应用层需先绑定系统选定的端口号(如微信服务端绑定 700)。
  2. 客户端发送消息时,在报文中写入目的端口号(700)。
  3. 报文经客户端传输层、网络层、数据链路层处理后,通过网络传递到服务器。
  4. 服务器传输层依据报文中的端口号(700)判断,把有效载荷交给对应端口号绑定的应用(微信服务端),完成客户端到服务器的消息传递。

服务端回消息流程

  1. 服务器的应用(如微信服务端)绑定端口号 700,回发消息时,在报文中携带自身及对应客户端的端口信息(如客户端微信端口 123)。
  2. 报文向下经服务器传输层、网络层、数据链路层处理,再通过网络传回客户端。
  3. 客户端传输层依据端口号(123),将有效载荷交给对应端口绑定的应用(微信客户端),实现双向通信。

1.1.4 socket 套接字机制

  • 在网络进程间通信中,客户端和服务端会分别以client_ip:client_portserver_ip:server_port的形式,明确两个不同进程的唯一标识。
  • 通信时,只需在报文中填入源 IP、目标 IP 以及对应端口,依靠网络将报文准确交付给另一主机,这种基于 IP + 端口组合实现通信的机制,就是 socket 套接字。

1.2 端口号和进程ID

1.2.1 端口号和进程PID的区别

pid已经能标识一个主机上进程的唯一性了,为什么还要搞一个端口号?

  1. 不是所有的进程都需要网络通信,但是所有的进程都要有pid。
  2. 系统和网络解耦。

1.2.2 传输层报文转发与端口绑定相关机制

传输层如何将报文转发给服务器

传输层通过识别报文中的目的端口号,结合自身维护的端口与进程关联信息,将报文准确转发给对应服务器的应用进程。具体来说,传输层会依据报文中的目的端口号,找到与之绑定的进程,进而将报文交付给该进程处理。

应用绑定端口号的本质

  • 传输层内部会构建一张元素类型为 task_struct* 的哈希表。
  • 应用绑定端口号时,本质是:
    1. 用要绑定的端口号在该哈希表中进行哈希运算。
    2. 根据运算得到的地址判断该端口是否已被占用(因一个端口仅能绑定一个进程)。
    3. 若未被占用,就将进程 PCB(进程控制块)的地址填入哈希表对应的位置,完成端口与进程的绑定关联;若已被占用,则无法绑定。

客户端如何知晓服务器的端口号

每一个服务器的端口号是众所周知、经过精心设计的,客户端通过这些公开且固定的端口号来定位服务器对应的应用进程。

2.认识TCP和UDP协议

这两个协议都是传输层的协议。

2.1 TCP 协议(传输层控制协议)

2.1.1 特点

  • 属于传输层协议。
  • 保证传输可靠性:通过一系列机制确保数据准确、完整地传输。
  • 面向连接:通信前客户端需向服务器发起连接请求(服务器一般比较"被动",一致处于等待连接到来的状态),连接建立成功后才能传输数据。
  • 面向字节流:数据以字节流的形式传输,无明显数据边界。
  • 全双工:允许同时进行读写操作。

2.1.2 应用场景

TCP 因具备连接建立、确认重传、有序交付等保证可靠性的机制,会产生额外开销(如握手延迟、流量控制计算等)。

适用于对数据准确性和完整性要求极高的场景,例如:

  • 文件传输
  • 网页加载
  • 邮件发送
  • 银行相关业务发送等(即使传输稍慢,也需保证数据可靠)。

2.2 UDP 协议(用户数据报协议)

2.2.1 特点

  • 属于传输层协议。
  • 无连接:通信前无需建立连接,直接发送数据。
  • 不可靠传输:发送数据后不关注接收方是否收到,可能出现数据乱序、丢失等问题,无法保证数据准确完整到达。
  • 面向数据报:发送的数据有明显边界,发送方发一个数据报,接收方需完整接收,以独立数据报为传输和接收单位。
  • 全双工:允许同时进行读写操作。

2.2.2 应用场景

UDP 抛弃复杂的可靠性机制,仅负责简单的数据封装和传输,虽无法保证可靠性,但延迟更低、传输效率更高。

适用于对延迟敏感,少量丢包或乱序影响较小的场景,例如:

  • 实时视频通话
  • 在线游戏
  • 直播等(不能忍受因重传等机制导致的卡顿)。

3.网络字节序列

3.1 大小端

内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分,网络数据流同样有大端小端之分。那么如何定义网络数据流的地址呢?

  • 发送和接收数据均按内存地址从低到高进行,先发数据对应低地址,后发对应高地址
  • TCP/IP规定网络数据流采用大端字节序(低地址存高字节)。
  • 无论主机是大端还是小端,发送时均需遵循该字节序:小端机需先转换,大端机可直接发送

3.2 字节序转换函数

为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。

  • h表示host,n表示network,l表示32位长整数,s表示16位短整数。
  • 例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
  • 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回 ;
  • 如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。

4.socket编程接口

4.1 socket 创建网络通信套接字

cpp 复制代码
#include <sys/types.h>          
#include <sys/socket.h>

int socket(int domain, int type, int protocol); 

**功能:**创建网络通信的套接字(端点),作为后续通信的标识(客户端和服务器均需调用)。

参数:

  • domain :协议族(地址族),如 AF_UNIX和 AF_LOCAL(本地通信)、AF_INET (IPv4)、 AF_INET6 (IPv6)。
  • type :套接字类型, SOCK_STREAM (TCP,字节流)、 SOCK_DGRAM (UDP,数据报)。
  • protocol :具体协议号,通常为 0 (由前两个参数自动推导)。

**返回值:**成功返回非负套接字描述符;失败返回 -1 ,并设置 errno 。

4.2 bind 将套接字与IP和端口绑定

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h> 

int bind(int socket, const struct sockaddr *address, socklen_t address_len); 

**功能:**将套接字与特定IP和端口绑定(主要用于服务器,固定通信地址)。

参数:

  • socket : socket() 返回的套接字描述符。
  • address :指向 struct sockaddr (或派生结构体,如 struct sockaddr_in )的指针,存储绑定的IP和端口。
  • address_len : address 结构体的长度(如 sizeof(struct sockaddr_in) )。

**返回值:**成功返回 0 ;失败返回 -1 ,并设置 errno (如端口被占用返回 EADDRINUSE )。

客户端不需要显式通过编码绑定,因为客户端在没有显式编码绑定的情况下,会由操作系统自动完成端口绑定。这是因为 TCP/UDP 协议中,客户端的端口主要用于标识本地进程与服务器的通信链路,无需手动指定固定值。当客户端调用 connect ()(TCP)或首次 sendto ()(UDP)时,操作系统会自动从可用端口池中分配一个临时端口,并完成与客户端套接字的绑定,从而简化客户端编程,避免手动管理端口冲突问题。

在实际场景中,如微信聊天时,客户端无需显式绑定端口,这是因为当用户发送消息时,微信客户端会通过系统自动从动态端口池(如 1024-65535)分配临时端口并完成绑定,既避免了手动指定端口可能引发的冲突,又满足了通信对端到端链路标识的需求,同时简化了应用开发,让用户无需关心端口管理细节即可正常使用。

4.3 listen 监听套接字

cpp 复制代码
#include <sys/socket.h> 

int listen(int socket, int backlog); 

**功能:**将TCP套接字设为监听状态,准备接收客户端连接(仅TCP服务器使用)。

参数:

  • socket :已绑定的TCP套接字描述符。
  • backlog :未完成连接队列的最大长度(一般不建议设置太大,因为超过则可能拒绝新连接)。

**返回值:**成功返回 0 ;失败返回 -1 ,并设置 errno 。

4.4 accept 获知客户端连接

cpp 复制代码
#include <sys/socket.h> 

int accept(int socket, struct sockaddr* address, socklen_t* address_len); 

**功能:**从TCP服务器的监听队列中取出已完成握手的客户端连接,创建新套接字用于单独通信(仅TCP服务器使用)。

参数:

  • socket :处于监听状态的TCP套接字描述符(监听套接字)。
  • address :输出型参数,(可选)存储客户端IP和端口的 struct sockaddr 指针(可传 NULL )。
  • address_len :输出型参数,(可选) address 的长度指针( address 为 NULL 时也传 NULL )。

**返回值:**成功返回新的通信套接字描述符;失败返回 -1 ,并设置 errno 。

参数socket与返回值socket的区别:

监听套接字(accept参数)

  • socket创建,经bind绑定端口、listen进入监听状态。
  • 作用:被动接收客户端连接请求(类似 "总机",只接请求不通信)。
  • 特性:服务器启动后长期存在,固定绑定端口。

连接套接字(accept返回值)

  • accept在接收连接时动态创建。
  • 作用:与单个客户端进行数据收发(类似 "分机",负责具体通信)。
  • 特性:每个客户端连接对应一个,断开后关闭。

4.5 connect 向服务器发起连接请求

cpp 复制代码
#include <sys/socket.h> 

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); 

**功能:**TCP客户端向服务器发起连接请求,完成三次握手(仅TCP客户端使用)。

参数:

  • sockfd :客户端通过 socket() 创建的套接字描述符。
  • addr :指向 struct sockaddr 的指针,存储服务器的IP和端口。
  • addrlen : addr 结构体的长度(如 sizeof(struct sockaddr_in) )。

**返回值:**成功返回 0 (连接建立);失败返回 -1 ,并设置 errno (如服务器未启动返回 ECONNREFUSED )。

4.6 recvform 接收数据

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);

**功能:**从指定的套接字( sockfd )接收数据,并获取发送方的地址信息(适用于无连接协议,如 UDP)。

参数:

  • sockfd ( int ):已创建并绑定(或连接)的套接字描述符,用于指定接收数据的套接字。
  • buf ( void * ):指向接收缓冲区的指针,用于存储接收到的数据。
  • len ( size_t ):接收缓冲区的大小(字节数),指定最多能接收的数据量。
  • flags ( int ):接收数据的标志,通常设为 0 (默认行为)。常见可选值:
    • MSG_OOB :接收带外数据(仅 TCP 支持)。
    • MSG_PEEK :查看数据但不从缓冲区移除(数据仍可被后续调用读取)。
  • src_addr ( struct sockaddr * ):输出型参数,指向套接字地址结构体的指针,用于存储发送方的地址信息(如 IP 地址、端口号);若不需要获取发送方地址,可设为 NULL (如 TCP 已连接状态)。
  • addrlen ( socklen_t * ):输出型参数,指向 socklen_t 类型的指针,用于指定 src_addr 缓冲区的大小(输入),以及实际存储的地址长度(输出)。

返回值( ssize_t )

  • 成功:返回接收到的字节数( >=0 )。
  • 失败:返回 -1 ,并设置 errno 指示错误原因(如 EAGAIN 表示非阻塞模式下无数据, EBADF 表示套接字描述符无效等)。
  • 特殊情况:若连接被关闭(如 TCP 对方关闭连接),可能返回 0 。

4.7 sendto 发送数据

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);

**功能:**向指定的目标地址发送数据,可直接用于无连接的套接字(如 UDP),无需提前建立连接;若套接字已连接(如通过 connect 绑定了目标地址),也可省略目标地址参数使用。

参数:

  • sockfd ( int ):已创建的套接字描述符,用于指定发送数据的套接字。
  • buf ( const void * ):指向待发送数据的缓冲区指针,即要发送的数据内容。
  • len ( size_t ):待发送数据的字节数,即 buf 中有效数据的长度。
  • flags ( int ):发送数据的标志,通常设为 0 (默认行为)。常见可选值:
  • MSG_OOB :发送带外数据(仅 TCP 支持)。
  • MSG_DONTROUTE :忽略路由表,直接发送到本地网络。
  • dest_addr ( const struct sockaddr * ):
  • 指向目标地址结构体的指针,包含接收方的 IP 地址、端口号等信息(适用于无连接套接字)。若套接字已通过 connect 连接到目标地址,可设为 NULL 。
  • addrlen ( socklen_t ):目标地址结构体 dest_addr 的长度(字节数),如 sizeof(struct sockaddr_in) (IPv4 地址)。

返回值( ssize_t )

  • 成功:返回实际发送的字节数( >=0 ),通常与 len 相等,但若被信号中断可能小于 len 。
  • 失败:返回 -1 ,并设置 errno 指示错误原因(如 EINVAL 表示参数无效, ENOTCONN 表示未连接且未指定目标地址等)。

为何端口号(port)需要进行大小端转换,而通过 recvfrom 和 sendto 发送的消息无需此类转换?

端口号(port)作为需要写入操作系统内核协议栈的标准化数值型字段,必须遵循网络字节序(大端序)规范以确保不同主机系统间的正确解析,而主机自身可能采用小端序,因此需要进行大小端转换;而recvfrom和sendto发送的消息是应用层数据,其格式与字节序由应用程序自行定义,不直接写入操作系统内核的协议栈字段,无需遵循网络字节序标准,故无需转换。

4.8 setsockopt 设置套接字选项参数的系统调用

cpp 复制代码
#include <sys/types.h>          
#include <sys/socket.h>

int setsockopt(int sockfd, int level, int optname, void *optval, socklen_t optlen);

参数:

  • sockfd: 要设置选项的套接字文件描述符(如 socket() 返回的监听套接字)。

  • **level:**选项所属的协议层。常见值包括:

    • SOL_SOCKET:通用套接字选项(不依赖具体协议);
    • IPPROTO_TCP:TCP 协议专属选项;
  • optname:具体要设置的选项名。例如:

    • SOL_SOCKET 层:SO_REUSEADDR(允许端口复用)、SO_REUSEPORT(允许多个进程 / 线程的 socket 绑定到同一 IP + 端口);
  • optval:指向存储选项值的缓冲区(如启用端口复用时,传入指向 int 类型值 1 的指针)。

  • optlenoptval 缓冲区的长度(以字节为单位)。

返回值:

  • 成功返回 0,表示选项设置生效。
  • 失败返回 -1,并设置 errno 指示错误原因(如权限不足、选项不支持等)。

4.9 相关结构体

套接字主要分为三类:
• 域间套接字编程:用于同一台机器的本地通信,对应 struct sockaddr_un 结构体。
• 原始套接字编程:可绕过传输层,直接使用网络层或链路层接口,常用于编写网络工具。
• 网络套接字编程:用于不同设备间的网络通信,对应 struct sockaddr_in 结构体。

4.9.1 通用结构 struct sockaddr(接口兼容核心)

sockaddr 结构是网络编程中用于统一不同类型套接字参数的核心结构,其设计目的是让各类套接字通信能通过一套统一接口实现,关键在于对不同套接字类型的兼容。

cpp 复制代码
#include <sys/types.h>  
#include <sys/socket.h>  

struct sockaddr {  
    sa_family_t sa_family;  // 16位地址类型(如 AF_INET、AF_UNIX)  
    char        sa_data[14]; // 14字节存具体地址数据(需配合类型解析)  
};  

4.9.2 网络套接字(IPv4)结构体 struct sockaddr_in

cpp 复制代码
#include <netinet/in.h>  

struct sockaddr_in {  
    sa_family_t sin_family; // 填 AF_INET ,固定标识IPv4地址族 
    in_port_t sin_port; // 16位端口号,常用htons()转网络字节序;也可填 0 ,由系统分配临时端口 
    struct in_addr sin_addr; //32位IP地址结构体,s_addr常用inet_addr()或htonl()转网络字节序,也可填INADDR_ANY(绑定所有网卡 )、INADDR_LOOPBACK(127.0.0.1 ,本地测试用 ) 
    unsigned char  sin_zero[8]; // 填充字节,使与sockaddr大小一致,一般用bzero或memset置0 
};  

// IP地址子结构体  
struct in_addr {  
    in_addr_t s_addr;  // 32位IP地址,需转换网络字节序,支持特殊值(如 INADDR_ANY ) 
};  

4.9.3 域间套接字(本地通信)结构体 struct sockaddr_un

cpp 复制代码
#include <sys/un.h>  

struct sockaddr_un {  
    sa_family_t sun_family; // 填 AF_UNIX(或 AF_LOCAL ,二者等价 ),标识本地域通信 
    char sun_path[108]; // 本地套接字路径,支持文件系统路径(如/tmp/test.sock)或抽象命名空间(以\0开头,如sun_path[0] = '\0'; strcpy(sun_path + 1, "test"),不生成实际文件 ) 
};  

4.9.4 IP地址绑定注意事项

(一)虚拟机与云服务器绑定差异

虚拟机:可尝试直接绑定公网 IP(若网络配置为桥接且获取到真实公网 IP ),但实际场景较少这样用。

云服务器 :无论是TCP还是UDP都禁止直接bind公网 IP,原因如下:

  • 云服务器公网 IP 多是云厂商通过 NAT 技术分配的虚拟 IP,并非网卡直接配置的真实 IP,实际绑定的是内网 IP,公网流量需经网关转发至内网 IP,直接绑定公网 IP 技术不可行;
  • 云服务器可能有多个网卡及对应 IP,仅绑定公网 IP(虚拟映射 ),会使服务器只能接收该 IP 交互报文,而云服务流量转发依赖内网 IP,会阻断正常通信、影响公网访问。

正确做法

  • 绑定0.0.0.0(对应INADDR_ANY ),监听所有网卡(任意地址绑定 ),接收发给主机且符合端口的流量,向上交付给对应端口;
  • 也可直接绑定内网 IP(云服务器网卡实际配置的 IP ),适配云服务流量转发逻辑。

(二)本地环回地址127.0.0.1

127.0.0.1是本地环回地址,常用于C/S 架构本地测试(如开发阶段让客户端和服务端在同一机器通信,不走物理网卡 ),绑定该地址时,仅本机进程可通过此地址访问服务。

4.9.5 端口相关知识

端口用于标识同一主机上不同的网络应用,范围是 0 - 65535 ,不同区间特性不同:

端口区间 特性说明 使用建议
0 - 1023 系统 / 知名端口,固定给常用应用层协议(如 HTTP 用 80、HTTPS 用 443、MySQL 用 3306 ) 未被占用的端口,需管理员权限(root )才能绑定,普通用户一般不建议使用
1024 - 49151 注册端口,给用户程序或应用层协议注册使用(如一些自定义服务、小众协议 ) 建议开发时绑定此区间端口,无需特殊权限(普通用户可操作 ),避免与系统端口冲突
49152 - 65535 动态 / 私有端口,系统临时分配给客户端程序(如浏览器访问服务时,系统临时选端口 ) 一般无需手动绑定,由系统自动分配,用于客户端发起连接时的临时源端口

补充说明:0 - 1023 端口并非全部被占用,若需绑定未被占用的系统端口,需通过管理员权限实现;1024 以上端口也不是都能随意用,要避免与已有应用冲突(如其他程序已占用某端口 )。

4.10 地址转换函数

我们通常用点分十进制的字符串表示IP地址,以下函数可以在字符串表示和in_addr表示之间转换:

4.10.1 字符串转in_addr的函数

cpp 复制代码
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int inet_aton(const char* strptr, struct in_addr* addrptr);

参数:

  • strptr:指向点分十进制 IP 地址字符串的指针。
  • addrptr:指向 in_addr 结构体的指针,用于存储转换后的结果。

返回值:转换成功返回非 0 值,失败返回 0。

cpp 复制代码
in_addr_t inet_addr(const char *cp);

参数cp:指向点分十进制 IP 地址字符串的指针。

返回值:转换成功返回对应的 32 位网络字节序整数;失败返回 INADDR_NONE(通常为 - 1,但需注意特殊情况)。

cpp 复制代码
int inet_pton(int family,const char* strptr,void* addrptr);

功能:是一个更通用的函数,支持 IPv4 和 IPv6 地址,将字符串形式的 IP 地址转换为对应的网络字节序二进制形式。

参数:

  • family:地址族,如 AF_INET(IPv4)、AF_INET6(IPv6)。
  • strptr:指向字符串形式 IP 地址的指针。
  • addrptr:指向存储转换后二进制结果的缓冲区指针。

返回值:转换成功返回 1;输入不是有效的格式返回 0;发生错误返回 - 1。

关于inet_ntoa与inet_ntop

  • inet_ntoa这个函数返回了一个char*,很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果,man手册上说,inet_ntoa函数是把这个返回结果放到了静态存储区,这个时候不需要我们手动进行释放。但是如果多次调用会出现覆盖掉上一次结果的情况,所以不建议使用inet_ntoa。
  • 而inet_ntop不会出现这种情况,因为inet_ntop 要求用户主动提供缓冲区:调用时需要传入一个用户自己分配的内存缓冲区(以及缓冲区大小),函数会将转换后的 IP 地址字符串写入这个缓冲区。由于缓冲区是用户管理的,每次调用可以使用不同的缓冲区,或者在重复使用同一缓冲区前做好数据保存,因此不会出现覆盖前一次结果的问题。

4.10.2 in_addr转字符串的函数

cpp 复制代码
char *inet_ntoa(struct in_addr in);

参数in:in_addr 结构体类型的 IP 地址。

返回值:返回指向转换后的点分十进制字符串的指针,该字符串存储在静态缓冲区中,后续调用会覆盖此缓冲区。

cpp 复制代码
const char* inet_ntop(int family,const void *addrptr,char* strptr,size_t len);

功能:通用函数,支持 IPv4 和 IPv6,将网络字节序的二进制 IP 地址转换为字符串形式。

参数:

  • family:地址族,如 AF_INET、AF_INET6。
  • addrptr:指向网络字节序二进制 IP 地址的指针。
  • strptr:指向存储转换后字符串的缓冲区指针。
  • len:缓冲区的大小。

返回值:转换成功返回指向字符串的指针;失败返回 NULL,并设置 errno。

5.netstat 命令

5.1 命令作用

netstat 是一款用于显示网络连接状态、路由表、接口统计等网络相关信息的命令行工具,常用于排查网络连接问题、查看端口占用情况、监控网络活动等场景。

5.2 常用参数

  • -a:显示所有连接(包括监听和非监听状态的套接字)。
  • -t:仅显示 TCP 协议相关的连接。
  • -u:仅显示 UDP 协议相关的连接。
  • -x:显示 UNIX 域套接字相关的连接。
  • -l:仅显示处于监听(Listening)状态的连接,常与 -t /-u 结合使用(如 netstat -tl 查看 TCP 监听端口)。
  • -p:显示每个连接对应的进程 PID 和名称(需要 root 权限,否则可能显示不全)。
  • -n:以数字形式显示 IP 地址和端口号(不进行域名解析,速度更快),例如直接显示 192.168.1.1:80 而非域名。

5.3 输出结果字段含义

  • Proto:当前连接所使用的网络协议,常见的有 TCP(传输控制协议)、UDP(用户数据报协议)、RAW(原始协议)等。
  • Recv-Q:已到达本地但尚未被应用程序读取的数据包数量(单位为字节)。
  • Send-Q:已由应用程序发送但尚未成功传递到对方的数据包数量(单位为字节)。
  • Local Address:本地地址,格式为 "IP 地址:端口号",表示当前连接在本机上绑定的 IP 和端口。例如 192.168.1.100:8080 表示本机的 192.168.1.100 这个 IP 的 8080 端口。
  • Foreign Address:远端地址,格式同样为 "IP 地址:端口号",表示与本机建立连接的外部设备的 IP 和端口。若显示 0.0.0.0:(针对 IPv4)或 ::(针对 IPv6),通常出现在监听状态的连接中,意为该端口可接收来自本机任意 IP(或任意远端 IP)、任意端口的连接请求。
  • State:连接状态,仅 TCP 协议有此字段(UDP 为无连接协议,无状态),常见状态包括 LISTEN(监听中,等待连接)、ESTABLISHED(已建立连接)、SYN_SENT(正在发起连接请求)、TIME_WAIT(连接已关闭,等待超时)等。
  • PID/Program name:进程信息,显示当前连接对应的进程 ID(PID)和进程名称。通过该字段可快速定位占用端口的应用程序(需管理员权限才能完整显示)。

6.终端

在Linux系统中,执行 ls /dev/pts/ 命令可以查看 /dev/pts 目录下的内容,该目录存放着当前系统中所有伪终端(Pseudoterminal)的设备文件,这些文件以数字(如0、1、2等)命名,每个数字对应一个正在运行的伪终端实例,通常关联着当前打开的终端窗口、SSH连接、远程登录会话等交互界面,能反映出系统中正在使用的所有"虚拟终端会话"的标识。

  • 02:当前正在使用的 2 个伪终端(SSH 连接、终端窗口),对应pts/0pts/2,是和系统交互的接口。
  • ptmx:所有伪终端的 "管理核心",负责生成新的伪终端。

7.简单的UDP网络程序

udp_client.cpp(UDP 客户端代码)

cpp 复制代码
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>   
#include <arpa/inet.h>   
#include <netinet/in.h>   
#include <string.h>       
#include <unistd.h>       
#include <stdio.h>
using namespace std;

// 定义服务器IP(127.0.0.1为本地回环地址,仅本机可访问)
const string ip = "127.0.0.1";
// 定义服务器端口(需与服务器端口一致)
const uint16_t port = 8888;

int main()
{
    // 1. 创建UDP套接字:AF_INET(IPv4协议)、SOCK_DGRAM(UDP类型)、0(默认协议)
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)  // 套接字创建失败(返回-1)
    {
        perror("socket create");  
        exit(1);                  
    }

    // 2. 初始化服务器地址结构体
    struct sockaddr_in server;    // 存储服务器IPv4地址信息的结构体
    socklen_t len = sizeof(server); // 地址结构体长度
    memset(&server, 0, sizeof(server)); // 清空结构体(避免垃圾值)
    server.sin_family = AF_INET;       // 协议族:IPv4
    server.sin_port = htons(port);     // 端口号:将主机字节序转为网络字节序(大端)
    // IP地址:将字符串格式的IP(如"127.0.0.1")转为网络字节序的二进制IP
    inet_pton(AF_INET, ip.c_str(), &server.sin_addr);

    string msg;         // 存储用户输入的发送消息
    char reply[1024];   // 存储服务器回复的消息缓冲区

    // 3. 循环:持续发送消息给服务器 + 接收服务器回复
    while (1)
    {
        cout << "Please send a message:";
        getline(cin, msg);  // 读取用户输入的消息

        // 4. 发送消息给服务器:
        sendto(sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr *)&server, len);

        // 5. 接收服务器的回复:
        // reply:存储回复的缓冲区;sizeof(reply)-1:预留1字节存字符串结束符'\0';
        
        ssize_t n = recvfrom(sockfd, reply, sizeof(reply) - 1, 0, (struct sockaddr *)&server, &len);
        if (n < 0)  // 接收失败(如网络错误)
        {
            perror("recvform");  
            continue;           
        }
        else
        {
            reply[n] = '\0';  // 给接收的消息加字符串结束符(确保打印正常)
            cout << "get a server's reply:" << reply << endl;  // 打印服务器回复
        }
    }

    close(sockfd);  // 关闭套接字
    return 0;
}

udp_server.cpp(UDP 服务器代码)

cpp 复制代码
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>   
#include <arpa/inet.h>    
#include <netinet/in.h>   
#include <string.h>
#include <unistd.h>       
using namespace std;

// 定义服务器监听端口(需与客户端目标端口一致)
const uint16_t defaultport = 8888;

int main()
{
    // 1. 创建UDP套接字:AF_INET(IPv4)、SOCK_DGRAM(UDP)、0(默认协议)
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)  // 套接字创建失败
    {
        perror("socket create");  
        exit(1);                  
    }

    // 2. 初始化服务器地址结构体(用于绑定端口)
    struct sockaddr_in server;    // 服务器地址结构体
    memset(&server, 0, sizeof(server)); // 清空结构体
    server.sin_family = AF_INET;       // 协议族:IPv4
    server.sin_port = htons(defaultport); // 端口号:主机字节序转网络字节序
    // IP地址:INADDR_ANY(绑定本机所有网卡的IP,允许任意网卡接收客户端请求)
    server.sin_addr.s_addr = INADDR_ANY;

    // 3. 绑定套接字与地址(UDP服务器必须绑定端口,否则客户端无法定位)
    int ret = bind(sockfd, (struct sockaddr *)&server, sizeof(server));
    if (ret == -1)  // 绑定失败(如端口已被占用、权限不足)
    {
        perror("bind");  
        exit(0);         
    }

    cout << "UDP server start..." << endl;  // 提示服务器启动成功
    char buffer[1024];                      // 存储客户端请求消息的缓冲区
    struct sockaddr_in client;              // 存储客户端地址(用于回复)
    socklen_t len = sizeof(client);         // 客户端地址结构体长度

    // 4. 循环:持续接收客户端请求 + 回复客户端
    while (1)
    {
        // 5. 接收客户端消息:
        ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&client, &len);
        if (n < 0)  // 接收失败
        {
            perror("recvform failed");  
            continue;                  
        }

        buffer[n] = '\0';  // 给消息加字符串结束符
        // 打印客户端请求(包含客户端地址的IP和端口,需转为主机字节序)
        cout << "get a client request:" << buffer 
             << " (client IP: " << inet_ntoa(client.sin_addr) 
             << ", port: " << ntohs(client.sin_port) << ")" << endl;

        // 6. 构造回复消息并发送给客户端
        string reply = "server got client's message.";  // 固定回复内容
        // 发送回复:目标地址为客户端地址(从recvfrom获取)
        sendto(sockfd, reply.c_str(), reply.size(), 0, (struct sockaddr *)&client, len);
    }

    close(sockfd);  // 关闭套接字(while(1)循环不会退出,此处代码不执行)
    return 0;
}

运行顺序:先启动服务器,再启动客户端(服务器需先绑定端口监听,客户端才能发送请求)

具体交互流程:客户端负责向服务器发送用户输入的自定义消息,同时接收服务器回复的固定内容「server got a message.」;服务器接收到客户端发送的自定义消息之后,向客户端发送固定回复「server got a message.」。

8.TCP网络程序

8.1 telnet 工具使用

8.1.1 工具核心定义

  • 本质:一款远程登录指定服务的客户端工具。
  • 底层协议 :默认基于 TCP 协议 实现通信。

8.1.2 主要用途

  • 测试目标服务器的 端口连通性(判断端口是否开放)。
  • 进行简单的 TCP 协议调试(模拟客户端与服务器的 TCP 交互)。

8.1.3 基本操作流程

1.建立连接

  • 命令格式:telnet [目标IP地址] [端口号]

  • 示例:

    cpp 复制代码
    telnet 192.168.1.1 80(连接 IP 为 192.168.1.1 的 80 端口)

2.连接结果判断

  • 成功:进入交互界面,可直接输入数据发送至服务器。
  • 失败:常见提示为 "连接超时""拒绝连接",需排查以下问题:
    • 目标 IP 地址是否正确
    • 目标端口号是否正确
    • 服务器是否在该端口监听

3.进入通信交互模式

连接成功后,直接输入内容并按 回车,即可向服务器发送数据。

若需切换到 telnet 控制台(查看命令帮助等):按 Ctrl + ](先按 Ctrl 键,再按右中括号键)。

4.退出连接

方法 1:在交互界面直接输入 quit 并按回车。

方法 2:先按 Ctrl + ] 进入 telnet 控制台,再输入 quit 并按回车。

8.2 单进程服务器模型

单执行流 TCP 服务端代码(server.cpp)

功能:创建 socket → 绑定端口 → 监听连接 → 接收客户端连接 → 读取客户端消息 → 回声回复 → 关闭连接。

cpp 复制代码
#include <iostream>
#include <sys/types.h> 
#include <sys/socket.h>
#include <cstdio>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#define port 8880       // 服务器监听端口
#define N 1024          // 缓冲区大小
using namespace std;

// 处理客户端连接的函数
// 参数: 新连接的文件描述符、客户端IP、客户端端口
void service(int newfd, const string &clientip, const uint16_t &clientport)
{
    char client_msg[N];  // 存储客户端消息的缓冲区
    // 循环读取客户端发送的消息
    while (1)
    {
        // 读取客户端发送的数据
        ssize_t n = read(newfd, client_msg, N);
        if (n > 0)  // 成功读取到数据
        {
            client_msg[n] = '\0';  // 添加字符串结束符
            cout << "client say:" << client_msg << endl;  // 打印客户端消息
            
            // 构造回复消息
            string reply = "TCP server reply: I have got client's message. ";
            reply += client_msg;  // 附加客户端发送的内容
            
            // 向客户端发送回复
            write(newfd, reply.c_str(), reply.size());
        }
        else if (n == 0)  // 客户端主动关闭连接
        {
            printf("[%s:%d] quit, server close newfd %d\n", clientip.c_str(), clientport, newfd);
            break;  // 退出循环,结束对该客户端的服务
        }
        else  // 读取操作出错
        {
            cout << "read error, newfd:" << newfd << endl;
            break;  // 退出循环
        }
    }
}

int main()
{
    // 1.创建socket套接字
    // 参数: AF_INET表示IPv4协议, SOCK_STREAM表示TCP协议, 0表示默认协议
    int serverfd = socket(AF_INET, SOCK_STREAM, 0);
    if (serverfd == -1)  // 创建失败
    {
        perror("socket failed");  
        exit(1);  
    }

    // 2.设置socket选项(允许端口复用)
    // 避免服务器重启时出现"地址已在使用"的错误
    int opt = 1;
    socklen_t optlen = sizeof(opt);
    // SOL_SOCKET: 套接字级别  SO_REUSEADDR: 允许重用本地地址和端口
    int ret = setsockopt(serverfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, optlen);
    if (ret == -1)
    {
        perror("setsockopt failed");
        exit(2);
    }

    // 3.配置服务器地址结构
    struct sockaddr_in local;  // 存储本地地址信息的结构体
    local.sin_family = AF_INET;  // IPv4协议
    local.sin_port = htons(port);  // 将端口号转换为网络字节序
    local.sin_addr.s_addr = INADDR_ANY;  // 绑定到所有可用的网络接口

    // 4.绑定socket到指定端口
    // 将创建的套接字与服务器地址结构绑定
    if (bind(serverfd, (struct sockaddr *)&local, sizeof(local)) < 0)
    {
        perror("bind failed");
        exit(3);
    }

    // 5.开始监听连接
    // 第二个参数为监听队列的最大长度,表示最多同时处理3个未完成连接
    if (listen(serverfd, 3) < 0)
    {
        perror("listen failed");
        exit(4);
    }

    cout << "Server is running, listening port " << port << " ...." << endl;
    
    // 循环接收客户端连接
    while (1)
    {
        // 6.接收客户端连接
        struct sockaddr_in client;  // 存储客户端地址信息的结构体
        socklen_t len = sizeof(client);  // 地址结构体的长度
        
        // 阻塞等待客户端连接,成功返回新的套接字描述符
        int newfd = accept(serverfd, (struct sockaddr *)&client, &len);
        if (newfd == -1)  // 接收连接失败
        {
            perror("accept failed");
            continue;  // 继续等待下一个连接
        }
        
        // 转换客户端端口和IP地址为本地字节序和字符串格式
        uint16_t clientport = ntohs(client.sin_port);  // 网络字节序转主机字节序
        char clientip[32];
        // 将网络字节序的IP地址转换为字符串格式
        inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
        cout << "client has connect. [" << clientip << ":" << clientport << "]" << endl;

        // 处理客户端请求
        service(newfd, clientip, clientport);
        // 关闭与该客户端的连接
        close(newfd);
    }
    
    // 9.关闭服务器监听套接字(实际不会执行到这里,因为上面是无限循环)
    close(serverfd);
    cout << "服务器已经关闭" << endl;

    return 0;
}

TCP 客户端代码(client.cpp)

功能:创建 socket → 连接服务端 → 发送消息 → 接收服务端响应 → 关闭连接。

cpp 复制代码
#include <iostream>
#include <sys/types.h> 
#include <sys/socket.h>
#include <cstdio>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#define serverPort 8880  // 服务器端口号
#define serverIP "127.0.0.1"  // 服务器IP地址
#define N 1024  // 缓冲区大小
using namespace std;

int main()
{
    // 1.创建socket文件描述符
    // 参数: AF_INET表示IPv4协议, SOCK_STREAM表示TCP协议
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1)  // 创建失败
    {
        perror("socket failed");  
        exit(1);  
    }

    // 2.配置服务端地址结构
    struct sockaddr_in server_addr;  // 存储服务器地址信息的结构体
    server_addr.sin_family = AF_INET;  // IPv4协议
    server_addr.sin_port = htons(serverPort);  // 服务器端口号(主机字节序转网络字节序)

    // 3.将IP地址从字符串转换为网络字节序
    // 把点分十进制表示的IP地址转换为网络字节序的二进制形式
    inet_pton(AF_INET, serverIP, &(server_addr.sin_addr));

    // 4.连接服务端
    // 发起TCP连接,与服务器建立连接
    if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
    {
        perror("connect failed");  // 连接失败
        exit(2);
    }
    cout << "Client has successfully connected server. [" << serverIP << ":" << serverPort << "]" << endl;

    string msg;  // 存储用户输入的消息
    // 循环与服务器进行通信
    while (1)
    {
        // 5.输入并发送消息给服务端
        cout << "Please send message:";
        getline(cin, msg);  // 读取用户输入
        // 发送消息到服务器
        write(sockfd, msg.c_str(), msg.size());

        // 6.接收服务端响应
        char ser_reply[N];  // 存储服务器回复的缓冲区
        // 读取服务器发送的数据
        int n = read(sockfd, ser_reply, sizeof(ser_reply));
        if (n > 0)  // 成功读取到数据
        {
            ser_reply[n] = '\0';  // 添加字符串结束符
            cout << ser_reply << endl;  // 打印服务器回复
        }
    }

    // 7.关闭连接(实际不会执行到这里,因为上面是无限循环)
    close(sockfd);
    return 0;
}

8.3 多进程服务器模型

需要注意的问题

1.多进程服务器模型中父子进程套接字关闭逻辑与资源管理

在多进程服务器模型中,子进程关闭监听套接字(listen_sockfd)是因为它仅负责与已连接客户端通信,无需继续监听新连接,避免文件描述符资源浪费;父进程关闭通信套接字(communication_sockfd)是因为该套接字的作用已由子进程接管,父进程需释放此资源以继续接受新连接,同时通过waitpid回收子进程资源,这样既保证父子进程职责分离(父进程监听、子进程通信),又避免文件描述符泄露。

2.多进程TCP服务器中子进程资源回收及僵尸进程产生的问题

多进程服务端模型中需要考虑进程回收问题。

若子进程负责通信,其长时间运行会导致父进程被waitpid阻塞而无法接收新连接 ,且由于父子进程执行顺序不确定子进程处理通信耗时可能很长父进程可能在子进程退出前就执行完waitpid(导致失败)或因处理新连接而无法及时回收子进程使其成为僵尸进程

为了解决上述问题,下面给了两种版本的代码来解决这个问题:

8.3.1 版本1:让孙子进程负责进程通信

如果让子进程创建孙子进程后立即退出,父进程能通过waitpid瞬间回收子进程,避免阻塞以继续接收新连接,孙子进程则被系统接管,退出后由系统自动回收,既解决了僵尸进程问题,又保证了并发处理能力。

TCP服务端代码(server.cpp)

cpp 复制代码
#include <iostream>
#include <sys/types.h> 
#include <sys/socket.h>
#include <cstdio>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define port 8808
#define N 1024
using namespace std;

enum{
    SOCKET_ERROR=1,
    SETSOCKOPT_ERROR,
    BIND_ERROR,
    LISTEN_ERROR,
    WAIT_ERROR,
    FORK_ERROR
};
void service(int communication_sockfd, const string &clientip, const uint16_t &clientport)
{
    // 7.读取客户端消息
    char client_msg[N];
    while (1)
    {
        ssize_t n = read(communication_sockfd, client_msg, N);
        if (n > 0)
        {
            // 8.接收响应消息并回复客户端
            client_msg[n] = '\0';
            cout << "client say:" << client_msg << endl;
            string reply = "TCP server reply: I have got client's message. ";
            reply += client_msg;
            write(communication_sockfd, reply.c_str(), reply.size());
        }
        else if (n == 0)
        {
            printf("[%s:%d] quit, server close newfd %d\n", clientip.c_str(), clientport, communication_sockfd);
            break;
        }
        else
        {
            cout << "read error, newfd:" << communication_sockfd << endl;
        }
    }
}

int main()
{
    // 1.创建监听套接字
    int listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_sockfd == -1)
    {
        perror("socket failed");
        exit(SOCKET_ERROR);
    }

    // 2.设置socket选项(允许端口复用,避免"地址已存在"错误)
    int opt = 1;
    socklen_t optlen = sizeof(opt);
    int ret = setsockopt(listen_sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, &optlen);
    if (ret == -1)
    {
        perror("setsockopt failed");
        exit(SETSOCKOPT_ERROR);
    }

    // 3.配置服务器地址结构
    struct sockaddr_in local;
    local.sin_family = AF_INET;
    local.sin_port = htons(port);
    local.sin_addr.s_addr = INADDR_ANY;

    // 4.绑定socket到指定端口
    if (bind(listen_sockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
    {
        perror("bind failed");
        exit(BIND_ERROR);
    }

    // 5.开始监听连接
    if (listen(listen_sockfd, 3) < 0)
    {
        perror("listen failed");
        exit(LISTEN_ERROR);
    }

    cout << "Server is running, listening port " << port << " ...." << endl;
    while (1)
    {
        // 6.接收客户端连接
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        int communication_sockfd = accept(listen_sockfd, (struct sockaddr *)&client, &len);
        if (communication_sockfd == -1)
        {
            perror("accept failed");
            continue;
        }
        uint16_t clientport = ntohs(client.sin_port);
        char clientip[32];
        inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
        cout << "client has connect. [" << clientip << ":" << clientport << "]" << endl;

        //version1:
        pid_t id=fork();
        if(id == 0){
            //child
            close(listen_sockfd);// 子进程关监听套接字:它只负责通信,用不上监听功能,节约资源。
            if(fork()>0)exit(0);// 避免子进程成为僵尸进程
            service(communication_sockfd,clientip,clientport);// 孙子进程负责通信,system领养
            close(communication_sockfd);// 孙子进程完成与客户端的通信后,关闭对应的通信套接字以释放文件描述符资源
            exit(0);
        }else if(id > 0){
            //father
            close(communication_sockfd);// 父进程关通信套接字:通信交给子进程,父进程释放资源好继续接新连接。
            pid_t child_pid=waitpid(id,nullptr,0);
            if(child_pid < 0){
                perror("waitpid failed");
                exit(WAIT_ERROR);
            }
        }else{
            perror("fork failed");
            exit(FORK_ERROR);
        }
        
    }
    // 9.关闭连接
    close(listen_sockfd);
    cout << "服务器已经关闭" << endl;

    return 0;
}

8.3.2 版本2:通过SIGCHLD信号让操作系统自动回收子进程

除此之外,还可以通过使用 signal(SIGCHLD, SIG_IGN) 后,操作系统会自动回收子进程资源,无需手动通过 waitpid 回收,也无需用"子进程创建孙子进程"的方式避免僵尸进程。

signal(SIGCHLD, SIG_IGN) 是一个信号处理机制的调用,作用是告诉操作系统:当子进程终止时不需要保留其状态信息直接自动回收该子进程的资源

其中:

  • SIGCHLD 是子进程终止时会向父进程发送的信号(通知父进程 "我已经结束了")。
  • SIG_IGN 是 "忽略" 的意思,是 "信号处理方式" 的一种,全称是 Signal Ignore(忽略信号) 。它表示:父进程收到 SIGCHLD 信号后,不做任何自定义处理,直接交给操作系统内核自动处理
    • 默认行为的缺陷 :如果父进程不处理 SIGCHLD 信号(默认处理方式是 "忽略信号",但此 "默认忽略" 和 SIG_IGN 不同),内核不会自动回收子进程 PCB------ 子进程会变成僵尸进程,必须父进程通过 wait()/waitpid() 手动回收。
    • **SIG_IGN 显式设置更特殊:**显式用 signal (SIGCHLD, SIG_IGN) 时,内核会明白 "父进程不关心子进程退出状态",会自动回收子进程 PCB,不用手动调用 waitpid (),也不用 "子进程生孙子" 的复杂方式避僵尸进程。

TCP服务端(server.cpp)

cpp 复制代码
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstdio>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#define port 8808
#define N 1024
using namespace std;

enum
{
    SOCKET_ERROR = 1,
    SETSOCKOPT_ERROR,
    BIND_ERROR,
    LISTEN_ERROR,
    WAIT_ERROR,
    FORK_ERROR
};
void service(int communication_sockfd, const string &clientip, const uint16_t &clientport)
{
    // 7.读取客户端消息
    char client_msg[N];
    while (1)
    {
        ssize_t n = read(communication_sockfd, client_msg, N);
        if (n > 0)
        {
            // 8.接收响应消息并回复客户端
            client_msg[n] = '\0';
            cout << "client say:" << client_msg << endl;
            string reply = "TCP server reply: I have got client's message. ";
            reply += client_msg;
            write(communication_sockfd, reply.c_str(), reply.size());
        }
        else if (n == 0)
        {
            printf("[%s:%d] quit, server close newfd %d\n", clientip.c_str(), clientport, communication_sockfd);
            break;
        }
        else
        {
            cout << "read error, newfd:" << communication_sockfd << endl;
        }
    }
}

int main()
{
    signal(SIGCHLD, SIG_IGN);// 显式设置父进程忽略SIGCHLD信号:子进程终止时,内核会自动回收其PCB等资源,无需父进程调用waitpid()手动回收

    // 1.创建socket套接字
    int listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_sockfd == -1)
    {
        perror("socket failed");
        exit(SOCKET_ERROR);
    }

    // 2.设置socket选项(允许端口复用,避免"地址已存在"错误)
    int opt = 1;
    socklen_t optlen = sizeof(opt);
    int ret = setsockopt(listen_sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, &optlen);
    if (ret == -1)
    {
        perror("setsockopt failed");
        exit(SETSOCKOPT_ERROR);
    }

    // 3.配置服务器地址结构
    struct sockaddr_in local;
    local.sin_family = AF_INET;
    local.sin_port = htons(port);
    local.sin_addr.s_addr = INADDR_ANY;

    // 4.绑定socket到指定端口
    if (bind(listen_sockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
    {
        perror("bind failed");
        exit(BIND_ERROR);
    }

    // 5.开始监听连接
    if (listen(listen_sockfd, 3) < 0)
    {
        perror("listen failed");
        exit(LISTEN_ERROR);
    }

    cout << "Server is running, listening port " << port << " ...." << endl;
    while (1)
    {
        // 6.接收客户端连接
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        int communication_sockfd = accept(listen_sockfd, (struct sockaddr *)&client, &len);
        if (communication_sockfd == -1)
        {
            perror("accept failed");
            continue;
        }
        uint16_t clientport = ntohs(client.sin_port);
        char clientip[32];
        inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
        cout << "client has connect. [" << clientip << ":" << clientport << "]" << endl;

        // version2:
        pid_t id = fork();
        if (id == 0)
        {
            // child
            close(listen_sockfd); // 子进程关监听套接字:它只负责通信,用不上监听功能,节约资源。
            service(communication_sockfd, clientip, clientport); // 子进程负责通信,system领养
            close(communication_sockfd);// 子进程完成与客户端的通信后,关闭对应的通信套接字以释放文件描述符资源
            exit(0);
        }
        else if (id > 0)
        {
            // father
            close(communication_sockfd); // 父进程关通信套接字:通信交给子进程,父进程释放资源好继续接新连接。
            // 去掉waitpid,依赖SIGCHLD忽略实现自动回收
        }
        else
        {
            perror("fork failed");
            exit(FORK_ERROR);
        }
    }
    // 9.关闭连接
    close(listen_sockfd);
    cout << "服务器已经关闭" << endl;

    return 0;
}

8.4 多线程服务器模型

优点:避免了频繁创建子进程的开销。

8.4.1 即时创建线程的版本

**注意1:**无需在主线程中等待线程,而是在新线程入口函数中分离线程。原因如下:

  1. 多线程服务器中无需获取线程返回值, pthread_join 的返回值获取功能无用,而 pthread_detach 可自动释放资源。
  2. pthread_join 会阻塞主线程,影响新连接处理,降低并发效率, pthread_detach 则无此问题。
  3. 用 pthread_detach 分离线程,能让线程结束后资源自动回收,避免类似僵尸进程的资源泄漏。

**注意2:**多线程服务器不用像多进程服务器关闭多余的套接字描述符

多线程服务器中,所有线程共享同一进程的文件描述符表,套接字描述符在线程间是共享的,无需重复关闭;而多进程通过 fork 复制文件描述符表,若不关闭无用套接字,会导致资源泄漏和描述符耗尽;此外,线程间可直接通过共享的描述符协作,无需像进程那样清理复制产生的冗余套接字。

TCP服务端(server.cpp)

cpp 复制代码
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstdio>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <pthread.h>
#define port 8808
#define N 1024
using namespace std;

enum
{
    SOCKET_ERROR = 1,
    SETSOCKOPT_ERROR,
    BIND_ERROR,
    LISTEN_ERROR,
    WAIT_ERROR,
    FORK_ERROR
};

class ThreadDate
{
public:
    ThreadDate(const int sockfd, const string &clientip, const uint16_t &clientport)
        : _sockfd(sockfd),
          _clientip(clientip),
          _clientport(clientport)
    {
    }

public:
    int _sockfd;
    string _clientip;
    uint16_t _clientport;
};

void service(int communication_sockfd, const string &clientip, const uint16_t &clientport)
{
    // 7.读取客户端消息
    char client_msg[N];
    while (1)
    {
        ssize_t n = read(communication_sockfd, client_msg, N);
        if (n > 0)
        {
            // 8.接收响应消息并回复客户端
            client_msg[n] = '\0';
            cout << "client say:" << client_msg << endl;
            string reply = "TCP server reply: I have got client's message. ";
            reply += client_msg;
            write(communication_sockfd, reply.c_str(), reply.size());
        }
        else if (n == 0)
        {
            printf("[%s:%d] quit, server close newfd %d\n", clientip.c_str(), clientport, communication_sockfd);
            break;
        }
        else
        {
            cout << "read error, newfd:" << communication_sockfd << endl;
        }
    }
}

// 线程入口函数:多线程模型的核心,每个线程对应一个客户端连接
void *handler(void *args)
{
    // 1.设置线程分离:避免主线程调用pthread_join回收线程资源,线程结束后自动释放资源
    pthread_detach(pthread_self());
    ThreadDate *td = static_cast<ThreadDate *>(args);

    // 2.调用通信处理函数:让线程专注处理单个客户端的消息交互
    service(td->_sockfd,td->_clientip,td->_clientport);
    delete td;
    return nullptr;
}

int main()
{
    // 1.创建socket套接字
    int listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_sockfd == -1)
    {
        perror("socket failed");
        exit(SOCKET_ERROR);
    }

    // 2.设置socket选项(允许端口复用,避免"地址已存在"错误)
    int opt = 1;
    socklen_t optlen = sizeof(opt);
    int ret = setsockopt(listen_sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, &optlen);
    if (ret == -1)
    {
        perror("setsockopt failed");
        exit(SETSOCKOPT_ERROR);
    }

    // 3.配置服务器地址结构
    struct sockaddr_in local;
    local.sin_family = AF_INET;
    local.sin_port = htons(port);
    local.sin_addr.s_addr = INADDR_ANY;

    // 4.绑定socket到指定端口
    if (bind(listen_sockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
    {
        perror("bind failed");
        exit(BIND_ERROR);
    }

    // 5.开始监听连接
    if (listen(listen_sockfd, 3) < 0)
    {
        perror("listen failed");
        exit(LISTEN_ERROR);
    }

    cout << "Server is running, listening port " << port << " ...." << endl;
    while (1)
    {
        // 6.接收客户端连接
        struct sockaddr_in client;
        socklen_t len = sizeof(client);
        int communication_sockfd = accept(listen_sockfd, (struct sockaddr *)&client, &len);
        if (communication_sockfd == -1)
        {
            perror("accept failed");
            continue;
        }
        uint16_t clientport = ntohs(client.sin_port);
        char clientip[32];
        inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
        cout << "client has connect. [" << clientip << ":" << clientport << "]" << endl;


        // 多线程服务器模型
        // 封装客户端信息:new一个ThreadDate对象,存储通信套接字、IP、端口
        ThreadDate *td = new ThreadDate(communication_sockfd, clientip, clientport);
        pthread_t tid;
        pthread_create(&tid, nullptr, handler, td);// 创建线程
    }
    // 9.关闭连接
    close(listen_sockfd);
    cout << "服务器已经关闭" << endl;

    return 0;
}

8.4.2 线程池版本

  • 多线程版本的缺点:动态创建线程会带来频繁创建/销毁的开销,高并发时线程数量激增易导致资源耗尽,还会因调度频繁降低效率。
  • 线程池版本的优点:预先创建并复用线程,减少开销;限制线程数量避免资源耗尽,且便于统一管理,高并发下更稳定高效。

网络编程中忽略 SIGPIPE 信号的防护意义

忽略SIGPIPE是网络编程的重要防护手段,核心是避免程序因网络异常崩溃,在并发场景中防护价值更突出:

  1. 基础防护 :通信一方异常断连后,若另一方仍写数据会触发SIGPIPE,默认终止进程;忽略信号后,写操作返回错误,程序可捕获异常处理,避免服务意外中断。
  2. 并发场景防护 :线程池等并发服务中,单个连接异常若触发SIGPIPE,可能终止整个进程、影响所有连接;忽略信号后,仅需单独处理异常连接,不波及其他连接,保障服务持续运行。

线程池(ThreadPool.hpp)

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <vector>
#include <queue>
#include <pthread.h>
using namespace std;

const static int defaultNum = 5;
struct ThreadInfo
{
    string name;
    pthread_t tid;
};

template <class T>
class ThreadPool
{
public:
    void lock()
    {
        pthread_mutex_lock(&_task_mutex);
    }

    void unlock()
    {
        pthread_mutex_unlock(&_task_mutex);
    }

    void wakeup()
    {
        pthread_cond_signal(&_cond);
    }

    void threadSleep()
    {
        pthread_cond_wait(&_cond, &_task_mutex);
    }

    string getThreadName(pthread_t tid)
    {
        for (auto thread : _threads)
        {
            if (tid == thread.tid)
            {
                return thread.name;
            }
        }
        return "No exist.";
    }

private:
    ThreadPool(const ThreadPool<T> &) = delete;
    const ThreadPool &operator=(const ThreadPool<T> &) = delete;

    ThreadPool(int num = defaultNum)
        : _threads(num)
    {
        pthread_mutex_init(&_task_mutex, nullptr);
        pthread_cond_init(&_cond, nullptr);
    }

    ~ThreadPool()
    {
        pthread_mutex_destroy(&_task_mutex);
        pthread_cond_destroy(&_cond);
    }

public:
    static ThreadPool<T> *getInstance(int num = defaultNum)
    {
        if(_tp==nullptr){//避免每次调用getInstance()时都进行加锁 / 解锁操作(加锁是开销较大的操作)
            pthread_mutex_lock(&_init_mutex);
            if (_tp == nullptr)
            {
                _tp = new ThreadPool<T>(num);
            }
            pthread_mutex_unlock(&_init_mutex);
        }

        return _tp;
    }

    static void *handler(void *args) // 线程循环加锁检测队列。若为空则阻塞等待;若有任务则取出解锁后执行
    {
        ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
        while (true)
        {
            tp->lock();
            while (tp->_tasks.empty())
            {
                tp->threadSleep();
            }
            T t = tp->_tasks.front();
            tp->_tasks.pop();
            tp->unlock();
            t.run();
        }
    }

    void start() // 预先创建一些线程
    {
        for (int i = 0; i < _threads.size(); ++i)
        {
            _threads[i].name = "thread-" + to_string(i + 1);
            pthread_create(&(_threads[i].tid), nullptr, handler, this);
        }
    }

    void push(const T &t) // 外部通过 push 向队列添加任务,添加后唤醒一个阻塞的线程处理任务。
    {
        lock();
        _tasks.push(t);
        wakeup();
        unlock();
    }

    T pop()
    {
        T t = _tasks.front();
        _tasks.pop();
        return t;
    }

private:
    vector<ThreadInfo> _threads;
    queue<T> _tasks;

    pthread_mutex_t _task_mutex;
    pthread_cond_t _cond;

    static ThreadPool *_tp;
    static pthread_mutex_t _init_mutex;
};

template <class T>
ThreadPool<T> *ThreadPool<T>::_tp = nullptr;

template <class T>
pthread_mutex_t ThreadPool<T>::_init_mutex = PTHREAD_MUTEX_INITIALIZER;

TCP服务端(server.cpp)

cpp 复制代码
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstdio>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <pthread.h>
#include "ThreadPool.hpp"
#define port 8808                  // 服务器监听端口号
#define N 1024                     // 消息缓冲区大小

using namespace std;

// 错误码枚举:清晰标识不同阶段的错误类型
enum
{
    SOCKET_ERROR = 1,              // socket创建失败
    SETSOCKOPT_ERROR,              // setsockopt配置失败
    BIND_ERROR,                    // 地址绑定失败
    LISTEN_ERROR,                  // 监听失败
    WAIT_ERROR,                    // 进程等待失败(预留)
    FORK_ERROR                     // 进程创建失败(预留)
};

// 任务类:封装单个客户端的通信任务,适配线程池处理逻辑
class Task
{
private:
    // 通信处理函数:负责与客户端进行数据交互(私有成员,仅内部调用)
    void service(int communication_sockfd, const string &clientip, const uint16_t &clientport)
    {
        // 读取客户端消息缓冲区
        char client_msg[N];
        while (1)
        {
            // 读取客户端发送的数据
            ssize_t n = read(communication_sockfd, client_msg, N);
            if (n > 0)  // 成功读取到数据
            {
                client_msg[n] = '\0';  // 手动添加字符串结束符
                cout << "client say:" << client_msg << endl;  // 打印客户端消息
                
                // 构建回复消息并发送
                string reply = "TCP server reply: I have got client's message. ";
                reply += client_msg;
                write(communication_sockfd, reply.c_str(), reply.size());
            }
            else if (n == 0)  // 客户端正常关闭连接
            {
                printf("[%s:%d] quit, server close newfd %d\n", 
                       clientip.c_str(), clientport, communication_sockfd);
                break;  // 退出通信循环
            }
            else  // 读取错误(如网络异常)
            {
                cout << "read error, newfd:" << communication_sockfd << endl;
                break;  // 退出通信循环
            }
        }
    }

public:
    // 构造函数:初始化客户端连接信息
    Task(int sockfd, const string &clientip, uint16_t clientport)
        : _sockfd(sockfd), _clientip(clientip), _clientport(clientport)
    {
    }

    // 任务执行接口:线程池工作线程会调用此方法执行任务
    // 必须实现该接口,否则线程池无法处理任务
    void run()
    {
        // 调用内部通信处理函数
        service(_sockfd, _clientip, _clientport);
        // 通信结束后关闭套接字,释放文件描述符资源
        close(_sockfd);
    }

private:
    int _sockfd;          // 客户端通信套接字(accept返回的新fd)
    string _clientip;     // 客户端IP地址(字符串格式)
    uint16_t _clientport; // 客户端端口号(主机字节序)
};

int main()
{
    // 忽略SIGPIPE信号:避免客户端异常断开时,服务器写操作触发进程终止
    signal(SIGPIPE, SIG_IGN);

    // 1.创建监听套接字(负责接收客户端连接请求)
    // 参数:AF_INET(IPv4)、SOCK_STREAM(TCP)、0(默认协议)
    int listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_sockfd == -1)
    {
        perror("socket failed");  // 打印错误详情
        exit(SOCKET_ERROR);       // 携带错误码退出
    }

    // 2.设置socket选项:允许端口复用(解决服务器重启时"地址已被使用"的问题)
    int opt = 1;  // 启用选项
    socklen_t optlen = sizeof(opt);  // 选项值长度
    // 参数:套接字、选项层级、选项名、选项值、选项值长度
    int ret = setsockopt(listen_sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, optlen);
    if (ret == -1)
    {
        perror("setsockopt failed");
        exit(SETSOCKOPT_ERROR);
    }

    // 3.配置服务器地址结构
    struct sockaddr_in local;
    local.sin_family = AF_INET;                // IPv4协议
    local.sin_port = htons(port);              // 端口号:主机字节序转网络字节序
    local.sin_addr.s_addr = INADDR_ANY;        // 绑定所有可用网卡的IP

    // 4.绑定:将监听套接字与服务器地址(IP+端口)关联
    if (bind(listen_sockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
    {
        perror("bind failed");
        exit(BIND_ERROR);
    }

    // 5.监听:将套接字转为被动模式,开始接收客户端连接请求
    // 参数:监听套接字、半连接队列最大长度(未完成三次握手的连接)
    if (listen(listen_sockfd, 3) < 0)
    {
        perror("listen failed");
        exit(LISTEN_ERROR);
    }

    cout << "Server is running, listening port " << port << " ...." << endl;

    // 主循环:持续接收客户端连接(服务器核心逻辑)
    while (1)
    {
        // 6.接收客户端连接:阻塞等待新连接到来
        struct sockaddr_in client;          // 存储客户端地址信息
        socklen_t len = sizeof(client);     // 地址结构长度(输入输出参数)
        // 返回值:与该客户端通信的专用套接字
        int communication_sockfd = accept(listen_sockfd, (struct sockaddr *)&client, &len);
        if (communication_sockfd == -1)
        {
            perror("accept failed");
            continue;  // 接受连接失败,继续等待下一个连接
        }

        // 解析客户端地址信息(网络字节序转主机字节序)
        uint16_t clientport = ntohs(client.sin_port);  // 客户端端口
        char clientip[32];
        inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));  // 客户端IP
        cout << "client has connect. [" << clientip << ":" << clientport << "]" << endl;

        // 线程池版本:使用线程池处理客户端通信
        // 静态变量:确保线程池只初始化一次
        static ThreadPool<Task>* tp = ThreadPool<Task>::getInstance();  // 获取线程池单例
        static bool is_threadpool_started = false;  // 线程池启动标志
        
        // 创建任务对象:封装当前客户端的连接信息和通信逻辑
        Task t(communication_sockfd, clientip, clientport);
        
        // 启动线程池(仅第一次接收连接时执行)
        if (!is_threadpool_started)
        {
            tp->start();  // 创建并启动所有工作线程
            is_threadpool_started = true;
            cout << "ThreadPool started with " << defaultNum << " threads" << endl;
        }
        
        // 将任务加入线程池的任务队列,由工作线程异步处理
        tp->push(t);
    }

    // 关闭监听套接字(理论上不会执行,因主循环是死循环)
    close(listen_sockfd);
    cout << "服务器已经关闭" << endl;

    return 0;
}

8.5 异常处理:客户端申请重新连接

在TCP通信中,客户端的异常退出是需要重点处理的场景,这就像我们在打游戏时,若客户端因信号或网络问题突然卡顿,系统会提示已掉线并尝试重新连接------其本质是连接中断后客户端调用connect进行重连。而重连时间越长往往越困难,因为此时需要服务器将断开连接期间的所有数据同步给用户,这一过程也反映了客户端异常退出时,服务器如何处理连接中断、数据同步等问题的重要性。

TCP客户端(client.cpp)

cpp 复制代码
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstdio>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#define serverPort 8808
#define serverIP "127.0.0.1"
#define N 1024
using namespace std;

int main()
{
    // 配置服务端地址结构
    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(serverPort);
    inet_pton(AF_INET, serverIP, &(server_addr.sin_addr)); // 将IP地址从字符串转换为网络字节序

    // 主循环:持续尝试连接服务器(断开后自动重连)
    while (1)
    {
        int cnt = 10;                // 最大重连次数(10次)
        int needtoreconnect = false; // 是否需要重连的标志

        // 创建socket文件描述符
        int sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (sockfd == -1)
        {
            perror("socket failed");
            exit(1);
        }

        // 重连循环:最多尝试10次连接服务器
        do
        {
            int ret = connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
            if (ret < 0)
            {
                needtoreconnect = true; // 连接失败,需要重连
                cerr << "connect error, reconnect... " << cnt << endl;
                --cnt;
                sleep(1);
            }
            else
            {
                break; // 连接成功,退出重连循环
            }

        } while (cnt && needtoreconnect);

        if (cnt == 0)
        {
            // 达到最大重连次数,连接失败,准备退出
            cerr << "user offline... " << endl;
            break;
        }

        // 连接成功:与服务器进行一次消息交互
        string msg;
        cout << "Please send message:";

        // 读取用户输入的消息(包含空格)
        getline(cin, msg);

        // 向服务器发送消息
        int n = write(sockfd, msg.c_str(), msg.size());
        if (n < 0)
        {
            // 发送失败则退出当前连接的处理
            cerr << "write error..." << endl;
            break;
        }

        // 接收服务器的响应
        char ser_reply[N];
        n = read(sockfd, ser_reply, sizeof(ser_reply));
        if (n > 0)
        {
            ser_reply[n] = '\0';
            cout << ser_reply << endl;
        }

        // 关闭当前连接的套接字(一次交互后断开)
        close(sockfd);
    }

    return 0;
}

9.TCP协议通讯流程

在TCP通信中,客户端的异常退出是需要重点处理的场景,这就像我们在打游戏时,若客户端因信号或网络问题突然卡顿,系统会提示已掉线并尝试重新连接------其本质是连接中断后客户端调用connect进行重连。而重连时间越长往往越困难,因为此时需要服务器将断开连接期间的所有数据同步给用户,这一过程也反映了客户端异常退出时,服务器如何处理连接中断、数据同步等问题的重要性。

10.TCP的全双工特性

TCP底层会给我们提供两个缓冲区,分别是发送缓冲区和接收缓冲区。

当两台机器进行通信时,若一方要发送消息,数据会从客户端的发送缓冲区通过文件描述符拷贝到对应套接字的发送缓冲区,再由网络传输到对方的接收缓冲区,对方则从自己的接收缓冲区读取数据。由此可见,我们使用的read和write函数本质上是拷贝函数,负责在不同缓冲区之间传递数据。而缓冲区中的数据何时发送、发送多少,完全由TCP自主决定,这也是TCP被称为传输控制协议的原因------发送的决定权由它掌控。同时,TCP之所以是全双工通信,是因为通信双方各自拥有独立的发送缓冲区和接收缓冲区,这些缓冲区资源相互隔离,使得双方可以同时进行读写操作而不相互干扰。

当服务器同时收到大量客户端的连接请求时,操作系统需要对这些连接进行管理。具体来说,在三次握手成功后,通信双方会在各自的内核中创建对应的连接结构体,而操作系统会通过链表的形式对这些连接结构体进行统一管理,从而有效处理多个客户端的连接。