【Linux】Socket编程TCP

本文主题内容

  • 理解 TCP Socket 的客户端与服务器流程
  • 掌握 listenacceptconnect
  • 区分监听 Socket 与已连接 Socket
  • 实现 TCP 回显服务器和客户端
  • 理解短读、短写与连接关闭
  • 对比单进程、多进程和多线程服务器
  • 掌握 TCP Socket 常见错误处理

引言:TCP 是面向连接的字节流协议。服务器不仅要创建和绑定 Socket,还要进入监听状态,再为每个客户端建立独立的已连接 Socket。理解这两个文件描述符的职责,是写对 TCP 服务器的第一步。

一、TCP Socket 通信流程

1.1 服务器流程

TCP 服务器通常执行:

  1. socket:创建监听 Socket
  2. bind:绑定本地 IP 和端口
  3. listen:把 Socket 设置为监听状态
  4. accept:从已完成连接队列中取出一个连接
  5. read/recvwrite/send:通过已连接 Socket 通信
  6. close:关闭连接

1.2 客户端流程

客户端通常执行:

  1. socket:创建 Socket
  2. 准备服务器地址
  3. connect:主动发起连接
  4. 进行读写
  5. close:关闭连接

connect 成功返回时,TCP 三次握手已经完成,Socket 可以进行双向读写。

二、TCP 核心接口

2.1 listen

c 复制代码
#include <sys/socket.h>

int listen(int sockfd, int backlog);

listen 把已绑定的流式 Socket 转换为监听 Socket。backlog 与内核连接队列容量有关,但不是简单等同于服务器最多能连接多少客户端,实际行为还受操作系统参数影响。

2.2 accept

c 复制代码
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

accept 返回一个新的文件描述符,用于和本次接入的客户端通信。原监听 Socket 继续监听后续连接。

监听 Socket 负责获取新连接,accept 返回的已连接 Socket 负责和某个客户端收发数据。

例:

c 复制代码
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int connfd = accept(listenfd, (struct sockaddr *)&peer, &len);
if (connfd < 0)
{
    perror("accept");
}

addrlen 同样是传入传出参数,调用前必须初始化。

2.3 connect

c 复制代码
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

客户端调用 connect 时,内核会选择本地 IP 和临时端口,向服务器发起连接。阻塞模式下,连接成功或失败后接口才返回。

三、TCP 回显服务器

例:

cpp 复制代码
#include <arpa/inet.h>
#include <cerrno>
#include <cstring>
#include <iostream>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

bool SendAll(int fd, const char *data, size_t size)
{
    size_t offset = 0;
    while (offset < size)
    {
        ssize_t n = send(fd, data + offset, size - offset, 0);
        if (n > 0)
        {
            offset += static_cast<size_t>(n);
            continue;
        }
        if (n < 0 && errno == EINTR)
        {
            continue;
        }
        return false;
    }
    return true;
}

void Service(int connfd)
{
    char buffer[4096];
    for (;;)
    {
        ssize_t n = recv(connfd, buffer, sizeof(buffer), 0);
        if (n > 0)
        {
            if (!SendAll(connfd, buffer, static_cast<size_t>(n)))
            {
                break;
            }
        }
        else if (n == 0)
        {
            std::cout << "peer closed\n";
            break;
        }
        else
        {
            if (errno == EINTR)
            {
                continue;
            }
            std::cerr << "recv: " << std::strerror(errno) << '\n';
            break;
        }
    }
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " port\n";
        return 1;
    }

    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0)
    {
        return 2;
    }

    int reuse = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));

    sockaddr_in local{};
    local.sin_family = AF_INET;
    local.sin_port = htons(static_cast<uint16_t>(std::stoi(argv[1])));
    local.sin_addr.s_addr = htonl(INADDR_ANY);

    if (bind(listenfd, reinterpret_cast<sockaddr *>(&local), sizeof(local)) < 0)
    {
        std::cerr << "bind: " << std::strerror(errno) << '\n';
        close(listenfd);
        return 3;
    }

    if (listen(listenfd, 128) < 0)
    {
        close(listenfd);
        return 4;
    }

    for (;;)
    {
        sockaddr_in peer{};
        socklen_t len = sizeof(peer);
        int connfd = accept(listenfd, reinterpret_cast<sockaddr *>(&peer), &len);
        if (connfd < 0)
        {
            if (errno == EINTR)
            {
                continue;
            }
            std::cerr << "accept: " << std::strerror(errno) << '\n';
            continue;
        }

        Service(connfd);
        close(connfd);
    }
}

这个版本一次只处理一个客户端,适合理解接口,不适合高并发场景。

四、TCP 客户端

例:

cpp 复制代码
#include <arpa/inet.h>
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        return 1;
    }

    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        return 2;
    }

    sockaddr_in server{};
    server.sin_family = AF_INET;
    server.sin_port = htons(static_cast<uint16_t>(std::stoi(argv[2])));
    if (inet_pton(AF_INET, argv[1], &server.sin_addr) != 1)
    {
        close(sockfd);
        return 3;
    }

    if (connect(sockfd, reinterpret_cast<sockaddr *>(&server), sizeof(server)) < 0)
    {
        close(sockfd);
        return 4;
    }

    std::string line;
    while (std::getline(std::cin, line))
    {
        line.push_back('\n');
        send(sockfd, line.data(), line.size(), 0);

        char buffer[4096];
        ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
        if (n <= 0)
        {
            break;
        }
        buffer[n] = '\0';
        std::cout << buffer;
    }

    close(sockfd);
    return 0;
}

