ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

TCP/IP Socket通信实战:C与Java双版本源码解析与排障指南

TCP/IP Socket通信实战:C与Java双版本源码解析与排障指南 简介TCP/IP客户端与服务端源码是一份面向网络编程初学者和进阶开发者的可运行示例工程以C#语言实现完整的Socket通信流程覆盖从套接字创建、地址绑定、监听连接到数据收发与关闭连接的全过程可用于理解TCP三次握手与四次挥手在实际代码中的对应实现。资源包共38个文件以cs源码、config配置、exe可执行程序、dll依赖库和sln工程文件为主压缩包仅73KB结构紧凑打开工程即可编译运行适合快速上手。目前已有1371人学习可见其内容具有较高参考价值。通过阅读这份代码读者能直接对照TCP/IP协议栈的关键步骤观察客户端与服务端如何通过bind、listen、accept、connect等接口协作并借此掌握网络编程中的地址结构初始化、字节序转换等细节为后续开发实际网络应用打下扎实基础。1. TCP/IP 客户端与服务端源码一份能直接跑通的 Socket 通信骨架写网络通信代码最尴尬的不是算法难而是你打开一台 Linux 机器想把两个进程用 TCP/IP 连通却发现自己卡在 socket() 和 accept() 的返回码上。这份 TCP/IP 创建客户端和服务端源码就是一套拿来能跑的 Socket 通信骨架C 和 Java 两套实现服务端、客户端各一份完整代码从 bind、listen、accept 到 connect、send、recv覆盖 TCP 连接的全生命周期。它能帮你在一小时内把通信链路建起来再把端口复用、粘包、断线重连这些“玄学”问题逐个讲透。适合做上位机、嵌入式或者课程设计的人也适合那些把 Socket 当黑匣子、想拆开看一次真实握手的同学。2. 先把通信模型立起来Socket 流程、阻塞 IO 与源码包结构2.1 三次握手在代码里长什么样connect 与 accept 的配合关系理解这套源码之前先弄清楚一件事TCP 的三次握手和系统调用不是一一对应的很多人误以为 accept 返回就是握手完成其实握手完成得更早。客户端调用 connect() 时内核发出 SYN服务端内核在收到 SYN 后自动回 SYNACK这个过程根本不需要应用层代码干预客户端收到后回 ACK连接进入 ESTABLISHED。服务端的 accept() 只是从内核维护的已完成连接队列里“取”一个连接出来。服务端这边的调用顺序是 socket() - bind() - listen() - accept()。listen() 传入的 backlog 参数决定内核里两个队列的上限一个是半连接队列存只发了 SYN 的连接一个是全连接队列存完成握手的连接。backlog 设得太小高并发下客户端会连不进来表现是 connect 超时或连接被重置。客户端这边的顺序更短socket() - connect()。connect() 是阻塞调用它要等到三次握手完成或者超时才返回。所以当你看到 connect 返回 0不代表服务端应用已经 accept只代表 TCP 层握手完成。这也是客户端刚 connect 成功就立刻发数据偶尔会遇到服务端还没 accept 的一个原因——TCP 层已经就绪但应用层还没来得及创建处理线程。正是这种错位让很多人第一次调 Socket 时摸不着头脑。把这条时间线梳理清楚再看代码就不慌了。2.2 为什么这套源码选阻塞 IO可读性优先并发交给线程代码里用的是最传统的阻塞式 socket没有上 epoll、select 或者 Reactor 模型。原因很实际这份资源的核心目标是让读者一次看懂 TCP 通信的完整链路。阻塞模型下服务端 accept 阻塞等待连接recv 阻塞等待数据每一行代码的意图都非常直白适合做教学骨架和课程设计底子。阻塞模型的问题也很明显一个线程同时只能处理一个连接的读写。如果服务端做成单线程 accept 再循环 recv那第一个客户端不发送数据第二个客户端就永远等不到被 accept。这份源码在 C 版里用“主循环 accept 短连接处理”来规避这个问题Java 版则直接上了线程池每个连接一个任务。如果日后要处理高并发长连接正确路线是切到非阻塞 epoll或者直接用 Netty。但那是把模型吃透之后的事不建议一上来就从 epoll 写起。选型方面我建议你按这个标准判断联调工具、课程设计、小型内部服务阻塞 IO 完全够用连接数上千、需要支撑长连接推送的业务再考虑非阻塞。下表把两条路的差别列一下免得方向选错对比项阻塞 IO 多线程非阻塞 IO epoll每连接资源开销一个线程约 8MB 虚拟机内存一个 fd几个 KB 状态支撑连接数几百封顶上万很轻松代码可读性线性逻辑容易理解回调驱动心智负担大调试难度直接看栈就能定位状态机错误非常隐蔽典型场景工具、课程设计、小型服务网关、游戏服、高并发推送2.3 源码包的文件构成C 和 Java 双实现的分工与编译入口这套资源里最重要的四个文件是 C 版服务端、C 版客户端、Java 版服务端、Java 版客户端。文件命名和用途如下表所示文件语言职责tcp_server.cC阻塞式服务端绑定 8080 端口收到消息原样回写tcp_client.cC阻塞式客户端连接本机 8080发送消息并接收回显TcpServer.javaJava线程池服务端带消息长度头解析TcpClient.javaJava客户端带连接超时与自动重连逻辑文件结构本身也暗示了一条学习路线先用 C 版跑通一次最小的收发再用 Java 版体会线程池和消息定界。C 版的代码量和系统调用最少方便对照《TCP/IP 详解》里讲的那几个 socket API 逐个验证Java 版则是把工程上常见的问题连接超时、粘包、重连直接塞进了代码里。编译入口很简单C 版用 gcc 一把过Java 版用 javac 编译两个类即可具体的命令行在下一章操作。3. C 语言版实现从 socket() 到 close() 的四个关键步骤3.1 服务端主循环bind、listen、accept 的职责边界服务端代码第一版建议直接做成单连接的 echo 服务逻辑最清晰。下面这段是完整的 C 服务端源码#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define PORT 8080 #define BACKLOG 16 // listen 队列长度并发低时 16 足够 #define MAX_BUF 1024 int main() { int server_fd, client_fd; struct sockaddr_in addr; socklen_t addr_len sizeof(addr); char buf[MAX_BUF]; // 1. 创建 IPv4 的 TCP socket server_fd socket(AF_INET, SOCK_STREAM, 0); if (server_fd 0) { perror(socket); exit(1); } // 2. 设置 SO_REUSEADDR防止重启时 TIME_WAIT 导致 bind 失败 int opt 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); // 3. 绑定 0.0.0.0:8080 memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(PORT); addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡 if (bind(server_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); close(server_fd); exit(1); } // 4. 进入被动监听状态 if (listen(server_fd, BACKLOG) 0) { perror(listen); close(server_fd); exit(1); } printf(listening on port %d\n, PORT); // 5. 主循环accept 返回新 fd原始 fd 继续监听 while (1) { client_fd accept(server_fd, (struct sockaddr *)addr, addr_len); if (client_fd 0) { perror(accept); continue; } // 这里只处理一条消息就关闭连接便于观察完整生命周期 int n recv(client_fd, buf, MAX_BUF, 0); if (n 0) { buf[n] \0; printf(recv: %s\n, buf); send(client_fd, buf, n, 0); // 原样回显 } close(client_fd); } close(server_fd); return 0; }这段代码里有三个边界要注意。第一个是 bind 只绑定一次后面 accept 返回的 client_fd 才是和数据收发绑定的 socket很多初学者误以为要重新 bind 新端口那是完全没有理解四元组的概念。第二个是 backlog 设成 16在局域网联调里完全够用如果用 wrk、ab 这类工具压测建议先提到 128 并观察连接失败率。第三个是 recv 的返回值返回 0 表示对端关闭返回 -1 要结合 errno 判断是 EINTR被信号中断还是其他错误不要无脑把 n 当数据长度。3.2 客户端实现connect 超时控制与循环收发客户端的难点不在打通而在超时和半包处理。很多教程里的客户端直接阻塞 connect一旦对端 IP 不可达默认要等一百多秒才返回这在联调现场根本等不起。我一般会用非阻塞 connect select 的方式把超时控制在一到三秒做法如下#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include errno.h #include sys/socket.h #include sys/select.h #include netinet/in.h #include arpa/inet.h #define SERVER_IP 127.0.0.1 #define SERVER_PORT 8080 int main() { int sock; struct sockaddr_in server_addr; char buf[1024]; sock socket(AF_INET, SOCK_STREAM, 0); if (sock 0) { perror(socket); exit(1); } memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(SERVER_PORT); inet_pton(AF_INET, SERVER_IP, server_addr.sin_addr); // 将 socket 设为非阻塞用于控制 connect 超时 int flags fcntl(sock, F_GETFL, 0); fcntl(sock, F_SETFL, flags | O_NONBLOCK); int ret connect(sock, (struct sockaddr *)server_addr, sizeof(server_addr)); if (ret 0 errno EINPROGRESS) { fd_set wfds; struct timeval tv {3, 0}; // 3 秒超时 FD_ZERO(wfds); FD_SET(sock, wfds); ret select(sock 1, NULL, wfds, NULL, tv); if (ret 0) { fprintf(stderr, connect timeout\\n); close(sock); exit(1); } // 此时还需检查 SO_ERROR确认没有连接失败 int err 0; socklen_t len sizeof(err); getsockopt(sock, SOL_SOCKET, SO_ERROR, err, len); if (err ! 0) { fprintf(stderr, connect error: %s\\n, strerror(err)); close(sock); exit(1); } } // 恢复阻塞模式后续收发阻塞等待即可 fcntl(sock, F_SETFL, flags); const char *msg hello tcp; send(sock, msg, strlen(msg), 0); int n recv(sock, buf, sizeof(buf), 0); if (n 0) { buf[n] \\0; printf(recv: %s\\n, buf); } close(sock); return 0; }说几个容易翻车的细节。connect 返回 -1 且 errno 是 EINPROGRESS说明连接正在建立中这时用 select 监听这个 fd 的可写事件可写不代表连接成功所以要再 getsockopt 一次拿 SO_ERROR 才能确认有没有真正连上。select 返回 0 是超时连接已经没戏了直接 close 走人。至于 send 和 recv循环发送和循环接收的写法跟文件读写完全一样我上面的精简版只处理了一次收发真实业务里建议写成 while 循环直到把数据收完。3.3 编译与联调gcc 命令、启动顺序和 nc 验证C 版两个文件编译非常简单不需要任何外部库gcc -o tcp_server tcp_server.c gcc -o tcp_client tcp_client.c建议的启动顺序是先运行服务端再运行客户端。如果服务端没有先启动客户端 connect 会直接返回 ECONNREFUSED。还可以用 nc 命令做一个快速的冒烟测试nc 相当于一个不写协议的极简客户端./tcp_server nc -v 127.0.0.1 8080启动服务端后先用ss -lntp | grep 8080确认端口状态是 LISTEN再跑 nc输入一行字符回车服务端终端会打印 recv 并将内容原样回显。nc 验证通过后再跑./tcp_client这时候服务端终端应该打印recv: hello tcp。如果 nc 能通而 tcp_client 连不上那问题基本在客户端 bind 或防火墙和协议无关。两段联调都通过C 版就算完全跑通了。4. Java 版实现线程池、断线重连与消息定界4.1 服务端线程池模型别再做 one thread per connectionJava 版和 C 版最大的差别在线程模型。如果按最直观的写法来一个客户端就 new 一个 Thread单机上三百个连接就能把内存吃出压力。更稳妥的是用固定线程池让连接处理排队import java.io.*; import java.net.*; import java.util.concurrent.*; public class TcpServer { private static final int PORT 8080; private static final int THREADS 4; public static void main(String[] args) throws IOException { ExecutorService pool Executors.newFixedThreadPool(THREADS); try (ServerSocket serverSocket new ServerSocket(PORT)) { System.out.println(listening on port PORT); while (true) { Socket socket serverSocket.accept(); pool.execute(new ClientHandler(socket)); } } } static class ClientHandler implements Runnable { private final Socket socket; ClientHandler(Socket socket) { this.socket socket; } Override public void run() { try (DataInputStream in new DataInputStream(socket.getInputStream()); DataOutputStream out new DataOutputStream(socket.getOutputStream())) { int len in.readInt(); // 先读 4 字节长度头 byte[] data new byte[len]; in.readFully(data); // 按长度精准读取 String msg new String(data, UTF-8); System.out.println(recv: msg); byte[] resp (echo: msg).getBytes(UTF-8); out.writeInt(resp.length); // 回包也带长度头 out.write(resp); out.flush(); } catch (IOException e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException ignored) {} } } } }线程池大小选 4这是基于双核四线程机器上的一个保守值。坑在 accept 和 execute 之间的衔接socket 一旦被 accept 出来就由线程池接管主循环立刻回到 accept 等待新连接千万不能在主线程里做 recv否则一个慢客户端会堵住整个服务端。DataInputStream 的 readInt 是网络字节序readFully 会持续阻塞读取直到填满整个数组这两行配合在一起就天然地规避了 C 版里 recv 一次可能只收到半条消息的问题。4.2 客户端断线重连绕过“地址已在使用”的 TIME_WAIT 坑服务端已经用上了线程池客户端这边也得解决两个工程问题连接超时和自动重连。直接看代码import java.io.*; import java.net.*; public class TcpClient { private static final String HOST 127.0.0.1; private static final int PORT 8080; private static final int MAX_RETRY 5; public static void main(String[] args) throws Exception { Socket socket connectWithRetry(HOST, PORT, MAX_RETRY); if (socket null) { System.err.println(give up after MAX_RETRY retries); return; } try (DataOutputStream out new DataOutputStream(socket.getOutputStream()); DataInputStream in new DataInputStream(socket.getInputStream())) { byte[] msg hello tcp with java.getBytes(UTF-8); out.writeInt(msg.length); out.write(msg); out.flush(); int respLen in.readInt(); byte[] resp new byte[respLen]; in.readFully(resp); System.out.println(recv: new String(resp, UTF-8)); } } private static Socket connectWithRetry(String host, int port, int maxRetries) throws InterruptedException { Socket socket null; for (int i 1; i maxRetries; i) { try { Socket s new Socket(); s.connect(new InetSocketAddress(host, port), 3000); // 3 秒超时 return s; } catch (IOException e) { System.err.println(attempt i failed: e.getMessage()); Thread.sleep(1000); } } return null; } }这里有个 Java 特有的细节new Socket() 之后如果不绑定本地地址直接 connect内核会自动分配一个临时端口TIME_WAIT 只落在那个临时端口上下次重连会自动换新端口所以教程里常见的“客户端 Address already in use”在 Java 里很少碰到。真正会触发这个错的是客户端自己也调用了socket.bind(new InetSocketAddress(8081))固定本地端口这时候就必须在 bind 之前调用socket.setReuseAddress(true)顺序反了就不生效。如果你将来写 C# 或者 Modbus TCP 客户端重连策略和 TIME_WAIT 的处理思路是完全一致的。4.3 报文边界为什么裸 Socket 必须自己定义长度头TCP 是字节流协议它自己不认识“消息”。你 send 一次 100 字节服务端 recv 可能只拿到 30 字节也可能一次性拿到两次 send 的数据。正因为这样业务代码必须定义消息边界。Java 版用的是最常见的方案4 字节长度头 变长消息体。发送时先写 int 长度再写内容接收时先读 int 再读对应长度的字节一收一送严格配对粘包和半包问题在应用层就被解决了。如果一定要把定界方案讲透实际上就是三个套路一是固定长度每条消息都补齐到固定字节数实现简单但浪费带宽二是分隔符比如 HTTP 用 CRLF 分割头部但消息体里出现分隔符要转义三是长度头最通用也最省流量。这套 Java 源码采用的是方案三这也是主流 RPC 框架的通用做法。C 版虽然没有显式做长度头但你完全可以把 C 服务端改成同样的协议先 recv 4 字节转成整数再循环 recv 该长度个字节改完后 C 和 Java 两端的程序就能互相通信了。这是这套源码最有价值的一个验证实验因为跨语言互通才是协议设计是否合理的试金石。5. 避坑与排查TCP 通信最常见的五个翻车现场5.1 服务端重启就崩bind: Address already in use现象服务端进程被 kill 后立刻重新启动bind 报错 Address already in use过几十秒再启动又正常。原因主动关闭连接的一端会进入 TIME_WAIT 状态默认等待 60 秒。服务端关闭监听 fd 时上一个连接的 socket 还占着端口内核不允许立刻 bind 同一个端口。解决在 bind 之前对 server_fd 设置 SO_REUSEADDR代码里已经这么做了。注意这个选项只对 TIME_WAIT 有效如果端口被一个正常运行中的进程占用设了也白设。调试时想避开这个坑最快的办法是改端口或者用ss -tlnp | grep 8080把旧的占用进程找出来杀掉。5.2 联调时间歇性连不上半连接队列溢出现象客户端手动重试几次后能连上但第一次几乎必失败服务端日志和 accept 都没报错。原因客户端 connect 的 SYN 到达服务端后如果半连接队列已满内核会直接丢弃 SYN客户端只能等超时重传。最常在压测或频繁快速重连时出现。解决把 listen 的 backlog 调大比如从 16 改成 128同时用ss -lnt看服务端的 Send-Q 和 Recv-Q 双重队列水位。内核参数net.ipv4.tcp_max_syn_backlog只在队列达到上限时起作用业务侧优先调 backlog。5.3 recv 返回 0 不一定是对端断开了现象客户端收到 recv 返回 0就打印“对端关闭”并关闭 socket结果服务端还能正常收发两边状态完全对不上。原因recv 返回 0 表示没有更多数据可读这既可能是对端调用了 close()也可能是对端只调用了 shutdown(SHUT_WR) 做了半关闭。半关闭时对端仍然可以接收数据。解决协议设计上必须区分“不能再收”和“不再发”。如果业务不需要半关闭要求对端必须有应用层再见消息比如先发一条 BYE 再 close收到 BYE 才释放本地资源。我在实际项目里就吃过这个亏后来所有内部协议一律强制带关闭码绝不用 recv 返回值判断连接生死。5.4 客户端一次 send 服务端多次 recv粘包与半包现象客户端 send 了 “hello” 和 “world”服务端第一次 recv 可能就收到 “helloworld”也可能只收到 “hel”。原因TCP 是流式协议包和包之间没有分隔符。Nagle 算法会把小包合并网络拥塞会把大包拆开recv 拿到的字节流和 send 的边界没有必然关系。解决应用层自己划边界最稳的是长度头方案。C 版至少要改成“先 recv 4 字节长度再按长度循环读”不能相信单次 recv 一次性拿完整条消息。Java 版已经用了 DataInputStream 的 readInt readFully这个问题天然规避了。5.5 telnet 通了但程序联调就是不行协议测试工具选错现象telnet 到 8080 端口能进随手敲的 abc 也有回显换成自己写的客户端服务端解析出来的全是乱码或者报错。原因telnet 发送的是裸字节回车换行是它自己加的。如果服务端协议是“固定长度头 消息体”telnet 敲进去的内容缺了长度头服务端自然拆不出来。解决不要用 telnet 测自定义二进制协议。我在联调阶段习惯直接用 nc 配合 printf 构造带长度头的报文比如发一条长度 5 的字符串printf \x00\x00\x00\x05hello | nc -v 127.0.0.1 8080四个字节的前缀是按网络字节序写的长度 5后面紧跟消息体。这样测出来如果服务端还是解析失败问题就在服务端长度解算逻辑上和工具无关了。测试工具选对能帮你省掉一半排障时间。6. 验证与进阶用 tcpdump 回放三次握手6.1 tcpdump 抓包把三次握手和回显全流程看一遍代码跑通之后建议做一次抓包验证确认你看到的代码行为就是 TCP 层真实发生的事。抓本机回环口最简单sudo tcpdump -i lo -nn -S tcp port 8080启动 tcpdump 后再运行 tcp_client你会看到五类四元组报文客户端发Flags [S]服务端回Flags [S.]客户端再回Flags [.]这是三次握手。之后跟着的是Flags [P.]意思是带数据的报文就是客户端发的 “hello tcp”。最后的Flags [F.]出现两次代表双方各发了一次 FIN四次挥手也齐了。数一遍握手报文和挥手报文如果少了最后一个 ACK说明对端可能处于 TIME_WAIT 还没完全释放正好对应避坑章节讲过的现象。6.2 压测与回归验证形成自己的联调清单验证做扎实之后我建议你固定的几件事每次改完协议代码先跑一遍 C 版客户端到 C 版服务端的冒烟再跑 Java 客户端到 Java 服务端最后做一次交叉验证C 客户端连 Java 服务端三层都通过才算协议没破。压测阶段可以写一个简单的并发脚本用 nc 开启多个连接同时发消息观察服务端是否出现 accept 失败或者 CPU 飙升那能暴露线程池配置是否合理。从那以后我每次写完一套 TCP 联调都会先在另一终端把 tcpdump 挂上确认握手数量正确、报文边界没乱再进入业务逻辑调试这个习惯帮我省下了无数次抓耳挠腮。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表