搜索

C++ 教程

C++基础

第1章 搭建开发环境与第一个程序 第2章 基本数据类型与变量 第3章 运算符与表达式 第4章 流程控制语句 第5章 数组与字符串 第6章 函数基础 第7章 指针基础 第8章 引用与const限定 第9章 动态内存管理 第10章 结构体与联合体 第11章 函数进阶

面向对象

第12章 面向对象编程入门 第13章 封装与访问控制 第14章 运算符重载 第15章 继承与派生 第16章 多态与虚函数

STL与进阶

第17章 模板编程基础 第18章 标准模板库(STL)容器 第19章 STL算法与迭代器 第20章 异常处理机制 第21章 文件输入输出 第22章 字符串类string 第23章 命名空间与作用域 第24章 类型转换操作符

现代C++

第25章 智能指针 第26章 移动语义与右值引用 第27章 Lambda表达式 第28章 多线程编程基础 第29章 模板元编程入门 第30章 设计模式基础 第31章 C++11/14新特性 第32章 C++17/20核心特性

实战进阶

第33章 性能优化技巧 第34章 调试与测试 第35章 项目结构与构建 第36章 网络编程入门 第37章 数据库连接操作 第38章 图形界面开发基础 第39章 综合项目实战 第40章 进阶学习路线

第36章 网络编程入门

第36章 网络编程入门

高级 👤 已掌握C++核心语法,希望让程序具备“对话”能力的开发者 🔧 构建聊天工具、游戏服务器、数据采集客户端等网络应用
本章你将学到
  • 理解TCP与UDP协议的核心差异与应用场景
  • 使用Berkeley Socket API编写客户端与服务器程序
  • 处理网络字节序、阻塞与非阻塞I/O等关键问题
核心概念SocketTCP/UDP

想象一下打电话和寄明信片的区别。打电话需要先拨号接通,双方确认在线后才能开始交谈,确保每一句话对方都能听到(TCP)。而寄明信片则是写好内容、贴上邮票、扔进邮筒,不关心对方是否在家,也不保证一定能送达(UDP)。网络编程,就是教会你的C++程序如何“打电话”或“寄明信片”。

网络编程是什么?

网络编程是让运行在不同计算机(或同一计算机的不同进程)上的程序,通过网络协议进行数据交换的技术。在C++中,我们通常使用操作系统提供的Socket(套接字)接口来实现。你可以把Socket理解为一个网络通信的“插座”,程序插上它,就能连接到网络上的另一个“插座”进行数据收发。

核心协议:TCP vs. UDP

选择TCP还是UDP,是网络编程的第一个关键决策。它们决定了通信的“性格”。下面这张表清晰地展示了它们的根本区别:

特性TCP (传输控制协议)UDP (用户数据报协议)
连接方式面向连接,需三次握手建立连接无连接,直接发送
可靠性高可靠,保证数据顺序、不丢失、不重复不可靠,可能丢包、乱序、重复
传输单元字节流(Stream)数据报(Datagram)
速度相对较慢(有确认、重传机制)非常快(几乎没有额外开销)
头部开销较大(20-60字节)很小(8字节)
典型应用HTTP/HTTPS、FTP、邮件(SMTP/POP3)、数据库连接DNS查询、视频流、语音通话、在线游戏状态广播

实际开发中,90%的场景你会使用TCP,因为它省心。只有当你明确需要极低延迟并能容忍少量数据丢失时,才选择UDP。我在一个实时传感器数据采集项目中踩过坑,最初用了TCP,结果高频数据导致缓冲区堆积和延迟激增,后来切换到UDP并设计了简单的应用层确认机制,问题才解决。

Socket编程基础流程

无论TCP还是UDP,Socket编程都遵循一套相似的API。我们先看TCP的典型流程,它比UDP多了一个“连接”的环节。下面的架构图展示了TCP服务器与客户端交互的核心步骤:

服务器启动
→
socket()
→
bind()
→
listen()
↓↓↓
客户端启动
→
socket()
→
connect()
↗
↓
accept()
⇄
数据通信 (send/recv)

上图中,绿色节点代表服务器端独有的步骤,蓝色节点代表客户端独有的步骤,橙色节点是双方共有的。`accept()`是一个阻塞调用,它会一直等待,直到有客户端尝试连接。

关键代码片段:创建TCP Socket

以下是在Linux/Unix或Windows(需包含winsock2.h)上创建一个TCP Socket的通用代码:

// 创建Socket
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
    perror("socket creation failed");
    return -1;
}

// 准备服务器地址结构
struct sockaddr_in address;
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY; // 绑定到本机所有IP
address.sin_port = htons(8080); // 绑定到8080端口

// 绑定Socket到地址
if (bind(server_fd, (struct sockaddr*)&address, sizeof(address)) < 0) {
    perror("bind failed");
    return -1;
}

必须跨越的“坑”:字节序与网络序

不同的CPU架构在内存中存储多字节数据(如int, short)的顺序可能不同,这被称为字节序。x86/ARM架构常用小端序(低位字节在前),而网络传输标准规定使用大端序(网络字节序)。如果你直接发送一个`int`变量,在不同机器上解读的结果会天差地别。

常见误区

  • 忽略字节序转换:直接发送`int`、`float`等类型。必须使用`htons()`(host to network short)、`htonl()`(host to network long)等函数转换主机字节序到网络字节序,接收方用`ntohs()`、`ntohl()`转换回来。
  • 错误处理不足:`send()`和`recv()`的返回值是实际发送/接收的字节数,它可能小于你请求的数量。必须循环发送/接收,直到所有数据完成处理。
  • 地址重用问题:服务器崩溃后重启,可能会遇到“Address already in use”错误。需要在`bind()`之前对socket设置`SO_REUSEADDR`选项。

