4、TCP客户端实现:创建客户端socket、连接服务端、收发数据、关闭连接
好,咱们今天来聊聊TCP客户端的实现。说实话,很多初学者一上来就盯着服务端代码猛看,觉得客户端就是调几个API,没啥技术含量。我当年也这么想,直到有一次线上排查问题,发现客户端连不上服务器,折腾了半天才发现是socket创建时参数传错了。嗯,从那以后我再也不敢小看客户端代码了。
客户端虽然代码量少,但每一步都有讲究。说白了,TCP客户端就干四件事:创建socket、连接服务端、收发数据、关闭连接。咱们一个一个拆开讲。
4.1 创建客户端socket
创建socket这一步,服务端和客户端其实是一样的。都是调用 socket() 函数。但有个细节——客户端通常不需要指定端口号,系统会自动分配一个临时端口。
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
int main() {
int sockfd;
// 创建socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket创建失败");
return -1;
}
printf("客户端socket创建成功,fd=%d\n", sockfd);
// ... 后续操作
}
这里 AF_INET 表示IPv4,SOCK_STREAM 表示TCP。第三个参数传0,让系统自动选择协议。我个人习惯在创建完socket后立刻检查返回值,别等到后面连接失败才回头排查。
fcntl(sockfd, F_GETFD) 检查文件描述符是否有效。我在项目中遇到过socket创建成功但描述符被占满的情况,加个检查能提前发现问题。
4.2 连接服务端
创建完socket,下一步就是连接服务端。这里要用到 connect() 函数。你需要告诉它:连到哪个IP、哪个端口。
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888); // 服务端端口
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("连接服务端失败");
close(sockfd);
return -1;
}
printf("成功连接到服务端\n");
这里有几个坑,我一个个说。
第一个坑:字节序。 端口号要用 htons() 转换。为什么?因为网络字节序是大端,而你的机器可能是小端。不转换的话,端口号就错了。我曾经帮同事排查过一个问题,他写死了端口号没转换,结果连不上,查了半天才发现。
第二个坑:IP地址转换。 老代码喜欢用 inet_addr(),但这个函数不支持IPv6,而且返回的是32位整数,容易出问题。我建议用 inet_pton(),它更安全,也支持IPv6。
第三个坑:连接超时。 默认情况下,connect() 会一直阻塞,直到连接成功或失败。如果服务端没启动,客户端可能会卡住几十秒。你可以设置socket为非阻塞模式,或者用 setsockopt() 设置超时时间。
connect() 失败,一定要记得 close(sockfd) 关闭socket。否则会造成文件描述符泄漏。我见过生产环境因为这个问题导致socket耗尽,服务直接挂掉。
4.3 收发数据
连接建立后,就可以用 send() 和 recv() 收发数据了。这两个函数是TCP客户端的核心。
char send_buf[] = "Hello, server!";
char recv_buf[1024] = {0};
// 发送数据
ssize_t sent = send(sockfd, send_buf, strlen(send_buf), 0);
if (sent < 0) {
perror("发送失败");
close(sockfd);
return -1;
}
printf("发送了 %zd 字节\n", sent);
// 接收数据
ssize_t received = recv(sockfd, recv_buf, sizeof(recv_buf) - 1, 0);
if (received < 0) {
perror("接收失败");
} else if (received == 0) {
printf("服务端关闭了连接\n");
} else {
recv_buf[received] = '\0';
printf("收到服务端回复: %s\n", recv_buf);
}
这里有个重要的点:send() 不一定一次发完所有数据。TCP是流协议,它可能只发送了部分数据。你需要检查返回值,如果小于你要发送的长度,就得继续发剩下的。我习惯写一个循环来保证数据全部发送。
// 安全的发送函数
ssize_t send_all(int sockfd, const char *data, size_t len) {
size_t total_sent = 0;
while (total_sent < len) {
ssize_t sent = send(sockfd, data + total_sent, len - total_sent, 0);
if (sent <= 0) {
return -1; // 出错或连接关闭
}
total_sent += sent;
}
return total_sent;
}
接收数据也一样。你调用 recv() 时,它可能只返回了一部分数据。比如你发了一个1000字节的包,对端可能分两次收到。所以接收端也要做好粘包处理。这个后面会专门讲,这里先提个醒。
4.4 关闭连接
数据收发完了,就该关闭连接了。关闭连接用 close() 函数。但这里有个讲究:是主动关闭还是被动关闭?
// 关闭写端,告诉服务端我发完了
shutdown(sockfd, SHUT_WR);
// 还可以继续接收数据
char last_buf[1024];
ssize_t n = recv(sockfd, last_buf, sizeof(last_buf), 0);
if (n > 0) {
printf("收到最后的回复: %s\n", last_buf);
}
// 最后完全关闭
close(sockfd);
close() 和 shutdown() 的区别,我简单说一下:
- close():把socket的引用计数减1,当计数为0时彻底关闭。如果有其他进程共享这个socket,它不会立即关闭。
- shutdown():立即关闭连接的读端或写端,不受引用计数影响。
我建议在关闭连接时,先用 shutdown(sockfd, SHUT_WR) 告诉对端「我发完了」,然后继续接收对端的剩余数据,最后再 close()。这样能保证数据完整传输。
close() 关闭连接,结果对端还在发数据,导致数据丢失。后来改用 shutdown() 先关闭写端,等对端也关闭后再 close(),问题就解决了。
4.5 完整示例代码
把上面的代码拼起来,就是一个完整的TCP客户端:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
int main() {
int sockfd;
struct sockaddr_in server_addr;
char send_buf[] = "Hello, TCP server!";
char recv_buf[1024] = {0};
// 1. 创建socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
// 2. 准备服务端地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
// 3. 连接服务端
if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("已连接到服务端\n");
// 4. 发送数据
if (send(sockfd, send_buf, strlen(send_buf), 0) < 0) {
perror("send");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("发送: %s\n", send_buf);
// 5. 接收数据
if (recv(sockfd, recv_buf, sizeof(recv_buf) - 1, 0) < 0) {
perror("recv");
} else {
printf("收到: %s\n", recv_buf);
}
// 6. 关闭连接
shutdown(sockfd, SHUT_WR);
close(sockfd);
printf("连接已关闭\n");
return 0;
}
4.6 TCP客户端核心流程
下面这张图总结了TCP客户端的完整流程,你可以对照着代码看:
4.7 常见问题与排查
写客户端代码时,有几个问题我经常遇到,这里列出来供你参考:
| 问题 | 原因 | 解决方法 |
|---|---|---|
| connect() 返回 -1 | 服务端未启动、IP/端口错误、防火墙拦截 | 检查服务端状态,用 telnet 测试端口 |
| send() 返回 0 | 连接已关闭 | 检查对端是否调用了 close() |
| recv() 阻塞不返回 | 对端没有发送数据,或者网络延迟 | 设置超时时间,或用非阻塞模式 |
| 数据接收不完整 | TCP粘包,一次 recv() 没读完 | 循环接收,直到收完指定长度 |
| 端口被占用 | 上一个程序没正常关闭 | 用 netstat 查看端口状态,等待 TIME_WAIT 结束 |
strace 命令可以跟踪系统调用,对排查网络问题特别有用。
好了,TCP客户端的实现就讲到这里。代码不多,但每个细节都值得认真对待。写客户端的时候,多想想边界情况:如果服务端挂了怎么办?如果网络断了怎么办?如果数据发不完怎么办?把这些想清楚了,你的代码就会健壮很多。
公众号:蓝海资料掘金营,微信deep3321