ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++网络编程实战:从socket到epoll的核心技术解析

C++网络编程实战:从socket到epoll的核心技术解析 简介面向初学网络编程的读者这份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() 创建 fdbind() 绑定地址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() 会立即返回 -1errno 为 EAGAIN 或 EWOULDBLOCK而超时模式下还在阻塞只是到时间后返回 -1errno 为 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 表示本机所有 IPlisten 的第二个参数 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 下一连接。如果你图省事把参数写成intclient_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 GETURL 解析、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::functionvoid(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::mapint, std::functionvoid(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 返回 -1errno 为 EPIPE你就能正常处理这个错误并清理连接。5.2 recv() 的缓冲区不是字符串长度、截断、\0 的处理现象收到的数据用printf(%s, buf)打印内容不完整或者后面跟乱码。原因recv 读到的字节流不保证以 \0 结尾而你当成了 C 字符串printf 会一直读到内存里的下一个 \0 为止越界了也不知道。解决严格按返回值 n 处理printf(%.*s, n, buf)或者把 buf 构造成 std::stringstd::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) 而不是 closeshutdown 不会释放 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 与线程池停机顺序CtrlC 直接退出会丢未落盘的日志和未处理的连接。我会用一个全局 atomic 标志在 signal handler 里置为 false主循环检测到后先停止接受新连接再等正在处理请求的线程结束最后关闭日志。顺序不能反先关日志会让排错信息全丢。6.3 压测验证从 nc 到自写并发客户端验证服务端能不能扛住先用nc -vz 127.0.0.1 9000测端口再写一个多线程客户端每个线程建 100 个连接同时收发。最简单的方式是统计每秒完成回显的请求数。如果发现 qps 上不去先看 CPU 占用再查是不是有线程在锁里等待。这套验证方法比看实例里的图更实际我每次改完连接池参数都靠它来兜底。最后说一句我的习惯任何网络程序上线前都先让它在测试环境跑 20 分钟用脚本模拟断连、半包、超时这些故障跑不挂再考虑发布。这个习惯帮我挡住了十几次线上事故希望对你也一样有用。本文还有配套的精品资源点击获取
返回列表