这里有个隐藏的坑:字符串不需要转换字节序,因为它是单字节数组。只有多字节的数值类型才需要。我曾调试过一个跨平台游戏服务器,客户端(x86)发送的坐标数据在服务器(PowerPC)上全乱了,就是因为忘了调用`htonl()`转换`float`类型。

编者提示:网络编程中频繁涉及内存操作和资源管理(Socket也是资源)。务必回顾第9章《动态内存管理》和第25章《智能指针》中关于资源泄露的教训。对于复杂的连接管理,可以考虑使用`std::unique_ptr`配合自定义删除器来管理Socket描述符。

阻塞 vs. 非阻塞 I/O

默认情况下,Socket操作是阻塞的。`recv()`会一直卡住,直到有数据到来;`accept()`会一直等待,直到有新连接。这对于简单程序没问题,但无法处理多个连接。解决方案是非阻塞I/O或多路复用。

阻塞I/O

  • 编程简单,逻辑清晰
  • 一个线程/进程只能处理一个连接
  • 资源利用率低
  • 适用:简单客户端、低频请求服务器

多线程/多进程

  • 主线程`accept()`,为每个连接创建新线程
  • 能同时服务多个客户端
  • 线程切换开销大,连接数上限低(约数百)
  • 适用:传统Apache服务器模式

I/O多路复用 (select/poll/epoll)

  • 单个线程监控多个Socket状态
  • 高并发(C10K问题解决方案)
  • 编程复杂度高
  • 适用:现代高性能服务器(Nginx, Redis)

对于入门者,我推荐先掌握多线程+阻塞I/O模型。理解其局限性后,再挑战`select`或Linux特有的`epoll`。Windows平台有对应的`WSAPoll`或`IOCP`模型。

一个简单的Echo服务器示例

Echo(回声)服务器是最经典的网络编程练习:客户端发送什么,服务器就原样返回什么。下面的流程图展示了服务器处理一个连接的核心逻辑:

创建监听Socket
→
绑定端口并监听
→
等待客户端连接 (accept)
→
循环:接收数据 (recv)
→
原样发回数据 (send)
→
客户端断开?
否 ↗
继续循环
是 ↓
关闭连接Socket

关键点在于`recv()`的返回值:返回0表示对方已关闭连接(收到FIN包),返回-1表示出错(需检查`errno`),返回正数才是接收到的数据长度。你必须根据这个返回值来决定下一步动作。

// 简化的Echo处理循环(省略错误处理细节)
char buffer[1024];
while (true) {
    int valread = recv(client_socket, buffer, sizeof(buffer), 0);
    if (valread == 0) {
        printf("Client disconnected.\n");
        break;
    } else if (valread < 0) {
        // 处理错误,例如 EAGAIN/EWOULDBLOCK 在非阻塞模式下是正常的
        break;
    }
    // 原样发回
    send(client_socket, buffer, valread, 0);
}

如果你看到`recv()`或`send()`返回-1并且`errno`被设为`ECONNRESET`(连接被对方重置),这通常意味着客户端程序崩溃或非正常关闭了连接。在实际项目中,服务器必须健壮地处理这种情形,清理资源,而不是跟着崩溃。

动手试一试

1. 实现基础TCP Echo服务器:使用上述代码框架,在本地(127.0.0.1)的8080端口启动一个服务器。编译运行后,用`telnet 127.0.0.1 8080`命令连接它,输入文字,观察是否回显。
2. 扩展为多线程服务器:修改服务器,使`accept()`到一个新连接后,创建一个新的`std::thread`来处理这个连接的Echo循环,主线程继续等待下一个连接。注意线程的参数传递和资源释放。
3. 尝试UDP版本:将Socket类型从`SOCK_STREAM`改为`SOCK_DGRAM`,去掉`listen()`和`accept()`,使用`recvfrom()`和`sendto()`函数(它们需要额外的地址参数)。体验无连接通信的区别。

检验你的理解

  1. TCP的“三次握手”发生在哪个函数调用过程中?如果握手失败,哪一方会最先感知到?
  2. 为什么即使发送一个简单的`int`变量,也需要用`htonl()`转换,而发送一个`char`数组(字符串)却不需要?
  3. 如果一个TCP服务器同时有1000个客户端连接,使用“一个连接一个线程”的模型可能会遇到什么性能瓶颈?
  4. 当`recv()`返回0时,意味着什么?程序下一步应该做什么?

本章小结

  • 协议选择是根本:TCP提供可靠、有序的字节流,UDP提供快速、无连接的数据报。根据应用对可靠性和延迟的要求进行选择。
  • Socket API是桥梁:掌握`socket()`, `bind()`, `listen()`, `accept()`, `connect()`, `send()`, `recv()`等核心函数的使用顺序和参数含义。
  • 字节序转换不可忘:网络传输使用大端序,必须使用`htons`/`htonl`系列函数转换主机字节序,这是跨平台通信的基础。
  • 错误处理要完备:每个Socket系统调用都必须检查返回值,并根据`errno`判断错误类型,区分正常断开(返回0)和异常错误。
  • 并发模型是进阶关键:从简单的阻塞I/O和多线程模型入手,理解其局限性,为学习I/O多路复用等高并发模型打下基础。

意见反馈

微信二维码 微信扫码关注 抖音二维码 抖音扫码关注