实际协议不能假设一次 send 对应一次 recv。这里使用换行只为了演示,生产代码必须明确消息边界。

五、字节流与短读短写

5.1 TCP 没有消息边界

TCP 只提供连续、有序的字节流:

  • 一次发送可能被拆成多次接收
  • 多次发送也可能被一次接收读到
  • recv 返回的是当前可用字节数,不代表一条业务消息结束

因此应用层必须设计协议,例如:

  • 固定长度
  • 特殊分隔符
  • 长度字段加正文
  • 自描述格式

5.2 send 不保证一次写完

阻塞 Socket 的 send 也可能只接受部分数据。发送完整缓冲区需要循环处理返回值,不能简单认为返回正数就全部成功。

5.3 recv 返回 0

recv 返回 0 表示对端已经有序关闭发送方向。此时应停止读取并释放连接资源。

注意:把 recv 返回 0 当作暂时没有数据,会导致服务器反复处理一个已经关闭的连接。

六、并发服务器

6.1 多进程

父进程负责 accept,每个连接交给子进程:

  • 父进程关闭子进程使用的 connfd
  • 子进程关闭继承的 listenfd
  • 父进程需要回收子进程,防止僵尸进程

多进程隔离性好,但创建和切换成本较高。

6.2 多线程

每个连接创建一个线程:

  • 线程参数必须拥有稳定生命周期
  • 工作线程结束后关闭自己的 connfd
  • 线程必须 joindetach
  • 多线程共享全局状态时需要同步

连接数量很大时,一连接一线程会消耗大量栈和调度资源,通常会进一步使用线程池或 IO 多路复用。

6.3 任务与通信分离

更可扩展的结构是:

text 复制代码
监听线程 -> 接收连接 -> 投递任务 -> 工作线程处理

但不能把同一个连接无序交给多个工作线程,否则可能破坏请求顺序和连接状态。任务边界、连接所有权和关闭协议必须明确。

七、连接关闭与异常

7.1 close 与 shutdown

close 释放文件描述符引用。shutdown 可以只关闭读方向或写方向:

c 复制代码
int shutdown(int sockfd, int how);
  • SHUT_RD:关闭读方向
  • SHUT_WR:关闭写方向,并发送 FIN
  • SHUT_RDWR:关闭两个方向

半关闭适合发送完请求后通知对方不会再发送,但仍继续读取响应。

7.2 SIGPIPE

向已经关闭的连接写数据可能触发 SIGPIPE,默认动作是终止进程。服务器通常需要忽略该信号,或使用 MSG_NOSIGNAL,并通过返回值处理 EPIPE

7.3 SO_REUSEADDR

服务器重启时可能遇到地址仍在使用。SO_REUSEADDR 可以改善常见的重启绑定问题,但它不会消除 TCP 状态机,也不能让多个普通监听 Socket 随意绑定同一地址。

八、常见问题

  • 忘记调用 listen
  • accept 返回值当成监听 Socket
  • 处理完连接后忘记关闭 connfd
  • 没有循环处理短写
  • 把一次 recv 当作一条完整请求
  • 参数对象已经销毁,工作线程仍在访问
  • 服务器在回调中执行长时间任务,阻塞后续连接
  • 把未经认证的网络输入直接交给 shell 执行

最后一项尤其危险。远程命令服务必须设计认证、授权、命令白名单、审计和隔离,不能把任意字符串直接传给 popensystem

九、总结

TCP 服务器通过监听 Socket 接收新连接,通过 accept 返回的已连接 Socket 与单个客户端通信。客户端使用 connect 主动建立连接,随后双方都可以使用普通读写接口传输字节流。

TCP 提供可靠有序的字节流,但不提供应用消息边界。程序必须正确处理短读、短写、对端关闭、SIGPIPE 和资源回收。并发模型可以从单进程逐步扩展到多进程、多线程、线程池和 IO 多路复用。

写对 TCP 服务器的关键,不是把接口按顺序调用一次,而是管理好监听描述符、连接描述符、字节流边界和每条连接的完整生命周期。

相关推荐
前进吧-程序员1 小时前
从零入门:eBPF 是什么、解决什么问题以及它的历史
linux
RisunJan2 小时前
Linux命令-whoami(显示当前有效用户名)
linux·运维·chrome
夜来小雨2 小时前
异常Wi-Fi信号问题处理
网络
Lost of 程序猿2 小时前
Kubernetes 排障实战:从 CrashLoopBackOff 到网络不通
网络·容器·kubernetes
三垣网安3 小时前
记一次渗透测试 | 教育src漏洞分享(2)
网络·安全·web安全·网络安全
yunwei374 小时前
eBPF 教程:用 BPF Qdisc 实现出口限速
linux·云原生·开源
mengge.cloud4 小时前
Linux三剑客 grep sed awk 小白基础教程
linux·运维·服务器·网络
盐焗鹌鹑蛋4 小时前
【Linux】进程间通信与进程池
linux
无毁的湖光-Al4 小时前
解Bug之路-with AI-应用被限流?
java·linux