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后立刻检查返回值,别等到后面连接失败才回头排查。

小技巧: 创建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字节的包,对端可能分两次收到。所以接收端也要做好粘包处理。这个后面会专门讲,这里先提个醒。

核心要点: TCP是流协议,没有消息边界。发送端和接收端必须自己约定消息的格式,比如固定长度、特殊分隔符、或者长度前缀。否则就会出现粘包问题。

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客户端的完整流程,你可以对照着代码看:

TCP客户端核心流程 1. socket() 创建 2. connect() 连接 3. send() 发送数据 4. recv() 接收数据 5. close() 关闭连接 关键点 • socket() 返回文件描述符 • connect() 需要IP+端口 • send() 可能发不完 • recv() 返回0表示关闭 • close() 要检查返回值 常见错误: • 端口未用 htons() • IP地址格式错误 • 忘记关闭socket • 粘包未处理 • 忽略 send() 返回值

4.7 常见问题与排查

写客户端代码时,有几个问题我经常遇到,这里列出来供你参考:

问题 原因 解决方法
connect() 返回 -1 服务端未启动、IP/端口错误、防火墙拦截 检查服务端状态,用 telnet 测试端口
send() 返回 0 连接已关闭 检查对端是否调用了 close()
recv() 阻塞不返回 对端没有发送数据,或者网络延迟 设置超时时间,或用非阻塞模式
数据接收不完整 TCP粘包,一次 recv() 没读完 循环接收,直到收完指定长度
端口被占用 上一个程序没正常关闭 用 netstat 查看端口状态,等待 TIME_WAIT 结束
调试技巧: 我习惯在客户端代码里加一些调试打印,比如每次 send/recv 后打印实际发送/接收的字节数。这样能快速定位是发送端的问题还是接收端的问题。另外,strace 命令可以跟踪系统调用,对排查网络问题特别有用。

好了,TCP客户端的实现就讲到这里。代码不多,但每个细节都值得认真对待。写客户端的时候,多想想边界情况:如果服务端挂了怎么办?如果网络断了怎么办?如果数据发不完怎么办?把这些想清楚了,你的代码就会健壮很多。


公众号:蓝海资料掘金营,微信deep3321