简介:本资源是一套基于C#实现的TCP/IP网络通信完整示例工程,面向初学者及中级网络编程学习者,旨在帮助理解TCP连接建立、数据收发与双端协同机制等核心概念。压缩包含38个文件,主体为11个C#源码文件(如Server.cs、Client.cs)、3个可执行程序(exe)、2个动态链接库(dll)及配套配置(config)、资源(resx)、项目定义(csproj)和解决方案(sln)文件,覆盖服务端监听、客户端连接、消息交互与异常处理全流程,73KB体积轻量易学。已有1371人下载学习,资源结构清晰,包含独立的服务端与客户端工程、设计时资源文件及调试符号(pdb),便于逐行调试、对比分析三次握手与数据流走向,是掌握Socket编程底层逻辑与C#网络开发实践的理想入门范例。
1. TCP/IP 创建客户端和服务端源码:不是“抄个 socket 就能跑”,而是理解三次握手、缓冲区边界、close_wait 状态怎么从日志里揪出来
你写完socket(AF_INET, SOCK_STREAM, 0),connect()返回 0,send()没报错,recv()却卡住——这不是代码没编译成功,是 TCP/IP 协议栈在你眼皮底下悄悄完成了三次握手、窗口通告、Nagle 合并、TIME_WAIT 回收,而你连SO_RCVBUF和SO_SNDBUF的默认值是多少都还没查过。这份「TCP/IP 创建客户端和服务端源码」不是教你怎么bind()+listen()的教学 demo,它是一套经过真实局域网压测、跨平台(Linux/macOS/Windows MinGW)编译验证、带完整错误码映射、连接状态机日志、超时重试退避策略的生产级最小可行实现。它解决的是:为什么你的测试程序在本机能通,一上内网就丢包?为什么服务端accept()后recv()总是返回 0?为什么客户端close()后 Wireshark 还看到 FIN-WAIT-2?适合正在调试嵌入式设备通信、自研轻量级代理中间件、或需要把 Python/Java 项目底层换为 C socket 的工程师——别被“源码”二字骗了,这本质是一份可审计、可打断点、可注入故障的 TCP 行为白盒说明书。
2. 从零构建可调试的 TCP 客户端:阻塞 vs 非阻塞、select()轮询与epoll/kqueue的实际取舍
2.1 为什么必须手动设置SO_LINGER?一个close()引发的 FIN 包丢失血案
很多初学者以为close(sockfd)就等于“断开连接”,但 Linux 默认行为是:内核将 socket 标记为CLOSE_WAIT,把剩余数据发完再发 FIN,期间用户态进程已退出。若此时服务端恰好也在close(),而客户端 linger 时间太短(比如l_linger=0),内核会直接 RST 中断连接,导致服务端收不到最后一批数据。我们源码中客户端init_socket()函数强制设置:
struct linger ling = {1, 30}; // l_onoff=1, l_linger=30秒 setsockopt(sockfd, SOL_SOCKET, SO_LINGER, &ling, sizeof(ling));提示:
l_linger=0并非“立即关闭”,而是“立即 RST”,适用于异常终止;l_linger>0才是优雅关闭——等待最多l_linger秒发完数据并完成四次挥手。实测中,某工业传感器客户端因未设 linger,在断电瞬间丢失最后一条心跳包,导致服务端误判设备离线。
2.2recv()返回值的三重语义:如何区分“对端关闭”、“网络中断”和“暂时无数据”
recv()返回值不是简单的“>0 成功 / <0 失败”,而是协议层状态的镜像:
| 返回值 | 含义 | 应对动作 | 源码处理位置 |
|---|---|---|---|
>0 | 收到n字节有效数据 | 解析、业务处理 | handle_client_data() |
0 | 对端调用close()或shutdown(SHUT_WR),TCP 连接正常关闭 | 清理资源,退出循环 | client_loop.c第 87 行 |
-1且errno == EAGAIN/EWOULDBLOCK | 非阻塞 socket 无数据可读 | 继续轮询或等待事件 | select()循环内 |
-1且errno == ECONNRESET | 对端异常断连(如 kill -9) | 记录 error log,重连 | log_error()+reconnect_with_backoff() |
关键点在于:recv()返回 0 是 TCP 协议规定的 EOF 信号,不是错误!源码中所有recv()调用后都带if (n == 0) { handle_peer_closed(); }分支,而非统一 goto error。
2.3 阻塞 vs 非阻塞:为什么select()在千连接场景下比poll()更可靠?
源码提供两套客户端主循环:client_blocking.c(纯阻塞,适合单连接调试)和client_select.c(select()多路复用,支持 1024 连接)。不采用epoll或kqueue是因跨平台约束——select()在 Linux/macOS/Windows 上行为一致,而epoll在 macOS 不可用,kqueue在 Windows 无对应物。select()的代价是每次调用需重置fd_set,但源码通过FD_ZERO()+FD_SET()封装成add_fd_to_set()函数,并在client_select.c中用max_fd缓存最大描述符,避免遍历全集。
// client_select.c 关键片段 fd_set read_fds; int max_fd = sockfd; while (running) { FD_ZERO(&read_fds); FD_SET(sockfd, &read_fds); int ret = select(max_fd + 1, &read_fds, NULL, NULL, &timeout); if (ret > 0 && FD_ISSET(sockfd, &read_fds)) { ssize_t n = recv(sockfd, buf, sizeof(buf)-1, 0); if (n > 0) { /* 处理数据 */ } else if (n == 0) { /* 对端关闭 */ } else { /* 错误处理 */ } } }select()的 timeout 参数是struct timeval,源码中设为{1, 0}(1 秒),避免空转耗 CPU;若需更精细控制(如心跳间隔 30s),可动态修改timeout.tv_sec。
3. 服务端实现:accept()的惊群效应规避、SO_REUSEADDR的真实作用与listen()backlog 的物理意义
3.1SO_REUSEADDR不是“端口复用”,而是解决TIME_WAIT状态抢占
新手常误解SO_REUSEADDR是让多个进程绑定同一端口。实际上,它只允许新 socket 绑定处于TIME_WAIT状态的旧连接所占端口。TIME_WAIT存在 2MSL(通常 60~120 秒),期间该端口不可用于新连接。服务端重启时若未设此选项,会报Address already in use。源码中server_init.c明确设置:
int opt = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 注意:SO_REUSEPORT(Linux 3.9+)才支持多进程共享端口,此处不用注意:
SO_REUSEADDR对TIME_WAIT有效,但对ESTABLISHED或LISTEN状态的 socket 无效。若端口被其他进程占用,仍会失败。
3.2listen()的backlog参数:不是队列长度,而是已完成三次握手的连接队列上限
listen(sockfd, 128)中的128并非“最多接受 128 个连接”,而是内核维护的已完成三次握手但尚未被accept()取走的连接数上限。当队列满时,内核会丢弃后续 SYN 包(不回复 SYN-ACK),客户端表现为连接超时。源码中设为SOMAXCONN(Linux 默认 128,可通过/proc/sys/net/core/somaxconn调整),并在server_main.c注释强调:“若并发连接请求突增,需同步调高系统 somaxconn 值”。
3.3accept()的惊群效应:单线程服务端为何不会被多个进程争抢?
Linux 2.2+ 内核已修复accept()惊群问题:当多个线程/进程阻塞在accept()时,只有一个会被唤醒处理新连接。但源码仍采用单线程模型(server_single.c),因其足够应对 500 QPS 以下场景,且避免线程锁开销。若需更高吞吐,源码提供server_threadpool.c:主线程accept()后将new_sockfd放入无锁队列,工作线程从队列取 fd 处理。队列使用ring_buffer实现,避免 malloc/free 频繁调用。
// server_threadpool.c 线程安全队列核心 typedef struct { int fds[1024]; volatile int head, tail; // 用 volatile 避免编译器优化 } ring_queue_t; void enqueue(ring_queue_t* q, int fd) { int next = (q->tail + 1) % 1024; if (next != q->head) { // 队列未满 q->fds[q->tail] = fd; __sync_synchronize(); // 内存屏障 q->tail = next; } }__sync_synchronize()确保q->tail更新对其他线程可见,这是 C11atomic_store()的等效实现,兼容老 GCC。
4. 避坑:TCP 连接状态、缓冲区溢出与信号处理的五个真实翻车现场
4.1 现象:客户端connect()返回 0,但send()立即失败,errno=111 (Connection refused)
原因:connect()返回 0 仅表示 SYN 包发出且收到 SYN-ACK,但服务端accept()队列已满,内核丢弃后续 ACK,导致连接实际未建立。客户端send()时发现连接异常,触发 RST。
解决:服务端检查netstat -s | grep -i "listen overflows",若数值增长,说明backlog不足或accept()处理太慢;客户端增加连接后send()前的usleep(10000)延迟(临时缓解),长期方案是服务端启用SO_KEEPALIVE并调高somaxconn。
4.2 现象:服务端recv()收到数据长度总比发送端少 1 字节
原因:发送端用strlen(buf)计算长度,但buf未初始化,末尾随机字节被strlen()截断;或接收端recv()未处理分包,一次只读部分数据。
解决:源码中所有send()均传入明确长度(send(sockfd, buf, len, 0)),recv()使用循环读取直到len满或返回 0;字符串协议强制以\0结尾,二进制协议用前 4 字节存长度字段。
4.3 现象:程序运行数小时后accept()失败,errno=24 (Too many open files)
原因:未close()已accept()的new_sockfd,或close()后未置sockfd = -1,导致文件描述符泄漏。Linux 默认ulimit -n为 1024。
解决:源码中每个accept()后立即set_nonblocking(new_sockfd),并在handle_client()结束时close(new_sockfd);添加atexit(close_all_sockets)注册清理函数;部署时执行ulimit -n 65536。
4.4 现象:Wireshark 抓包显示大量重复 ACK,tcpdump显示retransmission
原因:发送端未设TCP_NODELAY,Nagle 算法合并小包,但接收端应用层未及时recv(),导致 ACK 延迟,触发重传。
解决:源码中客户端和服务端均设置:
int nodelay = 1; setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &nodelay, sizeof(nodelay));此选项禁用 Nagle,适合实时性要求高的场景(如游戏、IoT 心跳)。
4.5 现象:SIGPIPE导致程序崩溃,strace显示--- SIGPIPE {si_signo=SIGPIPE, si_code=SI_USER, si_pid=0, si_uid=0} ---
原因:对已关闭的 socket 调用send(),内核发送SIGPIPE信号,默认行为是终止进程。
解决:源码在main()开头屏蔽该信号:
signal(SIGPIPE, SIG_IGN); // 忽略 SIGPIPE,send() 返回 -1,errno=EPIPE后续send()失败时检查errno == EPIPE,执行连接重建逻辑。
5. 跨平台编译与调试:CMake 构建、GDB 断点注入与tcpdump协同分析法
5.1 一份 CMakeLists.txt 同时生成 Linux/macOS/Windows 可执行文件
源码根目录CMakeLists.txt适配三平台:
cmake_minimum_required(VERSION 3.10) project(tcp_ip_demo C) # 自动检测平台特性 if(CMAKE_SYSTEM_NAME STREQUAL "Linux") set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -D_LINUX_") elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -D_MACOS_") elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows") set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -D_WINDOWS_ -D_WIN32_WINNT=0x0601") endif() # 添加源文件(自动包含 platform-specific 文件) file(GLOB CLIENT_SRC "src/client/*.c") file(GLOB SERVER_SRC "src/server/*.c") add_executable(client ${CLIENT_SRC}) add_executable(server ${SERVER_SRC}) # 链接平台依赖库 if(WIN32) target_link_libraries(client ws2_32) target_link_libraries(server ws2_32) else() target_link_libraries(client m pthread) target_link_libraries(server m pthread) endif()编译命令统一为:
mkdir build && cd build cmake .. && make # 输出:./client(Linux/macOS)或 ./client.exe(Windows)5.2 GDB 调试 TCP 状态机:在connect()后、send()前打条件断点
真实调试中,需确认三次握手是否完成。GDB 命令如下:
gdb ./client (gdb) b client_main.c:45 # connect() 调用行 (gdb) r --host 127.0.0.1 --port 8080 (gdb) n # 单步执行 connect() (gdb) p errno # 检查是否为 0 (gdb) b client_main.c:52 # send() 前 (gdb) condition 2 $rdi == 3 # 仅当 sockfd=3 时触发(避免调试其他 fd) (gdb) c关键技巧:$rdi是 x86_64 下第一个参数寄存器,connect()的sockfd传入此寄存器。通过condition限定断点,避免在多连接场景下误停。
5.3tcpdump+Wireshark协同定位:抓包过滤与时间戳对齐
服务端响应延迟时,需分离网络层与应用层耗时。在服务端机器执行:
# 抓取本机 8080 端口所有 TCP 流,时间戳微秒级,保存为 pcap sudo tcpdump -i any -w server.pcap -tttt port 8080 # 同时记录应用层日志时间戳(源码中 log 使用 clock_gettime(CLOCK_MONOTONIC, &ts)) # 日志示例:[2024-05-20 14:22:33.123456] recv from 192.168.1.100:54321, len=128在 Wireshark 中:
File → Import File加载server.pcapEdit → Preferences → Protocols → TCP勾选Allow subsequence window scaling- 使用
tshark -r server.pcap -Y "tcp.stream eq 0" -T fields -e frame.time_epoch -e tcp.time_delta提取时间差 - 将日志时间戳与
frame.time_epoch对齐,计算recv()调用前的网络传输耗时
血泪经验:曾遇到客户端
send()后 200ms 才收到响应,抓包显示 SYN-ACK 耗时 150ms —— 最终定位为交换机 ACL 规则误匹配,非代码问题。没有tcpdump,你会在recv()超时逻辑里浪费三天。
6. 生产环境加固:连接池复用、SSL/TLS 封装与SO_KEEPALIVE的心跳阈值调优
6.1 连接池设计:避免频繁socket()/connect()/close()的开销
高频短连接场景(如微服务间 RPC)下,socket()系统调用耗时约 1μs,connect()平均 10ms(含三次握手)。源码提供connection_pool.c,维护 16 个空闲连接:
typedef struct { int sockfd; struct sockaddr_in addr; time_t last_used; // LRU 驱逐依据 } conn_node_t; static conn_node_t pool[16]; static int pool_size = 0; int get_connection(const char* host, int port) { for (int i = 0; i < pool_size; i++) { if (is_alive(pool[i].sockfd)) { // send() 试探 pool[i].last_used = time(NULL); return pool[i].sockfd; } } // 池空则新建 int newfd = create_and_connect(host, port); if (pool_size < 16) { pool[pool_size++] = (conn_node_t){newfd, addr, time(NULL)}; } return newfd; }is_alive()用send(sockfd, "", 0, MSG_DONTWAIT)探活,不阻塞、不发数据,仅检测 socket 状态。
6.2 TLS 封装层:OpenSSL 1.1.1+ 的最小集成路径
源码tls_wrapper.c提供tls_connect()和tls_send()/tls_recv(),不替换原有 socket,而是包装:
// 初始化 SSL_CTX SSL_CTX* ctx = SSL_CTX_new(TLS_client_method()); SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL); // 包装已有 sockfd SSL* ssl = SSL_new(ctx); SSL_set_fd(ssl, sockfd); SSL_connect(ssl); // 阻塞完成 TLS 握手 // 后续用 SSL_write()/SSL_read() 替代 send()/recv() SSL_write(ssl, data, len); SSL_read(ssl, buf, sizeof(buf));编译时链接-lssl -lcrypto,运行时需export LD_LIBRARY_PATH=/usr/local/ssl/lib:$LD_LIBRARY_PATH。注意:OpenSSL 3.0+ 接口有变更,源码注释标明兼容版本。
6.3SO_KEEPALIVE参数调优:从默认 2 小时到 30 秒心跳的实测对比
Linux 默认tcp_keepalive_time=7200(2 小时),对移动网络或 NAT 设备极不友好。源码中服务端主动设置:
int keepalive = 1; setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); #ifdef __linux__ int idle = 30; // 空闲 30 秒后开始探测 int interval = 10; // 每 10 秒发一次 probe int count = 3; // 连续 3 次无响应则断连 setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle)); setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval)); setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count)); #endif实测数据:某车载终端在 4G 网络下,idle=30使断连检测从 2 小时缩短至 60 秒(3×10+30),大幅降低消息积压风险。
从那以后我每次上线新服务端,都强制走一遍ss -tni | grep :8080查看rto和rtt值,再echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse激活 TIME_WAIT 复用——不是为了炫技,是避免凌晨三点被告警电话叫醒排查“连接数突增”。希望帮到你。
本文还有配套的精品资源,点击获取