目录
[1.1 名称记忆](#1.1 名称记忆)
[1.2 传统 IPv4 专用函数](#1.2 传统 IPv4 专用函数)
[1.2.1 inet_addr(有缺陷)](#1.2.1 inet_addr(有缺陷))
[1.2.2 inet_aton(安全)](#1.2.2 inet_aton(安全))
[1.2.3 inet_ntoa(危险)(⭐⭐⭐)](#1.2.3 inet_ntoa(危险)(⭐⭐⭐))
[1.3 现代通用函数 (⭐⭐⭐)](#1.3 现代通用函数 (⭐⭐⭐))
[1.3.1 inet_pton (字符串 -> 网络整数)](#1.3.1 inet_pton (字符串 -> 网络整数))
[1.3.2 inet_ntop (网络整数 -> 字符串)](#1.3.2 inet_ntop (网络整数 -> 字符串))
[1.4 sockaddr_in 与 in_addr的明确区分](#1.4 sockaddr_in 与 in_addr的明确区分)
[二、简单的 TCP 网络程序架构](#二、简单的 TCP 网络程序架构)
[1.1 服务端创建套接字 (socket)](#1.1 服务端创建套接字 (socket))
[1.2 服务端绑定 (bind)](#1.2 服务端绑定 (bind))
[1.3 服务端监听 (listen)【TCP 独有步骤】(⭐⭐⭐)](#1.3 服务端监听 (listen)【TCP 独有步骤】(⭐⭐⭐))
[1.4 服务端获取连接 (accept)](#1.4 服务端获取连接 (accept))
[1.5 服务端处理请求](#1.5 服务端处理请求)
[2. 客户端](#2. 客户端)
[2.1 客户端创建套接字](#2.1 客户端创建套接字)
[2.2 客户端连接服务器(connect) 【TCP 独有步骤】(⭐⭐⭐)](#2.2 客户端连接服务器(connect) 【TCP 独有步骤】(⭐⭐⭐))
[2.3 客户端发起请求](#2.3 客户端发起请求)
[3. 服务器测试](#3. 服务器测试)
[4. 单执行流服务器的致命弊端](#4. 单执行流服务器的致命弊端)
[三、高并发 TCP 服务器的改进(⭐⭐⭐)](#三、高并发 TCP 服务器的改进(⭐⭐⭐))
[1. 多进程版 (Multi-Process)](#1. 多进程版 (Multi-Process))
[2. 多线程版 (Multi-Thread)](#2. 多线程版 (Multi-Thread))
[3. 线程池版 (ThreadPool)](#3. 线程池版 (ThreadPool))
一、inet_家族函数的学习
inet_ 家族函数能一次性完成:"字符串与整数的互转" 以及 "主机字节序与网络字节序的互转"。
1.1 名称记忆
inet:Internet,代表这是网络地址转换家族。
a:Ascii(或 Alphanumeric),代表我们人肉眼能看懂的字符串 格式(如"127.0.0.1")。
n:Network,代表计算机底层使用的网络字节序(二进制整数)格式。
p:Presentation(表达格式),现代 API 的新叫法,等同于a(字符串)。
to:转换的方向。
1.2 传统 IPv4 专用函数
1.2.1 inet_addr(有缺陷)
cpp
函数原型: in_addr_t inet_addr(const char *cp);
参数介绍: 传入一个指向点分十进制字符串的指针(比如
"192.168.1.1")。返回值: 成功则返回 32 位的网络字节序整数;失败返回
INADDR_NONE(就是0xFFFFFFFF)。缺陷: 它无法处理
255.255.255.255这个广播地址,因为这和它的失败返回值重合了。
1.2.2 inet_aton(安全)
cpp
函数原型: int inet_aton(const char *cp, struct in_addr *inp);
参数介绍:
cp是输入的字符串 IP;inp是输出型参数,也就是你要填充的那个结构体。返回值: 转换成功返回非 0 值(通常是 1),输入的字符串无效则返回 0。
1.2.3 inet_ntoa(危险)(⭐⭐⭐)
cpp
函数原型: char *inet_ntoa(struct in_addr in);
参数介绍: 它传的不是指针,而是按值传递 (pass by value) 整个
struct in_addr结构体!返回值: 返回转换好的字符串指针。
极其危险的线程安全缺陷: 这个函数内部使用了一块静态的全局内存区 来存放转换后的字符串。在单线程下没问题,但在我们写的多线程/线程池服务器中,如果线程 A 和线程 B 同时调用它,线程 B 转换的 IP 会瞬间覆盖掉线程 A 的结果。
1.3 现代通用函数 (⭐⭐⭐)
由于早期的函数既不支持 IPv6,又存在多线程覆盖的 Bug,在现代的网络编程(特别是高并发服务器)中,推荐使用下面这两个版本。它们的名字里带了 p 和 n。
1.3.1 inet_pton (字符串 -> 网络整数)
cpp
函数原型: int inet_pton(int af, const char *src, void *dst);
参数介绍:
af(Address Family):协议族。填AF_INET代表 IPv4,填AF_INET6代表 IPv6。
src:你要转换的字符串 IP(如"127.0.0.1")。
dst:输出型参数。准备一个struct sockaddr_in里的sin_addr地址传进去,转换好的整数会直接塞进这里。返回值: 成功返回 1;如果传入的字符串格式不对返回 0;如果
af协议族不认识返回 -1。
1.3.2 inet_ntop (网络整数 -> 字符串)
cpp
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
参数介绍:
af:AF_INET或AF_INET6。
src:包含底层网络整数的结构体指针。
dst: 必须由程序员自己提前在局部作用域里开辟好一个字符数组(Buffer)传进去。因为是局部变量,所以绝对线程安全!
size:你开辟的缓冲区的大小(防止缓冲区溢出)。返回值: 成功则返回指向
dst的指针,失败返回 NULL。
1.4 sockaddr_in 与 in_addr的明确区分
inet_ 家族的函数,绝大多数操作的都是最里面那个纯粹的 IP 结构体:struct in_addr(或者它的指针),而不是外层带有端口号的 struct sockaddr_in。

cpp
// IPv4 专用的套接字地址结构体
struct sockaddr_in {
sa_family_t sin_family; // 协议家族 (AF_INET,占用 2 字节)
in_port_t sin_port; // 端口号 (占用 2 字节,必须是网络字节序)
struct in_addr sin_addr; // 注意看这里!它把上面的结构体嵌套进来了 (占用 4 字节)
char sin_zero[8]; // 填充字符,通常全填 0 (占用 8 字节)
};
cpp
// 专门用来存储 IPv4 地址的结构体
struct in_addr {
uint32_t s_addr; // 一个 32 位的无符号整数 (存放网络字节序的 IP)
};
二、简单的 TCP 网络程序架构

1.服务端
1.1 服务端创建套接字 (socket)
这是通信的第一步,相当于"买一部手机"。
cpp
int listensock_ = socket(AF_INET, SOCK_STREAM, 0);
if (listensock_ < 0) {
// 处理创建失败
}
核心改变: 注意第二个参数 。在 UDP 中我们使用的是
SOCK_DGRAM(数据报),而在 TCP 中,我们必须使用SOCK_STREAM。原理解析:
STREAM意为"字节流"。TCP 将数据视为一连串无边界的字节,就像自来水管里的水一样,源源不断且保证顺序,这是 TCP 可靠性的基础。
1.2 服务端绑定 (bind)
手机买好了,得给它"办一张电话卡",这就是绑定 IP 和端口。这一步和 UDP 完全一样。告诉操作系统:"这台机器上 8080 端口的数据,以后全交给我处理!"
cpp
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8080); // 必须转为网络字节序
local.sin_addr.s_addr = INADDR_ANY; // 强烈建议绑定 0.0.0.0
bind(listensock_, (struct sockaddr *)&local, sizeof(local));
1.3 服务端监听 (listen)【TCP 独有步骤】(⭐⭐⭐)
cpp
int listen(int sockfd, int backlog);
sockfd: 套接字文件描述符 。这个套接字必须是已经通过socket()创建,并且通常已经使用bind()绑定了本地地址和端口。backlog:连接请求队列的最大长度。它定义了内核为该监听套接字排队的最大连接数。
1.4 服务端获取连接 (accept)
cpp
struct sockaddr_in client;
socklen_t len = sizeof(client);
// 阻塞等待,直到从队列中获取到一个建立好的连接
int sockfd = accept(listensock_, (struct sockaddr *)&client, &len);
listensock_(监听套接字): 它是餐厅门口的"迎宾员"。它的唯一职责就是站在门口看有没有新客人来(处理新连接)。它只负责拉客,不负责上菜。
sockfd(服务套接字): 它是餐厅里的"专属服务员"。当accept成功后,操作系统会返回这个新的sockfd。接下来的点菜、上菜(数据的 read/write),全部由这个专门的sockfd和特定的客户端进行一对一通信。
1.5 服务端处理请求
连接建立好了,接下来的收发数据就可以像读写普通文件一样了。
cpp
char buffer[1024];
// TCP 中可以直接使用 read 和 write,不需要像 UDP 那样用 recvfrom
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = 0;
std::cout << "收到客户端消息: " << buffer << std::endl;
// 回显给客户端
write(sockfd, buffer, n);
}
// 处理完毕后,务必关闭专属服务员,节约资源
close(sockfd);
2. 客户端
2.1 客户端创建套接字
客户端的创建非常简单,同样是买一部手机:
cpp
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
客户端同样不需要显式 bind,操作系统会在底层隐式绑定随机端口。
2.2 客户端连接服务器(connect) 【TCP 独有步骤】(⭐⭐⭐)
在 UDP 中,客户端可以直接 sendto 发送数据。但在 TCP 中,必须先拨通电话。
cpp
struct sockaddr_in server;
// ... 填充服务端的 IP 和 8080 端口 ...
// 发起三次握手,建立连接!
int n = connect(sockfd, (struct sockaddr *)&server, sizeof(server));
if (n == 0) {
std::cout << "连接服务器成功!" << std::endl;
}
底层逻辑: 当调用
connect时,操作系统会向服务端发送 SYN 报文,正式开始"三次握手"。如果服务端此时没有调用listen,连接将会被直接拒绝。
2.3 客户端发起请求
一旦 connect 成功,客户端和服务端之间就建立了一条无形的双向管道。
cpp
std::string msg = "Hello, TCP Server!";
write(sockfd, msg.c_str(), msg.size());
char buffer[1024];
ssize_t s = read(sockfd, buffer, sizeof(buffer) - 1);
if(s > 0) {
buffer[s] = 0;
std::cout << "服务器回显: " << buffer << std::endl;
}
3. 服务器测试
编译好代码后,我们进行本地环回测试:
启动服务端:
./tcpserver 8080不用自己写客户端,直接使用 Linux 的内置网络工具
telnet:telnet 127.0.0.1 8080敲击键盘输入内容,观察服务端是否成功接收并回显。成功意味打通了 TCP 协议栈。
4. 单执行流服务器的致命弊端
cpp
while(true) {
int sockfd = accept(listensock_, ...); // 获取连接
Service(sockfd); // 进入业务处理死循环:不断 read 客户端消息
}
客户端 A 连接成功,服务器的执行流进入了
Service里的read阻塞等待 A 发消息。此时,客户端 B 尝试连接服务器(拨打电话)。
结果: B 的三次握手在系统底层成功了,连接被放进了
listen的全连接队列中。但是! 你的应用程序只有一个线程,此时正卡在给 A 服务上,根本没空去调用下一轮的accept把 B 从队列里捞出来。这就好比: 餐厅只有一个员工。他既当迎宾又当服务员。他把客人 A 领进门后,就一直站在 A 桌旁等 A 慢慢看菜单。此时门口来了客人 B、客人 C,就算门没锁,也没人去接待他们,他们只能在冷风中死等。
三、高并发 TCP 服务器的改进(⭐⭐⭐)
单执行流的 TCP 服务器,就像餐厅里只有一个既当迎宾又当服务员的员工,一次只能接待一桌客人。 只要当前的客户端不断开,其他所有发起连接的客户端都会被卡在门外的"全连接队列"中死等。
解决问题的核心思路:让主线程只负责
accept(只做迎宾员),一旦拿到新连接,立刻把这个连接交给其他人(专属服务员)去处理业务,主线程马上回头继续去门口迎宾。 这个"其他人",可以是子进程 ,可以是新线程 ,也可以是线程池中的打工人。
1. 多进程版 (Multi-Process)
cpp
// version 2 -- 多进程版
pid_t id = fork();
if(id == 0)
{
// --- 这里是子进程 ---
close(listensock_); // 子进程不需要去门口迎宾,关掉监听套接字
if(fork() > 0) exit(0); // 孤儿进程托孤
// 此时已经是孙子进程在执行了
Service(sockfd, clientip, clientport); // 处理业务逻辑
close(sockfd); // 服务完毕,关掉连接
exit(0); // 孙子进程退出
}
// --- 这里是父进程 ---
close(sockfd); // 父进程不需要提供服务,关掉服务套接字,防止文件描述符泄漏!
pid_t rid = waitpid(id, nullptr, 0); // 阻塞等待子进程退出(因为子进程瞬间退出了,所以这里不会卡住)
fork()之后,子进程会继承父进程打开的文件描述符。为了防止资源浪费和泄漏,子进程必须关闭listensock_,父进程必须关闭sockfd。双重进程创建: 父进程如果不
wait子进程,子进程死后会变成"僵尸进程"造成内存泄漏;如果wait,父进程又会被阻塞。
解法 : 让子进程再
fork一次生成孙子进程,然后子进程立刻exit(0)自杀。结果: 父进程的
waitpid瞬间等到了子进程的尸体,回去继续accept。而真正干活的孙子进程因为生父死了,变成了孤儿进程,被 Linux 系统的init(或systemd) 进程领养。孙子进程干完活后,系统会自动帮它收尸,完美解决了僵尸进程问题且没有阻塞父进程。缺点: 进程的创建和销毁太重了。每一次都要拷贝页表、分配独立的内存空间。如果瞬间涌入一万个连接,服务器的内存会瞬间被万千个进程撑爆。
2. 多线程版 (Multi-Thread)
cpp
// 准备传给线程的参数结构体
class ThreadData {
public:
int sockfd;
std::string clientip;
uint16_t clientport;
TcpServer *tsvr; // 为了在静态函数中能调用类的普通成员方法
// ... 构造函数略 ...
};
// 线程的回调路由
static void *Routine(void *args) {
pthread_detach(pthread_self()); // 线程分离,自生自灭
ThreadData *td = static_cast<ThreadData *>(args);
td->tsvr->Service(td->sockfd, td->clientip, td->clientport);
delete td; // 防止内存泄漏
return nullptr;
}
// version 3 -- 多线程版本
// 极其重要:必须在堆区 (new) 开辟空间传参!
ThreadData *td = new ThreadData(sockfd, clientip, clientport, this);
pthread_t tid;
pthread_create(&tid, nullptr, Routine, td);
堆上
newThreadData: 如果在主线程里用局部变量ThreadData td;传参,因为多个线程是并发执行的,当主线程进入下一次while循环时,局部变量td的内存会被新的连接数据覆盖,甚至被销毁,导致新线程读取到乱码甚至引发段错误。在堆区new出来,由新线程处理完后自己delete,是最安全的做法。线程分离
pthread_detach: 主线程不能调用pthread_join等待新线程,否则又退化成串行死等了。缺点: 多线程虽然轻量,但依然面临一个问题:如果同一时刻来了 10 万个并发连接,系统就会狂建 10 万个线程。线程切换的上下文开销足以让 CPU 瘫痪。此外,如果某一个线程代码写错引发了崩溃(段错误),整个服务器进程内的所有线程都会被连累,全军覆没。
3. 线程池版 (ThreadPool)
cpp
// 将连接信息封装成一个 Task 对象
Task t(sockfd, clientip, clientport);
// 获取线程池单例,并将任务 Push 到任务队列中
ThreadPool<Task>::GetInstance()->Push(t);
解耦(生产者-消费者模型): 主线程(迎宾员)变成了纯粹的生产者 。它
accept拿到sockfd后,立刻将其打包成一个Task(任务),扔进线程池的任务队列中,然后马上转头去接下一个客人。它根本不关心这个任务最终是谁来处理。可控的并发量: 后台的线程池(消费者)在启动时就固定创建了 N 个线程。如果队列为空,这 N 个线程就通过条件变量 (
pthread_cond_wait) 陷入沉睡,不占 CPU 资源;一旦主线程Push了任务并唤醒 (Wakeup),它们会抢任务执行。哪怕瞬间涌入一万个连接,最多也只有 N 个线程在干活,其余任务在队列中排队,绝不会把服务器彻底压垮。单例模式 (Singleton) : 使用
GetInstance()确保整个服务器进程内部有且仅有一个全局的线程池。配合 C++11 的互斥锁,保障了线程池初始化的绝对安全。