简介:面向初学网络编程的读者,这份PDF教程以Visual C++和MFC类库为开发背景,系统讲解Windows平台下网络程序开发所需的基础知识。内容从网络编程概念入手,先展开OSI七层网络模型,说明各层职责,并借助数据逐层封装与解封装的过程,帮助读者直观理解网络通信的基本原理;随后介绍TCP/IP协议簇,区分TCP与UDP的不同应用场景,再结合C/S编程模型梳理服务器监听、客户端连接请求及双方基于IP地址与端口通信的完整流程。此外还补充了Sockets套接字类型、网络字节顺序及CAsyncSocket类的使用要点,为后续学习MFC网络编程打下基础。全文采用图示与分步讲解相结合,适合自学入门或作为课堂教学辅助材料;资源为单个PDF文件,整包约114KB,目前已有564人学习,是快速建立VC++网络编程知识框架的实用参考。
1. 为什么拿《C++网络编程实例.pdf》当入门主线
很多人学 c++ 网络编程是从 socket() 这一行 API 开始的,但翻完两三百页理论书,依然写不出一个能稳定跑十分钟的服务端。真正让我入门的,是照着《C++网络编程实例.pdf》这种实例驱动的材料一点点敲:先跑通 TCP 回显,再改多线程,最后上 epoll。它解决的痛点很明确——c++入门阶段最缺的不是语法,而是「一个可运行的最小程序 + 为什么这么写」的组合。
这份资料适合两类人:一类是已经会 C++ 语法但没碰过 socket 网络编程的开发者,另一类是写过一些脚本但想回补 C++ 工程细节的从业者。它能让你在一天内跑通第一个客户端/服务端程序,并且理解阻塞、非阻塞、粘包这些绕不开的概念。下面我按自己带团队时常用的落地路径,把这套实例拆成「原理 → 复现 → 排错 → 工程化」四步。
2. 从 C API 到 C++ 封装:socket 调用链与 TCP/UDP 参数
2.1 把 socket()、bind()、listen() 封装成 RAII:理由与代码骨架
Windows 和 Linux 的 socket 调用链大体一致:socket() 创建 fd,bind() 绑定地址,listen() 进入监听,accept() 接受连接。问题在于,这中间任何一步出错,fd 就可能泄漏。C 语言写法里漏掉 close() 的例子我见过太多,所以拿到实例的第一步,我建议先把 fd 封装成 RAII 对象。
class TcpSocket { public: TcpSocket(int fd = -1) : fd_(fd) {} ~TcpSocket() { if (fd_ >= 0) { close(fd_); // 析构时统一关闭,避免 fd 泄漏 } } TcpSocket(const TcpSocket&) = delete; TcpSocket& operator=(const TcpSocket&) = delete; TcpSocket(TcpSocket&& other) noexcept : fd_(other.fd_) { other.fd_ = -1; // 移动后原对象不再持有 fd } int fd() const { return fd_; } private: int fd_; };这段代码把 fd 的所有权交给了对象生命周期来管理。参数说明里有两个重点:第一,拷贝构造和赋值被 delete,因为两个对象同时持有同一个 fd 会在析构时 double close;第二,移动语义把原对象的 fd 置为 -1,保证只有一个对象负责关闭。实际写 accept() 返回的客户端 fd 时,我会先放进 TcpSocket,再传给业务线程,避免中途异常导致 fd 无人关闭。
2.2 阻塞与非阻塞的切换:fcntl 与 setsockopt 的边界
实例里最常见的第一道坎是「程序卡在 recv() 不返回」。默认 socket 是阻塞的,没有数据时线程就挂在那。有两种改法:用 fcntl 把 fd 设为非阻塞,或者用 setsockopt 设超时。两者效果不同,非阻塞模式下 recv() 会立即返回 -1,errno 为 EAGAIN 或 EWOULDBLOCK;而超时模式下还在阻塞,只是到时间后返回 -1,errno 为 EAGAIN。
// 设置为非阻塞 int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 或者设置接收超时 3 秒 struct timeval tv = {3, 0}; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));参数说明:F_GETFL 先取出原有状态,再加 O_NONBLOCK,不能直接赋值,否则会丢掉其他标志位。超时时间 tv 的精度到微秒,对业务而言,3 秒适合心跳超时,5 秒适合重试等待。如果两个都设置了,非阻塞优先级更高,超时设置会失效。我一般在写客户端时优先用超时,因为非阻塞 read 循环的代码复杂度会让新手立刻翻车。
2.3 粘包半包与缓冲区设计:TCP 字节流不是消息流
很多初学者以为 send() 一次,对端 recv() 就能收到同样的数据,这是 socket 网络编程里最大的误解。TCP 是字节流,不保证消息边界,于是出现粘包:两次 send 的数据被一次 recv 收走;半包:一次 send 的数据分两次 recv 收完。实例里通常用一个自定义协议头来定义消息长度,常见做法是「4 字节长度 + 消息体」。
// 读满 n 个字节,处理半包 size_t readn(int fd, char* buf, size_t n) { size_t already = 0; while (already < n) { ssize_t len = recv(fd, buf + already, n - already, 0); if (len <= 0) { // len == 0 对端关闭,len < 0 需要看 errno return already; // 把已读到的返回,让上层决定 } already += len; } return already; }参数说明:readn 循环里的偏移量 buf + already 是最容易写错的地方,每次 recv 后必须把剩余长度减小、指针后移。返回值 len 为 0 表示对端关闭连接,此时应该结束会话;len 为 -1 且 errno 为 EAGAIN 时,对非阻塞 fd 来说是正常情况,要退出等待下次事件。实例里如果能看到这个函数,基本就是用来解决半包问题的,粘包则由上层协议头长度字段来判断。
3. 照着实例写 TCP 回显服务器:从单线程到 std::thread 多线程
3.1 最小可跑版本:阻塞式 Echo Server 完整代码
回显服务器是网络编程实例的「hello world」:客户端发什么,服务端原样返回什么。第一版我建议用阻塞单线程,不用考虑并发,先把 accept → recv → send 的闭环跑通。
#include <sys/socket.h> #include <netinet/in.h> #include <unistd.h> #include <cstring> #include <cstdio> int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(9000); // 端口 9000 addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 bind(listen_fd, (sockaddr*)&addr, sizeof(addr)); listen(listen_fd, 32); // 等待队列长度 32 while (true) { int client_fd = accept(listen_fd, nullptr, nullptr); char buf[1024]; while (true) { ssize_t n = recv(client_fd, buf, sizeof(buf), 0); if (n <= 0) break; // 对端关闭或出错 send(client_fd, buf, n, 0); // 原样返回 } close(client_fd); } close(listen_fd); return 0; }逻辑说明:先 socket 创建 IPv4 TCP 套接字,bind 绑定端口和地址,listen 进入监听。外层循环每次 accept 到一个新连接,内层循环收发数据直到对方关闭。这里 recv 的返回值 n 同时是发送长度,避免把缓冲区末尾未初始化的数据发出去。参数说明:端口用 htons 转网络字节序,地址 INADDR_ANY 表示本机所有 IP;listen 的第二个参数 32 是连接请求队列上限,对入门实验够用,高并发场景要调大或用 epoll。
3.2 多线程处理多个客户端:thread 参数传递与引用陷阱
单线程服务端一次只能服务一个客户端,第二个客户端必须等第一个断开。实例里常见的升级是用 std::thread 为每个连接开线程。这里 c++ stl 的 thread 比 pthread_create 好用的地方是参数直接传值,但坑也在这:如果传引用,线程里访问的可能是已经销毁的对象。
void handle_client(int fd) { char buf[1024]; while (true) { std::ssize_t n = recv(fd, buf, sizeof(buf), 0); if (n <= 0) break; send(fd, buf, n, 0); } close(fd); } // 在 accept 之后 std::thread t(handle_client, client_fd); t.detach(); // 分离线程,不阻塞主循环逻辑说明:handle_client 的参数 fd 是 int,按值传递,线程持有自己的副本,主循环继续 accept 下一连接。如果你图省事把参数写成int&,client_fd 会在下一轮循环被重新赋值,线程里读到的 fd 就错了。这也是 c++ 引用 指针 和 值传递 区别中最容易在并发场景踩的一次。注意点:detach 后线程失控,程序退出时可能还在跑,工程上应该用线程池配合 join;但实例教学阶段,detach 能让服务端立刻支持多客户端。fd 的关闭放线程内做,主线程不 close,避免重复关闭。
3.3 客户端怎么写才不容易卡死:非阻塞与超时
服务端写好了,客户端反而容易翻车。最常见的现象是 connect 到一个不可达 IP 时卡很久,或者 recv 等响应时永久阻塞。实例里往往用 select 给 socket 设置超时,而不是简单地把 fd 设为非阻塞。
#include <sys/select.h> int wait_readable(int fd, int timeout_ms) { fd_set rds; FD_ZERO(&rds); FD_SET(fd, &rds); timeval tv = {timeout_ms / 1000, (timeout_ms % 1000) * 1000}; return select(fd + 1, &rds, nullptr, nullptr, &tv); } // 用法 int ret = wait_readable(sockfd, 3000); if (ret == 1) { recv(sockfd, buf, sizeof(buf), 0); } else if (ret == 0) { printf("recv timeout\n"); }逻辑说明:select 的第一个参数要传最大 fd 加 1,因为内核要扫描整个 fd 集合。返回值 1 表示可读,0 表示超时,-1 表示出错。参数说明:timeout_ms 拆成秒和微秒两个字段,select 在 Linux 上会把剩余时间写回 timeval,所以如果要循环调用,每次都要重置。这个办法比单纯 O_NONBLOCK 好理解,也让客户端在正常无数据时能优雅退出,不用靠信号或额外线程。
4. 进阶实例:HTTP 客户端与 epoll 高并发服务器怎么写
4.1 手写 HTTP/1.1 GET:URL 解析、connect、send、recv 的完整流程
当实例从 TCP 泛化到具体协议,第一个建议是手写 HTTP 客户端。它能把 socket 操作和文本协议解析结合起来。以一个简单 URLhttp://example.com:8080/index.html为例,需要拆出 host、port、path,然后 socket 连接并发送请求行。
std::string request = "GET " + path + " HTTP/1.1\r\n" "Host: " + host + "\r\n" "Connection: close\r\n" // 响应结束后服务器主动关闭 "\r\n"; send(sockfd, request.data(), request.size(), 0); std::string response; char buf[4096]; while (true) { ssize_t n = recv(sockfd, buf, sizeof(buf), 0); if (n <= 0) break; response.append(buf, n); // 用 append 保留 \0 }逻辑说明:HTTP/1.1 默认 Keep-Alive,如果不发 Connection: close,服务器不会关闭连接,你的 recv 循环会一直等。加了这个头,响应收完后服务端会正常关闭,recv 返回 0,循环退出。参数说明:buf 是 char 数组,如果直接用response += buf会遇到字符串截断问题,因为 recv 收到的字节里可能有 \0,所以必须用 append(buf, n) 指定长度。解析响应时先用\r\n\r\n找到头部结束位置,再按 Content-Length 读 body,这是 HTTP 协议里最常见的边界处理。
4.2 epoll 的 LT 与 ET:为什么生产环境我优先选 LT
多线程模型撑到几百连接后,线程切换会成为瓶颈。实例到中段一定会引入 epoll。epoll 对 fd 有两种触发模式:水平触发 LT 和边缘触发 ET。我直接给建议:如果实例代码没刻意讲 ET,一律用 LT。
| 对比项 | LT 水平触发 | ET 边缘触发 |
|---|---|---|
| 可读通知 | 缓冲区有数据就会通知 | 只有新数据到达时通知一次 |
| 是否必须非阻塞 | 可以阻塞 | 必须非阻塞 |
| 读取要求 | 能读多少读多少 | 必须一次读完 |
| 实现难度 | 低 | 高,容易漏读 |
| 适用场景 | 绝大多数业务 | 追求极致吞吐的大文件传输 |
原因:ET 场景下,如果一次 recv 没把数据读完,要等到下一次新数据到达才会再次通知,这中间数据一直留在内核缓冲区。所以 ET 要求循环读直到 EAGAIN,而且 fd 必须是非阻塞,否则最后一次读会卡住线程。LT 模式只要缓冲区还有数据就会一直通知,虽然多一次 epoll_wait 返回,但逻辑简单得多。实例里如果告诉你设置 EPOLLET,通常是配合while (recv() > 0)的循环,别照抄。
4.3 把事件循环封装成回调:可复用的 reactor 框架雏形
到这一步,可以把 epoll 逻辑从业务里抽出来,做成一个简单的 reactor:注册 fd 和回调函数,事件循环负责分发。这个结构在后续接数据库、接消息队列时能直接复用。
class Reactor { public: void add_handler(int fd, std::function<void(int)> handler) { epoll_event ev; ev.events = EPOLLIN; ev.data.fd = fd; epoll_ctl(epfd_, EPOLL_CTL_ADD, fd, &ev); handlers_[fd] = std::move(handler); } void loop() { epoll_event events[64]; while (true) { int n = epoll_wait(epfd_, events, 64, -1); for (int i = 0; i < n; ++i) { int fd = events[i].data.fd; handlers_[fd](fd); // 回调业务逻辑 } } } private: int epfd_ = epoll_create(1); std::map<int, std::function<void(int)>> handlers_; };逻辑说明:add_handler 把 fd 挂到 epoll 并保存回调,事件循环里拿到事件的 fd 后直接调用对应的函数。参数说明:epoll_wait 的第三个参数 64 是单次返回的最大事件数,不是 epoll 能监控的上限;-1 表示永久等待,如果做定时任务可以改成超时毫秒数。要注意回调里如果处理耗时太长,会阻塞整个循环,所以业务重的场景要在回调里再投递到线程池。
5. C++ 网络编程避坑手册:5 个最容易翻车的地方
5.1 SIGPIPE 导致进程直接退出:写已关闭连接时的信号默认行为
现象:客户端主动断开后,服务端继续 send(),进程直接退出,看不到任何报错。
原因:Linux 下写一个对端已关闭的 socket,内核会发送 SIGPIPE 信号,默认行为是终止进程。很多新手以为返回值会是 -1,但信号优先于返回值处理。
解决:在服务端启动时调用signal(SIGPIPE, SIG_IGN)忽略它,或者在 send 时加 MSG_NOSIGNAL 标志。我一般两种都加,因为线上不能赌每次 send 都记得带标志。设置之后,send 返回 -1,errno 为 EPIPE,你就能正常处理这个错误并清理连接。
5.2 recv() 的缓冲区不是字符串:长度、截断、\0 的处理
现象:收到的数据用printf("%s", buf)打印,内容不完整,或者后面跟乱码。
原因:recv 读到的字节流不保证以 \0 结尾,而你当成了 C 字符串,printf 会一直读到内存里的下一个 \0 为止,越界了也不知道。
解决:严格按返回值 n 处理,printf("%.*s", n, buf),或者把 buf 构造成 std::string:std::string data(buf, n)。记住一点:网络字节流里可能有 \0,它是数据的一部分,而不是字符串终止符。我在代码评审里看到用strlen(buf)取长度就一定会打回。
5.3 粘包半包:为什么你收到的消息多一块或少一块
现象:客户端连续发送两条消息,服务端 recv 一次收到两条;或者一条消息要 recv 两次才完整。
原因:TCP 是字节流,内核把数据按发送顺序拼在一起,应用层看不到「消息」边界。
解决:像 2.3 节那样,自定义协议头,先读 4 字节长度字段,再按长度读满整个消息体。这里有个小陷阱:4 字节长度字段本身也可能被拆成几次 recv,所以你不仅要 readn 消息体,还要 readn 协议头。别假设一次 recv 就能拿到完整头部。
5.4 多线程共享 fd 与误关闭:close 与 shutdown 的真实区别
现象:在多线程服务端,一个线程在 recv,另一个线程 close 了同一个 fd,导致连接异常断开或者 double close 崩溃。
原因:close 是释放 fd,如果两个线程都持有这个数字,第一个 close 后内核可能把这个数字分配给新连接,第二个 close 就把新连接误关了。
解决:约定 fd 的所有权属于某一个线程,单线程负责收发。需要终止对端时用 shutdown(fd, SHUT_RDWR) 而不是 close,shutdown 不会释放 fd,只是禁止收发,之后由持有者 close。检查你的实例代码,凡是 close 出现在两个线程里,都是定时炸弹。
5.5 Windows 下 VSCode 配置 C++ 环境:链接 ws2_32.lib 的坑
现象:在 Windows 上用 VSCode 编译网络程序,代码没问题,链接报undefined reference to WSAStartup或socket。
原因:Windows 的 socket API 在 ws2_32.dll 里,编译器默认不会自动链接这个库,而且使用前需要先调用 WSAStartup 初始化。
解决:在 tasks.json 的 args 里加-lws2_32,或者直接用 MSVC 的 cl 工具加ws2_32.lib。代码开头别忘了:
#ifdef _WIN32 WSADATA wsData; WSAStartup(MAKEWORD(2, 2), &wsData); #endif参数说明:MAKEWORD(2,2) 表示请求使用 Winsock 2.2 版本。很多 c++入门 教程只写 Linux,到了 Windows 就会卡在这一步。如果你是在 VSCode 里配 C/C++ 环境,MinGW 和 MSVC 都要确认链接库参数,否则 socket 函数全部报未定义。
6. 把实例升级成工程:日志、优雅退出与压测验证
6.1 用 spdlog 替换 printf
实例里打日志用 printf 没问题,但工程上我习惯用 spdlog。它按天或按大小滚动文件,还能分级别过滤,排查线上问题时能少敲很多 grep。基本用法是spdlog::info("client fd {} connected", fd),注意格式化占位符不要写错,写错类型会编译不过。
6.2 优雅退出:捕获 SIGINT 与线程池停机顺序
Ctrl+C 直接退出会丢未落盘的日志和未处理的连接。我会用一个全局 atomic 标志,在 signal handler 里置为 false,主循环检测到后先停止接受新连接,再等正在处理请求的线程结束,最后关闭日志。顺序不能反,先关日志会让排错信息全丢。
6.3 压测验证:从 nc 到自写并发客户端
验证服务端能不能扛住,先用nc -vz 127.0.0.1 9000测端口,再写一个多线程客户端每个线程建 100 个连接同时收发。最简单的方式是统计每秒完成回显的请求数。如果发现 qps 上不去,先看 CPU 占用,再查是不是有线程在锁里等待。这套验证方法比看实例里的图更实际,我每次改完连接池参数都靠它来兜底。
最后说一句我的习惯:任何网络程序上线前,都先让它在测试环境跑 20 分钟,用脚本模拟断连、半包、超时这些故障,跑不挂再考虑发布。这个习惯帮我挡住了十几次线上事故,希望对你也一样有用。
本文还有配套的精品资源,点击获取