简介:这份资源面向网络编程初学者与需要巩固TCP/IP基础的开发者,提供一套可直接运行的客户端与服务端通信源码,帮助理解面向连接、可靠传输的TCP协议以及IP路由机制在实际代码中的落地方式。压缩包共38个文件,约73KB,以C#源码文件为主,包含服务端与客户端核心逻辑、项目配置文件、资源文件、解决方案文件及少量编译产物,结构紧凑,便于在Visual Studio中直接打开调试。源码覆盖套接字创建、地址结构体初始化、绑定监听、接受连接、连接建立、数据收发与关闭连接等完整流程,读者可对照三次握手与四次挥手过程观察每一步的代码实现。目前已有1371人学习下载,适合作为网络编程课程实验、课程设计或自学练手的参考范例,通过阅读与修改这套代码,能够快速掌握TCP/IP通信的基本编程模型与常见排错思路。
1. 从一行 socket 代码说起:TCP/IP 客户端和服务端源码到底在解决什么
很多人第一次写 TCP/IP 的客户端和服务端,都是从一行socket()开始的。代码跑通了,本机127.0.0.1一发一收没问题,心里觉得这事已经翻篇了。可一旦把客户端挪到另一台机器,或者把服务端丢进容器、丢上云主机,立刻就是连接超时、半包、粘包、连接被重置,一堆玄学问题扑面而来。这时候你才会意识到,TCP/IP 客户端和服务端源码真正要解决的,从来不是「怎么调 API」,而是「怎么让两端在真实网络里稳定地把字节流说清楚」。
这篇笔记围绕 TCP/IP 客户端和服务端源码展开,把一套能落地的实现路径讲透:从 socket 生命周期、协议帧设计,到多路复用、异常排查,再到压测验证。适合两类人:一类是刚学完网络编程、想写出「能上生产」而不是「只能本机跑」的客户端和服务端的人;另一类是手上有现成源码,但被粘包、断连、性能问题反复折磨,想搞清楚每一行到底在干什么的人。下面所有代码都以 Linux 下的 C 和 Python 为例,思路对 Windows、嵌入式、跨平台场景同样成立。
2. TCP/IP 客户端和服务端源码的骨架:socket 生命周期与协议帧设计
写 TCP/IP 源码,第一步不是敲代码,而是把「连接怎么建立、数据怎么切分、连接怎么收场」这三件事想清楚。socket API 只是工具,真正决定源码质量的,是你对 TCP 字节流本质的理解。这一章先把骨架搭起来,后面所有优化和排错都建立在这个骨架上。
2.1 服务端源码:bind、listen、accept 三步到底在做什么
服务端的核心流程是socket → bind → listen → accept → recv/send → close。很多人背得滚瓜烂熟,但说不清listen的 backlog 参数到底影响什么。简单说,listen之后内核会维护两个队列:一个是「已完成三次握手、等待 accept」的队列,一个是「半连接」队列。backlog 主要约束前者。如果 accept 太慢,队列满了,新连接会被丢弃或收到 RST,客户端看到的就是「连接被拒绝」。
下面是一段最小可用的服务端源码,用 C 写,重点看注释里的参数含义:
#include <stdio.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define PORT 9000 #define BACKLOG 128 // 已完成握手队列长度,高并发场景要调大 #define BUF_SIZE 4096 int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); // SOCK_STREAM 即 TCP if (listen_fd < 0) { perror("socket"); return 1; } int opt = 1; // SO_REUSEADDR 让服务端重启时能立刻复用 TIME_WAIT 状态的端口 setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 addr.sin_port = htons(PORT); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } if (listen(listen_fd, BACKLOG) < 0) { perror("listen"); return 1; } printf("server listening on %d\n", PORT); while (1) { struct sockaddr_in cli; socklen_t cli_len = sizeof(cli); int conn_fd = accept(listen_fd, (struct sockaddr *)&cli, &cli_len); if (conn_fd < 0) { perror("accept"); continue; } char buf[BUF_SIZE]; ssize_t n = recv(conn_fd, buf, sizeof(buf) - 1, 0); if (n > 0) { buf[n] = '\0'; printf("recv from %s: %s\n", inet_ntoa(cli.sin_addr), buf); send(conn_fd, "ACK", 3, 0); } close(conn_fd); // 单连接处理完就关,真实场景要循环处理 } close(listen_fd); return 0; }这段代码里几个参数值得单独说。BACKLOG设成 128 只是示例,实际要按 QPS 和单连接处理耗时估算,处理越慢、并发越高,这个值越要大。SO_REUSEADDR几乎是服务端标配,没有它,服务重启时经常卡在Address already in use。recv的返回值必须判断:大于 0 是收到的字节数,等于 0 是对端正常关闭,小于 0 要区分EINTR(被信号打断,可重试)和真正的错误。
2.2 客户端源码:connect 超时、重试与地址解析
客户端流程是socket → connect → send/recv → close。看起来比服务端简单,但坑一点不少。最典型的是connect默认会阻塞很久(受内核 SYN 重传影响,可能几十秒),生产代码必须自己控制超时。常见做法是把 socket 设成非阻塞,用select/poll等待可写,再检查SO_ERROR。
import socket import select def connect_with_timeout(host, port, timeout=3): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setblocking(False) # 非阻塞,避免 connect 卡死 try: s.connect((host, port)) except BlockingIOError: pass # 非阻塞 connect 立即返回 EINPROGRESS _, writable, _ = select.select([], [s], [], timeout) if not writable: s.close() raise TimeoutError(f"connect {host}:{port} timeout") err = s.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) if err != 0: s.close() raise OSError(f"connect failed, errno={err}") s.setblocking(True) return s sock = connect_with_timeout("127.0.0.1", 9000) sock.sendall(b"hello") print(sock.recv(1024)) sock.close()这里的关键点是:非阻塞connect返回EINPROGRESS是正常的,不代表失败;必须用select等可写事件,再用SO_ERROR判断真实结果。sendall会循环发送直到全部写完,比裸send安全,因为send可能只发出去一部分。地址解析方面,如果传入的是域名,connect内部会做 DNS 解析,这一步也可能超时,严谨的源码会先用getaddrinfo拿到 IP 列表再逐个尝试。
2.3 协议帧设计:解决粘包和半包的根本手段
TCP 是字节流,没有消息边界。你send两次,对端可能一次recv全收到(粘包),也可能一次只收到半条(半包)。这不是 bug,是 TCP 的设计。解决办法只有一个:在应用层定义帧格式。常见三种方案对比如下:
| 方案 | 帧格式 | 优点 | 缺点 |
|---|---|---|---|
| 定长 | 每条消息固定 N 字节 | 解析简单 | 浪费带宽,不灵活 |
| 分隔符 | 消息 +\n或\0 | 文本协议友好 | 消息内含分隔符要转义 |
| 长度前缀 | 4 字节长度 + 消息体 | 通用、高效 | 需要处理长度字段本身 |
生产环境最常用的是长度前缀。下面是一个 Python 的封包和解包实现:
import struct def pack_message(payload: bytes) -> bytes: # 大端 4 字节无符号整数表示长度,网络字节序统一 return struct.pack("!I", len(payload)) + payload def unpack_stream(buffer: bytearray): """从缓冲区里尽可能多地取出完整消息,返回消息列表""" messages = [] while True: if len(buffer) < 4: break # 长度字段都不够,等更多数据 length = struct.unpack("!I", buffer[:4])[0] if len(buffer) < 4 + length: break # 消息体不完整,等更多数据 messages.append(bytes(buffer[4:4 + length])) del buffer[:4 + length] # 移除已消费的字节 return messagesunpack_stream的设计要点是「缓冲区 + 循环消费」:每次recv到的数据先追加到buffer,然后反复尝试解包,直到剩余数据不足以构成一条完整消息为止。这样无论对端怎么切分发送,接收端都能正确还原。长度字段用 4 字节大端是跨语言通用做法,Java、Go、C 都能无缝对接。注意要限制单条消息最大长度,否则恶意对端发一个超大长度字段就能把你的内存吃光。
3. 让源码扛住并发:多路复用、线程模型与连接管理
单连接能跑通只是起点。真实服务端要同时处理成百上千个连接,客户端也可能要维持长连接池。这一章讲怎么把第 2 章的骨架改造成能扛并发的结构,以及不同模型各自的适用边界。
3.1 从阻塞到 epoll:为什么 select 撑不住高并发
最朴素的并发方案是「一个连接一个线程」,代码直观,但线程切换和内存开销大,几千连接就吃力。select能监听多个 fd,但有两个硬伤:一是 fd 数量受FD_SETSIZE限制(通常 1024),二是每次调用都要把整个 fd 集合从用户态拷到内核态,O(n) 复杂度。epoll用红黑树管理 fd、就绪链表返回事件,复杂度 O(1),是 Linux 下高并发的首选。
#include <sys/epoll.h> #define MAX_EVENTS 1024 int epfd = epoll_create1(0); struct epoll_event ev, events[MAX_EVENTS]; ev.events = EPOLLIN; // 关注可读事件 ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); // -1 表示永久阻塞 for (int i = 0; i < n; i++) { if (events[i].data.fd == listen_fd) { // 新连接到来,accept 后注册到 epoll int conn = accept(listen_fd, NULL, NULL); ev.events = EPOLLIN | EPOLLET; // ET 边沿触发,配合非阻塞 fd ev.data.fd = conn; epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev); } else { // 已有连接可读,处理数据 handle_read(events[i].data.fd); } } }这里有个必须讲清的点:EPOLLET(边沿触发)要求 fd 是非阻塞的,而且必须一次把数据读干净(循环recv直到返回EAGAIN),否则会丢事件。水平触发(LT,默认)则每次有数据都会通知,写起来简单但效率略低。新手建议先用 LT 跑通,确认逻辑无误再换 ET。epoll_wait的超时参数 -1 表示一直等,实际服务里常设一个超时值,用来做定时任务和心跳检测。
3.2 线程池与 Reactor 模型:把 accept 和业务处理解耦
epoll解决了「怎么高效知道哪些连接有数据」,但业务处理如果直接在事件循环里做,一个慢操作就会拖住所有连接。常见做法是 Reactor 模式:主线程只负责epoll_wait和分发,把读写和业务逻辑丢给线程池。这样 IO 密集和计算密集能分开,单机轻松支撑上万连接。
线程池的核心参数有三个:核心线程数、最大线程数、任务队列长度。IO 密集型任务线程数可以设成 CPU 核数的 2 到 4 倍,计算密集型则接近核数。任务队列不能无限长,否则请求堆积到内存爆掉才暴露问题,应该设一个上限,满了就拒绝并返回错误,让客户端感知到背压。
连接管理上,服务端要给每个连接维护状态:最后活跃时间、当前解析到一半的缓冲区、所属用户等。用一个哈希表(fd → 连接对象)管理,连接关闭时及时清理,否则就是内存泄漏。心跳机制也在这里做:定期扫描超过 N 秒没数据的连接,主动关闭,防止死连接占着 fd。
3.3 客户端连接池:复用长连接而不是每次重连
客户端如果每次请求都connect一次,三次握手的开销会非常可观,尤其是跨机房场景。正确做法是维护一个连接池:启动时建立若干长连接,请求时从池里取,用完归还。池要处理连接失效——取出来的连接可能已经被对端关了,发送时才发现,这时要能自动重建并重试一次。
连接池的关键参数是最大连接数、空闲超时和健康检查间隔。最大连接数按服务端承载能力和客户端并发量折中;空闲超时避免长期占着不用的连接;健康检查可以定期发心跳包,提前发现死连接。注意重试要有次数上限和退避,否则服务端故障时会引发重试风暴,把刚恢复的服务再次打垮。
4. 避坑与排查:TCP/IP 源码里最容易翻车的五个地方
这一章是我自己踩过的坑,也是线上排查时最常遇到的五类问题。每条按「现象 → 原因 → 解决」写,照着对号入座能省不少时间。
4.1 现象:本机正常,跨机就连接超时
原因通常有三层:一是服务端bind的是127.0.0.1而不是0.0.0.0,只监听本地回环;二是防火墙或安全组没放行端口;三是客户端连的地址写错,比如容器里连localhost连到了自己而不是宿主机。排查顺序是先netstat -tlnp看服务端监听地址,再从客户端telnet host port或nc -vz host port测连通性,最后查防火墙规则。解决就是把bind地址改成INADDR_ANY,放行端口,容器场景用服务名或正确的主机地址。
4.2 现象:收到的数据缺一段,或者两条消息粘在一起
这就是粘包和半包。原因是没有应用层帧格式,直接假设「一次 recv 就是一条消息」。TCP 只保证字节顺序,不保证边界。解决就是第 2.3 节的长度前缀方案,接收端用缓冲区累积再解包。注意recv的缓冲区大小要合理,太小会频繁系统调用,太大浪费内存,一般 4KB 到 64KB 之间按业务调整。
4.3 现象:服务端跑一段时间后 accept 返回「Too many open files」
原因是 fd 泄漏,连接关闭后没有close,或者accept出来的 fd 在异常分支里漏关。每个进程能打开的 fd 有上限,默认往往只有 1024。排查用lsof -p 进程号 | wc -l看 fd 数量是否持续增长。解决一是补上所有异常路径的close,二是用 RAII 或try/finally保证释放,三是临时调大ulimit -n,但根治还得靠代码。
4.4 现象:大量连接处于 CLOSE_WAIT 状态
CLOSE_WAIT表示对端已经关闭,本端还没调close。堆积说明你的代码收到recv返回 0 后没有关闭连接。这是被动关闭方最常见的疏漏。解决就是在recv返回 0 时立即close并从 epoll 里注销。如果对端是主动关闭方,本端处理慢,还会看到大量TIME_WAIT,那是正常现象,可以通过SO_REUSEADDR和调整内核参数缓解。
4.5 现象:压测时吞吐上不去,CPU 却不高
这种「既不忙又慢」的情况,多半是锁竞争或系统调用过多。比如每个连接一把锁、频繁recv小包、日志同步写盘。排查用strace -c看系统调用分布,用perf top看热点函数。解决方向是减少锁粒度(用无锁队列或分片)、合并小包发送(Nagle 算法默认开启,但低延迟场景要设TCP_NODELAY)、日志改异步。注意TCP_NODELAY和 Nagle 是互斥的,交互式场景关 Nagle,批量传输场景可以留着。
5. 验证与进阶:用 iperf 和自写压测脚本确认源码真的靠谱
源码写完、坑也排完,最后一步是验证。没有量化的验证,你永远不知道这套 TCP/IP 客户端和服务端到底能扛多少。这一章讲两个层次的验证方法,以及一个我常用的进阶技巧。
第一层是链路层验证,用iperf3测纯网络吞吐,排除业务逻辑干扰。服务端起iperf3 -s,客户端跑iperf3 -c 服务端IP -t 30 -P 4,-P 4表示 4 个并发流。如果这里吞吐就上不去,问题在网络或内核参数,不在你的源码。常见调优是调大net.core.rmem_max、net.core.wmem_max和tcp_rmem、tcp_wmem。
第二层是业务层压测,自己写脚本模拟真实协议。下面是一个 Python 压测片段,重点看它怎么复用连接池和统计延迟:
import socket, time, statistics from concurrent.futures import ThreadPoolExecutor def worker(host, port, rounds): latencies = [] s = socket.create_connection((host, port), timeout=3) for _ in range(rounds): t0 = time.perf_counter() s.sendall(b"PING") data = s.recv(1024) latencies.append((time.perf_counter() - t0) * 1000) # 毫秒 s.close() return latencies with ThreadPoolExecutor(max_workers=50) as ex: results = list(ex.map(lambda _: worker("127.0.0.1", 9000, 200), range(50))) all_lat = [x for sub in results for x in sub] print(f"P50={statistics.median(all_lat):.2f}ms " f"P99={sorted(all_lat)[int(len(all_lat)*0.99)]:.2f}ms")这段脚本用 50 个线程各发 200 次请求,统计 P50 和 P99 延迟。P99 比平均值更能反映真实体验,因为平均值会被大量快请求拉低,掩盖长尾。压测时观察服务端 CPU、内存、fd 数量,以及客户端是否出现超时。如果 P99 远高于 P50,通常是锁竞争或 GC 停顿。
进阶技巧是「连接预热 + 慢启动规避」。TCP 有慢启动,新连接初期窗口小,吞吐爬升需要时间。长连接池在启动时先发几个小请求把窗口撑开,正式流量进来时就能跑满带宽。另外,跨机房场景可以开启TCP_QUICKACK减少延迟,但要注意它和延迟确认的交互,不是所有场景都适合。
我自己这些年最大的教训是:TCP/IP 源码的难点从来不在写,而在「知道自己写的每一行在什么条件下会失效」。本机跑通只是及格线,跨机、高并发、异常网络才是真正的考场。每次上线前,我都会问自己三个问题:粘包处理了吗?连接泄漏了吗?超时和重试有上限吗?这三个问题答不上来,代码就不算写完。希望帮到你。
本文还有配套的精品资源,点击获取