网络编程套接字(TCP)

目录

一、inet_家族函数的学习

[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.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,在现代的网络编程(特别是高并发服务器)中,推荐使用下面这两个版本。它们的名字里带了 pn

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);

参数介绍:

  • afAF_INETAF_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. 服务器测试

编译好代码后,我们进行本地环回测试:

  1. 启动服务端:./tcpserver 8080

  2. 不用自己写客户端,直接使用 Linux 的内置网络工具 telnettelnet 127.0.0.1 8080

  3. 敲击键盘输入内容,观察服务端是否成功接收并回显。成功意味打通了 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); // 阻塞等待子进程退出(因为子进程瞬间退出了,所以这里不会卡住)
  1. fork() 之后,子进程会继承父进程打开的文件描述符。为了防止资源浪费和泄漏,子进程必须关闭 listensock_,父进程必须关闭 sockfd

  2. 双重进程创建: 父进程如果不 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);
  • 堆上 new ThreadData: 如果在主线程里用局部变量 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 的互斥锁,保障了线程池初始化的绝对安全。

相关推荐
测试开发-学习笔记2 小时前
ERP在黄金珠宝行业的运行全流程
服务器
李昊哲小课2 小时前
Python 文件路径操作详细教程
linux·服务器·python
小小小米粒2 小时前
k8s网络通信ip申请如何层级同步进行pod网络层级网络访问请求路由流程
linux·运维·服务器
wanhengidc2 小时前
云手机 数据信息资源共享
大数据·运维·服务器·游戏·智能手机
星夜落月2 小时前
给自己搭一个私人阅读空间:FreshRSS 部署手记
运维·服务器·网络·rss
航Hang*2 小时前
第2章:进阶Linux系统——第1节:配置与管理Samba服务器
linux·运维·服务器·笔记·学习
摇滚侠2 小时前
从 Tomcat 服务最大连接数角度讲一讲高峰期高考查分网站打不开,服务器的资源是有限的,同一时间大量用户连接服务器,会耗尽服务器的资源,服务器会拒绝新的连接
java·服务器·tomcat
娇娇yyyyyy3 小时前
C++ 网络编程(22) beast网络库实现websocket服务器
网络·c++·websocket
心前阳光3 小时前
Mirror网络库插件使用4
java·linux·网络·unity·c#·游戏引擎