目录
[一、从 UDP 过渡到 TCP 协议](#一、从 UDP 过渡到 TCP 协议)
[二、TCP 编码环节](#二、TCP 编码环节)
[三、阶段一 : 单进程/单线程版本](#三、阶段一 : 单进程/单线程版本)
[EchoTcpServer.hpp 服务端:](#EchoTcpServer.hpp 服务端:)
[初始化函数 InitServer()](#初始化函数 InitServer())
[启动主循环 Start() 函数](#启动主循环 Start() 函数)
[业务处理函数 serviceIO()](#业务处理函数 serviceIO())
[EchoTcpServerMain.cc 服务端:](#EchoTcpServerMain.cc 服务端:)
[EchoTcpClient.cc 客户端:](#EchoTcpClient.cc 客户端:)
[四、阶段二 : 多进程版本](#四、阶段二 : 多进程版本)
[方法一 : 引入孙子进程](#方法一 : 引入孙子进程)
[方法二 : 引入信号](#方法二 : 引入信号)
[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 用到的核心接口如下,比较简单:
- 创建套接字:socket (AF_INET, SOCK_DGRAM, 0)
- 服务端绑定端口:bind()
- 收所有客户端消息:recvfrom()
- 给所有客户端发消息:sendto()
服务端整个过程只需要一个套接字,就能管理全部的客户端。
TCP 要用到的接口则会多一点,并且使用逻辑和 UDP 也不一样:
- 创建监听套接字:socket (AF_INET, SOCK_STREAM, 0)
- 服务端绑定端口:bind()
- 服务端开启监听,等待客户端上门:listen()
- 服务端接收客户端连接,生成专属通信套接字:accept()
- 服务端和客户端收数据:read() / recv()
- 服务端和客户端收发数据:write() / send()
- 最后关闭套接字:close()
UDP 靠 recvfrom / sendto 就能直接通信;而 TCP 需要先监听、先建立连接,生成一对一的专属套接字收发数据,结构更严谨,也更复杂。
5. 实际开发使用上,二者最直观的区别:
UDP 服务端使用一个套接字 socket 就能通吃全场,不用管谁在线、谁离线。
TCP 每增加一个客户端就要多开一个通信套接字 socket,必须多线程管理每一个客户端连接,架构更贴近企业真实后端服务。
UDP 不用处理粘包,TCP 需要手动解决粘包拆包问题。
UDP 只管收发,不管可靠;TCP 全程保障消息绝对送达,适合正式项目。
二、TCP 编码环节
在系统对比了 UDP 与 TCP 的核心差异后,我们正式进入 TCP 协议的编码环节。为了循序渐进地理解 TCP 服务端的通信模型与并发通信,我们将按照从简单到复杂、从基础到企业级的思路,分四个阶段来实现我们的 TCP 代码,和之前的 UDP 模型一样,我们依然使用服务端和客户端互相通信的结构模型:
-
阶段一:单进程/单线程版本:我们先不追求并发,用最简单的串行模型,把 TCP 通信的核心接口、调用流程和数据收发逻辑梳理清楚。为后续的优化打基础。
-
阶段二:多进程版本:在单线程的基础上,引入 fork() 系统调用,让服务端能够同时为多个客户端服务,体验操作系统进程模型在并发中的应用。
-
阶段三:多线程版本:基于多进程模型进行优化,服务端改用线程实现并发,感受线程比进程更轻量、更高效的优势。
-
阶段四:线程池版本:引入线程池,解决多线程频繁创建销毁的开销问题,实现企业级服务端常用的高效、可控的并发模型。
接下来,我们就从阶段一:单进程/单线程版本开始,一步步搭建我们的第一个 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 最大的区别:
-
监听套接字 _listensockfd:它是服务端的全局唯一资源,从 InitServer() 创建出来,就一直固定不变,整个进程里只有这一个,所以我们直接把它存为类的成员变量,从头到尾复用。
-
通信套接字 sockfd:它是服务端内核动态创建的。每次调用 accept() 成功时,内核会给服务端分配一个全新的、独立的 sockfd,专供服务端与当前客户端一对一收发数据使用。这个 sockfd 归服务端所有,客户端断开连接后,该 sockfd 就会被关闭释放。因此我们不把这个通信套接字作为成员变量储存。
构造函数
1. 我们先从 TcpServer 类的构造函数讲起,它是整个服务端的起点,核心作用就是初始化服务端的基础配置,最关键的就是绑定端口号。当我们启动服务端程序时,会给服务端传入一个端口号,这个端口号就是用来唯一标识这台服务器上的 TCP 服务的。
客户端要连接服务端,必须指定 服务端 IP + 这个端口号 ,才能精准找到对应的服务;
服务端拿到这个端口号后,会在后续的 InitServer() 中,通过 bind() 系统调用,把监听套接字 _listensockfd 和这个端口号绑定在一起,让内核知道:所有发往这个端口的 TCP 连接请求,都要交给这个监听套接字来处理。
初始化函数 InitServer()
1. 第一步 socket() 创建监听套接字,第一个参数 AF_INET 代表使用 IPv4 网络协议;第二个参数 SOCK_STREAM 代表面向连接、流式传输,这是 TCP 的专属特征;第三个参数 0 表示默认协议,内核会自动适配 TCP。这两个核心参数组合,明确告诉内核:当前要创建的套接字,是专门用于 TCP 通信的套接字。套接字创建成功后,返回一个文件描述符,也就是我们的 _listensockfd。从内核层面看,Socket 是一个特殊的网络文件对象。进程调用 socket() 系统调用时,内核会在内存中创建一个专门用于网络通信的文件对象,并分配唯一的文件描述符 fd。这个文件对象内部维护着网络状态、协议信息,以及用于收发数据的内核缓冲区。后续所有网络操作,本质上都是通过文件描述符 fd,来操作这个内核对象。
-
第二步填充本地 sockaddr_in 结构体,我们手动定义并填充本机地址信息,地址协议家族同样设为 AF_INET,和 socket() 创建时保持一致;填入服务端固定端口,并通过 htons() 转换为网络大端字节序;IP 地址填写 INADDR_ANY,含义是绑定本机所有网卡、所有可用 IP,外来连接只要访问当前端口,都会被接收。这一步只是在用户态代码中,写好一份 "本机地址配置信息",它只是一个普通结构体变量,尚未和任何文件描述符、内核对象产生关联。
-
第三步 bind() 绑定,将第二步填充好的地址结构体,写入到 _listensockfd 索引对应的内核 Socket 文件对象中,把本机 IP、端口信息记录进去。绑定完成后,内核就明确了:这个 _listensockfd 对应的套接字,专门负责监听结构体 local 中指定的「本机 IP + 端口」的 TCP 连接请求。后续只要有客户端访问该服务器的端口,内核就会把连接请求交给这个监听套接字处理。
listen()
- 第四步 listen() 开启监听:第一个参数是要开启监听的文件描述符,必须是已经 bind() 绑定好的监听套接字。第二个参数是等待队列长度:内核会为当前监听端口维护一条队列,用于存放未完成三次握手、或已完成握手但尚未被 accept() 取走的客户端连接请求,第二个参数就是这条队列的最大容量。listen() 的作用,是把原本普通的 TCP 套接字,升级为监听状态:告诉内核当前 _listensockfd 不再用于普通通信,专门用来被动等待客户端连接;内核会开始在底层网卡层面,监听对应端口的 TCP 连接请求,客户端发来的三次握手请求会被内核自动处理,放入等待队列,排队等待服务端 accept() 取用。
启动主循环 Start() 函数
1. 首先定义出来的 client_addr 是一个用户态的 sockaddr_in 结构体,它的作用就是用来存放连接进来的客户端的 IP 地址和端口号。方便我们在日志里打印客户端信息。
accept()
accept 的后两个参数是输出型参数。我们已经提前定义了一块空的地址结构体内存,无需手动填写客户端信息。当客户端完成三次握手连接服务端时,内核会自动获取客户端的 IP 与端口,并主动将这些信息写入我们传入的结构体中。第一个参数是服务端自身的监听文件描述符,用于等待新连接;后两个参数用于带出对端客户端地址,两者作用独立,互不冲突。accept 的本质是从内核的连接等待队列里,取出一条已完成三次握手的客户端连接,为其创建专属的通信套接字,并返回给用户态使用。
accept() 成功后会返回一个全新的、客户端专属的通信套接字 fd;因为 TCP 是面向连接的协议,每个客户端都需要独立的通信上下文(收发缓冲区、连接状态、地址信息)。内核为每个客户端单独创建 socket 结构体和 fd,能保证不同客户端之间的数据完全隔离,不会互相干扰。
-
accept() 后面的代码 InetAddr clientaddr(client_addr) 就是把 accept() 填好的 struct sockaddr_in client_addr,包装成我们自己的 InetAddr 对象;方便后续调用 ToString() 方法,打印出 [IP:端口] 格式的日志。
-
下一行的 serviceIO(sockfd, clientaddr) 就是把 accept() 返回的通信套接字 sockfd,和包装好的客户端地址对象,一起传给 serviceIO 函数;让 serviceIO 知道:这次要和哪个客户端(clientaddr) ,用哪个 fd(sockfd)来收发数据。
业务处理函数 serviceIO()

-
serviceIO() 函数是服务端与单个客户端通信的核心逻辑,它接收 accept() 返回的客户端通信套接字 sockfd 和地址信息,首先打印客户端地址日志;
-
先通过 address.ToString() 和日志打印出客户端的 IP 和端口,方便我们在日志里区分不同的客户端连接。
-
下面就进入这个 while(true) 循环,这是 "长连接" 的体现:只要客户端不断开,服务端就会一直循环,等待客户端发送新数据,不会一次读写就退出。
-
再下来就是客户端通过 read() 系统调用来读取数据了,read() 的第一个参数 sockfd 就是 accept() 返回的、客户端专属的通信套接字 fd。第二个参数 inbuffer 是用户态的缓冲区,用来存放从内核缓冲区读出来的数据。第三个参数 sizeof(inbuffer) - 1 是最多读取的字节数。
-
内核在底层会检查 sockfd 对应的 socket 网络文件的接收缓冲区;如果缓冲区里有数据,就把数据拷贝到用户态的 inbuffer 里,并返回实际读取的字节数 n;如果缓冲区为空,read() 会阻塞等待,直到客户端发送数据过来;如果客户端已经正常断开连接,read() 会直接返回 0;如果发生错误(比如客户端异常断开、网络错误),read() 会返回 -1,并设置 errno。
-
读成功后的处理( n>0),把客户端地址和收到的消息一起打印,方便我们在日志里看到 "哪个客户端说了什么"。再把收到的消息前面加上 server echo# 前缀,构造一个回显字符串,准备回发给客户端。
-
读完之后服务端就可以向客户端通过 write() 系统调用写了,write() 的第一个参数 sockfd 同样是客户端专属的通信套接字 fd;第二个参数是写的内容,我们将它转换为 C 风格的字符形式;
第三个参数是要发送的字节数。
-
write() 时底层会把用户态的字符串数据,拷贝到 sockfd 对应的内核 socket 网络文件的发送缓冲区里;内核的 TCP 协议栈会自动把发送缓冲区的数据封装成 TCP 报文,通过网络发给客户端;如果发送缓冲区满了,write() 会阻塞,直到缓冲区有空间写入。
-
再后面 n == 0 代表客户端已经正常关闭了连接,此时服务端也应该退出循环,后续关闭 sockfd,释放资源;n < 0 代表读取出错(比如客户端异常掉线、网络错误),此时也要退出循环,关闭连接。
EchoTcpServerMain.cc 服务端:
主文件是 TCP 回显服务器的入口,首先校验命令行参数,要求还要用户传入端口号;随后开启控制台日志系统,将端口号字符串转为整数,用于创建服务器实例;通过智能指针管理 TcpServer 对象,调用 InitServer() 完成套接字创建、绑定与监听的初始化工作,再调用 Start() 启动服务,进入监听循环,等待客户端连接并处理通信。
EchoTcpClient.cc 客户端:

这是一个客户端的主函数代码,它的核心流程是:
-
接收命令行传入的服务端 IP 和端口号
-
创建客户端套接字(socket)
-
发起连接请求(connect)
-
与服务端进行交互:用户输入消息 → 发送给服务端 → 接收回显并打印
-
处理连接断开与错误
-
第一步是命令行参数校验与解析,客户端运行时必须传入两个参数:服务端 IP和服务端端口号。当参数个数不对时,打印用法提示。把命令行传入的 IP 字符串存入 server_ip,端口号字符串转成整数存入 server_port,为后续连接做准备。
-
第二步就是客户端创建客户端套接字(socket),这一步和服务端创建 socket 一样,创建 socket() 时,内核会为客户端创建一个专属的网络文件对象,包含收发缓冲区、协议状态等信息,并返回文件描述符 sockfd 给用户态。需要注意的是客户端不需要手动 bind() 绑定端口,操作系统会自动为客户端分配一个临时端口,避免端口冲突。
-
第三部构造服务端地址信息,InetAddr serveraddress(server_port, server_ip) 就是把传入的服务端 IP 和端口,封装成我们自己的 InetAddr 对象,底层是 struct sockaddr_in 结构体,用来传递给 connect 系统调用。
connect()
connect() 的作用就是向指定的服务端发起 TCP 连接请求,完成三次握手。第一个参数是客户端自己的 socket fd,第二个参数是服务端地址结构体,第三个参数是地址结构体长度。
connect 就是在告诉内核三件事 : 我客户端要用 sockfd 这个套接字,去连接 serveraddress 里这个服务端地址,而这个地址结构体的大小是 addrlen。
- 第四部就是客户端向服务端的通信环节,这部分是客户端和服务端的交互逻辑,和服务端的 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 又要回收子进程" 的双重职责中,导致主进程阻塞在回收操作上,无法及时响应新的客户端连接请求,失去了多进程并发的意义。
解决方法:
那解决方法是什么呢? 有两种解决方法,我们逐个来看 :
方法一 : 引入孙子进程

-
主进程通过第一次 fork() 创建子进程,随后在子进程内部执行第二次 fork() 创建孙子进程,此时子进程直接调用 exit(0) 退出。此时,主进程只需调用 waitpid() 回收这个瞬间退出的子进程,而由于子进程已经退出,waitpid()不会产生任何阻塞,主进程可以立即回到 accept() 循环,继续接收下一个客户端连接。同时,被创建出来的孙子进程因为子进程已经退出了,所以就会成为孤儿进程,孤儿进程由系统 init 进程接管,孙子进程就将执行与客户端的 serviceIO 通信逻辑,通信结束后退出时会被系统自动回收,不会产生僵尸进程,也无需主进程额外处理。
-
在两次 fork 的并发模型中,三个进程各司其职,职责完全分离,不存在任何冲突:
主进程:仅负责创建子进程,随后立即调用 waitpid() 回收它;回收完成后立刻回到循环,继续执行 accept() 接收新的客户端连接,全程不阻塞、不参与任何业务通信。
子进程:仅负责创建孙子进程,创建完成后立刻调用 exit(0) 退出,成为一个一次性的 "过渡进程",由主进程快速回收,不占用任何系统资源。
孙子进程:被创建后成为孤儿进程,由系统init进程接管;它将执行与客户端的 serviceIO 通信逻辑,通信结束后正常退出,退出状态由系统自动回收,不会产生僵尸进程,也无需主进程额外处理。
-
仔细思考,此时仍然存在隐患问题,在 fork() 创建进程时,子进程会完整复制父进程的 task_struct 进程结构体,包括它的文件描述符表。因此,当主进程 accept() 拿到客户端连接(同时持有监听套接字 _listensockfd 和通信套接字 sockfd 后,fork() 出来的子进程、再 fork() 出来的孙子进程,它们的文件描述符表中,都会同时存在这两个文件描述符。
-
但三个进程的职责完全不同,因此必须针对性地关闭自己不需要的文件描述符,否则会导致文件描述符泄漏和连接无法正常关闭的问题:
主进程:它只负责 accept 新连接,不需要和客户端通信,因此它必须关闭自己的通信套接字 sockfd。
孙子进程:它只负责和客户端进行 serviceIO 通信,不再需要监听新连接,因此它必须关闭自己的监听套接字 _listensockfd。
子进程:它只是一个 "过渡进程",创建孙子进程后会立刻 exit() 退出,进程退出时操作系统会自动关闭它持有的所有文件描述符,因此它不需要手动关闭任何 fd。
- 通过这样的分工和关闭操作,每个进程都只保留自己业务所需的文件描述符,既避免了资源泄漏,也保证了 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;
};
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;
}
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 处理客户端的读写交互,线程结束前会自动关闭通信套接字并释放资源。
-
首先,我们的目标是实现一个多线程版本的 TCP 服务器,主线程负责 accept 接收连接,每来一个客户端就创建一个线程专门处理它的 IO,所以当主线程调用 pthread_create 创建线程时,必须给它一个符合格式要求的线程入口函数 thread_routine(),也就是必须是返回值为 void*、参数为 void的函数。

-
而在 C++ 里,普通的成员函数是不能直接作为线程入口的,因为它在底层会被编译器自动加上一个隐藏的 this 指针参数,用来指向当前对象,这样一来,普通成员函数的签名就变成了 void thread_routine (TcpServer* this, void* args),和 pthread_create 要求的格式不匹配,所以必须把 thread_routine 声明为静态成员函数,静态成员函数不依赖于任何对象,没有隐藏的 this 指针,它的签名自然就是 void* thread_routine (void* args),刚好符合 pthread_create 的要求。
-
但问题是,静态成员函数不能直接调用非静态的成员函数,比如我们的 serviceIO 函数,而 serviceIO 就是 TcpServer 类的成员函数,静态成员函数要调用必须通过类对象或类对象的指针来调用,所以我们需要把当前 TcpServer 对象的地址,也就是 EchoTcpServerMain.cc 文件里的定义出来的 TcpServer 类对象的 this 指针,传递给静态线程函数。
-
可 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 时,会自动关闭通信套接字,避免资源泄漏。可谓十分优雅!
-
整个流程里,主线程只负责创建线程,线程函数拿到打包好的参数后,就能独立处理客户端的读写,和主线程互不干扰,实现了并发的效果。可谓十分优雅!
-
改进的线程代码如下:


- 我们只需要在 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.cc 和 EchoTcpClient.cc 文件里的代码都没有改动,这里就不贴了。
六、阶段四: 线程池版本
在前面的版本中,我们实现了 "主线程每 accept 成功就创建一个线程执行 IO" 的多线程 TCP 服务器,解决了单进程阻塞和多进程开销大的问题。但随着并发量的提升,"来一个连接就创建一个线程" 的模式,也会暴露出了两个核心缺陷:
-
频繁创建 / 销毁线程的系统开销大,高并发场景下性能损耗明显
-
线程数量随客户端数无限制增长,可能耗尽系统资源,引发服务崩溃
为了解决这些问题,我们引入了线程池模型,这也是生产环境中服务器并发模型的主流实现。
逻辑梳理:
引入线程池之后,整个服务的并发模型变成了「主线程 + 固定数量工作线程」的协作模式,服务启动时,我们先创建线程池的单例实例,它会一次性创建好预设数量的工作线程,这些线程一创建就会进入阻塞状态,等待任务队列里有新任务到来。
主线程则在 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 接收新连接、持续往线程池抛任务就行。
- 线程池本身是一个重量级对象,提前创建固定数量的工作线程,是为了避免频繁创建 / 销毁线程的开销。如果主线程在 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服务器的开发演进路径,为网络编程提供了实用参考。
谢谢大家的观看!
1. 我们先从 TcpServer 类的构造函数讲起,它是整个服务端的起点,核心作用就是初始化服务端的基础配置,最关键的就是绑定端口号。当我们启动服务端程序时,会给服务端传入一个端口号,这个端口号就是用来唯一标识这台服务器上的 TCP 服务的。


服务端启动初始化成功,创建套接字、绑定和监听都已成功。
客户端启动并成功连接服务端,客户端 connect() 调用成功,TCP 连接建立。please Enter#:客户端进入等待用户输入的状态,准备发送消息。
服务端 accept 成功,拿到客户端连接。accept() 调用成功后 ,内核为这个客户端创建了专属的通信套接字,文件描述符为 4。服务端同时也拿到了客户端的 IP 和临时端口号,证明客户端连接已经被服务端接收。
客户端输入中午好,发送给服务端;收到服务端回显 server echo# 中午好。证明成功收到了客户端的消息;并向客户端发送了回显数据。此时客户端和服务端完成了双向数据收发,回显功能正常,长连接通信流程跑通。
三个客户端窗口的表现一致:连接建立成功每个客户端都输出了 connect to [124.222.191.171:80 80] success!,说明连接建立正常。消息收发正常。

此时我们输入的内容会直接发送给服务端,服务端的回显也会直接打印在终端上,和自己写的客户端效果一样。
退出时我们西药再按 Ctrl + ],再输入 quit 即可。
此时服务端也会显示退出。
这两条记录,就是我们 telnet 连接到 server_tcp 服务端后,系统里的一对 TCP 连接,它们是双向对应的:
这一步是引入我们封装好的线程池模块,它提供了任务队列、线程复用和并发控制的核心能力,是整个优化的基础。

两个客户端的连接都被主线程成功接收。线程池单例被成功创建。线程池里的两个工作线程已经启动,正处于等待任务的状态。
消息收发与回显功能正常客户端发送消息后,服务器的 serviceIO 线程成功读取了消息并原样回显:客户端 1 发送 aaa,收到 server echo# aaa。客户端 2 发送 hhh,收到 server echo# hhh。