Linux 网络套接字编程(五)TCP 回声服务器的实现(单进程(单线程)/多进程/多线程/线程池四个版本)

目录

[一、从 UDP 过渡到 TCP 协议](#一、从 UDP 过渡到 TCP 协议)

[二、TCP 编码环节](#二、TCP 编码环节)

[三、阶段一 : 单进程/单线程版本](#三、阶段一 : 单进程/单线程版本)

铺垫

思路总结

[EchoTcpServer.hpp 服务端:](#EchoTcpServer.hpp 服务端:)

构造函数

[初始化函数 InitServer()](#初始化函数 InitServer())

listen()

[启动主循环 Start() 函数](#启动主循环 Start() 函数)

accept()

[业务处理函数 serviceIO()](#业务处理函数 serviceIO())

[EchoTcpServerMain.cc 服务端:](#EchoTcpServerMain.cc 服务端:)

[EchoTcpClient.cc 客户端:](#EchoTcpClient.cc 客户端:)

connect()

运行结果:

总结:

[四、阶段二 : 多进程版本](#四、阶段二 : 多进程版本)

逻辑梳理:

引发的问题:

解决方法:

[方法一 : 引入孙子进程](#方法一 : 引入孙子进程)

[方法二 : 引入信号](#方法二 : 引入信号)

运行结果:

代码:

telnet

[netstat -natp](#netstat -natp)

[五、阶段三: 多线程版本](#五、阶段三: 多线程版本)

逻辑梳理:

[运行结果 :](#运行结果 :)

代码:

[六、阶段四: 线程池版本](#六、阶段四: 线程池版本)

逻辑梳理:

改动的地方:

运行结果:

长连接和短连接

代码:

七、总结


一、从 UDP 过渡到 TCP 协议

在前面的几篇文章中我们完整的手写、调试、运行了一套 UDP 多人广播聊天室的代码,从模块化封装、回调解耦、线程池异步调度,到最后多客户端同时在线聊天,整套网络通信流程可以准确跑通。

下来我们就 TCP 继续展开学习,和之前一样,我们学习 TCP 的时候还是以编码为主,在编码的同时讲解各个 TCP 接口API的含义。在正式学习 TCP 之前,我们先系统性复盘 UDP 的整体特点,再平滑过渡到 TCP 协议,对比两种协议的底层差异和使用区别,为接下来 TCP 编码做好准备。

1. 快速复盘:我们刚刚写完的 UDP 有什么核心特点 :

第一,UDP 是无连接协议。通信双方不需要提前打招呼、不需要建立专属通路,客户端直接打包数据往外发就行,服务端被动接收数据即可,整个流程轻量化,没有多余开销。

第二,UDP 通信不保证可靠传输。协议本身不会给数据做确认应答、不会自动重传丢失的数据。网络信号波动时,聊天消息有可能直接丢包、导致对方收不到,简单来说就是 "发出去就不管了"。

第三,UDP 编程接口简单统一。我们在编码时只用了 socket、bind、sendto、recvfrom 四个核心 API,就能实现收发消息,没有涉及其他的接口API和额外的,架构简单,适合入门练手。

第四,UDP 是面向数据报的协议,UDP 是以数据报为单位收发数据的,发送方调用一次 sendto,就会形成一个独立的数据包;接收方调用一次 recvfrom,也只能完整接收一个数据包。数据收发的边界是天然保留的。

第五,UDP 简单轻便,正是因为 UDP 简单轻便,所以我们才能轻松搭配回调函数、线程池,把广播任务异步解耦。

2. 过渡到 TCP

UDP 的优点是简单、快速、开销小,但缺点也非常明显:不可靠、容易丢消息、无法感知客户端上下线、不适合正式业务场景。

因此在真正工作中,比如聊天软件、浏览器访问、文件传输、远程登录,这些不能丢数据的场景中,我们使用的都是 TCP 协议。UDP 只能用在直播、语音通话、游戏实时帧同步这类可以容忍少量丢包、优先保速度的场景中使用。

3. 下面我们介绍一下 TCP 协议的特点 :

第一,TCP 是面向有连接的协议。就像打电话,必须先拨号接通、双方建立专属通道,之后才能说话通信。客户端必须主动连接服务端,服务端必须主动接收连接,链路建好之后,双方才能正常收发数据,不是想发就发。

第二,TCP 保证可靠传输。协议底层自带确认应答、超时重传、数据包排序、流量控制、拥塞控制全套机制。只要网络没断,数据一定能完整、有序、不重复、不丢失地送到对方手里,非常适合正式业务开发。

第三,TCP 是字节流传输,没有数据包边界。数据像流水一样源源不断往外流。连续发两条聊天消息,对方可能一次性收两条、也可能拆开乱收,因此会出现粘包、拆包问题,所以 TCP 编码时必须自己手动设计消息边界,这也是 TCP 最大的开发难点。

第四,TCP 可以实时感知客户端在线、离线状态。客户端掉线断网退出,服务端套接字会直接感知到,能自动把用户从在线列表剔除。

4. UDP 和 TCP 核心 API 对比

UDP 用到的核心接口如下,比较简单:

  1. 创建套接字:socket (AF_INET, SOCK_DGRAM, 0)
  2. 服务端绑定端口:bind()
  3. 收所有客户端消息:recvfrom()
  4. 给所有客户端发消息:sendto()

服务端整个过程只需要一个套接字,就能管理全部的客户端。
TCP 要用到的接口则会多一点,并且使用逻辑和 UDP 也不一样:

  1. 创建监听套接字:socket (AF_INET, SOCK_STREAM, 0)
  2. 服务端绑定端口:bind()
  3. 服务端开启监听,等待客户端上门:listen()
  4. 服务端接收客户端连接,生成专属通信套接字:accept()
  5. 服务端和客户端收数据:read() / recv()
  6. 服务端和客户端收发数据:write() / send()
  7. 最后关闭套接字:close()

UDP 靠 recvfrom / sendto 就能直接通信;而 TCP 需要先监听、先建立连接,生成一对一的专属套接字收发数据,结构更严谨,也更复杂。

5. 实际开发使用上,二者最直观的区别:

UDP 服务端使用一个套接字 socket 就能通吃全场,不用管谁在线、谁离线。

TCP 每增加一个客户端就要多开一个通信套接字 socket,必须多线程管理每一个客户端连接,架构更贴近企业真实后端服务。

UDP 不用处理粘包,TCP 需要手动解决粘包拆包问题。

UDP 只管收发,不管可靠;TCP 全程保障消息绝对送达,适合正式项目。

二、TCP 编码环节

在系统对比了 UDP 与 TCP 的核心差异后,我们正式进入 TCP 协议的编码环节。为了循序渐进地理解 TCP 服务端的通信模型与并发通信,我们将按照从简单到复杂、从基础到企业级的思路,分四个阶段来实现我们的 TCP 代码,和之前的 UDP 模型一样,我们依然使用服务端和客户端互相通信的结构模型:

  1. 阶段一:单进程/单线程版本:我们先不追求并发,用最简单的串行模型,把 TCP 通信的核心接口、调用流程和数据收发逻辑梳理清楚。为后续的优化打基础。

  2. 阶段二:多进程版本:在单线程的基础上,引入 fork() 系统调用,让服务端能够同时为多个客户端服务,体验操作系统进程模型在并发中的应用。

  3. 阶段三:多线程版本:基于多进程模型进行优化,服务端改用线程实现并发,感受线程比进程更轻量、更高效的优势。

  4. 阶段四:线程池版本:引入线程池,解决多线程频繁创建销毁的开销问题,实现企业级服务端常用的高效、可控的并发模型。

接下来,我们就从阶段一:单进程/单线程版本开始,一步步搭建我们的第一个 TCP 服务端。这个版本的目标不是高并发,而是让我们真正熟悉 TCP 的每一个接口和每一步流程。

三、阶段一 : 单进程/单线程版本

接下来,我们就开始编写这个版本的代码。我们边写代码边讲解每个接口的作用:

在正式编码之前,我们必须先理清 TCP 关键的接口以及接口之间的联系。因为 TCP 是面向连接的协议,服务端与客户端的系统调用不能随意调用,必须遵循固定的时序、互相配合,才能完成三次握手与数据交互。

铺垫

1. 两端都必须调用的公共基础接口

socket() : 无论服务端还是客户端,通信的第一步都是调用 socket()。作用:在内核中创建套接字文件,生成文件描述符,作为网络通信的基础载体。没有 socket 文件描述符,后续所有网络操作都无法进行。

2. 仅服务端需要的专属接口

bind(): 服务端必须手动 bind,绑定固定的本机 IP + 固定端口( INADDR_ANY )。让客户端可以通过 IP + 端口精准找到服务端进程。

listen() : 将普通绑定后的套接字开启端口监听,让操作系统内核自动维护连接队列、处理三次握手。

accept() : 阻塞等待并接受客户端连接,从内核完成握手的队列中取出连接,生成全新的通信套接字,专门用来和单个客户端做数据交互。

3. 仅客户端需要的专属接口

connect() : 客户端不需要手动 bind,操作系统会自动分配随机本地端口与临时 IP;客户端只需通过 connect(),携带服务端 IP + 服务端固定端口,主动发起三次握手请求,和服务端建立专属 TCP 连接通道。

4. 连接建立后,两端完全共用的收发接口

TCP 连接一旦建立成功,服务端 accept 返回的通信文件 fd 与 客户端 socket 的文件 fd 形成双向通路。两端地位对等,都可以使用两套完全等价的收发函数:

通用文件 IO :read() / write()

网络专用 IO : recv() / send()

补充知识点 :

在TCP 连接成功的前提下:read == recv、write == send,功能本质一致,底层都是拷贝数据到内核缓冲区;区别仅在于 recv / send 多了第四个标志位参数,可以设置特殊行为;日常普通 TCP 回显通信,直接使用 read / write 完全够用,代码更简洁通用。

思路总结

双方通信的起始,服务端与客户端都需要先通过 socket 创建套接字。服务端创建套接字后,需要主动调用 bind 绑定固定 IP 与端口,再通过 listen 将套接字设置为监听状态,最后依靠 accept 阻塞等待客户端的连接请求。客户端同样需要先创建套接字,但无需手动绑定本地地址与端口,操作系统会自动完成分配,随后客户端直接调用 connect,携带服务端的 IP 和端口主动发起连接。当服务端 accept 捕获到连接请求,双方完成三次握手、建立专属 TCP 通信通道后,服务端与客户端就可以通过 read/write 或 recv/send 接口,实现双向的数据收发交互。

下来我们先看 TCP 服务端头文件的代码:

EchoTcpServer.hpp 服务端:

因为我们第一个阶段是单进程 / 单线程串行执行的,结合上面我们梳理的 TCP 各个接口调用的顺序,完整还原服务端:socket -> bind -> listen -> accept -> 数据收发 的完整执行流程。

服务端整体执行逻辑遵循:先创建套接字、绑定固定端口、开启监听,循环阻塞等待客户端连接,获取通信套接字后,串行完成当前客户端的读写交互,直到客户端断开,才会重新回到 accept 接收下一个客户端。

下面直接放出服务端完整代码,我们逐行拆解讲解:

首先是头文件部分,我们引入了标准库、系统套接字相关头文件,同时包含了之前封装好的 Logger.hpp(日志工具)和 InetAddr.hpp(IP地址工具类),方便后续日志打印和客户端地址解析。全局部分还定义了错误码枚举、监听队列长度 gbacklog 和默认端口 gport,是服务端的基础配置。

整个服务端被封装成了 TcpServer 类,核心结构可以分为三类:构造函数、初始化函数 InitServer()、启动主循环 Start()、业务处理函数 serviceIO(),以及私有成员变量。

在进入函数细节之前,我们需要明确 TCP 服务端两个关键的文件描述符,这是和 UDP 最大的区别:

  1. 监听套接字 _listensockfd:它是服务端的全局唯一资源,从 InitServer() 创建出来,就一直固定不变,整个进程里只有这一个,所以我们直接把它存为类的成员变量,从头到尾复用。

  2. 通信套接字 sockfd:它是服务端内核动态创建的。每次调用 accept() 成功时,内核会给服务端分配一个全新的、独立的 sockfd,专供服务端与当前客户端一对一收发数据使用。这个 sockfd 归服务端所有,客户端断开连接后,该 sockfd 就会被关闭释放。因此我们不把这个通信套接字作为成员变量储存。

构造函数

1. 我们先从 TcpServer 类的构造函数讲起,它是整个服务端的起点,核心作用就是初始化服务端的基础配置,最关键的就是绑定端口号。当我们启动服务端程序时,会给服务端传入一个端口号,这个端口号就是用来唯一标识这台服务器上的 TCP 服务的。

  1. 客户端要连接服务端,必须指定 服务端 IP + 这个端口号 ,才能精准找到对应的服务;

  2. 服务端拿到这个端口号后,会在后续的 InitServer() 中,通过 bind() 系统调用,把监听套接字 _listensockfd 和这个端口号绑定在一起,让内核知道:所有发往这个端口的 TCP 连接请求,都要交给这个监听套接字来处理。

初始化函数 InitServer()

1. 第一步 socket() 创建监听套接字,第一个参数 AF_INET 代表使用 IPv4 网络协议;第二个参数 SOCK_STREAM 代表面向连接、流式传输,这是 TCP 的专属特征;第三个参数 0 表示默认协议,内核会自动适配 TCP。这两个核心参数组合,明确告诉内核:当前要创建的套接字,是专门用于 TCP 通信的套接字。套接字创建成功后,返回一个文件描述符,也就是我们的 _listensockfd。从内核层面看,Socket 是一个特殊的网络文件对象。进程调用 socket() 系统调用时,内核会在内存中创建一个专门用于网络通信的文件对象,并分配唯一的文件描述符 fd。这个文件对象内部维护着网络状态、协议信息,以及用于收发数据的内核缓冲区。后续所有网络操作,本质上都是通过文件描述符 fd,来操作这个内核对象。

  1. 第二步填充本地 sockaddr_in 结构体,我们手动定义并填充本机地址信息,地址协议家族同样设为 AF_INET,和 socket() 创建时保持一致;填入服务端固定端口,并通过 htons() 转换为网络大端字节序;IP 地址填写 INADDR_ANY,含义是绑定本机所有网卡、所有可用 IP,外来连接只要访问当前端口,都会被接收。这一步只是在用户态代码中,写好一份 "本机地址配置信息",它只是一个普通结构体变量,尚未和任何文件描述符、内核对象产生关联。

  2. 第三步 bind() 绑定,将第二步填充好的地址结构体,写入到 _listensockfd 索引对应的内核 Socket 文件对象中,把本机 IP、端口信息记录进去。绑定完成后,内核就明确了:这个 _listensockfd 对应的套接字,专门负责监听结构体 local 中指定的「本机 IP + 端口」的 TCP 连接请求。后续只要有客户端访问该服务器的端口,内核就会把连接请求交给这个监听套接字处理。

listen()
  1. 第四步 listen() 开启监听:第一个参数是要开启监听的文件描述符,必须是已经 bind() 绑定好的监听套接字。第二个参数是等待队列长度:内核会为当前监听端口维护一条队列,用于存放未完成三次握手、或已完成握手但尚未被 accept() 取走的客户端连接请求,第二个参数就是这条队列的最大容量。listen() 的作用,是把原本普通的 TCP 套接字,升级为监听状态:告诉内核当前 _listensockfd 不再用于普通通信,专门用来被动等待客户端连接;内核会开始在底层网卡层面,监听对应端口的 TCP 连接请求,客户端发来的三次握手请求会被内核自动处理,放入等待队列,排队等待服务端 accept() 取用。

启动主循环 Start() 函数

1. 首先定义出来的 client_addr 是一个用户态的 sockaddr_in 结构体,它的作用就是用来存放连接进来的客户端的 IP 地址和端口号。方便我们在日志里打印客户端信息。

accept()

accept 的后两个参数是输出型参数。我们已经提前定义了一块空的地址结构体内存,无需手动填写客户端信息。当客户端完成三次握手连接服务端时,内核会自动获取客户端的 IP 与端口,并主动将这些信息写入我们传入的结构体中。第一个参数是服务端自身的监听文件描述符,用于等待新连接;后两个参数用于带出对端客户端地址,两者作用独立,互不冲突。accept 的本质是从内核的连接等待队列里,取出一条已完成三次握手的客户端连接,为其创建专属的通信套接字,并返回给用户态使用。

accept() 成功后会返回一个全新的、客户端专属的通信套接字 fd;因为 TCP 是面向连接的协议,每个客户端都需要独立的通信上下文(收发缓冲区、连接状态、地址信息)。内核为每个客户端单独创建 socket 结构体和 fd,能保证不同客户端之间的数据完全隔离,不会互相干扰。

  1. accept() 后面的代码 InetAddr clientaddr(client_addr) 就是把 accept() 填好的 struct sockaddr_in client_addr,包装成我们自己的 InetAddr 对象;方便后续调用 ToString() 方法,打印出 [IP:端口] 格式的日志。

  2. 下一行的 serviceIO(sockfd, clientaddr) 就是把 accept() 返回的通信套接字 sockfd,和包装好的客户端地址对象,一起传给 serviceIO 函数;让 serviceIO 知道:这次要和哪个客户端(clientaddr) ,用哪个 fd(sockfd)来收发数据。

业务处理函数 serviceIO()

  1. serviceIO() 函数是服务端与单个客户端通信的核心逻辑,它接收 accept() 返回的客户端通信套接字 sockfd 和地址信息,首先打印客户端地址日志;

  2. 先通过 address.ToString() 和日志打印出客户端的 IP 和端口,方便我们在日志里区分不同的客户端连接。

  3. 下面就进入这个 while(true) 循环,这是 "长连接" 的体现:只要客户端不断开,服务端就会一直循环,等待客户端发送新数据,不会一次读写就退出。

  4. 再下来就是客户端通过 read() 系统调用来读取数据了,read() 的第一个参数 sockfd 就是 accept() 返回的、客户端专属的通信套接字 fd。第二个参数 inbuffer 是用户态的缓冲区,用来存放从内核缓冲区读出来的数据。第三个参数 sizeof(inbuffer) - 1 是最多读取的字节数。

  5. 内核在底层会检查 sockfd 对应的 socket 网络文件的接收缓冲区;如果缓冲区里有数据,就把数据拷贝到用户态的 inbuffer 里,并返回实际读取的字节数 n;如果缓冲区为空,read() 会阻塞等待,直到客户端发送数据过来;如果客户端已经正常断开连接,read() 会直接返回 0;如果发生错误(比如客户端异常断开、网络错误),read() 会返回 -1,并设置 errno。

  6. 读成功后的处理( n>0),把客户端地址和收到的消息一起打印,方便我们在日志里看到 "哪个客户端说了什么"。再把收到的消息前面加上 server echo# 前缀,构造一个回显字符串,准备回发给客户端。

  7. 读完之后服务端就可以向客户端通过 write() 系统调用写了,write() 的第一个参数 sockfd 同样是客户端专属的通信套接字 fd;第二个参数是写的内容,我们将它转换为 C 风格的字符形式;

第三个参数是要发送的字节数。

  1. write() 时底层会把用户态的字符串数据,拷贝到 sockfd 对应的内核 socket 网络文件的发送缓冲区里;内核的 TCP 协议栈会自动把发送缓冲区的数据封装成 TCP 报文,通过网络发给客户端;如果发送缓冲区满了,write() 会阻塞,直到缓冲区有空间写入。

  2. 再后面 n == 0 代表客户端已经正常关闭了连接,此时服务端也应该退出循环,后续关闭 sockfd,释放资源;n < 0 代表读取出错(比如客户端异常掉线、网络错误),此时也要退出循环,关闭连接。

EchoTcpServerMain.cc 服务端:

主文件是 TCP 回显服务器的入口,首先校验命令行参数,要求还要用户传入端口号;随后开启控制台日志系统,将端口号字符串转为整数,用于创建服务器实例;通过智能指针管理 TcpServer 对象,调用 InitServer() 完成套接字创建、绑定与监听的初始化工作,再调用 Start() 启动服务,进入监听循环,等待客户端连接并处理通信。

EchoTcpClient.cc 客户端:

这是一个客户端的主函数代码,它的核心流程是:

  1. 接收命令行传入的服务端 IP 和端口号

  2. 创建客户端套接字(socket)

  3. 发起连接请求(connect)

  4. 与服务端进行交互:用户输入消息 → 发送给服务端 → 接收回显并打印

  5. 处理连接断开与错误

  6. 第一步是命令行参数校验与解析,客户端运行时必须传入两个参数:服务端 IP和服务端端口号。当参数个数不对时,打印用法提示。把命令行传入的 IP 字符串存入 server_ip,端口号字符串转成整数存入 server_port,为后续连接做准备。

  7. 第二步就是客户端创建客户端套接字(socket),这一步和服务端创建 socket 一样,创建 socket() 时,内核会为客户端创建一个专属的网络文件对象,包含收发缓冲区、协议状态等信息,并返回文件描述符 sockfd 给用户态。需要注意的是客户端不需要手动 bind() 绑定端口,操作系统会自动为客户端分配一个临时端口,避免端口冲突。

  8. 第三部构造服务端地址信息,InetAddr serveraddress(server_port, server_ip) 就是把传入的服务端 IP 和端口,封装成我们自己的 InetAddr 对象,底层是 struct sockaddr_in 结构体,用来传递给 connect 系统调用。

connect()

connect() 的作用就是向指定的服务端发起 TCP 连接请求,完成三次握手。第一个参数是客户端自己的 socket fd,第二个参数是服务端地址结构体,第三个参数是地址结构体长度。

connect 就是在告诉内核三件事 : 我客户端要用 sockfd 这个套接字,去连接 serveraddress 里这个服务端地址,而这个地址结构体的大小是 addrlen。

  1. 第四部就是客户端向服务端的通信环节,这部分是客户端和服务端的交互逻辑,和服务端的 serviceIO 函数是双向对应的:

从终端读取用户输入的一行消息,存入 line 字符串;write() 把用户输入的消息写入客户端 socket 的发送缓冲区,内核自动通过 TCP 协议发送给服务端。

read() 从客户端 socket 的接收缓冲区读取服务端发回来的回显数据;

n > 0 表示读取成功,把数据拷贝到 inbuffer,手动加 \0 转成字符串,打印到终端;

n == 0 表示服务端正常断开连接,退出循环;

n < 0 表示读取错误(比如网络异常),退出循环。

至此整个服务端和客户端的代码逻辑就讲完了。下面我们看运行结果:

运行结果:

服务端启动初始化成功,创建套接字、绑定和监听都已成功。

客户端启动并成功连接服务端,客户端 connect() 调用成功,TCP 连接建立。please Enter#:客户端进入等待用户输入的状态,准备发送消息。

服务端 accept 成功,拿到客户端连接。accept() 调用成功后 ,内核为这个客户端创建了专属的通信套接字,文件描述符为 4。服务端同时也拿到了客户端的 IP 和临时端口号,证明客户端连接已经被服务端接收。

客户端输入中午好,发送给服务端;收到服务端回显 server echo# 中午好。证明成功收到了客户端的消息;并向客户端发送了回显数据。此时客户端和服务端完成了双向数据收发,回显功能正常,长连接通信流程跑通。

总结:

在我们的第一阶段实现中,单进程和单线程是等价的概念:我们服务端的程序只有一个进程,这个进程里也只有一个主线程,没有任何额外的线程或进程。

因此,这个服务端模型是串行执行的:服务端的主线程必须完整处理完当前客户端的所有收发逻辑(直到客户端断开连接),才能回到 accept() 调用,去接收下一个客户端的连接请求。

这就导致了一个直接的结果:这个服务端没有任何并发能力。同一时间只能服务一个客户端,后续的客户端连接会排队等待,甚至可能因为等待超时而失败。

所以我们将进入第二阶段的改造:引入多进程模型,为每一个客户端连接创建独立的子进程处理通信,实现并发服务。

四、阶段二 : 多进程版本

逻辑梳理:

在多进程改造中,我们采用主进程负责连接、子进程负责通信的分工模型:服务端主进程的职责仅保留为 accept() 接收客户端连接请求;每当成功建立一个新连接,主进程就通过 fork() 创建一个子进程,将该客户端的通信套接字交给子进程处理,由子进程执行 serviceIO() 函数,完成与客户端的读写交互与回显。主进程则立刻回到循环,继续监听和接收下一个客户端连接。这种分工让服务端能够同时处理多个客户端请求,实现并发服务能力。

引发的问题:

引入多进程模型后,我们实现了 "主进程专心 accept、子进程负责通信" 的并发分工,但也随之带来了一个新的问题:子进程退出后,如果父进程不主动调用 wait()/waitpid() 回收它的退出状态,子进程就会变成 "僵尸进程",长期占用系统资源。

而如果让主进程在 accept() 的同时,再去阻塞等待并处理子进程回收,就会破坏 "主进程只负责接收连接" 的设计初衷,让主进程再次陷入 "既要 accept 又要回收子进程" 的双重职责中,导致主进程阻塞在回收操作上,无法及时响应新的客户端连接请求,失去了多进程并发的意义。

解决方法:

那解决方法是什么呢? 有两种解决方法,我们逐个来看 :

方法一 : 引入孙子进程
  1. 主进程通过第一次 fork() 创建子进程,随后在子进程内部执行第二次 fork() 创建孙子进程,此时子进程直接调用 exit(0) 退出。此时,主进程只需调用 waitpid() 回收这个瞬间退出的子进程,而由于子进程已经退出,waitpid()不会产生任何阻塞,主进程可以立即回到 accept() 循环,继续接收下一个客户端连接。同时,被创建出来的孙子进程因为子进程已经退出了,所以就会成为孤儿进程,孤儿进程由系统 init 进程接管,孙子进程就将执行与客户端的 serviceIO 通信逻辑,通信结束后退出时会被系统自动回收,不会产生僵尸进程,也无需主进程额外处理。

  2. 在两次 fork 的并发模型中,三个进程各司其职,职责完全分离,不存在任何冲突:

主进程:仅负责创建子进程,随后立即调用 waitpid() 回收它;回收完成后立刻回到循环,继续执行 accept() 接收新的客户端连接,全程不阻塞、不参与任何业务通信。

子进程:仅负责创建孙子进程,创建完成后立刻调用 exit(0) 退出,成为一个一次性的 "过渡进程",由主进程快速回收,不占用任何系统资源。

孙子进程:被创建后成为孤儿进程,由系统init进程接管;它将执行与客户端的 serviceIO 通信逻辑,通信结束后正常退出,退出状态由系统自动回收,不会产生僵尸进程,也无需主进程额外处理。

  1. 仔细思考,此时仍然存在隐患问题,在 fork() 创建进程时,子进程会完整复制父进程的 task_struct 进程结构体,包括它的文件描述符表。因此,当主进程 accept() 拿到客户端连接(同时持有监听套接字 _listensockfd 和通信套接字 sockfd 后,fork() 出来的子进程、再 fork() 出来的孙子进程,它们的文件描述符表中,都会同时存在这两个文件描述符。

  2. 但三个进程的职责完全不同,因此必须针对性地关闭自己不需要的文件描述符,否则会导致文件描述符泄漏和连接无法正常关闭的问题:

主进程:它只负责 accept 新连接,不需要和客户端通信,因此它必须关闭自己的通信套接字 sockfd。

孙子进程:它只负责和客户端进行 serviceIO 通信,不再需要监听新连接,因此它必须关闭自己的监听套接字 _listensockfd。

子进程:它只是一个 "过渡进程",创建孙子进程后会立刻 exit() 退出,进程退出时操作系统会自动关闭它持有的所有文件描述符,因此它不需要手动关闭任何 fd。

  1. 通过这样的分工和关闭操作,每个进程都只保留自己业务所需的文件描述符,既避免了资源泄漏,也保证了 TCP 连接的正常生命周期管理。
方法二 : 引入信号

这个信号如上,当父进程在 fork() 之前执行这个信号后就代表着告诉操作系统:所有子进程退出后,由操作系统自己自动回收,父进程此时不会管理了。操作系统收到这个指令后,子进程退出时就会被系统自动清理,不会变成僵尸进程。父进程就不用写 wait() / waitpid(),也不用操心任何回收。

原理就是子进程退出时,内核会给父进程发一个信号:SIGCHLD。默认情况下父进程必须调用 wait/waitpid 去回收,否则子进程变僵尸。但是当我们执行了这个 signal(SIGCHLD, SIG_IGN); 代码后,就意味着父进程,选择【忽略】所有子进程退出信号。这个时候也就不存在上述的所有问题,此时父子进程就可以各做各的事。

所以方案二是比方案一更简单的解决方案。但是这两种解决方法都可以。这里我们就采取第一种方法了。

运行结果:

此时我们就将服务端成功的改造为多进程的并发模型、我们只在服务端 accept 成功之后,新增两层 fork 的相关逻辑,其余的所有代码都不用改动。下面我们看运行结果 :

初始化阶段日志显示服务端成功完成 socket 创建、bind 绑定、listen 监听三步初始化,端口 8080 处于正常监听状态。三次连接建立日志里出现了三次 accept success, sockfd: 4,说明有三个客户端成功的连接上了服务端,主进程三次成功接收了客户端的连接请求。

这里我们需要注意的是三次 accept 都返回 sockfd: 4,不是因为文件描述符冲突,而是因为主进程每次 accept 后都会立刻 close 通信套接字,让 4 号文件描述符回到空闲状态,内核会将它重新分配给下一次 accept。
三个客户端窗口的表现一致:连接建立成功每个客户端都输出了 connect to [124.222.191.171:80 80] success!,说明连接建立正常。消息收发正常。

客户端 1 输入 "我是客户端 1",收到服务端回显 server echo# 我是客户端1

客户端 2 输入 "我是客户端 2",收到服务端回显 server echo# 我是客户端2

客户端 3 输入 "我是客户端 3",收到服务端回显 server echo# 我是客户端3

其实在当前的终端界面上,我们是无法直观看到三个进程是 "同时" 运行的,因为三个客户端的交互窗口是分开的,我们只能逐个操作、逐个看回显,没法同时看到三个进程的执行状态。但代码层面,它们是并发执行的:三个孙子进程各自独立处理客户端的读写,主进程也在同时 accept 新连接,内核会调度三个进程交替占用 CPU 时间片,实现真正的并发。

还有个问题就是现在这个还不属于聊天室项目,当前是多进程回显服务器,只是 "客户端 ↔ 服务端" 的一对一通信模型:每个客户端的消息不会转发给其他客户端。聊天室项目需要的是广播模型:服务端需要把一个客户端的消息,转发给所有其他在线的客户端,这需要额外的 "客户端列表管理 + 消息广播" 逻辑。

代码:

EchoTcpServer.hpp :

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/wait.h>
#include "InetAddr.hpp"
#include "Logger.hpp"

using namespace NS_LOG_MODULE;

enum
{
    SUCCESS = 0,
    USAGE_ERR,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    FORK_ERR
};

static const int gbacklog = 16;
static const uint16_t gport = 8888;

class TcpServer
{
public:
    TcpServer(uint16_t port = gport) : _port(port)
    {
    }
    void InitServer()
    {
        // 1. 创建socket
        _listensockfd = socket(AF_INET, SOCK_STREAM, 0); // TCP
        if (_listensockfd < 0)
        {
            LOG(LogLevel::FATAL) << "create socket error";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::DEBUG) << "create socket success: " << _listensockfd;
        // 2. 填充本地socket信息
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = INADDR_ANY; // 任意地址bind

        // 3. bind
        int n = bind(_listensockfd, (struct sockaddr *)&local, sizeof(local));
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            exit(BIND_ERR);
        }
        LOG(LogLevel::DEBUG) << "bind socket success";

        // 4. tcp是面向连接的,所以,TCP服务器要处于一种叫做listen,监听状态。
        n = listen(_listensockfd, gbacklog);
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "listen error";
            exit(LISTEN_ERR);
        }
        LOG(LogLevel::DEBUG) << "listen socket success";
    }
    void serviceIO(int sockfd, InetAddr address)
    {
        // tcp sockfd, 全双工
        // 长服务,长连接
        LOG(LogLevel::DEBUG) << "clinet info is : " << address.ToString();
        while (true)
        {
            char inbuffer[1024] = {0};
            // 读
            ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
            if (n > 0)
            {
                inbuffer[n] = 0;
                LOG(LogLevel::INFO) << address.ToString() << " say# " << inbuffer;

                // 写
                std::string echo_string = "server echo# ";
                echo_string += inbuffer;

                write(sockfd, echo_string.c_str(), echo_string.size());
            }
            else if (n == 0)
            {
                LOG(LogLevel::INFO) << "client quit, address: " << address.ToString();
                break;
            }
            else
            {
                LOG(LogLevel::ERROR) << "client read error, address: " << address.ToString();
                break;
            }
        }
    }

    void Start()
    {
        signal(SIGCHLD, SIG_IGN); // 最佳实践
        while (true)
        {
            struct sockaddr_in clientaddr;
            socklen_t len = sizeof(clientaddr);
            // 5. 获取连接
            int sockfd = accept(_listensockfd, (struct sockaddr *)&clientaddr, &len);
            if (sockfd < 0)
            {
                LOG(LogLevel::WARNING) << "accept errr!";
                continue;
            }
            LOG(LogLevel::DEBUG) << "accept success, sockfd: " << sockfd;
            // version 2: 多进程版本
            pid_t id = fork();
            if (id < 0)
            {
                LOG(LogLevel::FATAL) << "fork errr!";
                exit(FORK_ERR);
            }
            else if (id == 0)
            {
                // 关闭掉自己不需要的sockfd
                close(_listensockfd);
                // 子进程
                if (fork() > 0)
                    exit(0); // 子进程直接退出

                // 孙子进程 - 孤儿进程
                InetAddr clientaddress(clientaddr);
                serviceIO(sockfd, clientaddress);
                close(sockfd);
                exit(0); // 重要
            }
            else
            {
                // 父进程
                // wait(id); waitpid: WNOHANG
                // 关闭掉自己不需要的sockfd
                close(sockfd);
                pid_t rid = waitpid(id, nullptr, 0);
                (void)rid;
            }
        }
    }
    ~TcpServer()
    {
        close(_listensockfd);
    }

private:
    uint16_t _port;
    // 不需要显示包含ip
    int _listensockfd;
};

EchoServerTcpMain.cc :

cpp 复制代码
#include "EchoTcpServer.hpp"
#include <memory>

static void Usage(const std::string &process)
{
    std::cerr << "Usage:\n\t";
    std::cerr << process << " local_port" << std::endl;
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    ENABLE_CONSOLE_LOG_STRATEGY();
    uint16_t server_port = std::stoi(argv[1]);

    std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(server_port);
    tsvr->InitServer();
    tsvr->Start();

    return 0;
}

EchoTcpClient.cc :

cpp 复制代码
#include <iostream>
#include <string>
#include <cstdlib>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "InetAddr.hpp"

static void Usage(const std::string &name)
{
    std::cerr << "Usage:\n\t";
    std::cerr << name << " server_ip server_port" << std::endl;
}

// ./client_tcp server_ip server_port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);
    // 1. 创建tcpsocket套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        std::cerr << "socket error" << std::endl;
        exit(2);
    }

    // 2. 要不要进行bind?需要
    // 要不要显示的bind本地IP和端口?不需要自己手动显示的进行bind,OS会自动bind的!
    // OS 帮助我们进行随机端口,防止端口号冲突
    // 2. 应该干什么?bind端口号ip,发起建立连接的工作
    // {client ip, client port} <-> {server ip, server port}
    InetAddr serveraddress(server_port, server_ip);
    int n = connect(sockfd, (struct sockaddr *)serveraddress.GetNetAddress(), serveraddress.Len());
    if (n < 0)
    {
        std::cerr << "connect to " << serveraddress.ToString() << " failed!"<< std::endl;
        exit(3);
    }
    std::cerr << "connect to " << serveraddress.ToString() << " success!" << std::endl;

    // 3. 通信
    while(true)
    {
        std::string line;
        std::cout << "please Enter# ";
        std::getline(std::cin, line);

        write(sockfd, line.c_str(), line.size());

        char inbuffer[1024];
        ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer));
        if(n > 0)
        {
            inbuffer[n] = 0;
            std::cout << inbuffer << std::endl;
        }
        else if(n == 0)
        {
            std::cout << "read enf of file!" << std::endl;
            break;
        }
        else
        {
            std::cerr << "read error!" << std::endl;
            break;
        }
    }

    return 0;
}

telnet

下面向大家介绍一个工具 telnet,telnet 是一个经典的 TCP 客户端工具,可以用来直接和 TCP 服务端建立连接、手动收发数据,常用来测试我们服务器是否正常工作。

它可以快速验证我们的 TCP 服务端(比如我们写的 8080 端口服务)是否能正常接收连接,还能直接和服务端的端口对话,手动发送数据、看服务端的响应。相当于一个 "极简版的 TCP 客户端",不用我们自己写客户端代码就能测试服务端。

下面我们试验一下 :

我们直接在终端中输入 telnet 124.222.191.171 8080,如果输出 Connected to 124.222.191.171. Escape character is '^]'.,说明连接成功,服务端正常。
此时我们按下 ctrl + ] 然后再按下回车后才能输入。此时我们输入的内容会直接发送给服务端,服务端的回显也会直接打印在终端上,和自己写的客户端效果一样。
退出时我们西药再按 Ctrl + ],再输入 quit 即可。此时服务端也会显示退出。

需要注意的是 telnet 本身不加密,传输的所有数据都是明文的,所以现在很少用来登录远程服务器,更多是用来临时测试 TCP 端口和服务。生产环境的远程登录,一般会用更安全的 ssh 替代。

netstat -natp

我们再介绍一个命令,是 Linux 里一个非常常用的网络调试命令,用来查看当前系统所有的 TCP 网络连接状态。

n 是以数字形式显示 IP 和端口。a 显示所有连接,包括监听和已建立的。t 只显示 TCP 协议的连接。p 显示每个连接对应的进程 PID 和程序名。

这两条记录,就是我们 telnet 连接到 server_tcp 服务端后,系统里的一对 TCP 连接,它们是双向对应的:

上面一条 Local Address: 10.0.16.7:44682 是我们 telnet 客户端用本地端口 44682 发起连接,Foreign Address: 124.222.191.171:8080 目标是我们的服务端的 8080 端口,State: ESTABLISHED 表示连接已经成功建立。

下面一条也同理,Local Address: 10.0.16.7:8080 是服务端正在监听的 8080 端口,Foreign Address: 124.222.191.171:44682 是对方客户端的 IP 和临时端口,正好对应上面 telnet 的 44682,State: ESTABLISHED 表示服务端也成功建立了连接。

一个 TCP 连接在一台机器上,一般只会显示一条记录。而我们现在看到的两条,是因为我们的客户端和服务端都在同一台 Linux 机器上,这台机器同时拥有连接的两个端点,所以 netstat 会把这两个端点的记录都列出来。

五、阶段三: 多线程版本

在完成多进程并发模型的改造后,我们迎来了第三阶段的升级:多线程版本的 TCP 并发服务器。相较于多进程方案,多线程模型以更低的系统开销、更简单的通信方式,实现了相同的并发能力。主线程专注 accept 接收新连接,每个客户端连接由独立的子线程处理,既避免了进程创建的高成本,也解决了进程间通信复杂的问题,是 Linux 下更轻量、更高效的并发实现方式。

多线程 vs 多进程的心差异

逻辑梳理:

其实多线程的并发思路和多进程的一样,主线程只在 while(true) 循环里调用 accept(),收到新连接后,立刻创建一个子线程处理这个连接,然后马上回到循环继续等待下一个客户端。子线程每个连接对应一个独立线程,线程入口函数 thread_routine 会调用 serviceIO 处理客户端的读写交互,线程结束前会自动关闭通信套接字并释放资源。

  1. 首先,我们的目标是实现一个多线程版本的 TCP 服务器,主线程负责 accept 接收连接,每来一个客户端就创建一个线程专门处理它的 IO,所以当主线程调用 pthread_create 创建线程时,必须给它一个符合格式要求的线程入口函数 thread_routine(),也就是必须是返回值为 void*、参数为 void的函数。

  2. 而在 C++ 里,普通的成员函数是不能直接作为线程入口的,因为它在底层会被编译器自动加上一个隐藏的 this 指针参数,用来指向当前对象,这样一来,普通成员函数的签名就变成了 void thread_routine (TcpServer* this, void* args),和 pthread_create 要求的格式不匹配,所以必须把 thread_routine 声明为静态成员函数,静态成员函数不依赖于任何对象,没有隐藏的 this 指针,它的签名自然就是 void* thread_routine (void* args),刚好符合 pthread_create 的要求。

  3. 但问题是,静态成员函数不能直接调用非静态的成员函数,比如我们的 serviceIO 函数,而 serviceIO 就是 TcpServer 类的成员函数,静态成员函数要调用必须通过类对象或类对象的指针来调用,所以我们需要把当前 TcpServer 对象的地址,也就是 EchoTcpServerMain.cc 文件里的定义出来的 TcpServer 类对象的 this 指针,传递给静态线程函数。

  4. 可 pthread_create 又只能传一个 void 类型的参数给 thread_routine 线程入口函数,而我们不仅要传 this指针,还要传客户端的通信套接字 sockfd 和客户端地址信息,所以就需要一个容器把这三样东西打包起来,因此我们在 TcpServer 类里面又写了个内部类 ThreadData,它就像一个快递盒,把主线程里的 this指针、sockfd 和 clientaddress 全部装进去,主线程 accept 到连接后,new 一个 ThreadData 对象,把这三个参数都传进去,然后把这个对象的地址作为 void 参数传给 pthread_create,在线程入口函数 thread_routine 里,我们再把传进来的 void 参数强转回 ThreadData 类型,就能拿到里面存好的 this 指针、sockfd 和地址,再通过 this 指针调用 serviceIO 成员函数,这样既满足了 pthread_create 对入口函数格式的要求,又能让静态函数成功调用非静态的成员函数,同时还能把线程需要的所有参数一次性传递过去,而 ThreadData 的析构函数里还写了 close (_sockfd),在线程函数执行完 delete td 时,会自动关闭通信套接字,避免资源泄漏。可谓十分优雅!

  5. 整个流程里,主线程只负责创建线程,线程函数拿到打包好的参数后,就能独立处理客户端的读写,和主线程互不干扰,实现了并发的效果。可谓十分优雅!

  6. 改进的线程代码如下:

  1. 我们只需要在 Start() 函数里新增红框里这几行代码,再配合上面的 ThreadData 内部类和 thread_routine 静态函数,就完成了多线程版本的改造,其他地方的代码都不用动。

运行结果 :

在多线程版本里,主线程 accept 拿到 sockfd=4 后,会打包交给 ThreadData 传给子线程,主线程没有关闭它,主线程马上回到循环,再次 accept,内核分配下一个可用的文件描述符,也就是 5

所以日志里会出现 4 和 5,它们分别对应两个不同的客户端连接,各自被对应的子线程持有,主线程这边的引用没有被关闭,内核就不会回收这些 fd 编号,只能按顺序递增分配。

而在前面的多进程版本中,主线程 accept 拿到 sockfd=4 后,会立刻执行 close(sockfd),把这个文件描述符关闭。这样,4 号 fd 就回到了空闲状态,下一次 accept 就会再次拿到 4,所以日志里一直是 4。

又因为在 ThreadData 类的析构函数里写了 close(_sockfd); 所以子线程执行完 serviceIO() 后,会 delete td,触发析构函数,自动关闭 sockfd,内核也会自动回收线程持有的文件描述符。

消息收发成功!

代码:

EchoTcpServer.hpp :

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/wait.h>
#include "Thread.hpp"
#include "InetAddr.hpp"
#include "Logger.hpp"

using namespace NS_LOG_MODULE;

enum
{
    SUCCESS = 0,
    USAGE_ERR,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    FORK_ERR
};

static const int gbacklog = 16;
static const uint16_t gport = 8888;

class TcpServer
{
public:
    TcpServer(uint16_t port = gport) : _port(port)
    {
    }
    void InitServer()
    {
        // 1. 创建socket
        _listensockfd = socket(AF_INET, SOCK_STREAM, 0); // TCP
        if (_listensockfd < 0)
        {
            LOG(LogLevel::FATAL) << "create socket error";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::DEBUG) << "create socket success: " << _listensockfd;
        // 2. 填充本地socket信息
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = INADDR_ANY; // 任意地址bind

        // 3. bind
        int n = bind(_listensockfd, (struct sockaddr *)&local, sizeof(local));
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            exit(BIND_ERR);
        }
        LOG(LogLevel::DEBUG) << "bind socket success";

        // 4. tcp是面向连接的,所以,TCP服务器要处于一种叫做listen,监听状态。
        n = listen(_listensockfd, gbacklog);
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "listen error";
            exit(LISTEN_ERR);
        }
        LOG(LogLevel::DEBUG) << "listen socket success";
    }
    void serviceIO(int sockfd, InetAddr address)
    {
        // tcp sockfd, 全双工
        // 长服务,长连接
        LOG(LogLevel::DEBUG) << "clinet info is : " << address.ToString();
        while (true)
        {
            char inbuffer[1024] = {0};
            // 读
            ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
            if (n > 0)
            {
                inbuffer[n] = 0;
                LOG(LogLevel::INFO) << address.ToString() << " say# " << inbuffer;

                // 写
                std::string echo_string = "server echo# ";
                echo_string += inbuffer;

                write(sockfd, echo_string.c_str(), echo_string.size());
            }
            else if (n == 0)
            {
                LOG(LogLevel::INFO) << "client quit, address: " << address.ToString();
                break;
            }
            else
            {
                LOG(LogLevel::ERROR) << "client read error, address: " << address.ToString();
                break;
            }
        }
    }
    class ThreadData  //内部类
    {
    public:
        ThreadData(TcpServer *ts, int sockfd, const InetAddr &addr)
            : _this(ts), _sockfd(sockfd), _addr(addr)
        {
        }
        ~ThreadData()
        {
            close(_sockfd);
        }

    public:
        TcpServer *_this;
        int _sockfd;
        InetAddr _addr;
    };

    static void *thread_routine(void *args)
    {
        ThreadData *td = static_cast<ThreadData *>(args);
        pthread_detach(pthread_self());
        td->_this->serviceIO(td->_sockfd, td->_addr);
        delete td;
        return nullptr;
    }
    
    void Start()
    {
        while (true)
        {
            struct sockaddr_in clientaddr;
            socklen_t len = sizeof(clientaddr);
            // 5. 获取连接
            int sockfd = accept(_listensockfd, (struct sockaddr *)&clientaddr, &len);
            if (sockfd < 0)
            {
                LOG(LogLevel::WARNING) << "accept errr!";
                continue;
            }
            LOG(LogLevel::DEBUG) << "accept success, sockfd: " << sockfd;
            // version 3: 多线程版本
            pthread_t tid;
            InetAddr clientaddress(clientaddr);
            ThreadData *td = new ThreadData(this, sockfd, clientaddress);
            pthread_create(&tid, nullptr, thread_routine, (void *)td);
        }
    }
    ~TcpServer()
    {
        close(_listensockfd);
    }

private:
    uint16_t _port;
    // 不需要显示包含ip
    int _listensockfd;
};

因为 EchoTcpServerMain.ccEchoTcpClient.cc 文件里的代码都没有改动,这里就不贴了。

六、阶段四: 线程池版本

在前面的版本中,我们实现了 "主线程每 accept 成功就创建一个线程执行 IO" 的多线程 TCP 服务器,解决了单进程阻塞和多进程开销大的问题。但随着并发量的提升,"来一个连接就创建一个线程" 的模式,也会暴露出了两个核心缺陷:

  1. 频繁创建 / 销毁线程的系统开销大,高并发场景下性能损耗明显

  2. 线程数量随客户端数无限制增长,可能耗尽系统资源,引发服务崩溃

为了解决这些问题,我们引入了线程池模型,这也是生产环境中服务器并发模型的主流实现。

逻辑梳理:

引入线程池之后,整个服务的并发模型变成了「主线程 + 固定数量工作线程」的协作模式,服务启动时,我们先创建线程池的单例实例,它会一次性创建好预设数量的工作线程,这些线程一创建就会进入阻塞状态,等待任务队列里有新任务到来。

主线程则在 while(true) 循环里,只负责一件事:调用 accept() 接收客户端连接。一旦收到新连接,主线程会把处理这个客户端 IO的逻辑封装成一个任务,调用 Enqueue() 提交到线程池的任务队列里。提交任务的操作是非阻塞的,主线程不会等任务执行完,而是立刻回到循环开头,继续下一次 accept()。

线程池内部,工作线程会持续从任务队列里取任务:队列里有任务时,就取出并执行 serviceIO 处理客户端读写;队列为空时,就阻塞等待,不消耗 CPU 资源。这样一来,所有客户端连接的 IO 处理,都由线程池里的固定线程复用执行,主线程只专注接收连接,两者互不阻塞。

和之前 "每连接就创建一个线程" 的模式相比,这种方式既避免了频繁创建销毁线程的开销,又通过线程池大小限制了最大并发数,不会因为客户端暴增而耗尽系统资源,是服务器开发中更稳定、更高效的并发方案。

改动的地方:

这一步是引入我们封装好的线程池模块,它提供了任务队列、线程复用和并发控制的核心能力,是整个优化的基础。

1. 我们先用 using task_t = std::function<void()>,统一规定了任务标准:必须是无参、无返回值的可调用对象。Lambda 表达式虽然内部要使用 TcpServer 对象的 this 指针,客户端通信套接字(sockfd)和客户端地址信息(clientaddress)等变量,但这些数据都是通过捕获列表提前保存的,不属于函数调用时需要传递的形参,Lambda 自身的执行括号为空、也没有返回值,对外完全符合无参无返回值的格式,正好和 std::function<void()> 完美匹配。最终整个 Lambda 会被隐式转为 task_t 任务类型,我们就可以把包含业务逻辑和所需全部数据的 Lambda,直接封装成一个标准任务,投递到线程池的任务队列中,让线程池的工作线程自动调度执行。主线程完全不用管任务怎么跑,只负责不停 accept 接收新连接、持续往线程池抛任务就行。

  1. 线程池本身是一个重量级对象,提前创建固定数量的工作线程,是为了避免频繁创建 / 销毁线程的开销。如果主线程在 while(true) 循环里每次 accept 到新连接时,都创建一个新的线程池,会导致线程池实例无限增多,完全失去复用线程的意义,甚至直接耗尽系统资源。为了解决这个问题,我们把线程池设计成单例模式:程序运行期间,全局只能有一个线程池实例;第一次调用 ThreadPool<task_t>::Instance() 时,才会真正创建线程池,并初始化所有工作线程;后续每次调用 Instance(),都会直接返回这个已经创建好的线程池对象,不再重复创建。这样一来,主线程在 while(true) 循环里,虽然每次都会执行 ThreadPool<task_t>::Instance()->Enqueue(...) 这行代码,但本质上只是往同一个线程池的任务队列里,不断提交新的客户端处理任务。线程池内部的工作线程会自动从队列中取出任务并执行,既保证了线程池只创建一次、线程复用,又不影响主线程继续接收新连接,实现了高效、可控的并发处理。

运行结果:

两个客户端的连接都被主线程成功接收。线程池单例被成功创建。线程池里的两个工作线程已经启动,正处于等待任务的状态。

消息收发与回显功能正常客户端发送消息后,服务器的 serviceIO 线程成功读取了消息并原样回显:客户端 1 发送 aaa,收到 server echo# aaa。客户端 2 发送 hhh,收到 server echo# hhh。

这证明两个线程池的工作线程,都成功从任务队列中取出了任务,并在后台执行了 serviceIO 函数,逻辑正常。

长连接和短连接

在实现线程池版本的回显服务器时,我们通常会遇到了一个典型的设计选择:长连接与短连接模型。这两种模型的核心区别,直接体现在 serviceIO 函数的实现方式上,也影响了线程池的工作表现。

在当前的实现中,serviceIO 采用了长连接模型:函数内部通过 while(true) 循环,持续为同一个客户端服务,直到客户端主动断开或发生错误。这种设计下,线程池中的一个工作线程,会被一个客户端长期占用,同样就不能被分配给其他客户端,这是长连接的特性。

如果改为短连接模型,只需移除 while(true) 循环,让 serviceIO 执行一次读写回显后,主动调用 close(sockfd) 关闭连接。这样一来,任务执行完毕后线程会立即释放,返回线程池等待下一个任务,就能看到 "任务完成" 的日志,线程也能被高效复用。

两种模型各有适用场景:长连接适合聊天、游戏这类需要频繁交互的场景,能减少连接建立的开销;短连接则更适合 HTTP 这类单次请求 - 响应的场景,能让线程资源被更高效地调度。

代码:

EchoTcpServer.hpp :

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/wait.h>
#include <pthread.h>
#include "ThreadPool.hpp"
#include "InetAddr.hpp"
#include "Logger.hpp"

using namespace NS_LOG_MODULE;
using namespace NS_THREAD_POOL_MODULE;

using task_t = std::function<void()>;

enum
{
    SUCCESS = 0,
    USAGE_ERR,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    FORK_ERR
};

static const int gbacklog = 16;
static const uint16_t gport = 8888;

class TcpServer
{
public:
    TcpServer(uint16_t port = gport) : _port(port)
    {
    }
    void InitServer()
    {
        // 1. 创建socket
        _listensockfd = socket(AF_INET, SOCK_STREAM, 0); // TCP
        if (_listensockfd < 0)
        {
            LOG(LogLevel::FATAL) << "create socket error";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::DEBUG) << "create socket success: " << _listensockfd;
        // 2. 填充本地socket信息
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = INADDR_ANY; // 任意地址bind

        // 3. bind
        int n = bind(_listensockfd, (struct sockaddr *)&local, sizeof(local));
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            exit(BIND_ERR);
        }
        LOG(LogLevel::DEBUG) << "bind socket success";

        // 4. tcp是面向连接的,所以,TCP服务器要处于一种叫做listen,监听状态。
        n = listen(_listensockfd, gbacklog);
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "listen error";
            exit(LISTEN_ERR);
        }
        LOG(LogLevel::DEBUG) << "listen socket success";
    }
    void serviceIO(int sockfd, InetAddr address)
    {
        // tcp sockfd, 全双工
        // 长服务,长连接
        LOG(LogLevel::DEBUG) << "clinet info is : " << address.ToString();
        while (true)
        {
            char inbuffer[1024] = {0};
            // 读
            ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
            if (n > 0)
            {
                inbuffer[n] = 0;
                LOG(LogLevel::INFO) << address.ToString() << " say# " << inbuffer;

                // 写
                std::string echo_string = "server echo# ";
                echo_string += inbuffer;

                write(sockfd, echo_string.c_str(), echo_string.size());
            }
            else if (n == 0)
            {
                LOG(LogLevel::INFO) << "client quit, address: " << address.ToString();
                break;
            }
            else
            {
                LOG(LogLevel::ERROR) << "client read error, address: " << address.ToString();
                break;
            }
        }
    }
    class ThreadData  //内部类
    {
    public:
        ThreadData(TcpServer *ts, int sockfd, const InetAddr &addr)
            : _this(ts), _sockfd(sockfd), _addr(addr)
        {
        }
        ~ThreadData()
        {
            close(_sockfd);
        }

    public:
        TcpServer *_this;
        int _sockfd;
        InetAddr _addr;
    };

    static void *thread_routine(void *args)
    {
        ThreadData *td = static_cast<ThreadData *>(args);
        pthread_detach(pthread_self());
        td->_this->serviceIO(td->_sockfd, td->_addr);
        delete td;
        return nullptr;
    }
    
    void Start()
    {
        while (true)
        {
            struct sockaddr_in clientaddr;
            socklen_t len = sizeof(clientaddr);
            // 5. 获取连接
            int sockfd = accept(_listensockfd, (struct sockaddr *)&clientaddr, &len);
            if (sockfd < 0)
            {
                LOG(LogLevel::WARNING) << "accept errr!";
                continue;
            }
            LOG(LogLevel::DEBUG) << "accept success, sockfd: " << sockfd;
            // version 4: 接入线程池
            InetAddr clientaddress(clientaddr);
            ThreadPool<task_t>::Instance()->Enqueue([this, sockfd, clientaddress]()->void{
                this->serviceIO(sockfd, clientaddress);
            });
        }
    }
    ~TcpServer()
    {
        close(_listensockfd);
    }

private:
    uint16_t _port;
    // 不需要显示包含ip
    int _listensockfd;
};

客户端代码、服务端主函数、网络连接逻辑和业务处理函数 serviceIO 均无需修改,仅需在服务端头文件中引入 ThreadPool.hpp 即可。

七、总结

本文详细介绍了从UDP过渡到TCP协议的学习过程,对比了两种协议的核心差异。TCP协议具有连接可靠、有序传输等特点,适合正式业务场景。文章通过四个阶段逐步实现TCP服务端:单线程版本梳理基础流程;多进程版本引入并发处理;多线程版本优化资源开销;最终采用线程池模型实现高效可控的并发。每个阶段都包含代码实现和运行验证,完整展现了TCP服务器的开发演进路径,为网络编程提供了实用参考。

谢谢大家的观看!

相关推荐
IMPYLH1 小时前
Linux 的 stty 命令
linux·运维·服务器·python·bash
嘻嘻哈哈樱桃1 小时前
牛客经典101题题解集--堆/栈/队列
java·开发语言·算法
csbysj20202 小时前
Memcached append 命令详解
开发语言
凯瑟琳.奥古斯特2 小时前
常见排序算法性能对比
数据结构·算法·排序算法
Hello!!!!!!2 小时前
C++基础(十二)——标准库算法
c++·算法
故事还在继续吗2 小时前
C++内存模型
开发语言·c++·内存
Tairitsu_H2 小时前
C++:构造函数与初始化列表详解
开发语言·c++·构造函数
落羽的落羽2 小时前
【Linux系统】总结线程:死锁问题、实现带有日志模块的线程池类
linux·运维·服务器·c++·人工智能·机器学习
林熙蕾LXL2 小时前
Ubuntu——远程连接
linux·运维·服务器