Linux 网络编程:从零掌握 TCP Socket 编程(附 C++ 实战)

🎬 个人主页 :道尔柯南
❄专栏传送门 :《C语言》《C++》《Linux操作系统》

昙花一现,却等待了整个白昼;蝉鸣一夏,却蛰伏了好几个四季。

文章目录

    • 一、前言
    • [二、TCP Socket 编程基础](#二、TCP Socket 编程基础)
      • [2.1 什么是 Socket?](#2.1 什么是 Socket?)
      • [2.2 TCP 与 UDP 的区别](#2.2 TCP 与 UDP 的区别)
      • [2.3 TCP 通信的基本流程](#2.3 TCP 通信的基本流程)
    • [三、TCP Socket 核心 API 详解](#三、TCP Socket 核心 API 详解)
      • [3.1 socket():创建 Socket](#3.1 socket():创建 Socket)
      • [3.2 bind():绑定 IP 地址和端口](#3.2 bind():绑定 IP 地址和端口)
      • [3.3 listen():让服务器进入监听状态](#3.3 listen():让服务器进入监听状态)
      • [3.4 accept():获取客户端连接](#3.4 accept():获取客户端连接)
      • [3.5 connect():客户端主动连接服务器](#3.5 connect():客户端主动连接服务器)
      • [3.6 read() 和 write():收发数据](#3.6 read() 和 write():收发数据)
    • [四、实战一:实现最简单的 TCP Echo Server](#四、实战一:实现最简单的 TCP Echo Server)
      • [4.1 服务器初始化流程](#4.1 服务器初始化流程)
      • [4.2 实现服务器的通信逻辑](#4.2 实现服务器的通信逻辑)
      • [4.3 接收客户端连接](#4.3 接收客户端连接)
    • 五、客户端实现:连接服务器并发送数据
      • [5.1 编译与运行](#5.1 编译与运行)
    • [六、理解 TCP 的两个重要细节](#六、理解 TCP 的两个重要细节)
      • [6.1 为什么客户端通常不需要显式调用 bind()?](#6.1 为什么客户端通常不需要显式调用 bind()?)
      • [6.2 为什么 accept() 返回的 Socket 很重要?](#6.2 为什么 accept() 返回的 Socket 很重要?)
    • 七、实战二:从单客户端到多客户端
      • [7.1 V1:单进程、单连接处理](#7.1 V1:单进程、单连接处理)
      • [7.2 V2:使用多进程处理客户端](#7.2 V2:使用多进程处理客户端)
      • [7.3 V3:使用多线程处理客户端](#7.3 V3:使用多线程处理客户端)
      • [7.4 V4:使用线程池处理客户端](#7.4 V4:使用线程池处理客户端)
    • 八、四种服务器模型对比
    • [九、扩展:TCP 远程命令执行](#九、扩展:TCP 远程命令执行)
    • [十、TCP Socket 编程中的常见问题](#十、TCP Socket 编程中的常见问题)
      • [10.1 bind() 失败怎么办?](#10.1 bind() 失败怎么办?)
      • [10.2 read() 返回 0 是什么意思?](#10.2 read() 返回 0 是什么意思?)
      • [10.3 为什么一个客户端会影响其他客户端?](#10.3 为什么一个客户端会影响其他客户端?)
      • [10.4 为什么不能把一次 read() 当成一条完整消息?](#10.4 为什么不能把一次 read() 当成一条完整消息?)
    • 十一、总结
  • 结尾

一、前言

在学习 Linux 网络编程时,我们经常会遇到一个问题:两台计算机上的程序,究竟是如何进行通信的?

例如:

  • 浏览器如何向 Web 服务器发送请求?
  • 聊天软件如何将消息发送给另一台设备?
  • 客户端如何连接服务器,并接收服务器返回的数据?
  • 一个服务器如何同时处理多个客户端的连接?

要理解这些问题,就需要学习 Socket 编程。

在之前的学习中,我们已经接触过 UDP Socket 编程。与 UDP 不同,TCP 提供面向连接的通信方式,能够提供可靠、有序的字节流传输。因此,TCP 被广泛应用于文件传输、网页访问、远程服务等场景。

本文将基于 Linux 下的 C++ TCP Socket 编程,带大家从基础 API 开始,逐步实现一个 Echo Server,并进一步探索多进程、多线程和线程池三种并发处理方案。

二、TCP Socket 编程基础

2.1 什么是 Socket?

Socket 可以理解为应用程序进行网络通信时使用的通信端点。

在 Linux 系统中,调用 socket() 创建 Socket 后,系统会返回一个文件描述符。我们可以使用这个描述符,通过相关系统调用进行网络数据的收发。

这与 Linux 中的文件操作有一定相似性:

  • 普通文件通过文件描述符进行读写。
  • Socket 也可以通过文件描述符进行网络通信。
  • 文件操作通常使用 read()、write() 等接口。
  • TCP Socket 同样可以使用 read()、write() 进行数据收发。

因此,可以把 Socket 理解为 Linux 文件描述符机制在网络通信中的一种应用。

2.2 TCP 与 UDP 的区别

对比项目 TCP UDP
通信方式 面向连接 无连接
传输形式 字节流 数据报
可靠性 提供可靠、有序的字节流传输 不保证可靠送达
是否需要建立连接 需要 不需要
常见接口 socket、bind、listen、accept、connect socket、bind、sendto、recvfrom

需要注意:TCP 保证的是字节流的可靠、有序传输,并不保证一次 write() 对应对方的一次 read()。如果应用程序需要明确的消息边界,就需要进一步设计应用层通信协议。

2.3 TCP 通信的基本流程

TCP 通信通常由客户端和服务器两部分组成。

服务器负责创建监听 Socket、绑定本地地址、监听连接请求,并通过 accept() 获取客户端连接。

客户端负责创建 Socket,并使用 connect() 主动连接服务器。

连接建立后,双方就可以进行双向数据通信。

复制代码
              TCP 客户端
                  |
                  | socket()
                  v
             创建客户端 Socket
                  |
                  | connect()
                  v
        +----------------------+
        |      TCP 服务器      |
        |                      |
        |     socket()         |
        |         |            |
        |       bind()         |
        |         |            |
        |      listen()        |
        |         |            |
        |      accept()        |
        +----------------------+
                  |
                  v
             建立 TCP 连接
                  |
          +-------+-------+
          |               |
       发送数据         接收数据
          |               |
          +-------+-------+
                  |
               关闭连接

理解这个流程后,我们就可以开始学习 TCP Socket 的核心 API。

三、TCP Socket 核心 API 详解

重点介绍 socket()、bind()、listen()、accept() 和 connect() 这几个接口。

3.1 socket():创建 Socket

socket() 用于创建一个通信端点。

函数原型如下:

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

int socket(int domain, int type, int protocol);

常见参数如下:

  • domain:指定通信协议族,IPv4 网络使用 AF_INET。
  • type:指定 Socket 类型,TCP 使用 SOCK_STREAM。
  • protocol:指定具体协议,通常传入 0,由系统根据前两个参数选择默认协议。

创建 TCP Socket 的代码如下:

复制代码
int sockfd = socket(AF_INET, SOCK_STREAM, 0);

if (sockfd < 0)
{
    perror("socket");
    return 1;
}

如果创建成功,socket() 返回一个非负文件描述符;如果失败,则返回 -1。

这里需要记住:

TCP Socket 的典型创建方式是 socket(AF_INET, SOCK_STREAM, 0)。

3.2 bind():绑定 IP 地址和端口

服务器通常需要监听一个固定的 IP 地址和端口,因此需要调用 bind(),将 Socket 与本地网络地址关联起来。

函数原型:

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

其中:

  • sockfd:需要绑定的 Socket 文件描述符。
  • addr:需要绑定的网络地址。
  • addrlen:地址结构体的长度。

在 IPv4 网络中,通常使用 sockaddr_in 描述网络地址。

复制代码
struct sockaddr_in local;

memset(&local, 0, sizeof(local));

local.sin_family = AF_INET;
local.sin_port = htons(9999);
local.sin_addr.s_addr = htonl(INADDR_ANY);

逐行理解:

第一步:设置地址类型。

复制代码
local.sin_family = AF_INET;

表示使用 IPv4 地址。

第二步:设置端口号。

复制代码
local.sin_port = htons(9999);

表示服务器使用 9999 端口。

网络通信中存在主机字节序和网络字节序的区别,因此需要使用 htons() 将主机字节序的 16 位端口号转换为网络字节序。

第三步:设置监听地址。

复制代码
local.sin_addr.s_addr = htonl(INADDR_ANY);

INADDR_ANY 表示绑定本机的任意 IPv4 地址,适用于希望服务器通过本机多个 IPv4 网络接口接收连接的情况。

最后调用 bind():

复制代码
if (bind(sockfd,
         (struct sockaddr *)&local,
         sizeof(local)) != 0)
{
    perror("bind");
    return 1;
}

这里的类型转换是因为 bind() 接收的是通用地址类型 sockaddr,而我们实际使用的是 IPv4 地址结构体 sockaddr_in。

使用 CONV() 宏封装这一转换:

复制代码
#define CONV(addr_ptr) ((struct sockaddr *)addr_ptr)

这样可以简化后续的代码。

3.3 listen():让服务器进入监听状态

listen() 用于将绑定后的 TCP Socket 设置为监听状态,使其能够等待客户端发起连接。

函数原型:

复制代码
int listen(int sockfd, int backlog);

例如:

复制代码
if (listen(sockfd, 6) != 0)
{
    perror("listen");
    return 1;
}

参数说明:

  • sockfd:服务器的监听 Socket。
  • backlog:用于设置连接等待队列的相关限制。

需要注意,backlog 不是服务器最多只能服务多少个客户端的简单上限。它主要与等待被接受的连接队列有关,实际行为还受操作系统和连接状态等因素影响。

listen() 成功后,服务器便进入等待客户端连接的阶段。

3.4 accept():获取客户端连接

accept() 用于从监听 Socket 对应的连接队列中取出一个已经完成连接建立的客户端连接。

函数原型:

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

示例代码:

复制代码
struct sockaddr_in peer;
socklen_t len = sizeof(peer);

int sockfd = accept(
    listensock,
    (struct sockaddr *)&peer,
    &len
);

if (sockfd < 0)
{
    perror("accept");
    return 1;
}

这里有一个非常重要的知识点:监听 Socket 和通信 Socket 不是同一个 Socket。

  • listensock:负责等待和接收新的客户端连接。
  • sockfd:由 accept() 返回,用于与某个具体客户端进行通信。

可以将它们理解为饭店中的两个角色:

监听 Socket 就像负责接待顾客的前台;accept() 获取连接后返回的新 Socket,则像分配给某位顾客的专属服务通道。

服务器可以继续使用监听 Socket 接待新的客户端,而已经建立的连接则使用各自的通信 Socket 进行数据收发。

另外,accept() 在没有可接收的连接时,通常会阻塞等待。

3.5 connect():客户端主动连接服务器

与服务器的 bind() 不同,客户端通常通过 connect() 指定服务器的 IP 地址和端口,主动发起连接。

函数原型:

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

示例代码:

复制代码
struct sockaddr_in server;

memset(&server, 0, sizeof(server));

server.sin_family = AF_INET;
server.sin_port = htons(9999);

inet_pton(AF_INET,
          "127.0.0.1",
          &server.sin_addr);

if (connect(sockfd,
            (struct sockaddr *)&server,
            sizeof(server)) < 0)
{
    perror("connect");
    return 1;
}

这里使用了 inet_pton(),将字符串形式的 IPv4 地址转换为网络地址格式。

例如:

复制代码
"127.0.0.1"

表示本机的回环地址,适合在同一台计算机上测试客户端和服务器。

客户端通常不需要显式调用 bind()。操作系统可以在连接过程中自动为客户端分配合适的本地 IP 地址和临时端口。

3.6 read() 和 write():收发数据

连接建立后,双方可以使用 read() 和 write() 进行通信。

客户端发送数据:

复制代码
write(sockfd, message, message_length);

服务器接收数据:

复制代码
char buffer[1024];

ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);

读取后需要判断返回值:

复制代码
if (n > 0)
{
    buffer[n] = '\0';
    std::cout << buffer << std::endl;
}
else if (n == 0)
{
    // 对端进行了有序关闭
}
else
{
    // 发生读取错误
    perror("read");
}

三种返回结果的含义:

  • n > 0:成功读取到数据。
  • n == 0:对端已关闭连接的发送方向,当前没有更多数据可读。
  • n < 0:读取过程中出现错误,需要结合 errno 判断具体原因。

需要注意,read() 和 write() 都可能只处理部分数据。实际工程中还需要考虑循环收发、EINTR 等情况,不能始终假设一次调用就能完成全部数据传输。

四、实战一:实现最简单的 TCP Echo Server

掌握核心 API 后,我们开始编写一个简单的 TCP Echo Server。

Echo Server 的功能非常简单:

  1. 客户端发送一段文本。
  2. 服务器接收文本。
  3. 服务器将文本内容返回给客户端。
  4. 客户端显示服务器返回的内容。

例如,客户端发送:

复制代码
Hello TCP

服务器返回:

复制代码
server echo# Hello TCP

4.1 服务器初始化流程

服务器的初始化主要包括三个步骤:

复制代码
socket()
   |
   v
创建监听 Socket
   |
   v
bind()
   |
   v
绑定本地 IP 和端口
   |
   v
listen()
   |
   v
进入监听状态

下面实现服务器的核心初始化代码。

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

int main()
{
    // 1. 创建 TCP Socket
    int listensock = socket(AF_INET, SOCK_STREAM, 0);

    if (listensock < 0)
    {
        perror("socket");
        return 1;
    }

    // 2. 填充本地网络地址
    struct sockaddr_in local{};
    local.sin_family = AF_INET;
    local.sin_port = htons(9999);
    local.sin_addr.s_addr = htonl(INADDR_ANY);

    // 3. 绑定地址和端口
    if (bind(listensock,
             (struct sockaddr *)&local,
             sizeof(local)) < 0)
    {
        perror("bind");
        close(listensock);
        return 1;
    }

    // 4. 开始监听
    if (listen(listensock, 6) < 0)
    {
        perror("listen");
        close(listensock);
        return 1;
    }

    std::cout << "TCP server is listening on port 9999..."
              << std::endl;

    // 后续在这里 accept() 并处理客户端连接

    close(listensock);
    return 0;
}

这段代码完成了服务器启动的前三个关键步骤。

不过,程序还没有真正处理客户端连接,接下来继续完善。

4.2 实现服务器的通信逻辑

根据 Service() 函数,我们可以将通信逻辑单独封装起来。

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

void Service(int sockfd)
{
    char buffer[1024];

    while (true)
    {
        ssize_t n = read(
            sockfd,
            buffer,
            sizeof(buffer) - 1
        );

        if (n > 0)
        {
            buffer[n] = '\0';

            std::cout << "client say# "
                      << buffer << std::endl;

            std::string echo_string = "server echo# ";
            echo_string += buffer;

            write(
                sockfd,
                echo_string.c_str(),
                echo_string.size()
            );
        }
        else if (n == 0)
        {
            std::cout << "client quit..." << std::endl;
            break;
        }
        else
        {
            perror("read");
            break;
        }
    }
}

这里的核心逻辑是一个循环:

  • 调用 read() 接收客户端数据。
  • 将接收到的数据打印出来。
  • 拼接服务器响应文本。
  • 调用 write() 将响应发送回客户端。
  • 如果客户端关闭连接或者读取发生错误,就退出循环。

注意:为了突出 Socket 的基本使用,这里省略了完整的部分写入处理。生产环境中应确保响应数据全部发送成功。

4.3 接收客户端连接

服务器完成初始化后,需要调用 accept(),然后将返回的通信 Socket 交给 Service() 处理。

复制代码
void Start(int listensock)
{
    while (true)
    {
        struct sockaddr_in peer{};
        socklen_t len = sizeof(peer);

        int sockfd = accept(
            listensock,
            (struct sockaddr *)&peer,
            &len
        );

        if (sockfd < 0)
        {
            perror("accept");
            continue;
        }

        // 为当前客户端提供服务
        Service(sockfd);

        // 服务结束,关闭通信 Socket
        close(sockfd);
    }
}

这里出现了一个值得思考的问题。

虽然服务器使用了 while 循环,但当前版本仍然无法很好地同时服务多个客户端。

为什么?

因为 Service(sockfd) 内部也是一个循环。当第一个客户端连接后,服务器会持续在 Service() 中读取数据,直到该客户端断开连接,才会返回 Start() 并再次调用 accept()。

如果第一个客户端一直保持连接,其他客户端就可能长时间得不到处理。

这就是接下来要解决的并发问题。

五、客户端实现:连接服务器并发送数据

客户端的流程相对简单:

复制代码
socket()
   |
   v
创建客户端 Socket
   |
   v
connect()
   |
   v
连接服务器
   |
   v
write() 发送数据
   |
   v
read() 接收响应

下面给出一个与上述 Echo Server 配合使用的客户端示例。

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

int main()
{
    // 1. 创建客户端 Socket
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    if (sockfd < 0)
    {
        perror("socket");
        return 1;
    }

    // 2. 设置服务器地址
    struct sockaddr_in server{};
    server.sin_family = AF_INET;
    server.sin_port = htons(9999);

    if (inet_pton(AF_INET,
                  "127.0.0.1",
                  &server.sin_addr) != 1)
    {
        std::cerr << "Invalid server IP" << std::endl;
        close(sockfd);
        return 1;
    }

    // 3. 主动连接服务器
    if (connect(sockfd,
                (struct sockaddr *)&server,
                sizeof(server)) < 0)
    {
        perror("connect");
        close(sockfd);
        return 1;
    }

    std::cout << "Connected to server!" << std::endl;

    // 4. 发送数据并接收响应
    while (true)
    {
        std::string message;

        std::cout << "Please Enter# ";

        if (!std::getline(std::cin, message))
        {
            break;
        }

        if (message.empty())
        {
            continue;
        }

        ssize_t n = write(
            sockfd,
            message.data(),
            message.size()
        );

        if (n <= 0)
        {
            perror("write");
            break;
        }

        char buffer[1024];

        ssize_t m = read(
            sockfd,
            buffer,
            sizeof(buffer) - 1
        );

        if (m > 0)
        {
            buffer[m] = '\0';

            std::cout << "get a echo message -> "
                      << buffer << std::endl;
        }
        else
        {
            if (m < 0)
            {
                perror("read");
            }

            break;
        }
    }

    close(sockfd);

    return 0;
}

这段代码使用 getline() 获取用户输入,再通过 write() 发送给服务器,最后使用 read() 获取服务器响应。

在这个简化示例中,我们假设一次请求能够通过一次读取获得完整响应。严格来说,这个假设并不总是成立:TCP 是字节流协议,不保留应用层消息边界。正式项目需要通过长度字段、固定长度消息或明确的分隔符等方式设计通信协议,并处理部分读写问题。

5.1 编译与运行

假设服务器代码保存为 server.cpp,客户端代码保存为 client.cpp。

分别编译:

复制代码
g++ -std=c++17 server.cpp -o tcp_server
g++ -std=c++17 client.cpp -o tcp_client

先启动服务器:

复制代码
./tcp_server

然后打开另一个终端,运行客户端:

复制代码
./tcp_client

如果服务器和客户端在同一台机器上,使用 127.0.0.1 即可进行测试。

客户端输入:

复制代码
Hello TCP

如果服务器正常工作,客户端就会收到服务器返回的文本。

六、理解 TCP 的两个重要细节

6.1 为什么客户端通常不需要显式调用 bind()?

服务器需要使用固定的 IP 地址和端口对外提供服务,因此通常需要显式调用 bind()。

客户端则不同。

客户端一般只需要知道服务器的 IP 地址和端口,然后调用 connect()。连接过程中,操作系统会自动为客户端选择合适的本地地址和临时端口。

如果多个客户端都显式绑定同一个本地 IP 和端口,就可能出现端口占用等问题。

因此,对于一般的 TCP 客户端,通常不需要手动调用 bind()。

6.2 为什么 accept() 返回的 Socket 很重要?

在 TCP 服务器中,监听 Socket 和通信 Socket 承担不同的职责。

例如:

复制代码
int listensock = socket(AF_INET, SOCK_STREAM, 0);

这个 Socket 用于监听新的连接。

当客户端连接成功时:

复制代码
int sockfd = accept(listensock, ...);

系统会返回一个新的通信 Socket。

此后,服务器通过 sockfd 与这个客户端进行数据收发。

服务器继续通过 listensock 接受其他客户端的连接。

记住:监听 Socket 负责接收连接,通信 Socket 负责传输数据。

这也是理解 TCP 服务器并发模型的基础。

七、实战二:从单客户端到多客户端

7.1 V1:单进程、单连接处理

前面实现的服务器,整体处理方式是:

复制代码
主进程
  |
  v
accept()
  |
  v
Service(client1)
  |
  v
client1 断开连接
  |
  v
accept()
  |
  v
Service(client2)

这种方式结构简单,适合学习 Socket 的基本流程,但存在明显的局限性。

当服务器正在为某个客户端提供服务时,主循环无法继续调用 accept() 处理新的连接。

因此,后续进一步介绍了多进程、多线程以及线程池版本。

7.2 V2:使用多进程处理客户端

多进程方案的思路是:每接收到一个客户端连接,就创建一个子进程,让子进程独立处理该客户端。

整体流程如下:

复制代码
              父进程
                |
             accept()
                |
         获取客户端 Socket
                |
              fork()
             /      \
            /        \
        父进程       子进程
          |             |
      关闭通信 fd    关闭监听 fd
          |             |
      继续接收连接   Service(sockfd)
                        |
                    关闭通信 fd
                        |
                      退出

在示例中,使用了 fork() 创建子进程,并通过额外的 fork() 让实际处理客户端连接的进程成为孤儿进程,由系统接管,以避免父进程长期等待客户端服务结束。

核心代码如下:

复制代码
pid_t id = fork();

if (id < 0)
{
    // 创建子进程失败
    close(sockfd);
}
else if (id == 0)
{
    // 子进程
    close(listensock);

    Service(sockfd);

    close(sockfd);
    exit(0);
}
else
{
    // 父进程
    close(sockfd);
}

这段代码展示了多进程方案的基本思想。

需要注意:如果直接采用这样的简单结构,父进程还需要正确处理子进程退出和资源回收,否则可能产生僵尸进程。实际工程中应根据设计使用 waitpid()、信号处理或其他适当机制管理子进程生命周期。

多进程的优点: 各个子进程相对独立,一个子进程的普通内存状态不会直接与其他子进程共享。

多进程的缺点: 创建和管理进程存在一定开销,而且进程间通信通常需要额外机制。

7.3 V3:使用多线程处理客户端

除了多进程,还可以使用多线程处理客户端连接。

多线程方案的思路是:主线程负责接受连接,每当有新的客户端连接时,就创建一个工作线程处理该客户端。

整体结构如下:

复制代码
                 主线程
                    |
                 accept()
                    |
              获取通信 Socket
                    |
               创建工作线程
                    |
          +---------+---------+
          |         |         |
       工作线程1  工作线程2  工作线程3
          |         |         |
       客户端1    客户端2    客户端3

使用 POSIX 线程 API,例如 pthread_create()。

基本示例:

复制代码
#include <pthread.h>

void *ThreadRoutine(void *args)
{
    int sockfd = *static_cast<int *>(args);

    // 为当前客户端提供服务
    Service(sockfd);

    return nullptr;
}

创建线程:

复制代码
pthread_t tid;

pthread_create(
    &tid,
    nullptr,
    ThreadRoutine,
    args
);

这里需要特别注意参数的生命周期:不能简单地把循环中局部变量的地址传递给新线程,否则线程可能在变量失效或被修改后才读取它。

通过 ThreadData 类封装通信 Socket 和客户端地址信息,再将数据传递给工作线程。

线程处理结束后,需要正确关闭通信 Socket,并释放动态分配的 ThreadData 对象。

还使用了:

复制代码
pthread_detach(pthread_self());

将工作线程设置为分离状态,使其结束时由系统自动回收线程相关资源,而不需要另一个线程调用 pthread_join() 等待它。

多线程的优点: 多个线程可以共享同一进程的地址空间,线程创建和通信的成本通常低于多进程方案。

多线程的缺点: 多个线程共享内存,如果同时访问共享数据,就需要考虑互斥锁、同步机制以及数据竞争等问题。

7.4 V4:使用线程池处理客户端

如果每接收到一个客户端连接,就创建一个新线程,那么在线程数量较多时,线程创建、销毁和调度也会带来额外开销。

因此,进一步引入了线程池。

线程池的核心思想是:

提前创建一组工作线程,后续将任务交给线程池执行,而不是每次都创建新线程。

整体结构如下:

复制代码
                TCP 服务器
                    |
                 accept()
                    |
              获取客户端连接
                    |
               封装处理任务
                    |
                    v
              +-------------+
              |   任务队列  |
              +-------------+
                |    |    |
                v    v    v
              线程1 线程2 线程3
                |    |    |
              处理客户端任务

使用 std::function 封装任务,再将任务提交给线程池:

复制代码
using func_t = std::function<void()>;

func_t func = std::bind(
    &TcpServer::Service,
    this,
    sockfd,
    addr
);

ThreadPool<func_t>::GetInstance()->Push(func);

这里的关键是 std::bind():它把成员函数以及对应的参数绑定在一起,形成一个可以延后执行的任务对象。

线程池通过 Push() 将任务加入任务队列,工作线程再从队列中取出任务并执行。

与前面的方案相比,线程池将连接接收和业务处理进一步解耦:

  • TCP 服务器负责接受客户端连接。
  • 任务队列负责暂存待处理任务。
  • 工作线程负责执行任务。
  • 线程池负责协调工作线程和任务分配。

线程池可以减少频繁创建线程的开销,也更容易控制并发任务的处理规模。

当然,线程池并不意味着连接处理一定更快。实际性能还取决于任务类型、线程数量、队列长度和资源限制等因素。

八、四种服务器模型对比

我们可以把四种方案放在一起理解。

版本 处理方式 优点 局限
V1 单进程、顺序处理 结构简单,容易理解 一个客户端可能阻塞其他客户端
V2 每个连接创建进程 进程之间相对独立 进程创建和管理成本较高
V3 每个连接创建线程 共享地址空间,处理结构清晰 线程数量过多时开销增大
V4 线程池处理任务 复用工作线程,控制并发规模 需要管理任务队列和线程同步

从学习路径来看,建议先理解 V1,再学习 V2 和 V3,最后掌握 V4。

不要一开始就追求复杂的服务器架构。理解每一种方案解决了什么问题,才是学习网络编程的关键。

九、扩展:TCP 远程命令执行

进一步介绍多线程远程命令执行的设计。

它将业务逻辑封装到 Command 类中,主要包括三个功能:

  • RecvCommand():接收客户端传来的命令。
  • Execute():检查命令是否允许执行,并获取执行结果。
  • SendCommand():将结果返回给客户端。

使用 std::set 保存允许执行的命令,例如:

复制代码
_safe_command.insert("ls");
_safe_command.insert("pwd");
_safe_command.insert("whoami");

在执行前,通过 IsSafe() 检查命令是否位于允许列表中。

这一部分不仅涉及 Socket 编程,还涉及命令执行、输入验证和系统安全。

重要安全提醒: 远程执行系统命令具有较高风险。简单地检查整条命令是否与白名单字符串完全相同,并不足以安全地处理带参数的命令、特殊字符或其他输入形式。真实项目应尽量避免通过 Shell 执行未经严格处理的客户端输入,优先使用固定操作和受控参数,并配合身份认证、权限隔离、审计与超时机制。

因此,这部分更适合作为理解"网络通信如何承载应用层业务"的教学案例,而不应直接作为生产环境中的远程管理服务使用。

十、TCP Socket 编程中的常见问题

10.1 bind() 失败怎么办?

常见原因包括:

  • 端口已经被其他程序占用。
  • 程序没有绑定目标地址所需的权限。
  • 地址参数设置错误。
  • 上一次程序退出后,连接状态等因素影响端口重用。

排查时可以查看错误信息:

复制代码
perror("bind");

使用 setsockopt() 配置地址和端口重用相关选项。实际使用时,应根据具体操作系统、选项及服务器部署方式选择合适的设置。

10.2 read() 返回 0 是什么意思?

对于 TCP Socket,read() 返回 0 通常表示对端已经有序关闭了连接的发送方向,当前没有更多数据可读。

因此,在 Echo Server 中,如果出现:

复制代码
if (n == 0)
{
    break;
}

就意味着服务器可以结束当前客户端的读取循环,并进行相应的资源清理。

10.3 为什么一个客户端会影响其他客户端?

如果服务器在主循环中直接调用:

复制代码
Service(sockfd);

而 Service() 又一直等待当前客户端发送数据,那么主循环就无法及时处理其他连接。

多进程、多线程和线程池方案,都是为了解决这一类并发处理问题。

10.4 为什么不能把一次 read() 当成一条完整消息?

TCP 是面向字节流的协议。

假设客户端发送:

复制代码
Hello
World

服务器可能一次读到全部数据,也可能分多次读到。具体情况取决于数据到达、缓冲区状态和读取时机。

同样,一次 write() 发送的数据,也不保证恰好对应接收端的一次 read()。

因此,实际应用需要设计明确的消息协议,例如:

  • 固定长度消息。
  • 以特定分隔符结尾的消息。
  • 带有消息长度字段的协议。

简单 Echo Server 主要用于理解 Socket API,而不是完整展示生产环境的协议设计。

十一、总结

通过本文,我们从 TCP Socket 的基础知识出发,逐步了解了 Linux 下 TCP 网络编程的核心流程。

需要掌握的重点包括:

  1. 使用 socket() 创建 TCP Socket。
  2. 使用 bind() 绑定服务器的本地地址和端口。
  3. 使用 listen() 进入监听状态。
  4. 使用 accept() 获取客户端连接。
  5. 使用 connect() 让客户端主动连接服务器。
  6. 使用 read() 和 write() 进行数据收发。
  7. 理解监听 Socket 和通信 Socket 的区别。
  8. 理解单客户端处理的局限,以及多进程、多线程和线程池的基本设计思想。

学习 TCP Socket 编程时,最重要的不是死记硬背函数,而是理解整个通信过程:

创建 Socket → 绑定地址 → 开始监听 → 接受连接 → 收发数据 → 关闭连接。

在掌握这条主线之后,再进一步学习并发处理、应用层协议、I/O 多路复用以及 HTTP 服务器开发,就会更加顺畅。

网络编程需要不断动手实践。建议大家先独立实现一个能够正常收发数据的 Echo Server,再逐步尝试多客户端处理、消息协议设计和线程池优化。

只有真正理解每一个 API 的作用,并且能够解释程序为什么这样运行,才算迈出了 Linux 网络编程的第一步。


结尾

uu们,本文的内容到这里就全部结束了,道尔在这里再次感谢您的阅读!

|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ### 道尔柯南 C/C++ & Linux 底层探索者 | 一个正在努力学习的技术博主 *** ** * ** *** 👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。 ❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。 ⭐ 【收藏】 把核心知识点存好,在需要时随时查、随时用。 💬 【评论】 分享你的经验或疑问,评论区一起交流避坑! 不要忘记给博主"一键四连"哦! "今日目标达成!" |

结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主"一键四连"哦!

相关推荐
星恒讯工业路由器1 小时前
工业路由器在中小 IoT 场景的选型与适配实践:从标准品到定制化的技术路径
开发语言·网络·物联网·php·工业路由器选型·工业网关odm·链路冗余切换
SHENGWEI胜为1 小时前
NVMe SSD写入速度突然下降?从SLC缓存耗尽到温度降速的排查方法
网络·缓存
0+1111 小时前
Linux --ARP的具体过程与ARP欺骗
linux·服务器·网络
shiyi.十一1 小时前
【CISP教材】第8知识域:物理与网络通信安全
网络·信息安全·cisp
科创致远1 小时前
第一代 e+SOP 盒子配显示器实测:物美价廉的产线可视化方案
大数据·网络·人工智能·信息可视化·计算机外设·制造·精益工程
阿星_1 小时前
Linux Ubuntu 查看 SQLite 工具
linux·ubuntu·sqlite
企业数字化笔记1 小时前
AI视频怎么做超分辨率增强?逐帧处理、画幅控制与音画验收
网络·人工智能·音视频
国科安芯1 小时前
星载微控制器MCU功能安全与信息安全协同机制综述
网络·单片机·嵌入式硬件·安全·抗辐射
李&@杰2 小时前
交换机网络环路导致MAC漂移及业务中断故障处理
网络·华为